# `layer-stack` — module-level mutation-layer engine _(no submodules)_ ## Purpose The `layer-stack` module is the runtime engine that backs ``, `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 `` 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` 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`.