jig-ui 0.17.2 → 0.18.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,70 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0
4
+
5
+ A page's frame gets tokens, a project gets a place to record where its layout
6
+ switches, and keyboard access a rule requires fits every script budget. All of
7
+ it found building jig-site's header and Reference with the full loop.
8
+
9
+ ### Added
10
+
11
+ - **Keyboard access a rule requires does not count against a script budget.**
12
+ `P-14` requires `Escape` to close an open menu, and `editorial`'s budget was
13
+ 0 KB, with `<details>` as the menu, which cannot do it. No editorial page
14
+ could satisfy both, and a real site's home and not-found pages kept a menu a
15
+ keyboard could not dismiss. `M-01` now allows, in every mode, a few lines
16
+ under 1 KB whose only job is keyboard behaviour a rule asks for and the
17
+ browser does not give, with the rule named in a comment. `P-14` carries the
18
+ six lines for `Escape`.
19
+ - **Layout sizes: `--size-container`, `--size-rail`, `--size-header`.** Every
20
+ mode now sizes a page's frame, not only what is inside it. Building one site
21
+ with the full loop, four gaps turned out to be one: no token for a page's
22
+ maximum width, a rail's width, or a fixed header's height. The site made up
23
+ its own, one of them a raw pixel value. The container is 1280px in every
24
+ mode, because it is not density. A rail is 288, 256 or 240px, sized to hold
25
+ labels at the mode's type size. The header is `--size-touch-target` plus
26
+ `--border-width-hairline`. See T-04.
27
+ - **Where a layout switches is measured and recorded.** Jig still defines no
28
+ breakpoint, and the four judged widths are checkpoints, not places the CSS
29
+ changes. T-04 now says how a project records a width it measured:
30
+ `--breakpoint-<what switches>` in its own layer, with the measurement
31
+ beside it.
32
+ - **`jig probe --run` measures either side of every declared switch.** A switch
33
+ falls between the judged widths by design, and a range nobody measured is
34
+ where a layout breaks: a real site's three-column frame appeared at 1216px,
35
+ and 1024 to 1215 still got the phone arrangement. The probe now also records
36
+ one pixel before and at each `--breakpoint-*` the project declares, and the
37
+ gate's refresh keeps those probes current too.
38
+
39
+ ## 0.17.3
40
+
41
+ Three defects found building jig-site's header with the full loop.
42
+
43
+ ### Fixed
44
+
45
+ - **The gate no longer asks `make` for a critique's verdicts.** A critique
46
+ counted as touched when any file in its folder changed, so a make round that
47
+ saved its probe there (`jig probe --save`) was held to that critique's
48
+ completeness. A critique written before a release that added rules has no
49
+ verdicts for them, and only `critique` may write verdicts, so the round was
50
+ blocked three times on something it was forbidden to do. A critique is now
51
+ touched only when its verdict files change.
52
+ - **`jig verdicts` accepts, and asks for, verdicts on hybrid rules with a
53
+ pass.** 0.17 gave ten code-pass rules (A-135 to A-140, A-143, A-144, A-146,
54
+ G-145) detectors and made them hybrid. Each kept its `pass: code`, because a
55
+ detector catches only some shapes of the failure, and the code arm judged
56
+ them as that field says. `verdicts` knew only judgment and mechanical ids,
57
+ so every verdict on them came back "not a rule", and a critique that did
58
+ what the rules said could not pass. A hybrid rule with a pass is now judged
59
+ in that pass, like a judgment rule: the code arm has 84 rules, not 74.
60
+ - **`jig update` refreshes a project's mode files where `init` wrote them.**
61
+ `init` has written `mode.<mode>.css` beside the brand file (for example
62
+ `src/styles/jig/`) since the token layer moved into the project's styles,
63
+ but `update` still looked for them under the pre-0.6 `.jig/tokens/`. It
64
+ found none, so every release left the old tokens in place, and nothing
65
+ said so; only `jig init --yes` refreshed them. `update` now refreshes each
66
+ untouched copy wherever `init` recorded writing it.
67
+
3
68
  ## 0.17.2
4
69
 
5
70
  Four defects found by building one page 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";
@@ -1014,15 +1014,17 @@ function updateInitFiles(opts) {
1014
1014
  let initChanged = false;
1015
1015
  const writer = createWriter(opts.projectRoot, initFiles);
1016
1016
  for (const file of bundleFiles(opts.packageRoot, "tokens")) {
1017
- const key = relKey(".jig", "tokens", file);
1018
- if (!(key in initManifest.files)) continue;
1019
- if (isInitFileModified(opts.projectRoot, key, initManifest)) {
1020
- skipped.push(key);
1021
- continue;
1017
+ if (!file.startsWith("mode.")) continue;
1018
+ const keys = Object.keys(initManifest.files).filter((k) => k === file || k.endsWith(`/${file}`));
1019
+ for (const key of keys) {
1020
+ if (isInitFileModified(opts.projectRoot, key, initManifest)) {
1021
+ skipped.push(key);
1022
+ continue;
1023
+ }
1024
+ const content = vendorHeader(file, opts.version, "css", null) + readFileSync7(join10(tokensDir, file), "utf8");
1025
+ updated.push(writer.write(key, content));
1026
+ initChanged = true;
1022
1027
  }
1023
- const content = vendorHeader(file, opts.version, "css", null) + readFileSync7(join10(tokensDir, file), "utf8");
1024
- updated.push(writer.write(key, content));
1025
- initChanged = true;
1026
1028
  }
1027
1029
  Object.assign(initFiles, writer.files);
1028
1030
  if (initChanged) writeInitManifest(opts.projectRoot, { ...initManifest, version: opts.version, files: initFiles });
@@ -5650,8 +5652,8 @@ Could not write to ${wireTarget}: ${err.message}`);
5650
5652
  }
5651
5653
 
5652
5654
  // src/commands/verdicts.ts
5653
- import { existsSync as existsSync23, readFileSync as readFileSync29 } from "fs";
5654
- import { join as join36, resolve as resolve5 } from "path";
5655
+ import { existsSync as existsSync23, readFileSync as readFileSync30 } from "fs";
5656
+ import { join as join37, resolve as resolve5 } from "path";
5655
5657
 
5656
5658
  // src/rules/citations.ts
5657
5659
  import { join as join29 } from "path";
@@ -5665,13 +5667,13 @@ function citableIds(packageRoot2) {
5665
5667
  }
5666
5668
 
5667
5669
  // src/probe/check.ts
5668
- import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as readFileSync26 } from "fs";
5669
- import { join as join33 } from "path";
5670
+ import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as readFileSync27 } from "fs";
5671
+ import { join as join34 } from "path";
5670
5672
 
5671
5673
  // src/probe/save.ts
5672
- import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync25, writeFileSync as writeFileSync6 } from "fs";
5674
+ import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync26, writeFileSync as writeFileSync6 } from "fs";
5673
5675
  import { fileURLToPath as fileURLToPath2 } from "url";
5674
- import { join as join32, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
5676
+ import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
5675
5677
 
5676
5678
  // src/probe/script.ts
5677
5679
  var PROBE_VERSION = 5;
@@ -6053,6 +6055,31 @@ async function serveDirectory(dir) {
6053
6055
  };
6054
6056
  }
6055
6057
 
6058
+ // src/probe/switches.ts
6059
+ import { readFileSync as readFileSync25 } from "fs";
6060
+ import { join as join32 } from "path";
6061
+ var DECLARATION = /--breakpoint-([a-z0-9-]+)\s*:\s*(\d+(?:\.\d+)?)(px|rem)\b/g;
6062
+ function declaredSwitches(projectRoot) {
6063
+ const found = /* @__PURE__ */ new Map();
6064
+ for (const file of wholeRepoFiles(projectRoot)) {
6065
+ if (!/\.(css|scss|astro|vue|svelte|html)$/i.test(file)) continue;
6066
+ let text;
6067
+ try {
6068
+ text = readFileSync25(join32(projectRoot, file), "utf8");
6069
+ } catch {
6070
+ continue;
6071
+ }
6072
+ for (const m of text.matchAll(DECLARATION)) {
6073
+ const px2 = Math.round(m[3] === "rem" ? Number(m[2]) * 16 : Number(m[2]));
6074
+ if (px2 > 0) found.set(`${m[1]}:${px2}`, { name: m[1], px: px2 });
6075
+ }
6076
+ }
6077
+ return [...found.values()].sort((a, b) => a.px - b.px);
6078
+ }
6079
+ function probeWidths(base, switches) {
6080
+ return [.../* @__PURE__ */ new Set([...base, ...switches.flatMap((s) => [s.px - 1, s.px])])].sort((a, b) => a - b);
6081
+ }
6082
+
6056
6083
  // src/probe/save.ts
6057
6084
  function saveProbe(opts) {
6058
6085
  let probe;
@@ -6073,11 +6100,11 @@ function saveProbe(opts) {
6073
6100
  }
6074
6101
  probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
6075
6102
  if (opts.serveRoot) probe.serveRoot = relative3(opts.projectRoot, resolve4(opts.projectRoot, opts.serveRoot)).split("\\").join("/") || ".";
6076
- probe.pageChecksum = checksum(readFileSync25(page, "utf8"));
6103
+ probe.pageChecksum = checksum(readFileSync26(page, "utf8"));
6077
6104
  probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
6078
- const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
6105
+ const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
6079
6106
  mkdirSync7(dir, { recursive: true });
6080
- const path = join32(dir, `probe-${width}.json`);
6107
+ const path = join33(dir, `probe-${width}.json`);
6081
6108
  writeFileSync6(path, JSON.stringify(probe), "utf8");
6082
6109
  return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
6083
6110
  }
@@ -6103,8 +6130,9 @@ async function runAndSaveProbes(opts) {
6103
6130
  const abs = resolve4(opts.projectRoot, opts.page);
6104
6131
  if (!existsSync19(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
6105
6132
  const saved = [];
6133
+ const widths = opts.widths ?? probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot));
6106
6134
  await withPageUrl(opts.projectRoot, abs, opts.serve, async (url) => {
6107
- for (const width of opts.widths ?? PROBE_WIDTHS) {
6135
+ for (const width of widths) {
6108
6136
  const json = await runProbe({ url, width });
6109
6137
  saved.push(saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json, page: abs, serveRoot: opts.serve }));
6110
6138
  }
@@ -6125,17 +6153,19 @@ async function withPageUrl(projectRoot, abs, serve, use) {
6125
6153
  }
6126
6154
  }
6127
6155
  async function ensureProbes(opts) {
6128
- const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
6156
+ const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
6129
6157
  const abs = resolve4(opts.projectRoot, opts.page);
6130
6158
  let current;
6131
6159
  try {
6132
- current = checksum(readFileSync25(abs, "utf8"));
6160
+ current = checksum(readFileSync26(abs, "utf8"));
6133
6161
  } catch {
6134
6162
  return { recorded: [], reason: `${opts.page} could not be read` };
6135
6163
  }
6136
- const missing = PROBE_WIDTHS.filter((width) => {
6164
+ const recorded = existsSync19(dir) ? readdirSync12(dir).map((f) => /^probe-(\d+)\.json$/.exec(f)?.[1]).filter((w) => !!w).map(Number) : [];
6165
+ const widths = [.../* @__PURE__ */ new Set([...probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot)), ...recorded])].sort((a, b) => a - b);
6166
+ const missing = widths.filter((width) => {
6137
6167
  try {
6138
- const probe = JSON.parse(readFileSync25(join32(dir, `probe-${width}.json`), "utf8"));
6168
+ const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
6139
6169
  return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
6140
6170
  } catch {
6141
6171
  return true;
@@ -6144,9 +6174,9 @@ async function ensureProbes(opts) {
6144
6174
  if (missing.length === 0) return { recorded: [] };
6145
6175
  if (!findChrome()) return { recorded: [], reason: "no browser on this machine" };
6146
6176
  let serve;
6147
- for (const width of PROBE_WIDTHS) {
6177
+ for (const width of widths) {
6148
6178
  try {
6149
- const probe = JSON.parse(readFileSync25(join32(dir, `probe-${width}.json`), "utf8"));
6179
+ const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
6150
6180
  if (probe.serveRoot) {
6151
6181
  serve = probe.serveRoot;
6152
6182
  break;
@@ -6162,9 +6192,9 @@ async function ensureProbes(opts) {
6162
6192
  return { recorded: missing };
6163
6193
  }
6164
6194
  function critiquedSurfaces(projectRoot) {
6165
- const root = join32(projectRoot, ".jig", "critique");
6195
+ const root = join33(projectRoot, ".jig", "critique");
6166
6196
  if (!existsSync19(root)) return [];
6167
- return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (existsSync19(join32(root, surface, "screen.json")) || existsSync19(join32(root, surface, "code.json"))));
6197
+ return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (existsSync19(join33(root, surface, "screen.json")) || existsSync19(join33(root, surface, "code.json"))));
6168
6198
  }
6169
6199
 
6170
6200
  // src/probe/check.ts
@@ -6176,7 +6206,7 @@ function stampProblem(projectRoot, file, p) {
6176
6206
  if (!page) return `${file} names a page outside this project (${p.serveRoot ? p.pageFile : p.url ?? "no url"}).`;
6177
6207
  let current;
6178
6208
  try {
6179
- current = readFileSync26(page, "utf8");
6209
+ current = readFileSync27(page, "utf8");
6180
6210
  } catch {
6181
6211
  return `${file} was taken on ${p.pageFile}, which no longer exists.`;
6182
6212
  }
@@ -6190,7 +6220,7 @@ function readProbes(projectRoot, dir, errors) {
6190
6220
  const probes = [];
6191
6221
  for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
6192
6222
  try {
6193
- const p = JSON.parse(readFileSync26(join33(dir, f), "utf8"));
6223
+ const p = JSON.parse(readFileSync27(join34(dir, f), "utf8"));
6194
6224
  if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
6195
6225
  errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
6196
6226
  continue;
@@ -6282,19 +6312,19 @@ function probeContradictions(probes, verdictOf, indexable = true) {
6282
6312
  }
6283
6313
 
6284
6314
  // src/check/decisions.ts
6285
- import { existsSync as existsSync21, readFileSync as readFileSync27 } from "fs";
6286
- import { join as join34, posix as posix3 } from "path";
6315
+ import { existsSync as existsSync21, readFileSync as readFileSync28 } from "fs";
6316
+ import { join as join35, posix as posix3 } from "path";
6287
6317
  var HEADING3 = /^(#{2,3})\s+(.+?)\s*$/gm;
6288
6318
  var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
6289
6319
  function decisionsFile(projectRoot) {
6290
6320
  const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
6291
6321
  try {
6292
- const brand = JSON.parse(readFileSync27(join34(projectRoot, "jig.config.json"), "utf8")).brand;
6322
+ const brand = JSON.parse(readFileSync28(join35(projectRoot, "jig.config.json"), "utf8")).brand;
6293
6323
  if (typeof brand === "string" && brand.includes("/")) candidates.unshift(`${posix3.dirname(brand.replace(/^\.\//, ""))}/DECISIONS.md`);
6294
6324
  } catch {
6295
6325
  }
6296
6326
  for (const candidate of candidates) {
6297
- if (existsSync21(join34(projectRoot, candidate))) return candidate;
6327
+ if (existsSync21(join35(projectRoot, candidate))) return candidate;
6298
6328
  }
6299
6329
  return void 0;
6300
6330
  }
@@ -6303,7 +6333,7 @@ function decisionNames(projectRoot) {
6303
6333
  if (!path) return [];
6304
6334
  let body;
6305
6335
  try {
6306
- body = readFileSync27(join34(projectRoot, path), "utf8");
6336
+ body = readFileSync28(join35(projectRoot, path), "utf8");
6307
6337
  } catch {
6308
6338
  return [];
6309
6339
  }
@@ -6317,15 +6347,15 @@ function decisionNames(projectRoot) {
6317
6347
  }
6318
6348
 
6319
6349
  // src/check/spec-shape.ts
6320
- import { existsSync as existsSync22, readFileSync as readFileSync28, readdirSync as readdirSync14, statSync as statSync3 } from "fs";
6321
- import { join as join35 } from "path";
6350
+ import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync3 } from "fs";
6351
+ import { join as join36 } from "path";
6322
6352
  function newestSpec(projectRoot) {
6323
- const dir = join35(projectRoot, ".jig", "specs");
6353
+ const dir = join36(projectRoot, ".jig", "specs");
6324
6354
  if (!existsSync22(dir)) return void 0;
6325
6355
  const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
6326
6356
  if (files.length === 0) return void 0;
6327
- const newest = files.map((f) => ({ f, at: statSync3(join35(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6328
- return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync28(join35(dir, newest), "utf8") };
6357
+ const newest = files.map((f) => ({ f, at: statSync3(join36(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6358
+ return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync29(join36(dir, newest), "utf8") };
6329
6359
  }
6330
6360
  function specProblems(spec) {
6331
6361
  const parts = spec.body.split(/^---\s*$/m);
@@ -6408,17 +6438,17 @@ var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (fi
6408
6438
  function readJson(path, errors) {
6409
6439
  if (!existsSync23(path)) return null;
6410
6440
  try {
6411
- return JSON.parse(readFileSync29(path, "utf8"));
6441
+ return JSON.parse(readFileSync30(path, "utf8"));
6412
6442
  } catch (e) {
6413
6443
  errors.push(`${path}: not valid JSON (${e.message})`);
6414
6444
  return {};
6415
6445
  }
6416
6446
  }
6417
6447
  function specIndexable(projectRoot, surface) {
6418
- const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6448
+ const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6419
6449
  let front = "";
6420
6450
  try {
6421
- front = readFileSync29(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6451
+ front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6422
6452
  } catch {
6423
6453
  }
6424
6454
  const declared = specIndexableField(front);
@@ -6427,9 +6457,9 @@ function specIndexable(projectRoot, surface) {
6427
6457
  return mode !== "product" && mode !== "operator";
6428
6458
  }
6429
6459
  function specNeedsNav(projectRoot, surface) {
6430
- const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6460
+ const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6431
6461
  if (!existsSync23(path)) return false;
6432
- const front = readFileSync29(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6462
+ const front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6433
6463
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
6434
6464
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
6435
6465
  return navField || navRegion;
@@ -6480,7 +6510,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
6480
6510
  function checkDecisions(projectRoot, dir, errors) {
6481
6511
  const required = decisionNames(projectRoot);
6482
6512
  if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
6483
- const file = readJson(join36(dir, "decisions.json"), errors);
6513
+ const file = readJson(join37(dir, "decisions.json"), errors);
6484
6514
  if (!file) {
6485
6515
  errors.push(
6486
6516
  `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.`
@@ -6525,8 +6555,8 @@ function checkDecisions(projectRoot, dir, errors) {
6525
6555
  function verifyVerdicts(opts) {
6526
6556
  const root = opts.packageRoot ?? assetRoot();
6527
6557
  const errors = [];
6528
- const index = JSON.parse(readFileSync29(join36(root, "rules.index.json"), "utf8"));
6529
- const judgment = index.filter((r) => r.bucket === "judgment");
6558
+ const index = JSON.parse(readFileSync30(join37(root, "rules.index.json"), "utf8"));
6559
+ const judgment = index.filter((r) => r.bucket === "judgment" || r.bucket === "hybrid" && r.pass !== void 0);
6530
6560
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
6531
6561
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
6532
6562
  const passOf = new Map([
@@ -6534,9 +6564,9 @@ function verifyVerdicts(opts) {
6534
6564
  ...judgment.map((r) => [r.id, r.pass ?? ""])
6535
6565
  ]);
6536
6566
  const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
6537
- const dir = join36(opts.projectRoot, ".jig", "critique", opts.surface);
6538
- const screenFile = readJson(join36(dir, "screen.json"), errors);
6539
- const codeFile = readJson(join36(dir, "code.json"), errors);
6567
+ const dir = join37(opts.projectRoot, ".jig", "critique", opts.surface);
6568
+ const screenFile = readJson(join37(dir, "screen.json"), errors);
6569
+ const codeFile = readJson(join37(dir, "code.json"), errors);
6540
6570
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
6541
6571
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
6542
6572
  const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
@@ -6564,7 +6594,7 @@ function verifyVerdicts(opts) {
6564
6594
  };
6565
6595
  let specFront = "";
6566
6596
  try {
6567
- specFront = readFileSync29(join36(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
6597
+ specFront = readFileSync30(join37(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
6568
6598
  } catch {
6569
6599
  }
6570
6600
  if (specIndexableField(specFront) === "unreadable") {
@@ -6580,15 +6610,15 @@ function verifyVerdicts(opts) {
6580
6610
  }
6581
6611
 
6582
6612
  // src/commands/gate.ts
6583
- import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync30, statSync as statSync4, writeFileSync as writeFileSync7 } from "fs";
6613
+ import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync31, statSync as statSync4, writeFileSync as writeFileSync7 } from "fs";
6584
6614
  import { createHash as createHash2 } from "crypto";
6585
- import { join as join37 } from "path";
6615
+ import { join as join38 } from "path";
6586
6616
  var MAX_BLOCKS = 3;
6587
6617
  function lastJigCommand(transcriptPath) {
6588
6618
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6589
6619
  let text;
6590
6620
  try {
6591
- text = readFileSync30(transcriptPath, "utf8");
6621
+ text = readFileSync31(transcriptPath, "utf8");
6592
6622
  } catch {
6593
6623
  return void 0;
6594
6624
  }
@@ -6606,7 +6636,7 @@ function lastAssistantText(transcriptPath) {
6606
6636
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6607
6637
  let found;
6608
6638
  try {
6609
- for (const line of readFileSync30(transcriptPath, "utf8").split("\n")) {
6639
+ for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
6610
6640
  if (!line.includes('"assistant"')) continue;
6611
6641
  let entry;
6612
6642
  try {
@@ -6632,7 +6662,7 @@ var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup"]);
6632
6662
  function sessionStart(transcriptPath) {
6633
6663
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6634
6664
  try {
6635
- for (const line of readFileSync30(transcriptPath, "utf8").split("\n")) {
6665
+ for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
6636
6666
  const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
6637
6667
  if (m) {
6638
6668
  const t = Date.parse(m[1]);
@@ -6643,23 +6673,24 @@ function sessionStart(transcriptPath) {
6643
6673
  }
6644
6674
  return void 0;
6645
6675
  }
6646
- function newestMtime(dir) {
6676
+ function verdictsMtime(dir) {
6647
6677
  let newest = 0;
6648
- for (const name of readdirSync15(dir, { withFileTypes: true })) {
6649
- if (name.name === LOCK) continue;
6650
- const path = join37(dir, name.name);
6651
- newest = Math.max(newest, name.isDirectory() ? newestMtime(path) : statSync4(path).mtimeMs);
6678
+ for (const name of VERDICT_FILES) {
6679
+ try {
6680
+ newest = Math.max(newest, statSync4(join38(dir, name)).mtimeMs);
6681
+ } catch {
6682
+ }
6652
6683
  }
6653
6684
  return newest;
6654
6685
  }
6655
6686
  function surfacesInPlay(root, command, transcriptPath) {
6656
- const critiqueDir = join37(root, ".jig", "critique");
6687
+ const critiqueDir = join38(root, ".jig", "critique");
6657
6688
  if (!existsSync24(critiqueDir)) return [];
6658
6689
  const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
6659
6690
  const start = sessionStart(transcriptPath);
6660
6691
  if (start === void 0) return all;
6661
6692
  const current = command === "critique" ? /^\s*surface\s*:\s*(.+)$/im.exec(newestSpec(root)?.body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim().replace(/^["']|["']$/g, "") : void 0;
6662
- return all.filter((s) => s === current || newestMtime(join37(critiqueDir, s)) >= start - 1e3);
6693
+ return all.filter((s) => s === current || verdictsMtime(join38(critiqueDir, s)) >= start - 1e3);
6663
6694
  }
6664
6695
  function commandProblems(root, command) {
6665
6696
  const problems = [];
@@ -6668,7 +6699,7 @@ function commandProblems(root, command) {
6668
6699
  const found = decisionsFile(root);
6669
6700
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
6670
6701
  else {
6671
- const body = readFileSync30(join37(root, found), "utf8");
6702
+ const body = readFileSync31(join38(root, found), "utf8");
6672
6703
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
6673
6704
  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.`");
6674
6705
  }
@@ -6689,13 +6720,13 @@ function commandProblems(root, command) {
6689
6720
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
6690
6721
  if (/^approved/i.test(mockup)) {
6691
6722
  if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
6692
- else if (!/^https?:/i.test(at) && !existsSync24(join37(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
6723
+ else if (!/^https?:/i.test(at) && !existsSync24(join38(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
6693
6724
  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.`);
6694
6725
  }
6695
6726
  }
6696
6727
  if (command === "critique") {
6697
- const dir = join37(root, ".jig", "critique");
6698
- const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join37(dir, s, "screen.json")) || existsSync24(join37(dir, s, "code.json"))) : [];
6728
+ const dir = join38(root, ".jig", "critique");
6729
+ const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join38(dir, s, "screen.json")) || existsSync24(join38(dir, s, "code.json"))) : [];
6699
6730
  for (const surface of surfaces) {
6700
6731
  const v = verifyVerdicts({ projectRoot: root, surface });
6701
6732
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
@@ -6717,13 +6748,13 @@ function surfacePage(projectRoot, surface) {
6717
6748
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
6718
6749
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
6719
6750
  for (const candidate of candidates) {
6720
- if (candidate && existsSync24(join37(projectRoot, candidate))) return candidate;
6751
+ if (candidate && existsSync24(join38(projectRoot, candidate))) return candidate;
6721
6752
  }
6722
6753
  return void 0;
6723
6754
  }
6724
6755
  function gate(opts) {
6725
6756
  const root = opts.projectRoot;
6726
- if (!existsSync24(join37(root, "jig.config.json")) && !existsSync24(join37(root, ".jig"))) {
6757
+ if (!existsSync24(join38(root, "jig.config.json")) && !existsSync24(join38(root, ".jig"))) {
6727
6758
  return { block: false, reason: "" };
6728
6759
  }
6729
6760
  const command = lastJigCommand(opts.input.transcript_path);
@@ -6750,11 +6781,11 @@ function gate(opts) {
6750
6781
  }
6751
6782
  }
6752
6783
  problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path)));
6753
- const critiqueDir = join37(root, ".jig", "critique");
6784
+ const critiqueDir = join38(root, ".jig", "critique");
6754
6785
  if (existsSync24(critiqueDir)) {
6755
6786
  for (const surface of surfacesInPlay(root, command, opts.input.transcript_path)) {
6756
- const dir = join37(critiqueDir, surface);
6757
- if (!existsSync24(join37(dir, "screen.json")) && !existsSync24(join37(dir, "code.json"))) continue;
6787
+ const dir = join38(critiqueDir, surface);
6788
+ if (!existsSync24(join38(dir, "screen.json")) && !existsSync24(join38(dir, "code.json"))) continue;
6758
6789
  const v = verifyVerdicts({ projectRoot: root, surface });
6759
6790
  if (!v.ok) {
6760
6791
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -6768,10 +6799,10 @@ function gate(opts) {
6768
6799
  }
6769
6800
  const session = opts.input.session_id ?? "unknown";
6770
6801
  const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
6771
- const stateFile = join37(root, ".jig", "gate.json");
6802
+ const stateFile = join38(root, ".jig", "gate.json");
6772
6803
  let state = {};
6773
6804
  try {
6774
- state = JSON.parse(readFileSync30(stateFile, "utf8"));
6805
+ state = JSON.parse(readFileSync31(stateFile, "utf8"));
6775
6806
  } catch {
6776
6807
  }
6777
6808
  if (problems.length === 0) {
@@ -6805,7 +6836,7 @@ function verdictChecksum(dir) {
6805
6836
  const parts = VERDICT_FILES.map((f) => {
6806
6837
  try {
6807
6838
  return `${f}
6808
- ${readFileSync30(join37(dir, f), "utf8")}`;
6839
+ ${readFileSync31(join38(dir, f), "utf8")}`;
6809
6840
  } catch {
6810
6841
  return "";
6811
6842
  }
@@ -6813,15 +6844,15 @@ ${readFileSync30(join37(dir, f), "utf8")}`;
6813
6844
  return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
6814
6845
  }
6815
6846
  function verdictGuard(root, command, inPlay) {
6816
- const critiqueDir = join37(root, ".jig", "critique");
6847
+ const critiqueDir = join38(root, ".jig", "critique");
6817
6848
  if (!existsSync24(critiqueDir)) return [];
6818
6849
  const problems = [];
6819
6850
  for (const surface of readdirSync15(critiqueDir)) {
6820
6851
  if (surface.startsWith("_")) continue;
6821
- const dir = join37(critiqueDir, surface);
6852
+ const dir = join38(critiqueDir, surface);
6822
6853
  const now = verdictChecksum(dir);
6823
6854
  if (!now) continue;
6824
- const lockPath = join37(dir, LOCK);
6855
+ const lockPath = join38(dir, LOCK);
6825
6856
  if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
6826
6857
  try {
6827
6858
  writeFileSync7(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
@@ -6831,7 +6862,7 @@ function verdictGuard(root, command, inPlay) {
6831
6862
  }
6832
6863
  let locked;
6833
6864
  try {
6834
- locked = JSON.parse(readFileSync30(lockPath, "utf8")).checksum;
6865
+ locked = JSON.parse(readFileSync31(lockPath, "utf8")).checksum;
6835
6866
  } catch {
6836
6867
  continue;
6837
6868
  }
@@ -6845,15 +6876,15 @@ function verdictGuard(root, command, inPlay) {
6845
6876
  }
6846
6877
  function save(file, state) {
6847
6878
  try {
6848
- mkdirSync8(join37(file, ".."), { recursive: true });
6879
+ mkdirSync8(join38(file, ".."), { recursive: true });
6849
6880
  writeFileSync7(file, JSON.stringify(state), "utf8");
6850
6881
  } catch {
6851
6882
  }
6852
6883
  }
6853
6884
 
6854
6885
  // src/commands/seo.ts
6855
- import { readFileSync as readFileSync31 } from "fs";
6856
- import { join as join38 } from "path";
6886
+ import { readFileSync as readFileSync32 } from "fs";
6887
+ import { join as join39 } from "path";
6857
6888
  var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
6858
6889
  var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
6859
6890
  var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
@@ -6894,7 +6925,7 @@ function seo(opts) {
6894
6925
  for (const file of files) {
6895
6926
  let raw;
6896
6927
  try {
6897
- raw = readFileSync31(join38(root, file), "utf8");
6928
+ raw = readFileSync32(join39(root, file), "utf8");
6898
6929
  } catch {
6899
6930
  continue;
6900
6931
  }
@@ -6950,7 +6981,7 @@ function seo(opts) {
6950
6981
 
6951
6982
  // src/index.ts
6952
6983
  var packageRoot = getPackageRoot();
6953
- var { version } = JSON.parse(readFileSync32(join39(packageRoot, "package.json"), "utf8"));
6984
+ var { version } = JSON.parse(readFileSync33(join40(packageRoot, "package.json"), "utf8"));
6954
6985
  function warnIfUnpublishedPin() {
6955
6986
  if (isPublishedBuild(packageRoot)) return;
6956
6987
  console.warn(
@@ -7100,7 +7131,7 @@ program.command("seo").description("Audit what a search engine and a link previe
7100
7131
  process.exit(1);
7101
7132
  }
7102
7133
  });
7103
- 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) => {
7134
+ 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) => {
7104
7135
  if (opts.run) {
7105
7136
  if (!opts.save) {
7106
7137
  console.error(" \u2717 --run records what it measures, so it needs --save <surface>.");
@@ -7142,7 +7173,7 @@ program.command("probe").description("Print the render probe. With --save, read
7142
7173
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
7143
7174
  let input = {};
7144
7175
  try {
7145
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync32(0, "utf8") || "{}");
7176
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync33(0, "utf8") || "{}");
7146
7177
  } catch {
7147
7178
  }
7148
7179
  const cwd = input.cwd ?? process.cwd();
@@ -7181,7 +7212,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
7181
7212
  });
7182
7213
  function resolveSurfaceMode(projectRoot) {
7183
7214
  try {
7184
- const config = JSON.parse(readFileSync32(join39(projectRoot, "jig.config.json"), "utf8"));
7215
+ const config = JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8"));
7185
7216
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
7186
7217
  return modes.length === 1 ? modes[0] : void 0;
7187
7218
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
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);