5.0 KiB
Extend @suaveplan/react-ui with runtime theme-token mutator + extension-token surface
Package: @suaveplan/react-ui
Status: PROPOSED
Type: feature
- Design: ./design.md
- Spec delta: ./specs/react-ui/spec.md
Why
The upcoming @suaveplan/react-auth-whitelabel package needs to push tenant-branded CSS custom properties (--brand-primary, --brand-secondary, --brand-accent, --brand-font) to document.documentElement at runtime, after fetching a tenant-branding JSON payload. The current genesis ThemeProvider only supports swapping between pre-registered ThemePlugin records: there is no runtime mutator for chassis tokens, and no path for arbitrary extension tokens that live outside the closed 19-key ThemeTokenSet. Upstream react-auth-whitelabel worked around this by writing to document.documentElement.style directly, bypassing the provider — a real architecture gap that prevents nesting, leaks variables on unmount, and is invisible to useThemeTokens() consumers. This change closes the gap so whitelabel can migrate without leaking CSS variables and so any future runtime-data-driven theming (A/B tests, user-customisable accent colour, calendar/widget micro-themes) has a first-class API.
What changes
- ADDED:
useThemeMutator()hook — returns a stable mutator handle{ mutatorId, setChassisOverrides, setExtensionTokens, clear }bound to the calling component's lifetime. On unmount, the mutator's contributions are removed automatically. - ADDED:
useResolvedThemeTokens()hook — returns{ chassis: ThemeTokenSet, extensions: Readonly<Record<string, string>> }reflecting the active theme merged with every mounted mutator's contributions (innermost wins per-key). - ADDED:
REACT_UI.INVALID_EXTENSION_TOKEN_NAMEerror code — thrown when an extension token name does not start with--. - ADDED: subpath exports
./theme/use-theme-mutatorand./theme/use-resolved-theme-tokens. - MODIFIED:
ThemeProvidernow drives a module-level mutation-layer stack: chassis tokens from the activeThemePluginare applied as layer 0, everyuseThemeMutator()adds a layer scoped to its calling component, and on every layer mutation/unmount the merged result is re-applied todocument.documentElement. Existing public surface (themes,defaultTheme,setTheme,useThemeTokens, FOUC script, reduced-motion behaviour) is unchanged. - MODIFIED:
ThemeContextValuegains a non-breakingmutatorsfield exposing the layer-registration entry points used by the new hooks.
Out of scope
- Persisting mutator state to
localStorage— mutators are explicitly runtime-only because they are sourced from per-request payloads (tenant branding, A/B test bucket). A separate change can add an opt-in persistor. - Server-side serialisation of mutator state into the FOUC script — mutators are post-hydration constructs; the FOUC script keeps its current single-theme-attribute scope.
- Variant overrides via mutators —
variantOverridesremain a build-time property ofThemePlugin; runtime variant overrides require a separate provider channel (useThemedVariantsis unchanged).
Source mapping
Genesis-native extension to enable @suaveplan/react-auth-whitelabel migration; see /mnt/s/web/suaveplan/packages/react/react-auth-whitelabel/src/context/branding-context.tsx for the consumer pattern (a useEffect that writes --brand-* properties directly to document.documentElement.style from a fetched payload). No enterprise/<pkg>.md exists for the extension because it is genesis-original.
Verification approach
src/theme/use-theme-mutator/use-theme-mutator.test.tsx— covers all chassis-override and extension-token requirements; mount/unmount cleanup; composability under nested providers.src/theme/use-resolved-theme-tokens/use-resolved-theme-tokens.test.tsx— covers the read hook's merge semantics and reactivity.src/theme/theme-provider/theme-provider.test.tsx— extended withit()blocks asserting layer-zero semantics (active plugin renders verbatim when no mutators present) and thatsetTheme(...)re-merges over the live mutator stack.src/errors/error-codes.test.ts— pins the newINVALID_EXTENSION_TOKEN_NAMEcode into the registry snapshot.
Migration impact
None for existing consumers — every new symbol is additive. Existing tests in theme-provider.test.tsx, use-theme-tokens.test.tsx, and use-theme.test.tsx continue to pass without modification. The whitelabel migration (handled in a separate change for @suaveplan/react-auth-whitelabel) will replace its direct document.documentElement.style.setProperty calls with useThemeMutator().setExtensionTokens(...).
Impact
Affected dependents
@suaveplan/react-auth-whitelabel(pending migration) — [x] no action needed (this change unblocks the migration; the whitelabel change set will consume the new hooks)@suaveplan/react-ui-pack-auth— [x] no action needed (does not write todocument.documentElement.style)- All other
react-uiconsumers — [x] no action needed (additive surface only)