Pantoken used to assume it owned the document. Themes were :root[data-pantoken-theme="…"], @property registrations were global, color-scheme was set on <html>, storage keys were unnamespaced, and theme broadcasts swept every iframe on the page. Two pantoken instances on one page — the docs site and the canvas-theme-editor preview, say — therefore fought, and whichever wrote last won.
Theming is now a property of a subtree, not of the page.
Five attributes, defined once in packages/utils/src/scope.ts and exposed at the dependency-free @pantoken/utils/scope entry. @pantoken/css selects on them; @pantoken/scope writes them.
Attribute
Class twin
Meaning
data-pantoken-theme
.--pantoken-theme-<key>
Roots a theme scope. Matches any element, not :root.
data-pantoken-scheme
.--pantoken-scheme-<key>
Pins the subtree to light or dark.
data-pantoken-color
.--pantoken-color-<key>
Selects a custom brand color.
data-pantoken-boundary
.--pantoken-boundary
Ancestor resolution stops here.
data-pantoken-instance
—
Names the owning instance.
Every selector is emitted in both forms, because some hosts sanitize data-* out of authored content but leave class alone — Canvas RCE being the one that forced it. The class twin follows the repo's global-modifier convention and is repeated three times for (0,3,0) specificity, so a scope class outranks any component-modifier compound that sets tokens.
The attribute wins when both sit on one element. resolveScope reads both, so the runtime and the stylesheet never disagree about what is in effect.
Declarations land in four cascade layers, lowest first:
The layers only matter when several attributes sit on the same element — scheme must beat theme there. Between different elements, plain inheritance decides: the nearest ancestor that declares a token wins for its subtree.
Load these two instead of a single style.*.css when more than one theme must be active:
@pantoken/css/properties.css — the @property registrations. Load once per document. Registrations are document-global: a second copy silently redefines every token's initial value for the whole page. ensureProperties() makes a repeat call a no-op.
@pantoken/css/scope.css — a shared base block plus one complete token block per theme. Includes the scheme pins below.
@pantoken/css/schemes.css — just the scheme pins (343 bytes), for layering onto a sheet that didn't bundle them.
A single-theme page should keep using style.lean.css; it is much smaller.
The docs sheet used to emit non-default themes as only the tokens that differ from rebrand. That is correct when exactly one theme block can ever apply. Under subtree scoping it is wrong: a canvas scope nested inside a canvasHighContrast scope would inherit high-contrast values for every token the canvas block omitted.
So multiScopeCss partitions tokens by whether they actually vary across the emitted theme set. Invariant tokens go in the shared base once. Varying tokens are repeated in full in every theme block. A test asserts the blocks declare identical token sets — that parity is the nesting-safety invariant, and breaking it reintroduces the bug silently.
That is the entire mechanism, and it is why a light subtree and a dark subtree can coexist. Nothing needs an inline style, so a host that can only add a class or an attribute still gets per-subtree schemes.
An earlier version of this sheet re-declared every themed token flattened to one branch — 87 kb to do what the browser already does from a single declaration. If you find yourself generating a per-token override table for a scheme, that's the mistake.
The behaviour this rests on — that light-dark() inside a custom property resolves against the consuming descendant's inherited color-scheme, not the element the token was declared on — is browser-verified by formats/css/tests/manual/scope-test.html. Re-run that page after any change to the scoped emitters; unit tests can only check the emitted strings, not the cascade.
Of 2,921 tokens, only 786 vary across themes — the other 73% are emitted once, in the shared base block and the @property registrations. The repetition that remains is irreducible: custom properties resolve by inheritance, not by lookup, so values that differ per subtree must physically exist on each subtree's root.
Emitting complete theme blocks rather than diffs is close to free, and the reason is worth knowing: canvas already differs from rebrand in 764 of those 786 tokens, and canvasHighContrast in 785. The diff is the full set. Completeness buys nesting safety for 1–3%.
import { createScope, resolveScope } from "@pantoken/scope";// Manual configuration — the way to avoid a collision in the first place.createScope(appEl, { instanceId: "app", theme: "rebrand", scheme: "dark" });createScope(previewEl, { instanceId: "preview", theme: "canvas", scheme: "light", boundary: true, // nothing outside can theme what is inside});// Auto-detection — what is in effect here?resolveScope(someDescendant); // { theme, scheme, color, instanceId, element, bounded }
Each field resolves independently, so a subtree can override the scheme while still inheriting the theme. Resolution reads the boundary element's own attributes and then stops.
The registry is keyed per document, so an iframe gets its own. Storage keys are pantoken:<instanceId>:<field>. Frames opt in with scope.attachFrame(iframe) rather than being found by a document-wide sweep, and messages carry instanceId so a receiver can reject another instance's.
Never read document.documentElement to decide what theme or scheme applies. Use resolveScope / resolveScheme from the element you actually care about. That global read is what desynchronises two instances.
Never write a bare pantoken-theme storage key. Namespace it by instance.
Never querySelectorAll the whole document for frames to broadcast to. Scope the query to your own scope element and skip frames owned by another instance.
Don't run applyMinify(css, { flatten: true }) over a scoped sheet. Flattening turns @property into unlayered:root declarations, which outrank @layer pantoken.theme and break scoping.
Use a hard boundary (boundary: true, a shadow root, or an iframe) when a subtree must be immune to its surroundings rather than merely override them.