# Design — runtime theme-token mutator + extension-token surface ## Goals 1. Let a runtime-data-driven consumer (e.g. tenant-branding fetcher) push partial chassis-token overrides on top of the active `ThemePlugin` without re-registering a whole `ThemePlugin`. 2. Let the same consumer push arbitrary `--*` CSS custom properties (extension tokens) that are namespace-orthogonal to chassis tokens. 3. Composability: nested ``s and stacked mutators co-exist with innermost-wins semantics per-key. 4. Cleanup: every `style.setProperty` is balanced by a `style.removeProperty` on the mutator's unmount. ## Non-goals - Persistence (out of scope). - Variant-override mutation (out of scope). - SSR mutator serialisation (out of scope). ## Approach — module-level mutation-layer stack The provider drives a single module-level stack of mutation layers attached to a single shared root element (default `document.documentElement`). Each layer carries: ```ts interface MutationLayer { readonly id: string; // unique per mount; uuid via Math.random+counter readonly providerId: string; // which provider hosts this layer readonly origin: "plugin" | "mutator"; chassis: Partial; // empty for layer-0-plugin source, full for it extensions: Record; // CSS-var → value } ``` Layer-0 of every provider is its own active `ThemePlugin`. Above it sit the mutator layers, in mount order. When the stack changes (push/pop/replace), the provider runs `applyMergedLayers(root)`: 1. Compute `tracked` — the union of all CSS-var names ever written by any layer in the stack so far (kept as a module-level `Set`). 2. For each name in `tracked`, walk the stack from bottom to top; the last layer that declares the name wins. If no layer declares it, `removeProperty(name)` (this is the unmount/clear path). 3. For each "winning" `(name, value)`, `setProperty(name, value)`. This is O(layers × tracked). For realistic counts (≤ ~5 layers, ≤ ~50 tracked names) it is trivially fast and runs only on mutation/unmount. ## API surface ### `useThemeMutator(): ThemeMutatorHandle` ```ts interface ThemeMutatorHandle { readonly mutatorId: string; setChassisOverrides(overrides: Partial): void; setExtensionTokens(tokens: Readonly>): void; clear(): void; } ``` - Returns a stable handle for the calling component's lifetime (single `useRef` + `useEffect` cleanup). - `setChassisOverrides` REPLACES the mutator's chassis overrides. To accumulate, the caller merges before calling. - `setExtensionTokens` REPLACES the mutator's extension tokens. Same semantics. Validates every key starts with `--`; throws `REACT_UI.INVALID_EXTENSION_TOKEN_NAME` with `context.tokenName` otherwise. - `clear()` removes the mutator's contributions and triggers a re-apply. - On unmount, an internal `useEffect` cleanup calls `clear()` for the consumer. ### `useResolvedThemeTokens(): ResolvedThemeTokens` ```ts interface ResolvedThemeTokens { readonly chassis: ThemeTokenSet; readonly extensions: Readonly>; } ``` Subscribes to provider's notifier; re-renders when the merged set changes. Used by consumers that want to render based on resolved theme state (e.g. a tenant logo that picks brand-primary). ## File layout ```text packages/react/react-ui/src/theme/ layer-stack/ index.ts layer-stack.ts # module-level stack + applyMergedLayers layer-stack.md layer-stack.test.ts layer-stack.types.ts use-theme-mutator/ index.ts use-theme-mutator.ts use-theme-mutator.md use-theme-mutator.test.tsx use-theme-mutator.types.ts use-resolved-theme-tokens/ index.ts use-resolved-theme-tokens.ts use-resolved-theme-tokens.md use-resolved-theme-tokens.test.tsx theme-provider/ theme-provider.tsx # MODIFIED — registers provider as a layer host theme-provider.test.tsx # extended ``` ## Decisions - **Why module-level stack** over per-provider stack: every layer ultimately writes to a single shared root element. Nested providers all target `document.documentElement` by default; the only coherent semantics are "all layers globally in mount order, innermost (= latest mounted) wins per key". A per-provider stack would still need a cross-provider tiebreaker. - **Why a notifier + module-level listeners** rather than tree-shaped context: `useResolvedThemeTokens` must see contributions from sibling providers if both target the same root. A flat listener registry handles this naturally. - **Why two setters not one**: keeps chassis (typed, schema-validated) separate from extensions (untyped, name-validated). Mixing them would force the consumer to spell each chassis token with `--` and lose schema typing. - **Why `clear()` is exposed**: lets a fetcher clear branding when the user logs out, without unmounting the component. ## Error codes - `REACT_UI.INVALID_EXTENSION_TOKEN_NAME` — extension token name does not start with `--`. Context: `{ tokenName: string }`.