@nebutra/design-tokens 0.1.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,8 @@ import { existsSync, readFileSync } from "node:fs";
24
24
  import { readFile, rm, writeFile } from "node:fs/promises";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import StyleDictionary from "style-dictionary";
27
+ import { multiThemeName, pathToCssVarName } from "./scripts/css-var-name.mjs";
28
+ import { assertLadder, deriveBorderTier } from "./scripts/derive-border-tier.mjs";
27
29
 
28
30
  // Multi-mood oklch catalog removed (2026.07). Only light/dark modes remain.
29
31
  // Design-language swap lives in @nebutra/theme (Brand Packages / skins.css).
@@ -49,10 +51,13 @@ function formatCssWithBiome(content, stdinFilePath) {
49
51
  }
50
52
  }
51
53
 
54
+ // Mirror of FONT_REGISTRY in packages/design/fonts/src/index.ts (duplicated
55
+ // because this generator is plain ESM and cannot import the TS source).
52
56
  const FONT_REGISTRY = {
53
57
  geist: "--font-geist-sans",
54
58
  "geist sans": "--font-geist-sans",
55
59
  "geist mono": "--font-geist-mono",
60
+ "vivo sans sc": "--font-noto-sans-sc",
56
61
  inter: "--font-inter",
57
62
  "space grotesk": "--font-space-grotesk",
58
63
  "playfair display": "--font-playfair-display",
@@ -85,85 +90,12 @@ function withRegistryFont(stack) {
85
90
  return variable ? `var(${variable}), ${stack}` : stack;
86
91
  }
87
92
 
88
- /**
89
- * Custom CSS variable namer.
90
- *
91
- * Maps DTCG paths to the same names the legacy SSOT uses.
92
- */
93
- function pathToCssVarName(token) {
94
- const path = token.path;
95
- const [head, ...rest] = path;
96
-
97
- // Drop primitive `color.` prefix → keep child name.
98
- if (head === "color") {
99
- if (rest[0] === "white" || rest[0] === "black") return null;
100
- if (rest[0] === "tertiary-purple") return "brand-tertiary";
101
- return rest.join("-");
102
- }
103
-
104
- if (head === "size") return rest.join("-");
105
- if (head === "duration") return `duration-${rest.join("-")}`;
106
- if (head === "easing") return `ease-${rest.join("-")}`;
107
- if (head === "fontFamily") return `font-${rest.join("-")}`;
108
-
109
- if (head === "brand") {
110
- if (rest[0] === "gradient") {
111
- const sub = rest.slice(1).join("-");
112
- return sub === "primary" ? "brand-gradient" : `brand-gradient-${sub}`;
113
- }
114
- return `brand-${rest.join("-")}`;
115
- }
116
-
117
- if (head === "status") return `status-${rest.join("-")}`;
118
-
119
- if (head === "container") return `container-${rest.join("-")}`;
120
- if (head === "radius") {
121
- return rest[0] === "default" ? "radius" : `radius-${rest.join("-")}`;
122
- }
123
- if (head === "transition") {
124
- if (rest[0] === "shorthand") return "transition";
125
- if (rest[0] === "default") return null; // composite — emitted via post-process
126
- return `transition-${rest.join("-")}`;
127
- }
128
- if (head === "focusRing") {
129
- if (rest[0] === "default") return "focus-ring";
130
- return `focus-ring-${rest.join("-")}`;
131
- }
132
-
133
- if (head === "scale") return rest.join("-");
134
- if (head === "shadcn") return rest.join("-");
135
- if (head === "ds") return `ds-${rest.join("-")}`;
136
- if (head === "elevation") return `elevation-${rest.join("-")}`;
137
-
138
- if (head === "theme") return null;
139
-
140
- if (head === "shadow") return `shadow-${rest.join("-")}`;
141
-
142
- return path.join("-");
143
- }
144
-
145
93
  StyleDictionary.registerTransform({
146
94
  name: "name/nebutra/css",
147
95
  type: "name",
148
96
  transform: (token) => pathToCssVarName(token) ?? `__skip__${token.path.join("-")}`,
149
97
  });
150
98
 
151
- /**
152
- * CSS variable namer for theme token trees (light/dark).
153
- * Emits Tailwind v4 `@theme`-compatible names: color.primary → --color-primary.
154
- */
155
- function multiThemeName(token) {
156
- const path = token.path;
157
- const [head, ...rest] = path;
158
- if (head === "theme") return null;
159
- if (head === "color") return `color-${rest.join("-")}`;
160
- if (head === "radius") return `radius-${rest.join("-")}`;
161
- if (head === "fontFamily") return `font-${rest.join("-")}`;
162
- if (head === "shadow") return `shadow-${rest.join("-")}`;
163
- if (head === "transition") return `transition-${rest.join("-")}`;
164
- return path.join("-");
165
- }
166
-
167
99
  StyleDictionary.registerTransform({
168
100
  name: "name/nebutra/css/multi-theme",
169
101
  type: "name",
@@ -292,6 +224,60 @@ function registerLayeredNameTransform({ name, sources, nameForPath }) {
292
224
  return name;
293
225
  }
294
226
 
227
+ /**
228
+ * Border-tier derivation.
229
+ *
230
+ * core.json ships 11 primitive stops; the functional layer needs 12 steps. The
231
+ * gap used to be closed by aliasing 6/7/8 to the same primitives as 3/4/5,
232
+ * which made the border tier identical to the component-background tier. Steps
233
+ * 6/7/8 are now computed from the scale's own 5 and 9 (or 10, for bright
234
+ * scales) in OKLab. See scripts/derive-border-tier.mjs for the rule.
235
+ *
236
+ * This runs as a global preprocessor, so it sees the merged core + semantic +
237
+ * theme tree and can resolve the `{color.…}` references the anchors point at.
238
+ * Deriving here rather than authoring literals in core.json is what makes the
239
+ * fix survive `scripts/brand-apply.ts`, which rewrites the three brand palettes
240
+ * wholesale over a fixed 11-stop list on every rebrand.
241
+ */
242
+ StyleDictionary.registerPreprocessor({
243
+ name: "nebutra/derive-border-tier",
244
+ preprocessor: (tokens) => {
245
+ deriveBorderTier(tokens);
246
+ return tokens;
247
+ },
248
+ });
249
+
250
+ /**
251
+ * A token description must not be able to break the build.
252
+ *
253
+ * $description is emitted verbatim into `/** … *\/` comments in the CSS and the
254
+ * TypeScript declarations, so a description containing the comment terminator
255
+ * closes it early and the generated .ts stops parsing. Writing an asterisk-slash inside a note
256
+ * about a colour ramp is enough to do it, and the failure surfaces as a
257
+ * sixty-line minified prettier stack trace pointing at a blank line in
258
+ * build/ts/estree.ts — nowhere near the token that caused it.
259
+ *
260
+ * Neutralised rather than rejected: the author's sentence still reads the same,
261
+ * and there is nothing for a contributor to learn or remember.
262
+ */
263
+ StyleDictionary.registerPreprocessor({
264
+ name: "nebutra/safe-descriptions",
265
+ preprocessor: (tokens) => {
266
+ const walk = (node) => {
267
+ if (node === null || typeof node !== "object") return;
268
+ if (typeof node.$description === "string" && node.$description.includes("*/")) {
269
+ node.$description = node.$description.replaceAll("*/", "* /");
270
+ }
271
+ for (const [key, child] of Object.entries(node)) {
272
+ if (key === "$description") continue;
273
+ walk(child);
274
+ }
275
+ };
276
+ walk(tokens);
277
+ return tokens;
278
+ },
279
+ });
280
+
295
281
  StyleDictionary.registerTransform({
296
282
  name: "color/nebutra/passthrough",
297
283
  type: "value",
@@ -330,7 +316,7 @@ const buildMode = ({ mode, selector, sources, outputFile, nameTransform = "name/
330
316
 
331
317
  return {
332
318
  log: { verbosity: "default", warnings: "error" },
333
- preprocessors: ["tokens-studio"],
319
+ preprocessors: ["tokens-studio", "nebutra/safe-descriptions", "nebutra/derive-border-tier"],
334
320
  include: sources.slice(0, -1),
335
321
  source: sources.slice(-1),
336
322
  platforms: {
@@ -448,6 +434,35 @@ for (const cfg of configs) {
448
434
  await sd.buildAllPlatforms();
449
435
  }
450
436
 
437
+ // ─── Ladder invariant ────────────────────────────────────────────────────────
438
+ //
439
+ // Fail the build if the 12-step scales regress to the state this package was
440
+ // fixed out of: duplicated steps, or a ramp (1…8) that is not monotonic in
441
+ // OKLab lightness. Step 9 is exempt from monotonicity — on bright scales the
442
+ // solid accent legitimately sits off the ramp.
443
+ //
444
+ // This is the guard that makes the derivation load-bearing rather than
445
+ // decorative: if the preprocessor ever stops running, the placeholder `$value`
446
+ // aliases collapse 6/7/8 onto step 5 and this throws instead of shipping.
447
+ for (const mode of ["light", "dark"]) {
448
+ const css = await readFile(`build/css/${mode}.css`, "utf8");
449
+ const scales = new Map();
450
+ for (const [, scaleName, step, value] of css.matchAll(
451
+ /--(neutral|blue|cyan)-(\d{1,2}):\s*(#[0-9a-f]{6})\s*;/giu,
452
+ )) {
453
+ const key = scaleName.toLowerCase();
454
+ if (!scales.has(key)) scales.set(key, {});
455
+ scales.get(key)[step] = value;
456
+ }
457
+
458
+ const problems = [...scales].flatMap(([name, values]) => assertLadder(`${mode} ${name}`, values));
459
+ if (problems.length > 0) {
460
+ throw new Error(
461
+ `[design-tokens] 12-step scale ladder invariant violated:\n ${problems.join("\n ")}`,
462
+ );
463
+ }
464
+ }
465
+
451
466
  const tsIndex = `${tsExportModes
452
467
  .map((mode) => `export * as ${toNamespaceIdentifier(mode)} from "./${mode}.js";`)
453
468
  .join("\n")}
@@ -788,6 +803,22 @@ function buildTailwindThemeInline() {
788
803
  --radius-3xl: 1.5rem;
789
804
  --radius-full: 9999px;
790
805
 
806
+ /* Containers — the page-width contract from CLAUDE.md, as real utilities.
807
+ max-w-text (reading), max-w-content (pricing/blog), max-w-wide (feature bento, navbar).
808
+
809
+ These values also reach :root through the semantic layer, so var(--container-wide) keeps
810
+ working; what was missing was the Tailwind side. Without it there is no max-w-wide to reach
811
+ for, and 92 call sites across apps/** had typed max-w-wide instead — a decided number
812
+ living in a className, where changing the token cannot move it.
813
+
814
+ Literals rather than var(--container-wide): Tailwind emits an @theme entry into its own
815
+ layer, so a same-named reference becomes self-referential and survives only because the
816
+ semantic :root happens to come later in the cascade. Duplicating the number and pinning it
817
+ with a parity test is the honest trade — see design-tokens' container parity test. */
818
+ --container-text: 896px;
819
+ --container-content: 1152px;
820
+ --container-wide: 1400px;
821
+
791
822
  /* Shadows */
792
823
  --shadow-xs: var(--elevation-xs);
793
824
  --shadow-sm: var(--elevation-sm);
@@ -798,23 +829,78 @@ function buildTailwindThemeInline() {
798
829
  --shadow-brand: var(--elevation-brand);
799
830
  --shadow-brand-lg: var(--elevation-brand-lg);
800
831
 
801
- /* Motion — Easing curves */
802
- --ease-in: cubic-bezier(0.4, 0, 1, 1);
803
- --ease-out: cubic-bezier(0, 0, 0.2, 1);
804
- --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
805
- --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
832
+ /* shadow-ambient-md / shadow-glass-lg / shadow-sheen — so marketing surfaces
833
+ reach for a step on the ramp instead of spelling out a blur and a spread.
834
+ Aliased to var() rather than inlined so the dark theme's values apply. */
835
+ --shadow-ambient-sm: var(--elevation-ambient-sm);
836
+ --shadow-ambient-md: var(--elevation-ambient-md);
837
+ --shadow-ambient-lg: var(--elevation-ambient-lg);
838
+ --shadow-glass-sm: var(--elevation-glass-sm);
839
+ --shadow-glass-md: var(--elevation-glass-md);
840
+ --shadow-glass-lg: var(--elevation-glass-lg);
841
+ --shadow-sheen: var(--elevation-sheen);
842
+
843
+ /* Coloured and centred elevation. shadow-ambient-glow is the one
844
+ non-directional step; shadow-glow-accent|-lg|-sm carry a brand-accent halo
845
+ so a glass card never hand-types the accent as raw rgba; shadow-glow-primary
846
+ tints the xl geometry with the action fill. */
847
+ --shadow-ambient-glow: var(--elevation-ambient-glow);
848
+ --shadow-glow-accent: var(--elevation-glow-accent);
849
+ --shadow-glow-accent-lg: var(--elevation-glow-accent-lg);
850
+ --shadow-glow-accent-sm: var(--elevation-glow-accent-sm);
851
+ --shadow-glow-primary: var(--elevation-glow-primary);
852
+
853
+ /* Motion — Easing curves.
854
+ Indirect, like the shadows above: an @theme entry holding a literal is
855
+ inlined into the utility, so the ease-out class compiled to a fixed
856
+ cubic-bezier and no Brand Package could move it. Pointing at the runtime
857
+ rail in static/base.css makes it follow the language the page is under.
858
+ (No backticks in this block — it is inside a JS template literal.) */
859
+ --ease-in: var(--motion-ease-in);
860
+ --ease-out: var(--motion-ease-out);
861
+ --ease-brand: var(--motion-ease-brand);
862
+ --ease-in-out: var(--motion-ease-in-out);
863
+ --ease-spring: var(--motion-ease-spring);
806
864
 
807
865
  /* Motion — Durations (four-rail; see core.json:duration). Names denote intent, not relative speed. */
808
- --duration-micro: 100ms;
809
- --duration-flow: 200ms;
810
- --duration-reveal: 300ms;
811
- --duration-cinematic: 500ms;
812
-
813
- /* Font family — Geist primary stack with CJK fallbacks */
814
- --font-sans: var(--font-geist-sans), "Geist", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
815
- --font-cn: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", "vivo Sans", sans-serif;
816
- --font-display: var(--font-geist-sans), "Geist", "Noto Sans SC", sans-serif;
817
- --font-heading: var(--font-geist-sans), "Geist", "Noto Sans SC", sans-serif;
866
+ --duration-micro: var(--motion-duration-micro);
867
+ --duration-flow: var(--motion-duration-flow);
868
+ --duration-reveal: var(--motion-duration-reveal);
869
+ --duration-cinematic: var(--motion-duration-cinematic);
870
+
871
+ /* The same four rails on the namespace Tailwind reads for duration-*.
872
+ --duration-* is the variable other CSS consumes; the utility class is built
873
+ from --transition-duration-*, and only that one. Without these,
874
+ duration-micro and duration-reveal are class names that generate nothing:
875
+ they were in use in the component library and on the marketing site, and
876
+ every one of them silently fell back to the 150ms default. */
877
+ --transition-duration-micro: var(--motion-duration-micro);
878
+ --transition-duration-flow: var(--motion-duration-flow);
879
+ --transition-duration-reveal: var(--motion-duration-reveal);
880
+ --transition-duration-cinematic: var(--motion-duration-cinematic);
881
+
882
+ /* Likewise for ease-*: the utility namespace is --ease-* already, so the four
883
+ above are reachable as ease-brand / ease-spring without further mapping. */
884
+
885
+ /* Spacing — DO NOT register --spacing-sm|md|lg|xl|2xl here.
886
+ Tailwind v4 puts every --spacing-{key} on the shared size rail, so
887
+ max-w-sm / max-w-2xl (and width/height/gap utilities with the same keys)
888
+ resolve to the breathing-room lengths (0.75rem to 3rem) instead of the
889
+ container scale (24rem to 42rem). That shipped as one-word-per-line prose
890
+ and a 32px install box on nebutra.com (measured 2026-08-03).
891
+ Brand Packages still override --space-source-*; consumers must read
892
+ those vars directly (var(--space-source-md, ...)), never via a @theme
893
+ --spacing-{step} alias. --spacing itself (0.25rem arithmetic base) stays
894
+ untouched. See static/base.css. */
895
+
896
+ /* Font family — Geist keeps Latin + numerals, self-hosted Noto Sans SC takes
897
+ CJK, system CJK faces behind it. Order is the design decision; see the
898
+ fontFamily $description in tokens/core.json. Mirror of those tokens — keep
899
+ the two in sync (verify:parity compares them). */
900
+ --font-sans: var(--font-geist-sans), "Geist", var(--font-noto-sans-sc, "Noto Sans SC"), "PingFang SC", "Microsoft YaHei", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
901
+ --font-cn: var(--font-geist-sans), "Geist", var(--font-noto-sans-sc, "Noto Sans SC"), "PingFang SC", "Microsoft YaHei", sans-serif;
902
+ --font-display: var(--font-geist-sans), "Geist", var(--font-noto-sans-sc, "Noto Sans SC"), sans-serif;
903
+ --font-heading: var(--font-geist-sans), "Geist", var(--font-noto-sans-sc, "Noto Sans SC"), sans-serif;
818
904
  --font-mono: var(--font-geist-mono), "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
819
905
  }
820
906
  `;
package/tokens/core.json CHANGED
@@ -267,52 +267,66 @@
267
267
  }
268
268
  },
269
269
  "radius": {
270
+ "$description": "Radius ladder. Every step names a job; nothing on the ladder exists only because a t-shirt scale has that size. Steps below 1rem climb by 2px so a nested corner can sit one step inside its parent without looking loose; above 1rem the ladder jumps, because a large panel does not read a 2px difference. The three intent aliases (button, card, panel) are the names product code should reach for — they resolve onto lg/xl/2xl today and exist so a future re-shape moves one value instead of a thousand call sites. Nothing above 3xl: a corner larger than 24px is a composition, not a surface, and belongs in the component that draws it.",
270
271
  "none": {
271
272
  "$value": "0",
272
- "$type": "dimension"
273
+ "$type": "dimension",
274
+ "$description": "Job: flush. Edge-to-edge surfaces — a full-bleed table row, a segment welded to its neighbour, an inset that must not round twice."
275
+ },
276
+ "xs": {
277
+ "$value": "0.125rem",
278
+ "$type": "dimension",
279
+ "$description": "Job: swatch. The smallest corner that still reads as intentional at 8-12px: chart legend keys, mark highlights, progress and sparkline bar caps. Added because 18 call sites had hand-written 1px, 2px and 3px corners with no step to reach for. Matches the bottom of the Tailwind scale, so rounded-xs and var(--radius-xs) agree."
273
280
  },
274
281
  "sm": {
275
282
  "$value": "0.25rem",
276
- "$type": "dimension"
283
+ "$type": "dimension",
284
+ "$description": "Job: inner. The corner of something nested inside a control — a thumbnail inside a chip, a swatch inside a tile, a code span inside a paragraph. One step inside md."
277
285
  },
278
286
  "md": {
279
287
  "$value": "0.375rem",
280
- "$type": "dimension"
288
+ "$type": "dimension",
289
+ "$description": "Job: control. The default for anything you can click or type into at text height: input, select trigger, small button, menu item, badge with square corners. The most-used step on the ladder."
281
290
  },
282
291
  "lg": {
283
292
  "$value": "0.5rem",
284
- "$type": "dimension"
293
+ "$type": "dimension",
294
+ "$description": "Job: container. Things that hold controls rather than being one: popover, dropdown surface, tooltip, toolbar, inline code block. Aliased as radius-button."
285
295
  },
286
296
  "xl": {
287
297
  "$value": "0.75rem",
288
- "$type": "dimension"
298
+ "$type": "dimension",
299
+ "$description": "Job: card. A discrete surface in a list or grid that carries its own padding and elevation. Aliased as radius-card."
289
300
  },
290
301
  "2xl": {
291
302
  "$value": "1rem",
292
- "$type": "dimension"
303
+ "$type": "dimension",
304
+ "$description": "Job: panel. A region that holds cards — bento cell, dialog, sheet, dashboard section. Aliased as radius-panel."
293
305
  },
294
306
  "3xl": {
295
307
  "$value": "1.5rem",
296
- "$type": "dimension"
308
+ "$type": "dimension",
309
+ "$description": "Job: frame. The outermost shell of a marketing or docs composition — mockup window, hero frame, screenshot chrome. The top of the ladder."
297
310
  },
298
311
  "full": {
299
312
  "$value": "9999px",
300
- "$type": "dimension"
313
+ "$type": "dimension",
314
+ "$description": "Job: pill. Fully round by intent, not by size: avatar, dot, toggle track, tag. Kept as one step, not a pair, so there is exactly one way to say round."
301
315
  },
302
316
  "button": {
303
317
  "$value": "0.5rem",
304
318
  "$type": "dimension",
305
- "$description": "2026 button radius — Vercel/Linear/Cursor consensus"
319
+ "$description": "Intent alias for the container step. Reach for this in product code instead of lg so the shape can be re-tuned in one place."
306
320
  },
307
321
  "card": {
308
322
  "$value": "0.75rem",
309
323
  "$type": "dimension",
310
- "$description": "2026 card radius"
324
+ "$description": "Intent alias for the card step. Reach for this in product code instead of xl."
311
325
  },
312
326
  "panel": {
313
327
  "$value": "1rem",
314
328
  "$type": "dimension",
315
- "$description": "2026 bento/hero panel radius"
329
+ "$description": "Intent alias for the panel step. Reach for this in product code instead of 2xl."
316
330
  }
317
331
  }
318
332
  },
@@ -345,7 +359,7 @@
345
359
  "$type": "cubicBezier"
346
360
  },
347
361
  "out": {
348
- "$value": "cubic-bezier(0, 0, 0.2, 1)",
362
+ "$value": "cubic-bezier(0.16, 1, 0.3, 1)",
349
363
  "$type": "cubicBezier"
350
364
  },
351
365
  "in-out": {
@@ -403,21 +417,22 @@
403
417
  }
404
418
  },
405
419
  "fontFamily": {
406
- "$description": "Geist + Geist Mono primary (Vercel typography) with Noto Sans SC / PingFang SC / Microsoft YaHei CJK fallbacks. Replaces Poppins per VI 2026 update. Each stack prefixes the next/font variable (--font-geist-sans / --font-geist-mono) so the actual loaded face wins over the literal 'Geist' family name (which the browser cannot resolve directly when next/font assigns a generated unique font-family identifier).",
420
+ "$description": "Geist + Geist Mono for Latin, self-hosted Noto Sans SC (SIL OFL) for CJK, system CJK faces (PingFang SC / Microsoft YaHei) as the fallback behind it. ORDER IS THE DESIGN DECISION: Geist comes first so it keeps Latin and the numerals (tabular figures, tighter x-height — what dense dashboard tables need, and the locked UI face); Noto Sans SC follows so only CJK falls through to it. Reversed, Noto would take the Latin too and its Latin is not as good as Geist's for UI. Each stack prefixes the next/font variable (--font-geist-sans / --font-geist-mono / --font-noto-sans-sc) so the actual loaded face wins over the literal family name, which the browser cannot resolve directly once next/font assigns a generated unique font-family identifier. The Noto entry carries a literal fallback inside var() so a surface that never applies cjkFontClassName degrades to the family name instead of making the whole declaration invalid at computed-value time. `cn` is kept rather than deleted because it still has a job: the `font-cn` utility and the `html:lang(zh|ja|ko) body` auto-activation rule in static/base.css both set font-family to that token ALONE (they exist for the CJK line-height / word-break metrics), so it governs Chinese pages entirely — including their Latin.",
407
421
  "sans": {
408
- "$value": "var(--font-geist-sans), \"Geist\", \"Noto Sans SC\", \"PingFang SC\", \"Microsoft YaHei\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif",
422
+ "$value": "var(--font-geist-sans), \"Geist\", var(--font-noto-sans-sc, \"Noto Sans SC\"), \"PingFang SC\", \"Microsoft YaHei\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif",
409
423
  "$type": "fontFamily"
410
424
  },
411
425
  "cn": {
412
- "$value": "\"Noto Sans SC\", \"PingFang SC\", \"Microsoft YaHei\", \"vivo Sans\", sans-serif",
413
- "$type": "fontFamily"
426
+ "$value": "var(--font-geist-sans), \"Geist\", var(--font-noto-sans-sc, \"Noto Sans SC\"), \"PingFang SC\", \"Microsoft YaHei\", sans-serif",
427
+ "$type": "fontFamily",
428
+ "$description": "CJK-locale stack — same order as sans, minus the Latin-only system faces"
414
429
  },
415
430
  "display": {
416
- "$value": "var(--font-geist-sans), \"Geist\", \"Noto Sans SC\", sans-serif",
431
+ "$value": "var(--font-geist-sans), \"Geist\", var(--font-noto-sans-sc, \"Noto Sans SC\"), sans-serif",
417
432
  "$type": "fontFamily"
418
433
  },
419
434
  "heading": {
420
- "$value": "var(--font-geist-sans), \"Geist\", \"Noto Sans SC\", sans-serif",
435
+ "$value": "var(--font-geist-sans), \"Geist\", var(--font-noto-sans-sc, \"Noto Sans SC\"), sans-serif",
421
436
  "$type": "fontFamily"
422
437
  },
423
438
  "mono": {
@@ -87,18 +87,6 @@
87
87
  "cinematic": { "$value": "{duration.cinematic}", "$type": "duration" }
88
88
  }
89
89
  },
90
- "transition": {
91
- "duration": {
92
- "$value": "{duration.flow}",
93
- "$type": "duration",
94
- "$description": "Default transition duration — 200ms flow rail."
95
- },
96
- "easing": {
97
- "$value": "ease-out",
98
- "$type": "string",
99
- "$description": "ease-out keyword form (CSS-friendly). Equivalent to cubic-bezier(0,0,0.2,1)."
100
- }
101
- },
102
90
  "focusRing": {
103
91
  "default": {
104
92
  "$type": "string",