@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
@@ -1,6 +1,7 @@
1
1
  // skills/design/scripts/lib/checks.mjs — the machine-check engine (beam 2, D19): every detector=source catalog rule as pure regex/token analysis over .tsx/.ts/.css/.html text, plus the lever-manifest drift check (cod-02, D10) whose vocabulary is read from the project's design-tokens artifact (design.tokens.json). Pure library — no argv, no CLI banner, no process.exit; fired at structural boundaries by phase-gate.mjs, preship-sweep.mjs, and self-integrity.mjs. Findings are [{rule, file, line, excerpt, fix}].
2
2
  import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { findBudgetStamps, MOTION_STAGGER_CLASS, STAGGER_CHILD_CAP } from './vocabulary.mjs';
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // File-type routing
@@ -123,6 +124,32 @@ function normColor(lit) {
123
124
  // ---------------------------------------------------------------------------
124
125
 
125
126
  const RE_COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|oklch\([^)]*\)/g;
127
+
128
+ // CSS generic + system family keywords — never counted as declared app families (typ-06);
129
+ // narrower than SYSTEM_FONT_KEYWORDS, whose web-safe named fonts (Roboto, Arial…) DO count.
130
+ const GENERIC_FAMILY_KEYWORDS = new Set([
131
+ 'sans-serif', 'serif', 'monospace', 'cursive', 'fantasy', 'math', 'emoji', 'fangsong',
132
+ 'system-ui', 'ui-sans-serif', 'ui-serif', 'ui-monospace', 'ui-rounded',
133
+ '-apple-system', 'blinkmacsystemfont', 'sfmono-regular',
134
+ ]);
135
+
136
+ // Base signature of a color literal — the first three channels with the alpha slot stripped,
137
+ // hex unified onto rgb — so an emitted color whose alpha rides var()/calc() (the intensity-
138
+ // multiplied shadow layers, which RE_COLOR_LITERAL truncates mid-calc) can still be matched
139
+ // against the manifest vocabulary.
140
+ function colorBaseSig(lit) {
141
+ const s = lit.trim().toLowerCase();
142
+ if (/^#[0-9a-f]{3,8}$/.test(s)) {
143
+ const h = expandHex(s);
144
+ return `rgb:${parseInt(h.slice(0, 2), 16)},${parseInt(h.slice(2, 4), 16)},${parseInt(h.slice(4, 6), 16)}`;
145
+ }
146
+ const fn = s.match(/^(rgba?|hsla?|oklch)\(([^/)]*)/);
147
+ if (!fn) return null;
148
+ const nums = fn[2].match(/-?\d*\.?\d+%?/g);
149
+ if (!nums || nums.length < 3) return null;
150
+ const kind = fn[1].startsWith('rgb') ? 'rgb' : fn[1].startsWith('hsl') ? 'hsl' : 'oklch';
151
+ return `${kind}:${nums.slice(0, 3).join(',')}`;
152
+ }
126
153
  const TW_UTILITY = '(?:bg|text|border|ring|ring-offset|fill|stroke|from|via|to|outline|divide|decoration|caret|accent|placeholder|shadow)';
127
154
  const PALETTE_HUES = 'red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|grey|zinc|neutral|stone';
128
155
  const RE_PALETTE_CLASS = new RegExp(`\\b${TW_UTILITY}-(${PALETTE_HUES})-(\\d{2,3})\\b`, 'g');
@@ -244,7 +271,10 @@ export function checkLine(add, file, ext, lineNo, line, fileText) {
244
271
  {
245
272
  const neg = line.match(/(?:letter-spacing\s*:\s*|tracking-\[)(-?\d*\.?\d+)em/);
246
273
  if (neg && parseFloat(neg[1]) < -0.04) add('typ-08', file, lineNo, neg[0], 'Letter-spacing never below the -0.04em floor.');
247
- if (/tracking-tighter\b/.test(line)) add('typ-08', file, lineNo, 'tracking-tighter', 'tracking-tighter (-0.05em) is below the -0.04em floor; use tracking-tight at most.');
274
+ // A `--tracking-tighter: var(…)` @theme line REMAPS the utility onto an authored token —
275
+ // that definition is the fix for this rule, not a violation; literal values still fall
276
+ // to the numeric floor check above.
277
+ if (/\btracking-tighter\b/.test(line) && !/--tracking-tighter\s*:\s*var\(/.test(line)) add('typ-08', file, lineNo, 'tracking-tighter', 'tracking-tighter (-0.05em) is below the -0.04em floor; use tracking-tight at most.');
248
278
  if (/\buppercase\b/.test(line) && !/tracking-(?:wide|wider|widest|\[)/.test(line)) add('typ-08', file, lineNo, 'uppercase', 'All-caps micro-labels carry positive tracking (>= 0.02em, caps band 0.05-0.12em); add tracking-wide.');
249
279
  }
250
280
  // typ-09: viewport-fluid font sizing.
@@ -406,6 +436,35 @@ export function checkLine(add, file, ext, lineNo, line, fileText) {
406
436
  if (d > 0 && (d < 150 || d > 300)) add('mot-07', file, lineNo, m[0], 'Loading-indicator delay sits in the 150-300ms band (nothing appears under 500ms).');
407
437
  }
408
438
  }
439
+ // mot-08 (numeric half): the per-item stagger unit holds the 0-60ms band, a declared cascade never
440
+ // reaches past the child cap, and no stagger delay lands at or over the 1s sequence ceiling. Child
441
+ // count and the actual total are confirmed on the rendered DOM at the render rung.
442
+ {
443
+ const unit = line.match(/--stagger-unit\s*:\s*(-?\d*\.?\d+)ms/);
444
+ if (unit && (parseFloat(unit[1]) < 0 || parseFloat(unit[1]) > 60)) {
445
+ add('mot-08', file, lineNo, unit[0], 'The stagger-unit token holds 0-60ms per item; pull the unit back into the band.');
446
+ }
447
+ const idxDelay = line.match(/(?:animationDelay|animation-delay|transitionDelay|transition-delay)[^;,}]*?(?:\b(?:i|idx|index)\s*\*\s*(\d*\.?\d+)|(\d*\.?\d+)\s*\*\s*(?:i|idx|index)\b)/);
448
+ if (idxDelay && parseFloat(idxDelay[1] ?? idxDelay[2]) > 60) {
449
+ add('mot-08', file, lineNo, idxDelay[0].slice(0, 60), 'Per-item stagger delay exceeds the 0-60ms unit band; derive it from the stagger-unit token (0-60ms).');
450
+ }
451
+ const calcUnit = line.match(/(?:animation|transition)-delay\s*:\s*calc\(\s*(\d*\.?\d+)ms\s*\*/);
452
+ if (calcUnit && parseFloat(calcUnit[1]) > 60) {
453
+ add('mot-08', file, lineNo, calcUnit[0], 'Per-item stagger delay exceeds the 0-60ms unit band; ride the stagger-unit token instead of a raw multiplier.');
454
+ }
455
+ if (/(?:animation|transition)-delay/.test(line)) {
456
+ const nth = line.match(/nth-child\((\d+)\)/);
457
+ if (nth && parseInt(nth[1], 10) > STAGGER_CHILD_CAP) {
458
+ add('mot-08', file, lineNo, nth[0], `Stagger cascades cap at ${STAGGER_CHILD_CAP} children; children past the cap arrive together.`);
459
+ }
460
+ if (/stagger|nth-child/i.test(line)) {
461
+ for (const m of line.matchAll(/(?:animation|transition)-delay\s*:\s*(\d*\.?\d+)(ms|s)\b/g)) {
462
+ const ms = m[2] === 's' ? parseFloat(m[1]) * 1000 : parseFloat(m[1]);
463
+ if (ms >= 1000) add('mot-08', file, lineNo, m[0], 'A stagger sequence totals under 1s; this per-item delay alone reaches the ceiling.');
464
+ }
465
+ }
466
+ }
467
+ }
409
468
  // mot-09: scroll-event listener driving animation.
410
469
  if (/addEventListener\(\s*["']scroll["']/.test(line)) add('mot-09', file, lineNo, 'addEventListener("scroll")', 'Ban scroll-event listeners for animation; use IntersectionObserver or scroll-linked scrubbing.');
411
470
  // mot-10: useState fed by a continuous scroll/pointer value.
@@ -561,6 +620,30 @@ export function checkFile(add, file, ext, text, lines) {
561
620
  }
562
621
  }
563
622
 
623
+ // typ-20 / col-16 / mot-08 (orchestration half): budget-gated vocabulary against the surface's
624
+ // budget stamp (lib/vocabulary.mjs). The display ramp step, wash consumption, and arrival cascades
625
+ // are legal only on a file stamped brand-forward; a baseline-stamped or unstamped file consuming
626
+ // them is the deterministic violation. Fired at the surfaces gate and the preship sweep through
627
+ // this shared engine.
628
+ if (ext === 'tsx' || ext === 'html') {
629
+ const brandForward = findBudgetStamps(text).includes('brand-forward');
630
+ const lineOf = (idx) => text.slice(0, idx).split('\n').length;
631
+ const displayUses = [...text.matchAll(/\btext-display\b|var\(--text-display\b/g)];
632
+ if (displayUses.length && !brandForward) {
633
+ add('typ-20', file, lineOf(displayUses[0].index), displayUses[0][0], 'The display ramp step appears only on surfaces stamped brand-forward; re-classify the surface with a recorded reason, or step this element down the ramp (text-h1).');
634
+ } else if (displayUses.length > 1) {
635
+ add('typ-20', file, lineOf(displayUses[1].index), `${displayUses.length} display-step elements`, 'At most one display-step element per surface; keep the focal one and step the rest down the ramp.');
636
+ }
637
+ const washUse = text.match(/var\(--wash-[a-z-]+\)/);
638
+ if (washUse && !brandForward) {
639
+ add('col-16', file, lineOf(washUse.index), washUse[0], 'A wash token is consumed only on brand-forward-stamped surfaces; re-classify the surface with a recorded reason, or remove the wash.');
640
+ }
641
+ const staggerUse = text.match(new RegExp(`\\b${MOTION_STAGGER_CLASS}\\b`));
642
+ if (staggerUse && !brandForward) {
643
+ add('mot-08', file, lineOf(staggerUse.index), staggerUse[0], 'Baseline-stamped surfaces skip arrival cascades; remove motion-stagger, or re-classify the surface with a recorded reason.');
644
+ }
645
+ }
646
+
564
647
  // int-01: a :focus / :focus-visible CSS rule that removes the outline with no visible ring replacement.
565
648
  if (ext === 'css') {
566
649
  for (const m of text.matchAll(/:focus(?:-visible)?\s*\{([^}]*)\}/g)) {
@@ -576,8 +659,10 @@ export function checkFile(add, file, ext, text, lines) {
576
659
  add('int-10', file, text.slice(0, text.search(/setInterval\(/)).split('\n').length, 'polling status update without aria-live', 'An aria-live region is present wherever async status updates render; wrap the live status in role="status" / aria-live="polite".');
577
660
  }
578
661
 
579
- // cod-01: a token-defining CSS file that omits some of the 34 consumed tokens.
580
- if (ext === 'css' && /--background\b/.test(text) && /--foreground\b/.test(text)) {
662
+ // cod-01: a token-defining CSS file that omits some of the 36 consumed tokens. The trigger
663
+ // requires DEFINITIONS at property position — a consumer file's `--color-background: var(--background)`
664
+ // @theme mapping must not read as a token-defining file.
665
+ if (ext === 'css' && /^\s*--background\s*:/m.test(text) && /^\s*--foreground\s*:/m.test(text)) {
581
666
  const missing = REQUIRED_TOKENS.filter((t) => !new RegExp(`--${t}\\s*:`).test(text));
582
667
  if (missing.length) add('cod-01', file, 1, `missing tokens: ${missing.slice(0, 6).join(', ')}${missing.length > 6 ? '…' : ''}`, 'Emitted CSS defines every token the substrate consumes; add the missing --token definitions.');
583
668
  }
@@ -599,6 +684,7 @@ export function checkFile(add, file, ext, text, lines) {
599
684
  const REQUIRED_TOKENS = [
600
685
  'background', 'foreground', 'card', 'card-foreground', 'muted', 'muted-foreground', 'border', 'input', 'ring',
601
686
  'primary', 'primary-foreground', 'accent', 'accent-foreground', 'destructive', 'destructive-foreground', 'success', 'warning',
687
+ 'surface-raised', 'surface-overlay',
602
688
  'popover', 'popover-foreground', 'secondary', 'secondary-foreground',
603
689
  'chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5',
604
690
  'sidebar', 'sidebar-foreground', 'sidebar-primary', 'sidebar-primary-foreground',
@@ -611,8 +697,10 @@ const REQUIRED_TOKENS = [
611
697
 
612
698
  // Run the checks that need the whole passed-in file set (token-set counts, distinct sizes).
613
699
  export function checkCrossFile(add, docs) {
614
- // typ-06: more than two font families declared across the passed files.
700
+ // typ-06: more than three font families declared across the passed files (fourth banned), or three
701
+ // families none of which carries the numeric-mono job.
615
702
  const families = new Set();
703
+ const monoFamilies = new Set();
616
704
  const iconLibs = new Set();
617
705
  const explicitSizes = new Map(); // px -> {file,line,text}
618
706
  const hexByHue = new Map(); // hue bucket -> Set(normHex)
@@ -622,11 +710,17 @@ export function checkCrossFile(add, docs) {
622
710
 
623
711
  for (const d of docs) {
624
712
  d.lines.forEach((line, i) => {
625
- // font families from declarations / --font-* / font-[Family]
626
- for (const m of line.matchAll(/font-family\s*:\s*([^;{}]+)[;}]?|--font-[\w-]+\s*:\s*([^;]+);|font-\[["']?([A-Za-z][\w\s-]+)["']?\]/g)) {
713
+ // font families from declarations / --font-* family slots / font-[Family].
714
+ // --font-weight-*/--font-size-* carry numbers, not families; a stack whose first entry
715
+ // is a CSS generic (the emitted baseline's generics-only stacks) declares no family.
716
+ for (const m of line.matchAll(/font-family\s*:\s*([^;{}]+)[;}]?|--font-(?!weight-|size-)[\w-]+\s*:\s*([^;]+);|font-\[["']?([A-Za-z][\w\s-]+)["']?\]/g)) {
627
717
  const raw = (m[1] || m[2] || m[3] || '').trim();
628
718
  const first = raw.split(',')[0].replace(/["']/g, '').trim().toLowerCase();
629
- if (first && !/^var\(/.test(first)) families.add(first);
719
+ if (first && !/^var\(/.test(first) && !GENERIC_FAMILY_KEYWORDS.has(first)) {
720
+ families.add(first);
721
+ // The numeric-mono job: declared as the mono slot or stacked onto a monospace generic.
722
+ if (/^--font-mono/.test(m[0]) || /\b(?:ui-)?monospace\b/i.test(raw)) monoFamilies.add(first);
723
+ }
630
724
  }
631
725
  // icon libraries from imports
632
726
  const imp = line.match(/from\s+["'](lucide-react|@heroicons\/\w+|react-icons(?:\/\w+)?|@phosphor-icons\/\w+|phosphor-react)["']/);
@@ -651,9 +745,19 @@ export function checkCrossFile(add, docs) {
651
745
  });
652
746
  }
653
747
 
654
- if (families.size > 2) {
748
+ if (families.size > 3) {
749
+ const d = docs[0];
750
+ add('typ-06', d.file, 1, `${families.size} font families: ${[...families].join(', ')}`, 'At most three font families per app, each with a distinct job (display / body / numeric-mono); a fourth family is banned — drop the extra.');
751
+ } else if (families.size === 3 && monoFamilies.size === 0) {
655
752
  const d = docs[0];
656
- add('typ-06', d.file, 1, `${families.size} font families: ${[...families].join(', ')}`, 'At most two font families per app (a sans and a mono); drop the extra family.');
753
+ add('typ-06', d.file, 1, `3 font families, none mono: ${[...families].join(', ')}`, 'Three families need three distinct jobs (display / body / numeric-mono); none of these is a declared mono — give the third family a real job or drop it.');
754
+ }
755
+
756
+ // typ-06 (scale half): distinct explicit sizes beyond the declared ramp's step count.
757
+ if (explicitSizes.size > 7) {
758
+ const anchorPx = [...explicitSizes.keys()].sort((a, b) => a - b)[7];
759
+ const anc = explicitSizes.get(anchorPx);
760
+ add('typ-06', anc.file, anc.line, `${explicitSizes.size} distinct explicit sizes`, 'The type scale holds the declared ramp\'s steps (<= 7); collapse the extras onto the ramp utilities (text-h1 … text-display).');
657
761
  }
658
762
 
659
763
  // typ-10: adjacent explicitly-declared type-scale steps closer than 25%.
@@ -709,6 +813,56 @@ export function checkSpacingScale(add, docs) {
709
813
  }
710
814
  }
711
815
 
816
+ // CSS generic families, the ui-* keywords, and the conventional OS fallback faces that need no
817
+ // package to render — the only stack entries cod-16 accepts without a registering import/@font-face.
818
+ // Exported so the fail-closed cod-16 gate at derive-personality --emit (stack vs package.json
819
+ // binding) reads the same vocabulary as this source shell.
820
+ export const SYSTEM_FONT_KEYWORDS = new Set([
821
+ 'sans-serif', 'serif', 'monospace', 'cursive', 'fantasy', 'math', 'emoji', 'fangsong',
822
+ 'system-ui', 'ui-sans-serif', 'ui-serif', 'ui-monospace', 'ui-rounded',
823
+ '-apple-system', 'blinkmacsystemfont', 'segoe ui', 'roboto', 'helvetica', 'helvetica neue', 'arial',
824
+ 'sfmono-regular', 'sf mono', 'menlo', 'consolas', 'courier new', 'liberation mono', 'monaco',
825
+ 'georgia', 'times new roman', 'inherit', 'initial', 'unset',
826
+ ]);
827
+
828
+ // Slugify a family name the way a fontsource package specifier spells it ("Inter Variable" -> inter).
829
+ export function familySlug(name) {
830
+ return name.toLowerCase().replace(/\s+variable$/, '').trim().replace(/[^a-z0-9]+/g, '-');
831
+ }
832
+
833
+ // cod-16 (source shell): every named family in an emitted font stack binds to a registered family or
834
+ // a system generic. Registration is an @font-face declaring the family or an @import whose package
835
+ // specifier carries the family's slug (@fontsource/<slug>). The check arms only when the passed file
836
+ // set carries at least one registration — the no-fonts-css-at-all case belongs to the fail-closed
837
+ // package.json binding at derive-personality --emit, which owns this rule's gate.
838
+ export function checkFontBinding(add, docs) {
839
+ const cssDocs = docs.filter((d) => d.ext === 'css');
840
+ const importSpecs = [];
841
+ const registered = new Set();
842
+ for (const d of cssDocs) {
843
+ for (const m of d.text.matchAll(/@import\s+(?:url\(\s*)?["']([^"']+)["']/g)) importSpecs.push(m[1].toLowerCase());
844
+ for (const m of d.text.matchAll(/@font-face\s*\{[^}]*font-family\s*:\s*["']?([^;"'}]+)/g)) registered.add(m[1].trim().toLowerCase());
845
+ }
846
+ if (!importSpecs.length && !registered.size) return;
847
+ const isBound = (name) => {
848
+ const n = name.toLowerCase();
849
+ if (SYSTEM_FONT_KEYWORDS.has(n) || registered.has(n)) return true;
850
+ const slug = familySlug(name);
851
+ return slug !== '' && importSpecs.some((spec) => spec.includes(`/${slug}`) || spec === slug);
852
+ };
853
+ for (const d of cssDocs) {
854
+ d.lines.forEach((line, i) => {
855
+ for (const m of line.matchAll(/(?:--font-[\w-]+|font-family)\s*:\s*([^;}]+)/g)) {
856
+ for (const fam of m[1].split(',')) {
857
+ const name = fam.replace(/["']/g, '').trim();
858
+ if (!name || /^var\(/.test(name)) continue;
859
+ if (!isBound(name)) add('cod-16', d.file, i + 1, name, `Font family "${name}" binds to nothing installed: add its registering @fontsource import (or @font-face) to the emitted CSS, or fall back to a system generic.`);
860
+ }
861
+ }
862
+ });
863
+ }
864
+ }
865
+
712
866
  // Bucket a 6-digit hex into a coarse hue family name for shade counting.
713
867
  function hexHueBucket(hex) {
714
868
  const h = expandHex(hex);
@@ -808,7 +962,37 @@ export function loadManifestVocab(manifestPath) {
808
962
  const strokeToken = primitive['icon-stroke'] ? resolveAlias(root, primitive['icon-stroke']) : null;
809
963
  const iconStroke = strokeToken && typeof strokeToken.$value === 'number' ? strokeToken.$value : null;
810
964
 
811
- return { colors, spacing, radius, durations, fonts, iconStroke };
965
+ // The v2 elevation/wash vocabularies: every color literal inside a shadow composite's or a wash
966
+ // gradient's preserved .css enters the color allowlist — both as the drift extractor will match it
967
+ // in emitted CSS (the raw RE_COLOR_LITERAL slice) and as the layer's own typed hex — so consuming
968
+ // the artifact's designed shadows/washes never reads as drift.
969
+ const addExpressiveGroup = (group) => {
970
+ for (const token of Object.values(group || {})) {
971
+ if (!token || typeof token !== 'object') continue;
972
+ const css = preservedCss(token);
973
+ if (css) {
974
+ for (const m of css.matchAll(RE_COLOR_LITERAL)) colors.add(normColor(m[0]));
975
+ }
976
+ const layers = Array.isArray(token.$value) ? token.$value : [];
977
+ for (const layer of layers) {
978
+ const c = layer && (layer.color || null);
979
+ if (c && typeof c === 'object' && typeof c.hex === 'string') colors.add(normColor(c.hex));
980
+ }
981
+ }
982
+ };
983
+ addExpressiveGroup(root.elevation);
984
+ addExpressiveGroup(root.wash);
985
+
986
+ // Alpha-stripped base signatures of every manifest color, plus the emitter's fixed neutral
987
+ // hairline base rgb(15 23 42) (the 2xs/xs shadow utilities the library leans on) — consulted
988
+ // only for literals whose alpha slot rides var()/calc(), where exact normalization is defeated.
989
+ const colorBases = new Set(['rgb:15,23,42']);
990
+ for (const c of colors) {
991
+ const b = colorBaseSig(c);
992
+ if (b) colorBases.add(b);
993
+ }
994
+
995
+ return { colors, spacing, radius, durations, fonts, iconStroke, colorBases };
812
996
  }
813
997
 
814
998
  // Flag any color/spacing/radius/duration/font literal in the diff files outside the manifest vocabulary (cod-02).
@@ -822,7 +1006,11 @@ export function checkDrift(add, docs, vocab) {
822
1006
  // colors
823
1007
  if (vocab.colors.size) {
824
1008
  for (const m of line.matchAll(RE_COLOR_LITERAL)) {
825
- if (!vocab.colors.has(normColor(m[0]))) add('cod-02', d.file, i + 1, m[0], "Value drifts from the lever manifest; use a color already in the artifact's color tokens, not a fresh literal.");
1009
+ if (vocab.colors.has(normColor(m[0]))) continue;
1010
+ // A var()/calc() alpha slot truncates the regex match mid-function and defeats exact
1011
+ // normalization; such intensity-riding literals match on their base channels instead.
1012
+ if (/(?:var|calc)\(/.test(m[0]) && vocab.colorBases && vocab.colorBases.has(colorBaseSig(m[0]))) continue;
1013
+ add('cod-02', d.file, i + 1, m[0], "Value drifts from the lever manifest; use a color already in the artifact's color tokens, not a fresh literal.");
826
1014
  }
827
1015
  }
828
1016
  // spacing (explicit only)
@@ -871,6 +1059,7 @@ export function checkDocs(docs, { vocab = null } = {}) {
871
1059
  }
872
1060
  checkCrossFile(sink.add, docs);
873
1061
  checkSpacingScale(sink.add, docs);
1062
+ checkFontBinding(sink.add, docs);
874
1063
  if (vocab) checkDrift(sink.add, docs, vocab);
875
1064
  // Stable sort: by file, then line, then rule id.
876
1065
  sink.list.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.rule.localeCompare(b.rule));
@@ -0,0 +1,65 @@
1
+ // skills/design/scripts/lib/vocabulary.mjs — single-source shared runtime vocabulary for the design skill's scripts: the delight-budget enum + file-header stamps, the surface-archetype budget defaults, the motion-moment class names, and the theme attribute convention. scaffold.mjs writes these strings; phase-gate.mjs reads them at the surfaces gate; the preship sweep and emission read them in later steps. One module so the stamp text, the enum, and the theme names never drift between the writer and its readers.
2
+
3
+ // The two delight-budget values a surface can carry (references/structure.md, the delight budget).
4
+ export const BUDGETS = ['baseline', 'brand-forward'];
5
+
6
+ // The verbatim file-header comment scaffold.mjs writes at the top of each surface route file, keyed by budget.
7
+ export const BUDGET_STAMPS = {
8
+ baseline: '// Surface: baseline',
9
+ 'brand-forward': '// Surface: brand-forward (delight-allowing)',
10
+ };
11
+
12
+ // The stamp comment for a budget, or null when it is not one of BUDGETS.
13
+ export function stampFor(budget) {
14
+ return Object.prototype.hasOwnProperty.call(BUDGET_STAMPS, budget) ? BUDGET_STAMPS[budget] : null;
15
+ }
16
+
17
+ // Matches any `// Surface:` header line, well-formed or not — gates count these to catch a malformed
18
+ // or duplicated stamp against the well-formed matches below.
19
+ export const BUDGET_STAMP_LOOSE_RE = /\/\/ Surface:.*/g;
20
+
21
+ // Every well-formed budget stamp in a source blob, in order, as budget values ('baseline' | 'brand-forward').
22
+ // Gates count occurrences (exactly one per route file) and read the stamped budget from this.
23
+ export function findBudgetStamps(source) {
24
+ const re = /\/\/ Surface: (brand-forward \(delight-allowing\)|baseline)(?=\s|$)/g;
25
+ const found = [];
26
+ let m;
27
+ while ((m = re.exec(String(source))) !== null) {
28
+ found.push(m[1].startsWith('brand-forward') ? 'brand-forward' : 'baseline');
29
+ }
30
+ return found;
31
+ }
32
+
33
+ // Surface archetypes whose delight budget defaults to `baseline` — a `brand-forward` stamp on one of
34
+ // these needs a recorded reason in the surface map (references/structure.md, the delight budget).
35
+ export const BASELINE_DEFAULT_ARCHETYPES = ['list-detail', 'log-stream', 'command-center'];
36
+
37
+ // Surface archetypes that are `brand-forward` *candidates* — granted when the look's delight is
38
+ // expressive or the brief asks for presence, else baseline; no recorded reason needed either way.
39
+ export const BRAND_FORWARD_CANDIDATE_ARCHETYPES = ['dashboard', 'entity-detail', 'create-edit'];
40
+
41
+ // True when a `brand-forward` stamp on this archetype requires a recorded reason in the surface map.
42
+ export function requiresBudgetReason(archetype, budget) {
43
+ return budget === 'brand-forward' && BASELINE_DEFAULT_ARCHETYPES.includes(String(archetype));
44
+ }
45
+
46
+ // Motion-moment CSS classes (references/structure.md the delight budget; data/combinations/motion.csv).
47
+ // scaffold consumes them where the archetype calls: table rows ride motion-lift (hover feedback,
48
+ // budget-free); card grids ride motion-stagger on brand-forward surfaces only — baseline-stamped
49
+ // surfaces skip arrival cascades (mot-08). The render rung audits rendered stagger containers.
50
+ export const MOTION_LIFT_CLASS = 'motion-lift';
51
+ export const MOTION_STAGGER_CLASS = 'motion-stagger';
52
+ // The derived custom property the stagger cascade reads; cap the staggered children at STAGGER_CHILD_CAP.
53
+ export const STAGGER_DELAY_VAR = '--stagger-delay';
54
+ export const STAGGER_CHILD_CAP = 8;
55
+
56
+ // The theme runtime attribute: its name and the two legal rendered values. look.theme `both` boots
57
+ // light with a working toggle, so the emitted default is always one of these two.
58
+ export const THEME_ATTR = 'data-theme';
59
+ export const THEME_VALUES = ['light', 'dark'];
60
+
61
+ // Resolve a look.theme (light | dark | both) to the emitted default data-theme value (both → light);
62
+ // anything unrecognized falls back to light.
63
+ export function resolveThemeDefault(theme) {
64
+ return theme === 'dark' ? 'dark' : 'light';
65
+ }
@@ -4,12 +4,20 @@ import { join, dirname, resolve, relative } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { runChecks, formatFindings, walkUiFiles } from './lib/checks.mjs';
7
+ import { findBudgetStamps, BUDGET_STAMP_LOOSE_RE, requiresBudgetReason } from './lib/vocabulary.mjs';
7
8
 
8
9
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
9
10
  const PHASES = ['intake', 'structure', 'scaffold', 'tokens', 'fixtures', 'surfaces'];
10
11
  const ARCHETYPES = new Set(['list-detail', 'dashboard', 'entity-detail', 'create-edit', 'log-stream', 'command-center']);
11
12
  const PROVENANCE = new Set(['project', 'extracted', 'library', 'default']);
12
13
  const STATE_SYNONYMS = { loading: ['loading', 'skeleton'], empty: ['empty'], error: ['error'], partial: ['partial', 'degraded', 'stale'] };
14
+ // The intake look block's enums (references/create.md, the look block). theme carries `both`; the emitted
15
+ // data-theme value the attribute cascade uses is light|dark (both boots light) — that pair lives in vocabulary.mjs.
16
+ const LOOK_THEMES = new Set(['light', 'dark', 'both']);
17
+ const DELIGHT_LEVELS = new Set(['baseline', 'expressive']);
18
+ const RESOLUTION_MODES = new Set(['confirmed', 'recorded']);
19
+ // The resolution spine's legal shapes: project | library:<slug> | seed:<style-row>+<palette-row> | default.
20
+ const RESOLUTION_RE = /^(project|default|library:.+|seed:.+\+.+)$/i;
13
21
 
14
22
  const useColor = process.stdout.isTTY;
15
23
  // Wrap a string in green/red when writing to a TTY, plain otherwise.
@@ -163,6 +171,11 @@ function requireMap(phase, project) {
163
171
 
164
172
  // Collect all .ts/.tsx source under a route directory into one lowercased blob.
165
173
  function routeSource(dir) {
174
+ return routeSourceRaw(dir).toLowerCase();
175
+ }
176
+
177
+ // Collect all .ts/.tsx source under a route directory, case preserved (the budget stamp is case-sensitive).
178
+ function routeSourceRaw(dir) {
166
179
  let out = '';
167
180
  const stack = [dir];
168
181
  while (stack.length) {
@@ -175,7 +188,7 @@ function routeSource(dir) {
175
188
  else if (/\.(tsx|ts)$/.test(ent.name)) out += `\n${readText(full)}`;
176
189
  }
177
190
  }
178
- return out.toLowerCase();
191
+ return out;
179
192
  }
180
193
 
181
194
  // Return the candidate route-dir slugs for a surface (name-kebab, route segments, archetype).
@@ -205,7 +218,7 @@ function missingStates(dir, states) {
205
218
  });
206
219
  }
207
220
 
208
- // Validate the load-bearing four-step globals.css import order (Cascade Contract 2).
221
+ // Validate the load-bearing five-step globals.css import order (Cascade Contract 2).
209
222
  function checkImportOrder(rawCss) {
210
223
  const css = rawCss.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
211
224
  const marks = [
@@ -217,7 +230,7 @@ function checkImportOrder(rawCss) {
217
230
  const absent = marks.filter(([, i]) => i < 0).map(([n]) => n);
218
231
  if (absent.length) {
219
232
  return { problem: `globals.css is missing: ${absent.join(', ')}.`, fix: [
220
- 'Emit the four-step cascade order (references/personality.md §4, Cascade Contract 2):',
233
+ 'Emit the five-step cascade order (references/personality.md §4, Cascade Contract 2):',
221
234
  '@import "./personality.css"; -> @import "tailwindcss"; -> @import "@nurix/components/styles.css"; -> @theme inline { ... }',
222
235
  ] };
223
236
  }
@@ -230,9 +243,63 @@ function checkImportOrder(rawCss) {
230
243
  return null;
231
244
  }
232
245
 
246
+ // Validate the payload's mandatory look block (references/create.md, the look block): presence, the theme /
247
+ // delight / resolution-mode enums, 2–3 named anchor strings, the resolution shape, and — when the
248
+ // resolution was authorized autonomously (resolution-mode: recorded) — that the artifact root
249
+ // $description carries a resolution record naming the runner-up. Fails the gate with the exact fix.
250
+ function validateLookBlock(payload, tokens) {
251
+ const look = payload.look;
252
+ if (!look || typeof look !== 'object' || Array.isArray(look)) {
253
+ fail('intake', 'payload carries no look block (payload.look).', [
254
+ 'Add the mandatory look block to the artifact payload (references/create.md, the look block):',
255
+ 'look: { theme: light|dark|both, anchors: ["Product A", "Product B"], delight: baseline|expressive,',
256
+ ' resolution: project | library:<slug> | seed:<style>+<palette> | default, resolution-mode: confirmed|recorded }.',
257
+ ]);
258
+ }
259
+ if (!LOOK_THEMES.has(look.theme)) {
260
+ fail('intake', `look.theme is "${look.theme ?? '(missing)'}"; must be light | dark | both.`, [
261
+ 'Set look.theme from the brief; it is binding when stated, default light (references/create.md, the look block).',
262
+ ]);
263
+ }
264
+ if (!DELIGHT_LEVELS.has(look.delight)) {
265
+ fail('intake', `look.delight is "${look.delight ?? '(missing)'}"; must be baseline | expressive.`, [
266
+ 'Set look.delight — the global delight ceiling per-surface budgets refine (references/create.md the look block, references/structure.md the delight budget).',
267
+ ]);
268
+ }
269
+ const mode = look['resolution-mode'];
270
+ if (!RESOLUTION_MODES.has(mode)) {
271
+ fail('intake', `look.resolution-mode is "${mode ?? '(missing)'}"; must be confirmed | recorded.`, [
272
+ 'Set look.resolution-mode: confirmed when a confirmer authorized the pick, recorded when it was resolved autonomously (references/create.md, Resolution).',
273
+ ]);
274
+ }
275
+ const anchors = look.anchors;
276
+ if (!Array.isArray(anchors) || anchors.length < 2 || anchors.length > 3 || !anchors.every((a) => typeof a === 'string' && a.trim())) {
277
+ fail('intake', `look.anchors must be 2–3 non-empty named strings; got ${JSON.stringify(anchors ?? null)}.`, [
278
+ 'Name two or three real products (["Linear", "Stripe Dashboard"]); adjectives are not anchors —',
279
+ 'convert an adjective to a named product by asking (interactive) or inferring-and-recording (autonomous) via the design-md resemblance lookup (references/create.md, the look block).',
280
+ ]);
281
+ }
282
+ if (typeof look.resolution !== 'string' || !RESOLUTION_RE.test(look.resolution.trim())) {
283
+ fail('intake', `look.resolution is "${look.resolution ?? '(missing)'}"; must match project | library:<slug> | seed:<style>+<palette> | default.`, [
284
+ 'Set look.resolution to the resolved source from the precedence spine (references/create.md, Resolution).',
285
+ ]);
286
+ }
287
+ if (mode === 'recorded') {
288
+ const desc = tokens.$description;
289
+ if (typeof desc !== 'string' || !desc.trim() || !/runner-up/i.test(desc)) {
290
+ fail('intake', 'resolution-mode is recorded but the artifact root $description carries no resolution record naming the runner-up.', [
291
+ 'Write the resolution record into the artifact root $description — winner, runner-up, and the deciding evidence, e.g.:',
292
+ 'resolution: library:stripe (recorded; runner-up palettes.csv#3; brief phrase "Stripe-dashboard territory" matched product + fintech traits).',
293
+ 'A recorded (autonomous) resolution is legitimate only once it is auditable (references/create.md, Resolution).',
294
+ ]);
295
+ }
296
+ }
297
+ }
298
+
233
299
  // intake gate: a structurally sound design-tokens artifact sits at project root — both files parse, the dark
234
- // file agrees with the resolver, and the payload carries a legal schema/provenance/product. Light checks only;
235
- // deep validation (coverage, aliases, parse-back) is derive-personality.mjs --check at the tokens gate.
300
+ // file agrees with the resolver, the payload carries a legal schema/provenance/product, and the mandatory
301
+ // look block is present and well-formed. Light checks only; deep validation (coverage, aliases, parse-back)
302
+ // is derive-personality.mjs --check at the tokens gate.
236
303
  function gateIntake(project) {
237
304
  const tokensPath = join(project, 'design.tokens.json');
238
305
  const resolverPath = join(project, 'design.resolver.json');
@@ -269,10 +336,11 @@ function gateIntake(project) {
269
336
  'Add the file-level payload (schema, dtcg, product, provenance, sourced, lever-manifest, …) per data/design-tokens/SCHEMA.md.',
270
337
  ]);
271
338
  }
272
- if (payload.schema !== 'design-tokens/v1') fail('intake', `payload schema is "${payload.schema || '(missing)'}", expected design-tokens/v1.`, ['Set schema: "design-tokens/v1" in the payload (data/design-tokens/SCHEMA.md).']);
339
+ if (payload.schema !== 'design-tokens/v2') fail('intake', `payload schema is "${payload.schema || '(missing)'}", expected design-tokens/v2.`, ['Set schema: "design-tokens/v2" in the payload (data/design-tokens/SCHEMA.md).']);
273
340
  if (!PROVENANCE.has(payload.provenance)) fail('intake', `payload provenance is "${payload.provenance || '(missing)'}"; must be one of project|extracted|library|default.`, ['Set provenance to one of: project | extracted | library | default (data/design-tokens/SCHEMA.md).']);
274
341
  if (typeof payload.product !== 'string' || !payload.product.trim()) fail('intake', 'payload product is missing or empty.', ['Set product to the product name; slugified it becomes the data-style value (data/design-tokens/SCHEMA.md).']);
275
- pass('intake', `design.tokens.json + design.resolver.json present and parsing; payload schema design-tokens/v1, provenance "${payload.provenance}"${namesDark ? ', dark set named and present' : ', light-only'}.`);
342
+ validateLookBlock(payload, tokens);
343
+ pass('intake', `design.tokens.json + design.resolver.json present and parsing; payload schema design-tokens/v2, provenance "${payload.provenance}"${namesDark ? ', dark set named and present' : ', light-only'}; look block valid (theme ${payload.look.theme}, delight ${payload.look.delight}, ${Array.isArray(payload.look.anchors) ? payload.look.anchors.length : 0} anchors, resolution-mode ${payload.look['resolution-mode']}).`);
276
344
  }
277
345
 
278
346
  // structure gate: the map parses, is populated, has a temporal block, and every surface archetype is one of the six.
@@ -325,7 +393,7 @@ function gateScaffold(project) {
325
393
  if (noWire.length) fail('scaffold', `Route(s) not wired into the router: ${noWire.join('; ')}.`, ['Every surface route must be registered in src/App.tsx (react-router-dom) — routes and nav derive from the map, never decorated on.']);
326
394
  if (noShell.length) fail('scaffold', `Missing state-shell stubs: ${noShell.join('; ')}.`, ['Each surface needs its loading / empty / error / degraded shells wired (references/create.md -> scaffold).']);
327
395
  const globals = join(project, 'src/styles/globals.css');
328
- if (!existsSync(globals)) fail('scaffold', 'Missing src/styles/globals.css (Tailwind glue with the four-step import order).', ['Emit src/styles/globals.css with the Cascade Contract 2 import order (references/personality.md §4).']);
396
+ if (!existsSync(globals)) fail('scaffold', 'Missing src/styles/globals.css (Tailwind glue with the five-step import order).', ['Emit src/styles/globals.css with the Cascade Contract 2 import order (references/personality.md §4).']);
329
397
  const orderErr = checkImportOrder(readText(globals));
330
398
  if (orderErr) fail('scaffold', orderErr.problem, orderErr.fix);
331
399
  pass('scaffold', `Shell, ${blk.surfaces.length} wired route dir(s) with state-shells, and globals.css import order all in place.`);
@@ -384,7 +452,11 @@ function gateFixtures(project) {
384
452
  pass('fixtures', `Seeded fixture module present for all ${blk.entities.length} entit(ies).`);
385
453
  }
386
454
 
387
- // surfaces gate: every surface's route dir exists and its shell contains every declared state branch.
455
+ // surfaces gate: every surface's route dir exists, its shell contains every declared state branch, and
456
+ // it carries exactly one well-formed budget stamp — with a recorded budget-reason in the map when a
457
+ // brand-forward stamp overrides a baseline-default archetype (references/structure.md, the delight budget). The
458
+ // budget-gated vocabulary sweeps (display step typ-20, wash col-16) land with the rule catalog in a
459
+ // later step; the stamp is parsed here via lib/vocabulary.mjs so those sweeps reuse the same reader.
388
460
  function gateSurfaces(project) {
389
461
  const blk = requireMap('surfaces', project);
390
462
  const routesDir = join(project, 'src/routes');
@@ -396,14 +468,26 @@ function gateSurfaces(project) {
396
468
  const states = (s.states && s.states.length) ? s.states : ['loading', 'empty', 'error'];
397
469
  const miss = missingStates(dir, states);
398
470
  if (miss.length) problems.push(`${name}: shell missing state branch(es) ${miss.join(', ')}`);
471
+ const src = routeSourceRaw(dir);
472
+ const looseCount = (src.match(BUDGET_STAMP_LOOSE_RE) || []).length;
473
+ const stamps = findBudgetStamps(src);
474
+ if (looseCount === 0) {
475
+ problems.push(`${name}: no budget stamp at the top of the route file`);
476
+ } else if (stamps.length !== 1 || looseCount !== 1) {
477
+ problems.push(`${name}: expected exactly one well-formed budget stamp, found ${looseCount} "// Surface:" line(s) (${stamps.length} well-formed)`);
478
+ } else if (requiresBudgetReason(s.archetype, stamps[0]) && !(typeof s['budget-reason'] === 'string' && s['budget-reason'].trim())) {
479
+ problems.push(`${name}: brand-forward stamp on a ${s.archetype} surface (archetype defaults baseline) with no budget-reason in the surface map`);
480
+ }
399
481
  }
400
482
  if (problems.length) {
401
- fail('surfaces', `${problems.length} surface(s) not fully composed:`, [
483
+ fail('surfaces', `${problems.length} surface(s) not fully composed. Handle these before finalizing:`, [
402
484
  ...problems.map((p) => `- ${p}`),
403
485
  'Fill every declared lifecycle state into its pre-created shell (references/create.md -> surfaces); states are composed with the surface, not bolted on.',
486
+ 'Every surface route file carries exactly one stamp — "// Surface: baseline" or "// Surface: brand-forward (delight-allowing)"; scaffold.mjs writes it from the map budget, so re-classify by editing the surface budget in docs/design/structure.md and re-running scaffold.mjs.',
487
+ 'A brand-forward stamp on a baseline-default archetype (list-detail / log-stream / command-center) needs a recorded budget-reason on that surface entry (references/structure.md, the delight budget).',
404
488
  ]);
405
489
  }
406
- pass('surfaces', `All ${blk.surfaces.length} surface(s) have a route dir with every declared state branch present.`);
490
+ pass('surfaces', `All ${blk.surfaces.length} surface(s) have a route dir, every declared state branch, and exactly one legal budget stamp.`);
407
491
  }
408
492
 
409
493
  const GATES = { intake: gateIntake, structure: gateStructure, scaffold: gateScaffold, tokens: gateTokens, fixtures: gateFixtures, surfaces: gateSurfaces };