Zudo Sg Docs

Type to search...

to open search from anywhere

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:

TierPurposeSource
PaletteRaw OKLCH values named --palette-{group}-{step-or-role}packages/ui/styles/colors.css
Semantic--color-* roles backed by palette refs, with a few zudo-doc-style AA-tuned light-mode literalspackages/ui/styles/colors.css
ComponentScoped overrides when a component truly needs themcomponent 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/config/design-token-panel-config.ts for the doc-chrome panel, src/config/preview-token-panel-config.ts 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).

TabTierDoc-chrome panel previewPreview panel previewWhy
Fontfont-size'size'noneSee "Preview panel font tab is bare" below
Fontline-height'line-height' (previewBase: '--text-body')noneSame
Fontfont-weight'weight'noneSame
Fontfont-family'family'noneSame
Fontfont-size-lh (preview panel only)noneSame
Spacinghsp / vsp / icon (doc), hsp / vsp (preview)'bar''bar'Both write a resolved length, not a var() reference — see below
Spacinglayout (doc only)noneMixes 0, 1px, and a clamp() readonly row; no preview kind fits a mixed tier like that
Sizeradius'radius''radius'Resolved-value glyph, same reasoning as bar
Sizetransition (doc only)'duration'Requires a ms/s unit on every item
Sizeshadow (preview only)noneFree-text tier; no preview kind matches text values
Color / Paletteall tiersnonenoneThese 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/lib/token-panel-native-bootstrap.ts), never through <DesignTokenPanelHost>. assertValidPanelConfig is also not exported from either @takazudo/zdtp or @takazudo/zdtp/testing, 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/radiuslength, line-heightnumber, familytext, weightselect/number, durationlength/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) and shadow (preview Size) tiers carry no preview. layout mixes a 0, a 1px, and a clamp() readonly row — no preview kind renders something meaningful across all three at once. shadow is a free-text tier; no preview kind matches text values.

  • domTweaker is 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 (from virtual:zdtp-apply-config). zdtp-panel-routing.json deliberately routes writes only to packages/ui/styles/*.css — letting a slider rewrite the site's own chrome CSS is a separate, riskier change that was not requested.

  • dock is 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 from enableAutoload(), or the element-path inspector;

  • affect the explicit developer opt-in path at all: window.sgDoc.enableAutoload() and window.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/ui/styles/tokens.css and packages/ui/styles/colors.css. If those CSS files change, regenerate the manifest and commit the result:

pnpm gen:token-manifest
pnpm check:token-manifest

The 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.