# Capability Spec Delta: react-ui (extend-react-ui-runtime-theme-mutation) **Package**: `@suaveplan/react-ui` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### 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` field to the bound root element as `--` 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 `` that calls `useThemeMutator()` - **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** `--primary` resolves to `"z"` AND `--accent` resolves 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 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` 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"` AND `getPropertyValue("--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)` with `error.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 AND `getPropertyValue("--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** `--a` resolves to `"9"` AND `--b` is 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 `` 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 called `setExtensionTokens({ "--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> }`. 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 ``, the hook SHALL throw `ReactUiError(REACT_UI.NO_PROVIDER)`. #### Scenario: returns active plugin tokens when no mutators present - **GIVEN** `` with no mutators - **WHEN** a child component calls `useResolvedThemeTokens()` - **THEN** `result.chassis` deep-equals `lightTheme.cssVariables` AND `result.extensions` is 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 `` 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"` --- ## MODIFIED Requirements ### Requirement: ThemeProvider mounts a theme registry and prevents FOUC `` 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 `