Files
a75d9938c7 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>
2026-05-11 22:23:34 +08:00

201 lines
11 KiB
CSS
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ─────────────────────────────────────────────────────────────────────────
* 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 (150–250ms) 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;
}