@sigx/zero-kit
v0.17.0
Published
Design-system authoring kit for SignalX Zero — typed tokens and recipes compiled to plain CSS, plus the `sigx` CLI plugin closing the AI-generate → validate → iterate loop.
Maintainers
Readme
@sigx/zero-kit
Design-system authoring for SignalX Zero:
typed tokens and per-part recipes compiled to plain, layered CSS against the
zero anatomy manifest. The barrel is Node-only — a built design system ships
CSS plus a tiny runtime module — with two purpose-built subpaths:
@sigx/zero-kit/define (the define* helpers from a node:-free module
graph, safe in a browser bundle) and @sigx/zero-kit/build (the standard
build pipeline as one function).
npm install -D @sigx/zero-kit @sigx/cliComing from 0.2.0-beta.1? The upgrade guide has a before/after for every breaking change, the kit's included.
@sigx/cli provides the sigx binary; the kit plugs its zero:build /
zero:validate commands into it on install (see CLI below). Install it
alongside — a package manager only links the executables of direct
dependencies.
import { defineTokens, defineRecipe, defineDesignSystem } from '@sigx/zero-kit';
export const designSystem = defineDesignSystem({
name: 'acme',
tokens: defineTokens({
// The color vocabulary is YOURS: declare any roles (omit for the
// recommended eight). Each emits --color-<role> (+ -content/-soft).
roles: { primary: {}, surface: { content: false, soft: false } },
// The size axis is yours too — omit for the recommended xs–xl ramp.
sizes: ['compact', 'comfortable', 'spacious'],
// The variant axis, and any custom axes. Declaring them closes the
// set: a recipe typo becomes a build error, not a minted value.
variants: ['solid', 'outline', 'ghost'],
axes: { density: ['compact', 'comfortable'] },
// Non-color tokens: declared ONCE for the design system, not per
// theme. Categories are closed; the keys inside them are yours.
system: {
radius: { selector: '0.375rem', field: '0.375rem', box: '0.75rem' },
border: '1px',
},
// Values that must differ by color scheme (light-dark() is a <color>
// function, so non-color tokens need this).
systemDark: { border: '2px' },
// DS-specific tokens, declared → validated + in the manifest.
custom: { 'glass-blur': { description: 'backdrop blur', syntax: '<length>' } },
defaultLight: 'acme', defaultDark: 'acme-dark',
themes: {
acme: {
colorScheme: 'light',
colors: { primary: 'oklch(60% 0.2 260)', 'primary-content': 'oklch(98% 0.01 260)', surface: 'oklch(97% 0 0)', /* + base-100/200/300/base-content */ },
custom: { 'glass-blur': '12px' },
}, /* … */
},
}),
recipes: [
defineRecipe({
component: 'tabs',
parts: { tab: { base: { padding: '0.5rem 1rem' }, states: { active: { color: 'var(--color-primary)' } } } },
}),
],
});A part's selectors keys are nested selectors scoped to that part: & is
the part's own selector, and a key without & selects descendants of it. A
key may be a selector list, and each item is scoped on its own —
'&:hover, svg' compiles to <part>:hover, <part> svg, never a global svg
rule. Only top-level commas split the list; the comma in
'&:not([data-disabled], [data-invalid])' stays inside its item, as does
one in a string, a comment or after a backslash escape. An & in a string,
a comment or escaped is text and is never substituted. An item whose only
&s sit inside a nested list with an argument lacking one
(':is(&:hover, svg)') is a build error — the & would not scope svg —
while ':where(.dark &)' and ':is(&.a, &.b)' are fine. A key the compiler cannot balance (an unclosed bracket, string or
comment) is a build error too, never a half-scoped rule.
Only the base surfaces (base-100/200/300/base-content) are fixed — they
anchor -soft derivation, light-dark() emission and theme swatches.
defaultDark is optional: without it (or naming the same theme as
defaultLight) there is one scheme, and :root takes the default theme's
own colorScheme — a dark-only design system names its dark theme as
defaultLight and ships color-scheme: dark.
Each <role>-soft is the theme's explicit value, else a live
color-mix(in oklab, var(--color-<role>) <softMix>%, var(--color-base-100)).
softMix is a ratio in [0, 1] (default 0.16; anything outside is a
validation error) and is written unrounded — 0.125 is 12.5%, the ratio
the lynx target and the contrast audit bake. On :root each scheme resolves
its own side, so system dark with no data-theme paints the dark theme's
tint (its explicit value or its own softMix), not the light one's.
Declared roles are @property-registered in the compiled CSS and surfaced,
with sizes, variants, modifiers, axes, system, custom and
breakpoints, in the DS's dist/manifest.json (which also lists every custom
property the design system emits, and the axis values each recipe wires, per
component).
A custom token that declares a syntax is @property-registered too, with
the default light theme's value as its initial-value. That value must be
computationally independent (no var(), env(), light-dark(),
currentColor, or font/container-relative unit such as em, rem, ch,
cqi), or the browser drops the whole rule. The compiler skips such a
registration and validate warns: the token still ships its value, untyped.
A universal syntax: '*' needs no independence (it keeps 1em), but a
substitution function (var(), env(), attr()) is still invalid there, so
it registers without the initial-value instead.
writeArtifacts additionally emits dist/register.d.ts — a generated,
never authored augmentation of @sigx/zero's ZeroVocabulary, so an app
importing @sigx/<ds>/register gets the design system's themes, tokens and
per-component axis values as closed types.
Every variant axis works this way. roles names what color accepts,
sizes what size accepts, variants what variant accepts, modifiers
what mods accepts, and axes declares any further axes. The rule is one
principle: an explicit declaration closes its set — recipe values outside a
declared vocabulary are errors, while the default recommended ramps stay
advisory warnings. (variants/modifiers/axes have no recommended default,
so omitting them leaves those axes unchecked.) sizes is the data-size axis
— not system.size, which is the --size-* control-sizing unit.
A vocabulary may belong to one scope. Real design systems do not give every
component the same variants — Radix Themes varies a select as
classic | surface | soft and a button as something else. Declare the union
at the top level and say which part of it each scope offers:
variants: ['solid', 'outline', 'classic', 'surface', 'soft'], // the UNION
scopes: {
button: { variants: ['solid', 'outline'] },
select: { variants: ['classic', 'surface', 'soft'] },
},Every axis takes a restriction (colors, sizes, variants, axes,
modifiers), a scope may only narrow, and the vocabulary reaches the manifest,
the report and register.d.ts. An absent key means the scope offers the
whole union; an empty list is the claim "this scope has no such axis at
all", the same grammar sizes: [] and variants: [] use design-system-wide
(an omitted variants is only silence — the manifest's
tokens.variantsDeclared tells the two apart). The unit is the
scope rather than the part: zero puts one attribute per axis on the scope's
carrier and cascades it to every part below, so two vocabularies inside one
component are two axes — declare the second in axes. See
docs/architecture.md, "Declared vocabulary".
The cascade to inner parts is emitted as an @scope donut
(@scope ([carrier][attr]) to ([carrier]) { [part] { … } }) rather than an
unbounded descendant selector: nest one instance of a scope inside another (a
card in a card) and each part resolves its axis to the NEAREST carrier by CSS
scoping proximity, instead of source order deciding which instance's value
leaks through.
A part may also re-carry an axis (zero#94): its anatomy declares
carries: ['color'] — timeline.marker does — and the component renders the
attribute on the part itself (<Timeline.Marker color="error">). The
compiler emits every variants.<axis>.<value> rule that targets such a part
a second time, flat on the part ([data-part="marker"][data-color="error"],
which outranks the carrier's donut rule by specificity), and a rule for a
part inside a re-carrier in a donut rooted on it. The nearest carrier wins,
and a part with no value of its own follows the carrier — which is why the
:not([attr]) default twin is never re-emitted, and why modifiers and
compounds stay on the carrier. Nothing to author: key the values on the part,
as every shipped skin keys variants.color.<c>.marker and, since zero#112,
variants.color.<c>.item for steps.item and, since zero#161, for
stats.item. The
axis-coverage rule reports a scope that wires the axis without keying the
re-carrying part, and the components artifact gives the member (Marker) the
carrier's vendor surface for the axes it carries.
Not every modifier is an axis, either. An axis answers which one and always
carries a value; some design-system modifiers answer is it on and carry none
— daisyUI's block and wide, Radix's high-contrast, HeroUI's icon-only.
Declare those in modifiers, wire them in a recipe's modifiers block, and
consumers set them through zero's mods prop:
// tokens.ts
modifiers: ['block', 'icon-only'],
// recipes.ts
modifiers: { block: { root: { base: { width: '100%' } } } },<Button.Root mods={{ block: true }}>Save</Button.Root> // → data-mod-blockThey render into their own data-mod-* namespace rather than as bare
data-<name> flags. Zero owns the unprefixed presence-only vocabulary
(data-disabled, data-pressed, …) and extends it between versions, so an
unprefixed modifier named busy would silently start matching a data-busy
flag a later zero adds — with exactly the right shape and no error. A valued
axis cannot fail that way: a collision there simply never matches, and the
runtime throws. Different hazard, different treatment. A modifier has no
defaultVariants analogue, because absence already is its default.
Nor is the SET of axes closed. color / size / variant have named props
because almost every design language has them; key variants on any other
axis your design needs and consumers reach it through zero's axes prop:
variants: { density: { compact: { root: { base: { paddingBlock: '0.15rem' } } } } },<Button.Root color="primary" axes={{ density: 'compact' }}>Save</Button.Root>Axis names are kebab-case and may not be ones the anatomy contract owns
(scope, part, state, orientation, or any flag) — see RESERVED_AXES.
Axis values take a wider grammar, AXIS_VALUE_PATTERN: lowercase letters,
digits and hyphens, repeated hyphens included — Carbon's danger--tertiary is
a legal declared value. What stays out is what an unescaped lynx class name
(zx-a-<axis>-<value>) cannot carry verbatim: %, ., quotes, whitespace,
uppercase. A spelling the grammar refuses is what the api.values remap is
for.
Both halves of the token contract work the same way: a closed set of
categories, each fixing a --prefix- and a value grammar, with open keys
inside that the design system declares. zero-kit curates the categories
because they carry semantics tooling needs; the vocabulary within is yours.
Omitting a category is fine — @sigx/zero/css ships fallbacks for the
recommended keys, so absence is never a validation error.
sigx zero:validate # tokens, WCAG contrast (a failing pair carries a suggested passing value), recipe structure + content, CSS property spelling (`paddding` is an error naming `padding`)
sigx zero:validate --report # what the design system covers, not what's wrong
sigx zero:audit # does what it built say what it claims — read from the compiled CSS
sigx zero:build # dist/css/index.css + per-component files + breakpoints.css + manifest + report + auditEvery emitted stylesheet a consumer can import on its own — index.css,
tokens.css, each css/components/<scope>.css, and zero:extend's
zero-extend.css — opens with the shared cascade-layer order statement
(LAYER_ORDER_STATEMENT), so it may be imported before or after
@sigx/zero/css without inverting the layers.
dist/css/index.d.ts is an empty module: point the types condition of the
package's extensionless stylesheet exports (./css, ./css/tokens,
./css/*) at it, as the shipped skins and create-zero-ds do, or
import '<pkg>/css' fails TypeScript's side-effect-import check.
The WCAG check covers every role / role-content pair and the base pairs
on its own, measured as the browser paints them: a translucent -content
ink is composited over its role, and a translucent role over base-100
first (a translucent base-100 has nothing opaque under it and is an
error). The validator and the report's per-theme table share that one
reading. Colour values parse case-insensitively, as CSS does — OKLCH(…)
validates. A custom colour token — a dim caption ink, a status tone — is
measured when the design system declares the pair it is read on:
defineTokens({
custom: { 'ink-dim': { syntax: '<color>' } },
contrast: [
{ fg: 'ink-dim', bg: 'base-300', min: 4.5, description: 'captions' },
{ fg: 'ink-dim', bg: 'color-base-100' }, // min defaults to 4.5
],
/* … */
});fg/bg name a declared custom token or a colour token (a role, its
-content/-soft, a base surface), with or without --; a bare name is a
custom token first, and color-<token> is always the colour token. Each
pair is checked in every theme, in the role pairs' format (contrast-floor,
with a passing suggest solved at the pair's own min) — but below min is
always an error, since the floor is one the author declared. A custom value
may be a var() of other tokens, color-mix() or light-dark(); a
translucent ink is composited over its surface, and a translucent surface or
a value the kit cannot evaluate is an error rather than a skipped pair.
Every value the kit writes into the stylesheet verbatim answers to one
break-out rule: no brace, semicolon or newline, since any of them would end
the declaration and turn the rest of the value into CSS. That covers recipe
declarations and every token value: system, systemDark and a theme's
system, its custom and extra values, and its colours. A
custom[].syntax also may not hold a quote or backslash, because it is
written inside a quoted @property string. sigx zero:validate and
tokens.schema.json report a violation, and the emitters throw on one.
Quoted strings are not exempt, so a data URI has to be written without a
literal ;: percent-encode the payload
(url("data:image/svg+xml,%3Csvg …%3E"), with any ; inside it as %3B).
A ;base64 marker cannot be encoded, so serve a base64 image as a file.
Conditional styles live in parts.<part>.at, keyed by a declared breakpoint
(@media (min-width: …)), a built-in preference query (reduced-motion,
hover-none, prefers-dark, forced-colors, print) or a raw @ prelude
(@container, @supports, @starting-style). Nesting composes the
at-rules, and because variants hold the same shape, responsive variants
need nothing extra. Author mobile-first — breakpoints are min-width, and
declaration order is emission order. A desktop-first rule is below-<name>:
at: { 'below-md': … } compiles to @media (width < 48rem), the exact
complement of the md rule, resolved from the same tokens. That means no
hand-written 767.98px. Below-rules sort after every breakpoint, narrowest
last, and a breakpoint may not be named below-*.
A recipe may style another scope in context with composes. This covers
a composer's embedded button and the buttons in a card's footer:
defineRecipe({
component: 'card',
parts: { /* … */ },
composes: {
button: { within: 'footer', parts: { root: { base: { flex: '1' }, states: { hover: { filter: 'none' } } } } },
},
});This compiles to [data-scope="card"][data-part="footer"] [data-scope="button"][data-part="root"]
in card's own stylesheet, and outranks button's own base and axis rules.
within defaults to the carrier part. The nested scope must be one the
manifest declares, and within must be one of card's parts. Every part and
state resolves against the nested scope's anatomy, so a pack stays confined
to what the manifest declares, and a mistake is a validation error rather
than a selector that matches nothing. Content checks and
fitRecipesToVocabulary treat it like any other declaration. The lynx
target drops it and records a report entry.
An entry can also borrow the nested scope's own axis values (#91), and a compound can make a composition conditional on the host's axes:
defineRecipe({
component: 'card',
parts: { /* … */ },
composes: {
// Footer buttons look like this design system's `sm` button…
button: { within: 'footer', axes: { size: 'sm' } },
},
compoundVariants: [
// …and like its `xs` button while the card itself is `xs`.
{ match: { size: 'xs' }, parts: {}, composes: { button: { within: 'footer', axes: { size: 'xs' } } } },
],
});Borrowing re-emits button's own variants.size.sm rules, the compounds
matching size: 'sm', and their at blocks under the card context. They
are guarded by :not(:where([data-size])), so a button that states its own
size keeps it. Explicit parts in the same entry are emitted after the
borrowed rules and win a tie. The value must be one button's recipe wires in
this design system: the card's CSS is built from button's recipe as compiled
here, so compileDesignSystem hands every recipe to every compile, and the
order of recipes does not matter. A conditioned composition compiles inside
an @scope donut on card's carrier, so a card nested in an xs card answers
for its own footer. See docs/architecture.md §3.3 for the specificity
ladder and the one edge: the nested recipe's default-value rules still match
underneath the borrowed ones.
The ramp is reachable from the app, too. The compiled :root declares
--breakpoint-<name> (for calc() and JS reads — a custom property cannot
stand in a media condition), and dist/css/breakpoints.css (exported as
<ds>/css/breakpoints) defines @custom-media --above-<name>
(min-width: …) and --below-<name> (width < …) for a build step that
resolves custom media (postcss-custom-media, Lightning CSS
drafts.customMedia): @import it into your own stylesheet and write
@media (--below-md) { … }. The same boundaries back @sigx/zero's
useMediaQuery({ above | below }), so app CSS, app JS and recipes agree on
every pixel.
The compiler also uses the ramp for one piece of structure no recipe
writes: the responsive Drawer's (Drawer.Root modal={{ below: 'md' }},
zero#82). Its server markup is always the docked form — the panel open, every
part stamped data-l-dock-above="<bp>" — and for each declared breakpoint
the kit emits, in @layer zero.structure, the rules that paint the right
half before any script runs: trigger and close hidden at or above the
breakpoint (the docked panel back in flow), the docked panel hidden below it
unless it is up as a :modal sheet. They ride the drawer's component
stylesheet, or index.css when a design system paints no drawer.
Unknown parts/states fail the build — the anatomy manifest is the contract.
So do undeclared token references: a recipe that says var(--color-brnad)
is an error naming the nearest declared token, not a stylesheet that silently
renders nothing. The vocabulary is derived from your own declaration, so it
grows with the design system rather than being a list to maintain.
The skills/design-system folder ships an agent skill that generates a
complete design system from a style brief and iterates against validate.
Starting a design system
pnpm create @sigx/zero-ds zero-acme --brief riso # or npm create @sigx/zero-ds …
cd zero-acme && pnpm install && pnpm build
npx sigx zero:validate --report@sigx/create-zero-ds lays down the whole package from
nothing: the brief's tokens and worked Button (src/tokens.ts,
src/button.ts), @sigx/zero-basic's 58 recipes as src/baseline.ts, and a
src/recipes.ts that composes them through fitRecipesToVocabulary — so the
first build styles every component, whatever axis shape the brief declares.
--brief takes brutalist | glass | corporate | terminal | riso | seeded | basic;
--baseline none scaffolds the Button alone; --targets web,lynx adds the
lynx target. Non-interactive throughout — it is built for agents to drive.
Building a design system
Every design system runs the same pipeline; it ships as one function on the
@sigx/zero-kit/build subpath, and a package's build.mjs is only its data:
import { fileURLToPath } from 'node:url';
import { anatomies } from '@sigx/zero/anatomy';
import { runStandardBuild } from '@sigx/zero-kit/build';
import { designSystem } from './dist/design-system.js';
await runStandardBuild({
designSystem,
manifest: { components: Object.values(anatomies).map((a) => a.toJSON()) },
// fragments: [fragment], // ecosystem manifest fragments, merged in
outDir: fileURLToPath(new URL('./dist', import.meta.url)),
});It validates, prints every issue, refuses to emit from an invalid source
(throws after printing), compiles, runs the audit, builds the coverage report
and writes the artifacts. sigx zero:build calls the same function. The
audit (below) never fails the build: its findings are logged, written as
dist/audit.json, summarised in report.json under audit and scored;
audit: false skips all of that.
A rebuild into an existing dist/ leaves nothing stale behind (#186):
css/components/ (and lynx/components/) is cleared before it is written,
components.{js,d.ts}, report.json and audit.json are removed when the
run does not produce them, and dist/lynx/ is removed when 'lynx' is not a
target. So a removed recipe, a dropped api, audit: false or a dropped lynx
target does not leave an importable, packable leftover. Nothing else in
outDir is touched — tsgo's emitted dist/*.js lives there too.
Those paths — dist/css/, dist/lynx/, dist/components.{js,d.ts},
manifest.json, register.{js,d.ts}, report.json and audit.json — are
reserved kit output: don't route tsgo emit (a src/lynx/** tree, a
hand-written src/components.ts) there, or the next build overwrites or
removes it.
targets selects the emit targets (default ['web'], which is today's
output exactly). The list is validated up front: unknown names fail, web
is not optional (every other target emits beside it), and 'lynx' — the
class-grammar target for platforms without attribute selectors — emits
dist/lynx/{tokens.css, components/<scope>.css, index.css, manifest.json}
beside the web artifacts, with every declaration translated, dropped with a
report.json entry, or refused (the capability verdicts live in
src/targets/lynx/capabilities.ts). @sigx/zero-basic and
@sigx/zero-daisyui pass targets: ['web', 'lynx'] in their build.mjs;
a recipe restates web-runtime references and lynx replacements in its
targets.web / targets.lynx sections.
Lynx parses hex, rgb() and hsl() only, so the lynx target bakes every
other colour function — oklch(), light-dark(), color-mix() — to a hex
literal per theme, however many a value holds. color-mix() follows CSS
Color 5's percentage rules: a percentage may sit before or after its colour,
two that sum to under 100% scale the result's alpha by that sum
(red 30%, blue 30% is purple at alpha 0.6), and two that sum to 0% — or
a percentage above 100% — are an error, as the spec makes them invalid.
Two things are dropped outright, with a report entry, because the engine
cannot draw them: an SVG data-URI image (url("data:image/svg+xml,…")),
which iOS fails to decode — raising an image error, not a quiet miss — along
with any token holding one and every declaration reading such a token
(signalxjs/lynx#1215); and clip-path, which lynx does not apply
(signalxjs/lynx#1216). A recipe draws a clipped shape another way in its
targets.lynx section.
The authoring surface in a browser graph
The kit's barrel is Node-only — a design-system package may never
value-import it at runtime (one import drags node:fs into every browser
consumer). The define* helpers live on @sigx/zero-kit/define, whose
module graph is node:-free by contract (pinned by a test that walks it), so
a design-system module that sits in a package's runtime graph writes:
import { defineApi } from '@sigx/zero-kit/define';and keeps the full literal narrowing without a satisfies reimplementation.
fitRecipesToVocabulary
Not a define* helper, but on /define all the same. Given a recipe
list and a TokensInput, it returns the recipes fitted to what the tokens
declare: variants.color keeps declared roles only; variants.size,
variants.variant, custom axes and modifiers keep declared values once
the corresponding vocabulary is declared (sizes: [] empties the size
axis); defaultVariants and compoundVariants naming a dropped value go
with it; every var(--color-<role>…) the tokens never define is redrawn on
the base surfaces (base-content / base-100 / base-200), and a
category step the tokens never declare (var(--ease-exit)) collapses to
the category's resting step (--ease-standard). Pure, and the identity for
recipes that already fit — every in-repo skin round-trips deep-equal.
explainFit returns the counts instead of the recipes. An at condition
naming a breakpoint the tokens do not declare (below-lg under a ramp that
stops at md) is dropped too, and counted as droppedConditions — once
tokens.breakpoints is declared at all. The scaffold's
generated src/recipes.ts is its caller; delete the call once the recipes
speak the design system's own vocabulary.
tableStackAt — a stacked table's cards
Table.Root stack="md" (zero#55) turns every row into a block below the
design system's md, each value captioned by its column's label (the
cell-label part). The geometry is zero's, and the kit writes it for you:
compileDesignSystem emits one @media (width < …) block per declared
breakpoint into @layer zero.structure, in the table's component
stylesheet (tableStackCss). The rows become blocks, the head is visually
hidden, <col> widths reset to auto, and the label hangs in the cell's
start padding (--table-stack-label-size, default 8rem, and
--table-stack-label-gap: lengths a skin may set on the root).
What the card looks like is the skin's. tableStackAt(tokens, part, styles)
returns the at entries that apply styles only while a table is stacked,
one below-<breakpoint> per declared breakpoint, so spread it into the
part:
import { tableStackAt } from '@sigx/zero-kit/define';
import { tokens } from './tokens.js';
row: {
base: { borderBlockEnd: hairline },
states: { selected: { background: wash } },
at: tableStackAt(tokens, 'row', { border: hairline, borderRadius: 'var(--radius-box)', padding: 'var(--space-md)' }),
},
body: { at: tableStackAt(tokens, 'body', { rowGap: 'var(--space-md)' }) },
'cell-label': { base: { fontSize: 'var(--text-xs)', color: muted } },The rule keeps the part's own specificity (its containment chain sits in
:where()), so it beats base and a state or modifier still beats it.
Style cell-label unconditionally: zero shows it only while the table
stacks. On lynx the conditions are dropped and reported, like any at.
popupArrow — the arrow on an anchored popup
Popover.Arrow, Tooltip.Arrow and Menu.Arrow (zero#279) render an empty
aria-hidden span inside the popup, and zero's position strategy writes
where the anchor's centre falls along the edge that faces it — after the
flip and the shift — as --arrow-x (a popup above or below) or --arrow-y
(one beside). Which edge that is comes from the popup's data-placement.
popupArrow(scope, { size, paint }) returns the arrow part's styles for
every placement: a size square of paint (the popup's own background and
border), rotated 45° and clipped to the half that sticks out, centred on the
facing edge at the published offset — the inline-start/-end edge for
start/end, with the tip turned under RTL. popupArrowHost(scope) is the
popup's half, spread into its selectors: overflow: visible while an arrow
is rendered, since [popover] clips by default.
import { popupArrow, popupArrowHost } from '@sigx/zero-kit/define';
popup: { base: panel, selectors: popupArrowHost('popover') },
arrow: popupArrow('popover', { size: '0.625rem', paint: { background: surface, border: hairline } }),Only a root popup places its arrow; anywhere else (a menu sub-popup) the
arrow keeps display: none. The two properties are ARROW_PROPERTIES in
the contract and part of RUNTIME_PROPERTIES, so in a design system that
also builds lynx both halves go in targets.web. left: var(--arrow-x) is
exempt from the physical-direction lint, like --press-x: it measures the
glass.
Extending a design system
A design system derived from another — a product skin on top of
@sigx/zero-daisyui — must still compile one recipe per scope: a second
recipe for a scope the base styles is a compile error. extendDesignSystem
patches the base as data instead, from /define:
import { designSystem as daisy } from '@sigx/zero-daisyui';
import { extendDesignSystem } from '@sigx/zero-kit/define';
export const designSystem = extendDesignSystem(daisy, {
name: 'control-room',
tokens: {
custom: { 'ag-line': { syntax: '<color>' } },
themes: { light: null, dark: null, dim: null, /* … */ 'control-room': controlRoom },
defaultLight: 'control-room',
},
recipes: {
button: { parts: { root: { base: { boxShadow: 'none' }, states: { 'focus-visible': ring } } } },
'toggle-group': { parts: { item: { selectors: { '&[data-orientation="horizontal"] + &': null } } } },
},
addRecipes: [...myFragmentPack],
css: [appCss],
});The merge rule is one rule everywhere: plain objects merge per key,
recursively (per part, per state, selector and at condition, per axis
value, per modifier, per theme, per token); arrays and scalars replace; and
null deletes the key — the way to undo something the base does rather
than counter it with a second declaration. Two sections are special:
compoundVariants are addressed by match (a patch entry whose match
equals a base entry's merges into it, any other is appended, one left with
no parts is dropped), and a recipe's css hatch concatenates. targets.web
/ targets.lynx are patched by the same rule.
recipespatches scopes the base styles; a patch for a scope it does not style throws (a typo would otherwise be a silent no-op), andnulldrops the base's recipe.addRecipesadds scopes the base does not style; one it already styles throws.cssentries are appended after the base's;apiis carried unchanged unless patched (nulldrops it).- The layout tier is generated from tokens, so where the base carries it unmodified it is regenerated from the derived tokens — a changed breakpoint or role reaches the layout step table. A base whose layout recipes differ from the generated ones keeps its own.
extendRecipe(base, patch) is the per-recipe half, for a design system
assembling its list by hand. Both return new objects and never mutate the
base.
Public hooks — what a patch may rely on
A patch can reach anything in the base: a custom property the base sets, a keyframe name, a pseudo-element it draws. What it reaches is only as stable as the base's source, so a recipe declares which of its names are public (#73), beside what it declares:
defineRecipe({
component: 'collapsible',
hooks: {
// name → what it means; each must be set or read by the recipe
properties: { '--collapsible-accent': 'The accent of the open trigger.' },
// each must be a key of `keyframes`
keyframes: ['my-reveal'],
// generated-content pseudo-elements only (::before, ::after), per part;
// each must be drawn by a `selectors` key on that part
pseudo: { trigger: ['::after'] },
},
tokens: { '--collapsible-accent': 'var(--color-primary)' },
keyframes: { 'my-reveal': 'from { opacity: 0 }' },
parts: {
trigger: { selectors: { '&::after': { content: '""' /* the chevron */ } } },
// …
},
});Hooks are metadata: they never reach the CSS. validateDesignSystem errors
on a hook that names nothing the recipe has, and the DS manifest carries them
as components[scope].hooks, where tooling and the docs site read the public
surface. extendRecipe patches hooks like any other section.
extendDesignSystem records its provenance on the result: derivedFrom: {
name, patches }, each patched scope with the base recipe as the patch found
it and the patch itself. validateDesignSystem reads it and warns (rule
private-name) for every base-private name a patch reaches — a custom
property the base recipe sets or reads that is not a hook (the token grammar,
runtime and medium properties are contract, never private), a base keyframe a
patch names in animation / animation-name or redefines, and a
::before / ::after the base draws on a part where it is not a hook:
recipes.switch: references --switch-p, private to "daisyui" — not a declared hookA warning, not an error: relying on a private name is a stability risk across
the base's releases, not a broken build. Platform pseudo-elements
(::placeholder, ::-webkit-slider-thumb) are nobody's private name and are
never flagged. Selector shapes ([data-scope="table"][data-part="body"] > &)
have no hook grammar on purpose — key a patch on the part and the states the
anatomy declares, which are already the contract. Hooks carry no since:
lockstep versioning makes the kit version the stability marker.
The six in-repo skins declare their component-level colour and metric
properties (button, switch, badge, table, card, timeline, toggle-group,
skeleton, collapsible, as each skin has them), plus zero-daisyui's
zero-daisy-pop dialog keyframe and its collapsible trigger's ::after
chevron.
Deriving a palette
Every theme colour used to be authored by hand, and the only thing between
a guessed oklch() and a shipped 2.8:1 label was the validator's contrast
check after the fact. derivePalette runs the other way: from the hues that
carry the brief it derives every token requiredColorTokens(roles) asks for
— base surfaces, roles, -content pairs — and solves each pair's lightness
so the floor holds by construction. deriveThemePair does it for a
light and a dark theme at once, already wired to each other:
import { deriveThemePair } from '@sigx/zero-kit/define';
export const tokens = {
roles,
defaultLight: 'ink',
defaultDark: 'ink-dark',
themes: {
...deriveThemePair({ roles, seeds: { primary: 205, accent: 55 }, light: 'ink', dark: 'ink-dark' }),
},
};{ primary: 260 } is enough. The four semantic roles take fixed hues (info
245, success 155, warning 85, error 25), secondary and accent follow the
harmony rotation (analogous by default; complementary, split,
triadic), neutral is the primary hue desaturated, and any role the kit
has no opinion about (tertiary, surface-container) falls back to the
primary hue at an index-derived lightness — seed it for a real colour. A
seed can be a bare hue or { hue, chroma?, lightness? }; base tints the
surfaces; floors moves the targets (4.5:1 per role pair, 7:1 for
base-100 against base-content by default).
What is guaranteed, measured on the emitted strings: every <role> /
<role>-content pair at or above the floor, every value inside the sRGB
gamut, a seeded hue preserved to the tenth of a degree, and exactly the
declared key set — no -content for a content: false role, never a
-soft (the compiler derives those from softMix), only the base surfaces
for roles: {}. The derivation is deterministic: the same seeds always
produce the same strings, so a derived theme can sit in a golden.
It lives on @sigx/zero-kit/define, so a tokens.ts in the browser bundle
may call it. The colour math is hand-rolled for that reason (the /define
graph may only reach relative modules) and is pinned against culori to 1e-6
by palette.test.ts. solveContentLightness(fg, against, floor) and
contrastRatio are exported for callers that have already parsed a colour
and want the same solver — the validator's suggested fix for a failing pair
is built on it.
The vendor-named component API
A design system may declare, beside tokens and recipes, how zero's axis
surfaces appear under the vendor's own prop names (issue #179;
docs/architecture.md, "The components artifact — vendor-named apis"):
import { defineApi } from '@sigx/zero-kit/define';
import { variants, modifiers } from './tokens.js';
export const api = defineApi({ variants, modifiers }, {
variant: { as: 'kind', values: { 'danger-tertiary': 'danger--tertiary' } },
size: { values: { sm: 'small', md: 'medium' } },
modifiers: { 'icon-only': { as: 'hasIconOnly' } },
components: { button: { variant: { as: 'type' } } },
});as renames a surface (Carbon's kind, Ant's type); values respells
individual members whose vendor spelling the attribute grammar cannot hold —
the rendered attribute keeps the zero spelling, only the prop surface
respells. All five surfaces map: color, size, variant, custom axes
and modifiers. components scopes an override to one component,
REPLACING the DS-wide entry for the surfaces it names — and it is where a
rename that shadows a component-specific prop must live: api.variant = {
as: 'name' } design-system-wide would silently delete Select's name, so
the validator rejects any DS-wide mapping onto a prop in
RESERVED_PROPS_BY_SCOPE and points at api.components.<scope>, where the
shadowing is a per-component decision (Ant's type over Button's native
type — vendor-faithful, chosen for Button alone). Zero's own components are untouched: variant stays variant
everywhere, and the declaration only shapes the design system's additional
./components module. The declaration is validated against the declared
vocabulary (validateApi, run inside validateDesignSystem), and the
conformance grade — exact | renamed | reshaped | unsupported — derives from
it mechanically (apiGrade / modifierGrade), so a conformance-matrix row
and the artifact it points at are the same object. The coverage report gains
an api section listing every vendor prop, where it routes, and its grade.
A design system that declares an api gets a generated ./components
module in its build: dist/components.d.ts (self-contained vendor-named
types — no /register import needed, nothing augments ZeroVocabulary) and
dist/components.js (data only — one PURE adapt() call per component that
routes anything, a plain re-export otherwise; all behaviour lives in
@sigx/zero/adapt, written once and never generated). Add the subpath to the
package's exports map:
"./components": { "types": "./dist/components.d.ts", "import": "./dist/components.js" }and consumers write import { Button } from '@sigx/<ds>/components' —
<Button kind="ghost" hasIconOnly> fully narrowed, rendering the unchanged
zero attributes. The DS manifest carries the per-component routing under
api for tooling. skills/design-system/conformance/ holds real vendor
fixtures (Carbon, Ant, Radix Themes, HeroUI, Material 3) that validate,
compile and grade in CI; @sigx/zero-heroui ships the first real adapter,
and docs/design-system-conformance.md — the conformance matrix
(docs/architecture.md §7) — is generated from the fixtures and the
in-repo coverage reports
(conformanceRows / reportRows / formatConformanceMatrix), so a matrix
row and the artifact it cites are the same object.
CLI
The kit is a plugin for the sigx CLI:
having @sigx/zero-kit in a package's dependencies is the whole wiring. The
CLI discovers it there and offers its commands in any directory that looks like
a design-system package.
sigx zero:validate [entry | --package <pkg>] [--manifest <path>] [--extra-manifest <path|package>]...
[--[no-]ecosystem] [--ecosystem-exclude <pkg>]...
[--strict] [--report] [--report-json <path>] [--diff <path>]
[--log <path>]
sigx zero:audit [entry | --package <pkg>] [--manifest <path>] [--extra-manifest <path|package>]...
[--[no-]ecosystem] [--ecosystem-exclude <pkg>]...
[--strict] [--rule <id>]... [--json <path>]
sigx zero:build [entry] [--manifest <path>] [--extra-manifest <path|package>]...
[--[no-]ecosystem] [--ecosystem-exclude <pkg>]...
[--out <dir>]
sigx zero:fragment [--manifest <path>] [--no-emit] [--strict]
sigx zero:extend --ds <pkg> [--out <dir>] [--manifest <path>] [--ecosystem-exclude <pkg>]...Every boolean flag takes a --no- form. --ecosystem is on by default, so
--no-ecosystem is how one run skips pack adoption (ZERO_ECOSYSTEM=0 does it
for every run in the environment); zero:fragment's --emit is on by
default, so --no-emit checks the fragment without writing fragment.json.
entry is a compiled ES module (default ./dist/design-system.js) exporting
the design system as designSystem or as its default export. --manifest
defaults to @sigx/zero/manifest.json resolved from the current directory, so
the contract checked is the one the project ships; it takes either a path or a
module specifier. --strict turns warnings into a failure — the flag to use in
CI.
An app can check the design system it consumes. --package <pkg> loads
an installed design system's ./design-system export (every shipped skin and
every create-zero-ds scaffold has one) instead of a path, so the command
reads "check the skin I use" rather than reaching into node_modules:
npm install -D @sigx/zero-kit @sigx/cli # the validator; never enters the bundle
npx sigx zero:validate --package @sigx/zero-daisyui --strictThe kit is still the dependency that turns the command on: the sigx CLI
discovers plugins from a project's direct dependencies only, so there is
nothing an app depending on @sigx/zero alone could activate — and the
validator's own dependencies (ajv, culori) do not belong in the runtime
package. Ecosystem discovery runs against the app's dependencies, so the
check also covers the ecosystem components the app installs.
--extra-manifest (repeatable) merges an ecosystem manifest fragment into
the base manifest instead of replacing it — how a design system opts into
covering a component some other package ships. It takes a JSON file or a JS
module exporting fragment (or a default export), by path or by package: a
bare name (@acme/stepper) is read through the package's "sigx-zero"
field, and a subpath (@acme/stepper/fragment) through its exports map, so
the module a build.mjs merges is exactly what the CLI can merge too. See
"Ecosystem components" below.
Spacing rides the ramp
sigx zero:audit includes two spacing/* rules, and they are about a
mechanism rather than tidiness. Because a recipe writes var(--space-md)
rather than 0.5rem, an app gets a density mode for free:
[data-density="compact"] { --space-md: 0.375rem; --space-lg: 0.5rem; }Custom properties inherit, app CSS is unlayered so it wins, no JS is involved, and it survives a design-system swap. Every literal is inert under that switch, so:
spacing/literal(warning) — the number IS a declared step, written out. Replacing it with the token is pixel-identical.spacing/off-ramp(error) — the number is on no declared step, so it is both untraceable to a token and inert. Fixing it changes pixels.
Exempt on purpose: em lengths (spacing that tracks type, not the ramp),
anything inside parentheses (calc(var(--space-lg) - 2px) already rides it),
and 0. Rules in @layer zero.structure are not graded either: that is
zero's geometry the kit writes into a component stylesheet (a stacked
table's visually hidden head), not the skin's spacing.
A recipe pack may read the standard ramps bare — var(--space-md),
var(--font-mono), var(--duration-fast). sigx zero:fragment's
hostile-vocabulary probe withholds the colour roles, the size ramp and the
breakpoints (#225: a pack's at: { 'below-md': … } is fitted away, not a
compile error), but it
defines every recommended key of --space-*, --font-*, --weight-*,
--leading-*, --tracking-*, --measure-*, --shadow-*, --duration-*
and --ease-* at @sigx/zero/css's fallback values, since every real design
system defines them (#158). A pack needs fallbacks only for its own tokens.
system.spacing is still optional, though, and lynx has no fallback layer:
an adopter that emits lynx and omits a category fails its own lynx build on
the dangling-var check, which names the property. A pack that wants to survive
that adopter too can still write var(--space-md, 0.5rem).
Ecosystem components
An ecosystem component package is a peer of @sigx/zero: it builds its
component from zero's public surface (defineAnatomy, the behaviors, the
contract helpers — see zero's "Building your own components") and publishes a
manifest fragment:
{
"version": 1,
"package": "@acme/zero-stepper",
"components": [ /* defineAnatomy(...).toJSON() */ ]
}version is the fragment contract version (FRAGMENT_VERSION) and is
required — the merge hard-errors on a missing or unknown one, so a fragment
built against an older contract fails by name instead of merging silently.
A ./fragment runtime entry imports the constant from @sigx/zero/contract
(zero is its peer; the kit is only a devDependency), and authoring code can
take it — with the ManifestFragment type — from the node:-free
@sigx/zero-kit/define.
sigx zero:fragment, run inside the component package, emits
dist/fragment.json and checks what would otherwise fail in an adopter's
build: the declared version against FRAGMENT_VERSION, the schema, the
merge against the installed @sigx/zero, that the declared path is inside
"files" (strictly for an exact path or a directory prefix like dist or
dist/**; a glob it cannot model is assumed to ship), that recipes style only
parts and scopes the fragment declares,
that the root exports componentExportName(scope), and that the pack still
compiles and paints when fitted to a vocabulary with no colour roles, no
size ramp and no breakpoints (breakpoint at keys are dropped, as for an
adopter with no ramp). An unprefixed scope and a pack that is not lynx-clean are warnings,
not failures. No bare alias — fragment is a word other plugins may want.
The package points at that data entry with a "sigx-zero" field, shaped like
the "sigx-cli" field this plugin is itself discovered through:
"sigx-zero": { "fragment": "./dist/fragment.js", "requires": ">=0.2.0" }A design system then adopts every dependency that declares one, by
default — installing the package is the opt-in. ecosystem: false on
runStandardBuild (or --no-ecosystem on the CLI) turns it off, and ZERO_ECOSYSTEM=0 overrides any build. Narrowing is programmatic:
ecosystem: { exclude: [...] } or { include: [...] }, where include
means only those; the CLI surfaces the exclusion half as
--ecosystem-exclude and has no include flag. A pack that cannot be
loaded or merged is named and skipped rather than swallowed — strict: true
makes it fatal — and packs are adopted in package-name order so the emitted
artifacts do not depend on how dependencies were written down.
Diagnostics about an adopted scope name their owner — validation issues,
audit findings and lynx capability findings all carry an optional package,
and the printed line reads recipes.acme-stepper (from @acme/zero-stepper).
The emitted dist/manifest.json carries a top-level externalScopes
(scope → owning package) for the same reason: a consumer must be able to tell
a foreign scope from one of the design system's own.
An adopted pack's recipes are fitted to the adopting vocabulary
(fitRecipesToVocabulary), restricted to the scopes the pack's own fragment
declares, de-duplicated against scopes the design system already styles (the
design system's recipe wins; two recipes for one scope is a hard compile
error, so this is a drop rather than a shadow) and then appended. A pack
recipe the lynx emitter refuses costs that scope the lynx target — recorded
in report.json under lynx.webOnly — where a first-party recipe would fail
the build.
sigx zero:extend --ds <package> --out <dir>, run in an app, compiles an
already-published design system against that app's own ecosystem packs. It
writes zero-extend.css (the added scopes only, opening with the layer-order
statement so its import position does not matter) and a zero-extend.js /
.d.ts pair — a replacement register, shaped like a design system's own
/register. The app imports the .js and removes its
<package>/register import: ZeroVocabulary.components is a property, so two
augmentations collide, and they accumulate across a program rather than
replacing one another. It
refuses a zeroVersion mismatch between the installed design system and the
app's kit. It also refuses to run under ZERO_ECOSYSTEM=0: adopting packs is
all it does, and with discovery switched off it would overwrite the previous
run's artifacts with pass-through ones that drop every pack scope. It writes
nothing in that case, so the existing files survive. (An empty set that is
real — a pack removed since the last run — is still written, which is how
that pack stops being declared.)
A design system can also merge the fragment by hand — --extra-manifest on
the CLI, or mergeManifests(base, fragment) in a build.mjs-style script —
and write (or import) a recipe for the scope like any other. Hand-passed
fragments: merge before discovery and win a scope collision. Everything downstream is scope-agnostic, so validation, recipe
compilation, the vocabulary system and the coverage report all just work; the
merge hard-errors on a scope collision, which is why fragment scopes should
carry a vendor prefix (acme-stepper). It also holds the fragment to the
shared vocabularies — flags, governed states (a synonym like expanded fails
with "use open"), placements, hiddenIn ⊆ states, carries (named axes
only, never on the carrier or a pseudo part), absorbable (presence-only,
requires asChild: true, never another part's parent, never with
hiddenIn/layout/pseudo — a lent part renders no element, #452), an
acyclic part tree, and the naming rule on any models it declares (default<Concept> +
<concept>Change, a named model's concept its name) — so the "no synonyms"
rule binds on the ecosystem surface, not only on zero's own anatomies.
Provenance travels with the merge. Merged scopes are tracked as external on
the compiled design system (externalScopes), the generated register.d.ts
excludes exactly them — by name — from its ZeroScope compile gate (the
typo/version-skew guard keeps full strength for zero-origin scopes, and the
emitted comment records who owns what), and under api mode the generated
./components module imports an external scope from its owning package's root
export instead of @sigx/zero/<scope>. When that owner is the design system's
own package, as with a system derived from a skin that also publishes a
fragment, the import is relative instead of by name, so the package never
imports itself. writeArtifacts resolves it from the package.json nearest the
outDir: the root export for components.js and its types condition for
components.d.ts.
A component may also ship a recipe pack — RecipeInput[] written against
the recommended token grammar (var(--color-primary), the recommended sizes)
— so any design system that keeps the recommended vocabulary can adopt its
styling by importing the recipes rather than writing them. A design system
that never merges the fragment simply leaves the component unstyled — which is
still accessible and correctly attributed, the contract's baseline.
The commands are namespaced so another plugin's build can't shadow them; the
bare sigx build / sigx validate / sigx audit aliases also resolve when
nothing else claims those names (zero:fragment and zero:extend have no bare
alias). Every command exits non-zero on failure, and sigx <command> --help
prints its current flags.
The coverage report
Validation answers "is this correct" and returns a flat list of issues.
--report answers the other question — what the design system actually
covers:
sigx zero:validate --report # human-readable summary
sigx zero:validate --report-json report.json
sigx zero:validate --report-json - # JSON on stdout, ready to pipeheroui — coverage report
score 68.4 (D): components 2 · vocabulary 92.3 · states 75 · contrast 100 · issues 94
components styled: 1/52 (2%)
unstyled: accordion, avatar, collapsible, combobox, menu, …
declared out of existence: color
color wired: 0/1 (0%) — no such axis
size wired: 1/1 (100%)
variant wired: 1/1 (100%)
states+flags covered: 6/8 (75%) (0 conditionally, 0 skipped deliberately)
theme hero-light: min contrast 14.33:1 (base-300 vs base-content)The score is the line a generating agent iterates against — one number
that moves, and a grade that says when to stop (A ≥ 90, B ≥ 80, C ≥ 70,
D ≥ 60, else F). It is folded from the sections under it, never from
anything else: components styled (weight 25), declared axis values honoured
by some recipe and claimed by some scope (15), part states and flags covered
(20), the WCAG margin of the declared role pairs in the weakest theme (25),
and the validation counts when the report was built alongside a validation
pass (15 — ten points an error, two a warning). A sixth criterion, audit,
joins the weighting when an audit score is handed to computeScore; absent
criteria drop out and the weights renormalise, so a report built without a
validation pass is comparable to one built with it. Three things it
deliberately does not do: penalise a declined axis (roles: {} / sizes: []
are statements — an axis in declaredOut leaves the denominator, and a role
declared as a fill with content: false / soft: false is a token, not an
axis value), give full credit for skipStates (half — a recipe that skips
every state cannot score above 50 on that criterion), or score per-scope
variant wiring (the carriers that leave it unwired do so by recorded
decision). Every criterion carries the counts it was computed from under
detail, so a number can be argued with. The six in-repo skins score
93–97 (A); a Button-only start scores 68 (D).
The report is emitted whether or not validation passes — a design system that fails is exactly the one whose coverage is worth reading. (The one exception is a design system that does not compile at all: that is already an error, and there is nothing to report about it.)
sigx zero:build writes the same report to dist/report.json alongside
manifest.json and register.d.ts, as does writeArtifacts when handed one —
so a built design system carries its report without anyone running validate.
The document is reportVersion: 2 (schemas/report.schema.json); version 2
added the required score section.
It carries, per design system: the score above; components styled against the anatomy manifest;
the axes each component wires, and which its register.d.ts types never
(derived from the same harvest, so the two cannot disagree); declared-but-unwired
values per axis and per modifier — the only place a declared-but-unused colour
role or size step surfaces, since the validator has no rule for those; per-part
state and flag coverage, including what skipStates delegates deliberately
(that field has a second reader — the state-legibility guard treats an entry as
"this state is deliberately indistinguishable from its siblings", so it waives
more than the coverage warning); the
axis-agnostic divergence report, listing per axis the per-component value
sets and flagging any component wiring a strict subset of its siblings; and the
minimum WCAG contrast margin per theme across the declared role pairs.
--report-json - makes stdout carry the JSON and nothing else — diagnostics go
to stderr and pass/fail is the exit code. Two flags rather than one
--report=json because @sigx/args has no optional-value form yet; they
collapse once it does.
Comparing two runs
sigx zero:validate --diff dist/report.json # what moved since the last build--diff <path> reads an earlier report.json and prints what changed between
it and the report of the current source: the score and each criterion's delta,
scopes newly styled or unstyled, values newly wired or unwired, states newly
covered or uncovered, declared role pairs that crossed the 4.5:1 or 3:1
contrast thresholds in either direction, and the validation counts when both
reports carry them. sigx zero:build writes dist/report.json every run, so
the loop is build → change → --diff dist/report.json. Programmatically it is
diffReports(prev, next) and formatReportDiff(diff), pure functions over two
reports — no design system, no manifest, no node:.
Compare like with like: a build that merged an ecosystem fragment
(build.mjs with mergeManifests) and a validate run without the matching
--extra-manifest were asked about different manifests, and the diff says so
(manifest differs … removed ext-stepper) rather than calling the missing scope
unstyled.
Two things the diff will not do. A state moved into skipStates is listed as
newly skipped, never as resolved — the same half-credit stance the score
takes, so a waiver cannot read as progress. And two reports of different
reportVersion are refused with a message naming both: the older one needs
regenerating with this kit, not a best-effort comparison of two shapes.
Watching the loop
export ZERO_ITERATION_LOG=.zero-iterations.jsonl # once, before the first run
sigx zero:validate --report # every run appends one line…
# [sigx] iteration 7 — errors 0 (was 3), warnings 3 (was 14), score 92 → A (was 71 C); top: contrast-floor ×2, recipes.button ×1The skill calls generate → validate → fix "the point", and this is what
observes it: opt-in, local, append-only. With ZERO_ITERATION_LOG=<path> in
the environment (or --log <path> on one run — the flag wins), every
zero:validate appends one JSON line — timestamp, error and warning counts,
the score and grade when the design system compiled, the five rules that
fired most, and wall-clock — and prints the trend line for that run, each
count beside what it was the run before. The rule ids are ValidationIssue.rule
where a rule has one and the first two segments of where (recipes.button,
themes.dark) where it does not. Nothing runs unless a path is named, and
nothing leaves the machine; add the file to .gitignore. A log that cannot
be written warns and the run carries on to its verdict. Programmatically:
iterationEntryFrom(...) builds an entry, formatIterationLine(entry, index, prev)
the line for one run and formatIterationLog(entries) every line — all pure. A line a killed run left half-written is skipped, never
fatal.
The audit
Validation says whether the design system is correct; the report says what it covers; the audit says whether what it built says what it claims — the questions this repo's own CI asked of its six skins from vitest files, and which a design system generated anywhere else could not ask at all until they shipped with the kit:
import { auditDesignSystem, formatAudit } from '@sigx/zero-kit';
const result = auditDesignSystem(designSystem, manifest);
console.log(formatAudit(result).join('\n'));
result.findings; // AuditFinding[] — severity → rule → where, each naming its fix
result.waived; // what a declared mechanism excused, listed rather than swallowed
result.contrast; // the static contrast matrix's full cell table, per theme
result.summary; // { errors, warnings, info, byRule }Every rule reads the compiled CSS, not the recipe tree — state styling
reaches the stylesheet through seven doors (states, selectors,
variants.*, compoundVariants, modifiers, nested at, raw css) and only
the artifact sees all of them. Twelve rules:
| Rule | Severity | It reports… | Waived by |
|---|---|---|---|
| state-legibility/component | error | two declared states no part of the component renders differently | skipStates or sameAs on every part that has them; the anatomy's hiddenIn |
| state-legibility/indicator | error | an *indicator part that renders identically across its own states — a spacer, not an indicator | skipStates or sameAs on the indicator; hiddenIn |
| state-legibility/disclosure | error | the control of an in-flow disclosure (collapsible, accordion, tree-view) that says nothing about open/closed — the panel expanding is the browser's doing | skipStates or sameAs on the control; a sibling *indicator that differentiates |
| button-affordance | error | a part zero renders as a real <button> with no unconditional appearance reset, so the user agent paints its chip | — (set appearance: none) |
| axis-value-coverage/gap | error | a declared step a sibling scope implements that this scope neither paints nor claims as its base (#258's shape) | tokens.scopes |
| axis-value-coverage/ambiguous-base | error | two values written as empty entries, both claiming the base and rendering identically | — |
| axis-value-coverage/unused | warning | a declared value no recipe paints or claims; or one in no scope's vocabulary | a role declared content: false / soft: false (a fill, not an axis value) |
| axis-coverage | warning | a styled scope that accepts a declared color/size axis at runtime and wires nothing — or wires it without keying a part that re-carries it (scope.part.axis) | roles: {} / sizes: []; tokens.scopes.<scope>.colors: [] / .sizes: [] |
| reduced-motion/loop | error | an infinite animation in the default render with no animation: none for the same selector under prefers-reduced-motion: reduce as its only condition — the kit collapses durations there, so a loop strobes rather than stops; a cancel also gated by @supports or a second @media stops it for some readers, not all | — (a loop that only exists behind @supports / @container is not the default render's and is not judged) |
| contrast/text | error below 3:1 per cell; warning 3–4.5:1, once per (part, theme) naming the worst cell; disabled error below 2:1 pre-fade | a text-bearing part whose computed ink against its effective background clears no floor, in any state × flag combination, in any theme — the design system's own axis surface (every wired variant/color value, each modifier) included | — |
| contrast/indicator | error below 3:1 per cell; 3–4.5:1 is info (a non-text mark meets WCAG 1.4.11 at 3:1) | a mark whose whole job is paint (the tick, the dot, the thumb, the range, the chevrons, the star) that cannot be seen against what it is painted on, measured inside its real ancestor chain | — |
| contrast/unmeasured | info | cells the static reader could not judge, once per (scope, part, reason) with the count — never a pass, never a failure | — |
Only the default render counts: a difference that lives under a @media
(the forced-colors glyph fallback, a breakpoint, print) is not the reader
differentiating. The rule modules under src/audit/rules/ carry the full
reasoning in their docblocks, and each has fixtures it MUST report — the
in-repo skins are held to zero findings through the same function.
The static contrast matrix
The three contrast/* rules are the browser contrast audit
(examples/playground/e2e/contrast-audit.spec.ts) computed from the compiled
CSS: the same two matrices (text legibility over every text-bearing part in
every renderable state combinat
