@nurix/etna 0.19.2 → 0.19.4

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.
Files changed (122) hide show
  1. package/data/harness-index.json +1 -1
  2. package/data/harnesses/code/CLAUDE.md +2 -2
  3. package/data/harnesses/code/harness.json +2 -2
  4. package/data/harnesses/code/rules/frontend.md +3 -3
  5. package/data/harnesses/code/skills/design/SKILL.md +20 -8
  6. package/data/harnesses/code/skills/design/data/catalog/rules.csv +10 -5
  7. package/data/harnesses/code/skills/design/data/combinations/Readme.md +8 -4
  8. package/data/harnesses/code/skills/design/data/combinations/accent-presets.md +113 -0
  9. package/data/harnesses/code/skills/design/data/combinations/fonts-known.json +11 -0
  10. package/data/harnesses/code/skills/design/data/combinations/manifest.json +57 -10
  11. package/data/harnesses/code/skills/design/data/combinations/motion.csv +10 -0
  12. package/data/harnesses/code/skills/design/data/combinations/palettes.csv +92 -49
  13. package/data/harnesses/code/skills/design/data/combinations/styles.csv +67 -54
  14. package/data/harnesses/code/skills/design/data/combinations/typography.csv +64 -61
  15. package/data/harnesses/code/skills/design/data/design-md/library/expo.md +4 -4
  16. package/data/harnesses/code/skills/design/data/design-md/library/lovable.md +5 -5
  17. package/data/harnesses/code/skills/design/data/design-md/library/minimax.md +6 -6
  18. package/data/harnesses/code/skills/design/data/design-md/library/notion.md +4 -4
  19. package/data/harnesses/code/skills/design/data/design-md/library/runwayml.md +4 -4
  20. package/data/harnesses/code/skills/design/data/design-md/library/sentry.md +2 -2
  21. package/data/harnesses/code/skills/design/data/design-md/library/slack.md +6 -6
  22. package/data/harnesses/code/skills/design/data/design-md/library/webflow.md +5 -5
  23. package/data/harnesses/code/skills/design/data/design-md/nurix.md +11 -7
  24. package/data/harnesses/code/skills/design/data/design-tokens/SCHEMA.md +256 -141
  25. package/data/harnesses/code/skills/design/data/design-tokens/airbnb/design.tokens.json +309 -1
  26. package/data/harnesses/code/skills/design/data/design-tokens/airtable/design.tokens.json +309 -1
  27. package/data/harnesses/code/skills/design/data/design-tokens/anthropic-claude/design.tokens.json +309 -1
  28. package/data/harnesses/code/skills/design/data/design-tokens/apple/design.tokens.json +309 -1
  29. package/data/harnesses/code/skills/design/data/design-tokens/binance/design.tokens.json +309 -1
  30. package/data/harnesses/code/skills/design/data/design-tokens/bmw/design.tokens.json +309 -1
  31. package/data/harnesses/code/skills/design/data/design-tokens/cal/design.tokens.json +313 -1
  32. package/data/harnesses/code/skills/design/data/design-tokens/clay/design.tokens.json +309 -1
  33. package/data/harnesses/code/skills/design/data/design-tokens/clickhouse/design.tokens.json +309 -1
  34. package/data/harnesses/code/skills/design/data/design-tokens/cohere/design.tokens.json +309 -1
  35. package/data/harnesses/code/skills/design/data/design-tokens/coinbase/design.tokens.json +309 -1
  36. package/data/harnesses/code/skills/design/data/design-tokens/composio/design.tokens.json +309 -1
  37. package/data/harnesses/code/skills/design/data/design-tokens/cursor/design.tokens.json +309 -1
  38. package/data/harnesses/code/skills/design/data/design-tokens/elevenlabs/design.tokens.json +313 -1
  39. package/data/harnesses/code/skills/design/data/design-tokens/expo/design.tokens.json +309 -1
  40. package/data/harnesses/code/skills/design/data/design-tokens/figma/design.tokens.json +313 -1
  41. package/data/harnesses/code/skills/design/data/design-tokens/framer/design.tokens.json +309 -1
  42. package/data/harnesses/code/skills/design/data/design-tokens/goldens.json +123 -123
  43. package/data/harnesses/code/skills/design/data/design-tokens/hashicorp/design.tokens.json +309 -1
  44. package/data/harnesses/code/skills/design/data/design-tokens/ibm/design.tokens.json +309 -1
  45. package/data/harnesses/code/skills/design/data/design-tokens/intercom/design.tokens.json +309 -1
  46. package/data/harnesses/code/skills/design/data/design-tokens/kraken/design.tokens.json +313 -1
  47. package/data/harnesses/code/skills/design/data/design-tokens/linear-app/design.resolver.json +1 -1
  48. package/data/harnesses/code/skills/design/data/design-tokens/linear-app/design.tokens.dark.json +10 -0
  49. package/data/harnesses/code/skills/design/data/design-tokens/linear-app/design.tokens.json +309 -1
  50. package/data/harnesses/code/skills/design/data/design-tokens/lovable/design.tokens.json +313 -1
  51. package/data/harnesses/code/skills/design/data/design-tokens/meta/design.tokens.json +313 -1
  52. package/data/harnesses/code/skills/design/data/design-tokens/minimax/design.tokens.json +309 -1
  53. package/data/harnesses/code/skills/design/data/design-tokens/mintlify/design.tokens.json +309 -1
  54. package/data/harnesses/code/skills/design/data/design-tokens/miro/design.tokens.json +313 -1
  55. package/data/harnesses/code/skills/design/data/design-tokens/mistral-ai/design.tokens.json +309 -1
  56. package/data/harnesses/code/skills/design/data/design-tokens/mongodb/design.tokens.json +313 -1
  57. package/data/harnesses/code/skills/design/data/design-tokens/notion/design.tokens.json +309 -1
  58. package/data/harnesses/code/skills/design/data/design-tokens/nurix/design.tokens.json +307 -2
  59. package/data/harnesses/code/skills/design/data/design-tokens/nvidia/design.tokens.json +309 -1
  60. package/data/harnesses/code/skills/design/data/design-tokens/ollama/design.tokens.json +309 -1
  61. package/data/harnesses/code/skills/design/data/design-tokens/opencode-ai/design.tokens.json +309 -1
  62. package/data/harnesses/code/skills/design/data/design-tokens/posthog/design.tokens.json +309 -1
  63. package/data/harnesses/code/skills/design/data/design-tokens/raycast/design.tokens.json +309 -1
  64. package/data/harnesses/code/skills/design/data/design-tokens/replicate/design.tokens.json +309 -1
  65. package/data/harnesses/code/skills/design/data/design-tokens/resend/design.resolver.json +1 -1
  66. package/data/harnesses/code/skills/design/data/design-tokens/resend/design.tokens.dark.json +10 -0
  67. package/data/harnesses/code/skills/design/data/design-tokens/resend/design.tokens.json +309 -1
  68. package/data/harnesses/code/skills/design/data/design-tokens/revolut/design.tokens.json +309 -1
  69. package/data/harnesses/code/skills/design/data/design-tokens/runwayml/design.tokens.json +310 -2
  70. package/data/harnesses/code/skills/design/data/design-tokens/sanity/design.tokens.json +313 -1
  71. package/data/harnesses/code/skills/design/data/design-tokens/sentry/design.tokens.json +309 -1
  72. package/data/harnesses/code/skills/design/data/design-tokens/shopify/design.tokens.json +309 -1
  73. package/data/harnesses/code/skills/design/data/design-tokens/slack/design.tokens.json +309 -1
  74. package/data/harnesses/code/skills/design/data/design-tokens/spacex/design.tokens.json +309 -1
  75. package/data/harnesses/code/skills/design/data/design-tokens/spotify/design.tokens.json +313 -1
  76. package/data/harnesses/code/skills/design/data/design-tokens/stripe/design.tokens.json +313 -1
  77. package/data/harnesses/code/skills/design/data/design-tokens/supabase/design.tokens.json +309 -1
  78. package/data/harnesses/code/skills/design/data/design-tokens/superhuman/design.tokens.json +313 -1
  79. package/data/harnesses/code/skills/design/data/design-tokens/tesla/design.tokens.json +309 -1
  80. package/data/harnesses/code/skills/design/data/design-tokens/theverge/design.tokens.json +309 -1
  81. package/data/harnesses/code/skills/design/data/design-tokens/together-ai/design.tokens.json +313 -1
  82. package/data/harnesses/code/skills/design/data/design-tokens/uber/design.tokens.json +309 -1
  83. package/data/harnesses/code/skills/design/data/design-tokens/vercel/design.tokens.json +309 -1
  84. package/data/harnesses/code/skills/design/data/design-tokens/voltagent/design.resolver.json +1 -1
  85. package/data/harnesses/code/skills/design/data/design-tokens/voltagent/design.tokens.dark.json +10 -0
  86. package/data/harnesses/code/skills/design/data/design-tokens/voltagent/design.tokens.json +309 -1
  87. package/data/harnesses/code/skills/design/data/design-tokens/warp/design.tokens.json +309 -1
  88. package/data/harnesses/code/skills/design/data/design-tokens/webflow/design.tokens.json +309 -1
  89. package/data/harnesses/code/skills/design/data/design-tokens/wired/design.tokens.json +309 -1
  90. package/data/harnesses/code/skills/design/data/design-tokens/wise/design.tokens.json +309 -1
  91. package/data/harnesses/code/skills/design/data/design-tokens/x-ai/design.tokens.json +309 -1
  92. package/data/harnesses/code/skills/design/data/design-tokens/zapier/design.tokens.json +309 -1
  93. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/command-center.tsx +25 -25
  94. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/create-edit.tsx +18 -18
  95. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/dashboard.tsx +82 -105
  96. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/entity-detail.tsx +97 -93
  97. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/list-detail.tsx +16 -15
  98. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/log-stream.tsx +34 -34
  99. package/data/harnesses/code/skills/design/data/operator-pack/surface-catalog.md +5 -5
  100. package/data/harnesses/code/skills/design/references/create.md +49 -7
  101. package/data/harnesses/code/skills/design/references/diagnosis.md +3 -3
  102. package/data/harnesses/code/skills/design/references/hard-rules.md +25 -3
  103. package/data/harnesses/code/skills/design/references/improve.md +32 -3
  104. package/data/harnesses/code/skills/design/references/interaction.md +3 -3
  105. package/data/harnesses/code/skills/design/references/introspect.md +1 -1
  106. package/data/harnesses/code/skills/design/references/lifecycle.md +2 -2
  107. package/data/harnesses/code/skills/design/references/personality.md +280 -133
  108. package/data/harnesses/code/skills/design/references/structure.md +22 -1
  109. package/data/harnesses/code/skills/design/scripts/convert-design-md.mjs +306 -26
  110. package/data/harnesses/code/skills/design/scripts/derive-personality.mjs +596 -104
  111. package/data/harnesses/code/skills/design/scripts/lib/checks.mjs +200 -11
  112. package/data/harnesses/code/skills/design/scripts/lib/vocabulary.mjs +65 -0
  113. package/data/harnesses/code/skills/design/scripts/phase-gate.mjs +95 -11
  114. package/data/harnesses/code/skills/design/scripts/preship-sweep.mjs +50 -5
  115. package/data/harnesses/code/skills/design/scripts/render-rung.mjs +214 -14
  116. package/data/harnesses/code/skills/design/scripts/scaffold.mjs +242 -25
  117. package/data/harnesses/code/skills/design/scripts/self-integrity.mjs +81 -6
  118. package/data/harnesses/code/skills/design/scripts/snapshot-goldens.mjs +101 -5
  119. package/data/harnesses/code/skills/design/scripts/validate-combinations.mjs +142 -17
  120. package/data/harnesses/etna/CLAUDE.md +1 -1
  121. package/data/harnesses/etna/harness.json +2 -2
  122. package/package.json +1 -1
@@ -1,19 +1,29 @@
1
1
  #!/usr/bin/env node
2
- // skills/design/scripts/derive-personality.mjs — design-tokens artifact resolver, coverage gate (--check; SCHEMA.md rules 1–12), and CSS emitter (--emit) for the operator-UI personality system (D29, D6).
2
+ // skills/design/scripts/derive-personality.mjs — design-tokens artifact resolver, coverage gate (--check; SCHEMA.md rules 1–15), and CSS emitter (--emit) for the operator-UI personality system (D29, D6).
3
3
  //
4
- // The reference consumer of data/design-tokens/SCHEMA.md: resolves an artifact directory
5
- // (design.tokens.json + design.resolver.json [+ design.tokens.dark.json]) through the resolver's
6
- // deterministic order — concatenate the set files, overlay the selected theme context, resolve
7
- // aliases — then validates (rules 1–12) or emits personality.css / globals.css / the <html> attributes.
4
+ // The reference consumer of data/design-tokens/SCHEMA.md (schema design-tokens/v2): resolves an
5
+ // artifact directory (design.tokens.json + design.resolver.json [+ design.tokens.dark.json]) through
6
+ // the resolver's deterministic order — concatenate the set files, overlay the selected theme context,
7
+ // resolve aliases — then validates (rules 1–15) or emits personality.css / globals.css / fonts.css /
8
+ // the <html> attributes. --emit refuses while any token carries a hint; --provisional (golden/
9
+ // regression flows only, never shipped output) emits hinted tokens at their provisional $values
10
+ // instead. --emit also enforces two emission contracts: font binding (contract 2, cod-16 — every
11
+ // emitted stack family binds to a generated @fontsource import / installed package or a system
12
+ // generic, fail-closed) and emitter completeness (contract 3 — an authored token group with no
13
+ // emitter refuses to emit, naming the group).
8
14
  //
9
- // Usage: derive-personality.mjs <artifact-dir> [--check | --emit <outdir>] [--theme light|dark]
15
+ // Usage: derive-personality.mjs <artifact-dir> [--check | --emit <outdir> [--provisional]] [--theme light|dark]
10
16
 
11
17
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
18
  import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { SYSTEM_FONT_KEYWORDS, familySlug } from "./lib/checks.mjs";
13
21
 
14
22
  const EXT = "com.nurix.design";
23
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
24
+ const COMBINATIONS_DIR = join(SCRIPT_DIR, "..", "data", "combinations");
15
25
 
16
- // The 26 visual primitives and their DTCG $type, in emission order (leaf names are law; no leading --).
26
+ // The 27 visual primitives and their DTCG $type, in emission order (leaf names are law; no leading --).
17
27
  const PRIMITIVE_TYPES = new Map([
18
28
  ["radius", "dimension"], ["shadow-intensity", "number"], ["border-weight", "dimension"],
19
29
  ["density-gap", "number"], ["density-inset", "number"],
@@ -21,7 +31,7 @@ const PRIMITIVE_TYPES = new Map([
21
31
  ["font-weight-normal", "fontWeight"], ["font-weight-strong", "fontWeight"],
22
32
  ["heading-tracking", "number"],
23
33
  ["duration-unit", "duration"], ["duration-fast", "duration"], ["duration-normal", "duration"],
24
- ["duration-slow", "duration"], ["ease-default", "cubicBezier"],
34
+ ["duration-slow", "duration"], ["ease-default", "cubicBezier"], ["stagger-unit", "duration"],
25
35
  ["contrast-boost", "number"],
26
36
  ["icon-stroke", "number"], ["icon-size-base", "dimension"],
27
37
  ["sidebar-width-expanded", "dimension"], ["sidebar-width-collapsed", "dimension"],
@@ -31,11 +41,12 @@ const PRIMITIVE_TYPES = new Map([
31
41
  ]);
32
42
  const PRIMITIVES = [...PRIMITIVE_TYPES.keys()];
33
43
 
34
- // The 17 semantic color tokens, in emission order.
44
+ // The 19 semantic color tokens, in emission order.
35
45
  const SEMANTIC_TOKENS = [
36
46
  "background", "foreground", "card", "card-foreground", "muted", "muted-foreground",
37
47
  "border", "input", "ring", "primary", "primary-foreground", "accent", "accent-foreground",
38
48
  "destructive", "destructive-foreground", "success", "warning",
49
+ "surface-raised", "surface-overlay",
39
50
  ];
40
51
 
41
52
  // The 17 substrate color tokens (the D6 set), in emission order.
@@ -48,17 +59,41 @@ const SUBSTRATE_TOKENS = [
48
59
 
49
60
  const ALL_TOKENS = [...SEMANTIC_TOKENS, ...SUBSTRATE_TOKENS];
50
61
 
51
- const RAMP_STEPS = ["h1", "h2", "h3", "body", "small", "caption"];
62
+ const RAMP_STEPS = ["h1", "h2", "h3", "body", "small", "caption", "display"];
63
+ const ELEVATION_TOKENS = ["sm", "md", "lg", "overlay"];
64
+ const WASH_TOKENS = ["surface", "emphasis"];
52
65
  const LEGAL_VERBOSITY = ["full", "default", "compact", "expert"];
53
66
  const LEGAL_FEEDBACK = ["subtle", "minimal"];
54
67
  const LEGAL_DENSITY = ["spacious", "standard", "dense"];
55
68
  const LEGAL_PROVENANCE = ["project", "extracted", "library", "default"];
56
- const LEGAL_TYPES = ["color", "dimension", "duration", "cubicBezier", "fontFamily", "fontWeight", "number", "typography"];
69
+ const LEGAL_TYPES = ["color", "dimension", "duration", "cubicBezier", "fontFamily", "fontWeight", "number", "typography", "shadow", "gradient"];
57
70
  const LEGAL_THEMES = ["light", "dark"];
58
71
  const MANIFEST_KEYS = ["spacing-scale", "radius-scale", "shadow-levels", "motion", "icons", "accent-count"];
59
72
 
73
+ // The optional persisted intake look block's enums — mirrored from phase-gate.mjs validateLookBlock
74
+ // (the intake authority) so a persisted payload.look cannot rot unchecked.
75
+ const LOOK_THEMES = ["light", "dark", "both"];
76
+ const LOOK_DELIGHTS = ["baseline", "expressive"];
77
+ const LOOK_RESOLUTION_MODES = ["confirmed", "recorded"];
78
+ const LOOK_RESOLUTION_RE = /^(project|default|library:.+|seed:.+\+.+)$/i;
79
+
80
+ // Contract 3 — the emitter registry: every authored token group's named consumer in the emitted
81
+ // output. --emit refuses an authored group absent from this map (a group with no emitter vanishes).
82
+ const EMITTED_GROUPS = new Map([
83
+ ["primitive", "the [data-style] primitives block"],
84
+ ["color", "the armored theme blocks (:root, :root[data-theme=\"light\"] / :root[data-theme=\"dark\"])"],
85
+ ["typography", "the @theme ramp text utilities (text-h1 … text-display)"],
86
+ ["elevation", "the --shadow-sm/md/lg/overlay composites + @theme shadow utilities"],
87
+ ["wash", "--wash-surface / --wash-emphasis (optional presence)"],
88
+ ]);
89
+ const TYPOGRAPHY_SUBGROUPS = ["family", "ramp"];
90
+
91
+ // Legal $description opening tags (rule 15): the provenance-layer vocabulary (source/brief/deviation,
92
+ // 00-layering.md Composition A) plus the established mechanical-annotation prefixes.
93
+ const LEGAL_DESCRIPTION_TAGS = ["source", "brief", "deviation", "derived", "corpus", "rationale", "note", "synthesized", "brand", "dark"];
94
+
60
95
  // Shadow-level names ranked by increasing intensity, for the ordering check.
61
- const SHADOW_RANK = { none: 0, "2xs": 1, xs: 2, sm: 3, md: 4, lg: 5, xl: 6, "2xl": 7 };
96
+ const SHADOW_RANK = { none: 0, "2xs": 1, xs: 2, sm: 3, md: 4, lg: 5, xl: 6, "2xl": 7, overlay: 8 };
62
97
 
63
98
  // ---------------------------------------------------------------------------
64
99
  // CSS value parsers — the parse-back authority (rule 9); byte-compatible with convert-design-md.mjs
@@ -111,16 +146,38 @@ function parseColorCss(css) {
111
146
  return value;
112
147
  }
113
148
  m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(s);
149
+ if (!m) m = /^rgba?\(\s*(\d+)\s+(\d+)\s+(\d+)\s*(?:\/\s*([\d.]+%?)\s*)?\)$/.exec(s); // modern space syntax
114
150
  if (m) {
115
151
  const bytes = [m[1], m[2], m[3]].map(Number);
116
152
  const hex = "#" + bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
117
153
  const value = { colorSpace: "srgb", components: bytes.map((b) => round5(b / 255)), hex };
118
- if (m[4] !== undefined) value.alpha = parseFloat(m[4]);
154
+ if (m[4] !== undefined) value.alpha = m[4].endsWith("%") ? round5(parseFloat(m[4]) / 100) : parseFloat(m[4]);
119
155
  return value;
120
156
  }
121
157
  throw new Error(`unparseable color: ${JSON.stringify(css)}`);
122
158
  }
123
159
 
160
+ // Parse one shadow-list string (the elevation byte form) into the DTCG layer array. A layer color's
161
+ // alpha may ride the master multiplier as calc(A * var(--shadow-intensity)) — A is the layer alpha.
162
+ function parseShadowCss(css) {
163
+ return splitTopLevel(css, ",").map((layerRaw) => {
164
+ const s = layerRaw.trim();
165
+ const colorStart = s.search(/rgba?\(|oklch\(|hsla?\(|#[0-9a-fA-F]/);
166
+ if (colorStart < 0) throw new Error(`shadow layer names no color: ${JSON.stringify(s)}`);
167
+ const colorCss = s.slice(colorStart).trim().replace(/calc\(\s*([\d.]+)\s*\*\s*var\(--shadow-intensity\)\s*\)/, "$1");
168
+ const dims = s.slice(0, colorStart).trim().split(/\s+/);
169
+ if (dims.length < 3 || dims.length > 4) throw new Error(`shadow layer wants 3-4 lengths (offsetX offsetY blur [spread]): ${JSON.stringify(s)}`);
170
+ const dim = (d) => (d === "0" ? { value: 0, unit: "px" } : parseDimensionCss(d));
171
+ return {
172
+ color: parseColorCss(colorCss),
173
+ offsetX: dim(dims[0]),
174
+ offsetY: dim(dims[1]),
175
+ blur: dim(dims[2]),
176
+ spread: dims[3] !== undefined ? dim(dims[3]) : { value: 0, unit: "px" },
177
+ };
178
+ });
179
+ }
180
+
124
181
  // Parse a tracking string ("0" | "0em" | "-0.015em") into its em-multiplier number.
125
182
  function parseTrackingCss(css) {
126
183
  const m = /^([+-]?\d*\.?\d+)(em)?$/.exec(css.trim());
@@ -182,6 +239,25 @@ function serializeValue(type, value) {
182
239
  if (value.colorSpace === "srgb" && value.hex) return value.hex;
183
240
  return `oklch(${value.components.join(" ")}${value.alpha !== undefined ? ` / ${value.alpha}` : ""})`;
184
241
  }
242
+ if (type === "shadow") {
243
+ // Deterministic fallback (the migration always preserves .css): zero lengths serialize bare "0";
244
+ // the layer alpha rides the master multiplier so --shadow-intensity keeps flattening shadows.
245
+ const dim = (d) => (d.value === 0 ? "0" : `${d.value}${d.unit}`);
246
+ const layerColor = (c) => {
247
+ const alpha = c.alpha ?? 1;
248
+ if (c.colorSpace === "srgb") {
249
+ const bytes = c.components.map((n) => Math.round(n * 255));
250
+ return `rgb(${bytes.join(" ")} / calc(${alpha} * var(--shadow-intensity)))`;
251
+ }
252
+ return `oklch(${c.components.join(" ")} / calc(${alpha} * var(--shadow-intensity)))`;
253
+ };
254
+ return value.map((l) => `${dim(l.offsetX)} ${dim(l.offsetY)} ${dim(l.blur)} ${dim(l.spread ?? { value: 0, unit: "px" })} ${layerColor(l.color)}`).join(", ");
255
+ }
256
+ if (type === "gradient") {
257
+ // Deterministic fallback (authored washes preserve .css): a top-down tint ramp from the stop list.
258
+ const stops = value.map((s) => `${serializeValue("color", s.color)} ${Math.round(s.position * 100)}%`).join(", ");
259
+ return `linear-gradient(180deg, ${stops})`;
260
+ }
185
261
  return String(value);
186
262
  }
187
263
 
@@ -275,7 +351,7 @@ function loadArtifact(dir, errors) {
275
351
  errors.push("rule 1 — design.resolver.json: missing the theme modifier");
276
352
  return null;
277
353
  }
278
- if (themeModifier.default !== "light") errors.push("rule 1 — design.resolver.json: theme default must be light (light-first)");
354
+ if (!LEGAL_THEMES.includes(themeModifier.default)) errors.push(`rule 1 — design.resolver.json: theme default must be light|dark (found ${JSON.stringify(themeModifier.default)}); it mirrors the payload's theme-default`);
279
355
  if (!Array.isArray(themeModifier.contexts?.light) || themeModifier.contexts.light.length !== 0) {
280
356
  errors.push("rule 1 — design.resolver.json: the light context must be the empty overlay [] (the base set is the light rendering)");
281
357
  }
@@ -406,24 +482,20 @@ function verifyScalarParseBack(name, token, css, errors) {
406
482
  }
407
483
  }
408
484
 
409
- // Run the full coverage gate (rules 1–11) over one artifact directory; returns everything emit needs.
410
- function runCheck(dir, theme) {
485
+ // Run the full coverage gate (rules 1–15) over one artifact directory; returns everything emit needs.
486
+ // requestedTheme null = boot theme follows the payload's theme-default (light when absent).
487
+ function runCheck(dir, requestedTheme) {
411
488
  const errors = [];
412
489
  const artifact = loadArtifact(dir, errors);
413
490
  if (!artifact) return { errors };
414
- const { base, files, views, payload } = artifact;
415
-
416
- if (!views.has(theme)) {
417
- errors.push(`rule 1 — resolver: --theme ${theme} requested but the resolver carries no ${theme} context`);
418
- return { errors };
419
- }
491
+ const { base, resolver, files, views, payload } = artifact;
420
492
 
421
493
  // Rule 2 — the file-level payload on the base root.
422
494
  if (!payload || typeof payload !== "object") {
423
495
  errors.push(`rule 2 — payload: missing $extensions["${EXT}"] on the base file's root group`);
424
496
  return { errors };
425
497
  }
426
- if (payload.schema !== "design-tokens/v1") errors.push(`rule 2 — payload.schema: must be "design-tokens/v1" (found ${JSON.stringify(payload.schema)})`);
498
+ if (payload.schema !== "design-tokens/v2") errors.push(`rule 2 — payload.schema: must be "design-tokens/v2" (found ${JSON.stringify(payload.schema)})`);
427
499
  if (payload.dtcg !== "2025.10") errors.push(`rule 2 — payload.dtcg: must be "2025.10" (found ${JSON.stringify(payload.dtcg)})`);
428
500
  if (typeof payload.product !== "string" || payload.product.trim() === "") errors.push("rule 2 — payload.product: missing or empty");
429
501
  if (!LEGAL_PROVENANCE.includes(payload.provenance)) errors.push(`rule 2 — payload.provenance: must be one of ${LEGAL_PROVENANCE.join("|")} (found ${JSON.stringify(payload.provenance)})`);
@@ -432,23 +504,63 @@ function runCheck(dir, theme) {
432
504
  if (!LEGAL_FEEDBACK.includes(payload.feedback)) errors.push(`rule 2 — payload.feedback: must be one of ${LEGAL_FEEDBACK.join("|")} (found ${JSON.stringify(payload.feedback)})`);
433
505
  if (!LEGAL_DENSITY.includes(payload["density-tier"])) errors.push(`rule 2 — payload.density-tier: must be one of ${LEGAL_DENSITY.join("|")} (found ${JSON.stringify(payload["density-tier"])})`);
434
506
  if (!Array.isArray(payload["product-traits"]) || payload["product-traits"].length === 0) errors.push("rule 2 — payload.product-traits: must be a non-empty array");
507
+ if (payload["theme-default"] !== undefined && !LEGAL_THEMES.includes(payload["theme-default"])) {
508
+ errors.push(`rule 2 — payload.theme-default: must be light|dark when present (found ${JSON.stringify(payload["theme-default"])})`);
509
+ }
510
+ const themeDefault = LEGAL_THEMES.includes(payload["theme-default"]) ? payload["theme-default"] : "light";
511
+ const resolverDefault = (resolver.modifiers ?? []).find((m) => m?.name === "theme")?.default;
512
+ if (LEGAL_THEMES.includes(resolverDefault) && resolverDefault !== themeDefault) {
513
+ errors.push(`rule 2 — design.resolver.json: theme default ${JSON.stringify(resolverDefault)} does not mirror payload theme-default ${JSON.stringify(themeDefault)}`);
514
+ }
515
+ const anchors = payload.anchors;
516
+ const anchorsShapeOk = Array.isArray(anchors) && anchors.length >= 2 && anchors.length <= 3 && anchors.every((a) => typeof a === "string" && a.trim() !== "");
517
+ if (anchors !== undefined && !anchorsShapeOk) errors.push(`rule 2 — payload.anchors: must be an array of 2–3 non-empty strings when present (found ${JSON.stringify(anchors)})`);
518
+ if (payload.provenance === "project" && anchors === undefined) errors.push("rule 2 — payload.anchors: required on provenance: project artifacts (the named real-product anchors from intake)");
519
+ // Rule 2 (look) — the optional persisted intake look block (SCHEMA.md, the payload): enum-checked
520
+ // when present; theme-default and anchors stay the canonical top-level projections emission consumes.
521
+ const look = payload.look;
522
+ if (look !== undefined) {
523
+ if (look === null || typeof look !== "object" || Array.isArray(look)) {
524
+ errors.push("rule 2 — payload.look: must be an object when present (the persisted intake look block)");
525
+ } else {
526
+ if (!LOOK_THEMES.includes(look.theme)) errors.push(`rule 2 — payload.look.theme: must be one of ${LOOK_THEMES.join("|")} (found ${JSON.stringify(look.theme)})`);
527
+ if (!LOOK_DELIGHTS.includes(look.delight)) errors.push(`rule 2 — payload.look.delight: must be one of ${LOOK_DELIGHTS.join("|")} (found ${JSON.stringify(look.delight)})`);
528
+ if (!LOOK_RESOLUTION_MODES.includes(look["resolution-mode"])) errors.push(`rule 2 — payload.look.resolution-mode: must be one of ${LOOK_RESOLUTION_MODES.join("|")} (found ${JSON.stringify(look["resolution-mode"])})`);
529
+ const lookAnchors = look.anchors;
530
+ if (!Array.isArray(lookAnchors) || lookAnchors.length < 2 || lookAnchors.length > 3 || !lookAnchors.every((a) => typeof a === "string" && a.trim() !== "")) {
531
+ errors.push(`rule 2 — payload.look.anchors: must be an array of 2–3 non-empty named strings (found ${JSON.stringify(lookAnchors)})`);
532
+ }
533
+ if (typeof look.resolution !== "string" || !LOOK_RESOLUTION_RE.test(look.resolution.trim())) {
534
+ errors.push(`rule 2 — payload.look.resolution: must match project | library:<slug> | seed:<style>+<palette> | default (found ${JSON.stringify(look.resolution)})`);
535
+ }
536
+ }
537
+ }
435
538
  const lm = payload["lever-manifest"];
436
539
  for (const key of MANIFEST_KEYS) {
437
540
  if (lm?.[key] === undefined) errors.push(`rule 2 — payload.lever-manifest.${key}: missing`);
438
541
  }
439
542
 
543
+ const theme = requestedTheme ?? themeDefault;
544
+ if (!views.has(theme)) {
545
+ errors.push(`rule 1 — resolver: theme ${theme} selected but the resolver carries no ${theme} context`);
546
+ return { errors };
547
+ }
548
+
440
549
  const lightView = views.get("light");
441
550
  const lightTokens = collectTokens(lightView);
442
551
 
443
- // Rule 3 — all 26 primitives present with the exact $type, each assigned or hinted.
552
+ // Rule 3 — all 27 primitives present with the exact $type, each assigned or hinted.
444
553
  for (const [key, type] of PRIMITIVE_TYPES) {
445
554
  const t = lightView.primitive?.[key];
446
555
  if (!isToken(t)) { errors.push(`rule 3 — primitive.${key}: missing`); continue; }
447
556
  if (t.$type !== type) errors.push(`rule 3 — primitive.${key}: $type ${JSON.stringify(t.$type)} (want ${type})`);
448
557
  if (isEmptyValue(t.$value)) errors.push(`rule 3 — primitive.${key}: empty $value`);
558
+ if (key === "stagger-unit" && typeof t.$value?.value === "number" && (t.$value.value < 0 || t.$value.value > 60)) {
559
+ errors.push(`rule 3 — primitive.stagger-unit: ${t.$value.value}ms outside the legal 0–60ms range`);
560
+ }
449
561
  }
450
562
 
451
- // Rule 4 — all 34 color roles present, typed color, legal colorSpace, .css present (via alias when aliased).
563
+ // Rule 4 — all 36 color roles present, typed color, legal colorSpace, .css present (via alias when aliased).
452
564
  for (const role of ALL_TOKENS) {
453
565
  const t = lightView.color?.[role];
454
566
  if (!isToken(t)) { errors.push(`rule 4 — color.${role}: missing`); continue; }
@@ -459,11 +571,15 @@ function runCheck(dir, theme) {
459
571
  if (typeof target.$extensions?.[EXT]?.css !== "string") errors.push(`rule 4 — color.${role}: missing preserved .css`);
460
572
  }
461
573
 
462
- // Rule 5 — typography coverage: the three families and the six-step ramp with all five sub-values.
574
+ // Rule 5 — typography coverage: the three families (display optional) and the seven-step ramp with all five sub-values.
463
575
  for (const fam of ["heading", "body", "mono"]) {
464
576
  const t = lightView.typography?.family?.[fam];
465
577
  if (!isToken(t) || t.$type !== "fontFamily") errors.push(`rule 5 — typography.family.${fam}: missing or not $type fontFamily`);
466
578
  }
579
+ const famDisplay = lightView.typography?.family?.display;
580
+ if (famDisplay !== undefined && (!isToken(famDisplay) || famDisplay.$type !== "fontFamily")) {
581
+ errors.push("rule 5 — typography.family.display: not $type fontFamily (the slot is optional; its shape is not)");
582
+ }
467
583
  for (const step of RAMP_STEPS) {
468
584
  const t = lightView.typography?.ramp?.[step];
469
585
  if (!isToken(t) || t.$type !== "typography") { errors.push(`rule 5 — typography.ramp.${step}: missing or not $type typography`); continue; }
@@ -504,21 +620,25 @@ function runCheck(dir, theme) {
504
620
  if (typeof lm.icons?.family !== "string" || lm.icons.family === "") errors.push("rule 7 — lever-manifest.icons.family: missing or empty");
505
621
  if (!(lm["accent-count"] >= 1)) errors.push(`rule 7 — lever-manifest.accent-count: must be >= 1 (found ${JSON.stringify(lm?.["accent-count"])})`);
506
622
 
507
- // Rule 8 — the look's own base values sit on its own allowlist.
508
- const radius = lightView.primitive?.radius?.$value?.value;
509
- if (Array.isArray(lm["radius-scale"]) && radius !== undefined && !lm["radius-scale"].includes(radius)) {
510
- errors.push(`rule 8 — primitive.radius: ${radius} not on lever-manifest.radius-scale`);
623
+ // Rule 8 — the look's own base values sit on its own allowlist. An off-manifest value is legal
624
+ // only as a recorded deviation: its $description must open with the deviation: layer tag (rule 15).
625
+ const deviationTagged = (t) => typeof t?.$description === "string" && t.$description.startsWith("deviation:");
626
+ const radiusToken = lightView.primitive?.radius;
627
+ const radius = radiusToken?.$value?.value;
628
+ if (Array.isArray(lm["radius-scale"]) && radius !== undefined && !lm["radius-scale"].includes(radius) && !deviationTagged(radiusToken)) {
629
+ errors.push(`rule 8 — primitive.radius: ${radius} not on lever-manifest.radius-scale (off-manifest values need a deviation: $description)`);
511
630
  }
512
631
  for (const key of ["duration-fast", "duration-normal", "duration-slow"]) {
513
- const ms = lightView.primitive?.[key]?.$value?.value;
514
- if (Array.isArray(lm.motion?.durations) && ms !== undefined && !lm.motion.durations.includes(ms)) {
515
- errors.push(`rule 8 — primitive.${key}: ${ms} not in lever-manifest.motion.durations`);
632
+ const t = lightView.primitive?.[key];
633
+ const ms = t?.$value?.value;
634
+ if (Array.isArray(lm.motion?.durations) && ms !== undefined && !lm.motion.durations.includes(ms) && !deviationTagged(t)) {
635
+ errors.push(`rule 8 — primitive.${key}: ${ms} not in lever-manifest.motion.durations (off-manifest values need a deviation: $description)`);
516
636
  }
517
637
  }
518
638
  const ease = lightView.primitive?.["ease-default"];
519
639
  if (isToken(ease) && Array.isArray(lm.motion?.easings)) {
520
640
  const easeCss = typeof ease.$extensions?.[EXT]?.css === "string" ? ease.$extensions[EXT].css : serializeValue("cubicBezier", ease.$value);
521
- if (!lm.motion.easings.includes(easeCss)) errors.push(`rule 8 — primitive.ease-default: ${JSON.stringify(easeCss)} not in lever-manifest.motion.easings`);
641
+ if (!lm.motion.easings.includes(easeCss) && !deviationTagged(ease)) errors.push(`rule 8 — primitive.ease-default: ${JSON.stringify(easeCss)} not in lever-manifest.motion.easings (off-manifest values need a deviation: $description)`);
522
642
  }
523
643
  }
524
644
 
@@ -531,6 +651,20 @@ function runCheck(dir, theme) {
531
651
  const name = `${prefix}${path}`;
532
652
  if (token.$type === "color") {
533
653
  verifyColorParseBack(name, token, errors);
654
+ } else if (token.$type === "shadow" && typeof css === "string") {
655
+ try {
656
+ const reparsed = parseShadowCss(css);
657
+ if (JSON.stringify(reparsed) !== JSON.stringify(token.$value)) errors.push(`rule 9 — ${name}: shadow css ${JSON.stringify(css)} does not parse back to $value`);
658
+ } catch (e) {
659
+ errors.push(`rule 9 — ${name}: ${e.message}`);
660
+ }
661
+ } else if (token.$type === "gradient" && typeof css === "string") {
662
+ // A gradient's .css carries geometry the stop list does not; verify each stop color's byte form appears in it.
663
+ for (const [i, stop] of (Array.isArray(token.$value) ? token.$value : []).entries()) {
664
+ if (stop?.color && typeof stop.color === "object" && !css.includes(serializeValue("color", stop.color))) {
665
+ errors.push(`rule 9 — ${name}: stop ${i} color ${serializeValue("color", stop.color)} not present in the preserved .css`);
666
+ }
667
+ }
534
668
  } else if (typeof css === "string") {
535
669
  verifyScalarParseBack(name, token, css, errors);
536
670
  } else if (token.$type === "typography" && typeof css === "object" && css !== null) {
@@ -546,7 +680,7 @@ function runCheck(dir, theme) {
546
680
  }
547
681
  }
548
682
 
549
- // Rule 10 — the dark file, when present, carries exactly the 34-role color group and nothing else.
683
+ // Rule 10 — the dark file, when present, carries exactly the 36-role color group and nothing else.
550
684
  const darkDoc = files.get("design.tokens.dark.json");
551
685
  if (darkDoc !== undefined) {
552
686
  const rootKeys = Object.keys(darkDoc).filter((k) => !k.startsWith("$"));
@@ -565,7 +699,7 @@ function runCheck(dir, theme) {
565
699
  }
566
700
  }
567
701
 
568
- // Rule 11 — only the eight legal $type values appear, in any file.
702
+ // Rule 11 — only the ten legal $type values appear, in any file.
569
703
  for (const [fileName, doc] of files) {
570
704
  const prefix = fileName === "design.tokens.json" ? "" : `${fileName} `;
571
705
  for (const [path, token] of collectTokens(doc)) {
@@ -573,6 +707,70 @@ function runCheck(dir, theme) {
573
707
  }
574
708
  }
575
709
 
710
+ // True when a shadow/wash layer color is a legal DTCG color value; records rule errors under `where`.
711
+ const checkLayerColor = (color, where, rule) => {
712
+ if (!color || typeof color !== "object" || !["oklch", "srgb"].includes(color.colorSpace) || !Array.isArray(color.components)) {
713
+ errors.push(`${rule} — ${where}: color must be a typed oklch|srgb value (found ${JSON.stringify(color)})`);
714
+ return;
715
+ }
716
+ const isPureBlack = color.colorSpace === "srgb"
717
+ ? color.components.every((c) => c === 0)
718
+ : color.components[0] === 0 && color.components[1] === 0;
719
+ if (rule === "rule 13" && isPureBlack && (color.alpha ?? 1) >= 0.15) {
720
+ errors.push(`${rule} — ${where}: pure black at alpha ${color.alpha ?? 1} (≥ 0.15) — shadow colors are brand-tintable, never a hard black drop`);
721
+ }
722
+ };
723
+
724
+ // Rule 13 — elevation coverage: all four elevation.* present, $type shadow, ≥ 1 layer each,
725
+ // every layer shape-legal and rule-conformant, the byte form preserved; each assigned or hinted.
726
+ for (const level of ELEVATION_TOKENS) {
727
+ const t = lightView.elevation?.[level];
728
+ if (!isToken(t)) { errors.push(`rule 13 — elevation.${level}: missing`); continue; }
729
+ if (t.$type !== "shadow") { errors.push(`rule 13 — elevation.${level}: $type ${JSON.stringify(t.$type)} (want shadow)`); continue; }
730
+ if (!Array.isArray(t.$value) || t.$value.length === 0) { errors.push(`rule 13 — elevation.${level}: $value must be a non-empty array of layers`); continue; }
731
+ for (const [i, layer] of t.$value.entries()) {
732
+ checkLayerColor(layer?.color, `elevation.${level} layer ${i}`, "rule 13");
733
+ for (const dim of ["offsetX", "offsetY", "blur"]) {
734
+ if (typeof layer?.[dim]?.value !== "number") errors.push(`rule 13 — elevation.${level} layer ${i}: ${dim} must be a dimension`);
735
+ }
736
+ }
737
+ if (typeof t.$extensions?.[EXT]?.css !== "string") errors.push(`rule 13 — elevation.${level}: missing preserved .css (the serialized shadow list)`);
738
+ }
739
+ for (const level of Object.keys(lightView.elevation ?? {})) {
740
+ if (!level.startsWith("$") && !ELEVATION_TOKENS.includes(level)) errors.push(`rule 13 — elevation.${level}: not a legal level (sm|md|lg|overlay)`);
741
+ }
742
+
743
+ // Rule 14 — wash shape: the group is optional; when present, ≤ 2 gradient tokens named
744
+ // surface/emphasis with rule-conformant stops. Presence is never required.
745
+ if (lightView.wash !== undefined) {
746
+ const washKeys = Object.keys(lightView.wash).filter((k) => !k.startsWith("$"));
747
+ if (washKeys.length > 2) errors.push(`rule 14 — wash: at most 2 tokens (found ${washKeys.length})`);
748
+ for (const key of washKeys) {
749
+ if (!WASH_TOKENS.includes(key)) { errors.push(`rule 14 — wash.${key}: not a legal wash slot (surface|emphasis)`); continue; }
750
+ const t = lightView.wash[key];
751
+ if (!isToken(t) || t.$type !== "gradient") { errors.push(`rule 14 — wash.${key}: must be $type gradient`); continue; }
752
+ if (!Array.isArray(t.$value) || t.$value.length < 2) { errors.push(`rule 14 — wash.${key}: $value must be an array of ≥ 2 stops`); continue; }
753
+ for (const [i, stop] of t.$value.entries()) {
754
+ checkLayerColor(stop?.color, `wash.${key} stop ${i}`, "rule 14");
755
+ if (typeof stop?.position !== "number" || stop.position < 0 || stop.position > 1) errors.push(`rule 14 — wash.${key} stop ${i}: position must be a number in 0–1`);
756
+ }
757
+ if (typeof t.$extensions?.[EXT]?.css !== "string") errors.push(`rule 14 — wash.${key}: missing preserved .css (the authored gradient)`);
758
+ }
759
+ }
760
+
761
+ // Rule 15 — provenance vocabulary: a per-value $description opening with a tag must use a legal
762
+ // one (source:/brief:/deviation: are the layer tags; untagged = inherited). Rule 8 enforces the
763
+ // off-manifest side (every off-manifest value carries deviation:).
764
+ for (const [fileName, doc] of files) {
765
+ const prefix = fileName === "design.tokens.json" ? "" : `${fileName} `;
766
+ for (const [path, token] of collectTokens(doc)) {
767
+ const m = typeof token.$description === "string" && /^([a-z][a-z-]*):/.exec(token.$description);
768
+ if (m && !LEGAL_DESCRIPTION_TAGS.includes(m[1])) {
769
+ errors.push(`rule 15 — ${prefix}${path}: $description opens with unknown tag "${m[1]}:" (layer tags: source:|brief:|deviation:; untagged = inherited)`);
770
+ }
771
+ }
772
+ }
773
+
576
774
  // Rule 12 inventory — every token still carrying a hint (passes --check; --emit refuses).
577
775
  const hints = [];
578
776
  for (const [fileName, doc] of files) {
@@ -583,17 +781,19 @@ function runCheck(dir, theme) {
583
781
  }
584
782
  }
585
783
 
586
- return { errors, payload, views, hints, hasDark: files.has("design.tokens.dark.json") };
784
+ return { errors, payload, views, hints, theme, hasDark: files.has("design.tokens.dark.json"), hasWash: lightView.wash !== undefined };
587
785
  }
588
786
 
589
787
  // ---------------------------------------------------------------------------
590
788
  // Emitter feed — flatten the resolved views into the value map the emitters consume
591
789
  // ---------------------------------------------------------------------------
592
790
 
593
- // Assemble the emitters' document: 26 primitive CSS strings + the payload dials, and the
594
- // 34-role CSS string map per theme (preferring each token's preserved .css).
791
+ // Assemble the emitters' document: 27 primitive CSS strings + the payload dials, the 36-role CSS
792
+ // string map per theme, the elevation/wash value strings, and the ramp steps as emitter-ready data
793
+ // (preferring each token's preserved .css throughout).
595
794
  function buildEmitterDoc(views, payload, errors) {
596
- const lightTokens = collectTokens(views.get("light"));
795
+ const lightView = views.get("light");
796
+ const lightTokens = collectTokens(lightView);
597
797
  const primitives = {};
598
798
  for (const key of PRIMITIVES) primitives[key] = cssFor(`primitive.${key}`, lightTokens, errors);
599
799
  primitives.verbosity = payload.verbosity;
@@ -609,7 +809,222 @@ function buildEmitterDoc(views, payload, errors) {
609
809
  for (const role of ALL_TOKENS) colorTokens.dark[role] = cssFor(`color.${role}`, darkTokens, errors);
610
810
  }
611
811
 
612
- return { primitives, manifest: { "color-tokens": colorTokens } };
812
+ const elevation = {};
813
+ for (const level of ELEVATION_TOKENS) elevation[level] = cssFor(`elevation.${level}`, lightTokens, errors);
814
+
815
+ let wash = null;
816
+ if (lightView.wash !== undefined) {
817
+ wash = {};
818
+ for (const key of WASH_TOKENS) {
819
+ if (isToken(lightView.wash[key])) wash[key] = cssFor(`wash.${key}`, lightTokens, errors);
820
+ }
821
+ }
822
+
823
+ // Ramp steps as @theme text-utility data: size/line-height as literals; weight and tracking as
824
+ // var() references when they alias a personality primitive, so the utilities track the cascade.
825
+ const ramp = {};
826
+ for (const step of RAMP_STEPS) {
827
+ const t = lightView.typography?.ramp?.[step];
828
+ if (!isToken(t) || !t.$value || typeof t.$value !== "object") { errors.push(`emit — typography.ramp.${step}: missing composite`); continue; }
829
+ const v = t.$value;
830
+ const weightVar = { "{primitive.font-weight-strong}": "var(--font-weight-strong)", "{primitive.font-weight-normal}": "var(--font-weight-normal)" };
831
+ let fontWeight;
832
+ if (typeof v.fontWeight === "number") fontWeight = String(v.fontWeight);
833
+ else if (weightVar[v.fontWeight]) fontWeight = weightVar[v.fontWeight];
834
+ else {
835
+ const target = resolveAlias(v.fontWeight, lightTokens, "fontWeight", `typography.ramp.${step}.fontWeight`, errors);
836
+ fontWeight = target ? String(target.$value) : "400";
837
+ }
838
+ const preservedTracking = t.$extensions?.[EXT]?.css?.letterSpacing;
839
+ let letterSpacing;
840
+ if (v.letterSpacing === "{primitive.heading-tracking}") letterSpacing = "var(--heading-tracking)";
841
+ else if (typeof preservedTracking === "string") letterSpacing = preservedTracking;
842
+ else if (typeof v.letterSpacing === "number") letterSpacing = `${v.letterSpacing}em`;
843
+ else {
844
+ const target = resolveAlias(v.letterSpacing, lightTokens, "number", `typography.ramp.${step}.letterSpacing`, errors);
845
+ letterSpacing = target ? `${target.$value}em` : "0em";
846
+ }
847
+ ramp[step] = {
848
+ fontSize: `${v.fontSize?.value}${v.fontSize?.unit ?? "px"}`,
849
+ lineHeight: String(v.lineHeight),
850
+ fontWeight,
851
+ letterSpacing,
852
+ };
853
+ }
854
+
855
+ return { primitives, elevation, wash, ramp, manifest: { "color-tokens": colorTokens } };
856
+ }
857
+
858
+ // ---------------------------------------------------------------------------
859
+ // Contract 3 — emitter completeness: every authored group reaches consumable output
860
+ // ---------------------------------------------------------------------------
861
+
862
+ // Refuse emission when any resolved view carries an authored token group (or typography subgroup)
863
+ // absent from the emitter registry — machinery that accepts data it never emits is a silent breach.
864
+ function checkEmitterCompleteness(views, emitErrors) {
865
+ for (const [context, view] of views) {
866
+ const label = context === "light" ? "" : ` [${context}]`;
867
+ for (const group of Object.keys(view)) {
868
+ if (group.startsWith("$")) continue;
869
+ if (!EMITTED_GROUPS.has(group)) {
870
+ emitErrors.push(`emitter completeness — authored token group "${group}"${label} has no emitter (contract 3): an authored group with no emitter simply vanishes; add its emitter to this script or remove the group. Emitters exist for: ${[...EMITTED_GROUPS.keys()].join(", ")}.`);
871
+ }
872
+ }
873
+ for (const sub of Object.keys(view.typography ?? {})) {
874
+ if (!sub.startsWith("$") && !TYPOGRAPHY_SUBGROUPS.includes(sub)) {
875
+ emitErrors.push(`emitter completeness — authored token group "typography.${sub}"${label} has no emitter (contract 3): only typography.family and typography.ramp reach the emitted output.`);
876
+ }
877
+ }
878
+ }
879
+ }
880
+
881
+ // ---------------------------------------------------------------------------
882
+ // Contract 2 — font binding: fonts.css generation + the fail-closed cod-16 gate
883
+ // ---------------------------------------------------------------------------
884
+
885
+ // Parse a CSV text into row objects keyed by its header (RFC quoting: "" escapes a quote inside a quoted field).
886
+ function parseCsv(text) {
887
+ const records = [];
888
+ let field = "", record = [], inQuotes = false;
889
+ const endField = () => { record.push(field); field = ""; };
890
+ const endRecord = () => { endField(); if (record.some((f) => f !== "")) records.push(record); record = []; };
891
+ for (let i = 0; i < text.length; i++) {
892
+ const c = text[i];
893
+ if (inQuotes) {
894
+ if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
895
+ else if (c === '"') inQuotes = false;
896
+ else field += c;
897
+ } else if (c === '"') inQuotes = true;
898
+ else if (c === ",") endField();
899
+ else if (c === "\n") endRecord();
900
+ else if (c !== "\r") field += c;
901
+ }
902
+ endRecord();
903
+ const header = records.shift() ?? [];
904
+ return records.map((cells) => Object.fromEntries(header.map((h, i) => [h, cells[i] ?? ""])));
905
+ }
906
+
907
+ // Load the family → registering-import resolver: typography.csv css-import rows first (paste-ready
908
+ // static @fontsource imports, registering the plain family names), fonts-known.json as the fallback
909
+ // oracle (family → static weights). Returns resolve(family) → { family, pkg, imports } | null.
910
+ function loadFontResolver() {
911
+ const byFamily = new Map();
912
+ try {
913
+ for (const row of parseCsv(readFileSync(join(COMBINATIONS_DIR, "typography.csv"), "utf8"))) {
914
+ for (const col of ["heading_family", "body_family", "mono_family"]) {
915
+ const family = (row[col] ?? "").trim();
916
+ if (family === "" || byFamily.has(family.toLowerCase())) continue;
917
+ const slug = familySlug(family);
918
+ const imports = [...String(row["css-import"] ?? "").matchAll(/@import\s+'(@fontsource\/([a-z0-9-]+)\/[^']+)'/g)]
919
+ .filter((m) => m[2] === slug)
920
+ .map((m) => `@import '${m[1]}';`);
921
+ if (imports.length > 0) byFamily.set(family.toLowerCase(), { family, pkg: `@fontsource/${slug}`, imports });
922
+ }
923
+ }
924
+ } catch { /* typography.csv unreadable — fonts-known.json stays the oracle */ }
925
+ let knownFamilies = {};
926
+ try { knownFamilies = JSON.parse(readFileSync(join(COMBINATIONS_DIR, "fonts-known.json"), "utf8")).families ?? {}; } catch { /* absent oracle degrades to csv-only resolution */ }
927
+ const knownLower = new Map(Object.entries(knownFamilies).map(([name, weights]) => [name.toLowerCase(), weights]));
928
+ return (family) => {
929
+ const hit = byFamily.get(family.toLowerCase());
930
+ if (hit) return hit;
931
+ const weights = knownLower.get(family.toLowerCase());
932
+ if (!Array.isArray(weights) || weights.length === 0) return null;
933
+ const slug = familySlug(family);
934
+ return { family, pkg: `@fontsource/${slug}`, imports: weights.map((w) => `@import '@fontsource/${slug}/${w}.css';`) };
935
+ };
936
+ }
937
+
938
+ // Collect the artifact's font surfaces: the two emitted stacks (the cod-16 gate's subjects) and the
939
+ // deduped named families across every fontFamily token (import generation covers all of them).
940
+ function collectFontSurfaces(views, doc, errors) {
941
+ const lightTokens = collectTokens(views.get("light"));
942
+ const stacks = [
943
+ { name: "--font-sans", families: parseFontStackCss(String(doc.primitives["font-sans"])) },
944
+ { name: "--font-mono", families: parseFontStackCss(String(doc.primitives["font-mono"])) },
945
+ ];
946
+ const importFamilies = [];
947
+ const seen = new Set();
948
+ const push = (family) => {
949
+ const key = family.toLowerCase();
950
+ if (!seen.has(key) && !SYSTEM_FONT_KEYWORDS.has(key)) { seen.add(key); importFamilies.push(family); }
951
+ };
952
+ for (const stack of stacks) for (const family of stack.families) push(family);
953
+ for (const slot of ["heading", "body", "mono", "display"]) {
954
+ if (!lightTokens.has(`typography.family.${slot}`)) continue;
955
+ for (const family of parseFontStackCss(cssFor(`typography.family.${slot}`, lightTokens, errors))) push(family);
956
+ }
957
+ return { stacks, importFamilies };
958
+ }
959
+
960
+ // Read the emit target's dependency map, or null when the target is standalone (no package.json) —
961
+ // the standalone case degrades the cod-16 gate to import-list validation.
962
+ function readTargetDeps(outdir) {
963
+ const pkgPath = join(outdir, "package.json");
964
+ if (!existsSync(pkgPath)) return null;
965
+ try {
966
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
967
+ return { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
968
+ } catch {
969
+ return null;
970
+ }
971
+ }
972
+
973
+ // The emitted src/styles/fonts.css content: the generated registering-import list, one derivation.
974
+ function emitFontsCss(entries, unresolved) {
975
+ const lines = [
976
+ "/* src/styles/fonts.css — @fontsource font registrations (emitted; do not hand-edit).",
977
+ " *",
978
+ " * One derivation, not two authored artifacts: this import list is GENERATED from the",
979
+ " * artifact's fontFamily tokens (typography.csv css-import rows; fonts-known.json fallback),",
980
+ " * and the emitted stacks use the same registered family names — so the flagship font cannot",
981
+ " * silently fail to bind (cod-16 gates the pair at --emit). Loaded FIRST by globals.css",
982
+ " * (step 1 of the five-step import order): faces register before any stack consumes them.",
983
+ " * To change a family, edit design.tokens.json and re-run derive-personality.mjs --emit. */",
984
+ ];
985
+ for (const entry of entries) lines.push("", `/* ${entry.family} — ${entry.pkg} */`, ...entry.imports);
986
+ for (const family of unresolved) lines.push("", `/* UNRESOLVED: "${family}" — no known @fontsource package registers it; register it via @font-face or swap the family (cod-16). */`);
987
+ if (entries.length === 0 && unresolved.length === 0) lines.push("", "/* every stack family is a system/generic face — nothing to register. */");
988
+ return lines.join("\n") + "\n";
989
+ }
990
+
991
+ // Contract 2 in full: generate fonts.css from the artifact's fontFamily tokens and run the cod-16
992
+ // binding gate over the emitted stacks — (a) each named family rides a generated import whose
993
+ // package is installed (project targets; standalone targets degrade to import-list validation), or
994
+ // (b) is a recognized system/generic family. Returns { fontsCss, failures }.
995
+ function buildFontBinding(views, doc, outdir, errors) {
996
+ const resolveFamily = loadFontResolver();
997
+ const { stacks, importFamilies } = collectFontSurfaces(views, doc, errors);
998
+ const deps = readTargetDeps(outdir);
999
+ const entries = [];
1000
+ const unresolved = [];
1001
+ for (const family of importFamilies) {
1002
+ const hit = resolveFamily(family);
1003
+ if (hit) { entries.push(hit); continue; }
1004
+ const variablePkg = `@fontsource-variable/${familySlug(family)}`;
1005
+ if (deps !== null && variablePkg in deps) {
1006
+ // The consumer already installed the variable package (registers the "<Family> Variable" name).
1007
+ entries.push({ family, pkg: variablePkg, imports: [`@import '${variablePkg}';`] });
1008
+ continue;
1009
+ }
1010
+ unresolved.push(family);
1011
+ }
1012
+ const entryByFamily = new Map(entries.map((entry) => [entry.family.toLowerCase(), entry]));
1013
+ const failures = [];
1014
+ for (const stack of stacks) {
1015
+ for (const family of stack.families) {
1016
+ const key = family.toLowerCase();
1017
+ if (SYSTEM_FONT_KEYWORDS.has(key)) continue;
1018
+ const entry = entryByFamily.get(key);
1019
+ const slug = familySlug(family);
1020
+ if (!entry) {
1021
+ failures.push(`"${family}" (emitted ${stack.name} stack): no known @fontsource package registers it (typography.csv, fonts-known.json${deps !== null ? ", package.json" : ""}) — install and register one (npm install @fontsource/${slug}, then re-run --emit) or swap to a bindable family / system generic.`);
1022
+ } else if (deps !== null && !(entry.pkg in deps) && !(`@fontsource/${slug}` in deps) && !(`@fontsource-variable/${slug}` in deps)) {
1023
+ failures.push(`"${family}" (emitted ${stack.name} stack): its registering import needs ${entry.pkg}, absent from package.json — npm install ${entry.pkg}`);
1024
+ }
1025
+ }
1026
+ }
1027
+ return { fontsCss: emitFontsCss(entries, unresolved), failures };
613
1028
  }
614
1029
 
615
1030
  // ---------------------------------------------------------------------------
@@ -639,17 +1054,11 @@ function deriveEaseEnter(easeDefault) {
639
1054
  return "cubic-bezier(0.16, 1, 0.3, 1)";
640
1055
  }
641
1056
 
642
- // Derive --stagger-delay in ms: duration-unit x 0.15, clamped 0-40.
643
- function deriveStaggerMs(durationUnit) {
644
- const n = parseFloat(durationUnit);
645
- const raw = Number.isNaN(n) ? 0 : n * 0.15;
646
- return Math.max(0, Math.min(40, Math.round(raw)));
647
- }
648
-
649
- // Build the [data-style] primitive lines (governed density refs + derived temporals inserted after ease-default).
650
- function buildStyleBlockLines(primitives) {
1057
+ // Build the [data-style] primitive lines (governed density refs + derived temporals inserted after
1058
+ // the temporal family), then the elevation composites and the optional wash pair.
1059
+ function buildStyleBlockLines(doc) {
1060
+ const primitives = doc.primitives;
651
1061
  const easeEnter = deriveEaseEnter(primitives["ease-default"]);
652
- const staggerMs = deriveStaggerMs(primitives["duration-unit"]);
653
1062
  const lines = [];
654
1063
  for (const key of PRIMITIVES) {
655
1064
  if (key === "density-gap") lines.push(" --density-gap: var(--density-base-gap);");
@@ -657,10 +1066,18 @@ function buildStyleBlockLines(primitives) {
657
1066
  else if (key === "font-sans") lines.push(` --font-sans: ${quoteFontStack(primitives["font-sans"])};`);
658
1067
  else if (key === "font-mono") lines.push(` --font-mono: ${quoteFontStack(primitives["font-mono"])};`);
659
1068
  else lines.push(` --${key}: ${primitives[key]};`);
660
- if (key === "ease-default") {
1069
+ if (key === "stagger-unit") {
661
1070
  lines.push(` --ease-enter: ${easeEnter}; /* derived from ease-default */`);
662
1071
  lines.push(" --ease-exit: var(--ease-default); /* derived: departure mirrors the neutral curve */");
663
- lines.push(` --stagger-delay: ${staggerMs}ms; /* derived: duration-unit × 0.15, clamped 0–40ms */`);
1072
+ lines.push(" --stagger-delay: var(--stagger-unit); /* derived: the motion-stagger cascade base */");
1073
+ }
1074
+ }
1075
+ lines.push(" /* elevation (4) — designed shadow composites; --shadow-intensity stays the master multiplier */");
1076
+ for (const level of ELEVATION_TOKENS) lines.push(` --shadow-${level}: ${doc.elevation[level]};`);
1077
+ if (doc.wash) {
1078
+ lines.push(" /* wash (optional, <= 2) — low-chroma tint ramps, consumed only on budget-granted surfaces */");
1079
+ for (const key of WASH_TOKENS) {
1080
+ if (doc.wash[key] !== undefined) lines.push(` --wash-${key}: ${doc.wash[key]};`);
664
1081
  }
665
1082
  }
666
1083
  return lines.join("\n");
@@ -678,23 +1095,26 @@ function emitPersonalityCss(doc, slug, meta) {
678
1095
 
679
1096
  const semanticLines = SEMANTIC_TOKENS.map((t) => ` --${t}: ${light[t]};`).join("\n");
680
1097
  const substrateLines = SUBSTRATE_TOKENS.map((t) => ` --${t}: ${light[t]};`).join("\n");
681
- const styleLines = buildStyleBlockLines(p);
1098
+ const styleLines = buildStyleBlockLines(doc);
682
1099
 
683
1100
  const darkSection = dark
684
1101
  ? `/* ── Dark theme — OPT-IN ONLY (this look ships a dark overlay). ──
685
- * design.tokens.dark.json remaps all 34 color tokens; <html data-theme="dark">
686
- * opts in. Light stays the default. */
687
- [data-theme="dark"] {
688
- /* semantic (17) */
1102
+ * CASCADE CONTRACT 1b: :root[data-theme="dark"] (0,2,0) — a lone [data-theme="dark"]
1103
+ * attribute selector (0,1,0) loses to the library's later bare :root block by source
1104
+ * order. design.tokens.dark.json remaps all 36 color tokens; <html data-theme="dark">
1105
+ * opts in (theme-default decides the boot attribute). */
1106
+ :root[data-theme="dark"] {
1107
+ /* semantic (19) */
689
1108
  ${SEMANTIC_TOKENS.map((t) => ` --${t}: ${dark[t]};`).join("\n")}
690
1109
  /* substrate (17, the D6 set) — popover/secondary/chart/sidebar the library consumes */
691
1110
  ${SUBSTRATE_TOKENS.map((t) => ` --${t}: ${dark[t]};`).join("\n")}
692
1111
  }`
693
1112
  : `/* ── Dark theme — OPT-IN ONLY. ──
694
- * Emitted only when the artifact ships a design.tokens.dark.json dark overlay.
695
- * Light stays the default; this block never appears for a light-only project.
696
- [data-theme="dark"] {
697
- --background: ...; --foreground: ...; ... all 34 tokens remapped ...
1113
+ * Emitted only when the artifact ships a design.tokens.dark.json dark overlay,
1114
+ * as :root[data-theme="dark"] (0,2,0 — armored against the library's later :root).
1115
+ * This block never appears for a light-only project.
1116
+ :root[data-theme="dark"] {
1117
+ --background: ...; --foreground: ...; ... all 36 tokens remapped ...
698
1118
  } */`;
699
1119
 
700
1120
  return `/* src/styles/personality.css — operator-UI personality tokens (emitted; do not hand-edit).
@@ -706,14 +1126,14 @@ ${SUBSTRATE_TOKENS.map((t) => ` --${t}: ${dark[t]};`).join("\n")}
706
1126
  * density: ${density} (<html data-density="${density}">)
707
1127
  * verbosity: ${verbosity} (<html data-verbosity="${verbosity}">)
708
1128
  * feedback: ${feedback} (<html data-feedback="${feedback}">)
709
- * theme: ${meta.theme} (<html data-theme="${meta.theme}">) — light is the default; dark is opt-in
1129
+ * theme: ${meta.theme} (<html data-theme="${meta.theme}">) — the payload's theme-default decides the boot theme
710
1130
  * artifact: design.tokens.json (resolved via design.resolver.json) — provenance: ${meta.provenance}, sourced ${meta.sourced}
711
1131
  *
712
1132
  * Semantic-token doctrine: components name purpose (bg-card, text-foreground, border-border);
713
1133
  * this file resolves those names to values. Components never use dark: prefixes or literal colors.
714
1134
  */
715
1135
 
716
- /* ── System baseline: the 26 primitives at neutral operator defaults. ──
1136
+ /* ── System baseline: the 27 primitives at neutral operator defaults. ──
717
1137
  * The safety net if <html> ever carries no data-style. The project's real
718
1138
  * values live in the single [data-style="${slug}"] block below, which
719
1139
  * overrides these. Improve/extract rewrites that block, never this baseline. */
@@ -726,9 +1146,10 @@ ${SUBSTRATE_TOKENS.map((t) => ` --${t}: ${dark[t]};`).join("\n")}
726
1146
  --density-base-inset: 1;
727
1147
  --density-gap: var(--density-base-gap);
728
1148
  --density-inset: var(--density-base-inset);
729
- /* typographic */
730
- --font-sans: "Inter", system-ui, sans-serif;
731
- --font-mono: "JetBrains Mono", ui-monospace, monospace;
1149
+ /* typographic — generics only: the baseline never names a webfont it cannot
1150
+ * guarantee is registered (cod-16); the [data-style] block carries the real stacks. */
1151
+ --font-sans: system-ui, sans-serif;
1152
+ --font-mono: ui-monospace, SFMono-Regular, monospace;
732
1153
  --font-weight-normal: 400;
733
1154
  --font-weight-strong: 600;
734
1155
  --heading-tracking: -0.01em;
@@ -738,11 +1159,17 @@ ${SUBSTRATE_TOKENS.map((t) => ` --${t}: ${dark[t]};`).join("\n")}
738
1159
  --duration-normal: var(--duration-unit);
739
1160
  --duration-slow: calc(var(--duration-unit) * 2);
740
1161
  --ease-default: cubic-bezier(0.4, 0, 0.2, 1);
1162
+ --stagger-unit: 30ms;
741
1163
  --ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
742
1164
  --ease-exit: var(--ease-default);
743
- --stagger-delay: 30ms;
1165
+ --stagger-delay: var(--stagger-unit);
744
1166
  /* surface */
745
1167
  --contrast-boost: 1;
1168
+ /* elevation (neutral slate; the [data-style] block below carries the designed composites) */
1169
+ --shadow-sm: 0 1px 3px 0 rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 1px 2px -1px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
1170
+ --shadow-md: 0 4px 6px -1px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 2px 4px -2px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
1171
+ --shadow-lg: 0 10px 15px -3px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 4px 6px -4px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
1172
+ --shadow-overlay: 0 20px 25px -5px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 8px 10px -6px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
746
1173
  /* iconography */
747
1174
  --icon-stroke: 1.75;
748
1175
  --icon-size-base: 16px;
@@ -765,13 +1192,14 @@ ${SUBSTRATE_TOKENS.map((t) => ` --${t}: ${dark[t]};`).join("\n")}
765
1192
  }
766
1193
 
767
1194
  /* ── Semantic + substrate color tokens — light theme. ──
768
- * CASCADE CONTRACT 1: the doubled selector out-specifies the library's bare
769
- * :root defaults in @nurix/components/styles.css — but ONLY because <html> carries
770
- * data-theme="light". A bare :root here loses to the later library import. All 34
771
- * tokens are set (D6): no consumed token falls back to a shadcn-gray default. */
1195
+ * CASCADE CONTRACT 1: both theme blocks carry the same 0,2,0 armor — light as
1196
+ * :root, :root[data-theme="light"] and dark as :root[data-theme="dark"] — because
1197
+ * a bare :root or a lone attribute selector loses to the library's later :root
1198
+ * block in @nurix/components/styles.css by source order. All 36 tokens are set
1199
+ * (D6): no consumed token falls back to a shadcn-gray default. */
772
1200
  :root,
773
1201
  :root[data-theme="light"] {
774
- /* semantic (17) */
1202
+ /* semantic (19) */
775
1203
  ${semanticLines}
776
1204
  /* substrate (17, the D6 set) — popover/secondary/chart/sidebar the library consumes */
777
1205
  ${substrateLines}
@@ -779,9 +1207,10 @@ ${substrateLines}
779
1207
 
780
1208
  /* ── The project personality: the single [data-style] block (D3, rosterless). ──
781
1209
  * Named after the project — not a preset. This materializes the artifact's
782
- * primitive token group: all 26 primitives + the derived temporal. It is the
783
- * one block improve/extract rewrites. density-gap/inset are governed refs, so
784
- * the global tier and per-surface step still compose. */
1210
+ * primitive token group (all 27 primitives + the derived temporals), the four
1211
+ * elevation composites, and the optional wash pair. It is the one block
1212
+ * improve/extract rewrites. density-gap/inset are governed refs, so the global
1213
+ * tier and per-surface step still compose. */
785
1214
  [data-style="${slug}"] {
786
1215
  ${styleLines}
787
1216
  }
@@ -812,10 +1241,34 @@ ${styleLines}
812
1241
 
813
1242
  ${darkSection}
814
1243
 
1244
+ /* ── Motion moments — named classes consuming the temporal tokens. ──
1245
+ * motion-lift: hover lift (-1px + elevation.sm) at duration-fast/ease-default.
1246
+ * motion-stagger: children arrival cascade at --stagger-delay, capped at 8
1247
+ * children (mot-08); consumption is budget-gated by the surface stamp. Both are
1248
+ * inert under prefers-reduced-motion (mot-03). */
1249
+ @media (prefers-reduced-motion: no-preference) {
1250
+ .motion-lift {
1251
+ transition: transform var(--duration-fast) var(--ease-default), box-shadow var(--duration-fast) var(--ease-default);
1252
+ }
1253
+ .motion-lift:hover {
1254
+ transform: translateY(-1px);
1255
+ box-shadow: var(--shadow-sm);
1256
+ }
1257
+ @keyframes motion-stagger-arrive {
1258
+ from { opacity: 0; transform: translateY(4px); }
1259
+ to { opacity: 1; transform: translateY(0); }
1260
+ }
1261
+ .motion-stagger > * {
1262
+ animation: motion-stagger-arrive var(--duration-normal) var(--ease-enter) backwards;
1263
+ animation-delay: calc(var(--stagger-delay) * 7); /* children past the 8-cap arrive with the 8th */
1264
+ }
1265
+ ${Array.from({ length: 8 }, (_, i) => ` .motion-stagger > :nth-child(${i + 1}) { animation-delay: calc(var(--stagger-delay) * ${i}); }`).join("\n")}
1266
+ }
1267
+
815
1268
  /* ── Accessibility ── */
816
1269
  @media (prefers-reduced-motion: reduce) {
817
1270
  :root, [data-style] {
818
- --duration-unit: 0ms; --stagger-delay: 0ms;
1271
+ --duration-unit: 0ms; --stagger-unit: 0ms; --stagger-delay: 0ms;
819
1272
  --ease-default: linear; --ease-enter: linear; --ease-exit: linear;
820
1273
  --feedback-press-scale: 1;
821
1274
  }
@@ -838,15 +1291,17 @@ function emitGlobalsCss(doc) {
838
1291
 
839
1292
  return `/* src/styles/globals.css — Tailwind v4 glue + @theme inline mappings (emitted). */
840
1293
 
841
- /* CASCADE CONTRACT 2: this four-step order is LOAD-BEARING. The library's
842
- * styles.css ships its own :root token defaults and its own @theme radius scale;
843
- * our @theme MUST come AFTER the import to win the radius scale and own every
844
- * mapping. rules/frontend.md states the same order — co-authority. */
845
- @import "./personality.css"; /* 1. token source (must be first) */
846
- @import "tailwindcss"; /* 2. framework */
847
- @import "@nurix/components/styles.css"; /* 3. library (before our @theme) */
848
-
849
- @theme inline { /* 4. consumer theme — AFTER the library, so it wins */
1294
+ /* CASCADE CONTRACT 2: this five-step order is LOAD-BEARING. fonts.css registers the
1295
+ * @fontsource faces before any stack consumes them; the library's styles.css ships
1296
+ * its own :root token defaults and its own @theme radius scale, so our @theme MUST
1297
+ * come AFTER the import to win the radius scale and own every mapping.
1298
+ * rules/frontend.md states the same order — co-authority. */
1299
+ @import "./fonts.css"; /* 1. font registration (faces before stacks) */
1300
+ @import "./personality.css"; /* 2. token source */
1301
+ @import "tailwindcss"; /* 3. framework */
1302
+ @import "@nurix/components/styles.css"; /* 4. library (before our @theme) */
1303
+
1304
+ @theme inline { /* 5. consumer theme — AFTER the library, so it wins */
850
1305
  /* radius scale — shadcn offsets on --radius, re-asserted so the CONSUMER owns it.
851
1306
  * A flat personality sets --radius: 0 → every step collapses to 0. */
852
1307
  --radius-sm: calc(var(--radius) - 4px);
@@ -859,12 +1314,14 @@ function emitGlobalsCss(doc) {
859
1314
  * tier AND the per-surface density step into the library's internals. */
860
1315
  --spacing: calc(0.25rem * var(--density-gap));
861
1316
 
862
- /* shadow — alpha rides --shadow-intensity (matches the library's shadow-xs/sm/md/lg use) */
1317
+ /* shadow — the elevation.* composites become the shadow-* utilities; every alpha rides
1318
+ * --shadow-intensity (2xs/xs stay the neutral slate hairlines the library leans on) */
863
1319
  --shadow-2xs: 0 1px 2px 0 rgb(15 23 42 / calc(0.04 * var(--shadow-intensity)));
864
1320
  --shadow-xs: 0 1px 2px 0 rgb(15 23 42 / calc(0.06 * var(--shadow-intensity)));
865
- --shadow-sm: 0 1px 3px 0 rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 1px 2px -1px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
866
- --shadow-md: 0 4px 6px -1px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 2px 4px -2px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
867
- --shadow-lg: 0 10px 15px -3px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 4px 6px -4px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
1321
+ --shadow-sm: ${doc.elevation.sm};
1322
+ --shadow-md: ${doc.elevation.md};
1323
+ --shadow-lg: ${doc.elevation.lg};
1324
+ --shadow-overlay: ${doc.elevation.overlay};
868
1325
 
869
1326
  /* font weight — two-weight discipline: medium/semibold/bold all resolve to the
870
1327
  * single emphasis weight; operator UI carries no third weight. normal is emitted
@@ -878,6 +1335,16 @@ function emitGlobalsCss(doc) {
878
1335
  --tracking-tighter: var(--heading-tracking);
879
1336
  --tracking-tight: var(--heading-tracking);
880
1337
 
1338
+ /* type ramp — the seven authored steps as text-* utilities (typ-09: the type scale IS the
1339
+ * declared ramp; text-display is the scale-drama step, brand-forward surfaces only, typ-20) */
1340
+ ${RAMP_STEPS.map((step) => {
1341
+ const r = doc.ramp[step];
1342
+ return ` --text-${step}: ${r.fontSize};
1343
+ --text-${step}--line-height: ${r.lineHeight};
1344
+ --text-${step}--font-weight: ${r.fontWeight};
1345
+ --text-${step}--letter-spacing: ${r.letterSpacing};`;
1346
+ }).join("\n")}
1347
+
881
1348
  /* fonts — emitted as the literal stack: a font is not a runtime-toggled token, and
882
1349
  * the theme key shares a name with the personality primitive (no self-reference).
883
1350
  * personality.css carries the same --font-sans/--font-mono for direct var() use. */
@@ -891,15 +1358,17 @@ function emitGlobalsCss(doc) {
891
1358
  --transition-enter: var(--duration-normal) var(--ease-enter);
892
1359
  --transition-exit: var(--duration-fast) var(--ease-exit);
893
1360
 
894
- /* semantic + substrate color maps — all 34, so bg-popover / bg-chart-1 / bg-sidebar resolve.
1361
+ /* semantic + substrate color maps — all 36, so bg-popover / bg-chart-1 / bg-sidebar and the
1362
+ * dark ladder's bg-surface-raised / bg-surface-overlay resolve.
895
1363
  * --color-destructive-foreground is mapped explicitly (D6): the library leaves it unmapped. */
896
1364
  ${colorMaps}
897
1365
  }
898
1366
 
899
- /* dark variant — emitted ONLY when the project ships dark; redefines the library's
900
- * class-based dark to follow data-theme so the library's dark: utilities honor our
901
- * convention. Absent for a light-only project.
902
- @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); */
1367
+ /* dark variant — ALWAYS ACTIVE: redefines the library's class-based dark: variant to
1368
+ * follow the data-theme attribute, so library dark: utilities (legal inside the library,
1369
+ * banned in project JSX by the no-literals rule) track the same attribute the theme
1370
+ * blocks armor. Inert until <html data-theme="dark">; harmless for a light-only project. */
1371
+ @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
903
1372
  `;
904
1373
  }
905
1374
 
@@ -932,7 +1401,7 @@ function writeFileEnsured(path, content) {
932
1401
 
933
1402
  // Print usage and exit non-zero.
934
1403
  function usage() {
935
- process.stderr.write("usage: derive-personality.mjs <artifact-dir> [--check | --emit <outdir>] [--theme light|dark]\n");
1404
+ process.stderr.write("usage: derive-personality.mjs <artifact-dir> [--check | --emit <outdir> [--provisional]] [--theme light|dark]\n");
936
1405
  process.exit(2);
937
1406
  }
938
1407
 
@@ -942,20 +1411,23 @@ function main() {
942
1411
  let artifactDir = null;
943
1412
  let mode = "check";
944
1413
  let outdir = null;
945
- let theme = "light";
1414
+ let theme = null; // null = follow the payload's theme-default
1415
+ let provisional = false;
946
1416
  for (let i = 0; i < argv.length; i++) {
947
1417
  const a = argv[i];
948
1418
  if (a === "--check") mode = "check";
949
1419
  else if (a === "--emit") { mode = "emit"; outdir = argv[++i] ?? null; }
950
1420
  else if (a === "--theme") { theme = argv[++i] ?? null; }
1421
+ else if (a === "--provisional") provisional = true;
951
1422
  else if (!a.startsWith("-") && artifactDir === null) artifactDir = a;
952
1423
  else usage();
953
1424
  }
954
1425
  if (!artifactDir) usage();
955
1426
  if (mode === "emit" && !outdir) usage();
956
- if (!LEGAL_THEMES.includes(theme)) usage();
1427
+ if (provisional && mode !== "emit") usage();
1428
+ if (theme !== null && !LEGAL_THEMES.includes(theme)) usage();
957
1429
 
958
- const { errors, payload, views, hints, hasDark } = runCheck(artifactDir, theme);
1430
+ const { errors, payload, views, hints, theme: bootTheme, hasDark, hasWash } = runCheck(artifactDir, theme);
959
1431
 
960
1432
  if (errors.length > 0) {
961
1433
  process.stderr.write(`check FAILED (${artifactDir}): ${errors.length} issue(s)\n`);
@@ -967,39 +1439,59 @@ function main() {
967
1439
 
968
1440
  if (mode === "check") {
969
1441
  const hintNote = hints.length > 0 ? `, ${hints.length} hint(s) outstanding (emit will refuse)` : "";
970
- process.stdout.write(`check PASSED (${artifactDir}): rules 1–11 OK — 26 primitives, 34 color roles, typography 3+6, payload/manifest/parse-back OK — provenance=${payload.provenance}, style=${slug}, theme=${theme}${hasDark ? ", dark overlay present" : ""}${hintNote}\n`);
1442
+ process.stdout.write(`check PASSED (${artifactDir}): rules 1–15 OK — 27 primitives, 36 color roles, typography 3+7, elevation 4${hasWash ? ", wash present" : ""}, payload/manifest/parse-back OK — provenance=${payload.provenance}, style=${slug}, theme=${bootTheme}${hasDark ? ", dark overlay present" : ""}${hintNote}\n`);
971
1443
  process.exit(0);
972
1444
  }
973
1445
 
974
- // Rule 12 — emission precondition: refuse while any token carries a hint.
975
- if (hints.length > 0) {
1446
+ // Rule 12 — emission precondition: refuse while any token carries a hint. --provisional (the
1447
+ // golden/regression flow) emits hinted tokens at their provisional $values instead — loudly.
1448
+ if (hints.length > 0 && !provisional) {
976
1449
  process.stderr.write(`emit REFUSED (${artifactDir}): ${hints.length} unresolved hint(s) — rule 12: every gated token must be concrete before emission.\n`);
977
1450
  for (const h of hints) process.stderr.write(` - ${h.key}: hint: ${h.text}\n`);
978
1451
  process.stderr.write(" Resolve each with judgment: set the real $value, delete the hint key, record the rationale in $description — then re-run --emit.\n");
979
1452
  process.exit(1);
980
1453
  }
1454
+ if (hints.length > 0) {
1455
+ process.stdout.write(`emit PROVISIONAL (${artifactDir}): ${hints.length} hint(s) emitted at their provisional $values — regression/golden flow only, never shipped output.\n`);
1456
+ }
981
1457
 
982
1458
  const emitErrors = [];
983
1459
  const doc = buildEmitterDoc(views, payload, emitErrors);
1460
+ checkEmitterCompleteness(views, emitErrors);
1461
+ const fontBinding = buildFontBinding(views, doc, outdir, emitErrors);
984
1462
  if (emitErrors.length > 0) {
985
1463
  process.stderr.write(`emit FAILED (${artifactDir}): ${emitErrors.length} issue(s)\n`);
986
1464
  for (const e of emitErrors) process.stderr.write(` - ${e}\n`);
987
1465
  process.exit(1);
988
1466
  }
989
1467
 
990
- const meta = { provenance: payload.provenance, sourced: payload.sourced, theme };
1468
+ // Contract 2 — the cod-16 font-binding gate, fail-closed at --emit. --provisional (the golden/
1469
+ // regression flow over hint-carrying or brand-font library twins) demotes it to a loud warning.
1470
+ if (fontBinding.failures.length > 0) {
1471
+ if (!provisional) {
1472
+ process.stderr.write(`emit FAILED (${artifactDir}): font binding (cod-16) — ${fontBinding.failures.length} unbound famil${fontBinding.failures.length === 1 ? "y" : "ies"}\n`);
1473
+ for (const f of fontBinding.failures) process.stderr.write(` - ${f}\n`);
1474
+ process.exit(1);
1475
+ }
1476
+ process.stdout.write(`emit PROVISIONAL font-binding warning (${artifactDir}): ${fontBinding.failures.length} unbound famil${fontBinding.failures.length === 1 ? "y" : "ies"} — cod-16 fails closed at a real --emit\n`);
1477
+ for (const f of fontBinding.failures) process.stdout.write(` - ${f}\n`);
1478
+ }
1479
+
1480
+ const meta = { provenance: payload.provenance, sourced: payload.sourced, theme: bootTheme };
991
1481
  const personalityCss = emitPersonalityCss(doc, slug, meta);
992
1482
  const globalsCss = emitGlobalsCss(doc);
993
1483
  const htmlAttrs = emitIndexHtmlAttributes(doc, slug, meta);
994
1484
  const personalityPath = join(outdir, "src/styles/personality.css");
995
1485
  const globalsPath = join(outdir, "src/styles/globals.css");
1486
+ const fontsPath = join(outdir, "src/styles/fonts.css");
996
1487
  const attrsPath = join(outdir, "index-html-attributes.txt");
997
1488
  writeFileEnsured(personalityPath, personalityCss);
998
1489
  writeFileEnsured(globalsPath, globalsCss);
1490
+ writeFileEnsured(fontsPath, fontBinding.fontsCss);
999
1491
  writeFileEnsured(attrsPath, htmlAttrs);
1000
1492
 
1001
1493
  process.stdout.write(`emitted personality system for style=${slug} → ${outdir}\n`);
1002
- process.stdout.write(` ${personalityPath}\n ${globalsPath}\n ${attrsPath}\n`);
1494
+ process.stdout.write(` ${personalityPath}\n ${globalsPath}\n ${fontsPath}\n ${attrsPath}\n`);
1003
1495
  process.exit(0);
1004
1496
  }
1005
1497