jig-ui 0.15.1 → 0.16.1

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,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.1
4
+
5
+ ### Fixed
6
+
7
+ - **The corpus's own headings follow I-118.** Twelve `R-` titles ("Frame 1:
8
+ Minimise usability risk", the seven tiebreakers) and `L-01`'s six steps used
9
+ an em dash. Headings are interface text wherever they show, in `explain`, in
10
+ `check` output and on a rendered page, so they now use a colon.
11
+ - **A quotation keeps its own punctuation.** I-118 now says text marked as a
12
+ quotation with `<blockquote>` or `<q>` is the source's words, and changing
13
+ it would misquote it; the page's own copy around it is still held to the
14
+ rule. The render probe leaves quoted text out of its dash scan. Without
15
+ this, a page quoting a rule verbatim could never pass its review.
16
+ - **A spec's `indexable:` is `true` or `false`.** A sentence on that line was
17
+ read as nothing, the mode's default was used, and a correct page was
18
+ reported as contradicting J-123. Anything else is now a spec-shape problem,
19
+ and an error in `verdicts` that names the field.
20
+
21
+ ## 0.16.0
22
+
23
+ With more than one mode, every barrel names its mode, and none sits in the
24
+ global stylesheet.
25
+
26
+ ### Changed
27
+
28
+ - **`theme.<mode>.css` for every mode, once there are two.** `init` kept
29
+ `theme.css` for the first mode and left it wired into the global stylesheet,
30
+ so a second mode's routes loaded the first mode's tokens globally and their
31
+ own from their layout, and got whichever came last. Now each barrel names its
32
+ mode, the global stylesheet keeps only what every route shares (Tailwind and
33
+ `utilities.css`), and each route's layout imports its own barrel. A project
34
+ with one mode is unchanged: `theme.css`, wired globally.
35
+ - **A project gaining a second mode is migrated.** An unedited `theme.css`
36
+ becomes `theme.<first mode>.css`, and every stylesheet import that resolves
37
+ to it is removed, found by where it points rather than by guessing the file.
38
+ An edited one is left, with a note on where its edits belong. `check` names a
39
+ `theme.css` left beside two modes.
40
+
3
41
  ## 0.15.1
4
42
 
5
43
  ### Fixed
package/README.md CHANGED
@@ -123,6 +123,11 @@ jig.config.json route → mode map
123
123
  .jig/state.json bookkeeping — version, modes, checksums
124
124
  ```
125
125
 
126
+ Declare a second mode and every barrel names its mode instead —
127
+ `theme.editorial.css`, `theme.operator.css` — and none sits in the global
128
+ stylesheet. Each route's layout imports the barrel for its mode; `init` removes
129
+ the import it added and prints which barrel each surface takes.
130
+
126
131
  Nothing you wrote is touched beyond that one import line. Re-running `init`
127
132
  never overwrites a config or brand file you have edited.
128
133
 
@@ -604,7 +609,7 @@ treatment.
604
609
  | `rules/05-copy.md` | Interface text rules |
605
610
  | `<css dir>/jig/brand.*.css` | Identity. One per project. |
606
611
  | `<css dir>/jig/mode.*.css` | Density, scale, rhythm, motion |
607
- | `<css dir>/jig/theme.css` | The barrel — brand + mode. This is what you import. |
612
+ | `<css dir>/jig/theme.css` | The barrel — brand + mode. This is what you import. With more than one mode, `theme.<mode>.css`, one per mode, each imported by its routes' layout. |
608
613
  | `.jig/state.json` | What `init` wrote, with checksums. `update` reads it to leave your edits alone. |
609
614
  | `.jig/specs/`, `.jig/mockups/`, `.jig/critique/` | The design loop's record: what was agreed, what was drawn, what the review found. |
610
615
 
package/dist/index.js CHANGED
@@ -3911,10 +3911,20 @@ function modeWiringProblems(projectRoot) {
3911
3911
  if (modes.length === 0) return [];
3912
3912
  const dir = dirname6(config.brand);
3913
3913
  const rel = (f) => dir === "." ? f : `${dir}/${f}`;
3914
- if (!existsSync12(join22(projectRoot, rel("theme.css")))) return [];
3914
+ const multi = modes.length > 1;
3915
+ const barrelOf = (mode) => multi ? `theme.${mode}.css` : "theme.css";
3916
+ const anyBarrel = ["theme.css", ...modes.map((m) => `theme.${m}.css`)].some((f) => existsSync12(join22(projectRoot, rel(f))));
3917
+ if (!anyBarrel) return [];
3915
3918
  const problems = [];
3916
- modes.forEach((mode, i) => {
3917
- const barrel = rel(i === 0 ? "theme.css" : `theme.${mode}.css`);
3919
+ if (multi && existsSync12(join22(projectRoot, rel("theme.css")))) {
3920
+ problems.push({
3921
+ mode: modes[0],
3922
+ barrel: rel("theme.css"),
3923
+ message: `jig.config.json declares ${modes.length} modes, so each barrel names its mode: ${rel("theme.css")} should be ${rel(barrelOf(modes[0]))}, imported by the layouts that serve it and not by the global stylesheet`
3924
+ });
3925
+ }
3926
+ modes.forEach((mode) => {
3927
+ const barrel = rel(barrelOf(mode));
3918
3928
  const modeFile = `mode.${mode}.css`;
3919
3929
  let body;
3920
3930
  try {
@@ -5084,7 +5094,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5084
5094
  }
5085
5095
  const modeAbsPath = modeAbsPaths[primaryMode];
5086
5096
  const brandFileOnly = brandRelPath.split("/").pop();
5087
- const barrelFor = (mode) => mode === primaryMode ? "theme.css" : `theme.${mode}.css`;
5097
+ const multiMode = declaredModes.length > 1;
5098
+ const barrelFor = (mode) => multiMode ? `theme.${mode}.css` : "theme.css";
5088
5099
  for (const mode of declaredModes) {
5089
5100
  const rel = relKey(...tokensRelDir, barrelFor(mode));
5090
5101
  const abs = join28(opts.projectRoot, ...tokensRelDir, barrelFor(mode));
@@ -5098,6 +5109,20 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5098
5109
  writeFileSync5(abs, content, "utf8");
5099
5110
  files[rel] = checksum(content);
5100
5111
  }
5112
+ if (multiMode) {
5113
+ const rel = relKey(...tokensRelDir, "theme.css");
5114
+ const abs = join28(opts.projectRoot, ...tokensRelDir, "theme.css");
5115
+ if (existsSync15(abs)) {
5116
+ const state = fileState(opts.projectRoot, abs, rel, initManifest);
5117
+ if (state.tracked && !state.modified) {
5118
+ rmSync2(abs);
5119
+ delete files[rel];
5120
+ log(` Removed ${rel}: with more than one mode, each barrel names its mode (${relKey(...tokensRelDir, barrelFor(primaryMode))}).`);
5121
+ } else {
5122
+ log(` ${rel} has been edited, so it is left alone. With more than one mode it is replaced by ${relKey(...tokensRelDir, barrelFor(primaryMode))}; move your edits there and delete it.`);
5123
+ }
5124
+ }
5125
+ }
5101
5126
  if (detection.cssSystem === "tailwind-v4") {
5102
5127
  const declared = declaredTokenNames(
5103
5128
  [brandAbsPath, modeAbsPath].map((f) => {
@@ -5148,8 +5173,9 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5148
5173
  }
5149
5174
  }
5150
5175
  }
5151
- if (declaredModes.length > 1) {
5152
- log("\nOne barrel per surface. Import each at that route's entry point:");
5176
+ if (multiMode) {
5177
+ log("\nOne barrel per mode, and none in the global stylesheet. Import each in the layout");
5178
+ log("that serves its routes, and keep Tailwind and utilities.css in the global one:");
5153
5179
  for (const surface of effectiveConfig.surfaces) {
5154
5180
  log(` '${surface.match}' \u2192 ${relKey(...tokensRelDir, barrelFor(surface.mode))}`);
5155
5181
  }
@@ -5174,7 +5200,33 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5174
5200
  }
5175
5201
  const wireTarget = findWireTarget(detection);
5176
5202
  let wiring;
5177
- if (wireTarget) {
5203
+ if (multiMode) {
5204
+ const bare = join28(opts.projectRoot, ...tokensRelDir, "theme.css");
5205
+ for (const file of detection.cssFiles.filter((f) => !isTokenLayerFile(f))) {
5206
+ const abs = join28(opts.projectRoot, file);
5207
+ try {
5208
+ const before = readFileSync23(abs, "utf8");
5209
+ const after = before.split("\n").filter((l) => {
5210
+ const m = /^\s*@import\s+["']([^"']+)["'];?\s*$/.exec(l);
5211
+ return !(m && !/^[a-z]+:|^\//i.test(m[1]) && resolve2(dirname7(abs), m[1]) === bare);
5212
+ }).join("\n");
5213
+ if (after !== before) {
5214
+ writeFileSync5(abs, after, "utf8");
5215
+ log(`
5216
+ Unwired ${file}: removed its import of ${relKey(...tokensRelDir, "theme.css")}.`);
5217
+ log(` With more than one mode, the global stylesheet imports no barrel: every route would carry that mode's tokens.`);
5218
+ }
5219
+ } catch (err) {
5220
+ log(`
5221
+ Could not edit ${file}: ${err.message}. Remove its import of ${relKey(...tokensRelDir, "theme.css")} by hand.`);
5222
+ }
5223
+ }
5224
+ wiring = {
5225
+ target: null,
5226
+ status: "per-route",
5227
+ snippet: effectiveConfig.surfaces.map((s) => `${s.match} \u2192 ${relKey(...tokensRelDir, barrelFor(s.mode))}`).join("\n")
5228
+ };
5229
+ } else if (wireTarget) {
5178
5230
  const targetAbsDir = dirname7(join28(opts.projectRoot, wireTarget));
5179
5231
  const barrelImport = relativeImportPath(
5180
5232
  targetAbsDir,
@@ -5262,8 +5314,8 @@ Could not write to ${wireTarget}: ${err.message}`);
5262
5314
  }
5263
5315
 
5264
5316
  // src/commands/verdicts.ts
5265
- import { existsSync as existsSync20, readFileSync as readFileSync27 } from "fs";
5266
- import { join as join34, resolve as resolve4 } from "path";
5317
+ import { existsSync as existsSync21, readFileSync as readFileSync28 } from "fs";
5318
+ import { join as join35, resolve as resolve4 } from "path";
5267
5319
 
5268
5320
  // src/rules/citations.ts
5269
5321
  import { join as join29 } from "path";
@@ -5296,6 +5348,14 @@ var PROBE_SCRIPT = `(async () => {
5296
5348
  const defaultFont = getComputedStyle(probe).fontFamily;
5297
5349
  probe.remove();
5298
5350
  const text = document.body.innerText || '';
5351
+ // I-118: a verbatim quotation keeps its own punctuation. Text marked as a
5352
+ // quotation is the source's words, so it is taken out before the dash scan;
5353
+ // the page's own copy around it still counts.
5354
+ let ownText = text;
5355
+ for (const q of document.querySelectorAll('blockquote, q')) {
5356
+ const quoted = (q.innerText || '').trim();
5357
+ if (quoted) ownText = ownText.split(quoted).join('\\n');
5358
+ }
5299
5359
  const junk = [...new Set(text.match(/\\$\\{|\\{\\{|\\bundefined\\b|\\bNaN\\b|\\[object Object\\]/g) || [])];
5300
5360
  const unresolved = new Set();
5301
5361
  for (const sheet of document.styleSheets) {
@@ -5406,7 +5466,7 @@ var PROBE_SCRIPT = `(async () => {
5406
5466
  defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
5407
5467
  unresolvedTokens: [...unresolved],
5408
5468
  junkText: junk,
5409
- emDashes: [...new Set((text.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
5469
+ emDashes: [...new Set((ownText.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
5410
5470
  brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
5411
5471
  navLinksVisible: navAtRest,
5412
5472
  head: {
@@ -5825,35 +5885,120 @@ function decisionNames(projectRoot) {
5825
5885
  return names;
5826
5886
  }
5827
5887
 
5888
+ // src/check/spec-shape.ts
5889
+ import { existsSync as existsSync20, readFileSync as readFileSync27, readdirSync as readdirSync14, statSync as statSync2 } from "fs";
5890
+ import { join as join34 } from "path";
5891
+ function newestSpec(projectRoot) {
5892
+ const dir = join34(projectRoot, ".jig", "specs");
5893
+ if (!existsSync20(dir)) return void 0;
5894
+ const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
5895
+ if (files.length === 0) return void 0;
5896
+ const newest = files.map((f) => ({ f, at: statSync2(join34(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
5897
+ return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync27(join34(dir, newest), "utf8") };
5898
+ }
5899
+ function specProblems(spec) {
5900
+ const parts = spec.body.split(/^---\s*$/m);
5901
+ const front = parts.length >= 3 ? parts[1] : "";
5902
+ if (!front.trim()) {
5903
+ return [`${spec.path} has no frontmatter. A spec is the frontmatter \u2014 feature, surface, mode, sizes (phone, tablet, desktop), states, decisions, later, mockup, confirmed \u2014 with the reasoning below it. Rewrite it in that shape.`];
5904
+ }
5905
+ const problems = [];
5906
+ const has = (field) => new RegExp(`^\\s*${field}\\s*:`, "im").test(front);
5907
+ if (specIndexableField(front) === "unreadable") {
5908
+ problems.push(`${spec.path}: \`indexable:\` is neither true nor false. Write \`indexable: true\` or \`indexable: false\`; a per-page override and its reason go in the body, where they can be read without being parsed.`);
5909
+ }
5910
+ for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
5911
+ if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
5912
+ }
5913
+ const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
5914
+ for (const size of ["phone", "tablet", "desktop", "wide"]) {
5915
+ const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
5916
+ if (!line) {
5917
+ problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
5918
+ continue;
5919
+ }
5920
+ if (line[1].trim()) {
5921
+ problems.push(`${spec.path}: \`${size}:\` is a one-line description. A size is a composition \u2014 \`regions:\` in order, \`hierarchy:\`, \`nav:\` \u2014 each on its own line, or \`same-as:\` with a \`why:\`.`);
5922
+ continue;
5923
+ }
5924
+ const block = sizeBlock(sizes, size);
5925
+ if (/^\s*same-as\s*:/im.test(block)) {
5926
+ if (!/^\s*why\s*:/im.test(block)) problems.push(`${spec.path}: \`${size}\` claims \`same-as:\` with no \`why:\`. The claim is checked on a render, so it says why it holds.`);
5927
+ continue;
5928
+ }
5929
+ for (const field of ["regions", "nav"]) {
5930
+ if (!new RegExp(`^\\s*${field}\\s*:`, "im").test(block)) {
5931
+ problems.push(`${spec.path}: \`${size}\` has no \`${field}:\`. Every size names its regions in order and what its navigation is at that width (\`nav: none\` when the screen has none).`);
5932
+ }
5933
+ }
5934
+ }
5935
+ return problems;
5936
+ }
5937
+ function sizeBlock(sizes, size) {
5938
+ const start = new RegExp(`^\\s+${size}\\s*:.*$`, "im").exec(sizes);
5939
+ if (!start) return "";
5940
+ const rest = sizes.slice(start.index + start[0].length);
5941
+ const indent = /^\s*/.exec(start[0])[0].length;
5942
+ const lines = [];
5943
+ for (const line of rest.split("\n")) {
5944
+ if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
5945
+ lines.push(line);
5946
+ }
5947
+ return lines.join("\n");
5948
+ }
5949
+ var MENU_RE = /(?<!\b(?:no|not|without|never)\s)(?<!\bno\s\w{1,12}\s)\b(menu|hamburger|drawer|burger)\b/i;
5950
+ var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
5951
+ function navProblems(spec) {
5952
+ const front = spec.body.split(/^---\s*$/m)[1] ?? "";
5953
+ const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
5954
+ const problems = [];
5955
+ for (const size of ["tablet", "landscape", "desktop", "wide"]) {
5956
+ const block = sizeBlock(sizes, size);
5957
+ const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
5958
+ if (!nav || NONE_RE.test(nav)) continue;
5959
+ if (MENU_RE.test(nav) && !/\bopen\b|\bexpanded\b/i.test(nav)) {
5960
+ problems.push(`${spec.path}: \`${size}\` has \`nav: ${nav}\`. Run P-14's table at that width \u2014 where every destination fits, the links show and there is no menu button. A decision about where the button sits is about position, not whether it exists.`);
5961
+ }
5962
+ }
5963
+ return problems;
5964
+ }
5965
+ function specIndexableField(front) {
5966
+ const line = /^\s*indexable\s*:(.*)$/im.exec(front);
5967
+ if (!line) return "absent";
5968
+ const value = line[1].trim().replace(/^["']|["']$/g, "").toLowerCase();
5969
+ if (value === "true" || value === "yes") return true;
5970
+ if (value === "false" || value === "no") return false;
5971
+ return "unreadable";
5972
+ }
5973
+
5828
5974
  // src/commands/verdicts.ts
5829
5975
  var VERDICTS = ["ok", "finding", "n/a"];
5830
5976
  var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
5831
5977
  function readJson(path, errors) {
5832
- if (!existsSync20(path)) return null;
5978
+ if (!existsSync21(path)) return null;
5833
5979
  try {
5834
- return JSON.parse(readFileSync27(path, "utf8"));
5980
+ return JSON.parse(readFileSync28(path, "utf8"));
5835
5981
  } catch (e) {
5836
5982
  errors.push(`${path}: not valid JSON (${e.message})`);
5837
5983
  return {};
5838
5984
  }
5839
5985
  }
5840
5986
  function specIndexable(projectRoot, surface) {
5841
- const path = join34(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5987
+ const path = join35(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5842
5988
  let front = "";
5843
5989
  try {
5844
- front = readFileSync27(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5990
+ front = readFileSync28(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5845
5991
  } catch {
5846
5992
  }
5847
- const declared = /^\s*indexable\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase();
5848
- if (declared === "true" || declared === "yes") return true;
5849
- if (declared === "false" || declared === "no") return false;
5993
+ const declared = specIndexableField(front);
5994
+ if (declared === true || declared === false) return declared;
5850
5995
  const mode = /^\s*mode\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase();
5851
5996
  return mode !== "product" && mode !== "operator";
5852
5997
  }
5853
5998
  function specNeedsNav(projectRoot, surface) {
5854
- const path = join34(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5855
- if (!existsSync20(path)) return false;
5856
- const front = readFileSync27(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5999
+ const path = join35(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6000
+ if (!existsSync21(path)) return false;
6001
+ const front = readFileSync28(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5857
6002
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
5858
6003
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
5859
6004
  return navField || navRegion;
@@ -5904,7 +6049,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
5904
6049
  function checkDecisions(projectRoot, dir, errors) {
5905
6050
  const required = decisionNames(projectRoot);
5906
6051
  if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
5907
- const file = readJson(join34(dir, "decisions.json"), errors);
6052
+ const file = readJson(join35(dir, "decisions.json"), errors);
5908
6053
  if (!file) {
5909
6054
  errors.push(
5910
6055
  `decisions.json is missing. Every decision in DECISIONS.md is judged against the built page, one verdict each: ${required.slice(0, 4).join(", ")}${required.length > 4 ? `, and ${required.length - 4} more` : ""}. A page can satisfy every rule and still break what this project decided.`
@@ -5949,7 +6094,7 @@ function checkDecisions(projectRoot, dir, errors) {
5949
6094
  function verifyVerdicts(opts) {
5950
6095
  const root = opts.packageRoot ?? assetRoot();
5951
6096
  const errors = [];
5952
- const index = JSON.parse(readFileSync27(join34(root, "rules.index.json"), "utf8"));
6097
+ const index = JSON.parse(readFileSync28(join35(root, "rules.index.json"), "utf8"));
5953
6098
  const judgment = index.filter((r) => r.bucket === "judgment");
5954
6099
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
5955
6100
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
@@ -5958,16 +6103,16 @@ function verifyVerdicts(opts) {
5958
6103
  ...judgment.map((r) => [r.id, r.pass ?? ""])
5959
6104
  ]);
5960
6105
  const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
5961
- const dir = join34(opts.projectRoot, ".jig", "critique", opts.surface);
5962
- const screenFile = readJson(join34(dir, "screen.json"), errors);
5963
- const codeFile = readJson(join34(dir, "code.json"), errors);
6106
+ const dir = join35(opts.projectRoot, ".jig", "critique", opts.surface);
6107
+ const screenFile = readJson(join35(dir, "screen.json"), errors);
6108
+ const codeFile = readJson(join35(dir, "code.json"), errors);
5964
6109
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
5965
6110
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
5966
6111
  const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
5967
6112
  let rendered = false;
5968
6113
  if (screenFile && screenFile.rendered === true) {
5969
6114
  const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
5970
- const absent = artefacts.filter((a) => !existsSync20(resolve4(opts.projectRoot, a)));
6115
+ const absent = artefacts.filter((a) => !existsSync21(resolve4(opts.projectRoot, a)));
5971
6116
  if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
5972
6117
  else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
5973
6118
  else rendered = true;
@@ -5986,7 +6131,16 @@ function verifyVerdicts(opts) {
5986
6131
  const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
5987
6132
  return typeof v?.verdict === "string" ? v.verdict : void 0;
5988
6133
  };
5989
- errors.push(...probeContradictions(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface)));
6134
+ let specFront = "";
6135
+ try {
6136
+ specFront = readFileSync28(join35(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
6137
+ } catch {
6138
+ }
6139
+ if (specIndexableField(specFront) === "unreadable") {
6140
+ errors.push(`.jig/specs/${opts.surface}.spec.md: \`indexable:\` is neither true nor false, so this review cannot tell whether the page is meant to be found and does not guess. Write \`indexable: true\` or \`indexable: false\`, and put the reason in the spec's body.`);
6141
+ } else {
6142
+ errors.push(...probeContradictions(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface)));
6143
+ }
5990
6144
  if (screen.state === "ran" && !rendered) screen.state = "skipped";
5991
6145
  const decisions = checkDecisions(opts.projectRoot, dir, errors);
5992
6146
  const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
@@ -5998,83 +6152,6 @@ function verifyVerdicts(opts) {
5998
6152
  import { existsSync as existsSync22, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync29, writeFileSync as writeFileSync7 } from "fs";
5999
6153
  import { createHash as createHash2 } from "crypto";
6000
6154
  import { join as join36 } from "path";
6001
-
6002
- // src/check/spec-shape.ts
6003
- import { existsSync as existsSync21, readFileSync as readFileSync28, readdirSync as readdirSync14, statSync as statSync2 } from "fs";
6004
- import { join as join35 } from "path";
6005
- function newestSpec(projectRoot) {
6006
- const dir = join35(projectRoot, ".jig", "specs");
6007
- if (!existsSync21(dir)) return void 0;
6008
- const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
6009
- if (files.length === 0) return void 0;
6010
- const newest = files.map((f) => ({ f, at: statSync2(join35(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6011
- return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync28(join35(dir, newest), "utf8") };
6012
- }
6013
- function specProblems(spec) {
6014
- const parts = spec.body.split(/^---\s*$/m);
6015
- const front = parts.length >= 3 ? parts[1] : "";
6016
- if (!front.trim()) {
6017
- return [`${spec.path} has no frontmatter. A spec is the frontmatter \u2014 feature, surface, mode, sizes (phone, tablet, desktop), states, decisions, later, mockup, confirmed \u2014 with the reasoning below it. Rewrite it in that shape.`];
6018
- }
6019
- const problems = [];
6020
- const has = (field) => new RegExp(`^\\s*${field}\\s*:`, "im").test(front);
6021
- for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
6022
- if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
6023
- }
6024
- const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
6025
- for (const size of ["phone", "tablet", "desktop", "wide"]) {
6026
- const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
6027
- if (!line) {
6028
- problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
6029
- continue;
6030
- }
6031
- if (line[1].trim()) {
6032
- problems.push(`${spec.path}: \`${size}:\` is a one-line description. A size is a composition \u2014 \`regions:\` in order, \`hierarchy:\`, \`nav:\` \u2014 each on its own line, or \`same-as:\` with a \`why:\`.`);
6033
- continue;
6034
- }
6035
- const block = sizeBlock(sizes, size);
6036
- if (/^\s*same-as\s*:/im.test(block)) {
6037
- if (!/^\s*why\s*:/im.test(block)) problems.push(`${spec.path}: \`${size}\` claims \`same-as:\` with no \`why:\`. The claim is checked on a render, so it says why it holds.`);
6038
- continue;
6039
- }
6040
- for (const field of ["regions", "nav"]) {
6041
- if (!new RegExp(`^\\s*${field}\\s*:`, "im").test(block)) {
6042
- problems.push(`${spec.path}: \`${size}\` has no \`${field}:\`. Every size names its regions in order and what its navigation is at that width (\`nav: none\` when the screen has none).`);
6043
- }
6044
- }
6045
- }
6046
- return problems;
6047
- }
6048
- function sizeBlock(sizes, size) {
6049
- const start = new RegExp(`^\\s+${size}\\s*:.*$`, "im").exec(sizes);
6050
- if (!start) return "";
6051
- const rest = sizes.slice(start.index + start[0].length);
6052
- const indent = /^\s*/.exec(start[0])[0].length;
6053
- const lines = [];
6054
- for (const line of rest.split("\n")) {
6055
- if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
6056
- lines.push(line);
6057
- }
6058
- return lines.join("\n");
6059
- }
6060
- var MENU_RE = /(?<!\b(?:no|not|without|never)\s)(?<!\bno\s\w{1,12}\s)\b(menu|hamburger|drawer|burger)\b/i;
6061
- var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
6062
- function navProblems(spec) {
6063
- const front = spec.body.split(/^---\s*$/m)[1] ?? "";
6064
- const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
6065
- const problems = [];
6066
- for (const size of ["tablet", "landscape", "desktop", "wide"]) {
6067
- const block = sizeBlock(sizes, size);
6068
- const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
6069
- if (!nav || NONE_RE.test(nav)) continue;
6070
- if (MENU_RE.test(nav) && !/\bopen\b|\bexpanded\b/i.test(nav)) {
6071
- problems.push(`${spec.path}: \`${size}\` has \`nav: ${nav}\`. Run P-14's table at that width \u2014 where every destination fits, the links show and there is no menu button. A decision about where the button sits is about position, not whether it exists.`);
6072
- }
6073
- }
6074
- return problems;
6075
- }
6076
-
6077
- // src/commands/gate.ts
6078
6155
  var MAX_BLOCKS = 3;
6079
6156
  function lastJigCommand(transcriptPath) {
6080
6157
  if (!transcriptPath || !existsSync22(transcriptPath)) return void 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "A design system for coding agents. 130 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",
@@ -25,9 +25,11 @@ lives in `src/styles/`, `app/assets/stylesheets/jig/` in a Rails app. Set `brand
25
25
  in `jig.config.json` to put it elsewhere. Projects set up before 0.7.0 keep their
26
26
  `.jig/tokens/` layout; `update` does not move them.
27
27
 
28
- So **never hardcode that path**. `init` writes one barrel per surface, `theme.css`,
29
- which imports the brand and the mode in the right order — import the barrel, and
30
- relocating the layer changes one line instead of every stylesheet:
28
+ So **never hardcode that path**. `init` writes a barrel, `theme.css`, which imports
29
+ the brand and the mode in the right order — import the barrel, and relocating the
30
+ layer changes one line instead of every stylesheet. With more than one mode, every
31
+ barrel names its mode instead (`theme.editorial.css`, `theme.operator.css`); see
32
+ "Multiple modes in one app" below:
31
33
 
32
34
  ```css
33
35
  /* src/styles/jig/theme.css — written by init */
@@ -512,8 +514,14 @@ the same token names with different values, so importing all three into one
512
514
  document leaves only the last — the other two are inert. That is the mechanical
513
515
  reason behind the seam rule below.
514
516
 
515
- **Multiple modes in one app** — scope by route, not by class. Each surface imports its own
516
- barrel — `jig/theme.css` for the primary surface, `jig/theme.<mode>.css` for the
517
- others — at that route's layout or entry level. `init` names them and does not
518
- wire them: which entry point serves `/admin/**` is your routing, which it cannot
519
- see. Do not attempt to nest two modes in one document (`01-modes.md`, seam rules).
517
+ **Multiple modes in one app** — scope by route, not by class. Every barrel names its
518
+ mode, `jig/theme.<mode>.css`, and each route's layout or entry point imports the
519
+ one for its mode. The global stylesheet imports **no** barrel: one imported there
520
+ puts that mode's tokens under every route, so the operator pages carry editorial's
521
+ too and get whichever loaded last. It keeps what every route shares — Tailwind and
522
+ `utilities.css`, whose aliases name tokens rather than values and so hold for any
523
+ mode. When a second mode is declared, `init` replaces `theme.css` with
524
+ `theme.<first mode>.css` and removes the import it had wired, then prints which
525
+ barrel each surface imports; which layout serves `/admin/**` is your routing, which
526
+ it cannot see. Do not attempt to nest two modes in one document (`01-modes.md`,
527
+ seam rules).
@@ -479,7 +479,7 @@ Compose it for the phone first. Mobile navigation is a different control — not
479
479
 
480
480
  Not a component. The procedure for structuring any screen, before styling anything.
481
481
 
482
- ### Step 1 — Group
482
+ ### Step 1: Group
483
483
 
484
484
  Four tools, weakest to strongest. Use the weakest that works (`A-67`):
485
485
 
@@ -492,7 +492,7 @@ Four tools, weakest to strongest. Use the weakest that works (`A-67`):
492
492
 
493
493
  Combine them and the container usually becomes unnecessary — a table's rows are already aligned, alike and close. Break continuity deliberately to mark the end of a group, or to interrupt a list with something that is not part of it.
494
494
 
495
- ### Step 2 — Order by importance
495
+ ### Step 2: Order by importance
496
496
 
497
497
  Six variables carry hierarchy: **size**, **colour**, **contrast**, **spacing**, **position**, **depth**. The procedure:
498
498
 
@@ -504,11 +504,11 @@ Position does more than it looks: people best recall the **first and last** item
504
504
 
505
505
  Give elements *similar* prominence where they should be read as a pair — matching a label's weight to its icon's balances them instead of letting one shout.
506
506
 
507
- ### Step 3 — Space from the inside out
507
+ ### Step 3: Space from the inside out
508
508
 
509
509
  Start at XS on the innermost rectangle and step up moving outward (`D-69`). Between two options, take the larger.
510
510
 
511
- ### Step 4 — Align to a grid
511
+ ### Step 4: Align to a grid
512
512
 
513
513
  Main containers align to a 12-column grid; small elements *inside* them do not — those use the spacing options.
514
514
 
@@ -516,7 +516,7 @@ Main containers align to a 12-column grid; small elements *inside* them do not
516
516
  - **Gutters** fixed, narrower than columns, and kept empty. `--grid-gutter`.
517
517
  - **Margins** keep content off the screen edge, wider on large screens. `--grid-margin`.
518
518
 
519
- ### Step 5 — The squint test
519
+ ### Step 5: The squint test
520
520
 
521
521
  Blur the design, zoom out, or step back. You should still be able to tell what the screen is for and which element matters most. If everything reads at one weight the hierarchy has failed; if elements smear together the white space is too tight.
522
522
 
@@ -524,7 +524,7 @@ Blur the design, zoom out, or step back. You should still be able to tell what t
524
524
 
525
525
  **When nothing can render it, use the analogue:** if all type were one size and one colour, would the layout still communicate its order? If the hierarchy depends entirely on type styling, it is too weak. The analogue is a fallback, not an equal — a reading of the source is not a look at the page.
526
526
 
527
- ### Step 6 — Decide how it collapses
527
+ ### Step 6: Decide how it collapses
528
528
 
529
529
  A spec writes a composition per size. This step is what happens **between** them:
530
530
  the same content, arranged for less room. Six rules, and the first is the one
@@ -15,7 +15,7 @@ If you reach for Part 2 often, the rules in `00`–`03` are underspecified and t
15
15
 
16
16
  # Part 1 · Frames
17
17
 
18
- ## R-01 · Frame 1 — Minimise usability risk
18
+ ## R-01 · Frame 1: Minimise usability risk
19
19
 
20
20
  **Ask: who could struggle with this, and why?**
21
21
 
@@ -35,7 +35,7 @@ The risk is rarely to the median user. It falls on people with reduced vision, l
35
35
 
36
36
  **Floor:** WCAG 2.1 level AA. Meeting AA is the starting point, not the achievement.
37
37
 
38
- ## R-02 · Frame 2 — Every detail has a reason you can state
38
+ ## R-02 · Frame 2: Every detail has a reason you can state
39
39
 
40
40
  **Ask: why this way rather than another way?**
41
41
 
@@ -45,7 +45,7 @@ This is the test every rule in this system had to pass, and it is why the token
45
45
 
46
46
  **Use it like this:** when you make a call the rules do not cover, state the reason in one line. If you cannot, you are guessing — and a guess should be surfaced as a question, not shipped as a decision (Tiebreaker 5).
47
47
 
48
- ## R-03 · Frame 3 — Minimise interaction cost
48
+ ## R-03 · Frame 3: Minimise interaction cost
49
49
 
50
50
  **Ask: what does this cost the user, counted?**
51
51
 
@@ -59,7 +59,7 @@ Three reliable reductions:
59
59
 
60
60
  **Use it like this:** count before and after, and state it. "3 clicks + 1 scroll → 2 clicks" is reviewable. "Improved the UX" is not. See `P-10`.
61
61
 
62
- ## R-04 · Frame 4 — Minimise cognitive load
62
+ ## R-04 · Frame 4: Minimise cognitive load
63
63
 
64
64
  **Ask: how much thinking does this require that is not the user's actual task?**
65
65
 
@@ -73,7 +73,7 @@ Attention spent decoding the interface is unavailable for the work. Reliable red
73
73
 
74
74
  **Use it like this:** when something feels heavy but no rule is broken, the load is usually ungrouped information or an unnecessary decision. Split it or remove it. A long form becomes steps; a wide table becomes fewer default columns; six equal options become two recommended and four behind "more".
75
75
 
76
- ## R-05 · Frame 5 — Optimise for the common path
76
+ ## R-05 · Frame 5: Optimise for the common path
77
77
 
78
78
  **Ask: what are most people here to do?**
79
79
 
@@ -91,31 +91,31 @@ Effort should follow it. Make the common task excellent before making the rare o
91
91
 
92
92
  Seven. Each resolves a specific conflict in a specific direction. A principle that does not tell you what to give up is decoration.
93
93
 
94
- ## R-06 · Tiebreaker 1 — Prefer the loud failure
94
+ ## R-06 · Tiebreaker 1: Prefer the loud failure
95
95
 
96
96
  **Between silent failure and visible failure, choose visible.**
97
97
 
98
98
  A form that discards a submission and shows success is worse than one that errors. A page serving stale data without saying so is worse than a slow one. Silent failure is the most expensive class of defect, because the cost is paid by someone who never finds out.
99
99
 
100
- ## R-07 · Tiebreaker 2 — Never destroy on suspicion
100
+ ## R-07 · Tiebreaker 2: Never destroy on suspicion
101
101
 
102
102
  **When the system suspects input is wrong, mark it and hold it. Do not discard it.**
103
103
 
104
104
  Spam scores, validation failures, duplicate detection — all heuristics, all wrong sometimes. Hold the item, record why, let a person decide. Applies equally to the user's typing: never clear a form, drop a draft, or overwrite without a copy.
105
105
 
106
- ## R-08 · Tiebreaker 3 — Recoverable beats correct
106
+ ## R-08 · Tiebreaker 3: Recoverable beats correct
107
107
 
108
108
  **Between preventing a mistake and allowing it to be undone, choose undo.**
109
109
 
110
110
  Prevention charges every user friction on every interaction to guard against a rare error. Recovery costs nothing until the error happens. Exception: genuinely irreversible operations, which confirm — and in `operator`, confirm by typing.
111
111
 
112
- ## R-09 · Tiebreaker 4 — Optimise for who is actually there
112
+ ## R-09 · Tiebreaker 4: Optimise for who is actually there
113
113
 
114
114
  **When density and legibility conflict, decide by the user's real conditions, not by preference.**
115
115
 
116
116
  A first-time visitor on mobile data in bright sun and an operator at a large display for eight hours need opposite things. Mode encodes this. When the mode is genuinely unclear, ask — do not average, because the average serves neither.
117
117
 
118
- ## R-10 · Tiebreaker 5 — Restraint is the default
118
+ ## R-10 · Tiebreaker 5: Restraint is the default
119
119
 
120
120
  **When a decision has not been made, ship the plainer thing and surface the question.**
121
121
 
@@ -125,13 +125,13 @@ An invented accent, a decorative animation, a gradient filling an empty space
125
125
 
126
126
  **Ceiling: restraint applies to decoration, never to information.** Minimal is not the same as simple. A sparse interface that has dropped labels, selected states or visible actions is harder to use than a busier one that keeps them — it just photographs better. Strip styling freely; never strip the answers to *what is this*, *which one is selected*, and *what can I do next* (`E-63`).
127
127
 
128
- ## R-11 · Tiebreaker 6 — The platform before the framework
128
+ ## R-11 · Tiebreaker 6: The platform before the framework
129
129
 
130
130
  **When the browser can already do it, use the browser.**
131
131
 
132
132
  `<dialog>`, `<details>`, `position: sticky`, `:has()`, container queries, native form validation, `popover`. Platform features carry accessibility, keyboard handling and state management that a reimplementation gets wrong and then needs maintaining.
133
133
 
134
- ## R-12 · Tiebreaker 7 — Match the codebase before matching this document
134
+ ## R-12 · Tiebreaker 7: Match the codebase before matching this document
135
135
 
136
136
  **When local convention conflicts with these rules, local convention wins.**
137
137
 
package/rules/05-copy.md CHANGED
@@ -89,6 +89,7 @@ Whichever you choose, be consistent across sibling elements — a list where thr
89
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
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
91
  This is about interface strings: labels, buttons, headings, errors, empty states, help text, and the prose a page ships. Markdown counts where a framework renders it as a page, which is most of them (`src/content`, `content/`, MDX routes). A repository document does not: `README.md`, `CHANGELOG.md`, `AGENTS.md` and their kin are written for whoever works on the code, and so are your commit messages and this file.
92
+ A verbatim quotation keeps its own punctuation. Text a page quotes from another source (a document, a spec, a person), marked as a quotation with `<blockquote>` or `<q>`, is that source's words, and changing them to satisfy this rule would misquote it. The page's own copy around the quotation, and any heading or label it gives it, is still held to the rule.
92
93
  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
94
 
94
95
  ### I-87 Inconsistent vocabulary