Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
# 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:
```ts
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`
```ts
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`
```ts
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
```text
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 }`.

View File

@@ -0,0 +1,50 @@
# 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_NAME` error code — thrown when an extension token name does not start with `--`.
- ADDED: subpath exports `./theme/use-theme-mutator` and `./theme/use-resolved-theme-tokens`.
- MODIFIED: `ThemeProvider` now drives a module-level mutation-layer stack: chassis tokens from the active `ThemePlugin` are applied as layer 0, every `useThemeMutator()` adds a layer scoped to its calling component, and on every layer mutation/unmount the merged result is re-applied to `document.documentElement`. Existing public surface (`themes`, `defaultTheme`, `setTheme`, `useThemeTokens`, FOUC script, reduced-motion behaviour) is **unchanged**.
- MODIFIED: `ThemeContextValue` gains a non-breaking `mutators` field 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 — `variantOverrides` remain a build-time property of `ThemePlugin`; runtime variant overrides require a separate provider channel (`useThemedVariants` is 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 with `it()` blocks asserting layer-zero semantics (active plugin renders verbatim when no mutators present) and that `setTheme(...)` re-merges over the live mutator stack.
- `src/errors/error-codes.test.ts` — pins the new `INVALID_EXTENSION_TOKEN_NAME` code 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 to `document.documentElement.style`)
- All other `react-ui` consumers — [x] no action needed (additive surface only)

View File

@@ -0,0 +1,193 @@
<!-- markdownlint-disable MD024 -->
# 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<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"`
---
## MODIFIED 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"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,77 @@
# Tasks — extend-react-ui-runtime-theme-mutation
## 1. Foundation
- [ ] Add `INVALID_EXTENSION_TOKEN_NAME` to `REACT_UI_CODES` registry; update the registry doc-comment.
- [ ] Update `src/errors/error-codes.test.ts` to pin the new code.
## 2. Layer stack (module-level)
- [ ] Create `src/theme/layer-stack/layer-stack.types.ts` with `MutationLayer`, `LayerOrigin`, `LayerStackListener`.
- [ ] Create `src/theme/layer-stack/layer-stack.ts` exporting `pushLayer`, `removeLayer`, `updateLayer`, `getMergedResolved`, `subscribe`. Tracks the `Set<string>` of every CSS-var name ever touched; on every change re-applies merged state to the bound root element and removes any tracked name not present in the merged set.
- [ ] Co-locate `.md` (≥200 words; basic + advanced examples).
- [ ] `layer-stack.test.ts` — runner-agnostic, covers push/pop/update/merge order; tracked-name cleanup on removeLayer.
- [ ] `layer-stack/index.ts` — explicit named re-exports only.
## 3. Provider integration
- [ ] Modify `theme-provider.tsx` to push a `MutationLayer` for the active plugin on mount and update it on `setTheme(...)` / theme change. Unmount removes the layer. Maintain `providerId` (uuid). Reduced-motion + font-stack writes also go through the layer (so they participate in the unmount cleanup).
- [ ] Extend `ThemeContextValue` with a `mutators` field carrying `{ providerId, push, update, remove }` private hooks for `useThemeMutator` consumption. Keep all existing fields and types unchanged.
- [ ] Extended `theme-provider.test.tsx` tests:
- [ ] "applying the active plugin writes all 19 chassis tokens"
- [ ] "switching themes re-applies merged result with mutators still on top"
- [ ] "unmounting the provider clears all 19 chassis tokens"
## 4. `useThemeMutator`
- [ ] `src/theme/use-theme-mutator/use-theme-mutator.types.ts` — `ThemeMutatorHandle`.
- [ ] `src/theme/use-theme-mutator/use-theme-mutator.ts` — implementation. `useRef` + `useEffect` lifecycle. Validates extension keys; throws `ReactUiError(REACT_UI.INVALID_EXTENSION_TOKEN_NAME)`. Calls `clear` on unmount.
- [ ] Co-located `.md` (basic = static prop; advanced = runtime fetch + apply).
- [ ] `.test.tsx` — at least:
- [ ] "setChassisOverrides writes Partial<ThemeTokenSet> on document.documentElement"
- [ ] "setExtensionTokens writes arbitrary -- properties on document.documentElement"
- [ ] "extension token without -- prefix throws INVALID_EXTENSION_TOKEN_NAME"
- [ ] "clear() removes all of this mutator's contributions"
- [ ] "unmount removes all of this mutator's contributions"
- [ ] "nested mutators stack innermost-wins per key"
- [ ] "nested mutator unmount restores outer mutator's value"
- [ ] "extension tokens are namespace-orthogonal to chassis tokens"
- [ ] "setExtensionTokens replaces previous extension tokens (removed keys are cleared)"
- [ ] "throws REACT_UI.NO_PROVIDER outside a ThemeProvider"
- [ ] `index.ts` — explicit named re-exports.
## 5. `useResolvedThemeTokens`
- [ ] `src/theme/use-resolved-theme-tokens/use-resolved-theme-tokens.ts` — subscribes to layer-stack notifier via `useSyncExternalStore`.
- [ ] Co-located `.md`.
- [ ] `.test.tsx`:
- [ ] "returns the active plugin's tokens when no mutators present"
- [ ] "reflects mutator overrides in chassis"
- [ ] "reflects mutator extension tokens"
- [ ] "throws REACT_UI.NO_PROVIDER outside a ThemeProvider"
- [ ] `index.ts`.
## 6. Subpath exports + barrel
- [ ] Add `./theme/use-theme-mutator` and `./theme/use-resolved-theme-tokens` to `package.json` exports.
- [ ] Re-export new hooks + types from `src/theme/index.ts` (explicit named).
- [ ] Re-export from `src/index.ts` (top-level barrel) if precedent is set for `useTheme` / `useThemeTokens`.
## 7. Story
- [ ] `theme-provider.stories.tsx` — add a story showing live runtime token-override input (text fields → calls `setExtensionTokens`).
## 8. Spec reconciliation + gates
- [ ] Verify every `### Requirement` in `specs/react-ui/spec.md` has a matching `**Verified by:**` `it(...)` title that exists in the codebase.
- [ ] `bun run check` (biome).
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/react-ui --force`.
- [ ] `bunx turbo test:coverage --filter=@suaveplan/react-ui` — no regression on per-metric coverage.
- [ ] `bun scripts/openspec-conformance.ts @suaveplan/react-ui` — 0 errors.
- [ ] `bunx turbo build --filter=@suaveplan/react-ui --force`.
- [ ] `bun scripts/lint/check-test-runner-imports.ts` — 0 violations.
## 9. Archival
- [ ] Fold `specs/react-ui/spec.md` delta into canonical `openspec/specs/react-ui/spec.md` (capability is being instantiated for the first time alongside this change; if a prior unarchived change already established the canonical file, merge instead of overwrite).
- [ ] Move `openspec/changes/extend-react-ui-runtime-theme-mutation/` → `openspec/archive/2026-05-19-extend-react-ui-runtime-theme-mutation/`.