@surea11y/core 1.7.0 → 1.8.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 (103) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +2 -2
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +1 -1
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +152 -26
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14419 -2231
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -42,11 +42,11 @@ A binding that crosses a realm boundary has to get the engine into the page
42
42
  somehow. The obvious way — serialize `runa11yCoreInPage` with `.toString()` and
43
43
  hand it to the driver's evaluate-in-page call — works, and is what
44
44
  `@surea11y/playwright` did first, but it sends the whole engine **on every
45
- call**: about 1.7MB per frame, per scan. A five-frame scan sends it five times,
45
+ call**: about 2.1MB per frame, per scan. A five-frame scan sends it five times,
46
46
  and the next scan sends it all again.
47
47
 
48
48
  The package ships a smaller way. `@surea11y/core/browser` is the standalone
49
- bundle: the same `runa11yCoreInPage`, minified, about 707KB, which defines
49
+ bundle: the same `runa11yCoreInPage`, minified, about 780KB, which defines
50
50
  `window.a11ycore`. Load it into the document once and every later scan costs a
51
51
  few hundred bytes.
52
52
 
@@ -98,6 +98,49 @@ pipelines:
98
98
 
99
99
  The step fails the pipeline on the CLI's exit code exactly like any other `script` entry; `a11y-report.html` (see [`REPORT.md`](./REPORT.md)) is attached as a downloadable build artifact so a reviewer can open it without re-running the scan locally.
100
100
 
101
+ ## JUnit test reports
102
+
103
+ GitLab, Azure DevOps, Jenkins and CircleCI show JUnit XML in their own test views: each WCAG criterion becomes a suite and each rule a test (see [`JUNIT.md`](./JUNIT.md)). Save the scan as JSON, render it with `@surea11y/core/junit`, and keep the CLI's exit code for gating. The render step needs `@surea11y/core` in the project's own `devDependencies`.
104
+
105
+ ### GitLab CI
106
+
107
+ ```yaml
108
+ a11y:
109
+ image: node:20
110
+ script:
111
+ - npm ci
112
+ - npm run build
113
+ - npx @surea11y/cli scan ./dist/index.html --json > a11y-scan.json || scan_exit=$?
114
+ - node -e "const fs = require('fs'); const { renderJunitReport } = require('@surea11y/core/junit'); fs.writeFileSync('a11y.junit.xml', renderJunitReport(JSON.parse(fs.readFileSync('a11y-scan.json', 'utf8'))))"
115
+ - exit ${scan_exit:-0}
116
+ artifacts:
117
+ when: always
118
+ reports:
119
+ junit: a11y.junit.xml
120
+ ```
121
+
122
+ `when: always` uploads the report even when the scan's exit code fails the job, which is exactly when the merge request widget is worth reading.
123
+
124
+ ### Azure DevOps
125
+
126
+ ```yaml
127
+ steps:
128
+ - script: |
129
+ npm ci
130
+ npm run build
131
+ npx @surea11y/cli scan ./dist/index.html --json > a11y-scan.json || scan_exit=$?
132
+ node -e "const fs = require('fs'); const { renderJunitReport } = require('@surea11y/core/junit'); fs.writeFileSync('a11y.junit.xml', renderJunitReport(JSON.parse(fs.readFileSync('a11y-scan.json', 'utf8'))))"
133
+ exit ${scan_exit:-0}
134
+ displayName: Accessibility scan
135
+ - task: PublishTestResults@2
136
+ condition: succeededOrFailed()
137
+ inputs:
138
+ testResultsFormat: JUnit
139
+ testResultsFiles: a11y.junit.xml
140
+ ```
141
+
142
+ `cantTell` rules arrive as skipped tests, never failures. To gate on them too, pass `{ cantTellAs: 'failure' }` as the second argument to `renderJunitReport`; note that it changes the report, not the CLI's exit code.
143
+
101
144
  ## Free-tier/private-repo minute limits
102
145
 
103
146
  If your pipeline provider's free tier is minute-limited (Bitbucket Pipelines' free tier is 50 build-minutes/month on private workspaces, for example), a `jsdom`-based scan of static/server-rendered HTML (what the CLI does) is far cheaper than driving a real browser — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md#what-it-can-and-cant-scan) for what that trades away (no client-rendered content, no real CSS layout).
@@ -2,6 +2,18 @@
2
2
 
3
3
  A running log of engine design decisions worth re-examining: cases where an existing choice turned out to conflict with a ground-truth source (usually the W3C ACT rules test corpus), or just looks questionable on a second look. Not all of these are bugs; some are tradeoffs that deserve a second opinion before being confirmed or overturned. Each entry has the decision as it stands, why it's being questioned, and its current status. Settled entries move to [Decided](#decided) at the bottom, with the reasoning kept, since a decision is only useful later if the argument behind it survives with it.
4
4
 
5
+ ## Method
6
+
7
+ Before changing behavior that already ships, or agreeing that a challenge to it is right, establish why the current behavior exists rather than assuming it was either careless or considered.
8
+
9
+ - **Read the history first.** `git log -L <start>,<end>:<file>` (or `git log -S<token>`) on the exact lines, and check whether the commit message, this document, `RULE_AUTHORING.md`, or `CHANGELOG.md` already recorded a reason. A comment that states what the code does is not evidence of why it does it — say explicitly when no rationale can be found instead of inventing one to fill the gap.
10
+ - **Check for a second implementation of the same concept.** A question this engine has needed to answer more than once (eligibility, focusability, naming, inertness) is sometimes reimplemented locally inside one rule rather than shared through `dom-helpers.js`. When two independent implementations disagree on the same input, that disagreement is stronger evidence of a real problem than either implementation's own comment defending itself.
11
+ - **Weigh it against ground truth, not intuition:** the WCAG Understanding documents and Techniques, the ACT rules corpus (this repo's primary source, see the intro above), and the HTML/ARIA spec text for the exact mechanism in question — what `inert`, `aria-hidden`, or a native role default actually do, not what seems reasonable.
12
+ - **Compare against other accessibility testing engines where practical**, as one more data point on how the same ambiguity is usually resolved in the field — never as an authority on its own, and never named or quoted in a rule's comments, commits, or docs (this repo never names competing engines).
13
+ - **State the counter-argument before concluding.** Write down why the current behavior might be right, and why it hasn't already been revisited, before deciding it should change. "Nobody thought this through" and "this was already considered and rejected" call for different next steps.
14
+ - **Land the decision somewhere.** Fix it with a `CHANGELOG.md` entry that states the reasoning, or add/update an entry in this file's [Open](#open) or [Decided](#decided) section, so the argument survives with the decision the next time someone re-examines it.
15
+ - **After changing a rule's behavior**, regenerate every generated artifact that describes it, not just the code and tests: `npm run docs:rule-catalog`, `npm run coverage`, `npm run fixtures:index`, `npm run fixtures:markers`, `npm run finding-ids`, and `npm run docs:rule-review` (the by-hand review page). None of these run automatically from a source edit, and a stale one is easy to miss since nothing fails loudly except `fixtures:markers:check`/`coverage:check`/`finding-ids`'s own test.
16
+
5
17
  ## Open
6
18
 
7
19
  ### `label-in-name` compares against accessibility-tree text, where ACT uses *visible* inner text
@@ -118,7 +130,7 @@ Then the remaining overstatement went too. `aria-allowed-role` declared SC 4.1.2
118
130
 
119
131
  **Decision as it stands (before the fix):** `hasHeading()` credited any `<h1>`-`<h6>`/`[role="heading"]` that was included in the accessibility tree; an off-screen-positioned, clipped, opacity:0, or zero-size-overflow-hidden heading counted the same as a fully visible one. This mismatch was never individually triaged; it sat inside `ye5d6e`/`047fe0`'s combined "1, 2" mismatch count, both filed under one blanket "deliberate leniency" reason that only actually described a *different* shape (a heading positioned inside the repeated content it's supposed to be an escape from).
120
132
 
121
- **Why it was questioned:** re-fetching `047fe0`'s live corpus surfaced a case that reason doesn't cover at all: `<h1 class="off-screen">` inside `<div id="main">`, correctly positioned *after* the repeated nav, still expected **failed** by ACT. Its own Expectation text requires the heading to be both "included in the accessibility tree" *and* "[visible](#visible)." A screen-reader-only heading gives sighted keyboard users no equivalent way to locate the start of non-repeated content, which is exactly the gap `047fe0` is checking for.
133
+ **Why it was questioned:** re-fetching `047fe0`'s live corpus surfaced a case that reason doesn't cover at all: `<h1 class="off-screen">` inside `<div id="main">`, correctly positioned *after* the repeated nav, still expected **failed** by ACT. Its own Expectation text requires the heading to be both "included in the accessibility tree" *and* "[visible](https://www.w3.org/WAI/standards-guidelines/act/rules/047fe0/#visible)." A screen-reader-only heading gives sighted keyboard users no equivalent way to locate the start of non-repeated content, which is exactly the gap `047fe0` is checking for.
122
134
 
123
135
  **Decision (2026-08-19):** `hasHeading()` now also requires the heading to carry no CSS-hiding hint (`helpers.getVisibilityHintsInfo`: off-screen, clipped, opacity:0, zero-size-overflow-hidden). The sibling `hasMainLandmark()`/`hasWorkingAnchorLink()` checks are left untouched; `cf77f2`'s own live text doesn't carry the same visibility requirement for a `<main>` landmark, confirmed by an existing regression test that pins a clipped-but-accessible `<main>` as still credited.
124
136
 
@@ -218,7 +230,7 @@ Then the remaining overstatement went too. `aria-allowed-role` declared SC 4.1.2
218
230
 
219
231
  **Decision as it stands:** `src/checks/automatic/duplicate-id-aria.js` only flags a duplicate `id` when it's referenced by an ARIA ID-reference attribute. Its header comment: "Scoped to ids referenced by ARIA, not the broader/deprecated page-wide duplicate-id check (see ROADMAP.md's 'Skip' list)." That `ROADMAP.md` no longer exists in the repo (not found in the working tree or as a tracked file in `git log`, likely a local planning doc that was never committed), so the original reasoning behind "skip" isn't recoverable verbatim, only the pointer to it.
220
232
 
221
- **Why it's being questioned:** while mining ACT's gap list (per the user's request to find gaps worth turning into new rules), `3ea0c8` "Id attribute value is unique" is exactly this broader page-wide check, and it's detectable with a simple, deterministic document-wide scan. Checked ACT's own SC mapping for it: `3ea0c8` maps to **WCAG 4.1.1 Parsing**, which the Working Group formally **removed in WCAG 2.2** (browsers/AT no longer depend on strict-parsing conformance the way they did when that SC was written), and axe-core deprecated its own equivalent broad `duplicate-id` check around the same time, for the same reason. So the original "skip" call was well-founded *for WCAG 2.2 conformance scoring specifically*.
233
+ **Why it's being questioned:** while mining ACT's gap list for gaps worth turning into new rules, `3ea0c8` "Id attribute value is unique" is exactly this broader page-wide check, and it's detectable with a simple, deterministic document-wide scan. Checked ACT's own SC mapping for it: `3ea0c8` maps to **WCAG 4.1.1 Parsing**, which the Working Group formally **removed in WCAG 2.2** (browsers/AT no longer depend on strict-parsing conformance the way they did when that SC was written) and other accessibility engines deprecated their equivalent broad `duplicate-id` checks around the same time, for the same reason. So the original "skip" call was well-founded *for WCAG 2.2 conformance scoring specifically*.
222
234
 
223
235
  That said, duplicate IDs are still a real, practical bug independent of which SC currently covers them: they break `<label for>` association, fragment navigation, and any `getElementById`/`querySelector('#...')` call, not just ARIA references. This engine already supports WCAG-version-scoped tagging (`wcag2a`/`wcag21a`/`wcag22aa`-style tags, see `docs/ENGINE_OPTIONS.md`'s WCAG-version filtering). A page-wide duplicate-id rule could be added and tagged as WCAG 2.0/2.1-only (`wcag411`-style, excluded from WCAG 2.2 tag sets) rather than either fully skipped or wrongly counted against 2.2 conformance, the two options the original either/or "skip" decision didn't have room for.
224
236
 
@@ -350,7 +362,7 @@ One existing test changed meaning with it: a bare `<option>` under `role="listbo
350
362
 
351
363
  **Decision as it stood:** `aria-valid-attr-value` treats every ID-reference attribute the same way. An idref-list whose tokens all fail to resolve is an invalid value, hence a `fail` under SC 4.1.2. Exactly one attribute already had a carve-out: `aria-errormessage`, on the strength of ACT 6a7281's own Background text, which names it as a non-required property whose target "may be created in response to an event that may or may not happen."
352
364
 
353
- **Why it was questioned:** that carve-out's reasoning covers `aria-controls` at least as well. A disclosure button, a combobox, a menu button and a tab all name content the widget *builds when it opens*, so the reference is correct and the element genuinely is not in the DOM yet. A static scan that looks for it and does not find it has not established a defect; it has established that it looked at the wrong moment. axe-core reached the same conclusion independently: it never reports a violation for a missing `aria-controls` target, passing when the element is collapsed and returning incomplete otherwise.
365
+ **Why it was questioned:** that carve-out's reasoning covers `aria-controls` at least as well. A disclosure button, a combobox, a menu button and a tab all name content the widget *builds when it opens*, so the reference is correct and the element genuinely is not in the DOM yet. A static scan that looks for it and does not find it has not established a defect; it has established that it looked at the wrong moment. Other accessibility engines reach the same conclusion: they do not report a missing `aria-controls` target as a violation, and at most ask for a review when the widget is expanded.
354
366
 
355
367
  **Decision (2026-08-27):** `aria-controls` no longer fails on an unresolved target. When the element carries `aria-expanded="false"` or `aria-selected="false"` the absence is exactly what that state means, so the rule passes outright; otherwise it reports `cantTell` for human review, with reason code `idref-controls-not-found`. Every other idref/idref-list attribute keeps its `fail`, since a dangling `aria-labelledby` or `aria-owns` names content that was supposed to be there already and no state excuses it. The rule now reports two tiers through `helpers.resolveTieredOutcome`, so a real invalid value elsewhere on the page still gates as `fail` and carries the `cantTell` occurrences along rather than dropping them.
356
368
 
@@ -365,3 +377,85 @@ One existing test changed meaning with it: a bare `<option>` under `role="listbo
365
377
  **Decision (2026-08-27):** the engine resolves a target WCAG version per run, from `engineOptions.wcagVersion`, else whatever the caller's own version-origin tags imply, else `2.2`, and reports it as `engine.wcagVersion`. Under a 2.2 target a `wcag22-removed` rule cannot report `fail`: it runs, keeps every occurrence, and its outcome is coerced to `cantTell` with a `wcagVersionScope` field naming the removed criterion. Coercing rather than excluding was the deliberate choice, since a duplicate id still breaks `<label for>`, fragment navigation and `getElementById`, so dropping the rule from a 2.2 run would hide a real defect, and this engine's whole premise is telling you what it cannot tell you. `excludeTags: ['wcag22-removed']` still removes it entirely for anyone who wants that. The coercion deliberately does not go through `error`, the channel the two existing coercions use, because consumers read a non-empty `error` as "this rule threw" and nothing went wrong here.
366
378
 
367
379
  **Status:** resolved 2026-08-27.
380
+
381
+ ### `iframe-title-unique` failed any repeated frame `title`, filed as a deliberate stricter-than-ACT check, now deprecated in favour of `identical-iframes-same-purpose`
382
+
383
+ **Decision as it stood:** `ACT_RULE_MAPPING.md` recorded that `iframe-title-unique` flags any duplicate `title` attribute on `<iframe>`/`<frame>` elements "by design", a stricter check with no ACT counterpart of its own, and that `4b1c6c` would be closed by a separate rule. `identical-iframes-same-purpose` later closed it, and the two ran side by side, the older one reporting `fail` with `defaultConfidence: 'high'` under a normative mapping to WCAG 4.1.2 level A.
384
+
385
+ **Why it was questioned:** running ACT 4b1c6c's own test cases against the engine (#16) showed seven of its ten passed examples and one inapplicable example reported as `fail` by `iframe-title-unique`: two frames titled "List of Contributors" both showing `page-one.html`, a directory written with and without its trailing slash, the same page under two paths. WCAG 4.1.2 asks that a frame's name be programmatically determinable, not that it be unique, so a duplicate title establishes no violation, and `POLICY.md` promises that `fail` means a deterministic normative violation. The rule was breaking the engine's own contract, not only disagreeing with ACT. The first proposal (#17 as opened) kept the rule and gave it the sibling's verdict on the `title` attribute: pass when every frame in a set resolves to one resource, `cantTell` otherwise. But a frame's `title` is its accessible name unless `aria-label`/`aria-labelledby` overrides it, and in the one case where the two differ the title becomes the accessible description, a duplicate of which 4.1.2 does not forbid either. Reshaped, the rule reported the same elements, with the same outcome and uncertainty code, as `identical-iframes-same-purpose`, and copied its accessibility-tree gating to do so.
386
+
387
+ **Decision (2026-09-03):** the rule is deprecated, `replacedBy: 'identical-iframes-same-purpose'`, `sinceVersion: '1.8.0'`, the first use of the mechanism in `API_STABILITY.md`. Because a deprecated rule keeps running and reporting normally, deprecation alone would have left the wrong `fail` alive until 2.0.0, so `runInPage` is reduced to `notApplicable` on every page. The id stays in the catalog until the file is removed in 2.0.0, so a `runOnly` list holding it keeps resolving. `IFRAME_TITLE_DUPLICATE` is no longer emitted and retires with the finding it named: it was listed, with its reason, under `retired` in `scripts/data/released-finding-ids.json` until the 1.8.0 release froze its own inventory, so a stored baseline entry or open Code Scanning alert for it closes, as for a fixed false failure. The rule's facet under SC 4.1.2 is retired and its `coverage.facetsBySc` points at the successor's facet, which the validator requires to be non-empty for a rule with a WCAG mapping; the coverage report leaves deprecated rules out of a facet's coverage (#31), so it does not count as covering it. The scenario fixture is removed rather than left claiming outcomes the rule no longer produces, which is what the fixture-marker check exists to catch; the one case the sibling's tests lacked, several same-named sets on one page where only the set whose resources differ is reported, moved to its test file. This is recorded as a change of mind, not a bug fix: the old behaviour was chosen on purpose, and a stricter check still has to rest on a requirement.
388
+
389
+ **Revised (2026-10-02):** how the retired code is recorded. The PR first kept `IFRAME_TITLE_DUPLICATE` in `scripts/data/finding-ids.json` through an exemption in `tests/finding-ids.test.js`. Review found that `npm run finding-ids` still dropped it with every test passing, and agreed that the rule would declare its retired codes for the generator to read. Before the PR landed, `main` gained `scripts/data/released-finding-ids.json` and `tests/released-finding-ids.test.js`: what a release shipped is frozen there, no regeneration rewrites it, and a code can go only when it is listed under `retired` with its reason. That closes the same gap without a new deprecation field, so the code is retired there and the exemption was dropped.
390
+
391
+ **Status:** resolved 2026-09-03. Over the 23 ACT 4b1c6c test cases, passed/inapplicable fixtures yielding `fail` went 8 → 0; every failed example either iframe rule caught before is still `cantTell` from `identical-iframes-same-purpose`.
392
+
393
+ ### `area-alt-present` treated `<area alt="">` as satisfying its check, borrowing `<img>`'s decorative marker for an element that can't be decorative
394
+
395
+ **Decision as it stands (before the change):** an `<area>` with a present-but-empty `alt` passed `area-alt-present` outright, the same treatment `<img alt="">` gets. A separate manual rule, `area-alt-decorative`, then asked a human to confirm the empty-`alt` area really was decorative.
396
+
397
+ **Why it was questioned:** `<img alt="">` has a real decorative use: the image can carry zero information while everything else on the page still works. An `<area>` has no equivalent — it exists in a used `<map>` only to be a hyperlink hotspot (that's the whole point of the `usemap`/`href` mechanism), so its HTML-AAM role is `link` and an empty `alt` just means an unnamed link, not a decorative one. There is no redundant sibling content standing in for it the way there is for a decorative image; the geometry itself is invisible.
398
+
399
+ **Decision (2026-09-14):** `area-alt-present` now fails an `<area>` with `alt=""` and no other accessible name (`aria-label`/`aria-labelledby`/`title` still count, same fallback order as before), with its own summary/hint distinct from the missing-`alt` case. `area-alt-decorative` is retired rather than kept dormant, since "is this decorative" never had a legitimate yes for this element — see [Removed] in `CHANGELOG.md`. `area-alt-quality` (non-empty `alt`, asking whether the text is accurate) is unaffected and stays a legitimate manual question.
400
+
401
+ **Accepted cost:** `scripts/data/finding-ids.json`'s rule-id inventory drops from 133 to 132. A stored baseline holding a `cantTell` finding against `area-alt-decorative` finds the id gone, not resolved.
402
+
403
+ **Status:** resolved 2026-09-14.
404
+
405
+ ### `hasBlockingInert` ignored `inert` on an `<area>` itself or on its `<map>`, undocumented since the project's first commit
406
+
407
+ **Decision as it stands (before the change):** `hasBlockingInert` in `dom-helpers.js` carried an `<area>`-specific exception: `inert` on the area itself, or on its closest `<map>`, was ignored; only an `inert` ancestor outside that chain excluded the area. Present since the very first commit (`4961752`), pinned by a pointed unit test, with no rationale recorded in the commit, this file, or `RULE_AUTHORING.md`.
408
+
409
+ **Why it was questioned:** the shape reads like a generalization of a different, correct rule — that `aria-hidden` on a focusable element does not remove it from eligibility, since a real user can still Tab onto it (the exact pattern `aria-hidden-focus` exists to catch). `inert` is not `aria-hidden`: the HTML spec has it remove focusability directly, for every element type, with no image-map carve-out in the algorithm, so the "still really reachable" premise that justifies the `aria-hidden` exception does not hold for `inert`. More directly: `aria-hidden-focus.js` already implements this exact question independently (`hasInertAncestor`, walking the element itself and every ancestor) with no `<area>`/`<map>` exception at all — two pieces of code in the same repo disagreed on the identical input, `<area inert>`.
410
+
411
+ **Decision (2026-09-14):** the exception is removed. `inert` on the `<area>`, its `<map>`, or any ancestor now excludes the area uniformly, matching every other element's default handling and matching `aria-hidden-focus.js`'s own independent check. A genuinely inert `<area>` is `notApplicable` rather than a reported failure.
412
+
413
+ **Correction (2026-09-14, same day):** wrong. This reasoned from spec text without checking what a real browser actually does, which is exactly the thing the Method section above says to verify. Tested via real keyboard Tab navigation in Chromium and Firefox: an `<area inert>` and an `<area>` inside an `inert <map>` both stay in the tab order. `<area>`/`<map>` generate no box, so a browser's image-map hit-testing sits outside the pipeline `inert` operates on — the original, undocumented exception was empirically correct despite having no stated reason, and the `aria-hidden-focus.js` comparison that seemed to settle the question was comparing the wrong thing: that rule never evaluates `<area>` in practice, so it never had occasion to get this right or wrong. Re-reverted; see the entry below for the full, verified picture, which also turned up two related gaps this entry didn't touch.
414
+
415
+ **Status:** superseded 2026-09-14 by the entry below.
416
+
417
+ ### `isPlatformFocusable` treated every `<area>` in a used map as focusable, `href` or not, unlike its own `<a>` branch three lines above it, fixed
418
+
419
+ **Decision as it stands (before the change):** `isPlatformFocusable`'s `tag === 'area'` branch (`dom-helpers.js`) treated an `<area>` as focusable purely for belonging to a `<map>` a rendered `<img usemap>` references — no `href` check. The `tag === 'a'` branch immediately above it requires a non-empty `href` before returning focusable. `area-alt-present` and `area-alt-quality` inherited this: both evaluated any `<area>` in a used map, `href` or not, at their own applicability gate (each rule's local `getReferencingImgForArea`/used-map lookup, not `isPlatformFocusable` itself for the baseline case).
420
+
421
+ **Why it was questioned:** per the HTML spec, an `<area>` with no `href` does not represent a hyperlink at all — "represents merely a region of the map that has no associated action" — so it is not focusable, not in the accessibility tree as a link, and has no accessible-name requirement to fail. Run against `scripts/other-engine-corpus-check.js` (built 2026-09-14) scoped to a third-party scanner's own area-alt rule, `area-alt-present`'s fixture agreed on exactly one of its seven failing cases — the one case that happened to carry `href` (`area_case_16`). The other six (missing alt, empty alt, aria-hidden-but-focusable, `role="presentation"`, `aria-disabled`, a dangling `aria-labelledby`) all omitted `href` entirely and the other tool didn't flag any of them. `area-alt-present` has no ACT counterpart (`docs/ACT_RULE_MAPPING.md`'s "Extra coverage beyond ACT" list), so this never surfaced through that comparison either — first time this rule had been checked against any outside ground truth. The `<area>` branch's own comment ("Engine policy: treat `<area>` as focusable when it's part of a *used* image map") dated to the project's first commit, with nothing explaining why `href` was left out unlike the `<a>` branch beside it.
422
+
423
+ **Decision (2026-09-14):** `isPlatformFocusable`'s `<area>` branch now requires a non-empty `href` before doing the used-map lookup, matching the `<a>` branch. `area-alt-present.js` and `area-alt-quality-manual.js` each gained the same `href` check at their own applicability gate, right after the existing used-map lookup, since that gate — not `isPlatformFocusable` — is what actually governs a case's baseline applicability; `isPlatformFocusable`'s branch only mattered for the `aria-hidden`-override and `role="presentation"`-exclusion paths. Both rules' fixtures had `href` retrofitted onto every case testing naming/eligibility logic (all but one case in each fixture previously omitted it), and `area_case_16` in `area-alt-present`'s fixture — previously "href present, still fails" — was repurposed to cover the newly-distinct branch, an `<area>` with no `href` in a used map, since its original point had become redundant with `area_case_01` once every other case also gained `href`.
424
+
425
+ **Status:** resolved 2026-09-14.
426
+
427
+ ### `<area>`/`<map>` eligibility was checked against the wrong ground truth: spec text and `isAccTreeEligible`'s own layered design, not what a real browser does with a non-rendered element pair
428
+
429
+ **Decision as it stands (before the change):** three related exclusions, none verified against a real browser. `hidden`/`display:none`/`inert` on the `<area>` itself, or `display:none`/`inert` on its `<map>`, all excluded the area (the entry above covers `inert` specifically; `hidden` and `display:none` on the `<map>` were separate, pre-existing gaps with no carve-out at all, not something this session introduced). Separately, `area-alt-present.js`/`area-alt-quality-manual.js` gated the area's applicability on `isAccTreeEligible(img, ctx)` for the referencing `<img>`, which folds in `aria-hidden`.
430
+
431
+ **Why it was questioned:** the entry above records getting the `inert` question wrong once already by reasoning from spec text instead of testing it. Doing the same check for `hidden`/`display:none` this time, and testing all of it directly: loaded each scenario in a real page and drove actual keyboard Tab navigation in Chromium and Firefox (WebKit's headless tab handling looked broken in the same harness, so not counted). Result: `hidden` on the `<area>`, and `display:none` or `inert` on the `<map>`, all leave the area fully reachable — same non-rendered-element reasoning as `inert`, just never applied to the other two mechanisms. Separately, `aria-hidden` on the referencing `<img>` also leaves the area reachable, for a different reason: `<area>` is not a DOM descendant of `<img>`, only linked by the `usemap` IDREF, so `aria-hidden` has no ancestor relationship to propagate along, and the img is still rendered regardless of its ARIA state. Gating the area's applicability on the img's full `isAccTreeEligible` — which folds in `aria-hidden` — conflated two different questions: is the img actually rendered (genuinely relevant, since that's what the hotspot geometry depends on) versus is the img exposed to the accessibility tree (irrelevant here).
432
+
433
+ **Decision (2026-09-14):** `hasBlockingInert`'s `<area>` carve-out is restored (see entry above) with the verified reason recorded this time. The structural `hidden`-attribute check and the CSS `display:none` ancestor check in `isAccTreeEligible` both gained the same carve-out, extended to a `<map>` ancestor as well as the area itself (`visibility:hidden` needed no change — it was already skipped for `<area>` nodes and already gave the right answer). `area-alt-present.js`/`area-alt-quality-manual.js`'s referencing-`<img>` gate switched from `isAccTreeEligible` to `isDomVisibleEligible` (`{ visibilityMode: 'styleOnly', disableGeometry: true }`), the same DOM-only helper `aria-hidden-focus.js` already uses for this exact distinction — `hidden`/`display:none`/`visibility` on the img still excludes the area; `aria-hidden` on the img no longer does. Both fixtures gained the corrected titles/spans and cases, and a note in the `hidden_css`/`inertness` section headings that these mechanisms are ignored on the area/map itself but still block on a genuine outside ancestor.
434
+
435
+ **Accepted cost:** `area-alt-present`'s fixture goes from 6 failing cases to 11; `area-alt-quality`'s goes from 1 applicable case to 4. Both increases are real markup this rule should have been flagging all along, not new false positives — a genuinely inert or `hidden`-marked `<area>` in a used map, or one behind an `aria-hidden` image, is still a real, reachable, unnamed link.
436
+
437
+ **Status:** resolved 2026-09-14.
438
+
439
+ ### Ancestor/group `opacity` was treated as an unconditional contrast-computability blocker, even when the backdrop is a single flat resolvable color, fixed
440
+
441
+ **Decision as it stands (before the change):** `contrast-computable`/`contrast-minimum`/`contrast-enhanced` all reported `cantTell` for any text with a fractional-`opacity` ancestor (group opacity), unconditionally. `contrast-all-scenarios.html`'s `case-blocker-ancestor-opacity` was the documented example: a `<p>` with `color:#000000; background-color:#ffffff; opacity:1` inside a `<div style="opacity:0.5">`, itself on a flat white (`bgWhite`) section.
442
+
443
+ **Why it was questioned:** run through `scripts/other-engine-corpus-check.js` unscoped against every fixture with a case, this was one of a small number of gaps that survived filtering out same-page noise and matched a same-topic finding (`color-contrast`) from the other tool. Verified directly rather than trusting either side: rendered the fixture in real Chromium and sampled the actual composited pixel color at the text (`(126,126,126)`) against the background (`(255,255,255)`) — a deterministic WCAG contrast ratio of ~4.06, which fails AA's 4.5:1 for normal text. The existing `getComputabilityBlocker` code comment gave the real reason it stayed unconditional: naively combining the existing per-element opacity product (used for the foreground) with the existing ancestor-opacity-aware background walk double-counts the ancestor's opacity, confirmed by hand with a second scenario (a colored ancestor background rather than white-on-white, where the two independently-computed colors landed on a visibly different, wrong answer from real compositing).
444
+
445
+ **Decision (2026-09-14):** `resolveGroupOpacityColors` in `contrast-helpers.js` resolves both colors in a single walk instead of combining two independently-computed ones after the fact: a background accumulator (as `computeEffectiveBackground` already builds) and a parallel foreground accumulator that receives the text's own color as its innermost layer, with every ancestor's own background-color and opacity applied to *both* accumulators in lockstep. Nothing is combined after the fact, so there is nothing left to double-count, and it handles any number of nested opacity ancestors — with or without their own background-color — uniformly. It only bails (falls back to the existing `ANCESTOR_OPACITY` `cantTell`, unchanged) for a blend-mode/filter/background-image anywhere in the chain, a missing declared text color, or a background that never reaches full opacity even after the whole chain is walked — the genuinely hard general case (a group over a gradient or other unresolvable content) stays exactly as conservative as before. `getComputabilityBlocker`, `computeEffectiveForeground` and `computeEffectiveBackground` all consult it transparently, so no call site elsewhere needed to change. `contrast-all-scenarios.html` gained a second ancestor-opacity case (a gradient sitting further out, beyond the resolvable opacity ancestor) specifically to keep `ANCESTOR_OPACITY` reachable in the fixture corpus `scripts/generate-finding-ids.js` scans — removing that reachability would have silently dropped a still-real reason code from the finding-ids inventory.
446
+
447
+ **Accepted cost:** the fixture's `case-blocker-ancestor-opacity` moves from `cantTell` (computable) to `pass` for `contrast-computable`, and from excluded to `fail` for `contrast-minimum`/`contrast-enhanced` — a real finding these rules should have caught all along, not a new false positive.
448
+
449
+ **Status:** resolved 2026-09-14.
450
+
451
+ ### Six ARIA-widget naming rules credited a `<label>` to elements it never actually named, via an unguarded local copy of the label-association lookup
452
+
453
+ **Decision as it stands (before the change):** `combobox-name-present`, `listbox-name-present`, `searchbox-name-present`, `spinbutton-name-present`, `textbox-name-present` and `slider-name-present` each carried an identical local `getNativeLabelText(el)`, falling back to `el.closest('label')` (wrapping) or a `label[for]` map (`buildLabelForMap`) with no check that `el` was actually a labelable element. Each rule's own JSDoc already stated the intended contract correctly ("On a labelable element (`<input role="combobox">`) an associated `<label>` counts as well") — the implementation just never enforced the "labelable" half of it.
454
+
455
+ **Why it was questioned:** run through `scripts/other-engine-corpus-check.js` unscoped, `combobox-name-present`/`listbox-name-present`/`searchbox-name-present`/`spinbutton-name-present`/`textbox-name-present` all disagreed with the *same* other-tool rule, `aria-input-field-name` — a recurring pattern across five rules pointed at one systemic thing rather than five coincidences. Verified directly: rendered `<label>Wrapped label text <div role="combobox" tabindex="0"></div></label>` in real Chromium and Firefox and read the actual accessibility tree (`ariaSnapshot()`) — the combobox has no accessible name in either browser, wrapping or `for`-based. `slider-name-present` shared the same buggy local function but never actually triggered it: its own call site already gates the label lookup behind `kind === 'native-slider'`, so it was dead code there, not a live bug — its fixture already had the correct expectation on record.
456
+
457
+ **Decision (2026-09-14):** all six rules' local `getNativeLabelText` now delegate to the shared `getAssociatedLabelElements` (`dom-helpers.js`), which already correctly restricted the *wrapping* case to `wrap.querySelector(LABELABLE_SELECTOR) === el` but, it turned out, never applied the equivalent check to the `for`-based case — so `getAssociatedLabelElements` itself gained a labelable check up front, benefiting every existing caller, not just these six. That surfaced a second instance of the identical bug one level down: a pinned unit test in `tests/core/dom-helpers-name-computation.test.js` explicitly asserted that `<label for="x">Name</label><div id="x" role="button">` resolves to the name `"Name"` — verified against real Chromium/Firefox as wrong the same way, and corrected alongside a new test pinning the correct behavior for a genuinely labelable target.
458
+
459
+ **Accepted cost:** each of the five previously-affected rules (not `slider-name-present`) gains two new failing fixture cases — a wrapping-`<label>` case and a `label[for]`-plus-empty-content-title-fallback case — both real markup a screen reader user hears as unnamed, not new false positives.
460
+
461
+ **Status:** resolved 2026-09-14. `form-control-single-label.js` and `binary-control-name-present.js` have their own separate, similarly-unguarded `closest('label')` fallbacks, not touched here since their applicability already appears scoped to genuinely labelable elements (not verified) — worth a look if they ever start evaluating non-labelable targets.
package/docs/EARL.md CHANGED
@@ -17,7 +17,7 @@ fs.writeFileSync('earl.jsonld', JSON.stringify(report, null, 2));
17
17
 
18
18
  Two audiences, and they want the same document for different reasons.
19
19
 
20
- An **implementation report** tells the ACT Rules community group how this engine behaves against their test cases, which is what gets an engine listed alongside the other implementations. Listing is not endorsement and the W3C does not verify the data — the accurate phrasing is *"listed as an ACT implementation"*, never *"W3C certified"*.
20
+ An **implementation report** tells the ACT Rules community group how this engine behaves against their test cases, which is what gets an engine listed alongside the other implementations. Listing is not endorsement and the W3C does not verify the data — the accurate phrasing is *"listed as an ACT implementation"*, never *"W3C certified"*. This engine's own report is published at <https://surea11y.github.io/act-report/act-report.jsonld> and regenerated by `scripts/act-report.js`.
21
21
 
22
22
  A **consumer** gets an interchange format. EARL is what accessibility tooling reads when it has to combine results from more than one source — an automated scan and a manual audit, say, or several engines — because every assertion carries who asserted it and how. That is worth having whether or not anything is ever submitted anywhere.
23
23
 
@@ -91,6 +91,78 @@ If you would rather not see the rule at all under 2.2, exclude it outright — t
91
91
 
92
92
  `duplicate-id` is the only rule carrying that tag today. Left in, it still reports something real — a duplicate id breaks `<label for>`, fragment links and `getElementById` whatever the standard says — it just is not a 2.2 conformance failure.
93
93
 
94
+ ### Conformance profiles
95
+
96
+ `engineOptions.profile` names a conformance target instead of spelling out its tag set:
97
+
98
+ | Profile | Runs the rules tagged | WCAG target | Why |
99
+ |---|---|---|---|
100
+ | `wcag22-aa` | `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, `wcag22a`, `wcag22aa` | 2.2 | WCAG 2.2 Level A and AA |
101
+ | `en301549-v4.1.1` | same as `wcag22-aa` | 2.2 | EN 301 549 V4.1.1 chapter 9 restates WCAG 2.2 A and AA |
102
+ | `en301549-v3.2.1` | `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa` | 2.1 | EN 301 549 V3.2.1 chapter 9 restates WCAG 2.1 A and AA, including 4.1.1 Parsing |
103
+ | `section508` | `wcag2a`, `wcag2aa` | 2.0 | The Revised 508 Standards incorporate WCAG 2.0 A and AA |
104
+
105
+ ```js
106
+ runDomRulesInPage(url, null, { profile: 'en301549-v3.2.1' }, null);
107
+ ```
108
+
109
+ A standard registered as a profile under `profiles/` brings its own profiles to this list ([`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md#adding-another-standard)).
110
+
111
+ The WCAG target follows from the tags the same way it does for a hand-written set (see [Filtering by WCAG version](#filtering-by-wcag-version-21-vs-22)), so under `en301549-v3.2.1` a duplicate id can still `fail`, and under `en301549-v4.1.1` it cannot. Names are matched case-insensitively. A run that used a profile reports it back as `engine.profile`.
112
+
113
+ Precedence: anything that *includes* rules selects them instead of the profile — a `runOnly` with `tags`, `includeRuleIds` or `includeTestIds`, or an `include` in `engineOptions.rules`/`.tags`/`.tests`. Excludes still apply on top of the profile, whether they come from `runOnly` (`excludeTags`, `excludeRuleIds`, `excludeTestIds`, so a binding's `disableTags()` narrows the profile rather than replacing it) or, when there is no `runOnly` filter, from `engineOptions` (`tags.exclude`, `rules.exclude`, `tests.exclude`), and an explicit `engineOptions.wcagVersion` still wins over the version the profile implies. A profile that does not take effect — an unknown name, or one overridden as above — is not an error: the run proceeds as if none was given, logs a `console.warn` saying why, and carries no `engine.profile`.
114
+
115
+ A standard's profile may also leave rules out, when its standard waives a WCAG criterion or replaces a WCAG check with its own (`exclude` in the registry, [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md#adding-another-standard)): those rules and their WCAG rollups do not run, the rule catalog leaves them out too, and `engine.profileExcludes` names what was left out. No built-in profile excludes anything today.
116
+
117
+ A profile only chooses which rules run. It says nothing about whether passing them meets the standard it is named after: most Success Criteria need human judgement no automated rule covers (see [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md#what-this-engine-cannot-tell-you)).
118
+
119
+ An EN 301 549 profile also switches on the EN 301 549 clauses of the version it targets, as if `mappings: ['en301549:V4.1.1']` (or `V3.2.1`) had been passed; see the next section. Any standard's profile switches on its own version the same way. A profile with `mappedRules` also runs, whatever their tags, the rules its standard is mapped to: the ones with no WCAG mapping, such as `heading-order` and `skip-link`, have no WCAG tag to be selected by.
120
+
121
+ ### Opt-in rules
122
+
123
+ Some rules check a standard's own requirements, ones WCAG does not make: a national standard may require a doctype, or forbid presentational attributes such as `bgcolor`. A `fail` from such a rule is a failure of that standard, not of WCAG, so these rules are **off by default**. Each carries its standard's rule tag and runs only when the selection asks for it:
124
+
125
+ - through the standard's profile, which lists the tag,
126
+ - by that tag (`tags: { include: '<tag>' }`, alone or with others), or
127
+ - by its id (`rules: { include: '<rule id>' }`, or `runOnly.includeRuleIds`),
128
+ - by the id of one of its standard's own rollups that groups it (`rules: { include: '<rollup id>' }` runs that rollup's rules, opt-in or not), or
129
+ - by unlocking it with `engineOptions.optInRules`, below.
130
+
131
+ Nothing else selects one: not a default run, not a WCAG tag set, not a WCAG or EN 301 549 profile, not a WCAG rollup id. A standard's own rollups carry the same tag and follow the same rule. Excludes apply to them as to any rule. This holds for a rule added through `customRules` that carries the tag too. The tags come from `ruleTag` in `src/coverage/standards.js`, one per registered standard that has rules of its own; core's built-in standards have none.
132
+
133
+ The tag selects the opt-in rules and nothing more. `tags: { include: '<tag>' }` alone runs that standard's own rules, not the WCAG and best-practice rules it is also mapped to, so it answers "what does the standard require beyond WCAG" and is not an audit against it. Its own rollups report `cantTell` for any requirement whose rules did not run (`missingChild`). For an audit, use the standard's profile.
134
+
135
+ #### Running every rule (`optInRules`)
136
+
137
+ To run every rule the engine has, whatever standard it belongs to, leave out the profile and unlock the opt-in rules:
138
+
139
+ ```js
140
+ runDomRulesInPage(url, null, { optInRules: 'all' }, null);
141
+ ```
142
+
143
+ `'all'` unlocks every standard's opt-in rules; a list of tags (`['<tag>']`, or `'<tag>'`) unlocks only those. It lifts the gate and nothing else: the rest of the selection still decides which rules run. So with no filter and no profile, as above, the run covers every rule and every standard's rollups; under `profile: 'wcag22-aa'` it still runs the WCAG rules only, since opt-in rules carry no WCAG tag, and under a standard's own profile it changes nothing for that standard's rules. Excludes apply as usual. A tag that is not an opt-in tag is ignored with a `console.warn`.
144
+
145
+ This is for seeing everything the engine can report, during development or when exploring a site. It is not a conformance target: such a run fails a page for things WCAG allows and only another standard forbids. The result says so: `engine.optInRules` lists the tags whose rules ran, and the HTML report, SARIF and JUnit show it next to the profile. It adds no other standard's numbers to results; pass `mappings` for that, for example `{ optInRules: 'all', mappings: ['<key>'] }` to see a standard's requirements each rule checks.
146
+
147
+ ### Other standards (`mappings`)
148
+
149
+ Every result's `meta.normativeMappings` names the WCAG Success Criteria it tests. `engineOptions.mappings` adds the requirement of another standard that corresponds to each one. By default it adds none: a clause of a standard you do not audit against is noise in every SARIF tag, JUnit property and report.
150
+
151
+ | Value | Adds |
152
+ |---|---|
153
+ | `'en301549'` | The EN 301 549 chapter 9 clause for each criterion, in every version that has it (V3.2.1 and V4.1.1) |
154
+ | `'en301549:V3.2.1'`, `'en301549:V4.1.1'` | The same, for that version only |
155
+
156
+ ```js
157
+ runDomRulesInPage(url, null, { mappings: ['en301549:V3.2.1'] }, null);
158
+ ```
159
+
160
+ A standard registered as a profile adds its own key (and `<key>:<version>`), with the requirements it maps each rule to.
161
+
162
+ It takes an array or a comma-separated string, names and versions matched case-insensitively. What it adds to comes from the profile too: under `profile: 'en301549-v4.1.1'` the V4.1.1 clauses are there without asking, and `mappings` can add more. A name or version the engine has no table for is ignored with a `console.warn`. A run that carries any other standard reports which in `engine.mappings`, canonically spelled (`["en301549:V3.2.1"]`, or `["en301549"]` for every version).
163
+
164
+ It changes only what a result names, never which rules run or their outcomes. The rule catalog follows the same option: `getChecksCatalog(engineOptions)`, `getCheckDefById(ruleId, engineOptions)`, `getChecksForRunOnly(runOnly, engineOptions)`, `getRulesCatalog(engineOptions)` and `getCompositeRuleById(id, engineOptions)` name the standards a scan with those options would, a profile included when it would apply, so a catalog entry and a result always agree. With no options they name WCAG only; pass every standard's key in `mappings` for all of them. The published tables (`@surea11y/core/en301549`) are not filtered. A rule added through `customRules` keeps exactly the mappings it declares, whatever this option says.
165
+
94
166
  ### Via `engineOptions` (no `runOnly`)
95
167
 
96
168
  Same filtering, expressed as comma-separated strings (or arrays) nested in `engineOptions`:
@@ -111,6 +183,9 @@ runDomRulesInPage(url, null, {
111
183
  const engineOptions = {
112
184
  locale: 'en', // default 'en'; de-DE falls back to de, then to en per string
113
185
  wcagVersion: '2.2', // default '2.2' — the conformance target, see "Filtering by WCAG version" above
186
+ profile: 'en301549-v4.1.1', // optional — a named conformance target, see "Conformance profiles" above
187
+ mappings: ['en301549'], // optional — standards besides WCAG to name on each result, see "Other standards" above
188
+ optInRules: 'all', // optional — also run rules other standards add beyond WCAG, see "Running every rule" above
114
189
  messages: { de: { /* key: text */ } }, // optional caller-supplied dictionaries; win over built-in ones
115
190
  includeHiddenElements: false, // default false — set true to evaluate hidden/collapsed subtrees too
116
191
  includeShadowDom: true, // default true — opt OUT with `false` to skip open shadow roots
@@ -157,6 +232,9 @@ const engineOptions = {
157
232
  |---|---|
158
233
  | `locale` | Any string. A code with a subtag falls back to its base language first, so `de-DE` uses `de`; failing that, English. Individual strings then fall back the same way (chosen locale → `en` → the rule's literal English text), so a partly-translated locale never produces missing text. All of that is silent in the strings themselves, so the result reports what actually happened in `engine.locale` — check it if you need to know whether you got the language you asked for. See [`I18N.md`](./I18N.md). |
159
234
  | `wcagVersion` | `'2.0'`, `'2.1'` or `'2.2'` — which version of WCAG the run is conformance-testing against. Defaults to whatever your version-origin tags imply, and to `'2.2'` when they imply nothing. The only thing it currently changes is SC 4.1.1 Parsing, removed in 2.2: under a 2.2 target a rule tagged `wcag22-removed` still runs and still reports its occurrences, but cannot `fail` — see ["Filtering by WCAG version"](#filtering-by-wcag-version-21-vs-22) above. Any other value is ignored and the default applies. |
235
+ | `profile` | Optional named conformance target: `'wcag22-aa'`, `'en301549-v4.1.1'`, `'en301549-v3.2.1'`, `'section508'`, or a registered standard's own. Selects rules by the matching tag set when nothing else includes any, and the WCAG target follows from those tags. Reported back as `engine.profile` when it took effect; otherwise ignored with a console warning. See ["Conformance profiles"](#conformance-profiles) above. |
236
+ | `mappings` | Optional standards besides WCAG whose requirements `meta.normativeMappings` names: `'en301549'`, `'en301549:V3.2.1'`, `'en301549:V4.1.1'`, or a registered standard's key, as an array or comma-separated string. Default none, so results name WCAG only; an EN 301 549 profile adds its own version. Reported back as `engine.mappings` when any applies. See ["Other standards"](#other-standards-mappings) above. |
237
+ | `optInRules` | Optional: `'all'`, or a list of opt-in rule tags, as an array or comma-separated string. Unlocks those rules outside their standard's profile; the rest of the selection still decides what runs. Reported back as `engine.optInRules` when it ran a rule the selection would not have run otherwise; unknown tags are ignored with a console warning. See ["Running every rule"](#running-every-rule-optinrules) above. |
160
238
  | `messages` | Optional `{ [locale]: { key: text } }`. Checked before the engine's own tables, so it can override individual strings or supply a language the build does not carry. Keys you omit fall back normally, so a partial override is fine. This is how the standalone browser bundle receives a locale side file, and it is the only way to get a dictionary into a page context, since the in-page runner is serialized and cannot read files. See [`I18N.md`](./I18N.md). |
161
239
  | `includeHiddenElements` | Default `false`: helper queries exclude elements hidden by structural/CSS mechanisms such as `display:none`, `[hidden]`, closed `<details>`, and hidden rendering-only host elements (with descendants excluded too). Set `true` to include those hidden/collapsed subtrees in evaluation (legacy/static-markup behavior). |
162
240
  | `includeShadowDom` | Default `true`: rules using `helpers.queryAllSmart` traverse into open shadow roots. Set `false` to scan only the light DOM. Closed shadow roots are never reachable either way (no DOM API exposes them). |
@@ -167,9 +245,9 @@ const engineOptions = {
167
245
  | `contrast.rootCanvasFallback` | The assumed page background color when it's not computable at all — only matters in `auditorAssist` mode. |
168
246
  | `visibilityMode` | Controls how strict the three contrast rules (`contrast-minimum`, `contrast-enhanced`, `contrast-computable`) are about deciding a text node is actually eligible to check. **Not read by any other rule.** `'styleOnly'` (default): eligibility is CSS-only — `display`, `visibility`, `opacity`, ancestor-hiding, etc. `'styleAndGeometry'`: adds real layout checks (`getClientRects()`/`getBoundingClientRect()`) on top of that — text with no client rects, or zero width/height, is excluded too. Reach for `'styleAndGeometry'` when running under a real browser/Playwright-Puppeteer (`runa11yCoreInPage`) and you want contrast findings to reflect actual rendered layout rather than just computed style; under plain jsdom (`runDomRulesInPage`) there's no real layout engine, so `'styleAndGeometry'` mostly just adds `getBoundingClientRect()` zero-size checks, not true clipping/overflow detection — see [`LIMITATIONS.md`](./LIMITATIONS.md). |
169
247
  | `policyContract` / `policy` | See [`POLICY.md`](./POLICY.md) — controls which outcomes/confidence values are allowed and whether manual rules' would-be `fail`s get coerced to `cantTell`. |
170
- | `output.includeSelector` / `.includeHtml` | Suppresses the engine's automatic `selector`/`html` fill-in. Since every rule was migrated to report its element rather than build occurrences by hand (1.5.0), that fill-in is the path almost all of them take: setting `includeSelector: false` strips selectors from 117 of the 130 rules, and `includeHtml: false` strips HTML snippets from 122. The remainder still assemble those fields themselves inside `runInPage` and are unaffected — among them `contrast-minimum`/`contrast-enhanced` (whose findings are text runs, not elements), `page-title-present` and `identical-links-same-purpose`. So this narrows output substantially but is still not a guarantee of *no* selectors or HTML anywhere in the result. |
171
- | `rules[ruleId]` | Passed through to that rule as `ctx.config`, and — for `excludeSelectors` specifically — read by the engine itself before the rule ever runs. See "Rule-scoped `excludeSelectors`" below. Any other key is passthrough only: **no shipped rule currently reads `ctx.config`** for anything besides `excludeSelectors`. |
172
- | `probes` | An optional, JSON-safe evidence object your host application can supply (depth- and size-capped by the engine before rules see it, via `ctx.inputs.probes`) — for future rules that might accept externally-supplied signals (e.g. real layout measurements a static DOM scan can't compute itself). Not consumed by any current rule. |
248
+ | `output.includeSelector` / `.includeHtml` | Suppresses the engine's automatic `selector`/`html` fill-in. Since every rule was migrated to report its element rather than build occurrences by hand (1.5.0), that fill-in is the path almost all of them take: setting `includeSelector: false` strips selectors from nearly every rule, and `includeHtml: false` HTML snippets. The remainder still assemble those fields themselves inside `runInPage` and are unaffected — among them `contrast-minimum`/`contrast-enhanced` (whose findings are text runs, not elements), `page-title-present` and `identical-links-same-purpose`. So this narrows output substantially but is still not a guarantee of *no* selectors or HTML anywhere in the result. |
249
+ | `rules[ruleId]` | Passed through to that rule as `ctx.config`, and — for `excludeSelectors` specifically — read by the engine itself before the rule ever runs. See "Rule-scoped `excludeSelectors`" below. Any other key is passthrough only: **no shipped rule takes anything else from it.** A rule's declared settings, such as `contrast-minimum`'s thresholds, are its standard's, so a caller's value for one is dropped: a result naming WCAG 1.4.3 is always decided at 4.5:1. A standard with other thresholds has its own rule, a variant ([`RULE_AUTHORING.md`](./RULE_AUTHORING.md#rule-variants)). |
250
+ | `probes` | An optional, JSON-safe evidence object your host application supplies, for what a scan of one page cannot see, such as the site's other pages. The engine caps it before rules read it (`ctx.inputs.probes`): six levels deep, 200 items per array, 50 keys per object, 2,000 characters per string. `crawl.pageTitles` (`{ pages: [{ url, title }] }`) is read by `page-title-patterns`, to look for generic and templated titles across a site. A profile's rules may read probes of their own, which its documentation describes. |
173
251
  | `perfStats` / `profileRules` | Debug-only. `perfStats: true` returns internal counters on the result's `perfStats` field; `profileRules: true` **additionally** adds a per-rule timing breakdown there. `profileRules` on its own does nothing — `perfStats` is what creates the object the breakdown lives in. Shape is not part of the stable output contract — don't build on it. Note also that `profileRules` is the one option that makes output non-deterministic: counters are stable across identical runs, wall-clock timings are not. Leave it off if you diff results between runs. |
174
252
  | `pingWaitTime` / `frameWaitTime` | Only read by `runa11yCoreAcrossFrames` (see [`INTEGRATION.md`](./INTEGRATION.md#cross-frame-scanning-including-cross-origin)) — how long to wait for a child frame to answer a ping (default `500`ms) and a full run request (default `60000`ms) before treating it as unreachable. Ignored by `runDomRulesInPage`/`runa11yCoreInPage`. |
175
253
 
package/docs/I18N.md CHANGED
@@ -4,16 +4,23 @@ Every rule's title/description, and every occurrence's summary/hint, is localize
4
4
 
5
5
  ## Current locale coverage
6
6
 
7
- | Locale | File | Keys | Values still in English |
7
+ | Locale | Files | Keys | Values identical to English |
8
8
  |---|---|---|---|
9
- | `en` (English) | `src/i18n/en.json` | 666 | — (the canonical/fallback set) |
10
- | `fr` (French) | `src/i18n/fr.json` | 666 | 1 |
11
- | `de` (German) | `src/i18n/de.json` | 666 | 0 |
12
- | `es` (Spanish) | `src/i18n/es.json` | 666 | 0 |
9
+ | `en` (English) | `src/i18n/en.json` | 841 | — (the canonical/fallback set) |
10
+ | `fr` (French) | `src/i18n/fr.json` | 841 | 3 — *Orientation*, *Interruptions*, *Occurrences*, which are the same words in French |
11
+ | `de` (German) | `src/i18n/de.json` | 841 | 0 |
12
+ | `es` (Spanish) | `src/i18n/es.json` | 841 | 2 — *Selector*, the same word in Spanish |
13
+ | `ja` (Japanese) | `src/i18n/ja.json` | 841 | 0 |
14
+
15
+ The last column is what `npm run i18n:report` measures: values that match the English text character for character. That catches a key nobody has translated yet, but it also counts a word that is simply the same in both languages, and it cannot see an English word left inside an otherwise translated sentence. Those are found by reading the text; none remain in the locales above.
13
16
 
14
17
  Locale files are plain JSON: a flat map of key to translated string, in the same key order as `en.json`. Nothing else lives in them, so contributing a language means editing text and never touching code.
15
18
 
16
- Every locale carries every key `en.json` has. Keeping it that way is the job of `npm run i18n:sync`: run it after any change to `en.json` and it rewrites every non-English locale file to match — adding keys that are new, dropping keys `en.json` no longer has, and leaving existing translations alone. A key it adds is seeded with the English text, which counts as untranslated until someone replaces it.
19
+ A locale's text may be split between several files. `src/i18n/<locale>.json` holds the engine's own messages; a profile's `profiles/<key>/i18n/<locale>.json` holds those of its rules (see [`profiles/README.md`](../profiles/README.md)). Each file is synced against the `en.json` next to it, and the build merges them into one dictionary per locale. A key lives in exactly one of them: the build fails if two files define it.
20
+
21
+ A profile chooses its languages: the locale files in its `i18n/` folder are the list. It always has `en.json`; a locale core has and the profile does not shows that profile's messages in English, as the engine does for any key a locale lacks. Because the profile chose it, `engine.locale.reason` stays `ok`: the build records the keys each locale leaves out that way, and only a key missing otherwise makes it `partial-dictionary`. Core's own messages are never left out by choice, so a locale only a profile has is reported as `partial-dictionary`.
22
+
23
+ Every locale file carries every key the `en.json` next to it has. Keeping it that way is the job of `npm run i18n:sync`: run it after any change to `en.json` and it rewrites every non-English locale file to match — adding keys that are new, dropping keys `en.json` no longer has, and leaving existing translations alone. A key it adds is seeded with the English text, which counts as untranslated until someone replaces it.
17
24
 
18
25
  Forget to run it and the build fails: `tests/i18n-sync.test.js` and `tests/i18n/i18n-locale-completeness.test.js` reject a missing key, an orphaned key, or any file that `i18n:sync` would rewrite. `npm run i18n:check` reports the same thing without touching the files, and `npm run i18n:report` prints per-locale coverage.
19
26
 
@@ -55,7 +62,7 @@ Graceful fallback has one drawback: ask for a language the build doesn't carry a
55
62
  "engine": {
56
63
  "tag": "a11ycore",
57
64
  "schemaVersion": "1.0.0",
58
- "locale": { "requested": "ja", "resolved": "en", "reason": "unknown-locale" }
65
+ "locale": { "requested": "ko", "resolved": "en", "reason": "unknown-locale" }
59
66
  }
60
67
  ```
61
68
 
@@ -66,8 +73,8 @@ Graceful fallback has one drawback: ask for a language the build doesn't carry a
66
73
  | `ok` | You got exactly what you asked for, and that dictionary carries every key. |
67
74
  | `primary-subtag` | Your code carried a subtag with no dictionary of its own, so its base language was used — you asked for `de-DE` and got `de`. Normal and expected; nothing to fix. A difference in case alone is not this: `DE` reports `ok`. |
68
75
  | `dictionary-not-loaded` | The project ships that language, but this copy of the engine doesn't carry it and none was supplied. In practice: the standalone browser bundle without its locale side file. |
69
- | `unknown-locale` | The project has no such translation at all, so English was used. `ja` and `pt-BR` both land here today. |
70
- | `partial-dictionary` | The dictionary was used but is missing some keys, so those individual strings fell back to English. |
76
+ | `unknown-locale` | The project has no such translation at all, so English was used. `ko` and `pt-BR` both land here today. |
77
+ | `partial-dictionary` | The dictionary was used but is missing some keys, so those individual strings fell back to English. A profile's messages in a language the profile does not offer also show in English, but do not count here. |
71
78
 
72
79
  Treat the list as open — a later release can add a value, so match on the ones you care about and let the rest fall through a default.
73
80
 
@@ -83,11 +90,14 @@ Which languages are available depends on how you load the engine.
83
90
  | A binding (Playwright, Cypress, …) | Every locale, built in. Nothing to configure. |
84
91
  | `surea11y.browser.js` in a `<script>` tag | English. Load `surea11y.i18n.<locale>.js` after it for anything else. |
85
92
 
86
- The bundle is split because it travels over the network to every page that uses it, and no page needs all four languages. Keeping English inline and the rest optional took about 280 KB off the download and stops it growing as languages are added. Nothing else changes: the Node package and the bindings are unaffected.
93
+ The bundle is split because it travels over the network to every page that uses it, and no page needs every language. Keeping English inline and the rest optional took about 280 KB off the download and stops it growing as languages are added. Nothing else changes: the Node package and the bindings are unaffected.
87
94
 
88
95
  ```html
89
96
  <script src="surea11y.browser.js"></script>
90
- <script src="surea11y.i18n.de.js"></script>
97
+ <script src="surea11y.i18n.ja.js"></script>
98
+ <script>
99
+ const result = a11ycore.runa11yCoreInPage(location.href, null, { locale: 'ja' }, null);
100
+ </script>
91
101
  ```
92
102
 
93
103
  Ask for a language whose file you didn't load and you get English, with `engine.locale.reason` set to `dictionary-not-loaded` — different from `unknown-locale`, which means the project has no such translation at all.
@@ -105,11 +115,35 @@ runDomRulesInPage(url, null, {
105
115
 
106
116
  Keys you don't supply fall back normally, so a partial override is fine.
107
117
 
118
+ ## Notes on individual locales
119
+
120
+ **Japanese (`ja`).** Terms that WCAG 2.2 defines follow the Japanese translation published by WAIC (Web Accessibility Infrastructure Committee): 達成基準 for success criterion, アクセシブルな名前 for accessible name, テキストによる代替 for text alternative, 支援技術 for assistive technology, and the WAIC names for the criteria themselves, such as ラベルを含む名前 (name), コントラスト (最低限) and ターゲットのサイズ (最低限). Messages use the です/ます style, and hints ask for a fix with 〜してください. A few conventions worth keeping when you edit it:
121
+
122
+ - Where an English title says *should*, the Japanese one ends in 〜が望ましい (or says 推奨), and *(manual review)* becomes (手動確認). A title that says *must* is phrased as a requirement. Keep that split: it is how a reader tells advisory findings from confirmed failures.
123
+ - A `cantTell` summary says what a person has to decide, usually ending in 人による確認が必要です or in a sentence stating what could not be determined. It never reads as a failure.
124
+ - A `pass` message describes the check that ran (計算できたすべてのテキストが…基準値を満たしています), not the page. Nothing in the dictionary claims conformance.
125
+ - Quoted page content uses 「…」, so `{{name}}` becomes 「{{name}}」. Code keeps its ASCII quotes: `alt=""`, `role="img"`.
126
+ - Rule descriptions quote the phrases a rule matches in both languages (「こちら」、"click here"), since the rules recognize both.
127
+
128
+ Some parameter values are CSS or attribute vocabulary and are interpolated as-is in every locale: `{{fontWeightLabel}}` (`bold`, `700`), `{{labelSource}}` and `{{nameMechanism}}` (`aria-label`, `title`).
129
+
130
+ ## Language-specific matching
131
+
132
+ Five rules judge text by known phrases, so they carry word lists as well as messages: `link-name-quality` ("click here", 「こちら」), `heading-quality` ("Untitled", 「見出し」), `form-control-label-quality` ("Label", 「入力欄」), `page-title-patterns` ("Home", 「トップページ」) and `media-alternative-transcript-evidence` ("transcript", 「文字起こし」). Each list covers `en`, `de`, `es`, `fr` and `ja`.
133
+
134
+ English is always checked. The list for the element's own language, taken from the nearest `lang` attribute (the `<html>` element's for a page title), is added on top. Checking every list everywhere would flag words that are generic in one language and a real name in another, such as "Suite" or "Plus" on an English page. Transcript words are the exception: they are distinctive in every language and a page can link a transcript in another language, so all of them are always checked. Text is NFKC-normalized first, so full-width forms such as 「見出し2」 match.
135
+
136
+ `page-title-patterns` also counts each Chinese, Japanese or Korean character as two when deciding whether a title is too short, since 「お問い合わせ」 is a complete title in six characters.
137
+
138
+ Adding a language means adding its words to these lists as well as translating its dictionary.
139
+
108
140
  ## Where keys are used
109
141
 
110
142
  Two independent key namespaces, both resolved the same way:
111
143
 
112
144
  - **Rule-level**: `meta.i18n.titleKey` / `meta.i18n.descriptionKey` — resolve a rule's `title`/`description` on every `checksResults[]` entry.
145
+ - **HTML report**: the `report_*` keys label the page `@surea11y/core/report` renders — headings, table columns, outcome and severity names, the headline, the pager. The report reads them in the locale the scan resolved to, so translating a dictionary translates the report too.
146
+ - **Composite-level**: `meta.titleKey` / `meta.descriptionKey` in `src/catalogs/composites.wcag.js` — resolve each `rulesResults[]` rollup's `title`/`description`. The catalog's own `title`/`description` must match the English value, which `tests/i18n/i18n-composite-titles.test.js` checks. Titles use each language's published name for the success criterion.
113
147
  - **Occurrence-level**: `i18n.summaryKey` / `i18n.hintKey`, with `i18n.params` for `{{placeholder}}` interpolation — resolve an occurrence's `summary`/`hint`. See [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md#an-occurrence-occurrencesi).
114
148
 
115
149
  Both are included in the result alongside the already-resolved text, so you can re-render in a different locale from a saved result without re-scanning — see the `i18n` field's presence in `OUTPUT_SCHEMA.md`.
@@ -132,10 +166,18 @@ npm install
132
166
  npm run i18n:new pt-BR
133
167
  ```
134
168
 
135
- That writes `src/i18n/pt-BR.json` containing every key `en.json` has, in the same order, each seeded with the English text as a placeholder. **Use the shortest code that identifies the language** — `pt`, `nl`, `pl`. A file named `pt.json` serves everyone who asks for `pt`, `pt-BR` or `pt-PT`, because a code with a subtag falls back to its base language. Name it `pt-BR.json` and only people who ask for exactly that get it; `pt` speakers elsewhere fall through to English.
169
+ That writes `src/i18n/pt-BR.json`, containing every key `en.json` has, in the same order, seeded with the English text as a placeholder. **Use the shortest code that identifies the language** — `pt`, `nl`, `pl`. A file named `pt.json` serves everyone who asks for `pt`, `pt-BR` or `pt-PT`, because a code with a subtag falls back to its base language. Name it `pt-BR.json` and only people who ask for exactly that get it; `pt` speakers elsewhere fall through to English.
136
170
 
137
171
  Add a regional file only when the wording genuinely has to differ, and add it alongside the base language rather than instead of it.
138
172
 
173
+ A profile's messages are a file of their own, added with `--profile`:
174
+
175
+ ```sh
176
+ npm run i18n:new -- pt-BR --profile <key>
177
+ ```
178
+
179
+ writes `profiles/<key>/i18n/pt-BR.json` the same way. Without it, that profile's rules show in English in that locale.
180
+
139
181
  The command refuses to overwrite a file that already exists. To pick up work on an existing locale, edit it directly.
140
182
 
141
183
  ### 3. Translate the values
@@ -161,7 +203,7 @@ Four things to leave alone:
161
203
 
162
204
  Write for someone fixing the page, not for a specialist: say what is wrong and what to do about it. Where your language has established accessibility vocabulary (a national WCAG translation, a government standard), follow it rather than inventing terms.
163
205
 
164
- If a string is genuinely identical in your language, leave it. It is counted as untranslated but behaves correctly.
206
+ If a string is genuinely identical in your language, leave it. The report counts it as identical to English, which is correct and needs no action.
165
207
 
166
208
  ### 4. Check your progress
167
209
 
@@ -169,7 +211,7 @@ If a string is genuinely identical in your language, leave it. It is counted as
169
211
  npm run i18n:report
170
212
  ```
171
213
 
172
- Prints, per locale, how many values differ from English, plus any missing or orphaned keys. Anything still matching the English text is reported as untranslated — that is a progress signal, not an error.
214
+ Prints, per locale, how many values differ from English, plus any missing or orphaned keys. A value that still matches the English text is counted as not yet translated. While you work, that is your progress signal; once you are done, whatever it still counts should be words your language shares with English.
173
215
 
174
216
  You do not need every string on day one. Per-string fallback means an unfinished locale renders in your language where you have translated it and in English everywhere else, which is exactly how `fr`, `de` and `es` started. Ship what you have.
175
217
 
@@ -182,25 +224,25 @@ npm test
182
224
 
183
225
  `npm run build` also emits `surea11y.i18n.<locale>.js` for the standalone browser bundle — generated, so there is nothing for you to write.
184
226
 
185
- Then open a pull request touching `src/i18n/<locale>.json`, plus one row in the coverage table at the top of this file. Tell us which language and, if you use one, which national terminology standard you followed — that helps whoever reviews it later.
227
+ Then open a pull request touching `src/i18n/<locale>.json` (and a profile's `profiles/<key>/i18n/<locale>.json` if you translated its messages too), plus one row in the coverage table at the top of this file. Tell us which language and, if you use one, which national terminology standard you followed — that helps whoever reviews it later.
186
228
 
187
229
  Once a locale is in the repository it is maintained with the rest of the engine: when a new rule adds strings, `npm run i18n:sync` seeds them in your file in English and `npm run i18n:report` shows them as outstanding.
188
230
 
189
231
  ## Maintaining the locales
190
232
 
191
- When you add, rename or remove a key in `src/i18n/en.json`:
233
+ When you add, rename or remove a key in `src/i18n/en.json` or a profile's `i18n/en.json`:
192
234
 
193
235
  ```sh
194
236
  npm run i18n:sync
195
237
  ```
196
238
 
197
- Every other locale is rewritten to match — new keys seeded in English, removed keys dropped, existing translations untouched. The command is idempotent and prints what it changed per locale.
239
+ Every other locale file in the same folder is rewritten to match its `en.json` — new keys seeded in English, removed keys dropped, existing translations untouched. A locale a profile has no file for is left out: it never creates one. The command is idempotent and prints what it changed per file.
198
240
 
199
241
  | Command | Does |
200
242
  |---|---|
201
- | `npm run i18n:new <locale>` | Create a new locale file from `en.json`. Refuses to overwrite. |
202
- | `npm run i18n:sync` | Bring every locale file back in line with `en.json`. Add `-- <locale>` to restrict it to one. |
243
+ | `npm run i18n:new <locale>` | Create core's file for the locale from `src/i18n/en.json`; with `-- <locale> --profile <key>`, a profile's from its own. Refuses to overwrite. |
244
+ | `npm run i18n:sync` | Bring every locale file back in line with its `en.json`. Add `-- <locale>` to restrict it to one. |
203
245
  | `npm run i18n:check` | Same comparison, writes nothing, exits non-zero on drift. |
204
- | `npm run i18n:report` | Per-locale translation coverage. |
246
+ | `npm run i18n:report` | Translation coverage per folder and locale: core's, then each profile's for the locales it has. |
205
247
 
206
248
  `npm test` fails if a locale file has drifted, so an added key cannot reach `main` without every locale carrying it.