@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,9 +1,16 @@
1
- // skills/design/scripts/scaffold.mjs — generates the non-creative app substrate (routes, nav, per-surface state shells) from docs/design/structure.md
1
+ // skills/design/scripts/scaffold.mjs — generates the non-creative app substrate (routes, nav, per-surface state shells, the theme + chart-colors lib helpers) from docs/design/structure.md
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
+ import { BUDGETS, BUDGET_STAMPS, stampFor, THEME_ATTR, THEME_VALUES, resolveThemeDefault, MOTION_LIFT_CLASS, MOTION_STAGGER_CLASS, STAGGER_CHILD_CAP } from "./lib/vocabulary.mjs";
4
5
 
5
6
  const ARCHETYPES = ["list-detail", "dashboard", "entity-detail", "create-edit", "log-stream", "command-center"];
6
7
  const LANDING = new Set(["list-detail", "dashboard", "log-stream", "command-center"]);
8
+ // Archetypes whose composition substrate is a row table (rows consume motion-lift) vs a card grid
9
+ // (the grid consumes motion-stagger — brand-forward surfaces only: baseline skips arrival cascades, mot-08).
10
+ const TABLE_ARCHETYPES = new Set(["list-detail", "log-stream", "command-center"]);
11
+ const CARD_GRID_ARCHETYPES = new Set(["dashboard"]);
12
+ // Archetypes whose surfaces typically carry charts — their composition region names the token wiring.
13
+ const CHART_ARCHETYPES = new Set(["dashboard", "command-center"]);
7
14
 
8
15
  // Print the refusal (problem + exact fix) to stderr and exit non-zero.
9
16
  function refuse(problem, fix) {
@@ -197,6 +204,13 @@ function entityNames(name) {
197
204
  };
198
205
  }
199
206
 
207
+ // Normalize a surface's declared delight budget to a legal enum value, defaulting to the safe floor
208
+ // `baseline` when the map omits it or carries an unknown value (references/structure.md, the delight budget).
209
+ function normalizeBudget(v) {
210
+ const b = String(v ?? "").trim().toLowerCase();
211
+ return BUDGETS.includes(b) ? b : "baseline";
212
+ }
213
+
200
214
  // Normalize a surface's declared states to the canonical loading/empty/error/degraded set.
201
215
  function normalizeStates(arr) {
202
216
  const alias = { partial: "degraded", degraded: "degraded", loading: "loading", empty: "empty", error: "error" };
@@ -254,6 +268,7 @@ function loadStructure(path, { requireSurfaces }) {
254
268
  route: String(s.route).trim(),
255
269
  entity: String(s.entity).trim(),
256
270
  densityModifier: mod,
271
+ budget: normalizeBudget(s.budget),
257
272
  states: normalizeStates(s.states),
258
273
  };
259
274
  });
@@ -280,18 +295,38 @@ function emit(root, rel, content, written) {
280
295
  written.push(rel);
281
296
  }
282
297
 
283
- // Render index.html carrying placeholder <html data-*> personality attributes (derive-personality --emit resolves them).
284
- function indexHtml() {
298
+ // Read the emitted default theme from the artifact payload (payload.theme-default, else derived from
299
+ // look.theme via both → light), returning light when the artifact is absent, unreadable, or silent.
300
+ function readThemeDefault(out) {
301
+ const artifactPath = join(out, "design.tokens.json");
302
+ if (!existsSync(artifactPath)) return "light";
303
+ let tokens;
304
+ try {
305
+ tokens = JSON.parse(readFileSync(artifactPath, "utf8"));
306
+ } catch {
307
+ return "light";
308
+ }
309
+ const payload = tokens && tokens.$extensions && tokens.$extensions["com.nurix.design"];
310
+ if (!payload || typeof payload !== "object") return "light";
311
+ if (THEME_VALUES.includes(payload["theme-default"])) return payload["theme-default"];
312
+ if (payload.look && typeof payload.look === "object") return resolveThemeDefault(payload.look.theme);
313
+ return "light";
314
+ }
315
+
316
+ // Render index.html. data-theme is written from the artifact's resolved theme-default; the other
317
+ // <html data-*> personality attributes are placeholders derive-personality.mjs --emit resolves.
318
+ function indexHtml(themeDefault) {
285
319
  return `<!doctype html>
286
320
  <!-- Generated by scaffold.mjs. Personality state lives in these <html data-*> attributes (code is the cache).
287
- The values below are placeholders; derive-personality.mjs --emit resolves them from the project's design-tokens artifact (design.tokens.json). -->
321
+ data-theme is written from the project artifact's theme-default; the remaining values are placeholders
322
+ derive-personality.mjs --emit resolves from the design-tokens artifact (design.tokens.json). -->
288
323
  <html
289
324
  lang="en"
290
325
  data-style="app"
291
326
  data-density="standard"
292
327
  data-verbosity="default"
293
328
  data-feedback="subtle"
294
- data-theme="light"
329
+ data-theme="${themeDefault}"
295
330
  >
296
331
  <head>
297
332
  <meta charset="UTF-8" />
@@ -306,14 +341,18 @@ function indexHtml() {
306
341
  `;
307
342
  }
308
343
 
309
- // Render src/main.tsx: mount the router app and load the emitted global stylesheet.
344
+ // Render src/main.tsx: apply the persisted theme, mount the router app, load the emitted stylesheet.
310
345
  function mainTsx() {
311
- return `// src/main.tsx — Vite entry: mounts the router app and loads the emitted global stylesheet (generated by scaffold.mjs; do not hand-edit).
346
+ return `// src/main.tsx — Vite entry: applies the persisted theme, mounts the router app, and loads the emitted global stylesheet (generated by scaffold.mjs; do not hand-edit).
312
347
  import { StrictMode } from "react";
313
348
  import { createRoot } from "react-dom/client";
314
349
  import App from "./App";
350
+ import { initTheme } from "./lib/theme";
315
351
  import "./styles/globals.css";
316
352
 
353
+ // A stored theme preference overrides the boot attribute before first paint.
354
+ initTheme();
355
+
317
356
  const rootElement = document.getElementById("root");
318
357
  if (!rootElement) {
319
358
  throw new Error("Root element #root not found in index.html");
@@ -327,6 +366,88 @@ createRoot(rootElement).render(
327
366
  `;
328
367
  }
329
368
 
369
+ // Render src/lib/theme.ts: the data-theme toggle + persistence helper — one attribute convention,
370
+ // four writers (personality.css armor, scaffold's <html>, @custom-variant dark, this helper).
371
+ function themeTs() {
372
+ const themeUnion = THEME_VALUES.map((v) => `"${v}"`).join(" | ");
373
+ return `// src/lib/theme.ts — ${THEME_ATTR} toggle + persistence (generated by scaffold.mjs; do not hand-edit).
374
+ // Code is the cache: <html ${THEME_ATTR}> is the single runtime theme state — the same attribute
375
+ // personality.css armors (:root[${THEME_ATTR}="dark"]) and globals.css's @custom-variant dark follows.
376
+ // index.html carries the artifact's theme-default as the boot value; a stored preference overrides it.
377
+
378
+ export type Theme = ${themeUnion};
379
+
380
+ const THEME_ATTRIBUTE = "${THEME_ATTR}";
381
+ const THEME_STORAGE_KEY = "theme";
382
+ const THEMES: Theme[] = [${THEME_VALUES.map((v) => `"${v}"`).join(", ")}];
383
+
384
+ // The active theme, read from the attribute (the cache), defaulting to light.
385
+ export function getTheme(): Theme {
386
+ const value = document.documentElement.getAttribute(THEME_ATTRIBUTE);
387
+ return THEMES.includes(value as Theme) ? (value as Theme) : "light";
388
+ }
389
+
390
+ // Set the theme: write the attribute (the cascade flips) and persist the preference.
391
+ export function setTheme(theme: Theme): void {
392
+ document.documentElement.setAttribute(THEME_ATTRIBUTE, theme);
393
+ try {
394
+ localStorage.setItem(THEME_STORAGE_KEY, theme);
395
+ } catch {
396
+ // storage unavailable (private mode) — the attribute still flips for this session
397
+ }
398
+ }
399
+
400
+ // Flip between light and dark, returning the new theme.
401
+ export function toggleTheme(): Theme {
402
+ const next: Theme = getTheme() === "dark" ? "light" : "dark";
403
+ setTheme(next);
404
+ return next;
405
+ }
406
+
407
+ // Apply a stored preference over the boot attribute; called once by main.tsx before mount.
408
+ export function initTheme(): void {
409
+ let stored: string | null = null;
410
+ try {
411
+ stored = localStorage.getItem(THEME_STORAGE_KEY);
412
+ } catch {
413
+ return;
414
+ }
415
+ if (stored !== null && THEMES.includes(stored as Theme)) {
416
+ document.documentElement.setAttribute(THEME_ATTRIBUTE, stored as Theme);
417
+ }
418
+ }
419
+ `;
420
+ }
421
+
422
+ // Render src/lib/chart-colors.ts: token-backed chart palettes so the identity survives to the pixel.
423
+ function chartColorsTs() {
424
+ return `// src/lib/chart-colors.ts — chart color wiring over the emitted tokens (generated by scaffold.mjs; do not hand-edit).
425
+ // @nurix/components charts accept color props (probed at 0.14.0): LineGraph/PieGraph take a series
426
+ // list, BarGraph takes a status map (success | degraded | error | info | empty). Always pass these
427
+ // exports — never literals — so every chart inherits the theme through the emitted tokens; chart
428
+ // chrome (gridlines, axis labels) stays library-internal, a known substrate limit.
429
+
430
+ // Series palette for LineGraph / PieGraph \`colors\` — the artifact's five chart tokens, in order.
431
+ export const SERIES_COLORS = [
432
+ "var(--chart-1)",
433
+ "var(--chart-2)",
434
+ "var(--chart-3)",
435
+ "var(--chart-4)",
436
+ "var(--chart-5)",
437
+ ];
438
+
439
+ // Status map for BarGraph \`colors\`. info/empty ride accent / muted-foreground: the 36-role
440
+ // artifact carries no dedicated info or empty token, and those two are their closest semantic homes.
441
+ export const STATUS_COLORS = {
442
+ success: "var(--success)",
443
+ degraded: "var(--warning)",
444
+ error: "var(--destructive)",
445
+ info: "var(--accent)",
446
+ empty: "var(--muted-foreground)",
447
+ };
448
+ `;
449
+ }
450
+
330
451
  // Render src/App.tsx: one route per surface, wrapped in the persistent operator shell.
331
452
  function appTsx(surfaces) {
332
453
  const imports = surfaces
@@ -418,6 +539,113 @@ export function AppNav() {
418
539
  `;
419
540
  }
420
541
 
542
+ // Composition region for one surface, per archetype: table archetypes get a substrate row table
543
+ // (rows consume motion-lift — hover feedback, budget-free); card-grid archetypes get a substrate
544
+ // grid (motion-stagger on brand-forward surfaces only — baseline skips arrival cascades, mot-08);
545
+ // chart archetypes carry the token wiring instruction; the rest keep the compose-here placeholder.
546
+ function compositionFn(surface, en) {
547
+ const comp = toPascal(surface.name);
548
+ const chartNote = CHART_ARCHETYPES.has(surface.archetype)
549
+ ? `
550
+ {/* Charts: import { SERIES_COLORS, STATUS_COLORS } from "../../lib/chart-colors" and pass
551
+ colors={SERIES_COLORS} to LineGraph/PieGraph, colors={STATUS_COLORS} to BarGraph —
552
+ token-backed palettes only; chart chrome is library-internal (a substrate limit). */}`
553
+ : "";
554
+
555
+ if (TABLE_ARCHETYPES.has(surface.archetype)) {
556
+ return `// Composition region: the surfaces phase composes the ${surface.archetype} archetype here, following its exemplar and the emitted tokens.
557
+ function ${comp}Composition({ rows, degraded = false }: { rows: ${en.iface}[]; degraded?: boolean }) {
558
+ const columns = rows.length > 0 ? Object.keys(rows[0] as unknown as Record<string, unknown>).slice(0, 5) : [];
559
+ return (
560
+ <div className="flex flex-1 flex-col rounded-lg border border-border bg-card text-card-foreground">
561
+ <div className="flex items-center justify-between border-b border-border px-4 py-3">
562
+ <p className="text-sm font-medium text-foreground">${surface.name}</p>
563
+ <p className="text-xs text-muted-foreground tabular-nums">
564
+ {rows.length} records{degraded ? " (partial)" : ""}
565
+ </p>
566
+ </div>
567
+ <div className="overflow-x-auto">
568
+ <table className="w-full table-fixed text-sm">
569
+ <thead>
570
+ <tr className="border-b border-border text-left">
571
+ {columns.map((column) => (
572
+ <th key={column} scope="col" className="truncate px-4 py-2 text-xs font-medium text-muted-foreground">
573
+ {column}
574
+ </th>
575
+ ))}
576
+ </tr>
577
+ </thead>
578
+ <tbody>
579
+ {rows.map((row, index) => (
580
+ <tr key={index} className="${MOTION_LIFT_CLASS} border-b border-border">
581
+ {columns.map((column) => (
582
+ <td key={column} className="truncate px-4 py-2">
583
+ {String((row as unknown as Record<string, unknown>)[column] ?? "")}
584
+ </td>
585
+ ))}
586
+ </tr>
587
+ ))}
588
+ </tbody>
589
+ </table>
590
+ </div>${chartNote}
591
+ <p className="border-t border-border px-4 py-3 text-xs text-muted-foreground">
592
+ Substrate table: compose the ${surface.archetype} surface for ${surface.entity} here.
593
+ </p>
594
+ </div>
595
+ );
596
+ }`;
597
+ }
598
+
599
+ if (CARD_GRID_ARCHETYPES.has(surface.archetype)) {
600
+ const staggerClass = surface.budget === "brand-forward" ? ` ${MOTION_STAGGER_CLASS}` : "";
601
+ return `// Composition region: the surfaces phase composes the ${surface.archetype} archetype here, following its exemplar and the emitted tokens.
602
+ function ${comp}Composition({ rows, degraded = false }: { rows: ${en.iface}[]; degraded?: boolean }) {
603
+ return (
604
+ <div className="flex flex-1 flex-col gap-3">
605
+ <div className="flex items-center justify-between">
606
+ <p className="text-sm font-medium text-foreground">${surface.name}</p>
607
+ <p className="text-xs text-muted-foreground tabular-nums">
608
+ {rows.length} records{degraded ? " (partial)" : ""}
609
+ </p>
610
+ </div>
611
+ <div className="grid auto-rows-min grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3${staggerClass}">
612
+ {rows.slice(0, ${STAGGER_CHILD_CAP}).map((row, index) => (
613
+ <article key={index} className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm">
614
+ {Object.entries(row as unknown as Record<string, unknown>).slice(0, 4).map(([field, value]) => (
615
+ <div key={field} className="flex items-baseline justify-between gap-3">
616
+ <span className="truncate text-xs text-muted-foreground">{field}</span>
617
+ <span className="truncate text-sm font-medium">{String(value ?? "")}</span>
618
+ </div>
619
+ ))}
620
+ </article>
621
+ ))}
622
+ </div>${chartNote}
623
+ <p className="text-xs text-muted-foreground">
624
+ Substrate card grid: compose the ${surface.archetype} surface for ${surface.entity} here.
625
+ </p>
626
+ </div>
627
+ );
628
+ }`;
629
+ }
630
+
631
+ return `// Composition region: the surfaces phase composes the ${surface.archetype} archetype here, following its exemplar and the emitted tokens.
632
+ function ${comp}Composition({ rows, degraded = false }: { rows: ${en.iface}[]; degraded?: boolean }) {
633
+ return (
634
+ <div className="flex flex-1 flex-col rounded-lg border border-border bg-card text-card-foreground">
635
+ <div className="flex items-center justify-between border-b border-border px-4 py-3">
636
+ <p className="text-sm font-medium text-foreground">${surface.name}</p>
637
+ <p className="text-xs text-muted-foreground tabular-nums">
638
+ {rows.length} records{degraded ? " (partial)" : ""}
639
+ </p>
640
+ </div>${chartNote}
641
+ <p className="px-4 py-6 text-sm text-muted-foreground">
642
+ Compose the ${surface.archetype} surface for ${surface.entity} here.
643
+ </p>
644
+ </div>
645
+ );
646
+ }`;
647
+ }
648
+
421
649
  // Render one surface shell: typed props, fixture wiring, pre-created lifecycle state shells, and the density-step root.
422
650
  function surfaceShell(surface) {
423
651
  const comp = toPascal(surface.name);
@@ -475,7 +703,9 @@ function surfaceShell(surface) {
475
703
  if (hasEmpty) pickBody.push(` if (state === "empty") return ${en.emptyVar};`);
476
704
  pickBody.push(` return ${en.arrayVar};`);
477
705
 
478
- const content = `// src/routes/${slug}/${comp}.tsx — ${surface.name} surface shell (${surface.archetype}; generated by scaffold.mjs; do not hand-edit).
706
+ const stamp = stampFor(surface.budget) || BUDGET_STAMPS.baseline;
707
+ const content = `${stamp}
708
+ // src/routes/${slug}/${comp}.tsx — ${surface.name} surface shell (${surface.archetype}; generated by scaffold.mjs; do not hand-edit).
479
709
  import type { ${en.iface} } from "../../types/${en.typeFile}";
480
710
  import { ${named.join(", ")} } from "../../fixtures/${en.fixtureFile}";
481
711
 
@@ -502,22 +732,7 @@ ${branches.join("\n")}
502
732
  );
503
733
  }
504
734
 
505
- // Composition region: the surfaces phase composes the ${surface.archetype} archetype here, following its exemplar and the emitted tokens.
506
- function ${comp}Composition({ rows, degraded = false }: { rows: ${en.iface}[]; degraded?: boolean }) {
507
- return (
508
- <div className="flex flex-1 flex-col rounded-lg border border-border bg-card text-card-foreground">
509
- <div className="flex items-center justify-between border-b border-border px-4 py-3">
510
- <p className="text-sm font-medium text-foreground">${surface.name}</p>
511
- <p className="text-xs text-muted-foreground tabular-nums">
512
- {rows.length} records{degraded ? " (partial)" : ""}
513
- </p>
514
- </div>
515
- <p className="px-4 py-6 text-sm text-muted-foreground">
516
- Compose the ${surface.archetype} surface for ${surface.entity} here.
517
- </p>
518
- </div>
519
- );
520
- }
735
+ ${compositionFn(surface, en)}
521
736
 
522
737
  // Select the fixture set backing the requested lifecycle state.
523
738
  function pickRows(state: SurfaceState): ${en.iface}[] {
@@ -537,8 +752,10 @@ function main() {
537
752
  }
538
753
  const model = loadStructure(structurePath, { requireSurfaces: true });
539
754
  const written = [];
540
- emit(out, "index.html", indexHtml(), written);
755
+ emit(out, "index.html", indexHtml(readThemeDefault(out)), written);
541
756
  emit(out, "src/main.tsx", mainTsx(), written);
757
+ emit(out, "src/lib/theme.ts", themeTs(), written);
758
+ emit(out, "src/lib/chart-colors.ts", chartColorsTs(), written);
542
759
  emit(out, "src/App.tsx", appTsx(model.surfaces), written);
543
760
  emit(out, "src/components/nav/AppNav.tsx", navTsx(model.surfaces), written);
544
761
  for (const surface of model.surfaces) {
@@ -1,4 +1,4 @@
1
- // skills/design/scripts/self-integrity.mjs — the design skill's own CI check: anchor parity, catalog invariants, stated-count and fixture-pair validation (pairs exercised through lib/checks.mjs), the token-era net — token file-tree + goldens parity, per-artifact --check validity, and (behind --goldens) golden emission hash match — plus combination-data validation (validate-combinations.mjs); --fixture <rule-id> runs a single pair
1
+ // skills/design/scripts/self-integrity.mjs — the design skill's own CI check: anchor parity (rule ids and the doctrine-floor f-ids), catalog invariants, stated-count and fixture-pair validation (pairs exercised through lib/checks.mjs), the token-era net — token file-tree + goldens parity, per-artifact --check validity, and (behind --goldens) golden emission hash match — plus combination-data validation (validate-combinations.mjs); --fixture <rule-id> runs a single pair
2
2
  import { readFileSync, existsSync, readdirSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { join, dirname, resolve } from "node:path";
4
4
  import { tmpdir } from "node:os";
@@ -26,7 +26,11 @@ const ANCHOR_PREFIXES = "typ|col|spc|dep|int|mot|sta|cod|proc|inst|trust";
26
26
  const ANCHOR_TOKEN = new RegExp("`(" + ANCHOR_PREFIXES + ")-(\\d{2})`", "g");
27
27
  const ANCHOR_ID = "(?:" + ANCHOR_PREFIXES + ")-\\d{2}";
28
28
 
29
- const PINNED = { rows: 131, source: 66, render: 10, archetypes: 6, primitives: 26, colorTokens: 34, semanticTokens: 17, substrateTokens: 17 };
29
+ const PINNED = { rows: 136, source: 70, render: 12, archetypes: 6, primitives: 27, colorTokens: 36, semanticTokens: 19, substrateTokens: 17, rampSteps: 7, elevation: 4 };
30
+
31
+ // The doctrine floor's canonical ids (f01-f12) — enumerated once in hard-rules.md's Doctrine floor
32
+ // section; every in-skill floor citation must name one of these (05-rule-amendments.md, the mapping).
33
+ const FLOOR_IDS = Array.from({ length: 12 }, (_, i) => "f" + String(i + 1).padStart(2, "0"));
30
34
 
31
35
  // Read a UTF-8 file, returning null when it does not exist.
32
36
  function readSafe(path) {
@@ -287,6 +291,58 @@ function checkContrastiveValidity(catalog, onlyId = null) {
287
291
  return { name: "contrastive fixture validity", pass: errors.length === 0, detail: `${checked} pair(s) exercised`, errors };
288
292
  }
289
293
 
294
+ // Recursively collect prose/data files (.md/.csv/.tsx/.json) under a dir for the floor-citation scan.
295
+ function walkTextFiles(dir, acc = []) {
296
+ if (!existsSync(dir)) return acc;
297
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
298
+ if (ent.name.startsWith(".") || ent.name === "node_modules") continue;
299
+ const full = join(dir, ent.name);
300
+ if (ent.isDirectory()) walkTextFiles(full, acc);
301
+ else if (/\.(md|csv|tsx|json)$/.test(ent.name)) acc.push(full);
302
+ }
303
+ return acc;
304
+ }
305
+
306
+ // ── Check 2b: doctrine-floor anchor parity (f-ids) ─────────────────────────────
307
+ // Same mechanism as rule-id parity: hard-rules.md's Doctrine floor section is the canonical
308
+ // enumeration (exactly f01-f12, once each); every f-id cited anywhere in the skill's prose/data
309
+ // names an enumerated id, and the retired "floor P<n>" numbering is gone.
310
+ function checkFloorParity() {
311
+ const errors = [];
312
+ const md = readSafe(HARD_RULES_MD);
313
+ if (md === null) return { name: "floor parity (f-ids)", pass: false, detail: "hard-rules.md missing", errors: ["hard-rules.md not found at " + HARD_RULES_MD] };
314
+
315
+ const section = /^##\s+Doctrine floor\s*$([\s\S]*?)(?=^##\s)/m.exec(md);
316
+ const enumerated = new Map();
317
+ if (!section) errors.push('hard-rules.md has no "## Doctrine floor" section (the canonical f01-f12 enumeration)');
318
+ else {
319
+ for (const m of section[1].matchAll(/`(f\d{2})`/g)) enumerated.set(m[1], (enumerated.get(m[1]) || 0) + 1);
320
+ for (const id of FLOOR_IDS) {
321
+ const n = enumerated.get(id) || 0;
322
+ if (n === 0) errors.push(`doctrine floor enumeration is missing ${id}`);
323
+ else if (n > 1) errors.push(`${id} enumerated ${n} times (want exactly 1)`);
324
+ }
325
+ for (const id of enumerated.keys()) if (!FLOOR_IDS.includes(id)) errors.push(`enumeration carries unknown floor id ${id} (legal: f01-f12)`);
326
+ }
327
+
328
+ const targets = [join(SKILL_ROOT, "SKILL.md"), ...walkTextFiles(join(SKILL_ROOT, "references")), ...walkTextFiles(join(SKILL_ROOT, "data"))];
329
+ let cited = 0;
330
+ for (const file of targets) {
331
+ const text = readSafe(file);
332
+ if (text === null) continue;
333
+ const rel = file.replace(SKILL_ROOT + "/", "");
334
+ text.split("\n").forEach((line, i) => {
335
+ for (const m of line.matchAll(/(?:\bfloor\s+|[(`/]\s?)(f\d{2})\b/g)) {
336
+ cited++;
337
+ if (!FLOOR_IDS.includes(m[1])) errors.push(`${rel}:${i + 1}: cites unknown floor id ${m[1]} (legal: f01-f12)`);
338
+ }
339
+ const stale = /\bfloor\s+P\d+\b/.exec(line);
340
+ if (stale) errors.push(`${rel}:${i + 1}: retired P-numbered floor citation "${stale[0]}" — rename to its f-id (05-rule-amendments.md mapping)`);
341
+ });
342
+ }
343
+ return { name: "floor parity (f-ids)", pass: errors.length === 0, detail: errors.length ? `${errors.length} parity break(s)` : `${enumerated.size} enumerated, ${cited} citation(s) checked`, errors };
344
+ }
345
+
290
346
  const NUMBER_WORDS = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, "twenty-six": 26 };
291
347
 
292
348
  // Resolve a numeric token (digits or an English number word) to a number, or NaN.
@@ -298,10 +354,15 @@ function toNumber(tok) {
298
354
  // ── Check 5: stated counts in prose match the ground truth ─────────────────────
299
355
  function checkStatedCounts(catalog, ground) {
300
356
  const errors = [];
301
- // Ground truth drift guard: derived CSV counts must match the pinned numbers.
357
+ // Ground truth drift guard: derived counts (CSV rows; the token surface counted off the Nurix
358
+ // default artifact) must match the pinned numbers.
302
359
  if (ground.rows !== PINNED.rows) errors.push(`derived row count ${ground.rows} != pinned ${PINNED.rows}`);
303
360
  if (ground.source !== PINNED.source) errors.push(`derived source count ${ground.source} != pinned ${PINNED.source}`);
304
361
  if (ground.render !== PINNED.render) errors.push(`derived machine-render count ${ground.render} != pinned ${PINNED.render}`);
362
+ if (ground.primitives !== null && ground.primitives !== PINNED.primitives) errors.push(`derived primitive count ${ground.primitives} != pinned ${PINNED.primitives}`);
363
+ if (ground.colorTokens !== null && ground.colorTokens !== PINNED.colorTokens) errors.push(`derived color-token count ${ground.colorTokens} != pinned ${PINNED.colorTokens}`);
364
+ if (ground.rampSteps !== null && ground.rampSteps !== PINNED.rampSteps) errors.push(`derived ramp-step count ${ground.rampSteps} != pinned ${PINNED.rampSteps}`);
365
+ if (ground.elevation !== null && ground.elevation !== PINNED.elevation) errors.push(`derived elevation count ${ground.elevation} != pinned ${PINNED.elevation}`);
305
366
 
306
367
  const gtMap = {
307
368
  rules: ground.rows, rule: ground.rows, rows: ground.rows, row: ground.rows,
@@ -310,6 +371,7 @@ function checkStatedCounts(catalog, ground) {
310
371
  primitives: PINNED.primitives, primitive: PINNED.primitives,
311
372
  "color-tokens": PINNED.colorTokens, "color tokens": PINNED.colorTokens, colortokens: PINNED.colorTokens,
312
373
  "semantic-tokens": PINNED.semanticTokens, "substrate-tokens": PINNED.substrateTokens,
374
+ "ramp-steps": PINNED.rampSteps, rampsteps: PINNED.rampSteps, elevation: PINNED.elevation,
313
375
  };
314
376
 
315
377
  const targets = [join(SKILL_ROOT, "SKILL.md"), ...(existsSync(join(SKILL_ROOT, "references")) ? readdirSync(join(SKILL_ROOT, "references")).filter((f) => f.endsWith(".md")).map((f) => join(SKILL_ROOT, "references", f)) : [])];
@@ -491,7 +553,7 @@ async function checkGoldenMatch(enabled) {
491
553
  const perSlug = await runPool(goldenSlugs, SPAWN_LIMIT, async (slug) => {
492
554
  const slugErrors = [];
493
555
  const outdir = join(emitRoot, slug);
494
- const res = await execNode([DERIVE_PERSONALITY, join(TOKENS_DIR, slug), "--emit", outdir]);
556
+ const res = await execNode([DERIVE_PERSONALITY, join(TOKENS_DIR, slug), "--emit", outdir, "--provisional"]);
495
557
  if (res.status !== 0) {
496
558
  slugErrors.push(`${slug}: --emit exited ${res.status}: ${firstLine(res.stderr || res.stdout)}`);
497
559
  return slugErrors;
@@ -513,13 +575,25 @@ async function checkGoldenMatch(enabled) {
513
575
  return { name: "golden emission match", pass: errors.length === 0, detail: `${matched}/${goldenSlugs.length * 2} hashes match goldens.json`, errors };
514
576
  }
515
577
 
516
- // Derive the ground-truth counts from the catalog itself.
578
+ // Derive the ground-truth counts: rule counts from the catalog itself; the token-surface counts
579
+ // (primitives, color roles, ramp steps, elevation levels) from the Nurix default artifact, the
580
+ // schema's worked example. null when the artifact is unreadable (check 8 reports that separately).
517
581
  function deriveGround(catalog) {
518
- return {
582
+ const ground = {
519
583
  rows: catalog.rows.length,
520
584
  source: catalog.rows.filter((r) => r.detector === "source").length,
521
585
  render: catalog.rows.filter((r) => r.detector === "render" && r.grade === "machine").length,
586
+ primitives: null, colorTokens: null, rampSteps: null, elevation: null,
522
587
  };
588
+ try {
589
+ const nurix = JSON.parse(readFileSync(join(TOKENS_DIR, "nurix", "design.tokens.json"), "utf8"));
590
+ const count = (group) => Object.keys(group || {}).filter((k) => !k.startsWith("$")).length;
591
+ ground.primitives = count(nurix.primitive);
592
+ ground.colorTokens = count(nurix.color);
593
+ ground.rampSteps = count(nurix.typography && nurix.typography.ramp);
594
+ ground.elevation = count(nurix.elevation);
595
+ } catch { /* artifact validity is check 8's job */ }
596
+ return ground;
523
597
  }
524
598
 
525
599
  // Run one rule's contrastive fixture pair through the check engine and exit 0/1 on its result.
@@ -563,6 +637,7 @@ async function main() {
563
637
  const runners = [
564
638
  () => checkCsvWellFormed(catalog),
565
639
  () => checkAnchorParity(catalog),
640
+ () => checkFloorParity(),
566
641
  () => checkFixturePresence(catalog),
567
642
  () => checkContrastiveValidity(catalog),
568
643
  () => checkStatedCounts(catalog, ground),
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // skills/design/scripts/snapshot-goldens.mjs — snapshot golden hashes of the CSS emitted for every design-tokens artifact (data/design-tokens/<slug>/) into data/design-tokens/goldens.json, the byte-match regression oracle for the emission pipeline.
2
+ // skills/design/scripts/snapshot-goldens.mjs — snapshot golden hashes of the CSS emitted for every design-tokens artifact (data/design-tokens/<slug>/) into data/design-tokens/goldens.json, the byte-match regression oracle for the emission pipeline, then run the Tailwind compile smoke (emission contract 5): one emitted globals.css + personality.css + fonts.css set is actually compiled by @tailwindcss/vite (the fonts.css @fontsource imports resolve against local stubs) — goldens hash bytes and never compile them, so a byte-green gate can hide unbuildable CSS (a derivation comment containing a Tailwind utility literal closes the comment early). Emission uses --provisional: hint-carrying library twins are hashed at their provisional values (--emit alone still refuses on hints).
3
3
 
4
4
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
@@ -26,20 +26,23 @@ const entries = readdirSync(tokensDir, { withFileTypes: true })
26
26
 
27
27
  const goldens = {};
28
28
  const failures = [];
29
+ const provisional = [];
29
30
 
30
31
  for (const { slug, path } of entries) {
31
32
  const outdir = join(emitRoot, slug);
32
- const run = spawnSync(process.execPath, [derivePersonalityScript, path, "--emit", outdir], { encoding: "utf8" });
33
+ const run = spawnSync(process.execPath, [derivePersonalityScript, path, "--emit", outdir, "--provisional"], { encoding: "utf8" });
33
34
  if (run.status !== 0) {
34
35
  failures.push({ slug, error: (run.stderr || run.stdout || `exit status ${run.status}`).trim() });
35
36
  console.log(`FAIL ${slug}`);
36
37
  continue;
37
38
  }
39
+ const wasProvisional = /^emit PROVISIONAL/m.test(run.stdout || "");
40
+ if (wasProvisional) provisional.push(slug);
38
41
  try {
39
42
  const personalityCss = readFileSync(join(outdir, "src", "styles", "personality.css"), "utf8");
40
43
  const globalsCss = readFileSync(join(outdir, "src", "styles", "globals.css"), "utf8");
41
44
  goldens[slug] = { personality: goldenHash(personalityCss), globals: goldenHash(globalsCss) };
42
- console.log(`OK ${slug}`);
45
+ console.log(`OK ${slug}${wasProvisional ? " (provisional)" : ""}`);
43
46
  } catch (err) {
44
47
  failures.push({ slug, error: String((err && err.message) || err) });
45
48
  console.log(`FAIL ${slug}`);
@@ -49,7 +52,7 @@ for (const { slug, path } of entries) {
49
52
  // Assemble the sorted-key document; "_meta" sorts first (ASCII "_" < "a").
50
53
  const document = {
51
54
  _meta: {
52
- description: "Post-carrier golden hashes of the CSS emitted by derive-personality.mjs --emit for every design-tokens artifact (data/design-tokens/<slug>/), dark-inclusive: an artifact shipping design.tokens.dark.json hashes its emitted [data-theme=\"dark\"] block too. Each entry records the sha256 of the comment-stripped personality.css and globals.css (see scripts/lib/golden.mjs). The byte-match regression oracle for the emission pipeline.",
55
+ description: "Post-carrier golden hashes of the CSS emitted by derive-personality.mjs --emit --provisional for every design-tokens artifact (data/design-tokens/<slug>/), dark-inclusive: an artifact shipping design.tokens.dark.json hashes its emitted :root[data-theme=\"dark\"] block too. Hint-carrying twins are hashed at their provisional $values (--emit without --provisional still refuses on hints). Each entry records the sha256 of the comment-stripped personality.css and globals.css (see scripts/lib/golden.mjs). The byte-match regression oracle for the emission pipeline; the Tailwind compile smoke (emission contract 5) runs after every refresh.",
53
56
  generator: "scripts/snapshot-goldens.mjs",
54
57
  strip: "css-comments",
55
58
  },
@@ -59,10 +62,103 @@ for (const slug of Object.keys(goldens).sort()) document[slug] = goldens[slug];
59
62
  mkdirSync(dirname(goldensJsonPath), { recursive: true });
60
63
  writeFileSync(goldensJsonPath, JSON.stringify(document, null, 2) + "\n");
61
64
 
62
- console.log(`\n${entries.length} entries: ${Object.keys(goldens).length} OK, ${failures.length} FAIL`);
65
+ console.log(`\n${entries.length} entries: ${Object.keys(goldens).length} OK (${provisional.length} provisional), ${failures.length} FAIL`);
63
66
  console.log(`goldens: ${goldensJsonPath}`);
64
67
  console.log(`emitted outputs: ${emitRoot}`);
65
68
  if (failures.length > 0) {
66
69
  for (const f of failures) console.error(`\nFAIL ${f.slug}:\n${f.error}`);
67
70
  process.exit(1);
68
71
  }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Tailwind compile smoke (emission contract 5) — compile one emitted pair for real
75
+ // ---------------------------------------------------------------------------
76
+
77
+ // True when a spawn result reads as "npm cannot reach the registry" (the only degradable failure).
78
+ function isNetworkFailure(res) {
79
+ const text = `${res.stderr || ""}${res.stdout || ""}`;
80
+ return res.error !== undefined || /ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNREFUSED|ENETUNREACH|network|offline/i.test(text);
81
+ }
82
+
83
+ // Compile the emitted pair of one slug with @tailwindcss/vite in a scratch project; exits non-zero
84
+ // on a compile failure (the smoke is a gate). Degrades with a notice only when npm is unreachable.
85
+ function runCompileSmoke(slug) {
86
+ const src = join(emitRoot, slug, "src", "styles");
87
+ if (!existsSync(join(src, "globals.css"))) {
88
+ console.error(`\ncompile smoke: no emitted output for ${slug} — cannot smoke`);
89
+ process.exit(1);
90
+ }
91
+ const smokeDir = process.env.GOLDENS_SMOKE_DIR || join(tmpdir(), "design-goldens-smoke");
92
+ mkdirSync(join(smokeDir, "src", "styles"), { recursive: true });
93
+
94
+ writeFileSync(join(smokeDir, "package.json"), JSON.stringify({
95
+ name: "design-goldens-smoke",
96
+ private: true,
97
+ type: "module",
98
+ devDependencies: { vite: "^7.0.0", tailwindcss: "^4.0.0", "@tailwindcss/vite": "^4.0.0" },
99
+ }, null, 2) + "\n");
100
+ writeFileSync(join(smokeDir, "vite.config.mjs"), `// design-goldens-smoke vite.config.mjs — @tailwindcss/vite over the emitted pair
101
+ import { defineConfig } from "vite";
102
+ import tailwindcss from "@tailwindcss/vite";
103
+ export default defineConfig({ plugins: [tailwindcss()], logLevel: "error" });
104
+ `);
105
+ // Utility classes in the content so the @theme-driven utilities (ramp, shadows, ladder roles,
106
+ // motion classes) actually generate — a bad @theme value fails here, not just at import.
107
+ writeFileSync(join(smokeDir, "index.html"), `<!doctype html>
108
+ <html lang="en" data-style="${slug}" data-theme="light" data-density="standard">
109
+ <head><meta charset="utf-8"><title>design-goldens-smoke</title></head>
110
+ <body>
111
+ <div class="text-h1 tracking-tight p-4 gap-2 rounded-lg shadow-sm bg-surface-raised motion-lift">smoke</div>
112
+ <div class="text-display shadow-overlay bg-surface-overlay motion-stagger"><span>a</span><span>b</span></div>
113
+ <script type="module" src="/src/main.js"></script>
114
+ </body>
115
+ </html>
116
+ `);
117
+ writeFileSync(join(smokeDir, "src", "main.js"), `// design-goldens-smoke entry — pulls the emitted pair through the Tailwind build
118
+ import "./styles/globals.css";
119
+ `);
120
+ for (const f of ["globals.css", "personality.css", "fonts.css"]) {
121
+ writeFileSync(join(smokeDir, "src", "styles", f), readFileSync(join(src, f), "utf8"));
122
+ }
123
+
124
+ if (!existsSync(join(smokeDir, "node_modules", "vite"))) {
125
+ const install = spawnSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: smokeDir, encoding: "utf8" });
126
+ if (install.status !== 0) {
127
+ if (isNetworkFailure(install)) {
128
+ console.log(`\ncompile smoke SKIPPED — npm unreachable (${(install.stderr || "").split("\n")[0] || "no network"}); the goldens are hashed but this refresh is NOT compile-verified. Re-run snapshot-goldens.mjs online.`);
129
+ return;
130
+ }
131
+ console.error(`\ncompile smoke: npm install failed in ${smokeDir}:\n${(install.stderr || install.stdout || "").slice(0, 2000)}`);
132
+ process.exit(1);
133
+ }
134
+ }
135
+ // The library import resolves against a local stub: the smoke gates OUR emitted bytes compiling,
136
+ // not restricted-registry availability. Written after install so npm pruning cannot drop it.
137
+ const stubDir = join(smokeDir, "node_modules", "@nurix", "components");
138
+ mkdirSync(stubDir, { recursive: true });
139
+ writeFileSync(join(stubDir, "package.json"), JSON.stringify({ name: "@nurix/components", version: "0.0.0-smoke-stub", exports: { "./styles.css": "./styles.css" } }, null, 2) + "\n");
140
+ writeFileSync(join(stubDir, "styles.css"), `/* @nurix/components/styles.css — compile-smoke stub: import resolution + cascade shape only */
141
+ :root { --background: oklch(1 0 0); --radius-sm: calc(var(--radius) - 4px); }
142
+ `);
143
+ // The emitted fonts.css imports resolve against local @fontsource stubs for the same reason:
144
+ // the smoke gates OUR bytes compiling, never per-package registry availability.
145
+ const fontsCss = readFileSync(join(smokeDir, "src", "styles", "fonts.css"), "utf8");
146
+ for (const m of fontsCss.matchAll(/@import\s+['"]((@fontsource(?:-variable)?\/[^'"/]+)(?:\/([^'"]+))?)['"]/g)) {
147
+ const [, spec, pkgName, relFile] = m;
148
+ const rel = relFile || "index.css";
149
+ const pkgDir = join(smokeDir, "node_modules", ...pkgName.split("/"));
150
+ mkdirSync(dirname(join(pkgDir, rel)), { recursive: true });
151
+ writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: pkgName, version: "0.0.0-smoke-stub" }, null, 2) + "\n");
152
+ writeFileSync(join(pkgDir, rel), `/* ${spec} — compile-smoke stub: import resolution only */\n`);
153
+ }
154
+
155
+ const build = spawnSync("npm", ["exec", "--no", "--", "vite", "build"], { cwd: smokeDir, encoding: "utf8" });
156
+ if (build.status !== 0) {
157
+ console.error(`\ncompile smoke FAILED — the emitted ${slug} pair does not compile under @tailwindcss/vite:`);
158
+ console.error((build.stderr || build.stdout || "").slice(0, 4000));
159
+ process.exit(1);
160
+ }
161
+ console.log(`compile smoke PASSED — emitted ${slug} pair compiles under @tailwindcss/vite (${smokeDir})`);
162
+ }
163
+
164
+ runCompileSmoke(goldens.nurix ? "nurix" : Object.keys(goldens).sort()[0]);