mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
87a95b7fb4
commit
a75d9938c7
10 changed files with 2675 additions and 1 deletions
179
design-systems/_schema/AGENTS.md
Normal file
179
design-systems/_schema/AGENTS.md
Normal 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`.
|
||||
100
design-systems/_schema/defaults.css
Normal file
100
design-systems/_schema/defaults.css
Normal 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 (150–250ms) 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);
|
||||
}
|
||||
272
design-systems/_schema/tokens.schema.ts
Normal file
272
design-systems/_schema/tokens.schema.ts
Normal 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 C→B→A 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 (≈11–12px)." },
|
||||
{ name: "--text-sm", layer: "A1-structure", description: "Type scale step — small (≈12–14px)." },
|
||||
{ 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;
|
||||
}
|
||||
523
design-systems/default/components.html
Normal file
523
design-systems/default/components.html
Normal 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>
|
||||
200
design-systems/default/tokens.css
Normal file
200
design-systems/default/tokens.css
Normal 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 (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;
|
||||
}
|
||||
601
design-systems/kami/components.html
Normal file
601
design-systems/kami/components.html
Normal 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 <style> 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>
|
||||
272
design-systems/kami/tokens.css
Normal file
272
design-systems/kami/tokens.css
Normal 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 12–64 — 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.10–1.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 2–4px; buttons and cards at 8px; featured at 12–16px. */
|
||||
--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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
506
scripts/check-tokens-fixture-sync.ts
Normal file
506
scripts/check-tokens-fixture-sync.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue