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.
Compare resolved dependencies as well as authored expressions. An alias can have identical spelling in all themes but point to different colors. It must still be declared on each theme root: otherwise a nested theme inherits the outer root's already-computed alias.
Ready-made token sheets follow the system scheme automatically, even without the optional component base reset. The default lives in pantoken.base; explicit light/dark attributes or class twins live in the later pantoken.scheme layer and remain authoritative, including on the document root. Nested scopes inherit the nearest pin. Shadow content inherits the host's scheme rather than choosing its own.
color-scheme is an ordinary inherited CSS property, so one rule keyed to the scope selector resolves light-dark() for everything below it:
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.
These policies apply automatically in the current token scope, including standalone component sheets and shadow content consuming the tokens:
prefers-contrast: more gives nonzero token-based borders a minimum width of 2px and focus rings a minimum width of 3px. Text, semantic status colors, and the active theme's palette stay unchanged. Component links and breadcrumb links gain underlines.
prefers-contrast: less makes decorative drop-shadow colors transparent. Text contrast, control boundaries, and focus cues aren't reduced.
prefers-reduced-transparency: reduce composites translucent surface tokens over an opaque, theme-aware background. The tint keeps its brand references. Masks and modal backdrops remove blur; disabled controls use opaque muted text rather than fading the whole control.
These are preference responses, not a substitute for checking text contrast, selected states, keyboard focus, forced colors, and nested themes in the consuming application.
Of 2,973 non-icon tokens, 1,653 vary across themes, including aliases with theme-dependent references. The other 44% are shared in the base block and @property registrations. The repetition that remains is necessary: custom properties resolve by inheritance, not by lookup, so values that differ per subtree must physically exist on each subtree's root.
Comparing only the literal values would identify 790 varying tokens but miss aliases whose resolved values differ. The larger scoped sheet keeps those aliases local and includes the automatic preference rules. The sizes above are a build snapshot, not a fixed budget.
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.