jig-ui 0.18.9 → 0.19.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 +41 -0
- package/README.md +4 -1
- package/dist/index.js +167 -11
- package/package.json +1 -1
- package/rules/00-anti-patterns.md +2 -2
- package/templates/COMMAND.md.tmpl +83 -1
- package/templates/command-metadata.json +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.0 (2026-09-27)
|
|
4
|
+
|
|
5
|
+
A new command, `tweak`, for a small change to a built page that its approved
|
|
6
|
+
mockup does not show.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`/jig tweak`: a small change to a built page, in one pass.** On jig-site
|
|
11
|
+
half a day's rounds were changes the approved mockup does not show: a word
|
|
12
|
+
that wrapped, a mark's colour, how a version is written. Each went through
|
|
13
|
+
`decide`, `spec`, `make` and a full critique of every page it touched, or
|
|
14
|
+
would have been an edit by hand, which is the drift Jig exists to catch.
|
|
15
|
+
`tweak` records the decision if the change is one, brings the spec in line if
|
|
16
|
+
it disagrees, builds the change, and has a reader that did not make it
|
|
17
|
+
re-judge only the verdicts the change could affect. The gate bounds it: the
|
|
18
|
+
regions under the spec's `sizes:` must match the ones approved with the
|
|
19
|
+
mockup, the drawing must be unchanged since its approval, and the tweak may
|
|
20
|
+
change only the verdicts it names in `tweak.json`, each re-judged and stamped.
|
|
21
|
+
A change to structure is refused and routed through `spec` and `mockup`.
|
|
22
|
+
- **A critique's lock records each verdict.** `verdicts.lock` now holds a digest
|
|
23
|
+
per verdict as well as the whole-file checksum, so the gate can tell which
|
|
24
|
+
verdicts a tweak changed. A lock written before this release is trusted once
|
|
25
|
+
by the first tweak and rewritten.
|
|
26
|
+
|
|
27
|
+
## 0.18.10 (2026-09-26)
|
|
28
|
+
|
|
29
|
+
The rule against a stranded word holds in every browser, and the probe checks
|
|
30
|
+
it where `text-wrap: pretty` is not supported.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **`B-106` holds in every browser, and the probe checks it there.** The rule's
|
|
35
|
+
correction was `text-wrap: balance` and `pretty`, which Firefox and Safari
|
|
36
|
+
before 26 ignore. jig-site's Versions page did exactly that, passed its
|
|
37
|
+
critique in Chromium, and stranded "back." under 0.6.0 on the owner's phone.
|
|
38
|
+
The correction now adds a fallback: join each block's last two words with a
|
|
39
|
+
non-breaking space, in one function in the template layer. The render probe
|
|
40
|
+
(version 7) also lays the page out without `text-wrap: pretty` and records
|
|
41
|
+
every block that ends on one word, and `jig verdicts` refuses a `B-106` "ok"
|
|
42
|
+
that it contradicts.
|
|
43
|
+
|
|
3
44
|
## 0.18.9 (2026-09-25)
|
|
4
45
|
|
|
5
46
|
Each theme names its colour scheme, and no release summary names a rule by its
|
package/README.md
CHANGED
|
@@ -250,7 +250,10 @@ Adopting Jig everywhere is not the price of using it anywhere. The rules apply
|
|
|
250
250
|
to what you point them at:
|
|
251
251
|
|
|
252
252
|
- **New work follows the loop** — decide, spec, mockup, make, critique — and the
|
|
253
|
-
Stop hook holds it to that, if you asked for the hook.
|
|
253
|
+
Stop hook holds it to that, if you asked for the hook. A small change to a
|
|
254
|
+
built page that its approved mockup does not show (a word, a colour, how text
|
|
255
|
+
wraps) goes through `tweak` instead: decided, specced, built and re-judged in
|
|
256
|
+
one pass, with the page's structure held as approved.
|
|
254
257
|
- **Old pages sit where they are.** They are not rewritten, and the default
|
|
255
258
|
`check` says nothing about a file nobody has touched.
|
|
256
259
|
- **`jig.config.json` can exempt paths** you have no intention of revisiting, and
|
package/dist/index.js
CHANGED
|
@@ -46,7 +46,7 @@ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as rea
|
|
|
46
46
|
import { join as join7 } from "path";
|
|
47
47
|
|
|
48
48
|
// src/adapters/types.ts
|
|
49
|
-
var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, plan and build one feature at a time (decide, spec, mockup, make, critique), check the UI against the rules, or refresh the install.";
|
|
49
|
+
var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, plan and build one feature at a time (decide, spec, mockup, make, critique), make a small change to a built page (tweak), check the UI against the rules, or refresh the install.";
|
|
50
50
|
var SKILL_DESCRIPTION = "Design system rules for generating and reviewing UI. Load before building any interface.";
|
|
51
51
|
function assertSafeRelPath(relPath, adapterName) {
|
|
52
52
|
const isAbsolute2 = relPath.startsWith("/") || /^[A-Za-z]:/.test(relPath);
|
|
@@ -5749,7 +5749,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
|
|
|
5749
5749
|
import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
|
|
5750
5750
|
|
|
5751
5751
|
// src/probe/script.ts
|
|
5752
|
-
var PROBE_VERSION =
|
|
5752
|
+
var PROBE_VERSION = 7;
|
|
5753
5753
|
var PROBE_SCRIPT = `(async () => {
|
|
5754
5754
|
const doc = document.documentElement;
|
|
5755
5755
|
// checkVisibility also sees content a closed <details> hides: Chromium hides
|
|
@@ -5839,6 +5839,32 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5839
5839
|
const navLinks = () => [...document.querySelectorAll('nav a, header a, [role=navigation] a')].filter(vis).length;
|
|
5840
5840
|
// Measured before anything is clicked: what a reader sees on arrival.
|
|
5841
5841
|
const navAtRest = navLinks();
|
|
5842
|
+
// B-106 in every browser. \`text-wrap: pretty\` moves a lone last word up in
|
|
5843
|
+
// Chromium and does nothing in Firefox or in Safari before 26, so the page is
|
|
5844
|
+
// also measured as those lay it out: wrap forced plain, and the last word of
|
|
5845
|
+
// each block compared with the word before it. jig-site's Versions page was
|
|
5846
|
+
// clean in this browser and stranded "back." on an iPhone.
|
|
5847
|
+
const plainWrap = document.createElement('style');
|
|
5848
|
+
plainWrap.textContent = '*{text-wrap:wrap !important}';
|
|
5849
|
+
document.head.appendChild(plainWrap);
|
|
5850
|
+
const stranded = [];
|
|
5851
|
+
for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,li,dd,dt,figcaption,blockquote,td,th,caption,summary')) {
|
|
5852
|
+
if (!vis(el) || el.closest('pre, script, style, [aria-hidden="true"]')) continue;
|
|
5853
|
+
if (el.querySelector('p,li,dd,dt,blockquote,h1,h2,h3,h4,h5,h6')) continue;
|
|
5854
|
+
const words = [];
|
|
5855
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
5856
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
5857
|
+
for (const m of n.data.matchAll(/\\S+/g)) words.push([n, m.index, m.index + m[0].length, m[0]]);
|
|
5858
|
+
}
|
|
5859
|
+
if (words.length < 3) continue;
|
|
5860
|
+
const top = (w) => { const r = document.createRange(); r.setStart(w[0], w[1]); r.setEnd(w[0], w[2]); const rs = r.getClientRects(); return rs.length ? rs[rs.length - 1].top : null; };
|
|
5861
|
+
const last = words[words.length - 1];
|
|
5862
|
+
const a = top(words[words.length - 2]), b = top(last);
|
|
5863
|
+
if (/[\\p{L}\\p{N}]/u.test(last[3]) && a !== null && b !== null && b - a > 2) {
|
|
5864
|
+
stranded.push({ word: last[3], text: (el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40) });
|
|
5865
|
+
}
|
|
5866
|
+
}
|
|
5867
|
+
plainWrap.remove();
|
|
5842
5868
|
// The menu control: inside the header or navigation, or named as the menu.
|
|
5843
5869
|
// Not any disclosure \u2014 an FAQ <summary> is not a menu.
|
|
5844
5870
|
// The site's navigation, not a page's own: a <nav> inside <main>, an
|
|
@@ -5901,6 +5927,8 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5901
5927
|
emDashes: [...new Set((ownText.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
|
|
5902
5928
|
brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
|
|
5903
5929
|
navLinksVisible: navAtRest,
|
|
5930
|
+
strandedCount: stranded.length,
|
|
5931
|
+
strandedWords: stranded.slice(0, 6),
|
|
5904
5932
|
head: {
|
|
5905
5933
|
title: (document.title || '').trim(),
|
|
5906
5934
|
description: (document.querySelector('meta[name=description]') || {}).content || '',
|
|
@@ -6352,6 +6380,10 @@ function probeContradictions(probes, verdictOf, indexable = true) {
|
|
|
6352
6380
|
if (p.emDashes?.length) {
|
|
6353
6381
|
errors.push(`${at(p)}: the rendered page shows an em dash in ${p.emDashes.map((t) => `"${t}"`).join(", ")} (I-118) \u2014 use a full stop, a comma, a colon, or a second element.`);
|
|
6354
6382
|
}
|
|
6383
|
+
if (clean("B-106") && p.strandedCount) {
|
|
6384
|
+
const shown = (p.strandedWords ?? []).map((w) => `"${w.word}" (in "${w.text}\u2026")`).join(", ");
|
|
6385
|
+
errors.push(`B-106 is "${verdictOf("B-106")}", but ${at(p)} found ${p.strandedCount} block(s) ending on one word where \`text-wrap: pretty\` is not supported (Firefox, Safari before 26): ${shown}. B-106 says what holds in every browser.`);
|
|
6386
|
+
}
|
|
6355
6387
|
const line = p.longestLine;
|
|
6356
6388
|
if (line && line.chars > 90 && clean("B-11")) {
|
|
6357
6389
|
errors.push(`B-11 is "${verdictOf("B-11")}", but ${at(p)} measured a line of about ${line.chars} characters ("${line.text}\u2026") \u2014 past every mode's measure. Cap prose at \`--measure-prose\`.`);
|
|
@@ -6883,6 +6915,7 @@ function sizeBlock2(sizes, size) {
|
|
|
6883
6915
|
}
|
|
6884
6916
|
|
|
6885
6917
|
// src/commands/gate.ts
|
|
6918
|
+
import { execFileSync as execFileSync4 } from "child_process";
|
|
6886
6919
|
var MAX_BLOCKS = 3;
|
|
6887
6920
|
function lastJigCommand(transcriptPath) {
|
|
6888
6921
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
@@ -6928,7 +6961,7 @@ function asksOwner(text) {
|
|
|
6928
6961
|
const prose = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
|
|
6929
6962
|
return /\?(?=[\s)\]"'*_]|$)/.test(prose);
|
|
6930
6963
|
}
|
|
6931
|
-
var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup"]);
|
|
6964
|
+
var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup", "tweak"]);
|
|
6932
6965
|
function sessionStart(transcriptPath) {
|
|
6933
6966
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6934
6967
|
try {
|
|
@@ -6959,7 +6992,7 @@ function surfacesInPlay(root, command, transcriptPath) {
|
|
|
6959
6992
|
const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
|
|
6960
6993
|
const start = sessionStart(transcriptPath);
|
|
6961
6994
|
if (start === void 0) return all;
|
|
6962
|
-
const current = command === "critique" ? /^\s*surface\s*:\s*(.+)$/im.exec(newestSpec(root)?.body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim().replace(/^["']|["']$/g, "") : void 0;
|
|
6995
|
+
const current = command === "critique" || command === "tweak" ? /^\s*surface\s*:\s*(.+)$/im.exec(newestSpec(root)?.body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim().replace(/^["']|["']$/g, "") : void 0;
|
|
6963
6996
|
return all.filter((s) => s === current || verdictsMtime(join39(critiqueDir, s)) >= start - 1e3);
|
|
6964
6997
|
}
|
|
6965
6998
|
function commandProblems(root, command) {
|
|
@@ -6976,7 +7009,7 @@ function commandProblems(root, command) {
|
|
|
6976
7009
|
if (/\[TODO\]/.test(body)) problems.push("DECISIONS.md still contains [TODO] markers.");
|
|
6977
7010
|
}
|
|
6978
7011
|
}
|
|
6979
|
-
if (command === "spec" || command === "mockup" || command === "make" || command === "critique") {
|
|
7012
|
+
if (command === "spec" || command === "mockup" || command === "make" || command === "critique" || command === "tweak") {
|
|
6980
7013
|
if (!spec) problems.push(`${command} needs a spec: there is no file in .jig/specs/.`);
|
|
6981
7014
|
else {
|
|
6982
7015
|
problems.push(...specProblems(spec));
|
|
@@ -6995,6 +7028,7 @@ function commandProblems(root, command) {
|
|
|
6995
7028
|
}
|
|
6996
7029
|
if (at && /\.html?$/i.test(at) && existsSync24(join39(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
|
|
6997
7030
|
}
|
|
7031
|
+
if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec));
|
|
6998
7032
|
if (command === "critique") {
|
|
6999
7033
|
const dir = join39(root, ".jig", "critique");
|
|
7000
7034
|
const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join39(dir, s, "screen.json")) || existsSync24(join39(dir, s, "code.json"))) : [];
|
|
@@ -7013,6 +7047,68 @@ function commandProblems(root, command) {
|
|
|
7013
7047
|
}
|
|
7014
7048
|
return problems;
|
|
7015
7049
|
}
|
|
7050
|
+
function tweakProblems(root, spec) {
|
|
7051
|
+
const problems = [];
|
|
7052
|
+
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7053
|
+
if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) problems.push(`${spec.path} is not confirmed. A tweak changes a page the owner has confirmed; an unconfirmed spec goes through \`spec\`.`);
|
|
7054
|
+
const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
|
|
7055
|
+
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
|
+
else problems.push(...structureSinceApproval(root, spec, front));
|
|
7057
|
+
const surface = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? spec.slug;
|
|
7058
|
+
const record = readTweak(join39(root, ".jig", "critique", surface));
|
|
7059
|
+
if (!record) {
|
|
7060
|
+
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\`).`);
|
|
7061
|
+
} else {
|
|
7062
|
+
if (!record.change) problems.push(`.jig/critique/${surface}/tweak.json has no \`change\`: the owner's words for what changed.`);
|
|
7063
|
+
if (!record.ids.length) problems.push(`.jig/critique/${surface}/tweak.json names nothing to re-judge. A change that no rule and no decision could see needs no tweak; name the ones it can.`);
|
|
7064
|
+
const unjudged = unjudgedTweakIds(join39(root, ".jig", "critique", surface), record);
|
|
7065
|
+
if (unjudged.length) {
|
|
7066
|
+
problems.push(
|
|
7067
|
+
`.jig/critique/${surface}: tweak.json names ${unjudged.slice(0, 6).join(", ")}${unjudged.length > 6 ? " and more" : ""} but no verdict for ${unjudged.length === 1 ? "it" : "them"} carries \`"tweak": "${record.at}"\`. Each named verdict is re-judged on the changed page by a reader that did not make the change.`
|
|
7068
|
+
);
|
|
7069
|
+
}
|
|
7070
|
+
}
|
|
7071
|
+
return problems;
|
|
7072
|
+
}
|
|
7073
|
+
function structureSinceApproval(root, spec, front) {
|
|
7074
|
+
const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
7075
|
+
let approvedIn;
|
|
7076
|
+
try {
|
|
7077
|
+
approvedIn = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:", "--", spec.path]).trim();
|
|
7078
|
+
} catch {
|
|
7079
|
+
return [];
|
|
7080
|
+
}
|
|
7081
|
+
if (!approvedIn) return [];
|
|
7082
|
+
const problems = [];
|
|
7083
|
+
let thenBody = "";
|
|
7084
|
+
try {
|
|
7085
|
+
thenBody = git(["show", `${approvedIn}:./${spec.path}`]);
|
|
7086
|
+
} catch {
|
|
7087
|
+
return [];
|
|
7088
|
+
}
|
|
7089
|
+
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
|
+
if (shape(thenBody) !== shape(spec.body)) {
|
|
7091
|
+
problems.push(`${spec.path}: the regions under \`sizes:\` differ from the ones approved with the mockup (${approvedIn.slice(0, 7)}). A change to what the page holds is not a tweak: take it through \`spec\` and \`mockup\`.`);
|
|
7092
|
+
}
|
|
7093
|
+
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
7094
|
+
if (at && !/^https?:/i.test(at) && existsSync24(join39(root, at))) {
|
|
7095
|
+
try {
|
|
7096
|
+
if (git(["show", `${approvedIn}:./${at}`]) !== readFileSync32(join39(root, at), "utf8")) {
|
|
7097
|
+
problems.push(`${at} has changed since the owner approved it (${approvedIn.slice(0, 7)}). A tweak leaves the drawing as approved; a change the drawing must show goes through \`mockup\`.`);
|
|
7098
|
+
}
|
|
7099
|
+
} catch {
|
|
7100
|
+
}
|
|
7101
|
+
}
|
|
7102
|
+
return problems;
|
|
7103
|
+
}
|
|
7104
|
+
function readTweak(dir) {
|
|
7105
|
+
try {
|
|
7106
|
+
const raw = JSON.parse(readFileSync32(join39(dir, "tweak.json"), "utf8"));
|
|
7107
|
+
return { at: String(raw.at ?? ""), change: String(raw.change ?? "").trim(), ids: Array.isArray(raw.ids) ? raw.ids.map(String) : [] };
|
|
7108
|
+
} catch {
|
|
7109
|
+
return void 0;
|
|
7110
|
+
}
|
|
7111
|
+
}
|
|
7016
7112
|
function surfacePage(projectRoot, surface) {
|
|
7017
7113
|
const spec = newestSpec(projectRoot);
|
|
7018
7114
|
const front = spec?.body.split(/^---\s*$/m)[1] ?? "";
|
|
@@ -7103,6 +7199,28 @@ ${problems.join("\n\n")}`
|
|
|
7103
7199
|
}
|
|
7104
7200
|
var VERDICT_FILES = ["screen.json", "code.json", "decisions.json"];
|
|
7105
7201
|
var LOCK = "verdicts.lock";
|
|
7202
|
+
function verdictDigests(dir) {
|
|
7203
|
+
const out = {};
|
|
7204
|
+
for (const f of VERDICT_FILES) {
|
|
7205
|
+
let file;
|
|
7206
|
+
try {
|
|
7207
|
+
file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
|
|
7208
|
+
} catch {
|
|
7209
|
+
continue;
|
|
7210
|
+
}
|
|
7211
|
+
for (const v of Array.isArray(file.verdicts) ? file.verdicts : []) {
|
|
7212
|
+
const key = String(v.id ?? v.decision ?? "").trim();
|
|
7213
|
+
if (key) out[`${f}:${key}`] = checksum(JSON.stringify(v));
|
|
7214
|
+
}
|
|
7215
|
+
}
|
|
7216
|
+
return out;
|
|
7217
|
+
}
|
|
7218
|
+
function writeLock(lockPath, dir, now) {
|
|
7219
|
+
try {
|
|
7220
|
+
writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir) }) + "\n", "utf8");
|
|
7221
|
+
} catch {
|
|
7222
|
+
}
|
|
7223
|
+
}
|
|
7106
7224
|
function verdictChecksum(dir) {
|
|
7107
7225
|
const parts = VERDICT_FILES.map((f) => {
|
|
7108
7226
|
try {
|
|
@@ -7125,18 +7243,22 @@ function verdictGuard(root, command, inPlay) {
|
|
|
7125
7243
|
if (!now) continue;
|
|
7126
7244
|
const lockPath = join39(dir, LOCK);
|
|
7127
7245
|
if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
|
|
7128
|
-
|
|
7129
|
-
writeFileSync6(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
|
|
7130
|
-
} catch {
|
|
7131
|
-
}
|
|
7246
|
+
writeLock(lockPath, dir, now);
|
|
7132
7247
|
continue;
|
|
7133
7248
|
}
|
|
7134
|
-
let
|
|
7249
|
+
let lock = {};
|
|
7135
7250
|
try {
|
|
7136
|
-
|
|
7251
|
+
lock = JSON.parse(readFileSync32(lockPath, "utf8"));
|
|
7137
7252
|
} catch {
|
|
7138
7253
|
continue;
|
|
7139
7254
|
}
|
|
7255
|
+
const locked = lock.checksum;
|
|
7256
|
+
if (command === "tweak" && (!inPlay || inPlay.includes(surface)) && locked !== now) {
|
|
7257
|
+
const own = tweakVerdictProblems(dir, surface, lock.verdicts);
|
|
7258
|
+
problems.push(...own);
|
|
7259
|
+
if (own.length === 0) writeLock(lockPath, dir, now);
|
|
7260
|
+
continue;
|
|
7261
|
+
}
|
|
7140
7262
|
if (locked && locked !== now) {
|
|
7141
7263
|
problems.push(
|
|
7142
7264
|
`.jig/critique/${surface}: the verdict files changed after \`/jig critique\` wrote them${command ? `, in a session that ran \`/jig ${command}\`` : ""}. Verdicts are the review's, not the builder's: restore them (\`git checkout -- .jig/critique/${surface}\`) and run \`/jig critique\` to judge the fix.`
|
|
@@ -7145,6 +7267,40 @@ function verdictGuard(root, command, inPlay) {
|
|
|
7145
7267
|
}
|
|
7146
7268
|
return problems;
|
|
7147
7269
|
}
|
|
7270
|
+
function tweakVerdictProblems(dir, surface, lockedVerdicts) {
|
|
7271
|
+
const record = readTweak(dir);
|
|
7272
|
+
if (!record) {
|
|
7273
|
+
return [`.jig/critique/${surface}: the verdict files changed in a \`/jig tweak\` session with no tweak.json naming what was re-judged.`];
|
|
7274
|
+
}
|
|
7275
|
+
const problems = [];
|
|
7276
|
+
const named = new Set(record.ids.map((id) => id.toLowerCase()));
|
|
7277
|
+
const nowVerdicts = verdictDigests(dir);
|
|
7278
|
+
if (lockedVerdicts) {
|
|
7279
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(lockedVerdicts), ...Object.keys(nowVerdicts)]);
|
|
7280
|
+
const outside = [...keys].filter((k) => lockedVerdicts[k] !== nowVerdicts[k]).map((k) => k.slice(k.indexOf(":") + 1)).filter((id) => !named.has(id.toLowerCase()));
|
|
7281
|
+
if (outside.length) {
|
|
7282
|
+
problems.push(
|
|
7283
|
+
`.jig/critique/${surface}: a tweak changed verdicts it did not name (${[...new Set(outside)].slice(0, 6).join(", ")}). A tweak re-judges only what its change could affect, listed in tweak.json's \`ids\`; restore the others (\`git checkout -- .jig/critique/${surface}\`).`
|
|
7284
|
+
);
|
|
7285
|
+
}
|
|
7286
|
+
}
|
|
7287
|
+
return problems;
|
|
7288
|
+
}
|
|
7289
|
+
function unjudgedTweakIds(dir, record) {
|
|
7290
|
+
const stamped = /* @__PURE__ */ new Set();
|
|
7291
|
+
for (const f of VERDICT_FILES) {
|
|
7292
|
+
let file;
|
|
7293
|
+
try {
|
|
7294
|
+
file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
|
|
7295
|
+
} catch {
|
|
7296
|
+
continue;
|
|
7297
|
+
}
|
|
7298
|
+
for (const v of Array.isArray(file.verdicts) ? file.verdicts : []) {
|
|
7299
|
+
if (record.at && v.tweak === record.at) stamped.add(String(v.id ?? v.decision ?? "").toLowerCase());
|
|
7300
|
+
}
|
|
7301
|
+
}
|
|
7302
|
+
return record.ids.filter((id) => !stamped.has(id.toLowerCase()));
|
|
7303
|
+
}
|
|
7148
7304
|
function save(file, state) {
|
|
7149
7305
|
try {
|
|
7150
7306
|
mkdirSync8(join39(file, ".."), { recursive: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jig-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|
|
@@ -210,9 +210,9 @@ The measurement is one line in a browser: render `x` in both faces at the same s
|
|
|
210
210
|
|
|
211
211
|
### B-106 A word stranded on its own line
|
|
212
212
|
❌ A heading that wraps to leave one word alone on the last line, or a paragraph ending on a single short word
|
|
213
|
-
✅ `text-wrap: balance` on headings and short blocks, `text-wrap: pretty` on body copy
|
|
213
|
+
✅ `text-wrap: balance` on headings and short blocks, `text-wrap: pretty` on body copy: one declaration in the type layer. Both do nothing in Firefox and in Safari before 26, so where the text is rendered, join each block's last two words with a non-breaking space (U+00A0): one function in the template layer that every heading and block of text passes through, never a space typed into content.
|
|
214
214
|
The eye reads a block's shape before it reads the words. A heading whose last line holds one word reads as a mistake to someone who could not name what is wrong with it — the silhouette says unfinished, and that impression lands before the sentence does.
|
|
215
|
-
This is invisible in the source. The same heading breaks cleanly at 1280px and badly at 900px: where a line breaks depends on the box, the face and the string together, and none of the three is decidable from the others. It is judged on the rendered page at more than one width, which is why it carries `pass: screen
|
|
215
|
+
This is invisible in the source. The same heading breaks cleanly at 1280px and badly at 900px: where a line breaks depends on the box, the face and the string together, and none of the three is decidable from the others. It is judged on the rendered page at more than one width, which is why it carries `pass: screen`, and the render probe also lays the page out without `text-wrap: pretty`, as those browsers do, because a page can be clean in one browser and stranded in the reader's.
|
|
216
216
|
A manual break is not the fix. `<br>` placed by eye is correct at exactly one viewport width and wrong at the next, and it survives into every layout the component is later used in.
|
|
217
217
|
Ragged-right is not the failure — that is correct, and `B-12` requires it. The failure is a *stranded* word, not an uneven edge. Do not chase every short last line; chase the one that is alone.
|
|
218
218
|
|
|
@@ -12,7 +12,7 @@ then do the work below for that subcommand. Read the command's full output —
|
|
|
12
12
|
findings are ordered by severity, not position, so `head`, `tail`, `grep` and
|
|
13
13
|
`jq` drop the ones that matter.
|
|
14
14
|
|
|
15
|
-
**Agent procedures** — `decide`, `spec`, `mockup`, `make`, `critique`. There is no binary. Do not try to run one:
|
|
15
|
+
**Agent procedures** — `decide`, `spec`, `mockup`, `make`, `critique`, `tweak`. There is no binary. Do not try to run one:
|
|
16
16
|
the section below **is** the command. A CLI can check what these produce; it
|
|
17
17
|
cannot do their work, because the work is judgment and authorship.
|
|
18
18
|
|
|
@@ -26,6 +26,9 @@ spec what exactly is being built: its smallest useful version, at every sc
|
|
|
26
26
|
mockup low-fidelity design of that spec, reviewed before any code
|
|
27
27
|
make high-fidelity: the actual page or feature, built from the spec & mockup
|
|
28
28
|
critique scrutinises what was built against the rules, its spec & mockup
|
|
29
|
+
|
|
30
|
+
tweak a small change the approved mockup does not show: decided if it is a
|
|
31
|
+
decision, specced, built, and re-judged where it could matter
|
|
29
32
|
```
|
|
30
33
|
|
|
31
34
|
## init
|
|
@@ -1166,6 +1169,85 @@ Only then suggest `{{command_prefix}}spec` for the next feature. Starting the ne
|
|
|
1166
1169
|
with findings open means the second feature is built on top of the first one's
|
|
1167
1170
|
problems, and nobody goes back for them.
|
|
1168
1171
|
|
|
1172
|
+
## tweak
|
|
1173
|
+
|
|
1174
|
+
**A small change to a page that is built, reviewed and drawn,** one the owner's
|
|
1175
|
+
approved mockup does not show: a word or a sentence, a colour or its contrast, a
|
|
1176
|
+
weight, spacing inside a region, how text wraps, a focus ring. One pass does what
|
|
1177
|
+
the full loop would: record the decision if there is one, bring the spec in line,
|
|
1178
|
+
build it, and re-judge what the change could affect. There is no mockup step,
|
|
1179
|
+
because the drawing stays true, and no full critique, because nothing else moved.
|
|
1180
|
+
|
|
1181
|
+
### Refuse rather than guess
|
|
1182
|
+
|
|
1183
|
+
- **No confirmed spec for the page, or its `mockup:` is still pending** → this is
|
|
1184
|
+
a page being made, not changed. Run `{{command_prefix}}spec`, `mockup` and
|
|
1185
|
+
`make`.
|
|
1186
|
+
- **No critique record for the page yet** (`.jig/critique/<surface>/`) → nothing
|
|
1187
|
+
has been judged, so nothing can be re-judged. Run `{{command_prefix}}critique`.
|
|
1188
|
+
- **The change adds, removes, reorders or regroups a region, changes the
|
|
1189
|
+
navigation at any size, or would make the drawing wrong** → it is not a tweak.
|
|
1190
|
+
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
|
+
approved with the mockup, and the drawing must be untouched.
|
|
1193
|
+
- **It is unclear what the owner wants** → ask one question and stop.
|
|
1194
|
+
|
|
1195
|
+
### 1. Decide, if it is a decision
|
|
1196
|
+
|
|
1197
|
+
If the change is a ruling of the owner's, or changes one already recorded (a
|
|
1198
|
+
colour for a mark, how versions are written), record it in `DECISIONS.md` the way
|
|
1199
|
+
`decide` does: the owner's words, dated, and what it changes. If the change
|
|
1200
|
+
applies a rule, or answers a finding the rules already settle (a word stranded on
|
|
1201
|
+
its own line, `B-106`), there is nothing to decide; do not invent a decision.
|
|
1202
|
+
|
|
1203
|
+
### 2. Bring the spec in line, if it disagrees
|
|
1204
|
+
|
|
1205
|
+
If the spec says something the change contradicts, or the page will no longer
|
|
1206
|
+
look as the spec describes, amend those lines and add a dated **Tweak** entry at
|
|
1207
|
+
the end quoting the owner. Their instruction is the confirmation; do not ask for
|
|
1208
|
+
it again. Leave `sizes:` regions and the mockup as they are.
|
|
1209
|
+
|
|
1210
|
+
### 3. Build it
|
|
1211
|
+
|
|
1212
|
+
As `make` builds: the project's tokens and components, inside the structure the
|
|
1213
|
+
page already has, and only what the change needs. Run `{{scripts_path}} check`,
|
|
1214
|
+
then render and record the page (`{{scripts_path}} probe --run`) at every width
|
|
1215
|
+
it records.
|
|
1216
|
+
|
|
1217
|
+
### 4. Re-judge what the change could affect
|
|
1218
|
+
|
|
1219
|
+
Write `.jig/critique/<surface>/tweak.json`:
|
|
1220
|
+
|
|
1221
|
+
```json
|
|
1222
|
+
{
|
|
1223
|
+
"at": "2026-09-26T14:02:00Z",
|
|
1224
|
+
"change": "the owner's words for the change",
|
|
1225
|
+
"ids": ["A-60", "C-19", "The GitHub link is GitHub's own mark"]
|
|
1226
|
+
}
|
|
1227
|
+
```
|
|
1228
|
+
|
|
1229
|
+
`ids` names every rule and decision the change could move: the ones that judged
|
|
1230
|
+
the thing that changed, not the whole index. A colour change names the contrast
|
|
1231
|
+
and emphasis rules and the decision it applies; a reworded label names the copy
|
|
1232
|
+
rules for labels and the decision about naming.
|
|
1233
|
+
|
|
1234
|
+
Then a reader that did not make the change re-judges exactly those. It is a
|
|
1235
|
+
subagent, run in the foreground, given the page, the ids and each rule's text,
|
|
1236
|
+
and not this conversation or your reasons for the change. For each id it replaces
|
|
1237
|
+
that verdict in `screen.json`, `code.json` or `decisions.json` with its own
|
|
1238
|
+
verdict and reason, adding `"tweak": "<at>"`. It changes nothing else. Then run
|
|
1239
|
+
`{{scripts_path}} verdicts <surface>`.
|
|
1240
|
+
|
|
1241
|
+
The gate holds both ends: it refuses a tweak that changed a verdict it did not
|
|
1242
|
+
name, and one that named a verdict it did not re-judge.
|
|
1243
|
+
|
|
1244
|
+
### Finish
|
|
1245
|
+
|
|
1246
|
+
Report what changed, in the owner's words; the decision and spec lines you
|
|
1247
|
+
touched, if any; each re-judged verdict and what it says now; and the
|
|
1248
|
+
`JIG_CHECK` line. A re-judged verdict that is a finding goes to the owner: it is
|
|
1249
|
+
fixed by another tweak, or by `make` if it needs more.
|
|
1250
|
+
|
|
1169
1251
|
## probe
|
|
1170
1252
|
|
|
1171
1253
|
Run `{{scripts_path}} probe`. It prints one JavaScript expression — the render probe —
|
|
@@ -68,5 +68,10 @@
|
|
|
68
68
|
"description": "Scrutinise what was built against the rules, its spec and its mockup: render it, judge what check cannot, and report by rule id. Needs DECISIONS.md. No CLI — the agent does this.",
|
|
69
69
|
"argumentHint": "<page, feature or functionality, in your own words>",
|
|
70
70
|
"status": "agent"
|
|
71
|
+
},
|
|
72
|
+
"tweak": {
|
|
73
|
+
"description": "A small change to a built page that its approved mockup does not show (wording, a colour, a weight, spacing inside a region, wrapping): records the decision if it is one, brings the spec in line, builds it, and re-judges only the verdicts the change could affect. Refuses a change that moves the page's structure. No CLI — the agent does this.",
|
|
74
|
+
"argumentHint": "<the change, in your own words>",
|
|
75
|
+
"status": "agent"
|
|
71
76
|
}
|
|
72
77
|
}
|