Files
suaveplan/openspec/specs/react-ui/spec.md
2026-05-27 23:29:53 +01:00

190 lines
14 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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">` AND `document.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)` with `error.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">` setting `data-theme` from `localStorage` before any React markup
#### Scenario: prefers-reduced-motion: reduce collapses motion when respectReducedMotion === 'strict'
- **GIVEN** active theme's `motionTokens.respectReducedMotion === 'strict'`
- **WHEN** `prefers-reduced-motion` flips to `reduce`
- **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 called `setChassisOverrides({ primary: "rgb(99 99 99)" })`
- **WHEN** `setTheme('dark')` is called
- **THEN** `--background` resolves to the dark theme's background token AND `--primary` still 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<ThemeTokenSet>
- **GIVEN** a component inside `<ThemeProvider themes={[lightTheme]} defaultTheme="light">` 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<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"` 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 `<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 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<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.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 `<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"`
<!-- markdownlint-enable MD024 -->