jig-ui 0.12.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,47 @@
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
+
3
45
  ## 0.12.0
4
46
 
5
47
  ### Added
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 114 rules, **17 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 17. 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";
@@ -3300,54 +3339,136 @@ var undeclaredToken = {
3300
3339
  };
3301
3340
 
3302
3341
  // src/check/detectors/em-dash.ts
3303
- var MARKUP = [".html", ".htm", ".vue", ".svelte", ".astro", ".jsx", ".tsx", ".php", ".erb", ".twig", ".hbs"];
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 = /[;{}`]|\$\{|=>/;
3304
3346
  var TEXT_ATTRS = /\b(aria-label|aria-description|aria-placeholder|title|placeholder|alt|label|value|content)\s*=\s*("([^"]*)"|'([^']*)')/gi;
3305
3347
  var EM_DASH = /—/;
3348
+ var TAG = /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>/g;
3306
3349
  function maskNonProse(source) {
3307
3350
  const blank3 = (m) => " ".repeat(m.length);
3308
- return source.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));
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));
3309
3352
  }
3310
- function proseSpans(masked) {
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) {
3311
3358
  const spans = [];
3359
+ const tags = [...masked.matchAll(TAG)];
3312
3360
  let last = 0;
3313
- for (const tag of masked.matchAll(/<[^>]*>/g)) {
3361
+ tags.forEach((tag, i) => {
3314
3362
  const between = masked.slice(last, tag.index);
3315
- if (between.trim()) spans.push({ index: last, text: between });
3363
+ if (between.trim() && (edges || i > 0)) spans.push({ index: last, text: between });
3316
3364
  for (const attr of tag[0].matchAll(TEXT_ATTRS)) {
3317
3365
  const value = attr[3] ?? attr[4] ?? "";
3318
3366
  spans.push({ index: tag.index + attr.index + attr[0].indexOf(value), text: value });
3319
3367
  }
3320
3368
  last = tag.index + tag[0].length;
3321
- }
3369
+ });
3322
3370
  const tail = masked.slice(last);
3323
- if (tail.trim()) spans.push({ index: last, text: tail });
3371
+ if (tail.trim() && edges && tags.length > 0) spans.push({ index: last, text: tail });
3324
3372
  return spans;
3325
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
+ }
3326
3378
  var emDash = {
3327
3379
  name: "em-dash",
3328
- appliesTo: (file) => hasExtension(file, MARKUP),
3380
+ appliesTo: (file) => isReaderText(file),
3329
3381
  run(_source, file, ctx) {
3330
- const masked = maskNonProse(ctx.raw);
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);
3331
3385
  const starts = buildLineIndex(ctx.raw);
3332
3386
  const findings = [];
3333
3387
  const seen = /* @__PURE__ */ new Set();
3334
- for (const span of proseSpans(masked)) {
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) {
3335
3391
  if (!EM_DASH.test(span.text)) continue;
3336
- const offset = span.index + span.text.indexOf("\u2014");
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) => {
3337
3432
  const line = lineForOffset(starts, offset);
3338
- if (seen.has(line)) continue;
3339
- seen.add(line);
3340
- findings.push(
3341
- mkFinding(
3342
- ctx,
3343
- "em-dash",
3344
- file,
3345
- line,
3346
- "an em dash in interface text: use a full stop, a comma, a colon, or a second element",
3347
- sourceLine(ctx.raw, line)
3348
- )
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"
3349
3439
  );
3350
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
+ }
3351
3472
  return findings;
3352
3473
  }
3353
3474
  };
@@ -3370,7 +3491,8 @@ var DETECTORS = [
3370
3491
  safeArea,
3371
3492
  menuState,
3372
3493
  undeclaredToken,
3373
- emDash
3494
+ emDash,
3495
+ semanticElement
3374
3496
  ];
3375
3497
  function getDetector(name) {
3376
3498
  return DETECTORS.find((d) => d.name === name);
@@ -4934,8 +5056,8 @@ Could not write to ${wireTarget}: ${err.message}`);
4934
5056
  }
4935
5057
 
4936
5058
  // src/commands/verdicts.ts
4937
- import { existsSync as existsSync18, readFileSync as readFileSync25 } from "fs";
4938
- 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";
4939
5061
 
4940
5062
  // src/rules/citations.ts
4941
5063
  import { join as join28 } from "path";
@@ -4949,16 +5071,16 @@ function citableIds(packageRoot2) {
4949
5071
  }
4950
5072
 
4951
5073
  // src/probe/check.ts
4952
- import { existsSync as existsSync17, readdirSync as readdirSync11, readFileSync as readFileSync24 } from "fs";
4953
- 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";
4954
5076
 
4955
5077
  // src/probe/save.ts
4956
- 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";
4957
5079
  import { fileURLToPath as fileURLToPath2 } from "url";
4958
- 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";
4959
5081
 
4960
5082
  // src/probe/script.ts
4961
- var PROBE_VERSION = 2;
5083
+ var PROBE_VERSION = 3;
4962
5084
  var PROBE_SCRIPT = `(async () => {
4963
5085
  const doc = document.documentElement;
4964
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'; };
@@ -4978,6 +5100,48 @@ var PROBE_SCRIPT = `(async () => {
4978
5100
  }
4979
5101
  }
4980
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
+ }
4981
5145
  const navLinks = () => [...document.querySelectorAll('nav a, header a, [role=navigation] a')].filter(vis).length;
4982
5146
  // Measured before anything is clicked: what a reader sees on arrival.
4983
5147
  const navAtRest = navLinks();
@@ -5022,12 +5186,183 @@ var PROBE_SCRIPT = `(async () => {
5022
5186
  defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
5023
5187
  unresolvedTokens: [...unresolved],
5024
5188
  junkText: junk,
5189
+ emDashes: [...new Set((text.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
5025
5190
  brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
5026
5191
  navLinksVisible: navAtRest,
5192
+ landmarks: landmarks.map(describe),
5193
+ orderInversions: inversions.slice(0, 5),
5027
5194
  menu,
5028
5195
  });
5029
5196
  })()`;
5030
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
+
5031
5366
  // src/probe/save.ts
5032
5367
  function saveProbe(opts) {
5033
5368
  let probe;
@@ -5049,9 +5384,9 @@ function saveProbe(opts) {
5049
5384
  probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
5050
5385
  probe.pageChecksum = checksum(readFileSync23(page, "utf8"));
5051
5386
  probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
5052
- const dir = join29(opts.projectRoot, ".jig", "critique", opts.surface);
5387
+ const dir = join30(opts.projectRoot, ".jig", "critique", opts.surface);
5053
5388
  mkdirSync7(dir, { recursive: true });
5054
- const path = join29(dir, `probe-${width}.json`);
5389
+ const path = join30(dir, `probe-${width}.json`);
5055
5390
  writeFileSync6(path, JSON.stringify(probe), "utf8");
5056
5391
  return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
5057
5392
  }
@@ -5065,7 +5400,49 @@ function pageFile(projectRoot, url) {
5065
5400
  }
5066
5401
  const abs = resolve3(path);
5067
5402
  if (!abs.startsWith(resolve3(projectRoot))) return void 0;
5068
- 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")));
5069
5446
  }
5070
5447
 
5071
5448
  // src/probe/check.ts
@@ -5087,11 +5464,11 @@ function stampProblem(projectRoot, file, p) {
5087
5464
  return void 0;
5088
5465
  }
5089
5466
  function readProbes(projectRoot, dir, errors) {
5090
- if (!existsSync17(dir)) return [];
5467
+ if (!existsSync18(dir)) return [];
5091
5468
  const probes = [];
5092
- 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()) {
5093
5470
  try {
5094
- const p = JSON.parse(readFileSync24(join30(dir, f), "utf8"));
5471
+ const p = JSON.parse(readFileSync24(join31(dir, f), "utf8"));
5095
5472
  if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
5096
5473
  errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
5097
5474
  continue;
@@ -5122,6 +5499,12 @@ function probeContradictions(probes, verdictOf) {
5122
5499
  if (p.unresolvedTokens.length) {
5123
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).`);
5124
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
+ }
5125
5508
  if (p.junkText.length) {
5126
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.`);
5127
5510
  }
@@ -5161,7 +5544,7 @@ function probeContradictions(probes, verdictOf) {
5161
5544
  var VERDICTS = ["ok", "finding", "n/a"];
5162
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;
5163
5546
  function readJson(path, errors) {
5164
- if (!existsSync18(path)) return null;
5547
+ if (!existsSync19(path)) return null;
5165
5548
  try {
5166
5549
  return JSON.parse(readFileSync25(path, "utf8"));
5167
5550
  } catch (e) {
@@ -5170,8 +5553,8 @@ function readJson(path, errors) {
5170
5553
  }
5171
5554
  }
5172
5555
  function specNeedsNav(projectRoot, surface) {
5173
- const path = join31(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5174
- if (!existsSync18(path)) return false;
5556
+ const path = join32(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5557
+ if (!existsSync19(path)) return false;
5175
5558
  const front = readFileSync25(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5176
5559
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
5177
5560
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
@@ -5223,7 +5606,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
5223
5606
  function verifyVerdicts(opts) {
5224
5607
  const root = opts.packageRoot ?? assetRoot();
5225
5608
  const errors = [];
5226
- const index = JSON.parse(readFileSync25(join31(root, "rules.index.json"), "utf8"));
5609
+ const index = JSON.parse(readFileSync25(join32(root, "rules.index.json"), "utf8"));
5227
5610
  const judgment = index.filter((r) => r.bucket === "judgment");
5228
5611
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
5229
5612
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
@@ -5232,16 +5615,16 @@ function verifyVerdicts(opts) {
5232
5615
  ...judgment.map((r) => [r.id, r.pass ?? ""])
5233
5616
  ]);
5234
5617
  const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
5235
- const dir = join31(opts.projectRoot, ".jig", "critique", opts.surface);
5236
- const screenFile = readJson(join31(dir, "screen.json"), errors);
5237
- 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);
5238
5621
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
5239
5622
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
5240
5623
  const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
5241
5624
  let rendered = false;
5242
5625
  if (screenFile && screenFile.rendered === true) {
5243
5626
  const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
5244
- const absent = artefacts.filter((a) => !existsSync18(resolve4(opts.projectRoot, a)));
5627
+ const absent = artefacts.filter((a) => !existsSync19(resolve4(opts.projectRoot, a)));
5245
5628
  if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
5246
5629
  else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
5247
5630
  else rendered = true;
@@ -5268,20 +5651,20 @@ function verifyVerdicts(opts) {
5268
5651
  }
5269
5652
 
5270
5653
  // src/commands/gate.ts
5271
- import { existsSync as existsSync20, mkdirSync as mkdirSync8, readdirSync as readdirSync13, readFileSync as readFileSync27, writeFileSync as writeFileSync7 } from "fs";
5654
+ import { existsSync as existsSync21, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync27, writeFileSync as writeFileSync7 } from "fs";
5272
5655
  import { createHash as createHash2 } from "crypto";
5273
- import { join as join33 } from "path";
5656
+ import { join as join34 } from "path";
5274
5657
 
5275
5658
  // src/check/spec-shape.ts
5276
- import { existsSync as existsSync19, readFileSync as readFileSync26, readdirSync as readdirSync12, statSync as statSync2 } from "fs";
5277
- 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";
5278
5661
  function newestSpec(projectRoot) {
5279
- const dir = join32(projectRoot, ".jig", "specs");
5280
- if (!existsSync19(dir)) return void 0;
5281
- 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"));
5282
5665
  if (files.length === 0) return void 0;
5283
- const newest = files.map((f) => ({ f, at: statSync2(join32(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
5284
- 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") };
5285
5668
  }
5286
5669
  function specProblems(spec) {
5287
5670
  const parts = spec.body.split(/^---\s*$/m);
@@ -5350,7 +5733,7 @@ function navProblems(spec) {
5350
5733
  // src/commands/gate.ts
5351
5734
  var MAX_BLOCKS = 3;
5352
5735
  function lastJigCommand(transcriptPath) {
5353
- if (!transcriptPath || !existsSync20(transcriptPath)) return void 0;
5736
+ if (!transcriptPath || !existsSync21(transcriptPath)) return void 0;
5354
5737
  let text;
5355
5738
  try {
5356
5739
  text = readFileSync27(transcriptPath, "utf8");
@@ -5371,7 +5754,7 @@ function commandProblems(root, command) {
5371
5754
  const problems = [];
5372
5755
  const spec = newestSpec(root);
5373
5756
  if (command === "decide") {
5374
- 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));
5375
5758
  if (!file) problems.push("decide wrote no DECISIONS.md beside the token layer.");
5376
5759
  else {
5377
5760
  const body = readFileSync27(file, "utf8");
@@ -5395,13 +5778,13 @@ function commandProblems(root, command) {
5395
5778
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
5396
5779
  if (/^approved/i.test(mockup)) {
5397
5780
  if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
5398
- 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.`);
5399
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.`);
5400
5783
  }
5401
5784
  }
5402
5785
  if (command === "critique") {
5403
- const dir = join33(root, ".jig", "critique");
5404
- 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"))) : [];
5405
5788
  for (const surface of surfaces) {
5406
5789
  const v = verifyVerdicts({ projectRoot: root, surface });
5407
5790
  if (v.ok && v.screen.state === "skipped") {
@@ -5414,9 +5797,19 @@ function commandProblems(root, command) {
5414
5797
  }
5415
5798
  return problems;
5416
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
+ }
5417
5810
  function gate(opts) {
5418
5811
  const root = opts.projectRoot;
5419
- if (!existsSync20(join33(root, "jig.config.json")) && !existsSync20(join33(root, ".jig"))) {
5812
+ if (!existsSync21(join34(root, "jig.config.json")) && !existsSync21(join34(root, ".jig"))) {
5420
5813
  return { block: false, reason: "" };
5421
5814
  }
5422
5815
  const command = lastJigCommand(opts.input.transcript_path);
@@ -5435,11 +5828,11 @@ function gate(opts) {
5435
5828
  );
5436
5829
  }
5437
5830
  }
5438
- const critiqueDir = join33(root, ".jig", "critique");
5439
- if (existsSync20(critiqueDir)) {
5440
- for (const surface of readdirSync13(critiqueDir)) {
5441
- const dir = join33(critiqueDir, surface);
5442
- 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;
5443
5836
  const v = verifyVerdicts({ projectRoot: root, surface });
5444
5837
  if (!v.ok) {
5445
5838
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -5453,7 +5846,7 @@ function gate(opts) {
5453
5846
  }
5454
5847
  const session = opts.input.session_id ?? "unknown";
5455
5848
  const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
5456
- const stateFile = join33(root, ".jig", "gate.json");
5849
+ const stateFile = join34(root, ".jig", "gate.json");
5457
5850
  let state = {};
5458
5851
  try {
5459
5852
  state = JSON.parse(readFileSync27(stateFile, "utf8"));
@@ -5486,7 +5879,7 @@ ${problems.join("\n\n")}`
5486
5879
  }
5487
5880
  function save(file, state) {
5488
5881
  try {
5489
- mkdirSync8(join33(file, ".."), { recursive: true });
5882
+ mkdirSync8(join34(file, ".."), { recursive: true });
5490
5883
  writeFileSync7(file, JSON.stringify(state), "utf8");
5491
5884
  } catch {
5492
5885
  }
@@ -5494,7 +5887,7 @@ function save(file, state) {
5494
5887
 
5495
5888
  // src/index.ts
5496
5889
  var packageRoot = getPackageRoot();
5497
- var { version } = JSON.parse(readFileSync28(join34(packageRoot, "package.json"), "utf8"));
5890
+ var { version } = JSON.parse(readFileSync28(join35(packageRoot, "package.json"), "utf8"));
5498
5891
  function warnIfUnpublishedPin() {
5499
5892
  if (isPublishedBuild(packageRoot)) return;
5500
5893
  console.warn(
@@ -5532,7 +5925,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
5532
5925
  projectRoot,
5533
5926
  packageRoot: assetRoot(),
5534
5927
  version,
5535
- homeDir: homedir(),
5928
+ homeDir: homedir2(),
5536
5929
  hook
5537
5930
  });
5538
5931
  if (result.warning) {
@@ -5570,7 +5963,7 @@ program.command("update").description("Update vendored Jig rules, skipping files
5570
5963
  projectRoot,
5571
5964
  packageRoot: assetRoot(),
5572
5965
  version,
5573
- homeDir: homedir()
5966
+ homeDir: homedir2()
5574
5967
  });
5575
5968
  const label = result.targets.map((t) => `${t.agent} (${t.scope}, ${t.fromVersion})`).join(", ");
5576
5969
  warnIfUnpublishedPin();
@@ -5608,7 +6001,7 @@ program.command("check").description("Check the repo against Jig's mechanical +
5608
6001
  try {
5609
6002
  const result = check({
5610
6003
  projectRoot,
5611
- homeDir: homedir(),
6004
+ homeDir: homedir2(),
5612
6005
  version,
5613
6006
  all: opts.all,
5614
6007
  ci: opts.ci
@@ -5620,7 +6013,23 @@ program.command("check").description("Check the repo against Jig's mechanical +
5620
6013
  process.exit(1);
5621
6014
  }
5622
6015
  });
5623
- 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
+ }
5624
6033
  if (!opts.save) {
5625
6034
  console.log(PROBE_SCRIPT);
5626
6035
  return;
@@ -5643,7 +6052,7 @@ program.command("probe").description("Print the render probe. With --save, read
5643
6052
  process.exit(1);
5644
6053
  }
5645
6054
  });
5646
- 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 () => {
5647
6056
  let input = {};
5648
6057
  try {
5649
6058
  if (!process.stdin.isTTY) input = JSON.parse(readFileSync28(0, "utf8") || "{}");
@@ -5651,7 +6060,16 @@ program.command("gate").description("Run by the Claude Code Stop hook: block sto
5651
6060
  }
5652
6061
  const cwd = input.cwd ?? process.cwd();
5653
6062
  try {
5654
- 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 });
5655
6073
  if (result.block) console.log(JSON.stringify({ decision: "block", reason: result.reason }));
5656
6074
  else if (result.reason) console.error(result.reason);
5657
6075
  } catch (err) {
@@ -5665,7 +6083,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
5665
6083
  await init({
5666
6084
  projectRoot,
5667
6085
  packageRoot: assetRoot(),
5668
- homeDir: homedir(),
6086
+ homeDir: homedir2(),
5669
6087
  version,
5670
6088
  yes: opts.yes
5671
6089
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.12.0",
4
- "description": "A design system for coding agents. 114 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
@@ -88,7 +88,7 @@ Whichever you choose, be consistent across sibling elements — a list where thr
88
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
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
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. It is not a rule about your documentation, your commit messages or this file.
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
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
93
 
94
94
  ### I-87 Inconsistent vocabulary
package/rules.index.json CHANGED
@@ -800,5 +800,12 @@
800
800
  "severity": "warning",
801
801
  "since": "0.12.0",
802
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"
803
810
  }
804
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": {