Architecture
How the design-token panel separates panel UI, host-adapter, and apply-pipeline across three layers — and how the abstract token-tier model unifies all tab types into a single host-supplied config shape.
The design-token panel is split into three layers connected through narrow, JSON-serializable contracts. Host-coined design-token families — spacing, typography, size, color, animation easing, or any custom domain — all pass through the same abstract tier model, so adding a new family to your project requires only a host-config change and zero package changes.
Three-layer overview
There are three concerns the package keeps in three separate layers:
Panel UI — the Preact island that renders the side panel, owns tab and control state, and writes overrides through the configured
applySink(or the default:rootsink). Lives entirely in the browser.Host-adapter — the thin shim a host imports as a side effect. It reads the inline JSON config, calls
configurePanel(...), installswindow.<consoleNamespace>.*plus the fixed-namewindow.zdtp.{show,hide,toggle}()alias, and gates the lazy-load of the panel module.Apply-pipeline — the optional path from the panel's Apply button back to disk. It is the in-package payload builder plus the standalone Node bin server (
zdtp-server) that owns the file rewrites.
┌─ Your dev server (Astro / Vite / any host) ──────────────┐
│ │
│ ┌─────────────┐ reads ┌────────────────┐ │
│ │ Layout │ ──JSON────▶ │ Host adapter │ │
│ │ (config) │ │ (lazy-gate) │ │
│ └─────────────┘ └────────┬───────┘ │
│ │ dynamic import │
│ ▼ │
│ ┌────────────────┐ │
│ │ Panel UI │ writes │
│ │ (Preact) │ ──────▶ CSS target
│ └────────┬───────┘ │
│ │ POST /apply │
└────────────────────────────────────────┼─────────────────┘
│ (HTTP, loopback)
▼
┌──────────────────────────┐
│ Bin server │
│ design-token-panel- │
│ server │
│ • CORS allow-list │
│ • write-root sandbox │
│ • atomic file rewrites │
└──────────────────────────┘The boundaries between these three layers are stable — every project-specific identifier (storage prefix, console namespace, CSS-var names, tab layout, routing) crosses a layer boundary as plain JSON.
The abstract token-tier model
Every tab in the panel — spacing, typography, size, color, easing, or any host-coined domain — is described by the same data shape. The host passes tabs: readonly TabConfig[] on PanelConfig; the panel iterates it to build the UI, and the resolver turns it into CSS custom property writes.
Why tiers?
A single-tier tab is a flat list of editable CSS tokens. That works well for simple cases, but design systems often need a two-layer structure: a raw layer of primitive values (e.g. a type scale, a color palette) and a semantic layer that maps role names onto those primitives.
Without tiers, a host would need to wire this by hand — maintaining two parallel token arrays, keeping the cross-references consistent, and patching the package whenever a new domain is added.
With tiers, the host declares the relationship once in config and the resolver takes care of emitting var(--tier1-cssvar) so edits to a raw primitive cascade through every semantic role that references it.
Config shape
interface PanelConfig {
// ...identity and routing fields...
tabs: readonly TabConfig[];
dock?: { reflow?: 'body-margin' | 'none' };
colorPresets?: Record<string, ColorScheme>;
}
interface TabConfig {
id: string;
label: string;
tiers: readonly TierConfig[];
colorExtras?: ColorClusterExtras; // color-tab-only non-item metadata
}
interface TierConfig {
id: string;
label: string;
items: readonly TierItem[];
referencesTier?: string; // when set, items hold refs to another tier
semantic?: true; // Color-tab semantic tier marker
referencesRamps?: readonly { tab?: string; tier: string }[];
preview?: 'size' | 'line-height' | 'family' | 'weight' | 'bar' | 'radius' | 'duration';
previewBase?: string;
}
interface TierItem {
id: string;
cssVar: string;
label: string;
default: string;
type: TierValueKind;
pill?: PillSpec;
readonly?: true;
}Value kinds
Each TierItem carries a type discriminated union that determines what edit control renders for it.
type.kind | Edit control | Use-cases |
|---|---|---|
'length' | Text input with step, unit | Spacing, font-size, border-radius |
'number' | Unconstrained text input (unitless) | Opacity, line-height, z-index scales |
'select' | Dropdown | Predefined option sets |
'text' | Free-form text input | Easing curves, font-family names, raw CSS expressions |
'color' | Color swatch picker | Palette entries, semantic color roles |
'cursor' | Free-form text input | cursor property values (keywords like pointer, crosshair, or url(...) <fallback>) |
'content' | Free-form text input | content property values for pseudo-elements (quoted strings, none, normal, url(...), generated-content functions) |
'mask-image' | Free-form text input | mask-image property values (none, url(...), gradient functions) |
type TierValueKind =
| { kind: 'length'; step: number; unit: string; units?: readonly string[] }
| { kind: 'number'; step: number; unit?: string }
| { kind: 'select'; options: readonly string[] }
| { kind: 'text' }
| { kind: 'color'; format?: 'hex' | 'oklch' }
| { kind: 'cursor' }
| { kind: 'content' }
| { kind: 'mask-image' };Flat (1-tier) tab
A tab with a single tier in tiers behaves like a simple editable token list. Each item's value is a literal CSS string.
const spacingTab: TabConfig = {
id: 'spacing',
label: 'Spacing',
tiers: [
{
id: 'raw',
label: 'Spacing',
items: [
{
id: 'myapp-spacing-md',
cssVar: '--myapp-spacing-md',
label: 'Spacing M',
default: '1rem',
type: { kind: 'length', step: 0.0625, unit: 'rem' },
},
],
},
],
};At apply time, the resolver emits --myapp-spacing-md: 1.25rem (or whatever the current override is) directly to the configured applySink, or to :root when no sink is configured.
Cross-tier references
When a TierConfig declares referencesTier, its items do not hold CSS strings — they hold the id of an item in the named tier. At apply time the resolver looks up the referenced item and emits var(--target-cssvar) instead of a literal value.
const fontTab: TabConfig = {
id: 'font',
label: 'Font',
tiers: [
{
id: 'raw',
label: 'Type Scale',
items: [
{ id: 'myapp-scale-sm', cssVar: '--myapp-scale-sm', label: 'Scale SM', default: '0.875rem', type: { kind: 'length', step: 0.0625, unit: 'rem' } },
{ id: 'myapp-scale-base', cssVar: '--myapp-scale-base', label: 'Scale Base', default: '1rem', type: { kind: 'length', step: 0.0625, unit: 'rem' } },
{ id: 'myapp-scale-xl', cssVar: '--myapp-scale-xl', label: 'Scale XL', default: '1.75rem', type: { kind: 'length', step: 0.0625, unit: 'rem' } },
],
},
{
id: 'semantic',
label: 'Semantic Roles',
referencesTier: 'raw', // items point at raw-tier item ids
items: [
{ id: 'myapp-text-body', cssVar: '--myapp-text-body', label: 'Body', default: 'myapp-scale-base', type: { kind: 'text' } },
{ id: 'myapp-text-heading', cssVar: '--myapp-text-heading', label: 'Heading', default: 'myapp-scale-xl', type: { kind: 'text' } },
],
},
],
};With referencesTier: 'raw', the default on each semantic item is the id of a raw item. The resolver emits:
--myapp-text-body: var(--myapp-scale-base);
--myapp-text-heading: var(--myapp-scale-xl);Changing --myapp-scale-base updates --myapp-text-body automatically through the CSS cascade — no extra apply step needed.
Color extras
The color tab carries extra non-item metadata (color-picker configuration, color schemes, base roles, and optional secondary-cluster data) in colorExtras on the TabConfig. This metadata does not map to individual TierItems; it drives the color tab's UI chrome and scheme switching.
const colorTab: TabConfig = {
id: 'color',
label: 'Color',
colorExtras: {
id: 'myapp-colors',
baseRoles: { background: '--myapp-palette-0', foreground: '--myapp-palette-15' },
baseDefaults: { background: 0, foreground: 15 },
defaultShikiTheme: 'github-dark',
colorSchemes: { /* scheme registry */ },
panelSettings: { /* cluster UI settings */ },
},
tiers: [
{
id: 'palette',
label: 'Palette',
items: [
{ id: 'myapp-palette-0', cssVar: '--myapp-palette-0', label: 'Palette 0', default: '#1e1e1e', type: { kind: 'color' } },
// ...more palette swatches
],
},
{
id: 'semantic',
label: 'Semantic',
referencesTier: 'palette',
items: [
{ id: 'primary', cssVar: '--myapp-color-primary', label: 'Primary', default: 'myapp-palette-1', type: { kind: 'color' } },
// ...more semantic roles
],
},
],
};The tiers array follows the same raw/semantic pattern as any other tab. colorExtras sits alongside tiers and is only consumed by the color tab's specialized UI.
Font specimen and host-page rendering
The font tab can mark tiers with preview metadata. size tiers are sorted by resolved pixel value and show the configured specimen text; a line-height tier repeats that text at a base size. When previewBase names a CSS variable, the renderer first resolves a matching manifest item and then the document-root computed value; otherwise it uses the nearest resolved size to 16px (or 16px when no size can be resolved). The first item in a family or weight tier supplies the font style used by every sample. bar, radius, and duration render compact glyphs in ordinary token rows.
The Font tab's Render on page option creates a body-first div.tokenpanel-on-page-specimen[data-zdtp-specimen] portal. The portal deliberately inherits the host page's font and foreground color; it is not panel chrome and is excluded from token/highlight/picker scans. It contains a level-three heading and one Scale section with one row for every preview: 'size' item, plus a Line height section for each line-height tier. Each line-height sample exposes a leading guide through --tokenpanel-specimen-line and uses the toolbar width (240–720px, default 420px).
While this portal is enabled, the panel temporarily claims the right dock so the page specimen has room. The panel remembers the prior mode in memory, never as a second storage key, and restores it when the option is disabled, the panel closes, navigation unmounts the shell, or a dock claim is lost.
Dock reflow and host mutations
dock.reflow controls the host-document side effect for a right or bottom dock. The default 'body-margin' sets body.style.margin-right or body.style.margin-bottom to the current dock dimension and publishes the same pixel value as --zdtp-dock-inset-right or --zdtp-dock-inset-bottom on the document root. 'none' publishes the root inset but restores the body's exact pre-claim value. The registry permits one owner per edge across bundles; another owner falls back to float. The first claim snapshots both inline values and priorities, and every release restores them exactly.
float is draggable and right/bottom are resizable from their inner edge. mini is a compact pill that expands back to the last full mode. Dock mode, dimensions, and the ghost-idle preference are independent of token state and are persisted under the instance's storage prefix.
One transaction path for token state
All mounted-panel token mutations go through commitTweakState(reason,
updater, options). The transaction applies the next state, saves the persisted envelope, updates component state, and then records history in that fixed order. It is used by single-row edits, bulk edits, element-inspect adjust/revert, imports, snapshot restores, and undo/redo; bulk operations therefore create one history entry even when they touch many rows. History is in memory only. The v4 envelope and the A/B snapshot slots are persisted separately, while last-applied stores the flat comparison baseline used by the changed-state indicator. After a successful disk apply it is reset to {}, and only confirmed-written variables are reconciled from live state.
Apply pipeline
State to CSS
The panel keeps an in-memory TabOverrides map — a two-level nested record:
tierId → itemId → overrideValue (CSS string or ref item id)On every input change or color pick, the resolver calls resolveTierItemValue(tab, tierId, itemId, overrides) and then emitTierItemCssValue(resolved) to get the final CSS string. The result is written immediately to the default :root target via document.documentElement.style.setProperty(cssVar, value), or to the configured applySink for that instance.
Resolver semantics
| Tier type | Override value | Resolver emits |
|---|---|---|
Literal (no referencesTier) | override string | The override string verbatim |
| Literal, no override | — | item.pill.customDefault if pill is set, else item.default |
Reference (referencesTier set) | ref item id | var(--target-cssvar) looked up from the named tier |
| Reference, no override | — | var(--cssVar-of-item-with-same-id) in the referenced tier |
| Reference, override points at unknown id | stale or missing id | Falls back to the item in the referenced tier whose id matches itemId; if that also doesn't exist, falls back to the first item in the referenced tier. No error is thrown. |
Storage schema and migration
State is persisted to localStorage under ${storagePrefix}-state-v4. On first load after an upgrade the panel automatically migrates any existing v3 state into the v4 envelope (the legacy v3 key is left in place, not deleted); v2 and v1 are normalised to v3, then copied into v4 in the same pass. No user action is required.
| Storage key | Contents | Lifecycle |
|---|---|---|
${storagePrefix}-state-v4 | Current unified envelope. Same slices as v3, except color (and any secondary cluster slice) is keyed by active (scheme, mode) identity — one slot per identity — rather than a single flat object. | Live; written on every change. See Per-scheme color persistence. |
| Previous v3 key | v3 envelope (flat, single-slot color) — migrated into v4 on first load. Left in place (not deleted) so a downgrade can still read it. | Transitional — see Storage-key derivation. |
| Previous v2 key | v2 envelope — migrated to v3 (and from there into v4) on first load, then deleted. | Transitional. |
${storagePrefix}-state | Pre-v2 flat state (color-only). Migrated to v3 (and from there into v4) on first load, then deleted. | Legacy. |
JSON export and import
The Export button emits a JSON file whose $schema field identifies it as SCHEMA_V2 or, when the state holds a semantic mapping v2's number-only leaf can't represent (a { literal } / { literal: { light, dark } } / { ref } value), the superset SCHEMA_V3. The Import modal accepts SCHEMA_V1, SCHEMA_V2, and SCHEMA_V3 payloads and normalises them internally before applying. PanelConfig.schemaId is a display-only label returned by getDesignTokenSchema(); the built-in UI does not read it, and it is never read by the serializer or validator; see PanelConfig interface. The export schema version is independent of the localStorage schema version — bumps to one do not imply a bump to the other.
Optional disk rewrite
A tweak never leaves the browser — the active CSS target updates synchronously on every change and the state is persisted to localStorage under ${storagePrefix}-state-v4. The bin server only runs when the host passes applyEndpoint + applyRouting on PanelConfig, and even then only activates when the user clicks Apply.
The bin server receives a POST with the current override diff, validates the request origin against its CORS allow-list, validates each routing entry against the write-root sandbox, and rewrites the target CSS files atomically (temp file + rename, per-file mutex). Any write failure triggers an in-memory snapshot rollback so no file is left in a partial state.
Worked example — easing tab (zfb demo)
The zfb example app ships an easing tab with four raw cubic-bezier primitives and three semantic roles. This is a host-config-only addition — zero package changes required.
const easingTab: TabConfig = {
id: 'easing',
label: 'Easing',
tiers: [
{
id: 'raw',
label: 'Raw Easings',
items: [
{ id: 'ease-in', cssVar: '--zfb-easing-ease-in', label: 'Ease In', default: 'cubic-bezier(0.42, 0, 1, 1)', type: { kind: 'text' } },
{ id: 'ease-out', cssVar: '--zfb-easing-ease-out', label: 'Ease Out', default: 'cubic-bezier(0, 0, 0.58, 1)', type: { kind: 'text' } },
{ id: 'ease-inout', cssVar: '--zfb-easing-ease-inout', label: 'Ease InOut', default: 'cubic-bezier(0.42, 0, 0.58, 1)', type: { kind: 'text' } },
{ id: 'linear', cssVar: '--zfb-easing-linear', label: 'Linear', default: 'linear', type: { kind: 'text' } },
],
},
{
id: 'semantic',
label: 'Semantic',
referencesTier: 'raw',
items: [
{ id: 'tab-open', cssVar: '--zfb-easing-tab-open', label: 'Tab Open', default: 'ease-in', type: { kind: 'text' } },
{ id: 'tab-close', cssVar: '--zfb-easing-tab-close', label: 'Tab Close', default: 'ease-out', type: { kind: 'text' } },
{ id: 'modal-enter', cssVar: '--zfb-easing-modal', label: 'Modal', default: 'ease-inout', type: { kind: 'text' } },
],
},
],
};At apply time the resolver emits:
/* raw tier — literal values editable in the panel */
--zfb-easing-ease-in: cubic-bezier(0.42, 0, 1, 1);
--zfb-easing-ease-out: cubic-bezier(0, 0, 0.58, 1);
--zfb-easing-ease-inout: cubic-bezier(0.42, 0, 0.58, 1);
--zfb-easing-linear: linear;
/* semantic tier — cascade through raw vars */
--zfb-easing-tab-open: var(--zfb-easing-ease-in);
--zfb-easing-tab-close: var(--zfb-easing-ease-out);
--zfb-easing-modal: var(--zfb-easing-ease-inout);If a user re-maps tab-open to point at ease-inout instead of ease-in, the resolver emits var(--zfb-easing-ease-inout) for that entry — no code change, just an override stored in the TabOverrides map.
Why this separation?
The three-layer split is what keeps the package portable. Each layer crosses its boundary through a specific contract, and every contract is JSON-serializable on purpose.
Panel UI ↔ host: a single
PanelConfigobject. Storage prefix, console namespace, modal class prefix, schema id, all tab definitions, and the optional preset library — all host-supplied, all plain JSON. The panel UI has no compile-time knowledge of any of them.Host-adapter ↔ panel UI: the inline
<script type="application/json" id="tokenpanel-config">payload plus theconfigurePanel(...)call.Panel UI ↔ bin: a single POST with
application/json. The bin re-validates origin and routing every time — the browser cannot reach disk without going through the sandbox.
Warning
The host-adapter contract is a paired-unit contract — <DesignTokenPanelHost> AND a sibling <script>void import('...host-adapter')</script> block must appear together in your layout. Omitting the script tag emits the JSON config but never executes the adapter that reads it; the console API throws ReferenceError.
Cross-references
Configure Panel reference — full
PanelConfigfield reference.Apply pipeline reference — CLI flags, security model, routing JSON shape.
Color cluster reference —
colorExtrasandColorClusterExtrasin depth.