jig-ui 0.11.0 → 0.13.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ Meaning before presentation, and one fewer step anyone can skip.
6
+
7
+ ### Added
8
+
9
+ - **`H-119` A generic element where a native one says what the content is.**
10
+ The rule carries a decision order: what is this content, is there a native
11
+ element whose meaning is that, does it describe it accurately, does order or
12
+ relationship matter, can CSS do the presentation, and only then a generic
13
+ container. It is not a rule against `div` — a `div` is right where nothing
14
+ more specific is true, and an approximate element is worse than a generic
15
+ one because it asserts something untrue. Presentation must not be required
16
+ to understand the content.
17
+ Its detector decides what the source can: a page with no `<main>`, a row of
18
+ destinations in a header or footer with no `<nav>`, a generic element named
19
+ or styled as a heading, and a repeated set on a page with no list or table.
20
+ - **`jig probe --run <page> --save <surface>`.** The CLI renders the page
21
+ itself, at 360, 768 and 1280, in a headless Chrome, Chromium or Edge it
22
+ finds for itself — including the browsers Playwright or Puppeteer have
23
+ already downloaded. No dependency: Chrome's own debugging protocol over the
24
+ WebSocket client Node has had since 22. The Stop hook runs it before judging
25
+ a critique, so a review is measured whether or not anyone remembered to.
26
+ - **The probe measures markup order against reading order.** A block the CSS
27
+ lifts above the one that precedes it in the markup is reported, whatever the
28
+ verdicts say. Two columns side by side are not an inversion: putting the
29
+ sidebar after the main content and moving it left is the correct pattern.
30
+ - **The probe reads the rendered text for em dashes.** A string built in code —
31
+ a description assembled in a framework's frontmatter, a label written by a
32
+ script — reaches the page having passed no file check.
33
+
34
+ ### Changed
35
+
36
+ - **`I-118` reads every place a reader sees text.** Every template language the
37
+ suite knows, and the markdown a framework renders as pages. Repository
38
+ documents are still exempt, and capitals now mean "document" only beside the
39
+ lockfile, so `docs/FAQ.md` is a page. Code is not copy: a script's text is
40
+ read only between real tags.
41
+ - **The scan skips `.claude`, `.codex`, `.cursor`, `.opencode`, `.gemini` and
42
+ `.github`.** Jig's own vendored rules are not the project's interface.
43
+ - **Probe files are version 3.** Older ones no longer validate.
44
+
45
+ ## 0.12.0
46
+
47
+ ### Added
48
+
49
+ - **`I-118` Em dashes in interface text.** Use a full stop, a comma, a colon
50
+ or a second element. The mark is ambiguous on its own: it stands in for
51
+ four different ones, and the reader only learns which after reading past
52
+ it. It is also the clearest tell of machine-written copy, and copy that
53
+ reads as generated is copy the reader trusts less. The `em-dash` detector
54
+ reads element text and the attributes a reader hears or sees; scripts,
55
+ styles, comments, class names and documentation are not interface text.
56
+ The en dash keeps its one job, a range read as "to" (`2-10 seats`).
57
+
58
+ ## 0.11.1
59
+
60
+ Two holes in the gate, both found by running the loop twice on 0.11.0 —
61
+ once on Haiku, once on Sonnet.
62
+
63
+ ### Fixed
64
+
65
+ - **The Stop hook's block budget is per failure, not per session.** A
66
+ headless session keeps one session id across every `/jig` step, so a run
67
+ that spent its three blocks on `init`, `spec` and `make` reached
68
+ `critique` with none left: it reported "complete and approved, zero
69
+ findings" having written no verdict files, and the gate that would have
70
+ caught it had already let go. Fixing a failure now returns its attempts,
71
+ a new failure starts fresh, and an agent that cannot fix a given failure
72
+ still gets out after three tries.
73
+ - **`nav: horizontal bar (no menu)` is no longer read as a menu button**
74
+ at a width where the links fit. A false positive is how a gate gets
75
+ ignored.
76
+
3
77
  ## 0.11.0
4
78
 
5
79
  One release, one lesson: a measurement an agent can type is not a
package/README.md CHANGED
@@ -12,13 +12,13 @@ Installed as `npx jig-ui` — the bare name was taken on npm.
12
12
  Jig is **a skill your coding agent reads**, and **a CLI you can run yourself**.
13
13
  They are two halves of the same thing, and the split is not arbitrary:
14
14
 
15
- - Of the 113 rules, **16 can be decided by a machine** — a hard-coded colour, a
15
+ - Of the 115 rules, **18 can be decided by a machine** — a hard-coded colour, a
16
16
  contrast ratio below the floor, a removed focus ring. The CLI decides those.
17
17
  - The other **97 are judgment** — whether an empty state says anything useful,
18
18
  whether a label reads as an instruction, whether motion earns its place. No
19
19
  regex settles those. An agent reads the rules and applies them.
20
20
 
21
- Running only the CLI gets you the 16. Running only the agent gets you the 97 with
21
+ Running only the CLI gets you the 18. Running only the agent gets you the 97 with
22
22
  no verification. **A clean `jig check` is not a clean review**, and the skill
23
23
  says so to every agent that reads it.
24
24
 
@@ -371,7 +371,7 @@ treatment.
371
371
 
372
372
  | File | Contents |
373
373
  | --- | --- |
374
- | `rules/00-anti-patterns.md` | 96 universal rules with corrections |
374
+ | `rules/00-anti-patterns.md` | 97 universal rules with corrections |
375
375
  | `rules/01-modes.md` | `editorial` / `product` / `operator` profiles |
376
376
  | `rules/02-tokens.md` | Token contract, naming, consumption |
377
377
  | `rules/03-patterns.md` | Component anatomy and behaviour |
package/dist/index.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
5
  import { readFileSync as readFileSync28 } from "fs";
6
- import { homedir } from "os";
7
- import { join as join34 } from "path";
6
+ import { homedir as homedir2 } from "os";
7
+ import { join as join35 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -1472,7 +1472,17 @@ var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
1472
1472
  "out",
1473
1473
  "coverage",
1474
1474
  ".svelte-kit",
1475
- "vendor"
1475
+ "vendor",
1476
+ // Where the agent harnesses keep their own files, including the copy of
1477
+ // Jig's rules and slash command that `install` vendors. Those are the
1478
+ // system's own text, not the project's interface: scanning them reported
1479
+ // 115 em dashes in Jig's command file and called them the user's copy.
1480
+ ".claude",
1481
+ ".codex",
1482
+ ".cursor",
1483
+ ".opencode",
1484
+ ".gemini",
1485
+ ".github"
1476
1486
  ]);
1477
1487
  function isGitRepo(root) {
1478
1488
  try {
@@ -1796,6 +1806,35 @@ var STYLE_HOST_EXTENSIONS = [
1796
1806
  function isStyleBearing(file) {
1797
1807
  return hasExtension(file, CSS_EXTENSIONS) || hasExtension(file, STYLE_HOST_EXTENSIONS);
1798
1808
  }
1809
+ var MARKDOWN_EXTENSIONS = [".md", ".markdown", ".mdx"];
1810
+ var REPO_DOCUMENTS = /* @__PURE__ */ new Set([
1811
+ "readme",
1812
+ "changelog",
1813
+ "contributing",
1814
+ "license",
1815
+ "licence",
1816
+ "notice",
1817
+ "code_of_conduct",
1818
+ "security",
1819
+ "support",
1820
+ "governance",
1821
+ "maintainers",
1822
+ "authors",
1823
+ "agents",
1824
+ "claude",
1825
+ "gemini",
1826
+ "copilot-instructions"
1827
+ ]);
1828
+ function isRepoDocument(file) {
1829
+ const base = file.split("/").pop() ?? file;
1830
+ const name = base.replace(/\.[^.]+$/, "");
1831
+ if (REPO_DOCUMENTS.has(name.toLowerCase())) return true;
1832
+ return !file.includes("/") && /^[A-Z0-9][A-Z0-9._-]*$/.test(name);
1833
+ }
1834
+ function isReaderText(file) {
1835
+ if (hasExtension(file, MARKDOWN_EXTENSIONS)) return !isRepoDocument(file);
1836
+ return hasExtension(file, STYLE_HOST_EXTENSIONS);
1837
+ }
1799
1838
 
1800
1839
  // src/check/run.ts
1801
1840
  import { readFileSync as readFileSync13 } from "fs";
@@ -3299,6 +3338,141 @@ var undeclaredToken = {
3299
3338
  }
3300
3339
  };
3301
3340
 
3341
+ // src/check/detectors/em-dash.ts
3342
+ var MARKDOWN = /\.(md|markdown|mdx)$/i;
3343
+ var INDENTED = /\.(pug|jade|haml|slim)$/i;
3344
+ var SCRIPT = /\.(js|mjs|cjs|ts|mts|cts)$/i;
3345
+ var CODEY = /[;{}`]|\$\{|=>/;
3346
+ var TEXT_ATTRS = /\b(aria-label|aria-description|aria-placeholder|title|placeholder|alt|label|value|content)\s*=\s*("([^"]*)"|'([^']*)')/gi;
3347
+ var EM_DASH = /—/;
3348
+ var TAG = /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>/g;
3349
+ function maskNonProse(source) {
3350
+ const blank3 = (m) => " ".repeat(m.length);
3351
+ return source.replace(/^---\n[\s\S]*?\n---/, blank3).replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
3352
+ }
3353
+ function markdownProse(source) {
3354
+ const blank3 = (m) => " ".repeat(m.length);
3355
+ return source.replace(/^ {0,3}(`{3,}|~{3,})[\s\S]*?^ {0,3}\1[^\n]*$/gm, blank3).replace(/`[^`\n]*`/g, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\]\([^)\s]*/g, blank3);
3356
+ }
3357
+ function proseSpans(masked, edges) {
3358
+ const spans = [];
3359
+ const tags = [...masked.matchAll(TAG)];
3360
+ let last = 0;
3361
+ tags.forEach((tag, i) => {
3362
+ const between = masked.slice(last, tag.index);
3363
+ if (between.trim() && (edges || i > 0)) spans.push({ index: last, text: between });
3364
+ for (const attr of tag[0].matchAll(TEXT_ATTRS)) {
3365
+ const value = attr[3] ?? attr[4] ?? "";
3366
+ spans.push({ index: tag.index + attr.index + attr[0].indexOf(value), text: value });
3367
+ }
3368
+ last = tag.index + tag[0].length;
3369
+ });
3370
+ const tail = masked.slice(last);
3371
+ if (tail.trim() && edges && tags.length > 0) spans.push({ index: last, text: tail });
3372
+ return spans;
3373
+ }
3374
+ function indentedProse(source) {
3375
+ const blank3 = (m) => " ".repeat(m.length);
3376
+ return source.split("\n").map((line) => /^\s*(-|\/\/|#(?!\w)|=)/.test(line) ? blank3(line) : line).join("\n");
3377
+ }
3378
+ var emDash = {
3379
+ name: "em-dash",
3380
+ appliesTo: (file) => isReaderText(file),
3381
+ run(_source, file, ctx) {
3382
+ const markdown = MARKDOWN.test(file);
3383
+ const indented = INDENTED.test(file);
3384
+ const masked = markdown ? markdownProse(ctx.raw) : indented ? indentedProse(maskNonProse(ctx.raw)) : maskNonProse(ctx.raw);
3385
+ const starts = buildLineIndex(ctx.raw);
3386
+ const findings = [];
3387
+ const seen = /* @__PURE__ */ new Set();
3388
+ const script = SCRIPT.test(file);
3389
+ const spans = markdown || indented ? [{ index: 0, text: masked }] : proseSpans(masked, !script).filter((span) => !script || !CODEY.test(span.text));
3390
+ for (const span of spans) {
3391
+ if (!EM_DASH.test(span.text)) continue;
3392
+ for (const hit of span.text.matchAll(/\u2014/g)) {
3393
+ const line = lineForOffset(starts, span.index + hit.index);
3394
+ if (seen.has(line)) continue;
3395
+ seen.add(line);
3396
+ findings.push(
3397
+ mkFinding(
3398
+ ctx,
3399
+ "em-dash",
3400
+ file,
3401
+ line,
3402
+ "an em dash in interface text: use a full stop, a comma, a colon, or a second element",
3403
+ sourceLine(ctx.raw, line)
3404
+ )
3405
+ );
3406
+ }
3407
+ }
3408
+ return findings.sort((a, b) => a.line - b.line);
3409
+ }
3410
+ };
3411
+
3412
+ // src/check/detectors/semantic-element.ts
3413
+ var BODY = /<body\b/i;
3414
+ var MAIN = /<main\b|role\s*=\s*["']main["']/i;
3415
+ var NAV_ELEMENT = /<nav\b|role\s*=\s*["']navigation["']/i;
3416
+ var CHROME = /<(header|footer)\b[\s\S]*?<\/\1\s*>/gi;
3417
+ var NAV_CONTAINER = /<(div|section|ul|span)\b[^>]*\b(?:class|className|id)\s*=\s*["'][^"']*\b(?:nav|navigation|navbar|menu)\b[^"']*["'][^>]*>/i;
3418
+ var LINK = /<a\b[^>]*\bhref\b/gi;
3419
+ var HEADING_SHAPED = /<(div|span|p)\b[^>]*\b(?:class|className)\s*=\s*["']([^"']*\b(?:title|heading|headline|text-(?:2xl|3xl|4xl|5xl))\b[^"']*)["'][^>]*>([\s\S]{0,120}?)<\/\1\s*>/gi;
3420
+ var HEADING_INSIDE = /<h[1-6]\b/i;
3421
+ var REPEATED = /<(div|article|section|a)\b[^>]*\b(?:class|className)\s*=\s*["']([^"']+)["']/gi;
3422
+ var LIST_OR_TABLE = /<(ul|ol|dl|table|tbody|menu)\b/i;
3423
+ var semanticElement = {
3424
+ name: "semantic-element",
3425
+ appliesTo: (file) => isReaderText(file),
3426
+ run(_source, file, ctx) {
3427
+ const raw = ctx.raw;
3428
+ if (!BODY.test(raw)) return [];
3429
+ const starts = buildLineIndex(raw);
3430
+ const findings = [];
3431
+ const at = (offset, message) => {
3432
+ const line = lineForOffset(starts, offset);
3433
+ findings.push(mkFinding(ctx, "semantic-element", file, line, message, sourceLine(raw, line)));
3434
+ };
3435
+ if (!MAIN.test(raw)) {
3436
+ at(
3437
+ BODY.exec(raw).index,
3438
+ "this page has no <main> \u2014 nothing tells a reader skipping the chrome where the page itself starts"
3439
+ );
3440
+ }
3441
+ if (!NAV_ELEMENT.test(raw)) {
3442
+ const container = NAV_CONTAINER.exec(raw);
3443
+ const links = (raw.match(LINK) ?? []).length;
3444
+ if (container && links >= 3) {
3445
+ at(container.index, "navigation links in a generic container, with no <nav> on the page \u2014 the element is what makes it a landmark, not the class name");
3446
+ } else {
3447
+ for (const region of raw.matchAll(CHROME)) {
3448
+ if ((region[0].match(LINK) ?? []).length < 3) continue;
3449
+ at(region.index, `the ${region[1].toLowerCase()} holds a row of links and the page has no <nav> \u2014 a set of destinations is a navigation landmark, and the element is what makes it one`);
3450
+ break;
3451
+ }
3452
+ }
3453
+ }
3454
+ for (const m of raw.matchAll(HEADING_SHAPED)) {
3455
+ if (HEADING_INSIDE.test(m[3])) continue;
3456
+ const text = m[3].replace(/<[^>]*>/g, "").trim();
3457
+ if (!text || text.length > 80) continue;
3458
+ at(m.index, `"${text.slice(0, 40)}" is a ${m[1].toLowerCase()} named and styled as a heading \u2014 if it is the heading, it is an h1-h6, and CSS gives it the size`);
3459
+ }
3460
+ if (!LIST_OR_TABLE.test(raw)) {
3461
+ const counts = /* @__PURE__ */ new Map();
3462
+ for (const m of raw.matchAll(REPEATED)) {
3463
+ const key = `${m[1].toLowerCase()}.${m[2].trim()}`;
3464
+ const seen = counts.get(key) ?? { n: 0, index: m.index };
3465
+ counts.set(key, { n: seen.n + 1, index: seen.index });
3466
+ }
3467
+ const repeated = [...counts.entries()].filter(([, v]) => v.n >= 3).sort((a, b) => a[1].index - b[1].index)[0];
3468
+ if (repeated) {
3469
+ at(repeated[1].index, `${repeated[1].n} sibling ${repeated[0]} elements are a repeated set, and nothing on this page is a list or a table \u2014 decide which collection this is (ul, ol, dl, table) before using a generic container`);
3470
+ }
3471
+ }
3472
+ return findings;
3473
+ }
3474
+ };
3475
+
3302
3476
  // src/check/registry.ts
3303
3477
  var DETECTORS = [
3304
3478
  gradientText,
@@ -3316,7 +3490,9 @@ var DETECTORS = [
3316
3490
  inputZoom,
3317
3491
  safeArea,
3318
3492
  menuState,
3319
- undeclaredToken
3493
+ undeclaredToken,
3494
+ emDash,
3495
+ semanticElement
3320
3496
  ];
3321
3497
  function getDetector(name) {
3322
3498
  return DETECTORS.find((d) => d.name === name);
@@ -4880,8 +5056,8 @@ Could not write to ${wireTarget}: ${err.message}`);
4880
5056
  }
4881
5057
 
4882
5058
  // src/commands/verdicts.ts
4883
- import { existsSync as existsSync18, readFileSync as readFileSync25 } from "fs";
4884
- import { join as join31, resolve as resolve4 } from "path";
5059
+ import { existsSync as existsSync19, readFileSync as readFileSync25 } from "fs";
5060
+ import { join as join32, resolve as resolve4 } from "path";
4885
5061
 
4886
5062
  // src/rules/citations.ts
4887
5063
  import { join as join28 } from "path";
@@ -4895,16 +5071,16 @@ function citableIds(packageRoot2) {
4895
5071
  }
4896
5072
 
4897
5073
  // src/probe/check.ts
4898
- import { existsSync as existsSync17, readdirSync as readdirSync11, readFileSync as readFileSync24 } from "fs";
4899
- import { join as join30 } from "path";
5074
+ import { existsSync as existsSync18, readdirSync as readdirSync13, readFileSync as readFileSync24 } from "fs";
5075
+ import { join as join31 } from "path";
4900
5076
 
4901
5077
  // src/probe/save.ts
4902
- import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync23, writeFileSync as writeFileSync6 } from "fs";
5078
+ import { existsSync as existsSync17, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync23, writeFileSync as writeFileSync6 } from "fs";
4903
5079
  import { fileURLToPath as fileURLToPath2 } from "url";
4904
- import { join as join29, relative as relative3, resolve as resolve3 } from "path";
5080
+ import { join as join30, relative as relative3, resolve as resolve3 } from "path";
4905
5081
 
4906
5082
  // src/probe/script.ts
4907
- var PROBE_VERSION = 2;
5083
+ var PROBE_VERSION = 3;
4908
5084
  var PROBE_SCRIPT = `(async () => {
4909
5085
  const doc = document.documentElement;
4910
5086
  const vis = (e) => { const r = e.getBoundingClientRect(); const s = getComputedStyle(e); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
@@ -4924,6 +5100,48 @@ var PROBE_SCRIPT = `(async () => {
4924
5100
  }
4925
5101
  }
4926
5102
  }
5103
+ // Markup order against reading order (H-119). Only a vertical inversion
5104
+ // counts: an element that sits entirely ABOVE another on screen while coming
5105
+ // after it in the markup. Two columns side by side are not an inversion \u2014
5106
+ // putting the sidebar after the main content and moving it left with CSS is
5107
+ // the correct pattern, and flagging it would punish the right answer.
5108
+ // What to call a region in a report: its own label, else its heading, else
5109
+ // nothing. Its whole text content reads as gibberish inside a finding.
5110
+ const label = (el) => {
5111
+ const aria = (el.getAttribute('aria-label') || '').trim();
5112
+ if (aria) return aria;
5113
+ const id = el.getAttribute('aria-labelledby');
5114
+ const target = id ? document.getElementById(id) : null;
5115
+ const heading = target || (/^h[1-6]$/i.test(el.tagName) ? el : el.querySelector('h1, h2, h3, h4, h5, h6'));
5116
+ const t = (heading ? heading.textContent : '').replace(/\\s+/g, ' ').trim();
5117
+ return t.length > 42 ? t.slice(0, 40).trimEnd() + '\u2026' : t;
5118
+ };
5119
+ const all = [...document.querySelectorAll('main, nav, aside, header, footer, section, article, h1, h2, h3')].filter(vis);
5120
+ // Only the outermost regions. A nested heading inverts with its own parent
5121
+ // and with every sibling of it, so one swapped column reported four times.
5122
+ const landmarks = all
5123
+ .filter((el) => !all.some((other) => other !== el && other.contains(el)))
5124
+ .map((el, order) => {
5125
+ const r = el.getBoundingClientRect();
5126
+ return {
5127
+ order,
5128
+ tag: el.tagName.toLowerCase(),
5129
+ name: label(el),
5130
+ top: Math.round(r.top + scrollY),
5131
+ bottom: Math.round(r.bottom + scrollY),
5132
+ };
5133
+ });
5134
+ const describe = (l) => '<' + l.tag + '>' + (l.name ? ' \u201C' + l.name + '\u201D' : '');
5135
+ const inversions = [];
5136
+ for (let i = 0; i < landmarks.length; i++) {
5137
+ for (let j = i + 1; j < landmarks.length; j++) {
5138
+ const earlier = landmarks[i], later = landmarks[j];
5139
+ // The later element sits wholly above the earlier one, past a rounding wobble.
5140
+ if (later.bottom - earlier.top <= 8 && later.top < earlier.top) {
5141
+ inversions.push({ markupFirst: describe(earlier), seenFirst: describe(later) });
5142
+ }
5143
+ }
5144
+ }
4927
5145
  const navLinks = () => [...document.querySelectorAll('nav a, header a, [role=navigation] a')].filter(vis).length;
4928
5146
  // Measured before anything is clicked: what a reader sees on arrival.
4929
5147
  const navAtRest = navLinks();
@@ -4968,12 +5186,183 @@ var PROBE_SCRIPT = `(async () => {
4968
5186
  defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
4969
5187
  unresolvedTokens: [...unresolved],
4970
5188
  junkText: junk,
5189
+ emDashes: [...new Set((text.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
4971
5190
  brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
4972
5191
  navLinksVisible: navAtRest,
5192
+ landmarks: landmarks.map(describe),
5193
+ orderInversions: inversions.slice(0, 5),
4973
5194
  menu,
4974
5195
  });
4975
5196
  })()`;
4976
5197
 
5198
+ // src/probe/browser.ts
5199
+ import { spawn } from "child_process";
5200
+ import { existsSync as existsSync16, mkdtempSync, readdirSync as readdirSync11, rmSync as rmSync3 } from "fs";
5201
+ import { homedir, tmpdir } from "os";
5202
+ import { join as join29 } from "path";
5203
+ var CHROME_ENV = ["JIG_CHROME", "CHROME_PATH", "PUPPETEER_EXECUTABLE_PATH", "CHROMIUM_PATH"];
5204
+ var CHROME_COMMANDS = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"];
5205
+ var CHROME_APPS = [
5206
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
5207
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
5208
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
5209
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
5210
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
5211
+ ];
5212
+ function cachedBrowsers() {
5213
+ const roots = [
5214
+ join29(homedir(), ".cache", "ms-playwright"),
5215
+ join29(homedir(), "Library", "Caches", "ms-playwright"),
5216
+ join29(homedir(), "AppData", "Local", "ms-playwright"),
5217
+ join29(homedir(), ".cache", "puppeteer", "chrome")
5218
+ ];
5219
+ const found = [];
5220
+ for (const root of roots) {
5221
+ let entries;
5222
+ try {
5223
+ entries = readdirSync11(root);
5224
+ } catch {
5225
+ continue;
5226
+ }
5227
+ for (const entry of entries.sort().reverse()) {
5228
+ for (const rel of [
5229
+ ["chrome-linux", "chrome"],
5230
+ ["chrome-linux64", "chrome"],
5231
+ ["chrome-headless-shell-linux64", "chrome-headless-shell"],
5232
+ ["chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"],
5233
+ ["chrome-mac-arm64", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"],
5234
+ ["chrome-win", "chrome.exe"],
5235
+ ["chrome-win64", "chrome.exe"]
5236
+ ]) {
5237
+ const candidate = join29(root, entry, ...rel);
5238
+ if (existsSync16(candidate)) found.push(candidate);
5239
+ }
5240
+ }
5241
+ }
5242
+ return found;
5243
+ }
5244
+ function findChrome() {
5245
+ for (const key of CHROME_ENV) {
5246
+ const value = process.env[key];
5247
+ if (value && existsSync16(value)) return value;
5248
+ }
5249
+ for (const command of CHROME_COMMANDS) {
5250
+ const resolved = which(command);
5251
+ if (resolved) return resolved;
5252
+ }
5253
+ for (const app of CHROME_APPS) if (existsSync16(app)) return app;
5254
+ return cachedBrowsers()[0];
5255
+ }
5256
+ function which(command) {
5257
+ const dirs = (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
5258
+ for (const dir of dirs) {
5259
+ if (!dir) continue;
5260
+ const candidate = join29(dir, command);
5261
+ if (existsSync16(candidate)) return candidate;
5262
+ }
5263
+ return void 0;
5264
+ }
5265
+ async function runProbe(opts) {
5266
+ const chrome = opts.chrome ?? findChrome();
5267
+ if (!chrome) {
5268
+ throw new Error(
5269
+ "No browser found to run the probe. Install one, or set JIG_CHROME to a Chrome, Chromium or Edge binary. A project with Playwright or Puppeteer already has one, and Jig will find it."
5270
+ );
5271
+ }
5272
+ const profile = mkdtempSync(join29(tmpdir(), "jig-probe-"));
5273
+ const child = spawn(chrome, [
5274
+ "--headless=new",
5275
+ "--remote-debugging-port=0",
5276
+ `--user-data-dir=${profile}`,
5277
+ "--no-first-run",
5278
+ "--no-default-browser-check",
5279
+ "--disable-gpu",
5280
+ "--hide-scrollbars",
5281
+ "--disable-extensions",
5282
+ "--disable-background-networking",
5283
+ "--no-sandbox",
5284
+ "about:blank"
5285
+ ], { stdio: ["ignore", "ignore", "pipe"] });
5286
+ const timeoutMs = opts.timeoutMs ?? 3e4;
5287
+ try {
5288
+ const endpoint = await new Promise((done, fail) => {
5289
+ const timer = setTimeout(() => fail(new Error(`${chrome} did not start within ${timeoutMs / 1e3}s.`)), timeoutMs);
5290
+ let buffered = "";
5291
+ child.stderr.on("data", (chunk) => {
5292
+ buffered += chunk.toString();
5293
+ const match = /ws:\/\/[^\s]+/.exec(buffered);
5294
+ if (match) {
5295
+ clearTimeout(timer);
5296
+ done(match[0]);
5297
+ }
5298
+ });
5299
+ child.on("exit", (code) => {
5300
+ clearTimeout(timer);
5301
+ fail(new Error(`${chrome} exited with code ${code} before it was ready.`));
5302
+ });
5303
+ });
5304
+ return await evaluate(endpoint, opts.url, opts.width, opts.height ?? 900, timeoutMs);
5305
+ } finally {
5306
+ child.kill();
5307
+ rmSync3(profile, { recursive: true, force: true });
5308
+ }
5309
+ }
5310
+ async function evaluate(endpoint, url, width, height, timeoutMs) {
5311
+ const socket = new WebSocket(endpoint);
5312
+ let nextId = 1;
5313
+ const pending = /* @__PURE__ */ new Map();
5314
+ const loaded = { fired: false, waiters: [] };
5315
+ await new Promise((done, fail) => {
5316
+ socket.addEventListener("open", () => done(), { once: true });
5317
+ socket.addEventListener("error", () => fail(new Error("Could not connect to the browser.")), { once: true });
5318
+ });
5319
+ socket.addEventListener("message", (event) => {
5320
+ const message = JSON.parse(String(event.data));
5321
+ if (message.id && pending.has(message.id)) {
5322
+ const { done, fail } = pending.get(message.id);
5323
+ pending.delete(message.id);
5324
+ if (message.error) fail(new Error(message.error.message));
5325
+ else done(message.result ?? {});
5326
+ }
5327
+ if (message.method === "Page.loadEventFired") {
5328
+ loaded.fired = true;
5329
+ for (const waiter of loaded.waiters.splice(0)) waiter();
5330
+ }
5331
+ });
5332
+ const send = (method, params = {}, sessionId) => new Promise((done, fail) => {
5333
+ const id = nextId++;
5334
+ pending.set(id, { done, fail });
5335
+ socket.send(JSON.stringify({ id, method, params, sessionId }));
5336
+ setTimeout(() => {
5337
+ if (pending.delete(id)) fail(new Error(`${method} timed out.`));
5338
+ }, timeoutMs);
5339
+ });
5340
+ try {
5341
+ const target = await send("Target.createTarget", { url: "about:blank" });
5342
+ const attached = await send("Target.attachToTarget", { targetId: target.targetId, flatten: true });
5343
+ const session = attached.sessionId;
5344
+ await send("Page.enable", {}, session);
5345
+ await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile: false }, session);
5346
+ await send("Page.navigate", { url }, session);
5347
+ if (!loaded.fired) {
5348
+ await Promise.race([
5349
+ new Promise((done) => loaded.waiters.push(done)),
5350
+ new Promise((done) => setTimeout(done, timeoutMs / 2))
5351
+ ]);
5352
+ }
5353
+ await new Promise((done) => setTimeout(done, 250));
5354
+ const result = await send("Runtime.evaluate", { expression: PROBE_SCRIPT, awaitPromise: true, returnByValue: true }, session);
5355
+ const value = result.result?.value;
5356
+ if (typeof value !== "string") {
5357
+ const description = result.exceptionDetails?.text;
5358
+ throw new Error(`The probe returned nothing${description ? `: ${description}` : ""}. Is ${url} a page this browser can open?`);
5359
+ }
5360
+ return value;
5361
+ } finally {
5362
+ socket.close();
5363
+ }
5364
+ }
5365
+
4977
5366
  // src/probe/save.ts
4978
5367
  function saveProbe(opts) {
4979
5368
  let probe;
@@ -4995,9 +5384,9 @@ function saveProbe(opts) {
4995
5384
  probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
4996
5385
  probe.pageChecksum = checksum(readFileSync23(page, "utf8"));
4997
5386
  probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
4998
- const dir = join29(opts.projectRoot, ".jig", "critique", opts.surface);
5387
+ const dir = join30(opts.projectRoot, ".jig", "critique", opts.surface);
4999
5388
  mkdirSync7(dir, { recursive: true });
5000
- const path = join29(dir, `probe-${width}.json`);
5389
+ const path = join30(dir, `probe-${width}.json`);
5001
5390
  writeFileSync6(path, JSON.stringify(probe), "utf8");
5002
5391
  return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
5003
5392
  }
@@ -5011,7 +5400,49 @@ function pageFile(projectRoot, url) {
5011
5400
  }
5012
5401
  const abs = resolve3(path);
5013
5402
  if (!abs.startsWith(resolve3(projectRoot))) return void 0;
5014
- return existsSync16(abs) ? abs : void 0;
5403
+ return existsSync17(abs) ? abs : void 0;
5404
+ }
5405
+ var PROBE_WIDTHS = [360, 768, 1280];
5406
+ async function runAndSaveProbes(opts) {
5407
+ const abs = resolve3(opts.projectRoot, opts.page);
5408
+ if (!existsSync17(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
5409
+ const url = /^https?:/i.test(opts.page) ? opts.page : `file://${abs}`;
5410
+ const saved = [];
5411
+ for (const width of opts.widths ?? PROBE_WIDTHS) {
5412
+ const json = await runProbe({ url, width });
5413
+ saved.push(saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json }));
5414
+ }
5415
+ return saved;
5416
+ }
5417
+ async function ensureProbes(opts) {
5418
+ const dir = join30(opts.projectRoot, ".jig", "critique", opts.surface);
5419
+ const abs = resolve3(opts.projectRoot, opts.page);
5420
+ let current;
5421
+ try {
5422
+ current = checksum(readFileSync23(abs, "utf8"));
5423
+ } catch {
5424
+ return { recorded: [], reason: `${opts.page} could not be read` };
5425
+ }
5426
+ const missing = PROBE_WIDTHS.filter((width) => {
5427
+ try {
5428
+ const probe = JSON.parse(readFileSync23(join30(dir, `probe-${width}.json`), "utf8"));
5429
+ return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
5430
+ } catch {
5431
+ return true;
5432
+ }
5433
+ });
5434
+ if (missing.length === 0) return { recorded: [] };
5435
+ if (!findChrome()) return { recorded: [], reason: "no browser on this machine" };
5436
+ const url = /^https?:/i.test(opts.page) ? opts.page : `file://${abs}`;
5437
+ for (const width of missing) {
5438
+ saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json: await runProbe({ url, width }) });
5439
+ }
5440
+ return { recorded: missing };
5441
+ }
5442
+ function critiquedSurfaces(projectRoot) {
5443
+ const root = join30(projectRoot, ".jig", "critique");
5444
+ if (!existsSync17(root)) return [];
5445
+ return readdirSync12(root).filter((surface) => existsSync17(join30(root, surface, "screen.json")) || existsSync17(join30(root, surface, "code.json")));
5015
5446
  }
5016
5447
 
5017
5448
  // src/probe/check.ts
@@ -5033,11 +5464,11 @@ function stampProblem(projectRoot, file, p) {
5033
5464
  return void 0;
5034
5465
  }
5035
5466
  function readProbes(projectRoot, dir, errors) {
5036
- if (!existsSync17(dir)) return [];
5467
+ if (!existsSync18(dir)) return [];
5037
5468
  const probes = [];
5038
- for (const f of readdirSync11(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
5469
+ for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
5039
5470
  try {
5040
- const p = JSON.parse(readFileSync24(join30(dir, f), "utf8"));
5471
+ const p = JSON.parse(readFileSync24(join31(dir, f), "utf8"));
5041
5472
  if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
5042
5473
  errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
5043
5474
  continue;
@@ -5068,6 +5499,12 @@ function probeContradictions(probes, verdictOf) {
5068
5499
  if (p.unresolvedTokens.length) {
5069
5500
  errors.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
5070
5501
  }
5502
+ for (const inv of p.orderInversions ?? []) {
5503
+ errors.push(`${at(p)}: "${inv.seenFirst}" is read first on screen but comes after "${inv.markupFirst}" in the markup. At this width the markup order is not the reading order (H-119) \u2014 reorder the document, or move it with CSS that leaves the order intact.`);
5504
+ }
5505
+ if (p.emDashes?.length) {
5506
+ errors.push(`${at(p)}: the rendered page shows an em dash in ${p.emDashes.map((t) => `"${t}"`).join(", ")} (I-118) \u2014 use a full stop, a comma, a colon, or a second element.`);
5507
+ }
5071
5508
  if (p.junkText.length) {
5072
5509
  errors.push(`${at(p)}: the rendered text contains ${p.junkText.map((j) => `"${j}"`).join(", ")} \u2014 template code or a failed value is showing to readers.`);
5073
5510
  }
@@ -5107,7 +5544,7 @@ function probeContradictions(probes, verdictOf) {
5107
5544
  var VERDICTS = ["ok", "finding", "n/a"];
5108
5545
  var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
5109
5546
  function readJson(path, errors) {
5110
- if (!existsSync18(path)) return null;
5547
+ if (!existsSync19(path)) return null;
5111
5548
  try {
5112
5549
  return JSON.parse(readFileSync25(path, "utf8"));
5113
5550
  } catch (e) {
@@ -5116,8 +5553,8 @@ function readJson(path, errors) {
5116
5553
  }
5117
5554
  }
5118
5555
  function specNeedsNav(projectRoot, surface) {
5119
- const path = join31(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5120
- if (!existsSync18(path)) return false;
5556
+ const path = join32(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5557
+ if (!existsSync19(path)) return false;
5121
5558
  const front = readFileSync25(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5122
5559
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
5123
5560
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
@@ -5169,7 +5606,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
5169
5606
  function verifyVerdicts(opts) {
5170
5607
  const root = opts.packageRoot ?? assetRoot();
5171
5608
  const errors = [];
5172
- const index = JSON.parse(readFileSync25(join31(root, "rules.index.json"), "utf8"));
5609
+ const index = JSON.parse(readFileSync25(join32(root, "rules.index.json"), "utf8"));
5173
5610
  const judgment = index.filter((r) => r.bucket === "judgment");
5174
5611
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
5175
5612
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
@@ -5178,16 +5615,16 @@ function verifyVerdicts(opts) {
5178
5615
  ...judgment.map((r) => [r.id, r.pass ?? ""])
5179
5616
  ]);
5180
5617
  const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
5181
- const dir = join31(opts.projectRoot, ".jig", "critique", opts.surface);
5182
- const screenFile = readJson(join31(dir, "screen.json"), errors);
5183
- const codeFile = readJson(join31(dir, "code.json"), errors);
5618
+ const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
5619
+ const screenFile = readJson(join32(dir, "screen.json"), errors);
5620
+ const codeFile = readJson(join32(dir, "code.json"), errors);
5184
5621
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
5185
5622
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
5186
5623
  const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
5187
5624
  let rendered = false;
5188
5625
  if (screenFile && screenFile.rendered === true) {
5189
5626
  const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
5190
- const absent = artefacts.filter((a) => !existsSync18(resolve4(opts.projectRoot, a)));
5627
+ const absent = artefacts.filter((a) => !existsSync19(resolve4(opts.projectRoot, a)));
5191
5628
  if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
5192
5629
  else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
5193
5630
  else rendered = true;
@@ -5214,19 +5651,20 @@ function verifyVerdicts(opts) {
5214
5651
  }
5215
5652
 
5216
5653
  // src/commands/gate.ts
5217
- import { existsSync as existsSync20, mkdirSync as mkdirSync8, readdirSync as readdirSync13, readFileSync as readFileSync27, writeFileSync as writeFileSync7 } from "fs";
5218
- import { join as join33 } from "path";
5654
+ import { existsSync as existsSync21, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync27, writeFileSync as writeFileSync7 } from "fs";
5655
+ import { createHash as createHash2 } from "crypto";
5656
+ import { join as join34 } from "path";
5219
5657
 
5220
5658
  // src/check/spec-shape.ts
5221
- import { existsSync as existsSync19, readFileSync as readFileSync26, readdirSync as readdirSync12, statSync as statSync2 } from "fs";
5222
- import { join as join32 } from "path";
5659
+ import { existsSync as existsSync20, readFileSync as readFileSync26, readdirSync as readdirSync14, statSync as statSync2 } from "fs";
5660
+ import { join as join33 } from "path";
5223
5661
  function newestSpec(projectRoot) {
5224
- const dir = join32(projectRoot, ".jig", "specs");
5225
- if (!existsSync19(dir)) return void 0;
5226
- const files = readdirSync12(dir).filter((f) => f.endsWith(".md"));
5662
+ const dir = join33(projectRoot, ".jig", "specs");
5663
+ if (!existsSync20(dir)) return void 0;
5664
+ const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
5227
5665
  if (files.length === 0) return void 0;
5228
- const newest = files.map((f) => ({ f, at: statSync2(join32(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
5229
- return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync26(join32(dir, newest), "utf8") };
5666
+ const newest = files.map((f) => ({ f, at: statSync2(join33(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
5667
+ return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync26(join33(dir, newest), "utf8") };
5230
5668
  }
5231
5669
  function specProblems(spec) {
5232
5670
  const parts = spec.body.split(/^---\s*$/m);
@@ -5275,7 +5713,7 @@ function sizeBlock(sizes, size) {
5275
5713
  }
5276
5714
  return lines.join("\n");
5277
5715
  }
5278
- var MENU_RE = /\b(menu|hamburger|drawer|burger)\b/i;
5716
+ var MENU_RE = /(?<!\b(?:no|not|without|never)\s)(?<!\bno\s\w{1,12}\s)\b(menu|hamburger|drawer|burger)\b/i;
5279
5717
  var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
5280
5718
  function navProblems(spec) {
5281
5719
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
@@ -5295,7 +5733,7 @@ function navProblems(spec) {
5295
5733
  // src/commands/gate.ts
5296
5734
  var MAX_BLOCKS = 3;
5297
5735
  function lastJigCommand(transcriptPath) {
5298
- if (!transcriptPath || !existsSync20(transcriptPath)) return void 0;
5736
+ if (!transcriptPath || !existsSync21(transcriptPath)) return void 0;
5299
5737
  let text;
5300
5738
  try {
5301
5739
  text = readFileSync27(transcriptPath, "utf8");
@@ -5316,7 +5754,7 @@ function commandProblems(root, command) {
5316
5754
  const problems = [];
5317
5755
  const spec = newestSpec(root);
5318
5756
  if (command === "decide") {
5319
- const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) => join33(root, p)).find((p) => existsSync20(p));
5757
+ const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) => join34(root, p)).find((p) => existsSync21(p));
5320
5758
  if (!file) problems.push("decide wrote no DECISIONS.md beside the token layer.");
5321
5759
  else {
5322
5760
  const body = readFileSync27(file, "utf8");
@@ -5340,13 +5778,13 @@ function commandProblems(root, command) {
5340
5778
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
5341
5779
  if (/^approved/i.test(mockup)) {
5342
5780
  if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
5343
- else if (!/^https?:/i.test(at) && !existsSync20(join33(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
5781
+ else if (!/^https?:/i.test(at) && !existsSync21(join34(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
5344
5782
  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.`);
5345
5783
  }
5346
5784
  }
5347
5785
  if (command === "critique") {
5348
- const dir = join33(root, ".jig", "critique");
5349
- const surfaces = existsSync20(dir) ? readdirSync13(dir).filter((s) => existsSync20(join33(dir, s, "screen.json")) || existsSync20(join33(dir, s, "code.json"))) : [];
5786
+ const dir = join34(root, ".jig", "critique");
5787
+ const surfaces = existsSync21(dir) ? readdirSync15(dir).filter((s) => existsSync21(join34(dir, s, "screen.json")) || existsSync21(join34(dir, s, "code.json"))) : [];
5350
5788
  for (const surface of surfaces) {
5351
5789
  const v = verifyVerdicts({ projectRoot: root, surface });
5352
5790
  if (v.ok && v.screen.state === "skipped") {
@@ -5359,9 +5797,19 @@ function commandProblems(root, command) {
5359
5797
  }
5360
5798
  return problems;
5361
5799
  }
5800
+ function surfacePage(projectRoot, surface) {
5801
+ const spec = newestSpec(projectRoot);
5802
+ const front = spec?.body.split(/^---\s*$/m)[1] ?? "";
5803
+ const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
5804
+ const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
5805
+ for (const candidate of candidates) {
5806
+ if (candidate && existsSync21(join34(projectRoot, candidate))) return candidate;
5807
+ }
5808
+ return void 0;
5809
+ }
5362
5810
  function gate(opts) {
5363
5811
  const root = opts.projectRoot;
5364
- if (!existsSync20(join33(root, "jig.config.json")) && !existsSync20(join33(root, ".jig"))) {
5812
+ if (!existsSync21(join34(root, "jig.config.json")) && !existsSync21(join34(root, ".jig"))) {
5365
5813
  return { block: false, reason: "" };
5366
5814
  }
5367
5815
  const command = lastJigCommand(opts.input.transcript_path);
@@ -5380,11 +5828,11 @@ function gate(opts) {
5380
5828
  );
5381
5829
  }
5382
5830
  }
5383
- const critiqueDir = join33(root, ".jig", "critique");
5384
- if (existsSync20(critiqueDir)) {
5385
- for (const surface of readdirSync13(critiqueDir)) {
5386
- const dir = join33(critiqueDir, surface);
5387
- if (!existsSync20(join33(dir, "screen.json")) && !existsSync20(join33(dir, "code.json"))) continue;
5831
+ const critiqueDir = join34(root, ".jig", "critique");
5832
+ if (existsSync21(critiqueDir)) {
5833
+ for (const surface of readdirSync15(critiqueDir)) {
5834
+ const dir = join34(critiqueDir, surface);
5835
+ if (!existsSync21(join34(dir, "screen.json")) && !existsSync21(join34(dir, "code.json"))) continue;
5388
5836
  const v = verifyVerdicts({ projectRoot: root, surface });
5389
5837
  if (!v.ok) {
5390
5838
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -5397,20 +5845,22 @@ function gate(opts) {
5397
5845
  }
5398
5846
  }
5399
5847
  const session = opts.input.session_id ?? "unknown";
5400
- const stateFile = join33(root, ".jig", "gate.json");
5848
+ const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
5849
+ const stateFile = join34(root, ".jig", "gate.json");
5401
5850
  let state = {};
5402
5851
  try {
5403
5852
  state = JSON.parse(readFileSync27(stateFile, "utf8"));
5404
5853
  } catch {
5405
5854
  }
5406
5855
  if (problems.length === 0) {
5407
- if (state[session]) {
5408
- delete state[session];
5856
+ const mine = Object.keys(state).filter((k) => k.startsWith(`${session}:`));
5857
+ if (mine.length) {
5858
+ for (const k of mine) delete state[k];
5409
5859
  save(stateFile, state);
5410
5860
  }
5411
5861
  return { block: false, reason: "" };
5412
5862
  }
5413
- const count = (state[session] ?? 0) + 1;
5863
+ const count = (state[key] ?? 0) + 1;
5414
5864
  if (count > MAX_BLOCKS) {
5415
5865
  return {
5416
5866
  block: false,
@@ -5418,7 +5868,7 @@ function gate(opts) {
5418
5868
  ${problems.join("\n\n")}`
5419
5869
  };
5420
5870
  }
5421
- state[session] = count;
5871
+ state[key] = count;
5422
5872
  save(stateFile, state);
5423
5873
  return {
5424
5874
  block: true,
@@ -5429,7 +5879,7 @@ ${problems.join("\n\n")}`
5429
5879
  }
5430
5880
  function save(file, state) {
5431
5881
  try {
5432
- mkdirSync8(join33(file, ".."), { recursive: true });
5882
+ mkdirSync8(join34(file, ".."), { recursive: true });
5433
5883
  writeFileSync7(file, JSON.stringify(state), "utf8");
5434
5884
  } catch {
5435
5885
  }
@@ -5437,7 +5887,7 @@ function save(file, state) {
5437
5887
 
5438
5888
  // src/index.ts
5439
5889
  var packageRoot = getPackageRoot();
5440
- var { version } = JSON.parse(readFileSync28(join34(packageRoot, "package.json"), "utf8"));
5890
+ var { version } = JSON.parse(readFileSync28(join35(packageRoot, "package.json"), "utf8"));
5441
5891
  function warnIfUnpublishedPin() {
5442
5892
  if (isPublishedBuild(packageRoot)) return;
5443
5893
  console.warn(
@@ -5475,7 +5925,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
5475
5925
  projectRoot,
5476
5926
  packageRoot: assetRoot(),
5477
5927
  version,
5478
- homeDir: homedir(),
5928
+ homeDir: homedir2(),
5479
5929
  hook
5480
5930
  });
5481
5931
  if (result.warning) {
@@ -5513,7 +5963,7 @@ program.command("update").description("Update vendored Jig rules, skipping files
5513
5963
  projectRoot,
5514
5964
  packageRoot: assetRoot(),
5515
5965
  version,
5516
- homeDir: homedir()
5966
+ homeDir: homedir2()
5517
5967
  });
5518
5968
  const label = result.targets.map((t) => `${t.agent} (${t.scope}, ${t.fromVersion})`).join(", ");
5519
5969
  warnIfUnpublishedPin();
@@ -5551,7 +6001,7 @@ program.command("check").description("Check the repo against Jig's mechanical +
5551
6001
  try {
5552
6002
  const result = check({
5553
6003
  projectRoot,
5554
- homeDir: homedir(),
6004
+ homeDir: homedir2(),
5555
6005
  version,
5556
6006
  all: opts.all,
5557
6007
  ci: opts.ci
@@ -5563,7 +6013,23 @@ program.command("check").description("Check the repo against Jig's mechanical +
5563
6013
  process.exit(1);
5564
6014
  }
5565
6015
  });
5566
- 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>/").action(async (opts) => {
6016
+ 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) => {
6017
+ if (opts.run) {
6018
+ if (!opts.save) {
6019
+ console.error(" \u2717 --run records what it measures, so it needs --save <surface>.");
6020
+ process.exit(1);
6021
+ }
6022
+ const projectRoot2 = findProjectRoot(process.cwd());
6023
+ try {
6024
+ for (const saved of await runAndSaveProbes({ projectRoot: projectRoot2, surface: opts.save, page: opts.run })) {
6025
+ console.log(` Recorded ${saved.path} \u2014 ${saved.page} at ${saved.width}px.`);
6026
+ }
6027
+ } catch (err) {
6028
+ console.error(` \u2717 ${err.message}`);
6029
+ process.exit(1);
6030
+ }
6031
+ return;
6032
+ }
5567
6033
  if (!opts.save) {
5568
6034
  console.log(PROBE_SCRIPT);
5569
6035
  return;
@@ -5586,7 +6052,7 @@ program.command("probe").description("Print the render probe. With --save, read
5586
6052
  process.exit(1);
5587
6053
  }
5588
6054
  });
5589
- program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(() => {
6055
+ program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
5590
6056
  let input = {};
5591
6057
  try {
5592
6058
  if (!process.stdin.isTTY) input = JSON.parse(readFileSync28(0, "utf8") || "{}");
@@ -5594,7 +6060,16 @@ program.command("gate").description("Run by the Claude Code Stop hook: block sto
5594
6060
  }
5595
6061
  const cwd = input.cwd ?? process.cwd();
5596
6062
  try {
5597
- const result = gate({ projectRoot: findProjectRoot(cwd), version, input });
6063
+ const projectRoot = findProjectRoot(cwd);
6064
+ for (const surface of critiquedSurfaces(projectRoot)) {
6065
+ const page = surfacePage(projectRoot, surface);
6066
+ if (!page) continue;
6067
+ try {
6068
+ await ensureProbes({ projectRoot, surface, page });
6069
+ } catch {
6070
+ }
6071
+ }
6072
+ const result = gate({ projectRoot, version, input });
5598
6073
  if (result.block) console.log(JSON.stringify({ decision: "block", reason: result.reason }));
5599
6074
  else if (result.reason) console.error(result.reason);
5600
6075
  } catch (err) {
@@ -5608,7 +6083,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
5608
6083
  await init({
5609
6084
  projectRoot,
5610
6085
  packageRoot: assetRoot(),
5611
- homeDir: homedir(),
6086
+ homeDir: homedir2(),
5612
6087
  version,
5613
6088
  yes: opts.yes
5614
6089
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.11.0",
4
- "description": "A design system for coding agents. 113 numbered UI rules, brand x mode design tokens, and an installer for Claude Code, Codex, Cursor and opencode.",
3
+ "version": "0.13.0",
4
+ "description": "A design system for coding agents. 115 numbered UI rules, brand x mode design tokens, and an installer for Claude Code, Codex, Cursor and opencode.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
@@ -544,6 +544,20 @@ Deletion is a real answer and the easy one to miss, because the correction point
544
544
  ❌ Scroll listeners for sticky positioning; scripted accordions and dialogs that have native equivalents
545
545
  ✅ Platform first: `position: sticky`, `<details>`, `<dialog>`, `:has()`, container queries, `scroll-behavior`, `popover`. Reach for a framework when the platform genuinely lacks the capability.
546
546
 
547
+ ### H-119 A generic element where a native one says what the content is
548
+ ❌ `<div class="page-title">Our products</div>`, a row of links in a `<div class="nav">`, a page whose top-level content sits in `<div class="main">`
549
+ ✅ `<h1>Our products</h1>`, `<nav>`, `<main>`. Choose the element from what the content **is**, then style it. The two versions can look identical; only one of them tells a screen reader, a search engine, a reader-mode button and a keyboard user what they are looking at.
550
+ **The decision order**, before writing any markup:
551
+ 1. What is this content? A heading, a list, a sequence, a quotation, tabular data, navigation, a control, a landmark region?
552
+ 2. Is there a native element whose meaning is that? If yes, use it.
553
+ 3. Does it describe the content accurately, or only approximately? An approximate fit is worse than a generic container, because it asserts something untrue.
554
+ 4. Does the content have an order or a relationship that the markup should carry? Ordered steps are `<ol>`; rows and columns of related values are a `<table>`; a term and its explanation are `<dl>`.
555
+ 5. Can CSS produce the presentation without changing the element? It nearly always can.
556
+ 6. Only when no native element fits, a generic container.
557
+ **This is not a rule against `div`.** A `div` is the right answer when nothing more specific is true: a grouping that exists for layout alone. What is forbidden is choosing an element for how it looks, or reaching for a generic one without asking steps 1 and 2.
558
+ **Presentation must not be required to understand the content.** Turn the stylesheet off in your head: the page should still read as what it is, in an order that makes sense. A sidebar that must come first visually does not have to come first in the DOM, and usually should not (`L-01`).
559
+ Related: `E-33` (an interactive `div`), `B-17` (heading levels), `H-48` (native elements over scripted ones), `P-14` (navigation).
560
+
547
561
  ### H-117 A token name nothing declares
548
562
  ❌ `font-family: var(--font-body)`, `padding: var(--space-lg)` — in a project whose token layer declares neither
549
563
  ✅ Use the names the token layer declares — `02-tokens.md` lists them, and the token files in the project are the source. If the value you need has no token, that is a finding to report or a value to delete (`H-47`), never a name to make up.
package/rules/01-modes.md CHANGED
@@ -190,7 +190,7 @@ Attempting to vary these by mode is a category error:
190
190
  - **Accessibility floors.** Contrast, focus indication, target size, semantic markup. Identical in all three. `operator` being dense does not license a 24px tap target or a 3:1 body contrast.
191
191
  - **Brand identity.** Palette, typeface, logo, voice.
192
192
  - **State completeness.** Every mode renders loading, empty, error and disabled.
193
- - **The anti-pattern file.** All 96 rules in it apply everywhere.
193
+ - **The anti-pattern file.** All 97 rules in it apply everywhere.
194
194
 
195
195
  ---
196
196
 
package/rules/05-copy.md CHANGED
@@ -83,6 +83,14 @@ The one legitimate use is a **short label** distinguishing itself from nearby te
83
83
  ✅ Most interface text is too short to need them. Use a full stop only where the text is a complete sentence containing commas.
84
84
  Whichever you choose, be consistent across sibling elements — a list where three items end in a stop and two do not looks like a mistake, because it is one.
85
85
 
86
+ ### I-118 Em dashes in interface text
87
+ ❌ "Your plan — including every seat — renews monthly", "Free — forever", "Deleted — this cannot be undone"
88
+ ✅ A full stop, a comma, a colon or a new element. "Your plan renews monthly, including every seat." "Free forever." "Deleted. This cannot be undone."
89
+ Interface text is read in fragments, at a glance, in a space someone else's content has to fit too. An em dash is a pause the reader has to interpret: it stands in for a comma, a colon, a bracket or a full stop, and which one it is only becomes clear after reading past it. The punctuation that says exactly one thing is faster.
90
+ It is also the clearest tell of machine-written copy. Generated text reaches for the em dash far more often than a person does, and readers have learned to notice. Copy that reads as generated is copy the reader trusts less, whatever it says.
91
+ This is about interface strings: labels, buttons, headings, errors, empty states, help text, and the prose a page ships. Markdown counts where a framework renders it as a page, which is most of them (`src/content`, `content/`, MDX routes). A repository document does not: `README.md`, `CHANGELOG.md`, `AGENTS.md` and their kin are written for whoever works on the code, and so are your commit messages and this file.
92
+ The en dash keeps its one job: ranges, where it is read as "to" (`2–10 seats`, `Mon–Fri`). That is not a pause, and it is not affected.
93
+
86
94
  ### I-87 Inconsistent vocabulary
87
95
  ❌ "Add to cart" beside a "Bag" icon; "Sign up" on the page and "Register" in the nav
88
96
  ✅ One word per concept, everywhere. Keep a term list in the project and follow it.
package/rules.index.json CHANGED
@@ -793,5 +793,19 @@
793
793
  "severity": "error",
794
794
  "since": "0.10.0",
795
795
  "detector": "undeclared-token"
796
+ },
797
+ {
798
+ "id": "I-118",
799
+ "bucket": "mechanical",
800
+ "severity": "warning",
801
+ "since": "0.12.0",
802
+ "detector": "em-dash"
803
+ },
804
+ {
805
+ "id": "H-119",
806
+ "bucket": "mechanical",
807
+ "severity": "warning",
808
+ "since": "0.13.0",
809
+ "detector": "semantic-element"
796
810
  }
797
811
  ]
@@ -318,6 +318,11 @@ deviations: [] # `make` writes here; `spec` leaves it empty
318
318
  - **No shell before there are features.** If the product has no navigation yet,
319
319
  `nav:` says `none yet` — it is decided once there is more than one feature to
320
320
  move between, not invented for the first one.
321
+ - **Regions say what the content is, not what it looks like.** `- plans: three
322
+ cards` describes a shape; `- plans: a comparable set, one per tier` describes
323
+ content, and the second is what `make` can choose an element from. `H-119` runs
324
+ its decision order against these lines, so a region named only by its
325
+ appearance leaves the element to chance.
321
326
  - **`nav:` at every size, on any screen with navigation — decided by `P-14`'s table
322
327
  at that width, not copied.** It is the field most likely to be written once and
323
328
  copied, and most likely to be genuinely different at every size on a page that
@@ -641,6 +646,14 @@ change. Write base styles for the phone and add width as it is available — not
641
646
  the desktop layout with overrides that take it apart again. A layout built wide
642
647
  and subtracted from ends up correct at exactly the widths somebody checked.
643
648
 
649
+ **Choose every element from what its content is, before styling any of it**
650
+ (`H-119`). For each region in the spec, run the decision order: what is this
651
+ content — a heading, a list, a sequence, a quotation, tabular data, navigation,
652
+ a control, a landmark? Is there a native element whose meaning is that? Does it
653
+ describe the content accurately? Only when nothing fits is a generic container
654
+ right. The page must read as what it is with the stylesheet off, in an order
655
+ that makes sense; CSS then arranges it, and never the reverse.
656
+
644
657
  Then the ordinary rules apply: tokens by semantic name, the relevant
645
658
  `03-patterns.md` section for each component, `05-copy.md` for every string.
646
659
 
@@ -852,12 +865,19 @@ Each reader arm writes its own file. The arm reports back only that it wrote it.
852
865
  probe in the browser and save what it returns beside the verdicts:
853
866
 
854
867
  ```
855
- {{scripts_path}} probe > .jig/probe.js
856
- # at each width: set the viewport, open the page, evaluate .jig/probe.js, and pipe
857
- # exactly what it returned into:
868
+ # If a browser is on this machine, one command renders and records all three:
869
+ {{scripts_path}} probe --run <page> --save <surface>
870
+
871
+ # Otherwise, at each width: open the page in whatever browser you have, evaluate
872
+ # what `{{scripts_path}} probe` prints, and pipe exactly what it returned into:
858
873
  {{scripts_path}} probe --save <surface>
859
874
  ```
860
875
 
876
+ `--run` drives a headless Chrome, Chromium or Edge it finds for itself — a
877
+ project with Playwright or Puppeteer already has one — and needs nothing
878
+ installed. The Stop hook runs it too, before it judges a review, so a render is
879
+ not a step anyone can forget.
880
+
861
881
  **The CLI writes the probe file, you do not.** `--save` reads the probe's output,
862
882
  stamps it with the page's checksum, and stores it. That is what makes the file a
863
883
  measurement: `verdicts` recomputes the checksum, so a file written by hand, or one
@@ -918,6 +938,13 @@ source and imagining the page is not a render.
918
938
  composition. The same layout with smaller numbers is not.
919
939
  - Check the page is styled at all. A stylesheet that 404s renders a page that
920
940
  passes every file-based check ever written.
941
+ - **Read the markup as a document, not a layout** (`H-119`). For each region:
942
+ does its element say what the content is? Would the page still read correctly
943
+ with the stylesheet off, in this order? A row of links that is not a `<nav>`, a
944
+ heading that is a styled `<div>`, steps in no particular element, a comparison
945
+ of values that is not a table: each is a finding, and each names the element
946
+ the content asks for. A `<div>` is right where nothing more specific is true;
947
+ say so rather than inventing a fault.
921
948
  - Run the squint test from `L-01` against the render, not the analogue: render
922
949
  each size once more with `filter: grayscale(1)` on the root, and check that the
923
950
  primary action, the headings and the groups still read in order without colour.
@@ -30,8 +30,8 @@
30
30
  "status": "available"
31
31
  },
32
32
  "probe": {
33
- "description": "Print the render probe: a script the critique runs in a browser at 360, 768 and 1280px. With --save it records what the probe returned, stamped with the page's checksum, so `verdicts` can tell a measurement from a claim.",
34
- "argumentHint": "[--save <surface>]",
33
+ "description": "Print the render probe, or run it here. `--run <page> --save <surface>` drives a headless browser it finds for itself and records the measurement at 360, 768 and 1280, stamped with the page's checksum, so `verdicts` can tell a measurement from a claim.",
34
+ "argumentHint": "[--run <page>] [--save <surface>]",
35
35
  "status": "available"
36
36
  },
37
37
  "gate": {