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 }`.