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 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 = 6;
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
- try {
7129
- writeFileSync6(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
7130
- } catch {
7131
- }
7246
+ writeLock(lockPath, dir, now);
7132
7247
  continue;
7133
7248
  }
7134
- let locked;
7249
+ let lock = {};
7135
7250
  try {
7136
- locked = JSON.parse(readFileSync32(lockPath, "utf8")).checksum;
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.18.9",
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. One declaration in the type layer, not a fix applied per heading.
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
  }