jig-ui 0.17.3 → 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,41 @@
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
+
3
39
  ## 0.17.3
4
40
 
5
41
  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";
@@ -5652,8 +5652,8 @@ Could not write to ${wireTarget}: ${err.message}`);
5652
5652
  }
5653
5653
 
5654
5654
  // src/commands/verdicts.ts
5655
- import { existsSync as existsSync23, readFileSync as readFileSync29 } from "fs";
5656
- 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";
5657
5657
 
5658
5658
  // src/rules/citations.ts
5659
5659
  import { join as join29 } from "path";
@@ -5667,13 +5667,13 @@ function citableIds(packageRoot2) {
5667
5667
  }
5668
5668
 
5669
5669
  // src/probe/check.ts
5670
- import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as readFileSync26 } from "fs";
5671
- 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";
5672
5672
 
5673
5673
  // src/probe/save.ts
5674
- 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";
5675
5675
  import { fileURLToPath as fileURLToPath2 } from "url";
5676
- 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";
5677
5677
 
5678
5678
  // src/probe/script.ts
5679
5679
  var PROBE_VERSION = 5;
@@ -6055,6 +6055,31 @@ async function serveDirectory(dir) {
6055
6055
  };
6056
6056
  }
6057
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
+
6058
6083
  // src/probe/save.ts
6059
6084
  function saveProbe(opts) {
6060
6085
  let probe;
@@ -6075,11 +6100,11 @@ function saveProbe(opts) {
6075
6100
  }
6076
6101
  probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
6077
6102
  if (opts.serveRoot) probe.serveRoot = relative3(opts.projectRoot, resolve4(opts.projectRoot, opts.serveRoot)).split("\\").join("/") || ".";
6078
- probe.pageChecksum = checksum(readFileSync25(page, "utf8"));
6103
+ probe.pageChecksum = checksum(readFileSync26(page, "utf8"));
6079
6104
  probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
6080
- const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
6105
+ const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
6081
6106
  mkdirSync7(dir, { recursive: true });
6082
- const path = join32(dir, `probe-${width}.json`);
6107
+ const path = join33(dir, `probe-${width}.json`);
6083
6108
  writeFileSync6(path, JSON.stringify(probe), "utf8");
6084
6109
  return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
6085
6110
  }
@@ -6105,8 +6130,9 @@ async function runAndSaveProbes(opts) {
6105
6130
  const abs = resolve4(opts.projectRoot, opts.page);
6106
6131
  if (!existsSync19(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
6107
6132
  const saved = [];
6133
+ const widths = opts.widths ?? probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot));
6108
6134
  await withPageUrl(opts.projectRoot, abs, opts.serve, async (url) => {
6109
- for (const width of opts.widths ?? PROBE_WIDTHS) {
6135
+ for (const width of widths) {
6110
6136
  const json = await runProbe({ url, width });
6111
6137
  saved.push(saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json, page: abs, serveRoot: opts.serve }));
6112
6138
  }
@@ -6127,17 +6153,19 @@ async function withPageUrl(projectRoot, abs, serve, use) {
6127
6153
  }
6128
6154
  }
6129
6155
  async function ensureProbes(opts) {
6130
- const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
6156
+ const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
6131
6157
  const abs = resolve4(opts.projectRoot, opts.page);
6132
6158
  let current;
6133
6159
  try {
6134
- current = checksum(readFileSync25(abs, "utf8"));
6160
+ current = checksum(readFileSync26(abs, "utf8"));
6135
6161
  } catch {
6136
6162
  return { recorded: [], reason: `${opts.page} could not be read` };
6137
6163
  }
6138
- 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) => {
6139
6167
  try {
6140
- const probe = JSON.parse(readFileSync25(join32(dir, `probe-${width}.json`), "utf8"));
6168
+ const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
6141
6169
  return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
6142
6170
  } catch {
6143
6171
  return true;
@@ -6146,9 +6174,9 @@ async function ensureProbes(opts) {
6146
6174
  if (missing.length === 0) return { recorded: [] };
6147
6175
  if (!findChrome()) return { recorded: [], reason: "no browser on this machine" };
6148
6176
  let serve;
6149
- for (const width of PROBE_WIDTHS) {
6177
+ for (const width of widths) {
6150
6178
  try {
6151
- const probe = JSON.parse(readFileSync25(join32(dir, `probe-${width}.json`), "utf8"));
6179
+ const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
6152
6180
  if (probe.serveRoot) {
6153
6181
  serve = probe.serveRoot;
6154
6182
  break;
@@ -6164,9 +6192,9 @@ async function ensureProbes(opts) {
6164
6192
  return { recorded: missing };
6165
6193
  }
6166
6194
  function critiquedSurfaces(projectRoot) {
6167
- const root = join32(projectRoot, ".jig", "critique");
6195
+ const root = join33(projectRoot, ".jig", "critique");
6168
6196
  if (!existsSync19(root)) return [];
6169
- 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"))));
6170
6198
  }
6171
6199
 
6172
6200
  // src/probe/check.ts
@@ -6178,7 +6206,7 @@ function stampProblem(projectRoot, file, p) {
6178
6206
  if (!page) return `${file} names a page outside this project (${p.serveRoot ? p.pageFile : p.url ?? "no url"}).`;
6179
6207
  let current;
6180
6208
  try {
6181
- current = readFileSync26(page, "utf8");
6209
+ current = readFileSync27(page, "utf8");
6182
6210
  } catch {
6183
6211
  return `${file} was taken on ${p.pageFile}, which no longer exists.`;
6184
6212
  }
@@ -6192,7 +6220,7 @@ function readProbes(projectRoot, dir, errors) {
6192
6220
  const probes = [];
6193
6221
  for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
6194
6222
  try {
6195
- const p = JSON.parse(readFileSync26(join33(dir, f), "utf8"));
6223
+ const p = JSON.parse(readFileSync27(join34(dir, f), "utf8"));
6196
6224
  if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
6197
6225
  errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
6198
6226
  continue;
@@ -6284,19 +6312,19 @@ function probeContradictions(probes, verdictOf, indexable = true) {
6284
6312
  }
6285
6313
 
6286
6314
  // src/check/decisions.ts
6287
- import { existsSync as existsSync21, readFileSync as readFileSync27 } from "fs";
6288
- 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";
6289
6317
  var HEADING3 = /^(#{2,3})\s+(.+?)\s*$/gm;
6290
6318
  var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
6291
6319
  function decisionsFile(projectRoot) {
6292
6320
  const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
6293
6321
  try {
6294
- 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;
6295
6323
  if (typeof brand === "string" && brand.includes("/")) candidates.unshift(`${posix3.dirname(brand.replace(/^\.\//, ""))}/DECISIONS.md`);
6296
6324
  } catch {
6297
6325
  }
6298
6326
  for (const candidate of candidates) {
6299
- if (existsSync21(join34(projectRoot, candidate))) return candidate;
6327
+ if (existsSync21(join35(projectRoot, candidate))) return candidate;
6300
6328
  }
6301
6329
  return void 0;
6302
6330
  }
@@ -6305,7 +6333,7 @@ function decisionNames(projectRoot) {
6305
6333
  if (!path) return [];
6306
6334
  let body;
6307
6335
  try {
6308
- body = readFileSync27(join34(projectRoot, path), "utf8");
6336
+ body = readFileSync28(join35(projectRoot, path), "utf8");
6309
6337
  } catch {
6310
6338
  return [];
6311
6339
  }
@@ -6319,15 +6347,15 @@ function decisionNames(projectRoot) {
6319
6347
  }
6320
6348
 
6321
6349
  // 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";
6350
+ import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync3 } from "fs";
6351
+ import { join as join36 } from "path";
6324
6352
  function newestSpec(projectRoot) {
6325
- const dir = join35(projectRoot, ".jig", "specs");
6353
+ const dir = join36(projectRoot, ".jig", "specs");
6326
6354
  if (!existsSync22(dir)) return void 0;
6327
6355
  const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
6328
6356
  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") };
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") };
6331
6359
  }
6332
6360
  function specProblems(spec) {
6333
6361
  const parts = spec.body.split(/^---\s*$/m);
@@ -6410,17 +6438,17 @@ var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (fi
6410
6438
  function readJson(path, errors) {
6411
6439
  if (!existsSync23(path)) return null;
6412
6440
  try {
6413
- return JSON.parse(readFileSync29(path, "utf8"));
6441
+ return JSON.parse(readFileSync30(path, "utf8"));
6414
6442
  } catch (e) {
6415
6443
  errors.push(`${path}: not valid JSON (${e.message})`);
6416
6444
  return {};
6417
6445
  }
6418
6446
  }
6419
6447
  function specIndexable(projectRoot, surface) {
6420
- const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6448
+ const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6421
6449
  let front = "";
6422
6450
  try {
6423
- front = readFileSync29(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6451
+ front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6424
6452
  } catch {
6425
6453
  }
6426
6454
  const declared = specIndexableField(front);
@@ -6429,9 +6457,9 @@ function specIndexable(projectRoot, surface) {
6429
6457
  return mode !== "product" && mode !== "operator";
6430
6458
  }
6431
6459
  function specNeedsNav(projectRoot, surface) {
6432
- const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6460
+ const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6433
6461
  if (!existsSync23(path)) return false;
6434
- const front = readFileSync29(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6462
+ const front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
6435
6463
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
6436
6464
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
6437
6465
  return navField || navRegion;
@@ -6482,7 +6510,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
6482
6510
  function checkDecisions(projectRoot, dir, errors) {
6483
6511
  const required = decisionNames(projectRoot);
6484
6512
  if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
6485
- const file = readJson(join36(dir, "decisions.json"), errors);
6513
+ const file = readJson(join37(dir, "decisions.json"), errors);
6486
6514
  if (!file) {
6487
6515
  errors.push(
6488
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.`
@@ -6527,7 +6555,7 @@ function checkDecisions(projectRoot, dir, errors) {
6527
6555
  function verifyVerdicts(opts) {
6528
6556
  const root = opts.packageRoot ?? assetRoot();
6529
6557
  const errors = [];
6530
- const index = JSON.parse(readFileSync29(join36(root, "rules.index.json"), "utf8"));
6558
+ const index = JSON.parse(readFileSync30(join37(root, "rules.index.json"), "utf8"));
6531
6559
  const judgment = index.filter((r) => r.bucket === "judgment" || r.bucket === "hybrid" && r.pass !== void 0);
6532
6560
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
6533
6561
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
@@ -6536,9 +6564,9 @@ function verifyVerdicts(opts) {
6536
6564
  ...judgment.map((r) => [r.id, r.pass ?? ""])
6537
6565
  ]);
6538
6566
  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);
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);
6542
6570
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
6543
6571
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
6544
6572
  const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
@@ -6566,7 +6594,7 @@ function verifyVerdicts(opts) {
6566
6594
  };
6567
6595
  let specFront = "";
6568
6596
  try {
6569
- 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] ?? "";
6570
6598
  } catch {
6571
6599
  }
6572
6600
  if (specIndexableField(specFront) === "unreadable") {
@@ -6582,15 +6610,15 @@ function verifyVerdicts(opts) {
6582
6610
  }
6583
6611
 
6584
6612
  // 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";
6613
+ import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync31, statSync as statSync4, writeFileSync as writeFileSync7 } from "fs";
6586
6614
  import { createHash as createHash2 } from "crypto";
6587
- import { join as join37 } from "path";
6615
+ import { join as join38 } from "path";
6588
6616
  var MAX_BLOCKS = 3;
6589
6617
  function lastJigCommand(transcriptPath) {
6590
6618
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6591
6619
  let text;
6592
6620
  try {
6593
- text = readFileSync30(transcriptPath, "utf8");
6621
+ text = readFileSync31(transcriptPath, "utf8");
6594
6622
  } catch {
6595
6623
  return void 0;
6596
6624
  }
@@ -6608,7 +6636,7 @@ function lastAssistantText(transcriptPath) {
6608
6636
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6609
6637
  let found;
6610
6638
  try {
6611
- for (const line of readFileSync30(transcriptPath, "utf8").split("\n")) {
6639
+ for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
6612
6640
  if (!line.includes('"assistant"')) continue;
6613
6641
  let entry;
6614
6642
  try {
@@ -6634,7 +6662,7 @@ var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup"]);
6634
6662
  function sessionStart(transcriptPath) {
6635
6663
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6636
6664
  try {
6637
- for (const line of readFileSync30(transcriptPath, "utf8").split("\n")) {
6665
+ for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
6638
6666
  const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
6639
6667
  if (m) {
6640
6668
  const t = Date.parse(m[1]);
@@ -6649,20 +6677,20 @@ function verdictsMtime(dir) {
6649
6677
  let newest = 0;
6650
6678
  for (const name of VERDICT_FILES) {
6651
6679
  try {
6652
- newest = Math.max(newest, statSync4(join37(dir, name)).mtimeMs);
6680
+ newest = Math.max(newest, statSync4(join38(dir, name)).mtimeMs);
6653
6681
  } catch {
6654
6682
  }
6655
6683
  }
6656
6684
  return newest;
6657
6685
  }
6658
6686
  function surfacesInPlay(root, command, transcriptPath) {
6659
- const critiqueDir = join37(root, ".jig", "critique");
6687
+ const critiqueDir = join38(root, ".jig", "critique");
6660
6688
  if (!existsSync24(critiqueDir)) return [];
6661
6689
  const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
6662
6690
  const start = sessionStart(transcriptPath);
6663
6691
  if (start === void 0) return all;
6664
6692
  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);
6693
+ return all.filter((s) => s === current || verdictsMtime(join38(critiqueDir, s)) >= start - 1e3);
6666
6694
  }
6667
6695
  function commandProblems(root, command) {
6668
6696
  const problems = [];
@@ -6671,7 +6699,7 @@ function commandProblems(root, command) {
6671
6699
  const found = decisionsFile(root);
6672
6700
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
6673
6701
  else {
6674
- const body = readFileSync30(join37(root, found), "utf8");
6702
+ const body = readFileSync31(join38(root, found), "utf8");
6675
6703
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
6676
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.`");
6677
6705
  }
@@ -6692,13 +6720,13 @@ function commandProblems(root, command) {
6692
6720
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
6693
6721
  if (/^approved/i.test(mockup)) {
6694
6722
  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.`);
6723
+ else if (!/^https?:/i.test(at) && !existsSync24(join38(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
6696
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.`);
6697
6725
  }
6698
6726
  }
6699
6727
  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"))) : [];
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"))) : [];
6702
6730
  for (const surface of surfaces) {
6703
6731
  const v = verifyVerdicts({ projectRoot: root, surface });
6704
6732
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
@@ -6720,13 +6748,13 @@ function surfacePage(projectRoot, surface) {
6720
6748
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
6721
6749
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
6722
6750
  for (const candidate of candidates) {
6723
- if (candidate && existsSync24(join37(projectRoot, candidate))) return candidate;
6751
+ if (candidate && existsSync24(join38(projectRoot, candidate))) return candidate;
6724
6752
  }
6725
6753
  return void 0;
6726
6754
  }
6727
6755
  function gate(opts) {
6728
6756
  const root = opts.projectRoot;
6729
- if (!existsSync24(join37(root, "jig.config.json")) && !existsSync24(join37(root, ".jig"))) {
6757
+ if (!existsSync24(join38(root, "jig.config.json")) && !existsSync24(join38(root, ".jig"))) {
6730
6758
  return { block: false, reason: "" };
6731
6759
  }
6732
6760
  const command = lastJigCommand(opts.input.transcript_path);
@@ -6753,11 +6781,11 @@ function gate(opts) {
6753
6781
  }
6754
6782
  }
6755
6783
  problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path)));
6756
- const critiqueDir = join37(root, ".jig", "critique");
6784
+ const critiqueDir = join38(root, ".jig", "critique");
6757
6785
  if (existsSync24(critiqueDir)) {
6758
6786
  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;
6787
+ const dir = join38(critiqueDir, surface);
6788
+ if (!existsSync24(join38(dir, "screen.json")) && !existsSync24(join38(dir, "code.json"))) continue;
6761
6789
  const v = verifyVerdicts({ projectRoot: root, surface });
6762
6790
  if (!v.ok) {
6763
6791
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -6771,10 +6799,10 @@ function gate(opts) {
6771
6799
  }
6772
6800
  const session = opts.input.session_id ?? "unknown";
6773
6801
  const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
6774
- const stateFile = join37(root, ".jig", "gate.json");
6802
+ const stateFile = join38(root, ".jig", "gate.json");
6775
6803
  let state = {};
6776
6804
  try {
6777
- state = JSON.parse(readFileSync30(stateFile, "utf8"));
6805
+ state = JSON.parse(readFileSync31(stateFile, "utf8"));
6778
6806
  } catch {
6779
6807
  }
6780
6808
  if (problems.length === 0) {
@@ -6808,7 +6836,7 @@ function verdictChecksum(dir) {
6808
6836
  const parts = VERDICT_FILES.map((f) => {
6809
6837
  try {
6810
6838
  return `${f}
6811
- ${readFileSync30(join37(dir, f), "utf8")}`;
6839
+ ${readFileSync31(join38(dir, f), "utf8")}`;
6812
6840
  } catch {
6813
6841
  return "";
6814
6842
  }
@@ -6816,15 +6844,15 @@ ${readFileSync30(join37(dir, f), "utf8")}`;
6816
6844
  return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
6817
6845
  }
6818
6846
  function verdictGuard(root, command, inPlay) {
6819
- const critiqueDir = join37(root, ".jig", "critique");
6847
+ const critiqueDir = join38(root, ".jig", "critique");
6820
6848
  if (!existsSync24(critiqueDir)) return [];
6821
6849
  const problems = [];
6822
6850
  for (const surface of readdirSync15(critiqueDir)) {
6823
6851
  if (surface.startsWith("_")) continue;
6824
- const dir = join37(critiqueDir, surface);
6852
+ const dir = join38(critiqueDir, surface);
6825
6853
  const now = verdictChecksum(dir);
6826
6854
  if (!now) continue;
6827
- const lockPath = join37(dir, LOCK);
6855
+ const lockPath = join38(dir, LOCK);
6828
6856
  if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
6829
6857
  try {
6830
6858
  writeFileSync7(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
@@ -6834,7 +6862,7 @@ function verdictGuard(root, command, inPlay) {
6834
6862
  }
6835
6863
  let locked;
6836
6864
  try {
6837
- locked = JSON.parse(readFileSync30(lockPath, "utf8")).checksum;
6865
+ locked = JSON.parse(readFileSync31(lockPath, "utf8")).checksum;
6838
6866
  } catch {
6839
6867
  continue;
6840
6868
  }
@@ -6848,15 +6876,15 @@ function verdictGuard(root, command, inPlay) {
6848
6876
  }
6849
6877
  function save(file, state) {
6850
6878
  try {
6851
- mkdirSync8(join37(file, ".."), { recursive: true });
6879
+ mkdirSync8(join38(file, ".."), { recursive: true });
6852
6880
  writeFileSync7(file, JSON.stringify(state), "utf8");
6853
6881
  } catch {
6854
6882
  }
6855
6883
  }
6856
6884
 
6857
6885
  // src/commands/seo.ts
6858
- import { readFileSync as readFileSync31 } from "fs";
6859
- import { join as join38 } from "path";
6886
+ import { readFileSync as readFileSync32 } from "fs";
6887
+ import { join as join39 } from "path";
6860
6888
  var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
6861
6889
  var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
6862
6890
  var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
@@ -6897,7 +6925,7 @@ function seo(opts) {
6897
6925
  for (const file of files) {
6898
6926
  let raw;
6899
6927
  try {
6900
- raw = readFileSync31(join38(root, file), "utf8");
6928
+ raw = readFileSync32(join39(root, file), "utf8");
6901
6929
  } catch {
6902
6930
  continue;
6903
6931
  }
@@ -6953,7 +6981,7 @@ function seo(opts) {
6953
6981
 
6954
6982
  // src/index.ts
6955
6983
  var packageRoot = getPackageRoot();
6956
- var { version } = JSON.parse(readFileSync32(join39(packageRoot, "package.json"), "utf8"));
6984
+ var { version } = JSON.parse(readFileSync33(join40(packageRoot, "package.json"), "utf8"));
6957
6985
  function warnIfUnpublishedPin() {
6958
6986
  if (isPublishedBuild(packageRoot)) return;
6959
6987
  console.warn(
@@ -7103,7 +7131,7 @@ program.command("seo").description("Audit what a search engine and a link previe
7103
7131
  process.exit(1);
7104
7132
  }
7105
7133
  });
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) => {
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) => {
7107
7135
  if (opts.run) {
7108
7136
  if (!opts.save) {
7109
7137
  console.error(" \u2717 --run records what it measures, so it needs --save <surface>.");
@@ -7145,7 +7173,7 @@ program.command("probe").description("Print the render probe. With --save, read
7145
7173
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
7146
7174
  let input = {};
7147
7175
  try {
7148
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync32(0, "utf8") || "{}");
7176
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync33(0, "utf8") || "{}");
7149
7177
  } catch {
7150
7178
  }
7151
7179
  const cwd = input.cwd ?? process.cwd();
@@ -7184,7 +7212,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
7184
7212
  });
7185
7213
  function resolveSurfaceMode(projectRoot) {
7186
7214
  try {
7187
- const config = JSON.parse(readFileSync32(join39(projectRoot, "jig.config.json"), "utf8"));
7215
+ const config = JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8"));
7188
7216
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
7189
7217
  return modes.length === 1 ? modes[0] : void 0;
7190
7218
  } 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.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);