jig-ui 0.16.0 → 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 (155) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +7 -6
  3. package/dist/index.js +730 -235
  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/03-patterns.md +6 -6
  152. package/rules/04-principles.md +12 -12
  153. package/rules/05-copy.md +3 -0
  154. package/rules.index.json +101 -0
  155. 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 existsSync20, readFileSync as readFileSync27 } from "fs";
5318
- import { join as join34, 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;
@@ -5348,6 +5684,14 @@ var PROBE_SCRIPT = `(async () => {
5348
5684
  const defaultFont = getComputedStyle(probe).fontFamily;
5349
5685
  probe.remove();
5350
5686
  const text = document.body.innerText || '';
5687
+ // I-118: a verbatim quotation keeps its own punctuation. Text marked as a
5688
+ // quotation is the source's words, so it is taken out before the dash scan;
5689
+ // the page's own copy around it still counts.
5690
+ let ownText = text;
5691
+ for (const q of document.querySelectorAll('blockquote, q')) {
5692
+ const quoted = (q.innerText || '').trim();
5693
+ if (quoted) ownText = ownText.split(quoted).join('\\n');
5694
+ }
5351
5695
  const junk = [...new Set(text.match(/\\$\\{|\\{\\{|\\bundefined\\b|\\bNaN\\b|\\[object Object\\]/g) || [])];
5352
5696
  const unresolved = new Set();
5353
5697
  for (const sheet of document.styleSheets) {
@@ -5458,7 +5802,7 @@ var PROBE_SCRIPT = `(async () => {
5458
5802
  defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
5459
5803
  unresolvedTokens: [...unresolved],
5460
5804
  junkText: junk,
5461
- emDashes: [...new Set((text.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
5805
+ emDashes: [...new Set((ownText.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
5462
5806
  brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
5463
5807
  navLinksVisible: navAtRest,
5464
5808
  head: {
@@ -5480,7 +5824,7 @@ var PROBE_SCRIPT = `(async () => {
5480
5824
 
5481
5825
  // src/probe/browser.ts
5482
5826
  import { spawn } from "child_process";
5483
- 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";
5484
5828
  import { homedir, tmpdir } from "os";
5485
5829
  import { join as join30 } from "path";
5486
5830
  var CHROME_ENV = ["JIG_CHROME", "CHROME_PATH", "PUPPETEER_EXECUTABLE_PATH", "CHROMIUM_PATH"];
@@ -5518,7 +5862,7 @@ function cachedBrowsers() {
5518
5862
  ["chrome-win64", "chrome.exe"]
5519
5863
  ]) {
5520
5864
  const candidate = join30(root, entry, ...rel);
5521
- if (existsSync16(candidate)) found.push(candidate);
5865
+ if (existsSync17(candidate)) found.push(candidate);
5522
5866
  }
5523
5867
  }
5524
5868
  }
@@ -5527,13 +5871,13 @@ function cachedBrowsers() {
5527
5871
  function findChrome() {
5528
5872
  for (const key of CHROME_ENV) {
5529
5873
  const value = process.env[key];
5530
- if (value && existsSync16(value)) return value;
5874
+ if (value && existsSync17(value)) return value;
5531
5875
  }
5532
5876
  for (const command of CHROME_COMMANDS) {
5533
5877
  const resolved = which(command);
5534
5878
  if (resolved) return resolved;
5535
5879
  }
5536
- for (const app of CHROME_APPS) if (existsSync16(app)) return app;
5880
+ for (const app of CHROME_APPS) if (existsSync17(app)) return app;
5537
5881
  return cachedBrowsers()[0];
5538
5882
  }
5539
5883
  function which(command) {
@@ -5541,7 +5885,7 @@ function which(command) {
5541
5885
  for (const dir of dirs) {
5542
5886
  if (!dir) continue;
5543
5887
  const candidate = join30(dir, command);
5544
- if (existsSync16(candidate)) return candidate;
5888
+ if (existsSync17(candidate)) return candidate;
5545
5889
  }
5546
5890
  return void 0;
5547
5891
  }
@@ -5586,8 +5930,13 @@ async function runProbe(opts) {
5586
5930
  });
5587
5931
  return await evaluate(endpoint, opts.url, opts.width, opts.height ?? 900, timeoutMs);
5588
5932
  } finally {
5933
+ const exited = child.exitCode !== null ? Promise.resolve() : new Promise((done) => child.once("exit", () => done()));
5589
5934
  child.kill();
5590
- 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
+ }
5591
5940
  }
5592
5941
  }
5593
5942
  async function evaluate(endpoint, url, width, height, timeoutMs) {
@@ -5646,6 +5995,59 @@ async function evaluate(endpoint, url, width, height, timeoutMs) {
5646
5995
  }
5647
5996
  }
5648
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
+
5649
6051
  // src/probe/save.ts
5650
6052
  function saveProbe(opts) {
5651
6053
  let probe;
@@ -5660,19 +6062,25 @@ function saveProbe(opts) {
5660
6062
  const width = typeof probe.width === "number" ? probe.width : NaN;
5661
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.");
5662
6064
  const url = typeof probe.url === "string" ? probe.url : "";
5663
- const page = pageFile(opts.projectRoot, url);
6065
+ const page = opts.page ? projectFile(opts.projectRoot, opts.page) : pageFile(opts.projectRoot, url);
5664
6066
  if (!page) {
5665
- 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.`);
5666
6068
  }
5667
6069
  probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
5668
- 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"));
5669
6072
  probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
5670
- const dir = join31(opts.projectRoot, ".jig", "critique", opts.surface);
6073
+ const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
5671
6074
  mkdirSync7(dir, { recursive: true });
5672
- const path = join31(dir, `probe-${width}.json`);
6075
+ const path = join32(dir, `probe-${width}.json`);
5673
6076
  writeFileSync6(path, JSON.stringify(probe), "utf8");
5674
6077
  return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
5675
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
+ }
5676
6084
  function pageFile(projectRoot, url) {
5677
6085
  if (!url.startsWith("file://")) return void 0;
5678
6086
  let path;
@@ -5681,34 +6089,48 @@ function pageFile(projectRoot, url) {
5681
6089
  } catch {
5682
6090
  return void 0;
5683
6091
  }
5684
- const abs = resolve3(path);
5685
- if (!abs.startsWith(resolve3(projectRoot))) return void 0;
5686
- 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;
5687
6095
  }
5688
6096
  var PROBE_WIDTHS = [360, 768, 1280, 1600];
5689
6097
  async function runAndSaveProbes(opts) {
5690
- const abs = resolve3(opts.projectRoot, opts.page);
5691
- if (!existsSync17(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
5692
- 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.`);
5693
6100
  const saved = [];
5694
- for (const width of opts.widths ?? PROBE_WIDTHS) {
5695
- const json = await runProbe({ url, width });
5696
- saved.push(saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json }));
5697
- }
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
+ });
5698
6107
  return saved;
5699
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
+ }
5700
6122
  async function ensureProbes(opts) {
5701
- const dir = join31(opts.projectRoot, ".jig", "critique", opts.surface);
5702
- 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);
5703
6125
  let current;
5704
6126
  try {
5705
- current = checksum(readFileSync24(abs, "utf8"));
6127
+ current = checksum(readFileSync25(abs, "utf8"));
5706
6128
  } catch {
5707
6129
  return { recorded: [], reason: `${opts.page} could not be read` };
5708
6130
  }
5709
6131
  const missing = PROBE_WIDTHS.filter((width) => {
5710
6132
  try {
5711
- const probe = JSON.parse(readFileSync24(join31(dir, `probe-${width}.json`), "utf8"));
6133
+ const probe = JSON.parse(readFileSync25(join32(dir, `probe-${width}.json`), "utf8"));
5712
6134
  return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
5713
6135
  } catch {
5714
6136
  return true;
@@ -5716,16 +6138,28 @@ async function ensureProbes(opts) {
5716
6138
  });
5717
6139
  if (missing.length === 0) return { recorded: [] };
5718
6140
  if (!findChrome()) return { recorded: [], reason: "no browser on this machine" };
5719
- const url = /^https?:/i.test(opts.page) ? opts.page : `file://${abs}`;
5720
- for (const width of missing) {
5721
- 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
+ }
5722
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
+ });
5723
6157
  return { recorded: missing };
5724
6158
  }
5725
6159
  function critiquedSurfaces(projectRoot) {
5726
- const root = join31(projectRoot, ".jig", "critique");
5727
- if (!existsSync17(root)) return [];
5728
- 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")));
5729
6163
  }
5730
6164
 
5731
6165
  // src/probe/check.ts
@@ -5733,11 +6167,11 @@ function stampProblem(projectRoot, file, p) {
5733
6167
  if (!p.pageChecksum || !p.pageFile) {
5734
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>\`.`;
5735
6169
  }
5736
- const page = pageFile(projectRoot, p.url ?? "");
5737
- 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"}).`;
5738
6172
  let current;
5739
6173
  try {
5740
- current = readFileSync25(page, "utf8");
6174
+ current = readFileSync26(page, "utf8");
5741
6175
  } catch {
5742
6176
  return `${file} was taken on ${p.pageFile}, which no longer exists.`;
5743
6177
  }
@@ -5747,11 +6181,11 @@ function stampProblem(projectRoot, file, p) {
5747
6181
  return void 0;
5748
6182
  }
5749
6183
  function readProbes(projectRoot, dir, errors) {
5750
- if (!existsSync18(dir)) return [];
6184
+ if (!existsSync20(dir)) return [];
5751
6185
  const probes = [];
5752
6186
  for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
5753
6187
  try {
5754
- const p = JSON.parse(readFileSync25(join32(dir, f), "utf8"));
6188
+ const p = JSON.parse(readFileSync26(join33(dir, f), "utf8"));
5755
6189
  if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
5756
6190
  errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
5757
6191
  continue;
@@ -5843,19 +6277,19 @@ function probeContradictions(probes, verdictOf, indexable = true) {
5843
6277
  }
5844
6278
 
5845
6279
  // src/check/decisions.ts
5846
- import { existsSync as existsSync19, readFileSync as readFileSync26 } from "fs";
5847
- 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";
5848
6282
  var HEADING3 = /^(#{2,3})\s+(.+?)\s*$/gm;
5849
6283
  var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
5850
6284
  function decisionsFile(projectRoot) {
5851
6285
  const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
5852
6286
  try {
5853
- 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;
5854
6288
  if (typeof brand === "string" && brand.includes("/")) candidates.unshift(`${posix3.dirname(brand.replace(/^\.\//, ""))}/DECISIONS.md`);
5855
6289
  } catch {
5856
6290
  }
5857
6291
  for (const candidate of candidates) {
5858
- if (existsSync19(join33(projectRoot, candidate))) return candidate;
6292
+ if (existsSync21(join34(projectRoot, candidate))) return candidate;
5859
6293
  }
5860
6294
  return void 0;
5861
6295
  }
@@ -5864,7 +6298,7 @@ function decisionNames(projectRoot) {
5864
6298
  if (!path) return [];
5865
6299
  let body;
5866
6300
  try {
5867
- body = readFileSync26(join33(projectRoot, path), "utf8");
6301
+ body = readFileSync27(join34(projectRoot, path), "utf8");
5868
6302
  } catch {
5869
6303
  return [];
5870
6304
  }
@@ -5877,35 +6311,120 @@ function decisionNames(projectRoot) {
5877
6311
  return names;
5878
6312
  }
5879
6313
 
6314
+ // src/check/spec-shape.ts
6315
+ import { existsSync as existsSync22, readFileSync as readFileSync28, readdirSync as readdirSync14, statSync as statSync3 } from "fs";
6316
+ import { join as join35 } from "path";
6317
+ function newestSpec(projectRoot) {
6318
+ const dir = join35(projectRoot, ".jig", "specs");
6319
+ if (!existsSync22(dir)) return void 0;
6320
+ const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
6321
+ if (files.length === 0) return void 0;
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") };
6324
+ }
6325
+ function specProblems(spec) {
6326
+ const parts = spec.body.split(/^---\s*$/m);
6327
+ const front = parts.length >= 3 ? parts[1] : "";
6328
+ if (!front.trim()) {
6329
+ return [`${spec.path} has no frontmatter. A spec is the frontmatter \u2014 feature, surface, mode, sizes (phone, tablet, desktop), states, decisions, later, mockup, confirmed \u2014 with the reasoning below it. Rewrite it in that shape.`];
6330
+ }
6331
+ const problems = [];
6332
+ const has = (field) => new RegExp(`^\\s*${field}\\s*:`, "im").test(front);
6333
+ if (specIndexableField(front) === "unreadable") {
6334
+ problems.push(`${spec.path}: \`indexable:\` is neither true nor false. Write \`indexable: true\` or \`indexable: false\`; a per-page override and its reason go in the body, where they can be read without being parsed.`);
6335
+ }
6336
+ for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
6337
+ if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
6338
+ }
6339
+ const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
6340
+ for (const size of ["phone", "tablet", "desktop", "wide"]) {
6341
+ const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
6342
+ if (!line) {
6343
+ problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
6344
+ continue;
6345
+ }
6346
+ if (line[1].trim()) {
6347
+ problems.push(`${spec.path}: \`${size}:\` is a one-line description. A size is a composition \u2014 \`regions:\` in order, \`hierarchy:\`, \`nav:\` \u2014 each on its own line, or \`same-as:\` with a \`why:\`.`);
6348
+ continue;
6349
+ }
6350
+ const block = sizeBlock(sizes, size);
6351
+ if (/^\s*same-as\s*:/im.test(block)) {
6352
+ if (!/^\s*why\s*:/im.test(block)) problems.push(`${spec.path}: \`${size}\` claims \`same-as:\` with no \`why:\`. The claim is checked on a render, so it says why it holds.`);
6353
+ continue;
6354
+ }
6355
+ for (const field of ["regions", "nav"]) {
6356
+ if (!new RegExp(`^\\s*${field}\\s*:`, "im").test(block)) {
6357
+ problems.push(`${spec.path}: \`${size}\` has no \`${field}:\`. Every size names its regions in order and what its navigation is at that width (\`nav: none\` when the screen has none).`);
6358
+ }
6359
+ }
6360
+ }
6361
+ return problems;
6362
+ }
6363
+ function sizeBlock(sizes, size) {
6364
+ const start = new RegExp(`^\\s+${size}\\s*:.*$`, "im").exec(sizes);
6365
+ if (!start) return "";
6366
+ const rest = sizes.slice(start.index + start[0].length);
6367
+ const indent = /^\s*/.exec(start[0])[0].length;
6368
+ const lines = [];
6369
+ for (const line of rest.split("\n")) {
6370
+ if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
6371
+ lines.push(line);
6372
+ }
6373
+ return lines.join("\n");
6374
+ }
6375
+ var MENU_RE = /(?<!\b(?:no|not|without|never)\s)(?<!\bno\s\w{1,12}\s)\b(menu|hamburger|drawer|burger)\b/i;
6376
+ var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
6377
+ function navProblems(spec) {
6378
+ const front = spec.body.split(/^---\s*$/m)[1] ?? "";
6379
+ const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
6380
+ const problems = [];
6381
+ for (const size of ["tablet", "landscape", "desktop", "wide"]) {
6382
+ const block = sizeBlock(sizes, size);
6383
+ const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
6384
+ if (!nav || NONE_RE.test(nav)) continue;
6385
+ if (MENU_RE.test(nav) && !/\bopen\b|\bexpanded\b/i.test(nav)) {
6386
+ problems.push(`${spec.path}: \`${size}\` has \`nav: ${nav}\`. Run P-14's table at that width \u2014 where every destination fits, the links show and there is no menu button. A decision about where the button sits is about position, not whether it exists.`);
6387
+ }
6388
+ }
6389
+ return problems;
6390
+ }
6391
+ function specIndexableField(front) {
6392
+ const line = /^\s*indexable\s*:(.*)$/im.exec(front);
6393
+ if (!line) return "absent";
6394
+ const value = line[1].trim().replace(/^["']|["']$/g, "").toLowerCase();
6395
+ if (value === "true" || value === "yes") return true;
6396
+ if (value === "false" || value === "no") return false;
6397
+ return "unreadable";
6398
+ }
6399
+
5880
6400
  // src/commands/verdicts.ts
5881
6401
  var VERDICTS = ["ok", "finding", "n/a"];
5882
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;
5883
6403
  function readJson(path, errors) {
5884
- if (!existsSync20(path)) return null;
6404
+ if (!existsSync23(path)) return null;
5885
6405
  try {
5886
- return JSON.parse(readFileSync27(path, "utf8"));
6406
+ return JSON.parse(readFileSync29(path, "utf8"));
5887
6407
  } catch (e) {
5888
6408
  errors.push(`${path}: not valid JSON (${e.message})`);
5889
6409
  return {};
5890
6410
  }
5891
6411
  }
5892
6412
  function specIndexable(projectRoot, surface) {
5893
- const path = join34(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6413
+ const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5894
6414
  let front = "";
5895
6415
  try {
5896
- front = readFileSync27(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6416
+ front = readFileSync29(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5897
6417
  } catch {
5898
6418
  }
5899
- const declared = /^\s*indexable\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase();
5900
- if (declared === "true" || declared === "yes") return true;
5901
- if (declared === "false" || declared === "no") return false;
6419
+ const declared = specIndexableField(front);
6420
+ if (declared === true || declared === false) return declared;
5902
6421
  const mode = /^\s*mode\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase();
5903
6422
  return mode !== "product" && mode !== "operator";
5904
6423
  }
5905
6424
  function specNeedsNav(projectRoot, surface) {
5906
- const path = join34(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5907
- if (!existsSync20(path)) return false;
5908
- const front = readFileSync27(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] ?? "";
5909
6428
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
5910
6429
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
5911
6430
  return navField || navRegion;
@@ -5956,7 +6475,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
5956
6475
  function checkDecisions(projectRoot, dir, errors) {
5957
6476
  const required = decisionNames(projectRoot);
5958
6477
  if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
5959
- const file = readJson(join34(dir, "decisions.json"), errors);
6478
+ const file = readJson(join36(dir, "decisions.json"), errors);
5960
6479
  if (!file) {
5961
6480
  errors.push(
5962
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.`
@@ -6001,7 +6520,7 @@ function checkDecisions(projectRoot, dir, errors) {
6001
6520
  function verifyVerdicts(opts) {
6002
6521
  const root = opts.packageRoot ?? assetRoot();
6003
6522
  const errors = [];
6004
- const index = JSON.parse(readFileSync27(join34(root, "rules.index.json"), "utf8"));
6523
+ const index = JSON.parse(readFileSync29(join36(root, "rules.index.json"), "utf8"));
6005
6524
  const judgment = index.filter((r) => r.bucket === "judgment");
6006
6525
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
6007
6526
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
@@ -6010,16 +6529,16 @@ function verifyVerdicts(opts) {
6010
6529
  ...judgment.map((r) => [r.id, r.pass ?? ""])
6011
6530
  ]);
6012
6531
  const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
6013
- const dir = join34(opts.projectRoot, ".jig", "critique", opts.surface);
6014
- const screenFile = readJson(join34(dir, "screen.json"), errors);
6015
- const codeFile = readJson(join34(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);
6016
6535
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
6017
6536
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
6018
6537
  const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
6019
6538
  let rendered = false;
6020
6539
  if (screenFile && screenFile.rendered === true) {
6021
6540
  const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
6022
- const absent = artefacts.filter((a) => !existsSync20(resolve4(opts.projectRoot, a)));
6541
+ const absent = artefacts.filter((a) => !existsSync23(resolve5(opts.projectRoot, a)));
6023
6542
  if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
6024
6543
  else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
6025
6544
  else rendered = true;
@@ -6038,7 +6557,16 @@ function verifyVerdicts(opts) {
6038
6557
  const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
6039
6558
  return typeof v?.verdict === "string" ? v.verdict : void 0;
6040
6559
  };
6041
- errors.push(...probeContradictions(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface)));
6560
+ let specFront = "";
6561
+ try {
6562
+ specFront = readFileSync29(join36(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
6563
+ } catch {
6564
+ }
6565
+ if (specIndexableField(specFront) === "unreadable") {
6566
+ errors.push(`.jig/specs/${opts.surface}.spec.md: \`indexable:\` is neither true nor false, so this review cannot tell whether the page is meant to be found and does not guess. Write \`indexable: true\` or \`indexable: false\`, and put the reason in the spec's body.`);
6567
+ } else {
6568
+ errors.push(...probeContradictions(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface)));
6569
+ }
6042
6570
  if (screen.state === "ran" && !rendered) screen.state = "skipped";
6043
6571
  const decisions = checkDecisions(opts.projectRoot, dir, errors);
6044
6572
  const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
@@ -6047,92 +6575,15 @@ function verifyVerdicts(opts) {
6047
6575
  }
6048
6576
 
6049
6577
  // src/commands/gate.ts
6050
- 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";
6051
6579
  import { createHash as createHash2 } from "crypto";
6052
- import { join as join36 } from "path";
6053
-
6054
- // src/check/spec-shape.ts
6055
- import { existsSync as existsSync21, readFileSync as readFileSync28, readdirSync as readdirSync14, statSync as statSync2 } from "fs";
6056
- import { join as join35 } from "path";
6057
- function newestSpec(projectRoot) {
6058
- const dir = join35(projectRoot, ".jig", "specs");
6059
- if (!existsSync21(dir)) return void 0;
6060
- const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
6061
- if (files.length === 0) return void 0;
6062
- const newest = files.map((f) => ({ f, at: statSync2(join35(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6063
- return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync28(join35(dir, newest), "utf8") };
6064
- }
6065
- function specProblems(spec) {
6066
- const parts = spec.body.split(/^---\s*$/m);
6067
- const front = parts.length >= 3 ? parts[1] : "";
6068
- if (!front.trim()) {
6069
- return [`${spec.path} has no frontmatter. A spec is the frontmatter \u2014 feature, surface, mode, sizes (phone, tablet, desktop), states, decisions, later, mockup, confirmed \u2014 with the reasoning below it. Rewrite it in that shape.`];
6070
- }
6071
- const problems = [];
6072
- const has = (field) => new RegExp(`^\\s*${field}\\s*:`, "im").test(front);
6073
- for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
6074
- if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
6075
- }
6076
- const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
6077
- for (const size of ["phone", "tablet", "desktop", "wide"]) {
6078
- const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
6079
- if (!line) {
6080
- problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
6081
- continue;
6082
- }
6083
- if (line[1].trim()) {
6084
- problems.push(`${spec.path}: \`${size}:\` is a one-line description. A size is a composition \u2014 \`regions:\` in order, \`hierarchy:\`, \`nav:\` \u2014 each on its own line, or \`same-as:\` with a \`why:\`.`);
6085
- continue;
6086
- }
6087
- const block = sizeBlock(sizes, size);
6088
- if (/^\s*same-as\s*:/im.test(block)) {
6089
- if (!/^\s*why\s*:/im.test(block)) problems.push(`${spec.path}: \`${size}\` claims \`same-as:\` with no \`why:\`. The claim is checked on a render, so it says why it holds.`);
6090
- continue;
6091
- }
6092
- for (const field of ["regions", "nav"]) {
6093
- if (!new RegExp(`^\\s*${field}\\s*:`, "im").test(block)) {
6094
- problems.push(`${spec.path}: \`${size}\` has no \`${field}:\`. Every size names its regions in order and what its navigation is at that width (\`nav: none\` when the screen has none).`);
6095
- }
6096
- }
6097
- }
6098
- return problems;
6099
- }
6100
- function sizeBlock(sizes, size) {
6101
- const start = new RegExp(`^\\s+${size}\\s*:.*$`, "im").exec(sizes);
6102
- if (!start) return "";
6103
- const rest = sizes.slice(start.index + start[0].length);
6104
- const indent = /^\s*/.exec(start[0])[0].length;
6105
- const lines = [];
6106
- for (const line of rest.split("\n")) {
6107
- if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
6108
- lines.push(line);
6109
- }
6110
- return lines.join("\n");
6111
- }
6112
- var MENU_RE = /(?<!\b(?:no|not|without|never)\s)(?<!\bno\s\w{1,12}\s)\b(menu|hamburger|drawer|burger)\b/i;
6113
- var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
6114
- function navProblems(spec) {
6115
- const front = spec.body.split(/^---\s*$/m)[1] ?? "";
6116
- const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
6117
- const problems = [];
6118
- for (const size of ["tablet", "landscape", "desktop", "wide"]) {
6119
- const block = sizeBlock(sizes, size);
6120
- const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
6121
- if (!nav || NONE_RE.test(nav)) continue;
6122
- if (MENU_RE.test(nav) && !/\bopen\b|\bexpanded\b/i.test(nav)) {
6123
- problems.push(`${spec.path}: \`${size}\` has \`nav: ${nav}\`. Run P-14's table at that width \u2014 where every destination fits, the links show and there is no menu button. A decision about where the button sits is about position, not whether it exists.`);
6124
- }
6125
- }
6126
- return problems;
6127
- }
6128
-
6129
- // src/commands/gate.ts
6580
+ import { join as join37 } from "path";
6130
6581
  var MAX_BLOCKS = 3;
6131
6582
  function lastJigCommand(transcriptPath) {
6132
- if (!transcriptPath || !existsSync22(transcriptPath)) return void 0;
6583
+ if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6133
6584
  let text;
6134
6585
  try {
6135
- text = readFileSync29(transcriptPath, "utf8");
6586
+ text = readFileSync30(transcriptPath, "utf8");
6136
6587
  } catch {
6137
6588
  return void 0;
6138
6589
  }
@@ -6147,10 +6598,10 @@ function lastJigCommand(transcriptPath) {
6147
6598
  return found;
6148
6599
  }
6149
6600
  function lastAssistantText(transcriptPath) {
6150
- if (!transcriptPath || !existsSync22(transcriptPath)) return void 0;
6601
+ if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6151
6602
  let found;
6152
6603
  try {
6153
- for (const line of readFileSync29(transcriptPath, "utf8").split("\n")) {
6604
+ for (const line of readFileSync30(transcriptPath, "utf8").split("\n")) {
6154
6605
  if (!line.includes('"assistant"')) continue;
6155
6606
  let entry;
6156
6607
  try {
@@ -6180,7 +6631,7 @@ function commandProblems(root, command) {
6180
6631
  const found = decisionsFile(root);
6181
6632
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
6182
6633
  else {
6183
- const body = readFileSync29(join36(root, found), "utf8");
6634
+ const body = readFileSync30(join37(root, found), "utf8");
6184
6635
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
6185
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.`");
6186
6637
  }
@@ -6201,13 +6652,13 @@ function commandProblems(root, command) {
6201
6652
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
6202
6653
  if (/^approved/i.test(mockup)) {
6203
6654
  if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
6204
- 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.`);
6205
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.`);
6206
6657
  }
6207
6658
  }
6208
6659
  if (command === "critique") {
6209
- const dir = join36(root, ".jig", "critique");
6210
- 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"))) : [];
6211
6662
  for (const surface of surfaces) {
6212
6663
  const v = verifyVerdicts({ projectRoot: root, surface });
6213
6664
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
@@ -6229,13 +6680,13 @@ function surfacePage(projectRoot, surface) {
6229
6680
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
6230
6681
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
6231
6682
  for (const candidate of candidates) {
6232
- if (candidate && existsSync22(join36(projectRoot, candidate))) return candidate;
6683
+ if (candidate && existsSync24(join37(projectRoot, candidate))) return candidate;
6233
6684
  }
6234
6685
  return void 0;
6235
6686
  }
6236
6687
  function gate(opts) {
6237
6688
  const root = opts.projectRoot;
6238
- if (!existsSync22(join36(root, "jig.config.json")) && !existsSync22(join36(root, ".jig"))) {
6689
+ if (!existsSync24(join37(root, "jig.config.json")) && !existsSync24(join37(root, ".jig"))) {
6239
6690
  return { block: false, reason: "" };
6240
6691
  }
6241
6692
  const command = lastJigCommand(opts.input.transcript_path);
@@ -6261,11 +6712,12 @@ function gate(opts) {
6261
6712
  );
6262
6713
  }
6263
6714
  }
6264
- const critiqueDir = join36(root, ".jig", "critique");
6265
- if (existsSync22(critiqueDir)) {
6715
+ problems.push(...verdictGuard(root, command));
6716
+ const critiqueDir = join37(root, ".jig", "critique");
6717
+ if (existsSync24(critiqueDir)) {
6266
6718
  for (const surface of readdirSync15(critiqueDir)) {
6267
- const dir = join36(critiqueDir, surface);
6268
- 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;
6269
6721
  const v = verifyVerdicts({ projectRoot: root, surface });
6270
6722
  if (!v.ok) {
6271
6723
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -6279,10 +6731,10 @@ function gate(opts) {
6279
6731
  }
6280
6732
  const session = opts.input.session_id ?? "unknown";
6281
6733
  const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
6282
- const stateFile = join36(root, ".jig", "gate.json");
6734
+ const stateFile = join37(root, ".jig", "gate.json");
6283
6735
  let state = {};
6284
6736
  try {
6285
- state = JSON.parse(readFileSync29(stateFile, "utf8"));
6737
+ state = JSON.parse(readFileSync30(stateFile, "utf8"));
6286
6738
  } catch {
6287
6739
  }
6288
6740
  if (problems.length === 0) {
@@ -6310,17 +6762,60 @@ ${problems.join("\n\n")}`
6310
6762
  ${problems.join("\n\n")}`
6311
6763
  };
6312
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
+ }
6313
6808
  function save(file, state) {
6314
6809
  try {
6315
- mkdirSync8(join36(file, ".."), { recursive: true });
6810
+ mkdirSync8(join37(file, ".."), { recursive: true });
6316
6811
  writeFileSync7(file, JSON.stringify(state), "utf8");
6317
6812
  } catch {
6318
6813
  }
6319
6814
  }
6320
6815
 
6321
6816
  // src/commands/seo.ts
6322
- import { readFileSync as readFileSync30 } from "fs";
6323
- import { join as join37 } from "path";
6817
+ import { readFileSync as readFileSync31 } from "fs";
6818
+ import { join as join38 } from "path";
6324
6819
  var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
6325
6820
  var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
6326
6821
  var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
@@ -6361,7 +6856,7 @@ function seo(opts) {
6361
6856
  for (const file of files) {
6362
6857
  let raw;
6363
6858
  try {
6364
- raw = readFileSync30(join37(root, file), "utf8");
6859
+ raw = readFileSync31(join38(root, file), "utf8");
6365
6860
  } catch {
6366
6861
  continue;
6367
6862
  }
@@ -6417,7 +6912,7 @@ function seo(opts) {
6417
6912
 
6418
6913
  // src/index.ts
6419
6914
  var packageRoot = getPackageRoot();
6420
- var { version } = JSON.parse(readFileSync31(join38(packageRoot, "package.json"), "utf8"));
6915
+ var { version } = JSON.parse(readFileSync32(join39(packageRoot, "package.json"), "utf8"));
6421
6916
  function warnIfUnpublishedPin() {
6422
6917
  if (isPublishedBuild(packageRoot)) return;
6423
6918
  console.warn(
@@ -6567,7 +7062,7 @@ program.command("seo").description("Audit what a search engine and a link previe
6567
7062
  process.exit(1);
6568
7063
  }
6569
7064
  });
6570
- 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) => {
6571
7066
  if (opts.run) {
6572
7067
  if (!opts.save) {
6573
7068
  console.error(" \u2717 --run records what it measures, so it needs --save <surface>.");
@@ -6575,7 +7070,7 @@ program.command("probe").description("Print the render probe. With --save, read
6575
7070
  }
6576
7071
  const projectRoot2 = findProjectRoot(process.cwd());
6577
7072
  try {
6578
- 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 })) {
6579
7074
  console.log(` Recorded ${saved.path} \u2014 ${saved.page} at ${saved.width}px.`);
6580
7075
  }
6581
7076
  } catch (err) {
@@ -6609,7 +7104,7 @@ program.command("probe").description("Print the render probe. With --save, read
6609
7104
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
6610
7105
  let input = {};
6611
7106
  try {
6612
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync31(0, "utf8") || "{}");
7107
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync32(0, "utf8") || "{}");
6613
7108
  } catch {
6614
7109
  }
6615
7110
  const cwd = input.cwd ?? process.cwd();
@@ -6648,7 +7143,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
6648
7143
  });
6649
7144
  function resolveSurfaceMode(projectRoot) {
6650
7145
  try {
6651
- const config = JSON.parse(readFileSync31(join38(projectRoot, "jig.config.json"), "utf8"));
7146
+ const config = JSON.parse(readFileSync32(join39(projectRoot, "jig.config.json"), "utf8"));
6652
7147
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
6653
7148
  return modes.length === 1 ? modes[0] : void 0;
6654
7149
  } catch {