Initial Commit
This commit is contained in:
@@ -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 }`.
|
||||
Reference in New Issue
Block a user