@nurix/etna 0.19.2 → 0.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/data/harness-index.json +1 -1
  2. package/data/harnesses/code/CLAUDE.md +2 -2
  3. package/data/harnesses/code/harness.json +2 -2
  4. package/data/harnesses/code/rules/frontend.md +3 -3
  5. package/data/harnesses/code/skills/design/SKILL.md +20 -8
  6. package/data/harnesses/code/skills/design/data/catalog/rules.csv +10 -5
  7. package/data/harnesses/code/skills/design/data/combinations/Readme.md +8 -4
  8. package/data/harnesses/code/skills/design/data/combinations/accent-presets.md +113 -0
  9. package/data/harnesses/code/skills/design/data/combinations/fonts-known.json +11 -0
  10. package/data/harnesses/code/skills/design/data/combinations/manifest.json +57 -10
  11. package/data/harnesses/code/skills/design/data/combinations/motion.csv +10 -0
  12. package/data/harnesses/code/skills/design/data/combinations/palettes.csv +92 -49
  13. package/data/harnesses/code/skills/design/data/combinations/styles.csv +67 -54
  14. package/data/harnesses/code/skills/design/data/combinations/typography.csv +64 -61
  15. package/data/harnesses/code/skills/design/data/design-md/library/expo.md +4 -4
  16. package/data/harnesses/code/skills/design/data/design-md/library/lovable.md +5 -5
  17. package/data/harnesses/code/skills/design/data/design-md/library/minimax.md +6 -6
  18. package/data/harnesses/code/skills/design/data/design-md/library/notion.md +4 -4
  19. package/data/harnesses/code/skills/design/data/design-md/library/runwayml.md +4 -4
  20. package/data/harnesses/code/skills/design/data/design-md/library/sentry.md +2 -2
  21. package/data/harnesses/code/skills/design/data/design-md/library/slack.md +6 -6
  22. package/data/harnesses/code/skills/design/data/design-md/library/webflow.md +5 -5
  23. package/data/harnesses/code/skills/design/data/design-md/nurix.md +11 -7
  24. package/data/harnesses/code/skills/design/data/design-tokens/SCHEMA.md +256 -141
  25. package/data/harnesses/code/skills/design/data/design-tokens/airbnb/design.tokens.json +309 -1
  26. package/data/harnesses/code/skills/design/data/design-tokens/airtable/design.tokens.json +309 -1
  27. package/data/harnesses/code/skills/design/data/design-tokens/anthropic-claude/design.tokens.json +309 -1
  28. package/data/harnesses/code/skills/design/data/design-tokens/apple/design.tokens.json +309 -1
  29. package/data/harnesses/code/skills/design/data/design-tokens/binance/design.tokens.json +309 -1
  30. package/data/harnesses/code/skills/design/data/design-tokens/bmw/design.tokens.json +309 -1
  31. package/data/harnesses/code/skills/design/data/design-tokens/cal/design.tokens.json +313 -1
  32. package/data/harnesses/code/skills/design/data/design-tokens/clay/design.tokens.json +309 -1
  33. package/data/harnesses/code/skills/design/data/design-tokens/clickhouse/design.tokens.json +309 -1
  34. package/data/harnesses/code/skills/design/data/design-tokens/cohere/design.tokens.json +309 -1
  35. package/data/harnesses/code/skills/design/data/design-tokens/coinbase/design.tokens.json +309 -1
  36. package/data/harnesses/code/skills/design/data/design-tokens/composio/design.tokens.json +309 -1
  37. package/data/harnesses/code/skills/design/data/design-tokens/cursor/design.tokens.json +309 -1
  38. package/data/harnesses/code/skills/design/data/design-tokens/elevenlabs/design.tokens.json +313 -1
  39. package/data/harnesses/code/skills/design/data/design-tokens/expo/design.tokens.json +309 -1
  40. package/data/harnesses/code/skills/design/data/design-tokens/figma/design.tokens.json +313 -1
  41. package/data/harnesses/code/skills/design/data/design-tokens/framer/design.tokens.json +309 -1
  42. package/data/harnesses/code/skills/design/data/design-tokens/goldens.json +123 -123
  43. package/data/harnesses/code/skills/design/data/design-tokens/hashicorp/design.tokens.json +309 -1
  44. package/data/harnesses/code/skills/design/data/design-tokens/ibm/design.tokens.json +309 -1
  45. package/data/harnesses/code/skills/design/data/design-tokens/intercom/design.tokens.json +309 -1
  46. package/data/harnesses/code/skills/design/data/design-tokens/kraken/design.tokens.json +313 -1
  47. package/data/harnesses/code/skills/design/data/design-tokens/linear-app/design.resolver.json +1 -1
  48. package/data/harnesses/code/skills/design/data/design-tokens/linear-app/design.tokens.dark.json +10 -0
  49. package/data/harnesses/code/skills/design/data/design-tokens/linear-app/design.tokens.json +309 -1
  50. package/data/harnesses/code/skills/design/data/design-tokens/lovable/design.tokens.json +313 -1
  51. package/data/harnesses/code/skills/design/data/design-tokens/meta/design.tokens.json +313 -1
  52. package/data/harnesses/code/skills/design/data/design-tokens/minimax/design.tokens.json +309 -1
  53. package/data/harnesses/code/skills/design/data/design-tokens/mintlify/design.tokens.json +309 -1
  54. package/data/harnesses/code/skills/design/data/design-tokens/miro/design.tokens.json +313 -1
  55. package/data/harnesses/code/skills/design/data/design-tokens/mistral-ai/design.tokens.json +309 -1
  56. package/data/harnesses/code/skills/design/data/design-tokens/mongodb/design.tokens.json +313 -1
  57. package/data/harnesses/code/skills/design/data/design-tokens/notion/design.tokens.json +309 -1
  58. package/data/harnesses/code/skills/design/data/design-tokens/nurix/design.tokens.json +307 -2
  59. package/data/harnesses/code/skills/design/data/design-tokens/nvidia/design.tokens.json +309 -1
  60. package/data/harnesses/code/skills/design/data/design-tokens/ollama/design.tokens.json +309 -1
  61. package/data/harnesses/code/skills/design/data/design-tokens/opencode-ai/design.tokens.json +309 -1
  62. package/data/harnesses/code/skills/design/data/design-tokens/posthog/design.tokens.json +309 -1
  63. package/data/harnesses/code/skills/design/data/design-tokens/raycast/design.tokens.json +309 -1
  64. package/data/harnesses/code/skills/design/data/design-tokens/replicate/design.tokens.json +309 -1
  65. package/data/harnesses/code/skills/design/data/design-tokens/resend/design.resolver.json +1 -1
  66. package/data/harnesses/code/skills/design/data/design-tokens/resend/design.tokens.dark.json +10 -0
  67. package/data/harnesses/code/skills/design/data/design-tokens/resend/design.tokens.json +309 -1
  68. package/data/harnesses/code/skills/design/data/design-tokens/revolut/design.tokens.json +309 -1
  69. package/data/harnesses/code/skills/design/data/design-tokens/runwayml/design.tokens.json +310 -2
  70. package/data/harnesses/code/skills/design/data/design-tokens/sanity/design.tokens.json +313 -1
  71. package/data/harnesses/code/skills/design/data/design-tokens/sentry/design.tokens.json +309 -1
  72. package/data/harnesses/code/skills/design/data/design-tokens/shopify/design.tokens.json +309 -1
  73. package/data/harnesses/code/skills/design/data/design-tokens/slack/design.tokens.json +309 -1
  74. package/data/harnesses/code/skills/design/data/design-tokens/spacex/design.tokens.json +309 -1
  75. package/data/harnesses/code/skills/design/data/design-tokens/spotify/design.tokens.json +313 -1
  76. package/data/harnesses/code/skills/design/data/design-tokens/stripe/design.tokens.json +313 -1
  77. package/data/harnesses/code/skills/design/data/design-tokens/supabase/design.tokens.json +309 -1
  78. package/data/harnesses/code/skills/design/data/design-tokens/superhuman/design.tokens.json +313 -1
  79. package/data/harnesses/code/skills/design/data/design-tokens/tesla/design.tokens.json +309 -1
  80. package/data/harnesses/code/skills/design/data/design-tokens/theverge/design.tokens.json +309 -1
  81. package/data/harnesses/code/skills/design/data/design-tokens/together-ai/design.tokens.json +313 -1
  82. package/data/harnesses/code/skills/design/data/design-tokens/uber/design.tokens.json +309 -1
  83. package/data/harnesses/code/skills/design/data/design-tokens/vercel/design.tokens.json +309 -1
  84. package/data/harnesses/code/skills/design/data/design-tokens/voltagent/design.resolver.json +1 -1
  85. package/data/harnesses/code/skills/design/data/design-tokens/voltagent/design.tokens.dark.json +10 -0
  86. package/data/harnesses/code/skills/design/data/design-tokens/voltagent/design.tokens.json +309 -1
  87. package/data/harnesses/code/skills/design/data/design-tokens/warp/design.tokens.json +309 -1
  88. package/data/harnesses/code/skills/design/data/design-tokens/webflow/design.tokens.json +309 -1
  89. package/data/harnesses/code/skills/design/data/design-tokens/wired/design.tokens.json +309 -1
  90. package/data/harnesses/code/skills/design/data/design-tokens/wise/design.tokens.json +309 -1
  91. package/data/harnesses/code/skills/design/data/design-tokens/x-ai/design.tokens.json +309 -1
  92. package/data/harnesses/code/skills/design/data/design-tokens/zapier/design.tokens.json +309 -1
  93. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/command-center.tsx +25 -25
  94. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/create-edit.tsx +18 -18
  95. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/dashboard.tsx +82 -105
  96. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/entity-detail.tsx +97 -93
  97. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/list-detail.tsx +16 -15
  98. package/data/harnesses/code/skills/design/data/operator-pack/exemplars/log-stream.tsx +34 -34
  99. package/data/harnesses/code/skills/design/data/operator-pack/surface-catalog.md +5 -5
  100. package/data/harnesses/code/skills/design/references/create.md +49 -7
  101. package/data/harnesses/code/skills/design/references/diagnosis.md +3 -3
  102. package/data/harnesses/code/skills/design/references/hard-rules.md +25 -3
  103. package/data/harnesses/code/skills/design/references/improve.md +32 -3
  104. package/data/harnesses/code/skills/design/references/interaction.md +3 -3
  105. package/data/harnesses/code/skills/design/references/introspect.md +1 -1
  106. package/data/harnesses/code/skills/design/references/lifecycle.md +2 -2
  107. package/data/harnesses/code/skills/design/references/personality.md +280 -133
  108. package/data/harnesses/code/skills/design/references/structure.md +22 -1
  109. package/data/harnesses/code/skills/design/scripts/convert-design-md.mjs +306 -26
  110. package/data/harnesses/code/skills/design/scripts/derive-personality.mjs +596 -104
  111. package/data/harnesses/code/skills/design/scripts/lib/checks.mjs +200 -11
  112. package/data/harnesses/code/skills/design/scripts/lib/vocabulary.mjs +65 -0
  113. package/data/harnesses/code/skills/design/scripts/phase-gate.mjs +95 -11
  114. package/data/harnesses/code/skills/design/scripts/preship-sweep.mjs +50 -5
  115. package/data/harnesses/code/skills/design/scripts/render-rung.mjs +214 -14
  116. package/data/harnesses/code/skills/design/scripts/scaffold.mjs +242 -25
  117. package/data/harnesses/code/skills/design/scripts/self-integrity.mjs +81 -6
  118. package/data/harnesses/code/skills/design/scripts/snapshot-goldens.mjs +101 -5
  119. package/data/harnesses/code/skills/design/scripts/validate-combinations.mjs +142 -17
  120. package/data/harnesses/etna/CLAUDE.md +1 -1
  121. package/data/harnesses/etna/harness.json +2 -2
  122. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- // skills/design/scripts/preship-sweep.mjs — verification-ladder rung 1 (D21/D23): static sweep — per-file catalog + manifest drift via lib/checks.mjs + cross-file machine checks + mandatory-procedures checklist + scope cross-check. --files <a> <b> … scopes the sweep (improve's end-of-run verify); no args = whole-app. --manifest <design.tokens.json | artifact dir> overrides the drift vocabulary source (default: <project>/design.tokens.json).
1
+ // skills/design/scripts/preship-sweep.mjs — verification-ladder rung 1 (D21/D23): static sweep — per-file catalog + manifest drift via lib/checks.mjs (incl. the budget-stamp vocabulary rules typ-20/col-16/mot-08) + cross-file machine checks + mandatory-procedures checklist + the resolved style row's checklist walk (the corpus's one positive verification) + scope cross-check. --files <a> <b> … scopes the sweep (improve's end-of-run verify); no args = whole-app. --manifest <design.tokens.json | artifact dir> overrides the drift vocabulary source (default: <project>/design.tokens.json).
2
2
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
3
3
  import { join, dirname, resolve, relative } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
@@ -194,6 +194,44 @@ function procChecklist(catalog) {
194
194
  return lines;
195
195
  }
196
196
 
197
+ // Resolve a --manifest argument or project default to the design.tokens.json file path, or null.
198
+ function tokensFileOf(manifestPath) {
199
+ if (!manifestPath || !existsSync(manifestPath)) return null;
200
+ try {
201
+ if (statSync(manifestPath).isDirectory()) {
202
+ const candidate = join(manifestPath, 'design.tokens.json');
203
+ return existsSync(candidate) ? candidate : null;
204
+ }
205
+ } catch { return null; }
206
+ return manifestPath;
207
+ }
208
+
209
+ // The resolved style row's checklist items, walked at preship — the sweep's one positive verification:
210
+ // the catalog proves nothing is broken; these items assert the resolved style actually landed. The row
211
+ // is read from the artifact root $description's resolution record (a "styles.csv#<id>" reference from
212
+ // seed composition); library/default/project looks carry no style row and print a one-line note.
213
+ function styleChecklist(manifestPath, section) {
214
+ const tokensFile = tokensFileOf(manifestPath);
215
+ if (!tokensFile) { section.push(' (no design.tokens.json found — no resolved style row to walk)'); return; }
216
+ let root;
217
+ try { root = JSON.parse(readText(tokensFile)); } catch { section.push(' (design.tokens.json does not parse — no resolved style row to walk)'); return; }
218
+ const record = typeof root?.$description === 'string' ? root.$description : '';
219
+ const ref = record.match(/styles\.csv#(\d+)/);
220
+ if (!ref) { section.push(' (the artifact resolution record names no styles.csv row — library/default/project looks carry no style checklist)'); return; }
221
+ const rows = parseCsv(readText(join(SCRIPT_DIR, '..', 'data', 'combinations', 'styles.csv')));
222
+ if (rows.length < 2) { section.push(yellow(' data/combinations/styles.csv missing or empty — cannot walk the resolved style row\'s checklist.')); return; }
223
+ const header = rows[0];
224
+ const idCol = header.indexOf('id');
225
+ const checklistCol = header.indexOf('checklist');
226
+ const row = rows.slice(1).find((r) => (r[idCol] || '').trim() === ref[1]);
227
+ if (!row || checklistCol < 0) { section.push(yellow(` resolution record names styles.csv#${ref[1]} but no such row (or no checklist column) exists — reconcile the record.`)); return; }
228
+ const items = (row[checklistCol] || '').split(';').map((s) => s.trim()).filter(Boolean);
229
+ if (!items.length) { section.push(` styles.csv#${ref[1]} carries no checklist items.`); return; }
230
+ section.push(` resolved style row: styles.csv#${ref[1]} — walk every box now, on the built surfaces:`);
231
+ for (const item of items) section.push(` [ ] ${item}`);
232
+ section.push(' Handle these before finalizing: an unchecked box means the resolved style did not land — fix the surface, not the checklist.');
233
+ }
234
+
197
235
  // Assemble and print the whole sweep, then exit non-zero when catalog or cross-file checks found violations.
198
236
  function main() {
199
237
  const args = process.argv.slice(2);
@@ -237,7 +275,13 @@ function main() {
237
275
  console.log(procChecklist(catalog).join('\n'));
238
276
  console.log('');
239
277
 
240
- console.log(bold('(c) Scope cross-check (proc-14)'));
278
+ console.log(bold('(d) Resolved style row — checklist walk (the positive verification)'));
279
+ const secD = [];
280
+ styleChecklist(manifestPath, secD);
281
+ console.log(secD.join('\n'));
282
+ console.log('');
283
+
284
+ console.log(bold('(e) Scope cross-check (proc-14)'));
241
285
  const surfaces = countSurfaces(project);
242
286
  const deliverables = countDeliverables(project);
243
287
  if (surfaces == null) {
@@ -248,7 +292,7 @@ function main() {
248
292
  }
249
293
  console.log('');
250
294
  } else {
251
- console.log('(scoped run — the mandatory-procedures checklist and scope cross-check run on the whole-app sweep)');
295
+ console.log('(scoped run — the mandatory-procedures checklist, style-row walk, and scope cross-check run on the whole-app sweep)');
252
296
  console.log('');
253
297
  }
254
298
 
@@ -257,10 +301,11 @@ function main() {
257
301
  const reasons = [];
258
302
  if (catalogCount > 0) reasons.push(`${catalogCount} per-file violation(s)`);
259
303
  if (crossCount > 0) reasons.push(`${crossCount} cross-file violation(s)`);
260
- console.log(red(`FAIL preship-sweep — ${reasons.join('; ')}. Resolve, then re-run.`));
304
+ console.log(red(`FAIL preship-sweep — ${reasons.join('; ')}. Handle every finding above before finalizing, then re-run.`));
261
305
  process.exit(1);
262
306
  }
263
- console.log(green(scoped ? 'PASS preship-sweep (scoped) — no static violations in the given files.' : 'PASS preship-sweep — no static violations; work the checklist above before ship.'));
307
+ console.log(green(scoped ? 'PASS preship-sweep (scoped) — no static violations in the given files.' : 'PASS preship-sweep — no static violations; work the checklists above before ship.'));
308
+ console.log('A clean scan means nothing is mechanically broken — it does not mean the design is good; the checklist walks above are where good is decided.');
264
309
  process.exit(0);
265
310
  }
266
311
 
@@ -1,9 +1,10 @@
1
- // skills/design/scripts/render-rung.mjs — verification-ladder rung 2: deterministic render checks + pixel instruments (D32)
1
+ // skills/design/scripts/render-rung.mjs — verification-ladder rung 2: deterministic render checks + pixel instruments (D32). --require makes the rung a gate (create's ladder: fail-closed when no headless browser is importable — emission contract 4); without it a skip degrades gracefully but always lands as a named finding, never a one-line notice. --screenshot <outdir> saves one PNG per viewport.
2
2
  import { createRequire } from 'node:module';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { inflateSync } from 'node:zlib';
5
- import { readFileSync, existsSync } from 'node:fs';
5
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
6
6
  import { join, resolve } from 'node:path';
7
+ import { MOTION_STAGGER_CLASS, STAGGER_CHILD_CAP } from './lib/vocabulary.mjs';
7
8
 
8
9
  // The four viewports the ladder sweeps; width is the breakpoint, height is a fixed tall canvas.
9
10
  const VIEWPORTS = [375, 768, 1024, 1440];
@@ -19,7 +20,11 @@ const DEV_PORTS = [5173, 4173, 3000, 8080];
19
20
  // Severity rank for the delivered (severity-ranked) list; auto-critical is never minted by machine render rules.
20
21
  const SEVERITY_RANK = { 'auto-critical': 0, critical: 1, major: 2, minor: 3 };
21
22
 
22
- const NO_BROWSER_LINE = 'render rung: no headless browser available in this project; rung skipped (rungs 0-1 still hold)';
23
+ // The exact install command --require prints when no headless browser is importable (contract 4).
24
+ const BROWSER_INSTALL_CMD = 'npm install -D playwright && npx playwright install chromium';
25
+
26
+ // The token trio cod-15 measures per declared theme context.
27
+ const THEME_DRIFT_PROPS = ['--background', '--foreground', '--primary'];
23
28
 
24
29
  // ----------------------------------------------------------------------------
25
30
  // Pure helpers (exported for unit testing — no browser required)
@@ -205,19 +210,77 @@ function rgbKey(rgb) {
205
210
  // CLI plumbing
206
211
  // ----------------------------------------------------------------------------
207
212
 
208
- // Parse argv into { project, url }.
213
+ // Parse argv into { project, url, require, screenshot }.
209
214
  function parseArgs(argv) {
210
- const out = { project: process.cwd(), url: null };
215
+ const out = { project: process.cwd(), url: null, require: false, screenshot: null };
211
216
  for (let i = 0; i < argv.length; i++) {
212
217
  const a = argv[i];
213
218
  if (a === '--project') out.project = resolve(argv[++i] ?? '.');
214
219
  else if (a.startsWith('--project=')) out.project = resolve(a.slice('--project='.length));
215
220
  else if (a === '--url') out.url = argv[++i] ?? null;
216
221
  else if (a.startsWith('--url=')) out.url = a.slice('--url='.length);
222
+ else if (a === '--require') out.require = true;
223
+ else if (a === '--screenshot') out.screenshot = resolve(argv[++i] ?? '.');
224
+ else if (a.startsWith('--screenshot=')) out.screenshot = resolve(a.slice('--screenshot='.length));
217
225
  }
218
226
  return out;
219
227
  }
220
228
 
229
+ // Report a skipped rung and return the process exit code. Never a one-line notice: without
230
+ // --require the skip lands as a named finding in the fix-or-classify register (exit 0 — improve
231
+ // keeps graceful degradation); with --require (create's ladder) the rung is a gate and fails closed
232
+ // with the exact remedy (emission contract 4 — a create run cannot complete without having rendered).
233
+ function reportSkip(requireMode, reason, remedy) {
234
+ console.log(`[${requireMode ? 'critical' : 'major'}] render-rung Verification — render rung skipped: ${reason}`);
235
+ console.log(` fix · ${remedy}`);
236
+ console.log(' consequence · the render-only rules (cod-15, spc-16, mot-08 DOM, spc-10, typ-01, int-17/18, cod-12) did not run; the surface ships unrendered.');
237
+ if (requireMode) {
238
+ console.log(' --require: this rung is a gate on create runs — fix the remedy above and re-run.');
239
+ return 1;
240
+ }
241
+ console.log(' classify this skip in the ship report or fix it — never ignore silently.');
242
+ console.log('');
243
+ return 0;
244
+ }
245
+
246
+ // cod-15 ground truth: the authored --background/--foreground/--primary byte forms per theme
247
+ // context the resolver declares, read from the project-root design-tokens artifact. Returns
248
+ // { light: { '--background': css, … }, dark?: … } or null when no artifact is readable.
249
+ export function loadAuthoredThemeColors(projectDir) {
250
+ const readJson = (name) => {
251
+ try { return JSON.parse(readFileSync(join(resolve(projectDir), name), 'utf8')); } catch { return null; }
252
+ };
253
+ const base = readJson('design.tokens.json');
254
+ const resolver = readJson('design.resolver.json');
255
+ if (!base || !base.color || !resolver) return null;
256
+ const themeModifier = (resolver.modifiers || []).find((m) => m && m.name === 'theme');
257
+ const contexts = Object.keys((themeModifier && themeModifier.contexts) || { light: [] });
258
+ const dark = readJson('design.tokens.dark.json');
259
+ const out = {};
260
+ for (const context of contexts) {
261
+ // The dark overlay carries the full color group (rule 10), so a token-level merge is the overlay.
262
+ const colorGroup = context === 'dark' && dark && dark.color ? { ...base.color, ...dark.color } : base.color;
263
+ const cssOf = (role) => {
264
+ let token = colorGroup[role];
265
+ for (let hops = 0; token && hops < 10; hops++) {
266
+ const ext = token.$extensions && token.$extensions['com.nurix.design'];
267
+ if (ext && typeof ext.css === 'string') return ext.css;
268
+ const v = token.$value;
269
+ if (typeof v === 'string' && /^\{color\.[^}]+\}$/.test(v)) token = colorGroup[v.slice(7, -1)];
270
+ else return null;
271
+ }
272
+ return null;
273
+ };
274
+ const values = {};
275
+ for (const prop of THEME_DRIFT_PROPS) {
276
+ const css = cssOf(prop.slice(2));
277
+ if (css) values[prop] = css;
278
+ }
279
+ if (Object.keys(values).length) out[context] = values;
280
+ }
281
+ return Object.keys(out).length ? out : null;
282
+ }
283
+
221
284
  // Try to import playwright, playwright-core, then puppeteer from the target project; return a driver adapter or null.
222
285
  async function loadBrowserDriver(projectDir) {
223
286
  const require = createRequire(join(resolve(projectDir), 'package.json'));
@@ -415,6 +478,77 @@ function inPagePerfRead() {
415
478
  return window.__perf || { cls: 0, lcp: 0 };
416
479
  }
417
480
 
481
+ // cod-15: flip data-theme through each declared context and read the rendered token trio off the
482
+ // root element, restoring the boot attribute afterwards.
483
+ function inPageThemeTokens(payload) {
484
+ const html = document.documentElement;
485
+ const previous = html.getAttribute('data-theme');
486
+ const out = {};
487
+ for (const context of payload.contexts) {
488
+ html.setAttribute('data-theme', context);
489
+ const cs = getComputedStyle(html);
490
+ const read = {};
491
+ for (const prop of payload.props) read[prop] = cs.getPropertyValue(prop).trim();
492
+ out[context] = read;
493
+ }
494
+ if (previous === null) html.removeAttribute('data-theme');
495
+ else html.setAttribute('data-theme', previous);
496
+ return out;
497
+ }
498
+
499
+ // spc-16: fixed-width table/grid cells whose rendered text exceeds the cell (scrollWidth >
500
+ // clientWidth) with no truncate or scroll affordance on the cell or a descendant wrapper.
501
+ function inPageCellTextFit(cap) {
502
+ const path = (el) => {
503
+ let s = el.tagName.toLowerCase();
504
+ if (el.id) s += '#' + el.id;
505
+ else if (el.classList.length) s += '.' + el.classList[0];
506
+ return s;
507
+ };
508
+ const offenders = [];
509
+ const cells = document.querySelectorAll('td, th, [role="cell"], [role="gridcell"], [role="columnheader"]');
510
+ for (const cell of cells) {
511
+ const cs = getComputedStyle(cell);
512
+ if (cs.display === 'none' || cs.visibility === 'hidden') continue;
513
+ if (cell.scrollWidth <= cell.clientWidth + 1) continue; // content fits — an auto-layout cell grows instead of overflowing
514
+ let excused = false;
515
+ for (const node of [cell, ...cell.querySelectorAll('*')]) {
516
+ const ns = getComputedStyle(node);
517
+ if (/(auto|scroll)/.test(ns.overflowX)) { excused = true; break; }
518
+ if (ns.textOverflow === 'ellipsis' && /(hidden|clip)/.test(ns.overflowX)) { excused = true; break; }
519
+ if (ns.webkitLineClamp && ns.webkitLineClamp !== 'none') { excused = true; break; }
520
+ }
521
+ if (excused) continue;
522
+ offenders.push({ selector: path(cell), scrollWidth: Math.round(cell.scrollWidth), clientWidth: Math.round(cell.clientWidth) });
523
+ if (offenders.length >= cap) break;
524
+ }
525
+ return offenders;
526
+ }
527
+
528
+ // mot-08 (DOM half): every rendered stagger container's child count and its total sequence length,
529
+ // the latter as the largest resolved child animation-delay.
530
+ function inPageStaggerAudit(payload) {
531
+ const path = (el) => {
532
+ let s = el.tagName.toLowerCase();
533
+ if (el.id) s += '#' + el.id;
534
+ else if (el.classList.length) s += '.' + el.classList[0];
535
+ return s;
536
+ };
537
+ const out = [];
538
+ for (const container of document.querySelectorAll('.' + payload.className)) {
539
+ let maxDelayMs = 0;
540
+ for (const child of container.children) {
541
+ for (const raw of getComputedStyle(child).animationDelay.split(',')) {
542
+ const t = raw.trim();
543
+ const ms = t.endsWith('ms') ? parseFloat(t) : parseFloat(t) * 1000;
544
+ if (!Number.isNaN(ms)) maxDelayMs = Math.max(maxDelayMs, ms);
545
+ }
546
+ }
547
+ out.push({ selector: path(container), childCount: container.children.length, maxDelayMs: Math.round(maxDelayMs) });
548
+ }
549
+ return out;
550
+ }
551
+
418
552
  // Read the page's effective background colour for the white-space instrument.
419
553
  function inPageBackground() {
420
554
  const parse = (s) => {
@@ -457,12 +591,11 @@ function printFinding(f) {
457
591
 
458
592
  // Run the render rung: load a browser, sweep viewports for machine findings, compute pixel instruments.
459
593
  async function main() {
460
- const { project, url: urlArg } = parseArgs(process.argv.slice(2));
594
+ const { project, url: urlArg, require: requireMode, screenshot: screenshotDir } = parseArgs(process.argv.slice(2));
461
595
 
462
596
  const driver = await loadBrowserDriver(project);
463
597
  if (!driver) {
464
- console.log(NO_BROWSER_LINE);
465
- return 0;
598
+ return reportSkip(requireMode, 'no headless browser importable from this project (tried playwright, playwright-core, puppeteer)', `install one: ${BROWSER_INSTALL_CMD}`);
466
599
  }
467
600
 
468
601
  let url = urlArg;
@@ -471,8 +604,7 @@ async function main() {
471
604
  if (url) console.log(`render rung: no --url given; using detected dev server ${url}`);
472
605
  }
473
606
  if (!url) {
474
- console.log('render rung: no --url given and no dev server detected on common ports; pass --url <dev-server-url>. rung skipped.');
475
- return 0;
607
+ return reportSkip(requireMode, 'no --url given and no dev server detected on common ports', 'start the dev server (npm run dev) or pass --url <dev-server-url>');
476
608
  }
477
609
 
478
610
  // Resolve axe-core from the project if present; gate only the accessible-name + ARIA-validity families.
@@ -493,6 +625,13 @@ async function main() {
493
625
  console.log('render rung: no src/styles/personality.css found; the rendered token-drift check (cod-02) is skipped.');
494
626
  }
495
627
 
628
+ // Load the authored per-theme trio for cod-15 from the project-root design-tokens artifact.
629
+ const authoredThemes = loadAuthoredThemeColors(project);
630
+ if (!authoredThemes) {
631
+ console.log('render rung: no readable design-tokens artifact at the project root; the per-theme rendered token drift check (cod-15) is skipped.');
632
+ }
633
+ let themeDriftChecked = false;
634
+
496
635
  const findings = new Map();
497
636
  const instruments = {};
498
637
  const axeRules = [...(axeSource ? AXE_ACCESSIBLE_NAME.concat(AXE_ARIA_VALIDITY) : [])];
@@ -508,9 +647,8 @@ async function main() {
508
647
  try {
509
648
  await page.goto(url, { waitUntil: 'load', timeout: 30000 });
510
649
  } catch (err) {
511
- console.log(`render rung: could not load ${url} at ${width}px (${err.message}); is the dev server running? rung skipped.`);
512
650
  await browser.close();
513
- return 0;
651
+ return reportSkip(requireMode, `could not load ${url} at ${width}px (${err.message})`, 'start the dev server (npm run dev) or pass --url <dev-server-url>');
514
652
  }
515
653
  await new Promise((r) => setTimeout(r, 150));
516
654
 
@@ -570,7 +708,65 @@ async function main() {
570
708
  }
571
709
  }
572
710
 
573
- // (5) int-17 / int-18 — axe accessible-name + ARIA-validity families.
711
+ // (5) cod-15 — per-theme rendered token drift (viewport-independent; measured once, with
712
+ // data-theme flipped through each context the resolver declares).
713
+ if (authoredThemes && !themeDriftChecked) {
714
+ themeDriftChecked = true;
715
+ try {
716
+ const rendered = await page.evaluate(inPageThemeTokens, { contexts: Object.keys(authoredThemes), props: THEME_DRIFT_PROPS });
717
+ const normalize = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
718
+ for (const [context, roles] of Object.entries(authoredThemes)) {
719
+ for (const [prop, authored] of Object.entries(roles)) {
720
+ const got = (rendered[context] && rendered[context][prop]) || '';
721
+ if (normalize(got) === normalize(authored)) continue;
722
+ addFinding(findings, width, {
723
+ anchor: 'cod-15', dimension: 'S3 Design Tokens', statement: 'rendered token drifts from the authored value in this theme context',
724
+ evidenceKey: `${context}|${prop}`, where: `data-theme="${context}" · ${prop} renders ${JSON.stringify(got || '(empty)')}`,
725
+ before: got || '(empty)', after: `${authored} (the ${context} context's authored value)`,
726
+ consequence: "the theme context is cascade-dead — the library's later :root block wins and the surface silently reverts to shadcn gray", severity: 'critical',
727
+ });
728
+ }
729
+ }
730
+ } catch (err) {
731
+ console.log(`render rung: the per-theme token drift check (cod-15) failed to run (${err.message}); continuing.`);
732
+ }
733
+ }
734
+
735
+ // (6) spc-16 — fixed-width cell text fit: rendered text exceeds the cell with no truncate or
736
+ // scroll affordance (document-level overflow cannot see intra-row column paint-over).
737
+ const cellOffenders = await page.evaluate(inPageCellTextFit, 40);
738
+ for (const cell of cellOffenders) {
739
+ addFinding(findings, width, {
740
+ anchor: 'spc-16', dimension: 'P3 Spatial', statement: 'rendered text exceeds its fixed-width cell with no truncate or scroll affordance',
741
+ evidenceKey: cell.selector, where: `${cell.selector} · content ${cell.scrollWidth}px > cell ${cell.clientWidth}px`,
742
+ before: `${cell.scrollWidth}px of content in a ${cell.clientWidth}px cell`, after: 'text fits, truncates (truncate), or scrolls (overflow-x-auto)',
743
+ consequence: 'cell text paints over or clips against the neighbouring column; the row reads as broken data', severity: 'major',
744
+ });
745
+ }
746
+
747
+ // (7) mot-08 (DOM half) — rendered stagger containers stay within the child cap and the
748
+ // 1s total-sequence budget (the source shell owns the per-item band and the budget stamp).
749
+ const staggerContainers = await page.evaluate(inPageStaggerAudit, { className: MOTION_STAGGER_CLASS });
750
+ for (const s of staggerContainers) {
751
+ if (s.childCount > STAGGER_CHILD_CAP) {
752
+ addFinding(findings, width, {
753
+ anchor: 'mot-08', dimension: 'B2 Motion', statement: `arrival cascade exceeds the ${STAGGER_CHILD_CAP}-child cap`,
754
+ evidenceKey: `count|${s.selector}`, where: `${s.selector} · ${s.childCount} children in a ${MOTION_STAGGER_CLASS} container`,
755
+ before: `${s.childCount} staggered children`, after: `<= ${STAGGER_CHILD_CAP} (children past the cap arrive with the last delayed one)`,
756
+ consequence: 'a long cascade turns arrival feedback into a wait; the operator reads it as slowness', severity: 'major',
757
+ });
758
+ }
759
+ if (s.maxDelayMs >= 1000) {
760
+ addFinding(findings, width, {
761
+ anchor: 'mot-08', dimension: 'B2 Motion', statement: 'total stagger sequence at or over the 1s budget',
762
+ evidenceKey: `sequence|${s.selector}`, where: `${s.selector} · last child arrives at ${s.maxDelayMs}ms`,
763
+ before: `${s.maxDelayMs}ms total sequence`, after: '< 1000ms (per-item delay x child count under the budget)',
764
+ consequence: 'content the operator came for is still arriving after a second; the cascade reads as lag, not polish', severity: 'major',
765
+ });
766
+ }
767
+ }
768
+
769
+ // (8) int-17 / int-18 — axe accessible-name + ARIA-validity families.
574
770
  if (axeSource) {
575
771
  try {
576
772
  await page.addScriptTag({ content: axeSource });
@@ -596,7 +792,7 @@ async function main() {
596
792
  }
597
793
  }
598
794
 
599
- // (6) cod-12 — runtime perf budgets measurable in a passive headless load: LCP and CLS.
795
+ // (9) cod-12 — runtime perf budgets measurable in a passive headless load: LCP and CLS.
600
796
  // INP and frame time require real interaction and belong to the interactive rung (3), not this one.
601
797
  try {
602
798
  const perf = await page.evaluate(inPagePerfRead);
@@ -621,6 +817,10 @@ async function main() {
621
817
  // Instruments — pixel evidence, never a verdict.
622
818
  try {
623
819
  const shot = Buffer.from(await page.screenshot({ type: 'png' }));
820
+ if (screenshotDir) {
821
+ mkdirSync(screenshotDir, { recursive: true });
822
+ writeFileSync(join(screenshotDir, `${width}.png`), shot);
823
+ }
624
824
  const bg = await page.evaluate(inPageBackground);
625
825
  const img = decodePng(shot);
626
826
  instruments[width] = {