14 KiB
react-ui — Capability Spec
Package: @suaveplan/react-ui
Status: SHIPPED
Requirements
Requirement: ThemeProvider mounts a theme registry and prevents FOUC
<ThemeProvider> SHALL accept themes: readonly ThemePlugin[], defaultTheme: string, optional storageKey: string (default 'suaveplan-theme'), optional enableSystem: boolean, optional attribute: string (default 'data-theme'), and optional scriptNonce: string. On mount, it SHALL inject an inline <script> tag (CSP-nonce-aware via scriptNonce) into <head> that reads the stored theme + system preference and sets the attribute on <html> before React hydrates, eliminating theme flash. It SHALL apply the active theme's cssVariables as inline --<token> declarations on <html> via the module-level mutation-layer stack, and listen for prefers-reduced-motion changes to update --motion-duration-* and --motion-easing-* per the active theme's motionTokens.respectReducedMotion setting. The provider SHALL register itself as a layer host so that mounted useThemeMutator() consumers can stack their contributions on top of the active plugin (innermost wins per key) and so that useResolvedThemeTokens() observes the merged result. On unmount, the provider SHALL remove its layer-zero plugin contributions and any orphaned tracked-name properties from the root element.
Scenario: setTheme switches to a registered theme
- GIVEN
<ThemeProvider themes={[lightTheme, darkTheme]} defaultTheme="light">mounted - WHEN
setTheme('dark')is called - THEN
<html data-theme="dark">ANDdocument.documentElement.style.getPropertyValue('--background')returns the dark theme's background token
Scenario: setTheme on unregistered theme throws THEME_NOT_REGISTERED
- GIVEN
<ThemeProvider themes={[lightTheme]} defaultTheme="light"> - WHEN
setTheme('dracula')is called - THEN the call throws
ReactUiError(REACT_UI.THEME_NOT_REGISTERED)witherror.context.themeName === 'dracula'
Scenario: FOUC-prevention script honours CSP nonce
- GIVEN
<ThemeProvider scriptNonce="abc123" themes={[lightTheme, darkTheme]}>rendered server-side - WHEN the SSR HTML is serialised
- THEN the head contains
<script nonce="abc123">settingdata-themefromlocalStoragebefore any React markup
Scenario: prefers-reduced-motion: reduce collapses motion when respectReducedMotion === 'strict'
- GIVEN active theme's
motionTokens.respectReducedMotion === 'strict' - WHEN
prefers-reduced-motionflips toreduce - THEN
document.documentElement.style.getPropertyValue('--motion-duration-base')equals'0ms'
Scenario: setTheme re-applies plugin layer beneath active mutators
- GIVEN
<ThemeProvider themes={[lightTheme, darkTheme]} defaultTheme="light">AND a mounted mutator that calledsetChassisOverrides({ primary: "rgb(99 99 99)" }) - WHEN
setTheme('dark')is called - THEN
--backgroundresolves to the dark theme's background token AND--primarystill resolves to"rgb(99 99 99)"(the mutator stays on top)
Scenario: unmounting the provider clears chassis tokens
- GIVEN a mounted provider that wrote 19 chassis tokens
- WHEN the provider unmounts AND no other provider is mounted
- THEN
document.documentElement.style.getPropertyValue('--background')returns""for every chassis token previously written
Verified by: packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"setTheme switches to a registered theme and updates <html>" AND packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"setTheme on an unregistered theme throws THEME_NOT_REGISTERED" AND packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"setTheme re-applies plugin layer beneath active mutators" AND packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"unmounting the provider clears chassis tokens"
Requirement: useThemeMutator pushes runtime chassis-token overrides
@suaveplan/react-ui SHALL export a useThemeMutator() hook that returns a ThemeMutatorHandle = { mutatorId, setChassisOverrides, setExtensionTokens, clear }. Calling setChassisOverrides(partial) SHALL write each declared Partial<ThemeTokenSet> field to the bound root element as --<token> CSS custom properties, layered on top of the active ThemePlugin's cssVariables. Calling setChassisOverrides again with a different partial SHALL REPLACE the mutator's previous chassis contributions (keys present in the prior call but absent from the new call are removed from the root element, falling back to the active plugin's value). The handle SHALL remain stable across renders for the calling component's lifetime.
Scenario: setChassisOverrides writes Partial
- GIVEN a component inside
<ThemeProvider themes={[lightTheme]} defaultTheme="light">that callsuseThemeMutator() - WHEN it calls
setChassisOverrides({ primary: "rgb(10 10 10)" }) - THEN
document.documentElement.style.getPropertyValue("--primary")equals"rgb(10 10 10)"
Scenario: setChassisOverrides REPLACES prior contributions per-mutator
- GIVEN a mutator that earlier called
setChassisOverrides({ primary: "x", accent: "y" }) - WHEN the same mutator calls
setChassisOverrides({ primary: "z" }) - THEN
--primaryresolves to"z"AND--accentresolves back to the active plugin's accent value
Verified by: packages/react/react-ui/src/theme/use-theme-mutator/use-theme-mutator.test.tsx::"setChassisOverrides writes Partial<ThemeTokenSet> on document.documentElement" AND packages/react/react-ui/src/theme/use-theme-mutator/use-theme-mutator.test.tsx::"setChassisOverrides REPLACES prior chassis contributions per mutator"
Requirement: useThemeMutator writes arbitrary extension tokens
The mutator handle SHALL accept arbitrary Record<string, string> extension-token bags via setExtensionTokens(tokens). Each key MUST begin with --; non-conforming keys SHALL throw ReactUiError(REACT_UI.INVALID_EXTENSION_TOKEN_NAME) with error.context.tokenName set to the offending key, and the call SHALL NOT mutate the root element on validation failure. Successful calls write every key/value pair to the bound root element. Extension tokens SHALL NOT shadow chassis tokens: setting --brand-primary does not affect --primary. Calling setExtensionTokens again SHALL REPLACE the prior extension bag for this mutator (keys present before but absent now are removed from the root element).
Scenario: setExtensionTokens writes -- properties
- GIVEN a mounted
useThemeMutator()handle - WHEN it calls
setExtensionTokens({ "--brand-primary": "#ff0000", "--brand-font": "Inter, sans-serif" }) - THEN
document.documentElement.style.getPropertyValue("--brand-primary")equals"#ff0000"ANDgetPropertyValue("--brand-font")equals"Inter, sans-serif"
Scenario: invalid extension token name throws
- GIVEN a mounted
useThemeMutator()handle - WHEN it calls
setExtensionTokens({ "brand-primary": "#ff0000" })(missing--prefix) - THEN the call throws
ReactUiError(REACT_UI.INVALID_EXTENSION_TOKEN_NAME)witherror.context.tokenName === "brand-primary"AND no property is written to the root element
Scenario: extension tokens orthogonal to chassis
- GIVEN active
lightTheme.cssVariables.primary === "oklch(0.55 0.20 250)" - WHEN a mutator calls
setExtensionTokens({ "--brand-primary": "#ff0000" }) - THEN
getPropertyValue("--primary")still equals the lightTheme primary value ANDgetPropertyValue("--brand-primary")equals"#ff0000"
Scenario: setExtensionTokens REPLACES the prior bag
- GIVEN a mutator that earlier called
setExtensionTokens({ "--a": "1", "--b": "2" }) - WHEN the same mutator calls
setExtensionTokens({ "--a": "9" }) - THEN
--aresolves to"9"AND--bis removed from the root element
Verified by: packages/react/react-ui/src/theme/use-theme-mutator/use-theme-mutator.test.tsx::"setExtensionTokens writes arbitrary -- properties on document.documentElement" AND packages/react/react-ui/src/theme/use-theme-mutator/use-theme-mutator.test.tsx::"extension token without -- prefix throws INVALID_EXTENSION_TOKEN_NAME" AND packages/react/react-ui/src/theme/use-theme-mutator/use-theme-mutator.test.tsx::"extension tokens are namespace-orthogonal to chassis tokens" AND packages/react/react-ui/src/theme/use-theme-mutator/use-theme-mutator.test.tsx::"setExtensionTokens replaces previous extension tokens (removed keys are cleared)"
Requirement: Mutator contributions clear on unmount and on explicit clear()
The mutator handle SHALL expose a clear() method that removes both the chassis and extension contributions from the bound root element, restoring the merged result computed without this mutator. The component-scoped useEffect cleanup SHALL invoke clear() automatically when the component owning the mutator unmounts. After unmount or clear(), the root element MUST NOT carry any property that was written exclusively by this mutator (it may still carry the same name if another mutator or the active plugin declares it).
Scenario: clear() removes contributions
- GIVEN a mutator that called
setExtensionTokens({ "--brand-primary": "#ff0000" }) - WHEN it calls
clear() - THEN
getPropertyValue("--brand-primary")returns""
Scenario: unmount removes contributions
- GIVEN a mutator that called
setExtensionTokens({ "--brand-primary": "#ff0000" }) - WHEN the component owning the mutator unmounts
- THEN
getPropertyValue("--brand-primary")returns""
Verified by: packages/react/react-ui/src/theme/use-theme-mutator/use-theme-mutator.test.tsx::"clear() removes all of this mutator's contributions" AND packages/react/react-ui/src/theme/use-theme-mutator/use-theme-mutator.test.tsx::"unmount removes all of this mutator's contributions"
Requirement: Nested providers and mutators stack innermost-wins
When multiple <ThemeProvider> instances are nested, or when multiple useThemeMutator() handles are mounted at different depths of the same provider, the chassis tokens and extension tokens SHALL stack such that the most-recently-mounted contribution wins per key. On unmount of any inner contribution, the outer value SHALL be restored on the bound root element (no stale value, no premature removal).
Scenario: nested mutators — innermost wins per key
- GIVEN an outer mutator that called
setExtensionTokens({ "--brand-primary": "#000" })AND an inner mutator that calledsetExtensionTokens({ "--brand-primary": "#fff" }) - WHEN both are mounted
- THEN
getPropertyValue("--brand-primary")equals"#fff"
Scenario: inner unmount restores outer value
- GIVEN the same nested setup
- WHEN the inner mutator's component unmounts
- THEN
getPropertyValue("--brand-primary")equals"#000"
Verified by: packages/react/react-ui/src/theme/use-theme-mutator/use-theme-mutator.test.tsx::"nested mutators stack innermost-wins per key" AND packages/react/react-ui/src/theme/use-theme-mutator/use-theme-mutator.test.tsx::"nested mutator unmount restores outer mutator's value"
Requirement: useResolvedThemeTokens exposes the merged read state
@suaveplan/react-ui SHALL export a useResolvedThemeTokens() hook that returns { chassis: ThemeTokenSet, extensions: Readonly<Record<string, string>> }. The chassis field SHALL contain every key from the active plugin's cssVariables, with any overlapping mutator overrides applied (innermost wins). The extensions field SHALL contain every extension token currently contributed by any mounted mutator (innermost wins per key). The hook SHALL re-render its consumer when the merged result changes (via useSyncExternalStore). When called outside a <ThemeProvider>, the hook SHALL throw ReactUiError(REACT_UI.NO_PROVIDER).
Scenario: returns active plugin tokens when no mutators present
- GIVEN
<ThemeProvider themes={[lightTheme]} defaultTheme="light">with no mutators - WHEN a child component calls
useResolvedThemeTokens() - THEN
result.chassisdeep-equalslightTheme.cssVariablesANDresult.extensionsis empty ({})
Scenario: reflects mutator chassis overrides
- GIVEN the same provider AND a mutator that called
setChassisOverrides({ primary: "rgb(99 99 99)" }) - WHEN the consumer reads
useResolvedThemeTokens() - THEN
result.chassis.primary === "rgb(99 99 99)"AND every other key equals the lightTheme value
Scenario: reflects mutator extension tokens
- GIVEN the same provider AND a mutator that called
setExtensionTokens({ "--brand-primary": "#ff0000" }) - WHEN the consumer reads
useResolvedThemeTokens() - THEN
result.extensions["--brand-primary"] === "#ff0000"
Scenario: throws outside ThemeProvider
- GIVEN no
<ThemeProvider>mounted - WHEN a component calls
useResolvedThemeTokens() - THEN the render throws
ReactUiError(REACT_UI.NO_PROVIDER)
Verified by: packages/react/react-ui/src/theme/use-resolved-theme-tokens/use-resolved-theme-tokens.test.tsx::"returns the active plugin's tokens when no mutators present" AND packages/react/react-ui/src/theme/use-resolved-theme-tokens/use-resolved-theme-tokens.test.tsx::"reflects mutator overrides in chassis" AND packages/react/react-ui/src/theme/use-resolved-theme-tokens/use-resolved-theme-tokens.test.tsx::"reflects mutator extension tokens" AND packages/react/react-ui/src/theme/use-resolved-theme-tokens/use-resolved-theme-tokens.test.tsx::"throws REACT_UI.NO_PROVIDER outside a ThemeProvider"
Requirement: REACT_UI.INVALID_EXTENSION_TOKEN_NAME error code is registered
@suaveplan/react-ui SHALL register INVALID_EXTENSION_TOKEN_NAME in the REACT_UI_CODES defineCodes registry. Every throw of this code SHALL carry error.context.tokenName: string identifying the offending key.
Scenario: code is present in the registry
- WHEN a consumer reads
REACT_UI_CODES.INVALID_EXTENSION_TOKEN_NAME - THEN the value equals
"REACT_UI.INVALID_EXTENSION_TOKEN_NAME"
Verified by: packages/react/react-ui/src/errors/error-codes.test.ts::"includes INVALID_EXTENSION_TOKEN_NAME"