jig-ui 0.18.3 → 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,55 @@
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
+
27
+ ## 0.18.5 (2026-09-25)
28
+
29
+ The gate holds an HTML mockup that does not draw the page at every size.
30
+
31
+ ### Fixed
32
+
33
+ - **The gate holds a mockup that does not show every size.** On jig-site three
34
+ HTML mockups in a row were single responsive pages, so the owner saw only the
35
+ width of the window they opened and asked twice where the other sizes were.
36
+ The procedure's wireframe template draws the page once per size, side by
37
+ side, in `.frame[data-size]` boxes, and nothing checked a drawing used them.
38
+ When a `mockup` session stops with an HTML drawing, the gate now requires a
39
+ frame for phone (360), tablet (768), desktop (1280) and wide (1600).
40
+
41
+ ## 0.18.4 (2026-09-25)
42
+
43
+ Every release summary is one or two lines, for jig-site's Versions page.
44
+
45
+ ### Changed
46
+
47
+ - **Every release summary is one or two lines, with no em dash.** jig-site's
48
+ Versions page shows the summary under each release, so it is interface text.
49
+ Nine ran to three to thirteen lines; their detail now sits under
50
+ `### Upgrading` or `### Notes` in the same release. A test keeps summaries
51
+ short.
52
+
3
53
  ## 0.18.3 (2026-09-25)
4
54
 
5
55
  Recording a decision no longer undoes the critiques written before it, and an
@@ -72,9 +122,8 @@ menu, and `A-05` missed an arrow that renders as an emoji.
72
122
 
73
123
  ## 0.18.0 (2026-09-25)
74
124
 
75
- A page's frame gets tokens, a project gets a place to record where its layout
76
- switches, and keyboard access a rule requires fits every script budget. All of
77
- it found building jig-site's header and Reference with the full loop.
125
+ A page's frame gets tokens, a project records where its layout switches, and
126
+ keyboard access a rule requires fits every script budget.
78
127
 
79
128
  ### Added
80
129
 
@@ -181,8 +230,12 @@ The gate judges only the critiques a session touched.
181
230
  ## 0.17.0 (2026-09-24)
182
231
 
183
232
  Thirteen more tells of a generated page, ten of them caught by `check`, and a
184
- picture for every rule. Also carries the probe and verdict fixes tagged as 0.16.2,
185
- which was not published to npm.
233
+ picture for every rule.
234
+
235
+ ### Notes
236
+
237
+ Also carries the probe and verdict fixes tagged as 0.16.2, which was not
238
+ published to npm.
186
239
 
187
240
  ### Added
188
241
 
@@ -468,8 +521,8 @@ One new rule: no em dashes in interface text (`I-118`), with a detector.
468
521
 
469
522
  ## 0.11.1 (2026-09-18)
470
523
 
471
- Two holes in the gate, both found by running the loop twice on 0.11.0 —
472
- once on Haiku, once on Sonnet.
524
+ Two holes in the gate, found by running the loop twice on 0.11.0: once on
525
+ Haiku, once on Sonnet.
473
526
 
474
527
  ### Fixed
475
528
 
@@ -487,9 +540,8 @@ once on Haiku, once on Sonnet.
487
540
 
488
541
  ## 0.11.0 (2026-09-18)
489
542
 
490
- One release, one lesson: a measurement an agent can type is not a
491
- measurement. Everything here came from two live runs at the capability
492
- floor the day 0.10.0 shipped.
543
+ A measurement an agent can type is not a measurement. Everything here came
544
+ from two live runs at the capability floor.
493
545
 
494
546
  ### Added
495
547
 
@@ -516,9 +568,8 @@ floor the day 0.10.0 shipped.
516
568
 
517
569
  ## 0.10.0 (2026-09-18)
518
570
 
519
- Mobile-first, and a design loop that a weak model cannot skip. Everything here
520
- was measured on live runs at the capability floor (Haiku), and most of it exists
521
- because an instruction the agent could choose to ignore was ignored.
571
+ Mobile-first, and a design loop that a weak model cannot skip, measured on live
572
+ runs with Haiku.
522
573
 
523
574
  ### Added
524
575
 
@@ -582,9 +633,8 @@ because an instruction the agent could choose to ignore was ignored.
582
633
 
583
634
  ## 0.9.0 (2026-09-13)
584
635
 
585
- One new rule and one amended correction, both from the same afternoon of
586
- dogfooding and both about the same blind spot: H-47's correction always pointed
587
- at a token, so an agent reading it literally always produced one.
636
+ One new rule and one amended correction, both about the same blind spot: H-47's
637
+ correction always pointed an agent at a token.
588
638
 
589
639
  ### Added
590
640
 
@@ -627,12 +677,12 @@ at a token, so an agent reading it literally always produced one.
627
677
 
628
678
  ## 0.8.2 (2026-09-12)
629
679
 
630
- Every fix here was found by a consumer using Jig rather than by Jig checking
631
- itself: the documentation site was upgraded to 0.8.1 and then styled in
632
- Tailwind, which is the first time Jig's Tailwind guidance had been followed
633
- end to end by anything other than its own tests.
680
+ Four fixes found by following Jig's Tailwind guidance end to end on its own
681
+ documentation site.
634
682
 
635
- Three of the four are the same shape — two places answering one question, and
683
+ ### Notes
684
+
685
+ Three of the four are the same shape: two places answering one question, and
636
686
  disagreeing without either knowing the other existed.
637
687
 
638
688
  ### Fixed
@@ -706,10 +756,8 @@ disagreeing without either knowing the other existed.
706
756
 
707
757
  ## 0.8.1 (2026-09-12)
708
758
 
709
- Both fixes here are the same shape: 0.8.0 corrected the instance it was looking
710
- at and left the class alone, and in each case the commit message claimed a
711
- verification that had only checked the file it had just edited. Both were found
712
- by inspecting the published tarball rather than the working tree.
759
+ Two fixes where 0.8.0 corrected one instance and left the class alone, both
760
+ found by inspecting the published tarball.
713
761
 
714
762
  ### Fixed
715
763
 
@@ -766,9 +814,10 @@ by inspecting the published tarball rather than the working tree.
766
814
 
767
815
  ## 0.8.0 (2026-09-11)
768
816
 
769
- Everything here was found by handing Jig to agents that had never seen it and
770
- asking them to build something real — its own documentation site. None of it
771
- was found by the test suite, which passed throughout.
817
+ Fixes found by agents new to Jig building its documentation site. The test
818
+ suite passed throughout and caught none of them.
819
+
820
+ ### Notes
772
821
 
773
822
  Minor rather than patch: `explain` prints lines it did not print before, and
774
823
  `init` can write a file it did not write before.
@@ -904,6 +953,8 @@ Minor rather than patch: `explain` prints lines it did not print before, and
904
953
 
905
954
  A rule file that contradicted the tool, and the guard that kept it that way.
906
955
 
956
+ ### Notes
957
+
907
958
  Patch: the shipped rules change, but no command writes anything different.
908
959
 
909
960
  ### Fixed
@@ -956,7 +1007,9 @@ already knew where the tokens were.
956
1007
 
957
1008
  ## 0.7.0 (2026-09-11)
958
1009
 
959
- A silent no-op, fixed, plus the missing half of 0.6.0's token-layer move.
1010
+ A silent no-op, fixed, and the missing half of 0.6.0's token-layer move.
1011
+
1012
+ ### Notes
960
1013
 
961
1014
  Minor rather than patch, and the call is arguable. The non-TTY fix is a
962
1015
  correction; the relocation offer is new behaviour that can move files, though
@@ -998,19 +1051,25 @@ to move their token layer should read these notes.
998
1051
 
999
1052
  ## 0.6.0 (2026-09-11)
1000
1053
 
1001
- The token layer stops hiding in a dotfolder, and `check` starts reading it back.
1054
+ The token layer leaves its dotfolder, and `check` reads the layer's own
1055
+ declarations back.
1056
+
1057
+ ### Upgrading
1002
1058
 
1003
- Both came from the same question: who should write the tokens. The answer turned
1004
- out to depend on something that did not exist — **nothing validated the token
1005
- layer's own declarations.** `init` checked a brand colour once, at write time,
1006
- and no command ever looked again. A generated file edited afterwards, by a person
1007
- or an agent, went unexamined: `--color-text-weak` dropped to 22% opacity and
1008
- `check` reported "No findings". With that closed, where the files live and who
1009
- writes them become ordinary decisions rather than load-bearing ones.
1059
+ Run `npx jig-ui@latest update`, then `npx jig-ui@latest init`. Existing installs
1060
+ keep their `.jig/tokens/` layout. Nothing moves unless you move it, because
1061
+ relocating files could break an import you wrote yourself.
1010
1062
 
1011
- **Upgrading:** run `npx jig-ui@latest update`, then `npx jig-ui@latest init`.
1012
- Existing installs keep their `.jig/tokens/` layout — nothing moves unless you
1013
- move it, because relocating files could break an import you wrote yourself.
1063
+ ### Notes
1064
+
1065
+ Both changes came from the same question: who should write the tokens. The
1066
+ answer turned out to depend on something that did not exist: **nothing
1067
+ validated the token layer's own declarations.** `init` checked a brand colour
1068
+ once, at write time, and no command ever looked again. A generated file edited
1069
+ afterwards, by a person or an agent, went unexamined: `--color-text-weak`
1070
+ dropped to 22% opacity and `check` reported "No findings". With that closed,
1071
+ where the files live and who writes them become ordinary decisions rather than
1072
+ load-bearing ones.
1014
1073
 
1015
1074
  ### Added
1016
1075
 
@@ -1092,18 +1151,24 @@ move it, because relocating files could break an import you wrote yourself.
1092
1151
 
1093
1152
  ## 0.5.0 (2026-09-10)
1094
1153
 
1095
- Four things shipped in 0.4.0 were broken in ways that reported success. `/jig
1096
- update` refreshed to the version already installed and said "Updated Jig →
1097
- 0.4.0". Dark mode could not be reached by choosing it. `check` skipped nearly
1098
- every colour in a project that had not run `init`. And the reconciliation of
1099
- every numeric default against an external reference is finished — 0 rows open —
1100
- which is where most of the rest of this release came from.
1154
+ Four things 0.4.0 reported as working did not, and the reconciliation of every
1155
+ numeric default against an external reference is finished.
1156
+
1157
+ ### Upgrading
1101
1158
 
1102
1159
  **If you are on 0.4.0, run `npx jig-ui@latest update` from a terminal.** The
1103
1160
  `/jig update` fix cannot deliver itself: your command file is the broken one, so
1104
1161
  the slash command will keep reporting a successful no-op until the CLI replaces
1105
1162
  it. Once, from the terminal, and the slash command works from then on.
1106
1163
 
1164
+ ### Notes
1165
+
1166
+ The four: `/jig update` refreshed to the version already installed and said
1167
+ "Updated Jig → 0.4.0". Dark mode could not be reached by choosing it. `check`
1168
+ skipped nearly every colour in a project that had not run `init`. And the
1169
+ reconciliation ended with 0 rows open, which is where most of the rest of this
1170
+ release came from.
1171
+
1107
1172
  ### Fixed
1108
1173
 
1109
1174
  - **`/jig update` could never upgrade anyone.** Every subcommand is invoked at
@@ -1186,11 +1251,14 @@ them, and nothing at all checked that a token cited in the rules exists.
1186
1251
 
1187
1252
  ## 0.4.0 (2026-09-04)
1188
1253
 
1189
- Jig stops copying itself into your project. It is a skill an agent reads, and
1190
- 0.3.0 wrote 220KB across 17 files into every consuming repo to deliver it —
1191
- roughly 200KB of that Jig's own property, read by an agent that already had it
1192
- from the skill install. A single-mode project now gets **three** files, all of
1193
- them its own.
1254
+ Jig stops copying itself into your project. A single-mode project now gets
1255
+ **three** files, all of them its own.
1256
+
1257
+ ### Notes
1258
+
1259
+ Jig is a skill an agent reads, and 0.3.0 wrote 220KB across 17 files into every
1260
+ consuming repo to deliver it. Roughly 200KB of that was Jig's own, read by an
1261
+ agent that already had it from the skill install.
1194
1262
 
1195
1263
  ### Changed
1196
1264
 
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,13 +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
  }
6931
+ if (at && /\.html?$/i.test(at) && existsSync24(join39(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
6801
6932
  }
6802
6933
  if (command === "critique") {
6803
- const dir = join38(root, ".jig", "critique");
6804
- 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"))) : [];
6805
6936
  for (const surface of surfaces) {
6806
6937
  const v = verifyVerdicts({ projectRoot: root, surface });
6807
6938
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
@@ -6823,13 +6954,13 @@ function surfacePage(projectRoot, surface) {
6823
6954
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
6824
6955
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
6825
6956
  for (const candidate of candidates) {
6826
- if (candidate && existsSync24(join38(projectRoot, candidate))) return candidate;
6957
+ if (candidate && existsSync24(join39(projectRoot, candidate))) return candidate;
6827
6958
  }
6828
6959
  return void 0;
6829
6960
  }
6830
6961
  function gate(opts) {
6831
6962
  const root = opts.projectRoot;
6832
- if (!existsSync24(join38(root, "jig.config.json")) && !existsSync24(join38(root, ".jig"))) {
6963
+ if (!existsSync24(join39(root, "jig.config.json")) && !existsSync24(join39(root, ".jig"))) {
6833
6964
  return { block: false, reason: "" };
6834
6965
  }
6835
6966
  const command = lastJigCommand(opts.input.transcript_path);
@@ -6856,11 +6987,11 @@ function gate(opts) {
6856
6987
  }
6857
6988
  }
6858
6989
  problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path)));
6859
- const critiqueDir = join38(root, ".jig", "critique");
6990
+ const critiqueDir = join39(root, ".jig", "critique");
6860
6991
  if (existsSync24(critiqueDir)) {
6861
6992
  for (const surface of surfacesInPlay(root, command, opts.input.transcript_path)) {
6862
- const dir = join38(critiqueDir, surface);
6863
- 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;
6864
6995
  const v = verifyVerdicts({ projectRoot: root, surface });
6865
6996
  if (!v.ok) {
6866
6997
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -6874,10 +7005,10 @@ function gate(opts) {
6874
7005
  }
6875
7006
  const session = opts.input.session_id ?? "unknown";
6876
7007
  const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
6877
- const stateFile = join38(root, ".jig", "gate.json");
7008
+ const stateFile = join39(root, ".jig", "gate.json");
6878
7009
  let state = {};
6879
7010
  try {
6880
- state = JSON.parse(readFileSync31(stateFile, "utf8"));
7011
+ state = JSON.parse(readFileSync32(stateFile, "utf8"));
6881
7012
  } catch {
6882
7013
  }
6883
7014
  if (problems.length === 0) {
@@ -6911,7 +7042,7 @@ function verdictChecksum(dir) {
6911
7042
  const parts = VERDICT_FILES.map((f) => {
6912
7043
  try {
6913
7044
  return `${f}
6914
- ${readFileSync31(join38(dir, f), "utf8")}`;
7045
+ ${readFileSync32(join39(dir, f), "utf8")}`;
6915
7046
  } catch {
6916
7047
  return "";
6917
7048
  }
@@ -6919,15 +7050,15 @@ ${readFileSync31(join38(dir, f), "utf8")}`;
6919
7050
  return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
6920
7051
  }
6921
7052
  function verdictGuard(root, command, inPlay) {
6922
- const critiqueDir = join38(root, ".jig", "critique");
7053
+ const critiqueDir = join39(root, ".jig", "critique");
6923
7054
  if (!existsSync24(critiqueDir)) return [];
6924
7055
  const problems = [];
6925
7056
  for (const surface of readdirSync15(critiqueDir)) {
6926
7057
  if (surface.startsWith("_")) continue;
6927
- const dir = join38(critiqueDir, surface);
7058
+ const dir = join39(critiqueDir, surface);
6928
7059
  const now = verdictChecksum(dir);
6929
7060
  if (!now) continue;
6930
- const lockPath = join38(dir, LOCK);
7061
+ const lockPath = join39(dir, LOCK);
6931
7062
  if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
6932
7063
  try {
6933
7064
  writeFileSync7(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
@@ -6937,7 +7068,7 @@ function verdictGuard(root, command, inPlay) {
6937
7068
  }
6938
7069
  let locked;
6939
7070
  try {
6940
- locked = JSON.parse(readFileSync31(lockPath, "utf8")).checksum;
7071
+ locked = JSON.parse(readFileSync32(lockPath, "utf8")).checksum;
6941
7072
  } catch {
6942
7073
  continue;
6943
7074
  }
@@ -6951,15 +7082,15 @@ function verdictGuard(root, command, inPlay) {
6951
7082
  }
6952
7083
  function save(file, state) {
6953
7084
  try {
6954
- mkdirSync8(join38(file, ".."), { recursive: true });
7085
+ mkdirSync8(join39(file, ".."), { recursive: true });
6955
7086
  writeFileSync7(file, JSON.stringify(state), "utf8");
6956
7087
  } catch {
6957
7088
  }
6958
7089
  }
6959
7090
 
6960
7091
  // src/commands/seo.ts
6961
- import { readFileSync as readFileSync32 } from "fs";
6962
- import { join as join39 } from "path";
7092
+ import { readFileSync as readFileSync33 } from "fs";
7093
+ import { join as join40 } from "path";
6963
7094
  var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
6964
7095
  var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
6965
7096
  var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
@@ -7000,7 +7131,7 @@ function seo(opts) {
7000
7131
  for (const file of files) {
7001
7132
  let raw;
7002
7133
  try {
7003
- raw = readFileSync32(join39(root, file), "utf8");
7134
+ raw = readFileSync33(join40(root, file), "utf8");
7004
7135
  } catch {
7005
7136
  continue;
7006
7137
  }
@@ -7056,7 +7187,7 @@ function seo(opts) {
7056
7187
 
7057
7188
  // src/index.ts
7058
7189
  var packageRoot = getPackageRoot();
7059
- var { version } = JSON.parse(readFileSync33(join40(packageRoot, "package.json"), "utf8"));
7190
+ var { version } = JSON.parse(readFileSync34(join41(packageRoot, "package.json"), "utf8"));
7060
7191
  function warnIfUnpublishedPin() {
7061
7192
  if (isPublishedBuild(packageRoot)) return;
7062
7193
  console.warn(
@@ -7250,7 +7381,7 @@ program.command("probe").description("Print the render probe. With --save, read
7250
7381
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
7251
7382
  let input = {};
7252
7383
  try {
7253
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync33(0, "utf8") || "{}");
7384
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync34(0, "utf8") || "{}");
7254
7385
  } catch {
7255
7386
  }
7256
7387
  const cwd = input.cwd ?? process.cwd();
@@ -7289,7 +7420,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
7289
7420
  });
7290
7421
  function resolveSurfaceMode(projectRoot) {
7291
7422
  try {
7292
- const config = JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8"));
7423
+ const config = JSON.parse(readFileSync34(join41(projectRoot, "jig.config.json"), "utf8"));
7293
7424
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
7294
7425
  return modes.length === 1 ? modes[0] : void 0;
7295
7426
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.18.3",
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