open-design/design-systems/kami/tokens.css
chaoxiaoche a75d9938c7
feat(design-systems): add structured tokens.css schema (default + kami) (#1231)
* feat(design-systems): add structured tokens.css schema (default + kami)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Layer model:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes mrcfps + lefarcen review comment thread on #1231:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@192.168.10.16>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 22:23:34 +08:00

272 lines
14 KiB
CSS
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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