ultra11y 2.22.0 → 2.24.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.
Files changed (43) hide show
  1. package/README.md +93 -9
  2. package/package.json +6 -2
  3. package/scripts/build-pack-rgaa.mjs +95 -1
  4. package/scripts/build-standards.mjs +5 -1
  5. package/scripts/ultra11y.mjs +2920 -320
  6. package/skills/review-a11y/SKILL.md +1 -1
  7. package/skills/review-a11y/scripts/ultra11y.mjs +2920 -320
  8. package/skills/ultra11y/SKILL.md +38 -2
  9. package/skills/ultra11y/references/ci.md +165 -0
  10. package/skills/ultra11y/references/criteria.md +5 -5
  11. package/skills/ultra11y/references/devtools.md +67 -0
  12. package/skills/ultra11y/references/e2e.md +112 -0
  13. package/skills/ultra11y/references/judgment.md +25 -0
  14. package/skills/ultra11y/references/pages.md +205 -0
  15. package/skills/ultra11y/scripts/ultra11y.mjs +2920 -320
  16. package/src/adjudicate.ts +270 -45
  17. package/src/annotate.ts +158 -0
  18. package/src/audit.ts +8 -1
  19. package/src/capture.ts +4 -0
  20. package/src/cli.ts +485 -14
  21. package/src/criteria.ts +58 -0
  22. package/src/data/standards/rgaa.json +155 -30
  23. package/src/data/wcag.json +5 -5
  24. package/src/dev.ts +427 -0
  25. package/src/e2e.ts +303 -0
  26. package/src/gh.ts +72 -0
  27. package/src/init.ts +39 -7
  28. package/src/messages.ts +87 -0
  29. package/src/orchestrate-templates.ts +12 -4
  30. package/src/pages.ts +283 -0
  31. package/src/parse/html.ts +12 -0
  32. package/src/pixel.ts +217 -0
  33. package/src/prd.ts +37 -0
  34. package/src/report.ts +28 -2
  35. package/src/rules/registry.ts +2 -0
  36. package/src/rules/rendered.ts +379 -0
  37. package/src/rules/rule.ts +3 -0
  38. package/src/sarif.ts +196 -0
  39. package/src/scan-local.ts +4 -0
  40. package/src/snapshot.ts +442 -0
  41. package/src/standards/derive.ts +32 -1
  42. package/src/types.ts +161 -1
  43. package/src/util.ts +18 -0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Audit HTML/CSS/JSX against **WCAG 2.2 AA** accessibility and produce a dated compliance report — or author/review accessible markup without regressions. A [skills.sh](https://skills.sh) agent skill: a deterministic, zero-dependency static engine **plus** the agent's judgment, with `check`/`verify` gates against hallucinated non-conformities. **The central deliverable is the auditor conformance block** — theme, criterion + official wording, test(s), WCAG mapping + level, finding, expected state, verification, `file:line` occurrences — rendered identically by the `report` (compliance doc), the `prd` backlog and the GitHub issues, in the active standard's vocabulary and **in your language** (`--lang auto` follows the conversation/repo). **WCAG is the worldwide core; country standards (RGAA, …) are pluggable in-repo packs.**
4
4
 
5
- ultra11y is built around an honest **division of labour**. Automated tools only catch a fraction of accessibility problems, so the engine does the *mechanical* work — 72 machine-detectable static checks tied to the WCAG 2.2 success criteria — and is explicit about everything it can't decide statically. What it can't, the **AI agent adjudicates** (statically, from the evidence, gated by `verify`/`check`) — not a deferral to a human:
5
+ ultra11y is built around an honest **division of labour**. Automated tools only catch a fraction of accessibility problems, so the engine does the *mechanical* work — 78 machine-detectable static checks tied to the WCAG 2.2 success criteria — and is explicit about everything it can't decide statically. What it can't, the **AI agent adjudicates** (statically, from the evidence, gated by `verify`/`check`) — not a deferral to a human:
6
6
 
7
7
  - **Automatable (engine):** missing `alt`/`lang`/`title`, unlabeled fields, empty links/buttons, icon-only controls, iframes without title, tables without headers, heading-level skips, empty/dangling headings & labels, duplicate ids, invalid/broken ARIA, positive `tabindex`, autoplay/timed-refresh/`blink`/`marquee` media…
8
8
  - **Agent judgment (gated):** alt-text relevance, link purpose in context, reading/tab order, caption accuracy — the agent rules on these via `verify --manual` → `--apply`, each verdict carrying a justification (or a groundable NC), never a silent "conforming".
@@ -28,17 +28,37 @@ Rules needing a rendered page (computed contrast, keyboard traps) or a human cal
28
28
 
29
29
  ## Install
30
30
 
31
+ **As a CLI** — on npm, zero runtime dependencies (Node ≥ 22.18):
32
+
33
+ ```sh
34
+ npx ultra11y --help # no install
35
+ npm i -D ultra11y # or as a dev dependency
36
+ ```
37
+
38
+ **As an agent skill** — the repository ships **two** (pick at the prompt, or pass the name):
39
+ `ultra11y` for full audits, compliance reports, PRD backlogs and standards packs, and
40
+ `review-a11y` for the fast loop that audits **exactly the code under change** (staged files,
41
+ working diff, or branch vs merge-base) and reports like a code reviewer. Both bundle the same
42
+ install-free engine.
43
+
31
44
  ```sh
32
45
  npx skills add maxgfr/ultra11y
33
46
  ```
34
47
 
35
- The repository ships **two skills** (pick at the prompt, or pass the skill name):
36
- `ultra11y` — full audits, compliance reports, PRD backlogs, standards packs — and
37
- `review-a11y` — the fast review loop that audits **exactly the code under change**
38
- (staged files, working diff, or branch vs merge-base) and reports like a code reviewer.
39
- Both bundle the same install-free engine.
48
+ **As an MCP server** — so an agent drives the engine as tools instead of shelling out:
49
+
50
+ ```sh
51
+ claude mcp add ultra11y -- npx -y ultra11y mcp
52
+ ```
53
+
54
+ **As a GitHub Action** — audits the PR diff and, optionally, the served pages:
55
+
56
+ ```yaml
57
+ - uses: maxgfr/ultra11y@v2
58
+ with: { since: auto, standard: rgaa, fail-on: blocking }
59
+ ```
40
60
 
41
- Or clone and run the bundled engine directly (Node ≥ 22.18, zero dependencies):
61
+ Or clone and run the bundled engine straight from the repo:
42
62
 
43
63
  ```sh
44
64
  node scripts/ultra11y.mjs --help
@@ -50,9 +70,14 @@ node scripts/ultra11y.mjs --help
50
70
  ultra11y audit <globs… | -> [--out <dir>] [--include <glob>] [--exclude <glob>] [--ext <list>] [--jsx] [--graph] [--json] [--lang auto|en|fr] [--no-default-excludes]
51
71
  ultra11y audit [--changed | --since <ref> | --staged] [--max-files <n>] [--dedup exact|normalized|off] [--baseline <file>] [--fail-on blocking|major|minor]
52
72
  ultra11y audit [--captures <dir>] [--no-captures] [--require-captures] # audit rendered-DOM captures alongside source
53
- ultra11y report --in <audit.json> [--out <dir>] [--standard <pack>] [--lang auto|en|fr]
73
+ ultra11y audit [--format sarif|github] # CI: code-scanning SARIF, or inline annotations + job summary
74
+ ultra11y report --in <audit.json> [--out <dir>] [--standard <pack>] [--format sarif|github] [--lang auto|en|fr]
54
75
  ultra11y prd --in <audit.json> [--out <dir>] [--split criterion] [--format audit|doc|remediation] [--standard <pack>] [--gh-issues | --gh-single] [--lang auto|en|fr]
55
- ultra11y render [<dir>] [--scaffold | --setup | --coverage | --storybook] [--captures <dir>] [--out <file>] [--json]
76
+ ultra11y render [<dir>] [--scaffold | --setup | --e2e | --coverage | --storybook] [--runner playwright|cypress] [--captures <dir>] [--out <file>] [--json]
77
+ ultra11y snapshot write [--root <dir>] [--fail-on blocking|major|minor] [--json] # payload on stdin → .ultra11y/pages/<id>/ + audit
78
+ ultra11y snapshot list [--root <dir>] [--json]
79
+ ultra11y pages --in <audit.json> [--standard <pack>] [--json] [--lang auto|en|fr] # the per-page criterion grid
80
+ ultra11y dev [--port <n>] [--standard <pack>] | dev --next # live overlay while you build + dashboard
56
81
  ultra11y criteria [<sc>] [--list] [--standard <pack> [--theme <N>]] [--generate] [--json] [--lang auto|en|fr]
57
82
  ultra11y check --report <md> [--standard <pack>] [--quiet] [--json]
58
83
  ultra11y verify --report <md> [--standard <pack>] [--semantic] [--apply <verdicts.json>] [--max-verify <n>] [--json]
@@ -172,6 +197,65 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
172
197
  auto-codemods, fill-in `TODO` placeholders for the agent to complete, and judgment-only
173
198
  proposals. `--dry-run` is the default; `--write` applies but only after a re-audit proves
174
199
  no new non-conformity, and never on lossy JSX/TSX. See `references/fix.md`.
200
+ - **During your E2E run** — `render --e2e` writes Playwright/Cypress fixtures that audit a
201
+ targeted page **inside the test run you already have**, so the page is checked in the state
202
+ your test built (logged in, form filled, modal open) — state a separate `scan` run does not
203
+ have. Each checked page is persisted as a **snapshot** (`.ultra11y/pages/<id>/`: the whole
204
+ rendered document + computed styles + boxes), which is the durable half: the same page
205
+ re-audits **offline, with no browser**, so CI and the per-page report work from it. Because
206
+ a snapshot is a full document, the page-scoped rules finally run — RGAA **8.3** (`lang`),
207
+ **8.5/8.6** (`title`) — and every finding carries both its page and the source file that
208
+ rendered it. See `references/e2e.md` and `references/pages.md`.
209
+ - **While you build** — `dev --next` writes a one-line Next overlay component and `dev` starts
210
+ a loopback side-car: a floating panel lists the current page's non-conformities as you
211
+ browse, each linking to its `file:line` in your editor, and `http://127.0.0.1:4111` shows
212
+ the per-page grid accumulating. The overlay detaches itself before collecting — otherwise it
213
+ would audit itself and shift every element index — and renders nothing outside development.
214
+ See `references/devtools.md`.
215
+ - **A country standard is adjudicated at its OWN granularity** — 99 of RGAA's 106 criteria can
216
+ only ever derive `manual`, so ~93% of an RGAA audit is settled by the agent. `verify --manual
217
+ --standard rgaa` now keys the worklist by **RGAA criteria**, each carrying its numbered tests
218
+ in full, its technical note, its particular cases, its guidance and the glossary definitions
219
+ its tests cite (119 entries that previously had no reader). A `normativeRef` must cite one of
220
+ the item's **own** tests — the laxer check accepted a WCAG id that happened to collide with
221
+ an unrelated RGAA test number. Verdicts fold into a separate `packAdjudication` layer so a
222
+ pack decision never rewrites the WCAG core. See `references/judgment.md`.
223
+ - **Rendered tier (offline)** — six rules read a snapshot's browser-only signals inside the
224
+ ordinary `audit`: **no browser, no Docker, no running server**. `rendered-contrast` measures
225
+ contrast on the *computed* styles (the inline-literal rule could only see colours written in
226
+ the markup); `rendered-contrast-pixel` measures it **on the screenshot** for text over a
227
+ gradient or image, where `background-color` is transparent and every CSSOM-based checker —
228
+ axe-core included — is blind; `rendered-nontext-contrast` decides whether a form control's
229
+ boundary is perceivable (**RGAA 3.3**); and two more read the *stylesheet itself*, which is
230
+ the only place they exist: a focus indicator removed with nothing put back (**10.7**, until
231
+ now reachable only by a live browser probe) and an orientation lock (**13.9**).
232
+ `rendered-link-colour-only` makes **RGAA 10.6** decidable at all.
233
+ Each can say *"I don't know"*: an unresolvable backdrop, a varied region, a cross-origin
234
+ stylesheet, a `box-shadow` that might be the boundary — all leave the criterion undecided
235
+ rather than guessed, and without signals the rules do not fire, so no pre-existing verdict
236
+ changes. `tests/rgaa-coverage.test.ts` ratchets the result: **48 of 106** RGAA criteria now
237
+ map onto an engine rule, up from 43, and the number can only go up.
238
+ - **Page by page** — RGAA is a per-page norm; the engine's verdict is scope-wide. `pages`
239
+ bridges the two: one row per criterion, one column per page, embedded in `report` and
240
+ rebuilt from a committed `audit.json` alone. The per-page status is not recomputed — a
241
+ per-page *view* of the audit runs through the very same projection the report uses, so grid
242
+ and report agree by construction. Two rules hold the line: a finding is attributed to a page
243
+ only when something **says** so (else it is counted as unattributed, never spread across
244
+ pages), and a criterion is conforming *by silence* only on a page whose real rendered DOM
245
+ was audited. See `references/pages.md`.
246
+ - **A shipped GitHub Action** — `uses: maxgfr/ultra11y@main` audits the **code** (PR diff) and,
247
+ optionally, the **pages** (it can start your app, wait for it, then scan real URLs or your
248
+ declared sample). The engine ships inside the action, so there is nothing to install and no
249
+ `setup-node`. The gate runs **last**, after SARIF, annotations, the summary, the sticky PR
250
+ comment and the report — a red job is never a dead end. `ultra11y init --ci` writes a
251
+ workflow using it. See `references/ci.md`.
252
+ - **CI surfaces** — a red job says *that* the build broke, not *where*. `--format sarif` emits
253
+ SARIF 2.1.0 for GitHub code scanning, so each finding lands as an **inline annotation on the
254
+ causing line** of the PR; `--format github` does the same via `::error::` workflow commands
255
+ plus a `$GITHUB_STEP_SUMMARY` table, for plans without code scanning. Alert identity reuses
256
+ the baseline's `findingId`, so an alert survives line drift; a URL-keyed finding gets **no**
257
+ location rather than a guessed one. Run it from `report --standard rgaa` for pack-keyed
258
+ output. See `references/ci.md`.
175
259
  - **Automation** — `init --hook` (default) wires a zero-dependency git pre-commit gate over the
176
260
  **strict staged snapshot**: it audits the exact index blobs, auto-applies the safe fixes and
177
261
  re-stages them, and blocks only on judgment issues. `init --baseline`/`--ci` is the opt-in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "2.22.0",
3
+ "version": "2.24.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",
@@ -56,10 +56,11 @@
56
56
  "@semantic-release/changelog": "^6.0.3",
57
57
  "@semantic-release/exec": "^7.1.0",
58
58
  "@semantic-release/git": "^10.0.1",
59
+ "@semantic-release/npm": "^13.1.5",
59
60
  "@types/node": "^26.0.1",
60
61
  "@vitest/coverage-v8": "^4.1.9",
61
62
  "jsdom": "^29.1.1",
62
- "semantic-release": "^25.0.5",
63
+ "semantic-release": "^25.0.8",
63
64
  "tsup": "^8.5.1",
64
65
  "typescript": "^6.0.3",
65
66
  "vitest": "^4.1.9",
@@ -69,5 +70,8 @@
69
70
  "onlyBuiltDependencies": [
70
71
  "esbuild"
71
72
  ]
73
+ },
74
+ "publishConfig": {
75
+ "access": "public"
72
76
  }
73
77
  }
@@ -58,7 +58,23 @@ const slug = (s) =>
58
58
 
59
59
  // [term](#anchor) -> term
60
60
  const plain = (s) => s.replace(/\[([^\]]+)\]\(#[^)]*\)/g, "$1");
61
- const toArr = (v) => (Array.isArray(v) ? v.map(String) : v == null ? [] : [String(v)]);
61
+ // DINUM nests a bullet list inside a note as `{ ul: [...] }` (17 places across
62
+ // technicalNote / particularCases). A plain `String(v)` on those produced a literal
63
+ // "[object Object]" in the shipped pack, silently deleting the normative sub-conditions an
64
+ // auditor needs — e.g. every exception under RGAA 1.2's images-of-text rule. Flatten the
65
+ // node instead of stringifying it, and refuse anything still unrecognised rather than
66
+ // emitting placeholder text (see the assertion at the end of this file).
67
+ const flattenNode = (v) => {
68
+ if (v == null) return [];
69
+ if (typeof v === "string") return [v];
70
+ if (Array.isArray(v)) return v.flatMap(flattenNode);
71
+ if (typeof v === "object") {
72
+ const list = v.ul ?? v.ol ?? v.li ?? v.p ?? v.text;
73
+ if (list !== undefined) return flattenNode(list);
74
+ }
75
+ return [];
76
+ };
77
+ const toArr = (v) => flattenNode(v);
62
78
 
63
79
  // crude HTML -> plaintext for glossary bodies
64
80
  const deHtml = (s) =>
@@ -94,6 +110,8 @@ const RULE_SC = {
94
110
  "aria-required-children": ["4.1.2"], "autoplay-media": ["1.4.2", "2.2.2"], "blink-marquee": ["2.2.2"],
95
111
  "button-empty-name": ["4.1.2"], "canvas-fallback-missing": ["1.1.1"], "chart-no-accessible-name": ["1.1.1"],
96
112
  "clickable-noninteractive": ["4.1.2", "2.1.1"], "contrast-literal": ["1.4.3"], "control-label-missing": ["4.1.2"],
113
+ "rendered-contrast": ["1.4.3"], "rendered-contrast-pixel": ["1.4.3"], "rendered-link-colour-only": ["1.4.1"],
114
+ "rendered-nontext-contrast": ["1.4.11"], "rendered-focus-not-visible": ["2.4.7"], "rendered-orientation-lock": ["1.3.4"],
97
115
  "css-generated-content-informative": ["1.3.1"], "date-fields-ungrouped": ["3.3.2"], "disabled-context-content": ["4.1.2"],
98
116
  "control-name-title-only": ["4.1.2"], "cross-icon-only-unnamed": ["4.1.2"],
99
117
  "cross-prop-drilled-name-lost": ["4.1.2"], "data-table-no-headers": ["1.3.1"],
@@ -125,6 +143,22 @@ const RULE_TO_CRITERIA = {
125
143
  "iframe-title-missing": ["2.1"], "axe:frame-title": ["2.1"], "axe:frame-title-unique": ["2.1"],
126
144
  // Theme 3 / 10.5 — colour contrast (3.2 text/bg, 10.5 CSS declarations)
127
145
  "contrast-literal": ["3.2", "10.5"], "axe:color-contrast": ["3.2", "10.5"], "axe:color-contrast-enhanced": ["3.2", "10.5"],
146
+ // Rendered tier: contrast measured on the real page (computed styles) and on the
147
+ // screenshot, for the gradient/background-image case the CSSOM cannot express.
148
+ "rendered-contrast": ["3.2", "10.5"], "rendered-contrast-pixel": ["3.2", "10.5"],
149
+ // 10.6 — a link in running text identified by colour alone. Deliberately NOT also
150
+ // mapped to 3.1 (the general "information by colour alone"): 10.6 is RGAA's specific
151
+ // criterion for links, and claiming both would count one defect twice.
152
+ "rendered-link-colour-only": ["10.6"],
153
+ // 3.3 — the boundary of a form control, measured on the computed borders/fill. The
154
+ // only WCAG SC RGAA 3.3 maps to (1.4.11) had no rule at all before.
155
+ "rendered-nontext-contrast": ["3.3"],
156
+ // 10.7 — a focus indicator removed by the stylesheet with nothing put back. The live
157
+ // `scan` probe (dyn-focus-visible) already evidenced this; the CSSOM route decides it
158
+ // OFFLINE, with no browser and no running app.
159
+ "rendered-focus-not-visible": ["10.7"],
160
+ // 13.9 — orientation lock. Its SC (1.3.4) had no rule.
161
+ "rendered-orientation-lock": ["13.9"],
128
162
  // Theme 4 — multimedia (4.3 captions)
129
163
  "media-no-track": ["4.3"], "axe:audio-caption": ["4.3"], "axe:video-caption": ["4.3"],
130
164
  // Theme 5 — tables (5.4 title, 5.6/5.7 headers, 5.8 layout-table markup)
@@ -267,6 +301,51 @@ async function main() {
267
301
  fr: "Indiquez le format et le poids du fichier dans l’intitulé du lien, par exemple « Rapport annuel (PDF, 2 Mo) ».",
268
302
  },
269
303
  },
304
+ // RGAA 11.8.2 — "Dans chaque balise <select>, chaque balise <optgroup> possède-t-elle un
305
+ // attribut label ?" Fully mechanical, and the criterion had no rule at all. Its siblings
306
+ // 11.8.1 ("si nécessaire") and 11.8.3 ("le label est-il pertinent ?") stay the agent's:
307
+ // this rule decides the one test of the three that is decidable from markup, which is
308
+ // exactly what the DSL is for. NORMATIVE — a missing `label` is a plain failure.
309
+ {
310
+ id: "optgroup-without-label",
311
+ criterion: "11.8",
312
+ wcag: ["1.3.1"],
313
+ severity: "majeur",
314
+ match: { tag: "optgroup", attrs: [{ name: "label", op: "absent" }] },
315
+ message: {
316
+ en: "<optgroup> without a label attribute — the group of options is unnamed (RGAA test 11.8.2).",
317
+ fr: "<optgroup> sans attribut label — le regroupement d’options n’est pas nommé (test RGAA 11.8.2).",
318
+ },
319
+ remediation: {
320
+ en: 'Add label="…" naming what the options in this group have in common, e.g. <optgroup label="Europe">.',
321
+ fr: 'Ajoutez label="…" nommant ce que les options du groupe ont en commun, par ex. <optgroup label="Europe">.',
322
+ },
323
+ },
324
+ // RGAA 8.10.2 — the FIRST of its two sub-conditions: "La valeur de l'attribut dir est
325
+ // conforme (rtl ou ltr)". Mechanical. The second ("la valeur est pertinente") and 8.10.1
326
+ // (is a reading-direction change signalled at all?) need language detection and stay
327
+ // manual. Anchored negative lookahead so only a genuinely invalid value matches; `auto`
328
+ // is valid HTML and is accepted.
329
+ {
330
+ id: "dir-value-invalid",
331
+ criterion: "8.10",
332
+ wcag: ["1.3.2"],
333
+ severity: "majeur",
334
+ match: {
335
+ attrs: [
336
+ { name: "dir", op: "present" },
337
+ { name: "dir", op: "matches", value: "^(?!(rtl|ltr|auto)$).+$" },
338
+ ],
339
+ },
340
+ message: {
341
+ en: "dir attribute with a value that is not rtl, ltr or auto — the reading direction change is not declared conformantly (RGAA test 8.10.2).",
342
+ fr: "Attribut dir dont la valeur n’est ni rtl, ni ltr, ni auto — le changement de sens de lecture n’est pas déclaré conformément (test RGAA 8.10.2).",
343
+ },
344
+ remediation: {
345
+ en: 'Use dir="rtl" or dir="ltr" (or dir="auto"), matching the reading direction of the text it carries.',
346
+ fr: 'Utilisez dir="rtl" ou dir="ltr" (ou dir="auto"), en accord avec le sens de lecture du texte porté.',
347
+ },
348
+ },
270
349
  ];
271
350
  // Wire each rule's namespaced id (`pack:rgaa:<id>`) into the criterion it reports under,
272
351
  // so derivePackResults routes its finding onto that criterion through the same
@@ -385,6 +464,21 @@ async function main() {
385
464
  writeFileSync(join(OUT, "rgaa.json"), packText);
386
465
  writeFileSync(join(OUT, "rgaa.glossary.json"), glossaryText);
387
466
 
467
+ // A normative sentence that stringified to "[object Object]" is a DELETED sub-condition an
468
+ // auditor would have had to apply. Fail the build rather than ship placeholder text.
469
+ const stringified = [];
470
+ for (const c of criteria) {
471
+ for (const field of ["technicalNote", "particularCases"]) {
472
+ for (const line of c[field] ?? []) if (String(line).includes("[object Object]")) stringified.push(`${c.id}.${field}`);
473
+ }
474
+ for (const [k, lines] of Object.entries(c.tests ?? {})) {
475
+ for (const line of lines) if (String(line).includes("[object Object]")) stringified.push(`${c.id} test ${k}`);
476
+ }
477
+ }
478
+ if (stringified.length) {
479
+ throw new Error(`build-pack-rgaa: ${stringified.length} normative text(s) stringified to "[object Object]" — extend flattenNode: ${stringified.join(", ")}`);
480
+ }
481
+
388
482
  const noWcag = criteria.filter((c) => c.wcag.length === 0).map((c) => c.id);
389
483
  console.log(`build-pack-rgaa: ${themes.length} themes, ${criteria.length} criteria → src/data/standards/rgaa.json`);
390
484
  console.log(`build-pack-rgaa: glossary ${Object.keys(glossary).length} entries → src/data/standards/rgaa.glossary.json`);
@@ -341,7 +341,10 @@ const RULE_SC_COVERAGE = {
341
341
  ],
342
342
  "1.3.5": ["field-purpose-incomplete", "autocomplete-token-invalid"],
343
343
  "1.4.2": ["autoplay-media"],
344
- "1.4.3": ["contrast-literal"],
344
+ "1.3.4": ["rendered-orientation-lock"],
345
+ "1.4.1": ["rendered-link-colour-only"],
346
+ "1.4.3": ["contrast-literal", "rendered-contrast", "rendered-contrast-pixel"],
347
+ "1.4.11": ["rendered-nontext-contrast"],
345
348
  "1.4.4": ["meta-viewport-zoom-block"],
346
349
  "2.1.1": ["clickable-noninteractive"],
347
350
  "2.2.1": ["meta-refresh-redirect"],
@@ -349,6 +352,7 @@ const RULE_SC_COVERAGE = {
349
352
  "2.4.1": ["skip-link-target-missing"],
350
353
  "2.4.2": ["title-missing-empty"],
351
354
  "2.4.3": ["positive-tabindex"],
355
+ "2.4.7": ["rendered-focus-not-visible"],
352
356
  "2.4.4": ["link-empty-name", "icon-only-control-unnamed"],
353
357
  "2.5.3": ["label-in-name-mismatch"],
354
358
  "3.2.2": ["on-input-context-change"],