Files
suaveplan/openspec/archive/2026-05-19-extend-react-ui-runtime-theme-mutation/design.md
2026-05-27 23:29:53 +01:00

5.0 KiB
Raw Blame History

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 <ThemeProvider>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:

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<ThemeTokenSet>;     // empty for layer-0-plugin source, full for it
    extensions: Record<string, string>;  // 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<string>).
  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

interface ThemeMutatorHandle {
    readonly mutatorId: string;
    setChassisOverrides(overrides: Partial<ThemeTokenSet>): void;
    setExtensionTokens(tokens: Readonly<Record<string, string>>): 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

interface ResolvedThemeTokens {
    readonly chassis: ThemeTokenSet;
    readonly extensions: Readonly<Record<string, string>>;
}

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

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 }.