feat(design-systems): add structured tokens.css schema (default + kami) (#1231)

* feat(design-systems): add structured tokens.css schema (default + kami)

Compile each brand's DESIGN.md prose into a machine-readable :root
block agents paste verbatim, removing the "Primary → --accent"
translation step where most token misuse happens. Daemon prompt
injection lands in a follow-up; lint-artifact already enforces the
shared token vocabulary so no rule changes needed.

Schema validated across two contrasting aesthetics:
- default (sans-serif, cobalt, B2B utility) — stress test the
  shallow form, 2-level fg / 2-level surface
- kami (serif, parchment, ink-blue, print-first) — stress test the
  rich form, 4-level fg ramp, 3-level surface, ring elevation, i18n
  font stacks, and solid-hex tag tints (print renderers double-paint
  alpha)

Schema growth from kami's stress test (5 new optional slots, all
backward-compatible — default aliases via var() to existing tokens):
- --fg-2 / --meta (4-level fg ramp)
- --surface-warm (3-level surface)
- --border-soft (2-level border)
- --elev-ring (ring elevation as first-class level)

Brand-specific extensions live in tokens.css with explicit "NOT in
shared schema" labels and a documented promotion path (≥2 brands
need it → promote to schema slot).

components.html in each brand is a self-contained reference fixture
that exercises every token through real layouts. Both fixtures lint
clean against apps/daemon/src/lint-artifact.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(design-systems): add token-fixture drift guard

Each design system in design-systems/<brand>/ ships two files agents
consume in tandem: tokens.css (canonical token bindings) and
components.html (a self-contained fixture whose first <style> embeds
the same :root paste so the file renders standalone). The fixture's
:root block is a copy of tokens.css's :root block, kept in sync only
by an inline comment.

This adds scripts/check-tokens-fixture-sync.ts and registers it in
pnpm guard. The check pairs each brand's tokens.css with its
components.html and asserts the unscoped :root block is byte-equivalent
after canonical normalization (CSS comments stripped, whitespace
collapsed, separator spacing normalized). Brands missing one half of
the pair, or with no :root rule in either file, fail the guard.

Scoped overrides like :root[lang="zh-CN"] are not required to appear
in the fixture (per the kami fixture's inline comment they are pasted
only when an artifact's <html lang> matches), so the check only
compares the unscoped :root block.

Verified: pnpm guard passes for default + kami, fails on intentional
value drift, fails on missing token, tolerates whitespace-only
formatting differences.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(design-systems): point fixture CTAs to real files

Both default and kami components.html advertised in-page anchors
(#tokens, #spec, #surface, #accent, #type, #components) but defined
no matching ids, so every CTA was a no-op when the fixture was
opened locally — flagged by mrcfps in #1231.

Re-point each link to a real artifact in the same brand directory:

- "View tokens" / "Inspect tokens" / "Inspect typography" → ./tokens.css
- "Read the spec" / "Read the rule" → ./DESIGN.md

Browsers render these as raw source views, which is the desired UX
for a reference fixture: clicking the CTA shows the underlying
contract instead of jumping to nothing. Agents copying the fixture
also learn the pattern of "buttons link to actual sibling resources".

The :root token block is unchanged, so the token-fixture drift guard
still passes for both brands.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(design-systems): codify token schema (A1/A2/B/C layers)

The two-brand pilot (default + kami) settled the shape of the shared
token schema; this commit codifies it as a machine-readable contract
and enforces it in pnpm guard, addressing lefarcen's review on #1231:

> the optional-vs-required split won't generalize cleanly when brand
> #3 needs different Layer A tokens or when multiple brands converge
> on the same extension (promoting C→B→A). Consider surfacing that
> limitation in the PR narrative or in a future SCHEMA.md.

Schema lives under design-systems/_schema/ as three files:

- tokens.schema.ts   — TypeScript declaration of every shared token
                       with its layer (A1-identity / A1-structure /
                       A2 / B-slot), plus per-brand C-extension
                       allowlists and a global C-prefix allowlist
- defaults.css       — CSS mirror of A2 fallback values, used as the
                       human-readable contract reviewer's-eye copy
                       and the future input to the derive script
- AGENTS.md          — schema layer model, C → B-slot → A2 promotion
                       rules, when-not-to-add-a-token guidance

Layer model:

  A1-identity    8 tokens — bg/surface/fg/muted/border/accent +
                 font-display/font-body. The brand IS these values;
                 no fallback is defensible.

  A1-structure  18 tokens — type scale (8), leading (2), tracking
                 (1), section-y (3), container (4). Structural
                 decisions vary per brand by design and have no
                 cross-brand default.

  A2            26 tokens — accent states, semantic colors, motion,
                 base spacing scale, radius, elevation, focus,
                 font-mono. Required in every tokens.css; fallback
                 lives in defaults.css for the future derive script
                 to inline when DESIGN.md does not specify the value.

  B-slot         4 tokens — fg-2 / meta / surface-warm / border-soft.
                 Brand may bind independently or alias the named
                 sibling via var(...) for components that target the
                 richer ramp.

  C-extension    n tokens — brand-specific names (kami's tag-bg-*,
                 leading-display, accent-light, etc.). Allowlisted
                 per-brand in BRAND_EXTENSIONS or globally by prefix
                 in BRAND_EXTENSION_PREFIXES. Promote when a second
                 brand adopts the same name.

Why A2 fails the guard today:
  Artifacts are generated by agents pasting one brand's :root block
  into a single <style>; there is no global stylesheet that supplies
  fallbacks at runtime. A tokens.css missing an A2 declaration would
  silently break any var() reference in the fixture. Until the
  derive script (PR-B) lands and inlines defaults, every brand's
  tokens.css must declare every A2 token directly. The guard
  enforces this strictly.

Why --font-mono lands in A2 (not A1):
  149 brands' DESIGN.md files were surveyed: 87 (58%) declare a
  monospace stack, 62 (42%) do not — including major brands like
  bmw / nike / apple / notion / mastercard / meta. Agent paste
  cannot rely on the brand author having written it down; a
  defaultable A2 fallback (with CJK brands like kami overriding) is
  safer than forcing every brand author to add a field they may not
  realize their kbd / code-block components need.

Five guard checks, each registered as its own entry in scripts/guard.ts
so failures attribute to a specific contract:

  1. token-fixture sync       — components.html :root ↔ tokens.css :root
                                 byte-equivalent (existing)
  2. A1 required tokens       — every brand declares every A1 token
  3. A2 required tokens       — every brand declares every A2 token
  4. unknown token allowlist  — every declared token is in schema or
                                 brand-extension allowlist
  5. A2 defaults parity       — defaults.css ↔ tokens.schema.ts
                                 fallback byte-equivalent

Verified on default + kami:
  - 26 A1 tokens declared in both brands
  - 26 A2 tokens declared in both brands
  - 129 total declarations, all match shared schema or brand extensions
  - defaults.css ↔ tokens.schema.ts parity holds
  - sanity test: drifting --motion-fast in defaults.css fails check 5
    with a clear divergence message

The PR description originally listed "Dedicated SCHEMA.md" as
explicitly NOT in this PR ("Once 3+ brands ship, extracting a single
source of truth becomes worthwhile"). That boundary moves: lefarcen's
review surfaced the schema-generalization risk, and the schema must
exist as a machine-enforced contract before the derive script can
read it. The TS file replaces the markdown that was deferred.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/tests): pass missing designTemplates prop to ProjectView

Pre-existing typecheck regression on main: PR #955 (b5eb8c16,
"generic skills + split skills/design-templates + finalize-design
API") added required `designTemplates: SkillSummary[]` to ProjectView
Props but updated only two of the three test fixtures that render
ProjectView directly. The third — ProjectView.api-empty-response.test.tsx
— was missed, so `pnpm typecheck` (and CI on any PR merging into
main) fails on:

  apps/web/tests/components/ProjectView.api-empty-response.test.tsx
    (168,6): error TS2741: Property 'designTemplates' is missing in
    type ...

The other two ProjectView tests already pass `designTemplates={[]}`,
so this aligns this fixture with the existing pattern. Out of scope
for #1231 strictly, but the regression blocks the merged-state
typecheck CI runs that #1231 triggers, and the one-line fix here
restores main's typecheck health for everyone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(design-systems): enforce B-slot required tokens in pnpm guard

Closes mrcfps + lefarcen review comment thread on #1231:

> The guard validates A2 required tokens here, but there's no
> sibling check for B-slot aliases (--fg-2, --meta, --surface-warm,
> --border-soft). Per the schema docs, every brand must declare
> A1 + A2 + B-slot names so shared components can safely read
> var(--fg-2) etc. Without a B-slot guard, a brand can omit those
> aliases, pass pnpm guard, and break any artifact that references
> them.

Same artifact-paste constraint as A2: agents render artifacts by
pasting one brand's :root block into a single <style>; there is no
runtime cascade, so a missing B-slot makes any var(--fg-2) reference
resolve to nothing. Until now the schema narrative claimed B-slots
were optional with a var() default, but no machine check enforced
declaration — a contract gap reviewers reasonably refused to merge.

This commit closes the gap in three places so machine and narrative
agree:

1. scripts/check-tokens-fixture-sync.ts
   - Add checkDesignSystemBSlotRequiredTokens, mirroring the A2
     check but using getBSlotNames() from the schema.
   - Failure message names each missing slot AND the schema-suggested
     alias (--fg-2 (default alias: var(--fg))) so a brand author
     fixing the failure has a copy-pasteable resolution.
   - Renumber section comments: 5 checks → 6 checks.

2. scripts/guard.ts
   - Register the new check between A2 required and unknown
     allowlist so failures attribute to a specific contract.

3. design-systems/_schema/AGENTS.md
   - Update the layer table: B-slot row's "If omitted" column
     changes from "resolves via var() to a richer sibling" to
     "guard fails — brand must declare, either as var(--sibling)
     (collapsed) or independent value (richer)".
   - Add a "Why B-slot is required (and what the alias is for)"
     section that distinguishes the schema-suggested alias from a
     runtime fallback, with worked examples for default (alias) and
     kami (independent bind).

Verified on default + kami:
- pnpm guard passes all 6 design-system checks
- 4 B-slot tokens declared in both brands (default aliases via var(),
  kami binds independently — both forms satisfy the contract)
- pnpm typecheck clean across the workspace
- Sanity test: removing --fg-2 + --meta from default/tokens.css fires
  the new guard with a precise per-token alias hint:
    [default] design-systems/default/tokens.css is missing 2 B-slot
    tokens (alias the named sibling via var(...) or bind
    independently):
      --fg-2 (default alias: var(--fg)),
      --meta (default alias: var(--muted))

The schema contract is now machine-enforced end-to-end (A1 + A2 +
B-slot all required-with-fixed-form-of-fallback). The derive script
in PR-B can rely on every brand's tokens.css containing every shared
slot name.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(e2e): skip leading-underscore meta-directories under design-systems/

CI for #1231 went red on `Validate workspace` after merging origin/main.
Cause is a clean collision between two recently-landed changes:

- main #1270 (be77dc03 "Default English resource i18n fallback")
  tightened tests/localized-content.test.ts so every directory under
  design-systems/ is run through assertResourceId() with the strict
  RESOURCE_ID_PATTERN /^[a-z0-9][a-z0-9-]*$/.

- this branch #1231 introduced design-systems/_schema/ as the home
  of the shared token contract (tokens.schema.ts, defaults.css,
  AGENTS.md). The leading underscore signals "meta-directory, not
  brand" — the same convention SCSS partials, Jekyll, Hugo all use.

The two changes never met until CI built the merge commit, where
assertResourceId('_schema') deterministically failed:

  Error: Design system directory _schema has malformed resource id: _schema
    at invariant tests/localized-content.test.ts:66:11
    at assertResourceId tests/localized-content.test.ts:71:3
    at readDesignSystemResources tests/localized-content.test.ts:202:8

Fix tightens readDesignSystemResources's directory filter so the
leading-underscore convention is recognised explicitly:

    .filter((entry) => entry.isDirectory() && !entry.name.startsWith('_'))

This aligns with what apps/daemon/src/design-systems.ts:listDesignSystems
already does implicitly — it requires DESIGN.md per directory, so
_schema/ was always invisible at runtime; the test was the only place
that surfaced it.

Verified locally on the post-merge tree:
- pnpm test (e2e vitest) — tests/localized-content.test.ts: 4 passed
- pnpm guard — all 6 design-system checks pass on default + kami
- pnpm typecheck — clean across the workspace (after pnpm install
  to pull deps for tools/pr that arrived with main)

The fix is intentionally narrow (one filter line in one test) and
documents the convention inline so future meta-directories under
design-systems/ (e.g. _archive/, _drafts/) are covered for free.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@192.168.10.16>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
chaoxiaoche 2026-05-11 22:23:34 +08:00 committed by GitHub
parent 87a95b7fb4
commit a75d9938c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2675 additions and 1 deletions

View file

@ -0,0 +1,179 @@
# `_schema/` — the shared token contract
This directory codifies the structural contract that every brand under
`design-systems/<brand>/` must satisfy. It is the input to the drift
guard (`scripts/check-tokens-fixture-sync.ts`) and the future derive
script that will bulk-generate `tokens.css` for the ~140 brands that do
not yet have hand-authored tokens.
```
_schema/
├── tokens.schema.ts ← canonical schema (TS, machine-enforced)
├── defaults.css ← A2 fallback values (CSS, human reference)
└── AGENTS.md ← this file
```
The TypeScript schema is the source of truth. `defaults.css` is a
human-readable mirror of the A2 `fallback` fields 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.
## 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`.

View file

@ -0,0 +1,100 @@
/*
* design-systems/_schema/defaults.css
*
* Fallback values for every Layer A2 token in the shared schema.
*
* What this file is FOR:
* - A human-readable mirror of the `fallback` field on every A2
* entry in `tokens.schema.ts`. Reviewers can scan real CSS and
* spot weird defaults faster than they can read a TS array.
* - The future input to the derive script (PR-B): when a brand's
* DESIGN.md does not specify an A2 token, the script copies the
* declaration from this file into the brand's tokens.css.
*
* What this file is NOT FOR:
* - Runtime cascade. 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. A brand's tokens.css
* must therefore declare every A2 token directly this file
* never reaches the browser.
*
* Drift contract:
* The `design-system: A2 defaults parity` guard check asserts that
* each declaration here matches the `fallback` field on the
* corresponding entry in `tokens.schema.ts`. Update both together.
*
* Tokens absent from this file:
* - A1-identity tokens (--bg, --fg, --accent, font stacks) have no
* defensible cross-brand default; brands must author them.
* - A1-structure tokens (type scale, container, section-y) are
* structural decisions that vary per brand by design.
* - B-slot tokens (--fg-2, --meta, --surface-warm, --border-soft)
* resolve via `var()` aliasing inside each brand's tokens.css,
* not via this file.
* - C-extension tokens are brand-specific and have no shared
* default by definition.
* */
:root {
/* Accent states (over --accent bg). The black-mix formulas work for
mid-luminance accents; brands with very dark or very light accents
should override the value with a hand-picked one (e.g. kami binds
--accent-hover to var(--accent) because ink-blue cannot darken
further visibly). */
--accent-on: #ffffff;
--accent-hover: color-mix(in oklab, var(--accent), black 8%);
--accent-active: color-mix(in oklab, var(--accent), black 14%);
/* Semantic state. Reserve under 5% of any surface area; not all
brands need these print-first kami inherits defaults rather than
designing custom warm equivalents. */
--success: #16a34a;
--warn: #eab308;
--danger: #dc2626;
/* Monospace. Every brand uses kbd / tabular-nums / code somewhere;
CJK brands override with a stack that includes their preferred
CJK monospace face (kami adds TsangerJinKai02 / Source Han Serif). */
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Monaco, Consolas, monospace;
/* Base spacing scale on a 4px grid. Identical across default and
kami today. Brands with a print-rooted rhythm (different physical
paper sizes) may rebind these wholesale; keep the names stable. */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
/* Radius scale. Pill is functionally fixed at 9999px; the small /
medium / large tiers are defaults that brands routinely override
to express softness or sharpness as part of brand mood. */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-pill: 9999px;
/* Elevation. Three sanctioned levels (no fourth that is
neumorphism territory). Brands forbidding blur shadows (kami,
paper, editorial) override --elev-raised with a whisper or
ring-only treatment. */
--elev-flat: none;
--elev-ring: 0 0 0 1px var(--border);
--elev-raised: 0 2px 8px color-mix(in oklab, var(--fg), transparent 92%);
/* Focus. Implemented as a box-shadow so it layers outside the
element without affecting layout. Brands forbidding cool-blue
glows must override; rebind via accent or a hand-picked ring. */
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 70%);
/* Motion. Two durations + one easing curve, per the anti-ai-slop
"short, purposeful transitions (150250ms) with stable easing"
contract. Add a third duration only when a real interaction
needs it; do not invent --motion-slow speculatively. */
--motion-fast: 150ms;
--motion-base: 200ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
}

View file

@ -0,0 +1,272 @@
/*
* design-systems/_schema/tokens.schema.ts
*
* The structural contract for every brand's `tokens.css`.
*
* Each token in the open-design ecosystem belongs to exactly one of
* four layers, distinguished by who decides the value and what
* happens when a brand omits the token.
*
* A1-identity Required. The token *is* the brand. No fallback can
* substitute. (--bg, --fg, --accent, font stacks.)
*
* A1-structure Required. The token is a structural decision (type
* scale, layout grid, section rhythm) that has no
* cross-brand sensible default every brand authors
* its own.
*
* A2 Required *in the final tokens.css*, but a sensible
* fallback exists in `_schema/defaults.css` that the
* derive script (PR-B) will inline if a brand's
* DESIGN.md does not specify the value. Authors of
* hand-written brands (default, kami) must include
* every A2 token directly until the derive script
* ships.
*
* B-slot Optional schema slot. The token exists for cross-
* brand consistency but a brand without the richer
* tier may alias it to the named sibling via `var()`.
* Components that reference B-slot tokens always
* resolve, even on brands that do not differentiate
* the tier.
*
* C-extension Brand-specific token, declared explicitly per
* brand. Generic cross-brand components must NOT
* reference these. Promote to a B-slot when 2 brands
* need the same name; promote to A2 when there is a
* meaningful global default.
*
* Why A2 is "required-with-fallback" rather than "optional":
* Artifacts are generated by agents pasting one brand's :root block
* into a single <style>. There is no runtime cascade from a global
* defaults stylesheet. Agents that paste a tokens.css missing a
* var() target will produce broken artifacts (`var(--motion-fast)`
* resolves to nothing, `transition: var(--motion-fast)` becomes
* `transition: ` and the rule is dropped). The fallback lives in
* `_schema/defaults.css` so the *derive script* can inline it; the
* *runtime* contract remains "every tokens.css must declare every
* A1 + A2 + B-slot token".
*
* Why C-extension is allowlisted, not free:
* Without an allowlist, brand authors can ship arbitrary token
* names that other brands' components silently miss. The allowlist
* forces a deliberate review when a new brand-only name appears,
* and makes the CBA promotion path explicit (move the name from
* the brand-specific list into TOKEN_SCHEMA when 2 brands need it).
*
* Sources of truth:
* - This file: every shared schema token with its layer + metadata.
* - `_schema/defaults.css`: A2 fallback values, mirrored from this
* file so humans can sanity-check the contract in real CSS form.
* - `_schema/AGENTS.md`: prose narrative + promotion path rules.
*
* Drift between this file and defaults.css is enforced by the
* `design-system: A2 defaults parity` guard check.
* */
export type TokenLayer = "A1-identity" | "A1-structure" | "A2" | "B-slot";
export type TokenSpec = {
/** CSS custom property name including the `--` prefix. */
readonly name: string;
readonly layer: TokenLayer;
/** One-line description for documentation generators. */
readonly description: string;
/**
* A2 only. The default value the derive script inlines when a
* brand's DESIGN.md does not specify one. Must stay byte-equivalent
* to the matching declaration in `_schema/defaults.css`.
*/
readonly fallback?: string;
/**
* B-slot only. Sibling token to alias to when the brand has no
* richer tier (e.g. `--fg-2` aliases to `--fg`). The aliasTo string
* is a CSS expression, typically `var(--name)`.
*/
readonly aliasTo?: string;
};
/* eslint-disable @typescript-eslint/no-inferrable-types */
/**
* Every brand's tokens.css must declare every entry in this list.
*
* Order is meaningful for human review tokens are grouped by intent
* rather than by layer so reviewers can scan the visual stack from
* surface text border accent semantic typography spacing
* radius elevation focus motion layout.
*/
export const TOKEN_SCHEMA: readonly TokenSpec[] = [
// ─── Surface ──────────────────────────────────────────────────────
{ name: "--bg", layer: "A1-identity", description: "Page background — defines the brand canvas." },
{ name: "--surface", layer: "A1-identity", description: "Card / lifted container background." },
{ name: "--surface-warm", layer: "B-slot", description: "Tertiary surface tier (kami warm-sand).",
aliasTo: "var(--surface)" },
// ─── Foreground ───────────────────────────────────────────────────
{ name: "--fg", layer: "A1-identity", description: "Primary text color." },
{ name: "--fg-2", layer: "B-slot", description: "Secondary text tier (kami dark-warm).",
aliasTo: "var(--fg)" },
{ name: "--muted", layer: "A1-identity", description: "Subtext / captions." },
{ name: "--meta", layer: "B-slot", description: "Tertiary FG / metadata tier (kami stone).",
aliasTo: "var(--muted)" },
// ─── Border ───────────────────────────────────────────────────────
{ name: "--border", layer: "A1-identity", description: "Default border / card edge." },
{ name: "--border-soft", layer: "B-slot", description: "Inner row separator that should not visually compete.",
aliasTo: "var(--border)" },
// ─── Accent ───────────────────────────────────────────────────────
{ name: "--accent", layer: "A1-identity", description: "Brand accent. ≤2 visible uses per screen (lint enforced)." },
{ name: "--accent-on", layer: "A2", description: "FG when --accent is the bg.",
fallback: "#ffffff" },
{ name: "--accent-hover", layer: "A2", description: "Hover state for elements using --accent as bg.",
fallback: "color-mix(in oklab, var(--accent), black 8%)" },
{ name: "--accent-active", layer: "A2", description: "Active state for elements using --accent as bg.",
fallback: "color-mix(in oklab, var(--accent), black 14%)" },
// ─── Semantic ─────────────────────────────────────────────────────
{ name: "--success", layer: "A2", description: "Success state.", fallback: "#16a34a" },
{ name: "--warn", layer: "A2", description: "Warning state.", fallback: "#eab308" },
{ name: "--danger", layer: "A2", description: "Danger state.", fallback: "#dc2626" },
// ─── Typography — fonts ───────────────────────────────────────────
{ name: "--font-display", layer: "A1-identity", description: "Display / heading font stack." },
{ name: "--font-body", layer: "A1-identity", description: "Body font stack." },
{ name: "--font-mono", layer: "A2", description: "Monospace font stack — used by kbd, code, tabular metrics.",
fallback: 'ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Monaco, Consolas, monospace' },
// ─── Typography — type scale ──────────────────────────────────────
{ name: "--text-xs", layer: "A1-structure", description: "Type scale step — extra small (≈1112px)." },
{ name: "--text-sm", layer: "A1-structure", description: "Type scale step — small (≈1214px)." },
{ name: "--text-base", layer: "A1-structure", description: "Type scale step — body baseline." },
{ name: "--text-lg", layer: "A1-structure", description: "Type scale step — H3 / featured body." },
{ name: "--text-xl", layer: "A1-structure", description: "Type scale step — H2." },
{ name: "--text-2xl", layer: "A1-structure", description: "Type scale step — section title." },
{ name: "--text-3xl", layer: "A1-structure", description: "Type scale step — H1." },
{ name: "--text-4xl", layer: "A1-structure", description: "Type scale step — display / hero." },
// ─── Typography — leading & tracking ──────────────────────────────
{ name: "--leading-body", layer: "A1-structure", description: "Line-height for reading body." },
{ name: "--leading-tight", layer: "A1-structure", description: "Line-height for headings." },
{ name: "--tracking-display", layer: "A1-structure", description: "Letter-spacing applied to display sizes." },
// ─── Spacing — base scale ─────────────────────────────────────────
{ name: "--space-1", layer: "A2", description: "Base spacing — 4px tier.", fallback: "4px" },
{ name: "--space-2", layer: "A2", description: "Base spacing — 8px tier.", fallback: "8px" },
{ name: "--space-3", layer: "A2", description: "Base spacing — 12px tier.", fallback: "12px" },
{ name: "--space-4", layer: "A2", description: "Base spacing — 16px tier.", fallback: "16px" },
{ name: "--space-5", layer: "A2", description: "Base spacing — 20px tier.", fallback: "20px" },
{ name: "--space-6", layer: "A2", description: "Base spacing — 24px tier.", fallback: "24px" },
{ name: "--space-8", layer: "A2", description: "Base spacing — 32px tier.", fallback: "32px" },
{ name: "--space-12", layer: "A2", description: "Base spacing — 48px tier.", fallback: "48px" },
// ─── Section rhythm ───────────────────────────────────────────────
{ name: "--section-y-desktop", layer: "A1-structure", description: "Vertical padding between sections — desktop." },
{ name: "--section-y-tablet", layer: "A1-structure", description: "Vertical padding between sections — tablet." },
{ name: "--section-y-phone", layer: "A1-structure", description: "Vertical padding between sections — phone." },
// ─── Radius ───────────────────────────────────────────────────────
{ name: "--radius-sm", layer: "A2", description: "Small radius — buttons, inputs, chips.", fallback: "8px" },
{ name: "--radius-md", layer: "A2", description: "Medium radius — cards, modals.", fallback: "12px" },
{ name: "--radius-lg", layer: "A2", description: "Large radius — featured containers.", fallback: "16px" },
{ name: "--radius-pill", layer: "A2", description: "Pill radius — avatars, badges.", fallback: "9999px" },
// ─── Elevation ────────────────────────────────────────────────────
{ name: "--elev-flat", layer: "A2", description: "No elevation.", fallback: "none" },
{ name: "--elev-ring", layer: "A2", description: "Hairline ring (1px box-shadow border).", fallback: "0 0 0 1px var(--border)" },
{ name: "--elev-raised", layer: "A2", description: "Raised surface (blur or whisper).",
fallback: "0 2px 8px color-mix(in oklab, var(--fg), transparent 92%)" },
// ─── Focus ────────────────────────────────────────────────────────
{ name: "--focus-ring", layer: "A2", description: "Keyboard focus indicator.",
fallback: "0 0 0 3px color-mix(in oklab, var(--accent), transparent 70%)" },
// ─── Motion ───────────────────────────────────────────────────────
{ name: "--motion-fast", layer: "A2", description: "Hover / micro-state duration.", fallback: "150ms" },
{ name: "--motion-base", layer: "A2", description: "General state-change duration.", fallback: "200ms" },
{ name: "--ease-standard", layer: "A2", description: "Standard easing curve.",
fallback: "cubic-bezier(0.2, 0, 0, 1)" },
// ─── Layout ───────────────────────────────────────────────────────
{ name: "--container-max", layer: "A1-structure", description: "Max content container width." },
{ name: "--container-gutter-desktop", layer: "A1-structure", description: "Container side gutter — desktop." },
{ name: "--container-gutter-tablet", layer: "A1-structure", description: "Container side gutter — tablet." },
{ name: "--container-gutter-phone", layer: "A1-structure", description: "Container side gutter — phone." },
];
/* eslint-enable @typescript-eslint/no-inferrable-types */
/**
* Brand-specific tokens (Layer C) that are not part of the shared
* schema but are explicitly allowed for the named brand.
*
* Adding a name here means: "this token exists only in this brand's
* tokens.css; cross-brand components must not reference it." When a
* second brand adopts the same name, promote the entry into
* TOKEN_SCHEMA (typically as a B-slot or A2) and remove it here.
*/
export const BRAND_EXTENSIONS: Readonly<Record<string, readonly string[]>> = {
default: [
"--space-20", // 80px — used as section-y-desktop's twin; only default needs it
],
kami: [
"--accent-light", // brighter ink-blue for links on dark surfaces
"--text-md", // 15px lede tier between --text-base and --text-lg
"--leading-display", // 1.10 — only kami needs this tier
"--leading-dense", // 1.40 — resume / one-pager rhythm
"--tracking-eyebrow", // uppercase eyebrow tracking
"--tracking-label", // small uppercase label tracking
"--space-7", // 28px — kami's card interior
"--space-18", // 72px — section gap (web)
"--space-22", // 88px — page top padding (web)
"--radius-xs", // 2px — kami tags
"--radius-xl", // 16px — kami hero containers
"--elev-ring-accent", // 1px brand ring used as primary-button edge
],
};
/**
* Prefixes that match any token starting with the given string. Use
* for whole families of brand-specific tokens (e.g. kami's pre-blended
* tag tints `--tag-bg-faint / --tag-bg-soft / ...`) where the
* individual member tokens shouldn't have to be enumerated.
*
* A prefix in this list applies to *any* brand. To restrict a prefix
* to one brand, list each member name in BRAND_EXTENSIONS instead.
*/
export const BRAND_EXTENSION_PREFIXES: readonly string[] = [
"--tag-bg-",
];
/**
* Names that are intentionally absent from the shared schema and not
* tracked per-brand: `--leading-display`, `--leading-dense`, etc.
* begin life in BRAND_EXTENSIONS[brand] and graduate to the schema
* once a second brand adopts them.
*/
// ─── Helpers (consumed by the guard checks) ─────────────────────────
export function getRequiredA1Names(): readonly string[] {
return TOKEN_SCHEMA.filter((t) => t.layer === "A1-identity" || t.layer === "A1-structure").map((t) => t.name);
}
export function getRequiredA2Names(): readonly string[] {
return TOKEN_SCHEMA.filter((t) => t.layer === "A2").map((t) => t.name);
}
export function getBSlotNames(): readonly string[] {
return TOKEN_SCHEMA.filter((t) => t.layer === "B-slot").map((t) => t.name);
}
export function getAllSchemaNames(): readonly string[] {
return TOKEN_SCHEMA.map((t) => t.name);
}
export function isAllowedExtension(brand: string, name: string): boolean {
if (BRAND_EXTENSION_PREFIXES.some((prefix) => name.startsWith(prefix))) return true;
const brandList = BRAND_EXTENSIONS[brand];
if (brandList != null && brandList.includes(name)) return true;
return false;
}

View file

@ -0,0 +1,523 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Neutral Modern — reference components</title>
<meta
name="description"
content="Reference fixture for design-systems/default. Every visible
value comes from tokens.css; if the agent paste-replaces the
:root block and reuses the component selectors below verbatim,
the artifact passes lint without further audit."
/>
<style>
/* :root paste — sourced verbatim from
design-systems/default/tokens.css. Keep this block in sync
when tokens.css changes. The agent prompt instructs the
Designer panelist to paste this block as the FIRST thing in
their <style>, then reference everything below via var(...). */
:root {
--bg: #fafafa;
--surface: #ffffff;
--surface-warm: var(--surface);
--fg: #111111;
--fg-2: var(--fg);
--muted: #6b6b6b;
--meta: var(--muted);
--border: #e5e5e5;
--border-soft: var(--border);
--accent: #2f6feb;
--accent-on: #ffffff;
--accent-hover: color-mix(in oklab, var(--accent), black 8%);
--accent-active: color-mix(in oklab, var(--accent), black 14%);
--success: #17a34a;
--warn: #eab308;
--danger: #dc2626;
--font-display: "Inter", -apple-system, system-ui, sans-serif;
--font-body: "Inter", -apple-system, system-ui, sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", monospace;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 20px;
--text-xl: 24px;
--text-2xl: 32px;
--text-3xl: 48px;
--text-4xl: 64px;
--leading-body: 1.5;
--leading-tight: 1.2;
--tracking-display: -0.01em;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-20: 80px;
--section-y-desktop: 80px;
--section-y-tablet: 48px;
--section-y-phone: 32px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-pill: 9999px;
--elev-flat: none;
--elev-ring: 0 0 0 1px var(--border);
--elev-raised: 0 2px 8px
color-mix(in oklab, var(--fg), transparent 92%);
--focus-ring: 0 0 0 3px
color-mix(in oklab, var(--accent), transparent 70%);
--motion-fast: 150ms;
--motion-base: 200ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--container-max: 1200px;
--container-gutter-desktop: 24px;
--container-gutter-tablet: 16px;
--container-gutter-phone: 12px;
}
/* ─── Reset (minimal) ───────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-body);
-webkit-font-smoothing: antialiased;
}
/* ─── Layout primitives ─────────────────────────────────── */
.container {
max-width: var(--container-max);
margin-inline: auto;
padding-inline: var(--container-gutter-desktop);
}
section {
padding-block: var(--section-y-desktop);
}
section + section {
border-top: 1px solid var(--border);
}
@media (max-width: 1023px) {
.container { padding-inline: var(--container-gutter-tablet); }
section { padding-block: var(--section-y-tablet); }
}
@media (max-width: 639px) {
.container { padding-inline: var(--container-gutter-phone); }
section { padding-block: var(--section-y-phone); }
}
/* ─── Typography ────────────────────────────────────────── */
h1, h2, h3 {
font-family: var(--font-display);
line-height: var(--leading-tight);
margin: 0;
}
h1 {
font-size: var(--text-3xl);
font-weight: 600;
letter-spacing: var(--tracking-display);
}
h2 {
font-size: var(--text-2xl);
font-weight: 600;
letter-spacing: var(--tracking-display);
}
h3 {
font-size: var(--text-lg);
font-weight: 600;
}
p { margin: 0; }
.lead {
font-size: var(--text-lg);
color: var(--muted);
}
.body-muted { color: var(--muted); }
.body-sm { font-size: var(--text-sm); }
/* `.eyebrow` is the only component that uses uppercase + tracking.
craft/typography.md requires letter-spacing ≥ 0.06em on any
uppercase rule; the token below is well above that floor. */
.eyebrow {
font-size: var(--text-xs);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 500;
}
.stack-3 > * + * { margin-block-start: var(--space-3); }
.stack-4 > * + * { margin-block-start: var(--space-4); }
.stack-6 > * + * { margin-block-start: var(--space-6); }
/* ─── Buttons ───────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 10px 16px;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: var(--text-sm);
font-weight: 500;
line-height: 1;
cursor: pointer;
border: 1px solid transparent;
transition:
transform var(--motion-fast) var(--ease-standard),
background-color var(--motion-fast) var(--ease-standard),
border-color var(--motion-fast) var(--ease-standard);
text-decoration: none;
}
.btn:active { transform: translateY(1px); }
.btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.btn-primary {
background: var(--accent);
color: var(--accent-on);
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-primary:active {
background: var(--accent-active);
}
.btn-secondary {
background: transparent;
color: var(--fg);
border-color: var(--border);
}
.btn-secondary:hover {
background: var(--surface);
border-color: color-mix(in oklab, var(--border), var(--fg) 20%);
}
/* ─── Inputs ────────────────────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.field label {
font-size: var(--text-sm);
font-weight: 500;
}
.field input {
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--fg);
font-family: inherit;
font-size: var(--text-sm);
outline: none;
transition:
border-color var(--motion-fast) var(--ease-standard),
box-shadow var(--motion-fast) var(--ease-standard);
}
.field input:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.field input::placeholder { color: var(--muted); }
.field-help {
font-size: var(--text-xs);
color: var(--muted);
}
/* ─── Cards ─────────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* No left-border accent on cards — that pattern is in the
lint's P0 list (`left-accent-card`). Use a hairline border
all-around or rely on the surface contrast against --bg. */
/* ─── Badges ────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
font-size: var(--text-xs);
font-weight: 500;
line-height: 1.6;
}
.badge-success {
color: var(--success);
background: color-mix(in oklab, var(--success), transparent 90%);
}
.badge-muted {
color: var(--muted);
background: color-mix(in oklab, var(--muted), transparent 88%);
}
.badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* ─── Links ─────────────────────────────────────────────── */
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
/* ─── Kbd ───────────────────────────────────────────────── */
kbd {
font-family: var(--font-mono);
font-size: var(--text-xs);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
}
/* ─── Section-specific layout ───────────────────────────── */
.hero-grid {
display: grid;
/* Asymmetric hero — left column wider — keeps it from looking
like an AI-default centered template. */
grid-template-columns: 1.4fr 1fr;
gap: var(--space-12);
align-items: end;
}
@media (max-width: 1023px) {
.hero-grid { grid-template-columns: 1fr; gap: var(--space-8); }
}
.hero-actions {
display: flex;
gap: var(--space-3);
margin-block-start: var(--space-6);
}
.hero-meta {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-5);
}
@media (max-width: 1023px) {
.features-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 639px) {
.features-grid { grid-template-columns: 1fr; }
}
.form-row {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: var(--space-12);
align-items: start;
}
@media (max-width: 1023px) {
.form-row { grid-template-columns: 1fr; }
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
max-width: 420px;
}
.form-actions {
display: flex;
gap: var(--space-3);
margin-block-start: var(--space-2);
}
.icon { width: 16px; height: 16px; flex-shrink: 0; }
.row-between {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
</style>
</head>
<body>
<main class="container">
<!-- ════════════════════════════════════════════════════════════
HERO — exercises: h1, .lead, .eyebrow, .btn-primary,
.btn-secondary, kbd, .badge-success, container, section,
--space-* rhythm. Asymmetric grid on purpose.
═══════════════════════════════════════════════════════════════ -->
<section data-od-id="hero">
<div class="hero-grid">
<div class="stack-4">
<p class="eyebrow">Reference fixture · default</p>
<h1>One source for the values your team keeps re-deciding.</h1>
<p class="lead" style="max-width: 52ch">
A starter pack that turns brand decisions into shippable
tokens — colors, type, spacing — so every interface
inherits the same answer instead of reaching for a default.
</p>
<div class="hero-actions">
<a href="./tokens.css" class="btn btn-primary">
View tokens
<svg
class="icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</a>
<a href="./DESIGN.md" class="btn btn-secondary">Read the spec</a>
</div>
</div>
<aside class="hero-meta" aria-label="System status">
<div class="row-between">
<span class="body-sm">System status</span>
<span class="badge badge-success">
<span class="badge-dot" aria-hidden="true"></span>
Stable
</span>
</div>
<p class="body-sm body-muted">
Last reviewed
<time datetime="2026-05-11">2026-05-11</time> · v0.1
</p>
<p class="body-sm body-muted">
Press <kbd></kbd> <kbd>K</kbd> to search tokens.
</p>
</aside>
</div>
</section>
<!-- ════════════════════════════════════════════════════════════
FEATURES — exercises: h2, h3, .card, .body-muted, link,
.features-grid. Each card describes a real property of this
fixture (no "feature one/two/three" filler).
═══════════════════════════════════════════════════════════════ -->
<section data-od-id="features">
<div class="stack-3">
<p class="eyebrow">What this fixture exercises</p>
<h2 style="max-width: 28ch">
Every component below uses only var(--*) — no raw hex, no
off-token type.
</h2>
</div>
<div class="features-grid" style="margin-block-start: var(--space-8)">
<article class="card">
<h3>Surface tokens</h3>
<p class="body-muted body-sm">
--bg, --surface, --fg, --muted, --border. The whole page
derives from these five names; rebind them for any other
brand and the layout follows.
</p>
<a href="./tokens.css" class="body-sm">Inspect tokens →</a>
</article>
<article class="card">
<h3>Accent discipline</h3>
<p class="body-muted body-sm">
--accent appears at most twice on this screen — the
primary CTA and the focus ring. The hero status uses
--success instead, so the page does not feel mono-blue.
</p>
<a href="./DESIGN.md" class="body-sm">Read the rule →</a>
</article>
<article class="card">
<h3>Type rhythm</h3>
<p class="body-muted body-sm">
Display and body share Inter but differ in size, weight,
and tracking. No third type face, no fourth size on this
screen.
</p>
<a href="./tokens.css" class="body-sm">Inspect typography →</a>
</article>
</div>
</section>
<!-- ════════════════════════════════════════════════════════════
FORM — exercises: .field, input :focus-visible, .btn-primary
(reused), .btn-secondary, .field-help.
═══════════════════════════════════════════════════════════════ -->
<section data-od-id="form">
<div class="form-row">
<div class="stack-4">
<p class="eyebrow">Form components</p>
<h2>Inputs inherit the same tokens.</h2>
<p class="body-muted" style="max-width: 48ch">
Focus rings, borders, placeholder color — all derive from
--accent and --border. The submit button reuses
.btn-primary unchanged. No new token introduced for this
section.
</p>
</div>
<form class="form" onsubmit="event.preventDefault();">
<div class="field">
<label for="email">Work email</label>
<input
id="email"
type="email"
placeholder="you@team.dev"
autocomplete="email"
required
/>
<p class="field-help">
We'll send the spec PDF and nothing else.
</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Send the spec
</button>
<button type="button" class="btn btn-secondary">
Skip for now
</button>
</div>
</form>
</div>
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,200 @@
/*
* design-systems/default/tokens.css
*
* Structured token bindings for "Neutral Modern" the canonical
* starter design system. This file is the *machine-readable* form of
* the values described in `DESIGN.md`. Agents are expected to paste
* the `:root { }` block verbatim into the first `<style>` of every
* artifact they generate against this design system, then reference
* tokens via `var(--name)` from then on.
*
* Why this file exists:
* DESIGN.md gives humans context ("Accent #2F6FEB — primary CTAs"),
* but agents have to translate prose names like "Accent" into the
* standard token names the lint enforces (`--accent`). That
* translation is where token misuse happens. This file pre-translates
* the brand once, so agents copy structure instead of inventing it.
*
* Contract sources:
* - Standard token names: craft/color.md
* (--bg / --surface / --fg / --muted / --border / --accent
* / --success / --warn / --danger)
* - Display-face contract: craft/anti-ai-slop.md
* (--font-display, must be referenced via var())
* - Lint enforcement: apps/daemon/src/lint-artifact.ts
* (raw-hex >12 outside :root P1; indigo laundering P0)
*
* Schema notes (the shared schema grew from kami's stress test
* see design-systems/kami/tokens.css for the rich form, and the
* #Gap N tags below for what each addition resolves):
* #Gap 1 4-level foreground ramp (fg / fg-2 / muted / meta)
* #Gap 2 3-level surface (bg / surface / surface-warm)
* #Gap 3 2-level border (border / border-soft)
* #Gap 4 accent-hover binds to value, not formula
* #Gap 5 ring elevation as a first-class level (--elev-ring)
*
* Default doesn't differentiate every level kami needs, so the
* shallow tokens (`--fg-2`, `--meta`, `--surface-warm`, `--border-soft`)
* collapse to their richer siblings via `var()`. The names still
* exist so components can reference them uniformly across brands.
*
* Keep this file additive: never invent token names not also documented
* in DESIGN.md or the craft contracts. New brands cloning this template
* should overwrite values, not rename keys.
* */
:root {
/* Surface (3 levels #Gap 2)
* Per craft/color.md: never pure black, never pure white. Default
* uses #FAFAFA for bg and #FFFFFF for surface the cream-to-white
* contrast gives cards lift without a shadow. `--surface-warm` is a
* schema slot for brands that need a tertiary tier (kami's warm-sand
* button bg); default has no third tier and aliases it to surface. */
--bg: #fafafa;
--surface: #ffffff;
--surface-warm: var(--surface); /* alias — default has no warm tier */
/* Foreground ramp (4 levels #Gap 1)
* Default differentiates only two text levels (primary + muted).
* `--fg-2` and `--meta` are schema slots for brands with richer
* ramps (kami uses near-black / dark-warm / olive / stone). They
* alias here so components targeting the full ramp resolve. */
--fg: #111111;
--fg-2: var(--fg); /* alias — default has no secondary tier */
--muted: #6b6b6b;
--meta: var(--muted); /* alias — default has no metadata tier */
/* Border (2 levels #Gap 3)
* Default has one border weight; `--border-soft` is a schema slot
* for brands with row-separator vs card-edge differentiation. */
--border: #e5e5e5;
--border-soft: var(--border); /* alias — default has no soft tier */
/* Accent
* Cobalt primary CTAs, links, ONE hero element per screen.
* Hard cap of 2 visible uses per screen is enforced by lint
* (`accent-overuse` P1 fires at >6 inline occurrences). */
--accent: #2f6feb;
--accent-on: #ffffff; /* fg when accent is the bg (e.g. button label) */
/* Accent states (#Gap 4)
* Hover and active variants for any element using --accent as bg.
* Default's mid-luminance cobalt admits a black-mix formula
* cleanly; that's a brand-specific binding, NOT a schema rule.
* kami binds --accent-hover to var(--accent) (no color shift,
* hover via elevation) because ink-blue is too dark for further
* darkening to read; other brands with light accents must
* hand-pick.
*
* Schema rule: every brand provides --accent-hover and
* --accent-active. The binding strategy (formula / identity /
* hand-picked) is brand-decided.
*
* Why these two are tokens (and other tints stay inline):
* - cross-component (button, chip, tab, dropdown all need them)
* - cross-mode reversal (dark-mode hover should mix white, not
* black token re-binding is one line per mode)
* - cross-brand customization (formula breaks on light accents)
* - lint-enforceable contract (every brand must provide these)
* Other inline `color-mix(...)` calls in components don't yet hit
* any of the above promote them to tokens when a second use
* appears. */
--accent-hover: color-mix(in oklab, var(--accent), black 8%);
--accent-active: color-mix(in oklab, var(--accent), black 14%);
/* Semantic
* Reserved for state, not decoration. Keep total semantic-color
* pixels under 5% of the surface. */
--success: #17a34a;
--warn: #eab308;
--danger: #dc2626;
/* Typography
* Inter for display is the documented "modern minimal" override
* to anti-ai-slop's serif-display rule (see DESIGN.md
* §Visual Theme & Atmosphere "Calm, functional, quietly
* confident"). Other brands should rebind --font-display to a
* serif unless their direction is also tech/utility. */
--font-display: "Inter", -apple-system, system-ui, sans-serif;
--font-body: "Inter", -apple-system, system-ui, sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", monospace;
/* Type scale (px) — direct copy of DESIGN.md §Typography Rules */
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 20px;
--text-xl: 24px;
--text-2xl: 32px;
--text-3xl: 48px;
--text-4xl: 64px;
--leading-body: 1.5;
--leading-tight: 1.2;
--tracking-display: -0.01em; /* applied to display sizes ≥32px */
/* Spacing
* 4px base unit. Section rhythm (80/48/32) lives below as named
* tokens because DESIGN.md §Layout Principles treats them as
* breakpoint-specific decisions, not generic spacing. */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-20: 80px;
--section-y-desktop: 80px;
--section-y-tablet: 48px;
--section-y-phone: 32px;
/* Radius
* Two intents: small (button/input) and medium (card/modal).
* `--radius-pill` reserved for chips/avatars; do not use it on
* cards or buttons. */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-pill: 9999px;
/* Elevation (3 levels #Gap 5)
* Default uses two levels (flat + raised blur shadow) per its own
* DESIGN.md §Depth & Elevation. The schema gains `--elev-ring` as
* a first-class level so brands using ring shadows as primary
* elevation (kami, paper, editorial) don't need to rebind
* --elev-raised away from blur. Default declares all three; ring
* is available for hairline edges where a 1px border would shift
* layout. No fourth level that's neumorphism territory. */
--elev-flat: none;
--elev-ring: 0 0 0 1px var(--border);
--elev-raised: 0 2px 8px color-mix(in oklab, var(--fg), transparent 92%);
/* Focus ring
* Single source of truth for keyboard-focus indicators. Every
* `:focus-visible` rule on buttons, inputs, links, and tabs must
* use this token uniform behavior is itself a brand signal, and
* craft/accessibility-baseline.md treats focus visibility as a
* non-negotiable. Implemented as a `box-shadow` so it layers
* outside the element without affecting layout. */
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 70%);
/* Motion
* Two durations + one easing curve, per anti-ai-slop's "short,
* purposeful transitions (150250ms) with stable easing". Add a
* third duration only when a real interaction needs it; do not
* invent `--motion-slow` speculatively. */
--motion-fast: 150ms; /* hover, focus, micro-states */
--motion-base: 200ms; /* general state changes */
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
/* Layout
* Container width and per-breakpoint gutter. Skill-side responsive
* code reads these to decide grid columns. */
--container-max: 1200px;
--container-gutter-desktop: 24px;
--container-gutter-tablet: 16px;
--container-gutter-phone: 12px;
}

View file

@ -0,0 +1,601 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>kami — reference components</title>
<meta
name="description"
content="Reference fixture for design-systems/kami. Every visible
token comes from tokens.css; the page itself follows kami's
print-first rules — parchment background, ink-blue accent
capped at ≤5%, serif at one weight, no italic, no rgba tints,
no hard drop shadows, no cool grays."
/>
<style>
/* :root paste — sourced verbatim from
design-systems/kami/tokens.css. Keep this block in sync
when tokens.css changes. CJK overrides (`:root[lang="zh-CN"]`,
`:root[lang="ja"]`) live in tokens.css and should be pasted
only when the artifact's <html lang="..."> matches. */
:root {
--bg: #f5f4ed;
--surface: #faf9f5;
--surface-warm: #e8e6dc;
--fg: #141413;
--fg-2: #3d3d3a;
--muted: #504e49;
--meta: #6b6a64;
--border: #e8e6dc;
--border-soft: #e5e3d8;
--accent: #1b365d;
--accent-on: #faf9f5;
--accent-light: #2d5a8a;
--accent-hover: var(--accent);
--accent-active: #142a48;
--success: #4a6b3a;
--warn: #8a6b1f;
--danger: #8a3a30;
--font-display:
Charter, Georgia, Palatino, "Times New Roman", serif;
--font-body:
Charter, Georgia, Palatino, "Times New Roman", serif;
--font-mono:
"JetBrains Mono", "SF Mono", "Fira Code", Consolas, Monaco,
"TsangerJinKai02", "Source Han Serif SC", monospace;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 14px;
--text-md: 15px;
--text-lg: 17px;
--text-xl: 22px;
--text-2xl: 32px;
--text-3xl: 48px;
--text-4xl: 96px;
--leading-display: 1.1;
--leading-tight: 1.25;
--leading-body: 1.55;
--leading-dense: 1.4;
--tracking-display: -1.2px;
--tracking-eyebrow: 1.2px;
--tracking-label: 0.4px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 28px;
--space-8: 32px;
--space-12: 48px;
--space-18: 72px;
--space-22: 88px;
--section-y-desktop: 72px;
--section-y-tablet: 48px;
--section-y-phone: 32px;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-pill: 9999px;
--elev-flat: none;
--elev-ring: 0 0 0 1px var(--border);
--elev-ring-accent: 0 0 0 1px var(--accent);
--elev-raised: 0 4px 24px rgba(0, 0, 0, 0.05);
--focus-ring: 0 0 0 2px var(--accent-active);
--motion-fast: 150ms;
--motion-base: 200ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--container-max: 1120px;
--container-gutter-desktop: 64px;
--container-gutter-tablet: 32px;
--container-gutter-phone: 16px;
--tag-bg-faint: #eef2f7;
--tag-bg-soft: #e4ecf5;
--tag-bg-base: #d6e1ee;
--tag-bg-strong: #d0dce9;
}
/* ─── Reset (minimal) ───────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-body);
font-weight: 400;
-webkit-font-smoothing: antialiased;
/* Print fidelity — kami is page-first. The on-screen render
still benefits from the same color-adjust hint so background
parchment doesn't get optimized away in print preview. */
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
strong { font-weight: 500; } /* kami forbids synthetic bold (>500) */
em, i { font-style: normal; } /* kami forbids italic — keep upright */
/* ─── Layout primitives ─────────────────────────────────────── */
.container {
max-width: var(--container-max);
margin-inline: auto;
padding-inline: var(--container-gutter-desktop);
}
section {
padding-block: var(--section-y-desktop);
}
section + section {
border-top: 1px solid var(--border);
}
@media (max-width: 1023px) {
.container { padding-inline: var(--container-gutter-tablet); }
section { padding-block: var(--section-y-tablet); }
}
@media (max-width: 639px) {
.container { padding-inline: var(--container-gutter-phone); }
section { padding-block: var(--section-y-phone); }
}
/* ─── Typography ────────────────────────────────────────────── */
h1, h2, h3 {
font-family: var(--font-display);
font-weight: 500;
margin: 0;
}
h1 {
font-size: var(--text-4xl);
line-height: var(--leading-display);
letter-spacing: var(--tracking-display);
}
h2 {
font-size: var(--text-2xl);
line-height: var(--leading-tight);
letter-spacing: 0.4px;
}
h3 {
font-size: var(--text-lg);
line-height: 1.3;
}
p { margin: 0; }
.lede {
font-size: var(--text-md);
line-height: var(--leading-body);
color: var(--muted);
}
.body-muted { color: var(--muted); }
.body-meta { color: var(--meta); font-size: var(--text-sm); }
.body-sm { font-size: var(--text-sm); }
/* `.eyebrow` is the only place sans is used (and "sans" literally
equals serif here per DESIGN.md §3 — kami has no separate
sans family). Uppercase + tracking 1.2px — well above the
craft 0.06em floor. */
.eyebrow {
font-family: var(--font-display);
font-size: var(--text-sm);
font-weight: 500;
line-height: 1;
color: var(--meta);
text-transform: uppercase;
letter-spacing: var(--tracking-eyebrow);
}
/* Section number pattern — DESIGN.md §4: the number IS the
marker. No underline, no left bar, no eyebrow. Same serif
as the title, ink-blue, 14px. */
.section-num {
font-family: var(--font-display);
font-weight: 500;
font-size: var(--text-base);
color: var(--accent);
letter-spacing: var(--tracking-label);
font-variant-numeric: tabular-nums;
margin-block-end: var(--space-3);
}
.stack-3 > * + * { margin-block-start: var(--space-3); }
.stack-4 > * + * { margin-block-start: var(--space-4); }
.stack-6 > * + * { margin-block-start: var(--space-6); }
/* ─── Buttons ───────────────────────────────────────────────
* kami buttons use `box-shadow: 0 0 0 1px ...` as the edge
* instead of border, so the rectangle aligns perfectly with
* the fill. Hover lifts via whisper shadow only — no color
* shift, no transform. This is kami's elevation-led
* interaction, encoded in tokens. */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 8px 14px;
border: none;
border-radius: var(--radius-md);
font-family: var(--font-display);
font-weight: 500;
font-size: var(--text-sm);
line-height: 1;
letter-spacing: var(--tracking-label);
cursor: pointer;
text-decoration: none;
transition:
box-shadow var(--motion-base) var(--ease-standard);
}
.btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.btn-primary {
background: var(--accent);
color: var(--accent-on);
box-shadow: var(--elev-ring-accent);
}
.btn-primary:hover {
/* Layered: ring stays as edge; whisper adds lift. */
box-shadow: var(--elev-ring-accent), var(--elev-raised);
}
.btn-secondary {
background: var(--surface-warm);
color: var(--fg-2);
box-shadow: var(--elev-ring);
}
.btn-secondary:hover {
box-shadow: var(--elev-ring), var(--elev-raised);
}
.btn-ghost {
background: transparent;
color: var(--accent);
box-shadow: var(--elev-ring-accent);
}
.btn-ghost:hover {
box-shadow: var(--elev-ring-accent), var(--elev-raised);
}
/* ─── Cards ─────────────────────────────────────────────────
* Cards lift one shade above bg (ivory on parchment). 1px
* hairline border + whisper shadow on hover — no transform,
* no brightness shift. */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-7) var(--space-7) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-3);
transition: box-shadow var(--motion-base) var(--ease-standard);
}
.card:hover {
box-shadow: var(--elev-raised);
}
/* ─── Tags / chips ──────────────────────────────────────────
* Solid hex backgrounds, NEVER rgba — print renderers double-
* paint alpha. The pre-blends live as --tag-bg-* tokens.
* radius is 2px, 4px max — kami forbids pill chips with heavy
* borders. */
.tag {
display: inline-flex;
align-items: center;
font-family: var(--font-display);
font-weight: 500;
font-size: var(--text-sm);
line-height: 1;
color: var(--accent);
letter-spacing: var(--tracking-label);
padding: 4px 8px;
border-radius: var(--radius-xs);
background: var(--tag-bg-soft);
}
.tag-faint { background: var(--tag-bg-faint); }
.tag-strong {
background: var(--tag-bg-strong);
padding: 4px 10px;
border-radius: var(--radius-sm);
}
/* ─── Quote ─────────────────────────────────────────────────
* Left rule in ink-blue, olive body, 1.55 line-height. The
* single place letter-spacing 0.05em earns its keep. */
.quote {
border-left: 2px solid var(--accent);
padding: var(--space-1) 0 var(--space-1) var(--space-3);
font-family: var(--font-display);
font-weight: 400;
font-size: var(--text-md);
line-height: var(--leading-body);
color: var(--muted);
letter-spacing: 0.05em;
}
/* ─── Metric (DESIGN.md §4) ─────────────────────────────────
* Big ink-blue value with tabular-nums so columns of metrics
* align across the row; olive label below. */
.metric {
display: flex;
flex-direction: column;
gap: 2px;
}
.metric-value {
font-family: var(--font-display);
font-weight: 500;
font-size: var(--text-xl);
line-height: 1.1;
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.metric-label {
font-family: var(--font-display);
font-weight: 400;
font-size: var(--text-sm);
color: var(--muted);
}
/* ─── Dash list (DESIGN.md §4) ──────────────────────────────
* Bullets are en-dashes in ink-blue. Never filled discs. */
ul.dash {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
ul.dash li {
position: relative;
padding-left: var(--space-4);
line-height: var(--leading-body);
}
ul.dash li::before {
content: "\2013"; /* en-dash */
position: absolute;
left: 0;
color: var(--accent);
}
/* ─── Code block ────────────────────────────────────────────
* Ivory bg + hairline border + warm fg. Mono font has CJK
* fallback so labels in CJK code don't render as boxes. */
.code {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--space-3) var(--space-4);
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: var(--leading-body);
color: var(--fg);
white-space: pre;
overflow-x: auto;
}
.code .k { color: var(--accent); } /* keyword */
.code .c { color: var(--meta); } /* comment */
/* ─── Links ─────────────────────────────────────────────────
* Default link color is ink-blue. Underline on hover at
* generous offset so the rule reads as a deliberate gesture,
* not a default. */
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
/* ─── Section-specific layout ───────────────────────────── */
.hero {
padding-block: var(--space-22) var(--space-18);
}
.hero h1 { margin-block-end: var(--space-6); max-width: 14ch; }
.hero .lede { max-width: 56ch; }
.hero-actions {
display: flex;
gap: var(--space-3);
margin-block-start: var(--space-8);
}
.metric-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-12);
}
@media (max-width: 639px) {
.metric-row { grid-template-columns: 1fr 1fr; }
}
.features-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-5);
margin-block-start: var(--space-8);
}
@media (max-width: 767px) {
.features-grid { grid-template-columns: 1fr; }
}
.quote-block {
max-width: 56ch;
margin-block-start: var(--space-6);
}
</style>
</head>
<body>
<main class="container">
<!-- ════════════════════════════════════════════════════════════
HERO — exercises: section-num pattern, h1 (96px serif at
tracking -1.2px), .lede, .btn-primary (ring-shadow edge),
.btn-secondary (warm-sand bg), .tag (solid hex). The grid
is single-column intentionally — kami heroes don't use
asymmetric two-column layouts; the page IS the column.
═══════════════════════════════════════════════════════════════ -->
<section class="hero" data-od-id="hero">
<p class="section-num">00 · Reference</p>
<h1>One paper, set carefully, can hold every brand decision.</h1>
<p class="lede">
A token system distilled from the kami print rules: warm
parchment canvas, single ink-blue accent capped under five
percent of the page, serif at one weight. The fixture you
are reading uses the same token block agents paste into
every artifact.
</p>
<div class="hero-actions">
<a href="./tokens.css" class="btn btn-primary">View tokens</a>
<a href="./DESIGN.md" class="btn btn-secondary">
Read the spec
</a>
</div>
</section>
<!-- ════════════════════════════════════════════════════════════
METRICS — exercises: .metric pattern with tabular-nums
values, ink-blue at the W500 weight, olive label. Numbers
describe the fixture itself, not invented claims.
═══════════════════════════════════════════════════════════════ -->
<section data-od-id="metrics">
<p class="section-num">01</p>
<h2>Schema in numbers</h2>
<div class="metric-row" style="margin-block-start: var(--space-8)">
<div class="metric">
<div class="metric-value">42</div>
<div class="metric-label">tokens in :root</div>
</div>
<div class="metric">
<div class="metric-value">3</div>
<div class="metric-label">font stacks (EN · zh · ja)</div>
</div>
<div class="metric">
<div class="metric-value">≤5%</div>
<div class="metric-label">accent surface cap</div>
</div>
</div>
</section>
<!-- ════════════════════════════════════════════════════════════
FEATURE CARDS — exercises: .card (ivory bg, hairline
border, whisper shadow on hover), h3, body text, dash list,
tag (solid hex bg).
═══════════════════════════════════════════════════════════════ -->
<section data-od-id="components">
<p class="section-num">02</p>
<h2>What this fixture exercises</h2>
<div class="features-grid">
<article class="card">
<span class="tag tag-faint">Surface</span>
<h3>Three-tier surface ramp</h3>
<p class="body-muted body-sm">
Parchment for the page, ivory for cards, warm sand for
secondary buttons. Cards lift one half-shade above the
page; the contrast is what gives them edge without a
shadow.
</p>
<ul class="dash body-sm">
<li>--bg covers the page</li>
<li>--surface lifts containers</li>
<li>--surface-warm fills secondary controls</li>
</ul>
</article>
<article class="card">
<span class="tag">Accent</span>
<h3>One color, two states</h3>
<p class="body-muted body-sm">
Ink blue is the only chromatic move. Hover holds the
same color — kami expresses lift through elevation, not
brightness. Active darkens by a hand-picked value, not a
formula.
</p>
<ul class="dash body-sm">
<li>--accent appears at most twice on this page</li>
<li>--accent-hover identical to --accent</li>
<li>Active state is a hand-picked deeper ink</li>
</ul>
</article>
<article class="card">
<span class="tag tag-strong">Type</span>
<h3>One weight does the work</h3>
<p class="body-muted body-sm">
Serif at weight 500 carries every heading. There is no
bold, no italic, no second face. Hierarchy comes from
size, tracking, and color — never from another type
family.
</p>
<ul class="dash body-sm">
<li>--font-display equals --font-body equals serif</li>
<li>Display tracking compresses (-1.2px at 96px)</li>
<li>Eyebrow tracking opens (1.2px on uppercase)</li>
</ul>
</article>
<article class="card">
<span class="tag">Elevation</span>
<h3>Ring before whisper before drop</h3>
<p class="body-muted body-sm">
Three sanctioned levels. A 1px hairline ring is the
default edge; a 4-24-rgba whisper appears only on hover.
The schema reserves --elev-ring as a first-class level so
brands like this one don't have to misuse --elev-raised
for hairlines.
</p>
</article>
</div>
</section>
<!-- ════════════════════════════════════════════════════════════
QUOTE + CODE — exercises: .quote (left rule in ink-blue),
.code with .k / .c spans, link.
═══════════════════════════════════════════════════════════════ -->
<section data-od-id="quote">
<p class="section-num">03</p>
<h2>The token block, in full</h2>
<div class="quote-block">
<blockquote class="quote">
kami is not a UI framework. It is a constraint system for
the page, designed to keep deliverables stable, clear, and
unmistakably printed.
</blockquote>
</div>
<pre class="code" style="margin-block-start: var(--space-8)"><span class="c">/* Paste this block into the first &lt;style&gt; of every kami artifact. */</span>
<span class="k">:root</span> {
<span class="c">/* Surface */</span>
<span class="k">--bg</span>: <span>#f5f4ed</span>;
<span class="k">--surface</span>: <span>#faf9f5</span>;
<span class="k">--accent</span>: <span>#1b365d</span>;
<span class="c">/* … 38 more, see tokens.css */</span>
}</pre>
<p class="body-meta" style="margin-block-start: var(--space-4)">
Full reference at
<a href="./tokens.css">tokens.css</a> ·
spec at
<a href="./DESIGN.md">DESIGN.md</a>
</p>
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,272 @@
/*
* design-systems/kami/tokens.css
*
* Structured token bindings for "kami / 紙 / 纸" a print-first
* editorial system: warm parchment canvas, single ink-blue accent,
* serif at one weight (500), no italic, no cool grays.
*
* This file pre-compiles the values described in `DESIGN.md` into
* the schema shared with all OD design systems. Two paste paths:
*
* 1. Agents generating an artifact for kami should paste the
* :root block verbatim into the first <style> of the artifact,
* then reference everything via var(--*).
* 2. The optional `:root[lang="zh-CN"]` and `:root[lang="ja"]`
* blocks below override --font-display / --font-body for
* Chinese and Japanese typesetting. Include them only when
* the artifact's <html lang="..."> matches; otherwise drop
* to keep the paste minimal.
*
* Schema notes (kami pushed the schema in five places see comments
* inline tagged "#Gap N" for the matching item in the design log):
* #Gap 1 4-level foreground ramp (--fg / --fg-2 / --muted / --meta)
* #Gap 2 3-level surface (--bg / --surface / --surface-warm)
* #Gap 3 2-level border (--border / --border-soft)
* #Gap 4 accent-hover binds to value, not formula
* #Gap 5 ring elevation as a first-class level (--elev-ring)
*
* Brand-specific extensions (NOT part of the shared schema, only in
* kami because of print-fidelity needs see DESIGN.md §2 "Tag tints"):
* --tag-bg-soft / --tag-bg-base / --tag-bg-strong solid-hex
* pre-blends of ink-blue over parchment, replacing rgba/color-mix
* tints because print renderers double-paint alpha fills.
* */
:root {
/* Surface (3 levels #Gap 2)
* Warm parchment canvas; cards lift one half-shade brighter to
* ivory; secondary interactive surfaces drop to warm-sand. Never
* #ffffff anywhere the cream tone *is* kami's identity.
* `--surface-warm` is new in the shared schema; brands without a
* tertiary surface tier should alias it to var(--surface). */
--bg: #f5f4ed; /* parchment — page background */
--surface: #faf9f5; /* ivory — cards, lifted containers */
--surface-warm: #e8e6dc; /* warm sand — default button bg, secondary surfaces */
/* Foreground ramp (4 levels #Gap 1)
* kami's text uses four named levels primary / secondary /
* subtext / metadata. Cool blue-grays are forbidden everywhere;
* each token below has the warm yellow-brown undertone (R G > B)
* the brand requires.
*
* `--fg-2` and `--meta` are new in the shared schema; brands that
* only differentiate two levels should alias these to var(--fg)
* and var(--muted). */
--fg: #141413; /* near-black — primary text, slight olive warmth */
--fg-2: #3d3d3a; /* dark warm — secondary text, table headers */
--muted: #504e49; /* olive — subtext, captions */
--meta: #6b6a64; /* stone — tertiary, dates, metadata */
/* Border (2 levels #Gap 3)
* Primary border for card edges and section dividers; soft border
* for inner row separators that should not visually compete.
* `--border-soft` is new in the shared schema; brands with one
* border tier should alias it to var(--border). */
--border: #e8e6dc;
--border-soft: #e5e3d8;
/* Accent
* The single chromatic move. CTAs, section numbers, link text,
* the left rule of a quote, the W500 weight in a metric. Hard
* cap of 5% of any surface area (DESIGN.md §2). */
--accent: #1b365d; /* ink blue */
--accent-on: #faf9f5; /* ivory — fg when accent is the bg (NOT pure white) */
--accent-light: #2d5a8a; /* brighter variant — links on dark surfaces only */
/* Accent states (#Gap 4)
* kami treats --accent-hover and --accent-active as VALUE
* bindings, not formula derivations. Ink blue is already deep;
* mixing further black makes hover invisible. The brand instead
* expresses hover through elevation (whisper shadow) and keeps
* the color identical. Active darkens slightly via a hand-picked
* value, not a formula.
*
* Schema rule: every brand provides --accent-hover and
* --accent-active. Default's brand uses a black-mix formula;
* kami uses identity / hand-picked. Both satisfy the contract. */
--accent-hover: var(--accent); /* color-stable; hover via elevation */
--accent-active: #142a48; /* hand-picked, ~12% darker ink */
/* Semantic
* kami's DESIGN.md doesn't specify success/warn/danger because the
* system is print-first and rarely renders status. We bind them to
* desaturated warm hues that survive parchment without breaking
* the "single chromatic move" rule. Use sparingly; kami artifacts
* almost never need these. */
--success: #4a6b3a; /* warm forest — toned down for parchment */
--warn: #8a6b1f; /* burnt sienna */
--danger: #8a3a30; /* warm terracotta — never tailwind red */
/* Typography
* Default to English Charter stack on :root. The `:root[lang]`
* blocks below override for CN and JA. `--font-body` and
* `--font-display` are equal kami uses a single serif weight
* (500) and lets size carry hierarchy.
*
* Sans is reserved for eyebrows, switchers, small labels it
* literally equals the serif stack, so there is no separate
* sans token. */
--font-display:
Charter, Georgia, Palatino, "Times New Roman", serif;
--font-body:
Charter, Georgia, Palatino, "Times New Roman", serif;
--font-mono:
"JetBrains Mono", "SF Mono", "Fira Code", Consolas, Monaco,
"TsangerJinKai02", "Source Han Serif SC", monospace;
/* Type scale (px) derived from DESIGN.md §3 hierarchy table.
* kami runs lighter on the small end and heavier on the display
* end than default's 1264 print rhythm needs both 11px captions
* and 96px hero display.
*
* `--text-md` (15px) is a brand-specific extension kami needs an
* in-between size for ledes that the schema's --text-base (body)
* and --text-lg (H3) ramp doesn't supply. Generic cross-brand
* components must NOT reference --text-md; only kami's own
* components.html consumes it. Promote to schema if a second
* brand reports the same need. */
--text-xs: 11px;
--text-sm: 12px;
--text-base: 14px; /* body — print-dense */
--text-md: 15px; /* brand-specific — lede / large body */
--text-lg: 17px; /* H3 */
--text-xl: 22px; /* H2 */
--text-2xl: 32px; /* section title */
--text-3xl: 48px; /* CJK display ceiling */
--text-4xl: 96px; /* EN hero / cover slide title */
/* kami's line-height envelope is 1.101.55, narrower than default's */
--leading-display: 1.1; /* hero, H1, H2 */
--leading-tight: 1.25; /* section title */
--leading-body: 1.55; /* reading body */
--leading-dense: 1.4; /* resume / one-pager */
/* Letter-spacing rules from DESIGN.md §3. EN body is 0; CN/JA
* override below. Display tracking is negative (compresses
* 96px hero); eyebrow tracking is positive (uppercase labels). */
--tracking-display: -1.2px; /* applied to 96px hero only */
--tracking-eyebrow: 1.2px; /* uppercase eyebrow / overline */
--tracking-label: 0.4px; /* small uppercase labels */
/* Spacing
* kami is print-rooted; section gaps and card paddings come from
* the layout grid in DESIGN.md §5, not the 4px web-grid that
* default uses. We keep the same token names but rebind values
* to kami's actual rhythm. */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 28px; /* card interior */
--space-8: 32px;
--space-12: 48px;
--space-18: 72px; /* section gap (web) */
--space-22: 88px; /* page top padding (web) */
/* Web page geometry (DESIGN.md §5) */
--section-y-desktop: 72px;
--section-y-tablet: 48px;
--section-y-phone: 32px;
/* Radius
* `2px 4px 6px 8px 12px 16px` per DESIGN.md §6. Tags
* sit at 24px; buttons and cards at 8px; featured at 1216px. */
--radius-xs: 2px; /* tags */
--radius-sm: 4px; /* small chips */
--radius-md: 8px; /* default — buttons, cards */
--radius-lg: 12px; /* featured cards */
--radius-xl: 16px; /* hero containers */
--radius-pill: 9999px; /* avatars (rare) */
/* Elevation (3 levels #Gap 5)
* kami forbids hard drop shadows. Three sanctioned levels:
* flat / ring (1px hairline OR 1px brand ring) / whisper. The
* shared schema gains `--elev-ring` so brands using ring shadows
* as primary elevation (kami, paper, editorial) don't have to
* rebind --elev-raised away from blur shadow.
*
* `--elev-ring-accent` is brand-specific kami uses it as the
* edge for primary buttons (`box-shadow: var(--elev-ring-accent)`
* instead of border, to keep the rectangle perfectly aligned
* with the fill). */
--elev-flat: none;
--elev-ring: 0 0 0 1px var(--border);
--elev-ring-accent: 0 0 0 1px var(--accent); /* brand-specific */
--elev-raised: 0 4px 24px rgba(0, 0, 0, 0.05); /* whisper */
/* Focus ring
* kami's focus is more restrained than default's the brand
* forbids cool-blue glows. We use a smaller ring with the active
* variant of accent. */
--focus-ring: 0 0 0 2px var(--accent-active);
/* Motion
* kami's hover is a 200ms whisper-shadow lift only no transform,
* no brightness shift. Two durations, one easing. */
--motion-fast: 150ms;
--motion-base: 200ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
/* Layout
* Web container: 1120px max, 64px gutter desktop. Print pages
* have margin tokens specific to document type (resume / one-pager
* / long-doc / letter / portfolio) those live in the print
* skill, not in this shared token block. */
--container-max: 1120px;
--container-gutter-desktop: 64px;
--container-gutter-tablet: 32px;
--container-gutter-phone: 16px;
/* Brand-specific: tag tint scale (#Gap 6)
* Print renderers double-paint alpha fills. kami pre-blends ink
* blue over parchment as solid hex at 5 effective alphas. These
* tokens are NOT part of the shared schema; they live here only
* because kami's rendering target needs them. Other brands tint
* with `color-mix(... transparent N%)` inline. */
--tag-bg-faint: #eef2f7; /* ≈ ink at 0.08 */
--tag-bg-soft: #e4ecf5; /* ≈ ink at 0.18 — DEFAULT tag */
--tag-bg-base: #d6e1ee; /* ≈ ink at 0.30 */
--tag-bg-strong: #d0dce9; /* ≈ ink at 0.22, denser */
}
/* i18n font overrides (#Gap 7)
* Switch the dominant font stack based on the artifact's lang.
* Apply ONLY ONE of these blocks the one matching <html lang="...">
* and keep --font-mono on the EN stack since code labels stay Latin
* regardless of the document language.
*
* For multi-language artifacts (e.g. a deck with one Japanese chapter),
* keep the dominant stack on :root and scope the override on a
* wrapper element instead:
*
* section[lang="ja"] { --font-display: <JA stack>; ... }
*
* Do NOT chain all three font families inside a single font-family
* declaration that dilutes the visual character of every page.
* */
:root[lang="zh-CN"],
:root[lang="zh"] {
--font-display:
"TsangerJinKai02", "Source Han Serif SC", "Noto Serif CJK SC",
"Songti SC", "STSong", Georgia, serif;
--font-body:
"TsangerJinKai02", "Source Han Serif SC", "Noto Serif CJK SC",
"Songti SC", "STSong", Georgia, serif;
}
:root[lang="ja"] {
--font-display:
"YuMincho", "Yu Mincho", "Hiragino Mincho ProN",
"Noto Serif CJK JP", "Source Han Serif JP",
"TsangerJinKai02", Georgia, serif;
--font-body:
"YuMincho", "Yu Mincho", "Hiragino Mincho ProN",
"Noto Serif CJK JP", "Source Han Serif JP",
"TsangerJinKai02", Georgia, serif;
/* JA needs the lighter olive override YuMincho strokes are thinner
* and the standard olive looks anemic against parchment. */
--muted: #4d4c48;
}

View file

@ -198,7 +198,13 @@ async function readDesignSystemResources(): Promise<DesignSystemResource[]> {
const entries = await readdir(systemsRoot, { withFileTypes: true });
const resources = await Promise.all(
entries
.filter((entry) => entry.isDirectory())
// Skip meta-directories whose names begin with `_` (e.g. `_schema/`,
// which holds the shared token contract — not a brand). This mirrors
// the leading-underscore-is-meta convention used by Jekyll, Hugo,
// SCSS partials, etc. The daemon's listDesignSystems already filters
// these out implicitly (it requires DESIGN.md); doing the same here
// keeps the localized-content guard aligned with the runtime registry.
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('_'))
.map(async (entry) => {
assertResourceId(entry.name, `Design system directory ${entry.name}`);
const filePath = path.join(systemsRoot, entry.name, 'DESIGN.md');

View file

@ -0,0 +1,506 @@
/*
* scripts/check-tokens-fixture-sync.ts
*
* Guard checks that enforce the design-system token contract.
*
* The shared schema lives in `design-systems/_schema/tokens.schema.ts`;
* its A2 fallback values mirror into `design-systems/_schema/defaults.css`.
* Every brand under `design-systems/<brand>/` ships two consumer-facing
* artifacts:
*
* - tokens.css canonical token bindings (`:root { ... }`)
* - components.html self-contained fixture whose first <style>
* embeds the same `:root` so the file renders
* standalone in any browser.
*
* This file exports six check functions, each registered as its own
* entry in `pnpm guard` so failures attribute to a specific contract.
*
* 1. checkDesignSystemTokenFixtureSync
* components.html `:root` is byte-equivalent to tokens.css
* `:root` after canonical normalization.
*
* 2. checkDesignSystemA1RequiredTokens
* Every brand declares every A1-identity / A1-structure token
* from the schema. Missing fail.
*
* 3. checkDesignSystemA2RequiredTokens
* Every brand declares every A2 token from the schema. Missing
* fail (until the derive script lands; see _schema/AGENTS.md).
*
* 4. checkDesignSystemBSlotRequiredTokens
* Every brand declares every B-slot token. The brand may bind
* independently or alias the named sibling via `var(...)`, but
* it must appear in `:root`; artifacts paste a single `:root`
* block, so a missing slot resolves to nothing at runtime.
*
* 5. checkDesignSystemUnknownTokens
* Every token a brand declares is either in the shared schema
* or explicitly allowed by `BRAND_EXTENSIONS` /
* `BRAND_EXTENSION_PREFIXES`. Stray names fail.
*
* 6. checkDesignSystemA2DefaultsParity
* Each A2 declaration in `_schema/defaults.css` matches the
* `fallback` field on the matching entry in `tokens.schema.ts`.
*
* Run standalone: `pnpm exec tsx scripts/check-tokens-fixture-sync.ts`
* Or as part of `pnpm guard` (registered in scripts/guard.ts).
* */
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
BRAND_EXTENSIONS,
BRAND_EXTENSION_PREFIXES,
TOKEN_SCHEMA,
getAllSchemaNames,
getBSlotNames,
getRequiredA1Names,
getRequiredA2Names,
isAllowedExtension,
} from "../design-systems/_schema/tokens.schema.ts";
const repoRoot = path.resolve(import.meta.dirname, "..");
const designSystemsRoot = path.join(repoRoot, "design-systems");
const schemaRoot = path.join(designSystemsRoot, "_schema");
const defaultsCssPath = path.join(schemaRoot, "defaults.css");
const SKIPPED_BRAND_DIRECTORIES = new Set(["_schema"]);
function toRepositoryPath(filePath: string): string {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
// ─── CSS parsing utilities ──────────────────────────────────────────
function stripCssComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, "");
}
function extractUnscopedRootBlockBody(commentlessCss: string): string | null {
const match = commentlessCss.match(/:root(?!\[)\s*\{([\s\S]*?)\}/);
return match == null ? null : (match[1] ?? null);
}
function canonicalizeRootBlockBody(body: string): string {
const declarations = body
.split(";")
.map((decl) =>
decl
.trim()
.replace(/\s+/g, " ")
.replace(/\s*:\s*/, ": "),
)
.filter((decl) => decl.length > 0);
return declarations.map((decl) => `${decl};`).join("\n");
}
/** Parse a normalized `:root` body into a name→value map for tokens (--*). */
function parseTokenDeclarations(commentlessRootBody: string): Map<string, string> {
const declarations = new Map<string, string>();
for (const rawDecl of commentlessRootBody.split(";")) {
const decl = rawDecl.trim();
if (decl.length === 0) continue;
const colonIndex = decl.indexOf(":");
if (colonIndex === -1) continue;
const name = decl.slice(0, colonIndex).trim();
if (!name.startsWith("--")) continue;
const value = decl
.slice(colonIndex + 1)
.trim()
.replace(/\s+/g, " ");
declarations.set(name, value);
}
return declarations;
}
/** Normalize a CSS expression for byte-level comparison. */
function normalizeCssValue(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
// ─── Brand discovery ────────────────────────────────────────────────
type BrandSources = {
brand: string;
tokensPath: string;
fixturePath: string;
tokensCss: string;
fixtureHtml: string;
};
async function fileExists(filePath: string): Promise<boolean> {
try {
await readFile(filePath, "utf8");
return true;
} catch {
return false;
}
}
type BrandDiscovery = { sources: BrandSources[]; pairingErrors: string[] };
async function discoverBrandSources(): Promise<BrandDiscovery> {
let designSystemEntries;
try {
designSystemEntries = await readdir(designSystemsRoot, { withFileTypes: true });
} catch {
return { sources: [], pairingErrors: [] };
}
const sources: BrandSources[] = [];
const pairingErrors: string[] = [];
for (const entry of designSystemEntries) {
if (!entry.isDirectory()) continue;
if (SKIPPED_BRAND_DIRECTORIES.has(entry.name)) continue;
const brand = entry.name;
const brandRoot = path.join(designSystemsRoot, brand);
const tokensPath = path.join(brandRoot, "tokens.css");
const fixturePath = path.join(brandRoot, "components.html");
const [tokensExists, fixtureExists] = await Promise.all([fileExists(tokensPath), fileExists(fixturePath)]);
if (!tokensExists && !fixtureExists) continue;
if (tokensExists !== fixtureExists) {
const present = tokensExists ? tokensPath : fixturePath;
const missing = tokensExists ? fixturePath : tokensPath;
pairingErrors.push(
`${toRepositoryPath(present)} exists but ${toRepositoryPath(missing)} does not — ` +
`token / fixture pairs must travel together so agents always have both the values and a working example.`,
);
continue;
}
const [tokensCss, fixtureHtml] = await Promise.all([readFile(tokensPath, "utf8"), readFile(fixturePath, "utf8")]);
sources.push({ brand, tokensPath, fixturePath, tokensCss, fixtureHtml });
}
sources.sort((a, b) => a.brand.localeCompare(b.brand));
return { sources, pairingErrors };
}
function reportFailure(checkLabel: string, violations: string[], remediation?: string): boolean {
if (violations.length === 0) return true;
console.error(`${checkLabel} violations:`);
for (const violation of violations) {
console.error(`- ${violation}`);
}
if (remediation != null) console.error(remediation);
return false;
}
// ─── 1. Sync between tokens.css and components.html ──────────────────
function describeFirstDivergence(canonicalTokens: string, canonicalFixture: string): string {
const tokenLines = canonicalTokens.split("\n");
const fixtureLines = canonicalFixture.split("\n");
const longest = Math.max(tokenLines.length, fixtureLines.length);
for (let index = 0; index < longest; index += 1) {
if (tokenLines[index] !== fixtureLines[index]) {
const left = tokenLines[index] ?? "(missing — fixture has extra declarations beyond tokens.css)";
const right = fixtureLines[index] ?? "(missing — tokens.css has extra declarations beyond fixture)";
return [
` first divergence at declaration ${index + 1}:`,
` tokens.css → ${left}`,
` components.html → ${right}`,
].join("\n");
}
}
return " declarations align by index but the canonical strings still differ — inspect manually";
}
export async function checkDesignSystemTokenFixtureSync(): Promise<boolean> {
const { sources, pairingErrors } = await discoverBrandSources();
const violations = [...pairingErrors];
let pairsChecked = 0;
for (const { brand, tokensPath, fixturePath, tokensCss, fixtureHtml } of sources) {
const tokensRootBody = extractUnscopedRootBlockBody(stripCssComments(tokensCss));
const fixtureRootBody = extractUnscopedRootBlockBody(stripCssComments(fixtureHtml));
if (tokensRootBody == null) {
violations.push(`${toRepositoryPath(tokensPath)} contains no \`:root { ... }\` rule.`);
continue;
}
if (fixtureRootBody == null) {
violations.push(
`${toRepositoryPath(fixturePath)} contains no \`:root { ... }\` rule — fixture must paste the canonical token bindings into a <style>.`,
);
continue;
}
const canonicalTokens = canonicalizeRootBlockBody(tokensRootBody);
const canonicalFixture = canonicalizeRootBlockBody(fixtureRootBody);
pairsChecked += 1;
if (canonicalTokens !== canonicalFixture) {
violations.push(
[
`[${brand}] ${toRepositoryPath(fixturePath)} :root drifted from ${toRepositoryPath(tokensPath)} :root.`,
describeFirstDivergence(canonicalTokens, canonicalFixture),
` Re-paste the canonical block from tokens.css (declarations only — comments and whitespace are normalized).`,
].join("\n"),
);
}
}
const passed = reportFailure(
"Design system token-fixture sync",
violations,
"Each design-systems/<brand>/components.html must keep its first `:root { ... }` block byte-equivalent (after comment / whitespace normalization) to the same brand's tokens.css `:root` block.",
);
if (passed) {
console.log(
`Design system token-fixture sync passed: ${pairsChecked} brand pair${pairsChecked === 1 ? "" : "s"} aligned (components.html :root matches tokens.css :root).`,
);
}
return passed;
}
// ─── 2. A1 required tokens ──────────────────────────────────────────
export async function checkDesignSystemA1RequiredTokens(): Promise<boolean> {
const { sources } = await discoverBrandSources();
const requiredA1 = getRequiredA1Names();
const violations: string[] = [];
for (const { brand, tokensPath, tokensCss } of sources) {
const rootBody = extractUnscopedRootBlockBody(stripCssComments(tokensCss));
if (rootBody == null) continue; // sync check covers this case
const declared = parseTokenDeclarations(rootBody);
const missing = requiredA1.filter((name) => !declared.has(name));
if (missing.length > 0) {
violations.push(
`[${brand}] ${toRepositoryPath(tokensPath)} is missing ${missing.length} A1 token${missing.length === 1 ? "" : "s"} (brand identity / structure must be explicit per brand):\n ${missing.join(", ")}`,
);
}
}
const passed = reportFailure(
"Design system A1 required tokens",
violations,
"A1 tokens (identity + structure) have no defensible cross-brand fallback. Every brand must declare them explicitly. See design-systems/_schema/AGENTS.md for the layer model.",
);
if (passed) {
console.log(
`Design system A1 required tokens passed: ${sources.length} brand${sources.length === 1 ? "" : "s"} declare all ${requiredA1.length} A1 tokens.`,
);
}
return passed;
}
// ─── 3. A2 required tokens ──────────────────────────────────────────
export async function checkDesignSystemA2RequiredTokens(): Promise<boolean> {
const { sources } = await discoverBrandSources();
const requiredA2 = getRequiredA2Names();
const violations: string[] = [];
for (const { brand, tokensPath, tokensCss } of sources) {
const rootBody = extractUnscopedRootBlockBody(stripCssComments(tokensCss));
if (rootBody == null) continue;
const declared = parseTokenDeclarations(rootBody);
const missing = requiredA2.filter((name) => !declared.has(name));
if (missing.length > 0) {
violations.push(
`[${brand}] ${toRepositoryPath(tokensPath)} is missing ${missing.length} A2 token${missing.length === 1 ? "" : "s"} (default values exist in design-systems/_schema/defaults.css; copy or override):\n ${missing.join(", ")}`,
);
}
}
const passed = reportFailure(
"Design system A2 required tokens",
violations,
"A2 tokens carry sensible cross-brand defaults but artifacts paste a single :root block — agents that paste a tokens.css missing an A2 declaration will produce broken artifacts. Every brand's tokens.css must declare every A2 token (until the derive script lands and inlines fallbacks automatically).",
);
if (passed) {
console.log(
`Design system A2 required tokens passed: ${sources.length} brand${sources.length === 1 ? "" : "s"} declare all ${requiredA2.length} A2 tokens.`,
);
}
return passed;
}
// ─── 4. B-slot required tokens ──────────────────────────────────────
export async function checkDesignSystemBSlotRequiredTokens(): Promise<boolean> {
const { sources } = await discoverBrandSources();
const bSlotNames = getBSlotNames();
const violations: string[] = [];
for (const { brand, tokensPath, tokensCss } of sources) {
const rootBody = extractUnscopedRootBlockBody(stripCssComments(tokensCss));
if (rootBody == null) continue;
const declared = parseTokenDeclarations(rootBody);
const missing = bSlotNames.filter((name) => !declared.has(name));
if (missing.length > 0) {
const hints = missing
.map((name) => {
const spec = TOKEN_SCHEMA.find((t) => t.name === name);
return spec?.aliasTo != null ? `${name} (default alias: ${spec.aliasTo})` : name;
})
.join(", ");
violations.push(
`[${brand}] ${toRepositoryPath(tokensPath)} is missing ${missing.length} B-slot token${missing.length === 1 ? "" : "s"} (alias the named sibling via var(...) or bind independently):\n ${hints}`,
);
}
}
const passed = reportFailure(
"Design system B-slot required tokens",
violations,
"B-slot tokens (--fg-2, --meta, --surface-warm, --border-soft) let shared components target richer tiers without forking. Artifacts paste a single :root block — a missing slot resolves to nothing at runtime, so every brand must declare every B-slot, either as `var(--sibling)` (collapsed brand) or an independent value (richer brand). See design-systems/_schema/AGENTS.md.",
);
if (passed) {
console.log(
`Design system B-slot required tokens passed: ${sources.length} brand${sources.length === 1 ? "" : "s"} declare all ${bSlotNames.length} B-slot tokens.`,
);
}
return passed;
}
// ─── 5. Unknown token allowlist ─────────────────────────────────────
export async function checkDesignSystemUnknownTokens(): Promise<boolean> {
const { sources } = await discoverBrandSources();
const schemaNames = new Set(getAllSchemaNames());
const violations: string[] = [];
for (const { brand, tokensPath, tokensCss } of sources) {
const rootBody = extractUnscopedRootBlockBody(stripCssComments(tokensCss));
if (rootBody == null) continue;
const declared = parseTokenDeclarations(rootBody);
const unknown: string[] = [];
for (const name of declared.keys()) {
if (schemaNames.has(name)) continue;
if (isAllowedExtension(brand, name)) continue;
unknown.push(name);
}
if (unknown.length > 0) {
violations.push(
`[${brand}] ${toRepositoryPath(tokensPath)} declares ${unknown.length} unknown token${unknown.length === 1 ? "" : "s"} (not in shared schema, not in BRAND_EXTENSIONS["${brand}"], not matching any BRAND_EXTENSION_PREFIXES):\n ${unknown.join(", ")}`,
);
}
}
const passed = reportFailure(
"Design system unknown token allowlist",
violations,
'Every token must be declared in design-systems/_schema/tokens.schema.ts (shared schema), or listed in BRAND_EXTENSIONS["<brand>"] (brand-specific), or match a prefix in BRAND_EXTENSION_PREFIXES. See _schema/AGENTS.md for the C → B-slot → A2 promotion path before adding new shared tokens.',
);
if (passed) {
const totalTokens = sources.reduce((sum, source) => {
const body = extractUnscopedRootBlockBody(stripCssComments(source.tokensCss));
return sum + (body == null ? 0 : parseTokenDeclarations(body).size);
}, 0);
console.log(
`Design system unknown token allowlist passed: ${totalTokens} declarations across ${sources.length} brand${sources.length === 1 ? "" : "s"} all match shared schema or brand extensions.`,
);
}
return passed;
}
// ─── 6. A2 defaults parity (schema fallback ↔ defaults.css) ─────────
export async function checkDesignSystemA2DefaultsParity(): Promise<boolean> {
let defaultsCss: string;
try {
defaultsCss = await readFile(defaultsCssPath, "utf8");
} catch {
return reportFailure(
"Design system A2 defaults parity",
[`${toRepositoryPath(defaultsCssPath)} does not exist — A2 fallback contract requires a CSS mirror of tokens.schema.ts.`],
);
}
const rootBody = extractUnscopedRootBlockBody(stripCssComments(defaultsCss));
if (rootBody == null) {
return reportFailure(
"Design system A2 defaults parity",
[`${toRepositoryPath(defaultsCssPath)} contains no \`:root { ... }\` rule.`],
);
}
const declared = parseTokenDeclarations(rootBody);
const violations: string[] = [];
const a2Specs = TOKEN_SCHEMA.filter((spec) => spec.layer === "A2");
for (const spec of a2Specs) {
const fallback = spec.fallback;
if (fallback == null) {
violations.push(
`tokens.schema.ts entry ${spec.name} has layer "A2" but no \`fallback\` field — every A2 token must specify the value the derive script will inline.`,
);
continue;
}
const actual = declared.get(spec.name);
if (actual == null) {
violations.push(
`${toRepositoryPath(defaultsCssPath)} is missing a declaration for ${spec.name} (schema fallback is \`${fallback}\`).`,
);
continue;
}
if (normalizeCssValue(actual) !== normalizeCssValue(fallback)) {
violations.push(
[
`${spec.name} drifted between schema and defaults.css:`,
` tokens.schema.ts → ${normalizeCssValue(fallback)}`,
` defaults.css → ${normalizeCssValue(actual)}`,
].join("\n"),
);
}
}
const a2Names = new Set(a2Specs.map((spec) => spec.name));
for (const declaredName of declared.keys()) {
if (!a2Names.has(declaredName)) {
violations.push(
`${toRepositoryPath(defaultsCssPath)} declares ${declaredName}, which is not an A2 token in tokens.schema.ts. defaults.css mirrors only A2 fallbacks.`,
);
}
}
const passed = reportFailure(
"Design system A2 defaults parity",
violations,
"Update both tokens.schema.ts and defaults.css together. defaults.css exists as a human-readable mirror of A2 fallback fields and is the future input to the derive script.",
);
if (passed) {
console.log(
`Design system A2 defaults parity passed: ${a2Specs.length} A2 fallback${a2Specs.length === 1 ? "" : "s"} match tokens.schema.ts ↔ defaults.css byte-for-byte.`,
);
}
return passed;
}
// ─── Standalone entrypoint ───────────────────────────────────────────
const isInvokedDirectly = process.argv[1] != null && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isInvokedDirectly) {
const checks = [
checkDesignSystemTokenFixtureSync,
checkDesignSystemA1RequiredTokens,
checkDesignSystemA2RequiredTokens,
checkDesignSystemBSlotRequiredTokens,
checkDesignSystemUnknownTokens,
checkDesignSystemA2DefaultsParity,
];
const results = await Promise.all(checks.map((check) => check()));
if (results.some((passed) => !passed)) {
process.exitCode = 1;
}
}

View file

@ -1,6 +1,15 @@
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import {
checkDesignSystemA1RequiredTokens,
checkDesignSystemA2DefaultsParity,
checkDesignSystemA2RequiredTokens,
checkDesignSystemBSlotRequiredTokens,
checkDesignSystemTokenFixtureSync,
checkDesignSystemUnknownTokens,
} from "./check-tokens-fixture-sync.ts";
const repoRoot = path.resolve(import.meta.dirname, "..");
const allowedE2eScripts = new Set([
"e2e/scripts/playwright.ts",
@ -416,6 +425,12 @@ const checks: GuardCheck[] = [
{ name: "e2e layout", run: checkE2eLayout },
{ name: "web test layout", run: checkWebTestLayout },
{ name: "tools layout", run: checkToolsLayout },
{ name: "design system token-fixture sync", run: checkDesignSystemTokenFixtureSync },
{ name: "design system A1 required tokens", run: checkDesignSystemA1RequiredTokens },
{ name: "design system A2 required tokens", run: checkDesignSystemA2RequiredTokens },
{ name: "design system B-slot required tokens", run: checkDesignSystemBSlotRequiredTokens },
{ name: "design system unknown token allowlist", run: checkDesignSystemUnknownTokens },
{ name: "design system A2 defaults parity", run: checkDesignSystemA2DefaultsParity },
];
const results: boolean[] = [];