ultra11y 3.0.0 → 3.1.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/README.md +3 -2
- package/package.json +1 -1
- package/scripts/build-extension-icons.mjs +11 -43
- package/scripts/ultra11y.mjs +2 -2
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +2 -2
- package/skills/ultra11y/SKILL.md +1 -1
- package/skills/ultra11y/references/ci.md +11 -9
- package/skills/ultra11y/scripts/ultra11y.mjs +2 -2
- package/src/evidence.ts +499 -0
- package/src/pixel.ts +174 -2
- package/src/snapshot.ts +9 -4
- package/src/types.ts +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "ultra11y",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.1.0",
|
|
14
14
|
"author": { "name": "maxgfr" },
|
|
15
15
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
16
16
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
3
|
"description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.1.0",
|
|
5
5
|
"author": { "name": "maxgfr" },
|
|
6
6
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
7
7
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
3
|
"description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.1.0",
|
|
5
5
|
"author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
|
|
6
6
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
7
7
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
|
|
|
27
27
|
// Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
|
|
28
28
|
// does not carry it, so a user's own ultra11y.js is never clobbered.
|
|
29
29
|
const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
|
|
30
|
-
const ULTRA11Y_PLUGIN_VERSION = "3.
|
|
30
|
+
const ULTRA11Y_PLUGIN_VERSION = "3.1.0";
|
|
31
31
|
|
|
32
32
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
33
|
|
package/README.md
CHANGED
|
@@ -125,7 +125,7 @@ claude mcp add ultra11y -- npx -y ultra11y mcp
|
|
|
125
125
|
**As a GitHub Action** — audits the PR diff and, optionally, the served pages:
|
|
126
126
|
|
|
127
127
|
```yaml
|
|
128
|
-
- uses: maxgfr/ultra11y@
|
|
128
|
+
- uses: maxgfr/ultra11y@v3
|
|
129
129
|
with: { since: auto, standard: rgaa, fail-on: blocking }
|
|
130
130
|
```
|
|
131
131
|
|
|
@@ -429,7 +429,8 @@ Releases are cut automatically by **semantic-release** on push to `main` (GitHub
|
|
|
429
429
|
npm publish via OIDC trusted publishing, plus the moving `v2` major tag). The version bump is
|
|
430
430
|
decided by **Conventional Commits**: a commit whose subject carries no `feat:`/`fix:` prefix
|
|
431
431
|
is analysed as *no release*, so a change that must ship needs at least one conventional commit
|
|
432
|
-
in the range — otherwise
|
|
432
|
+
in the range — otherwise the major alias and npm keep serving the previous version while `main`
|
|
433
|
+
moves on.
|
|
433
434
|
|
|
434
435
|
## Data & licensing
|
|
435
436
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.33.0",
|
|
@@ -7,15 +7,22 @@
|
|
|
7
7
|
// script produces — the same reproducibility contract the engine bundle is held to.
|
|
8
8
|
//
|
|
9
9
|
// The mark is an eye (the action's own branding icon), drawn as a filled lens with a pupil on
|
|
10
|
-
// the purple the action already declares.
|
|
11
|
-
//
|
|
10
|
+
// the purple the action already declares. The PNG encoder is the engine's own
|
|
11
|
+
// (`src/pixel.ts`), imported through Node's type-stripping — the repo has no image dependency
|
|
12
|
+
// and is not about to grow one for four small files.
|
|
13
|
+
//
|
|
14
|
+
// That import runs both ways: because these four icons are committed, `--check` re-encodes
|
|
15
|
+
// them and proves `encodePng` still emits the exact bytes it emitted when they were drawn.
|
|
16
|
+
// A decode/encode round-trip could only prove the pair is self-consistent; this proves the
|
|
17
|
+
// encoder is PNG-correct against files a browser accepts. Keep `src/pixel.ts` a leaf (one
|
|
18
|
+
// runtime import, `node:zlib`) or this import stops resolving.
|
|
12
19
|
//
|
|
13
20
|
// node scripts/build-extension-icons.mjs # write
|
|
14
21
|
// node scripts/build-extension-icons.mjs --check # fail if the committed files differ
|
|
15
|
-
import { deflateSync } from "node:zlib";
|
|
16
22
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
17
23
|
import { dirname, join } from "node:path";
|
|
18
24
|
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { encodePng } from "../src/pixel.ts";
|
|
19
26
|
|
|
20
27
|
const OUT = join(dirname(fileURLToPath(import.meta.url)), "..", "extension", "icons");
|
|
21
28
|
const SIZES = [16, 32, 48, 128];
|
|
@@ -24,45 +31,6 @@ const SIZES = [16, 32, 48, 128];
|
|
|
24
31
|
const BG = [107, 70, 193]; // #6b46c1
|
|
25
32
|
const FG = [255, 255, 255];
|
|
26
33
|
|
|
27
|
-
function crc32(buf) {
|
|
28
|
-
let c = ~0;
|
|
29
|
-
for (const b of buf) {
|
|
30
|
-
c ^= b;
|
|
31
|
-
for (let i = 0; i < 8; i++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
|
|
32
|
-
}
|
|
33
|
-
return ~c >>> 0;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function chunk(type, data) {
|
|
37
|
-
const len = Buffer.alloc(4);
|
|
38
|
-
len.writeUInt32BE(data.length);
|
|
39
|
-
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
|
|
40
|
-
const crc = Buffer.alloc(4);
|
|
41
|
-
crc.writeUInt32BE(crc32(body));
|
|
42
|
-
return Buffer.concat([len, body, crc]);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** An RGBA pixel buffer → a PNG. Colour type 6, 8-bit, one filter byte per row. */
|
|
46
|
-
function png(size, rgba) {
|
|
47
|
-
const raw = Buffer.alloc(size * (size * 4 + 1));
|
|
48
|
-
for (let y = 0; y < size; y++) {
|
|
49
|
-
raw[y * (size * 4 + 1)] = 0; // filter: none — the images are tiny, filtering buys nothing
|
|
50
|
-
rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
|
|
51
|
-
}
|
|
52
|
-
const ihdr = Buffer.alloc(13);
|
|
53
|
-
ihdr.writeUInt32BE(size, 0);
|
|
54
|
-
ihdr.writeUInt32BE(size, 4);
|
|
55
|
-
ihdr[8] = 8; // bit depth
|
|
56
|
-
ihdr[9] = 6; // RGBA
|
|
57
|
-
return Buffer.concat([
|
|
58
|
-
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
59
|
-
chunk("IHDR", ihdr),
|
|
60
|
-
// level 9 so the bytes are a function of the pixels alone, not of zlib's default tuning.
|
|
61
|
-
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
|
62
|
-
chunk("IEND", Buffer.alloc(0)),
|
|
63
|
-
]);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
34
|
/** The eye mark, supersampled 4× so the curves do not alias at 16px. */
|
|
67
35
|
function draw(size) {
|
|
68
36
|
const S = 4;
|
|
@@ -118,7 +86,7 @@ mkdirSync(OUT, { recursive: true });
|
|
|
118
86
|
let stale = [];
|
|
119
87
|
for (const size of SIZES) {
|
|
120
88
|
const file = join(OUT, `icon-${size}.png`);
|
|
121
|
-
const bytes =
|
|
89
|
+
const bytes = encodePng({ width: size, height: size, data: draw(size) });
|
|
122
90
|
if (check) {
|
|
123
91
|
let current = null;
|
|
124
92
|
try {
|
package/scripts/ultra11y.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { join as join45, relative as relative5, sep as sep7, dirname as dirname1
|
|
|
6
6
|
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
|
|
7
7
|
|
|
8
8
|
// src/types.ts
|
|
9
|
-
var VERSION = "3.
|
|
9
|
+
var VERSION = "3.1.0";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -39131,7 +39131,7 @@ var colorsRules = [contrastLiteral];
|
|
|
39131
39131
|
import { readFileSync as readFileSync12 } from "fs";
|
|
39132
39132
|
|
|
39133
39133
|
// src/pixel.ts
|
|
39134
|
-
import { inflateSync } from "zlib";
|
|
39134
|
+
import { deflateSync, inflateSync } from "zlib";
|
|
39135
39135
|
var SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
39136
39136
|
function paeth(a, b, c2) {
|
|
39137
39137
|
const p = a + b - c2;
|
|
@@ -5,7 +5,7 @@ when_to_use: "Invoke WITHOUT being asked whenever changed front-end code should
|
|
|
5
5
|
allowed-tools: Bash(node ${CLAUDE_SKILL_DIR}/scripts/ultra11y.mjs *), Read, Grep, Glob
|
|
6
6
|
license: MIT
|
|
7
7
|
metadata:
|
|
8
|
-
version: 3.
|
|
8
|
+
version: 3.1.0
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# review-a11y — accessibility review of changed code
|
|
@@ -6,7 +6,7 @@ import { join as join45, relative as relative5, sep as sep7, dirname as dirname1
|
|
|
6
6
|
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
|
|
7
7
|
|
|
8
8
|
// src/types.ts
|
|
9
|
-
var VERSION = "3.
|
|
9
|
+
var VERSION = "3.1.0";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -39131,7 +39131,7 @@ var colorsRules = [contrastLiteral];
|
|
|
39131
39131
|
import { readFileSync as readFileSync12 } from "fs";
|
|
39132
39132
|
|
|
39133
39133
|
// src/pixel.ts
|
|
39134
|
-
import { inflateSync } from "zlib";
|
|
39134
|
+
import { deflateSync, inflateSync } from "zlib";
|
|
39135
39135
|
var SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
39136
39136
|
function paeth(a, b, c2) {
|
|
39137
39137
|
const p = a + b - c2;
|
package/skills/ultra11y/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: "Use to AUDIT existing HTML/CSS/JSX against WCAG 2.2 AA accessibili
|
|
|
4
4
|
when_to_use: "Invoke when the user asks for an accessibility AUDIT or a formal deliverable: audit a repo, site or page against WCAG 2.2 AA or a country standard (RGAA, Section 508, EN 301 549); produce a dated conformance report, a criterion grid, a PRD backlog, or tickets (GitHub/GitLab/Jira); author accessible markup from scratch; wire the repo gate (init --hook/--ci) or a rendered-DOM capture pipeline. For a review of the code UNDER CHANGE (diff, branch, PR), use the `review-a11y` skill instead."
|
|
5
5
|
license: MIT
|
|
6
6
|
metadata:
|
|
7
|
-
version: 3.
|
|
7
|
+
version: 3.1.0
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# ultra11y — audit WCAG 2.2 AA and write accessible markup
|
|
@@ -15,7 +15,7 @@ permissions:
|
|
|
15
15
|
steps:
|
|
16
16
|
- uses: actions/checkout@v4
|
|
17
17
|
with: { fetch-depth: 0 } # the diff gate needs the base ref
|
|
18
|
-
- uses: maxgfr/ultra11y@
|
|
18
|
+
- uses: maxgfr/ultra11y@v3 # or pin the exact version, as `init --ci` does
|
|
19
19
|
with:
|
|
20
20
|
since: auto # the PR's base branch
|
|
21
21
|
standard: rgaa
|
|
@@ -27,7 +27,7 @@ steps:
|
|
|
27
27
|
Page by page, with a real browser, in the same step:
|
|
28
28
|
|
|
29
29
|
```yaml
|
|
30
|
-
- uses: maxgfr/ultra11y@
|
|
30
|
+
- uses: maxgfr/ultra11y@v3
|
|
31
31
|
with:
|
|
32
32
|
standard: rgaa
|
|
33
33
|
start: npm run start
|
|
@@ -37,9 +37,11 @@ Page by page, with a real browser, in the same step:
|
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
`ultra11y init --ci` writes a workflow using it, **pinned to the exact engine version that
|
|
40
|
-
generated the file** (`@v<that version>`) so a CI run stays reproducible. `@
|
|
41
|
-
the release workflow
|
|
42
|
-
fixes automatically.
|
|
40
|
+
generated the file** (`@v<that version>`) so a CI run stays reproducible. `@v<major>` — `@v3`
|
|
41
|
+
today — is a moving alias the release workflow repoints at each release, for teams who would
|
|
42
|
+
rather take fixes automatically. It moves **within** a major only: when a breaking change cuts
|
|
43
|
+
the next one, the old alias freezes where it is, so a pipeline pinned to it keeps working and
|
|
44
|
+
stops receiving features. Never `@main`: it would change under you with no version to blame.
|
|
43
45
|
|
|
44
46
|
**Order matters, and it is deliberate**: the audit runs first, then SARIF, annotations, the
|
|
45
47
|
summary, the comment and the report — and the **gate runs last**. A failing audit has
|
|
@@ -64,7 +66,7 @@ partial by construction. `adjudicate` closes that — opt-in, in two modes.
|
|
|
64
66
|
env:
|
|
65
67
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # the job's env, never an input
|
|
66
68
|
steps:
|
|
67
|
-
- uses: maxgfr/ultra11y@
|
|
69
|
+
- uses: maxgfr/ultra11y@v3
|
|
68
70
|
with:
|
|
69
71
|
standard: rgaa
|
|
70
72
|
adjudicate: api # or `agent`
|
|
@@ -233,7 +235,7 @@ The action audits the **code** and, when you point it at a served app, the **pag
|
|
|
233
235
|
list can come from three places, and none of them has to be written by hand:
|
|
234
236
|
|
|
235
237
|
```yaml
|
|
236
|
-
- uses: maxgfr/ultra11y@
|
|
238
|
+
- uses: maxgfr/ultra11y@v3
|
|
237
239
|
with:
|
|
238
240
|
standard: rgaa
|
|
239
241
|
start: npm run start
|
|
@@ -284,9 +286,9 @@ one job — the code diff, then the served pages — died on a `409 Conflict`, w
|
|
|
284
286
|
written and never uploaded. Pass `artifact-name` on each invocation:
|
|
285
287
|
|
|
286
288
|
```yaml
|
|
287
|
-
- uses: maxgfr/ultra11y@
|
|
289
|
+
- uses: maxgfr/ultra11y@v3
|
|
288
290
|
with: { since: auto, artifact-name: a11y-code }
|
|
289
|
-
- uses: maxgfr/ultra11y@
|
|
291
|
+
- uses: maxgfr/ultra11y@v3
|
|
290
292
|
with: { crawl: http://localhost:3000, artifact-name: a11y-pages }
|
|
291
293
|
```
|
|
292
294
|
|
|
@@ -6,7 +6,7 @@ import { join as join45, relative as relative5, sep as sep7, dirname as dirname1
|
|
|
6
6
|
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
|
|
7
7
|
|
|
8
8
|
// src/types.ts
|
|
9
|
-
var VERSION = "3.
|
|
9
|
+
var VERSION = "3.1.0";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -39131,7 +39131,7 @@ var colorsRules = [contrastLiteral];
|
|
|
39131
39131
|
import { readFileSync as readFileSync12 } from "fs";
|
|
39132
39132
|
|
|
39133
39133
|
// src/pixel.ts
|
|
39134
|
-
import { inflateSync } from "zlib";
|
|
39134
|
+
import { deflateSync, inflateSync } from "zlib";
|
|
39135
39135
|
var SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
39136
39136
|
function paeth(a, b, c2) {
|
|
39137
39137
|
const p = a + b - c2;
|
package/src/evidence.ts
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
// EVIDENCE TIER — a non-conformity, shown.
|
|
2
|
+
//
|
|
3
|
+
// A rendered finding cites `.ultra11y/pages/accueil/dom.html:412` (`div.card`), and under the
|
|
4
|
+
// rules that anchor at the document element it cites `dom.html:2` — the same line, for
|
|
5
|
+
// hundreds of findings at once. `selectorHint` is doing all the locating work and it is lossy
|
|
6
|
+
// by construction (src/rules/rule.ts `selectorOf`: first class only, href truncated). For a
|
|
7
|
+
// reviewer who did not write the page, that is not a location. A picture is.
|
|
8
|
+
//
|
|
9
|
+
// Everything this needs is already on disk and already verified:
|
|
10
|
+
// • `screen.png` — the page as the browser painted it
|
|
11
|
+
// • `boxes.json` — every element's rect, joined to the DOM by document-order ordinal
|
|
12
|
+
// • `Finding.sourceStart` — the byte offset of the anchoring element in that same dom.html
|
|
13
|
+
// So the join is `sourceStart -> ordinal -> box -> pixels`, with no new capture, no new
|
|
14
|
+
// dependency, and no new field on Finding.
|
|
15
|
+
//
|
|
16
|
+
// WHY DERIVE, NOT STAMP. A box could have been written onto Finding when the rule fired.
|
|
17
|
+
// It is not, deliberately: a pixel rectangle is a property of an IMAGE, not of a finding. A
|
|
18
|
+
// stamped box survives into the audit JSON and goes silently wrong the moment that JSON is
|
|
19
|
+
// re-rendered against a re-captured page — and silently wrong is the one failure mode this
|
|
20
|
+
// engine refuses. Deriving re-runs the same verified join every time, and refuses wholesale
|
|
21
|
+
// when it does not hold, exactly as `align()` already does for styles and boxes.
|
|
22
|
+
//
|
|
23
|
+
// WHAT IT REFUSES. Most findings get no crop, and each reason is recorded and reported rather
|
|
24
|
+
// than left as a gap: a source finding has no pixels, a component capture was rendered by
|
|
25
|
+
// jsdom which paints nothing, a page-scope rule points at `<html>` and would "highlight" the
|
|
26
|
+
// whole screenshot, and — the one that surprises people — an element below the fold is simply
|
|
27
|
+
// not in the image, because every producer captures the VIEWPORT on purpose so the screenshot
|
|
28
|
+
// shares boxes.json's coordinate system (src/integrations/playwright.ts). None of these is a
|
|
29
|
+
// bug to work around; each is a fact to state.
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { findingId } from "./baseline.js";
|
|
34
|
+
import { parseHtml } from "./parse/html.js";
|
|
35
|
+
import { cropImage, decodePng, encodePng, fillRect, strokeRect } from "./pixel.js";
|
|
36
|
+
import { PAGES_DIR, alignedBoxes, isSnapshotDom, snapshotDir, validateSnapshotMeta } from "./snapshot.js";
|
|
37
|
+
import type { Image, Raster, Rect } from "./pixel.js";
|
|
38
|
+
import type { SnapshotMeta, SnapshotViewport } from "./snapshot.js";
|
|
39
|
+
import type { AuditResult, BoxDigest, BoxEntry, Finding, Lang } from "./types.js";
|
|
40
|
+
|
|
41
|
+
/** Why a finding got no picture. Every one of these is REPORTED, never swallowed: a reader
|
|
42
|
+
* who sees fewer images than non-conformities must be told which, and why. */
|
|
43
|
+
export type EvidenceSkip =
|
|
44
|
+
| "no-snapshot" // not raised on a page snapshot's dom.html (source finding, component capture)
|
|
45
|
+
| "no-screenshot" // the producer captured no image for this page
|
|
46
|
+
| "unreadable-image" // screen.png is not 8-bit truecolour, or is corrupt
|
|
47
|
+
| "no-boxes" // no boxes.json, or it did not verify against this dom.html
|
|
48
|
+
| "truncated" // the collector hit its element cap; this element has no box
|
|
49
|
+
| "no-offsets" // the finding carries no sourceStart (cannot be joined)
|
|
50
|
+
| "unjoinable" // sourceStart matches no element in dom.html
|
|
51
|
+
| "page-scope" // anchored at the document element: the page screenshot IS the illustration
|
|
52
|
+
| "zero-area" // the element has no painted box (display:contents, empty inline)
|
|
53
|
+
| "below-the-fold" // outside a viewport-only capture
|
|
54
|
+
| "unknown-scale" // image-pixels-per-CSS-pixel is indeterminate
|
|
55
|
+
| "capped"; // de-duplicated away by the caps below
|
|
56
|
+
|
|
57
|
+
export interface EvidenceGeometry {
|
|
58
|
+
/** Context kept around the element, in CSS pixels. */
|
|
59
|
+
pad: number;
|
|
60
|
+
minWidth: number;
|
|
61
|
+
minHeight: number;
|
|
62
|
+
maxWidth: number;
|
|
63
|
+
maxHeight: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The MINIMUM is the load-bearing number. A `rendered-link-colour-only` box is a 40×18 inline
|
|
67
|
+
// link; cropped tight it is an unreadable smear, and the whole question that rule asks —
|
|
68
|
+
// "is this distinguishable from the running text around it?" — is a question about the
|
|
69
|
+
// surroundings. So the crop is padded out to something a human can judge.
|
|
70
|
+
export const DEFAULT_GEOMETRY: EvidenceGeometry = { pad: 24, minWidth: 320, minHeight: 120, maxWidth: 960, maxHeight: 540 };
|
|
71
|
+
|
|
72
|
+
/** De-duplication. Issue #16's real audit holds 472 findings of ONE rule across 38 pages for
|
|
73
|
+
* 7 distinct selectors: one image per occurrence would be 472 pictures of the same defect.
|
|
74
|
+
* One per (page, rule, selector), then hard caps — and the report says what it did not draw. */
|
|
75
|
+
export const DEFAULT_CAPS = { perRule: 6, perPage: 12, total: 200 };
|
|
76
|
+
|
|
77
|
+
export interface PageEvidenceContext {
|
|
78
|
+
pageId: string;
|
|
79
|
+
img: Image;
|
|
80
|
+
/** Image pixels per CSS pixel. Derived, never assumed — see `resolveScale`. */
|
|
81
|
+
scale: number;
|
|
82
|
+
viewport: SnapshotViewport;
|
|
83
|
+
/** `Finding.sourceStart` → document-order ordinal, the key `boxes.json` is joined on. */
|
|
84
|
+
ordinalOf: Map<number, number>;
|
|
85
|
+
boxes: Map<number, BoxEntry>;
|
|
86
|
+
truncated: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface EvidenceCrop {
|
|
90
|
+
findingId: string;
|
|
91
|
+
page: string;
|
|
92
|
+
ruleId: string;
|
|
93
|
+
criteriaId: string;
|
|
94
|
+
/** Path relative to the output directory, for a Markdown or HTML `src`. */
|
|
95
|
+
href: string;
|
|
96
|
+
/** Absolute path on disk. */
|
|
97
|
+
path: string;
|
|
98
|
+
width: number;
|
|
99
|
+
height: number;
|
|
100
|
+
box: BoxEntry;
|
|
101
|
+
scale: number;
|
|
102
|
+
/** The element ran past the capture edge: what is drawn is part of it, not all of it. */
|
|
103
|
+
clipped: boolean;
|
|
104
|
+
alt: Record<Lang, string>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface EvidencePageTally {
|
|
108
|
+
located: number;
|
|
109
|
+
imaged: number;
|
|
110
|
+
skipped: Partial<Record<EvidenceSkip, number>>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface EvidenceManifest {
|
|
114
|
+
crops: Map<string, EvidenceCrop>;
|
|
115
|
+
skipped: Map<string, EvidenceSkip>;
|
|
116
|
+
perPage: Map<string, EvidencePageTally>;
|
|
117
|
+
totals: EvidencePageTally;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface EvidenceOptions {
|
|
121
|
+
/** Where `assets/<page-id>/` is written. */
|
|
122
|
+
outDir: string;
|
|
123
|
+
/** Repository root holding `.ultra11y/pages`. Defaults to the process cwd. */
|
|
124
|
+
root?: string;
|
|
125
|
+
caps?: Partial<typeof DEFAULT_CAPS>;
|
|
126
|
+
geometry?: Partial<EvidenceGeometry>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The ring. Red alone would encode the meaning by colour, which is a 1.4.1 failure — and this
|
|
130
|
+
// tool exists to find those. So the mark carries three independent channels: a light halo (so
|
|
131
|
+
// it survives a dark page), a solid ring, and corner brackets whose SHAPE says "here" with no
|
|
132
|
+
// hue at all. A dashed inner line adds texture for a monochrome print.
|
|
133
|
+
const HALO = { r: 255, g: 255, b: 255, a: 0.92 };
|
|
134
|
+
const MARK = { r: 179, g: 38, b: 30, a: 1 }; // #b3261e — the NC colour the reports already use
|
|
135
|
+
const RING = 3;
|
|
136
|
+
|
|
137
|
+
/** The page id of a snapshot dom.html path, or null when it is not one. */
|
|
138
|
+
export function pageIdOfSnapshot(file: string): string | null {
|
|
139
|
+
if (!isSnapshotDom(file)) return null;
|
|
140
|
+
const posix = file.split("\\").join("/");
|
|
141
|
+
const at = posix.lastIndexOf(`${PAGES_DIR}/`);
|
|
142
|
+
if (at < 0) return null;
|
|
143
|
+
const rest = posix.slice(at + PAGES_DIR.length + 1);
|
|
144
|
+
const id = rest.slice(0, rest.indexOf("/"));
|
|
145
|
+
return id || null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readJson<T>(file: string): T | undefined {
|
|
149
|
+
try {
|
|
150
|
+
return JSON.parse(readFileSync(file, "utf8")) as T;
|
|
151
|
+
} catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Image pixels per CSS pixel.
|
|
157
|
+
*
|
|
158
|
+
* Nothing records `devicePixelRatio`, so it is derived from the image against the viewport
|
|
159
|
+
* the collector recorded. When that is unusable, the `<html>` box is the fallback — it is by
|
|
160
|
+
* definition the full viewport width. When BOTH are unusable the answer is genuinely unknown,
|
|
161
|
+
* and the honest result is no crop: a guessed scale of 1 on a 2× capture rings the wrong half
|
|
162
|
+
* of the page, which is worse than no picture. */
|
|
163
|
+
function resolveScale(img: Image, viewport: SnapshotViewport | undefined, boxes: Map<number, BoxEntry>): number | null {
|
|
164
|
+
const candidates = [viewport?.width, boxes.get(0)?.w];
|
|
165
|
+
for (const w of candidates) {
|
|
166
|
+
if (!w || w <= 0) continue;
|
|
167
|
+
const scale = img.width / w;
|
|
168
|
+
// A capture is 1×, 2× or 3×. Anything outside that band means the image and the boxes do
|
|
169
|
+
// not describe the same render, and pretending otherwise would misplace every ring.
|
|
170
|
+
if (scale >= 0.5 && scale <= 4) return scale;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Open a page snapshot's evidence, or say why it has none. Reads meta.json, dom.html,
|
|
176
|
+
* boxes.json and screen.png ONLY — styles.json and css.json can be megabytes and answer
|
|
177
|
+
* nothing here. */
|
|
178
|
+
export function loadPageEvidence(root: string, pageId: string): PageEvidenceContext | { skip: EvidenceSkip } {
|
|
179
|
+
const dir = snapshotDir(root, pageId);
|
|
180
|
+
const domFile = join(dir, "dom.html");
|
|
181
|
+
const shotFile = join(dir, "screen.png");
|
|
182
|
+
if (!existsSync(domFile)) return { skip: "no-snapshot" };
|
|
183
|
+
if (!existsSync(shotFile)) return { skip: "no-screenshot" };
|
|
184
|
+
|
|
185
|
+
let dom: string;
|
|
186
|
+
let shot: Buffer;
|
|
187
|
+
try {
|
|
188
|
+
dom = readFileSync(domFile, "utf8");
|
|
189
|
+
shot = readFileSync(shotFile);
|
|
190
|
+
} catch {
|
|
191
|
+
return { skip: "no-snapshot" };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const img = decodePng(shot);
|
|
195
|
+
if (!img) return { skip: "unreadable-image" };
|
|
196
|
+
|
|
197
|
+
const digest = readJson<BoxDigest>(join(dir, "boxes.json"));
|
|
198
|
+
if (!digest) return { skip: "no-boxes" };
|
|
199
|
+
const doc = parseHtml(dom, domFile);
|
|
200
|
+
const boxes = alignedBoxes(dom, digest, doc);
|
|
201
|
+
if (!boxes) return { skip: "no-boxes" };
|
|
202
|
+
|
|
203
|
+
const scale = resolveScale(img, metaOf(dir)?.viewport, boxes);
|
|
204
|
+
if (scale === null) return { skip: "unknown-scale" };
|
|
205
|
+
|
|
206
|
+
const ordinalOf = new Map<number, number>();
|
|
207
|
+
for (let i = 0; i < doc.elements.length; i++) {
|
|
208
|
+
const start = doc.elements[i]?.start;
|
|
209
|
+
if (start !== undefined && !ordinalOf.has(start)) ordinalOf.set(start, i);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const viewport: SnapshotViewport = metaOf(dir)?.viewport ?? { width: Math.round(img.width / scale), height: Math.round(img.height / scale) };
|
|
213
|
+
return { pageId, img, scale, viewport, ordinalOf, boxes, truncated: Boolean(digest.truncated) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function metaOf(dir: string): SnapshotMeta | undefined {
|
|
217
|
+
const raw = readJson<unknown>(join(dir, "meta.json"));
|
|
218
|
+
if (raw === undefined) return undefined;
|
|
219
|
+
const v = validateSnapshotMeta(raw);
|
|
220
|
+
return v.ok && v.meta ? v.meta : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The crop rect around an element box, in IMAGE pixels: padded for context, grown to the
|
|
224
|
+
* legible minimum, capped at the maximum, and slid back inside the image rather than
|
|
225
|
+
* hanging off it. */
|
|
226
|
+
function cropRect(box: BoxEntry, g: EvidenceGeometry, scale: number, img: Image): Rect {
|
|
227
|
+
const bx = box.x * scale;
|
|
228
|
+
const by = box.y * scale;
|
|
229
|
+
const bw = box.w * scale;
|
|
230
|
+
const bh = box.h * scale;
|
|
231
|
+
const w = Math.min(Math.max(bw + g.pad * 2 * scale, g.minWidth * scale), g.maxWidth * scale, img.width);
|
|
232
|
+
const h = Math.min(Math.max(bh + g.pad * 2 * scale, g.minHeight * scale), g.maxHeight * scale, img.height);
|
|
233
|
+
let x = bx + bw / 2 - w / 2;
|
|
234
|
+
let y = by + bh / 2 - h / 2;
|
|
235
|
+
x = Math.max(0, Math.min(x, img.width - w));
|
|
236
|
+
y = Math.max(0, Math.min(y, img.height - h));
|
|
237
|
+
return { x, y, w, h };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Ring the element inside its crop. Three channels, none of them colour alone. */
|
|
241
|
+
function annotate(raster: Raster, target: Rect): void {
|
|
242
|
+
strokeRect(raster, { x: target.x - RING, y: target.y - RING, w: target.w + RING * 2, h: target.h + RING * 2 }, HALO, RING);
|
|
243
|
+
strokeRect(raster, target, MARK, RING);
|
|
244
|
+
strokeRect(raster, { x: target.x + RING, y: target.y + RING, w: target.w - RING * 2, h: target.h - RING * 2 }, HALO, 1, 6);
|
|
245
|
+
|
|
246
|
+
// Corner brackets: a shape cue that survives greyscale, a colour-blind reader and a print.
|
|
247
|
+
const arm = Math.max(6, Math.min(18, Math.round(Math.min(target.w, target.h) / 3)));
|
|
248
|
+
const t = RING;
|
|
249
|
+
const x0 = target.x - RING;
|
|
250
|
+
const y0 = target.y - RING;
|
|
251
|
+
const x1 = target.x + target.w + RING;
|
|
252
|
+
const y1 = target.y + target.h + RING;
|
|
253
|
+
for (const [cx, cy, dx, dy] of [
|
|
254
|
+
[x0, y0, 1, 1],
|
|
255
|
+
[x1, y0, -1, 1],
|
|
256
|
+
[x0, y1, 1, -1],
|
|
257
|
+
[x1, y1, -1, -1],
|
|
258
|
+
] as const) {
|
|
259
|
+
fillRect(raster, { x: dx > 0 ? cx : cx - arm, y: dy > 0 ? cy - t : cy, w: arm, h: t }, MARK);
|
|
260
|
+
fillRect(raster, { x: dx > 0 ? cx - t : cx, y: dy > 0 ? cy : cy - arm, w: t, h: arm }, MARK);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface CroppedFinding {
|
|
265
|
+
png: Buffer;
|
|
266
|
+
width: number;
|
|
267
|
+
height: number;
|
|
268
|
+
box: BoxEntry;
|
|
269
|
+
scale: number;
|
|
270
|
+
clipped: boolean;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** One finding → one annotated PNG, or the reason there is none. */
|
|
274
|
+
export function cropFinding(ctx: PageEvidenceContext, f: Finding, geometry?: Partial<EvidenceGeometry>): CroppedFinding | { skip: EvidenceSkip } {
|
|
275
|
+
const g = { ...DEFAULT_GEOMETRY, ...geometry };
|
|
276
|
+
if (f.sourceStart === undefined) return { skip: "no-offsets" };
|
|
277
|
+
const ordinal = ctx.ordinalOf.get(f.sourceStart);
|
|
278
|
+
if (ordinal === undefined) return { skip: "unjoinable" };
|
|
279
|
+
// The document element: its box IS the page, so ringing it would outline the screenshot.
|
|
280
|
+
// The page's own screenshot already illustrates that, and the sheet already shows it.
|
|
281
|
+
if (ordinal === 0) return { skip: "page-scope" };
|
|
282
|
+
const box = ctx.boxes.get(ordinal);
|
|
283
|
+
if (!box) return { skip: ctx.truncated ? "truncated" : "no-boxes" };
|
|
284
|
+
if (box.w <= 0 || box.h <= 0) return { skip: "zero-area" };
|
|
285
|
+
const vpArea = ctx.viewport.width * ctx.viewport.height;
|
|
286
|
+
if (vpArea > 0 && (box.w * box.h) / vpArea >= 0.7) return { skip: "page-scope" };
|
|
287
|
+
|
|
288
|
+
// Viewport-only captures, on every producer, on purpose: the screenshot shares boxes.json's
|
|
289
|
+
// coordinate system. An element mostly below the fold is not in the image to be drawn.
|
|
290
|
+
const visibleTop = Math.max(0, box.y * ctx.scale);
|
|
291
|
+
const visibleBottom = Math.min(ctx.img.height, (box.y + box.h) * ctx.scale);
|
|
292
|
+
const visibleFraction = box.h > 0 ? (visibleBottom - visibleTop) / (box.h * ctx.scale) : 0;
|
|
293
|
+
if (visibleFraction < 0.5) return { skip: "below-the-fold" };
|
|
294
|
+
|
|
295
|
+
const rect = cropRect(box, g, ctx.scale, ctx.img);
|
|
296
|
+
const crop = cropImage(ctx.img, rect);
|
|
297
|
+
if (!crop) return { skip: "below-the-fold" };
|
|
298
|
+
|
|
299
|
+
annotate(crop.raster, {
|
|
300
|
+
x: box.x * ctx.scale - crop.rect.x,
|
|
301
|
+
y: box.y * ctx.scale - crop.rect.y,
|
|
302
|
+
w: box.w * ctx.scale,
|
|
303
|
+
h: box.h * ctx.scale,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
png: encodePng(crop.raster),
|
|
308
|
+
width: crop.raster.width,
|
|
309
|
+
height: crop.raster.height,
|
|
310
|
+
box,
|
|
311
|
+
scale: ctx.scale,
|
|
312
|
+
clipped: visibleFraction < 1,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- prose -------------------------------------------------------------------------------
|
|
317
|
+
// Every refusal has a sentence, in both languages. A missing image with no explanation reads
|
|
318
|
+
// as "the tool found nothing here", which is the opposite of what happened.
|
|
319
|
+
|
|
320
|
+
const S: Record<Lang, { alt: (sel: string, page: string) => string; notImaged: (n: number) => string; reasons: Record<EvidenceSkip, string> }> = {
|
|
321
|
+
fr: {
|
|
322
|
+
// No `N.N —` sequence: src/check.ts scans the WHOLE document for criterion mentions.
|
|
323
|
+
alt: (sel, page) => `Capture recadrée de l'élément ${sel} sur la page ${page}, entouré d'un cadre`,
|
|
324
|
+
notImaged: (n) => `${n} occurrence(s) ne sont pas illustrées :`,
|
|
325
|
+
reasons: {
|
|
326
|
+
"no-snapshot": "constat levé sur le code source, pas sur une page capturée — il n'y a pas de pixels à montrer",
|
|
327
|
+
"no-screenshot": "le producteur n'a pas fourni de capture pour cette page, donc le niveau pixel est inactif ici",
|
|
328
|
+
"unreadable-image": "la capture n'est pas décodable (le moteur lit le truecolour 8 bits uniquement) — mieux vaut aucune image qu'une fausse",
|
|
329
|
+
"no-boxes":
|
|
330
|
+
"les positions d'éléments ne se vérifient pas contre le DOM sérialisé, elles ont donc été refusées en bloc plutôt que d'encadrer le mauvais élément",
|
|
331
|
+
truncated: "la page dépasse la limite de collecte : aucune position n'a été enregistrée pour cet élément",
|
|
332
|
+
"no-offsets": "le constat ne porte pas d'ancrage dans la source, il ne peut pas être joint à une position",
|
|
333
|
+
unjoinable: "l'ancrage du constat ne correspond à aucun élément du DOM sérialisé",
|
|
334
|
+
"page-scope": "le constat porte sur la page entière — la capture de page ci-dessus est l'illustration",
|
|
335
|
+
"zero-area": "l'élément n'occupe aucune surface peinte",
|
|
336
|
+
"below-the-fold": "l'élément est hors de la capture (la capture couvre la fenêtre, pas la page entière) — sa référence fichier:ligne reste valable",
|
|
337
|
+
"unknown-scale": "le rapport pixels/CSS est indéterminé pour cette page ; un rapport deviné encadrerait le mauvais endroit",
|
|
338
|
+
capped: "occurrences regroupées : une illustration par élément distinct, dans la limite fixée",
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
en: {
|
|
342
|
+
alt: (sel, page) => `Cropped screenshot of the ${sel} element on the ${page} page, outlined`,
|
|
343
|
+
notImaged: (n) => `${n} occurrence(s) are not illustrated:`,
|
|
344
|
+
reasons: {
|
|
345
|
+
"no-snapshot": "raised on source code, not on a captured page — there are no pixels to show",
|
|
346
|
+
"no-screenshot": "the producer supplied no screenshot for this page, so the pixel tier is inactive here",
|
|
347
|
+
"unreadable-image": "the screenshot does not decode (the engine reads 8-bit truecolour only) — no image beats a wrong one",
|
|
348
|
+
"no-boxes": "the element positions do not verify against the serialized DOM, so they were refused wholesale rather than outlining the wrong element",
|
|
349
|
+
truncated: "the page exceeded the collection cap: no position was recorded for this element",
|
|
350
|
+
"no-offsets": "the finding carries no source anchor, so it cannot be joined to a position",
|
|
351
|
+
unjoinable: "the finding's anchor matches no element in the serialized DOM",
|
|
352
|
+
"page-scope": "the finding is about the whole page — the page screenshot above is the illustration",
|
|
353
|
+
"zero-area": "the element paints no box",
|
|
354
|
+
"below-the-fold": "the element sits outside the capture (the screenshot covers the viewport, not the whole page) — its file:line reference still holds",
|
|
355
|
+
"unknown-scale": "the image-pixel to CSS-pixel ratio is indeterminate for this page; a guessed ratio would outline the wrong place",
|
|
356
|
+
capped: "occurrences grouped: one illustration per distinct element, up to the cap",
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
/** What the report says about what it did NOT draw — per page, or overall when `pageId` is
|
|
362
|
+
* null. Empty when everything located was imaged. */
|
|
363
|
+
export function evidenceNotice(m: EvidenceManifest, pageId: string | null, lang: Lang): string[] {
|
|
364
|
+
const t = pageId === null ? m.totals : m.perPage.get(pageId);
|
|
365
|
+
if (!t) return [];
|
|
366
|
+
const missing = t.located - t.imaged;
|
|
367
|
+
if (missing <= 0) return [];
|
|
368
|
+
const s = S[lang];
|
|
369
|
+
const out = [s.notImaged(missing)];
|
|
370
|
+
for (const [reason, n] of Object.entries(t.skipped).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))) {
|
|
371
|
+
if (!n) continue;
|
|
372
|
+
out.push(`- ${n} — ${s.reasons[reason as EvidenceSkip]}`);
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---- the run -----------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
function bump(t: EvidencePageTally, reason: EvidenceSkip): void {
|
|
380
|
+
t.skipped[reason] = (t.skipped[reason] ?? 0) + 1;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** A filesystem-safe, stable, collision-free name for a finding's crop. Derived from the same
|
|
384
|
+
* identity SARIF fingerprints use (src/baseline.ts `findingId`), hashed only because that
|
|
385
|
+
* identity contains path separators and pipes. The identity itself is never altered. */
|
|
386
|
+
function cropName(f: Finding): string {
|
|
387
|
+
return `${createHash("sha1").update(findingId(f)).digest("hex").slice(0, 12)}.png`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Draw every non-conformity that can be drawn, write the PNGs under
|
|
391
|
+
* `<outDir>/assets/<page-id>/`, and record what was not drawn and why.
|
|
392
|
+
*
|
|
393
|
+
* Pages are the OUTER loop and the context is dropped between them on purpose: a 2560×1440
|
|
394
|
+
* screenshot inflates to ~15 MB, and holding 38 of them at once does not fit. Do not hoist. */
|
|
395
|
+
export function writeEvidence(result: AuditResult, opts: EvidenceOptions): EvidenceManifest {
|
|
396
|
+
const root = opts.root ?? process.cwd();
|
|
397
|
+
const caps = { ...DEFAULT_CAPS, ...opts.caps };
|
|
398
|
+
const manifest: EvidenceManifest = {
|
|
399
|
+
crops: new Map(),
|
|
400
|
+
skipped: new Map(),
|
|
401
|
+
perPage: new Map(),
|
|
402
|
+
totals: { located: 0, imaged: 0, skipped: {} },
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// The discriminator is the snapshot PATH, never `f.page`: attribution stamps a page id on
|
|
406
|
+
// source findings too (src/pages.ts), and those have no pixels.
|
|
407
|
+
const byPage = new Map<string, Finding[]>();
|
|
408
|
+
for (const f of [...result.findings, ...(result.packFindings ?? [])]) {
|
|
409
|
+
if (f.advisory) continue;
|
|
410
|
+
const page = pageIdOfSnapshot(f.file);
|
|
411
|
+
if (!page) continue;
|
|
412
|
+
const list = byPage.get(page);
|
|
413
|
+
if (list) list.push(f);
|
|
414
|
+
else byPage.set(page, [f]);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
for (const [pageId, findings] of [...byPage].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
418
|
+
const tally: EvidencePageTally = { located: findings.length, imaged: 0, skipped: {} };
|
|
419
|
+
manifest.perPage.set(pageId, tally);
|
|
420
|
+
manifest.totals.located += findings.length;
|
|
421
|
+
|
|
422
|
+
const ctx = loadPageEvidence(root, pageId);
|
|
423
|
+
if ("skip" in ctx) {
|
|
424
|
+
for (const f of findings) {
|
|
425
|
+
manifest.skipped.set(findingId(f), ctx.skip);
|
|
426
|
+
bump(tally, ctx.skip);
|
|
427
|
+
bump(manifest.totals, ctx.skip);
|
|
428
|
+
}
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const dir = join(opts.outDir, "assets", pageId);
|
|
433
|
+
let wrote = false;
|
|
434
|
+
const perRule = new Map<string, number>();
|
|
435
|
+
const seen = new Set<string>();
|
|
436
|
+
let onPage = 0;
|
|
437
|
+
|
|
438
|
+
for (const f of findings) {
|
|
439
|
+
const id = findingId(f);
|
|
440
|
+
// One image per distinct element per page: 472 findings of one rule collapse to the
|
|
441
|
+
// handful of DIFFERENT things actually wrong.
|
|
442
|
+
const key = `${f.ruleId}\u0000${f.selectorHint}`;
|
|
443
|
+
const ruleCount = perRule.get(f.ruleId) ?? 0;
|
|
444
|
+
if (seen.has(key) || ruleCount >= caps.perRule || onPage >= caps.perPage || manifest.crops.size >= caps.total) {
|
|
445
|
+
manifest.skipped.set(id, "capped");
|
|
446
|
+
bump(tally, "capped");
|
|
447
|
+
bump(manifest.totals, "capped");
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const crop = cropFinding(ctx, f, opts.geometry);
|
|
452
|
+
if ("skip" in crop) {
|
|
453
|
+
manifest.skipped.set(id, crop.skip);
|
|
454
|
+
bump(tally, crop.skip);
|
|
455
|
+
bump(manifest.totals, crop.skip);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const name = cropName(f);
|
|
460
|
+
const path = join(dir, name);
|
|
461
|
+
try {
|
|
462
|
+
if (!wrote) {
|
|
463
|
+
mkdirSync(dir, { recursive: true });
|
|
464
|
+
wrote = true;
|
|
465
|
+
}
|
|
466
|
+
writeFileSync(path, crop.png);
|
|
467
|
+
} catch {
|
|
468
|
+
// A failed image write must never fail a report — the same posture the screenshot
|
|
469
|
+
// copier already takes. The finding keeps its file:line and is counted as not imaged.
|
|
470
|
+
manifest.skipped.set(id, "no-screenshot");
|
|
471
|
+
bump(tally, "no-screenshot");
|
|
472
|
+
bump(manifest.totals, "no-screenshot");
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
seen.add(key);
|
|
477
|
+
perRule.set(f.ruleId, ruleCount + 1);
|
|
478
|
+
onPage++;
|
|
479
|
+
tally.imaged++;
|
|
480
|
+
manifest.totals.imaged++;
|
|
481
|
+
manifest.crops.set(id, {
|
|
482
|
+
findingId: id,
|
|
483
|
+
page: pageId,
|
|
484
|
+
ruleId: f.ruleId,
|
|
485
|
+
criteriaId: f.criteriaId,
|
|
486
|
+
href: `./assets/${pageId}/${name}`,
|
|
487
|
+
path,
|
|
488
|
+
width: crop.width,
|
|
489
|
+
height: crop.height,
|
|
490
|
+
box: crop.box,
|
|
491
|
+
scale: crop.scale,
|
|
492
|
+
clipped: crop.clipped,
|
|
493
|
+
alt: { fr: S.fr.alt(f.selectorHint, pageId), en: S.en.alt(f.selectorHint, pageId) },
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return manifest;
|
|
499
|
+
}
|
package/src/pixel.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// PIXEL TIER — reading a page screenshot, with no dependencies.
|
|
1
|
+
// PIXEL TIER — reading a page screenshot, and writing one back, with no dependencies.
|
|
2
2
|
//
|
|
3
3
|
// Why bother, when the computed styles already give us colours? Because `getComputedStyle`
|
|
4
4
|
// answers with the CSS *declaration*, not with what a user sees. Text over a gradient, over a
|
|
@@ -12,7 +12,17 @@
|
|
|
12
12
|
// Playwright actually emits is supported — 8-bit truecolour, with or without alpha. Anything
|
|
13
13
|
// else returns null rather than decoding garbage, because a wrong pixel here becomes a
|
|
14
14
|
// fabricated non-conformity.
|
|
15
|
-
|
|
15
|
+
//
|
|
16
|
+
// The WRITE side (`encodePng` and the small raster ops beneath it) is the exact reciprocal,
|
|
17
|
+
// and exists so a non-conformity can be shown rather than only cited: `src/evidence.ts` crops
|
|
18
|
+
// the screenshot around the offending element and rings it. It is deliberately the same
|
|
19
|
+
// encoder `scripts/build-extension-icons.mjs` uses, so the committed icons double as an
|
|
20
|
+
// external PNG-conformance fixture that a decode/encode round-trip could never provide.
|
|
21
|
+
//
|
|
22
|
+
// THIS MODULE IS A LEAF, and must stay one: exactly one runtime import (`node:zlib`),
|
|
23
|
+
// everything else `import type`. The icon script imports it directly through Node's
|
|
24
|
+
// type-stripping, which a value import would break.
|
|
25
|
+
import { deflateSync, inflateSync } from "node:zlib";
|
|
16
26
|
import type { RGBA } from "./color.js";
|
|
17
27
|
|
|
18
28
|
const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
@@ -215,3 +225,165 @@ export function dominantBackground(img: Image, rect: Rect): RGBA | null {
|
|
|
215
225
|
if (!best || best.n / total < DOMINANCE) return null;
|
|
216
226
|
return { r: Math.round(best.r / best.n), g: Math.round(best.g / best.n), b: Math.round(best.b / best.n), a: 1 };
|
|
217
227
|
}
|
|
228
|
+
|
|
229
|
+
// ---- the write side ---------------------------------------------------------------------
|
|
230
|
+
//
|
|
231
|
+
// A mutable pixel buffer, unlike `Image` which is a read-only accessor over decoded bytes.
|
|
232
|
+
// The two are deliberately different types: everything that only READS a screenshot keeps
|
|
233
|
+
// taking `Image`, so adding the encoder cannot accidentally hand a rule a writable page.
|
|
234
|
+
|
|
235
|
+
/** Row-major RGBA8. `data` is exactly `width * height * 4` bytes. */
|
|
236
|
+
export interface Raster {
|
|
237
|
+
width: number;
|
|
238
|
+
height: number;
|
|
239
|
+
data: Buffer;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A cropped raster, with the rect it actually took — which is NOT the rect that was asked
|
|
243
|
+
* for when the request ran off the edge. The caller needs the real one to translate an
|
|
244
|
+
* element box into crop-local coordinates. */
|
|
245
|
+
export interface Crop {
|
|
246
|
+
raster: Raster;
|
|
247
|
+
rect: Rect;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The standard PNG CRC-32 (IEEE 802.3), table-driven because a screenshot's IDAT is megabytes
|
|
251
|
+
// and the bit-at-a-time loop is measurably slower over that. Same polynomial, same output.
|
|
252
|
+
// `@__PURE__` so a bundle that never encodes drops the table with the rest of the write side.
|
|
253
|
+
// Without it esbuild must assume the IIFE has side effects and keeps 1 KB of dead constant.
|
|
254
|
+
const CRC_TABLE = /* @__PURE__ */ (() => {
|
|
255
|
+
const t = new Uint32Array(256);
|
|
256
|
+
for (let n = 0; n < 256; n++) {
|
|
257
|
+
let c = n;
|
|
258
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
259
|
+
t[n] = c >>> 0;
|
|
260
|
+
}
|
|
261
|
+
return t;
|
|
262
|
+
})();
|
|
263
|
+
|
|
264
|
+
function crc32(buf: Buffer): number {
|
|
265
|
+
let c = 0xffffffff;
|
|
266
|
+
for (let i = 0; i < buf.length; i++) c = (CRC_TABLE[(c ^ (buf[i] as number)) & 0xff] as number) ^ (c >>> 8);
|
|
267
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function chunk(type: string, data: Buffer): Buffer {
|
|
271
|
+
const len = Buffer.alloc(4);
|
|
272
|
+
len.writeUInt32BE(data.length);
|
|
273
|
+
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
|
|
274
|
+
const crc = Buffer.alloc(4);
|
|
275
|
+
crc.writeUInt32BE(crc32(body));
|
|
276
|
+
return Buffer.concat([len, body, crc]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** An RGBA raster → a PNG. Colour type 6, 8-bit, filter 0 on every scanline.
|
|
280
|
+
*
|
|
281
|
+
* Filter 0 and `level: 9` are not tuning knobs, they are the reproducibility contract: the
|
|
282
|
+
* bytes are then a function of the pixels alone, never of zlib's defaults or of a heuristic
|
|
283
|
+
* filter choice that could change between Node releases. `scripts/build-extension-icons.mjs`
|
|
284
|
+
* `--check` proves the committed icons still match, which only holds while this stays fixed.
|
|
285
|
+
*
|
|
286
|
+
* Throws when `data` disagrees with the dimensions — that is a programming error upstream,
|
|
287
|
+
* never user input, and silently padding it would write a skewed image. */
|
|
288
|
+
export function encodePng(r: Raster): Buffer {
|
|
289
|
+
const expected = r.width * r.height * 4;
|
|
290
|
+
if (r.width <= 0 || r.height <= 0) throw new Error(`encodePng: refusing a ${r.width}×${r.height} raster`);
|
|
291
|
+
if (r.data.length !== expected) {
|
|
292
|
+
throw new Error(`encodePng: ${r.width}×${r.height} needs ${expected} bytes of RGBA, got ${r.data.length}`);
|
|
293
|
+
}
|
|
294
|
+
const stride = r.width * 4;
|
|
295
|
+
const raw = Buffer.alloc(r.height * (stride + 1));
|
|
296
|
+
for (let y = 0; y < r.height; y++) {
|
|
297
|
+
raw[y * (stride + 1)] = 0; // filter: none
|
|
298
|
+
r.data.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
|
299
|
+
}
|
|
300
|
+
const ihdr = Buffer.alloc(13);
|
|
301
|
+
ihdr.writeUInt32BE(r.width, 0);
|
|
302
|
+
ihdr.writeUInt32BE(r.height, 4);
|
|
303
|
+
ihdr[8] = 8; // bit depth
|
|
304
|
+
ihdr[9] = 6; // RGBA
|
|
305
|
+
// bytes 10..12 stay 0: deflate compression, adaptive filtering, no interlace.
|
|
306
|
+
return Buffer.concat([Buffer.from(SIGNATURE), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", Buffer.alloc(0))]);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Copy a region of a decoded image into a writable raster. Null when nothing of the rect is
|
|
310
|
+
* inside — inherited from `clamp`, so a request that ran entirely off the page yields no
|
|
311
|
+
* image at all rather than a fabricated one. */
|
|
312
|
+
export function cropImage(img: Image, rect: Rect): Crop | null {
|
|
313
|
+
const r = clamp(img, rect);
|
|
314
|
+
if (!r) return null;
|
|
315
|
+
const data = Buffer.alloc(r.w * r.h * 4);
|
|
316
|
+
let i = 0;
|
|
317
|
+
for (let y = r.y; y < r.y + r.h; y++) {
|
|
318
|
+
for (let x = r.x; x < r.x + r.w; x++) {
|
|
319
|
+
const p = img.at(x, y);
|
|
320
|
+
data[i] = p?.r ?? 0;
|
|
321
|
+
data[i + 1] = p?.g ?? 0;
|
|
322
|
+
data[i + 2] = p?.b ?? 0;
|
|
323
|
+
data[i + 3] = Math.round((p?.a ?? 1) * 255);
|
|
324
|
+
i += 4;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return { raster: { width: r.w, height: r.h, data }, rect: r };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Source-over blend one pixel. Out of bounds is a no-op, never a throw: every drawing call
|
|
331
|
+
* here takes coordinates derived from a browser's box, and a box may legitimately sit
|
|
332
|
+
* partly outside the crop. */
|
|
333
|
+
function blend(raster: Raster, x: number, y: number, c: RGBA): void {
|
|
334
|
+
if (x < 0 || y < 0 || x >= raster.width || y >= raster.height) return;
|
|
335
|
+
const p = (y * raster.width + x) * 4;
|
|
336
|
+
const a = Math.min(1, Math.max(0, c.a));
|
|
337
|
+
if (a >= 1) {
|
|
338
|
+
raster.data[p] = c.r;
|
|
339
|
+
raster.data[p + 1] = c.g;
|
|
340
|
+
raster.data[p + 2] = c.b;
|
|
341
|
+
raster.data[p + 3] = 255;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const inv = 1 - a;
|
|
345
|
+
raster.data[p] = Math.round(c.r * a + (raster.data[p] as number) * inv);
|
|
346
|
+
raster.data[p + 1] = Math.round(c.g * a + (raster.data[p + 1] as number) * inv);
|
|
347
|
+
raster.data[p + 2] = Math.round(c.b * a + (raster.data[p + 2] as number) * inv);
|
|
348
|
+
raster.data[p + 3] = Math.max(raster.data[p + 3] as number, Math.round(a * 255));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Fill a rect, source-over. Clipped to the raster; never throws. */
|
|
352
|
+
export function fillRect(raster: Raster, rect: Rect, color: RGBA): void {
|
|
353
|
+
const x0 = Math.max(0, Math.round(rect.x));
|
|
354
|
+
const y0 = Math.max(0, Math.round(rect.y));
|
|
355
|
+
const x1 = Math.min(raster.width, Math.round(rect.x + rect.w));
|
|
356
|
+
const y1 = Math.min(raster.height, Math.round(rect.y + rect.h));
|
|
357
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) blend(raster, x, y, color);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Stroke a rect INWARD — the band lies inside the rect's own bounds, so a box flush with the
|
|
361
|
+
* raster edge keeps its full outline instead of losing the half that would fall outside.
|
|
362
|
+
*
|
|
363
|
+
* `dash` is a period in pixels (half on, half off), phased on absolute coordinates so the
|
|
364
|
+
* four sides meet consistently at the corners. Undefined draws solid. */
|
|
365
|
+
export function strokeRect(raster: Raster, rect: Rect, color: RGBA, width: number, dash?: number): void {
|
|
366
|
+
const t = Math.max(1, Math.round(width));
|
|
367
|
+
const x0 = Math.round(rect.x);
|
|
368
|
+
const y0 = Math.round(rect.y);
|
|
369
|
+
const x1 = Math.round(rect.x + rect.w);
|
|
370
|
+
const y1 = Math.round(rect.y + rect.h);
|
|
371
|
+
if (x1 <= x0 || y1 <= y0) return;
|
|
372
|
+
const on = (coord: number): boolean => {
|
|
373
|
+
if (!dash || dash <= 1) return true;
|
|
374
|
+
const half = dash / 2;
|
|
375
|
+
return Math.floor(coord / half) % 2 === 0;
|
|
376
|
+
};
|
|
377
|
+
for (let y = y0; y < y1; y++) {
|
|
378
|
+
for (let x = x0; x < x1; x++) {
|
|
379
|
+
const top = y < y0 + t;
|
|
380
|
+
const bottom = y >= y1 - t;
|
|
381
|
+
const left = x < x0 + t;
|
|
382
|
+
const right = x >= x1 - t;
|
|
383
|
+
if (!top && !bottom && !left && !right) continue;
|
|
384
|
+
// Horizontal bands dash along x, vertical ones along y, so a corner never blinks twice.
|
|
385
|
+
if (!on(top || bottom ? x : y)) continue;
|
|
386
|
+
blend(raster, x, y, color);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
package/src/snapshot.ts
CHANGED
|
@@ -298,12 +298,17 @@ export function alignedStyles(dom: string, styles: StyleDigest): Map<number, Sty
|
|
|
298
298
|
return out;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
/** Same verified join for bounding boxes.
|
|
302
|
-
|
|
303
|
-
|
|
301
|
+
/** Same verified join for bounding boxes.
|
|
302
|
+
*
|
|
303
|
+
* `doc` is optional and purely an economy: a caller that has already parsed this very `dom`
|
|
304
|
+
* passes it rather than paying for a second parse of a whole page (src/evidence.ts needs the
|
|
305
|
+
* same tree to map source offsets to ordinals). The join itself has ONE definition either
|
|
306
|
+
* way — that is the point of this function, and of `align` beside it. */
|
|
307
|
+
export function alignedBoxes(dom: string, boxes: BoxDigest, doc?: Doc): Map<number, BoxEntry> | null {
|
|
308
|
+
const parsed = doc ?? parseHtml(dom, "snapshot");
|
|
304
309
|
const out = new Map<number, BoxEntry>();
|
|
305
310
|
for (const e of boxes.entries) {
|
|
306
|
-
const el =
|
|
311
|
+
const el = parsed.elements[e.i];
|
|
307
312
|
if (!el || el.tag !== e.tag) return null;
|
|
308
313
|
out.set(e.i, e);
|
|
309
314
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single source of truth for shared types + the embedded version. sync-version.mjs
|
|
2
2
|
// rewrites VERSION at release time (kept in lockstep with package.json + SKILL.md).
|
|
3
|
-
export const VERSION = "3.
|
|
3
|
+
export const VERSION = "3.1.0";
|
|
4
4
|
export const SCHEMA_VERSION = 2;
|
|
5
5
|
|
|
6
6
|
// Lang = the UI FRAME's language — the `L` localization tables each module (report,
|