first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
# `layer-stack` — module-level mutation-layer engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `layer-stack` module is the runtime engine that backs `<ThemeProvider>`, `useThemeMutator()`, and `useResolvedThemeTokens()`. It owns a single ordered list of `MutationLayer` records, each carrying that layer's contribution to the CSS custom-property bag applied on `document.documentElement` (or whatever root element the provider has bound). Each `<ThemeProvider>` pushes one `plugin` layer when it mounts and replaces it whenever `setTheme(...)` resolves. Each `useThemeMutator()` push s its own `mutator` layer on mount and removes it on unmount. The stack engine is the single point that touches the DOM — every consumer surface ultimately delegates to it, which is how nested providers and stacked mutators end up with a coherent innermost-wins-per-key semantics on a shared root element.
The stack is module-level by design: every provider in the tree targets the same root, so per-provider state would still need a cross-provider tiebreaker. Putting the stack at the module level means the ordering is exactly the React mount order, which is the only ordering with a sensible "innermost wins" interpretation.
## Features
- Push / update / remove a layer, identified by a stable `id` generated via `newLayerId(...)`.
- Track every CSS custom-property name the stack has ever written, so unmount cleanly removes any orphan.
- Subscribe to stack changes via `subscribe(listener)` — fired AFTER the DOM has been re-applied so listeners observe the live state.
- Read the merged result against an active-plugin baseline via `getMergedResolved(baseline)` for consumer hooks.
- Test-only entry points (`__resetLayerStackForTests`, `bindRoot`) so each test can mount into a clean root and tear down without leaking state across files.
## Usage: Basic
```ts
import {
bindRoot,
newLayerId,
newProviderId,
pushLayer,
removeLayer,
} from "@suaveplan/react-ui/theme/layer-stack";
bindRoot(document.documentElement);
const providerId = newProviderId();
const layerId = newLayerId("plugin");
pushLayer({
id: layerId,
providerId,
origin: "plugin",
chassis: { primary: "rgb(10 10 10)" },
extensions: { "--brand-font": "Inter" },
});
// later
removeLayer(layerId);
```
## Usage: Advanced
A consumer that wants to react to merged stack changes — for example a recharts colour palette consumer — subscribes via `subscribe()` and reads the live snapshot:
```ts
import {
getMergedResolved,
subscribe,
} from "@suaveplan/react-ui/theme/layer-stack";
import { lightTheme } from "@suaveplan/react-ui/theme";
const unsubscribe = subscribe(() => {
const { chassis, extensions } = getMergedResolved(lightTheme.cssVariables);
repaint(chassis.primary, extensions["--brand-accent"] ?? chassis.accent);
});
// teardown
unsubscribe();
```
## API Reference
- `newProviderId(): string` — mint a unique provider identifier.
- `newLayerId(prefix: "plugin" | "mutator"): string` — mint a unique layer identifier.
- `pushLayer({ id, providerId, origin, chassis?, extensions?, motionTokens?, fontTokens? })` — append a new layer and re-apply.
- `updateLayer(id, { chassis?, extensions?, motionTokens?, fontTokens? })` — replace a layer's bags (each bag is replaced, not merged).
- `removeLayer(id)` — drop a layer and re-apply.
- `subscribe(listener)` — register a change listener; returns an `unsubscribe` function.
- `getMergedResolved(baseline)` — compute the live read state against a baseline chassis bag.
- `snapshotLayers()` — read-only copy of the current layer list. For tests and `useSyncExternalStore`.
- `chassisTokenToCssVar(name)` — `cardForeground` → `--card-foreground`.
- `bindRoot(root)` / `__resetLayerStackForTests()` — test-only.
## Implementation Notes
The merge function walks layers in mount order; the last layer to declare a name wins. After computing the merged set, every name that was previously tracked but is no longer present gets `removeProperty(...)`, which is how unmount cleanup falls out for free. Tracked names are never forgotten — that is intentional, because a name first written by layer N and later supplied only by layer M still has to be removed if M itself unmounts.
`getMergedResolved` deliberately copies its inputs into fresh `Record<string,string>` bags so consumers cannot mutate the live layer storage by accident. The returned `extensions` bag is frozen.
`pushLayer` notifies AFTER applying the DOM, never before. This guarantees that a listener reading `getMergedResolved(...)` (or `getPropertyValue(...)`) on the next tick sees the post-write state. A pre-write notify would race with `useSyncExternalStore`.