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 +24 -0
- package/dist/index.js +164 -48
- package/package.json +1 -1
- package/templates/COMMAND.md.tmpl +13 -1
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
|
|
5
|
+
import { readFileSync as readFileSync34 } from "fs";
|
|
6
6
|
import { homedir as homedir2 } from "os";
|
|
7
|
-
import { join as
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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 =
|
|
6805
|
-
const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
6864
|
-
if (!existsSync24(
|
|
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 =
|
|
7008
|
+
const stateFile = join39(root, ".jig", "gate.json");
|
|
6879
7009
|
let state = {};
|
|
6880
7010
|
try {
|
|
6881
|
-
state = JSON.parse(
|
|
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
|
-
${
|
|
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 =
|
|
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 =
|
|
7058
|
+
const dir = join39(critiqueDir, surface);
|
|
6943
7059
|
const now = verdictChecksum(dir);
|
|
6944
7060
|
if (!now) continue;
|
|
6945
|
-
const lockPath =
|
|
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(
|
|
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(
|
|
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
|
|
6977
|
-
import { join as
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|