Files
2026-05-27 23:29:53 +01:00

104 lines
5.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 }`.