jig-ui 0.16.1 → 0.17.0

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 (153) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +7 -6
  3. package/dist/index.js +630 -160
  4. package/examples/A-01.html +7 -0
  5. package/examples/A-02.html +7 -0
  6. package/examples/A-03.html +7 -0
  7. package/examples/A-04.html +7 -0
  8. package/examples/A-05.html +7 -0
  9. package/examples/A-06.html +7 -0
  10. package/examples/A-07.html +7 -0
  11. package/examples/A-08.html +7 -0
  12. package/examples/A-09.html +7 -0
  13. package/examples/A-10.html +7 -0
  14. package/examples/A-135.html +7 -0
  15. package/examples/A-136.html +7 -0
  16. package/examples/A-137.html +7 -0
  17. package/examples/A-138.html +7 -0
  18. package/examples/A-139.html +7 -0
  19. package/examples/A-140.html +7 -0
  20. package/examples/A-141.html +7 -0
  21. package/examples/A-142.html +7 -0
  22. package/examples/A-143.html +7 -0
  23. package/examples/A-144.html +7 -0
  24. package/examples/A-146.html +7 -0
  25. package/examples/A-58.html +7 -0
  26. package/examples/A-59.html +7 -0
  27. package/examples/A-60.html +7 -0
  28. package/examples/A-67.html +7 -0
  29. package/examples/B-105.html +7 -0
  30. package/examples/B-106.html +7 -0
  31. package/examples/B-11.html +7 -0
  32. package/examples/B-12.html +7 -0
  33. package/examples/B-13.html +7 -0
  34. package/examples/B-14.html +7 -0
  35. package/examples/B-15.html +7 -0
  36. package/examples/B-16.html +7 -0
  37. package/examples/B-17.html +7 -0
  38. package/examples/B-75.html +7 -0
  39. package/examples/B-76.html +7 -0
  40. package/examples/B-77.html +7 -0
  41. package/examples/B-78.html +7 -0
  42. package/examples/C-18.html +7 -0
  43. package/examples/C-19.html +7 -0
  44. package/examples/C-20.html +7 -0
  45. package/examples/C-21.html +7 -0
  46. package/examples/C-22.html +11 -0
  47. package/examples/C-49.html +7 -0
  48. package/examples/C-50.html +7 -0
  49. package/examples/C-66.html +7 -0
  50. package/examples/C-68.html +7 -0
  51. package/examples/D-111.html +7 -0
  52. package/examples/D-112.html +7 -0
  53. package/examples/D-114.html +7 -0
  54. package/examples/D-115.html +7 -0
  55. package/examples/D-23.html +11 -0
  56. package/examples/D-24.html +7 -0
  57. package/examples/D-25.html +7 -0
  58. package/examples/D-26.html +7 -0
  59. package/examples/D-27.html +7 -0
  60. package/examples/D-69.html +7 -0
  61. package/examples/D-70.html +7 -0
  62. package/examples/D-71.html +7 -0
  63. package/examples/D-72.html +7 -0
  64. package/examples/D-96.html +7 -0
  65. package/examples/E-116.html +7 -0
  66. package/examples/E-28.html +7 -0
  67. package/examples/E-29.html +7 -0
  68. package/examples/E-30.html +7 -0
  69. package/examples/E-31.html +7 -0
  70. package/examples/E-32.html +7 -0
  71. package/examples/E-33.html +11 -0
  72. package/examples/E-34.html +7 -0
  73. package/examples/E-35.html +7 -0
  74. package/examples/E-51.html +7 -0
  75. package/examples/E-52.html +7 -0
  76. package/examples/E-61.html +7 -0
  77. package/examples/E-62.html +7 -0
  78. package/examples/E-63.html +7 -0
  79. package/examples/E-64.html +7 -0
  80. package/examples/E-65.html +7 -0
  81. package/examples/E-73.html +7 -0
  82. package/examples/E-74.html +7 -0
  83. package/examples/E-91.html +7 -0
  84. package/examples/E-92.html +7 -0
  85. package/examples/E-93.html +7 -0
  86. package/examples/E-94.html +7 -0
  87. package/examples/E-95.html +7 -0
  88. package/examples/F-100.html +7 -0
  89. package/examples/F-101.html +7 -0
  90. package/examples/F-102.html +7 -0
  91. package/examples/F-103.html +7 -0
  92. package/examples/F-104.html +7 -0
  93. package/examples/F-113.html +7 -0
  94. package/examples/F-147.html +7 -0
  95. package/examples/F-36.html +7 -0
  96. package/examples/F-37.html +7 -0
  97. package/examples/F-38.html +7 -0
  98. package/examples/F-39.html +7 -0
  99. package/examples/F-40.html +7 -0
  100. package/examples/F-41.html +11 -0
  101. package/examples/F-97.html +7 -0
  102. package/examples/F-98.html +7 -0
  103. package/examples/F-99.html +7 -0
  104. package/examples/G-145.html +7 -0
  105. package/examples/G-42.html +7 -0
  106. package/examples/G-43.html +10 -0
  107. package/examples/G-44.html +7 -0
  108. package/examples/H-117.html +11 -0
  109. package/examples/H-119.html +13 -0
  110. package/examples/H-45.html +13 -0
  111. package/examples/H-46.html +10 -0
  112. package/examples/H-47.html +15 -0
  113. package/examples/H-48.html +8 -0
  114. package/examples/I-118.html +7 -0
  115. package/examples/I-53.html +7 -0
  116. package/examples/I-54.html +7 -0
  117. package/examples/I-55.html +7 -0
  118. package/examples/I-56.html +7 -0
  119. package/examples/I-57.html +7 -0
  120. package/examples/I-79.html +7 -0
  121. package/examples/I-80.html +7 -0
  122. package/examples/I-81.html +7 -0
  123. package/examples/I-82.html +7 -0
  124. package/examples/I-83.html +7 -0
  125. package/examples/I-84.html +7 -0
  126. package/examples/I-85.html +7 -0
  127. package/examples/I-86.html +7 -0
  128. package/examples/I-87.html +7 -0
  129. package/examples/I-88.html +7 -0
  130. package/examples/I-89.html +7 -0
  131. package/examples/I-90.html +7 -0
  132. package/examples/J-120.html +7 -0
  133. package/examples/J-121.html +7 -0
  134. package/examples/J-122.html +7 -0
  135. package/examples/J-123.html +7 -0
  136. package/examples/J-124.html +8 -0
  137. package/examples/J-125.html +10 -0
  138. package/examples/J-126.html +9 -0
  139. package/examples/J-127.html +10 -0
  140. package/examples/K-128.html +9 -0
  141. package/examples/K-129.html +7 -0
  142. package/examples/K-130.html +7 -0
  143. package/examples/K-131.html +11 -0
  144. package/examples/K-132.html +7 -0
  145. package/examples/K-133.html +8 -0
  146. package/examples/K-134.html +9 -0
  147. package/examples/README.md +44 -0
  148. package/package.json +3 -2
  149. package/rules/00-anti-patterns.md +67 -0
  150. package/rules/01-modes.md +1 -1
  151. package/rules/05-copy.md +2 -0
  152. package/rules.index.json +101 -0
  153. package/templates/COMMAND.md.tmpl +10 -3
package/dist/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import { readFileSync as readFileSync31 } from "fs";
5
+ import { readFileSync as readFileSync32 } from "fs";
6
6
  import { homedir as homedir2 } from "os";
7
- import { join as join38 } from "path";
7
+ import { join as join39 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -844,15 +844,15 @@ function detectLegacyCursorRules(projectRoot) {
844
844
  }
845
845
  return { present: files.length > 0, files };
846
846
  }
847
- function describeLegacyFiles(report, headerLine, subheaderLine) {
848
- if (!report.present) return [];
847
+ function describeLegacyFiles(report2, headerLine, subheaderLine) {
848
+ if (!report2.present) return [];
849
849
  const lines = [];
850
850
  lines.push(`
851
851
  ${headerLine}`);
852
852
  lines.push(subheaderLine);
853
- for (const f of report.files) lines.push(` ${f.relPath}`);
854
- const edited = report.files.filter((f) => f.modified === true).map((f) => f.relPath);
855
- const unknown = report.files.filter((f) => f.modified === "unknown").map((f) => f.relPath);
853
+ for (const f of report2.files) lines.push(` ${f.relPath}`);
854
+ const edited = report2.files.filter((f) => f.modified === true).map((f) => f.relPath);
855
+ const unknown = report2.files.filter((f) => f.modified === "unknown").map((f) => f.relPath);
856
856
  if (edited.length) {
857
857
  lines.push(" Edited since install \u2014 kept, never removed automatically:");
858
858
  for (const relPath of edited) lines.push(` ${relPath}`);
@@ -863,22 +863,22 @@ ${headerLine}`);
863
863
  }
864
864
  return lines;
865
865
  }
866
- function describeLegacyReport(report) {
866
+ function describeLegacyReport(report2) {
867
867
  return describeLegacyFiles(
868
- report,
869
- `Found a pre-0.4.0 Jig install in .jig/ (${report.files.length} file(s)). These are no longer used \u2014`,
868
+ report2,
869
+ `Found a pre-0.4.0 Jig install in .jig/ (${report2.files.length} file(s)). These are no longer used \u2014`,
870
870
  " since 0.4.0 the skill and its rules live in your agent's skill directory, not the project:"
871
871
  );
872
872
  }
873
- function describeLegacyCursorReport(report) {
873
+ function describeLegacyCursorReport(report2) {
874
874
  return describeLegacyFiles(
875
- report,
876
- `Found a legacy Cursor install (${report.files.length} file(s)). Cursor's skill has moved \u2014`,
875
+ report2,
876
+ `Found a legacy Cursor install (${report2.files.length} file(s)). Cursor's skill has moved \u2014`,
877
877
  " from .cursor/rules/jig.mdc to .cursor/skills/jig/SKILL.md; these are no longer read:"
878
878
  );
879
879
  }
880
- function removableLegacyFiles(report) {
881
- return report.files.filter((f) => f.modified === false).map((f) => f.relPath);
880
+ function removableLegacyFiles(report2) {
881
+ return report2.files.filter((f) => f.modified === false).map((f) => f.relPath);
882
882
  }
883
883
  function removeLegacyFiles(projectRoot, relPaths) {
884
884
  for (const relPath of relPaths) {
@@ -1031,6 +1031,7 @@ function updateInitFiles(opts) {
1031
1031
  }
1032
1032
 
1033
1033
  // src/commands/explain.ts
1034
+ import { existsSync as existsSync9 } from "fs";
1034
1035
  import { join as join15 } from "path";
1035
1036
 
1036
1037
  // src/rules/load.ts
@@ -1410,7 +1411,12 @@ function explain(opts) {
1410
1411
  if (shape) {
1411
1412
  const id = `${shape[1]}-${shape[2]}`;
1412
1413
  const rule = rules.find((r) => r.id === id);
1413
- if (rule) return renderRule(rule);
1414
+ if (rule) {
1415
+ const example = join15(root, "examples", `${id}.html`);
1416
+ return existsSync9(example) ? `${renderRule(rule)}
1417
+
1418
+ Example: ${example} (a dont and a do, each a self-contained HTML fragment)` : renderRule(rule);
1419
+ }
1414
1420
  const spec = specs.find((s) => s.id === id);
1415
1421
  if (spec) return renderSpec(spec);
1416
1422
  const siblings = entries.filter((e) => e.id.startsWith(`${shape[1]}-`)).map((e) => e.id).sort(byNumber);
@@ -1463,7 +1469,7 @@ function sectionsOf(entries) {
1463
1469
  }
1464
1470
 
1465
1471
  // src/commands/check.ts
1466
- import { existsSync as existsSync14, readFileSync as readFileSync19 } from "fs";
1472
+ import { existsSync as existsSync15, readFileSync as readFileSync19 } from "fs";
1467
1473
  import { join as join24 } from "path";
1468
1474
 
1469
1475
  // src/check/files.ts
@@ -2764,9 +2770,9 @@ function palettePairs(classes) {
2764
2770
  let background;
2765
2771
  let foreground;
2766
2772
  for (const token of classes.split(/\s+/)) {
2767
- const bare = token.replace(/^(?:[\w-]+:)*/, "");
2768
- const bg = /^bg-(.+)$/.exec(bare);
2769
- const fg = /^text-(.+)$/.exec(bare);
2773
+ const bare2 = token.replace(/^(?:[\w-]+:)*/, "");
2774
+ const bg = /^bg-(.+)$/.exec(bare2);
2775
+ const fg = /^text-(.+)$/.exec(bare2);
2770
2776
  if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
2771
2777
  if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
2772
2778
  }
@@ -2787,11 +2793,11 @@ function resolveFloor(body) {
2787
2793
  const raw = m[1].trim();
2788
2794
  const pxMatch = FONT_SIZE_PX_RE.exec(raw);
2789
2795
  const remMatch = FONT_SIZE_REM_RE.exec(raw);
2790
- let px;
2791
- if (pxMatch) px = parseFloat(pxMatch[1]);
2792
- else if (remMatch) px = parseFloat(remMatch[1]) * REM_TO_PX;
2796
+ let px2;
2797
+ if (pxMatch) px2 = parseFloat(pxMatch[1]);
2798
+ else if (remMatch) px2 = parseFloat(remMatch[1]) * REM_TO_PX;
2793
2799
  else return LARGE_FLOOR;
2794
- const large = BOLD_RE.test(body) ? px >= 18.66 : px >= 24;
2800
+ const large = BOLD_RE.test(body) ? px2 >= 18.66 : px2 >= 24;
2795
2801
  return large ? LARGE_FLOOR : FLOOR;
2796
2802
  }
2797
2803
  var DECL_RE = /(?<![-\w])(color|background(?:-color)?)\s*:\s*([^;]+)(?:;|$)/gi;
@@ -3142,8 +3148,8 @@ var inputZoom = {
3142
3148
  SIZE_RE.lastIndex = 0;
3143
3149
  let m;
3144
3150
  while (m = SIZE_RE.exec(block.body)) {
3145
- const px = pixels(m[1], ctx.mode);
3146
- if (px === void 0 || px >= ROOT_PX) continue;
3151
+ const px2 = pixels(m[1], ctx.mode);
3152
+ if (px2 === void 0 || px2 >= ROOT_PX) continue;
3147
3153
  const line = lineOfOffset(block, m.index);
3148
3154
  findings.push(
3149
3155
  mkFinding(
@@ -3151,7 +3157,7 @@ var inputZoom = {
3151
3157
  "input-zoom",
3152
3158
  file,
3153
3159
  line,
3154
- `form control text resolves to ${px}px; iOS zooms the page on focus below 16px`,
3160
+ `form control text resolves to ${px2}px; iOS zooms the page on focus below 16px`,
3155
3161
  sourceLine(source, line)
3156
3162
  )
3157
3163
  );
@@ -3255,7 +3261,7 @@ var menuState = {
3255
3261
  };
3256
3262
 
3257
3263
  // src/check/declared-properties.ts
3258
- import { existsSync as existsSync9, readFileSync as readFileSync12 } from "fs";
3264
+ import { existsSync as existsSync10, readFileSync as readFileSync12 } from "fs";
3259
3265
  import { join as join17 } from "path";
3260
3266
  var DECLARATION_RE = /(?<![\w-])['"]?(--[A-Za-z_][\w-]*)['"]?\s*:/g;
3261
3267
  var AT_PROPERTY_RE = /@property\s+(--[\w-]+)/g;
@@ -3271,7 +3277,7 @@ function readPackageImport(projectRoot, spec) {
3271
3277
  const base = join17(projectRoot, "node_modules", spec);
3272
3278
  for (const candidate of [base, `${base}.css`]) {
3273
3279
  try {
3274
- if (existsSync9(candidate) && candidate.endsWith(".css")) return readFileSync12(candidate, "utf8");
3280
+ if (existsSync10(candidate) && candidate.endsWith(".css")) return readFileSync12(candidate, "utf8");
3275
3281
  } catch {
3276
3282
  }
3277
3283
  }
@@ -3635,6 +3641,335 @@ var interfaceSafety = {
3635
3641
  }
3636
3642
  };
3637
3643
 
3644
+ // src/check/detectors/generated-look.ts
3645
+ function report(ctx, name, file, raw, hits) {
3646
+ const seen = /* @__PURE__ */ new Set();
3647
+ return hits.filter((h) => seen.has(h.line) ? false : (seen.add(h.line), true)).map((h) => mkFinding(ctx, name, file, h.line, h.message, sourceLine(raw, h.line))).sort((a, b) => a.line - b.line);
3648
+ }
3649
+ function classLists(file, raw) {
3650
+ if (!isMarkupHost(file)) return [];
3651
+ return classAttributeValues(raw).map((a) => ({ classes: a.classes.split(/\s+/).filter(Boolean), line: a.line }));
3652
+ }
3653
+ var bare = (c) => c.replace(/^(?:[\w-]+:)+/, "").replace(/^!/, "");
3654
+ var px = (v) => {
3655
+ const m = /^(-?\d*\.?\d+)(px|rem|em)?$/.exec(v.trim());
3656
+ if (!m) return void 0;
3657
+ const n = parseFloat(m[1]);
3658
+ return m[2] === "rem" || m[2] === "em" ? n * 16 : n;
3659
+ };
3660
+ function twBorderWidth(suffix) {
3661
+ if (!suffix) return 1;
3662
+ const arb = /^\[(.+)\]$/.exec(suffix);
3663
+ if (arb) return px(arb[1]) ?? 0;
3664
+ return Number(suffix) || 0;
3665
+ }
3666
+ var TW_COLOUR = /^(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}/;
3667
+ function saturated(value, tokens, minS = 25, minAlpha = 0.2) {
3668
+ const c = extractColorComponents(value.trim(), tokens);
3669
+ return !!c && c.alpha >= minAlpha && c.hsl.s >= minS && c.hsl.l > 8 && c.hsl.l < 92;
3670
+ }
3671
+ function colourIn(value) {
3672
+ return value.replace(/\b\d*\.?\d+(px|rem|em)\b/g, " ").replace(/\b(solid|dashed|dotted|double|groove|ridge|inset|outset|none|hidden)\b/gi, " ").trim();
3673
+ }
3674
+ var SIDE_DECL = /(?<![-\w])border-(left|inline-start|right|inline-end)(?:-width)?\s*:\s*([^;}]+)/gi;
3675
+ var OTHER_SIDE_WIDE = /(?<![-\w])border(?:-(?:top|bottom))?(?:-width)?\s*:\s*(\d*\.?\d+)px/gi;
3676
+ var sideTabBorder = {
3677
+ name: "side-tab-border",
3678
+ appliesTo: (file) => isStyleBearing(file),
3679
+ run(source, file, ctx) {
3680
+ const hits = [];
3681
+ for (const block of leafBlocks(source)) {
3682
+ if (/\bblockquote\b|\bq\b/.test(block.selector)) continue;
3683
+ SIDE_DECL.lastIndex = 0;
3684
+ let m;
3685
+ while (m = SIDE_DECL.exec(block.body)) {
3686
+ const width = Math.max(0, ...[...m[2].matchAll(/(\d*\.?\d+)(px|rem)/g)].map((w) => px(w[0]) ?? 0));
3687
+ if (width < 3) continue;
3688
+ const colour = colourIn(m[2]);
3689
+ const colourDecl = new RegExp(`border-${m[1]}-color\\s*:\\s*([^;}]+)`, "i").exec(block.body)?.[1] ?? colour;
3690
+ if (colourDecl && !saturated(colourDecl, ctx.tokens) && !/var\(--color-(?:brand|text-brand|stroke-brand|accent)/.test(colourDecl)) continue;
3691
+ OTHER_SIDE_WIDE.lastIndex = 0;
3692
+ if ([...block.body.matchAll(OTHER_SIDE_WIDE)].some((o) => parseFloat(o[1]) >= width)) continue;
3693
+ hits.push({ line: lineOfOffset(block, m.index), message: `a ${width}px coloured stripe on one side of ${block.selector.trim()}: the shape of an alert on something that is not one` });
3694
+ }
3695
+ }
3696
+ for (const { classes, line } of classLists(file, ctx.raw)) {
3697
+ const plain = classes.map(bare);
3698
+ const side = plain.map((c) => /^border-(l|s|r|e)(?:-(\d+|\[[^\]]+\]))?$/.exec(c)).find((x) => x && twBorderWidth(x[2]) >= 3);
3699
+ if (!side) continue;
3700
+ const colour = plain.find((c) => new RegExp(`^border-(?:${side[1]}-)?(.+)$`).test(c) && TW_COLOUR.test(c.replace(/^border-(?:[lsre]-)?/, "")));
3701
+ if (!colour) continue;
3702
+ if (plain.some((c) => /^border-(?:\d+|\[[^\]]+\])$/.test(c))) continue;
3703
+ hits.push({ line, message: `\`${side[0]}\` with \`${colour}\`: a coloured stripe on one side, the shape of an alert on something that is not one` });
3704
+ }
3705
+ return report(ctx, "side-tab-border", file, ctx.raw, hits);
3706
+ }
3707
+ };
3708
+ var BORDER_ALL = /(?<![-\w])border(?:-width)?\s*:\s*([^;}]+)/i;
3709
+ var RADIUS = /(?<![-\w])border-radius\s*:\s*([^;}]+)/i;
3710
+ var roundedAccentBorder = {
3711
+ name: "rounded-accent-border",
3712
+ appliesTo: (file) => isStyleBearing(file),
3713
+ run(source, file, ctx) {
3714
+ const hits = [];
3715
+ for (const block of leafBlocks(source)) {
3716
+ const border = BORDER_ALL.exec(block.body);
3717
+ const radius = RADIUS.exec(block.body);
3718
+ if (!border || !radius) continue;
3719
+ const width = Math.max(0, ...[...border[1].matchAll(/(\d*\.?\d+)px/g)].map((w) => parseFloat(w[1])));
3720
+ const r = /%|9999|999px/.test(radius[1]) ? 999 : Math.max(0, ...[...radius[1].matchAll(/(\d*\.?\d+)(px|rem)/g)].map((w) => px(w[0]) ?? 0));
3721
+ if (width < 2 || r < 12) continue;
3722
+ const colour = /border-color\s*:\s*([^;}]+)/i.exec(block.body)?.[1] ?? colourIn(border[1]);
3723
+ if (!saturated(colour, ctx.tokens)) continue;
3724
+ hits.push({ line: lineOfOffset(block, border.index), message: `a ${width}px coloured border on a ${r >= 999 ? "fully" : `${r}px`} rounded element: the outline becomes the most visible shape in it` });
3725
+ }
3726
+ for (const { classes, line } of classLists(file, ctx.raw)) {
3727
+ const plain = classes.map(bare);
3728
+ const width = plain.map((c) => /^border-(\d+|\[[^\]]+\])$/.exec(c)).find((x) => x && twBorderWidth(x[1]) >= 2);
3729
+ const colour = plain.find((c) => /^border-/.test(c) && TW_COLOUR.test(c.slice("border-".length)));
3730
+ const round3 = plain.find((c) => /^rounded-(?:xl|2xl|3xl|full|\[(?:1[2-9]|[2-9]\d|\d{3,})px\])$/.test(c));
3731
+ if (width && colour && round3) hits.push({ line, message: `\`${width[0]} ${colour} ${round3}\`: a heavy coloured outline on a large curve` });
3732
+ }
3733
+ return report(ctx, "rounded-accent-border", file, ctx.raw, hits);
3734
+ }
3735
+ };
3736
+ var BG_IMAGE = /(?<![-\w])background(?:-image)?\s*:\s*([^;}]+)/i;
3737
+ function gridLike(value, block, sizeHint) {
3738
+ if (/repeating-(?:linear|radial)-gradient\(/i.test(value)) return true;
3739
+ const linear = (value.match(/linear-gradient\(/gi) ?? []).length;
3740
+ const radial = (value.match(/radial-gradient\(/gi) ?? []).length;
3741
+ const lines = /\b1px\b|transparent\s+1px|\s1px\s*,\s*transparent/i.test(value);
3742
+ const sized = sizeHint || (block ? /background-size\s*:/i.test(block.body) : false);
3743
+ return sized && (linear >= 2 && lines || radial >= 1 && /\b1px\b|\b2px\b/.test(value));
3744
+ }
3745
+ var decorativeGridBackground = {
3746
+ name: "decorative-grid-background",
3747
+ appliesTo: (file) => isStyleBearing(file),
3748
+ run(source, file, ctx) {
3749
+ const hits = [];
3750
+ for (const block of leafBlocks(source)) {
3751
+ const bg = BG_IMAGE.exec(block.body);
3752
+ if (bg && gridLike(bg[1], block, false)) {
3753
+ hits.push({ line: lineOfOffset(block, bg.index), message: "a background of grid lines, dots or stripes behind content: noise at the contrast that makes text harder to read" });
3754
+ }
3755
+ }
3756
+ for (const { classes, line } of classLists(file, ctx.raw)) {
3757
+ const arb = classes.map(bare).filter((c) => /^bg-\[/.test(c)).map((c) => c.replace(/_/g, " "));
3758
+ const image = arb.find((c) => /gradient\(/.test(c));
3759
+ if (image && gridLike(image, void 0, arb.some((c) => /^bg-\[size:/.test(c)) || /repeating-/.test(image))) {
3760
+ hits.push({ line, message: "a grid, dot or stripe background drawn with utility classes: decoration behind content" });
3761
+ }
3762
+ }
3763
+ return report(ctx, "decorative-grid-background", file, ctx.raw, hits);
3764
+ }
3765
+ };
3766
+ var SHADOW = /(?<![-\w])(box-shadow|text-shadow)\s*:\s*([^;}]+)/gi;
3767
+ var DROP = /drop-shadow\(\s*0(?:px)?\s+0(?:px)?\s+(\d*\.?\d+)px\s+([^)]+\)?)\s*\)/gi;
3768
+ var COLOUR_IN = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-f]{3,8}\b|var\([^)]*\)|\b[a-z]{3,}\b/i;
3769
+ function glowLayers(value, tokens) {
3770
+ return value.split(/,(?![^(]*\))/).some((layer) => {
3771
+ if (/\binset\b/.test(layer)) return false;
3772
+ const colour = COLOUR_IN.exec(layer.replace(/\binset\b/, ""))?.[0];
3773
+ if (!colour) return false;
3774
+ const nums = [...layer.replace(colour, " ").matchAll(/-?\d*\.?\d+/g)].map((n) => parseFloat(n[0]));
3775
+ if (nums.length < 3) return false;
3776
+ const [x, y, blur] = nums;
3777
+ if (x !== 0 || y !== 0 || blur < 8) return false;
3778
+ return saturated(colour, tokens, 40, 0.15);
3779
+ });
3780
+ }
3781
+ var glowAccent = {
3782
+ name: "glow-accent",
3783
+ appliesTo: (file) => isStyleBearing(file),
3784
+ run(source, file, ctx) {
3785
+ const hits = [];
3786
+ for (const block of leafBlocks(source)) {
3787
+ SHADOW.lastIndex = 0;
3788
+ let m;
3789
+ while (m = SHADOW.exec(block.body)) {
3790
+ if (glowLayers(m[2], ctx.tokens)) hits.push({ line: lineOfOffset(block, m.index), message: `a coloured ${m[1]} glow: contrast added without meaning` });
3791
+ }
3792
+ DROP.lastIndex = 0;
3793
+ while (m = DROP.exec(block.body)) {
3794
+ if (parseFloat(m[1]) >= 8 && saturated(m[2], ctx.tokens, 40)) hits.push({ line: lineOfOffset(block, m.index), message: "a coloured drop-shadow glow: contrast added without meaning" });
3795
+ }
3796
+ }
3797
+ for (const { classes, line } of classLists(file, ctx.raw)) {
3798
+ const plain = classes.map(bare);
3799
+ const arb = plain.find((c) => /^(?:shadow|drop-shadow)-\[/.test(c) && glowLayers(c.replace(/^[\w-]+-\[|\]$/g, "").replace(/_/g, " "), ctx.tokens));
3800
+ const tinted = plain.find((c) => /^shadow-/.test(c) && TW_COLOUR.test(c.slice("shadow-".length)));
3801
+ if (arb) hits.push({ line, message: `\`${arb}\`: a coloured glow` });
3802
+ else if (tinted && plain.some((c) => /^shadow-(?:lg|xl|2xl)$/.test(c))) hits.push({ line, message: `\`${tinted}\`: a large shadow in a saturated colour, which reads as a glow` });
3803
+ }
3804
+ return report(ctx, "glow-accent", file, ctx.raw, hits);
3805
+ }
3806
+ };
3807
+ var PAGE_SELECTOR = /(?:^|[\s,>])(?:html|body|:root|main)\b|--[\w-]*(?:bg|background|surface|page|canvas)[\w-]*/i;
3808
+ var TW_CREAM = /^bg-(?:amber|orange|yellow)-(?:50|100)$|^bg-stone-(?:100|200)$/;
3809
+ function cream(value, tokens) {
3810
+ const c = extractColorComponents(value.trim(), tokens);
3811
+ if (!c || c.alpha < 0.9) return false;
3812
+ const { r, g, b } = c.rgb;
3813
+ return Math.min(r, g, b) >= 215 && r >= g && g >= b && r - b >= 6;
3814
+ }
3815
+ var creamPalette = {
3816
+ name: "cream-palette",
3817
+ appliesTo: (file) => isStyleBearing(file),
3818
+ run(source, file, ctx) {
3819
+ const hits = [];
3820
+ for (const block of leafBlocks(source)) {
3821
+ for (const m of block.body.matchAll(/(?:^|[;{\s])((?:--[\w-]+|background(?:-color)?))\s*:\s*([^;}]+)/g)) {
3822
+ const prop = m[1];
3823
+ const onPage = prop.startsWith("--") ? PAGE_SELECTOR.test(prop) : PAGE_SELECTOR.test(block.selector);
3824
+ if (!onPage || !cream(m[2], ctx.tokens)) continue;
3825
+ hits.push({ line: lineOfOffset(block, m.index), message: `a cream page surface (${m[2].trim()}): the palette a model reaches for when asked to look tasteful` });
3826
+ }
3827
+ }
3828
+ if (isMarkupHost(file)) {
3829
+ for (const m of ctx.raw.matchAll(/<(html|body|main)\b[^>]*\b(?:class|className)\s*=\s*["']([^"']*)["']/gi)) {
3830
+ const hit = m[2].split(/\s+/).map(bare).find((c) => TW_CREAM.test(c) || /^bg-\[#?[\w(),.%\s]+\]$/.test(c) && cream(c.slice(4, -1), ctx.tokens));
3831
+ if (hit) hits.push({ line: lineForOffset(buildLineIndex(ctx.raw), m.index), message: `\`${hit}\` on <${m[1]}>: a cream page surface by default` });
3832
+ }
3833
+ }
3834
+ return report(ctx, "cream-palette", file, ctx.raw, hits);
3835
+ }
3836
+ };
3837
+ var pulsingDot = {
3838
+ name: "pulsing-dot",
3839
+ appliesTo: (file) => isStyleBearing(file),
3840
+ run(source, file, ctx) {
3841
+ const hits = [];
3842
+ const pulsingClasses = /* @__PURE__ */ new Set();
3843
+ for (const block of leafBlocks(source)) {
3844
+ const anim = /(?<![-\w])animation(?:-name)?\s*:\s*([^;}]+)/i.exec(block.body);
3845
+ if (!anim || !/\b(?:pulse|ping|blink|breathe|beacon|glow)[\w-]*/i.test(anim[1])) continue;
3846
+ if (!/infinite/i.test(anim[1]) && !/animation-iteration-count\s*:\s*infinite/i.test(block.body)) continue;
3847
+ const single = /^\s*\.([\w-]+)\s*$/.exec(block.selector);
3848
+ if (single) pulsingClasses.add(single[1]);
3849
+ if (!/border-radius\s*:\s*(?:50%|9999px|999px|100%)/i.test(block.body)) continue;
3850
+ hits.push({ line: lineOfOffset(block, anim.index), message: "a round element pulsing forever: an alarm on a state that is not changing" });
3851
+ }
3852
+ for (const { classes, line } of classLists(file, ctx.raw)) {
3853
+ const plain = classes.map(bare);
3854
+ const ping = plain.includes("animate-ping");
3855
+ const dot = plain.includes("rounded-full") && plain.some((c) => /^(?:size|w|h)-(?:1|1\.5|2|2\.5|3|3\.5)$/.test(c));
3856
+ const pulsing = plain.includes("animate-pulse") || plain.some((c) => pulsingClasses.has(c));
3857
+ if (ping || pulsing && dot) hits.push({ line, message: `a status dot that ${ping ? "pings" : "pulses"} forever: motion on a state that is not changing` });
3858
+ }
3859
+ return report(ctx, "pulsing-dot", file, ctx.raw, hits);
3860
+ }
3861
+ };
3862
+ var HEADLINE_SELECTOR = /\bh1\b|\bh2\b|hero|display|headline|title/i;
3863
+ var SERIF = /font-family\s*:\s*([^;}]+)/i;
3864
+ function serifFamily(value) {
3865
+ const first = value.split(",")[0].trim().toLowerCase();
3866
+ if (/sans|mono|system-ui|ui-sans|inter\b|helvetica|arial/.test(first)) return false;
3867
+ return /serif|georgia|garamond|playfair|fraunces|lora|merriweather|instrument serif|dm serif|cormorant|newsreader|times|baskerville|bodoni|caslon|libre caslon|source serif|pt serif|crimson/.test(value.toLowerCase());
3868
+ }
3869
+ var italicSerifDisplay = {
3870
+ name: "italic-serif-display",
3871
+ appliesTo: (file) => isStyleBearing(file),
3872
+ run(source, file, ctx) {
3873
+ const hits = [];
3874
+ const serifClasses = /* @__PURE__ */ new Set();
3875
+ for (const block of leafBlocks(source)) {
3876
+ const family = SERIF.exec(block.body);
3877
+ const single = /^\s*\.([\w-]+)\s*$/.exec(block.selector);
3878
+ if (family && single && serifFamily(family[1])) serifClasses.add(single[1]);
3879
+ }
3880
+ for (const block of leafBlocks(source)) {
3881
+ if (!HEADLINE_SELECTOR.test(block.selector) && !/\bh1\b.*\b(?:em|i)\b/.test(block.selector)) continue;
3882
+ const italic = /font-style\s*:\s*italic/i.exec(block.body);
3883
+ const family = SERIF.exec(block.body);
3884
+ if (italic && family && serifFamily(family[1])) {
3885
+ hits.push({ line: lineOfOffset(block, italic.index), message: `${block.selector.trim()} is an italic serif headline: the shortcut to an editorial look` });
3886
+ }
3887
+ }
3888
+ if (isMarkupHost(file)) {
3889
+ const starts = buildLineIndex(ctx.raw);
3890
+ for (const m of ctx.raw.matchAll(/<(h[12])\b([^>]*)>([\s\S]{0,400}?)<\/\1\s*>/gi)) {
3891
+ const own = (/\b(?:class|className)\s*=\s*["']([^"']*)["']/.exec(m[2])?.[1] ?? "").split(/\s+/).map(bare);
3892
+ const serif = own.includes("font-serif") || own.some((c) => serifClasses.has(c) || /^font-\[.*serif/i.test(c) && !/sans/i.test(c));
3893
+ const italicSelf = own.includes("italic");
3894
+ const italicChild = /<(em|i)\b(?![^>]*\bnot-italic\b)/i.test(m[3]) || /\bclass(?:Name)?\s*=\s*["'][^"']*\bitalic\b/.test(m[3]);
3895
+ const childSerif = /\bclass(?:Name)?\s*=\s*["'][^"']*\bfont-serif\b/.test(m[3]);
3896
+ if (serif && (italicSelf || italicChild) || italicChild && childSerif) {
3897
+ hits.push({ line: lineForOffset(starts, m.index), message: `an italic serif <${m[1].toLowerCase()}>: the shortcut to an editorial look` });
3898
+ }
3899
+ }
3900
+ }
3901
+ return report(ctx, "italic-serif-display", file, ctx.raw, hits);
3902
+ }
3903
+ };
3904
+ var BEFORE_HEADING = /<(p|span|div|small|a)\b([^>]*)>((?:[^<]|<\/?(?:span|svg|path|circle|i|b|strong|em|img)\b[^>]*>){1,400}?)<\/\1\s*>\s*(?:<[^>]*>\s*){0,2}<(h[1-3])\b/gi;
3905
+ var LABEL_CLASS = /\b(?:eyebrow|kicker|overline|pretitle|pre-title|tagline|section-label|superscript-label)\b/i;
3906
+ function labelKind(attrs) {
3907
+ const cls = /\b(?:class|className)\s*=\s*["']([^"']*)["']/.exec(attrs)?.[1] ?? "";
3908
+ const plain = cls.split(/\s+/).map(bare);
3909
+ const pill = plain.includes("rounded-full") && plain.some((c) => /^(?:border|bg-|ring)/.test(c)) && plain.some((c) => /^(?:px|py|p)-/.test(c));
3910
+ if (pill) return "chip";
3911
+ const caps = plain.includes("uppercase") && plain.some((c) => /^tracking-/.test(c) && !/tracking-(?:tight|tighter|normal)/.test(c));
3912
+ if (caps || LABEL_CLASS.test(cls)) return "kicker";
3913
+ return void 0;
3914
+ }
3915
+ function labelHits(raw) {
3916
+ const out = [];
3917
+ for (const m of raw.matchAll(BEFORE_HEADING)) {
3918
+ const kind = labelKind(m[2]);
3919
+ const text = m[3].replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
3920
+ if (kind && text && text.length <= 60) out.push({ kind, heading: m[4].toLowerCase(), offset: m.index, text });
3921
+ }
3922
+ return out;
3923
+ }
3924
+ var headingKicker = {
3925
+ name: "heading-kicker",
3926
+ appliesTo: (file) => isReaderText(file) && isMarkupHost(file),
3927
+ run(_source, file, ctx) {
3928
+ const kickers = labelHits(ctx.raw).filter((h) => h.kind === "kicker");
3929
+ if (kickers.length < 2) return [];
3930
+ const starts = buildLineIndex(ctx.raw);
3931
+ return report(ctx, "heading-kicker", file, ctx.raw, kickers.map((k) => ({
3932
+ line: lineForOffset(starts, k.offset),
3933
+ message: `"${k.text}" as a small caps line over an <${k.heading}>, one of ${kickers.length} in this file: a kicker over every heading`
3934
+ })));
3935
+ }
3936
+ };
3937
+ var eyebrowChip = {
3938
+ name: "eyebrow-chip",
3939
+ appliesTo: (file) => isReaderText(file) && isMarkupHost(file),
3940
+ run(_source, file, ctx) {
3941
+ const starts = buildLineIndex(ctx.raw);
3942
+ return report(ctx, "eyebrow-chip", file, ctx.raw, labelHits(ctx.raw).filter((h) => h.kind === "chip" && h.heading === "h1").map((h) => ({ line: lineForOffset(starts, h.offset), message: `"${h.text}" in a pill over the headline: the shape of a control, pressed and doing nothing` })));
3943
+ }
3944
+ };
3945
+ var NUMBER_LABEL = /<(span|p|div|small)\b[^>]*>\s*(?:\(|№\s*)?0[1-9](?:\s*[./)]|\s*\/\s*\d{2})?\s*<\/\1\s*>\s*(?:<[^>]*>\s*){0,2}<h[2-4]\b|<h[2-4]\b[^>]*>\s*(?:<[^>]+>\s*)?0[1-9]\s*(?:<\/[^>]+>)?\s*[.·—–/|:]?\s*(?:<[^>]+>)?\s*[A-Za-z]/gi;
3946
+ var numberedSectionLabels = {
3947
+ name: "numbered-section-labels",
3948
+ appliesTo: (file) => isReaderText(file) && isMarkupHost(file),
3949
+ run(_source, file, ctx) {
3950
+ const raw = ctx.raw.replace(/<ol\b[\s\S]*?<\/ol\s*>/gi, (m) => m.replace(/[^\n]/g, " "));
3951
+ const found = [...raw.matchAll(NUMBER_LABEL)];
3952
+ if (found.length < 2) return [];
3953
+ const starts = buildLineIndex(ctx.raw);
3954
+ return report(ctx, "numbered-section-labels", file, ctx.raw, found.map((m) => ({
3955
+ line: lineForOffset(starts, m.index),
3956
+ message: `a "0N" label on a section heading, one of ${found.length} here: a number promises an order`
3957
+ })));
3958
+ }
3959
+ };
3960
+ var generatedLookDetectors = [
3961
+ sideTabBorder,
3962
+ roundedAccentBorder,
3963
+ decorativeGridBackground,
3964
+ glowAccent,
3965
+ creamPalette,
3966
+ pulsingDot,
3967
+ italicSerifDisplay,
3968
+ headingKicker,
3969
+ eyebrowChip,
3970
+ numberedSectionLabels
3971
+ ];
3972
+
3638
3973
  // src/check/registry.ts
3639
3974
  var DETECTORS = [
3640
3975
  gradientText,
@@ -3656,7 +3991,8 @@ var DETECTORS = [
3656
3991
  emDash,
3657
3992
  semanticElement,
3658
3993
  metadata,
3659
- interfaceSafety
3994
+ interfaceSafety,
3995
+ ...generatedLookDetectors
3660
3996
  ];
3661
3997
  function getDetector(name) {
3662
3998
  return DETECTORS.find((d) => d.name === name);
@@ -3689,7 +4025,7 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
3689
4025
  }
3690
4026
 
3691
4027
  // src/check/tokens.ts
3692
- import { existsSync as existsSync10, readdirSync as readdirSync8, readFileSync as readFileSync14 } from "fs";
4028
+ import { existsSync as existsSync11, readdirSync as readdirSync8, readFileSync as readFileSync14 } from "fs";
3693
4029
  import { join as join19 } from "path";
3694
4030
  function rootDeclarations(source) {
3695
4031
  const out = {};
@@ -3706,7 +4042,7 @@ function rootDeclarations(source) {
3706
4042
  function loadTokenMap(projectRoot, consumerFiles = []) {
3707
4043
  const jig = {};
3708
4044
  const tokensDir = join19(projectRoot, ".jig", "tokens");
3709
- if (existsSync10(tokensDir)) {
4045
+ if (existsSync11(tokensDir)) {
3710
4046
  for (const file of readdirSync8(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
3711
4047
  Object.assign(jig, rootDeclarations(readFileSync14(join19(tokensDir, file), "utf8")));
3712
4048
  }
@@ -3737,7 +4073,7 @@ function loadTokenMap(projectRoot, consumerFiles = []) {
3737
4073
  }
3738
4074
 
3739
4075
  // src/check/exempt.ts
3740
- import { existsSync as existsSync11, readFileSync as readFileSync15 } from "fs";
4076
+ import { existsSync as existsSync12, readFileSync as readFileSync15 } from "fs";
3741
4077
  import { join as join20 } from "path";
3742
4078
  function globToRegExp(glob) {
3743
4079
  let out = "";
@@ -3758,7 +4094,7 @@ function globToRegExp(glob) {
3758
4094
  }
3759
4095
  function readExemptions(projectRoot) {
3760
4096
  const configPath = join20(projectRoot, "jig.config.json");
3761
- if (!existsSync11(configPath)) return [];
4097
+ if (!existsSync12(configPath)) return [];
3762
4098
  let declared;
3763
4099
  try {
3764
4100
  declared = JSON.parse(readFileSync15(configPath, "utf8")).exempt;
@@ -3874,7 +4210,7 @@ function hasMenuToggle(raw) {
3874
4210
  }
3875
4211
 
3876
4212
  // src/check/mode-wiring.ts
3877
- import { existsSync as existsSync12, readFileSync as readFileSync17 } from "fs";
4213
+ import { existsSync as existsSync13, readFileSync as readFileSync17 } from "fs";
3878
4214
  import { dirname as dirname6, join as join22 } from "path";
3879
4215
  var MODES = ["editorial", "product", "operator"];
3880
4216
  function configProblems(config) {
@@ -3898,7 +4234,7 @@ function configProblems(config) {
3898
4234
  function modeWiringProblems(projectRoot) {
3899
4235
  let config;
3900
4236
  const path = join22(projectRoot, "jig.config.json");
3901
- if (!existsSync12(path)) return [];
4237
+ if (!existsSync13(path)) return [];
3902
4238
  try {
3903
4239
  config = JSON.parse(readFileSync17(path, "utf8"));
3904
4240
  } catch {
@@ -3913,10 +4249,10 @@ function modeWiringProblems(projectRoot) {
3913
4249
  const rel = (f) => dir === "." ? f : `${dir}/${f}`;
3914
4250
  const multi = modes.length > 1;
3915
4251
  const barrelOf = (mode) => multi ? `theme.${mode}.css` : "theme.css";
3916
- const anyBarrel = ["theme.css", ...modes.map((m) => `theme.${m}.css`)].some((f) => existsSync12(join22(projectRoot, rel(f))));
4252
+ const anyBarrel = ["theme.css", ...modes.map((m) => `theme.${m}.css`)].some((f) => existsSync13(join22(projectRoot, rel(f))));
3917
4253
  if (!anyBarrel) return [];
3918
4254
  const problems = [];
3919
- if (multi && existsSync12(join22(projectRoot, rel("theme.css")))) {
4255
+ if (multi && existsSync13(join22(projectRoot, rel("theme.css")))) {
3920
4256
  problems.push({
3921
4257
  mode: modes[0],
3922
4258
  barrel: rel("theme.css"),
@@ -3940,7 +4276,7 @@ function modeWiringProblems(projectRoot) {
3940
4276
  barrel,
3941
4277
  message: `jig.config.json declares ${mode}, but ${barrel} imports ${imported ? `mode.${imported}.css` : "no mode file"} \u2014 the page is styled at ${imported ?? "no"} density`
3942
4278
  });
3943
- } else if (!existsSync12(join22(projectRoot, rel(modeFile)))) {
4279
+ } else if (!existsSync13(join22(projectRoot, rel(modeFile)))) {
3944
4280
  problems.push({ mode, barrel, message: `jig.config.json declares ${mode}, but ${rel(modeFile)} does not exist` });
3945
4281
  }
3946
4282
  });
@@ -3948,7 +4284,7 @@ function modeWiringProblems(projectRoot) {
3948
4284
  }
3949
4285
 
3950
4286
  // src/check/token-audit.ts
3951
- import { existsSync as existsSync13, readdirSync as readdirSync9, readFileSync as readFileSync18 } from "fs";
4287
+ import { existsSync as existsSync14, readdirSync as readdirSync9, readFileSync as readFileSync18 } from "fs";
3952
4288
  import { join as join23 } from "path";
3953
4289
  var TEXT_FLOOR = 4.5;
3954
4290
  var UI_FLOOR = 3;
@@ -4011,7 +4347,7 @@ function tokenFiles(projectRoot) {
4011
4347
  }
4012
4348
  };
4013
4349
  const statePath = join23(projectRoot, ".jig", "state.json");
4014
- if (existsSync13(statePath)) {
4350
+ if (existsSync14(statePath)) {
4015
4351
  try {
4016
4352
  const state = JSON.parse(readFileSync18(statePath, "utf8"));
4017
4353
  const recorded = Object.keys(state.files ?? {}).filter((f) => f.endsWith(".css")).sort();
@@ -4021,7 +4357,7 @@ function tokenFiles(projectRoot) {
4021
4357
  }
4022
4358
  }
4023
4359
  const legacy = join23(projectRoot, ".jig", "tokens");
4024
- if (!existsSync13(legacy)) return [];
4360
+ if (!existsSync14(legacy)) return [];
4025
4361
  return readdirSync9(legacy).filter((f) => f.endsWith(".css")).sort().map((f) => ({ path: `.jig/tokens/${f}`, source: readFileSync18(join23(legacy, f), "utf8") }));
4026
4362
  }
4027
4363
  function auditTokenLayer(projectRoot) {
@@ -4071,9 +4407,9 @@ function auditTokenLayer(projectRoot) {
4071
4407
  for (const { token, min, why } of NUMERIC_FLOORS) {
4072
4408
  const decl = scope[token];
4073
4409
  if (!decl) continue;
4074
- const px = /^([\d.]+)px$/.exec(decl.value.trim());
4075
- if (!px) continue;
4076
- const value = Number(px[1]);
4410
+ const px2 = /^([\d.]+)px$/.exec(decl.value.trim());
4411
+ if (!px2) continue;
4412
+ const value = Number(px2[1]);
4077
4413
  if (value >= min) continue;
4078
4414
  problems.push({
4079
4415
  token,
@@ -4093,7 +4429,7 @@ function auditTokenLayer(projectRoot) {
4093
4429
  var LEGACY_INDEX_REL = join24(".jig", "rules.index.json");
4094
4430
  function resolveIndexPath(projectRoot) {
4095
4431
  const legacy = join24(projectRoot, LEGACY_INDEX_REL);
4096
- if (existsSync14(legacy)) return legacy;
4432
+ if (existsSync15(legacy)) return legacy;
4097
4433
  return join24(assetRoot(), "rules.index.json");
4098
4434
  }
4099
4435
  function resolveMode(projectRoot) {
@@ -4203,7 +4539,7 @@ function check(opts) {
4203
4539
  }
4204
4540
  })
4205
4541
  );
4206
- const report = formatReport(findings, {
4542
+ const report2 = formatReport(findings, {
4207
4543
  totalRules: index.length,
4208
4544
  // From the CLI's own bundle, like `explain` — the spec count describes the
4209
4545
  // system, not whatever a project happens to have vendored. A legacy project
@@ -4221,11 +4557,11 @@ function check(opts) {
4221
4557
  modeUnwired: modeWiringProblems(opts.projectRoot),
4222
4558
  waived
4223
4559
  });
4224
- return { findings, report, hasError, waived };
4560
+ return { findings, report: report2, hasError, waived };
4225
4561
  }
4226
4562
 
4227
4563
  // src/commands/init.ts
4228
- import { existsSync as existsSync15, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync23, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
4564
+ import { existsSync as existsSync16, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync23, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
4229
4565
  import { dirname as dirname7, isAbsolute, join as join28, resolve as resolve2, sep as sep3 } from "path";
4230
4566
 
4231
4567
  // src/init/detect.ts
@@ -4749,7 +5085,7 @@ function parseSurfaces(answer, fallback) {
4749
5085
  return parsed.length > 0 ? parsed : fallback;
4750
5086
  }
4751
5087
  function fileState(projectRoot, absPath, relPath, initManifest) {
4752
- const existsOnDisk = existsSync15(absPath);
5088
+ const existsOnDisk = existsSync16(absPath);
4753
5089
  const tracked = !!initManifest && relPath in initManifest.files;
4754
5090
  const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
4755
5091
  return { existsOnDisk, tracked, modified };
@@ -4760,11 +5096,11 @@ function isValidSurfaceArray(v) {
4760
5096
  );
4761
5097
  }
4762
5098
  function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
4763
- if (!existsSync15(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
5099
+ if (!existsSync16(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
4764
5100
  try {
4765
5101
  const parsed = JSON.parse(readFileSync23(configAbsPath, "utf8"));
4766
5102
  const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
4767
- const brandResolves = brandCandidate ? existsSync15(join28(projectRoot, ...brandCandidate.split("/"))) : false;
5103
+ const brandResolves = brandCandidate ? existsSync16(join28(projectRoot, ...brandCandidate.split("/"))) : false;
4768
5104
  return {
4769
5105
  brand: brandResolves ? brandCandidate : fallbackBrand,
4770
5106
  surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
@@ -4774,7 +5110,7 @@ function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallback
4774
5110
  }
4775
5111
  }
4776
5112
  function configuredBrandPath(projectRoot, configAbsPath) {
4777
- if (!existsSync15(configAbsPath)) return { path: null };
5113
+ if (!existsSync16(configAbsPath)) return { path: null };
4778
5114
  let declared;
4779
5115
  try {
4780
5116
  declared = JSON.parse(readFileSync23(configAbsPath, "utf8")).brand;
@@ -4805,7 +5141,7 @@ function defaultTokenDir(detection) {
4805
5141
  return ["jig"];
4806
5142
  }
4807
5143
  function readConfiguredSurfaces(configAbsPath) {
4808
- if (!existsSync15(configAbsPath)) return null;
5144
+ if (!existsSync16(configAbsPath)) return null;
4809
5145
  try {
4810
5146
  const parsed = JSON.parse(readFileSync23(configAbsPath, "utf8"));
4811
5147
  return isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : null;
@@ -4818,7 +5154,7 @@ function isCssModule(path) {
4818
5154
  }
4819
5155
  function legacyBrandFile(projectRoot) {
4820
5156
  const dir = join28(projectRoot, ".jig", "tokens");
4821
- if (!existsSync15(dir)) return void 0;
5157
+ if (!existsSync16(dir)) return void 0;
4822
5158
  const found = readdirSync10(dir).find((f) => /^brand\.[\w.-]+\.css$/.test(f));
4823
5159
  return found ? relKey(".jig", "tokens", found) : void 0;
4824
5160
  }
@@ -4849,7 +5185,7 @@ var LEGACY_PAIR_RE = new RegExp(
4849
5185
  `@import\\s+["'][^"']*brand\\.[\\w.-]+\\.css["'];?\\r?\\n?\\s*@import\\s+["'][^"']*mode\\.(?:${MODES2.join("|")})\\.css["'];?\\r?\\n?`
4850
5186
  );
4851
5187
  function wireBarrel(absPath, barrelImport) {
4852
- const content = existsSync15(absPath) ? readFileSync23(absPath, "utf8") : "";
5188
+ const content = existsSync16(absPath) ? readFileSync23(absPath, "utf8") : "";
4853
5189
  const line = `@import "${barrelImport}";`;
4854
5190
  if (content.includes(line)) return { status: "already-present" };
4855
5191
  const legacy = LEGACY_PAIR_RE.exec(content);
@@ -4877,10 +5213,10 @@ async function init(opts) {
4877
5213
  "'jig init' asks questions and stdin is not a terminal, so it cannot. Re-run with --yes to accept the derived defaults, or run it in a terminal. A jig.config.json does not replace --yes, because this check runs before it is read. With both, init takes the mode from the config instead of deriving it."
4878
5214
  );
4879
5215
  }
4880
- const migrateLegacy = async (report, describe) => {
4881
- if (!report.present) return;
4882
- for (const line of describe(report)) log(line);
4883
- const removable = removableLegacyFiles(report);
5216
+ const migrateLegacy = async (report2, describe) => {
5217
+ if (!report2.present) return;
5218
+ for (const line of describe(report2)) log(line);
5219
+ const removable = removableLegacyFiles(report2);
4884
5220
  if (removable.length === 0) return;
4885
5221
  if (opts.yes) {
4886
5222
  log(" Re-run without --yes to be prompted for removal, or delete them yourself.");
@@ -5055,7 +5391,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5055
5391
  existing = {};
5056
5392
  }
5057
5393
  const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
5058
- const brandStillResolves = existingBrand ? existsSync15(join28(opts.projectRoot, ...existingBrand.split("/"))) : false;
5394
+ const brandStillResolves = existingBrand ? existsSync16(join28(opts.projectRoot, ...existingBrand.split("/"))) : false;
5059
5395
  const existingSurfaces = Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : void 0;
5060
5396
  const merged = {
5061
5397
  brand: brandStillResolves ? existingBrand : brandRelPath,
@@ -5112,7 +5448,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5112
5448
  if (multiMode) {
5113
5449
  const rel = relKey(...tokensRelDir, "theme.css");
5114
5450
  const abs = join28(opts.projectRoot, ...tokensRelDir, "theme.css");
5115
- if (existsSync15(abs)) {
5451
+ if (existsSync16(abs)) {
5116
5452
  const state = fileState(opts.projectRoot, abs, rel, initManifest);
5117
5453
  if (state.tracked && !state.modified) {
5118
5454
  rmSync2(abs);
@@ -5184,7 +5520,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5184
5520
  if (relocateTo && currentBrand) {
5185
5521
  const oldDir = currentBrand.split("/").slice(0, -1);
5186
5522
  const oldAbsDir = join28(opts.projectRoot, ...oldDir);
5187
- if (existsSync15(oldAbsDir)) {
5523
+ if (existsSync16(oldAbsDir)) {
5188
5524
  for (const name of readdirSync10(oldAbsDir).filter((f) => f.endsWith(".css"))) {
5189
5525
  const rel = relKey(...oldDir, name);
5190
5526
  const abs = join28(oldAbsDir, name);
@@ -5201,14 +5537,14 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5201
5537
  const wireTarget = findWireTarget(detection);
5202
5538
  let wiring;
5203
5539
  if (multiMode) {
5204
- const bare = join28(opts.projectRoot, ...tokensRelDir, "theme.css");
5540
+ const bare2 = join28(opts.projectRoot, ...tokensRelDir, "theme.css");
5205
5541
  for (const file of detection.cssFiles.filter((f) => !isTokenLayerFile(f))) {
5206
5542
  const abs = join28(opts.projectRoot, file);
5207
5543
  try {
5208
5544
  const before = readFileSync23(abs, "utf8");
5209
5545
  const after = before.split("\n").filter((l) => {
5210
5546
  const m = /^\s*@import\s+["']([^"']+)["'];?\s*$/.exec(l);
5211
- return !(m && !/^[a-z]+:|^\//i.test(m[1]) && resolve2(dirname7(abs), m[1]) === bare);
5547
+ return !(m && !/^[a-z]+:|^\//i.test(m[1]) && resolve2(dirname7(abs), m[1]) === bare2);
5212
5548
  }).join("\n");
5213
5549
  if (after !== before) {
5214
5550
  writeFileSync5(abs, after, "utf8");
@@ -5237,7 +5573,7 @@ Could not edit ${file}: ${err.message}. Remove its import of ${relKey(...tokensR
5237
5573
  if (relocateTo && currentBrand) {
5238
5574
  const abs = join28(opts.projectRoot, wireTarget);
5239
5575
  const oldDirName = currentBrand.split("/").slice(0, -1).join("/");
5240
- const before = existsSync15(abs) ? readFileSync23(abs, "utf8") : "";
5576
+ const before = existsSync16(abs) ? readFileSync23(abs, "utf8") : "";
5241
5577
  const after = before.split("\n").filter((l) => !(l.includes("@import") && l.includes(oldDirName))).join("\n");
5242
5578
  if (after !== before) writeFileSync5(abs, after, "utf8");
5243
5579
  }
@@ -5314,8 +5650,8 @@ Could not write to ${wireTarget}: ${err.message}`);
5314
5650
  }
5315
5651
 
5316
5652
  // src/commands/verdicts.ts
5317
- import { existsSync as existsSync21, readFileSync as readFileSync28 } from "fs";
5318
- import { join as join35, resolve as resolve4 } from "path";
5653
+ import { existsSync as existsSync23, readFileSync as readFileSync29 } from "fs";
5654
+ import { join as join36, resolve as resolve5 } from "path";
5319
5655
 
5320
5656
  // src/rules/citations.ts
5321
5657
  import { join as join29 } from "path";
@@ -5329,13 +5665,13 @@ function citableIds(packageRoot2) {
5329
5665
  }
5330
5666
 
5331
5667
  // src/probe/check.ts
5332
- import { existsSync as existsSync18, readdirSync as readdirSync13, readFileSync as readFileSync25 } from "fs";
5333
- import { join as join32 } from "path";
5668
+ import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as readFileSync26 } from "fs";
5669
+ import { join as join33 } from "path";
5334
5670
 
5335
5671
  // src/probe/save.ts
5336
- import { existsSync as existsSync17, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync24, writeFileSync as writeFileSync6 } from "fs";
5672
+ import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync25, writeFileSync as writeFileSync6 } from "fs";
5337
5673
  import { fileURLToPath as fileURLToPath2 } from "url";
5338
- import { join as join31, relative as relative3, resolve as resolve3 } from "path";
5674
+ import { join as join32, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
5339
5675
 
5340
5676
  // src/probe/script.ts
5341
5677
  var PROBE_VERSION = 5;
@@ -5488,7 +5824,7 @@ var PROBE_SCRIPT = `(async () => {
5488
5824
 
5489
5825
  // src/probe/browser.ts
5490
5826
  import { spawn } from "child_process";
5491
- import { existsSync as existsSync16, mkdtempSync, readdirSync as readdirSync11, rmSync as rmSync3 } from "fs";
5827
+ import { existsSync as existsSync17, mkdtempSync, readdirSync as readdirSync11, rmSync as rmSync3 } from "fs";
5492
5828
  import { homedir, tmpdir } from "os";
5493
5829
  import { join as join30 } from "path";
5494
5830
  var CHROME_ENV = ["JIG_CHROME", "CHROME_PATH", "PUPPETEER_EXECUTABLE_PATH", "CHROMIUM_PATH"];
@@ -5526,7 +5862,7 @@ function cachedBrowsers() {
5526
5862
  ["chrome-win64", "chrome.exe"]
5527
5863
  ]) {
5528
5864
  const candidate = join30(root, entry, ...rel);
5529
- if (existsSync16(candidate)) found.push(candidate);
5865
+ if (existsSync17(candidate)) found.push(candidate);
5530
5866
  }
5531
5867
  }
5532
5868
  }
@@ -5535,13 +5871,13 @@ function cachedBrowsers() {
5535
5871
  function findChrome() {
5536
5872
  for (const key of CHROME_ENV) {
5537
5873
  const value = process.env[key];
5538
- if (value && existsSync16(value)) return value;
5874
+ if (value && existsSync17(value)) return value;
5539
5875
  }
5540
5876
  for (const command of CHROME_COMMANDS) {
5541
5877
  const resolved = which(command);
5542
5878
  if (resolved) return resolved;
5543
5879
  }
5544
- for (const app of CHROME_APPS) if (existsSync16(app)) return app;
5880
+ for (const app of CHROME_APPS) if (existsSync17(app)) return app;
5545
5881
  return cachedBrowsers()[0];
5546
5882
  }
5547
5883
  function which(command) {
@@ -5549,7 +5885,7 @@ function which(command) {
5549
5885
  for (const dir of dirs) {
5550
5886
  if (!dir) continue;
5551
5887
  const candidate = join30(dir, command);
5552
- if (existsSync16(candidate)) return candidate;
5888
+ if (existsSync17(candidate)) return candidate;
5553
5889
  }
5554
5890
  return void 0;
5555
5891
  }
@@ -5594,8 +5930,13 @@ async function runProbe(opts) {
5594
5930
  });
5595
5931
  return await evaluate(endpoint, opts.url, opts.width, opts.height ?? 900, timeoutMs);
5596
5932
  } finally {
5933
+ const exited = child.exitCode !== null ? Promise.resolve() : new Promise((done) => child.once("exit", () => done()));
5597
5934
  child.kill();
5598
- rmSync3(profile, { recursive: true, force: true });
5935
+ await Promise.race([exited, new Promise((done) => setTimeout(done, 5e3))]);
5936
+ try {
5937
+ rmSync3(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
5938
+ } catch {
5939
+ }
5599
5940
  }
5600
5941
  }
5601
5942
  async function evaluate(endpoint, url, width, height, timeoutMs) {
@@ -5654,6 +5995,59 @@ async function evaluate(endpoint, url, width, height, timeoutMs) {
5654
5995
  }
5655
5996
  }
5656
5997
 
5998
+ // src/probe/serve.ts
5999
+ import { createServer } from "http";
6000
+ import { existsSync as existsSync18, readFileSync as readFileSync24, statSync as statSync2 } from "fs";
6001
+ import { extname, join as join31, resolve as resolve3, sep as sep4 } from "path";
6002
+ var TYPES = {
6003
+ ".html": "text/html; charset=utf-8",
6004
+ ".htm": "text/html; charset=utf-8",
6005
+ ".css": "text/css; charset=utf-8",
6006
+ ".js": "text/javascript; charset=utf-8",
6007
+ ".mjs": "text/javascript; charset=utf-8",
6008
+ ".json": "application/json",
6009
+ ".txt": "text/plain; charset=utf-8",
6010
+ ".xml": "application/xml",
6011
+ ".svg": "image/svg+xml",
6012
+ ".png": "image/png",
6013
+ ".jpg": "image/jpeg",
6014
+ ".jpeg": "image/jpeg",
6015
+ ".gif": "image/gif",
6016
+ ".webp": "image/webp",
6017
+ ".avif": "image/avif",
6018
+ ".ico": "image/x-icon",
6019
+ ".woff": "font/woff",
6020
+ ".woff2": "font/woff2",
6021
+ ".ttf": "font/ttf",
6022
+ ".otf": "font/otf"
6023
+ };
6024
+ async function serveDirectory(dir) {
6025
+ const root = resolve3(dir);
6026
+ const server = createServer((req, res) => {
6027
+ let path = decodeURIComponent((req.url ?? "/").split("?")[0].split("#")[0]);
6028
+ let file = resolve3(join31(root, path));
6029
+ if (file !== root && !file.startsWith(root + sep4)) {
6030
+ res.writeHead(403).end();
6031
+ return;
6032
+ }
6033
+ if (existsSync18(file) && statSync2(file).isDirectory()) file = join31(file, "index.html");
6034
+ else if (!existsSync18(file) && existsSync18(`${file}.html`)) file = `${file}.html`;
6035
+ if (!existsSync18(file)) {
6036
+ res.writeHead(404).end();
6037
+ return;
6038
+ }
6039
+ res.writeHead(200, { "content-type": TYPES[extname(file).toLowerCase()] ?? "application/octet-stream" });
6040
+ res.end(readFileSync24(file));
6041
+ });
6042
+ await new Promise((done) => server.listen(0, "127.0.0.1", done));
6043
+ const address = server.address();
6044
+ const port = typeof address === "object" && address ? address.port : 0;
6045
+ return {
6046
+ origin: `http://127.0.0.1:${port}`,
6047
+ close: () => new Promise((done) => server.close(() => done()))
6048
+ };
6049
+ }
6050
+
5657
6051
  // src/probe/save.ts
5658
6052
  function saveProbe(opts) {
5659
6053
  let probe;
@@ -5668,19 +6062,25 @@ function saveProbe(opts) {
5668
6062
  const width = typeof probe.width === "number" ? probe.width : NaN;
5669
6063
  if (!Number.isFinite(width)) throw new Error("The probe output has no width. Evaluate it in a browser, at the width you are judging.");
5670
6064
  const url = typeof probe.url === "string" ? probe.url : "";
5671
- const page = pageFile(opts.projectRoot, url);
6065
+ const page = opts.page ? projectFile(opts.projectRoot, opts.page) : pageFile(opts.projectRoot, url);
5672
6066
  if (!page) {
5673
- throw new Error(`The probe ran at ${url || "no url"}, which is not a file in this project. Open the page you built \u2014 a file:// URL under the project \u2014 and probe that.`);
6067
+ throw new Error(`The probe ran at ${url || "no url"}, which is not a file in this project. Open the page you built, as a file:// URL under the project, or let \`jig probe --run <page> --serve <dir>\` serve it.`);
5674
6068
  }
5675
6069
  probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
5676
- probe.pageChecksum = checksum(readFileSync24(page, "utf8"));
6070
+ if (opts.serveRoot) probe.serveRoot = relative3(opts.projectRoot, resolve4(opts.projectRoot, opts.serveRoot)).split("\\").join("/") || ".";
6071
+ probe.pageChecksum = checksum(readFileSync25(page, "utf8"));
5677
6072
  probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
5678
- const dir = join31(opts.projectRoot, ".jig", "critique", opts.surface);
6073
+ const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
5679
6074
  mkdirSync7(dir, { recursive: true });
5680
- const path = join31(dir, `probe-${width}.json`);
6075
+ const path = join32(dir, `probe-${width}.json`);
5681
6076
  writeFileSync6(path, JSON.stringify(probe), "utf8");
5682
6077
  return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
5683
6078
  }
6079
+ function projectFile(projectRoot, path) {
6080
+ const abs = resolve4(projectRoot, path);
6081
+ if (abs !== resolve4(projectRoot) && !abs.startsWith(resolve4(projectRoot) + sep5)) return void 0;
6082
+ return existsSync19(abs) ? abs : void 0;
6083
+ }
5684
6084
  function pageFile(projectRoot, url) {
5685
6085
  if (!url.startsWith("file://")) return void 0;
5686
6086
  let path;
@@ -5689,34 +6089,48 @@ function pageFile(projectRoot, url) {
5689
6089
  } catch {
5690
6090
  return void 0;
5691
6091
  }
5692
- const abs = resolve3(path);
5693
- if (!abs.startsWith(resolve3(projectRoot))) return void 0;
5694
- return existsSync17(abs) ? abs : void 0;
6092
+ const abs = resolve4(path);
6093
+ if (!abs.startsWith(resolve4(projectRoot))) return void 0;
6094
+ return existsSync19(abs) ? abs : void 0;
5695
6095
  }
5696
6096
  var PROBE_WIDTHS = [360, 768, 1280, 1600];
5697
6097
  async function runAndSaveProbes(opts) {
5698
- const abs = resolve3(opts.projectRoot, opts.page);
5699
- if (!existsSync17(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
5700
- const url = /^https?:/i.test(opts.page) ? opts.page : `file://${abs}`;
6098
+ const abs = resolve4(opts.projectRoot, opts.page);
6099
+ if (!existsSync19(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
5701
6100
  const saved = [];
5702
- for (const width of opts.widths ?? PROBE_WIDTHS) {
5703
- const json = await runProbe({ url, width });
5704
- saved.push(saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json }));
5705
- }
6101
+ await withPageUrl(opts.projectRoot, abs, opts.serve, async (url) => {
6102
+ for (const width of opts.widths ?? PROBE_WIDTHS) {
6103
+ const json = await runProbe({ url, width });
6104
+ saved.push(saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json, page: abs, serveRoot: opts.serve }));
6105
+ }
6106
+ });
5706
6107
  return saved;
5707
6108
  }
6109
+ async function withPageUrl(projectRoot, abs, serve, use) {
6110
+ if (!serve) return use(`file://${abs}`);
6111
+ const root = resolve4(projectRoot, serve);
6112
+ if (!existsSync19(root)) throw new Error(`${serve} does not exist, so there is nothing to serve.`);
6113
+ if (!abs.startsWith(root + sep5)) throw new Error(`The page is not inside ${serve}, so serving ${serve} cannot show it.`);
6114
+ const server = await serveDirectory(root);
6115
+ try {
6116
+ const path = relative3(root, abs).split(sep5).join("/");
6117
+ await use(`${server.origin}/${path}`);
6118
+ } finally {
6119
+ await server.close();
6120
+ }
6121
+ }
5708
6122
  async function ensureProbes(opts) {
5709
- const dir = join31(opts.projectRoot, ".jig", "critique", opts.surface);
5710
- const abs = resolve3(opts.projectRoot, opts.page);
6123
+ const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
6124
+ const abs = resolve4(opts.projectRoot, opts.page);
5711
6125
  let current;
5712
6126
  try {
5713
- current = checksum(readFileSync24(abs, "utf8"));
6127
+ current = checksum(readFileSync25(abs, "utf8"));
5714
6128
  } catch {
5715
6129
  return { recorded: [], reason: `${opts.page} could not be read` };
5716
6130
  }
5717
6131
  const missing = PROBE_WIDTHS.filter((width) => {
5718
6132
  try {
5719
- const probe = JSON.parse(readFileSync24(join31(dir, `probe-${width}.json`), "utf8"));
6133
+ const probe = JSON.parse(readFileSync25(join32(dir, `probe-${width}.json`), "utf8"));
5720
6134
  return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
5721
6135
  } catch {
5722
6136
  return true;
@@ -5724,16 +6138,28 @@ async function ensureProbes(opts) {
5724
6138
  });
5725
6139
  if (missing.length === 0) return { recorded: [] };
5726
6140
  if (!findChrome()) return { recorded: [], reason: "no browser on this machine" };
5727
- const url = /^https?:/i.test(opts.page) ? opts.page : `file://${abs}`;
5728
- for (const width of missing) {
5729
- saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json: await runProbe({ url, width }) });
6141
+ let serve;
6142
+ for (const width of PROBE_WIDTHS) {
6143
+ try {
6144
+ const probe = JSON.parse(readFileSync25(join32(dir, `probe-${width}.json`), "utf8"));
6145
+ if (probe.serveRoot) {
6146
+ serve = probe.serveRoot;
6147
+ break;
6148
+ }
6149
+ } catch {
6150
+ }
5730
6151
  }
6152
+ await withPageUrl(opts.projectRoot, abs, serve, async (url) => {
6153
+ for (const width of missing) {
6154
+ saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json: await runProbe({ url, width }), page: abs, serveRoot: serve });
6155
+ }
6156
+ });
5731
6157
  return { recorded: missing };
5732
6158
  }
5733
6159
  function critiquedSurfaces(projectRoot) {
5734
- const root = join31(projectRoot, ".jig", "critique");
5735
- if (!existsSync17(root)) return [];
5736
- return readdirSync12(root).filter((surface) => existsSync17(join31(root, surface, "screen.json")) || existsSync17(join31(root, surface, "code.json")));
6160
+ const root = join32(projectRoot, ".jig", "critique");
6161
+ if (!existsSync19(root)) return [];
6162
+ return readdirSync12(root).filter((surface) => existsSync19(join32(root, surface, "screen.json")) || existsSync19(join32(root, surface, "code.json")));
5737
6163
  }
5738
6164
 
5739
6165
  // src/probe/check.ts
@@ -5741,11 +6167,11 @@ function stampProblem(projectRoot, file, p) {
5741
6167
  if (!p.pageChecksum || !p.pageFile) {
5742
6168
  return `${file} was not written by \`jig probe --save\`, so nothing measured it. Evaluate \`jig probe\` in the browser and pipe its output into \`jig probe --save <surface>\`.`;
5743
6169
  }
5744
- const page = pageFile(projectRoot, p.url ?? "");
5745
- if (!page) return `${file} names a page outside this project (${p.url ?? "no url"}).`;
6170
+ const page = p.serveRoot ? projectFile(projectRoot, p.pageFile) : pageFile(projectRoot, p.url ?? "");
6171
+ if (!page) return `${file} names a page outside this project (${p.serveRoot ? p.pageFile : p.url ?? "no url"}).`;
5746
6172
  let current;
5747
6173
  try {
5748
- current = readFileSync25(page, "utf8");
6174
+ current = readFileSync26(page, "utf8");
5749
6175
  } catch {
5750
6176
  return `${file} was taken on ${p.pageFile}, which no longer exists.`;
5751
6177
  }
@@ -5755,11 +6181,11 @@ function stampProblem(projectRoot, file, p) {
5755
6181
  return void 0;
5756
6182
  }
5757
6183
  function readProbes(projectRoot, dir, errors) {
5758
- if (!existsSync18(dir)) return [];
6184
+ if (!existsSync20(dir)) return [];
5759
6185
  const probes = [];
5760
6186
  for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
5761
6187
  try {
5762
- const p = JSON.parse(readFileSync25(join32(dir, f), "utf8"));
6188
+ const p = JSON.parse(readFileSync26(join33(dir, f), "utf8"));
5763
6189
  if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
5764
6190
  errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
5765
6191
  continue;
@@ -5851,19 +6277,19 @@ function probeContradictions(probes, verdictOf, indexable = true) {
5851
6277
  }
5852
6278
 
5853
6279
  // src/check/decisions.ts
5854
- import { existsSync as existsSync19, readFileSync as readFileSync26 } from "fs";
5855
- import { join as join33, posix as posix3 } from "path";
6280
+ import { existsSync as existsSync21, readFileSync as readFileSync27 } from "fs";
6281
+ import { join as join34, posix as posix3 } from "path";
5856
6282
  var HEADING3 = /^(#{2,3})\s+(.+?)\s*$/gm;
5857
6283
  var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
5858
6284
  function decisionsFile(projectRoot) {
5859
6285
  const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
5860
6286
  try {
5861
- const brand = JSON.parse(readFileSync26(join33(projectRoot, "jig.config.json"), "utf8")).brand;
6287
+ const brand = JSON.parse(readFileSync27(join34(projectRoot, "jig.config.json"), "utf8")).brand;
5862
6288
  if (typeof brand === "string" && brand.includes("/")) candidates.unshift(`${posix3.dirname(brand.replace(/^\.\//, ""))}/DECISIONS.md`);
5863
6289
  } catch {
5864
6290
  }
5865
6291
  for (const candidate of candidates) {
5866
- if (existsSync19(join33(projectRoot, candidate))) return candidate;
6292
+ if (existsSync21(join34(projectRoot, candidate))) return candidate;
5867
6293
  }
5868
6294
  return void 0;
5869
6295
  }
@@ -5872,7 +6298,7 @@ function decisionNames(projectRoot) {
5872
6298
  if (!path) return [];
5873
6299
  let body;
5874
6300
  try {
5875
- body = readFileSync26(join33(projectRoot, path), "utf8");
6301
+ body = readFileSync27(join34(projectRoot, path), "utf8");
5876
6302
  } catch {
5877
6303
  return [];
5878
6304
  }
@@ -5886,15 +6312,15 @@ function decisionNames(projectRoot) {
5886
6312
  }
5887
6313
 
5888
6314
  // src/check/spec-shape.ts
5889
- import { existsSync as existsSync20, readFileSync as readFileSync27, readdirSync as readdirSync14, statSync as statSync2 } from "fs";
5890
- import { join as join34 } from "path";
6315
+ import { existsSync as existsSync22, readFileSync as readFileSync28, readdirSync as readdirSync14, statSync as statSync3 } from "fs";
6316
+ import { join as join35 } from "path";
5891
6317
  function newestSpec(projectRoot) {
5892
- const dir = join34(projectRoot, ".jig", "specs");
5893
- if (!existsSync20(dir)) return void 0;
6318
+ const dir = join35(projectRoot, ".jig", "specs");
6319
+ if (!existsSync22(dir)) return void 0;
5894
6320
  const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
5895
6321
  if (files.length === 0) return void 0;
5896
- const newest = files.map((f) => ({ f, at: statSync2(join34(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
5897
- return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync27(join34(dir, newest), "utf8") };
6322
+ const newest = files.map((f) => ({ f, at: statSync3(join35(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6323
+ return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync28(join35(dir, newest), "utf8") };
5898
6324
  }
5899
6325
  function specProblems(spec) {
5900
6326
  const parts = spec.body.split(/^---\s*$/m);
@@ -5975,19 +6401,19 @@ function specIndexableField(front) {
5975
6401
  var VERDICTS = ["ok", "finding", "n/a"];
5976
6402
  var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
5977
6403
  function readJson(path, errors) {
5978
- if (!existsSync21(path)) return null;
6404
+ if (!existsSync23(path)) return null;
5979
6405
  try {
5980
- return JSON.parse(readFileSync28(path, "utf8"));
6406
+ return JSON.parse(readFileSync29(path, "utf8"));
5981
6407
  } catch (e) {
5982
6408
  errors.push(`${path}: not valid JSON (${e.message})`);
5983
6409
  return {};
5984
6410
  }
5985
6411
  }
5986
6412
  function specIndexable(projectRoot, surface) {
5987
- const path = join35(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6413
+ const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5988
6414
  let front = "";
5989
6415
  try {
5990
- front = readFileSync28(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6416
+ front = readFileSync29(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5991
6417
  } catch {
5992
6418
  }
5993
6419
  const declared = specIndexableField(front);
@@ -5996,9 +6422,9 @@ function specIndexable(projectRoot, surface) {
5996
6422
  return mode !== "product" && mode !== "operator";
5997
6423
  }
5998
6424
  function specNeedsNav(projectRoot, surface) {
5999
- const path = join35(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6000
- if (!existsSync21(path)) return false;
6001
- const front = readFileSync28(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6425
+ const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6426
+ if (!existsSync23(path)) return false;
6427
+ const front = readFileSync29(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6002
6428
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
6003
6429
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
6004
6430
  return navField || navRegion;
@@ -6049,7 +6475,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
6049
6475
  function checkDecisions(projectRoot, dir, errors) {
6050
6476
  const required = decisionNames(projectRoot);
6051
6477
  if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
6052
- const file = readJson(join35(dir, "decisions.json"), errors);
6478
+ const file = readJson(join36(dir, "decisions.json"), errors);
6053
6479
  if (!file) {
6054
6480
  errors.push(
6055
6481
  `decisions.json is missing. Every decision in DECISIONS.md is judged against the built page, one verdict each: ${required.slice(0, 4).join(", ")}${required.length > 4 ? `, and ${required.length - 4} more` : ""}. A page can satisfy every rule and still break what this project decided.`
@@ -6094,7 +6520,7 @@ function checkDecisions(projectRoot, dir, errors) {
6094
6520
  function verifyVerdicts(opts) {
6095
6521
  const root = opts.packageRoot ?? assetRoot();
6096
6522
  const errors = [];
6097
- const index = JSON.parse(readFileSync28(join35(root, "rules.index.json"), "utf8"));
6523
+ const index = JSON.parse(readFileSync29(join36(root, "rules.index.json"), "utf8"));
6098
6524
  const judgment = index.filter((r) => r.bucket === "judgment");
6099
6525
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
6100
6526
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
@@ -6103,16 +6529,16 @@ function verifyVerdicts(opts) {
6103
6529
  ...judgment.map((r) => [r.id, r.pass ?? ""])
6104
6530
  ]);
6105
6531
  const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
6106
- const dir = join35(opts.projectRoot, ".jig", "critique", opts.surface);
6107
- const screenFile = readJson(join35(dir, "screen.json"), errors);
6108
- const codeFile = readJson(join35(dir, "code.json"), errors);
6532
+ const dir = join36(opts.projectRoot, ".jig", "critique", opts.surface);
6533
+ const screenFile = readJson(join36(dir, "screen.json"), errors);
6534
+ const codeFile = readJson(join36(dir, "code.json"), errors);
6109
6535
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
6110
6536
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
6111
6537
  const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
6112
6538
  let rendered = false;
6113
6539
  if (screenFile && screenFile.rendered === true) {
6114
6540
  const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
6115
- const absent = artefacts.filter((a) => !existsSync21(resolve4(opts.projectRoot, a)));
6541
+ const absent = artefacts.filter((a) => !existsSync23(resolve5(opts.projectRoot, a)));
6116
6542
  if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
6117
6543
  else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
6118
6544
  else rendered = true;
@@ -6133,7 +6559,7 @@ function verifyVerdicts(opts) {
6133
6559
  };
6134
6560
  let specFront = "";
6135
6561
  try {
6136
- specFront = readFileSync28(join35(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
6562
+ specFront = readFileSync29(join36(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
6137
6563
  } catch {
6138
6564
  }
6139
6565
  if (specIndexableField(specFront) === "unreadable") {
@@ -6149,15 +6575,15 @@ function verifyVerdicts(opts) {
6149
6575
  }
6150
6576
 
6151
6577
  // src/commands/gate.ts
6152
- import { existsSync as existsSync22, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync29, writeFileSync as writeFileSync7 } from "fs";
6578
+ import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync30, writeFileSync as writeFileSync7 } from "fs";
6153
6579
  import { createHash as createHash2 } from "crypto";
6154
- import { join as join36 } from "path";
6580
+ import { join as join37 } from "path";
6155
6581
  var MAX_BLOCKS = 3;
6156
6582
  function lastJigCommand(transcriptPath) {
6157
- if (!transcriptPath || !existsSync22(transcriptPath)) return void 0;
6583
+ if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6158
6584
  let text;
6159
6585
  try {
6160
- text = readFileSync29(transcriptPath, "utf8");
6586
+ text = readFileSync30(transcriptPath, "utf8");
6161
6587
  } catch {
6162
6588
  return void 0;
6163
6589
  }
@@ -6172,10 +6598,10 @@ function lastJigCommand(transcriptPath) {
6172
6598
  return found;
6173
6599
  }
6174
6600
  function lastAssistantText(transcriptPath) {
6175
- if (!transcriptPath || !existsSync22(transcriptPath)) return void 0;
6601
+ if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6176
6602
  let found;
6177
6603
  try {
6178
- for (const line of readFileSync29(transcriptPath, "utf8").split("\n")) {
6604
+ for (const line of readFileSync30(transcriptPath, "utf8").split("\n")) {
6179
6605
  if (!line.includes('"assistant"')) continue;
6180
6606
  let entry;
6181
6607
  try {
@@ -6205,7 +6631,7 @@ function commandProblems(root, command) {
6205
6631
  const found = decisionsFile(root);
6206
6632
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
6207
6633
  else {
6208
- const body = readFileSync29(join36(root, found), "utf8");
6634
+ const body = readFileSync30(join37(root, found), "utf8");
6209
6635
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
6210
6636
  problems.push("DECISIONS.md has no `## Unresolved` section. Round 3 asks by name what is still undecided; write what the owner named, or `None named by the owner.`");
6211
6637
  }
@@ -6226,13 +6652,13 @@ function commandProblems(root, command) {
6226
6652
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
6227
6653
  if (/^approved/i.test(mockup)) {
6228
6654
  if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
6229
- else if (!/^https?:/i.test(at) && !existsSync22(join36(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
6655
+ else if (!/^https?:/i.test(at) && !existsSync24(join37(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
6230
6656
  else if (!/^https?:/i.test(at) && !at.startsWith(".jig/mockups/")) problems.push(`The drawing is at ${at}. A mockup lives in .jig/mockups/, outside what check scans and outside what ships.`);
6231
6657
  }
6232
6658
  }
6233
6659
  if (command === "critique") {
6234
- const dir = join36(root, ".jig", "critique");
6235
- const surfaces = existsSync22(dir) ? readdirSync15(dir).filter((s) => existsSync22(join36(dir, s, "screen.json")) || existsSync22(join36(dir, s, "code.json"))) : [];
6660
+ const dir = join37(root, ".jig", "critique");
6661
+ const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join37(dir, s, "screen.json")) || existsSync24(join37(dir, s, "code.json"))) : [];
6236
6662
  for (const surface of surfaces) {
6237
6663
  const v = verifyVerdicts({ projectRoot: root, surface });
6238
6664
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
@@ -6254,13 +6680,13 @@ function surfacePage(projectRoot, surface) {
6254
6680
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
6255
6681
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
6256
6682
  for (const candidate of candidates) {
6257
- if (candidate && existsSync22(join36(projectRoot, candidate))) return candidate;
6683
+ if (candidate && existsSync24(join37(projectRoot, candidate))) return candidate;
6258
6684
  }
6259
6685
  return void 0;
6260
6686
  }
6261
6687
  function gate(opts) {
6262
6688
  const root = opts.projectRoot;
6263
- if (!existsSync22(join36(root, "jig.config.json")) && !existsSync22(join36(root, ".jig"))) {
6689
+ if (!existsSync24(join37(root, "jig.config.json")) && !existsSync24(join37(root, ".jig"))) {
6264
6690
  return { block: false, reason: "" };
6265
6691
  }
6266
6692
  const command = lastJigCommand(opts.input.transcript_path);
@@ -6286,11 +6712,12 @@ function gate(opts) {
6286
6712
  );
6287
6713
  }
6288
6714
  }
6289
- const critiqueDir = join36(root, ".jig", "critique");
6290
- if (existsSync22(critiqueDir)) {
6715
+ problems.push(...verdictGuard(root, command));
6716
+ const critiqueDir = join37(root, ".jig", "critique");
6717
+ if (existsSync24(critiqueDir)) {
6291
6718
  for (const surface of readdirSync15(critiqueDir)) {
6292
- const dir = join36(critiqueDir, surface);
6293
- if (!existsSync22(join36(dir, "screen.json")) && !existsSync22(join36(dir, "code.json"))) continue;
6719
+ const dir = join37(critiqueDir, surface);
6720
+ if (!existsSync24(join37(dir, "screen.json")) && !existsSync24(join37(dir, "code.json"))) continue;
6294
6721
  const v = verifyVerdicts({ projectRoot: root, surface });
6295
6722
  if (!v.ok) {
6296
6723
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -6304,10 +6731,10 @@ function gate(opts) {
6304
6731
  }
6305
6732
  const session = opts.input.session_id ?? "unknown";
6306
6733
  const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
6307
- const stateFile = join36(root, ".jig", "gate.json");
6734
+ const stateFile = join37(root, ".jig", "gate.json");
6308
6735
  let state = {};
6309
6736
  try {
6310
- state = JSON.parse(readFileSync29(stateFile, "utf8"));
6737
+ state = JSON.parse(readFileSync30(stateFile, "utf8"));
6311
6738
  } catch {
6312
6739
  }
6313
6740
  if (problems.length === 0) {
@@ -6335,17 +6762,60 @@ ${problems.join("\n\n")}`
6335
6762
  ${problems.join("\n\n")}`
6336
6763
  };
6337
6764
  }
6765
+ var VERDICT_FILES = ["screen.json", "code.json", "decisions.json"];
6766
+ var LOCK = "verdicts.lock";
6767
+ function verdictChecksum(dir) {
6768
+ const parts = VERDICT_FILES.map((f) => {
6769
+ try {
6770
+ return `${f}
6771
+ ${readFileSync30(join37(dir, f), "utf8")}`;
6772
+ } catch {
6773
+ return "";
6774
+ }
6775
+ });
6776
+ return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
6777
+ }
6778
+ function verdictGuard(root, command) {
6779
+ const critiqueDir = join37(root, ".jig", "critique");
6780
+ if (!existsSync24(critiqueDir)) return [];
6781
+ const problems = [];
6782
+ for (const surface of readdirSync15(critiqueDir)) {
6783
+ const dir = join37(critiqueDir, surface);
6784
+ const now = verdictChecksum(dir);
6785
+ if (!now) continue;
6786
+ const lockPath = join37(dir, LOCK);
6787
+ if (command === "critique") {
6788
+ try {
6789
+ writeFileSync7(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
6790
+ } catch {
6791
+ }
6792
+ continue;
6793
+ }
6794
+ let locked;
6795
+ try {
6796
+ locked = JSON.parse(readFileSync30(lockPath, "utf8")).checksum;
6797
+ } catch {
6798
+ continue;
6799
+ }
6800
+ if (locked && locked !== now) {
6801
+ problems.push(
6802
+ `.jig/critique/${surface}: the verdict files changed after \`/jig critique\` wrote them${command ? `, in a session that ran \`/jig ${command}\`` : ""}. Verdicts are the review's, not the builder's: restore them (\`git checkout -- .jig/critique/${surface}\`) and run \`/jig critique\` to judge the fix.`
6803
+ );
6804
+ }
6805
+ }
6806
+ return problems;
6807
+ }
6338
6808
  function save(file, state) {
6339
6809
  try {
6340
- mkdirSync8(join36(file, ".."), { recursive: true });
6810
+ mkdirSync8(join37(file, ".."), { recursive: true });
6341
6811
  writeFileSync7(file, JSON.stringify(state), "utf8");
6342
6812
  } catch {
6343
6813
  }
6344
6814
  }
6345
6815
 
6346
6816
  // src/commands/seo.ts
6347
- import { readFileSync as readFileSync30 } from "fs";
6348
- import { join as join37 } from "path";
6817
+ import { readFileSync as readFileSync31 } from "fs";
6818
+ import { join as join38 } from "path";
6349
6819
  var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
6350
6820
  var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
6351
6821
  var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
@@ -6386,7 +6856,7 @@ function seo(opts) {
6386
6856
  for (const file of files) {
6387
6857
  let raw;
6388
6858
  try {
6389
- raw = readFileSync30(join37(root, file), "utf8");
6859
+ raw = readFileSync31(join38(root, file), "utf8");
6390
6860
  } catch {
6391
6861
  continue;
6392
6862
  }
@@ -6442,7 +6912,7 @@ function seo(opts) {
6442
6912
 
6443
6913
  // src/index.ts
6444
6914
  var packageRoot = getPackageRoot();
6445
- var { version } = JSON.parse(readFileSync31(join38(packageRoot, "package.json"), "utf8"));
6915
+ var { version } = JSON.parse(readFileSync32(join39(packageRoot, "package.json"), "utf8"));
6446
6916
  function warnIfUnpublishedPin() {
6447
6917
  if (isPublishedBuild(packageRoot)) return;
6448
6918
  console.warn(
@@ -6592,7 +7062,7 @@ program.command("seo").description("Audit what a search engine and a link previe
6592
7062
  process.exit(1);
6593
7063
  }
6594
7064
  });
6595
- program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").option("--run <page>", "render this page here, at 360, 768 and 1280, and record each (needs --save)").action(async (opts) => {
7065
+ program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").option("--run <page>", "render this page here, at 360, 768, 1280 and 1600, and record each (needs --save)").option("--serve <dir>", "serve this directory over local http and load --run from it, so root-relative links resolve (a built static site)").action(async (opts) => {
6596
7066
  if (opts.run) {
6597
7067
  if (!opts.save) {
6598
7068
  console.error(" \u2717 --run records what it measures, so it needs --save <surface>.");
@@ -6600,7 +7070,7 @@ program.command("probe").description("Print the render probe. With --save, read
6600
7070
  }
6601
7071
  const projectRoot2 = findProjectRoot(process.cwd());
6602
7072
  try {
6603
- for (const saved of await runAndSaveProbes({ projectRoot: projectRoot2, surface: opts.save, page: opts.run })) {
7073
+ for (const saved of await runAndSaveProbes({ projectRoot: projectRoot2, surface: opts.save, page: opts.run, serve: opts.serve })) {
6604
7074
  console.log(` Recorded ${saved.path} \u2014 ${saved.page} at ${saved.width}px.`);
6605
7075
  }
6606
7076
  } catch (err) {
@@ -6634,7 +7104,7 @@ program.command("probe").description("Print the render probe. With --save, read
6634
7104
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
6635
7105
  let input = {};
6636
7106
  try {
6637
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync31(0, "utf8") || "{}");
7107
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync32(0, "utf8") || "{}");
6638
7108
  } catch {
6639
7109
  }
6640
7110
  const cwd = input.cwd ?? process.cwd();
@@ -6673,7 +7143,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
6673
7143
  });
6674
7144
  function resolveSurfaceMode(projectRoot) {
6675
7145
  try {
6676
- const config = JSON.parse(readFileSync31(join38(projectRoot, "jig.config.json"), "utf8"));
7146
+ const config = JSON.parse(readFileSync32(join39(projectRoot, "jig.config.json"), "utf8"));
6677
7147
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
6678
7148
  return modes.length === 1 ? modes[0] : void 0;
6679
7149
  } catch {