jig-ui 0.10.0 → 0.11.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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0
4
+
5
+ One release, one lesson: a measurement an agent can type is not a
6
+ measurement. Everything here came from two live runs at the capability
7
+ floor the day 0.10.0 shipped.
8
+
9
+ ### Added
10
+
11
+ - **`jig probe --save <surface>`.** The CLI writes the probe file now. It
12
+ reads what the probe returned on stdin, checks it is probe output for a
13
+ page inside the project, and stamps it with that page's checksum and the
14
+ time. A run had written its own probe file by hand — five links and an
15
+ Escape key that closed a menu with thirteen links and no Escape handler —
16
+ and its review passed on those numbers.
17
+ - **`install --hook` / `--no-hook`.** The Stop hook is opt-in. An
18
+ interactive install offers it once and takes silence as no; `--yes` never
19
+ adds it, because that is the path an agent takes and nobody is there to
20
+ consent. `update` moves an existing hook and never adds one.
21
+
22
+ ### Changed
23
+
24
+ - **Probe files are version 2.** `jig verdicts` rejects a probe with no
25
+ stamp — nothing measured it — and one whose page changed after it was
26
+ taken. Version 1 files no longer validate.
27
+ - **README and `init` say which directory to commit, and why.** The tokens
28
+ live in `<css dir>/jig/`; `.jig/` holds `state.json`, specs, mockups and
29
+ critique verdicts. Both are warned about separately, each with what
30
+ ignoring it costs. Since 0.7.0 both had said `.jig/` holds the tokens.
31
+
3
32
  ## 0.10.0
4
33
 
5
34
  Mobile-first, and a design loop that a weak model cannot skip. Everything here
package/README.md CHANGED
@@ -149,13 +149,23 @@ Set `brand` in `jig.config.json` to put it somewhere else. Projects set up
149
149
  before 0.7.0 keep their `.jig/tokens/` layout; `update` does not move them, and
150
150
  `init` offers to.
151
151
 
152
- The mode file is the one thing genuinely copied: a stylesheet `@import` is an
153
- edge in a build graph and has to resolve locally, on every machine that builds.
154
-
155
- **Commit `.jig/`.** It holds the token files your stylesheet imports, so a
156
- gitignored `.jig/` means the design system does not exist for anyone who did not
157
- run `init` themselves — their build breaks on a missing import, and CI's `jig
158
- check` sees no token layer at all. `init` warns if it finds `.jig/` ignored.
152
+ The mode file is the one token file copied verbatim from the package: a
153
+ stylesheet `@import` is an edge in a build graph and has to resolve locally, on
154
+ every machine that builds. The brand file and the barrel are generated for your
155
+ project, not copied.
156
+
157
+ **Commit the token directory** — `<css dir>/jig/`. It holds the files your
158
+ stylesheet `@import`s, so ignoring it means the design system does not exist for
159
+ anyone who did not run `init` themselves: their build breaks on a missing import,
160
+ and CI's `jig check` sees no token layer at all.
161
+
162
+ **Commit `.jig/` too.** It is not the tokens (it was, before 0.7.0). It holds
163
+ `state.json`, which records the version and checksum of every file `init` wrote
164
+ and is what `update` reads to know what it may refresh, plus the durable record of
165
+ the design loop: your specs, approved mockups and critique verdicts. Ignored, a
166
+ teammate's `update` cannot tell a file you edited from one it wrote, and every
167
+ spec the project agreed is invisible to the next agent. `init` warns if it finds
168
+ either directory ignored.
159
169
 
160
170
  Add `--yes` to accept every derived default non-interactively — the mode CI and
161
171
  agents run in. It states the mode it chose and where to change it, because
@@ -370,6 +380,8 @@ treatment.
370
380
  | `<css dir>/jig/brand.*.css` | Identity. One per project. |
371
381
  | `<css dir>/jig/mode.*.css` | Density, scale, rhythm, motion |
372
382
  | `<css dir>/jig/theme.css` | The barrel — brand + mode. This is what you import. |
383
+ | `.jig/state.json` | What `init` wrote, with checksums. `update` reads it to leave your edits alone. |
384
+ | `.jig/specs/`, `.jig/mockups/`, `.jig/critique/` | The design loop's record: what was agreed, what was drawn, what the review found. |
373
385
 
374
386
  `rules/*` and `rules.index.json` live beside your installed skill file, not
375
387
  in the project — see above.
package/dist/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import { readFileSync as readFileSync27 } from "fs";
5
+ import { readFileSync as readFileSync28 } from "fs";
6
6
  import { homedir } from "os";
7
- import { join as join33 } from "path";
7
+ import { join as join34 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -4847,9 +4847,16 @@ Could not write to ${wireTarget}: ${err.message}`);
4847
4847
  log(` ${snippet.split("\n").join("\n ")}`);
4848
4848
  }
4849
4849
  }
4850
+ const tokenDirRel = tokensRelDir.join("/");
4851
+ if (isIgnored(opts.projectRoot, tokenDirRel)) {
4852
+ log(
4853
+ `
4854
+ WARNING: ${tokenDirRel}/ is gitignored, but it holds this project's tokens \u2014 the files your stylesheet @imports. Committed, teammates and CI get the same design system; ignored, their builds break on a missing import. Remove the ${tokenDirRel}/ rule from .gitignore, or commit the directory explicitly.`
4855
+ );
4856
+ }
4850
4857
  if (isIgnored(opts.projectRoot, ".jig")) {
4851
4858
  log(
4852
- "\n WARNING: .jig/ is gitignored, but it holds this project's tokens \u2014 the files your stylesheet @imports. Committed, teammates and CI get the same design system; ignored, their builds break on a missing import. Remove the .jig/ rule from .gitignore, or commit the directory explicitly."
4859
+ "\n WARNING: .jig/ is gitignored. It holds state.json \u2014 which `update` reads to leave your edited files alone \u2014 and the specs, mockups and critique verdicts this project agreed. Ignored, update cannot tell your edits from its own writes, and every spec is invisible to the next agent. Commit it."
4853
4860
  );
4854
4861
  }
4855
4862
  const baselineResult = check({ projectRoot: opts.projectRoot, homeDir: opts.homeDir, version: opts.version, all: true, ci: false });
@@ -4873,8 +4880,8 @@ Could not write to ${wireTarget}: ${err.message}`);
4873
4880
  }
4874
4881
 
4875
4882
  // src/commands/verdicts.ts
4876
- import { existsSync as existsSync17, readFileSync as readFileSync24 } from "fs";
4877
- import { join as join30, resolve as resolve3 } from "path";
4883
+ import { existsSync as existsSync18, readFileSync as readFileSync25 } from "fs";
4884
+ import { join as join31, resolve as resolve4 } from "path";
4878
4885
 
4879
4886
  // src/rules/citations.ts
4880
4887
  import { join as join28 } from "path";
@@ -4888,11 +4895,16 @@ function citableIds(packageRoot2) {
4888
4895
  }
4889
4896
 
4890
4897
  // src/probe/check.ts
4891
- import { existsSync as existsSync16, readdirSync as readdirSync11, readFileSync as readFileSync23 } from "fs";
4892
- import { join as join29 } from "path";
4898
+ import { existsSync as existsSync17, readdirSync as readdirSync11, readFileSync as readFileSync24 } from "fs";
4899
+ import { join as join30 } from "path";
4900
+
4901
+ // src/probe/save.ts
4902
+ import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync23, writeFileSync as writeFileSync6 } from "fs";
4903
+ import { fileURLToPath as fileURLToPath2 } from "url";
4904
+ import { join as join29, relative as relative3, resolve as resolve3 } from "path";
4893
4905
 
4894
4906
  // src/probe/script.ts
4895
- var PROBE_VERSION = 1;
4907
+ var PROBE_VERSION = 2;
4896
4908
  var PROBE_SCRIPT = `(async () => {
4897
4909
  const doc = document.documentElement;
4898
4910
  const vis = (e) => { const r = e.getBoundingClientRect(); const s = getComputedStyle(e); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
@@ -4962,17 +4974,79 @@ var PROBE_SCRIPT = `(async () => {
4962
4974
  });
4963
4975
  })()`;
4964
4976
 
4977
+ // src/probe/save.ts
4978
+ function saveProbe(opts) {
4979
+ let probe;
4980
+ try {
4981
+ probe = JSON.parse(opts.json);
4982
+ } catch (e) {
4983
+ throw new Error(`That is not the probe's output: ${e.message}. Evaluate \`jig probe\` in the browser and pipe what it returns straight in.`);
4984
+ }
4985
+ if (probe.jigProbe !== PROBE_VERSION) {
4986
+ throw new Error(`That JSON is not version ${PROBE_VERSION} probe output (jigProbe: ${JSON.stringify(probe.jigProbe)}). Re-read \`jig probe\` and evaluate it; do not write the file yourself.`);
4987
+ }
4988
+ const width = typeof probe.width === "number" ? probe.width : NaN;
4989
+ if (!Number.isFinite(width)) throw new Error("The probe output has no width. Evaluate it in a browser, at the width you are judging.");
4990
+ const url = typeof probe.url === "string" ? probe.url : "";
4991
+ const page = pageFile(opts.projectRoot, url);
4992
+ if (!page) {
4993
+ throw new Error(`The probe ran at ${url || "no url"}, which is not a file in this project. Open the page you built \u2014 a file:// URL under the project \u2014 and probe that.`);
4994
+ }
4995
+ probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
4996
+ probe.pageChecksum = checksum(readFileSync23(page, "utf8"));
4997
+ probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
4998
+ const dir = join29(opts.projectRoot, ".jig", "critique", opts.surface);
4999
+ mkdirSync7(dir, { recursive: true });
5000
+ const path = join29(dir, `probe-${width}.json`);
5001
+ writeFileSync6(path, JSON.stringify(probe), "utf8");
5002
+ return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
5003
+ }
5004
+ function pageFile(projectRoot, url) {
5005
+ if (!url.startsWith("file://")) return void 0;
5006
+ let path;
5007
+ try {
5008
+ path = fileURLToPath2(url.split("#")[0].split("?")[0]);
5009
+ } catch {
5010
+ return void 0;
5011
+ }
5012
+ const abs = resolve3(path);
5013
+ if (!abs.startsWith(resolve3(projectRoot))) return void 0;
5014
+ return existsSync16(abs) ? abs : void 0;
5015
+ }
5016
+
4965
5017
  // src/probe/check.ts
4966
- function readProbes(dir, errors) {
4967
- if (!existsSync16(dir)) return [];
5018
+ function stampProblem(projectRoot, file, p) {
5019
+ if (!p.pageChecksum || !p.pageFile) {
5020
+ return `${file} was not written by \`jig probe --save\`, so nothing measured it. Evaluate \`jig probe\` in the browser and pipe its output into \`jig probe --save <surface>\`.`;
5021
+ }
5022
+ const page = pageFile(projectRoot, p.url ?? "");
5023
+ if (!page) return `${file} names a page outside this project (${p.url ?? "no url"}).`;
5024
+ let current;
5025
+ try {
5026
+ current = readFileSync24(page, "utf8");
5027
+ } catch {
5028
+ return `${file} was taken on ${p.pageFile}, which no longer exists.`;
5029
+ }
5030
+ if (checksum(current) !== p.pageChecksum) {
5031
+ return `${file} was taken on an older ${p.pageFile} \u2014 the page changed after it. Render and probe again at that width.`;
5032
+ }
5033
+ return void 0;
5034
+ }
5035
+ function readProbes(projectRoot, dir, errors) {
5036
+ if (!existsSync17(dir)) return [];
4968
5037
  const probes = [];
4969
5038
  for (const f of readdirSync11(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
4970
5039
  try {
4971
- const p = JSON.parse(readFileSync23(join29(dir, f), "utf8"));
5040
+ const p = JSON.parse(readFileSync24(join30(dir, f), "utf8"));
4972
5041
  if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
4973
5042
  errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
4974
5043
  continue;
4975
5044
  }
5045
+ const stale = stampProblem(projectRoot, f, p);
5046
+ if (stale) {
5047
+ errors.push(stale);
5048
+ continue;
5049
+ }
4976
5050
  probes.push(p);
4977
5051
  } catch (e) {
4978
5052
  errors.push(`${f}: not valid JSON (${e.message}).`);
@@ -5033,18 +5107,18 @@ function probeContradictions(probes, verdictOf) {
5033
5107
  var VERDICTS = ["ok", "finding", "n/a"];
5034
5108
  var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
5035
5109
  function readJson(path, errors) {
5036
- if (!existsSync17(path)) return null;
5110
+ if (!existsSync18(path)) return null;
5037
5111
  try {
5038
- return JSON.parse(readFileSync24(path, "utf8"));
5112
+ return JSON.parse(readFileSync25(path, "utf8"));
5039
5113
  } catch (e) {
5040
5114
  errors.push(`${path}: not valid JSON (${e.message})`);
5041
5115
  return {};
5042
5116
  }
5043
5117
  }
5044
5118
  function specNeedsNav(projectRoot, surface) {
5045
- const path = join30(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5046
- if (!existsSync17(path)) return false;
5047
- const front = readFileSync24(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5119
+ const path = join31(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5120
+ if (!existsSync18(path)) return false;
5121
+ const front = readFileSync25(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5048
5122
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
5049
5123
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
5050
5124
  return navField || navRegion;
@@ -5095,7 +5169,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
5095
5169
  function verifyVerdicts(opts) {
5096
5170
  const root = opts.packageRoot ?? assetRoot();
5097
5171
  const errors = [];
5098
- const index = JSON.parse(readFileSync24(join30(root, "rules.index.json"), "utf8"));
5172
+ const index = JSON.parse(readFileSync25(join31(root, "rules.index.json"), "utf8"));
5099
5173
  const judgment = index.filter((r) => r.bucket === "judgment");
5100
5174
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
5101
5175
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
@@ -5104,21 +5178,21 @@ function verifyVerdicts(opts) {
5104
5178
  ...judgment.map((r) => [r.id, r.pass ?? ""])
5105
5179
  ]);
5106
5180
  const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
5107
- const dir = join30(opts.projectRoot, ".jig", "critique", opts.surface);
5108
- const screenFile = readJson(join30(dir, "screen.json"), errors);
5109
- const codeFile = readJson(join30(dir, "code.json"), errors);
5181
+ const dir = join31(opts.projectRoot, ".jig", "critique", opts.surface);
5182
+ const screenFile = readJson(join31(dir, "screen.json"), errors);
5183
+ const codeFile = readJson(join31(dir, "code.json"), errors);
5110
5184
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
5111
5185
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
5112
5186
  const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
5113
5187
  let rendered = false;
5114
5188
  if (screenFile && screenFile.rendered === true) {
5115
5189
  const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
5116
- const absent = artefacts.filter((a) => !existsSync17(resolve3(opts.projectRoot, a)));
5190
+ const absent = artefacts.filter((a) => !existsSync18(resolve4(opts.projectRoot, a)));
5117
5191
  if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
5118
5192
  else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
5119
5193
  else rendered = true;
5120
5194
  }
5121
- const probes = readProbes(dir, errors);
5195
+ const probes = readProbes(opts.projectRoot, dir, errors);
5122
5196
  if (screenFile && screenFile.rendered === true) {
5123
5197
  const widths = new Set(probes.map((p) => p.width));
5124
5198
  const missing = [360, 768, 1280].filter((w) => !widths.has(w));
@@ -5140,19 +5214,19 @@ function verifyVerdicts(opts) {
5140
5214
  }
5141
5215
 
5142
5216
  // src/commands/gate.ts
5143
- import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync13, readFileSync as readFileSync26, writeFileSync as writeFileSync6 } from "fs";
5144
- import { join as join32 } from "path";
5217
+ import { existsSync as existsSync20, mkdirSync as mkdirSync8, readdirSync as readdirSync13, readFileSync as readFileSync27, writeFileSync as writeFileSync7 } from "fs";
5218
+ import { join as join33 } from "path";
5145
5219
 
5146
5220
  // src/check/spec-shape.ts
5147
- import { existsSync as existsSync18, readFileSync as readFileSync25, readdirSync as readdirSync12, statSync as statSync2 } from "fs";
5148
- import { join as join31 } from "path";
5221
+ import { existsSync as existsSync19, readFileSync as readFileSync26, readdirSync as readdirSync12, statSync as statSync2 } from "fs";
5222
+ import { join as join32 } from "path";
5149
5223
  function newestSpec(projectRoot) {
5150
- const dir = join31(projectRoot, ".jig", "specs");
5151
- if (!existsSync18(dir)) return void 0;
5224
+ const dir = join32(projectRoot, ".jig", "specs");
5225
+ if (!existsSync19(dir)) return void 0;
5152
5226
  const files = readdirSync12(dir).filter((f) => f.endsWith(".md"));
5153
5227
  if (files.length === 0) return void 0;
5154
- const newest = files.map((f) => ({ f, at: statSync2(join31(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
5155
- return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync25(join31(dir, newest), "utf8") };
5228
+ const newest = files.map((f) => ({ f, at: statSync2(join32(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
5229
+ return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync26(join32(dir, newest), "utf8") };
5156
5230
  }
5157
5231
  function specProblems(spec) {
5158
5232
  const parts = spec.body.split(/^---\s*$/m);
@@ -5221,10 +5295,10 @@ function navProblems(spec) {
5221
5295
  // src/commands/gate.ts
5222
5296
  var MAX_BLOCKS = 3;
5223
5297
  function lastJigCommand(transcriptPath) {
5224
- if (!transcriptPath || !existsSync19(transcriptPath)) return void 0;
5298
+ if (!transcriptPath || !existsSync20(transcriptPath)) return void 0;
5225
5299
  let text;
5226
5300
  try {
5227
- text = readFileSync26(transcriptPath, "utf8");
5301
+ text = readFileSync27(transcriptPath, "utf8");
5228
5302
  } catch {
5229
5303
  return void 0;
5230
5304
  }
@@ -5242,10 +5316,10 @@ function commandProblems(root, command) {
5242
5316
  const problems = [];
5243
5317
  const spec = newestSpec(root);
5244
5318
  if (command === "decide") {
5245
- const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) => join32(root, p)).find((p) => existsSync19(p));
5319
+ const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) => join33(root, p)).find((p) => existsSync20(p));
5246
5320
  if (!file) problems.push("decide wrote no DECISIONS.md beside the token layer.");
5247
5321
  else {
5248
- const body = readFileSync26(file, "utf8");
5322
+ const body = readFileSync27(file, "utf8");
5249
5323
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
5250
5324
  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.`");
5251
5325
  }
@@ -5266,13 +5340,13 @@ function commandProblems(root, command) {
5266
5340
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
5267
5341
  if (/^approved/i.test(mockup)) {
5268
5342
  if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
5269
- else if (!/^https?:/i.test(at) && !existsSync19(join32(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
5343
+ else if (!/^https?:/i.test(at) && !existsSync20(join33(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
5270
5344
  else if (!/^https?:/i.test(at) && !at.startsWith(".jig/mockups/")) problems.push(`The drawing is at ${at}. A mockup lives in .jig/mockups/, outside what check scans and outside what ships.`);
5271
5345
  }
5272
5346
  }
5273
5347
  if (command === "critique") {
5274
- const dir = join32(root, ".jig", "critique");
5275
- const surfaces = existsSync19(dir) ? readdirSync13(dir).filter((s) => existsSync19(join32(dir, s, "screen.json")) || existsSync19(join32(dir, s, "code.json"))) : [];
5348
+ const dir = join33(root, ".jig", "critique");
5349
+ const surfaces = existsSync20(dir) ? readdirSync13(dir).filter((s) => existsSync20(join33(dir, s, "screen.json")) || existsSync20(join33(dir, s, "code.json"))) : [];
5276
5350
  for (const surface of surfaces) {
5277
5351
  const v = verifyVerdicts({ projectRoot: root, surface });
5278
5352
  if (v.ok && v.screen.state === "skipped") {
@@ -5287,7 +5361,7 @@ function commandProblems(root, command) {
5287
5361
  }
5288
5362
  function gate(opts) {
5289
5363
  const root = opts.projectRoot;
5290
- if (!existsSync19(join32(root, "jig.config.json")) && !existsSync19(join32(root, ".jig"))) {
5364
+ if (!existsSync20(join33(root, "jig.config.json")) && !existsSync20(join33(root, ".jig"))) {
5291
5365
  return { block: false, reason: "" };
5292
5366
  }
5293
5367
  const command = lastJigCommand(opts.input.transcript_path);
@@ -5306,11 +5380,11 @@ function gate(opts) {
5306
5380
  );
5307
5381
  }
5308
5382
  }
5309
- const critiqueDir = join32(root, ".jig", "critique");
5310
- if (existsSync19(critiqueDir)) {
5383
+ const critiqueDir = join33(root, ".jig", "critique");
5384
+ if (existsSync20(critiqueDir)) {
5311
5385
  for (const surface of readdirSync13(critiqueDir)) {
5312
- const dir = join32(critiqueDir, surface);
5313
- if (!existsSync19(join32(dir, "screen.json")) && !existsSync19(join32(dir, "code.json"))) continue;
5386
+ const dir = join33(critiqueDir, surface);
5387
+ if (!existsSync20(join33(dir, "screen.json")) && !existsSync20(join33(dir, "code.json"))) continue;
5314
5388
  const v = verifyVerdicts({ projectRoot: root, surface });
5315
5389
  if (!v.ok) {
5316
5390
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -5323,10 +5397,10 @@ function gate(opts) {
5323
5397
  }
5324
5398
  }
5325
5399
  const session = opts.input.session_id ?? "unknown";
5326
- const stateFile = join32(root, ".jig", "gate.json");
5400
+ const stateFile = join33(root, ".jig", "gate.json");
5327
5401
  let state = {};
5328
5402
  try {
5329
- state = JSON.parse(readFileSync26(stateFile, "utf8"));
5403
+ state = JSON.parse(readFileSync27(stateFile, "utf8"));
5330
5404
  } catch {
5331
5405
  }
5332
5406
  if (problems.length === 0) {
@@ -5355,15 +5429,15 @@ ${problems.join("\n\n")}`
5355
5429
  }
5356
5430
  function save(file, state) {
5357
5431
  try {
5358
- mkdirSync7(join32(file, ".."), { recursive: true });
5359
- writeFileSync6(file, JSON.stringify(state), "utf8");
5432
+ mkdirSync8(join33(file, ".."), { recursive: true });
5433
+ writeFileSync7(file, JSON.stringify(state), "utf8");
5360
5434
  } catch {
5361
5435
  }
5362
5436
  }
5363
5437
 
5364
5438
  // src/index.ts
5365
5439
  var packageRoot = getPackageRoot();
5366
- var { version } = JSON.parse(readFileSync27(join33(packageRoot, "package.json"), "utf8"));
5440
+ var { version } = JSON.parse(readFileSync28(join34(packageRoot, "package.json"), "utf8"));
5367
5441
  function warnIfUnpublishedPin() {
5368
5442
  if (isPublishedBuild(packageRoot)) return;
5369
5443
  console.warn(
@@ -5489,13 +5563,33 @@ program.command("check").description("Check the repo against Jig's mechanical +
5489
5563
  process.exit(1);
5490
5564
  }
5491
5565
  });
5492
- program.command("probe").description("Print the render probe: run it in a browser at each width and save its output for `jig verdicts`.").action(() => {
5493
- console.log(PROBE_SCRIPT);
5566
+ program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").action(async (opts) => {
5567
+ if (!opts.save) {
5568
+ console.log(PROBE_SCRIPT);
5569
+ return;
5570
+ }
5571
+ const projectRoot = findProjectRoot(process.cwd());
5572
+ try {
5573
+ const json = process.stdin.isTTY ? "" : await new Promise((done, fail) => {
5574
+ let text = "";
5575
+ process.stdin.setEncoding("utf8");
5576
+ process.stdin.on("data", (chunk) => {
5577
+ text += chunk;
5578
+ });
5579
+ process.stdin.on("end", () => done(text));
5580
+ process.stdin.on("error", fail);
5581
+ });
5582
+ const result = saveProbe({ projectRoot, surface: opts.save, json });
5583
+ console.log(` Recorded ${result.path} \u2014 ${result.page} at ${result.width}px.`);
5584
+ } catch (err) {
5585
+ console.error(` \u2717 ${err.message}`);
5586
+ process.exit(1);
5587
+ }
5494
5588
  });
5495
5589
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(() => {
5496
5590
  let input = {};
5497
5591
  try {
5498
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync27(0, "utf8") || "{}");
5592
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync28(0, "utf8") || "{}");
5499
5593
  } catch {
5500
5594
  }
5501
5595
  const cwd = input.cwd ?? process.cwd();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "A design system for coding agents. 113 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",
@@ -853,10 +853,18 @@ probe in the browser and save what it returns beside the verdicts:
853
853
 
854
854
  ```
855
855
  {{scripts_path}} probe > .jig/probe.js
856
- # at each width: set the viewport, open the page, evaluate .jig/probe.js, save the
857
- # JSON it returns as .jig/critique/<surface>/probe-<width>.json
856
+ # at each width: set the viewport, open the page, evaluate .jig/probe.js, and pipe
857
+ # exactly what it returned into:
858
+ {{scripts_path}} probe --save <surface>
858
859
  ```
859
860
 
861
+ **The CLI writes the probe file, you do not.** `--save` reads the probe's output,
862
+ stamps it with the page's checksum, and stores it. That is what makes the file a
863
+ measurement: `verdicts` recomputes the checksum, so a file written by hand, or one
864
+ taken before the page's last edit, is rejected. A live run wrote a probe file itself
865
+ — five links and Escape closing a menu that has thirteen links and no Escape handler
866
+ — and its review passed on those numbers.
867
+
860
868
  The probe opens the phone menu, presses Escape, measures sideways scroll, and reads
861
869
  whether the styles and tokens actually applied. `verdicts` refuses `rendered: true`
862
870
  without a probe at each width, and refuses an `ok` the probe contradicts — a `P-14`
@@ -30,8 +30,8 @@
30
30
  "status": "available"
31
31
  },
32
32
  "probe": {
33
- "description": "Print the render probe: a script the critique runs in a browser at 360, 768 and 1280px. It opens the phone menu, presses Escape, measures sideways scroll and whether styles and tokens applied; `verdicts` refuses verdicts it contradicts.",
34
- "argumentHint": "",
33
+ "description": "Print the render probe: a script the critique runs in a browser at 360, 768 and 1280px. With --save it records what the probe returned, stamped with the page's checksum, so `verdicts` can tell a measurement from a claim.",
34
+ "argumentHint": "[--save <surface>]",
35
35
  "status": "available"
36
36
  },
37
37
  "gate": {