---
status: accepted
---

# Dynamic icon names route through runtime injectors, not per-name CSS

Every Brightspot path that renders a Lucide icon whose name is **chosen at runtime** (editor selections via `@ToolUi.IconName`, plugin code calling `createNavAreaHeader(..., "chart-pie")`, Material-to-Lucide compat lookups for `data-icon-button-name` elements, ProseMirror toolbar buttons, the `<btu-icon symbol="…">` web component itself) resolves the name in **JavaScript** and applies the SVG via **inline CSS custom properties** on the host element. None of these paths reads the Preset's per-name `.btu-icon-via-mask-NAME` rules. Therefore the Preset's **Safelist** must not force-emit those per-name rules — doing so ships ~1.18 MB of `v5.css` that the browser fetches, parses, and never consults.

## Why

The icon Component Plugin (`tailwind-plugin-icon.ts`) generates 1,792 per-name rules — one per Lucide icon — each inlining the entire SVG as a URL-encoded data URL. Until this decision they were force-emitted to v5.css by two redundant safelist patterns (`/btu-icon-via-mask-.+/` and `/^btu-icon(-.*)?$/`). Measured: 2,638 emitted rules (postcss-rtl doubles the directional ones), 1,182,531 bytes, **24.9% of v5.css**.

Every actual rendering path bypasses these rules:

| Dynamic path | Resolution mechanism | CSS slot the SVG lands in |
| --- | --- | --- |
| `<btu-icon symbol="X">` (Nav-rail, content widgets) | `Icon.ts` calls `getIcon(name)`, inlines the SVG as Light-DOM markup with a `<mask>` `<defs>`, sets `style="--mask-url: url(#X)"` | `--mask-url` (highest-priority slot in the base rule's `var()` chain) |
| `<button data-icon-button-name="X">` (Right-rail / IconButton) | `v5.ts:applyIconProperties` (an `onFind`-registered host injector) maps Material → Lucide, calls `getIcon(name)`, sets `style="--compat-icon-via-mask: url(…)"` | `--compat-icon-via-mask` (third slot in the base rule's `var()` chain) |
| ProseMirror toolbar (RTE-blacklisted) | `resolveIconCompat.js` fires a `btu-resolve-icon` custom event that re-enters `applyIconProperties` from outside the RTE blacklist | same as above |
| `@apply btu-icon-via-mask-NAME` in developer CSS | Tailwind's `@apply` resolver copies the rule body from the plugin's internal registry into the consumer's selector at build time | the consumer's own rule (per-name source rule is not required in output) |
| `<i class="btu-icon-via-mask-NAME">` in scanned source | Tailwind's content scanner emits the per-name source rule | the per-name source rule itself (~6 names total today) |

The base rule the runtime injectors target is unchanged:

```css
.btu-icon-via-mask {
  mask: var(--mask-url, var(--icon-svg, var(--compat-icon-via-mask))) center / contain no-repeat;
  …shared properties…
}
```

The base rule survives any safelist edit that excludes only `btu-icon-via-mask-X` (X non-empty). The runtime injectors continue to work because they consume the **base** rule via inline custom properties — they never depended on the per-name rules.

The remaining ~1,786 names exist in v5.css purely as safelist artifacts. They are dead bytes.

## Consequences

- The Preset's safelist explicitly excludes the per-name via-mask namespace. Concretely, no safelist pattern may match a class of the form `btu-icon-via-mask-X` for any X — enforced by a convention guard in `scripts/check-conventions.mjs` alongside the existing `no-foreign-btu-class` and `safelist-runtime-classes` rules.
- The Component Plugin remains unchanged. It continues to register all 1,792 per-name rules into Tailwind's internal registry so that **Cross-multiplication** for `@apply btu-icon-via-mask-NAME` in developer CSS keeps working (Tailwind copies the rule body into the consumer regardless of whether the source class survives output purging).
- The ~6 statically-discoverable raw-class names (`eye`, `eye-closed`, `file-text`, `sparkles`, `upload`, `zap` as of this writing) continue to emit per-name rules because Tailwind's content scanner finds them. New static-class names self-emit on first use; no maintenance step.
- A new raw-class name added by a downstream consumer in **non-scanned content** (e.g. a hand-built `<i class="btu-icon-via-mask-some-name">` injected at runtime by JS or by a template engine Tailwind doesn't scan) will **not** render its mask, because no rule will match. The supported alternatives are:
  - Use `<btu-icon symbol="some-name">` (preferred — already runtime-resolved).
  - Use the `data-icon` / `data-icon-name` / `data-icon-button-name` pattern (handled by `applyIconProperties`).
  - Add the class to `additional-tailwind-classes.txt` so Tailwind scans it.
- A byte-budget regression test in `test/css-bloat/` asserts the aggregate size of per-name mask rules in v5.css stays under 20 KB — leaving headroom for the natural growth of statically-discoverable names while catching any reintroduction of force-emission.
- This ADR is the load-bearing premise for the Lever #1 safelist change in [`../../plans/lever-1-icon-emission-prd.md`](../../plans/lever-1-icon-emission-prd.md). It is reachable through any of the runtime-injector files (`src/legacy/tool-ui/src/v5.ts`, `src/legacy/tool-ui/src/main/webapp/v4/dom/resolveIconCompat.js`, `src/components/icon/Icon.ts`, `src/LucideDynamicLoader.ts`).

If a future requirement appears where dynamic icon names *must* render through the raw class form without scanning and without using `data-icon*` attributes, this ADR is the place to revisit. Two follow-up paths are sketched in the Lever #1 PRD's design discussion: (a) plugin refactor splitting shared behavior onto the base class so a library-side runtime scanner can complete the rule from inline style, and (b) per-element `<style>`-block injection. Neither is needed under the current consumer contract; both would be reopened only if the contract changed.

See also: [ADR 0001](./0001-monolith-css-delivery.md) (the Monolith is the single CSS delivery vehicle), [ADR 0002](./0002-complete-component-preset.md) (the Preset registers the complete component set; safelist patterns govern which variants force-emit), [ADR 0003](./0003-plugin-selector-isolation.md) (cross-referencing plugin selectors are forbidden).
