jig-ui 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/dist/index.js +358 -58
- package/package.json +1 -1
- package/rules/01-modes.md +1 -1
- package/rules/03-patterns.md +6 -1
- package/templates/COMMAND.md.tmpl +84 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.0 (2026-09-27)
|
|
4
|
+
|
|
5
|
+
Critiques count owner rulings apart and say what the last round fixed; the gate
|
|
6
|
+
keeps decisions the owner's, and tweak works on its first real pages.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **A `ruled` verdict.** A finding the owner has already decided is judged
|
|
11
|
+
`ruled`, citing the decision's heading in `ruling`, and counted apart
|
|
12
|
+
(`ruled=`). On jig-site five of a header critique's 15 findings were the
|
|
13
|
+
owner's rulings, counted as findings and sent back to make.
|
|
14
|
+
- **What happened to the last round's findings.** `jig verdicts` compares a
|
|
15
|
+
critique with the one before it in git and prints how many were fixed, are
|
|
16
|
+
still open, were ruled, and are new, with the ids. The report carries it.
|
|
17
|
+
- **`jig verdicts --reprobe`** re-takes probes taken on an older page. The
|
|
18
|
+
gate and `--reprobe` now find the page from the probes themselves, so a spec
|
|
19
|
+
whose `surface:` is a sentence no longer leaves stale probes blocking.
|
|
20
|
+
- **The probe names text drawn in a weight or style no loaded face covers**,
|
|
21
|
+
which the browser fakes from the nearest face. It is an error, like a page in
|
|
22
|
+
the default font. Probe version 8.
|
|
23
|
+
- **`jig update` names the tokens** each refreshed mode file gained or lost,
|
|
24
|
+
and those an edited one did not get, since anything aliasing tokens (a
|
|
25
|
+
Tailwind `@theme`) needs the same change.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **`decide` keeps the owner's reasons apart from its own.** A `Why:` is the
|
|
30
|
+
owner's words in quotation marks or `not given`; anything the agent adds goes
|
|
31
|
+
under `**Why (inferred):**`. The gate checks each decision the session wrote
|
|
32
|
+
or changed.
|
|
33
|
+
- **`make` does not edit DECISIONS.md.** The gate stops a make session that
|
|
34
|
+
changed it; decisions go through `decide` or `tweak`.
|
|
35
|
+
- **A spec that no longer matches its approved drawing is not approved.** In
|
|
36
|
+
`spec` and `make`, each region the spec lists must be labelled in its size's
|
|
37
|
+
frame. Region names are read as the spec means them: a parenthetical
|
|
38
|
+
qualifies, "(… only)" is conditional, and a list names several regions.
|
|
39
|
+
- **Critique's reader arms do not read the page's history**: no earlier
|
|
40
|
+
critiques, no git log. Which findings were fixed is the CLI's question.
|
|
41
|
+
- **P-14 says how a menu in a fixed header behaves**: it lies over the page, a
|
|
42
|
+
tap outside closes it and does nothing else, controls in the header row work
|
|
43
|
+
on the first tap, and a long list scrolls inside itself.
|
|
44
|
+
- **M-01's script budget is "none by default"**, not an absolute zero: a
|
|
45
|
+
feature may have script, named in a comment and as small as it allows.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **`tweak`'s gate blocked both of its first real uses, on jig-site.** Three
|
|
50
|
+
causes, all in the gate:
|
|
51
|
+
- It took the session's command from any line that mentioned `/jig`, so the
|
|
52
|
+
command file Jig loads into the session, and tool results quoting it, read
|
|
53
|
+
as `/jig critique`. Only what the user sent counts now.
|
|
54
|
+
- It looked for the critique under the spec's `surface:` field, which on
|
|
55
|
+
jig-site is a sentence. A critique lives under the spec's file name, as
|
|
56
|
+
the command file says.
|
|
57
|
+
- It held the page to its structure at the commit that first approved the
|
|
58
|
+
mockup, before five confirmed, critiqued rounds. It now holds the page to
|
|
59
|
+
its structure at its last critique.
|
|
60
|
+
|
|
61
|
+
The gate also works on the spec the command names (`/jig tweak site-header`),
|
|
62
|
+
not whichever spec was written last.
|
|
63
|
+
- **`jig verdicts` refuses reasons that judge nothing.** On jig-site a render
|
|
64
|
+
arm stopped with 33 of 34 verdicts reading "DRAFT, being refined", and the
|
|
65
|
+
review passed with full counts. A placeholder reason is now an error, and so
|
|
66
|
+
is one reason given word for word by four or more `ok` or `finding` verdicts
|
|
67
|
+
in a file. `n/a` verdicts may still share a reason, since one absence can
|
|
68
|
+
clear many rules.
|
|
69
|
+
|
|
3
70
|
## 0.19.0 (2026-09-27)
|
|
4
71
|
|
|
5
72
|
A new command, `tweak`, for a small change to a built page that its approved
|
package/dist/index.js
CHANGED
|
@@ -941,6 +941,16 @@ function removeLegacyFiles(projectRoot, relPaths) {
|
|
|
941
941
|
}
|
|
942
942
|
|
|
943
943
|
// src/commands/update.ts
|
|
944
|
+
function declaredTokens(css) {
|
|
945
|
+
return new Set([...css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/(--[\w-]+)\s*:/g)].map((m) => m[1]));
|
|
946
|
+
}
|
|
947
|
+
function tokenChange(file, before, after, skipped = false) {
|
|
948
|
+
const was = declaredTokens(before);
|
|
949
|
+
const now = declaredTokens(after);
|
|
950
|
+
const added = [...now].filter((t) => !was.has(t)).sort();
|
|
951
|
+
const removed = [...was].filter((t) => !now.has(t)).sort();
|
|
952
|
+
return added.length || removed.length ? { file, added, removed, ...skipped ? { skipped } : {} } : void 0;
|
|
953
|
+
}
|
|
944
954
|
function update(opts) {
|
|
945
955
|
const resolved = resolveAllInstalled(opts.projectRoot, opts.homeDir);
|
|
946
956
|
if (resolved.length === 0) {
|
|
@@ -972,7 +982,8 @@ function update(opts) {
|
|
|
972
982
|
skipped,
|
|
973
983
|
fromVersion: resolved[0].manifest.version,
|
|
974
984
|
toVersion: opts.version,
|
|
975
|
-
targets
|
|
985
|
+
targets,
|
|
986
|
+
tokens: initResult.tokens
|
|
976
987
|
};
|
|
977
988
|
}
|
|
978
989
|
function updateTarget(opts, resolved) {
|
|
@@ -1058,6 +1069,7 @@ function updateTarget(opts, resolved) {
|
|
|
1058
1069
|
function updateInitFiles(opts) {
|
|
1059
1070
|
const updated = [];
|
|
1060
1071
|
const skipped = [];
|
|
1072
|
+
const tokens = [];
|
|
1061
1073
|
const tokensDir = join10(opts.packageRoot, "tokens");
|
|
1062
1074
|
const initManifest = readInitManifest(opts.projectRoot);
|
|
1063
1075
|
if (initManifest) {
|
|
@@ -1068,11 +1080,21 @@ function updateInitFiles(opts) {
|
|
|
1068
1080
|
if (!file.startsWith("mode.")) continue;
|
|
1069
1081
|
const keys = Object.keys(initManifest.files).filter((k) => k === file || k.endsWith(`/${file}`));
|
|
1070
1082
|
for (const key of keys) {
|
|
1083
|
+
let before = "";
|
|
1084
|
+
try {
|
|
1085
|
+
before = readFileSync7(join10(opts.projectRoot, ...key.split("/")), "utf8");
|
|
1086
|
+
} catch {
|
|
1087
|
+
}
|
|
1088
|
+
const fresh = readFileSync7(join10(tokensDir, file), "utf8");
|
|
1071
1089
|
if (isInitFileModified(opts.projectRoot, key, initManifest)) {
|
|
1072
1090
|
skipped.push(key);
|
|
1091
|
+
const change2 = tokenChange(key, before, fresh, true);
|
|
1092
|
+
if (change2) tokens.push(change2);
|
|
1073
1093
|
continue;
|
|
1074
1094
|
}
|
|
1075
|
-
const
|
|
1095
|
+
const change = tokenChange(key, before, fresh);
|
|
1096
|
+
if (change) tokens.push(change);
|
|
1097
|
+
const content = vendorHeader(file, opts.version, "css", null) + fresh;
|
|
1076
1098
|
updated.push(writer.write(key, content));
|
|
1077
1099
|
initChanged = true;
|
|
1078
1100
|
}
|
|
@@ -1080,7 +1102,7 @@ function updateInitFiles(opts) {
|
|
|
1080
1102
|
Object.assign(initFiles, writer.files);
|
|
1081
1103
|
if (initChanged) writeInitManifest(opts.projectRoot, { ...initManifest, version: opts.version, files: initFiles });
|
|
1082
1104
|
}
|
|
1083
|
-
return { updated, skipped };
|
|
1105
|
+
return { updated, skipped, tokens };
|
|
1084
1106
|
}
|
|
1085
1107
|
|
|
1086
1108
|
// src/commands/explain.ts
|
|
@@ -5749,7 +5771,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
|
|
|
5749
5771
|
import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
|
|
5750
5772
|
|
|
5751
5773
|
// src/probe/script.ts
|
|
5752
|
-
var PROBE_VERSION =
|
|
5774
|
+
var PROBE_VERSION = 8;
|
|
5753
5775
|
var PROBE_SCRIPT = `(async () => {
|
|
5754
5776
|
const doc = document.documentElement;
|
|
5755
5777
|
// checkVisibility also sees content a closed <details> hides: Chromium hides
|
|
@@ -5913,6 +5935,32 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5913
5935
|
focusReturned: document.activeElement === toggle,
|
|
5914
5936
|
};
|
|
5915
5937
|
}
|
|
5938
|
+
// A weight or style the page asks for that no loaded face covers: the
|
|
5939
|
+
// browser draws it by thickening or slanting the nearest face it has. On
|
|
5940
|
+
// jig-site a heading asked for a weight the site never loaded and every
|
|
5941
|
+
// review passed it, since the page was styled and nothing was missing.
|
|
5942
|
+
// Only web fonts are judged; a family with no @font-face is the system's.
|
|
5943
|
+
if (document.fonts) await document.fonts.ready;
|
|
5944
|
+
const faces = document.fonts ? [...document.fonts] : [];
|
|
5945
|
+
const unquote = (f) => f.trim().replace(/^["']|["']$/g, '').toLowerCase();
|
|
5946
|
+
const weightOf = (w) => (w === 'normal' ? 400 : w === 'bold' ? 700 : Number(w));
|
|
5947
|
+
const covers = (face, w) => { const [lo, hi = lo] = String(face.weight).trim().split(/\\s+/).map(weightOf); return w >= lo && w <= hi; };
|
|
5948
|
+
const fauxFaces = [];
|
|
5949
|
+
const fauxSeen = new Set();
|
|
5950
|
+
for (const el of [...document.body.querySelectorAll('*')].slice(0, 4000)) {
|
|
5951
|
+
if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()) || !vis(el)) continue;
|
|
5952
|
+
const cs = getComputedStyle(el);
|
|
5953
|
+
const first = cs.fontFamily.split(',')[0];
|
|
5954
|
+
const own = faces.filter((f) => unquote(f.family) === unquote(first));
|
|
5955
|
+
if (!own.length) continue;
|
|
5956
|
+
const weight = weightOf(cs.fontWeight);
|
|
5957
|
+
const italic = cs.fontStyle !== 'normal';
|
|
5958
|
+
if (own.some((f) => f.status === 'loaded' && covers(f, weight) && (f.style !== 'normal') === italic)) continue;
|
|
5959
|
+
const key = unquote(first) + ' ' + weight + ' ' + italic;
|
|
5960
|
+
if (fauxSeen.has(key)) continue;
|
|
5961
|
+
fauxSeen.add(key);
|
|
5962
|
+
fauxFaces.push({ family: first.trim().replace(/^["']|["']$/g, ''), weight, style: italic ? 'italic' : 'normal', text: (el.innerText || '').trim().slice(0, 40) });
|
|
5963
|
+
}
|
|
5916
5964
|
return JSON.stringify({
|
|
5917
5965
|
jigProbe: ${PROBE_VERSION},
|
|
5918
5966
|
url: location.href,
|
|
@@ -5929,6 +5977,7 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5929
5977
|
navLinksVisible: navAtRest,
|
|
5930
5978
|
strandedCount: stranded.length,
|
|
5931
5979
|
strandedWords: stranded.slice(0, 6),
|
|
5980
|
+
fauxFaces: fauxFaces.slice(0, 6),
|
|
5932
5981
|
head: {
|
|
5933
5982
|
title: (document.title || '').trim(),
|
|
5934
5983
|
description: (document.querySelector('meta[name=description]') || {}).content || '',
|
|
@@ -6269,6 +6318,18 @@ async function withPageUrl(projectRoot, abs, serve, use) {
|
|
|
6269
6318
|
await server.close();
|
|
6270
6319
|
}
|
|
6271
6320
|
}
|
|
6321
|
+
function recordedPage(projectRoot, surface) {
|
|
6322
|
+
const dir = join33(projectRoot, ".jig", "critique", surface);
|
|
6323
|
+
if (!existsSync19(dir)) return void 0;
|
|
6324
|
+
for (const f of readdirSync12(dir).filter((name) => /^probe-\d+\.json$/.test(name)).sort()) {
|
|
6325
|
+
try {
|
|
6326
|
+
const probe = JSON.parse(readFileSync26(join33(dir, f), "utf8"));
|
|
6327
|
+
if (probe.pageFile && existsSync19(resolve4(projectRoot, probe.pageFile))) return probe.pageFile;
|
|
6328
|
+
} catch {
|
|
6329
|
+
}
|
|
6330
|
+
}
|
|
6331
|
+
return void 0;
|
|
6332
|
+
}
|
|
6272
6333
|
async function ensureProbes(opts) {
|
|
6273
6334
|
const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6274
6335
|
const abs = resolve4(opts.projectRoot, opts.page);
|
|
@@ -6371,6 +6432,10 @@ function probeContradictions(probes, verdictOf, indexable = true) {
|
|
|
6371
6432
|
if (p.defaultFont) {
|
|
6372
6433
|
errors.push(`${at(p)}: the page renders in the browser's default font (${p.bodyFont ?? "unknown"}) \u2014 its styles are not applying. No review of this page can pass until they do.`);
|
|
6373
6434
|
}
|
|
6435
|
+
if (p.fauxFaces?.length) {
|
|
6436
|
+
const shown = p.fauxFaces.map((f) => `${f.family} ${f.weight}${f.style === "italic" ? " italic" : ""} ("${f.text}")`).join(", ");
|
|
6437
|
+
errors.push(`${at(p)}: the page asks for ${shown}, and no loaded face covers ${p.fauxFaces.length === 1 ? "it" : "them"}, so the browser fakes the weight or slant from the nearest face it has. Load that weight, or use one the page already loads.`);
|
|
6438
|
+
}
|
|
6374
6439
|
if (p.unresolvedTokens.length) {
|
|
6375
6440
|
errors.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
|
|
6376
6441
|
}
|
|
@@ -6473,6 +6538,43 @@ function decisionHeadings(projectRoot) {
|
|
|
6473
6538
|
}
|
|
6474
6539
|
return headings;
|
|
6475
6540
|
}
|
|
6541
|
+
function decisionSections(body) {
|
|
6542
|
+
const sections = /* @__PURE__ */ new Map();
|
|
6543
|
+
const lines = body.split("\n");
|
|
6544
|
+
let name;
|
|
6545
|
+
let text = [];
|
|
6546
|
+
const close = () => {
|
|
6547
|
+
if (name && !NOT_A_DECISION.test(name) && !sections.has(name)) sections.set(name, text.join("\n").trim());
|
|
6548
|
+
};
|
|
6549
|
+
for (const line of lines) {
|
|
6550
|
+
const heading = /^(#{1,3})\s+(.+?)\s*$/.exec(line);
|
|
6551
|
+
if (heading) {
|
|
6552
|
+
close();
|
|
6553
|
+
name = heading[1].length >= 2 ? heading[2].replace(/[`*]/g, "").trim() : void 0;
|
|
6554
|
+
text = [];
|
|
6555
|
+
} else text.push(line);
|
|
6556
|
+
}
|
|
6557
|
+
close();
|
|
6558
|
+
return sections;
|
|
6559
|
+
}
|
|
6560
|
+
function unsourcedReasons(current, before) {
|
|
6561
|
+
const then = decisionSections(before);
|
|
6562
|
+
const problems = [];
|
|
6563
|
+
for (const [name, text] of decisionSections(current)) {
|
|
6564
|
+
if (then.get(name) === text) continue;
|
|
6565
|
+
const whys = text.split(/\n\s*\n/).filter((p) => /^\s*\*\*Why\b[^*]*:\*\*/.test(p));
|
|
6566
|
+
for (const why of whys) {
|
|
6567
|
+
const label = /^\s*\*\*(Why\b[^*]*):\*\*/.exec(why)[1];
|
|
6568
|
+
const said = why.replace(/^\s*\*\*Why\b[^*]*:\*\*/, "").trim();
|
|
6569
|
+
if (/inferred/i.test(label)) continue;
|
|
6570
|
+
if (/^not given\b/i.test(said)) continue;
|
|
6571
|
+
if (/["“][^"”]{3,}["”]/.test(said)) continue;
|
|
6572
|
+
problems.push(`"${name}": its \`**${label}:**\` is not the owner's words in quotation marks. Quote what the owner said, write \`not given\`, or put what you added under \`**Why (inferred):**\`.`);
|
|
6573
|
+
break;
|
|
6574
|
+
}
|
|
6575
|
+
}
|
|
6576
|
+
return problems;
|
|
6577
|
+
}
|
|
6476
6578
|
|
|
6477
6579
|
// src/check/spec-shape.ts
|
|
6478
6580
|
import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
|
|
@@ -6485,6 +6587,13 @@ function newestSpec(projectRoot) {
|
|
|
6485
6587
|
const newest = files.map((f) => ({ f, at: statSync4(join36(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
6486
6588
|
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync29(join36(dir, newest), "utf8") };
|
|
6487
6589
|
}
|
|
6590
|
+
function specFor(projectRoot, surface) {
|
|
6591
|
+
if (surface) {
|
|
6592
|
+
const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
6593
|
+
if (existsSync22(path)) return { path: `.jig/specs/${surface}.spec.md`, slug: surface, body: readFileSync29(path, "utf8") };
|
|
6594
|
+
}
|
|
6595
|
+
return newestSpec(projectRoot);
|
|
6596
|
+
}
|
|
6488
6597
|
function specProblems(spec) {
|
|
6489
6598
|
const parts = spec.body.split(/^---\s*$/m);
|
|
6490
6599
|
const front = parts.length >= 3 ? parts[1] : "";
|
|
@@ -6562,7 +6671,28 @@ function specIndexableField(front) {
|
|
|
6562
6671
|
|
|
6563
6672
|
// src/commands/verdicts.ts
|
|
6564
6673
|
var VERDICTS = ["ok", "finding", "n/a"];
|
|
6674
|
+
var RULE_VERDICTS = [...VERDICTS, "ruled"];
|
|
6565
6675
|
var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
|
|
6676
|
+
var PLACEHOLDER = /^\W*(draft|tbd|todo|placeholder|wip|fixme|xxx|lorem ipsum)\W*($|[,.;:(\u2014-])|\bbeing refined\b|\bto be (judged|written|refined|filled in|completed)\b|\bfill (this )?in later\b/i;
|
|
6677
|
+
var REPEAT_LIMIT = 4;
|
|
6678
|
+
function reasonProblems(file, judged, errors) {
|
|
6679
|
+
const shared = /* @__PURE__ */ new Map();
|
|
6680
|
+
for (const { label, verdict, reason } of judged) {
|
|
6681
|
+
const bare2 = reason.replace(/^[A-Z]{1,2}-\d+[a-z0-9-]*\s*[:\u2014-]?\s*/i, "");
|
|
6682
|
+
if (PLACEHOLDER.test(bare2)) {
|
|
6683
|
+
errors.push(`${file}: ${label} \u2014 "${reason}" is a placeholder, not a judgment. Judge it against the page and write what you saw.`);
|
|
6684
|
+
continue;
|
|
6685
|
+
}
|
|
6686
|
+
if (verdict === "n/a") continue;
|
|
6687
|
+
const key = reason.toLowerCase().replace(/\s+/g, " ");
|
|
6688
|
+
shared.set(key, [...shared.get(key) ?? [], label]);
|
|
6689
|
+
}
|
|
6690
|
+
for (const [, labels] of shared) {
|
|
6691
|
+
if (labels.length < REPEAT_LIMIT) continue;
|
|
6692
|
+
const reason = judged.find((j) => j.label === labels[0]).reason;
|
|
6693
|
+
errors.push(`${file}: ${labels.length} verdicts give the same reason, "${reason}" (${labels.slice(0, 5).join(", ")}${labels.length > 5 ? ", \u2026" : ""}). A reason names what on this page holds or breaks that one rule; judge each of them.`);
|
|
6694
|
+
}
|
|
6695
|
+
}
|
|
6566
6696
|
function readJson(path, errors) {
|
|
6567
6697
|
if (!existsSync23(path)) return null;
|
|
6568
6698
|
try {
|
|
@@ -6592,7 +6722,7 @@ function specNeedsNav(projectRoot, surface) {
|
|
|
6592
6722
|
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
6593
6723
|
return navField || navRegion;
|
|
6594
6724
|
}
|
|
6595
|
-
function checkArm(name, file, required, otherPass, extraAllowed, extraRequired, errors) {
|
|
6725
|
+
function checkArm(name, file, required, otherPass, extraAllowed, extraRequired, errors, decisionList = []) {
|
|
6596
6726
|
const total = required.length + extraRequired.length;
|
|
6597
6727
|
if (file === null) {
|
|
6598
6728
|
errors.push(`${name}.json is missing \u2014 the ${name} arm has not run, or did not write its verdicts.`);
|
|
@@ -6601,7 +6731,9 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
6601
6731
|
const list = Array.isArray(file.verdicts) ? file.verdicts : [];
|
|
6602
6732
|
if (!Array.isArray(file.verdicts)) errors.push(`${name}.json has no "verdicts" array.`);
|
|
6603
6733
|
const seen = /* @__PURE__ */ new Set();
|
|
6734
|
+
const reasons = [];
|
|
6604
6735
|
let findings = 0;
|
|
6736
|
+
let ruled = 0;
|
|
6605
6737
|
for (const v of list) {
|
|
6606
6738
|
const written = typeof v.id === "string" ? v.id.trim() : "";
|
|
6607
6739
|
const id = written.toUpperCase();
|
|
@@ -6619,21 +6751,30 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
6619
6751
|
errors.push(pass === "mechanical" ? `${name}.json: ${id} is a mechanical rule \u2014 \`jig check\` decides it, so it has no verdict here. Remove it.` : pass ? `${name}.json: ${id} is a pass: ${pass} rule \u2014 it belongs to the other arm.` : `${name}.json: ${written} is not a rule or spec in this corpus. Run \`jig explain ${written}\`; an id that does not resolve is not a verdict.`);
|
|
6620
6752
|
continue;
|
|
6621
6753
|
}
|
|
6622
|
-
if (typeof v.verdict !== "string" || !
|
|
6623
|
-
errors.push(`${name}.json: ${id} has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding or n/a.`);
|
|
6754
|
+
if (typeof v.verdict !== "string" || !RULE_VERDICTS.includes(v.verdict)) {
|
|
6755
|
+
errors.push(`${name}.json: ${id} has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding, ruled or n/a.`);
|
|
6624
6756
|
continue;
|
|
6625
6757
|
}
|
|
6758
|
+
if (v.verdict === "ruled") {
|
|
6759
|
+
const ruling = typeof v.ruling === "string" ? v.ruling.trim() : "";
|
|
6760
|
+
const match = decisionList.find((d) => d.toLowerCase() === ruling.toLowerCase());
|
|
6761
|
+
if (!ruling) errors.push(`${name}.json: ${id} is ruled, but names no \`ruling\`. A ruled verdict cites the DECISIONS.md heading that decided it; with none, it is a finding.`);
|
|
6762
|
+
else if (!match) errors.push(`${name}.json: ${id} cites the ruling "${ruling}", which is not a heading in DECISIONS.md. Cite the decision as its heading reads, or judge it a finding.`);
|
|
6763
|
+
else ruled++;
|
|
6764
|
+
}
|
|
6626
6765
|
const reason = typeof v.reason === "string" ? v.reason.trim() : "";
|
|
6627
6766
|
if (!reason) errors.push(`${name}.json: ${id} has no reason.`);
|
|
6628
6767
|
else if (ABSENCE.test(reason)) errors.push(`${name}.json: ${id} \u2014 "${reason}" says the rule was not read. Read it with \`jig explain ${id}\` and judge it.`);
|
|
6768
|
+
else reasons.push({ label: id, verdict: v.verdict, reason });
|
|
6629
6769
|
if (v.verdict === "finding") findings++;
|
|
6630
6770
|
}
|
|
6771
|
+
reasonProblems(`${name}.json`, reasons, errors);
|
|
6631
6772
|
const missing = [...required, ...extraRequired].filter((id) => !seen.has(id));
|
|
6632
6773
|
if (missing.length) {
|
|
6633
6774
|
errors.push(`${name}.json: ${missing.length} of ${total} ids have no verdict: ${missing.join(", ")}. Re-run the arm; never report a short pass.`);
|
|
6634
6775
|
}
|
|
6635
6776
|
const judged = total - missing.length;
|
|
6636
|
-
return { state: missing.length ? "incomplete" : "ran", judged, total, findings };
|
|
6777
|
+
return { state: missing.length ? "incomplete" : "ran", judged, total, findings, ...ruled ? { ruled } : {} };
|
|
6637
6778
|
}
|
|
6638
6779
|
function checkDecisions(projectRoot, dir, errors) {
|
|
6639
6780
|
const required = decisionNames(projectRoot);
|
|
@@ -6648,6 +6789,7 @@ function checkDecisions(projectRoot, dir, errors) {
|
|
|
6648
6789
|
const list = Array.isArray(file.verdicts) ? file.verdicts : [];
|
|
6649
6790
|
if (!Array.isArray(file.verdicts)) errors.push('decisions.json has no "verdicts" array.');
|
|
6650
6791
|
const seen = /* @__PURE__ */ new Set();
|
|
6792
|
+
const reasons = [];
|
|
6651
6793
|
let findings = 0;
|
|
6652
6794
|
for (const v of list) {
|
|
6653
6795
|
const name = typeof v.decision === "string" ? v.decision.trim() : "";
|
|
@@ -6672,8 +6814,10 @@ function checkDecisions(projectRoot, dir, errors) {
|
|
|
6672
6814
|
const reason = typeof v.reason === "string" ? v.reason.trim() : "";
|
|
6673
6815
|
if (!reason) errors.push(`decisions.json: "${match}" has no reason. Name what on the page satisfies it, or what does not.`);
|
|
6674
6816
|
else if (ABSENCE.test(reason)) errors.push(`decisions.json: "${match}" \u2014 "${reason}" says the decision was not read.`);
|
|
6817
|
+
else reasons.push({ label: `"${match}"`, verdict: v.verdict, reason });
|
|
6675
6818
|
if (v.verdict === "finding") findings++;
|
|
6676
6819
|
}
|
|
6820
|
+
reasonProblems("decisions.json", reasons, errors);
|
|
6677
6821
|
const unjudged = required.filter((r) => !seen.has(r));
|
|
6678
6822
|
const since = decisionsSince(projectRoot, dir, unjudged);
|
|
6679
6823
|
const missing = unjudged.filter((r) => !since.includes(r));
|
|
@@ -6726,8 +6870,9 @@ function verifyVerdicts(opts) {
|
|
|
6726
6870
|
const screenFile = readJson(join37(dir, "screen.json"), errors);
|
|
6727
6871
|
const codeFile = readJson(join37(dir, "code.json"), errors);
|
|
6728
6872
|
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
6729
|
-
const
|
|
6730
|
-
const
|
|
6873
|
+
const decisionList = decisionNames(opts.projectRoot);
|
|
6874
|
+
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors, decisionList);
|
|
6875
|
+
const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors, decisionList);
|
|
6731
6876
|
let rendered = false;
|
|
6732
6877
|
if (screenFile && screenFile.rendered === true) {
|
|
6733
6878
|
const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
|
|
@@ -6763,8 +6908,60 @@ function verifyVerdicts(opts) {
|
|
|
6763
6908
|
if (screen.state === "ran" && !rendered) screen.state = "skipped";
|
|
6764
6909
|
const decisions = checkDecisions(opts.projectRoot, dir, errors);
|
|
6765
6910
|
const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
|
|
6766
|
-
const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}`;
|
|
6767
|
-
|
|
6911
|
+
const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}${(screen.ruled ?? 0) + (code.ruled ?? 0) ? ` ruled=${(screen.ruled ?? 0) + (code.ruled ?? 0)}` : ""}`;
|
|
6912
|
+
const previous = previousFindings(opts.projectRoot, dir);
|
|
6913
|
+
return { ok: errors.length === 0, errors, screen, code, decisions, rendered, line, ...previous ? { previous } : {} };
|
|
6914
|
+
}
|
|
6915
|
+
function previousFindings(projectRoot, dir) {
|
|
6916
|
+
const git = (args) => execFileSync3("git", args, { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
6917
|
+
const rel = relative4(projectRoot, dir).split("\\").join("/");
|
|
6918
|
+
const files = ["screen.json", "code.json", "decisions.json"];
|
|
6919
|
+
const paths = files.map((f) => `${rel}/${f}`);
|
|
6920
|
+
let commit;
|
|
6921
|
+
try {
|
|
6922
|
+
const dirty = git(["status", "--porcelain", "--", ...paths]).trim() !== "";
|
|
6923
|
+
const touched = git(["log", "--format=%H", "-2", "--", ...paths]).split("\n").filter(Boolean);
|
|
6924
|
+
commit = dirty ? touched[0] ?? "" : touched[1] ?? "";
|
|
6925
|
+
} catch {
|
|
6926
|
+
return void 0;
|
|
6927
|
+
}
|
|
6928
|
+
if (!commit) return void 0;
|
|
6929
|
+
const read = (f, at) => {
|
|
6930
|
+
const out = /* @__PURE__ */ new Map();
|
|
6931
|
+
let text;
|
|
6932
|
+
try {
|
|
6933
|
+
text = at ? git(["show", `${at}:./${rel}/${f}`]) : readFileSync30(join37(dir, f), "utf8");
|
|
6934
|
+
} catch {
|
|
6935
|
+
return out;
|
|
6936
|
+
}
|
|
6937
|
+
let body;
|
|
6938
|
+
try {
|
|
6939
|
+
body = JSON.parse(text);
|
|
6940
|
+
} catch {
|
|
6941
|
+
return out;
|
|
6942
|
+
}
|
|
6943
|
+
for (const v of Array.isArray(body.verdicts) ? body.verdicts : []) {
|
|
6944
|
+
const key = String(v.id ?? v.decision ?? "").trim();
|
|
6945
|
+
if (key && typeof v.verdict === "string") out.set(f === "decisions.json" ? `"${key}"` : key.toUpperCase(), v.verdict);
|
|
6946
|
+
}
|
|
6947
|
+
return out;
|
|
6948
|
+
};
|
|
6949
|
+
const before = /* @__PURE__ */ new Map();
|
|
6950
|
+
const now = /* @__PURE__ */ new Map();
|
|
6951
|
+
for (const f of files) {
|
|
6952
|
+
for (const [k, v] of read(f, commit)) before.set(k, v);
|
|
6953
|
+
for (const [k, v] of read(f)) now.set(k, v);
|
|
6954
|
+
}
|
|
6955
|
+
const result = { commit: commit.slice(0, 7), fixed: [], open: [], added: [], ruled: [] };
|
|
6956
|
+
for (const [k, v] of before) {
|
|
6957
|
+
if (v !== "finding") continue;
|
|
6958
|
+
const after = now.get(k);
|
|
6959
|
+
if (after === "finding") result.open.push(k);
|
|
6960
|
+
else if (after === "ruled") result.ruled.push(k);
|
|
6961
|
+
else if (after === "ok" || after === "n/a") result.fixed.push(k);
|
|
6962
|
+
}
|
|
6963
|
+
for (const [k, v] of now) if (v === "finding" && before.get(k) !== "finding") result.added.push(k);
|
|
6964
|
+
return result;
|
|
6768
6965
|
}
|
|
6769
6966
|
|
|
6770
6967
|
// src/commands/gate.ts
|
|
@@ -6797,23 +6994,7 @@ function mockupDrawingProblems(root, specBody, at) {
|
|
|
6797
6994
|
`${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.`
|
|
6798
6995
|
);
|
|
6799
6996
|
}
|
|
6800
|
-
|
|
6801
|
-
for (const [size] of FRAMES) {
|
|
6802
|
-
const listed = regions[size];
|
|
6803
|
-
const inFrame = frames.filter((f) => f.size === size).flatMap((f) => f.labels);
|
|
6804
|
-
if (!listed || listed.length === 0 || !frames.some((f) => f.size === size)) continue;
|
|
6805
|
-
const named = listed.filter((r) => r.name).map((r) => r.name);
|
|
6806
|
-
const absent = named.filter((name) => !inFrame.some((label) => labelMatches(label, name)));
|
|
6807
|
-
if (absent.length) {
|
|
6808
|
-
problems.push(
|
|
6809
|
-
`${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.`
|
|
6810
|
-
);
|
|
6811
|
-
} else if (inFrame.length < listed.length) {
|
|
6812
|
-
problems.push(
|
|
6813
|
-
`${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.`
|
|
6814
|
-
);
|
|
6815
|
-
}
|
|
6816
|
-
}
|
|
6997
|
+
problems.push(...frameRegionProblems(frames, specBody, at));
|
|
6817
6998
|
const widths = new Set(frames.map((f) => f.width).filter((w) => w !== void 0));
|
|
6818
6999
|
const crossed = specSwitches(specBody);
|
|
6819
7000
|
const recorded = declaredSwitches(root);
|
|
@@ -6833,6 +7014,37 @@ function mockupDrawingProblems(root, specBody, at) {
|
|
|
6833
7014
|
}
|
|
6834
7015
|
return problems;
|
|
6835
7016
|
}
|
|
7017
|
+
function frameRegionProblems(frames, specBody, at) {
|
|
7018
|
+
const problems = [];
|
|
7019
|
+
const regions = specRegions(specBody);
|
|
7020
|
+
for (const [size] of FRAMES) {
|
|
7021
|
+
const listed = regions[size];
|
|
7022
|
+
const inFrame = frames.filter((f) => f.size === size).flatMap((f) => f.labels);
|
|
7023
|
+
if (!listed || listed.length === 0 || !frames.some((f) => f.size === size)) continue;
|
|
7024
|
+
const absent = listed.filter((r) => r.name && !r.optional && !regionDrawn(inFrame, r)).map((r) => r.name);
|
|
7025
|
+
if (absent.length) {
|
|
7026
|
+
problems.push(
|
|
7027
|
+
`${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.`
|
|
7028
|
+
);
|
|
7029
|
+
} else if (inFrame.length < listed.filter((r) => !r.optional).length) {
|
|
7030
|
+
problems.push(
|
|
7031
|
+
`${at}: the ${size} frame labels ${inFrame.length} region(s) and the spec lists ${listed.filter((r) => !r.optional).length} for ${size}. Draw each one in the frame, with its name in a \`.name\` label.`
|
|
7032
|
+
);
|
|
7033
|
+
}
|
|
7034
|
+
}
|
|
7035
|
+
return problems;
|
|
7036
|
+
}
|
|
7037
|
+
function approvedDrawingProblems(root, specBody, at) {
|
|
7038
|
+
let html;
|
|
7039
|
+
try {
|
|
7040
|
+
html = readFileSync31(join38(root, at), "utf8");
|
|
7041
|
+
} catch {
|
|
7042
|
+
return [];
|
|
7043
|
+
}
|
|
7044
|
+
const frames = readFrames(html);
|
|
7045
|
+
if (!frames.some((f) => f.size)) return [];
|
|
7046
|
+
return frameRegionProblems(frames, specBody, at);
|
|
7047
|
+
}
|
|
6836
7048
|
function specSwitches(specBody) {
|
|
6837
7049
|
const front = specBody.split(/^---\s*$/m)[1] ?? "";
|
|
6838
7050
|
const value = /^switches\s*:\s*(.+)$/im.exec(front)?.[1]?.replace(/#.*$/, "").trim();
|
|
@@ -6894,7 +7106,18 @@ function regionEntries(block) {
|
|
|
6894
7106
|
function regionName(entry) {
|
|
6895
7107
|
const text = entry.trim().replace(/^["']|["']$/g, "").replace(/\\"/g, '"');
|
|
6896
7108
|
const head = normalise(text.split(":")[0]);
|
|
6897
|
-
|
|
7109
|
+
const optional = /\([^)]*\bonly\b[^)]*\)/.test(head);
|
|
7110
|
+
const bare2 = stripQualifiers(head);
|
|
7111
|
+
if (!bare2 || bare2.split(" ").length > 4) return {};
|
|
7112
|
+
const parts = bare2.split(/\s*,\s*(?:and\s+)?|\s+and\s+(?=[^,]*$)/).map((p) => p.trim()).filter(Boolean);
|
|
7113
|
+
return { name: bare2, ...parts.length > 1 ? { parts } : {}, ...optional ? { optional } : {} };
|
|
7114
|
+
}
|
|
7115
|
+
function stripQualifiers(text) {
|
|
7116
|
+
return text.replace(/\s*\([^)]*\)/g, "").replace(/\s+/g, " ").trim();
|
|
7117
|
+
}
|
|
7118
|
+
function regionDrawn(labels, region) {
|
|
7119
|
+
const found = (name) => labels.some((label) => labelMatches(label, name) || labelMatches(stripQualifiers(label), name));
|
|
7120
|
+
return found(region.name) || !!region.parts && region.parts.every(found);
|
|
6898
7121
|
}
|
|
6899
7122
|
function labelMatches(label, name) {
|
|
6900
7123
|
return label === name || label.startsWith(`${name} `) || label.startsWith(`${name}:`) || new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(label);
|
|
@@ -6917,7 +7140,7 @@ function sizeBlock2(sizes, size) {
|
|
|
6917
7140
|
// src/commands/gate.ts
|
|
6918
7141
|
import { execFileSync as execFileSync4 } from "child_process";
|
|
6919
7142
|
var MAX_BLOCKS = 3;
|
|
6920
|
-
function
|
|
7143
|
+
function lastJigInvocation(transcriptPath) {
|
|
6921
7144
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6922
7145
|
let text;
|
|
6923
7146
|
try {
|
|
@@ -6928,10 +7151,20 @@ function lastJigCommand(transcriptPath) {
|
|
|
6928
7151
|
let found;
|
|
6929
7152
|
for (const line of text.split("\n")) {
|
|
6930
7153
|
if (!line.includes("/jig")) continue;
|
|
6931
|
-
|
|
6932
|
-
|
|
6933
|
-
|
|
6934
|
-
|
|
7154
|
+
let entry;
|
|
7155
|
+
try {
|
|
7156
|
+
entry = JSON.parse(line);
|
|
7157
|
+
} catch {
|
|
7158
|
+
continue;
|
|
7159
|
+
}
|
|
7160
|
+
if (entry.type !== "user" || entry.isMeta) continue;
|
|
7161
|
+
const content = entry.message?.content;
|
|
7162
|
+
const texts = typeof content === "string" ? [content] : Array.isArray(content) ? content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text) : [];
|
|
7163
|
+
for (const t of texts) {
|
|
7164
|
+
const args = /<command-name>\/?jig<\/command-name>[\s\S]{0,200}?<command-args>([^<]*)<\/command-args>/.exec(t)?.[1] ?? /^\s*\/jig\s+([^\n]*)/.exec(t)?.[1];
|
|
7165
|
+
const [command, surface] = (args ?? "").trim().split(/\s+/);
|
|
7166
|
+
if (command && /^[a-z]+$/i.test(command)) found = { command: command.toLowerCase(), ...surface && /^[\w.-]+$/.test(surface) ? { surface } : {} };
|
|
7167
|
+
}
|
|
6935
7168
|
}
|
|
6936
7169
|
return found;
|
|
6937
7170
|
}
|
|
@@ -6986,18 +7219,27 @@ function verdictsMtime(dir) {
|
|
|
6986
7219
|
}
|
|
6987
7220
|
return newest;
|
|
6988
7221
|
}
|
|
6989
|
-
function surfacesInPlay(root, command, transcriptPath) {
|
|
7222
|
+
function surfacesInPlay(root, command, transcriptPath, surface) {
|
|
6990
7223
|
const critiqueDir = join39(root, ".jig", "critique");
|
|
6991
7224
|
if (!existsSync24(critiqueDir)) return [];
|
|
6992
7225
|
const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
|
|
6993
7226
|
const start = sessionStart(transcriptPath);
|
|
6994
7227
|
if (start === void 0) return all;
|
|
6995
|
-
const current = command === "critique" || command === "tweak" ?
|
|
7228
|
+
const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
|
|
6996
7229
|
return all.filter((s) => s === current || verdictsMtime(join39(critiqueDir, s)) >= start - 1e3);
|
|
6997
7230
|
}
|
|
6998
|
-
function
|
|
7231
|
+
function fileAtSessionStart(root, path, start) {
|
|
7232
|
+
const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
7233
|
+
try {
|
|
7234
|
+
const base = start === void 0 ? "HEAD" : git(["rev-list", "-1", `--before=@${Math.floor(start / 1e3)}`, "HEAD"]).trim();
|
|
7235
|
+
return base ? git(["show", `${base}:./${path}`]) : "";
|
|
7236
|
+
} catch {
|
|
7237
|
+
return "";
|
|
7238
|
+
}
|
|
7239
|
+
}
|
|
7240
|
+
function commandProblems(root, command, surface, start) {
|
|
6999
7241
|
const problems = [];
|
|
7000
|
-
const spec =
|
|
7242
|
+
const spec = specFor(root, surface);
|
|
7001
7243
|
if (command === "decide") {
|
|
7002
7244
|
const found = decisionsFile(root);
|
|
7003
7245
|
if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
@@ -7007,6 +7249,7 @@ function commandProblems(root, command) {
|
|
|
7007
7249
|
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.`");
|
|
7008
7250
|
}
|
|
7009
7251
|
if (/\[TODO\]/.test(body)) problems.push("DECISIONS.md still contains [TODO] markers.");
|
|
7252
|
+
problems.push(...unsourcedReasons(body, fileAtSessionStart(root, found, start)).map((p) => `DECISIONS.md: ${p}`));
|
|
7010
7253
|
}
|
|
7011
7254
|
}
|
|
7012
7255
|
if (command === "spec" || command === "mockup" || command === "make" || command === "critique" || command === "tweak") {
|
|
@@ -7029,16 +7272,43 @@ function commandProblems(root, command) {
|
|
|
7029
7272
|
if (at && /\.html?$/i.test(at) && existsSync24(join39(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
|
|
7030
7273
|
}
|
|
7031
7274
|
if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec));
|
|
7275
|
+
if ((command === "spec" || command === "make") && spec) {
|
|
7276
|
+
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7277
|
+
const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
|
|
7278
|
+
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
7279
|
+
if (/^approved/i.test(mockup) && /\.html?$/i.test(at) && !/^https?:/i.test(at)) {
|
|
7280
|
+
const drift = approvedDrawingProblems(root, spec.body, at);
|
|
7281
|
+
if (drift.length) {
|
|
7282
|
+
problems.push(
|
|
7283
|
+
`${spec.path} says \`mockup: approved\`, but the approved drawing no longer shows what the spec lists: ${drift.join(" ")} ` + (command === "spec" ? `The spec changed after the owner approved the drawing. Set \`mockup: pending\` in the spec; \`/jig mockup\` redraws it for the owner.` : `make builds from a drawing the owner approved of this spec, and this one is of an earlier spec. Stop, and ask for \`/jig mockup\`.`)
|
|
7284
|
+
);
|
|
7285
|
+
}
|
|
7286
|
+
}
|
|
7287
|
+
}
|
|
7288
|
+
if (command === "make") {
|
|
7289
|
+
const found = decisionsFile(root);
|
|
7290
|
+
if (found) {
|
|
7291
|
+
let now = "";
|
|
7292
|
+
try {
|
|
7293
|
+
now = readFileSync32(join39(root, found), "utf8");
|
|
7294
|
+
} catch {
|
|
7295
|
+
}
|
|
7296
|
+
const then = fileAtSessionStart(root, found, start);
|
|
7297
|
+
if (then && now !== then) {
|
|
7298
|
+
problems.push(`${found} changed in a \`make\` session. make carries decisions out; it does not take them. Restore it (\`git checkout -- ${found}\`, or \`git show <commit>:${found}\` if the change is committed), and put what the owner must decide to them: \`/jig decide\`, or \`/jig tweak\` for a small change to a built page.`);
|
|
7299
|
+
}
|
|
7300
|
+
}
|
|
7301
|
+
}
|
|
7032
7302
|
if (command === "critique") {
|
|
7033
7303
|
const dir = join39(root, ".jig", "critique");
|
|
7034
7304
|
const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join39(dir, s, "screen.json")) || existsSync24(join39(dir, s, "code.json"))) : [];
|
|
7035
|
-
for (const
|
|
7036
|
-
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
7305
|
+
for (const surface2 of surfaces) {
|
|
7306
|
+
const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
|
|
7037
7307
|
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
7038
|
-
problems.push(`${
|
|
7308
|
+
problems.push(`${surface2}: ${v.decisions.total - v.decisions.judged} of ${v.decisions.total} decisions in DECISIONS.md have no verdict. A page can satisfy every rule and still break what this project decided.`);
|
|
7039
7309
|
}
|
|
7040
7310
|
if (v.ok && v.screen.state === "skipped") {
|
|
7041
|
-
problems.push(`${
|
|
7311
|
+
problems.push(`${surface2}: the screen pass judged ${v.screen.judged} rules with rendered: false. Those rules are judged on a render \u2014 open the page at 360, 768 and 1280, run \`jig probe\` at each, and judge them there.`);
|
|
7042
7312
|
}
|
|
7043
7313
|
}
|
|
7044
7314
|
if (surfaces.length === 0) {
|
|
@@ -7054,7 +7324,7 @@ function tweakProblems(root, spec) {
|
|
|
7054
7324
|
const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
|
|
7055
7325
|
if (!/^(approved|skipped)/i.test(mockup)) problems.push(`${spec.path}: \`mockup:\` is ${mockup || "empty"}. A tweak changes a page whose drawing the owner has approved (or skipped); take a new page through \`mockup\` and \`make\`.`);
|
|
7056
7326
|
else problems.push(...structureSinceApproval(root, spec, front));
|
|
7057
|
-
const surface =
|
|
7327
|
+
const surface = spec.slug;
|
|
7058
7328
|
const record = readTweak(join39(root, ".jig", "critique", surface));
|
|
7059
7329
|
if (!record) {
|
|
7060
7330
|
problems.push(`.jig/critique/${surface}/tweak.json is missing. It names the change in the owner's words (\`change\`), when it was made (\`at\`) and the rule ids and decisions re-judged for it (\`ids\`).`);
|
|
@@ -7072,29 +7342,35 @@ function tweakProblems(root, spec) {
|
|
|
7072
7342
|
}
|
|
7073
7343
|
function structureSinceApproval(root, spec, front) {
|
|
7074
7344
|
const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
7075
|
-
let
|
|
7345
|
+
let baseline;
|
|
7346
|
+
let judged = true;
|
|
7076
7347
|
try {
|
|
7077
|
-
|
|
7348
|
+
baseline = git(["log", "-1", "--format=%H", "--", `.jig/critique/${spec.slug}/${LOCK}`]).trim();
|
|
7349
|
+
if (!baseline) {
|
|
7350
|
+
judged = false;
|
|
7351
|
+
baseline = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:", "--", spec.path]).trim();
|
|
7352
|
+
}
|
|
7078
7353
|
} catch {
|
|
7079
7354
|
return [];
|
|
7080
7355
|
}
|
|
7081
|
-
if (!
|
|
7356
|
+
if (!baseline) return [];
|
|
7357
|
+
const when = judged ? `when the last critique judged it (${baseline.slice(0, 7)})` : `when the owner approved the mockup (${baseline.slice(0, 7)})`;
|
|
7082
7358
|
const problems = [];
|
|
7083
7359
|
let thenBody = "";
|
|
7084
7360
|
try {
|
|
7085
|
-
thenBody = git(["show", `${
|
|
7361
|
+
thenBody = git(["show", `${baseline}:./${spec.path}`]);
|
|
7086
7362
|
} catch {
|
|
7087
7363
|
return [];
|
|
7088
7364
|
}
|
|
7089
7365
|
const shape = (body) => JSON.stringify(Object.entries(specRegions(body)).sort(([a], [b]) => a.localeCompare(b)).map(([size, regions]) => [size, regions.length, regions.map((r) => r.name ?? "")]));
|
|
7090
7366
|
if (shape(thenBody) !== shape(spec.body)) {
|
|
7091
|
-
problems.push(`${spec.path}: the regions under \`sizes:\` differ from the ones
|
|
7367
|
+
problems.push(`${spec.path}: the regions under \`sizes:\` differ from the ones the page had ${when}. A change to what the page holds is not a tweak: take it through \`spec\` and \`mockup\`.`);
|
|
7092
7368
|
}
|
|
7093
7369
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
7094
7370
|
if (at && !/^https?:/i.test(at) && existsSync24(join39(root, at))) {
|
|
7095
7371
|
try {
|
|
7096
|
-
if (git(["show", `${
|
|
7097
|
-
problems.push(`${at} has changed since
|
|
7372
|
+
if (git(["show", `${baseline}:./${at}`]) !== readFileSync32(join39(root, at), "utf8")) {
|
|
7373
|
+
problems.push(`${at} has changed since ${when.replace(/^when /, "")}. A tweak leaves the drawing as approved; a change the drawing must show goes through \`mockup\`.`);
|
|
7098
7374
|
}
|
|
7099
7375
|
} catch {
|
|
7100
7376
|
}
|
|
@@ -7110,7 +7386,7 @@ function readTweak(dir) {
|
|
|
7110
7386
|
}
|
|
7111
7387
|
}
|
|
7112
7388
|
function surfacePage(projectRoot, surface) {
|
|
7113
|
-
const spec =
|
|
7389
|
+
const spec = specFor(projectRoot, surface);
|
|
7114
7390
|
const front = spec?.body.split(/^---\s*$/m)[1] ?? "";
|
|
7115
7391
|
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
7116
7392
|
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
@@ -7124,9 +7400,10 @@ function gate(opts) {
|
|
|
7124
7400
|
if (!existsSync24(join39(root, "jig.config.json")) && !existsSync24(join39(root, ".jig"))) {
|
|
7125
7401
|
return { block: false, reason: "" };
|
|
7126
7402
|
}
|
|
7127
|
-
const
|
|
7403
|
+
const invocation = lastJigInvocation(opts.input.transcript_path);
|
|
7404
|
+
const command = invocation?.command;
|
|
7128
7405
|
const waiting = command !== void 0 && ASKS_THE_OWNER.has(command) && asksOwner(lastAssistantText(opts.input.transcript_path));
|
|
7129
|
-
const problems = command && !waiting ? commandProblems(root, command).map((p) => `/jig ${command}: ${p}`) : [];
|
|
7406
|
+
const problems = command && !waiting ? commandProblems(root, command, invocation?.surface, sessionStart(opts.input.transcript_path)).map((p) => `/jig ${command}: ${p}`) : [];
|
|
7130
7407
|
const selection = selectFiles(root, false);
|
|
7131
7408
|
const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
|
|
7132
7409
|
if (changedUi) {
|
|
@@ -7147,10 +7424,10 @@ function gate(opts) {
|
|
|
7147
7424
|
);
|
|
7148
7425
|
}
|
|
7149
7426
|
}
|
|
7150
|
-
problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path)));
|
|
7427
|
+
problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
|
|
7151
7428
|
const critiqueDir = join39(root, ".jig", "critique");
|
|
7152
7429
|
if (existsSync24(critiqueDir)) {
|
|
7153
|
-
for (const surface of surfacesInPlay(root, command, opts.input.transcript_path)) {
|
|
7430
|
+
for (const surface of surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)) {
|
|
7154
7431
|
const dir = join39(critiqueDir, surface);
|
|
7155
7432
|
if (!existsSync24(join39(dir, "screen.json")) && !existsSync24(join39(dir, "code.json"))) continue;
|
|
7156
7433
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
@@ -7499,19 +7776,42 @@ program.command("update").description("Update vendored Jig rules, skipping files
|
|
|
7499
7776
|
);
|
|
7500
7777
|
for (const f of result.updated) console.log(` ~ ${f}`);
|
|
7501
7778
|
for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
|
|
7779
|
+
for (const t of result.tokens) {
|
|
7780
|
+
const parts = [t.added.length ? `adds ${t.added.join(", ")}` : "", t.removed.length ? `removes ${t.removed.join(", ")}` : ""].filter(Boolean).join("; ");
|
|
7781
|
+
console.log(t.skipped ? ` ! ${t.file}: this release ${parts}, and did not apply it, because the file is edited locally.` : ` + ${t.file}: ${parts}.`);
|
|
7782
|
+
}
|
|
7783
|
+
if (result.tokens.length) console.log(" Anything that aliases tokens (a Tailwind @theme, a generated utilities file) needs the same change.");
|
|
7502
7784
|
} catch (err) {
|
|
7503
7785
|
console.error(err.message);
|
|
7504
7786
|
process.exit(1);
|
|
7505
7787
|
}
|
|
7506
7788
|
});
|
|
7507
|
-
program.command("verdicts").description("Verify a critique's verdict files and compute its counts.").argument("<surface>", "the surface slug the critique wrote under .jig/critique/").action((surface) => {
|
|
7789
|
+
program.command("verdicts").description("Verify a critique's verdict files and compute its counts.").argument("<surface>", "the surface slug the critique wrote under .jig/critique/").option("--reprobe", "re-take every probe that is missing or was taken on an older version of the page, first (needs a browser)").action(async (surface, opts) => {
|
|
7508
7790
|
const projectRoot = findProjectRoot(process.cwd());
|
|
7509
7791
|
try {
|
|
7792
|
+
if (opts.reprobe) {
|
|
7793
|
+
const page = surfacePage(projectRoot, surface) ?? recordedPage(projectRoot, surface);
|
|
7794
|
+
if (!page) console.error(` \u2717 --reprobe: no probe in .jig/critique/${surface}/ names a page that exists, so there is nothing to render. Run \`jig probe --run <page> --save ${surface}\`.`);
|
|
7795
|
+
else {
|
|
7796
|
+
const { recorded, reason } = await ensureProbes({ projectRoot, surface, page });
|
|
7797
|
+
if (reason) console.error(` \u2717 --reprobe: ${reason}.`);
|
|
7798
|
+
else if (recorded.length) console.log(` Re-probed ${page} at ${recorded.join(", ")}px.`);
|
|
7799
|
+
else console.log(` Every probe is current.`);
|
|
7800
|
+
}
|
|
7801
|
+
}
|
|
7510
7802
|
const result = verifyVerdicts({ projectRoot, surface });
|
|
7511
7803
|
for (const error of result.errors) console.error(` \u2717 ${error}`);
|
|
7804
|
+
if (!opts.reprobe && result.errors.some((e) => /was taken (on|by) an older/.test(e))) {
|
|
7805
|
+
console.error(` The page changed after these probes. \`jig verdicts ${surface} --reprobe\` re-takes them on the page as it is now.`);
|
|
7806
|
+
}
|
|
7512
7807
|
if (result.ok) console.log(` Every rule in both passes has a verdict.`);
|
|
7513
7808
|
const since = result.decisions.since ?? [];
|
|
7514
7809
|
if (since.length) console.log(` ${since.length} decision(s) recorded after this critique, for the next one to judge: ${since.join(", ")}.`);
|
|
7810
|
+
const p = result.previous;
|
|
7811
|
+
if (p) {
|
|
7812
|
+
const list = (ids) => ids.length ? ` (${ids.slice(0, 8).join(", ")}${ids.length > 8 ? ", \u2026" : ""})` : "";
|
|
7813
|
+
console.log(` Since the critique before this one (${p.commit}): ${p.fixed.length} fixed${list(p.fixed)}, ${p.open.length} still open${list(p.open)}, ${p.ruled.length} ruled by the owner${list(p.ruled)}, ${p.added.length} new${list(p.added)}.`);
|
|
7814
|
+
}
|
|
7515
7815
|
console.log(` ${result.line}`);
|
|
7516
7816
|
process.exit(result.ok ? 0 : 1);
|
|
7517
7817
|
} catch (err) {
|
|
@@ -7609,7 +7909,7 @@ program.command("gate").description("Run by the Claude Code Stop hook: block sto
|
|
|
7609
7909
|
try {
|
|
7610
7910
|
const projectRoot = findProjectRoot(cwd);
|
|
7611
7911
|
for (const surface of critiquedSurfaces(projectRoot)) {
|
|
7612
|
-
const page = surfacePage(projectRoot, surface);
|
|
7912
|
+
const page = surfacePage(projectRoot, surface) ?? recordedPage(projectRoot, surface);
|
|
7613
7913
|
if (!page) continue;
|
|
7614
7914
|
try {
|
|
7615
7915
|
await ensureProbes({ projectRoot, surface, page });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jig-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "A design system for coding agents. 143 numbered UI rules, brand x mode design tokens, and an installer for Claude Code, Codex, Cursor and opencode.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/rules/01-modes.md
CHANGED
|
@@ -86,7 +86,7 @@ Resolved values: `02-tokens.md` — the option sets for type, spacing, radius an
|
|
|
86
86
|
- Every page states its subject above the fold in text, not only in an image.
|
|
87
87
|
- Prose blocks are measure-capped even when the container is wide.
|
|
88
88
|
- No horizontal scrolling regions on mobile. Reflow instead.
|
|
89
|
-
-
|
|
89
|
+
- JavaScript: **none by default.** A content page ships no script unless a feature on it needs some: a theme toggle, a search box, a filter. That is a default, not an absolute zero. Each script names, in a comment, the feature and the decision or rule that asks for it, and a review judges whether the feature needs it and whether it is as small as the feature allows, not whether it exists. What the budget forbids is script no feature asks for: a framework runtime to render static text, a library for one effect, tracking bundled into the page. **Behaviour a rule requires is not a feature and is not counted**: a few lines whose only job is something a rule asks for and the browser does not give, such as `Escape` closing a `<details>` menu, or a tap outside closing a menu in a fixed header (`P-14`), are allowed in every mode, under 1 KB, with the rule named in a comment. Nothing else rides along in them.
|
|
90
90
|
|
|
91
91
|
---
|
|
92
92
|
|
package/rules/03-patterns.md
CHANGED
|
@@ -476,11 +476,16 @@ Compose it for the phone first. Mobile navigation is a different control — not
|
|
|
476
476
|
- Style the mark from that attribute — `[aria-current="page"]` in CSS — not from a separate `.active` or `.current` class. One source for both what is seen and what is announced means the two cannot drift apart; a class alone looks marked and tells a screen reader nothing.
|
|
477
477
|
- The visible cue is not colour alone (`C-20`): weight, an underline or bar, or a filled state. Which one is the project's decision.
|
|
478
478
|
- Inside an open menu, the current item is marked the same way. When the menu is closed nothing in the navigation is visible, so the page's `<h1>` is what tells the reader where they are — every page has one, and it names the page.
|
|
479
|
-
- **It works with no JavaScript** (`F-41`). The links are ordinary links in the page and render visibly by default; script, if there is any, only adds the collapse. In `editorial`, where
|
|
479
|
+
- **It works with no JavaScript** (`F-41`). The links are ordinary links in the page and render visibly by default; script, if there is any, only adds the collapse. In `editorial`, where a page has no script by default (`M-01`), use `<details>` with `<summary>Menu</summary>` — a disclosure the browser provides with no script at all — and the few lines above for `Escape`.
|
|
480
480
|
- **Never let a row that does not fit scroll sideways.** Its last items go past the edge where nobody sees them (`E-62`), and `editorial` forbids horizontal scrolling on mobile outright. An open menu is a vertical list.
|
|
481
481
|
- **Same destinations, same order, at every width.** The phone may show fewer at once. It never shows different ones, and never reorders them — `product` fixes navigation position across the app (`M-02`), and a reader who learned the order on one screen should not have to relearn it on another.
|
|
482
482
|
- **Every item is at least `--size-touch-target` tall**, made with padding rather than a larger font. The target grows; the text does not.
|
|
483
483
|
- **An open menu does not cover the page unless it has to.** If it does cover the page, it is a dialog and `P-07` applies: focus moves into it, `Escape` closes it, and focus returns to the button. A menu that opens inline needs none of that — but `Escape` still closes it and returns focus to the button.
|
|
484
|
+
- **In a fixed or sticky header, the open menu lies over the page.** It cannot push the page down, so it covers what is under it, and it is still not a dialog: focus is not trapped and the page is not made inert. What it owes the reader instead:
|
|
485
|
+
- A tap outside the menu closes it, and that tap is spent closing it. It does not also follow the link or press the button that lay under it, so nothing on the page is activated by accident.
|
|
486
|
+
- A control in the header row itself — the theme toggle, a search button, the site name — works on the first tap: the menu closes and the control does its job. Only a tap on the page below is swallowed.
|
|
487
|
+
- When the list is taller than the screen below the header, the menu scrolls inside itself, so its last link is reachable without scrolling the page under it.
|
|
488
|
+
- These need a few lines of script where `<details>` alone is used; they are behaviour this rule requires, counted as `M-01` counts `Escape`.
|
|
484
489
|
- **A sticky header at phone width is one row.** Two sticky rows permanently spend a sixth of a phone's height on chrome.
|
|
485
490
|
- **The wide row appears where the labels fit, not at a device width.** Set the breakpoint from the content — the width at which every destination sits on one line at full touch size — so a longer label moves the breakpoint instead of breaking the row.
|
|
486
491
|
- **`operator`:** the wide screen is the real case. At phone width, one **Menu** button for everything is enough, and keyboard operation of the open menu is mandatory (`M-03`).
|
|
@@ -464,6 +464,14 @@ spec is not a weaker spec — it is not a spec, and `make` will refuse it.
|
|
|
464
464
|
If the user changes something, revise and ask again. The run is incomplete until
|
|
465
465
|
they confirm.
|
|
466
466
|
|
|
467
|
+
**Revising a spec whose drawing was approved.** If the revision adds, removes or
|
|
468
|
+
renames a region at any size, the approved drawing no longer shows the page: set
|
|
469
|
+
`mockup: pending`, and `{{command_prefix}}mockup` redraws it for the owner. The gate
|
|
470
|
+
checks it for a drawing in frames: while `mockup: approved`, each region the spec lists must be labelled in
|
|
471
|
+
its size's frame of the drawing at `mockup_at:`. On jig-site the header spec went
|
|
472
|
+
through five confirmed rounds after its drawing was approved, and still said
|
|
473
|
+
`approved` over a drawing of none of them.
|
|
474
|
+
|
|
467
475
|
## mockup
|
|
468
476
|
|
|
469
477
|
**Low-fidelity design.** Draw the confirmed spec in grayscale, at every size it
|
|
@@ -724,6 +732,16 @@ between them yourself.
|
|
|
724
732
|
|
|
725
733
|
If `mockup: skipped`, build from the spec alone.
|
|
726
734
|
|
|
735
|
+
**`make` does not edit `DECISIONS.md`.** The file records what the owner decided,
|
|
736
|
+
and `make` is where decisions are carried out, not taken. When the build shows a
|
|
737
|
+
decision is wrong, missing or in conflict with the spec, stop and say so: the owner
|
|
738
|
+
changes it through `{{command_prefix}}decide`, or `{{command_prefix}}tweak` for a
|
|
739
|
+
small change to a built page. A note that a decision has been carried out belongs in
|
|
740
|
+
the spec, under `deviations:` or its as-built notes, not in the decision. On jig-site
|
|
741
|
+
one make round added a decision and another rewrote one to match the tokens it had
|
|
742
|
+
just switched to; the next critique judged the page against text the build had
|
|
743
|
+
written. The gate stops a `make` session that changed `DECISIONS.md`.
|
|
744
|
+
|
|
727
745
|
Build V1 only. Nothing in `later:`, however little extra it looks.
|
|
728
746
|
|
|
729
747
|
Build the **phone** composition first, then add what `tablet` and `desktop`
|
|
@@ -877,16 +895,38 @@ A and C are kept apart for a narrower reason: an arm that has seen the render
|
|
|
877
895
|
stops reading the source and starts confirming the picture. They answer to
|
|
878
896
|
different evidence and must not share it.
|
|
879
897
|
|
|
898
|
+
**Nor does either arm read the page's history.** Earlier critiques' verdict
|
|
899
|
+
files, their reports, the folders set aside beside them, and the project's git
|
|
900
|
+
log and commit messages all say what a reviewer found before, and an arm that
|
|
901
|
+
has read "the header critique, 17 findings" judges the page it is shown against
|
|
902
|
+
that list. Tell each arm so in its brief: it reads the page, the spec, the
|
|
903
|
+
mockup, `DECISIONS.md` and the corpus, and runs no `git log`, `git show` or
|
|
904
|
+
`git diff`, and opens nothing else under `.jig/critique/`. Which earlier findings
|
|
905
|
+
were fixed is not the arms' question; `verdicts` answers it from git after both
|
|
906
|
+
have written (step 4).
|
|
907
|
+
|
|
880
908
|
### 1a. Arms A and C walk the index. They do not search it.
|
|
881
909
|
|
|
882
910
|
**This is not a style preference. It is the difference between covering the
|
|
883
911
|
corpus and sampling it.**
|
|
884
912
|
|
|
885
913
|
Load `rules.index.json`, filter to the pass the arm owns, and return a verdict
|
|
886
|
-
for **every id in that list**: `ok`, `finding`, or `n/a` with one line
|
|
887
|
-
reason. A rule that does not apply is still answered — `n/a, this page has no
|
|
914
|
+
for **every id in that list**: `ok`, `finding`, `ruled`, or `n/a` with one line
|
|
915
|
+
of reason. A rule that does not apply is still answered — `n/a, this page has no
|
|
888
916
|
form` is a verdict.
|
|
889
917
|
|
|
918
|
+
**`ruled` is a finding the owner has already decided.** The page breaks the rule
|
|
919
|
+
because a decision in `DECISIONS.md` says it should: an icon-only control the
|
|
920
|
+
owner ruled icon-only, a menu the owner put at every phone width. The verdict
|
|
921
|
+
names that decision in `ruling`, exactly as its heading reads, and `verdicts`
|
|
922
|
+
refuses a `ruled` with no ruling or with one the file does not hold. A ruled
|
|
923
|
+
verdict is reported, and counted apart (`ruled=`); it is not a finding, and it
|
|
924
|
+
does not go back to `make`, which could only leave it as it is. On jig-site a
|
|
925
|
+
header critique counted 15 findings, five of them rulings labelled "owner-ruled"
|
|
926
|
+
in prose: every count was inflated, and the real findings were harder to see.
|
|
927
|
+
A rule the page breaks with no decision behind it is a `finding`, however sure
|
|
928
|
+
you are the owner would agree.
|
|
929
|
+
|
|
890
930
|
Searching the corpus is how a *builder* works, and it is the right method there:
|
|
891
931
|
it finds the rules you can already name. It cannot find the rule for the mistake
|
|
892
932
|
you do not know you are making, which is the only kind of rule worth writing
|
|
@@ -948,7 +988,8 @@ Each reader arm writes its own file. The arm reports back only that it wrote it.
|
|
|
948
988
|
"verdicts": [
|
|
949
989
|
{ "id": "D-115", "verdict": "ok", "reason": "no sideways scroll at 360, 768, 1280 or 1600" },
|
|
950
990
|
{ "id": "P-14", "verdict": "finding", "reason": "menu button at 360 does not open; aria-expanded never set" },
|
|
951
|
-
{ "id": "A-60", "verdict": "n/a", "reason": "A-60 is about competing icons; this page has none" }
|
|
991
|
+
{ "id": "A-60", "verdict": "n/a", "reason": "A-60 is about competing icons; this page has none" },
|
|
992
|
+
{ "id": "E-51", "verdict": "ruled", "ruling": "The theme toggle is icon-only", "reason": "the toggle shows only a sun or a moon; the owner ruled it icon-only" }
|
|
952
993
|
]
|
|
953
994
|
}
|
|
954
995
|
```
|
|
@@ -1025,7 +1066,9 @@ was not read, requires `P-14` when the spec has navigation, and refuses
|
|
|
1025
1066
|
`rendered: true` without artefacts that exist. It ends with a `JIG_VERDICTS:` line.
|
|
1026
1067
|
|
|
1027
1068
|
When it fails, **re-run the arm it names**. Do not edit the file to make it pass, and
|
|
1028
|
-
do not report an incomplete arm as having run.
|
|
1069
|
+
do not report an incomplete arm as having run. If it says the probes were taken
|
|
1070
|
+
on an older page, `{{scripts_path}} verdicts <surface> --reprobe` re-takes them
|
|
1071
|
+
on the page as it is now. A live Haiku run of this command
|
|
1029
1072
|
before it existed produced invented rule ids, `screen=ran:1` filed as a review,
|
|
1030
1073
|
`code=ran:97` against 67, `ran:100` against both, and arms that reported to the
|
|
1031
1074
|
wrong agent entirely. Every one of those was forbidden here in prose. Prose did not
|
|
@@ -1114,6 +1157,17 @@ Say what is right as well as what is wrong — two or three things, and why they
|
|
|
1114
1157
|
work. A review that only lists faults tells the reader nothing about what to
|
|
1115
1158
|
preserve while fixing them.
|
|
1116
1159
|
|
|
1160
|
+
**Say what happened to the last round's findings.** `verdicts` compares this
|
|
1161
|
+
critique with the one before it in git and prints `Since the critique before
|
|
1162
|
+
this one: <n> fixed, <n> still open, <n> ruled by the owner, <n> new`, with the
|
|
1163
|
+
ids. Put that line in the report, as `verdicts` printed it, above the findings.
|
|
1164
|
+
The owner should not have to set two reports side by side to learn whether the
|
|
1165
|
+
make round worked. The comparison is made after both arms have written, so it
|
|
1166
|
+
tells the arms nothing.
|
|
1167
|
+
|
|
1168
|
+
List `ruled` verdicts after the findings, under their own heading, each with the
|
|
1169
|
+
decision it cites. They are not findings, and they are not hidden either.
|
|
1170
|
+
|
|
1117
1171
|
Then the attestation:
|
|
1118
1172
|
|
|
1119
1173
|
```text
|
|
@@ -1188,8 +1242,10 @@ because the drawing stays true, and no full critique, because nothing else moved
|
|
|
1188
1242
|
- **The change adds, removes, reorders or regroups a region, changes the
|
|
1189
1243
|
navigation at any size, or would make the drawing wrong** → it is not a tweak.
|
|
1190
1244
|
Say so and name the route: `spec`, then `mockup`, then `make`. The gate checks
|
|
1191
|
-
this whatever you decide: the regions under `sizes:` must match the ones
|
|
1192
|
-
|
|
1245
|
+
this whatever you decide: the regions under `sizes:` must match the ones the
|
|
1246
|
+
page had at its last critique, and the drawing must be untouched since then.
|
|
1247
|
+
`<surface>` is the spec's file name (`.jig/specs/<surface>.spec.md`), not its
|
|
1248
|
+
`surface:` field.
|
|
1193
1249
|
- **It is unclear what the owner wants** → ask one question and stop.
|
|
1194
1250
|
|
|
1195
1251
|
### 1. Decide, if it is a decision
|
|
@@ -1473,9 +1529,10 @@ The accent appears as a whole field or not at all. A full-bleed section, a solid
|
|
|
1473
1529
|
button, a filled active state — never a 2px underline, never a small icon tint,
|
|
1474
1530
|
never a gradient stop. Its authority comes from arriving in quantity, rarely.
|
|
1475
1531
|
|
|
1476
|
-
**Why:**
|
|
1477
|
-
|
|
1478
|
-
|
|
1532
|
+
**Why:** "Thinly spread, it reads as decoration and stops meaning anything."
|
|
1533
|
+
|
|
1534
|
+
**Why (inferred):** the audit test that follows from it: if a coloured element is
|
|
1535
|
+
smaller than a section band, it is probably wrong.
|
|
1479
1536
|
```
|
|
1480
1537
|
|
|
1481
1538
|
A name gives the team something to cite in review. A reason lets a future agent
|
|
@@ -1490,23 +1547,30 @@ the same shape and judged against every page:
|
|
|
1490
1547
|
A reader leaves knowing which plan fits them and which limit they would hit
|
|
1491
1548
|
first. Nothing else on the page outranks that.
|
|
1492
1549
|
|
|
1493
|
-
**Why:**
|
|
1494
|
-
remove the last doubt, not to sell again.
|
|
1550
|
+
**Why:** "They arrive from the product page already interested. The job is to
|
|
1551
|
+
remove the last doubt, not to sell again."
|
|
1495
1552
|
|
|
1496
1553
|
### When two options conflict
|
|
1497
1554
|
|
|
1498
1555
|
Comprehension wins over persuasion. If a clearer page converts worse, we ship
|
|
1499
1556
|
the clearer page.
|
|
1500
1557
|
|
|
1501
|
-
**Why:**
|
|
1502
|
-
|
|
1558
|
+
**Why:** "Our reader is an engineer who has been sold to badly before, and a
|
|
1559
|
+
wrong-plan signup costs more in support than it brings in."
|
|
1503
1560
|
```
|
|
1504
1561
|
|
|
1505
|
-
**The reason is the owner's, or it is not written
|
|
1506
|
-
|
|
1507
|
-
none, write `**Why:** not given
|
|
1508
|
-
|
|
1509
|
-
|
|
1562
|
+
**The reason is the owner's, in quotation marks, or it is not written as theirs.**
|
|
1563
|
+
`**Why:**` holds the words the user gave, quoted. If they gave none, ask for it. If
|
|
1564
|
+
they still give none, write `**Why:** not given`. A live run invented reasons for
|
|
1565
|
+
two real reversals; an invented reason is worse than none, because the next agent
|
|
1566
|
+
weighs it as the team's and applies the rule where the team never would.
|
|
1567
|
+
|
|
1568
|
+
What you add to explain a reason, work out from it, or carry over from what the
|
|
1569
|
+
owner said elsewhere goes in its own paragraph, `**Why (inferred):**`, which says it
|
|
1570
|
+
is yours. On jig-site, reasons read "given directly by the owner", followed by
|
|
1571
|
+
sentences the agent had written; a later agent could not tell which half to weigh
|
|
1572
|
+
as the team's. The gate checks each decision this session wrote or changed: its
|
|
1573
|
+
`Why` is quoted, `not given`, or labelled inferred.
|
|
1510
1574
|
|
|
1511
1575
|
**Record what is still open in its own section, at the end:**
|
|
1512
1576
|
|
|
@@ -1531,7 +1595,8 @@ round by round, and the section of the file it went to — a named decision, its
|
|
|
1531
1595
|
`Why:`, or **Unresolved**. An answer that went nowhere is either added or shown to
|
|
1532
1596
|
the user as left out on purpose, with the reason. Then go the other way: every
|
|
1533
1597
|
`Why:` in the file must trace to something the user said; delete any that does not,
|
|
1534
|
-
and ask.
|
|
1598
|
+
and ask. A `Why (inferred):` traces to what it was inferred from; say what that was.
|
|
1599
|
+
Include this mapping in your message, below the file.
|
|
1535
1600
|
|
|
1536
1601
|
Show it and ask the user to confirm it is right before you stop. Leave no
|
|
1537
1602
|
`[TODO]` markers — the gate treats them as an unwritten file, correctly, because
|