Design Tokens
The doc-chrome and UI token systems, the three-tier color strategy, and how token panel edits flow back to CSS.
Two token worlds
zudo-sg has two related token systems:
Doc chrome tokens control the root styleguide and zudo-doc shell: navigation, docs pages, code blocks, sidebars, and styleguide chrome.
UI package tokens control target-site components exported by
@zudo-sg/ui.
Keep those worlds separate. A styleguide chrome color scheme is not automatically the same as the target website palette, even though both can be edited from zdtp-powered panels.
UI package token contract
Consumers import the shared UI token CSS after Tailwind preflight and utilities:
@import "tailwindcss/preflight";
@import "tailwindcss/utilities";
@import "@zudo-sg/ui/styles/tokens.css";
@import "@zudo-sg/ui/styles/colors.css";tokens.css defines spacing, typography, radius, shadow, and related non-color tokens. colors.css defines the UI package color system. Components use semantic utilities such as bg-accent, text-fg, border-border, and bg-surface-2; they do not reach into raw palette variables.
Three-tier color strategy
The UI package follows the Three-Tier Color Strategy:
| Tier | Purpose | Source |
|---|---|---|
| Palette | Raw OKLCH values named --palette-{group}-{step-or-role} | packages/ |
| Semantic | --color-* roles backed by palette refs, with a few zudo-doc-style AA-tuned light-mode literals | packages/ |
| Component | Scoped overrides when a component truly needs them | component code or CSS |
Palette groups include base, accent, state, and a line-* ramp per business line. base is the grayscale / warm-neutral ramp, accent is the amber action ramp, state contains named status colors (danger/success/warning/info), and each line-* group supplies a business line's own accent + hover pair, layered in through a [data-line="<key>"] override of the semantic --color-accent/--color-accent-hover roles below — components never bind to a line-* palette entry directly.
Semantic (Tier 2) roles built from these groups: bg, surface, surface-2, border, fg, muted, accent, accent-hover, on-accent, focus, the four state colors, and the rail-* family (a persistent dark nav surface — deliberately not a light-dark() pair, so it stays dark in both schemes).
No palette utilities
Palette variables live in plain :root, not Tailwind @theme, so Tailwind does not generate bg-palette-* or text-palette-* utilities. Components must bind to semantic --color-* roles instead.
Doc chrome color model
The zudo-doc 3.x chrome uses a ramp-native model for its own color schemes. It exposes palette ramps and semantic --zd-* roles through the root design-token panel. That model is for the documentation and styleguide shell, not for target-site UI components.
The preview token panel is a second panel instance for @zudo-sg/ui. It has separate storage keys, event names, and an apply sink that writes preview values into registered styleguide iframes instead of the host document root.
Panel tier previews
Both panel configs (src/ for the doc-chrome panel, src/ for the preview panel) use TierConfig.preview, a zdtp 0.4.15+ opt-in. Omitting it renders a tier as a flat row of numeric/text controls; setting it renders a specimen next to each row. This project historically never opted in — the panel simply rendered flat rows where upstream renders type-size samples, spacing bars, and radius/duration glyphs — until this token panel previews epic (Takazudo/zudo-sg#575).
| Tab | Tier | Doc-chrome panel preview | Preview panel preview | Why |
|---|---|---|---|---|
| Font | font-size | 'size' | none | See "Preview panel font tab is bare" below |
| Font | line-height | 'line-height' (previewBase: '--text-body') | none | Same |
| Font | font-weight | 'weight' | none | Same |
| Font | font-family | 'family' | none | Same |
| Font | font-size-lh (preview panel only) | — | none | Same |
| Spacing | hsp / vsp / icon (doc), hsp / vsp (preview) | 'bar' | 'bar' | Both write a resolved length, not a var() reference — see below |
| Spacing | layout (doc only) | none | — | Mixes 0, 1px, and a clamp() readonly row; no preview kind fits a mixed tier like that |
| Size | radius | 'radius' | 'radius' | Resolved-value glyph, same reasoning as bar |
| Size | transition (doc only) | 'duration' | — | Requires a ms/s unit on every item |
| Size | shadow (preview only) | — | none | Free-text tier; no preview kind matches text values |
| Color / Palette | all tiers | none | none | These tiers render their own swatch/curve editors instead of a preview glyph |
A tier with preview: 'line-height' may also set previewBase — it names the font-size CSS variable the line-height specimen paragraph is rendered at (--text-body here). It is optional: with no previewBase, zdtp falls back to whichever item of the tab's 'size' preview tier is closest to 16px, and to a flat 16px if the tab has no 'size' tier at all. It is worth setting anyway — the fallback silently re-picks a different base whenever the font-size tier changes. previewBase is only read for 'line-height', and zdtp's validator rejects it on any other preview kind.
'family' and 'weight' tiers do not render a preview row of their own. Per PORTABLE-CONTRACT.md §3.2, the first item in each of those tiers instead supplies the font-family / font-weight that styles every 'size' and 'line-height' specimen sample rendered elsewhere in the same font tab — there is no separate family/weight specimen to look at.
The font-specimen toolbar and its Render on page control need the reserved font tab id plus at least one 'size' or 'line-height' preview tier inside that tab — there is no separate flag that turns the toolbar on. This is why removing every preview from the preview panel's font tab (see below) also removed that panel's specimen toolbar entirely, rather than leaving a toolbar with nothing to show.
The { kind: 'number' } tier-item opt-in
preview: 'line-height' requires every item in that tier to have type.kind === 'number'. That rule lives in zdtp's assertValidPanelConfig, which this project never reaches at runtime (see "configurePanel() does not validate" below) — the repo's own tests are what enforce it. The unitless line-height tokens (--leading-*, --text-*--line-height) would otherwise map to { kind: 'length', unit: '' } through the normal toTierItem path, so both panel configs add a local numberKind option to toTierItem/tierFromGroup that emits { kind: 'number', step, unit } instead, used only for the line-height groups.
This opt-in lives in the panel configs themselves, not in the generated manifests (design-tokens-manifest.ts, ui-design-tokens-manifest.ts), for two reasons: TokenControl (the upstream zdtp type the manifests' TokenDef.control values must map onto) has no 'number' member, so a manifest-level change has nowhere to encode it; and the manifests are --check-gated codegen (pnpm check:token-manifest) regenerated straight from the CSS source, so a hand-added field would be silently reverted at the next regen.
The change needed no state migration. Every persisted panel state stores typography values as item-id -> string and records no kind — the length-vs-number distinction only changes how the panel edits a value in the UI, not how it is read from or written to localStorage. The panel-config-isolation.test.ts state-continuity block seeds v2/v3/v4 legacy envelopes directly and asserts loadPersistedState round-trips fractional line-height values unchanged, with no legacyIdRenameMap involved.
Preview panel font tab is bare (partial opt-in, not a decision to skip previews)
The preview panel's font tab tiers carry no preview at all, unlike the doc-chrome panel's font tab, which carries the full set. This is the one tab where the two configs deliberately diverge, and the reason is a specific upstream rendering defect rather than a stylistic choice:
zdtp 0.5.1's font-specimen renderer styles its 'size'/'line-height' samples with var(--token, <panel value>), and mounts those samples in the host document — the document that already defines every one of these CSS variables at :root. The doc-chrome panel writes directly to the host :root (no applySink), so for it the var(--token, ...) reference always resolves to the panel's own live edit — the preview correctly tracks that panel.
The preview panel is different: it is an applySink instance (applyPreviewVars / clearPreviewVars), and its writes go only to the registered styleguide preview iframes, never to the host :root. So when the preview panel's font specimen tries to resolve var(--token, <preview-panel-value>) in the host document, the host's own pre-existing definition wins and the fallback is never reached — the specimen shows the host's value, not the preview panel's edit. Worse, because the doc-chrome panel's and the preview panel's first family/weight rows happen to name variables in the same host :root, editing the doc-chrome panel visibly restyles the preview panel's specimen sample. A preview panel showing another panel's state is worse than showing no preview at all, so all five font tiers were left bare (commit 3730128) rather than shipped broken. This also removes that panel's specimen toolbar and Render on page control, per the toolbar requirement described above — that is expected, not a separate regression.
The 'bar' (Spacing) and 'radius' (Size) previews are not affected by this and stay enabled on the preview panel: those glyphs paint the resolved token value directly into an inline style with no var() indirection, so they always reflect whichever panel wrote them, regardless of which document they render in. That resolved-value-vs-var() split is exactly why this opt-in is partial rather than all-or-nothing — it is also what makes it safe to re-enable just the font tiers later, with no other change, once the renderer stops resolving specimen styles through the host cascade. Filed upstream as Takazudo/zudo-design-token-panel#850.
configurePanel() does not validate — the tests are the real guard
zdtp ships assertValidPanelConfig, but configurePanel() itself does not call it. That assertion only runs inside the Astro host-adapter's inline-JSON-config boundary (reading a <script id="tokenpanel-config"> tag), and this project bypasses that boundary entirely — both panels bootstrap through a direct zdtp.configurePanel(getConfig()) call (src/), never through <DesignTokenPanelHost>. assertValidPanelConfig is also not exported from either @takazudo/zdtp or @takazudo/, so this project could not call it directly even if it wanted to.
Consequently, panel-config-isolation.test.ts's "configurePanel accepts both configs without
throwing" test is a smoke test — it proves the configs import and construct cleanly, not that they satisfy zdtp's validation rules. The real correctness guards for the preview/kind pairing are the hand-written tests that re-encode those rules directly: a PREVIEW_ITEM_KINDS table covering all seven preview kinds (size/bar/radius → length, line-height → number, family → text, weight → select/number, duration → length/number) asserted per item, the 'ms'/'s' unit rule for 'duration', and a check that previewBase only appears on a 'line-height' tier and names a token that actually exists in the same tab. Covering every kind — not just the two this epic happened to touch — matters because both manifests are regenerated codegen: a regen that flips one token's control would otherwise ship a broken glyph silently. Treat those tests, not the configurePanel smoke test, as the thing that fails CI if a future tier/preview pairing is wrong. Filed upstream as Takazudo/zudo-design-token-panel#851.
Other deliberate opt-outs
layout(doc-chrome Spacing) andshadow(preview Size) tiers carry nopreview.layoutmixes a0, a1px, and aclamp()readonly row — no preview kind renders something meaningful across all three at once.shadowis a free-text tier; no preview kind matches text values.domTweakeris not enabled on either panel. It would add a Tailwind-in-browser runtime and a second Alt+click owner-mode entry point to a panel that is reachable by any site visitor, with no dev-only gate protecting it here.The doc-chrome panel config has no
applyEndpoint/applyRouting. Only the preview panel config wires those fields (fromvirtual:zdtp-apply-config).zdtp-panel-routing.jsondeliberately routes writes only topackages/— letting a slider rewrite the site's own chrome CSS is a separate, riskier change that was not requested.ui/ styles/ *. css dockis left at its zdtp default ({ reflow: 'body-margin' }) on both configs — no observed problem motivated overriding it.
autoRememberOnOpen: false
Both panel configs set autoRememberOnOpen: false. Scope this narrowly: it stops the panel-open call sites from writing the ${storagePrefix}:autoload flag with 'auto' provenance the first time a visitor opens a panel — the specific footgun zdtp's README calls out for a public site with a visible panel-open button, where opening the panel once would otherwise permanently arm owner-mode autoload (and its bundle cost) for that visitor.
It does not:
clear an
'auto'-provenance autoload flag a visitor's browser already has stored from before this change;prevent the lazy panel-bundle loader from firing on any of its other gate signals — saved panel visibility, persisted token overrides, an explicit
'1'-provenance flag fromenableAutoload(), or the element-path inspector;affect the explicit developer opt-in path at all:
window.sgDoc.enableAutoload()andwindow.sgPreview.enableAutoload()still work exactly as before.
"The panel never auto-loads now" overstates what this flag does — it only removes the implicit, one-click path to permanent autoload for a first-time visitor.
Apply-to-source pipeline
The zdtp panels can preview token changes in the browser. During local development, the apply pipeline can persist supported edits back to CSS source through the zdtp apply proxy wiring.
For UI package tokens, the generated manifest comes from packages/ and packages/. If those CSS files change, regenerate the manifest and commit the result:
pnpm gen:token-manifest
pnpm check:token-manifestThe detailed panel operating manual belongs on the styleguide site. This page is the architecture map: CSS files are the source of truth, manifests keep panels in sync, and checks catch drift.