jig-ui 0.10.0 → 0.12.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,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Added
6
+
7
+ - **`I-118` Em dashes in interface text.** Use a full stop, a comma, a colon
8
+ or a second element. The mark is ambiguous on its own: it stands in for
9
+ four different ones, and the reader only learns which after reading past
10
+ it. It is also the clearest tell of machine-written copy, and copy that
11
+ reads as generated is copy the reader trusts less. The `em-dash` detector
12
+ reads element text and the attributes a reader hears or sees; scripts,
13
+ styles, comments, class names and documentation are not interface text.
14
+ The en dash keeps its one job, a range read as "to" (`2-10 seats`).
15
+
16
+ ## 0.11.1
17
+
18
+ Two holes in the gate, both found by running the loop twice on 0.11.0 —
19
+ once on Haiku, once on Sonnet.
20
+
21
+ ### Fixed
22
+
23
+ - **The Stop hook's block budget is per failure, not per session.** A
24
+ headless session keeps one session id across every `/jig` step, so a run
25
+ that spent its three blocks on `init`, `spec` and `make` reached
26
+ `critique` with none left: it reported "complete and approved, zero
27
+ findings" having written no verdict files, and the gate that would have
28
+ caught it had already let go. Fixing a failure now returns its attempts,
29
+ a new failure starts fresh, and an agent that cannot fix a given failure
30
+ still gets out after three tries.
31
+ - **`nav: horizontal bar (no menu)` is no longer read as a menu button**
32
+ at a width where the links fit. A false positive is how a gate gets
33
+ ignored.
34
+
35
+ ## 0.11.0
36
+
37
+ One release, one lesson: a measurement an agent can type is not a
38
+ measurement. Everything here came from two live runs at the capability
39
+ floor the day 0.10.0 shipped.
40
+
41
+ ### Added
42
+
43
+ - **`jig probe --save <surface>`.** The CLI writes the probe file now. It
44
+ reads what the probe returned on stdin, checks it is probe output for a
45
+ page inside the project, and stamps it with that page's checksum and the
46
+ time. A run had written its own probe file by hand — five links and an
47
+ Escape key that closed a menu with thirteen links and no Escape handler —
48
+ and its review passed on those numbers.
49
+ - **`install --hook` / `--no-hook`.** The Stop hook is opt-in. An
50
+ interactive install offers it once and takes silence as no; `--yes` never
51
+ adds it, because that is the path an agent takes and nobody is there to
52
+ consent. `update` moves an existing hook and never adds one.
53
+
54
+ ### Changed
55
+
56
+ - **Probe files are version 2.** `jig verdicts` rejects a probe with no
57
+ stamp — nothing measured it — and one whose page changed after it was
58
+ taken. Version 1 files no longer validate.
59
+ - **README and `init` say which directory to commit, and why.** The tokens
60
+ live in `<css dir>/jig/`; `.jig/` holds `state.json`, specs, mockups and
61
+ critique verdicts. Both are warned about separately, each with what
62
+ ignoring it costs. Since 0.7.0 both had said `.jig/` holds the tokens.
63
+
3
64
  ## 0.10.0
4
65
 
5
66
  Mobile-first, and a design loop that a weak model cannot skip. Everything here
package/README.md CHANGED
@@ -12,13 +12,13 @@ Installed as `npx jig-ui` — the bare name was taken on npm.
12
12
  Jig is **a skill your coding agent reads**, and **a CLI you can run yourself**.
13
13
  They are two halves of the same thing, and the split is not arbitrary:
14
14
 
15
- - Of the 113 rules, **16 can be decided by a machine** — a hard-coded colour, a
15
+ - Of the 114 rules, **17 can be decided by a machine** — a hard-coded colour, a
16
16
  contrast ratio below the floor, a removed focus ring. The CLI decides those.
17
17
  - The other **97 are judgment** — whether an empty state says anything useful,
18
18
  whether a label reads as an instruction, whether motion earns its place. No
19
19
  regex settles those. An agent reads the rules and applies them.
20
20
 
21
- Running only the CLI gets you the 16. Running only the agent gets you the 97 with
21
+ Running only the CLI gets you the 17. Running only the agent gets you the 97 with
22
22
  no verification. **A clean `jig check` is not a clean review**, and the skill
23
23
  says so to every agent that reads it.
24
24
 
@@ -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.
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.
154
156
 
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.
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";
@@ -3299,6 +3299,59 @@ var undeclaredToken = {
3299
3299
  }
3300
3300
  };
3301
3301
 
3302
+ // src/check/detectors/em-dash.ts
3303
+ var MARKUP = [".html", ".htm", ".vue", ".svelte", ".astro", ".jsx", ".tsx", ".php", ".erb", ".twig", ".hbs"];
3304
+ var TEXT_ATTRS = /\b(aria-label|aria-description|aria-placeholder|title|placeholder|alt|label|value|content)\s*=\s*("([^"]*)"|'([^']*)')/gi;
3305
+ var EM_DASH = /—/;
3306
+ function maskNonProse(source) {
3307
+ const blank3 = (m) => " ".repeat(m.length);
3308
+ return source.replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
3309
+ }
3310
+ function proseSpans(masked) {
3311
+ const spans = [];
3312
+ let last = 0;
3313
+ for (const tag of masked.matchAll(/<[^>]*>/g)) {
3314
+ const between = masked.slice(last, tag.index);
3315
+ if (between.trim()) spans.push({ index: last, text: between });
3316
+ for (const attr of tag[0].matchAll(TEXT_ATTRS)) {
3317
+ const value = attr[3] ?? attr[4] ?? "";
3318
+ spans.push({ index: tag.index + attr.index + attr[0].indexOf(value), text: value });
3319
+ }
3320
+ last = tag.index + tag[0].length;
3321
+ }
3322
+ const tail = masked.slice(last);
3323
+ if (tail.trim()) spans.push({ index: last, text: tail });
3324
+ return spans;
3325
+ }
3326
+ var emDash = {
3327
+ name: "em-dash",
3328
+ appliesTo: (file) => hasExtension(file, MARKUP),
3329
+ run(_source, file, ctx) {
3330
+ const masked = maskNonProse(ctx.raw);
3331
+ const starts = buildLineIndex(ctx.raw);
3332
+ const findings = [];
3333
+ const seen = /* @__PURE__ */ new Set();
3334
+ for (const span of proseSpans(masked)) {
3335
+ if (!EM_DASH.test(span.text)) continue;
3336
+ const offset = span.index + span.text.indexOf("\u2014");
3337
+ const line = lineForOffset(starts, offset);
3338
+ if (seen.has(line)) continue;
3339
+ seen.add(line);
3340
+ findings.push(
3341
+ mkFinding(
3342
+ ctx,
3343
+ "em-dash",
3344
+ file,
3345
+ line,
3346
+ "an em dash in interface text: use a full stop, a comma, a colon, or a second element",
3347
+ sourceLine(ctx.raw, line)
3348
+ )
3349
+ );
3350
+ }
3351
+ return findings;
3352
+ }
3353
+ };
3354
+
3302
3355
  // src/check/registry.ts
3303
3356
  var DETECTORS = [
3304
3357
  gradientText,
@@ -3316,7 +3369,8 @@ var DETECTORS = [
3316
3369
  inputZoom,
3317
3370
  safeArea,
3318
3371
  menuState,
3319
- undeclaredToken
3372
+ undeclaredToken,
3373
+ emDash
3320
3374
  ];
3321
3375
  function getDetector(name) {
3322
3376
  return DETECTORS.find((d) => d.name === name);
@@ -4847,9 +4901,16 @@ Could not write to ${wireTarget}: ${err.message}`);
4847
4901
  log(` ${snippet.split("\n").join("\n ")}`);
4848
4902
  }
4849
4903
  }
4904
+ const tokenDirRel = tokensRelDir.join("/");
4905
+ if (isIgnored(opts.projectRoot, tokenDirRel)) {
4906
+ log(
4907
+ `
4908
+ 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.`
4909
+ );
4910
+ }
4850
4911
  if (isIgnored(opts.projectRoot, ".jig")) {
4851
4912
  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."
4913
+ "\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
4914
  );
4854
4915
  }
4855
4916
  const baselineResult = check({ projectRoot: opts.projectRoot, homeDir: opts.homeDir, version: opts.version, all: true, ci: false });
@@ -4873,8 +4934,8 @@ Could not write to ${wireTarget}: ${err.message}`);
4873
4934
  }
4874
4935
 
4875
4936
  // src/commands/verdicts.ts
4876
- import { existsSync as existsSync17, readFileSync as readFileSync24 } from "fs";
4877
- import { join as join30, resolve as resolve3 } from "path";
4937
+ import { existsSync as existsSync18, readFileSync as readFileSync25 } from "fs";
4938
+ import { join as join31, resolve as resolve4 } from "path";
4878
4939
 
4879
4940
  // src/rules/citations.ts
4880
4941
  import { join as join28 } from "path";
@@ -4888,11 +4949,16 @@ function citableIds(packageRoot2) {
4888
4949
  }
4889
4950
 
4890
4951
  // src/probe/check.ts
4891
- import { existsSync as existsSync16, readdirSync as readdirSync11, readFileSync as readFileSync23 } from "fs";
4892
- import { join as join29 } from "path";
4952
+ import { existsSync as existsSync17, readdirSync as readdirSync11, readFileSync as readFileSync24 } from "fs";
4953
+ import { join as join30 } from "path";
4954
+
4955
+ // src/probe/save.ts
4956
+ import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync23, writeFileSync as writeFileSync6 } from "fs";
4957
+ import { fileURLToPath as fileURLToPath2 } from "url";
4958
+ import { join as join29, relative as relative3, resolve as resolve3 } from "path";
4893
4959
 
4894
4960
  // src/probe/script.ts
4895
- var PROBE_VERSION = 1;
4961
+ var PROBE_VERSION = 2;
4896
4962
  var PROBE_SCRIPT = `(async () => {
4897
4963
  const doc = document.documentElement;
4898
4964
  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 +5028,79 @@ var PROBE_SCRIPT = `(async () => {
4962
5028
  });
4963
5029
  })()`;
4964
5030
 
5031
+ // src/probe/save.ts
5032
+ function saveProbe(opts) {
5033
+ let probe;
5034
+ try {
5035
+ probe = JSON.parse(opts.json);
5036
+ } catch (e) {
5037
+ 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.`);
5038
+ }
5039
+ if (probe.jigProbe !== PROBE_VERSION) {
5040
+ 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.`);
5041
+ }
5042
+ const width = typeof probe.width === "number" ? probe.width : NaN;
5043
+ if (!Number.isFinite(width)) throw new Error("The probe output has no width. Evaluate it in a browser, at the width you are judging.");
5044
+ const url = typeof probe.url === "string" ? probe.url : "";
5045
+ const page = pageFile(opts.projectRoot, url);
5046
+ if (!page) {
5047
+ 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.`);
5048
+ }
5049
+ probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
5050
+ probe.pageChecksum = checksum(readFileSync23(page, "utf8"));
5051
+ probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
5052
+ const dir = join29(opts.projectRoot, ".jig", "critique", opts.surface);
5053
+ mkdirSync7(dir, { recursive: true });
5054
+ const path = join29(dir, `probe-${width}.json`);
5055
+ writeFileSync6(path, JSON.stringify(probe), "utf8");
5056
+ return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
5057
+ }
5058
+ function pageFile(projectRoot, url) {
5059
+ if (!url.startsWith("file://")) return void 0;
5060
+ let path;
5061
+ try {
5062
+ path = fileURLToPath2(url.split("#")[0].split("?")[0]);
5063
+ } catch {
5064
+ return void 0;
5065
+ }
5066
+ const abs = resolve3(path);
5067
+ if (!abs.startsWith(resolve3(projectRoot))) return void 0;
5068
+ return existsSync16(abs) ? abs : void 0;
5069
+ }
5070
+
4965
5071
  // src/probe/check.ts
4966
- function readProbes(dir, errors) {
4967
- if (!existsSync16(dir)) return [];
5072
+ function stampProblem(projectRoot, file, p) {
5073
+ if (!p.pageChecksum || !p.pageFile) {
5074
+ 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>\`.`;
5075
+ }
5076
+ const page = pageFile(projectRoot, p.url ?? "");
5077
+ if (!page) return `${file} names a page outside this project (${p.url ?? "no url"}).`;
5078
+ let current;
5079
+ try {
5080
+ current = readFileSync24(page, "utf8");
5081
+ } catch {
5082
+ return `${file} was taken on ${p.pageFile}, which no longer exists.`;
5083
+ }
5084
+ if (checksum(current) !== p.pageChecksum) {
5085
+ return `${file} was taken on an older ${p.pageFile} \u2014 the page changed after it. Render and probe again at that width.`;
5086
+ }
5087
+ return void 0;
5088
+ }
5089
+ function readProbes(projectRoot, dir, errors) {
5090
+ if (!existsSync17(dir)) return [];
4968
5091
  const probes = [];
4969
5092
  for (const f of readdirSync11(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
4970
5093
  try {
4971
- const p = JSON.parse(readFileSync23(join29(dir, f), "utf8"));
5094
+ const p = JSON.parse(readFileSync24(join30(dir, f), "utf8"));
4972
5095
  if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
4973
5096
  errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
4974
5097
  continue;
4975
5098
  }
5099
+ const stale = stampProblem(projectRoot, f, p);
5100
+ if (stale) {
5101
+ errors.push(stale);
5102
+ continue;
5103
+ }
4976
5104
  probes.push(p);
4977
5105
  } catch (e) {
4978
5106
  errors.push(`${f}: not valid JSON (${e.message}).`);
@@ -5033,18 +5161,18 @@ function probeContradictions(probes, verdictOf) {
5033
5161
  var VERDICTS = ["ok", "finding", "n/a"];
5034
5162
  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
5163
  function readJson(path, errors) {
5036
- if (!existsSync17(path)) return null;
5164
+ if (!existsSync18(path)) return null;
5037
5165
  try {
5038
- return JSON.parse(readFileSync24(path, "utf8"));
5166
+ return JSON.parse(readFileSync25(path, "utf8"));
5039
5167
  } catch (e) {
5040
5168
  errors.push(`${path}: not valid JSON (${e.message})`);
5041
5169
  return {};
5042
5170
  }
5043
5171
  }
5044
5172
  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] ?? "";
5173
+ const path = join31(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5174
+ if (!existsSync18(path)) return false;
5175
+ const front = readFileSync25(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5048
5176
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
5049
5177
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
5050
5178
  return navField || navRegion;
@@ -5095,7 +5223,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
5095
5223
  function verifyVerdicts(opts) {
5096
5224
  const root = opts.packageRoot ?? assetRoot();
5097
5225
  const errors = [];
5098
- const index = JSON.parse(readFileSync24(join30(root, "rules.index.json"), "utf8"));
5226
+ const index = JSON.parse(readFileSync25(join31(root, "rules.index.json"), "utf8"));
5099
5227
  const judgment = index.filter((r) => r.bucket === "judgment");
5100
5228
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
5101
5229
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
@@ -5104,21 +5232,21 @@ function verifyVerdicts(opts) {
5104
5232
  ...judgment.map((r) => [r.id, r.pass ?? ""])
5105
5233
  ]);
5106
5234
  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);
5235
+ const dir = join31(opts.projectRoot, ".jig", "critique", opts.surface);
5236
+ const screenFile = readJson(join31(dir, "screen.json"), errors);
5237
+ const codeFile = readJson(join31(dir, "code.json"), errors);
5110
5238
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
5111
5239
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
5112
5240
  const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
5113
5241
  let rendered = false;
5114
5242
  if (screenFile && screenFile.rendered === true) {
5115
5243
  const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
5116
- const absent = artefacts.filter((a) => !existsSync17(resolve3(opts.projectRoot, a)));
5244
+ const absent = artefacts.filter((a) => !existsSync18(resolve4(opts.projectRoot, a)));
5117
5245
  if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
5118
5246
  else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
5119
5247
  else rendered = true;
5120
5248
  }
5121
- const probes = readProbes(dir, errors);
5249
+ const probes = readProbes(opts.projectRoot, dir, errors);
5122
5250
  if (screenFile && screenFile.rendered === true) {
5123
5251
  const widths = new Set(probes.map((p) => p.width));
5124
5252
  const missing = [360, 768, 1280].filter((w) => !widths.has(w));
@@ -5140,19 +5268,20 @@ function verifyVerdicts(opts) {
5140
5268
  }
5141
5269
 
5142
5270
  // 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";
5271
+ import { existsSync as existsSync20, mkdirSync as mkdirSync8, readdirSync as readdirSync13, readFileSync as readFileSync27, writeFileSync as writeFileSync7 } from "fs";
5272
+ import { createHash as createHash2 } from "crypto";
5273
+ import { join as join33 } from "path";
5145
5274
 
5146
5275
  // 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";
5276
+ import { existsSync as existsSync19, readFileSync as readFileSync26, readdirSync as readdirSync12, statSync as statSync2 } from "fs";
5277
+ import { join as join32 } from "path";
5149
5278
  function newestSpec(projectRoot) {
5150
- const dir = join31(projectRoot, ".jig", "specs");
5151
- if (!existsSync18(dir)) return void 0;
5279
+ const dir = join32(projectRoot, ".jig", "specs");
5280
+ if (!existsSync19(dir)) return void 0;
5152
5281
  const files = readdirSync12(dir).filter((f) => f.endsWith(".md"));
5153
5282
  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") };
5283
+ const newest = files.map((f) => ({ f, at: statSync2(join32(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
5284
+ return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync26(join32(dir, newest), "utf8") };
5156
5285
  }
5157
5286
  function specProblems(spec) {
5158
5287
  const parts = spec.body.split(/^---\s*$/m);
@@ -5201,7 +5330,7 @@ function sizeBlock(sizes, size) {
5201
5330
  }
5202
5331
  return lines.join("\n");
5203
5332
  }
5204
- var MENU_RE = /\b(menu|hamburger|drawer|burger)\b/i;
5333
+ var MENU_RE = /(?<!\b(?:no|not|without|never)\s)(?<!\bno\s\w{1,12}\s)\b(menu|hamburger|drawer|burger)\b/i;
5205
5334
  var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
5206
5335
  function navProblems(spec) {
5207
5336
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
@@ -5221,10 +5350,10 @@ function navProblems(spec) {
5221
5350
  // src/commands/gate.ts
5222
5351
  var MAX_BLOCKS = 3;
5223
5352
  function lastJigCommand(transcriptPath) {
5224
- if (!transcriptPath || !existsSync19(transcriptPath)) return void 0;
5353
+ if (!transcriptPath || !existsSync20(transcriptPath)) return void 0;
5225
5354
  let text;
5226
5355
  try {
5227
- text = readFileSync26(transcriptPath, "utf8");
5356
+ text = readFileSync27(transcriptPath, "utf8");
5228
5357
  } catch {
5229
5358
  return void 0;
5230
5359
  }
@@ -5242,10 +5371,10 @@ function commandProblems(root, command) {
5242
5371
  const problems = [];
5243
5372
  const spec = newestSpec(root);
5244
5373
  if (command === "decide") {
5245
- const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) => join32(root, p)).find((p) => existsSync19(p));
5374
+ const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) => join33(root, p)).find((p) => existsSync20(p));
5246
5375
  if (!file) problems.push("decide wrote no DECISIONS.md beside the token layer.");
5247
5376
  else {
5248
- const body = readFileSync26(file, "utf8");
5377
+ const body = readFileSync27(file, "utf8");
5249
5378
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
5250
5379
  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
5380
  }
@@ -5266,13 +5395,13 @@ function commandProblems(root, command) {
5266
5395
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
5267
5396
  if (/^approved/i.test(mockup)) {
5268
5397
  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.`);
5398
+ else if (!/^https?:/i.test(at) && !existsSync20(join33(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
5270
5399
  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
5400
  }
5272
5401
  }
5273
5402
  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"))) : [];
5403
+ const dir = join33(root, ".jig", "critique");
5404
+ const surfaces = existsSync20(dir) ? readdirSync13(dir).filter((s) => existsSync20(join33(dir, s, "screen.json")) || existsSync20(join33(dir, s, "code.json"))) : [];
5276
5405
  for (const surface of surfaces) {
5277
5406
  const v = verifyVerdicts({ projectRoot: root, surface });
5278
5407
  if (v.ok && v.screen.state === "skipped") {
@@ -5287,7 +5416,7 @@ function commandProblems(root, command) {
5287
5416
  }
5288
5417
  function gate(opts) {
5289
5418
  const root = opts.projectRoot;
5290
- if (!existsSync19(join32(root, "jig.config.json")) && !existsSync19(join32(root, ".jig"))) {
5419
+ if (!existsSync20(join33(root, "jig.config.json")) && !existsSync20(join33(root, ".jig"))) {
5291
5420
  return { block: false, reason: "" };
5292
5421
  }
5293
5422
  const command = lastJigCommand(opts.input.transcript_path);
@@ -5306,11 +5435,11 @@ function gate(opts) {
5306
5435
  );
5307
5436
  }
5308
5437
  }
5309
- const critiqueDir = join32(root, ".jig", "critique");
5310
- if (existsSync19(critiqueDir)) {
5438
+ const critiqueDir = join33(root, ".jig", "critique");
5439
+ if (existsSync20(critiqueDir)) {
5311
5440
  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;
5441
+ const dir = join33(critiqueDir, surface);
5442
+ if (!existsSync20(join33(dir, "screen.json")) && !existsSync20(join33(dir, "code.json"))) continue;
5314
5443
  const v = verifyVerdicts({ projectRoot: root, surface });
5315
5444
  if (!v.ok) {
5316
5445
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -5323,20 +5452,22 @@ function gate(opts) {
5323
5452
  }
5324
5453
  }
5325
5454
  const session = opts.input.session_id ?? "unknown";
5326
- const stateFile = join32(root, ".jig", "gate.json");
5455
+ const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
5456
+ const stateFile = join33(root, ".jig", "gate.json");
5327
5457
  let state = {};
5328
5458
  try {
5329
- state = JSON.parse(readFileSync26(stateFile, "utf8"));
5459
+ state = JSON.parse(readFileSync27(stateFile, "utf8"));
5330
5460
  } catch {
5331
5461
  }
5332
5462
  if (problems.length === 0) {
5333
- if (state[session]) {
5334
- delete state[session];
5463
+ const mine = Object.keys(state).filter((k) => k.startsWith(`${session}:`));
5464
+ if (mine.length) {
5465
+ for (const k of mine) delete state[k];
5335
5466
  save(stateFile, state);
5336
5467
  }
5337
5468
  return { block: false, reason: "" };
5338
5469
  }
5339
- const count = (state[session] ?? 0) + 1;
5470
+ const count = (state[key] ?? 0) + 1;
5340
5471
  if (count > MAX_BLOCKS) {
5341
5472
  return {
5342
5473
  block: false,
@@ -5344,7 +5475,7 @@ function gate(opts) {
5344
5475
  ${problems.join("\n\n")}`
5345
5476
  };
5346
5477
  }
5347
- state[session] = count;
5478
+ state[key] = count;
5348
5479
  save(stateFile, state);
5349
5480
  return {
5350
5481
  block: true,
@@ -5355,15 +5486,15 @@ ${problems.join("\n\n")}`
5355
5486
  }
5356
5487
  function save(file, state) {
5357
5488
  try {
5358
- mkdirSync7(join32(file, ".."), { recursive: true });
5359
- writeFileSync6(file, JSON.stringify(state), "utf8");
5489
+ mkdirSync8(join33(file, ".."), { recursive: true });
5490
+ writeFileSync7(file, JSON.stringify(state), "utf8");
5360
5491
  } catch {
5361
5492
  }
5362
5493
  }
5363
5494
 
5364
5495
  // src/index.ts
5365
5496
  var packageRoot = getPackageRoot();
5366
- var { version } = JSON.parse(readFileSync27(join33(packageRoot, "package.json"), "utf8"));
5497
+ var { version } = JSON.parse(readFileSync28(join34(packageRoot, "package.json"), "utf8"));
5367
5498
  function warnIfUnpublishedPin() {
5368
5499
  if (isPublishedBuild(packageRoot)) return;
5369
5500
  console.warn(
@@ -5489,13 +5620,33 @@ program.command("check").description("Check the repo against Jig's mechanical +
5489
5620
  process.exit(1);
5490
5621
  }
5491
5622
  });
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);
5623
+ 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) => {
5624
+ if (!opts.save) {
5625
+ console.log(PROBE_SCRIPT);
5626
+ return;
5627
+ }
5628
+ const projectRoot = findProjectRoot(process.cwd());
5629
+ try {
5630
+ const json = process.stdin.isTTY ? "" : await new Promise((done, fail) => {
5631
+ let text = "";
5632
+ process.stdin.setEncoding("utf8");
5633
+ process.stdin.on("data", (chunk) => {
5634
+ text += chunk;
5635
+ });
5636
+ process.stdin.on("end", () => done(text));
5637
+ process.stdin.on("error", fail);
5638
+ });
5639
+ const result = saveProbe({ projectRoot, surface: opts.save, json });
5640
+ console.log(` Recorded ${result.path} \u2014 ${result.page} at ${result.width}px.`);
5641
+ } catch (err) {
5642
+ console.error(` \u2717 ${err.message}`);
5643
+ process.exit(1);
5644
+ }
5494
5645
  });
5495
5646
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(() => {
5496
5647
  let input = {};
5497
5648
  try {
5498
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync27(0, "utf8") || "{}");
5649
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync28(0, "utf8") || "{}");
5499
5650
  } catch {
5500
5651
  }
5501
5652
  const cwd = input.cwd ?? process.cwd();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.10.0",
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.",
3
+ "version": "0.12.0",
4
+ "description": "A design system for coding agents. 114 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",
7
7
  "bin": {
package/rules/05-copy.md CHANGED
@@ -83,6 +83,14 @@ The one legitimate use is a **short label** distinguishing itself from nearby te
83
83
  ✅ Most interface text is too short to need them. Use a full stop only where the text is a complete sentence containing commas.
84
84
  Whichever you choose, be consistent across sibling elements — a list where three items end in a stop and two do not looks like a mistake, because it is one.
85
85
 
86
+ ### I-118 Em dashes in interface text
87
+ ❌ "Your plan — including every seat — renews monthly", "Free — forever", "Deleted — this cannot be undone"
88
+ ✅ A full stop, a comma, a colon or a new element. "Your plan renews monthly, including every seat." "Free forever." "Deleted. This cannot be undone."
89
+ Interface text is read in fragments, at a glance, in a space someone else's content has to fit too. An em dash is a pause the reader has to interpret: it stands in for a comma, a colon, a bracket or a full stop, and which one it is only becomes clear after reading past it. The punctuation that says exactly one thing is faster.
90
+ It is also the clearest tell of machine-written copy. Generated text reaches for the em dash far more often than a person does, and readers have learned to notice. Copy that reads as generated is copy the reader trusts less, whatever it says.
91
+ This is about interface strings — labels, buttons, headings, errors, empty states, help text, and the prose a page ships. It is not a rule about your documentation, your commit messages or this file.
92
+ The en dash keeps its one job: ranges, where it is read as "to" (`2–10 seats`, `Mon–Fri`). That is not a pause, and it is not affected.
93
+
86
94
  ### I-87 Inconsistent vocabulary
87
95
  ❌ "Add to cart" beside a "Bag" icon; "Sign up" on the page and "Register" in the nav
88
96
  ✅ One word per concept, everywhere. Keep a term list in the project and follow it.
package/rules.index.json CHANGED
@@ -793,5 +793,12 @@
793
793
  "severity": "error",
794
794
  "since": "0.10.0",
795
795
  "detector": "undeclared-token"
796
+ },
797
+ {
798
+ "id": "I-118",
799
+ "bucket": "mechanical",
800
+ "severity": "warning",
801
+ "since": "0.12.0",
802
+ "detector": "em-dash"
796
803
  }
797
804
  ]
@@ -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": {