jig-ui 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/README.md +3 -3
- package/dist/index.js +535 -60
- package/package.json +2 -2
- package/rules/00-anti-patterns.md +14 -0
- package/rules/01-modes.md +1 -1
- package/rules/05-copy.md +8 -0
- package/rules.index.json +14 -0
- package/templates/COMMAND.md.tmpl +30 -3
- package/templates/command-metadata.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,79 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
Meaning before presentation, and one fewer step anyone can skip.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`H-119` A generic element where a native one says what the content is.**
|
|
10
|
+
The rule carries a decision order: what is this content, is there a native
|
|
11
|
+
element whose meaning is that, does it describe it accurately, does order or
|
|
12
|
+
relationship matter, can CSS do the presentation, and only then a generic
|
|
13
|
+
container. It is not a rule against `div` — a `div` is right where nothing
|
|
14
|
+
more specific is true, and an approximate element is worse than a generic
|
|
15
|
+
one because it asserts something untrue. Presentation must not be required
|
|
16
|
+
to understand the content.
|
|
17
|
+
Its detector decides what the source can: a page with no `<main>`, a row of
|
|
18
|
+
destinations in a header or footer with no `<nav>`, a generic element named
|
|
19
|
+
or styled as a heading, and a repeated set on a page with no list or table.
|
|
20
|
+
- **`jig probe --run <page> --save <surface>`.** The CLI renders the page
|
|
21
|
+
itself, at 360, 768 and 1280, in a headless Chrome, Chromium or Edge it
|
|
22
|
+
finds for itself — including the browsers Playwright or Puppeteer have
|
|
23
|
+
already downloaded. No dependency: Chrome's own debugging protocol over the
|
|
24
|
+
WebSocket client Node has had since 22. The Stop hook runs it before judging
|
|
25
|
+
a critique, so a review is measured whether or not anyone remembered to.
|
|
26
|
+
- **The probe measures markup order against reading order.** A block the CSS
|
|
27
|
+
lifts above the one that precedes it in the markup is reported, whatever the
|
|
28
|
+
verdicts say. Two columns side by side are not an inversion: putting the
|
|
29
|
+
sidebar after the main content and moving it left is the correct pattern.
|
|
30
|
+
- **The probe reads the rendered text for em dashes.** A string built in code —
|
|
31
|
+
a description assembled in a framework's frontmatter, a label written by a
|
|
32
|
+
script — reaches the page having passed no file check.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **`I-118` reads every place a reader sees text.** Every template language the
|
|
37
|
+
suite knows, and the markdown a framework renders as pages. Repository
|
|
38
|
+
documents are still exempt, and capitals now mean "document" only beside the
|
|
39
|
+
lockfile, so `docs/FAQ.md` is a page. Code is not copy: a script's text is
|
|
40
|
+
read only between real tags.
|
|
41
|
+
- **The scan skips `.claude`, `.codex`, `.cursor`, `.opencode`, `.gemini` and
|
|
42
|
+
`.github`.** Jig's own vendored rules are not the project's interface.
|
|
43
|
+
- **Probe files are version 3.** Older ones no longer validate.
|
|
44
|
+
|
|
45
|
+
## 0.12.0
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- **`I-118` Em dashes in interface text.** Use a full stop, a comma, a colon
|
|
50
|
+
or a second element. The mark is ambiguous on its own: it stands in for
|
|
51
|
+
four different ones, and the reader only learns which after reading past
|
|
52
|
+
it. It is also the clearest tell of machine-written copy, and copy that
|
|
53
|
+
reads as generated is copy the reader trusts less. The `em-dash` detector
|
|
54
|
+
reads element text and the attributes a reader hears or sees; scripts,
|
|
55
|
+
styles, comments, class names and documentation are not interface text.
|
|
56
|
+
The en dash keeps its one job, a range read as "to" (`2-10 seats`).
|
|
57
|
+
|
|
58
|
+
## 0.11.1
|
|
59
|
+
|
|
60
|
+
Two holes in the gate, both found by running the loop twice on 0.11.0 —
|
|
61
|
+
once on Haiku, once on Sonnet.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- **The Stop hook's block budget is per failure, not per session.** A
|
|
66
|
+
headless session keeps one session id across every `/jig` step, so a run
|
|
67
|
+
that spent its three blocks on `init`, `spec` and `make` reached
|
|
68
|
+
`critique` with none left: it reported "complete and approved, zero
|
|
69
|
+
findings" having written no verdict files, and the gate that would have
|
|
70
|
+
caught it had already let go. Fixing a failure now returns its attempts,
|
|
71
|
+
a new failure starts fresh, and an agent that cannot fix a given failure
|
|
72
|
+
still gets out after three tries.
|
|
73
|
+
- **`nav: horizontal bar (no menu)` is no longer read as a menu button**
|
|
74
|
+
at a width where the links fit. A false positive is how a gate gets
|
|
75
|
+
ignored.
|
|
76
|
+
|
|
3
77
|
## 0.11.0
|
|
4
78
|
|
|
5
79
|
One release, one lesson: a measurement an agent can type is not a
|
package/README.md
CHANGED
|
@@ -12,13 +12,13 @@ Installed as `npx jig-ui` — the bare name was taken on npm.
|
|
|
12
12
|
Jig is **a skill your coding agent reads**, and **a CLI you can run yourself**.
|
|
13
13
|
They are two halves of the same thing, and the split is not arbitrary:
|
|
14
14
|
|
|
15
|
-
- Of the
|
|
15
|
+
- Of the 115 rules, **18 can be decided by a machine** — a hard-coded colour, a
|
|
16
16
|
contrast ratio below the floor, a removed focus ring. The CLI decides those.
|
|
17
17
|
- The other **97 are judgment** — whether an empty state says anything useful,
|
|
18
18
|
whether a label reads as an instruction, whether motion earns its place. No
|
|
19
19
|
regex settles those. An agent reads the rules and applies them.
|
|
20
20
|
|
|
21
|
-
Running only the CLI gets you the
|
|
21
|
+
Running only the CLI gets you the 18. Running only the agent gets you the 97 with
|
|
22
22
|
no verification. **A clean `jig check` is not a clean review**, and the skill
|
|
23
23
|
says so to every agent that reads it.
|
|
24
24
|
|
|
@@ -371,7 +371,7 @@ treatment.
|
|
|
371
371
|
|
|
372
372
|
| File | Contents |
|
|
373
373
|
| --- | --- |
|
|
374
|
-
| `rules/00-anti-patterns.md` |
|
|
374
|
+
| `rules/00-anti-patterns.md` | 97 universal rules with corrections |
|
|
375
375
|
| `rules/01-modes.md` | `editorial` / `product` / `operator` profiles |
|
|
376
376
|
| `rules/02-tokens.md` | Token contract, naming, consumption |
|
|
377
377
|
| `rules/03-patterns.md` | Component anatomy and behaviour |
|
package/dist/index.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import { readFileSync as readFileSync28 } from "fs";
|
|
6
|
-
import { homedir } from "os";
|
|
7
|
-
import { join as
|
|
6
|
+
import { homedir as homedir2 } from "os";
|
|
7
|
+
import { join as join35 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/paths.ts
|
|
10
10
|
import { existsSync } from "fs";
|
|
@@ -1472,7 +1472,17 @@ var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
|
1472
1472
|
"out",
|
|
1473
1473
|
"coverage",
|
|
1474
1474
|
".svelte-kit",
|
|
1475
|
-
"vendor"
|
|
1475
|
+
"vendor",
|
|
1476
|
+
// Where the agent harnesses keep their own files, including the copy of
|
|
1477
|
+
// Jig's rules and slash command that `install` vendors. Those are the
|
|
1478
|
+
// system's own text, not the project's interface: scanning them reported
|
|
1479
|
+
// 115 em dashes in Jig's command file and called them the user's copy.
|
|
1480
|
+
".claude",
|
|
1481
|
+
".codex",
|
|
1482
|
+
".cursor",
|
|
1483
|
+
".opencode",
|
|
1484
|
+
".gemini",
|
|
1485
|
+
".github"
|
|
1476
1486
|
]);
|
|
1477
1487
|
function isGitRepo(root) {
|
|
1478
1488
|
try {
|
|
@@ -1796,6 +1806,35 @@ var STYLE_HOST_EXTENSIONS = [
|
|
|
1796
1806
|
function isStyleBearing(file) {
|
|
1797
1807
|
return hasExtension(file, CSS_EXTENSIONS) || hasExtension(file, STYLE_HOST_EXTENSIONS);
|
|
1798
1808
|
}
|
|
1809
|
+
var MARKDOWN_EXTENSIONS = [".md", ".markdown", ".mdx"];
|
|
1810
|
+
var REPO_DOCUMENTS = /* @__PURE__ */ new Set([
|
|
1811
|
+
"readme",
|
|
1812
|
+
"changelog",
|
|
1813
|
+
"contributing",
|
|
1814
|
+
"license",
|
|
1815
|
+
"licence",
|
|
1816
|
+
"notice",
|
|
1817
|
+
"code_of_conduct",
|
|
1818
|
+
"security",
|
|
1819
|
+
"support",
|
|
1820
|
+
"governance",
|
|
1821
|
+
"maintainers",
|
|
1822
|
+
"authors",
|
|
1823
|
+
"agents",
|
|
1824
|
+
"claude",
|
|
1825
|
+
"gemini",
|
|
1826
|
+
"copilot-instructions"
|
|
1827
|
+
]);
|
|
1828
|
+
function isRepoDocument(file) {
|
|
1829
|
+
const base = file.split("/").pop() ?? file;
|
|
1830
|
+
const name = base.replace(/\.[^.]+$/, "");
|
|
1831
|
+
if (REPO_DOCUMENTS.has(name.toLowerCase())) return true;
|
|
1832
|
+
return !file.includes("/") && /^[A-Z0-9][A-Z0-9._-]*$/.test(name);
|
|
1833
|
+
}
|
|
1834
|
+
function isReaderText(file) {
|
|
1835
|
+
if (hasExtension(file, MARKDOWN_EXTENSIONS)) return !isRepoDocument(file);
|
|
1836
|
+
return hasExtension(file, STYLE_HOST_EXTENSIONS);
|
|
1837
|
+
}
|
|
1799
1838
|
|
|
1800
1839
|
// src/check/run.ts
|
|
1801
1840
|
import { readFileSync as readFileSync13 } from "fs";
|
|
@@ -3299,6 +3338,141 @@ var undeclaredToken = {
|
|
|
3299
3338
|
}
|
|
3300
3339
|
};
|
|
3301
3340
|
|
|
3341
|
+
// src/check/detectors/em-dash.ts
|
|
3342
|
+
var MARKDOWN = /\.(md|markdown|mdx)$/i;
|
|
3343
|
+
var INDENTED = /\.(pug|jade|haml|slim)$/i;
|
|
3344
|
+
var SCRIPT = /\.(js|mjs|cjs|ts|mts|cts)$/i;
|
|
3345
|
+
var CODEY = /[;{}`]|\$\{|=>/;
|
|
3346
|
+
var TEXT_ATTRS = /\b(aria-label|aria-description|aria-placeholder|title|placeholder|alt|label|value|content)\s*=\s*("([^"]*)"|'([^']*)')/gi;
|
|
3347
|
+
var EM_DASH = /—/;
|
|
3348
|
+
var TAG = /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>/g;
|
|
3349
|
+
function maskNonProse(source) {
|
|
3350
|
+
const blank3 = (m) => " ".repeat(m.length);
|
|
3351
|
+
return source.replace(/^---\n[\s\S]*?\n---/, blank3).replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
|
|
3352
|
+
}
|
|
3353
|
+
function markdownProse(source) {
|
|
3354
|
+
const blank3 = (m) => " ".repeat(m.length);
|
|
3355
|
+
return source.replace(/^ {0,3}(`{3,}|~{3,})[\s\S]*?^ {0,3}\1[^\n]*$/gm, blank3).replace(/`[^`\n]*`/g, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\]\([^)\s]*/g, blank3);
|
|
3356
|
+
}
|
|
3357
|
+
function proseSpans(masked, edges) {
|
|
3358
|
+
const spans = [];
|
|
3359
|
+
const tags = [...masked.matchAll(TAG)];
|
|
3360
|
+
let last = 0;
|
|
3361
|
+
tags.forEach((tag, i) => {
|
|
3362
|
+
const between = masked.slice(last, tag.index);
|
|
3363
|
+
if (between.trim() && (edges || i > 0)) spans.push({ index: last, text: between });
|
|
3364
|
+
for (const attr of tag[0].matchAll(TEXT_ATTRS)) {
|
|
3365
|
+
const value = attr[3] ?? attr[4] ?? "";
|
|
3366
|
+
spans.push({ index: tag.index + attr.index + attr[0].indexOf(value), text: value });
|
|
3367
|
+
}
|
|
3368
|
+
last = tag.index + tag[0].length;
|
|
3369
|
+
});
|
|
3370
|
+
const tail = masked.slice(last);
|
|
3371
|
+
if (tail.trim() && edges && tags.length > 0) spans.push({ index: last, text: tail });
|
|
3372
|
+
return spans;
|
|
3373
|
+
}
|
|
3374
|
+
function indentedProse(source) {
|
|
3375
|
+
const blank3 = (m) => " ".repeat(m.length);
|
|
3376
|
+
return source.split("\n").map((line) => /^\s*(-|\/\/|#(?!\w)|=)/.test(line) ? blank3(line) : line).join("\n");
|
|
3377
|
+
}
|
|
3378
|
+
var emDash = {
|
|
3379
|
+
name: "em-dash",
|
|
3380
|
+
appliesTo: (file) => isReaderText(file),
|
|
3381
|
+
run(_source, file, ctx) {
|
|
3382
|
+
const markdown = MARKDOWN.test(file);
|
|
3383
|
+
const indented = INDENTED.test(file);
|
|
3384
|
+
const masked = markdown ? markdownProse(ctx.raw) : indented ? indentedProse(maskNonProse(ctx.raw)) : maskNonProse(ctx.raw);
|
|
3385
|
+
const starts = buildLineIndex(ctx.raw);
|
|
3386
|
+
const findings = [];
|
|
3387
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3388
|
+
const script = SCRIPT.test(file);
|
|
3389
|
+
const spans = markdown || indented ? [{ index: 0, text: masked }] : proseSpans(masked, !script).filter((span) => !script || !CODEY.test(span.text));
|
|
3390
|
+
for (const span of spans) {
|
|
3391
|
+
if (!EM_DASH.test(span.text)) continue;
|
|
3392
|
+
for (const hit of span.text.matchAll(/\u2014/g)) {
|
|
3393
|
+
const line = lineForOffset(starts, span.index + hit.index);
|
|
3394
|
+
if (seen.has(line)) continue;
|
|
3395
|
+
seen.add(line);
|
|
3396
|
+
findings.push(
|
|
3397
|
+
mkFinding(
|
|
3398
|
+
ctx,
|
|
3399
|
+
"em-dash",
|
|
3400
|
+
file,
|
|
3401
|
+
line,
|
|
3402
|
+
"an em dash in interface text: use a full stop, a comma, a colon, or a second element",
|
|
3403
|
+
sourceLine(ctx.raw, line)
|
|
3404
|
+
)
|
|
3405
|
+
);
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
return findings.sort((a, b) => a.line - b.line);
|
|
3409
|
+
}
|
|
3410
|
+
};
|
|
3411
|
+
|
|
3412
|
+
// src/check/detectors/semantic-element.ts
|
|
3413
|
+
var BODY = /<body\b/i;
|
|
3414
|
+
var MAIN = /<main\b|role\s*=\s*["']main["']/i;
|
|
3415
|
+
var NAV_ELEMENT = /<nav\b|role\s*=\s*["']navigation["']/i;
|
|
3416
|
+
var CHROME = /<(header|footer)\b[\s\S]*?<\/\1\s*>/gi;
|
|
3417
|
+
var NAV_CONTAINER = /<(div|section|ul|span)\b[^>]*\b(?:class|className|id)\s*=\s*["'][^"']*\b(?:nav|navigation|navbar|menu)\b[^"']*["'][^>]*>/i;
|
|
3418
|
+
var LINK = /<a\b[^>]*\bhref\b/gi;
|
|
3419
|
+
var HEADING_SHAPED = /<(div|span|p)\b[^>]*\b(?:class|className)\s*=\s*["']([^"']*\b(?:title|heading|headline|text-(?:2xl|3xl|4xl|5xl))\b[^"']*)["'][^>]*>([\s\S]{0,120}?)<\/\1\s*>/gi;
|
|
3420
|
+
var HEADING_INSIDE = /<h[1-6]\b/i;
|
|
3421
|
+
var REPEATED = /<(div|article|section|a)\b[^>]*\b(?:class|className)\s*=\s*["']([^"']+)["']/gi;
|
|
3422
|
+
var LIST_OR_TABLE = /<(ul|ol|dl|table|tbody|menu)\b/i;
|
|
3423
|
+
var semanticElement = {
|
|
3424
|
+
name: "semantic-element",
|
|
3425
|
+
appliesTo: (file) => isReaderText(file),
|
|
3426
|
+
run(_source, file, ctx) {
|
|
3427
|
+
const raw = ctx.raw;
|
|
3428
|
+
if (!BODY.test(raw)) return [];
|
|
3429
|
+
const starts = buildLineIndex(raw);
|
|
3430
|
+
const findings = [];
|
|
3431
|
+
const at = (offset, message) => {
|
|
3432
|
+
const line = lineForOffset(starts, offset);
|
|
3433
|
+
findings.push(mkFinding(ctx, "semantic-element", file, line, message, sourceLine(raw, line)));
|
|
3434
|
+
};
|
|
3435
|
+
if (!MAIN.test(raw)) {
|
|
3436
|
+
at(
|
|
3437
|
+
BODY.exec(raw).index,
|
|
3438
|
+
"this page has no <main> \u2014 nothing tells a reader skipping the chrome where the page itself starts"
|
|
3439
|
+
);
|
|
3440
|
+
}
|
|
3441
|
+
if (!NAV_ELEMENT.test(raw)) {
|
|
3442
|
+
const container = NAV_CONTAINER.exec(raw);
|
|
3443
|
+
const links = (raw.match(LINK) ?? []).length;
|
|
3444
|
+
if (container && links >= 3) {
|
|
3445
|
+
at(container.index, "navigation links in a generic container, with no <nav> on the page \u2014 the element is what makes it a landmark, not the class name");
|
|
3446
|
+
} else {
|
|
3447
|
+
for (const region of raw.matchAll(CHROME)) {
|
|
3448
|
+
if ((region[0].match(LINK) ?? []).length < 3) continue;
|
|
3449
|
+
at(region.index, `the ${region[1].toLowerCase()} holds a row of links and the page has no <nav> \u2014 a set of destinations is a navigation landmark, and the element is what makes it one`);
|
|
3450
|
+
break;
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
for (const m of raw.matchAll(HEADING_SHAPED)) {
|
|
3455
|
+
if (HEADING_INSIDE.test(m[3])) continue;
|
|
3456
|
+
const text = m[3].replace(/<[^>]*>/g, "").trim();
|
|
3457
|
+
if (!text || text.length > 80) continue;
|
|
3458
|
+
at(m.index, `"${text.slice(0, 40)}" is a ${m[1].toLowerCase()} named and styled as a heading \u2014 if it is the heading, it is an h1-h6, and CSS gives it the size`);
|
|
3459
|
+
}
|
|
3460
|
+
if (!LIST_OR_TABLE.test(raw)) {
|
|
3461
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3462
|
+
for (const m of raw.matchAll(REPEATED)) {
|
|
3463
|
+
const key = `${m[1].toLowerCase()}.${m[2].trim()}`;
|
|
3464
|
+
const seen = counts.get(key) ?? { n: 0, index: m.index };
|
|
3465
|
+
counts.set(key, { n: seen.n + 1, index: seen.index });
|
|
3466
|
+
}
|
|
3467
|
+
const repeated = [...counts.entries()].filter(([, v]) => v.n >= 3).sort((a, b) => a[1].index - b[1].index)[0];
|
|
3468
|
+
if (repeated) {
|
|
3469
|
+
at(repeated[1].index, `${repeated[1].n} sibling ${repeated[0]} elements are a repeated set, and nothing on this page is a list or a table \u2014 decide which collection this is (ul, ol, dl, table) before using a generic container`);
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
return findings;
|
|
3473
|
+
}
|
|
3474
|
+
};
|
|
3475
|
+
|
|
3302
3476
|
// src/check/registry.ts
|
|
3303
3477
|
var DETECTORS = [
|
|
3304
3478
|
gradientText,
|
|
@@ -3316,7 +3490,9 @@ var DETECTORS = [
|
|
|
3316
3490
|
inputZoom,
|
|
3317
3491
|
safeArea,
|
|
3318
3492
|
menuState,
|
|
3319
|
-
undeclaredToken
|
|
3493
|
+
undeclaredToken,
|
|
3494
|
+
emDash,
|
|
3495
|
+
semanticElement
|
|
3320
3496
|
];
|
|
3321
3497
|
function getDetector(name) {
|
|
3322
3498
|
return DETECTORS.find((d) => d.name === name);
|
|
@@ -4880,8 +5056,8 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
4880
5056
|
}
|
|
4881
5057
|
|
|
4882
5058
|
// src/commands/verdicts.ts
|
|
4883
|
-
import { existsSync as
|
|
4884
|
-
import { join as
|
|
5059
|
+
import { existsSync as existsSync19, readFileSync as readFileSync25 } from "fs";
|
|
5060
|
+
import { join as join32, resolve as resolve4 } from "path";
|
|
4885
5061
|
|
|
4886
5062
|
// src/rules/citations.ts
|
|
4887
5063
|
import { join as join28 } from "path";
|
|
@@ -4895,16 +5071,16 @@ function citableIds(packageRoot2) {
|
|
|
4895
5071
|
}
|
|
4896
5072
|
|
|
4897
5073
|
// src/probe/check.ts
|
|
4898
|
-
import { existsSync as
|
|
4899
|
-
import { join as
|
|
5074
|
+
import { existsSync as existsSync18, readdirSync as readdirSync13, readFileSync as readFileSync24 } from "fs";
|
|
5075
|
+
import { join as join31 } from "path";
|
|
4900
5076
|
|
|
4901
5077
|
// src/probe/save.ts
|
|
4902
|
-
import { existsSync as
|
|
5078
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync23, writeFileSync as writeFileSync6 } from "fs";
|
|
4903
5079
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4904
|
-
import { join as
|
|
5080
|
+
import { join as join30, relative as relative3, resolve as resolve3 } from "path";
|
|
4905
5081
|
|
|
4906
5082
|
// src/probe/script.ts
|
|
4907
|
-
var PROBE_VERSION =
|
|
5083
|
+
var PROBE_VERSION = 3;
|
|
4908
5084
|
var PROBE_SCRIPT = `(async () => {
|
|
4909
5085
|
const doc = document.documentElement;
|
|
4910
5086
|
const vis = (e) => { const r = e.getBoundingClientRect(); const s = getComputedStyle(e); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
|
|
@@ -4924,6 +5100,48 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
4924
5100
|
}
|
|
4925
5101
|
}
|
|
4926
5102
|
}
|
|
5103
|
+
// Markup order against reading order (H-119). Only a vertical inversion
|
|
5104
|
+
// counts: an element that sits entirely ABOVE another on screen while coming
|
|
5105
|
+
// after it in the markup. Two columns side by side are not an inversion \u2014
|
|
5106
|
+
// putting the sidebar after the main content and moving it left with CSS is
|
|
5107
|
+
// the correct pattern, and flagging it would punish the right answer.
|
|
5108
|
+
// What to call a region in a report: its own label, else its heading, else
|
|
5109
|
+
// nothing. Its whole text content reads as gibberish inside a finding.
|
|
5110
|
+
const label = (el) => {
|
|
5111
|
+
const aria = (el.getAttribute('aria-label') || '').trim();
|
|
5112
|
+
if (aria) return aria;
|
|
5113
|
+
const id = el.getAttribute('aria-labelledby');
|
|
5114
|
+
const target = id ? document.getElementById(id) : null;
|
|
5115
|
+
const heading = target || (/^h[1-6]$/i.test(el.tagName) ? el : el.querySelector('h1, h2, h3, h4, h5, h6'));
|
|
5116
|
+
const t = (heading ? heading.textContent : '').replace(/\\s+/g, ' ').trim();
|
|
5117
|
+
return t.length > 42 ? t.slice(0, 40).trimEnd() + '\u2026' : t;
|
|
5118
|
+
};
|
|
5119
|
+
const all = [...document.querySelectorAll('main, nav, aside, header, footer, section, article, h1, h2, h3')].filter(vis);
|
|
5120
|
+
// Only the outermost regions. A nested heading inverts with its own parent
|
|
5121
|
+
// and with every sibling of it, so one swapped column reported four times.
|
|
5122
|
+
const landmarks = all
|
|
5123
|
+
.filter((el) => !all.some((other) => other !== el && other.contains(el)))
|
|
5124
|
+
.map((el, order) => {
|
|
5125
|
+
const r = el.getBoundingClientRect();
|
|
5126
|
+
return {
|
|
5127
|
+
order,
|
|
5128
|
+
tag: el.tagName.toLowerCase(),
|
|
5129
|
+
name: label(el),
|
|
5130
|
+
top: Math.round(r.top + scrollY),
|
|
5131
|
+
bottom: Math.round(r.bottom + scrollY),
|
|
5132
|
+
};
|
|
5133
|
+
});
|
|
5134
|
+
const describe = (l) => '<' + l.tag + '>' + (l.name ? ' \u201C' + l.name + '\u201D' : '');
|
|
5135
|
+
const inversions = [];
|
|
5136
|
+
for (let i = 0; i < landmarks.length; i++) {
|
|
5137
|
+
for (let j = i + 1; j < landmarks.length; j++) {
|
|
5138
|
+
const earlier = landmarks[i], later = landmarks[j];
|
|
5139
|
+
// The later element sits wholly above the earlier one, past a rounding wobble.
|
|
5140
|
+
if (later.bottom - earlier.top <= 8 && later.top < earlier.top) {
|
|
5141
|
+
inversions.push({ markupFirst: describe(earlier), seenFirst: describe(later) });
|
|
5142
|
+
}
|
|
5143
|
+
}
|
|
5144
|
+
}
|
|
4927
5145
|
const navLinks = () => [...document.querySelectorAll('nav a, header a, [role=navigation] a')].filter(vis).length;
|
|
4928
5146
|
// Measured before anything is clicked: what a reader sees on arrival.
|
|
4929
5147
|
const navAtRest = navLinks();
|
|
@@ -4968,12 +5186,183 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
4968
5186
|
defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
|
|
4969
5187
|
unresolvedTokens: [...unresolved],
|
|
4970
5188
|
junkText: junk,
|
|
5189
|
+
emDashes: [...new Set((text.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
|
|
4971
5190
|
brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
|
|
4972
5191
|
navLinksVisible: navAtRest,
|
|
5192
|
+
landmarks: landmarks.map(describe),
|
|
5193
|
+
orderInversions: inversions.slice(0, 5),
|
|
4973
5194
|
menu,
|
|
4974
5195
|
});
|
|
4975
5196
|
})()`;
|
|
4976
5197
|
|
|
5198
|
+
// src/probe/browser.ts
|
|
5199
|
+
import { spawn } from "child_process";
|
|
5200
|
+
import { existsSync as existsSync16, mkdtempSync, readdirSync as readdirSync11, rmSync as rmSync3 } from "fs";
|
|
5201
|
+
import { homedir, tmpdir } from "os";
|
|
5202
|
+
import { join as join29 } from "path";
|
|
5203
|
+
var CHROME_ENV = ["JIG_CHROME", "CHROME_PATH", "PUPPETEER_EXECUTABLE_PATH", "CHROMIUM_PATH"];
|
|
5204
|
+
var CHROME_COMMANDS = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"];
|
|
5205
|
+
var CHROME_APPS = [
|
|
5206
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
5207
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
5208
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
5209
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
5210
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
5211
|
+
];
|
|
5212
|
+
function cachedBrowsers() {
|
|
5213
|
+
const roots = [
|
|
5214
|
+
join29(homedir(), ".cache", "ms-playwright"),
|
|
5215
|
+
join29(homedir(), "Library", "Caches", "ms-playwright"),
|
|
5216
|
+
join29(homedir(), "AppData", "Local", "ms-playwright"),
|
|
5217
|
+
join29(homedir(), ".cache", "puppeteer", "chrome")
|
|
5218
|
+
];
|
|
5219
|
+
const found = [];
|
|
5220
|
+
for (const root of roots) {
|
|
5221
|
+
let entries;
|
|
5222
|
+
try {
|
|
5223
|
+
entries = readdirSync11(root);
|
|
5224
|
+
} catch {
|
|
5225
|
+
continue;
|
|
5226
|
+
}
|
|
5227
|
+
for (const entry of entries.sort().reverse()) {
|
|
5228
|
+
for (const rel of [
|
|
5229
|
+
["chrome-linux", "chrome"],
|
|
5230
|
+
["chrome-linux64", "chrome"],
|
|
5231
|
+
["chrome-headless-shell-linux64", "chrome-headless-shell"],
|
|
5232
|
+
["chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"],
|
|
5233
|
+
["chrome-mac-arm64", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"],
|
|
5234
|
+
["chrome-win", "chrome.exe"],
|
|
5235
|
+
["chrome-win64", "chrome.exe"]
|
|
5236
|
+
]) {
|
|
5237
|
+
const candidate = join29(root, entry, ...rel);
|
|
5238
|
+
if (existsSync16(candidate)) found.push(candidate);
|
|
5239
|
+
}
|
|
5240
|
+
}
|
|
5241
|
+
}
|
|
5242
|
+
return found;
|
|
5243
|
+
}
|
|
5244
|
+
function findChrome() {
|
|
5245
|
+
for (const key of CHROME_ENV) {
|
|
5246
|
+
const value = process.env[key];
|
|
5247
|
+
if (value && existsSync16(value)) return value;
|
|
5248
|
+
}
|
|
5249
|
+
for (const command of CHROME_COMMANDS) {
|
|
5250
|
+
const resolved = which(command);
|
|
5251
|
+
if (resolved) return resolved;
|
|
5252
|
+
}
|
|
5253
|
+
for (const app of CHROME_APPS) if (existsSync16(app)) return app;
|
|
5254
|
+
return cachedBrowsers()[0];
|
|
5255
|
+
}
|
|
5256
|
+
function which(command) {
|
|
5257
|
+
const dirs = (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
|
|
5258
|
+
for (const dir of dirs) {
|
|
5259
|
+
if (!dir) continue;
|
|
5260
|
+
const candidate = join29(dir, command);
|
|
5261
|
+
if (existsSync16(candidate)) return candidate;
|
|
5262
|
+
}
|
|
5263
|
+
return void 0;
|
|
5264
|
+
}
|
|
5265
|
+
async function runProbe(opts) {
|
|
5266
|
+
const chrome = opts.chrome ?? findChrome();
|
|
5267
|
+
if (!chrome) {
|
|
5268
|
+
throw new Error(
|
|
5269
|
+
"No browser found to run the probe. Install one, or set JIG_CHROME to a Chrome, Chromium or Edge binary. A project with Playwright or Puppeteer already has one, and Jig will find it."
|
|
5270
|
+
);
|
|
5271
|
+
}
|
|
5272
|
+
const profile = mkdtempSync(join29(tmpdir(), "jig-probe-"));
|
|
5273
|
+
const child = spawn(chrome, [
|
|
5274
|
+
"--headless=new",
|
|
5275
|
+
"--remote-debugging-port=0",
|
|
5276
|
+
`--user-data-dir=${profile}`,
|
|
5277
|
+
"--no-first-run",
|
|
5278
|
+
"--no-default-browser-check",
|
|
5279
|
+
"--disable-gpu",
|
|
5280
|
+
"--hide-scrollbars",
|
|
5281
|
+
"--disable-extensions",
|
|
5282
|
+
"--disable-background-networking",
|
|
5283
|
+
"--no-sandbox",
|
|
5284
|
+
"about:blank"
|
|
5285
|
+
], { stdio: ["ignore", "ignore", "pipe"] });
|
|
5286
|
+
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
5287
|
+
try {
|
|
5288
|
+
const endpoint = await new Promise((done, fail) => {
|
|
5289
|
+
const timer = setTimeout(() => fail(new Error(`${chrome} did not start within ${timeoutMs / 1e3}s.`)), timeoutMs);
|
|
5290
|
+
let buffered = "";
|
|
5291
|
+
child.stderr.on("data", (chunk) => {
|
|
5292
|
+
buffered += chunk.toString();
|
|
5293
|
+
const match = /ws:\/\/[^\s]+/.exec(buffered);
|
|
5294
|
+
if (match) {
|
|
5295
|
+
clearTimeout(timer);
|
|
5296
|
+
done(match[0]);
|
|
5297
|
+
}
|
|
5298
|
+
});
|
|
5299
|
+
child.on("exit", (code) => {
|
|
5300
|
+
clearTimeout(timer);
|
|
5301
|
+
fail(new Error(`${chrome} exited with code ${code} before it was ready.`));
|
|
5302
|
+
});
|
|
5303
|
+
});
|
|
5304
|
+
return await evaluate(endpoint, opts.url, opts.width, opts.height ?? 900, timeoutMs);
|
|
5305
|
+
} finally {
|
|
5306
|
+
child.kill();
|
|
5307
|
+
rmSync3(profile, { recursive: true, force: true });
|
|
5308
|
+
}
|
|
5309
|
+
}
|
|
5310
|
+
async function evaluate(endpoint, url, width, height, timeoutMs) {
|
|
5311
|
+
const socket = new WebSocket(endpoint);
|
|
5312
|
+
let nextId = 1;
|
|
5313
|
+
const pending = /* @__PURE__ */ new Map();
|
|
5314
|
+
const loaded = { fired: false, waiters: [] };
|
|
5315
|
+
await new Promise((done, fail) => {
|
|
5316
|
+
socket.addEventListener("open", () => done(), { once: true });
|
|
5317
|
+
socket.addEventListener("error", () => fail(new Error("Could not connect to the browser.")), { once: true });
|
|
5318
|
+
});
|
|
5319
|
+
socket.addEventListener("message", (event) => {
|
|
5320
|
+
const message = JSON.parse(String(event.data));
|
|
5321
|
+
if (message.id && pending.has(message.id)) {
|
|
5322
|
+
const { done, fail } = pending.get(message.id);
|
|
5323
|
+
pending.delete(message.id);
|
|
5324
|
+
if (message.error) fail(new Error(message.error.message));
|
|
5325
|
+
else done(message.result ?? {});
|
|
5326
|
+
}
|
|
5327
|
+
if (message.method === "Page.loadEventFired") {
|
|
5328
|
+
loaded.fired = true;
|
|
5329
|
+
for (const waiter of loaded.waiters.splice(0)) waiter();
|
|
5330
|
+
}
|
|
5331
|
+
});
|
|
5332
|
+
const send = (method, params = {}, sessionId) => new Promise((done, fail) => {
|
|
5333
|
+
const id = nextId++;
|
|
5334
|
+
pending.set(id, { done, fail });
|
|
5335
|
+
socket.send(JSON.stringify({ id, method, params, sessionId }));
|
|
5336
|
+
setTimeout(() => {
|
|
5337
|
+
if (pending.delete(id)) fail(new Error(`${method} timed out.`));
|
|
5338
|
+
}, timeoutMs);
|
|
5339
|
+
});
|
|
5340
|
+
try {
|
|
5341
|
+
const target = await send("Target.createTarget", { url: "about:blank" });
|
|
5342
|
+
const attached = await send("Target.attachToTarget", { targetId: target.targetId, flatten: true });
|
|
5343
|
+
const session = attached.sessionId;
|
|
5344
|
+
await send("Page.enable", {}, session);
|
|
5345
|
+
await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile: false }, session);
|
|
5346
|
+
await send("Page.navigate", { url }, session);
|
|
5347
|
+
if (!loaded.fired) {
|
|
5348
|
+
await Promise.race([
|
|
5349
|
+
new Promise((done) => loaded.waiters.push(done)),
|
|
5350
|
+
new Promise((done) => setTimeout(done, timeoutMs / 2))
|
|
5351
|
+
]);
|
|
5352
|
+
}
|
|
5353
|
+
await new Promise((done) => setTimeout(done, 250));
|
|
5354
|
+
const result = await send("Runtime.evaluate", { expression: PROBE_SCRIPT, awaitPromise: true, returnByValue: true }, session);
|
|
5355
|
+
const value = result.result?.value;
|
|
5356
|
+
if (typeof value !== "string") {
|
|
5357
|
+
const description = result.exceptionDetails?.text;
|
|
5358
|
+
throw new Error(`The probe returned nothing${description ? `: ${description}` : ""}. Is ${url} a page this browser can open?`);
|
|
5359
|
+
}
|
|
5360
|
+
return value;
|
|
5361
|
+
} finally {
|
|
5362
|
+
socket.close();
|
|
5363
|
+
}
|
|
5364
|
+
}
|
|
5365
|
+
|
|
4977
5366
|
// src/probe/save.ts
|
|
4978
5367
|
function saveProbe(opts) {
|
|
4979
5368
|
let probe;
|
|
@@ -4995,9 +5384,9 @@ function saveProbe(opts) {
|
|
|
4995
5384
|
probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
|
|
4996
5385
|
probe.pageChecksum = checksum(readFileSync23(page, "utf8"));
|
|
4997
5386
|
probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4998
|
-
const dir =
|
|
5387
|
+
const dir = join30(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
4999
5388
|
mkdirSync7(dir, { recursive: true });
|
|
5000
|
-
const path =
|
|
5389
|
+
const path = join30(dir, `probe-${width}.json`);
|
|
5001
5390
|
writeFileSync6(path, JSON.stringify(probe), "utf8");
|
|
5002
5391
|
return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
|
|
5003
5392
|
}
|
|
@@ -5011,7 +5400,49 @@ function pageFile(projectRoot, url) {
|
|
|
5011
5400
|
}
|
|
5012
5401
|
const abs = resolve3(path);
|
|
5013
5402
|
if (!abs.startsWith(resolve3(projectRoot))) return void 0;
|
|
5014
|
-
return
|
|
5403
|
+
return existsSync17(abs) ? abs : void 0;
|
|
5404
|
+
}
|
|
5405
|
+
var PROBE_WIDTHS = [360, 768, 1280];
|
|
5406
|
+
async function runAndSaveProbes(opts) {
|
|
5407
|
+
const abs = resolve3(opts.projectRoot, opts.page);
|
|
5408
|
+
if (!existsSync17(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
|
|
5409
|
+
const url = /^https?:/i.test(opts.page) ? opts.page : `file://${abs}`;
|
|
5410
|
+
const saved = [];
|
|
5411
|
+
for (const width of opts.widths ?? PROBE_WIDTHS) {
|
|
5412
|
+
const json = await runProbe({ url, width });
|
|
5413
|
+
saved.push(saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json }));
|
|
5414
|
+
}
|
|
5415
|
+
return saved;
|
|
5416
|
+
}
|
|
5417
|
+
async function ensureProbes(opts) {
|
|
5418
|
+
const dir = join30(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
5419
|
+
const abs = resolve3(opts.projectRoot, opts.page);
|
|
5420
|
+
let current;
|
|
5421
|
+
try {
|
|
5422
|
+
current = checksum(readFileSync23(abs, "utf8"));
|
|
5423
|
+
} catch {
|
|
5424
|
+
return { recorded: [], reason: `${opts.page} could not be read` };
|
|
5425
|
+
}
|
|
5426
|
+
const missing = PROBE_WIDTHS.filter((width) => {
|
|
5427
|
+
try {
|
|
5428
|
+
const probe = JSON.parse(readFileSync23(join30(dir, `probe-${width}.json`), "utf8"));
|
|
5429
|
+
return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
|
|
5430
|
+
} catch {
|
|
5431
|
+
return true;
|
|
5432
|
+
}
|
|
5433
|
+
});
|
|
5434
|
+
if (missing.length === 0) return { recorded: [] };
|
|
5435
|
+
if (!findChrome()) return { recorded: [], reason: "no browser on this machine" };
|
|
5436
|
+
const url = /^https?:/i.test(opts.page) ? opts.page : `file://${abs}`;
|
|
5437
|
+
for (const width of missing) {
|
|
5438
|
+
saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json: await runProbe({ url, width }) });
|
|
5439
|
+
}
|
|
5440
|
+
return { recorded: missing };
|
|
5441
|
+
}
|
|
5442
|
+
function critiquedSurfaces(projectRoot) {
|
|
5443
|
+
const root = join30(projectRoot, ".jig", "critique");
|
|
5444
|
+
if (!existsSync17(root)) return [];
|
|
5445
|
+
return readdirSync12(root).filter((surface) => existsSync17(join30(root, surface, "screen.json")) || existsSync17(join30(root, surface, "code.json")));
|
|
5015
5446
|
}
|
|
5016
5447
|
|
|
5017
5448
|
// src/probe/check.ts
|
|
@@ -5033,11 +5464,11 @@ function stampProblem(projectRoot, file, p) {
|
|
|
5033
5464
|
return void 0;
|
|
5034
5465
|
}
|
|
5035
5466
|
function readProbes(projectRoot, dir, errors) {
|
|
5036
|
-
if (!
|
|
5467
|
+
if (!existsSync18(dir)) return [];
|
|
5037
5468
|
const probes = [];
|
|
5038
|
-
for (const f of
|
|
5469
|
+
for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
|
|
5039
5470
|
try {
|
|
5040
|
-
const p = JSON.parse(readFileSync24(
|
|
5471
|
+
const p = JSON.parse(readFileSync24(join31(dir, f), "utf8"));
|
|
5041
5472
|
if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
|
|
5042
5473
|
errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
|
|
5043
5474
|
continue;
|
|
@@ -5068,6 +5499,12 @@ function probeContradictions(probes, verdictOf) {
|
|
|
5068
5499
|
if (p.unresolvedTokens.length) {
|
|
5069
5500
|
errors.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
|
|
5070
5501
|
}
|
|
5502
|
+
for (const inv of p.orderInversions ?? []) {
|
|
5503
|
+
errors.push(`${at(p)}: "${inv.seenFirst}" is read first on screen but comes after "${inv.markupFirst}" in the markup. At this width the markup order is not the reading order (H-119) \u2014 reorder the document, or move it with CSS that leaves the order intact.`);
|
|
5504
|
+
}
|
|
5505
|
+
if (p.emDashes?.length) {
|
|
5506
|
+
errors.push(`${at(p)}: the rendered page shows an em dash in ${p.emDashes.map((t) => `"${t}"`).join(", ")} (I-118) \u2014 use a full stop, a comma, a colon, or a second element.`);
|
|
5507
|
+
}
|
|
5071
5508
|
if (p.junkText.length) {
|
|
5072
5509
|
errors.push(`${at(p)}: the rendered text contains ${p.junkText.map((j) => `"${j}"`).join(", ")} \u2014 template code or a failed value is showing to readers.`);
|
|
5073
5510
|
}
|
|
@@ -5107,7 +5544,7 @@ function probeContradictions(probes, verdictOf) {
|
|
|
5107
5544
|
var VERDICTS = ["ok", "finding", "n/a"];
|
|
5108
5545
|
var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
|
|
5109
5546
|
function readJson(path, errors) {
|
|
5110
|
-
if (!
|
|
5547
|
+
if (!existsSync19(path)) return null;
|
|
5111
5548
|
try {
|
|
5112
5549
|
return JSON.parse(readFileSync25(path, "utf8"));
|
|
5113
5550
|
} catch (e) {
|
|
@@ -5116,8 +5553,8 @@ function readJson(path, errors) {
|
|
|
5116
5553
|
}
|
|
5117
5554
|
}
|
|
5118
5555
|
function specNeedsNav(projectRoot, surface) {
|
|
5119
|
-
const path =
|
|
5120
|
-
if (!
|
|
5556
|
+
const path = join32(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
5557
|
+
if (!existsSync19(path)) return false;
|
|
5121
5558
|
const front = readFileSync25(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
5122
5559
|
const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
|
|
5123
5560
|
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
@@ -5169,7 +5606,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
5169
5606
|
function verifyVerdicts(opts) {
|
|
5170
5607
|
const root = opts.packageRoot ?? assetRoot();
|
|
5171
5608
|
const errors = [];
|
|
5172
|
-
const index = JSON.parse(readFileSync25(
|
|
5609
|
+
const index = JSON.parse(readFileSync25(join32(root, "rules.index.json"), "utf8"));
|
|
5173
5610
|
const judgment = index.filter((r) => r.bucket === "judgment");
|
|
5174
5611
|
const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
|
|
5175
5612
|
const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
|
|
@@ -5178,16 +5615,16 @@ function verifyVerdicts(opts) {
|
|
|
5178
5615
|
...judgment.map((r) => [r.id, r.pass ?? ""])
|
|
5179
5616
|
]);
|
|
5180
5617
|
const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
|
|
5181
|
-
const dir =
|
|
5182
|
-
const screenFile = readJson(
|
|
5183
|
-
const codeFile = readJson(
|
|
5618
|
+
const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
5619
|
+
const screenFile = readJson(join32(dir, "screen.json"), errors);
|
|
5620
|
+
const codeFile = readJson(join32(dir, "code.json"), errors);
|
|
5184
5621
|
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
5185
5622
|
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
|
|
5186
5623
|
const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
|
|
5187
5624
|
let rendered = false;
|
|
5188
5625
|
if (screenFile && screenFile.rendered === true) {
|
|
5189
5626
|
const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
|
|
5190
|
-
const absent = artefacts.filter((a) => !
|
|
5627
|
+
const absent = artefacts.filter((a) => !existsSync19(resolve4(opts.projectRoot, a)));
|
|
5191
5628
|
if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
|
|
5192
5629
|
else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
|
|
5193
5630
|
else rendered = true;
|
|
@@ -5214,19 +5651,20 @@ function verifyVerdicts(opts) {
|
|
|
5214
5651
|
}
|
|
5215
5652
|
|
|
5216
5653
|
// src/commands/gate.ts
|
|
5217
|
-
import { existsSync as
|
|
5218
|
-
import {
|
|
5654
|
+
import { existsSync as existsSync21, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync27, writeFileSync as writeFileSync7 } from "fs";
|
|
5655
|
+
import { createHash as createHash2 } from "crypto";
|
|
5656
|
+
import { join as join34 } from "path";
|
|
5219
5657
|
|
|
5220
5658
|
// src/check/spec-shape.ts
|
|
5221
|
-
import { existsSync as
|
|
5222
|
-
import { join as
|
|
5659
|
+
import { existsSync as existsSync20, readFileSync as readFileSync26, readdirSync as readdirSync14, statSync as statSync2 } from "fs";
|
|
5660
|
+
import { join as join33 } from "path";
|
|
5223
5661
|
function newestSpec(projectRoot) {
|
|
5224
|
-
const dir =
|
|
5225
|
-
if (!
|
|
5226
|
-
const files =
|
|
5662
|
+
const dir = join33(projectRoot, ".jig", "specs");
|
|
5663
|
+
if (!existsSync20(dir)) return void 0;
|
|
5664
|
+
const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
|
|
5227
5665
|
if (files.length === 0) return void 0;
|
|
5228
|
-
const newest = files.map((f) => ({ f, at: statSync2(
|
|
5229
|
-
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync26(
|
|
5666
|
+
const newest = files.map((f) => ({ f, at: statSync2(join33(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
5667
|
+
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync26(join33(dir, newest), "utf8") };
|
|
5230
5668
|
}
|
|
5231
5669
|
function specProblems(spec) {
|
|
5232
5670
|
const parts = spec.body.split(/^---\s*$/m);
|
|
@@ -5275,7 +5713,7 @@ function sizeBlock(sizes, size) {
|
|
|
5275
5713
|
}
|
|
5276
5714
|
return lines.join("\n");
|
|
5277
5715
|
}
|
|
5278
|
-
var MENU_RE =
|
|
5716
|
+
var MENU_RE = /(?<!\b(?:no|not|without|never)\s)(?<!\bno\s\w{1,12}\s)\b(menu|hamburger|drawer|burger)\b/i;
|
|
5279
5717
|
var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
|
|
5280
5718
|
function navProblems(spec) {
|
|
5281
5719
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
@@ -5295,7 +5733,7 @@ function navProblems(spec) {
|
|
|
5295
5733
|
// src/commands/gate.ts
|
|
5296
5734
|
var MAX_BLOCKS = 3;
|
|
5297
5735
|
function lastJigCommand(transcriptPath) {
|
|
5298
|
-
if (!transcriptPath || !
|
|
5736
|
+
if (!transcriptPath || !existsSync21(transcriptPath)) return void 0;
|
|
5299
5737
|
let text;
|
|
5300
5738
|
try {
|
|
5301
5739
|
text = readFileSync27(transcriptPath, "utf8");
|
|
@@ -5316,7 +5754,7 @@ function commandProblems(root, command) {
|
|
|
5316
5754
|
const problems = [];
|
|
5317
5755
|
const spec = newestSpec(root);
|
|
5318
5756
|
if (command === "decide") {
|
|
5319
|
-
const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) =>
|
|
5757
|
+
const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) => join34(root, p)).find((p) => existsSync21(p));
|
|
5320
5758
|
if (!file) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
5321
5759
|
else {
|
|
5322
5760
|
const body = readFileSync27(file, "utf8");
|
|
@@ -5340,13 +5778,13 @@ function commandProblems(root, command) {
|
|
|
5340
5778
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
5341
5779
|
if (/^approved/i.test(mockup)) {
|
|
5342
5780
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
5343
|
-
else if (!/^https?:/i.test(at) && !
|
|
5781
|
+
else if (!/^https?:/i.test(at) && !existsSync21(join34(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
|
|
5344
5782
|
else if (!/^https?:/i.test(at) && !at.startsWith(".jig/mockups/")) problems.push(`The drawing is at ${at}. A mockup lives in .jig/mockups/, outside what check scans and outside what ships.`);
|
|
5345
5783
|
}
|
|
5346
5784
|
}
|
|
5347
5785
|
if (command === "critique") {
|
|
5348
|
-
const dir =
|
|
5349
|
-
const surfaces =
|
|
5786
|
+
const dir = join34(root, ".jig", "critique");
|
|
5787
|
+
const surfaces = existsSync21(dir) ? readdirSync15(dir).filter((s) => existsSync21(join34(dir, s, "screen.json")) || existsSync21(join34(dir, s, "code.json"))) : [];
|
|
5350
5788
|
for (const surface of surfaces) {
|
|
5351
5789
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
5352
5790
|
if (v.ok && v.screen.state === "skipped") {
|
|
@@ -5359,9 +5797,19 @@ function commandProblems(root, command) {
|
|
|
5359
5797
|
}
|
|
5360
5798
|
return problems;
|
|
5361
5799
|
}
|
|
5800
|
+
function surfacePage(projectRoot, surface) {
|
|
5801
|
+
const spec = newestSpec(projectRoot);
|
|
5802
|
+
const front = spec?.body.split(/^---\s*$/m)[1] ?? "";
|
|
5803
|
+
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
5804
|
+
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
5805
|
+
for (const candidate of candidates) {
|
|
5806
|
+
if (candidate && existsSync21(join34(projectRoot, candidate))) return candidate;
|
|
5807
|
+
}
|
|
5808
|
+
return void 0;
|
|
5809
|
+
}
|
|
5362
5810
|
function gate(opts) {
|
|
5363
5811
|
const root = opts.projectRoot;
|
|
5364
|
-
if (!
|
|
5812
|
+
if (!existsSync21(join34(root, "jig.config.json")) && !existsSync21(join34(root, ".jig"))) {
|
|
5365
5813
|
return { block: false, reason: "" };
|
|
5366
5814
|
}
|
|
5367
5815
|
const command = lastJigCommand(opts.input.transcript_path);
|
|
@@ -5380,11 +5828,11 @@ function gate(opts) {
|
|
|
5380
5828
|
);
|
|
5381
5829
|
}
|
|
5382
5830
|
}
|
|
5383
|
-
const critiqueDir =
|
|
5384
|
-
if (
|
|
5385
|
-
for (const surface of
|
|
5386
|
-
const dir =
|
|
5387
|
-
if (!
|
|
5831
|
+
const critiqueDir = join34(root, ".jig", "critique");
|
|
5832
|
+
if (existsSync21(critiqueDir)) {
|
|
5833
|
+
for (const surface of readdirSync15(critiqueDir)) {
|
|
5834
|
+
const dir = join34(critiqueDir, surface);
|
|
5835
|
+
if (!existsSync21(join34(dir, "screen.json")) && !existsSync21(join34(dir, "code.json"))) continue;
|
|
5388
5836
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
5389
5837
|
if (!v.ok) {
|
|
5390
5838
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -5397,20 +5845,22 @@ function gate(opts) {
|
|
|
5397
5845
|
}
|
|
5398
5846
|
}
|
|
5399
5847
|
const session = opts.input.session_id ?? "unknown";
|
|
5400
|
-
const
|
|
5848
|
+
const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
|
|
5849
|
+
const stateFile = join34(root, ".jig", "gate.json");
|
|
5401
5850
|
let state = {};
|
|
5402
5851
|
try {
|
|
5403
5852
|
state = JSON.parse(readFileSync27(stateFile, "utf8"));
|
|
5404
5853
|
} catch {
|
|
5405
5854
|
}
|
|
5406
5855
|
if (problems.length === 0) {
|
|
5407
|
-
|
|
5408
|
-
|
|
5856
|
+
const mine = Object.keys(state).filter((k) => k.startsWith(`${session}:`));
|
|
5857
|
+
if (mine.length) {
|
|
5858
|
+
for (const k of mine) delete state[k];
|
|
5409
5859
|
save(stateFile, state);
|
|
5410
5860
|
}
|
|
5411
5861
|
return { block: false, reason: "" };
|
|
5412
5862
|
}
|
|
5413
|
-
const count = (state[
|
|
5863
|
+
const count = (state[key] ?? 0) + 1;
|
|
5414
5864
|
if (count > MAX_BLOCKS) {
|
|
5415
5865
|
return {
|
|
5416
5866
|
block: false,
|
|
@@ -5418,7 +5868,7 @@ function gate(opts) {
|
|
|
5418
5868
|
${problems.join("\n\n")}`
|
|
5419
5869
|
};
|
|
5420
5870
|
}
|
|
5421
|
-
state[
|
|
5871
|
+
state[key] = count;
|
|
5422
5872
|
save(stateFile, state);
|
|
5423
5873
|
return {
|
|
5424
5874
|
block: true,
|
|
@@ -5429,7 +5879,7 @@ ${problems.join("\n\n")}`
|
|
|
5429
5879
|
}
|
|
5430
5880
|
function save(file, state) {
|
|
5431
5881
|
try {
|
|
5432
|
-
mkdirSync8(
|
|
5882
|
+
mkdirSync8(join34(file, ".."), { recursive: true });
|
|
5433
5883
|
writeFileSync7(file, JSON.stringify(state), "utf8");
|
|
5434
5884
|
} catch {
|
|
5435
5885
|
}
|
|
@@ -5437,7 +5887,7 @@ function save(file, state) {
|
|
|
5437
5887
|
|
|
5438
5888
|
// src/index.ts
|
|
5439
5889
|
var packageRoot = getPackageRoot();
|
|
5440
|
-
var { version } = JSON.parse(readFileSync28(
|
|
5890
|
+
var { version } = JSON.parse(readFileSync28(join35(packageRoot, "package.json"), "utf8"));
|
|
5441
5891
|
function warnIfUnpublishedPin() {
|
|
5442
5892
|
if (isPublishedBuild(packageRoot)) return;
|
|
5443
5893
|
console.warn(
|
|
@@ -5475,7 +5925,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
|
|
|
5475
5925
|
projectRoot,
|
|
5476
5926
|
packageRoot: assetRoot(),
|
|
5477
5927
|
version,
|
|
5478
|
-
homeDir:
|
|
5928
|
+
homeDir: homedir2(),
|
|
5479
5929
|
hook
|
|
5480
5930
|
});
|
|
5481
5931
|
if (result.warning) {
|
|
@@ -5513,7 +5963,7 @@ program.command("update").description("Update vendored Jig rules, skipping files
|
|
|
5513
5963
|
projectRoot,
|
|
5514
5964
|
packageRoot: assetRoot(),
|
|
5515
5965
|
version,
|
|
5516
|
-
homeDir:
|
|
5966
|
+
homeDir: homedir2()
|
|
5517
5967
|
});
|
|
5518
5968
|
const label = result.targets.map((t) => `${t.agent} (${t.scope}, ${t.fromVersion})`).join(", ");
|
|
5519
5969
|
warnIfUnpublishedPin();
|
|
@@ -5551,7 +6001,7 @@ program.command("check").description("Check the repo against Jig's mechanical +
|
|
|
5551
6001
|
try {
|
|
5552
6002
|
const result = check({
|
|
5553
6003
|
projectRoot,
|
|
5554
|
-
homeDir:
|
|
6004
|
+
homeDir: homedir2(),
|
|
5555
6005
|
version,
|
|
5556
6006
|
all: opts.all,
|
|
5557
6007
|
ci: opts.ci
|
|
@@ -5563,7 +6013,23 @@ program.command("check").description("Check the repo against Jig's mechanical +
|
|
|
5563
6013
|
process.exit(1);
|
|
5564
6014
|
}
|
|
5565
6015
|
});
|
|
5566
|
-
program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").action(async (opts) => {
|
|
6016
|
+
program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").option("--run <page>", "render this page here, at 360, 768 and 1280, and record each (needs --save)").action(async (opts) => {
|
|
6017
|
+
if (opts.run) {
|
|
6018
|
+
if (!opts.save) {
|
|
6019
|
+
console.error(" \u2717 --run records what it measures, so it needs --save <surface>.");
|
|
6020
|
+
process.exit(1);
|
|
6021
|
+
}
|
|
6022
|
+
const projectRoot2 = findProjectRoot(process.cwd());
|
|
6023
|
+
try {
|
|
6024
|
+
for (const saved of await runAndSaveProbes({ projectRoot: projectRoot2, surface: opts.save, page: opts.run })) {
|
|
6025
|
+
console.log(` Recorded ${saved.path} \u2014 ${saved.page} at ${saved.width}px.`);
|
|
6026
|
+
}
|
|
6027
|
+
} catch (err) {
|
|
6028
|
+
console.error(` \u2717 ${err.message}`);
|
|
6029
|
+
process.exit(1);
|
|
6030
|
+
}
|
|
6031
|
+
return;
|
|
6032
|
+
}
|
|
5567
6033
|
if (!opts.save) {
|
|
5568
6034
|
console.log(PROBE_SCRIPT);
|
|
5569
6035
|
return;
|
|
@@ -5586,7 +6052,7 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
5586
6052
|
process.exit(1);
|
|
5587
6053
|
}
|
|
5588
6054
|
});
|
|
5589
|
-
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(() => {
|
|
6055
|
+
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
|
|
5590
6056
|
let input = {};
|
|
5591
6057
|
try {
|
|
5592
6058
|
if (!process.stdin.isTTY) input = JSON.parse(readFileSync28(0, "utf8") || "{}");
|
|
@@ -5594,7 +6060,16 @@ program.command("gate").description("Run by the Claude Code Stop hook: block sto
|
|
|
5594
6060
|
}
|
|
5595
6061
|
const cwd = input.cwd ?? process.cwd();
|
|
5596
6062
|
try {
|
|
5597
|
-
const
|
|
6063
|
+
const projectRoot = findProjectRoot(cwd);
|
|
6064
|
+
for (const surface of critiquedSurfaces(projectRoot)) {
|
|
6065
|
+
const page = surfacePage(projectRoot, surface);
|
|
6066
|
+
if (!page) continue;
|
|
6067
|
+
try {
|
|
6068
|
+
await ensureProbes({ projectRoot, surface, page });
|
|
6069
|
+
} catch {
|
|
6070
|
+
}
|
|
6071
|
+
}
|
|
6072
|
+
const result = gate({ projectRoot, version, input });
|
|
5598
6073
|
if (result.block) console.log(JSON.stringify({ decision: "block", reason: result.reason }));
|
|
5599
6074
|
else if (result.reason) console.error(result.reason);
|
|
5600
6075
|
} catch (err) {
|
|
@@ -5608,7 +6083,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
5608
6083
|
await init({
|
|
5609
6084
|
projectRoot,
|
|
5610
6085
|
packageRoot: assetRoot(),
|
|
5611
|
-
homeDir:
|
|
6086
|
+
homeDir: homedir2(),
|
|
5612
6087
|
version,
|
|
5613
6088
|
yes: opts.yes
|
|
5614
6089
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jig-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A design system for coding agents.
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "A design system for coding agents. 115 numbered UI rules, brand x mode design tokens, and an installer for Claude Code, Codex, Cursor and opencode.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -544,6 +544,20 @@ Deletion is a real answer and the easy one to miss, because the correction point
|
|
|
544
544
|
❌ Scroll listeners for sticky positioning; scripted accordions and dialogs that have native equivalents
|
|
545
545
|
✅ Platform first: `position: sticky`, `<details>`, `<dialog>`, `:has()`, container queries, `scroll-behavior`, `popover`. Reach for a framework when the platform genuinely lacks the capability.
|
|
546
546
|
|
|
547
|
+
### H-119 A generic element where a native one says what the content is
|
|
548
|
+
❌ `<div class="page-title">Our products</div>`, a row of links in a `<div class="nav">`, a page whose top-level content sits in `<div class="main">`
|
|
549
|
+
✅ `<h1>Our products</h1>`, `<nav>`, `<main>`. Choose the element from what the content **is**, then style it. The two versions can look identical; only one of them tells a screen reader, a search engine, a reader-mode button and a keyboard user what they are looking at.
|
|
550
|
+
**The decision order**, before writing any markup:
|
|
551
|
+
1. What is this content? A heading, a list, a sequence, a quotation, tabular data, navigation, a control, a landmark region?
|
|
552
|
+
2. Is there a native element whose meaning is that? If yes, use it.
|
|
553
|
+
3. Does it describe the content accurately, or only approximately? An approximate fit is worse than a generic container, because it asserts something untrue.
|
|
554
|
+
4. Does the content have an order or a relationship that the markup should carry? Ordered steps are `<ol>`; rows and columns of related values are a `<table>`; a term and its explanation are `<dl>`.
|
|
555
|
+
5. Can CSS produce the presentation without changing the element? It nearly always can.
|
|
556
|
+
6. Only when no native element fits, a generic container.
|
|
557
|
+
**This is not a rule against `div`.** A `div` is the right answer when nothing more specific is true: a grouping that exists for layout alone. What is forbidden is choosing an element for how it looks, or reaching for a generic one without asking steps 1 and 2.
|
|
558
|
+
**Presentation must not be required to understand the content.** Turn the stylesheet off in your head: the page should still read as what it is, in an order that makes sense. A sidebar that must come first visually does not have to come first in the DOM, and usually should not (`L-01`).
|
|
559
|
+
Related: `E-33` (an interactive `div`), `B-17` (heading levels), `H-48` (native elements over scripted ones), `P-14` (navigation).
|
|
560
|
+
|
|
547
561
|
### H-117 A token name nothing declares
|
|
548
562
|
❌ `font-family: var(--font-body)`, `padding: var(--space-lg)` — in a project whose token layer declares neither
|
|
549
563
|
✅ Use the names the token layer declares — `02-tokens.md` lists them, and the token files in the project are the source. If the value you need has no token, that is a finding to report or a value to delete (`H-47`), never a name to make up.
|
package/rules/01-modes.md
CHANGED
|
@@ -190,7 +190,7 @@ Attempting to vary these by mode is a category error:
|
|
|
190
190
|
- **Accessibility floors.** Contrast, focus indication, target size, semantic markup. Identical in all three. `operator` being dense does not license a 24px tap target or a 3:1 body contrast.
|
|
191
191
|
- **Brand identity.** Palette, typeface, logo, voice.
|
|
192
192
|
- **State completeness.** Every mode renders loading, empty, error and disabled.
|
|
193
|
-
- **The anti-pattern file.** All
|
|
193
|
+
- **The anti-pattern file.** All 97 rules in it apply everywhere.
|
|
194
194
|
|
|
195
195
|
---
|
|
196
196
|
|
package/rules/05-copy.md
CHANGED
|
@@ -83,6 +83,14 @@ The one legitimate use is a **short label** distinguishing itself from nearby te
|
|
|
83
83
|
✅ Most interface text is too short to need them. Use a full stop only where the text is a complete sentence containing commas.
|
|
84
84
|
Whichever you choose, be consistent across sibling elements — a list where three items end in a stop and two do not looks like a mistake, because it is one.
|
|
85
85
|
|
|
86
|
+
### I-118 Em dashes in interface text
|
|
87
|
+
❌ "Your plan — including every seat — renews monthly", "Free — forever", "Deleted — this cannot be undone"
|
|
88
|
+
✅ A full stop, a comma, a colon or a new element. "Your plan renews monthly, including every seat." "Free forever." "Deleted. This cannot be undone."
|
|
89
|
+
Interface text is read in fragments, at a glance, in a space someone else's content has to fit too. An em dash is a pause the reader has to interpret: it stands in for a comma, a colon, a bracket or a full stop, and which one it is only becomes clear after reading past it. The punctuation that says exactly one thing is faster.
|
|
90
|
+
It is also the clearest tell of machine-written copy. Generated text reaches for the em dash far more often than a person does, and readers have learned to notice. Copy that reads as generated is copy the reader trusts less, whatever it says.
|
|
91
|
+
This is about interface strings: labels, buttons, headings, errors, empty states, help text, and the prose a page ships. 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
|
+
The en dash keeps its one job: ranges, where it is read as "to" (`2–10 seats`, `Mon–Fri`). That is not a pause, and it is not affected.
|
|
93
|
+
|
|
86
94
|
### I-87 Inconsistent vocabulary
|
|
87
95
|
❌ "Add to cart" beside a "Bag" icon; "Sign up" on the page and "Register" in the nav
|
|
88
96
|
✅ One word per concept, everywhere. Keep a term list in the project and follow it.
|
package/rules.index.json
CHANGED
|
@@ -793,5 +793,19 @@
|
|
|
793
793
|
"severity": "error",
|
|
794
794
|
"since": "0.10.0",
|
|
795
795
|
"detector": "undeclared-token"
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
"id": "I-118",
|
|
799
|
+
"bucket": "mechanical",
|
|
800
|
+
"severity": "warning",
|
|
801
|
+
"since": "0.12.0",
|
|
802
|
+
"detector": "em-dash"
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
"id": "H-119",
|
|
806
|
+
"bucket": "mechanical",
|
|
807
|
+
"severity": "warning",
|
|
808
|
+
"since": "0.13.0",
|
|
809
|
+
"detector": "semantic-element"
|
|
796
810
|
}
|
|
797
811
|
]
|
|
@@ -318,6 +318,11 @@ deviations: [] # `make` writes here; `spec` leaves it empty
|
|
|
318
318
|
- **No shell before there are features.** If the product has no navigation yet,
|
|
319
319
|
`nav:` says `none yet` — it is decided once there is more than one feature to
|
|
320
320
|
move between, not invented for the first one.
|
|
321
|
+
- **Regions say what the content is, not what it looks like.** `- plans: three
|
|
322
|
+
cards` describes a shape; `- plans: a comparable set, one per tier` describes
|
|
323
|
+
content, and the second is what `make` can choose an element from. `H-119` runs
|
|
324
|
+
its decision order against these lines, so a region named only by its
|
|
325
|
+
appearance leaves the element to chance.
|
|
321
326
|
- **`nav:` at every size, on any screen with navigation — decided by `P-14`'s table
|
|
322
327
|
at that width, not copied.** It is the field most likely to be written once and
|
|
323
328
|
copied, and most likely to be genuinely different at every size on a page that
|
|
@@ -641,6 +646,14 @@ change. Write base styles for the phone and add width as it is available — not
|
|
|
641
646
|
the desktop layout with overrides that take it apart again. A layout built wide
|
|
642
647
|
and subtracted from ends up correct at exactly the widths somebody checked.
|
|
643
648
|
|
|
649
|
+
**Choose every element from what its content is, before styling any of it**
|
|
650
|
+
(`H-119`). For each region in the spec, run the decision order: what is this
|
|
651
|
+
content — a heading, a list, a sequence, a quotation, tabular data, navigation,
|
|
652
|
+
a control, a landmark? Is there a native element whose meaning is that? Does it
|
|
653
|
+
describe the content accurately? Only when nothing fits is a generic container
|
|
654
|
+
right. The page must read as what it is with the stylesheet off, in an order
|
|
655
|
+
that makes sense; CSS then arranges it, and never the reverse.
|
|
656
|
+
|
|
644
657
|
Then the ordinary rules apply: tokens by semantic name, the relevant
|
|
645
658
|
`03-patterns.md` section for each component, `05-copy.md` for every string.
|
|
646
659
|
|
|
@@ -852,12 +865,19 @@ Each reader arm writes its own file. The arm reports back only that it wrote it.
|
|
|
852
865
|
probe in the browser and save what it returns beside the verdicts:
|
|
853
866
|
|
|
854
867
|
```
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
868
|
+
# If a browser is on this machine, one command renders and records all three:
|
|
869
|
+
{{scripts_path}} probe --run <page> --save <surface>
|
|
870
|
+
|
|
871
|
+
# Otherwise, at each width: open the page in whatever browser you have, evaluate
|
|
872
|
+
# what `{{scripts_path}} probe` prints, and pipe exactly what it returned into:
|
|
858
873
|
{{scripts_path}} probe --save <surface>
|
|
859
874
|
```
|
|
860
875
|
|
|
876
|
+
`--run` drives a headless Chrome, Chromium or Edge it finds for itself — a
|
|
877
|
+
project with Playwright or Puppeteer already has one — and needs nothing
|
|
878
|
+
installed. The Stop hook runs it too, before it judges a review, so a render is
|
|
879
|
+
not a step anyone can forget.
|
|
880
|
+
|
|
861
881
|
**The CLI writes the probe file, you do not.** `--save` reads the probe's output,
|
|
862
882
|
stamps it with the page's checksum, and stores it. That is what makes the file a
|
|
863
883
|
measurement: `verdicts` recomputes the checksum, so a file written by hand, or one
|
|
@@ -918,6 +938,13 @@ source and imagining the page is not a render.
|
|
|
918
938
|
composition. The same layout with smaller numbers is not.
|
|
919
939
|
- Check the page is styled at all. A stylesheet that 404s renders a page that
|
|
920
940
|
passes every file-based check ever written.
|
|
941
|
+
- **Read the markup as a document, not a layout** (`H-119`). For each region:
|
|
942
|
+
does its element say what the content is? Would the page still read correctly
|
|
943
|
+
with the stylesheet off, in this order? A row of links that is not a `<nav>`, a
|
|
944
|
+
heading that is a styled `<div>`, steps in no particular element, a comparison
|
|
945
|
+
of values that is not a table: each is a finding, and each names the element
|
|
946
|
+
the content asks for. A `<div>` is right where nothing more specific is true;
|
|
947
|
+
say so rather than inventing a fault.
|
|
921
948
|
- Run the squint test from `L-01` against the render, not the analogue: render
|
|
922
949
|
each size once more with `filter: grayscale(1)` on the root, and check that the
|
|
923
950
|
primary action, the headings and the groups still read in order without colour.
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"status": "available"
|
|
31
31
|
},
|
|
32
32
|
"probe": {
|
|
33
|
-
"description": "Print the render probe
|
|
34
|
-
"argumentHint": "[--save <surface>]",
|
|
33
|
+
"description": "Print the render probe, or run it here. `--run <page> --save <surface>` drives a headless browser it finds for itself and records the measurement at 360, 768 and 1280, stamped with the page's checksum, so `verdicts` can tell a measurement from a claim.",
|
|
34
|
+
"argumentHint": "[--run <page>] [--save <surface>]",
|
|
35
35
|
"status": "available"
|
|
36
36
|
},
|
|
37
37
|
"gate": {
|