jig-ui 0.18.5 → 0.18.8

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,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.8 (2026-09-25)
4
+
5
+ Two runs writing one manifest no longer lose an entry.
6
+
7
+ ### Fixed
8
+
9
+ - **Two runs writing one manifest no longer lose an entry.** The merge added
10
+ for this in 0.4 re-read after writing and retried if its own keys were gone,
11
+ but a writer only checked its own keys: one that read before another's write
12
+ could rename over it afterwards, and both reported success. The
13
+ parallel-process test caught it under load in the 0.18.7 release run. The
14
+ read, merge and write now happen under a lock file; a lock whose process has
15
+ died, or older than any write takes, is taken over, so a killed run cannot
16
+ wedge an install.
17
+
18
+ ## 0.18.7 (2026-09-25)
19
+
20
+ The mockup gate reads a spec's switches however they are spelled, and refuses
21
+ icons and images in a drawing's frames.
22
+
23
+ ### Fixed
24
+
25
+ - **`switches:` reads a switch however the spec spells it.** jig-site's
26
+ Versions spec wrote `switches: [breakpoint-nav]`; read as a switch named
27
+ that, it matched nothing, no switch was checked, and the gate passed. `nav`,
28
+ `breakpoint-nav` and `--breakpoint-nav` now name the same switch, and a name
29
+ the project does not record is reported with the ones it does.
30
+ - **The gate refuses icons and images in a mockup's frames.** The procedure
31
+ says an icon is its name in brackets and an image a labelled box; jig-site's
32
+ Versions drawing carried the site's real icons and GitHub's mark, and nothing
33
+ checked. `<svg>`, `<img>`, `<picture>`, `<canvas>` and icon fonts inside a
34
+ frame now stop the session.
35
+
36
+ ## 0.18.6 (2026-09-25)
37
+
38
+ A mockup's frames must hold each size's regions, and the drawing shows either
39
+ side of each switch the project records.
40
+
41
+ ### Fixed
42
+
43
+ - **The gate checks what a mockup's frames hold, not only that they exist.**
44
+ 0.18.5 required a frame per size; a frame can still be empty, or hold the
45
+ desktop composition narrower. The procedure's self-check, "go down the spec's
46
+ `regions:` and find each one in that frame", was prose only. Now each size's
47
+ regions must be labelled in its frame: by name where the spec gives a short
48
+ one (`nav`, or `h1: "Versions"`), and by count where it writes a description,
49
+ so wording never trips it.
50
+ - **A mockup draws either side of each switch the project records.** The probe
51
+ has measured one pixel either side of every `--breakpoint-*` since 0.18.0, but
52
+ the drawing the owner approves showed only the four sizes; jig-site's header
53
+ changes at 540, between phone and tablet, and no frame showed 539 beside 540.
54
+ A new spec field, `switches:`, names the switches a page crosses (`none` if it
55
+ crosses none); absent, the mockup draws every one.
56
+ - **A spec's `indexable:` reads past a YAML comment.** The spec template itself
57
+ writes `indexable: true # from the mode ...`; a spec that copied it was
58
+ reported unreadable.
59
+
3
60
  ## 0.18.5 (2026-09-25)
4
61
 
5
62
  The gate holds an HTML mockup that does not draw the page at every size.
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 readFileSync33 } from "fs";
5
+ import { readFileSync as readFileSync34 } from "fs";
6
6
  import { homedir as homedir2 } from "os";
7
- import { join as join40 } from "path";
7
+ import { join as join41 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -42,7 +42,7 @@ function isPublishedBuild(packageRoot2) {
42
42
  }
43
43
 
44
44
  // src/commands/install.ts
45
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
45
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
46
46
  import { join as join7 } from "path";
47
47
 
48
48
  // src/adapters/types.ts
@@ -270,7 +270,7 @@ function referenceFiles(packageRoot2) {
270
270
 
271
271
  // src/install/manifest.ts
272
272
  import { createHash } from "crypto";
273
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2 } from "fs";
273
+ import { closeSync, existsSync as existsSync3, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, statSync, unlinkSync as unlinkSync2, writeSync } from "fs";
274
274
  import { dirname as dirname3, join as join4 } from "path";
275
275
 
276
276
  // src/install/atomic.ts
@@ -342,19 +342,70 @@ function readManifest(root, manifestDir = ".jig") {
342
342
  }
343
343
  return validateManifest(raw, path);
344
344
  }
345
- var MERGE_ATTEMPTS = 5;
345
+ var LOCK_STALE_MS = 1e4;
346
+ var LOCK_WAIT_MS = 15e3;
346
347
  function writeManifest(root, m, manifestDir = ".jig") {
347
348
  const path = manifestPath(root, manifestDir);
348
349
  mkdirSync2(dirname3(path), { recursive: true });
349
- for (let attempt = 0; attempt < MERGE_ATTEMPTS; attempt++) {
350
+ withLock(`${path}.lock`, () => {
350
351
  const onDisk = readManifestQuietly(path);
351
352
  const merged = { ...m, files: { ...onDisk?.files ?? {}, ...m.files } };
352
353
  writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}
353
354
  `);
354
- const after = readManifestQuietly(path);
355
- if (after && Object.keys(merged.files).every((k) => k in after.files)) return;
355
+ });
356
+ }
357
+ function withLock(lock, fn) {
358
+ const deadline = Date.now() + LOCK_WAIT_MS;
359
+ for (; ; ) {
360
+ try {
361
+ const fd = openSync(lock, "wx");
362
+ try {
363
+ writeSync(fd, `${process.pid} ${Date.now()}`);
364
+ } finally {
365
+ closeSync(fd);
366
+ }
367
+ break;
368
+ } catch (err) {
369
+ if (err.code !== "EEXIST") throw err;
370
+ if (lockIsStale(lock) || Date.now() > deadline) {
371
+ try {
372
+ unlinkSync2(lock);
373
+ } catch {
374
+ }
375
+ continue;
376
+ }
377
+ sleep(10 + Math.random() * 20);
378
+ }
379
+ }
380
+ try {
381
+ fn();
382
+ } finally {
383
+ try {
384
+ unlinkSync2(lock);
385
+ } catch {
386
+ }
356
387
  }
357
388
  }
389
+ function lockIsStale(lock) {
390
+ try {
391
+ const [pid, at] = readFileSync2(lock, "utf8").trim().split(/\s+/).map(Number);
392
+ if (at && Date.now() - at > LOCK_STALE_MS) return true;
393
+ if (pid && pid !== process.pid) {
394
+ try {
395
+ process.kill(pid, 0);
396
+ } catch (e) {
397
+ if (e.code === "ESRCH") return true;
398
+ }
399
+ }
400
+ if (!pid || !at) return Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS;
401
+ return false;
402
+ } catch {
403
+ return false;
404
+ }
405
+ }
406
+ function sleep(ms) {
407
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
408
+ }
358
409
  function readManifestQuietly(path) {
359
410
  try {
360
411
  const parsed = JSON.parse(readFileSync2(path, "utf8"));
@@ -695,7 +746,7 @@ function installStopHook(projectRoot, version2) {
695
746
  stop.push({ hooks: [{ type: "command", command }] });
696
747
  settings.hooks = { ...hooks, Stop: stop };
697
748
  mkdirSync4(join7(projectRoot, ".claude"), { recursive: true });
698
- writeFileSync3(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
749
+ writeFileSync2(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
699
750
  return true;
700
751
  }
701
752
 
@@ -764,11 +815,11 @@ function readInitManifest(projectRoot) {
764
815
  return null;
765
816
  }
766
817
  }
767
- var MERGE_ATTEMPTS2 = 5;
818
+ var MERGE_ATTEMPTS = 5;
768
819
  function writeInitManifest(projectRoot, m) {
769
820
  const path = join8(projectRoot, INIT_MANIFEST_REL);
770
821
  mkdirSync5(dirname5(path), { recursive: true });
771
- for (let attempt = 0; attempt < MERGE_ATTEMPTS2; attempt++) {
822
+ for (let attempt = 0; attempt < MERGE_ATTEMPTS; attempt++) {
772
823
  const onDisk = readInitManifest(projectRoot);
773
824
  const merged = {
774
825
  ...m,
@@ -1476,7 +1527,7 @@ import { join as join24 } from "path";
1476
1527
 
1477
1528
  // src/check/files.ts
1478
1529
  import { execFileSync } from "child_process";
1479
- import { readdirSync as readdirSync7, realpathSync, statSync } from "fs";
1530
+ import { readdirSync as readdirSync7, realpathSync, statSync as statSync2 } from "fs";
1480
1531
  import { join as join16, relative, sep } from "path";
1481
1532
  var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
1482
1533
  "node_modules",
@@ -1557,7 +1608,7 @@ function walk(root, dir, out, seen) {
1557
1608
  let isFile = entry.isFile();
1558
1609
  if (entry.isSymbolicLink()) {
1559
1610
  try {
1560
- const target = statSync(abs);
1611
+ const target = statSync2(abs);
1561
1612
  isDir = target.isDirectory();
1562
1613
  isFile = target.isFile();
1563
1614
  } catch {
@@ -4584,7 +4635,7 @@ function check(opts) {
4584
4635
  }
4585
4636
 
4586
4637
  // src/commands/init.ts
4587
- import { existsSync as existsSync16, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync23, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
4638
+ import { existsSync as existsSync16, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync23, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
4588
4639
  import { dirname as dirname7, isAbsolute, join as join28, resolve as resolve2, sep as sep3 } from "path";
4589
4640
 
4590
4641
  // src/init/detect.ts
@@ -5215,7 +5266,7 @@ function wireBarrel(absPath, barrelImport) {
5215
5266
  if (legacy) {
5216
5267
  const next2 = content.slice(0, legacy.index) + `${line}
5217
5268
  ` + content.slice(legacy.index + legacy[0].length);
5218
- writeFileSync5(absPath, next2, "utf8");
5269
+ writeFileSync4(absPath, next2, "utf8");
5219
5270
  return { status: "rewired", detail: "replaced the separate brand and mode imports with the barrel" };
5220
5271
  }
5221
5272
  const tailwind = /@import\s+["']tailwindcss["'];?\r?\n?/.exec(content);
@@ -5225,7 +5276,7 @@ function wireBarrel(absPath, barrelImport) {
5225
5276
  ` : `${line}
5226
5277
  `);
5227
5278
  mkdirSync6(dirname7(absPath), { recursive: true });
5228
- writeFileSync5(next === content ? absPath : absPath, next, "utf8");
5279
+ writeFileSync4(next === content ? absPath : absPath, next, "utf8");
5229
5280
  return { status: "wired" };
5230
5281
  }
5231
5282
  async function init(opts) {
@@ -5385,7 +5436,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5385
5436
  const defaultCssRaw = readFileSync23(join28(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
5386
5437
  const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
5387
5438
  mkdirSync6(dirname7(brandAbsPath), { recursive: true });
5388
- writeFileSync5(brandAbsPath, content, "utf8");
5439
+ writeFileSync4(brandAbsPath, content, "utf8");
5389
5440
  files[brandRelPath] = checksum(content);
5390
5441
  }
5391
5442
  if (relocateTo && configAction !== "written") {
@@ -5393,7 +5444,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5393
5444
  const existing = JSON.parse(readFileSync23(configAbsPath, "utf8"));
5394
5445
  const content = `${JSON.stringify({ ...existing, brand: brandRelPath }, null, 2)}
5395
5446
  `;
5396
- writeFileSync5(configAbsPath, content, "utf8");
5447
+ writeFileSync4(configAbsPath, content, "utf8");
5397
5448
  files[configRelPath] = checksum(content);
5398
5449
  log(` Updated ${configRelPath} to point at the new location.`);
5399
5450
  } catch {
@@ -5404,7 +5455,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5404
5455
  const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces };
5405
5456
  const content = `${JSON.stringify(config, null, 2)}
5406
5457
  `;
5407
- writeFileSync5(configAbsPath, content, "utf8");
5458
+ writeFileSync4(configAbsPath, content, "utf8");
5408
5459
  files[configRelPath] = checksum(content);
5409
5460
  } else if (configAction === "merged") {
5410
5461
  let existing = {};
@@ -5424,7 +5475,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5424
5475
  };
5425
5476
  const content = `${JSON.stringify(merged, null, 2)}
5426
5477
  `;
5427
- writeFileSync5(configAbsPath, content, "utf8");
5478
+ writeFileSync4(configAbsPath, content, "utf8");
5428
5479
  files[configRelPath] = checksum(content);
5429
5480
  }
5430
5481
  const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
@@ -5445,7 +5496,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5445
5496
  const modeSourceRaw = readFileSync23(join28(opts.packageRoot, "tokens", modeFileName), "utf8");
5446
5497
  const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
5447
5498
  mkdirSync6(dirname7(modeAbsPath2), { recursive: true });
5448
- writeFileSync5(modeAbsPath2, content, "utf8");
5499
+ writeFileSync4(modeAbsPath2, content, "utf8");
5449
5500
  files[modeRelPath] = checksum(content);
5450
5501
  } else {
5451
5502
  log(` ${modeRelPath} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone (re-run 'jig update' to refresh it).`);
@@ -5465,7 +5516,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5465
5516
  }
5466
5517
  const content = barrelBody(barrelFor(mode), brandFileOnly, mode, opts.version);
5467
5518
  mkdirSync6(dirname7(abs), { recursive: true });
5468
- writeFileSync5(abs, content, "utf8");
5519
+ writeFileSync4(abs, content, "utf8");
5469
5520
  files[rel] = checksum(content);
5470
5521
  }
5471
5522
  if (multiMode) {
@@ -5518,7 +5569,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5518
5569
  } else {
5519
5570
  const content = utilitiesBody(aliasable, opts.version);
5520
5571
  mkdirSync6(dirname7(abs), { recursive: true });
5521
- writeFileSync5(abs, content, "utf8");
5572
+ writeFileSync4(abs, content, "utf8");
5522
5573
  files[rel] = checksum(content);
5523
5574
  const target = findWireTarget(detection);
5524
5575
  const importPath = target ? relativeImportPath(
@@ -5570,7 +5621,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5570
5621
  return !(m && !/^[a-z]+:|^\//i.test(m[1]) && resolve2(dirname7(abs), m[1]) === bare2);
5571
5622
  }).join("\n");
5572
5623
  if (after !== before) {
5573
- writeFileSync5(abs, after, "utf8");
5624
+ writeFileSync4(abs, after, "utf8");
5574
5625
  log(`
5575
5626
  Unwired ${file}: removed its import of ${relKey(...tokensRelDir, "theme.css")}.`);
5576
5627
  log(` With more than one mode, the global stylesheet imports no barrel: every route would carry that mode's tokens.`);
@@ -5598,7 +5649,7 @@ Could not edit ${file}: ${err.message}. Remove its import of ${relKey(...tokensR
5598
5649
  const oldDirName = currentBrand.split("/").slice(0, -1).join("/");
5599
5650
  const before = existsSync16(abs) ? readFileSync23(abs, "utf8") : "";
5600
5651
  const after = before.split("\n").filter((l) => !(l.includes("@import") && l.includes(oldDirName))).join("\n");
5601
- if (after !== before) writeFileSync5(abs, after, "utf8");
5652
+ if (after !== before) writeFileSync4(abs, after, "utf8");
5602
5653
  }
5603
5654
  const outcome = wireBarrel(join28(opts.projectRoot, wireTarget), barrelImport);
5604
5655
  wiring = { target: wireTarget, status: outcome.status, snippet };
@@ -5693,7 +5744,7 @@ import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync
5693
5744
  import { join as join34 } from "path";
5694
5745
 
5695
5746
  // src/probe/save.ts
5696
- import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync26, writeFileSync as writeFileSync6 } from "fs";
5747
+ import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync26, writeFileSync as writeFileSync5 } from "fs";
5697
5748
  import { fileURLToPath as fileURLToPath2 } from "url";
5698
5749
  import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
5699
5750
 
@@ -6042,7 +6093,7 @@ async function evaluate(endpoint, url, width, height, timeoutMs, expression) {
6042
6093
 
6043
6094
  // src/probe/serve.ts
6044
6095
  import { createServer } from "http";
6045
- import { existsSync as existsSync18, readFileSync as readFileSync24, statSync as statSync2 } from "fs";
6096
+ import { existsSync as existsSync18, readFileSync as readFileSync24, statSync as statSync3 } from "fs";
6046
6097
  import { extname, join as join31, resolve as resolve3, sep as sep4 } from "path";
6047
6098
  var TYPES = {
6048
6099
  ".html": "text/html; charset=utf-8",
@@ -6075,7 +6126,7 @@ async function serveDirectory(dir) {
6075
6126
  res.writeHead(403).end();
6076
6127
  return;
6077
6128
  }
6078
- if (existsSync18(file) && statSync2(file).isDirectory()) file = join31(file, "index.html");
6129
+ if (existsSync18(file) && statSync3(file).isDirectory()) file = join31(file, "index.html");
6079
6130
  else if (!existsSync18(file) && existsSync18(`${file}.html`)) file = `${file}.html`;
6080
6131
  if (!existsSync18(file)) {
6081
6132
  res.writeHead(404).end();
@@ -6143,7 +6194,7 @@ function saveProbe(opts) {
6143
6194
  const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
6144
6195
  mkdirSync7(dir, { recursive: true });
6145
6196
  const path = join33(dir, `probe-${width}.json`);
6146
- writeFileSync6(path, JSON.stringify(probe), "utf8");
6197
+ writeFileSync5(path, JSON.stringify(probe), "utf8");
6147
6198
  return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
6148
6199
  }
6149
6200
  function projectFile(projectRoot, path) {
@@ -6392,14 +6443,14 @@ function decisionHeadings(projectRoot) {
6392
6443
  }
6393
6444
 
6394
6445
  // src/check/spec-shape.ts
6395
- import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync3 } from "fs";
6446
+ import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
6396
6447
  import { join as join36 } from "path";
6397
6448
  function newestSpec(projectRoot) {
6398
6449
  const dir = join36(projectRoot, ".jig", "specs");
6399
6450
  if (!existsSync22(dir)) return void 0;
6400
6451
  const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
6401
6452
  if (files.length === 0) return void 0;
6402
- const newest = files.map((f) => ({ f, at: statSync3(join36(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6453
+ const newest = files.map((f) => ({ f, at: statSync4(join36(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6403
6454
  return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync29(join36(dir, newest), "utf8") };
6404
6455
  }
6405
6456
  function specProblems(spec) {
@@ -6471,7 +6522,7 @@ function navProblems(spec) {
6471
6522
  function specIndexableField(front) {
6472
6523
  const line = /^\s*indexable\s*:(.*)$/im.exec(front);
6473
6524
  if (!line) return "absent";
6474
- const value = line[1].trim().replace(/^["']|["']$/g, "").toLowerCase();
6525
+ const value = line[1].replace(/\s+#.*$/, "").trim().replace(/^["']|["']$/g, "").toLowerCase();
6475
6526
  if (value === "true" || value === "yes") return true;
6476
6527
  if (value === "false" || value === "no") return false;
6477
6528
  return "unreadable";
@@ -6685,15 +6736,159 @@ function verifyVerdicts(opts) {
6685
6736
  }
6686
6737
 
6687
6738
  // src/commands/gate.ts
6688
- import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync31, statSync as statSync4, writeFileSync as writeFileSync7 } from "fs";
6739
+ import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync32, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
6689
6740
  import { createHash as createHash2 } from "crypto";
6741
+ import { join as join39 } from "path";
6742
+
6743
+ // src/check/mockup-drawing.ts
6744
+ import { readFileSync as readFileSync31 } from "fs";
6690
6745
  import { join as join38 } from "path";
6746
+ var FRAMES = [["phone", 360], ["tablet", 768], ["desktop", 1280], ["wide", 1600]];
6747
+ function mockupDrawingProblems(root, specBody, at) {
6748
+ let html;
6749
+ try {
6750
+ html = readFileSync31(join38(root, at), "utf8");
6751
+ } catch {
6752
+ return [];
6753
+ }
6754
+ const frames = readFrames(html);
6755
+ const problems = [];
6756
+ const missing = FRAMES.filter(([size]) => !frames.some((f) => f.size === size));
6757
+ if (missing.length) {
6758
+ problems.push(
6759
+ `${at} has no frame for ${missing.map(([size, px2]) => `${size} (${px2}px)`).join(", ")}. A mockup draws the page once per size, side by side, in the wireframe template's \`<section class="frame" data-size="\u2026">\` boxes, so the owner sees every size at once. A single responsive page shows only the width of the window it is opened in.`
6760
+ );
6761
+ }
6762
+ const drawn = frames.flatMap((f) => f.art.map((a) => `${f.size ?? "a"} frame: ${a}`));
6763
+ if (drawn.length) {
6764
+ problems.push(
6765
+ `${at} draws icons or images in its frames (${[...new Set(drawn)].slice(0, 4).join("; ")}). A mockup is structure only: an icon is its name in brackets, \`[search]\`, and an image is a labelled box saying what goes there.`
6766
+ );
6767
+ }
6768
+ const regions = specRegions(specBody);
6769
+ for (const [size] of FRAMES) {
6770
+ const listed = regions[size];
6771
+ const inFrame = frames.filter((f) => f.size === size).flatMap((f) => f.labels);
6772
+ if (!listed || listed.length === 0 || !frames.some((f) => f.size === size)) continue;
6773
+ const named = listed.filter((r) => r.name).map((r) => r.name);
6774
+ const absent = named.filter((name) => !inFrame.some((label) => labelMatches(label, name)));
6775
+ if (absent.length) {
6776
+ problems.push(
6777
+ `${at}: the ${size} frame has no labelled region for ${absent.map((n) => `"${n}"`).join(", ")}. Every region the spec lists for a size is drawn in that size's frame, with its name in a \`.name\` label.`
6778
+ );
6779
+ } else if (inFrame.length < listed.length) {
6780
+ problems.push(
6781
+ `${at}: the ${size} frame labels ${inFrame.length} region(s) and the spec lists ${listed.length} for ${size}. Draw each one in the frame, with its name in a \`.name\` label.`
6782
+ );
6783
+ }
6784
+ }
6785
+ const widths = new Set(frames.map((f) => f.width).filter((w) => w !== void 0));
6786
+ const crossed = specSwitches(specBody);
6787
+ const recorded = declaredSwitches(root);
6788
+ const unknown = (crossed ?? []).filter((name) => !recorded.some((sw) => sw.name === name));
6789
+ if (unknown.length) {
6790
+ problems.push(
6791
+ `The spec's \`switches:\` names ${unknown.map((n) => `"${n}"`).join(", ")}, which the project does not record. Recorded: ${recorded.map((sw) => `${sw.name} (${sw.px}px)`).join(", ") || "none"}. Name them as \`--breakpoint-<name>\` gives them.`
6792
+ );
6793
+ }
6794
+ for (const s of recorded.filter((sw) => !crossed || crossed.includes(sw.name))) {
6795
+ const absent = [s.px - 1, s.px].filter((w) => !widths.has(w));
6796
+ if (absent.length) {
6797
+ problems.push(
6798
+ `${at} does not draw either side of the \`--breakpoint-${s.name}\` switch at ${s.px}px (no frame at ${absent.join(" or ")}px). The layout changes there, between the four sizes, so the owner sees the last width before it and the first at it: a frame with \`data-width="${s.px - 1}"\` and one with \`data-width="${s.px}"\`, each that wide. A page that never crosses it says so in its spec: \`switches:\` lists the ones it does, or \`none\`.`
6799
+ );
6800
+ }
6801
+ }
6802
+ return problems;
6803
+ }
6804
+ function specSwitches(specBody) {
6805
+ const front = specBody.split(/^---\s*$/m)[1] ?? "";
6806
+ const value = /^switches\s*:\s*(.+)$/im.exec(front)?.[1]?.replace(/#.*$/, "").trim();
6807
+ if (value === void 0) return void 0;
6808
+ if (/^(none|\[\s*\])$/i.test(value)) return [];
6809
+ return value.replace(/^\[|\]$/g, "").split(",").map((n) => n.trim().replace(/^["']|["']$/g, "").replace(/^(--)?breakpoint-/, "")).filter(Boolean);
6810
+ }
6811
+ function readFrames(html) {
6812
+ const opens = [...html.matchAll(/<\w+\b[^>]*\bclass\s*=\s*["'][^"']*\bframe\b[^"']*["'][^>]*>/gi)];
6813
+ return opens.map((open, i) => {
6814
+ const tag = open[0];
6815
+ const body = html.slice(open.index + tag.length, opens[i + 1]?.index ?? html.length);
6816
+ const size = /\bdata-size\s*=\s*["']([\w-]+)["']/i.exec(tag)?.[1]?.toLowerCase();
6817
+ const width = Number(/\bdata-width\s*=\s*["'](\d+)["']/i.exec(tag)?.[1]) || void 0;
6818
+ const labels = [...body.matchAll(/<\w+\b[^>]*\bclass\s*=\s*["'][^"']*\bname\b[^"']*["'][^>]*>([^<]*)/gi)].map((m) => normalise(m[1])).filter(Boolean);
6819
+ const art = [...body.matchAll(/<(svg|img|picture|canvas)\b|<i\b[^>]*\bclass\s*=\s*["'][^"']*\b(?:fa|fa-[\w-]+|material-icons|material-symbols[\w-]*|bi|bi-[\w-]+)\b/gi)].map((m) => m[1] ? `<${m[1].toLowerCase()}>` : "an icon font");
6820
+ return { size, width, labels, art };
6821
+ });
6822
+ }
6823
+ function specRegions(specBody) {
6824
+ const front = specBody.split(/^---\s*$/m)[1] ?? "";
6825
+ const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
6826
+ const out = {};
6827
+ const own = {};
6828
+ const sameAs = {};
6829
+ for (const [size] of [...FRAMES, ["landscape", 900]]) {
6830
+ const block = sizeBlock2(sizes, size);
6831
+ if (!block) continue;
6832
+ const alias = /^\s*same-as\s*:\s*([\w-]+)/im.exec(block)?.[1];
6833
+ if (alias) {
6834
+ sameAs[size] = alias.toLowerCase();
6835
+ continue;
6836
+ }
6837
+ own[size] = regionEntries(block).map(regionName);
6838
+ }
6839
+ for (const size of [...Object.keys(own), ...Object.keys(sameAs)]) {
6840
+ let at = size;
6841
+ for (let hops = 0; sameAs[at] && hops < 5; hops++) at = sameAs[at];
6842
+ if (own[at]) out[size] = own[at];
6843
+ }
6844
+ return out;
6845
+ }
6846
+ function regionEntries(block) {
6847
+ const inline = /^\s*regions\s*:\s*\[(.*)\]\s*$/im.exec(block);
6848
+ if (inline) return inline[1].split(",").map((s) => s.trim()).filter(Boolean);
6849
+ const start = /^(\s*)regions\s*:\s*$/im.exec(block);
6850
+ if (!start) return [];
6851
+ const indent = start[1].length;
6852
+ const entries = [];
6853
+ for (const line of block.slice(start.index + start[0].length).split("\n")) {
6854
+ if (!line.trim()) continue;
6855
+ const lead = /^\s*/.exec(line)[0].length;
6856
+ if (lead <= indent) break;
6857
+ const item = /^\s*-\s*(.+)$/.exec(line);
6858
+ if (item) entries.push(item[1]);
6859
+ }
6860
+ return entries;
6861
+ }
6862
+ function regionName(entry) {
6863
+ const text = entry.trim().replace(/^["']|["']$/g, "").replace(/\\"/g, '"');
6864
+ const head = normalise(text.split(":")[0]);
6865
+ return head && head.split(" ").length <= 4 ? { name: head } : {};
6866
+ }
6867
+ function labelMatches(label, name) {
6868
+ return label === name || label.startsWith(`${name} `) || label.startsWith(`${name}:`) || new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(label);
6869
+ }
6870
+ function normalise(text) {
6871
+ return text.toLowerCase().replace(/[`"'“”]/g, "").replace(/\s+/g, " ").trim();
6872
+ }
6873
+ function sizeBlock2(sizes, size) {
6874
+ const start = new RegExp(`^(\\s+)${size}\\s*:.*$`, "im").exec(sizes);
6875
+ if (!start) return "";
6876
+ const indent = start[1].length;
6877
+ const lines = [];
6878
+ for (const line of sizes.slice(start.index + start[0].length).split("\n")) {
6879
+ if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
6880
+ lines.push(line);
6881
+ }
6882
+ return lines.join("\n");
6883
+ }
6884
+
6885
+ // src/commands/gate.ts
6691
6886
  var MAX_BLOCKS = 3;
6692
6887
  function lastJigCommand(transcriptPath) {
6693
6888
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6694
6889
  let text;
6695
6890
  try {
6696
- text = readFileSync31(transcriptPath, "utf8");
6891
+ text = readFileSync32(transcriptPath, "utf8");
6697
6892
  } catch {
6698
6893
  return void 0;
6699
6894
  }
@@ -6711,7 +6906,7 @@ function lastAssistantText(transcriptPath) {
6711
6906
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6712
6907
  let found;
6713
6908
  try {
6714
- for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
6909
+ for (const line of readFileSync32(transcriptPath, "utf8").split("\n")) {
6715
6910
  if (!line.includes('"assistant"')) continue;
6716
6911
  let entry;
6717
6912
  try {
@@ -6737,7 +6932,7 @@ var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup"]);
6737
6932
  function sessionStart(transcriptPath) {
6738
6933
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6739
6934
  try {
6740
- for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
6935
+ for (const line of readFileSync32(transcriptPath, "utf8").split("\n")) {
6741
6936
  const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
6742
6937
  if (m) {
6743
6938
  const t = Date.parse(m[1]);
@@ -6752,20 +6947,20 @@ function verdictsMtime(dir) {
6752
6947
  let newest = 0;
6753
6948
  for (const name of VERDICT_FILES) {
6754
6949
  try {
6755
- newest = Math.max(newest, statSync4(join38(dir, name)).mtimeMs);
6950
+ newest = Math.max(newest, statSync5(join39(dir, name)).mtimeMs);
6756
6951
  } catch {
6757
6952
  }
6758
6953
  }
6759
6954
  return newest;
6760
6955
  }
6761
6956
  function surfacesInPlay(root, command, transcriptPath) {
6762
- const critiqueDir = join38(root, ".jig", "critique");
6957
+ const critiqueDir = join39(root, ".jig", "critique");
6763
6958
  if (!existsSync24(critiqueDir)) return [];
6764
6959
  const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
6765
6960
  const start = sessionStart(transcriptPath);
6766
6961
  if (start === void 0) return all;
6767
6962
  const current = command === "critique" ? /^\s*surface\s*:\s*(.+)$/im.exec(newestSpec(root)?.body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim().replace(/^["']|["']$/g, "") : void 0;
6768
- return all.filter((s) => s === current || verdictsMtime(join38(critiqueDir, s)) >= start - 1e3);
6963
+ return all.filter((s) => s === current || verdictsMtime(join39(critiqueDir, s)) >= start - 1e3);
6769
6964
  }
6770
6965
  function commandProblems(root, command) {
6771
6966
  const problems = [];
@@ -6774,7 +6969,7 @@ function commandProblems(root, command) {
6774
6969
  const found = decisionsFile(root);
6775
6970
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
6776
6971
  else {
6777
- const body = readFileSync31(join38(root, found), "utf8");
6972
+ const body = readFileSync32(join39(root, found), "utf8");
6778
6973
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
6779
6974
  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.`");
6780
6975
  }
@@ -6795,14 +6990,14 @@ function commandProblems(root, command) {
6795
6990
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
6796
6991
  if (/^approved/i.test(mockup)) {
6797
6992
  if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
6798
- else if (!/^https?:/i.test(at) && !existsSync24(join38(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
6993
+ else if (!/^https?:/i.test(at) && !existsSync24(join39(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
6799
6994
  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.`);
6800
6995
  }
6801
- if (at && /\.html?$/i.test(at) && existsSync24(join38(root, at))) problems.push(...mockupFrameProblems(root, at));
6996
+ if (at && /\.html?$/i.test(at) && existsSync24(join39(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
6802
6997
  }
6803
6998
  if (command === "critique") {
6804
- const dir = join38(root, ".jig", "critique");
6805
- const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join38(dir, s, "screen.json")) || existsSync24(join38(dir, s, "code.json"))) : [];
6999
+ const dir = join39(root, ".jig", "critique");
7000
+ const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join39(dir, s, "screen.json")) || existsSync24(join39(dir, s, "code.json"))) : [];
6806
7001
  for (const surface of surfaces) {
6807
7002
  const v = verifyVerdicts({ projectRoot: root, surface });
6808
7003
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
@@ -6824,13 +7019,13 @@ function surfacePage(projectRoot, surface) {
6824
7019
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
6825
7020
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
6826
7021
  for (const candidate of candidates) {
6827
- if (candidate && existsSync24(join38(projectRoot, candidate))) return candidate;
7022
+ if (candidate && existsSync24(join39(projectRoot, candidate))) return candidate;
6828
7023
  }
6829
7024
  return void 0;
6830
7025
  }
6831
7026
  function gate(opts) {
6832
7027
  const root = opts.projectRoot;
6833
- if (!existsSync24(join38(root, "jig.config.json")) && !existsSync24(join38(root, ".jig"))) {
7028
+ if (!existsSync24(join39(root, "jig.config.json")) && !existsSync24(join39(root, ".jig"))) {
6834
7029
  return { block: false, reason: "" };
6835
7030
  }
6836
7031
  const command = lastJigCommand(opts.input.transcript_path);
@@ -6857,11 +7052,11 @@ function gate(opts) {
6857
7052
  }
6858
7053
  }
6859
7054
  problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path)));
6860
- const critiqueDir = join38(root, ".jig", "critique");
7055
+ const critiqueDir = join39(root, ".jig", "critique");
6861
7056
  if (existsSync24(critiqueDir)) {
6862
7057
  for (const surface of surfacesInPlay(root, command, opts.input.transcript_path)) {
6863
- const dir = join38(critiqueDir, surface);
6864
- if (!existsSync24(join38(dir, "screen.json")) && !existsSync24(join38(dir, "code.json"))) continue;
7058
+ const dir = join39(critiqueDir, surface);
7059
+ if (!existsSync24(join39(dir, "screen.json")) && !existsSync24(join39(dir, "code.json"))) continue;
6865
7060
  const v = verifyVerdicts({ projectRoot: root, surface });
6866
7061
  if (!v.ok) {
6867
7062
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -6875,10 +7070,10 @@ function gate(opts) {
6875
7070
  }
6876
7071
  const session = opts.input.session_id ?? "unknown";
6877
7072
  const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
6878
- const stateFile = join38(root, ".jig", "gate.json");
7073
+ const stateFile = join39(root, ".jig", "gate.json");
6879
7074
  let state = {};
6880
7075
  try {
6881
- state = JSON.parse(readFileSync31(stateFile, "utf8"));
7076
+ state = JSON.parse(readFileSync32(stateFile, "utf8"));
6882
7077
  } catch {
6883
7078
  }
6884
7079
  if (problems.length === 0) {
@@ -6906,27 +7101,13 @@ ${problems.join("\n\n")}`
6906
7101
  ${problems.join("\n\n")}`
6907
7102
  };
6908
7103
  }
6909
- var MOCKUP_FRAMES = [["phone", 360], ["tablet", 768], ["desktop", 1280], ["wide", 1600]];
6910
- function mockupFrameProblems(root, at) {
6911
- let html;
6912
- try {
6913
- html = readFileSync31(join38(root, at), "utf8");
6914
- } catch {
6915
- return [];
6916
- }
6917
- const missing = MOCKUP_FRAMES.filter(([size]) => !new RegExp(`data-size\\s*=\\s*["']${size}["']`).test(html));
6918
- if (missing.length === 0) return [];
6919
- return [
6920
- `${at} has no frame for ${missing.map(([size, px2]) => `${size} (${px2}px)`).join(", ")}. A mockup draws the page once per size, side by side, in the wireframe template's \`<div class="frame" data-size="\u2026">\` boxes, so the owner sees every size at once. A single responsive page shows only the width of the window it is opened in.`
6921
- ];
6922
- }
6923
7104
  var VERDICT_FILES = ["screen.json", "code.json", "decisions.json"];
6924
7105
  var LOCK = "verdicts.lock";
6925
7106
  function verdictChecksum(dir) {
6926
7107
  const parts = VERDICT_FILES.map((f) => {
6927
7108
  try {
6928
7109
  return `${f}
6929
- ${readFileSync31(join38(dir, f), "utf8")}`;
7110
+ ${readFileSync32(join39(dir, f), "utf8")}`;
6930
7111
  } catch {
6931
7112
  return "";
6932
7113
  }
@@ -6934,25 +7115,25 @@ ${readFileSync31(join38(dir, f), "utf8")}`;
6934
7115
  return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
6935
7116
  }
6936
7117
  function verdictGuard(root, command, inPlay) {
6937
- const critiqueDir = join38(root, ".jig", "critique");
7118
+ const critiqueDir = join39(root, ".jig", "critique");
6938
7119
  if (!existsSync24(critiqueDir)) return [];
6939
7120
  const problems = [];
6940
7121
  for (const surface of readdirSync15(critiqueDir)) {
6941
7122
  if (surface.startsWith("_")) continue;
6942
- const dir = join38(critiqueDir, surface);
7123
+ const dir = join39(critiqueDir, surface);
6943
7124
  const now = verdictChecksum(dir);
6944
7125
  if (!now) continue;
6945
- const lockPath = join38(dir, LOCK);
7126
+ const lockPath = join39(dir, LOCK);
6946
7127
  if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
6947
7128
  try {
6948
- writeFileSync7(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
7129
+ writeFileSync6(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
6949
7130
  } catch {
6950
7131
  }
6951
7132
  continue;
6952
7133
  }
6953
7134
  let locked;
6954
7135
  try {
6955
- locked = JSON.parse(readFileSync31(lockPath, "utf8")).checksum;
7136
+ locked = JSON.parse(readFileSync32(lockPath, "utf8")).checksum;
6956
7137
  } catch {
6957
7138
  continue;
6958
7139
  }
@@ -6966,15 +7147,15 @@ function verdictGuard(root, command, inPlay) {
6966
7147
  }
6967
7148
  function save(file, state) {
6968
7149
  try {
6969
- mkdirSync8(join38(file, ".."), { recursive: true });
6970
- writeFileSync7(file, JSON.stringify(state), "utf8");
7150
+ mkdirSync8(join39(file, ".."), { recursive: true });
7151
+ writeFileSync6(file, JSON.stringify(state), "utf8");
6971
7152
  } catch {
6972
7153
  }
6973
7154
  }
6974
7155
 
6975
7156
  // src/commands/seo.ts
6976
- import { readFileSync as readFileSync32 } from "fs";
6977
- import { join as join39 } from "path";
7157
+ import { readFileSync as readFileSync33 } from "fs";
7158
+ import { join as join40 } from "path";
6978
7159
  var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
6979
7160
  var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
6980
7161
  var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
@@ -7015,7 +7196,7 @@ function seo(opts) {
7015
7196
  for (const file of files) {
7016
7197
  let raw;
7017
7198
  try {
7018
- raw = readFileSync32(join39(root, file), "utf8");
7199
+ raw = readFileSync33(join40(root, file), "utf8");
7019
7200
  } catch {
7020
7201
  continue;
7021
7202
  }
@@ -7071,7 +7252,7 @@ function seo(opts) {
7071
7252
 
7072
7253
  // src/index.ts
7073
7254
  var packageRoot = getPackageRoot();
7074
- var { version } = JSON.parse(readFileSync33(join40(packageRoot, "package.json"), "utf8"));
7255
+ var { version } = JSON.parse(readFileSync34(join41(packageRoot, "package.json"), "utf8"));
7075
7256
  function warnIfUnpublishedPin() {
7076
7257
  if (isPublishedBuild(packageRoot)) return;
7077
7258
  console.warn(
@@ -7265,7 +7446,7 @@ program.command("probe").description("Print the render probe. With --save, read
7265
7446
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
7266
7447
  let input = {};
7267
7448
  try {
7268
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync33(0, "utf8") || "{}");
7449
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync34(0, "utf8") || "{}");
7269
7450
  } catch {
7270
7451
  }
7271
7452
  const cwd = input.cwd ?? process.cwd();
@@ -7304,7 +7485,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
7304
7485
  });
7305
7486
  function resolveSurfaceMode(projectRoot) {
7306
7487
  try {
7307
- const config = JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8"));
7488
+ const config = JSON.parse(readFileSync34(join41(projectRoot, "jig.config.json"), "utf8"));
7308
7489
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
7309
7490
  return modes.length === 1 ? modes[0] : void 0;
7310
7491
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.18.5",
3
+ "version": "0.18.8",
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",
@@ -343,6 +343,7 @@ sizes: # phone first. Each size is a whole composition, not
343
343
  wide: # judged at 1600px — where a layout with no upper bound shows itself
344
344
  same-as: desktop
345
345
  why: "content is capped at 1200px, so 1600 adds margin and nothing else"
346
+ switches: none # the recorded switches (--breakpoint-*, T-04) this page crosses, by name; none if it crosses none. Absent, the mockup draws either side of every one
346
347
  states: [default, loading, error, success]
347
348
  decisions: [The Engineer Reads First] # every DECISIONS.md entry this screen implements, by name
348
349
  later: [social sign-in, remember this device] # cut from V1, by name — the next specs start here
@@ -561,6 +562,14 @@ look. So the drawing contains:
561
562
  detail: a phone composition that is only the desktop one narrower is the failure
562
563
  specs are written per size to prevent. A `same-as:` size is drawn anyway, so the
563
564
  claim can be seen to hold.
565
+ - **Either side of each switch the project records.** Where the project has
566
+ measured and recorded a width its layout changes at (`--breakpoint-<what>`,
567
+ `T-04`), draw the last width before it and the first at it, after the four
568
+ sizes: `<section class="frame" data-size="switch" data-width="539"
569
+ style="width:539px">` and the same at 540. The spec's `switches:` names the
570
+ ones this page crosses; absent, draw every one. The four sizes are where a page is
571
+ judged, not where it changes, and a switch between them is otherwise never
572
+ seen before it is built.
564
573
  - **Navigation at every size, exactly as the spec's `nav:` says for that size.** On
565
574
  the phone that usually means the Menu button, drawn where `DECISIONS.md` puts it —
566
575
  and, because an open menu changes the structure, a second phone frame labelled
@@ -634,7 +643,10 @@ for is review: is the header taking a sixth of the phone? Is a tap target under
634
643
 
635
644
  **Check the drawing against the spec before anyone sees it.** For each size, go
636
645
  down the spec's `regions:` and its `nav:` and find each one in that frame. A region
637
- the spec names and the frame lacks is drawn now, not raised in review. This matters
646
+ the spec names and the frame lacks is drawn now, not raised in review. The gate
647
+ checks an HTML drawing when you stop: a frame for each of the four sizes, each
648
+ size's regions labelled in its frame (by name where the spec names them), a
649
+ frame either side of each recorded switch, and no icon or image in any frame. This matters
638
650
  most on the phone, where a region dropped for space is easy to miss and the reviewer
639
651
  is looking at what is there, not at what is not.
640
652