npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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/cli

Coming 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-block

They 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 + audit

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

  • recipes patches scopes the base styles; a patch for a scope it does not style throws (a typo would otherwise be a silent no-op), and null drops the base's recipe.
  • addRecipes adds scopes the base does not style; one it already styles throws.
  • css entries are appended after the base's; api is carried unchanged unless patched (null drops 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 hook

A 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 --strict

The 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 pipe
heroui — 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 ×1

The 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