jig-ui 0.17.3 → 0.18.1

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,76 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.1
4
+
5
+ Two fixes found by jig-site's fourth round: the probe misread a `<details>`
6
+ menu, and `A-05` missed an arrow that renders as an emoji.
7
+
8
+ ### Fixed
9
+
10
+ - **The probe reads a `<details>` menu the way the browser shows it.** On
11
+ jig-site's Reference it reported a working phone menu as broken, and
12
+ `jig verdicts` refused a correct `P-14` verdict on the strength of it. Four
13
+ defects, each now covered by a browser test:
14
+ - Where the banner's menu is hidden at a width, the probe took the next
15
+ disclosure in some other `<nav>` (a docs rail's section tree) as the menu,
16
+ and reported "a menu button beside links that already show". A menu the
17
+ page names, or one in its banner, is now the only candidate once it exists.
18
+ - A closed `<details>` hides its content with `content-visibility` in
19
+ Chromium, which still gives its links boxes, so the closed menu's links
20
+ counted as showing and opening it "showed no more links". Visibility now
21
+ also asks `checkVisibility()`.
22
+ - A label swapped with CSS ("Menu" to "Close") keeps both words in
23
+ `textContent`; the label is now read from the rendered text.
24
+ - A native `<summary>` has no `aria-expanded` attribute; its expanded state
25
+ is its `<details>`'s `open`, which is what assistive technology is given.
26
+
27
+ Probe files are now version 6, so earlier ones are re-recorded.
28
+
29
+ - **`A-05` catches an arrow that turns into an emoji.** ↔ ↕ ↖ ↗ ↘ ↙ ↩ ↪ ▶ ◀
30
+ are text by default but have an emoji form, and the detector skipped the
31
+ whole arrow block so prose arrows would not fire. jig-site marked its
32
+ external links with `<span aria-hidden="true">↗</span>`, which some platforms
33
+ draw as a colour emoji, and `check --all` and three critiques passed it. One
34
+ of these characters now fires when it stands alone as an icon (the whole
35
+ content of an element, or a pseudo-element's `content`) without U+FE0E. The
36
+ same arrow in a sentence still does not.
37
+
38
+ ## 0.18.0
39
+
40
+ A page's frame gets tokens, a project gets a place to record where its layout
41
+ switches, and keyboard access a rule requires fits every script budget. All of
42
+ it found building jig-site's header and Reference with the full loop.
43
+
44
+ ### Added
45
+
46
+ - **Keyboard access a rule requires does not count against a script budget.**
47
+ `P-14` requires `Escape` to close an open menu, and `editorial`'s budget was
48
+ 0 KB, with `<details>` as the menu, which cannot do it. No editorial page
49
+ could satisfy both, and a real site's home and not-found pages kept a menu a
50
+ keyboard could not dismiss. `M-01` now allows, in every mode, a few lines
51
+ under 1 KB whose only job is keyboard behaviour a rule asks for and the
52
+ browser does not give, with the rule named in a comment. `P-14` carries the
53
+ six lines for `Escape`.
54
+ - **Layout sizes: `--size-container`, `--size-rail`, `--size-header`.** Every
55
+ mode now sizes a page's frame, not only what is inside it. Building one site
56
+ with the full loop, four gaps turned out to be one: no token for a page's
57
+ maximum width, a rail's width, or a fixed header's height. The site made up
58
+ its own, one of them a raw pixel value. The container is 1280px in every
59
+ mode, because it is not density. A rail is 288, 256 or 240px, sized to hold
60
+ labels at the mode's type size. The header is `--size-touch-target` plus
61
+ `--border-width-hairline`. See T-04.
62
+ - **Where a layout switches is measured and recorded.** Jig still defines no
63
+ breakpoint, and the four judged widths are checkpoints, not places the CSS
64
+ changes. T-04 now says how a project records a width it measured:
65
+ `--breakpoint-<what switches>` in its own layer, with the measurement
66
+ beside it.
67
+ - **`jig probe --run` measures either side of every declared switch.** A switch
68
+ falls between the judged widths by design, and a range nobody measured is
69
+ where a layout breaks: a real site's three-column frame appeared at 1216px,
70
+ and 1024 to 1215 still got the phone arrangement. The probe now also records
71
+ one pixel before and at each `--breakpoint-*` the project declares, and the
72
+ gate's refresh keeps those probes current too.
73
+
3
74
  ## 0.17.3
4
75
 
5
76
  Three defects found building jig-site's header with the full loop.
package/dist/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import { readFileSync as readFileSync32 } from "fs";
5
+ import { readFileSync as readFileSync33 } from "fs";
6
6
  import { homedir as homedir2 } from "os";
7
- import { join as join39 } from "path";
7
+ import { join as join40 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -2265,6 +2265,11 @@ function maskProseComments(raw) {
2265
2265
  // src/check/detectors/emoji-icon.ts
2266
2266
  var EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F2FF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}]/gu;
2267
2267
  var TEXTUAL = /* @__PURE__ */ new Set(["\u2192", "\u2190", "\u2191", "\u2193", "\u2194", "\u21D2", "\u21D0", "\u2122", "\u2713", "\u2717", "\u2212", "\u2217", "\u2318", "\u2325", "\u23CE"]);
2268
+ var EMOJI_CAPABLE = "\\u2194-\\u2199\\u21A9\\u21AA\\u25B6\\u25C0";
2269
+ var LONE_GLYPH = new RegExp(
2270
+ `>\\s*([${EMOJI_CAPABLE}])(?!\\uFE0E)\\s*<|content\\s*:\\s*["']([${EMOJI_CAPABLE}])(?!\\uFE0E)["']`,
2271
+ "gu"
2272
+ );
2268
2273
  var emojiIcon = {
2269
2274
  name: "emoji-icon",
2270
2275
  appliesTo: (file) => isStyleBearing(file),
@@ -2289,6 +2294,22 @@ var emojiIcon = {
2289
2294
  )
2290
2295
  );
2291
2296
  }
2297
+ for (const m of masked.matchAll(LONE_GLYPH)) {
2298
+ const glyph = m[1] ?? m[2];
2299
+ const line = masked.slice(0, m.index + m[0].indexOf(glyph)).split("\n").length;
2300
+ if (seen.has(line)) continue;
2301
+ seen.add(line);
2302
+ findings.push(
2303
+ mkFinding(
2304
+ ctx,
2305
+ "emoji-icon",
2306
+ file,
2307
+ line,
2308
+ `A character with an emoji form used as an icon (${glyph}). Standing alone, it renders as a colour emoji on some platforms. Use an icon set, or words.`,
2309
+ lines[line - 1] ?? ""
2310
+ )
2311
+ );
2312
+ }
2292
2313
  return findings;
2293
2314
  }
2294
2315
  };
@@ -5652,8 +5673,8 @@ Could not write to ${wireTarget}: ${err.message}`);
5652
5673
  }
5653
5674
 
5654
5675
  // src/commands/verdicts.ts
5655
- import { existsSync as existsSync23, readFileSync as readFileSync29 } from "fs";
5656
- import { join as join36, resolve as resolve5 } from "path";
5676
+ import { existsSync as existsSync23, readFileSync as readFileSync30 } from "fs";
5677
+ import { join as join37, resolve as resolve5 } from "path";
5657
5678
 
5658
5679
  // src/rules/citations.ts
5659
5680
  import { join as join29 } from "path";
@@ -5667,19 +5688,22 @@ function citableIds(packageRoot2) {
5667
5688
  }
5668
5689
 
5669
5690
  // src/probe/check.ts
5670
- import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as readFileSync26 } from "fs";
5671
- import { join as join33 } from "path";
5691
+ import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as readFileSync27 } from "fs";
5692
+ import { join as join34 } from "path";
5672
5693
 
5673
5694
  // src/probe/save.ts
5674
- import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync25, writeFileSync as writeFileSync6 } from "fs";
5695
+ import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync26, writeFileSync as writeFileSync6 } from "fs";
5675
5696
  import { fileURLToPath as fileURLToPath2 } from "url";
5676
- import { join as join32, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
5697
+ import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
5677
5698
 
5678
5699
  // src/probe/script.ts
5679
- var PROBE_VERSION = 5;
5700
+ var PROBE_VERSION = 6;
5680
5701
  var PROBE_SCRIPT = `(async () => {
5681
5702
  const doc = document.documentElement;
5682
- const vis = (e) => { const r = e.getBoundingClientRect(); const s = getComputedStyle(e); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
5703
+ // checkVisibility also sees content a closed <details> hides: Chromium hides
5704
+ // it with content-visibility, which still gives its links real boxes, so a
5705
+ // closed menu counted as open and an opened one showed "no more links".
5706
+ const vis = (e) => { const r = e.getBoundingClientRect(); const s = getComputedStyle(e); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none' && (!e.checkVisibility || e.checkVisibility()); };
5683
5707
  const probe = document.createElement('div');
5684
5708
  probe.style.all = 'initial';
5685
5709
  document.body.appendChild(probe);
@@ -5772,19 +5796,32 @@ var PROBE_SCRIPT = `(async () => {
5772
5796
  const inChrome = (b) => !!b.closest('header, [role=banner]') ||
5773
5797
  (!!b.closest('nav, [role=navigation]') && !b.closest('main, article, aside, [role=main]'));
5774
5798
  const named = (b) => /\b(menu|navigation)\b/i.test((b.getAttribute('aria-label') || '') + ' ' + b.textContent);
5775
- const toggle = [...document.querySelectorAll('button, summary, [role=button]')].find((b) => vis(b) &&
5776
- (named(b) || (inChrome(b) && (b.tagName === 'SUMMARY' || b.hasAttribute('aria-expanded') || b.hasAttribute('aria-controls')))));
5799
+ const candidates = [...document.querySelectorAll('button, summary, [role=button]')].filter((b) =>
5800
+ named(b) || (inChrome(b) && (b.tagName === 'SUMMARY' || b.hasAttribute('aria-expanded') || b.hasAttribute('aria-controls'))));
5801
+ // A menu the page names, or one in its banner, is the menu. Where it exists
5802
+ // but is hidden at this width, the links show instead and there is no menu
5803
+ // here; the next disclosure in some other <nav> (a docs rail's tree) is not
5804
+ // a stand-in for it.
5805
+ const primary = (b) => named(b) || !!b.closest('header, [role=banner]');
5806
+ const pool = candidates.some(primary) ? candidates.filter(primary) : candidates;
5807
+ const toggle = pool.find(vis);
5777
5808
  let menu = null;
5778
5809
  if (toggle) {
5779
- const name = () => ((toggle.getAttribute('aria-label') || '') + ' ' + (toggle.textContent || '')).replace(/\\s+/g, ' ').trim();
5810
+ // The label as rendered: a menu that swaps "Menu" for "Close" with CSS keeps
5811
+ // both words in its textContent at all times, so only innerText sees the swap.
5812
+ const name = () => ((toggle.getAttribute('aria-label') || '') + ' ' + (toggle.innerText || toggle.textContent || '')).replace(/\\s+/g, ' ').trim();
5813
+ // A native <summary> carries no aria-expanded attribute; the browser exposes
5814
+ // its <details>'s open state as the expanded state, which is what counts.
5815
+ const expanded = () => toggle.getAttribute('aria-expanded') ??
5816
+ (toggle.tagName === 'SUMMARY' && toggle.parentElement && toggle.parentElement.tagName === 'DETAILS' ? String(toggle.parentElement.open) : null);
5780
5817
  // Opening is judged by what becomes visible anywhere: a menu panel is often
5781
5818
  // a sibling of the header, not inside it (arm test 3, tw-1).
5782
5819
  const allLinks = () => [...document.querySelectorAll('a')].filter(vis).length;
5783
5820
  const controlled = () => { const id = toggle.getAttribute('aria-controls'); const el = id && document.getElementById(id); return el ? vis(el) : null; };
5784
- const before = { links: allLinks(), controlled: controlled(), expanded: toggle.getAttribute('aria-expanded'), name: name(), html: toggle.innerHTML };
5821
+ const before = { links: allLinks(), controlled: controlled(), expanded: expanded(), name: name(), html: toggle.innerHTML };
5785
5822
  toggle.click();
5786
5823
  await new Promise((r) => setTimeout(r, 350));
5787
- const after = { links: allLinks(), controlled: controlled(), expanded: toggle.getAttribute('aria-expanded'), name: name(), htmlChanged: toggle.innerHTML !== before.html };
5824
+ const after = { links: allLinks(), controlled: controlled(), expanded: expanded(), name: name(), htmlChanged: toggle.innerHTML !== before.html };
5788
5825
  toggle.focus();
5789
5826
  toggle.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
5790
5827
  document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
@@ -5794,7 +5831,7 @@ var PROBE_SCRIPT = `(async () => {
5794
5831
  expandedBefore: before.expanded, expandedAfter: after.expanded,
5795
5832
  opened: after.links > before.links || (before.controlled === false && after.controlled === true),
5796
5833
  labelChanged: after.name !== before.name || after.htmlChanged,
5797
- escapeCloses: toggle.getAttribute('aria-expanded') !== 'true' && allLinks() <= before.links,
5834
+ escapeCloses: expanded() !== 'true' && allLinks() <= before.links,
5798
5835
  focusReturned: document.activeElement === toggle,
5799
5836
  };
5800
5837
  }
@@ -6055,6 +6092,31 @@ async function serveDirectory(dir) {
6055
6092
  };
6056
6093
  }
6057
6094
 
6095
+ // src/probe/switches.ts
6096
+ import { readFileSync as readFileSync25 } from "fs";
6097
+ import { join as join32 } from "path";
6098
+ var DECLARATION = /--breakpoint-([a-z0-9-]+)\s*:\s*(\d+(?:\.\d+)?)(px|rem)\b/g;
6099
+ function declaredSwitches(projectRoot) {
6100
+ const found = /* @__PURE__ */ new Map();
6101
+ for (const file of wholeRepoFiles(projectRoot)) {
6102
+ if (!/\.(css|scss|astro|vue|svelte|html)$/i.test(file)) continue;
6103
+ let text;
6104
+ try {
6105
+ text = readFileSync25(join32(projectRoot, file), "utf8");
6106
+ } catch {
6107
+ continue;
6108
+ }
6109
+ for (const m of text.matchAll(DECLARATION)) {
6110
+ const px2 = Math.round(m[3] === "rem" ? Number(m[2]) * 16 : Number(m[2]));
6111
+ if (px2 > 0) found.set(`${m[1]}:${px2}`, { name: m[1], px: px2 });
6112
+ }
6113
+ }
6114
+ return [...found.values()].sort((a, b) => a.px - b.px);
6115
+ }
6116
+ function probeWidths(base, switches) {
6117
+ return [.../* @__PURE__ */ new Set([...base, ...switches.flatMap((s) => [s.px - 1, s.px])])].sort((a, b) => a - b);
6118
+ }
6119
+
6058
6120
  // src/probe/save.ts
6059
6121
  function saveProbe(opts) {
6060
6122
  let probe;
@@ -6075,11 +6137,11 @@ function saveProbe(opts) {
6075
6137
  }
6076
6138
  probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
6077
6139
  if (opts.serveRoot) probe.serveRoot = relative3(opts.projectRoot, resolve4(opts.projectRoot, opts.serveRoot)).split("\\").join("/") || ".";
6078
- probe.pageChecksum = checksum(readFileSync25(page, "utf8"));
6140
+ probe.pageChecksum = checksum(readFileSync26(page, "utf8"));
6079
6141
  probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
6080
- const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
6142
+ const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
6081
6143
  mkdirSync7(dir, { recursive: true });
6082
- const path = join32(dir, `probe-${width}.json`);
6144
+ const path = join33(dir, `probe-${width}.json`);
6083
6145
  writeFileSync6(path, JSON.stringify(probe), "utf8");
6084
6146
  return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
6085
6147
  }
@@ -6105,8 +6167,9 @@ async function runAndSaveProbes(opts) {
6105
6167
  const abs = resolve4(opts.projectRoot, opts.page);
6106
6168
  if (!existsSync19(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
6107
6169
  const saved = [];
6170
+ const widths = opts.widths ?? probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot));
6108
6171
  await withPageUrl(opts.projectRoot, abs, opts.serve, async (url) => {
6109
- for (const width of opts.widths ?? PROBE_WIDTHS) {
6172
+ for (const width of widths) {
6110
6173
  const json = await runProbe({ url, width });
6111
6174
  saved.push(saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json, page: abs, serveRoot: opts.serve }));
6112
6175
  }
@@ -6127,17 +6190,19 @@ async function withPageUrl(projectRoot, abs, serve, use) {
6127
6190
  }
6128
6191
  }
6129
6192
  async function ensureProbes(opts) {
6130
- const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
6193
+ const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
6131
6194
  const abs = resolve4(opts.projectRoot, opts.page);
6132
6195
  let current;
6133
6196
  try {
6134
- current = checksum(readFileSync25(abs, "utf8"));
6197
+ current = checksum(readFileSync26(abs, "utf8"));
6135
6198
  } catch {
6136
6199
  return { recorded: [], reason: `${opts.page} could not be read` };
6137
6200
  }
6138
- const missing = PROBE_WIDTHS.filter((width) => {
6201
+ const recorded = existsSync19(dir) ? readdirSync12(dir).map((f) => /^probe-(\d+)\.json$/.exec(f)?.[1]).filter((w) => !!w).map(Number) : [];
6202
+ const widths = [.../* @__PURE__ */ new Set([...probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot)), ...recorded])].sort((a, b) => a - b);
6203
+ const missing = widths.filter((width) => {
6139
6204
  try {
6140
- const probe = JSON.parse(readFileSync25(join32(dir, `probe-${width}.json`), "utf8"));
6205
+ const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
6141
6206
  return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
6142
6207
  } catch {
6143
6208
  return true;
@@ -6146,9 +6211,9 @@ async function ensureProbes(opts) {
6146
6211
  if (missing.length === 0) return { recorded: [] };
6147
6212
  if (!findChrome()) return { recorded: [], reason: "no browser on this machine" };
6148
6213
  let serve;
6149
- for (const width of PROBE_WIDTHS) {
6214
+ for (const width of widths) {
6150
6215
  try {
6151
- const probe = JSON.parse(readFileSync25(join32(dir, `probe-${width}.json`), "utf8"));
6216
+ const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
6152
6217
  if (probe.serveRoot) {
6153
6218
  serve = probe.serveRoot;
6154
6219
  break;
@@ -6164,9 +6229,9 @@ async function ensureProbes(opts) {
6164
6229
  return { recorded: missing };
6165
6230
  }
6166
6231
  function critiquedSurfaces(projectRoot) {
6167
- const root = join32(projectRoot, ".jig", "critique");
6232
+ const root = join33(projectRoot, ".jig", "critique");
6168
6233
  if (!existsSync19(root)) return [];
6169
- return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (existsSync19(join32(root, surface, "screen.json")) || existsSync19(join32(root, surface, "code.json"))));
6234
+ return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (existsSync19(join33(root, surface, "screen.json")) || existsSync19(join33(root, surface, "code.json"))));
6170
6235
  }
6171
6236
 
6172
6237
  // src/probe/check.ts
@@ -6178,7 +6243,7 @@ function stampProblem(projectRoot, file, p) {
6178
6243
  if (!page) return `${file} names a page outside this project (${p.serveRoot ? p.pageFile : p.url ?? "no url"}).`;
6179
6244
  let current;
6180
6245
  try {
6181
- current = readFileSync26(page, "utf8");
6246
+ current = readFileSync27(page, "utf8");
6182
6247
  } catch {
6183
6248
  return `${file} was taken on ${p.pageFile}, which no longer exists.`;
6184
6249
  }
@@ -6192,7 +6257,7 @@ function readProbes(projectRoot, dir, errors) {
6192
6257
  const probes = [];
6193
6258
  for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
6194
6259
  try {
6195
- const p = JSON.parse(readFileSync26(join33(dir, f), "utf8"));
6260
+ const p = JSON.parse(readFileSync27(join34(dir, f), "utf8"));
6196
6261
  if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
6197
6262
  errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
6198
6263
  continue;
@@ -6284,19 +6349,19 @@ function probeContradictions(probes, verdictOf, indexable = true) {
6284
6349
  }
6285
6350
 
6286
6351
  // src/check/decisions.ts
6287
- import { existsSync as existsSync21, readFileSync as readFileSync27 } from "fs";
6288
- import { join as join34, posix as posix3 } from "path";
6352
+ import { existsSync as existsSync21, readFileSync as readFileSync28 } from "fs";
6353
+ import { join as join35, posix as posix3 } from "path";
6289
6354
  var HEADING3 = /^(#{2,3})\s+(.+?)\s*$/gm;
6290
6355
  var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
6291
6356
  function decisionsFile(projectRoot) {
6292
6357
  const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
6293
6358
  try {
6294
- const brand = JSON.parse(readFileSync27(join34(projectRoot, "jig.config.json"), "utf8")).brand;
6359
+ const brand = JSON.parse(readFileSync28(join35(projectRoot, "jig.config.json"), "utf8")).brand;
6295
6360
  if (typeof brand === "string" && brand.includes("/")) candidates.unshift(`${posix3.dirname(brand.replace(/^\.\//, ""))}/DECISIONS.md`);
6296
6361
  } catch {
6297
6362
  }
6298
6363
  for (const candidate of candidates) {
6299
- if (existsSync21(join34(projectRoot, candidate))) return candidate;
6364
+ if (existsSync21(join35(projectRoot, candidate))) return candidate;
6300
6365
  }
6301
6366
  return void 0;
6302
6367
  }
@@ -6305,7 +6370,7 @@ function decisionNames(projectRoot) {
6305
6370
  if (!path) return [];
6306
6371
  let body;
6307
6372
  try {
6308
- body = readFileSync27(join34(projectRoot, path), "utf8");
6373
+ body = readFileSync28(join35(projectRoot, path), "utf8");
6309
6374
  } catch {
6310
6375
  return [];
6311
6376
  }
@@ -6319,15 +6384,15 @@ function decisionNames(projectRoot) {
6319
6384
  }
6320
6385
 
6321
6386
  // src/check/spec-shape.ts
6322
- import { existsSync as existsSync22, readFileSync as readFileSync28, readdirSync as readdirSync14, statSync as statSync3 } from "fs";
6323
- import { join as join35 } from "path";
6387
+ import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync3 } from "fs";
6388
+ import { join as join36 } from "path";
6324
6389
  function newestSpec(projectRoot) {
6325
- const dir = join35(projectRoot, ".jig", "specs");
6390
+ const dir = join36(projectRoot, ".jig", "specs");
6326
6391
  if (!existsSync22(dir)) return void 0;
6327
6392
  const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
6328
6393
  if (files.length === 0) return void 0;
6329
- const newest = files.map((f) => ({ f, at: statSync3(join35(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6330
- return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync28(join35(dir, newest), "utf8") };
6394
+ const newest = files.map((f) => ({ f, at: statSync3(join36(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6395
+ return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync29(join36(dir, newest), "utf8") };
6331
6396
  }
6332
6397
  function specProblems(spec) {
6333
6398
  const parts = spec.body.split(/^---\s*$/m);
@@ -6410,17 +6475,17 @@ var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (fi
6410
6475
  function readJson(path, errors) {
6411
6476
  if (!existsSync23(path)) return null;
6412
6477
  try {
6413
- return JSON.parse(readFileSync29(path, "utf8"));
6478
+ return JSON.parse(readFileSync30(path, "utf8"));
6414
6479
  } catch (e) {
6415
6480
  errors.push(`${path}: not valid JSON (${e.message})`);
6416
6481
  return {};
6417
6482
  }
6418
6483
  }
6419
6484
  function specIndexable(projectRoot, surface) {
6420
- const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6485
+ const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6421
6486
  let front = "";
6422
6487
  try {
6423
- front = readFileSync29(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6488
+ front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6424
6489
  } catch {
6425
6490
  }
6426
6491
  const declared = specIndexableField(front);
@@ -6429,9 +6494,9 @@ function specIndexable(projectRoot, surface) {
6429
6494
  return mode !== "product" && mode !== "operator";
6430
6495
  }
6431
6496
  function specNeedsNav(projectRoot, surface) {
6432
- const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6497
+ const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6433
6498
  if (!existsSync23(path)) return false;
6434
- const front = readFileSync29(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6499
+ const front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6435
6500
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
6436
6501
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
6437
6502
  return navField || navRegion;
@@ -6482,7 +6547,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
6482
6547
  function checkDecisions(projectRoot, dir, errors) {
6483
6548
  const required = decisionNames(projectRoot);
6484
6549
  if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
6485
- const file = readJson(join36(dir, "decisions.json"), errors);
6550
+ const file = readJson(join37(dir, "decisions.json"), errors);
6486
6551
  if (!file) {
6487
6552
  errors.push(
6488
6553
  `decisions.json is missing. Every decision in DECISIONS.md is judged against the built page, one verdict each: ${required.slice(0, 4).join(", ")}${required.length > 4 ? `, and ${required.length - 4} more` : ""}. A page can satisfy every rule and still break what this project decided.`
@@ -6527,7 +6592,7 @@ function checkDecisions(projectRoot, dir, errors) {
6527
6592
  function verifyVerdicts(opts) {
6528
6593
  const root = opts.packageRoot ?? assetRoot();
6529
6594
  const errors = [];
6530
- const index = JSON.parse(readFileSync29(join36(root, "rules.index.json"), "utf8"));
6595
+ const index = JSON.parse(readFileSync30(join37(root, "rules.index.json"), "utf8"));
6531
6596
  const judgment = index.filter((r) => r.bucket === "judgment" || r.bucket === "hybrid" && r.pass !== void 0);
6532
6597
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
6533
6598
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
@@ -6536,9 +6601,9 @@ function verifyVerdicts(opts) {
6536
6601
  ...judgment.map((r) => [r.id, r.pass ?? ""])
6537
6602
  ]);
6538
6603
  const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
6539
- const dir = join36(opts.projectRoot, ".jig", "critique", opts.surface);
6540
- const screenFile = readJson(join36(dir, "screen.json"), errors);
6541
- const codeFile = readJson(join36(dir, "code.json"), errors);
6604
+ const dir = join37(opts.projectRoot, ".jig", "critique", opts.surface);
6605
+ const screenFile = readJson(join37(dir, "screen.json"), errors);
6606
+ const codeFile = readJson(join37(dir, "code.json"), errors);
6542
6607
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
6543
6608
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
6544
6609
  const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
@@ -6566,7 +6631,7 @@ function verifyVerdicts(opts) {
6566
6631
  };
6567
6632
  let specFront = "";
6568
6633
  try {
6569
- specFront = readFileSync29(join36(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
6634
+ specFront = readFileSync30(join37(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
6570
6635
  } catch {
6571
6636
  }
6572
6637
  if (specIndexableField(specFront) === "unreadable") {
@@ -6582,15 +6647,15 @@ function verifyVerdicts(opts) {
6582
6647
  }
6583
6648
 
6584
6649
  // src/commands/gate.ts
6585
- import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync30, statSync as statSync4, writeFileSync as writeFileSync7 } from "fs";
6650
+ import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync31, statSync as statSync4, writeFileSync as writeFileSync7 } from "fs";
6586
6651
  import { createHash as createHash2 } from "crypto";
6587
- import { join as join37 } from "path";
6652
+ import { join as join38 } from "path";
6588
6653
  var MAX_BLOCKS = 3;
6589
6654
  function lastJigCommand(transcriptPath) {
6590
6655
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6591
6656
  let text;
6592
6657
  try {
6593
- text = readFileSync30(transcriptPath, "utf8");
6658
+ text = readFileSync31(transcriptPath, "utf8");
6594
6659
  } catch {
6595
6660
  return void 0;
6596
6661
  }
@@ -6608,7 +6673,7 @@ function lastAssistantText(transcriptPath) {
6608
6673
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6609
6674
  let found;
6610
6675
  try {
6611
- for (const line of readFileSync30(transcriptPath, "utf8").split("\n")) {
6676
+ for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
6612
6677
  if (!line.includes('"assistant"')) continue;
6613
6678
  let entry;
6614
6679
  try {
@@ -6634,7 +6699,7 @@ var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup"]);
6634
6699
  function sessionStart(transcriptPath) {
6635
6700
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6636
6701
  try {
6637
- for (const line of readFileSync30(transcriptPath, "utf8").split("\n")) {
6702
+ for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
6638
6703
  const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
6639
6704
  if (m) {
6640
6705
  const t = Date.parse(m[1]);
@@ -6649,20 +6714,20 @@ function verdictsMtime(dir) {
6649
6714
  let newest = 0;
6650
6715
  for (const name of VERDICT_FILES) {
6651
6716
  try {
6652
- newest = Math.max(newest, statSync4(join37(dir, name)).mtimeMs);
6717
+ newest = Math.max(newest, statSync4(join38(dir, name)).mtimeMs);
6653
6718
  } catch {
6654
6719
  }
6655
6720
  }
6656
6721
  return newest;
6657
6722
  }
6658
6723
  function surfacesInPlay(root, command, transcriptPath) {
6659
- const critiqueDir = join37(root, ".jig", "critique");
6724
+ const critiqueDir = join38(root, ".jig", "critique");
6660
6725
  if (!existsSync24(critiqueDir)) return [];
6661
6726
  const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
6662
6727
  const start = sessionStart(transcriptPath);
6663
6728
  if (start === void 0) return all;
6664
6729
  const current = command === "critique" ? /^\s*surface\s*:\s*(.+)$/im.exec(newestSpec(root)?.body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim().replace(/^["']|["']$/g, "") : void 0;
6665
- return all.filter((s) => s === current || verdictsMtime(join37(critiqueDir, s)) >= start - 1e3);
6730
+ return all.filter((s) => s === current || verdictsMtime(join38(critiqueDir, s)) >= start - 1e3);
6666
6731
  }
6667
6732
  function commandProblems(root, command) {
6668
6733
  const problems = [];
@@ -6671,7 +6736,7 @@ function commandProblems(root, command) {
6671
6736
  const found = decisionsFile(root);
6672
6737
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
6673
6738
  else {
6674
- const body = readFileSync30(join37(root, found), "utf8");
6739
+ const body = readFileSync31(join38(root, found), "utf8");
6675
6740
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
6676
6741
  problems.push("DECISIONS.md has no `## Unresolved` section. Round 3 asks by name what is still undecided; write what the owner named, or `None named by the owner.`");
6677
6742
  }
@@ -6692,13 +6757,13 @@ function commandProblems(root, command) {
6692
6757
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
6693
6758
  if (/^approved/i.test(mockup)) {
6694
6759
  if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
6695
- else if (!/^https?:/i.test(at) && !existsSync24(join37(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
6760
+ else if (!/^https?:/i.test(at) && !existsSync24(join38(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
6696
6761
  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.`);
6697
6762
  }
6698
6763
  }
6699
6764
  if (command === "critique") {
6700
- const dir = join37(root, ".jig", "critique");
6701
- const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join37(dir, s, "screen.json")) || existsSync24(join37(dir, s, "code.json"))) : [];
6765
+ const dir = join38(root, ".jig", "critique");
6766
+ const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join38(dir, s, "screen.json")) || existsSync24(join38(dir, s, "code.json"))) : [];
6702
6767
  for (const surface of surfaces) {
6703
6768
  const v = verifyVerdicts({ projectRoot: root, surface });
6704
6769
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
@@ -6720,13 +6785,13 @@ function surfacePage(projectRoot, surface) {
6720
6785
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
6721
6786
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
6722
6787
  for (const candidate of candidates) {
6723
- if (candidate && existsSync24(join37(projectRoot, candidate))) return candidate;
6788
+ if (candidate && existsSync24(join38(projectRoot, candidate))) return candidate;
6724
6789
  }
6725
6790
  return void 0;
6726
6791
  }
6727
6792
  function gate(opts) {
6728
6793
  const root = opts.projectRoot;
6729
- if (!existsSync24(join37(root, "jig.config.json")) && !existsSync24(join37(root, ".jig"))) {
6794
+ if (!existsSync24(join38(root, "jig.config.json")) && !existsSync24(join38(root, ".jig"))) {
6730
6795
  return { block: false, reason: "" };
6731
6796
  }
6732
6797
  const command = lastJigCommand(opts.input.transcript_path);
@@ -6753,11 +6818,11 @@ function gate(opts) {
6753
6818
  }
6754
6819
  }
6755
6820
  problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path)));
6756
- const critiqueDir = join37(root, ".jig", "critique");
6821
+ const critiqueDir = join38(root, ".jig", "critique");
6757
6822
  if (existsSync24(critiqueDir)) {
6758
6823
  for (const surface of surfacesInPlay(root, command, opts.input.transcript_path)) {
6759
- const dir = join37(critiqueDir, surface);
6760
- if (!existsSync24(join37(dir, "screen.json")) && !existsSync24(join37(dir, "code.json"))) continue;
6824
+ const dir = join38(critiqueDir, surface);
6825
+ if (!existsSync24(join38(dir, "screen.json")) && !existsSync24(join38(dir, "code.json"))) continue;
6761
6826
  const v = verifyVerdicts({ projectRoot: root, surface });
6762
6827
  if (!v.ok) {
6763
6828
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -6771,10 +6836,10 @@ function gate(opts) {
6771
6836
  }
6772
6837
  const session = opts.input.session_id ?? "unknown";
6773
6838
  const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
6774
- const stateFile = join37(root, ".jig", "gate.json");
6839
+ const stateFile = join38(root, ".jig", "gate.json");
6775
6840
  let state = {};
6776
6841
  try {
6777
- state = JSON.parse(readFileSync30(stateFile, "utf8"));
6842
+ state = JSON.parse(readFileSync31(stateFile, "utf8"));
6778
6843
  } catch {
6779
6844
  }
6780
6845
  if (problems.length === 0) {
@@ -6808,7 +6873,7 @@ function verdictChecksum(dir) {
6808
6873
  const parts = VERDICT_FILES.map((f) => {
6809
6874
  try {
6810
6875
  return `${f}
6811
- ${readFileSync30(join37(dir, f), "utf8")}`;
6876
+ ${readFileSync31(join38(dir, f), "utf8")}`;
6812
6877
  } catch {
6813
6878
  return "";
6814
6879
  }
@@ -6816,15 +6881,15 @@ ${readFileSync30(join37(dir, f), "utf8")}`;
6816
6881
  return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
6817
6882
  }
6818
6883
  function verdictGuard(root, command, inPlay) {
6819
- const critiqueDir = join37(root, ".jig", "critique");
6884
+ const critiqueDir = join38(root, ".jig", "critique");
6820
6885
  if (!existsSync24(critiqueDir)) return [];
6821
6886
  const problems = [];
6822
6887
  for (const surface of readdirSync15(critiqueDir)) {
6823
6888
  if (surface.startsWith("_")) continue;
6824
- const dir = join37(critiqueDir, surface);
6889
+ const dir = join38(critiqueDir, surface);
6825
6890
  const now = verdictChecksum(dir);
6826
6891
  if (!now) continue;
6827
- const lockPath = join37(dir, LOCK);
6892
+ const lockPath = join38(dir, LOCK);
6828
6893
  if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
6829
6894
  try {
6830
6895
  writeFileSync7(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
@@ -6834,7 +6899,7 @@ function verdictGuard(root, command, inPlay) {
6834
6899
  }
6835
6900
  let locked;
6836
6901
  try {
6837
- locked = JSON.parse(readFileSync30(lockPath, "utf8")).checksum;
6902
+ locked = JSON.parse(readFileSync31(lockPath, "utf8")).checksum;
6838
6903
  } catch {
6839
6904
  continue;
6840
6905
  }
@@ -6848,15 +6913,15 @@ function verdictGuard(root, command, inPlay) {
6848
6913
  }
6849
6914
  function save(file, state) {
6850
6915
  try {
6851
- mkdirSync8(join37(file, ".."), { recursive: true });
6916
+ mkdirSync8(join38(file, ".."), { recursive: true });
6852
6917
  writeFileSync7(file, JSON.stringify(state), "utf8");
6853
6918
  } catch {
6854
6919
  }
6855
6920
  }
6856
6921
 
6857
6922
  // src/commands/seo.ts
6858
- import { readFileSync as readFileSync31 } from "fs";
6859
- import { join as join38 } from "path";
6923
+ import { readFileSync as readFileSync32 } from "fs";
6924
+ import { join as join39 } from "path";
6860
6925
  var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
6861
6926
  var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
6862
6927
  var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
@@ -6897,7 +6962,7 @@ function seo(opts) {
6897
6962
  for (const file of files) {
6898
6963
  let raw;
6899
6964
  try {
6900
- raw = readFileSync31(join38(root, file), "utf8");
6965
+ raw = readFileSync32(join39(root, file), "utf8");
6901
6966
  } catch {
6902
6967
  continue;
6903
6968
  }
@@ -6953,7 +7018,7 @@ function seo(opts) {
6953
7018
 
6954
7019
  // src/index.ts
6955
7020
  var packageRoot = getPackageRoot();
6956
- var { version } = JSON.parse(readFileSync32(join39(packageRoot, "package.json"), "utf8"));
7021
+ var { version } = JSON.parse(readFileSync33(join40(packageRoot, "package.json"), "utf8"));
6957
7022
  function warnIfUnpublishedPin() {
6958
7023
  if (isPublishedBuild(packageRoot)) return;
6959
7024
  console.warn(
@@ -7103,7 +7168,7 @@ program.command("seo").description("Audit what a search engine and a link previe
7103
7168
  process.exit(1);
7104
7169
  }
7105
7170
  });
7106
- program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").option("--run <page>", "render this page here, at 360, 768, 1280 and 1600, and record each (needs --save)").option("--serve <dir>", "serve this directory over local http and load --run from it, so root-relative links resolve (a built static site)").action(async (opts) => {
7171
+ program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").option("--run <page>", "render this page here, at 360, 768, 1280 and 1600 and either side of each --breakpoint-* the project declares, and record each (needs --save)").option("--serve <dir>", "serve this directory over local http and load --run from it, so root-relative links resolve (a built static site)").action(async (opts) => {
7107
7172
  if (opts.run) {
7108
7173
  if (!opts.save) {
7109
7174
  console.error(" \u2717 --run records what it measures, so it needs --save <surface>.");
@@ -7145,7 +7210,7 @@ program.command("probe").description("Print the render probe. With --save, read
7145
7210
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
7146
7211
  let input = {};
7147
7212
  try {
7148
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync32(0, "utf8") || "{}");
7213
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync33(0, "utf8") || "{}");
7149
7214
  } catch {
7150
7215
  }
7151
7216
  const cwd = input.cwd ?? process.cwd();
@@ -7184,7 +7249,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
7184
7249
  });
7185
7250
  function resolveSurfaceMode(projectRoot) {
7186
7251
  try {
7187
- const config = JSON.parse(readFileSync32(join39(projectRoot, "jig.config.json"), "utf8"));
7252
+ const config = JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8"));
7188
7253
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
7189
7254
  return modes.length === 1 ? modes[0] : void 0;
7190
7255
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.17.3",
3
+ "version": "0.18.1",
4
4
  "description": "A design system for coding agents. 143 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",
package/rules/01-modes.md CHANGED
@@ -86,7 +86,7 @@ Resolved values: `02-tokens.md` — the option sets for type, spacing, radius an
86
86
  - Every page states its subject above the fold in text, not only in an image.
87
87
  - Prose blocks are measure-capped even when the container is wide.
88
88
  - No horizontal scrolling regions on mobile. Reflow instead.
89
- - Total JS budget for a content page: **0 KB** unless a specific feature requires it. Interactivity is opt-in per component and must be justified in a comment.
89
+ - Total JS budget for a content page: **0 KB** unless a specific feature requires it. Interactivity is opt-in per component and must be justified in a comment. **Keyboard access a rule requires is not a feature and is not counted**: a few lines whose only job is behaviour a rule asks for and the browser does not give, such as `Escape` closing a `<details>` menu (`P-14`), are allowed in every mode, under 1 KB, with the rule named in a comment. Nothing else rides along in them.
90
90
 
91
91
  ---
92
92
 
@@ -80,9 +80,10 @@ Modes **select** from these; they never define their own values. `--spacing-card
80
80
  **A range means the heading is fluid.** `32–48` is not two values to choose
81
81
  between: `--text-h1` interpolates continuously with viewport width, reaching its
82
82
  minimum at a 360px viewport and its maximum at 1024px, saturating outside that
83
- range. There is no breakpoint here and none anywhere else in the system — a
84
- `clamp()` has no threshold to place, which is exactly why it was chosen over a
85
- second `-sm` scale.
83
+ range. There is no breakpoint here, and the system defines none anywhere
84
+ else — a `clamp()` has no threshold to place, which is exactly why it was chosen
85
+ over a second `-sm` scale. Where a project's layout does switch, the project
86
+ measures and records the width (T-04).
86
87
 
87
88
  Editorial `--text-h1` at a fixed 48px gives **13 characters per line** on a
88
89
  360px screen, so a 45-character headline sets as four lines and 211px of
@@ -258,6 +259,55 @@ It is an accessibility floor, so it is excluded from the table above — there i
258
259
  nothing per-mode about it to resolve. The same is true of `--focus-ring-width`
259
260
  and `--focus-ring-offset`, which live in the brand file for that reason.
260
261
 
262
+ **Layout sizes.** Three tokens size a page's frame, where the others size what
263
+ is inside it. A real site found none of them and made up its own three:
264
+
265
+ | Token | `editorial` | `product` | `operator` |
266
+ | --- | --- | --- | --- |
267
+ | `--size-container` | 1280px | 1280px | 1280px |
268
+ | `--size-rail` | 288px | 256px | 240px |
269
+
270
+ - `--size-container` is the page's frame: a rail, a readable column and a rail
271
+ fit inside it, centred, and the viewport less `--grid-margin-sm` below that.
272
+ - `--size-rail` is a side column of navigation or filters, sized to hold labels
273
+ at the mode's type size.
274
+ - `--size-header` is one row of touch targets and its hairline, in every mode:
275
+ `calc(var(--size-touch-target) + var(--border-width-hairline))`, 49px with
276
+ the default brand. A fixed header's rails
277
+ stick at it, their height is the viewport less it, and `scroll-padding-top`
278
+ clears it.
279
+
280
+ `--size-container` is one value for the same reason the touch target is: it is
281
+ not density. Past it, a header's last item drifts hundreds of pixels from where
282
+ the text stops, and the page runs empty down one side. A product whose screens
283
+ are wall-to-wall data raises it in its own layer, after the barrel, with the
284
+ reason written beside it.
285
+
286
+ **Where a layout switches is measured, not chosen.** This system defines no
287
+ breakpoint, and the four widths a page is judged at (360, 768, 1280, 1600) are
288
+ checkpoints, not places the CSS changes. A layout switches where its content
289
+ needs it: a header row where its labels fit (`P-14`), a third column where two
290
+ rails and a readable column fit. That width is the project's, found by
291
+ measuring, and it is recorded once, in the project's own layer:
292
+
293
+ ```css
294
+ @theme {
295
+ /* The four header labels and the wordmark need 504px, measured in the
296
+ site's own fonts; the row replaces Menu here. */
297
+ --breakpoint-nav: 540px;
298
+ }
299
+ ```
300
+
301
+ Name it `--breakpoint-<what switches>`, never after a device (a name like
302
+ "tablet" says nothing about what changes), and write the measurement beside it. In
303
+ Tailwind 4, `@theme` makes it the variant `nav:`. In plain CSS a custom property
304
+ cannot be read inside `@media`, so the literal is repeated there, with a
305
+ comment naming the token. `jig probe --run` reads these declarations and also
306
+ measures one pixel either side of each, because a switch falls between the
307
+ judged widths by design, and a range nobody measured is where a layout breaks:
308
+ a real site's three-column frame appeared at 1216px, and 1024 to 1215 still got
309
+ the phone arrangement.
310
+
261
311
  **Spacing selections.** `--spacing-card` and `--spacing-section` pick from the
262
312
  shared ladder rather than stating their own values:
263
313
 
@@ -458,13 +458,25 @@ Compose it for the phone first. Mobile navigation is a different control — not
458
458
  - While open, its visible label or icon reads as close — the word **Close**, or a cross — and its accessible name says so. Tapping it again closes the menu.
459
459
  - `Escape` closes an open menu and returns focus to the button.
460
460
  - `<details>`/`<summary>` gives the first three for free; a hand-rolled button has to do each one.
461
+ - `Escape` is the one `<details>` does not give. A few lines add it, and they fit every mode's budget, `editorial`'s included (`M-01`), because keyboard access a rule requires is not counted:
462
+
463
+ ```js
464
+ // P-14: Escape closes the open menu and returns focus to its button.
465
+ addEventListener('keydown', (e) => {
466
+ if (e.key !== 'Escape') return;
467
+ const menu = document.activeElement?.closest('details[open]');
468
+ if (!menu) return;
469
+ menu.open = false;
470
+ menu.querySelector('summary')?.focus();
471
+ });
472
+ ```
461
473
  - **Where the menu button sits is the project's decision.** Top right, top left, centred — that is taste, and it belongs in `DECISIONS.md`, not here. What the system asks is only that it stays in the same place on every screen and at every width it appears. **The decision is where it sits, never whether it exists:** at a width where every destination fits, the table above shows the links and there is no menu button, whatever `DECISIONS.md` says about its position.
462
474
  - **Mark where the reader is, the same way at every width.** Every screen has to answer *where am I?* without the reader remembering how they arrived.
463
475
  - The link to the current page carries `aria-current="page"`. A section link whose child page is open may carry `aria-current="true"`.
464
476
  - Style the mark from that attribute — `[aria-current="page"]` in CSS — not from a separate `.active` or `.current` class. One source for both what is seen and what is announced means the two cannot drift apart; a class alone looks marked and tells a screen reader nothing.
465
477
  - The visible cue is not colour alone (`C-20`): weight, an underline or bar, or a filled state. Which one is the project's decision.
466
478
  - Inside an open menu, the current item is marked the same way. When the menu is closed nothing in the navigation is visible, so the page's `<h1>` is what tells the reader where they are — every page has one, and it names the page.
467
- - **It works with no JavaScript** (`F-41`). The links are ordinary links in the page and render visibly by default; script, if there is any, only adds the collapse. In `editorial`, where the script budget is zero (`M-01`), use `<details>` with `<summary>Menu</summary>` — a disclosure the browser provides with no script at all.
479
+ - **It works with no JavaScript** (`F-41`). The links are ordinary links in the page and render visibly by default; script, if there is any, only adds the collapse. In `editorial`, where the script budget is zero (`M-01`), use `<details>` with `<summary>Menu</summary>` — a disclosure the browser provides with no script at all — and the few lines above for `Escape`.
468
480
  - **Never let a row that does not fit scroll sideways.** Its last items go past the edge where nobody sees them (`E-62`), and `editorial` forbids horizontal scrolling on mobile outright. An open menu is a vertical list.
469
481
  - **Same destinations, same order, at every width.** The phone may show fewer at once. It never shows different ones, and never reorders them — `product` fixes navigation position across the app (`M-02`), and a reader who learned the order on one screen should not have to relearn it on another.
470
482
  - **Every item is at least `--size-touch-target` tall**, made with padding rather than a larger font. The target grows; the text does not.
@@ -78,6 +78,13 @@
78
78
  --size-row: 56px; --size-row-compact: 48px;
79
79
  --size-touch-target: 48px; --size-icon: 20px;
80
80
 
81
+ /* Layout sizes (T-04). The container is the page's frame: a rail, a readable
82
+ column and a rail fit inside it, and above it navigation stays near what it
83
+ navigates. A rail is sized to hold labels at this mode's type size. The
84
+ header is one touch-target row and its hairline. */
85
+ --size-container: 1280px; --size-rail: 288px;
86
+ --size-header: calc(var(--size-touch-target) + var(--border-width-hairline));
87
+
81
88
  --border-width-control: var(--border-width-hairline);
82
89
  --radius-control: var(--radius-sm); --radius-surface: var(--radius-md);
83
90
  --shadow-surface: var(--shadow-none);
@@ -70,6 +70,13 @@
70
70
  --size-row: 36px; --size-row-compact: 32px;
71
71
  --size-touch-target: 48px; --size-icon: 16px;
72
72
 
73
+ /* Layout sizes (T-04). The container is the page's frame: a rail, a readable
74
+ column and a rail fit inside it, and above it navigation stays near what it
75
+ navigates. A rail is sized to hold labels at this mode's type size. The
76
+ header is one touch-target row and its hairline. */
77
+ --size-container: 1280px; --size-rail: 240px;
78
+ --size-header: calc(var(--size-touch-target) + var(--border-width-hairline));
79
+
73
80
  --border-width-control: var(--border-width-hairline);
74
81
  --radius-control: var(--radius-sm); --radius-surface: var(--radius-sm);
75
82
  --shadow-surface: var(--shadow-none);
@@ -74,6 +74,13 @@
74
74
  --size-control: 40px; --size-control-sm: 32px; --size-row: 48px; --size-row-compact: 40px;
75
75
  --size-touch-target: 48px; --size-icon: 18px;
76
76
 
77
+ /* Layout sizes (T-04). The container is the page's frame: a rail, a readable
78
+ column and a rail fit inside it, and above it navigation stays near what it
79
+ navigates. A rail is sized to hold labels at this mode's type size. The
80
+ header is one touch-target row and its hairline. */
81
+ --size-container: 1280px; --size-rail: 256px;
82
+ --size-header: calc(var(--size-touch-target) + var(--border-width-hairline));
83
+
77
84
  --border-width-control: var(--border-width-hairline);
78
85
  --radius-control: var(--radius-sm); --radius-surface: var(--radius-md);
79
86
  --shadow-surface: var(--shadow-none);