jig-ui 0.12.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 +42 -0
- package/README.md +3 -3
- package/dist/index.js +495 -77
- 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 +1 -1
- package/rules.index.json +7 -0
- package/templates/COMMAND.md.tmpl +30 -3
- package/templates/command-metadata.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
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
|
+
|
|
3
45
|
## 0.12.0
|
|
4
46
|
|
|
5
47
|
### Added
|
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";
|
|
@@ -3300,54 +3339,136 @@ var undeclaredToken = {
|
|
|
3300
3339
|
};
|
|
3301
3340
|
|
|
3302
3341
|
// src/check/detectors/em-dash.ts
|
|
3303
|
-
var
|
|
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 = /[;{}`]|\$\{|=>/;
|
|
3304
3346
|
var TEXT_ATTRS = /\b(aria-label|aria-description|aria-placeholder|title|placeholder|alt|label|value|content)\s*=\s*("([^"]*)"|'([^']*)')/gi;
|
|
3305
3347
|
var EM_DASH = /—/;
|
|
3348
|
+
var TAG = /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>/g;
|
|
3306
3349
|
function maskNonProse(source) {
|
|
3307
3350
|
const blank3 = (m) => " ".repeat(m.length);
|
|
3308
|
-
return source.replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
|
|
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));
|
|
3309
3352
|
}
|
|
3310
|
-
function
|
|
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) {
|
|
3311
3358
|
const spans = [];
|
|
3359
|
+
const tags = [...masked.matchAll(TAG)];
|
|
3312
3360
|
let last = 0;
|
|
3313
|
-
|
|
3361
|
+
tags.forEach((tag, i) => {
|
|
3314
3362
|
const between = masked.slice(last, tag.index);
|
|
3315
|
-
if (between.trim()) spans.push({ index: last, text: between });
|
|
3363
|
+
if (between.trim() && (edges || i > 0)) spans.push({ index: last, text: between });
|
|
3316
3364
|
for (const attr of tag[0].matchAll(TEXT_ATTRS)) {
|
|
3317
3365
|
const value = attr[3] ?? attr[4] ?? "";
|
|
3318
3366
|
spans.push({ index: tag.index + attr.index + attr[0].indexOf(value), text: value });
|
|
3319
3367
|
}
|
|
3320
3368
|
last = tag.index + tag[0].length;
|
|
3321
|
-
}
|
|
3369
|
+
});
|
|
3322
3370
|
const tail = masked.slice(last);
|
|
3323
|
-
if (tail.trim()) spans.push({ index: last, text: tail });
|
|
3371
|
+
if (tail.trim() && edges && tags.length > 0) spans.push({ index: last, text: tail });
|
|
3324
3372
|
return spans;
|
|
3325
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
|
+
}
|
|
3326
3378
|
var emDash = {
|
|
3327
3379
|
name: "em-dash",
|
|
3328
|
-
appliesTo: (file) =>
|
|
3380
|
+
appliesTo: (file) => isReaderText(file),
|
|
3329
3381
|
run(_source, file, ctx) {
|
|
3330
|
-
const
|
|
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);
|
|
3331
3385
|
const starts = buildLineIndex(ctx.raw);
|
|
3332
3386
|
const findings = [];
|
|
3333
3387
|
const seen = /* @__PURE__ */ new Set();
|
|
3334
|
-
|
|
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) {
|
|
3335
3391
|
if (!EM_DASH.test(span.text)) continue;
|
|
3336
|
-
const
|
|
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) => {
|
|
3337
3432
|
const line = lineForOffset(starts, offset);
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
file,
|
|
3345
|
-
line,
|
|
3346
|
-
"an em dash in interface text: use a full stop, a comma, a colon, or a second element",
|
|
3347
|
-
sourceLine(ctx.raw, line)
|
|
3348
|
-
)
|
|
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"
|
|
3349
3439
|
);
|
|
3350
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
|
+
}
|
|
3351
3472
|
return findings;
|
|
3352
3473
|
}
|
|
3353
3474
|
};
|
|
@@ -3370,7 +3491,8 @@ var DETECTORS = [
|
|
|
3370
3491
|
safeArea,
|
|
3371
3492
|
menuState,
|
|
3372
3493
|
undeclaredToken,
|
|
3373
|
-
emDash
|
|
3494
|
+
emDash,
|
|
3495
|
+
semanticElement
|
|
3374
3496
|
];
|
|
3375
3497
|
function getDetector(name) {
|
|
3376
3498
|
return DETECTORS.find((d) => d.name === name);
|
|
@@ -4934,8 +5056,8 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
4934
5056
|
}
|
|
4935
5057
|
|
|
4936
5058
|
// src/commands/verdicts.ts
|
|
4937
|
-
import { existsSync as
|
|
4938
|
-
import { join as
|
|
5059
|
+
import { existsSync as existsSync19, readFileSync as readFileSync25 } from "fs";
|
|
5060
|
+
import { join as join32, resolve as resolve4 } from "path";
|
|
4939
5061
|
|
|
4940
5062
|
// src/rules/citations.ts
|
|
4941
5063
|
import { join as join28 } from "path";
|
|
@@ -4949,16 +5071,16 @@ function citableIds(packageRoot2) {
|
|
|
4949
5071
|
}
|
|
4950
5072
|
|
|
4951
5073
|
// src/probe/check.ts
|
|
4952
|
-
import { existsSync as
|
|
4953
|
-
import { join as
|
|
5074
|
+
import { existsSync as existsSync18, readdirSync as readdirSync13, readFileSync as readFileSync24 } from "fs";
|
|
5075
|
+
import { join as join31 } from "path";
|
|
4954
5076
|
|
|
4955
5077
|
// src/probe/save.ts
|
|
4956
|
-
import { existsSync as
|
|
5078
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync23, writeFileSync as writeFileSync6 } from "fs";
|
|
4957
5079
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4958
|
-
import { join as
|
|
5080
|
+
import { join as join30, relative as relative3, resolve as resolve3 } from "path";
|
|
4959
5081
|
|
|
4960
5082
|
// src/probe/script.ts
|
|
4961
|
-
var PROBE_VERSION =
|
|
5083
|
+
var PROBE_VERSION = 3;
|
|
4962
5084
|
var PROBE_SCRIPT = `(async () => {
|
|
4963
5085
|
const doc = document.documentElement;
|
|
4964
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'; };
|
|
@@ -4978,6 +5100,48 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
4978
5100
|
}
|
|
4979
5101
|
}
|
|
4980
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
|
+
}
|
|
4981
5145
|
const navLinks = () => [...document.querySelectorAll('nav a, header a, [role=navigation] a')].filter(vis).length;
|
|
4982
5146
|
// Measured before anything is clicked: what a reader sees on arrival.
|
|
4983
5147
|
const navAtRest = navLinks();
|
|
@@ -5022,12 +5186,183 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5022
5186
|
defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
|
|
5023
5187
|
unresolvedTokens: [...unresolved],
|
|
5024
5188
|
junkText: junk,
|
|
5189
|
+
emDashes: [...new Set((text.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
|
|
5025
5190
|
brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
|
|
5026
5191
|
navLinksVisible: navAtRest,
|
|
5192
|
+
landmarks: landmarks.map(describe),
|
|
5193
|
+
orderInversions: inversions.slice(0, 5),
|
|
5027
5194
|
menu,
|
|
5028
5195
|
});
|
|
5029
5196
|
})()`;
|
|
5030
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
|
+
|
|
5031
5366
|
// src/probe/save.ts
|
|
5032
5367
|
function saveProbe(opts) {
|
|
5033
5368
|
let probe;
|
|
@@ -5049,9 +5384,9 @@ function saveProbe(opts) {
|
|
|
5049
5384
|
probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
|
|
5050
5385
|
probe.pageChecksum = checksum(readFileSync23(page, "utf8"));
|
|
5051
5386
|
probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5052
|
-
const dir =
|
|
5387
|
+
const dir = join30(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
5053
5388
|
mkdirSync7(dir, { recursive: true });
|
|
5054
|
-
const path =
|
|
5389
|
+
const path = join30(dir, `probe-${width}.json`);
|
|
5055
5390
|
writeFileSync6(path, JSON.stringify(probe), "utf8");
|
|
5056
5391
|
return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
|
|
5057
5392
|
}
|
|
@@ -5065,7 +5400,49 @@ function pageFile(projectRoot, url) {
|
|
|
5065
5400
|
}
|
|
5066
5401
|
const abs = resolve3(path);
|
|
5067
5402
|
if (!abs.startsWith(resolve3(projectRoot))) return void 0;
|
|
5068
|
-
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")));
|
|
5069
5446
|
}
|
|
5070
5447
|
|
|
5071
5448
|
// src/probe/check.ts
|
|
@@ -5087,11 +5464,11 @@ function stampProblem(projectRoot, file, p) {
|
|
|
5087
5464
|
return void 0;
|
|
5088
5465
|
}
|
|
5089
5466
|
function readProbes(projectRoot, dir, errors) {
|
|
5090
|
-
if (!
|
|
5467
|
+
if (!existsSync18(dir)) return [];
|
|
5091
5468
|
const probes = [];
|
|
5092
|
-
for (const f of
|
|
5469
|
+
for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
|
|
5093
5470
|
try {
|
|
5094
|
-
const p = JSON.parse(readFileSync24(
|
|
5471
|
+
const p = JSON.parse(readFileSync24(join31(dir, f), "utf8"));
|
|
5095
5472
|
if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
|
|
5096
5473
|
errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
|
|
5097
5474
|
continue;
|
|
@@ -5122,6 +5499,12 @@ function probeContradictions(probes, verdictOf) {
|
|
|
5122
5499
|
if (p.unresolvedTokens.length) {
|
|
5123
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).`);
|
|
5124
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
|
+
}
|
|
5125
5508
|
if (p.junkText.length) {
|
|
5126
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.`);
|
|
5127
5510
|
}
|
|
@@ -5161,7 +5544,7 @@ function probeContradictions(probes, verdictOf) {
|
|
|
5161
5544
|
var VERDICTS = ["ok", "finding", "n/a"];
|
|
5162
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;
|
|
5163
5546
|
function readJson(path, errors) {
|
|
5164
|
-
if (!
|
|
5547
|
+
if (!existsSync19(path)) return null;
|
|
5165
5548
|
try {
|
|
5166
5549
|
return JSON.parse(readFileSync25(path, "utf8"));
|
|
5167
5550
|
} catch (e) {
|
|
@@ -5170,8 +5553,8 @@ function readJson(path, errors) {
|
|
|
5170
5553
|
}
|
|
5171
5554
|
}
|
|
5172
5555
|
function specNeedsNav(projectRoot, surface) {
|
|
5173
|
-
const path =
|
|
5174
|
-
if (!
|
|
5556
|
+
const path = join32(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
5557
|
+
if (!existsSync19(path)) return false;
|
|
5175
5558
|
const front = readFileSync25(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
5176
5559
|
const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
|
|
5177
5560
|
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
@@ -5223,7 +5606,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
5223
5606
|
function verifyVerdicts(opts) {
|
|
5224
5607
|
const root = opts.packageRoot ?? assetRoot();
|
|
5225
5608
|
const errors = [];
|
|
5226
|
-
const index = JSON.parse(readFileSync25(
|
|
5609
|
+
const index = JSON.parse(readFileSync25(join32(root, "rules.index.json"), "utf8"));
|
|
5227
5610
|
const judgment = index.filter((r) => r.bucket === "judgment");
|
|
5228
5611
|
const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
|
|
5229
5612
|
const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
|
|
@@ -5232,16 +5615,16 @@ function verifyVerdicts(opts) {
|
|
|
5232
5615
|
...judgment.map((r) => [r.id, r.pass ?? ""])
|
|
5233
5616
|
]);
|
|
5234
5617
|
const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
|
|
5235
|
-
const dir =
|
|
5236
|
-
const screenFile = readJson(
|
|
5237
|
-
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);
|
|
5238
5621
|
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
5239
5622
|
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
|
|
5240
5623
|
const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
|
|
5241
5624
|
let rendered = false;
|
|
5242
5625
|
if (screenFile && screenFile.rendered === true) {
|
|
5243
5626
|
const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
|
|
5244
|
-
const absent = artefacts.filter((a) => !
|
|
5627
|
+
const absent = artefacts.filter((a) => !existsSync19(resolve4(opts.projectRoot, a)));
|
|
5245
5628
|
if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
|
|
5246
5629
|
else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
|
|
5247
5630
|
else rendered = true;
|
|
@@ -5268,20 +5651,20 @@ function verifyVerdicts(opts) {
|
|
|
5268
5651
|
}
|
|
5269
5652
|
|
|
5270
5653
|
// src/commands/gate.ts
|
|
5271
|
-
import { existsSync as
|
|
5654
|
+
import { existsSync as existsSync21, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync27, writeFileSync as writeFileSync7 } from "fs";
|
|
5272
5655
|
import { createHash as createHash2 } from "crypto";
|
|
5273
|
-
import { join as
|
|
5656
|
+
import { join as join34 } from "path";
|
|
5274
5657
|
|
|
5275
5658
|
// src/check/spec-shape.ts
|
|
5276
|
-
import { existsSync as
|
|
5277
|
-
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";
|
|
5278
5661
|
function newestSpec(projectRoot) {
|
|
5279
|
-
const dir =
|
|
5280
|
-
if (!
|
|
5281
|
-
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"));
|
|
5282
5665
|
if (files.length === 0) return void 0;
|
|
5283
|
-
const newest = files.map((f) => ({ f, at: statSync2(
|
|
5284
|
-
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") };
|
|
5285
5668
|
}
|
|
5286
5669
|
function specProblems(spec) {
|
|
5287
5670
|
const parts = spec.body.split(/^---\s*$/m);
|
|
@@ -5350,7 +5733,7 @@ function navProblems(spec) {
|
|
|
5350
5733
|
// src/commands/gate.ts
|
|
5351
5734
|
var MAX_BLOCKS = 3;
|
|
5352
5735
|
function lastJigCommand(transcriptPath) {
|
|
5353
|
-
if (!transcriptPath || !
|
|
5736
|
+
if (!transcriptPath || !existsSync21(transcriptPath)) return void 0;
|
|
5354
5737
|
let text;
|
|
5355
5738
|
try {
|
|
5356
5739
|
text = readFileSync27(transcriptPath, "utf8");
|
|
@@ -5371,7 +5754,7 @@ function commandProblems(root, command) {
|
|
|
5371
5754
|
const problems = [];
|
|
5372
5755
|
const spec = newestSpec(root);
|
|
5373
5756
|
if (command === "decide") {
|
|
5374
|
-
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));
|
|
5375
5758
|
if (!file) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
5376
5759
|
else {
|
|
5377
5760
|
const body = readFileSync27(file, "utf8");
|
|
@@ -5395,13 +5778,13 @@ function commandProblems(root, command) {
|
|
|
5395
5778
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
5396
5779
|
if (/^approved/i.test(mockup)) {
|
|
5397
5780
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
5398
|
-
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.`);
|
|
5399
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.`);
|
|
5400
5783
|
}
|
|
5401
5784
|
}
|
|
5402
5785
|
if (command === "critique") {
|
|
5403
|
-
const dir =
|
|
5404
|
-
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"))) : [];
|
|
5405
5788
|
for (const surface of surfaces) {
|
|
5406
5789
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
5407
5790
|
if (v.ok && v.screen.state === "skipped") {
|
|
@@ -5414,9 +5797,19 @@ function commandProblems(root, command) {
|
|
|
5414
5797
|
}
|
|
5415
5798
|
return problems;
|
|
5416
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
|
+
}
|
|
5417
5810
|
function gate(opts) {
|
|
5418
5811
|
const root = opts.projectRoot;
|
|
5419
|
-
if (!
|
|
5812
|
+
if (!existsSync21(join34(root, "jig.config.json")) && !existsSync21(join34(root, ".jig"))) {
|
|
5420
5813
|
return { block: false, reason: "" };
|
|
5421
5814
|
}
|
|
5422
5815
|
const command = lastJigCommand(opts.input.transcript_path);
|
|
@@ -5435,11 +5828,11 @@ function gate(opts) {
|
|
|
5435
5828
|
);
|
|
5436
5829
|
}
|
|
5437
5830
|
}
|
|
5438
|
-
const critiqueDir =
|
|
5439
|
-
if (
|
|
5440
|
-
for (const surface of
|
|
5441
|
-
const dir =
|
|
5442
|
-
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;
|
|
5443
5836
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
5444
5837
|
if (!v.ok) {
|
|
5445
5838
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -5453,7 +5846,7 @@ function gate(opts) {
|
|
|
5453
5846
|
}
|
|
5454
5847
|
const session = opts.input.session_id ?? "unknown";
|
|
5455
5848
|
const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
|
|
5456
|
-
const stateFile =
|
|
5849
|
+
const stateFile = join34(root, ".jig", "gate.json");
|
|
5457
5850
|
let state = {};
|
|
5458
5851
|
try {
|
|
5459
5852
|
state = JSON.parse(readFileSync27(stateFile, "utf8"));
|
|
@@ -5486,7 +5879,7 @@ ${problems.join("\n\n")}`
|
|
|
5486
5879
|
}
|
|
5487
5880
|
function save(file, state) {
|
|
5488
5881
|
try {
|
|
5489
|
-
mkdirSync8(
|
|
5882
|
+
mkdirSync8(join34(file, ".."), { recursive: true });
|
|
5490
5883
|
writeFileSync7(file, JSON.stringify(state), "utf8");
|
|
5491
5884
|
} catch {
|
|
5492
5885
|
}
|
|
@@ -5494,7 +5887,7 @@ function save(file, state) {
|
|
|
5494
5887
|
|
|
5495
5888
|
// src/index.ts
|
|
5496
5889
|
var packageRoot = getPackageRoot();
|
|
5497
|
-
var { version } = JSON.parse(readFileSync28(
|
|
5890
|
+
var { version } = JSON.parse(readFileSync28(join35(packageRoot, "package.json"), "utf8"));
|
|
5498
5891
|
function warnIfUnpublishedPin() {
|
|
5499
5892
|
if (isPublishedBuild(packageRoot)) return;
|
|
5500
5893
|
console.warn(
|
|
@@ -5532,7 +5925,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
|
|
|
5532
5925
|
projectRoot,
|
|
5533
5926
|
packageRoot: assetRoot(),
|
|
5534
5927
|
version,
|
|
5535
|
-
homeDir:
|
|
5928
|
+
homeDir: homedir2(),
|
|
5536
5929
|
hook
|
|
5537
5930
|
});
|
|
5538
5931
|
if (result.warning) {
|
|
@@ -5570,7 +5963,7 @@ program.command("update").description("Update vendored Jig rules, skipping files
|
|
|
5570
5963
|
projectRoot,
|
|
5571
5964
|
packageRoot: assetRoot(),
|
|
5572
5965
|
version,
|
|
5573
|
-
homeDir:
|
|
5966
|
+
homeDir: homedir2()
|
|
5574
5967
|
});
|
|
5575
5968
|
const label = result.targets.map((t) => `${t.agent} (${t.scope}, ${t.fromVersion})`).join(", ");
|
|
5576
5969
|
warnIfUnpublishedPin();
|
|
@@ -5608,7 +6001,7 @@ program.command("check").description("Check the repo against Jig's mechanical +
|
|
|
5608
6001
|
try {
|
|
5609
6002
|
const result = check({
|
|
5610
6003
|
projectRoot,
|
|
5611
|
-
homeDir:
|
|
6004
|
+
homeDir: homedir2(),
|
|
5612
6005
|
version,
|
|
5613
6006
|
all: opts.all,
|
|
5614
6007
|
ci: opts.ci
|
|
@@ -5620,7 +6013,23 @@ program.command("check").description("Check the repo against Jig's mechanical +
|
|
|
5620
6013
|
process.exit(1);
|
|
5621
6014
|
}
|
|
5622
6015
|
});
|
|
5623
|
-
program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").action(async (opts) => {
|
|
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
|
+
}
|
|
5624
6033
|
if (!opts.save) {
|
|
5625
6034
|
console.log(PROBE_SCRIPT);
|
|
5626
6035
|
return;
|
|
@@ -5643,7 +6052,7 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
5643
6052
|
process.exit(1);
|
|
5644
6053
|
}
|
|
5645
6054
|
});
|
|
5646
|
-
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 () => {
|
|
5647
6056
|
let input = {};
|
|
5648
6057
|
try {
|
|
5649
6058
|
if (!process.stdin.isTTY) input = JSON.parse(readFileSync28(0, "utf8") || "{}");
|
|
@@ -5651,7 +6060,16 @@ program.command("gate").description("Run by the Claude Code Stop hook: block sto
|
|
|
5651
6060
|
}
|
|
5652
6061
|
const cwd = input.cwd ?? process.cwd();
|
|
5653
6062
|
try {
|
|
5654
|
-
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 });
|
|
5655
6073
|
if (result.block) console.log(JSON.stringify({ decision: "block", reason: result.reason }));
|
|
5656
6074
|
else if (result.reason) console.error(result.reason);
|
|
5657
6075
|
} catch (err) {
|
|
@@ -5665,7 +6083,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
5665
6083
|
await init({
|
|
5666
6084
|
projectRoot,
|
|
5667
6085
|
packageRoot: assetRoot(),
|
|
5668
|
-
homeDir:
|
|
6086
|
+
homeDir: homedir2(),
|
|
5669
6087
|
version,
|
|
5670
6088
|
yes: opts.yes
|
|
5671
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
|
@@ -88,7 +88,7 @@ Whichever you choose, be consistent across sibling elements — a list where thr
|
|
|
88
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
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
|
-
This is about interface strings
|
|
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
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
93
|
|
|
94
94
|
### I-87 Inconsistent vocabulary
|
package/rules.index.json
CHANGED
|
@@ -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": {
|