jig-ui 0.16.0 → 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,23 @@
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
+
3
21
  ## 0.16.0
4
22
 
5
23
  With more than one mode, every barrel names its mode, and none sits in the
package/dist/index.js CHANGED
@@ -5314,8 +5314,8 @@ Could not write to ${wireTarget}: ${err.message}`);
5314
5314
  }
5315
5315
 
5316
5316
  // src/commands/verdicts.ts
5317
- import { existsSync as existsSync20, readFileSync as readFileSync27 } from "fs";
5318
- 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";
5319
5319
 
5320
5320
  // src/rules/citations.ts
5321
5321
  import { join as join29 } from "path";
@@ -5348,6 +5348,14 @@ var PROBE_SCRIPT = `(async () => {
5348
5348
  const defaultFont = getComputedStyle(probe).fontFamily;
5349
5349
  probe.remove();
5350
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
+ }
5351
5359
  const junk = [...new Set(text.match(/\\$\\{|\\{\\{|\\bundefined\\b|\\bNaN\\b|\\[object Object\\]/g) || [])];
5352
5360
  const unresolved = new Set();
5353
5361
  for (const sheet of document.styleSheets) {
@@ -5458,7 +5466,7 @@ var PROBE_SCRIPT = `(async () => {
5458
5466
  defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
5459
5467
  unresolvedTokens: [...unresolved],
5460
5468
  junkText: junk,
5461
- 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),
5462
5470
  brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
5463
5471
  navLinksVisible: navAtRest,
5464
5472
  head: {
@@ -5877,35 +5885,120 @@ function decisionNames(projectRoot) {
5877
5885
  return names;
5878
5886
  }
5879
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
+
5880
5974
  // src/commands/verdicts.ts
5881
5975
  var VERDICTS = ["ok", "finding", "n/a"];
5882
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;
5883
5977
  function readJson(path, errors) {
5884
- if (!existsSync20(path)) return null;
5978
+ if (!existsSync21(path)) return null;
5885
5979
  try {
5886
- return JSON.parse(readFileSync27(path, "utf8"));
5980
+ return JSON.parse(readFileSync28(path, "utf8"));
5887
5981
  } catch (e) {
5888
5982
  errors.push(`${path}: not valid JSON (${e.message})`);
5889
5983
  return {};
5890
5984
  }
5891
5985
  }
5892
5986
  function specIndexable(projectRoot, surface) {
5893
- const path = join34(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5987
+ const path = join35(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5894
5988
  let front = "";
5895
5989
  try {
5896
- front = readFileSync27(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5990
+ front = readFileSync28(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5897
5991
  } catch {
5898
5992
  }
5899
- const declared = /^\s*indexable\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase();
5900
- if (declared === "true" || declared === "yes") return true;
5901
- if (declared === "false" || declared === "no") return false;
5993
+ const declared = specIndexableField(front);
5994
+ if (declared === true || declared === false) return declared;
5902
5995
  const mode = /^\s*mode\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase();
5903
5996
  return mode !== "product" && mode !== "operator";
5904
5997
  }
5905
5998
  function specNeedsNav(projectRoot, surface) {
5906
- const path = join34(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5907
- if (!existsSync20(path)) return false;
5908
- 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] ?? "";
5909
6002
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
5910
6003
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
5911
6004
  return navField || navRegion;
@@ -5956,7 +6049,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
5956
6049
  function checkDecisions(projectRoot, dir, errors) {
5957
6050
  const required = decisionNames(projectRoot);
5958
6051
  if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
5959
- const file = readJson(join34(dir, "decisions.json"), errors);
6052
+ const file = readJson(join35(dir, "decisions.json"), errors);
5960
6053
  if (!file) {
5961
6054
  errors.push(
5962
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.`
@@ -6001,7 +6094,7 @@ function checkDecisions(projectRoot, dir, errors) {
6001
6094
  function verifyVerdicts(opts) {
6002
6095
  const root = opts.packageRoot ?? assetRoot();
6003
6096
  const errors = [];
6004
- const index = JSON.parse(readFileSync27(join34(root, "rules.index.json"), "utf8"));
6097
+ const index = JSON.parse(readFileSync28(join35(root, "rules.index.json"), "utf8"));
6005
6098
  const judgment = index.filter((r) => r.bucket === "judgment");
6006
6099
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
6007
6100
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
@@ -6010,16 +6103,16 @@ function verifyVerdicts(opts) {
6010
6103
  ...judgment.map((r) => [r.id, r.pass ?? ""])
6011
6104
  ]);
6012
6105
  const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
6013
- const dir = join34(opts.projectRoot, ".jig", "critique", opts.surface);
6014
- const screenFile = readJson(join34(dir, "screen.json"), errors);
6015
- 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);
6016
6109
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
6017
6110
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
6018
6111
  const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
6019
6112
  let rendered = false;
6020
6113
  if (screenFile && screenFile.rendered === true) {
6021
6114
  const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
6022
- const absent = artefacts.filter((a) => !existsSync20(resolve4(opts.projectRoot, a)));
6115
+ const absent = artefacts.filter((a) => !existsSync21(resolve4(opts.projectRoot, a)));
6023
6116
  if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
6024
6117
  else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
6025
6118
  else rendered = true;
@@ -6038,7 +6131,16 @@ function verifyVerdicts(opts) {
6038
6131
  const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
6039
6132
  return typeof v?.verdict === "string" ? v.verdict : void 0;
6040
6133
  };
6041
- 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
+ }
6042
6144
  if (screen.state === "ran" && !rendered) screen.state = "skipped";
6043
6145
  const decisions = checkDecisions(opts.projectRoot, dir, errors);
6044
6146
  const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
@@ -6050,83 +6152,6 @@ function verifyVerdicts(opts) {
6050
6152
  import { existsSync as existsSync22, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync29, writeFileSync as writeFileSync7 } from "fs";
6051
6153
  import { createHash as createHash2 } from "crypto";
6052
6154
  import { join as join36 } from "path";
6053
-
6054
- // src/check/spec-shape.ts
6055
- import { existsSync as existsSync21, readFileSync as readFileSync28, readdirSync as readdirSync14, statSync as statSync2 } from "fs";
6056
- import { join as join35 } from "path";
6057
- function newestSpec(projectRoot) {
6058
- const dir = join35(projectRoot, ".jig", "specs");
6059
- if (!existsSync21(dir)) return void 0;
6060
- const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
6061
- if (files.length === 0) return void 0;
6062
- const newest = files.map((f) => ({ f, at: statSync2(join35(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6063
- return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync28(join35(dir, newest), "utf8") };
6064
- }
6065
- function specProblems(spec) {
6066
- const parts = spec.body.split(/^---\s*$/m);
6067
- const front = parts.length >= 3 ? parts[1] : "";
6068
- if (!front.trim()) {
6069
- 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.`];
6070
- }
6071
- const problems = [];
6072
- const has = (field) => new RegExp(`^\\s*${field}\\s*:`, "im").test(front);
6073
- for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
6074
- if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
6075
- }
6076
- const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
6077
- for (const size of ["phone", "tablet", "desktop", "wide"]) {
6078
- const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
6079
- if (!line) {
6080
- problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
6081
- continue;
6082
- }
6083
- if (line[1].trim()) {
6084
- 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:\`.`);
6085
- continue;
6086
- }
6087
- const block = sizeBlock(sizes, size);
6088
- if (/^\s*same-as\s*:/im.test(block)) {
6089
- 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.`);
6090
- continue;
6091
- }
6092
- for (const field of ["regions", "nav"]) {
6093
- if (!new RegExp(`^\\s*${field}\\s*:`, "im").test(block)) {
6094
- 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).`);
6095
- }
6096
- }
6097
- }
6098
- return problems;
6099
- }
6100
- function sizeBlock(sizes, size) {
6101
- const start = new RegExp(`^\\s+${size}\\s*:.*$`, "im").exec(sizes);
6102
- if (!start) return "";
6103
- const rest = sizes.slice(start.index + start[0].length);
6104
- const indent = /^\s*/.exec(start[0])[0].length;
6105
- const lines = [];
6106
- for (const line of rest.split("\n")) {
6107
- if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
6108
- lines.push(line);
6109
- }
6110
- return lines.join("\n");
6111
- }
6112
- var MENU_RE = /(?<!\b(?:no|not|without|never)\s)(?<!\bno\s\w{1,12}\s)\b(menu|hamburger|drawer|burger)\b/i;
6113
- var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
6114
- function navProblems(spec) {
6115
- const front = spec.body.split(/^---\s*$/m)[1] ?? "";
6116
- const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
6117
- const problems = [];
6118
- for (const size of ["tablet", "landscape", "desktop", "wide"]) {
6119
- const block = sizeBlock(sizes, size);
6120
- const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
6121
- if (!nav || NONE_RE.test(nav)) continue;
6122
- if (MENU_RE.test(nav) && !/\bopen\b|\bexpanded\b/i.test(nav)) {
6123
- 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.`);
6124
- }
6125
- }
6126
- return problems;
6127
- }
6128
-
6129
- // src/commands/gate.ts
6130
6155
  var MAX_BLOCKS = 3;
6131
6156
  function lastJigCommand(transcriptPath) {
6132
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.16.0",
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",
@@ -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