jig-ui 0.18.10 → 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,29 @@
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
+
3
27
  ## 0.18.10 (2026-09-26)
4
28
 
5
29
  The rule against a stranded word holds in every browser, and the probe checks
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);
@@ -6915,6 +6915,7 @@ function sizeBlock2(sizes, size) {
6915
6915
  }
6916
6916
 
6917
6917
  // src/commands/gate.ts
6918
+ import { execFileSync as execFileSync4 } from "child_process";
6918
6919
  var MAX_BLOCKS = 3;
6919
6920
  function lastJigCommand(transcriptPath) {
6920
6921
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
@@ -6960,7 +6961,7 @@ function asksOwner(text) {
6960
6961
  const prose = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
6961
6962
  return /\?(?=[\s)\]"'*_]|$)/.test(prose);
6962
6963
  }
6963
- var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup"]);
6964
+ var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup", "tweak"]);
6964
6965
  function sessionStart(transcriptPath) {
6965
6966
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6966
6967
  try {
@@ -6991,7 +6992,7 @@ function surfacesInPlay(root, command, transcriptPath) {
6991
6992
  const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
6992
6993
  const start = sessionStart(transcriptPath);
6993
6994
  if (start === void 0) return all;
6994
- 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;
6995
6996
  return all.filter((s) => s === current || verdictsMtime(join39(critiqueDir, s)) >= start - 1e3);
6996
6997
  }
6997
6998
  function commandProblems(root, command) {
@@ -7008,7 +7009,7 @@ function commandProblems(root, command) {
7008
7009
  if (/\[TODO\]/.test(body)) problems.push("DECISIONS.md still contains [TODO] markers.");
7009
7010
  }
7010
7011
  }
7011
- if (command === "spec" || command === "mockup" || command === "make" || command === "critique") {
7012
+ if (command === "spec" || command === "mockup" || command === "make" || command === "critique" || command === "tweak") {
7012
7013
  if (!spec) problems.push(`${command} needs a spec: there is no file in .jig/specs/.`);
7013
7014
  else {
7014
7015
  problems.push(...specProblems(spec));
@@ -7027,6 +7028,7 @@ function commandProblems(root, command) {
7027
7028
  }
7028
7029
  if (at && /\.html?$/i.test(at) && existsSync24(join39(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
7029
7030
  }
7031
+ if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec));
7030
7032
  if (command === "critique") {
7031
7033
  const dir = join39(root, ".jig", "critique");
7032
7034
  const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join39(dir, s, "screen.json")) || existsSync24(join39(dir, s, "code.json"))) : [];
@@ -7045,6 +7047,68 @@ function commandProblems(root, command) {
7045
7047
  }
7046
7048
  return problems;
7047
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
+ }
7048
7112
  function surfacePage(projectRoot, surface) {
7049
7113
  const spec = newestSpec(projectRoot);
7050
7114
  const front = spec?.body.split(/^---\s*$/m)[1] ?? "";
@@ -7135,6 +7199,28 @@ ${problems.join("\n\n")}`
7135
7199
  }
7136
7200
  var VERDICT_FILES = ["screen.json", "code.json", "decisions.json"];
7137
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
+ }
7138
7224
  function verdictChecksum(dir) {
7139
7225
  const parts = VERDICT_FILES.map((f) => {
7140
7226
  try {
@@ -7157,18 +7243,22 @@ function verdictGuard(root, command, inPlay) {
7157
7243
  if (!now) continue;
7158
7244
  const lockPath = join39(dir, LOCK);
7159
7245
  if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
7160
- try {
7161
- writeFileSync6(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
7162
- } catch {
7163
- }
7246
+ writeLock(lockPath, dir, now);
7164
7247
  continue;
7165
7248
  }
7166
- let locked;
7249
+ let lock = {};
7167
7250
  try {
7168
- locked = JSON.parse(readFileSync32(lockPath, "utf8")).checksum;
7251
+ lock = JSON.parse(readFileSync32(lockPath, "utf8"));
7169
7252
  } catch {
7170
7253
  continue;
7171
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
+ }
7172
7262
  if (locked && locked !== now) {
7173
7263
  problems.push(
7174
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.`
@@ -7177,6 +7267,40 @@ function verdictGuard(root, command, inPlay) {
7177
7267
  }
7178
7268
  return problems;
7179
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
+ }
7180
7304
  function save(file, state) {
7181
7305
  try {
7182
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.10",
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",
@@ -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
  }