@nurix/etna 0.19.3 → 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 (119) hide show
  1. package/data/harness-index.json +1 -1
  2. package/data/harnesses/code/harness.json +1 -1
  3. package/data/harnesses/code/rules/frontend.md +3 -3
  4. package/data/harnesses/code/skills/design/SKILL.md +20 -8
  5. package/data/harnesses/code/skills/design/data/catalog/rules.csv +10 -5
  6. package/data/harnesses/code/skills/design/data/combinations/Readme.md +8 -4
  7. package/data/harnesses/code/skills/design/data/combinations/accent-presets.md +113 -0
  8. package/data/harnesses/code/skills/design/data/combinations/fonts-known.json +11 -0
  9. package/data/harnesses/code/skills/design/data/combinations/manifest.json +57 -10
  10. package/data/harnesses/code/skills/design/data/combinations/motion.csv +10 -0
  11. package/data/harnesses/code/skills/design/data/combinations/palettes.csv +92 -49
  12. package/data/harnesses/code/skills/design/data/combinations/styles.csv +67 -54
  13. package/data/harnesses/code/skills/design/data/combinations/typography.csv +64 -61
  14. package/data/harnesses/code/skills/design/data/design-md/library/expo.md +4 -4
  15. package/data/harnesses/code/skills/design/data/design-md/library/lovable.md +5 -5
  16. package/data/harnesses/code/skills/design/data/design-md/library/minimax.md +6 -6
  17. package/data/harnesses/code/skills/design/data/design-md/library/notion.md +4 -4
  18. package/data/harnesses/code/skills/design/data/design-md/library/runwayml.md +4 -4
  19. package/data/harnesses/code/skills/design/data/design-md/library/sentry.md +2 -2
  20. package/data/harnesses/code/skills/design/data/design-md/library/slack.md +6 -6
  21. package/data/harnesses/code/skills/design/data/design-md/library/webflow.md +5 -5
  22. package/data/harnesses/code/skills/design/data/design-md/nurix.md +11 -7
  23. package/data/harnesses/code/skills/design/data/design-tokens/SCHEMA.md +256 -141
  24. package/data/harnesses/code/skills/design/data/design-tokens/airbnb/design.tokens.json +309 -1
  25. package/data/harnesses/code/skills/design/data/design-tokens/airtable/design.tokens.json +309 -1
  26. package/data/harnesses/code/skills/design/data/design-tokens/anthropic-claude/design.tokens.json +309 -1
  27. package/data/harnesses/code/skills/design/data/design-tokens/apple/design.tokens.json +309 -1
  28. package/data/harnesses/code/skills/design/data/design-tokens/binance/design.tokens.json +309 -1
  29. package/data/harnesses/code/skills/design/data/design-tokens/bmw/design.tokens.json +309 -1
  30. package/data/harnesses/code/skills/design/data/design-tokens/cal/design.tokens.json +313 -1
  31. package/data/harnesses/code/skills/design/data/design-tokens/clay/design.tokens.json +309 -1
  32. package/data/harnesses/code/skills/design/data/design-tokens/clickhouse/design.tokens.json +309 -1
  33. package/data/harnesses/code/skills/design/data/design-tokens/cohere/design.tokens.json +309 -1
  34. package/data/harnesses/code/skills/design/data/design-tokens/coinbase/design.tokens.json +309 -1
  35. package/data/harnesses/code/skills/design/data/design-tokens/composio/design.tokens.json +309 -1
  36. package/data/harnesses/code/skills/design/data/design-tokens/cursor/design.tokens.json +309 -1
  37. package/data/harnesses/code/skills/design/data/design-tokens/elevenlabs/design.tokens.json +313 -1
  38. package/data/harnesses/code/skills/design/data/design-tokens/expo/design.tokens.json +309 -1
  39. package/data/harnesses/code/skills/design/data/design-tokens/figma/design.tokens.json +313 -1
  40. package/data/harnesses/code/skills/design/data/design-tokens/framer/design.tokens.json +309 -1
  41. package/data/harnesses/code/skills/design/data/design-tokens/goldens.json +123 -123
  42. package/data/harnesses/code/skills/design/data/design-tokens/hashicorp/design.tokens.json +309 -1
  43. package/data/harnesses/code/skills/design/data/design-tokens/ibm/design.tokens.json +309 -1
  44. package/data/harnesses/code/skills/design/data/design-tokens/intercom/design.tokens.json +309 -1
  45. package/data/harnesses/code/skills/design/data/design-tokens/kraken/design.tokens.json +313 -1
  46. package/data/harnesses/code/skills/design/data/design-tokens/linear-app/design.resolver.json +1 -1
  47. package/data/harnesses/code/skills/design/data/design-tokens/linear-app/design.tokens.dark.json +10 -0
  48. package/data/harnesses/code/skills/design/data/design-tokens/linear-app/design.tokens.json +309 -1
  49. package/data/harnesses/code/skills/design/data/design-tokens/lovable/design.tokens.json +313 -1
  50. package/data/harnesses/code/skills/design/data/design-tokens/meta/design.tokens.json +313 -1
  51. package/data/harnesses/code/skills/design/data/design-tokens/minimax/design.tokens.json +309 -1
  52. package/data/harnesses/code/skills/design/data/design-tokens/mintlify/design.tokens.json +309 -1
  53. package/data/harnesses/code/skills/design/data/design-tokens/miro/design.tokens.json +313 -1
  54. package/data/harnesses/code/skills/design/data/design-tokens/mistral-ai/design.tokens.json +309 -1
  55. package/data/harnesses/code/skills/design/data/design-tokens/mongodb/design.tokens.json +313 -1
  56. package/data/harnesses/code/skills/design/data/design-tokens/notion/design.tokens.json +309 -1
  57. package/data/harnesses/code/skills/design/data/design-tokens/nurix/design.tokens.json +307 -2
  58. package/data/harnesses/code/skills/design/data/design-tokens/nvidia/design.tokens.json +309 -1
  59. package/data/harnesses/code/skills/design/data/design-tokens/ollama/design.tokens.json +309 -1
  60. package/data/harnesses/code/skills/design/data/design-tokens/opencode-ai/design.tokens.json +309 -1
  61. package/data/harnesses/code/skills/design/data/design-tokens/posthog/design.tokens.json +309 -1
  62. package/data/harnesses/code/skills/design/data/design-tokens/raycast/design.tokens.json +309 -1
  63. package/data/harnesses/code/skills/design/data/design-tokens/replicate/design.tokens.json +309 -1
  64. package/data/harnesses/code/skills/design/data/design-tokens/resend/design.resolver.json +1 -1
  65. package/data/harnesses/code/skills/design/data/design-tokens/resend/design.tokens.dark.json +10 -0
  66. package/data/harnesses/code/skills/design/data/design-tokens/resend/design.tokens.json +309 -1
  67. package/data/harnesses/code/skills/design/data/design-tokens/revolut/design.tokens.json +309 -1
  68. package/data/harnesses/code/skills/design/data/design-tokens/runwayml/design.tokens.json +310 -2
  69. package/data/harnesses/code/skills/design/data/design-tokens/sanity/design.tokens.json +313 -1
  70. package/data/harnesses/code/skills/design/data/design-tokens/sentry/design.tokens.json +309 -1
  71. package/data/harnesses/code/skills/design/data/design-tokens/shopify/design.tokens.json +309 -1
  72. package/data/harnesses/code/skills/design/data/design-tokens/slack/design.tokens.json +309 -1
  73. package/data/harnesses/code/skills/design/data/design-tokens/spacex/design.tokens.json +309 -1
  74. package/data/harnesses/code/skills/design/data/design-tokens/spotify/design.tokens.json +313 -1
  75. package/data/harnesses/code/skills/design/data/design-tokens/stripe/design.tokens.json +313 -1
  76. package/data/harnesses/code/skills/design/data/design-tokens/supabase/design.tokens.json +309 -1
  77. package/data/harnesses/code/skills/design/data/design-tokens/superhuman/design.tokens.json +313 -1
  78. package/data/harnesses/code/skills/design/data/design-tokens/tesla/design.tokens.json +309 -1
  79. package/data/harnesses/code/skills/design/data/design-tokens/theverge/design.tokens.json +309 -1
  80. package/data/harnesses/code/skills/design/data/design-tokens/together-ai/design.tokens.json +313 -1
  81. package/data/harnesses/code/skills/design/data/design-tokens/uber/design.tokens.json +309 -1
  82. package/data/harnesses/code/skills/design/data/design-tokens/vercel/design.tokens.json +309 -1
  83. package/data/harnesses/code/skills/design/data/design-tokens/voltagent/design.resolver.json +1 -1
  84. package/data/harnesses/code/skills/design/data/design-tokens/voltagent/design.tokens.dark.json +10 -0
  85. package/data/harnesses/code/skills/design/data/design-tokens/voltagent/design.tokens.json +309 -1
  86. package/data/harnesses/code/skills/design/data/design-tokens/warp/design.tokens.json +309 -1
  87. package/data/harnesses/code/skills/design/data/design-tokens/webflow/design.tokens.json +309 -1
  88. package/data/harnesses/code/skills/design/data/design-tokens/wired/design.tokens.json +309 -1
  89. package/data/harnesses/code/skills/design/data/design-tokens/wise/design.tokens.json +309 -1
  90. package/data/harnesses/code/skills/design/data/design-tokens/x-ai/design.tokens.json +309 -1
  91. package/data/harnesses/code/skills/design/data/design-tokens/zapier/design.tokens.json +309 -1
  92. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/command-center.tsx +25 -25
  93. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/create-edit.tsx +18 -18
  94. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/dashboard.tsx +82 -105
  95. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/entity-detail.tsx +97 -93
  96. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/list-detail.tsx +16 -15
  97. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/log-stream.tsx +34 -34
  98. package/data/harnesses/code/skills/design/data/operator-pack/surface-catalog.md +5 -5
  99. package/data/harnesses/code/skills/design/references/create.md +49 -7
  100. package/data/harnesses/code/skills/design/references/diagnosis.md +3 -3
  101. package/data/harnesses/code/skills/design/references/hard-rules.md +25 -3
  102. package/data/harnesses/code/skills/design/references/improve.md +32 -3
  103. package/data/harnesses/code/skills/design/references/interaction.md +3 -3
  104. package/data/harnesses/code/skills/design/references/introspect.md +1 -1
  105. package/data/harnesses/code/skills/design/references/lifecycle.md +2 -2
  106. package/data/harnesses/code/skills/design/references/personality.md +280 -133
  107. package/data/harnesses/code/skills/design/references/structure.md +22 -1
  108. package/data/harnesses/code/skills/design/scripts/convert-design-md.mjs +306 -26
  109. package/data/harnesses/code/skills/design/scripts/derive-personality.mjs +596 -104
  110. package/data/harnesses/code/skills/design/scripts/lib/checks.mjs +200 -11
  111. package/data/harnesses/code/skills/design/scripts/lib/vocabulary.mjs +65 -0
  112. package/data/harnesses/code/skills/design/scripts/phase-gate.mjs +95 -11
  113. package/data/harnesses/code/skills/design/scripts/preship-sweep.mjs +50 -5
  114. package/data/harnesses/code/skills/design/scripts/render-rung.mjs +214 -14
  115. package/data/harnesses/code/skills/design/scripts/scaffold.mjs +242 -25
  116. package/data/harnesses/code/skills/design/scripts/self-integrity.mjs +81 -6
  117. package/data/harnesses/code/skills/design/scripts/snapshot-goldens.mjs +101 -5
  118. package/data/harnesses/code/skills/design/scripts/validate-combinations.mjs +142 -17
  119. package/package.json +1 -1
@@ -49,6 +49,22 @@ Membership is closed. New archetypes enter only as pack data, ledger-recorded
49
49
 
50
50
  **Navigation derives from the object model, never decorated on.** Top-level nav entries are the primary entities and their home surfaces; secondary entities are reached through their parent's detail surface, not promoted. The unit of work is the home. Cap top-level entries at what stays countable on one hand — nav categories the model does not carry are a structural defect, not a convenience.
51
51
 
52
+ ## The delight budget — permission is structural
53
+
54
+ The object pass classifies **every** surface with a mandatory `budget` — `baseline` or `brand-forward`. Permission is a structural fact decided here, not a look value: the budget grants which token *vocabulary* a surface may consume, never a value. `scaffold.mjs` stamps the budget into each route file's header comment (`// Surface: baseline` / `// Surface: brand-forward (delight-allowing)`); `phase-gate.mjs surfaces` reads the stamp back.
55
+
56
+ **Defaults by archetype** — assign the default, override only against a recorded reason:
57
+
58
+ | Archetype | Default | Note |
59
+ | --- | --- | --- |
60
+ | `list-detail`, `log-stream`, `command-center` | `baseline` | throughput surfaces; decoration is noise here |
61
+ | `dashboard`, `entity-detail` (header region), `create-edit` (empty / success moments) | `brand-forward` **candidate** | granted when the look's `delight` is `expressive` or the brief asks for presence; else `baseline` |
62
+
63
+ - **Overridable, against a recorded reason.** A budget that contradicts the archetype default — a `brand-forward` stamp on a `baseline`-default archetype — carries a `budget-reason` on that surface entry, or the surfaces gate fails. Candidacy is not a grant: a candidate archetype still defaults `baseline` and only rises to `brand-forward` when the look or brief earns it.
64
+ - **`baseline` blocks decoration, never signal.** A `baseline` stamp never suppresses status or feedback color — error red, warning amber, the success tick all render at every budget. It withholds *ornament*: the display ramp step, elevation lifts, washes, arrival motion.
65
+ - **What `brand-forward` unlocks** — all still tokens, all still on-scale: the `display` ramp step (one per surface), `elevation.lg`, the `wash.*` tokens, the motion moments in `motion.csv`, and the generous spacing tier.
66
+ - **What no budget ever unlocks:** literal colors, off-manifest values, slop-gradient vocabulary, serif in dashboards. The floor is budget-independent — a brand-forward surface clears the same rules a baseline one does. Expressiveness is granted *inside* the floor, never as an exception to it.
67
+
52
68
  ## The map: `docs/design/structure.md`
53
69
 
54
70
  The derivation's whole output. Human prose above the block; the fenced `yaml structure` block is the machine contract — `scaffold.mjs` reads it, so the field names are law. Scaffold generates routes, navigation wiring, and per-surface state-shell stubs from `surfaces[]`, and keys fixture wiring off `entities[]`.
@@ -73,6 +89,8 @@ surfaces:
73
89
  route: /<path>
74
90
  entity: <Entity>
75
91
  density-modifier: <integer step; positive = denser; mirrors the catalog's per-archetype modifier>
92
+ budget: baseline | brand-forward # mandatory; the surface's delight budget
93
+ budget-reason: <one line> # required only when budget overrides the archetype default
76
94
  states: [loading, empty, error, partial] # applicable subset; data surfaces carry all four
77
95
  ```
78
96
 
@@ -120,19 +138,22 @@ surfaces:
120
138
  route: /tickets
121
139
  entity: Ticket
122
140
  density-modifier: 1
141
+ budget: baseline
123
142
  states: [loading, empty, error, partial]
124
143
  - name: Ticket Detail
125
144
  archetype: entity-detail
126
145
  route: /tickets/:id
127
146
  entity: Ticket
128
147
  density-modifier: 0
148
+ budget: baseline
129
149
  states: [loading, empty, error, partial]
130
150
  - name: New Ticket
131
151
  archetype: create-edit
132
152
  route: /tickets/new
133
153
  entity: Ticket
134
154
  density-modifier: -1
155
+ budget: baseline
135
156
  states: [loading, error]
136
157
  ```
137
158
 
138
- Navigation: top-level is **Tickets** — the unit of work's home. Customer and Agent are reached through Ticket Detail, not promoted to top-level nav (derived, not decorated). History — the audit of status changes and replies — surfaces as a timeline on the detail, not a fifth temporal key. OOUX pass: the status pill renders identically on queue and detail (no Shapeshifter), and Customer links out to its own detail rather than living only as a string (no Masked).
159
+ Navigation: top-level is **Tickets** — the unit of work's home. Customer and Agent are reached through Ticket Detail, not promoted to top-level nav (derived, not decorated). History — the audit of status changes and replies — surfaces as a timeline on the detail, not a fifth temporal key. OOUX pass: the status pill renders identically on queue and detail (no Shapeshifter), and Customer links out to its own detail rather than living only as a string (no Masked). Budgets: every surface stamps `baseline` — the brief states no expressive delight, so the two candidate archetypes (Ticket Detail's header, New Ticket's success moment) stay at their default; a queue is `baseline` outright. No `budget-reason` is needed because no stamp overrides an archetype default.
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // skills/design/scripts/convert-design-md.mjs — mechanical design.md → design-tokens converter (schema design-tokens/v1, DTCG 2025.10): parses each source's frontmatter + two machine blocks + §3 type ramp, emits design.tokens.json / design.tokens.dark.json / design.resolver.json per data/design-tokens/SCHEMA.md, self-validates (schema rules 2–10), and offers --strip to remove the machine blocks (and their orphaned prose references) from source files once the byte-match gate passes.
2
+ // skills/design/scripts/convert-design-md.mjs — mechanical design.md → design-tokens converter (schema design-tokens/v2, DTCG 2025.10): parses each source's frontmatter + two machine blocks + §3 type ramp, emits design.tokens.json / design.tokens.dark.json / design.resolver.json per data/design-tokens/SCHEMA.md (running the v1→v2 upgrade transform over each converted entry), self-validates (schema rules 2–10 + the v2 groups), offers --upgrade-v2 to run the same idempotent transform over existing artifact directories, and --strip to remove the machine blocks (and their orphaned prose references) from source files once the byte-match gate passes.
3
3
  //
4
4
  // Usage:
5
5
  // node convert-design-md.mjs convert every entry (nurix.md + library/*.md), then validate
6
6
  // node convert-design-md.mjs --only <slug> convert/validate a single entry
7
7
  // node convert-design-md.mjs --validate validate existing artifacts only (no conversion)
8
+ // node convert-design-md.mjs --upgrade-v2 idempotent v1→v2 migration over data/design-tokens/*/, then validate
8
9
  // node convert-design-md.mjs --report <file> also write the JSON conversion report to <file>
9
10
  // node convert-design-md.mjs --strip <file...> remove both machine blocks + orphaned prose from the named
10
11
  // .md files IN PLACE (explicit paths required — never implicit)
@@ -22,7 +23,8 @@ const tokensDir = join(skillDir, "data", "design-tokens");
22
23
  // Vocabulary (names are law — mirrors data/design-tokens/SCHEMA.md)
23
24
  // ---------------------------------------------------------------------------
24
25
 
25
- // The 26 visual primitives in emission order, with their DTCG $type.
26
+ // The 26 v1 source primitives in emission order, with their DTCG $type (the design.md machine
27
+ // blocks carry these; primitive.stagger-unit is added by the v1→v2 upgrade transform).
26
28
  const PRIMITIVE_TYPES = new Map([
27
29
  ["radius", "dimension"], ["shadow-intensity", "number"], ["border-weight", "dimension"],
28
30
  ["density-gap", "number"], ["density-inset", "number"],
@@ -40,7 +42,12 @@ const PRIMITIVE_TYPES = new Map([
40
42
  ]);
41
43
  const PRIMITIVES = [...PRIMITIVE_TYPES.keys()];
42
44
 
43
- // The 34 color roles in emission order (17 semantic + 17 substrate).
45
+ // The 27 v2 primitives with stagger-unit in its emission slot (after ease-default).
46
+ const PRIMITIVE_TYPES_V2 = new Map(
47
+ [...PRIMITIVE_TYPES].flatMap(([k, t]) => (k === "ease-default" ? [[k, t], ["stagger-unit", "duration"]] : [[k, t]])),
48
+ );
49
+
50
+ // The 34 v1 source color roles in emission order (17 semantic + 17 substrate; the machine blocks carry these).
44
51
  const COLOR_ROLES = [
45
52
  "background", "foreground", "card", "card-foreground", "muted", "muted-foreground",
46
53
  "border", "input", "ring", "primary", "primary-foreground", "accent", "accent-foreground",
@@ -51,7 +58,12 @@ const COLOR_ROLES = [
51
58
  "sidebar-accent", "sidebar-accent-foreground", "sidebar-border", "sidebar-ring",
52
59
  ];
53
60
 
61
+ // The 36 v2 roles: the dark-ladder pair (surface-raised/surface-overlay) closes the semantic set at 19.
62
+ const COLOR_ROLES_V2 = COLOR_ROLES.flatMap((r) => (r === "warning" ? [r, "surface-raised", "surface-overlay"] : [r]));
63
+
54
64
  const RAMP_STEPS = ["h1", "h2", "h3", "body", "small", "caption"];
65
+ const RAMP_STEPS_V2 = [...RAMP_STEPS, "display"];
66
+ const ELEVATION_TOKENS = ["sm", "md", "lg", "overlay"];
55
67
  // Source §3 row names that fill the caption slot when no literal `caption` row exists.
56
68
  const CAPTION_ALIASES = ["caption", "eyebrow / caption", "eyebrow", "label", "mono-label"];
57
69
 
@@ -59,8 +71,8 @@ const LEGAL_VERBOSITY = ["full", "default", "compact", "expert"];
59
71
  const LEGAL_FEEDBACK = ["subtle", "minimal"];
60
72
  const LEGAL_DENSITY = ["spacious", "standard", "dense"];
61
73
  const LEGAL_PROVENANCE = ["project", "extracted", "library", "default"];
62
- const LEGAL_TYPES = ["color", "dimension", "duration", "cubicBezier", "fontFamily", "fontWeight", "number", "typography"];
63
- const SHADOW_RANK = { none: 0, "2xs": 1, xs: 2, sm: 3, md: 4, lg: 5, xl: 6, "2xl": 7 };
74
+ const LEGAL_TYPES = ["color", "dimension", "duration", "cubicBezier", "fontFamily", "fontWeight", "number", "typography", "shadow", "gradient"];
75
+ const SHADOW_RANK = { none: 0, "2xs": 1, xs: 2, sm: 3, md: 4, lg: 5, xl: 6, "2xl": 7, overlay: 8 };
64
76
  const EXT = "com.nurix.design";
65
77
 
66
78
  // Provisional values worn when a source key is a `hint:` (the Nurix defaults, per SCHEMA.md examples).
@@ -151,16 +163,38 @@ function parseColorCss(css) {
151
163
  return value;
152
164
  }
153
165
  m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(s);
166
+ if (!m) m = /^rgba?\(\s*(\d+)\s+(\d+)\s+(\d+)\s*(?:\/\s*([\d.]+%?)\s*)?\)$/.exec(s); // modern space syntax
154
167
  if (m) {
155
168
  const bytes = [m[1], m[2], m[3]].map(Number);
156
169
  const hex = "#" + bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
157
170
  const value = { colorSpace: "srgb", components: bytes.map((b) => round5(b / 255)), hex };
158
- if (m[4] !== undefined) value.alpha = parseFloat(m[4]);
171
+ if (m[4] !== undefined) value.alpha = m[4].endsWith("%") ? round5(parseFloat(m[4]) / 100) : parseFloat(m[4]);
159
172
  return value;
160
173
  }
161
174
  throw new Error(`unparseable color: ${JSON.stringify(css)}`);
162
175
  }
163
176
 
177
+ // Parse one shadow-list string (the elevation byte form) into the DTCG layer array; byte-compatible
178
+ // with derive-personality.mjs. A layer alpha may ride calc(A * var(--shadow-intensity)) — A is the alpha.
179
+ function parseShadowCss(css) {
180
+ return splitTopLevel(css, ",").map((layerRaw) => {
181
+ const s = layerRaw.trim();
182
+ const colorStart = s.search(/rgba?\(|oklch\(|hsla?\(|#[0-9a-fA-F]/);
183
+ if (colorStart < 0) throw new Error(`shadow layer names no color: ${JSON.stringify(s)}`);
184
+ const colorCss = s.slice(colorStart).trim().replace(/calc\(\s*([\d.]+)\s*\*\s*var\(--shadow-intensity\)\s*\)/, "$1");
185
+ const dims = s.slice(0, colorStart).trim().split(/\s+/);
186
+ if (dims.length < 3 || dims.length > 4) throw new Error(`shadow layer wants 3-4 lengths: ${JSON.stringify(s)}`);
187
+ const dim = (d) => (d === "0" ? { value: 0, unit: "px" } : parseDimensionCss(d));
188
+ return {
189
+ color: parseColorCss(colorCss),
190
+ offsetX: dim(dims[0]),
191
+ offsetY: dim(dims[1]),
192
+ blur: dim(dims[2]),
193
+ spread: dims[3] !== undefined ? dim(dims[3]) : { value: 0, unit: "px" },
194
+ };
195
+ });
196
+ }
197
+
164
198
  // Parse a tracking string ("0" | "0em" | "-0.015em") into its em-multiplier number.
165
199
  function parseTrackingCss(css) {
166
200
  const m = /^([+-]?\d*\.?\d+)(em)?$/.exec(css.trim());
@@ -637,6 +671,202 @@ function convertEntry(source, slug) {
637
671
  return { base, dark, resolver, notes };
638
672
  }
639
673
 
674
+ // ---------------------------------------------------------------------------
675
+ // v1 → v2 upgrade transform (SCHEMA.md migration; idempotent — every step skips when already applied)
676
+ // ---------------------------------------------------------------------------
677
+
678
+ // The v1 hardcoded slate shadow recipes (the old globals.css @theme block) reified as elevation byte
679
+ // forms — value-preserving: the calc(α × --shadow-intensity) rider keeps the twin's shadow-intensity
680
+ // as the master multiplier, so emitted --shadow-sm/md/lg are byte-identical before/after migration.
681
+ // overlay is the widest-blur member of the same family (new in v2, additive).
682
+ const V1_SHADOW_RECIPES = {
683
+ 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)))",
684
+ 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)))",
685
+ 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)))",
686
+ 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)))",
687
+ };
688
+
689
+ // Collect design.md code spans that name real shadow values — a color function (shadow tints ride
690
+ // alpha'd rgba/oklch) or box-shadow geometry (>= 2 consecutive lengths) on a shadow-context line.
691
+ // A bare hex near the word "shadow" (a border color in a "never shadow" sentence) is not one.
692
+ function findShadowProse(designMd) {
693
+ if (designMd === null) return [];
694
+ const evidence = [];
695
+ const lines = designMd.split("\n");
696
+ for (const [i, line] of lines.entries()) {
697
+ // The shadow context may sit on the previous line when the prose wraps; a negated mention
698
+ // ("no shadow", "not shadow", "never ... shadow", "shadowless") is not a shadow context.
699
+ const window = `${lines[i - 1] ?? ""} ${line}`
700
+ .replace(/\b(?:no|not|never|without|zero)\b[^.;:]*?shadow\w*/gi, "")
701
+ .replace(/shadow-?less/gi, "");
702
+ if (!/shadow/i.test(window)) continue;
703
+ for (const m of line.matchAll(/`[^`]+`/g)) {
704
+ const span = m[0];
705
+ const hasColorFn = /rgba?\(|oklch\(|hsla?\(/.test(span);
706
+ const hasGeometry = /(?:^|[\s`])(?:-?\d+(?:\.\d+)?px|0)\s+(?:-?\d+(?:\.\d+)?px|0)(?:\s|`|,)/.test(span);
707
+ if (hasColorFn || hasGeometry) evidence.push(span);
708
+ }
709
+ }
710
+ return evidence;
711
+ }
712
+
713
+ // True when a design.md declares the dark theme as the product's default.
714
+ function declaresDarkFirst(designMd) {
715
+ return designMd !== null && /dark by default|default to dark|wear the dark theme by default/i.test(designMd);
716
+ }
717
+
718
+ // Rebuild an object with `additions` ([key, value] pairs) inserted immediately after `afterKey`.
719
+ function insertAfter(obj, afterKey, additions) {
720
+ const out = {};
721
+ for (const [k, v] of Object.entries(obj)) {
722
+ out[k] = v;
723
+ if (k === afterKey) for (const [ak, av] of additions) out[ak] = av;
724
+ }
725
+ return out;
726
+ }
727
+
728
+ // Apply the seven-step v1→v2 migration to one artifact's documents in place-order (idempotent:
729
+ // every step is skipped when its key already exists). Returns per-entry stats.
730
+ function upgradeArtifact(base, dark, resolver, designMd, slug) {
731
+ const stats = { elevationHinted: false, darkFirst: false, displayHinted: false };
732
+ const payload = base.$extensions?.[EXT];
733
+ if (!payload) throw new Error("upgrade: no payload on the base root");
734
+
735
+ // Step 1 — the schema literal.
736
+ payload.schema = "design-tokens/v2";
737
+
738
+ // Step 6 — theme-default: light everywhere except twins whose design.md declares dark-first
739
+ // (only a twin shipping a dark set can boot dark); the resolver's theme default mirrors it.
740
+ if (payload["theme-default"] === undefined) {
741
+ const darkFirst = dark !== null && declaresDarkFirst(designMd);
742
+ base.$extensions[EXT] = insertAfter(payload, "sourced", [["theme-default", darkFirst ? "dark" : "light"]]);
743
+ }
744
+ stats.darkFirst = base.$extensions[EXT]["theme-default"] === "dark";
745
+ const themeModifier = (resolver.modifiers ?? []).find((m) => m?.name === "theme");
746
+ if (themeModifier) themeModifier.default = base.$extensions[EXT]["theme-default"];
747
+
748
+ // The Nurix default's stated v2 shadow vocabulary (SCHEMA.md elevation section).
749
+ if (slug === "nurix") base.$extensions[EXT]["lever-manifest"]["shadow-levels"] = ["none", "sm", "md", "lg", "overlay"];
750
+
751
+ // Step 2 — elevation from the v1 slate recipes × the twin's shadow-intensity; hinted when the
752
+ // design.md names real shadow values (judgment owed: the composites should carry that prose).
753
+ if (base.elevation === undefined) {
754
+ const prose = findShadowProse(designMd);
755
+ stats.elevationHinted = prose.length > 0;
756
+ const elevation = {};
757
+ for (const level of ELEVATION_TOKENS) {
758
+ const css = V1_SHADOW_RECIPES[level];
759
+ const ext = { css };
760
+ if (prose.length > 0) {
761
+ ext.hint = `elevation unset — the design.md names real shadow values (${prose.slice(0, 3).join("; ")}); resolve the composites from that prose at the data build`;
762
+ }
763
+ elevation[level] = {
764
+ $type: "shadow",
765
+ $value: parseShadowCss(css),
766
+ $description: "derived: v1 shadow-intensity migration",
767
+ $extensions: { [EXT]: ext },
768
+ };
769
+ }
770
+ base.elevation = elevation;
771
+ } else {
772
+ stats.elevationHinted = ELEVATION_TOKENS.some((l) => base.elevation?.[l]?.$extensions?.[EXT]?.hint !== undefined);
773
+ }
774
+
775
+ // Step 3 — the dark-ladder roles as aliases: zero visual change in light; a dark set gains the
776
+ // same aliases (they resolve post-overlay to the dark card/popover).
777
+ if (base.color["surface-raised"] === undefined) {
778
+ base.color = insertAfter(base.color, "warning", [
779
+ ["surface-raised", { $type: "color", $value: "{color.card}", $description: "derived: v1→v2 migration — light aliases card; dark looks carry the surface-lightness ladder" }],
780
+ ["surface-overlay", { $type: "color", $value: "{color.popover}", $description: "derived: v1→v2 migration — light aliases popover; dark looks carry the surface-lightness ladder" }],
781
+ ]);
782
+ }
783
+ if (dark !== null && dark.color["surface-raised"] === undefined) {
784
+ dark.color = insertAfter(dark.color, "warning", [
785
+ ["surface-raised", { $type: "color", $value: "{color.card}", $description: "derived: v1→v2 migration — aliased to the dark card; the +4–6 L surface ladder is owed at budget-time" }],
786
+ ["surface-overlay", { $type: "color", $value: "{color.popover}", $description: "derived: v1→v2 migration — aliased to the dark popover; the +4–6 L surface ladder is owed at budget-time" }],
787
+ ]);
788
+ }
789
+
790
+ // Step 4 — the display step, hinted (provisional: h1 × 1.75, strong weight). The Nurix default
791
+ // adopts the provisional derivation as its own look, assigned, so emission stays possible.
792
+ if (base.typography?.ramp && base.typography.ramp.display === undefined) {
793
+ const h1 = base.typography.ramp.h1;
794
+ if (!h1?.$value?.fontSize?.value) throw new Error("upgrade: ramp.h1 carries no fontSize to scale");
795
+ const display = {
796
+ $type: "typography",
797
+ $value: {
798
+ fontFamily: h1.$value.fontFamily,
799
+ fontSize: { value: Math.round(h1.$value.fontSize.value * 1.75), unit: "px" },
800
+ lineHeight: h1.$value.lineHeight,
801
+ fontWeight: "{primitive.font-weight-strong}",
802
+ letterSpacing: h1.$value.letterSpacing,
803
+ },
804
+ };
805
+ if (slug === "nurix") {
806
+ display.$description = "derived: v2 display step — the provisional h1 × 1.75 derivation adopted as the default's own look (KPI numerals, the one focal metric)";
807
+ } else {
808
+ display.$description = "derived: v1→v2 migration — provisional display step (h1 × 1.75, strong weight)";
809
+ display.$extensions = { [EXT]: { hint: "display step unset — resolve from the product's design.md or leave for budget-time" } };
810
+ }
811
+ base.typography.ramp.display = display;
812
+ }
813
+ stats.displayHinted = base.typography?.ramp?.display?.$extensions?.[EXT]?.hint !== undefined;
814
+
815
+ // Step 5 — the stagger base, 30ms default (the prior derived clamp's value).
816
+ if (base.primitive["stagger-unit"] === undefined) {
817
+ base.primitive = insertAfter(base.primitive, "ease-default", [
818
+ ["stagger-unit", { $type: "duration", $value: { value: 30, unit: "ms" }, $description: "derived: v1→v2 migration — the 30ms default stagger base (0–60ms legal)" }],
819
+ ]);
820
+ }
821
+
822
+ return stats;
823
+ }
824
+
825
+ // Every artifact directory under data/design-tokens/ (a directory holding design.tokens.json), sorted.
826
+ function listArtifactSlugs() {
827
+ return readdirSync(tokensDir, { withFileTypes: true })
828
+ .filter((d) => d.isDirectory() && existsSync(join(tokensDir, d.name, "design.tokens.json")))
829
+ .map((d) => d.name)
830
+ .sort();
831
+ }
832
+
833
+ // The design.md source text for one artifact slug, or null when the twin ships no source.
834
+ function readDesignMd(slug) {
835
+ const path = slug === "nurix" ? join(designMdDir, "nurix.md") : join(designMdDir, "library", `${slug}.md`);
836
+ return existsSync(path) ? readFileSync(path, "utf8") : null;
837
+ }
838
+
839
+ // Run the idempotent v1→v2 migration over the existing artifact directories, then re-validate.
840
+ function runUpgradeV2(only) {
841
+ const slugs = listArtifactSlugs().filter((s) => !only || s === only);
842
+ if (slugs.length === 0) fail(`no artifact directory named ${only}`);
843
+ const totals = { upgraded: 0, elevationHinted: [], darkFirst: [], displayHinted: [] };
844
+ for (const slug of slugs) {
845
+ const dir = join(tokensDir, slug);
846
+ const base = JSON.parse(readFileSync(join(dir, "design.tokens.json"), "utf8"));
847
+ const resolver = JSON.parse(readFileSync(join(dir, "design.resolver.json"), "utf8"));
848
+ const darkPath = join(dir, "design.tokens.dark.json");
849
+ const dark = existsSync(darkPath) ? JSON.parse(readFileSync(darkPath, "utf8")) : null;
850
+
851
+ const stats = upgradeArtifact(base, dark, resolver, readDesignMd(slug), slug);
852
+ writeFileSync(join(dir, "design.tokens.json"), JSON.stringify(base, null, 2) + "\n");
853
+ writeFileSync(join(dir, "design.resolver.json"), JSON.stringify(resolver, null, 2) + "\n");
854
+ if (dark !== null) writeFileSync(darkPath, JSON.stringify(dark, null, 2) + "\n");
855
+
856
+ totals.upgraded++;
857
+ if (stats.elevationHinted) totals.elevationHinted.push(slug);
858
+ if (stats.darkFirst) totals.darkFirst.push(slug);
859
+ if (stats.displayHinted) totals.displayHinted.push(slug);
860
+ const marks = [stats.darkFirst ? "dark-first" : null, stats.elevationHinted ? "elevation hinted" : null, stats.displayHinted ? "display hinted" : "display assigned"].filter(Boolean);
861
+ console.log(`UPGRADE OK ${slug} (${marks.join(", ")})`);
862
+ }
863
+ console.log(`\nupgraded: ${totals.upgraded}`);
864
+ console.log(`elevation hinted (design.md names shadow values): ${totals.elevationHinted.length} — ${totals.elevationHinted.join(", ") || "none"}`);
865
+ console.log(`dark-first (theme-default: dark): ${totals.darkFirst.join(", ") || "none"}`);
866
+ console.log(`display hinted: ${totals.displayHinted.length} of ${totals.upgraded}`);
867
+ return totals;
868
+ }
869
+
640
870
  // ---------------------------------------------------------------------------
641
871
  // Validation — SCHEMA.md rules 2–10 over an emitted artifact directory
642
872
  // ---------------------------------------------------------------------------
@@ -696,10 +926,13 @@ function validateArtifact(dir) {
696
926
  try { doc = read("design.tokens.json"); } catch (e) { return [`design.tokens.json: ${e.message}`]; }
697
927
  try { resolver = read("design.resolver.json"); } catch (e) { return [`design.resolver.json: ${e.message}`]; }
698
928
 
929
+ // An alias-valued token ("{path.to.token}") defers shape/.css to its target (rule 6 resolves it).
930
+ const isAliasValue = (t) => typeof t?.$value === "string" && /^\{[^}]+\}$/.test(t.$value);
931
+
699
932
  // Rule 2 — payload.
700
933
  const payload = doc.$extensions?.[EXT];
701
934
  if (!payload) { errors.push("payload: missing $extensions[com.nurix.design] on the base root"); return errors; }
702
- if (payload.schema !== "design-tokens/v1") errors.push(`payload.schema: ${payload.schema}`);
935
+ if (payload.schema !== "design-tokens/v2") errors.push(`payload.schema: ${payload.schema}`);
703
936
  if (payload.dtcg !== "2025.10") errors.push(`payload.dtcg: ${payload.dtcg}`);
704
937
  if (!payload.product) errors.push("payload.product: empty");
705
938
  if (!LEGAL_PROVENANCE.includes(payload.provenance)) errors.push(`payload.provenance: ${payload.provenance}`);
@@ -708,6 +941,10 @@ function validateArtifact(dir) {
708
941
  if (!LEGAL_FEEDBACK.includes(payload.feedback)) errors.push(`payload.feedback: ${payload.feedback}`);
709
942
  if (!LEGAL_DENSITY.includes(payload["density-tier"])) errors.push(`payload.density-tier: ${payload["density-tier"]}`);
710
943
  if (!Array.isArray(payload["product-traits"]) || payload["product-traits"].length === 0) errors.push("payload.product-traits: empty");
944
+ if (payload["theme-default"] !== undefined && !["light", "dark"].includes(payload["theme-default"])) errors.push(`payload.theme-default: ${payload["theme-default"]}`);
945
+ if (payload.anchors !== undefined && (!Array.isArray(payload.anchors) || payload.anchors.length < 2 || payload.anchors.length > 3 || payload.anchors.some((a) => typeof a !== "string" || a.trim() === ""))) {
946
+ errors.push(`payload.anchors: must be 2–3 non-empty strings (${JSON.stringify(payload.anchors)})`);
947
+ }
711
948
  const lm = payload["lever-manifest"];
712
949
  for (const key of ["spacing-scale", "radius-scale", "shadow-levels", "motion", "icons", "accent-count"]) {
713
950
  if (lm?.[key] === undefined) errors.push(`lever-manifest.${key}: missing`);
@@ -715,34 +952,64 @@ function validateArtifact(dir) {
715
952
  if (errors.length > 0) return errors;
716
953
 
717
954
  // Rule 3 — primitive coverage with exact $type.
718
- for (const [key, type] of PRIMITIVE_TYPES) {
955
+ for (const [key, type] of PRIMITIVE_TYPES_V2) {
719
956
  const t = doc.primitive?.[key];
720
957
  if (!t) errors.push(`primitive.${key}: missing`);
721
958
  else if (t.$type !== type) errors.push(`primitive.${key}: $type ${t.$type} (want ${type})`);
722
959
  else if (t.$value === undefined || t.$value === "") errors.push(`primitive.${key}: empty $value`);
723
960
  }
961
+ const stagger = doc.primitive?.["stagger-unit"]?.$value?.value;
962
+ if (typeof stagger === "number" && (stagger < 0 || stagger > 60)) errors.push(`primitive.stagger-unit: ${stagger}ms outside 0–60ms`);
724
963
 
725
- // Rule 4 — color coverage.
726
- for (const role of COLOR_ROLES) {
964
+ // Rule 4 — color coverage (an aliased role takes its target's shape and .css).
965
+ for (const role of COLOR_ROLES_V2) {
727
966
  const t = doc.color?.[role];
728
967
  if (!t) { errors.push(`color.${role}: missing`); continue; }
729
968
  if (t.$type !== "color") errors.push(`color.${role}: $type ${t.$type}`);
969
+ if (isAliasValue(t)) continue;
730
970
  if (!["oklch", "srgb"].includes(t.$value?.colorSpace)) errors.push(`color.${role}: colorSpace ${t.$value?.colorSpace}`);
731
971
  if (typeof t.$extensions?.[EXT]?.css !== "string") errors.push(`color.${role}: missing .css`);
732
972
  }
733
973
 
734
- // Rule 5 — typography coverage.
974
+ // Rule 5 — typography coverage (the seven-step ramp; family.display optional).
735
975
  for (const fam of ["heading", "body", "mono"]) {
736
976
  const t = doc.typography?.family?.[fam];
737
977
  if (!t || t.$type !== "fontFamily") errors.push(`typography.family.${fam}: missing or mistyped`);
738
978
  }
739
- for (const step of RAMP_STEPS) {
979
+ if (doc.typography?.family?.display !== undefined && doc.typography.family.display.$type !== "fontFamily") {
980
+ errors.push("typography.family.display: mistyped (optional slot, fontFamily shape)");
981
+ }
982
+ for (const step of RAMP_STEPS_V2) {
740
983
  const t = doc.typography?.ramp?.[step];
741
984
  if (!t || t.$type !== "typography") { errors.push(`typography.ramp.${step}: missing or mistyped`); continue; }
742
985
  for (const sub of ["fontFamily", "fontSize", "fontWeight", "letterSpacing", "lineHeight"]) {
743
986
  if (t.$value?.[sub] === undefined) errors.push(`typography.ramp.${step}.${sub}: missing sub-value`);
744
987
  }
745
988
  }
989
+
990
+ // Elevation coverage (rule 13) — four shadow composites, byte form preserved and parse-backed.
991
+ for (const level of ELEVATION_TOKENS) {
992
+ const t = doc.elevation?.[level];
993
+ if (!t || t.$type !== "shadow") { errors.push(`elevation.${level}: missing or not $type shadow`); continue; }
994
+ if (!Array.isArray(t.$value) || t.$value.length === 0) { errors.push(`elevation.${level}: $value must be a non-empty layer array`); continue; }
995
+ const css = t.$extensions?.[EXT]?.css;
996
+ if (typeof css !== "string") { errors.push(`elevation.${level}: missing .css`); continue; }
997
+ try {
998
+ if (JSON.stringify(parseShadowCss(css)) !== JSON.stringify(t.$value)) errors.push(`elevation.${level}: css does not parse back to $value`);
999
+ } catch (e) {
1000
+ errors.push(`elevation.${level}: ${e.message}`);
1001
+ }
1002
+ }
1003
+
1004
+ // Wash shape (rule 14) — optional group, ≤ 2 gradient tokens.
1005
+ if (doc.wash !== undefined) {
1006
+ const washKeys = Object.keys(doc.wash).filter((k) => !k.startsWith("$"));
1007
+ if (washKeys.length > 2) errors.push(`wash: at most 2 tokens (found ${washKeys.length})`);
1008
+ for (const key of washKeys) {
1009
+ if (!["surface", "emphasis"].includes(key)) errors.push(`wash.${key}: not a legal slot`);
1010
+ else if (doc.wash[key].$type !== "gradient") errors.push(`wash.${key}: not $type gradient`);
1011
+ }
1012
+ }
746
1013
  if (errors.length > 0) return errors;
747
1014
 
748
1015
  // Rule 6 — alias integrity (token values and composite sub-values), with type expectations.
@@ -774,16 +1041,20 @@ function validateArtifact(dir) {
774
1041
  if (!lm.icons?.family) errors.push("lever-manifest.icons.family: empty");
775
1042
  if (!(lm["accent-count"] >= 1)) errors.push(`lever-manifest.accent-count: ${lm["accent-count"]}`);
776
1043
 
777
- // Rule 8 — the look's own base values sit on its own allowlist.
778
- if (!lm["radius-scale"].includes(doc.primitive.radius.$value.value)) errors.push(`radius ${doc.primitive.radius.$value.value} not on radius-scale`);
1044
+ // Rule 8 — the look's own base values sit on its own allowlist; an off-manifest value is legal
1045
+ // only as a recorded deviation (a deviation: $description — rule 15's vocabulary).
1046
+ const deviationTagged = (t) => typeof t?.$description === "string" && t.$description.startsWith("deviation:");
1047
+ if (!lm["radius-scale"].includes(doc.primitive.radius.$value.value) && !deviationTagged(doc.primitive.radius)) errors.push(`radius ${doc.primitive.radius.$value.value} not on radius-scale`);
779
1048
  for (const key of ["duration-fast", "duration-normal", "duration-slow"]) {
780
- if (!lm.motion.durations.includes(doc.primitive[key].$value.value)) errors.push(`${key} ${doc.primitive[key].$value.value} not in motion.durations`);
1049
+ if (!lm.motion.durations.includes(doc.primitive[key].$value.value) && !deviationTagged(doc.primitive[key])) errors.push(`${key} ${doc.primitive[key].$value.value} not in motion.durations`);
781
1050
  }
782
1051
  const easeCss = doc.primitive["ease-default"].$extensions?.[EXT]?.css;
783
- if (!lm.motion.easings.includes(easeCss)) errors.push(`ease-default ${easeCss} not in motion.easings`);
1052
+ if (!lm.motion.easings.includes(easeCss) && !deviationTagged(doc.primitive["ease-default"])) errors.push(`ease-default ${easeCss} not in motion.easings`);
784
1053
 
785
- // Rule 9 — parse-back for every preserved .css.
786
- for (const role of COLOR_ROLES) verifyColorParseBack(`color.${role}`, doc.color[role], errors);
1054
+ // Rule 9 — parse-back for every preserved .css (aliased roles carry none).
1055
+ for (const role of COLOR_ROLES_V2) {
1056
+ if (!isAliasValue(doc.color[role])) verifyColorParseBack(`color.${role}`, doc.color[role], errors);
1057
+ }
787
1058
  const ht = doc.primitive["heading-tracking"];
788
1059
  const htCss = ht.$extensions?.[EXT]?.css;
789
1060
  if (typeof htCss !== "string") errors.push("primitive.heading-tracking: missing .css");
@@ -796,7 +1067,7 @@ function validateArtifact(dir) {
796
1067
  if (typeof css !== "string") { errors.push(`primitive.${key}: missing .css`); continue; }
797
1068
  if (JSON.stringify(parseFontStackCss(css)) !== JSON.stringify(t.$value)) errors.push(`primitive.${key}: css/$value mismatch`);
798
1069
  }
799
- for (const step of RAMP_STEPS) {
1070
+ for (const step of RAMP_STEPS_V2) {
800
1071
  const t = doc.typography.ramp[step];
801
1072
  const css = t.$extensions?.[EXT]?.css;
802
1073
  if (css?.letterSpacing !== undefined) {
@@ -805,25 +1076,27 @@ function validateArtifact(dir) {
805
1076
  }
806
1077
  }
807
1078
 
808
- // Rule 10 + resolver coherence — the dark file carries exactly the 34-role color group.
1079
+ // Rule 10 + resolver coherence — the dark file carries exactly the 36-role color group; the
1080
+ // resolver's theme default mirrors the payload's theme-default.
809
1081
  const darkNamed = resolver.modifiers?.[0]?.contexts?.dark !== undefined;
810
1082
  const darkExists = existsSync(join(dir, "design.tokens.dark.json"));
811
1083
  if (darkNamed !== darkExists) errors.push(`dark file/resolver mismatch: named=${darkNamed} exists=${darkExists}`);
812
1084
  if (resolver.schema !== "design-resolver/v1") errors.push(`resolver.schema: ${resolver.schema}`);
813
- if (resolver.modifiers?.[0]?.default !== "light") errors.push("resolver: theme default must be light");
1085
+ const expectedDefault = payload["theme-default"] ?? "light";
1086
+ if (resolver.modifiers?.[0]?.default !== expectedDefault) errors.push(`resolver: theme default ${resolver.modifiers?.[0]?.default} does not mirror payload theme-default ${expectedDefault}`);
814
1087
  if (darkExists) {
815
1088
  const dark = read("design.tokens.dark.json");
816
1089
  const rootKeys = Object.keys(dark);
817
1090
  if (rootKeys.length !== 1 || rootKeys[0] !== "color") errors.push(`dark file: root keys ${rootKeys.join(",")} (want exactly color)`);
818
- for (const role of COLOR_ROLES) {
1091
+ for (const role of COLOR_ROLES_V2) {
819
1092
  const t = dark.color?.[role];
820
1093
  if (!t || t.$type !== "color") { errors.push(`dark color.${role}: missing or mistyped`); continue; }
821
- verifyColorParseBack(`dark color.${role}`, t, errors);
1094
+ if (!isAliasValue(t)) verifyColorParseBack(`dark color.${role}`, t, errors);
822
1095
  }
823
- for (const role of Object.keys(dark.color ?? {})) if (!COLOR_ROLES.includes(role)) errors.push(`dark color.${role}: not a legal role`);
1096
+ for (const role of Object.keys(dark.color ?? {})) if (!COLOR_ROLES_V2.includes(role)) errors.push(`dark color.${role}: not a legal role`);
824
1097
  }
825
1098
 
826
- // Rule 11 (type whitelist sweep) — only the eight legal $type values appear.
1099
+ // Rule 11 (type whitelist sweep) — only the ten legal $type values appear.
827
1100
  for (const [path, token] of tokens) {
828
1101
  if (!LEGAL_TYPES.includes(token.$type)) errors.push(`${path}: illegal $type ${token.$type}`);
829
1102
  }
@@ -943,7 +1216,11 @@ function runConvert(only, reportPath) {
943
1216
  for (const { slug, path } of sources) {
944
1217
  let result;
945
1218
  try {
946
- result = convertEntry(readFileSync(path, "utf8"), slug);
1219
+ const sourceText = readFileSync(path, "utf8");
1220
+ result = convertEntry(sourceText, slug);
1221
+ // The converter emits the v2 schema: the same idempotent upgrade transform the migration
1222
+ // mode runs, applied to the freshly converted (v1-shaped) documents.
1223
+ upgradeArtifact(result.base, result.dark, result.resolver, sourceText, slug);
947
1224
  } catch (e) {
948
1225
  report.failed.push({ slug, error: e.message });
949
1226
  console.log(`CONVERT FAIL ${slug}: ${e.message}`);
@@ -1018,6 +1295,9 @@ const opt = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1
1018
1295
 
1019
1296
  if (flag("--strip")) {
1020
1297
  runStrip(args.slice(args.indexOf("--strip") + 1).filter((a) => !a.startsWith("--")));
1298
+ } else if (flag("--upgrade-v2")) {
1299
+ runUpgradeV2(opt("--only"));
1300
+ process.exit(runValidate(opt("--only")) ? 0 : 1);
1021
1301
  } else if (flag("--validate")) {
1022
1302
  process.exit(runValidate(opt("--only")) ? 0 : 1);
1023
1303
  } else {