Imported from andtii/zero-wip (
packages/zero-kit/skills/design-system/SKILL.md). Install upstream withnpx skills add andtii/zero-wip --skill design-system. Copyright stays with the author.
Generating a Zero design system
A Zero design system is data: a TokensInput (theme palettes on the
shared token contract) plus RecipeInputs (per-part styles against each
component's anatomy). No component code is ever written or changed.
Workflow
-
Read the anatomy manifest —
node_modules/@sigx/zero/dist/manifest.json(or https://signalxjs.github.io/zero/manifest.json). It lists every component's parts, theirdata-statevalues (as ready-made selectors), boolean flags, and token hints. Style ONLY what the manifest declares — where "the manifest" means zero's PLUS any ecosystem fragments the project merges (see "Ecosystem components" below): check the project for packages shipping a manifest fragment ({ "package": …, "components": […] }, or afragmentexport) before deciding the component list is zero's built-ins. A part may also carryhiddenIn: the states in which zero's runtime sets thehiddenattribute on it (avatar.imagewhileerror,tabs.panelwhileinactive). Those states never paint — don't style them, and don't worry about telling them apart from the visible ones. Zero enforces this from@layer zero.structure, which sits afterzero.recipes, so adisplayyou set on such a part is dead in those states rather than dangerous — it used to defeat the hiding entirely (#209). -
Set up the package. There is no scaffolding command and there will not be one in this package (#10, closed): a sigx CLI plugin only loads where
@sigx/zero-kitis already installed, so it could never run in the empty directory a new design system starts as. Copy the shape of an existing design system instead —@sigx/zero-basicis the smallest:packages/<name>/ package.json # peerDependency + devDependency on @sigx/zero, # devDependency on @sigx/zero-kit; "build": tsgo && node build.mjs tsconfig.json build.mjs # validate → compile → writeArtifacts (copy it verbatim) src/{tokens,recipes,design-system,index}.tssrc/index.tsis the runtime half: it registers each theme sothemeController()can switch between them. Hand your wholetokensobject toregisterThemesand it derives the rest — including the theme picker's swatch, which it reads from your owntokens.swatch(see step 3) so it matches your compiledmanifest.json. Never hardcode role names here: a design system whose distinguishing colours aren'tprimary/neutralwould render every theme identically in a picker.import { registerThemes } from '@sigx/zero'; import { tokens } from './tokens.js'; export { roles, system, tokens } from './tokens.js'; export { recipes } from './recipes.js'; export { designSystem } from './design-system.js'; export function installThemes(): void { registerThemes(tokens); }Import from the kit type-only in
tokens.tsandrecipes.ts: those modules ship in the browser bundle, and the kit is Node-only. (@sigx/zerois the one runtime import a design system makes.) -
Author tokens (
src/tokens.ts): one light + one dark theme minimum, paired viapair. Declare the color vocabulary first:rolesnames every color role the design language needs — use the recommended eight (primary|secondary|accent|neutral|info|success|warning|error, the default whenrolesis omitted) unless the brief demands otherwise, and add/rename/drop roles freely when it does (e.g. Material-stylesurface: { content: false, soft: false }tonal steps). Every theme must then define every declared role (+ its-contentwhen declared) plus the fixed base surfacesbase-100/200/300/base-content. Rules of thumb:x-contentmust contrast withxat ≥ 4.5:1 (the validator errors < 3:1).- oklch() everywhere; keep hue families consistent between light and dark.
softMix(0.08–0.2) controls the derived-softtinted surfaces.- Structural feel goes in
system, declared once for the whole design system — not repeated per theme. Categories today:radius(selector/field/box),size(selector/field),spacing,shadow,motion,typography,border,disabledOpacity. Brutalist ⇒ radius 0- thick border; soft/friendly ⇒ large radius.
The
--text-*ramp lives atsystem.typography.sizes, NOTsystem.text— an unknown key undersystemis ignored silently, so the ramp would simply never appear.
Annotate withexport const system = { radius: { selector: '0', field: '0', box: '0' }, border: '3px', } as const satisfies SystemTokens; export const tokens: TokensInput<typeof roles, typeof system> = { roles, system, defaultLight: 'brut', themes: { /* … */ }, };TokensInput<typeof roles, typeof system>— that is what narrows per-theme overrides to the keys you declared. - thick border; soft/friendly ⇒ large radius.
The
- The keys inside a category are yours:
recommendedis what@sigx/zero/cssships fallbacks for, not a limit. Declareradius: { pill: '9999px' }and it flows into your manifest. - Typography is the axis a style brief leans on hardest.
typographydeclaresfonts(FAMILIES —--font-sansis a stack, never a size),weights,leading,tracking, and the--text-*size ramp. Give the ramp either as explicitsizesor as a modularscale:typography: { fonts: { sans: 'Inter, system-ui', mono: 'JetBrains Mono, monospace' }, weights: { normal: 400, medium: 500, semibold: 600, bold: 700 }, leading: { tight: 1.2, normal: 1.5 }, tracking: { tight: '-0.01em', normal: '0em', wide: '0.05em' }, scale: { base: '1rem', ratio: 1.25 }, // generates --text-* sizes: { '3xl': 'clamp(2rem, 5vw, 4rem)' }, // …one step hand-tuned }sizeswins per key, so a generated ramp with a hand-tuned display size is a normal thing to write.ratiois the whole personality: 1.125 is restrained, 1.5+ is dramatic. Weights and leading are unitless — the validator rejects700pxand1.5rem, because CSS drops both silently. Fluid type belongs insizesas aclamp(), not in the generator: the bounds are a design decision, not a ratio. - Density and elevation are personality axes too.
spacingemits--space-*(padding, gap, margin) andshadowemits--shadow-*. Recipes reference them instead of literal rems and box-shadows, so the whole system can be tightened or flattened in one place. Airy ⇒ a wider ramp; brutalist ⇒ hard offset shadows (4px 4px 0 0) and often no blur. Shadows usually need a dark counterpart — one tuned for a white page is nearly invisible on a dark surface, so put the heavier ramp insystemDark.shadow.light-dark()cannot help here; it only takes colors. - Motion is a personality axis — declare it, don't inline it.
motion: { durations, easings }emits--duration-*/--ease-*; recipes then writetransition: background var(--duration-fast) var(--ease-standard)instead of magic numbers, and the whole system retunes in one place. Snappy ⇒ 100–150ms with a sharp curve; stately ⇒ 300ms+ and gentler. Durations must carry a unit — CSS silently ignores a bare150, and the validator errors on it. Referencingvar(--duration-*)is what makes a recipe respectprefers-reduced-motion: the kit collapses every declared duration to ~0 in that mode. A hardcoded0.2sopts out of that, so don't. One exception: a looping animation (a spinner) should not take its duration from these tokens — collapsing it would spin absurdly fast rather than stop. Leave it literal until recipe conditions land. - Omitting a category entirely is fine — the fallbacks apply. Absence is never a validation error.
- Values that must differ by color scheme go in
systemDark(applies to everycolorScheme: 'dark'theme); a single theme overrides via its ownsystemblock. Resolution order:system→systemDark→theme.system. - DS-specific tokens (a blur radius, a glow color…) go in
customdeclarations (name →{ description, syntax? }), valued per-theme incustom— never inextra, which the validator flags as undeclared. A custom token inside a category namespace (--radius-…) is an error; declare it insysteminstead.
-
Author recipes (
src/recipes.ts): for each component in the manifest, aRecipeInputwithparts.<name>.basestyles andparts.<name>.states. State names resolve automatically: machine states (open,checked,active, …) →[data-state]selectors; flags (disabled,focus-visible) →[data-*];hover/focus-visible/active(when not a machine state) → real pseudo-classes. Reference tokens withvar(--color-*),var(--radius-*)— never hardcode palette colors in recipes. Cover every declared state (empty{}marks intentional no-styling). Always stylefocus-visiblevisibly. Conditional styles go inat— the same shape, recursively:popup: { base: { width: '100%', height: '100dvh', borderRadius: '0' }, // mobile first at: { sm: { base: { maxWidth: '32rem', borderRadius: 'var(--radius-box)' } }, 'reduced-motion': { base: { transition: 'none' } }, '@starting-style': { states: { open: { opacity: '0' } } }, }, }A key is a breakpoint declared in
tokens.breakpoints(emitted as@media (min-width: …)), a built-in (reduced-motion,hover-none,prefers-dark,forced-colors,print), or anything starting with@, used as a raw prelude. Anything else is a hard error listing what was available.- Author mobile-first: breakpoints are
min-width, sobaseis the small-screen case. Declare them ascending — declaration order is emission order, and the validator enforces it. atworks insidevariantstoo, so responsive variants need nothing new.prefers-darkis the system preference, not your dark theme; it does not fire for[data-theme="…-dark"].- A looping animation should be stopped under
reduced-motion(animation: 'none'), never shortened — collapsing its duration makes it spin faster instead of settling. - Animate presence in the recipe — presence needs no runtime helper.
Zero never unmounts a popup: it keeps the node mounted,
toggles
data-stateand calls the nativeshowPopover()/showModal(). So both directions are CSS:popup: { base: { opacity: '0', transform: 'translateY(-4px)', transition: 'opacity var(--duration-fast) var(--ease-standard), ' + 'transform var(--duration-fast) var(--ease-standard), ' + 'display var(--duration-fast) allow-discrete, ' + 'overlay var(--duration-fast) allow-discrete', }, states: { open: { opacity: '1', transform: 'none' } }, at: { 'starting-style': { states: { open: { opacity: '0', transform: 'translateY(-4px)' } } }, 'reduced-motion': { base: { transition: 'none' } }, }, }starting-styleis the state the entry animates FROM. The twoallow-discreteentries are the exit: they keep the element rendered — and, viaoverlay, in the top layer — for the length of the transition. Leave them out and the entry animates while the close is instant, which the validator warns about because nothing else would tell you. (overlayis Chromium-only as of writing; elsewhere the exit degrades to instant.) - Toast presence is runtime-managed — the one exception to the rule
above. Do NOT use
@starting-style/allow-discreteon toast parts. Toasts must eventually unmount (popups never do), so zero drives their presence: a toast root mounts asclosed, flips toopena frame later, and afterdismiss()stays mounted ascloseduntil its longest computed transition/animation finishes (instantly when there is none — reduced motion included, in every engine). Style the plain two-state transition and both directions work everywhere:
Theroot: { base: { opacity: '0', transform: 'translateY(8px)', transition: 'opacity var(--duration-normal) var(--ease-standard), ' + 'transform var(--duration-normal) var(--ease-standard)', }, states: { open: { opacity: '1', transform: 'none' } }, at: { 'reduced-motion': { base: { transition: 'none' }, states: { open: { transform: 'none' } } } }, }viewportis apopover="manual"top layer that zero shows while any toast is mounted. Override the UA popover defaults (position: fixed,inset: auto,margin: 0,border,background) and position it fromdata-placement(top-start|top|top-end|bottom-start|bottom| bottom-end, also mirrored on each root, so the enter direction can follow the edge). Gate anydisplayyou set behind&:popover-open— an unconditional one would defeat the UA's hiding of the closed popover. Stacked/offset effects key on the published--toast-index/--toast-countcustom properties, the same contract idea as--press-*. The toast root also carriesdata-colorper toast, so avariants.colorblock routing roles through a component token is the natural shape. - A disclosure panel animates through
::details-content. Collapsible and Accordion are native<details>, so the height animation belongs on the browser's own wrapper, withinterpolate-sizemakingautoa legal endpoint. Set it on the element, not globally:
Accordion'sroot: { base: { interpolateSize: 'allow-keywords' }, selectors: { '&::details-content': { blockSize: '0', overflow: 'hidden', transition: 'block-size var(--duration-normal) var(--ease-standard), ' + 'content-visibility var(--duration-normal) allow-discrete', }, '&[open]::details-content': { blockSize: 'auto' }, }, }<details>is itsitempart, notroot. - …and the header still has to say it is open. The panel expanding is
the browser doing its job, not your design system saying anything, so
trigger: { states: { open: {}, closed: {} } }on a collapsible or an accordion ships a header that is byte-identical either way — and a list of items that are all collapsed has no open one to compare against. CI fails it (__tests__/state-legibility.test.ts): a*triggerpart in a component with nopopuppart — collapsible, accordion, tree-view, i.e. the ones that disclose in flow — must differentiate its states on itself or on a sibling*indicator. Tree-view's rotatingbranch-indicatoris the idiomatic answer; collapsible and accordion declare no indicator part, so the signal has nowhere to go but the header itself — a tint, an accent ink, an inset rule under it. If you have a reason for an in-flow trigger to say nothing,skipStates: { trigger: ['open'] }waives the rule — state the reason. Triggers of components that open an overlay (dialog, popover, tooltip, menu, select, combobox) are outside the rule entirely, not waived by it: the revealed thing floats above the page and takes focus, so whether the trigger also changes is your call and the guard never asks. - Spell direction logically. It is a correctness rule, not a style.
inset-inline-start/margin-inline-*/padding-inline-*/border-inline-*, neverleft/right/margin-left/border-left. A physical property compiles and renders; it is simply the same side in both writing directions, so underdir="rtl"one rule stays put while everything around it mirrors.validate-recipeswarns on every physical property that has a logical twin, so you will be told.- Symmetric pairs are exempt and stay physical.
left: 50%with atranslateX(-50%)is centring, not a side; a logical inset there would decentre it. So is a value the runtime measures physically —left: var(--press-x)is a pixel offset from the element's own left edge. - A rotated part is drawing, not positioning. Once a box is rotated,
its
border-leftis a stroke of a glyph rather than an edge of a box, and mirroring it would mirror the drawing. A check mark is not mirrored in RTL. The lint exempts any part that declares a rotation. - The lynx target is the inverse: physical only. Lynx has no RTL
flow, and its Android engine does not resolve the logical
inset/margin/padding spellings or the standalone
translate/rotate/scaleproperties at all (iOS does — measured, signalxjs/lynx#1084), so the lynx emitter refuses them with a report entry. A recipe whose geometry rides a logical spelling restates it physically (top/left/margin-left/transform: translate…()) in itstargets.lynxsection. transformhas no logical form, so it needs a shape rather than a rename: put the sign in a custom property and rebind it. This is the one case the lint cannot see, so it is on you.
Half of this is worse than neither half: a logical anchor with a physical travel starts the thumb at the reading end and then moves it further that way, off the track. The same applies insidethumb: { base: { insetInlineStart: 'var(--switch-pad)', '--switch-thumb-dir': '1' }, states: { checked: { transform: 'translateX(calc(var(--switch-thumb-dir) * 2rem))' } }, selectors: { [`&${rtl}`]: { '--switch-thumb-dir': '-1' } }, }@keyframes— a custom property in a keyframe resolves against the animated element, so the multiplier works there too.- Write the RTL selector the forgiving way, as a const beside your
other helpers:
const rtl = ':where(:dir(rtl), [dir="rtl"], [dir="rtl"] *)';:where()is forgiving, so an engine without:dir()drops that one argument and still matches the attribute forms. It also contributes no specificity, so the rule ties with the one it corrects and wins on source order — declare it after, not before. - A glyph that points is direction-bearing too.
›points right in every writing direction. Swap it under RTL when it comes fromcontent:('"\2039"'); mirror the part withscale: '-1 1'when the glyph is element text the runtime renders and CSS cannot replace it.scalecomposes outsidetransform, so a chevron that rotates to open keeps rotating correctly.
- Symmetric pairs are exempt and stay physical.
- A state indicator is DRAWN GEOMETRY, and it must look different in
every state it declares. An
indicatorpart — checkbox's tick, radio's dot, a rating symbol, select's checkmark — exists for exactly one reason: to say which state the thing is in. Three rules, and the first two are enforced in CI by__tests__/state-legibility.test.ts:- Every pair of a part's declared states must render differently.
Declaring
statesand styling them alike is the one bug the anatomy cannot catch for you:fullandhalfboth settingcolor: var(--rating-fill)draws a half rating as a full one. The guard reads the compiled CSS, so the difference may live anywhere —states,selectors, a variant, a pseudo-element — but it must exist in the default render: a difference that only appears inside@media(theforced-colors/printglyph fallback of rule 3, a breakpoint,hover: none) does not count, and neither does one made only oftransition/will-change, which changes how a state arrives and not how it looks. The only opt-out you author isskipStates; it is read per part (skippingcheckedonitem-labelwill not excuseitem-indicator) and it is a design claim, so comment it. The manifest supplies a second one for free: a state in a part'shiddenInpaints nothing at all, so the guard never asks you to differentiate it — that is why avatar's three states may be styled identically everywhere. - Draw the mark, don't typeset it.
content: "✓"hands the mark's weight to whatever font the reader has, cannot express a fraction, and cannot animate.clip-pathon acurrentColorslab, two borders of a rotated box, or a masked gradient all scale with the size token and interpolate — so the mark draws itself. If you paint by masking or clipping the runtime's own default symbol instead of drawing your own shape, you inherit its font coverage: rating's defaults are★/☆(U+2605/U+2606) and itshalfis a full star you are expected to halve, because the half-star codepoint (U+2BEA) is poorly covered in the common system sans stacks and renders as tofu.
Grow it from a degenerate form of itself (an arm of zero length, full thickness), not fromindicator: { base: { background: 'currentColor', clipPath: 'polygon(20% 100%, 20% 80%, 50% 80%, 50% 80%, 70% 80%, 70% 100%)', transition: 'clip-path var(--duration-normal) var(--ease-standard)', }, states: { // Same point COUNT and order in every state, or the browser // has nothing to interpolate and the mark pops. checked: { clipPath: 'polygon(20% 100%, 20% 80%, 50% 80%, 50% 0%, 70% 0%, 70% 100%)' }, unchecked: { opacity: '0' }, }, }scale(0)— the latter animates the stroke weight too. Every length a percentage of the control, so one declaration rides the whole size ramp. - Geometry painted with
backgrounddisappears in two media —forced-colors(author paint is revalued) andprint(backgrounds are dropped). Give the mark a glyph fallback in both, keyed by the named conditions:at: { 'forced-colors': fallback, print: fallback }, where the fallback setsclip-path: noneand hangscontent: "\2714"off::afterinCanvasText. The exception is a mark whose state is a fraction — a half rating — which no glyph can say: keep the geometry and re-source the paint instead (background: CanvasTextunder forced colors,print-color-adjust: exacton paper).
- Every pair of a part's declared states must render differently.
Declaring
- Press feedback: the runtime publishes the press, the recipe styles
it. CSS can see
:activebut not where a press landed, so on parts whose anatomy declares thepressedflag zero writes the data below. Publishing parts: button root; tabs tab; dialog/popover trigger+close; menu trigger+item+sub-trigger; combobox trigger+item; select trigger+item (select and combobox items are pointer-only — keyboard selection stays on the trigger via aria-activedescendant, so item ripples fire for pointer presses only); toast action+close; collapsible/accordion trigger; switch/checkboxcontroland radio-groupitem-control(the press lands anywhere in the label row, the feedback on the control); slider'scontrol(data-pressedonly — a drag has no one-shot). Lifecycle: a press ends when the gesture ends — uncaptured pointerleave cancels it, a captured pointer (touch) holds it until release, and drag surfaces listen for the release at the window instead of ending on leave.data-pressed— present while the pointer/key is physically down. Key non-animated press effects on this (a tint, a scale, an offset).data-press-animating— present from press-start until the part's CSS animation finishes, not until release, so a quick tap plays a one-shot effect (a ripple) to completion. The runtime clears it when that animation ends, however it ends — finished, cancelled, or destroyed with the stylesheet that declared it — so a design-system swap mid-ripple leaves nothing stranded. Put the whole effect in ONE keyframe animation whose duration isvar(--duration-*); a rule on this flag that starts no animation is dead (the validator warns).--press-x/--press-y— the press point in px relative to the part; keyboard presses (Enter/Space) get the box center.--press-r— distance to the farthest corner, so a covering circle iscalc(var(--press-r) * 2)wide without trigonometry. Material's ink ripple as a recipe (needsposition: relative+overflow: hiddenon the part):
Reduced motion needs nothing extra — token durations collapse, the animation ends immediately, and theselectors: { '&::before': { // held state layer — also the reduced-motion fallback content: '""', position: 'absolute', inset: '0', opacity: '0', background: 'var(--btn-ripple)', pointerEvents: 'none', transition: 'opacity var(--duration-fast) var(--ease-standard)', }, '&[data-pressed]::before': { opacity: '0.12' }, '&::after': { // the ripple, anchored to the press point content: '""', position: 'absolute', left: 'var(--press-x, 50%)', top: 'var(--press-y, 50%)', width: 'calc(var(--press-r, 0px) * 2)', height: 'calc(var(--press-r, 0px) * 2)', borderRadius: '50%', background: 'var(--btn-ripple)', transform: 'translate(-50%, -50%) scale(0)', opacity: '0', pointerEvents: 'none', }, '&[data-press-animating]::after': { animation: 'btn-ripple var(--duration-slow) var(--ease-standard)', }, }, keyframes: { 'btn-ripple': 'from { … scale(0); opacity: 0.12; } to { … scale(1); opacity: 0; }' },data-pressedtint remains as the non-motion feedback. Hide decorative press layers underforced-colors. The same hooks express non-Material ideas: a brutalist stamp ([data-pressed] { translate: 2px 2px }), a scale-press, a spotlight at--press-x/y. The unbounded variant (MD3 selection controls): a fixed circle centered on the part — same two pseudos, butleft/top: 50%, a fixedwidth/height(e.g.calc(var(--size-selector) * 15)for a 2.5× halo), coordinates ignored, and NOoverflow: hiddenso the halo extends past the box. Two lifecycle constraints worth knowing:- A one-shot animation must target the FLAGGED element (or its own
pseudo-elements). The runtime clears
data-press-animatingunless a CSS animation targets that element, and itsanimationendlistener ignores events from descendants — so a ripple on a child's pseudo silently never plays. Material's switch routes around this: the held layer lives on the thumb's::before, lit from the control's flag via a descendant selector ('&[data-pressed] [data-part="thumb"]::before'), with no one-shot at all. - A native slider needs a custom skin before press feedback can
render in Blink: it ignores thumb-pseudo styling on a native
(
appearance: auto) range input, so a halo written against::-webkit-slider-thumbsilently never paints there (Gecko honours::-moz-range-thumbeither way — skin it anyway, for one look). Skin thecontrolpart (appearance: 'none', own track and thumb pseudos), then set ONE custom property from the states and read it in each vendor thumb pseudo — the pseudos cannot share a selector list (one unknown selector invalidates the whole rule), and the variable defines the halo once per engine instead of once per state per engine:
Chrome treats range inputs as ALWAYSbase: { appearance: 'none', outline: 'none', '--slider-halo': 'transparent' }, states: { 'focus-visible': { '--slider-halo': 'color-mix(in oklab, var(--color-primary) 10%, transparent)' }, pressed: { '--slider-halo': 'color-mix(in oklab, var(--color-primary) 12%, transparent)' }, }, selectors: { '&::-webkit-slider-thumb': { appearance: 'none', /* size, radius, background, */ boxShadow: '0 0 0 calc(var(--size-selector) * 2.5) var(--slider-halo)' }, '&::-moz-range-thumb': { /* same, separate key */ }, },:focus-visible— even on mouse focus — so an input-box outline reads as a stuck rectangle on press; the halo must BE the focus indicator. Fill the track with the runtime-published--slider-percentas a gradient stop, and revert toappearance: 'auto'underforced-colors(native rendering knows forced colors better than a custom skin).
RecipeInput.csstakes raw CSS for anything the typed surface can't say.- Style Button first, and make its axes compose. It is the component a
design system is judged on, and the only one where all three axes matter
at once. Do NOT write a rule per
color×variant— eight roles by four fills is thirty-two rule sets before sizes. Route the colour through component tokens the fill rules read:
Adding a ninth role then costs one rule instead of four.tokens: { '--btn-accent': 'var(--color-primary)', '--btn-on-accent': '…' }, variants: { color: { success: { root: { base: { '--btn-accent': 'var(--color-success)', … } } } }, variant: { solid: { root: { base: { background: 'var(--btn-accent)' } } } }, size: { lg: { root: { base: { padding: 'var(--space-md) var(--space-xl)' } } } }, }, defaultVariants: { color: 'primary', variant: 'solid', size: 'md' }, - Derive the colour axis from
roles; never retype the list. A role declared intokens.tsbut missing from a recipe'scoloraxis renders as the default colour — the variant reads as broken rather than as one role being unwired, and nothing in the type system notices. Importrolesas a value and filter it:
Wire the SAME set on every component that has a colour axis. The validator errors on a colour value that names no declared role, and warns when one component wires fewer roles than its siblings — holding a role back everywhere on purpose is fine and says nothing.import { roles } from './tokens.js'; // Roles opting out of `-content` or `-soft` are fills and hairlines // (Material's `surface*`, `outline`) — not something a button can be. const ROLES = Object.entries(roles as Record<string, RoleDecl>) .filter(([, decl]) => decl.content !== false && decl.soft !== false) .map(([name]) => name); - The
sizeaxis is your vocabulary too.xs|sm|md|lg|xlis the recommended ramp, not a fixed one. If the brief calls for density steps or a numbered ramp, declare it and use it —<Button.Root size="…">accepts any name, and the validator checks recipes against what you declared rather than against xs–xl:
Declare it even when you keep the recommended ramp verbatim only if you want it stated in the manifest; omitting// src/tokens.ts export const tokens: TokensInput<typeof roles, typeof system> = { roles, system, sizes: ['compact', 'comfortable', 'spacious'], // or [...SIZE_SCALE_LIST, '2xl'] defaultLight: '…', themes: { /* … */ }, };sizesalready means xs–xl. Notesizesis thedata-sizeaxis — unrelated tosystem.size(--size-*, the control-sizing unit) and tosystem.typography.sizes(the--text-*ramp). - An axis you don't have, declare out of existence.
sizes: []means "this design system has no size axis";roles: {}means the same about colour. Empty is not the same statement as absent — omitting the key means "I didn't say", and takes the recommended vocabulary. Declaring it empty means "there isn't one", and that reaches the manifest, the coverage report and the generated types, wheresize/colorbecomesneverrather than a prop that is offered and then matches nothing. Note the two spellings differ because the declarations do:rolesis a map,sizesa list.
Reach for this whenever the brief's colour story isn't "eight interchangeable semantic roles". A design system with one accent, or with colour folded intoroles: {}, // no `color` axis — the palette lives in `custom` sizes: [], // no `size` axis — one set of metrics, deliberatelyvariant, genuinely has no colour axis, and saying so is better than declaring eight roles and wiring two.@sigx/zero-herouiand@sigx/zero-carbonboth declareroles: {}with their palette as declaredcustomtokens — themed and validated like any other token, just not passable ascolor="…". Therisobrief shows both opt-outs at once. - Every declared value is a promise — honour it in every scope that
takes the axis, and write the un-attributed step down. A step you
declare and do not wire renders as the base, so a ramp with a hole in
it goes BACKWARDS: zero-carbon declared five sizes and shipped
xl/2xlonbuttononly, and every other control got smaller atxlthan atlg(avatar 48 → 40px, checkbox 22 → 18). Nothing failed, because the step was declared, not misspelled. CI now asks (__tests__/axis-value-coverage.test.ts), and it reads the compiled CSS, so the rule may come fromvariants, acompoundVariantsmatch or the rawcssescape hatch:- A scope that wires an axis at all must account for every value the
design system implements anywhere. Wiring
smandlgand stopping is the failure —buttonshippingxlis what makesxla step this design system has, and every sibling that takessizeowes it one. A scope that wires no value of an axis is outside the rule (a dialog takes no size); whether it ought to wire one isaxis-coverage.test.ts's question. - Write the middle step as an empty entry.
md: {}is not noise — it is how you say "the base already ISmd", which is why a step that emits no rule is not a hole. Restating the base's values there instead would be a second copy free to drift. Put it wherever your base actually sits: zero-carbon's button writeslg: {}, because Carbon's default button is the 48px one.size: { sm: { root: { base: { minHeight: 'var(--size-8)' } } }, md: {}, // the base IS md lg: { root: { base: { minHeight: 'var(--size-12)' } } }, },defaultVariants: { size: 'md' }does not count as saying it — that field applies CSS defaults, and accepting it would let a step you simply forgot be excused by a line written for another purpose. - Exactly one value per scope may claim the base. Two empty entries
render identically, so "fixing" a missing
xlby writingxl: {}fails too — and rightly:xlwould still paint asmd. An entry whose only rule sits insideat: { … }does not claim the base either; at the default viewport it silently renders as one. - A value NOTHING in the design system wires is reported once, for the
whole system. Usually it means you declared a vocabulary wider than
the one you built — narrow the declaration, wire the step, or say the
vocabulary belongs to one scope in
tokens.scopes. The one exemption is colour: a role declaredcontent: falseorsoft: falseis a fill or a hairline (Material'ssurface*,outline), which is a token and not something a control can be, so it is never expected on thecoloraxis.
- A scope that wires an axis at all must account for every value the
design system implements anywhere. Wiring
- Declare the
variantaxis and any custom axes (tokens.variants/tokens.axes) with the values your recipes key on — declaring closes the set, so a recipe typo is a build error instead of a silently minted value, and the vocabulary reaches the manifest and the generated types:variants: ['solid', 'outline', 'soft', 'ghost'], axes: { density: ['compact', 'comfortable'] }, solid | outline | soft | ghostis a convention, not the contract. Four of the six in-repo design systems declare exactly that set, which makes it look load-bearing. Nothing requires it, and copying it into a brief that doesn't mean it is the most common way to get a design system that reads as generic. Thevariantvocabulary is yours. It does not even have to be orthogonal to colour. HeroUI v3 has nocolorprop at all and fuses colour into a seven-membervariant, wheredanger-softis a single value rather than adanger×softcrossing:
Carbon does the same under the name// packages/zero-heroui/src/tokens.ts — a fused vocabulary roles: {}, // no colour axis to be orthogonal TO variants: ['primary', 'secondary', 'tertiary', 'outline', 'ghost', 'danger', 'danger-soft'],kind. Both are real packages here, so read them when the brief's axis surface isn't the default one. If the brief's colours and treatments genuinely are independent, keep them on two axes — the point is to decide, not to inherit. And the set you declare is usually a button's: see the next bullet before pooling every component's variants into one flat list.- A vocabulary may belong to one scope (
tokens.scopes). Real design systems do not give every component the same variants: Radix Themes varies a select asclassic | surface | softand a button as something else entirely. Declare the union attokens.variantsand say which part of it each scope offers:
Every axis works this way —variants: ['solid', 'outline', 'classic', 'surface', 'soft'], // the UNION scopes: { button: { variants: ['solid', 'outline'] }, select: { variants: ['classic', 'surface', 'soft'] }, },colors,sizes,variants,axes,modifiers— and a scope never widens, only narrows. An absent key means the scope offers the whole union — but once ANY scope narrows an axis, every sibling that actually paints that axis should declare too, and the validator warns until they do. Restating the union is not redundancy: it is the explicit claim "yes, this one carries all of it", which is the answer the first narrowing puts in question. Scopes that wire nothing for the axis are not asked; an empty list is the claim "this scope has no such axis at all" (variants: []), the same grammarsizes: []uses design-system-wide. Two things to know before using it. Restricting one scope while a styled sibling stays open is warned about, because the sibling really is still offering values declared for someone else — restrict both, and restating the whole union for a scope is a legitimate, un-warned way to say "yes, this one carries all of it". And a value in the union that no scope claims is a warning of its own: give it to a scope, or drop it. - Presence-only styling is a
modifier, not a one-member axis. Some things a control is have no vocabulary: it is icon-only or it isn't, pending or not. Declaringaxes: { block: ['block'] }to express that is the encoding modifiers replaced — it mints a value whose only job is to be present.// tokens.ts — declared design-system-wide, like any vocabulary modifiers: ['icon-only', 'pending'],
They emit as// recipes.ts — keyed by name, no value layer modifiers: { 'icon-only': { root: { base: { padding: 'var(--space-sm)', aspectRatio: '1' } } }, pending: { root: { base: { cursor: 'progress', opacity: '0.8' } } }, },[data-mod-<name>]and consumers pass them asmods={{ 'icon-only': true }}. Thedata-mod-*prefix keeps them disjoint from zero's closed flag vocabulary by construction, so a modifier calleddisabledorselectedcan never collide with the runtime's own attribute — which is why modifiers are prefixed and axes are not. compoundVariantsis for the rule that no single declaration can state — where a combination needs something neither member implies. Amatchis a plain object; a value oftruenames a modifier rather than an axis value:compoundVariants: [{ // A ghost control has no fill, so `overprint` alone would do nothing. match: { variant: 'ghost', overprint: true }, parts: { root: { base: { background: 'var(--riso-tint)' } } }, }],matchis checked againstdefaultVariantstoo, so an entry matching{ variant: 'solid' }fires on an element carrying nodata-variantat all whensolidis the default. Don't reach for it to avoid the component-token pattern above — routing colour through--btn-accenthandles the colour × variant cross product in a handful of rules, where compounds would need one entry per pair.- You are not limited to three axes.
color,sizeandvarianthave named props because almost every design language has them. If the brief needs another — density, emphasis, tone, elevation — declare it intokens.axes, keyvariantson it, and consumers reach it through zero'saxesprop:
This is also the answer when one component wants two vocabularies. Radix's Select varies its Trigger asvariants: { density: { compact: { root: { base: { paddingBlock: 'var(--space-2xs)' } } } } },classic | surface | soft | ghostand its Content assolid | soft. That is not one axis restricted twice — zero puts one attribute per axis on the scope's carrier part and cascades it to every part below, so a second vocabulary is a second axis:
which compiles toaxes: { 'content-variant': ['solid', 'soft'] }, // tokens.ts variants: { 'content-variant': { soft: { popup: { base: { … } }, item: { … } } } },[data-part="root"][data-content-variant="soft"] [data-part="popup"]and reaches the popup, because zero has no portals. Don't look for a per-part restriction intokens.scopes; there isn't one, andpartsis rejected by name to keep it that way (docs/architecture.md, "Declared vocabulary"). dist/register.d.tsis generated, never authored.writeArtifactsemits it (withdist/register.js) from the compiled system: it augments@sigx/zero'sZeroVocabulary, so an app that addsimport '@sigx/<your-ds>/register'gets your themes, tokens and per-component axis values as types. Add the"./register"entry to yourpackage.jsonexports(copy it from@sigx/zero-basic) and never edit the emitted file.
An axis name must be kebab-case and may NOT be one the anatomy contract owns (<Button.Root color="primary" axes={{ density: 'compact' }}>Save</Button.Root>scope,part,state,orientation, or any flag such asdisabled/selected) — the validator errors and zero refuses to render it, because shadowingdata-statewould silently repoint every[data-state="open"]rule you wrote.
- Author mobile-first: breakpoints are
-
Assemble (
src/design-system.ts):{ name, tokens, recipes }exported asdesignSystem. -
Validate and iterate:
sigx zero:validate(after building the TS), or programmaticallyvalidateDesignSystem(ds, manifest). Fix every error and drive warnings to zero unless deliberate. This loop is the point: generate → validate → fix → repeat.Then run
sigx zero:validate --report. Validation answers "is anything wrong?"; the report answers "did I build what I said I would?" — the question a generated design system most often gets wrong, because nothing about it is an error. (--report-json <path>for the machine-readable form,-for stdout;zero:buildalso writesdist/report.jsonevery time.) Read four things:declared out of existence— the axes you opted out of withroles: {}/sizes: []. If an axis you meant to ship is on this list, you declared it empty by accident; if one you don't have is missing from it, you left the recommended vocabulary in place by omission.wired by nothing— a value the whole design system declares and no scope implements. Almost always a vocabulary wider than the thing you built: narrow the declaration, wire the step, or give the value to the scope it belongs to (tokens.scopes). Colour roles that opt out of-content/-softare exempt, being fills rather than variants.in no scope's vocabulary— you declared per-scope vocabularies and the union carries a value none of them claims. Different from the above: nothing is missing a rule, the declaration is simply carrying a word nobody asked for.- per-scope axis status — which components wire which axes, and where a
scope declared its own vocabulary, what it
offeredbeside what it wired. A scope that takes an axis and wires none of it is the gapaxis-coverageasks about; one that wires some values is the ramp-with-a-hole above. A scope listed underdiverges across componentswith(declared)beside it is not diverging — it narrowed on purpose.
-
Build:
sigx zero:build(or the package'sbuild.mjs) emitsdist/css/index.css+ per-component files. The app consumes it with two lines:import '<pkg>/css'andinstallThemes().
Ecosystem components (merged manifest fragments)
A project may use components zero doesn't ship — peer packages built on
zero's public authoring surface, each publishing a manifest fragment
({ "package": "<specifier>", "components": [anatomy.toJSON()] }, JSON
Schema fragment.schema.json). A design system that should cover them
merges the fragment rather than replacing the manifest:
- CLI:
sigx zero:validate --extra-manifest <path|specifier>(repeatable), same flag onzero:build. - Programmatic (
build.mjs):mergeManifests(zeroManifest, fragment)from@sigx/zero-kit. If the ecosystem package is private, do the adoption inbuild.mjsonly — never import it from the package'ssrc/, or the published module graph breaks.
Once merged, the scope is ordinary: write a RecipeInput for it like any
component, or adopt the package's recipe pack (a recipes export written
against the recommended token grammar) by spreading it into recipes — but
only when this design system keeps the recommended role names; a fused or
renamed vocabulary needs a hand-written recipe. The merge hard-errors on
scope collisions, provenance is stamped per component, and the generated
register.d.ts excludes merged scopes by name from its ZeroScope gate — all
automatic. A design system that deliberately does NOT cover an ecosystem
component simply never merges its fragment: the component renders unstyled
but accessible, which is the contract's baseline, not a failure.
The reference pair: @sigx/zero-ext-example (the fragment + pack) and
@sigx/zero-basic's build.mjs (the adoption).
What validate will catch
Content is checked, not just structure. These are errors:
- a
var(--…)this design system never declares — it resolves to nothing. The message suggests the nearest declared name. - a component that styles
focus-visiblenowhere, so keyboard focus is invisible. - a
skipStatesentry naming neither a state nor a flag of that part. - variants on a component with no
rootpart (Dialog, Popover, Tooltip, Menu) — the generated selectors can't match, so the rules would be dead CSS.
And these are warnings worth driving to zero:
- a hardcoded palette colour. Achromatic-with-alpha (
oklch(0% 0 0 / 0.3)) is exempt — that's a shadow or scrim, not palette. - a literal duration in a
transition: reduced motion only collapsesvar(--duration-*), so a literal opts out of the preference. - a component in the manifest with no recipe at all.
- a part that declares
starting-stylebut never transitions (the entry styles are never used), or transitions no discrete property withallow-discrete(the entry animates and the exit cannot —allow-discreteoveropacityalone changes nothing). - a part that declares
focus-visibleand doesn't style it. If the ring genuinely belongs on an inner part, say so withskipStates: { root: ['focus-visible'] }rather than leaving it implicit. Note thatskipStateshas a second reader: the state-legibility guard takes an entry as "this state is deliberately indistinguishable from its siblings". Silencing a coverage warning with it therefore also waives the guard for that state, on that part only — write the reason next to it. var(--x, fallback)referencing something undeclared — the fallback makes it safe, so it's the sanctioned way to read an app-supplied property.
Reference
The brief pack — start here
skills/design-system/briefs/ holds five complete, compiling starting points.
Each file is one TokensInput (every category filled, both schemes, contrast
clean) plus one worked RecipeInput for Button. Copy the closest one to
src/tokens.ts and src/recipes.ts, then diverge. They are compiled and
validated by the repo's test suite, so a brief that has gone stale is a
failing test rather than a trap.
The five are deliberately not five palettes — each one teaches a different
mechanic, and reading all five is the fastest way to learn what the token
contract can express. Note that the first four all take the default axis
surface (the recommended eight roles, xs–xl, and the four-name variant set);
riso is the one that doesn't, and it is the one to read when the brief's
shape isn't the conventional one:
| Brief | radius | border | Signature move | Teaches |
|---|---|---|---|---|
| brutalist | 0 | 3px | shadows drawn in var(--color-base-content) with zero blur, and steps() easings |
how far the standard categories stretch before you need a custom token |
| glass | 1.25rem | 1px | backdrop-filter: blur(var(--glass-blur)) on every floating surface |
declared custom tokens, and translucency that survives both schemes |
| corporate | 0.5rem | 1px | a two-part shadow ramp (contact + ambient) and a 1.2 type ratio | contrast discipline and declared breakpoints — the two things this brief is judged on |
| terminal | 0 | 1px | every duration is 0ms, and --shadow-* is a glow in var(--color-primary) |
0ms durations instead of transition:none, and a glow built from theme colours |
| riso | 0.125rem | 2px | overlapping ink multiplies instead of covering, via a modifier and a compound that matches it | roles:{} and sizes:[] to decline an axis, a fused variant vocabulary, modifiers and a compound that matches one |
Typography carries a brief further than anything else: brutalist wants a
mono or condensed stack with 800+ weights and wide tracking; editorial
wants a serif with generous leading; corporate wants a humanist sans and
a restrained ratio. The five ratios above — 1.414, 1.25, 1.2, 1.125, 1.333 —
are most of the difference between those five looks.
Worked design systems
Five live in this repo, in increasing distance from the defaults:
@sigx/zero-basic— the canonical starting point. Read itssrc/tokens.tsandsrc/recipes.tsbefore writing your own.@sigx/zero-brutalist— a brief taken to its extreme: radius 0,steps()easings, hard offset shadows drawn in the foreground colour, a 1.414 type ratio. Generated from this skill.@sigx/zero-material— a foreign vocabulary: thirteen colour roles, alevel1–level5elevation ramp,soft: falsetonal surfaces, and a role (outline) withcontent: false. Read this one when the brief needs names the recommended eight don't cover.@sigx/zero-heroui— a differently shaped vocabulary rather than a wider one:roles: {}(no colour axis at all), colour fused into a seven-membervariant, a three-step size ramp, and HeroUI'sisIconOnly/isPendingasdata-mod-*modifiers. The reference for everything in this skill about declining an axis or fusing one.@sigx/zero-carbon— the same shape under a vendor's own names: no colour axis, and the fused vocabulary declared askindwith Carbon's double-hyphen spellings (danger--tertiary) restored at the prop boundary by its generated./componentsmodule. Read it when the brief has to match an existing product's API rather than zero's.
Conformance fixtures — non-default axis surfaces, in miniature
skills/design-system/conformance/ holds one small file per surveyed vendor
(HeroUI, Material 3, Radix Themes, Ant Design, Carbon). Each declares that
system's real vocabulary and — for three of them — a compiling TokensInput
plus a Button RecipeInput exercising it, in the same shape as a brief but a
fraction of the size. They are the worked examples for the shapes the brief
pack doesn't cover: a numeric size ramp (sizes: ['1','2','3','4']), a custom
tokens.axes entry, a vendor-renamed axis, and camelCase modifier names
restored at the API boundary. docs/design-system-conformance.md is the
generated matrix they prove; docs/architecture.md §7 (the conformance
program) is the reasoning.
Briefs the pack does not cover
Reach for the nearest file and change these axes:
| Brief | Nearest | Change |
|---|---|---|
| editorial / magazine | corporate | a serif fonts.sans, leading.relaxed up to 1.8, ratio to 1.333 |
| playful / toy | glass | radius to 9999px on field, a bouncy emphasized easing, ratio 1.2 |
| dense / data-tool | corporate | halve the spacing ramp, ratio to 1.125, text.base to 0.875rem |
| neon / cyberpunk | terminal | keep the glow, raise chroma, restore real durations |