open-design/design-systems/_schema/AGENTS.md
chaoxiaoche 6a08dfe111
Add design system package quality guard (#2224)
* Add design system import manifest schema

* Generate hybrid design system imports

* Read design system usage and cached manifests

* Add design system pull-file tool

* Show design system package evidence

* Wire design system import semantics

* Add design system package quality guard

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
2026-05-19 16:53:29 +08:00

9.5 KiB

_schema/ — design-system contracts

This directory codifies the structural contracts for design systems. tokens.schema.ts is the token contract that every tokenized brand under design-systems/<brand>/ must satisfy. manifest.schema.ts is the project contract for folders that opt into the Design System Project shape by adding manifest.json; legacy DESIGN.md-only folders remain valid until they are migrated.

_schema/
├── manifest.schema.ts ← project manifest schema (TS, machine-enforced when present)
├── tokens.schema.ts   ← canonical token schema (TS, machine-enforced)
├── defaults.css       ← A2 fallback values (CSS, human reference)
└── AGENTS.md          ← this file

The TypeScript schemas are the source of truth. defaults.css is a human-readable mirror of the A2 fallback fields in tokens.schema.ts and exists so that reviewers can scan real CSS without parsing a TS array — drift between the two is enforced by the design-system: A2 defaults parity guard. Manifest shape is enforced by scripts/check-design-system-manifests.ts for any design-systems/<brand>/manifest.json that exists.

Project manifest contract

Design System Project folders use fixed v1 file names:

  • manifest.json — machine-readable project entry.
  • DESIGN.md — canonical design prose.
  • tokens.css — canonical compiled tokens.
  • components.html — optional standalone component fixture.
  • assets/ — optional brand assets.
  • preview/ — optional static preview pages.
  • USAGE.md — optional agent-facing package guide.
  • components.manifest.json — optional rebuildable cache derived from components.html and tokens.css.
  • fonts/ — optional webfont files.
  • source/ — optional importer evidence (scanned-files.json, evidence.md, tokens.source.json, and snippets/INDEX.json).

The manifest guard validates only folders that ship manifest.json; it does not require the bundled catalog to migrate all at once. Rich import fields are structural in PR0: when declared, paths must be safe and present, JSON indexes must parse, and committed components.manifest.json files must match a fresh derivation from components.html plus tokens.css. Runtime prompt composition and picker behavior are unchanged until later PRs consume those fields.

Four layers, two questions

Every shared token answers two questions:

  1. Who decides the value? — the brand author (Layer A) or the schema author (Layer B-slot, when the brand has no opinion).
  2. What happens if the brand omits it? — required, fallback, or alias.

The four layers fall out of those answers:

Layer Who decides If omitted Examples
A1-identity brand guard fails --bg, --fg, --accent, --font-display
A1-structure brand guard fails type scale, --container-max, --section-y-*
A2 brand (with fallback) guard fails today; derive script fills tomorrow --motion-fast, --success, --space-4, --font-mono
B-slot brand or schema-suggested alias guard fails — brand must declare, either as var(--sibling) (collapsed) or independent value (richer) --fg-2 → var(--fg), --surface-warm → var(--surface)

Brand-specific tokens that fall outside the shared schema are tracked as C-extensions in BRAND_EXTENSIONS (per-brand allowlist) or BRAND_EXTENSION_PREFIXES (global prefix allowlist for whole families like --tag-bg-*).

Why A2 fails the guard today (and might not later)

A2 conceptually means "optional with fallback" — but artifacts are generated by agents pasting one brand's :root block into a single <style>. There is no global stylesheet that loads alongside the brand, so a missing --motion-fast resolves to nothing inside the artifact and any transition: var(--motion-fast) rule silently breaks.

Until the derive script (PR-B) lands and inlines defaults.css values into every brand's tokens.css, the only safe contract is "every brand must declare every A2 token". The design-system: A2 required tokens guard enforces that strictly.

After the derive script ships, brand authors only need to write the A1 tokens (and any A2 they want to override); the script populates A2 slots from defaults.css. The guard contract does not change — every final tokens.css still contains every A2 token — but the work shifts from human author to script.

Why B-slot is required (and what the alias is for)

Same artifact-paste constraint applies to B-slot tokens. Shared components reference richer tiers via var(--fg-2), var(--meta), var(--surface-warm), var(--border-soft) — if a brand omits the slot, those references resolve to nothing and the artifact silently breaks.

The aliasTo field on each B-slot entry is the schema-suggested default, not a runtime fallback. A brand with no opinion on the richer tier copies the alias verbatim into its :root:

--fg-2: var(--fg);                /* default brand: 2-level fg */
--surface-warm: var(--surface);   /* default brand: 2-level surface */

A brand that does have the richer tier binds an independent value:

--fg-2: #3d3d3a;                  /* kami brand: dark warm */
--surface-warm: #e8e6dc;          /* kami brand: warm sand */

Either form satisfies the design-system: B-slot required tokens guard. The pre-derive-script contract is identical to A2: every brand's :root declares every shared slot.

C → B-slot → A2 promotion path

Brand-specific tokens start in BRAND_EXTENSIONS[brand]. They earn promotion when a second brand needs the same name:

C-extension                    B-slot                       A2
(one brand declares it)        (multiple brands declare,    (every brand declares
                                some alias to a sibling)     with a sensible default)

kami: --leading-display    →   schema: --leading-display    →   schema: --leading-display
                               aliasTo: var(--leading-tight)    fallback: 1.1

Concrete promotion rules:

  1. C → B-slot when ≥2 brands declare a token of the same name and there is a meaningful sibling to alias to for brands that lack the richer tier. Move the entry from BRAND_EXTENSIONS to TOKEN_SCHEMA with layer: "B-slot" and aliasTo: "var(--sibling)".
  2. C → A2 when ≥2 brands declare a token of the same name and a defensible cross-brand fallback exists (no aliasing needed). Move to TOKEN_SCHEMA with layer: "A2" and a fallback, then mirror the value in defaults.css.
  3. B-slot → A2 when a B-slot starts being independently bound by ≥2 brands (instead of aliasing). Replace aliasTo with fallback and add a defaults.css declaration.
  4. A2 → A1 is rare. It happens when the previously-defaultable value turns out to be brand-determining — e.g. if a future brand redefines --motion-base from 200ms to 50ms because its identity is "instant", and that change ripples meaningfully through the brand voice. Drop the fallback and reclassify.

Demotion (A → B → C) is not currently supported. A token that is genuinely no longer needed should be marked @deprecated in the schema for one release and then deleted from every brand's tokens.css in the same PR.

When not to add a token

Schema growth has a cost — every new entry forces 138 brands to declare or alias the new name when the derive script next runs. Resist adding tokens that are:

  • Component-internal: a .btn-primary background offset that no other component will ever read. Inline the value in the component rule.
  • One-off: a single layout's hero crop ratio. Not a token.
  • Speculative: "we might want a --motion-slow someday." Add it the first time a real interaction needs it, not before.
  • Already expressible: a --accent-tint-50 that resolves to color-mix(in oklab, var(--accent), transparent 50%). Inline the color-mix(...) call until ≥2 components need the same tint with the same alpha, then promote to a token.

Editing this directory

When you change tokens.schema.ts:

  • Run pnpm guard and confirm both default and kami still pass every design-system sub-check.
  • If you added an A2 entry: also update defaults.css with the matching declaration, byte-equivalent to the fallback field.
  • If you renamed a token: bump every brand's tokens.css and the matching components.html :root paste in the same commit. Otherwise the drift guard will fail.
  • If you removed a token from TOKEN_SCHEMA and the same name now appears in only one brand: add it to that brand's BRAND_EXTENSIONS entry so the unknown-token guard does not fail.

Open questions deferred to PR-B

This PR codifies the schema and enforces it on hand-authored brands. The following questions are intentionally not answered here:

  • How does the derive script source A1 values from DESIGN.md? Some sections (color palette, type scale) parse cleanly; others (visual atmosphere, do's and don'ts) do not. A frontmatter or fenced-block convention will likely emerge.
  • What happens when a brand's DESIGN.md contradicts itself? e.g. accents listed as both cobalt and indigo. The derive script will need a deterministic resolution (last-wins, manual override flag, or hard fail).
  • Are A2 fallback formulas stable when re-derived? Bit-for-bit reproducibility of the script's output is required so that running the script twice on the same input does not churn 138 files.

These will be addressed in the PR that introduces scripts/derive-tokens-css.ts.