jig-ui 0.18.5 → 0.18.6

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,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.6 (2026-09-25)
4
+
5
+ A mockup's frames must hold each size's regions, and the drawing shows either
6
+ side of each switch the project records.
7
+
8
+ ### Fixed
9
+
10
+ - **The gate checks what a mockup's frames hold, not only that they exist.**
11
+ 0.18.5 required a frame per size; a frame can still be empty, or hold the
12
+ desktop composition narrower. The procedure's self-check, "go down the spec's
13
+ `regions:` and find each one in that frame", was prose only. Now each size's
14
+ regions must be labelled in its frame: by name where the spec gives a short
15
+ one (`nav`, or `h1: "Versions"`), and by count where it writes a description,
16
+ so wording never trips it.
17
+ - **A mockup draws either side of each switch the project records.** The probe
18
+ has measured one pixel either side of every `--breakpoint-*` since 0.18.0, but
19
+ the drawing the owner approves showed only the four sizes; jig-site's header
20
+ changes at 540, between phone and tablet, and no frame showed 539 beside 540.
21
+ A new spec field, `switches:`, names the switches a page crosses (`none` if it
22
+ crosses none); absent, the mockup draws every one.
23
+ - **A spec's `indexable:` reads past a YAML comment.** The spec template itself
24
+ writes `indexable: true # from the mode ...`; a spec that copied it was
25
+ reported unreadable.
26
+
3
27
  ## 0.18.5 (2026-09-25)
4
28
 
5
29
  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";
@@ -6471,7 +6471,7 @@ function navProblems(spec) {
6471
6471
  function specIndexableField(front) {
6472
6472
  const line = /^\s*indexable\s*:(.*)$/im.exec(front);
6473
6473
  if (!line) return "absent";
6474
- const value = line[1].trim().replace(/^["']|["']$/g, "").toLowerCase();
6474
+ const value = line[1].replace(/\s+#.*$/, "").trim().replace(/^["']|["']$/g, "").toLowerCase();
6475
6475
  if (value === "true" || value === "yes") return true;
6476
6476
  if (value === "false" || value === "no") return false;
6477
6477
  return "unreadable";
@@ -6685,15 +6685,145 @@ function verifyVerdicts(opts) {
6685
6685
  }
6686
6686
 
6687
6687
  // 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";
6688
+ import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync32, statSync as statSync4, writeFileSync as writeFileSync7 } from "fs";
6689
6689
  import { createHash as createHash2 } from "crypto";
6690
+ import { join as join39 } from "path";
6691
+
6692
+ // src/check/mockup-drawing.ts
6693
+ import { readFileSync as readFileSync31 } from "fs";
6690
6694
  import { join as join38 } from "path";
6695
+ var FRAMES = [["phone", 360], ["tablet", 768], ["desktop", 1280], ["wide", 1600]];
6696
+ function mockupDrawingProblems(root, specBody, at) {
6697
+ let html;
6698
+ try {
6699
+ html = readFileSync31(join38(root, at), "utf8");
6700
+ } catch {
6701
+ return [];
6702
+ }
6703
+ const frames = readFrames(html);
6704
+ const problems = [];
6705
+ const missing = FRAMES.filter(([size]) => !frames.some((f) => f.size === size));
6706
+ if (missing.length) {
6707
+ problems.push(
6708
+ `${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.`
6709
+ );
6710
+ }
6711
+ const regions = specRegions(specBody);
6712
+ for (const [size] of FRAMES) {
6713
+ const listed = regions[size];
6714
+ const inFrame = frames.filter((f) => f.size === size).flatMap((f) => f.labels);
6715
+ if (!listed || listed.length === 0 || !frames.some((f) => f.size === size)) continue;
6716
+ const named = listed.filter((r) => r.name).map((r) => r.name);
6717
+ const absent = named.filter((name) => !inFrame.some((label) => labelMatches(label, name)));
6718
+ if (absent.length) {
6719
+ problems.push(
6720
+ `${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.`
6721
+ );
6722
+ } else if (inFrame.length < listed.length) {
6723
+ problems.push(
6724
+ `${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.`
6725
+ );
6726
+ }
6727
+ }
6728
+ const widths = new Set(frames.map((f) => f.width).filter((w) => w !== void 0));
6729
+ const crossed = specSwitches(specBody);
6730
+ for (const s of declaredSwitches(root).filter((sw) => !crossed || crossed.includes(sw.name))) {
6731
+ const absent = [s.px - 1, s.px].filter((w) => !widths.has(w));
6732
+ if (absent.length) {
6733
+ problems.push(
6734
+ `${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\`.`
6735
+ );
6736
+ }
6737
+ }
6738
+ return problems;
6739
+ }
6740
+ function specSwitches(specBody) {
6741
+ const front = specBody.split(/^---\s*$/m)[1] ?? "";
6742
+ const value = /^switches\s*:\s*(.+)$/im.exec(front)?.[1]?.replace(/#.*$/, "").trim();
6743
+ if (value === void 0) return void 0;
6744
+ if (/^(none|\[\s*\])$/i.test(value)) return [];
6745
+ return value.replace(/^\[|\]$/g, "").split(",").map((n) => n.trim().replace(/^["']|["']$/g, "").replace(/^--breakpoint-/, "")).filter(Boolean);
6746
+ }
6747
+ function readFrames(html) {
6748
+ const opens = [...html.matchAll(/<\w+\b[^>]*\bclass\s*=\s*["'][^"']*\bframe\b[^"']*["'][^>]*>/gi)];
6749
+ return opens.map((open, i) => {
6750
+ const tag = open[0];
6751
+ const body = html.slice(open.index + tag.length, opens[i + 1]?.index ?? html.length);
6752
+ const size = /\bdata-size\s*=\s*["']([\w-]+)["']/i.exec(tag)?.[1]?.toLowerCase();
6753
+ const width = Number(/\bdata-width\s*=\s*["'](\d+)["']/i.exec(tag)?.[1]) || void 0;
6754
+ const labels = [...body.matchAll(/<\w+\b[^>]*\bclass\s*=\s*["'][^"']*\bname\b[^"']*["'][^>]*>([^<]*)/gi)].map((m) => normalise(m[1])).filter(Boolean);
6755
+ return { size, width, labels };
6756
+ });
6757
+ }
6758
+ function specRegions(specBody) {
6759
+ const front = specBody.split(/^---\s*$/m)[1] ?? "";
6760
+ const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
6761
+ const out = {};
6762
+ const own = {};
6763
+ const sameAs = {};
6764
+ for (const [size] of [...FRAMES, ["landscape", 900]]) {
6765
+ const block = sizeBlock2(sizes, size);
6766
+ if (!block) continue;
6767
+ const alias = /^\s*same-as\s*:\s*([\w-]+)/im.exec(block)?.[1];
6768
+ if (alias) {
6769
+ sameAs[size] = alias.toLowerCase();
6770
+ continue;
6771
+ }
6772
+ own[size] = regionEntries(block).map(regionName);
6773
+ }
6774
+ for (const size of [...Object.keys(own), ...Object.keys(sameAs)]) {
6775
+ let at = size;
6776
+ for (let hops = 0; sameAs[at] && hops < 5; hops++) at = sameAs[at];
6777
+ if (own[at]) out[size] = own[at];
6778
+ }
6779
+ return out;
6780
+ }
6781
+ function regionEntries(block) {
6782
+ const inline = /^\s*regions\s*:\s*\[(.*)\]\s*$/im.exec(block);
6783
+ if (inline) return inline[1].split(",").map((s) => s.trim()).filter(Boolean);
6784
+ const start = /^(\s*)regions\s*:\s*$/im.exec(block);
6785
+ if (!start) return [];
6786
+ const indent = start[1].length;
6787
+ const entries = [];
6788
+ for (const line of block.slice(start.index + start[0].length).split("\n")) {
6789
+ if (!line.trim()) continue;
6790
+ const lead = /^\s*/.exec(line)[0].length;
6791
+ if (lead <= indent) break;
6792
+ const item = /^\s*-\s*(.+)$/.exec(line);
6793
+ if (item) entries.push(item[1]);
6794
+ }
6795
+ return entries;
6796
+ }
6797
+ function regionName(entry) {
6798
+ const text = entry.trim().replace(/^["']|["']$/g, "").replace(/\\"/g, '"');
6799
+ const head = normalise(text.split(":")[0]);
6800
+ return head && head.split(" ").length <= 4 ? { name: head } : {};
6801
+ }
6802
+ function labelMatches(label, name) {
6803
+ return label === name || label.startsWith(`${name} `) || label.startsWith(`${name}:`) || new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(label);
6804
+ }
6805
+ function normalise(text) {
6806
+ return text.toLowerCase().replace(/[`"'“”]/g, "").replace(/\s+/g, " ").trim();
6807
+ }
6808
+ function sizeBlock2(sizes, size) {
6809
+ const start = new RegExp(`^(\\s+)${size}\\s*:.*$`, "im").exec(sizes);
6810
+ if (!start) return "";
6811
+ const indent = start[1].length;
6812
+ const lines = [];
6813
+ for (const line of sizes.slice(start.index + start[0].length).split("\n")) {
6814
+ if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
6815
+ lines.push(line);
6816
+ }
6817
+ return lines.join("\n");
6818
+ }
6819
+
6820
+ // src/commands/gate.ts
6691
6821
  var MAX_BLOCKS = 3;
6692
6822
  function lastJigCommand(transcriptPath) {
6693
6823
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6694
6824
  let text;
6695
6825
  try {
6696
- text = readFileSync31(transcriptPath, "utf8");
6826
+ text = readFileSync32(transcriptPath, "utf8");
6697
6827
  } catch {
6698
6828
  return void 0;
6699
6829
  }
@@ -6711,7 +6841,7 @@ function lastAssistantText(transcriptPath) {
6711
6841
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6712
6842
  let found;
6713
6843
  try {
6714
- for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
6844
+ for (const line of readFileSync32(transcriptPath, "utf8").split("\n")) {
6715
6845
  if (!line.includes('"assistant"')) continue;
6716
6846
  let entry;
6717
6847
  try {
@@ -6737,7 +6867,7 @@ var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup"]);
6737
6867
  function sessionStart(transcriptPath) {
6738
6868
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6739
6869
  try {
6740
- for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
6870
+ for (const line of readFileSync32(transcriptPath, "utf8").split("\n")) {
6741
6871
  const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
6742
6872
  if (m) {
6743
6873
  const t = Date.parse(m[1]);
@@ -6752,20 +6882,20 @@ function verdictsMtime(dir) {
6752
6882
  let newest = 0;
6753
6883
  for (const name of VERDICT_FILES) {
6754
6884
  try {
6755
- newest = Math.max(newest, statSync4(join38(dir, name)).mtimeMs);
6885
+ newest = Math.max(newest, statSync4(join39(dir, name)).mtimeMs);
6756
6886
  } catch {
6757
6887
  }
6758
6888
  }
6759
6889
  return newest;
6760
6890
  }
6761
6891
  function surfacesInPlay(root, command, transcriptPath) {
6762
- const critiqueDir = join38(root, ".jig", "critique");
6892
+ const critiqueDir = join39(root, ".jig", "critique");
6763
6893
  if (!existsSync24(critiqueDir)) return [];
6764
6894
  const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
6765
6895
  const start = sessionStart(transcriptPath);
6766
6896
  if (start === void 0) return all;
6767
6897
  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);
6898
+ return all.filter((s) => s === current || verdictsMtime(join39(critiqueDir, s)) >= start - 1e3);
6769
6899
  }
6770
6900
  function commandProblems(root, command) {
6771
6901
  const problems = [];
@@ -6774,7 +6904,7 @@ function commandProblems(root, command) {
6774
6904
  const found = decisionsFile(root);
6775
6905
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
6776
6906
  else {
6777
- const body = readFileSync31(join38(root, found), "utf8");
6907
+ const body = readFileSync32(join39(root, found), "utf8");
6778
6908
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
6779
6909
  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
6910
  }
@@ -6795,14 +6925,14 @@ function commandProblems(root, command) {
6795
6925
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
6796
6926
  if (/^approved/i.test(mockup)) {
6797
6927
  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.`);
6928
+ else if (!/^https?:/i.test(at) && !existsSync24(join39(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
6799
6929
  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
6930
  }
6801
- if (at && /\.html?$/i.test(at) && existsSync24(join38(root, at))) problems.push(...mockupFrameProblems(root, at));
6931
+ if (at && /\.html?$/i.test(at) && existsSync24(join39(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
6802
6932
  }
6803
6933
  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"))) : [];
6934
+ const dir = join39(root, ".jig", "critique");
6935
+ const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join39(dir, s, "screen.json")) || existsSync24(join39(dir, s, "code.json"))) : [];
6806
6936
  for (const surface of surfaces) {
6807
6937
  const v = verifyVerdicts({ projectRoot: root, surface });
6808
6938
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
@@ -6824,13 +6954,13 @@ function surfacePage(projectRoot, surface) {
6824
6954
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
6825
6955
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
6826
6956
  for (const candidate of candidates) {
6827
- if (candidate && existsSync24(join38(projectRoot, candidate))) return candidate;
6957
+ if (candidate && existsSync24(join39(projectRoot, candidate))) return candidate;
6828
6958
  }
6829
6959
  return void 0;
6830
6960
  }
6831
6961
  function gate(opts) {
6832
6962
  const root = opts.projectRoot;
6833
- if (!existsSync24(join38(root, "jig.config.json")) && !existsSync24(join38(root, ".jig"))) {
6963
+ if (!existsSync24(join39(root, "jig.config.json")) && !existsSync24(join39(root, ".jig"))) {
6834
6964
  return { block: false, reason: "" };
6835
6965
  }
6836
6966
  const command = lastJigCommand(opts.input.transcript_path);
@@ -6857,11 +6987,11 @@ function gate(opts) {
6857
6987
  }
6858
6988
  }
6859
6989
  problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path)));
6860
- const critiqueDir = join38(root, ".jig", "critique");
6990
+ const critiqueDir = join39(root, ".jig", "critique");
6861
6991
  if (existsSync24(critiqueDir)) {
6862
6992
  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;
6993
+ const dir = join39(critiqueDir, surface);
6994
+ if (!existsSync24(join39(dir, "screen.json")) && !existsSync24(join39(dir, "code.json"))) continue;
6865
6995
  const v = verifyVerdicts({ projectRoot: root, surface });
6866
6996
  if (!v.ok) {
6867
6997
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -6875,10 +7005,10 @@ function gate(opts) {
6875
7005
  }
6876
7006
  const session = opts.input.session_id ?? "unknown";
6877
7007
  const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
6878
- const stateFile = join38(root, ".jig", "gate.json");
7008
+ const stateFile = join39(root, ".jig", "gate.json");
6879
7009
  let state = {};
6880
7010
  try {
6881
- state = JSON.parse(readFileSync31(stateFile, "utf8"));
7011
+ state = JSON.parse(readFileSync32(stateFile, "utf8"));
6882
7012
  } catch {
6883
7013
  }
6884
7014
  if (problems.length === 0) {
@@ -6906,27 +7036,13 @@ ${problems.join("\n\n")}`
6906
7036
  ${problems.join("\n\n")}`
6907
7037
  };
6908
7038
  }
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
7039
  var VERDICT_FILES = ["screen.json", "code.json", "decisions.json"];
6924
7040
  var LOCK = "verdicts.lock";
6925
7041
  function verdictChecksum(dir) {
6926
7042
  const parts = VERDICT_FILES.map((f) => {
6927
7043
  try {
6928
7044
  return `${f}
6929
- ${readFileSync31(join38(dir, f), "utf8")}`;
7045
+ ${readFileSync32(join39(dir, f), "utf8")}`;
6930
7046
  } catch {
6931
7047
  return "";
6932
7048
  }
@@ -6934,15 +7050,15 @@ ${readFileSync31(join38(dir, f), "utf8")}`;
6934
7050
  return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
6935
7051
  }
6936
7052
  function verdictGuard(root, command, inPlay) {
6937
- const critiqueDir = join38(root, ".jig", "critique");
7053
+ const critiqueDir = join39(root, ".jig", "critique");
6938
7054
  if (!existsSync24(critiqueDir)) return [];
6939
7055
  const problems = [];
6940
7056
  for (const surface of readdirSync15(critiqueDir)) {
6941
7057
  if (surface.startsWith("_")) continue;
6942
- const dir = join38(critiqueDir, surface);
7058
+ const dir = join39(critiqueDir, surface);
6943
7059
  const now = verdictChecksum(dir);
6944
7060
  if (!now) continue;
6945
- const lockPath = join38(dir, LOCK);
7061
+ const lockPath = join39(dir, LOCK);
6946
7062
  if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
6947
7063
  try {
6948
7064
  writeFileSync7(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
@@ -6952,7 +7068,7 @@ function verdictGuard(root, command, inPlay) {
6952
7068
  }
6953
7069
  let locked;
6954
7070
  try {
6955
- locked = JSON.parse(readFileSync31(lockPath, "utf8")).checksum;
7071
+ locked = JSON.parse(readFileSync32(lockPath, "utf8")).checksum;
6956
7072
  } catch {
6957
7073
  continue;
6958
7074
  }
@@ -6966,15 +7082,15 @@ function verdictGuard(root, command, inPlay) {
6966
7082
  }
6967
7083
  function save(file, state) {
6968
7084
  try {
6969
- mkdirSync8(join38(file, ".."), { recursive: true });
7085
+ mkdirSync8(join39(file, ".."), { recursive: true });
6970
7086
  writeFileSync7(file, JSON.stringify(state), "utf8");
6971
7087
  } catch {
6972
7088
  }
6973
7089
  }
6974
7090
 
6975
7091
  // src/commands/seo.ts
6976
- import { readFileSync as readFileSync32 } from "fs";
6977
- import { join as join39 } from "path";
7092
+ import { readFileSync as readFileSync33 } from "fs";
7093
+ import { join as join40 } from "path";
6978
7094
  var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
6979
7095
  var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
6980
7096
  var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
@@ -7015,7 +7131,7 @@ function seo(opts) {
7015
7131
  for (const file of files) {
7016
7132
  let raw;
7017
7133
  try {
7018
- raw = readFileSync32(join39(root, file), "utf8");
7134
+ raw = readFileSync33(join40(root, file), "utf8");
7019
7135
  } catch {
7020
7136
  continue;
7021
7137
  }
@@ -7071,7 +7187,7 @@ function seo(opts) {
7071
7187
 
7072
7188
  // src/index.ts
7073
7189
  var packageRoot = getPackageRoot();
7074
- var { version } = JSON.parse(readFileSync33(join40(packageRoot, "package.json"), "utf8"));
7190
+ var { version } = JSON.parse(readFileSync34(join41(packageRoot, "package.json"), "utf8"));
7075
7191
  function warnIfUnpublishedPin() {
7076
7192
  if (isPublishedBuild(packageRoot)) return;
7077
7193
  console.warn(
@@ -7265,7 +7381,7 @@ program.command("probe").description("Print the render probe. With --save, read
7265
7381
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
7266
7382
  let input = {};
7267
7383
  try {
7268
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync33(0, "utf8") || "{}");
7384
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync34(0, "utf8") || "{}");
7269
7385
  } catch {
7270
7386
  }
7271
7387
  const cwd = input.cwd ?? process.cwd();
@@ -7304,7 +7420,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
7304
7420
  });
7305
7421
  function resolveSurfaceMode(projectRoot) {
7306
7422
  try {
7307
- const config = JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8"));
7423
+ const config = JSON.parse(readFileSync34(join41(projectRoot, "jig.config.json"), "utf8"));
7308
7424
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
7309
7425
  return modes.length === 1 ? modes[0] : void 0;
7310
7426
  } 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.6",
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), and a
649
+ frame either side of each recorded switch. 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