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 +18 -0
- package/dist/index.js +122 -97
- package/package.json +1 -1
- package/rules/03-patterns.md +6 -6
- package/rules/04-principles.md +12 -12
- package/rules/05-copy.md +1 -0
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
|
|
5318
|
-
import { join as
|
|
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((
|
|
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 (!
|
|
5978
|
+
if (!existsSync21(path)) return null;
|
|
5885
5979
|
try {
|
|
5886
|
-
return JSON.parse(
|
|
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 =
|
|
5987
|
+
const path = join35(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
5894
5988
|
let front = "";
|
|
5895
5989
|
try {
|
|
5896
|
-
front =
|
|
5990
|
+
front = readFileSync28(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
5897
5991
|
} catch {
|
|
5898
5992
|
}
|
|
5899
|
-
const declared =
|
|
5900
|
-
if (declared ===
|
|
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 =
|
|
5907
|
-
if (!
|
|
5908
|
-
const front =
|
|
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(
|
|
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(
|
|
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 =
|
|
6014
|
-
const screenFile = readJson(
|
|
6015
|
-
const codeFile = readJson(
|
|
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) => !
|
|
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
|
-
|
|
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.
|
|
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",
|
package/rules/03-patterns.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/rules/04-principles.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|