@surea11y/core 1.4.1 → 1.6.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 (151) hide show
  1. package/CHANGELOG.md +212 -128
  2. package/README.md +46 -9
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +4 -4
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +30 -14
  8. package/docs/I18N.md +176 -20
  9. package/docs/INTEGRATION.md +29 -7
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/OUTPUT_SCHEMA.md +13 -3
  12. package/docs/REPORT.md +3 -1
  13. package/docs/RULE_AUTHORING.md +104 -23
  14. package/docs/RULE_CATALOG.md +1878 -169
  15. package/docs/RULE_TAXONOMY.md +2 -2
  16. package/docs/TROUBLESHOOTING.md +4 -4
  17. package/docs/WCAG_CONFORMANCE.md +25 -9
  18. package/package.json +8 -7
  19. package/src/baseline.js +3 -3
  20. package/src/checks/automatic/area-alt-present.js +2 -2
  21. package/src/checks/automatic/aria-allowed-attr.js +95 -40
  22. package/src/checks/automatic/aria-allowed-role.js +16 -18
  23. package/src/checks/automatic/aria-braille-equivalent.js +19 -21
  24. package/src/checks/automatic/aria-conditional-attr.js +22 -24
  25. package/src/checks/automatic/aria-deprecated-role.js +63 -50
  26. package/src/checks/automatic/aria-hidden-body.js +4 -11
  27. package/src/checks/automatic/aria-hidden-focus.js +104 -23
  28. package/src/checks/automatic/aria-prohibited-attr.js +71 -72
  29. package/src/checks/automatic/aria-prohibited-children.js +154 -61
  30. package/src/checks/automatic/aria-required-attr.js +74 -29
  31. package/src/checks/automatic/aria-required-children.js +38 -34
  32. package/src/checks/automatic/aria-required-parent.js +78 -29
  33. package/src/checks/automatic/aria-role-name-present.js +36 -22
  34. package/src/checks/automatic/aria-roles-valid.js +37 -23
  35. package/src/checks/automatic/aria-valid-attr-value.js +33 -33
  36. package/src/checks/automatic/aria-valid-attr.js +15 -18
  37. package/src/checks/automatic/autocomplete-valid.js +17 -19
  38. package/src/checks/automatic/avoid-inline-spacing.js +14 -16
  39. package/src/checks/automatic/binary-control-name-present.js +46 -26
  40. package/src/checks/automatic/button-name-present.js +115 -34
  41. package/src/checks/automatic/combobox-name-present.js +40 -22
  42. package/src/checks/automatic/contrast-computable.js +32 -0
  43. package/src/checks/automatic/contrast-enhanced.js +21 -1
  44. package/src/checks/automatic/contrast-minimum.js +21 -1
  45. package/src/checks/automatic/css-orientation-lock.js +118 -41
  46. package/src/checks/automatic/definition-list-children-valid.js +25 -29
  47. package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
  48. package/src/checks/automatic/dialog-name-present.js +36 -20
  49. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  50. package/src/checks/automatic/duplicate-id-aria.js +50 -40
  51. package/src/checks/automatic/duplicate-id.js +198 -0
  52. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  53. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  54. package/src/checks/automatic/form-control-single-label.js +39 -41
  55. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  56. package/src/checks/automatic/iframe-focusable-content.js +92 -38
  57. package/src/checks/automatic/iframe-name-present.js +53 -21
  58. package/src/checks/automatic/iframe-title-unique.js +19 -24
  59. package/src/checks/automatic/img-alt-present.js +12 -4
  60. package/src/checks/automatic/label-in-name.js +198 -49
  61. package/src/checks/automatic/link-in-text-block.js +29 -31
  62. package/src/checks/automatic/link-name-present.js +47 -31
  63. package/src/checks/automatic/list-children-valid.js +21 -23
  64. package/src/checks/automatic/listbox-name-present.js +42 -24
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -21
  66. package/src/checks/automatic/menuitem-name-present.js +36 -20
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
  70. package/src/checks/automatic/meter-name-present.js +38 -21
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
  72. package/src/checks/automatic/option-name-present.js +39 -22
  73. package/src/checks/automatic/page-title-present.js +21 -3
  74. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  75. package/src/checks/automatic/progressbar-name-present.js +41 -24
  76. package/src/checks/automatic/role-img-alt-present.js +64 -16
  77. package/src/checks/automatic/searchbox-name-present.js +46 -24
  78. package/src/checks/automatic/server-side-image-map-absent.js +16 -19
  79. package/src/checks/automatic/slider-name-present.js +42 -23
  80. package/src/checks/automatic/spinbutton-name-present.js +46 -24
  81. package/src/checks/automatic/summary-name-present.js +34 -20
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  83. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  84. package/src/checks/automatic/tab-name-present.js +37 -20
  85. package/src/checks/automatic/table-headers-attr-valid.js +57 -24
  86. package/src/checks/automatic/table-th-has-data-cells.js +76 -24
  87. package/src/checks/automatic/target-size-minimum.js +172 -131
  88. package/src/checks/automatic/td-has-header.js +20 -25
  89. package/src/checks/automatic/textbox-name-present.js +42 -24
  90. package/src/checks/automatic/tooltip-name-present.js +37 -20
  91. package/src/checks/automatic/treeitem-name-present.js +39 -22
  92. package/src/checks/automatic/valid-lang.js +107 -24
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  94. package/src/checks/manual/accesskeys-manual.js +21 -22
  95. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  96. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
  98. package/src/checks/manual/aria-text-manual.js +4 -4
  99. package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  101. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/empty-heading-manual.js +73 -28
  104. package/src/checks/manual/empty-table-header-manual.js +33 -36
  105. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  106. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  107. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
  108. package/src/checks/manual/heading-order-manual.js +20 -25
  109. package/src/checks/manual/heading-quality-manual.js +338 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +18 -21
  112. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  113. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  116. package/src/checks/manual/label-title-only-manual.js +19 -21
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
  123. package/src/checks/manual/landmark-one-main-manual.js +8 -15
  124. package/src/checks/manual/landmark-unique-manual.js +31 -36
  125. package/src/checks/manual/link-name-quality-manual.js +162 -35
  126. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  127. package/src/checks/manual/meta-viewport-large-manual.js +16 -19
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
  129. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  131. package/src/checks/manual/p-as-heading-manual.js +4 -4
  132. package/src/checks/manual/page-has-heading-one-manual.js +8 -15
  133. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  134. package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
  135. package/src/checks/manual/region-manual.js +32 -25
  136. package/src/checks/manual/scope-attr-valid-manual.js +16 -19
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  138. package/src/checks/manual/skip-link-manual.js +44 -52
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  140. package/src/checks/manual/tabindex-manual.js +16 -19
  141. package/src/checks/manual/table-duplicate-name-manual.js +16 -19
  142. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  143. package/src/checks/manual/video-caption-manual.js +3 -3
  144. package/src/checks/manual-review.js +17 -1
  145. package/src/core.js +13086 -5169
  146. package/src/report.js +16 -2
  147. package/surea11y.browser.js +5665 -4219
  148. package/surea11y.i18n.de.js +22 -0
  149. package/surea11y.i18n.es.js +22 -0
  150. package/surea11y.i18n.fr.js +22 -0
  151. package/bin/surea11y-core.js +0 -20
package/docs/I18N.md CHANGED
@@ -4,14 +4,18 @@ 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 | Coverage vs. English |
7
+ | Locale | File | Keys | Values still in English |
8
8
  |---|---|---|---|
9
- | `en` (English) | `src/i18n/en.js` | 614 | 100% (the canonical/fallback set) |
10
- | `fr` (French) | `src/i18n/fr.js` | 614 | 100% |
11
- | `de` (German) | `src/i18n/de.js` | 614 | 100% |
12
- | `es` (Spanish) | `src/i18n/es.js` | 614 | 100% |
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 |
13
13
 
14
- All four locales are fully translated as of this writing. That won't stay automatically true — every time a new rule (or a new i18n key) is added to `en.js`, every other locale needs the same key added, or it silently falls back to English for that string (see the fallback behavior below). `tests/i18n/i18n-locale-completeness.test.js` catches this: it fails the build if a locale listed in `FULLY_TRANSLATED_LOCALES` (currently `fr`, `de`, `es`) is missing any key present in `en.js`, and fails for *any* locale that has an orphaned key not in `en.js` (a sign of a typo or a stale key left behind after a rule was removed). Run `npm run i18n:report` any time to see per-locale coverage.
14
+ 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
+
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.
17
+
18
+ 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.
15
19
 
16
20
  ## Selecting a locale
17
21
 
@@ -19,17 +23,87 @@ All four locales are fully translated as of this writing. That won't stay automa
19
23
  runDomRulesInPage(url, null, { locale: 'fr' }, null);
20
24
  ```
21
25
 
22
- Default is `'en'` if omitted. Any string is accepted — an unrecognized locale (e.g. `'ja'`, not yet built) behaves exactly like a locale that's 0% translated: every string falls through to English (see below), not an error.
26
+ Default is `'en'` if omitted. Any string is accepted; an unrecognized locale is never an error.
27
+
28
+ ## How a locale is chosen
29
+
30
+ Two things happen, in this order. Getting them mixed up is the usual source of confusion, so they are described separately.
31
+
32
+ ### Step 1 — pick a dictionary (once per scan)
33
+
34
+ 1. Use the dictionary matching your code. `de` finds `de.json`. Case doesn't matter — `pt-br`, `pt-BR` and `PT-BR` all find `pt-BR.json`.
35
+ 2. Otherwise, drop everything after the first `-` and try that. `de-DE` finds `de.json`; so does `de-AT`.
36
+ 3. Otherwise, use English.
37
+
38
+ So you only need a `de-DE.json` if German in Austria and Germany should actually read differently. Ship `de.json` and every German variant is covered.
39
+
40
+ ### Step 2 — resolve each string (per string)
41
+
42
+ Within the chosen dictionary, every string is looked up on its own:
43
+
44
+ 1. Take the key's value from the chosen dictionary.
45
+ 2. If that key is missing, take the English one.
46
+ 3. If English is missing it too — which shouldn't happen for a built-in key, but can for a hand-rolled one — use the literal text the rule itself carries.
47
+
48
+ The result is never a blank, an `undefined`, or a thrown error. A half-finished translation renders in your language where it exists and in English everywhere else. See `t()` in `scripts/build-core.js` for the exact implementation.
49
+
50
+ ## Knowing which locale you actually got
51
+
52
+ Graceful fallback has one drawback: ask for a language the build doesn't carry and you get fluent English back, with nothing in the strings to say so. Every result therefore reports the resolution once, at the top:
53
+
54
+ ```json
55
+ "engine": {
56
+ "tag": "a11ycore",
57
+ "schemaVersion": "1.0.0",
58
+ "locale": { "requested": "ja", "resolved": "en", "reason": "unknown-locale" }
59
+ }
60
+ ```
61
+
62
+ `requested` is what you asked for (after trimming; `en` if you passed nothing or a non-string), `resolved` is the dictionary that was used, and `reason` is one of:
63
+
64
+ | `reason` | Meaning |
65
+ |---|---|
66
+ | `ok` | You got exactly what you asked for, and that dictionary carries every key. |
67
+ | `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
+ | `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. |
23
71
 
24
- ## Fallback behavior (per-string, not per-locale)
72
+ 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.
25
73
 
26
- Resolution happens independently for *every individual string*, not once for the whole scan:
74
+ If you need a particular language, `requested !== resolved` is the condition to check in CI. Note it is also true for the harmless `primary-subtag` case, so gate on `reason === 'unknown-locale' || reason === 'dictionary-not-loaded'` if a base-language match is good enough for you. See [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md#top-level-result) for the field's place in the result and [`API_STABILITY.md`](./API_STABILITY.md) for what is guaranteed about it.
27
75
 
28
- 1. Look up the key in the requested locale's dictionary.
29
- 2. If missing, fall back to the English (`en`) dictionary.
30
- 3. If still missing (shouldn't happen for a built-in key, but true for anything hand-rolled), fall back to the literal string the rule itself provided as a default.
76
+ ## Where the dictionaries live
31
77
 
32
- This means requesting `locale: 'fr'` today gives you a real mix — translated strings where `fr.js` has the key, English strings where it doesn't — never a blank, `undefined`, or thrown error. See `t()` in `scripts/build-core.js` if you need the exact implementation.
78
+ Which languages are available depends on how you load the engine.
79
+
80
+ | How you load it | What you get |
81
+ |---|---|
82
+ | `require('@surea11y/core')` | Every locale, built in. Nothing to configure. |
83
+ | A binding (Playwright, Cypress, …) | Every locale, built in. Nothing to configure. |
84
+ | `surea11y.browser.js` in a `<script>` tag | English. Load `surea11y.i18n.<locale>.js` after it for anything else. |
85
+
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.
87
+
88
+ ```html
89
+ <script src="surea11y.browser.js"></script>
90
+ <script src="surea11y.i18n.de.js"></script>
91
+ ```
92
+
93
+ 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.
94
+
95
+ ### Supplying a dictionary yourself
96
+
97
+ `engineOptions.messages` accepts `{ [locale]: { key: value } }` and takes precedence over anything built in or loaded from a side file. Useful for overriding a handful of strings, or for a language you maintain privately:
98
+
99
+ ```js
100
+ runDomRulesInPage(url, null, {
101
+ locale: 'de',
102
+ messages: { de: { img_altPresent_title: 'Eigener Text' } }
103
+ }, null);
104
+ ```
105
+
106
+ Keys you don't supply fall back normally, so a partial override is fine.
33
107
 
34
108
  ## Where keys are used
35
109
 
@@ -40,11 +114,93 @@ Two independent key namespaces, both resolved the same way:
40
114
 
41
115
  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`.
42
116
 
43
- ## Contributing a translation
117
+ ## Adding your language
118
+
119
+ You do not need to know how the engine works, and you will not write any JavaScript beyond editing quoted strings. A translation is one data file.
120
+
121
+ ### 1. Get the repository running
122
+
123
+ ```sh
124
+ git clone https://github.com/SureA11y/core.git
125
+ cd core
126
+ npm install
127
+ ```
128
+
129
+ ### 2. Create the file
130
+
131
+ ```sh
132
+ npm run i18n:new pt-BR
133
+ ```
134
+
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.
136
+
137
+ 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
+
139
+ The command refuses to overwrite a file that already exists. To pick up work on an existing locale, edit it directly.
140
+
141
+ ### 3. Translate the values
142
+
143
+ Each entry is `"key": "text"`. Change only the text on the right:
144
+
145
+ ```json
146
+ "img_altPresent_title": "<img> must have an alt attribute",
147
+ ```
148
+
149
+ becomes
150
+
151
+ ```json
152
+ "img_altPresent_title": "<img> precisa ter um atributo alt",
153
+ ```
154
+
155
+ Four things to leave alone:
156
+
157
+ - **The keys.** `img_altPresent_title` is an identifier the engine looks up. Renaming one breaks the lookup.
158
+ - **`{{placeholder}}` tokens** — `{{ratio}}`, `{{element}}`, `{{role}}` and friends are substituted with real values at scan time. Keep them spelled exactly as in English. You may move them within the sentence if your grammar needs it.
159
+ - **`{{#name}}…{{/name}}` and `{{^name}}…{{/name}}` blocks** — conditional sections, shown or hidden depending on the finding. Translate the text inside them; keep the markers.
160
+ - **Code identifiers** — `<img>`, `aria-label`, `role="dialog"`, `alt=""`, CSS property names. These are things the reader will look for in their own source, so they stay in the original. A double quote inside a value has to stay escaped as `\"`, which is the one piece of JSON syntax you need.
161
+
162
+ 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
+
164
+ If a string is genuinely identical in your language, leave it. It is counted as untranslated but behaves correctly.
165
+
166
+ ### 4. Check your progress
167
+
168
+ ```sh
169
+ npm run i18n:report
170
+ ```
171
+
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.
173
+
174
+ 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
+
176
+ ### 5. Before opening the pull request
177
+
178
+ ```sh
179
+ npm run i18n:sync # confirms your file matches en.json key-for-key
180
+ npm test
181
+ ```
182
+
183
+ `npm run build` also emits `surea11y.i18n.<locale>.js` for the standalone browser bundle — generated, so there is nothing for you to write.
184
+
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.
186
+
187
+ 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
+
189
+ ## Maintaining the locales
190
+
191
+ When you add, rename or remove a key in `src/i18n/en.json`:
192
+
193
+ ```sh
194
+ npm run i18n:sync
195
+ ```
196
+
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.
198
+
199
+ | Command | Does |
200
+ |---|---|
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. |
203
+ | `npm run i18n:check` | Same comparison, writes nothing, exits non-zero on drift. |
204
+ | `npm run i18n:report` | Per-locale translation coverage. |
44
205
 
45
- 1. Scaffold the file: `npm run i18n:new <locale>` (e.g. `npm run i18n:new de`) creates `src/i18n/<locale>.js` with all of `en.js`'s keys already present, each seeded with the English text as a placeholder. It refuses to overwrite an existing locale file unless you pass `--force`.
46
- 2. Replace the placeholder values with real translations, key by key. Leave any you're unsure about as-is for now — a value identical to English is treated as untranslated, not broken (see the fallback behavior above).
47
- 3. Check progress any time with `npm run i18n:report` — it prints, per locale, how many keys have been translated vs. still match the English placeholder, plus any missing or orphaned keys.
48
- 4. You don't need every key on day one — the fallback behavior above means a partial translation degrades gracefully to English per-string, exactly like `fr`/`de`/`es` did during their own early stages. Ship what you have. A partial locale is valid and won't fail `i18n-locale-completeness.test.js` unless you also add it to `FULLY_TRANSLATED_LOCALES` in that file — only do that once `npm run i18n:report` shows 100% coverage.
49
- 5. Keep `{{placeholder}}` tokens (and `{{#foo}}...{{/foo}}` conditional blocks) in translated strings exactly as they appear in the English source — they're substituted/evaluated verbatim regardless of locale (e.g. `{{element}}`, `{{role}}`). Never translate HTML tag/attribute names (`<img>`, `aria-label`, `role="dialog"`, etc.) — they're code identifiers, not prose.
50
- 6. Run `npm run build && npm test` to confirm nothing broke, including locale-completeness.
206
+ `npm test` fails if a locale file has drifted, so an added key cannot reach `main` without every locale carrying it.
@@ -107,7 +107,29 @@ Good for: a manual check against a page open in a real browser, a bookmarklet, o
107
107
 
108
108
  `a11ycore.runa11yCoreInPage` is the exact same function described in Pattern 2 above — the bundle exists only to solve *loading* it without `require`/a module system, not to add a separate API surface. It carries the same self-containment property (own inlined rule catalog, no free variables), which is what makes a plain `<script>` tag sufficient.
109
109
 
110
- Deliberately excluded from this bundle: `runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder`. Cross-frame scanning needs the embedded frame to load the engine and opt in too (see "Cross-frame scanning" below) — not a fit for a single dropped-in script tag. Use the npm package directly if you need it.
110
+ ### Scanning in a language other than English
111
+
112
+ The bundle carries English only. Every other locale ships beside it as its own file — load one after the bundle and that language becomes available:
113
+
114
+ ```html
115
+ <script src="node_modules/@surea11y/core/surea11y.browser.js"></script>
116
+ <script src="node_modules/@surea11y/core/surea11y.i18n.de.js"></script>
117
+ <script>
118
+ const result = a11ycore.runa11yCoreInPage(location.href, null, { locale: 'de' }, null);
119
+ </script>
120
+ ```
121
+
122
+ Load as many as you need; each adds one language and they don't interfere. Order matters only in that the bundle has to come first — a side file loaded on its own throws with a message saying so.
123
+
124
+ Ask for a locale whose file you haven't loaded and you get English, not an error, with `result.engine.locale.reason` set to `dictionary-not-loaded` so it's visible rather than silent. See [`I18N.md`](./I18N.md).
125
+
126
+ Why the split: the bundle is fetched over the network, and every language would otherwise be paid for by every page whether used or not. Keeping English inline and the rest optional took roughly 280 KB off the download and stops it growing as languages are added. The Node package is unaffected — `require('@surea11y/core')` still has every locale built in.
127
+
128
+ If you'd rather supply a dictionary yourself, `engineOptions.messages` takes `{ [locale]: { key: value } }` directly and wins over a loaded side file.
129
+
130
+ ### What the bundle leaves out
131
+
132
+ Deliberately excluded: `runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder`. Cross-frame scanning needs the embedded frame to load the engine and opt in too (see "Cross-frame scanning" below) — not a fit for a single dropped-in script tag. Use the npm package directly if you need it.
111
133
 
112
134
  ## Scoping a scan to part of the page
113
135
 
@@ -139,11 +161,11 @@ Notes for CI specifically:
139
161
 
140
162
  ### Cross-frame scanning (including cross-origin)
141
163
 
142
- `runa11yCoreInPage` only ever scans the single document it runs in — it has no visibility into `<iframe>` content, same-origin or not. For most uses that's fine (rules apply to the current document; a consumer running once per frame, e.g. once per content-script injection into `all_frames: true`, already covers every frame independently). But sometimes you want ONE scan's result to include what's inside embedded frames too — payment widgets, cookie-consent dialogs, third-party embeds — the same real scenario other engines' own cross-frame messaging protocols exist for.
164
+ `runa11yCoreInPage` only ever scans the single document it runs in — it has no visibility into `<iframe>` content, same-origin or not. For most uses that's fine (rules apply to the current document; a consumer running once per frame, e.g. once per content-script injection into `all_frames: true`, already covers every frame independently). But sometimes you want ONE scan's result to include what's inside embedded frames too — payment widgets, cookie-consent dialogs, third-party embeds — which is what the cross-frame functions below are for.
143
165
 
144
- `runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder` are a separate, additive pair of functions for exactly this — not needed at all if you're driving the browser with Puppeteer/Playwright (see "Pattern 2" above): an automation driver already reaches every frame unconditionally via CDP, which is strictly *better* than what's described here. This exists specifically for when there's **no automation driver** — a plain script/bundled widget/browser extension running inside the page itself, fully subject to the same-origin policy, exactly like other engines' equivalent mechanisms are.
166
+ `runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder` are a separate, additive pair of functions for exactly this — not needed at all if you're driving the browser with Puppeteer/Playwright (see "Pattern 2" above): an automation driver already reaches every frame unconditionally via CDP, which is strictly *better* than what's described here. This exists specifically for when there's **no automation driver** — a plain script/bundled widget/browser extension running inside the page itself, fully subject to the same-origin policy.
145
167
 
146
- **How it works**: a parent frame's `runa11yCoreAcrossFrames()` call pings each direct child `<iframe>`/`<frame>` via `postMessage`; if — and only if — that child has *also* called `a11yCoreEnableFrameResponder()` (its own opt-in to being scannable from above), it runs its own scan and replies with the result, which the parent includes. **A non-cooperating frame (the common case for most third-party embeds you don't control) is simply unreachable** — this mirrors the same real limitation other engines have for non-cooperating frames; it is not a gap `surea11y` closes that they don't have either.
168
+ **How it works**: a parent frame's `runa11yCoreAcrossFrames()` call pings each direct child `<iframe>`/`<frame>` via `postMessage`; if — and only if — that child has *also* called `a11yCoreEnableFrameResponder()` (its own opt-in to being scannable from above), it runs its own scan and replies with the result, which the parent includes. **A non-cooperating frame (the common case for most third-party embeds you don't control) is simply unreachable** — the same-origin policy allows no way around it from inside the page.
147
169
 
148
170
  ```js
149
171
  // Inside the embedded/child page (e.g. a widget's own bundle), once, at load:
@@ -167,8 +189,8 @@ for (const frame of result.frames) {
167
189
 
168
190
  A few things worth knowing:
169
191
  - **Async, unlike the other two runners** — `postMessage` round-trips can't be synchronous, so this is a separate, Promise-returning pair rather than an `engineOptions` flag on `runa11yCoreInPage` (which stays synchronous, unchanged, for every existing caller).
170
- - **`engineOptions.pingWaitTime`** (default `500`ms) and **`engineOptions.frameWaitTime`** (default `60000`ms) control how long a child frame gets to answer a ping and a full run request respectively, matching the defaults other engines use for the equivalent options.
192
+ - **`engineOptions.pingWaitTime`** (default `500`ms) and **`engineOptions.frameWaitTime`** (default `60000`ms) control how long a child frame gets to answer a ping and a full run request respectively.
171
193
  - **No jsdom/Node equivalent** — this is browser-only. jsdom's window/frame model doesn't meaningfully represent independent-realm cross-origin `postMessage`, and the feature has no purpose in Node anyway.
172
194
  - **Bundler-free, like `runa11yCoreInPage`** — both functions are fully self-contained (their own private copy of the rule catalog and every helper they need), so raw-source injection (a bookmarklet, a content script with no build step) works with zero bundler needed, exactly like `runa11yCoreInPage` already does. If you *do* use a normal bundler/`require`/`import`, that works too, unchanged.
173
- - **Cost of that self-containment**: `src/core.js` grew from ~1.86MB to ~3.1MB, since these two functions each needed their own complete private copy of the rule catalog and shared helpers rather than sharing the outer `RULE_IMPLS`. If this file's size ever becomes a real problem, the fix would be to drop the bundler-free requirement for just these two functions (accepting that cross-frame scanning in "plain script injection" mode needs a real bundler, unlike `runa11yCoreInPage` alone) rather than tripling the embedded catalog again for some future feature.
174
- - **No origin/identity check on the sender** beyond the message's own namespaced envelope — matching the same permissiveness other engines take here. Running a read-only scan and replying with DOM-derived results isn't a privileged operation; the content involved is no more sensitive than what's already rendered on the page.
195
+ - **Cost of that self-containment**: `src/core.js` grew from ~1.86MB to ~3.1MB when these two functions landed, since each needed its own complete private copy of the rule catalog and shared helpers rather than sharing the outer `RULE_IMPLS`; it is ~4.3MB now, and grows with every rule added, three times over. If this file's size ever becomes a real problem, the fix would be to drop the bundler-free requirement for just these two functions (accepting that cross-frame scanning in "plain script injection" mode needs a real bundler, unlike `runa11yCoreInPage` alone) rather than tripling the embedded catalog again for some future feature.
196
+ - **No origin/identity check on the sender** beyond the message's own namespaced envelope. Running a read-only scan and replying with DOM-derived results isn't a privileged operation; the content involved is no more sensitive than what's already rendered on the page.
@@ -6,7 +6,7 @@ Stated plainly and upfront, not left for you to discover. Every item here is a d
6
6
 
7
7
  surea11y is a **static DOM scan**: it reads the DOM tree and computed styles at one instant, with no ability to simulate user interaction, wait for async state changes, or measure real layout at arbitrary viewport sizes. These aren't missing rules — no rule implementation, however clever, can close them without a fundamentally different architecture (real browser automation driving actual keyboard/pointer events over time):
8
8
 
9
- - **Keyboard-trap detection** (WCAG 2.1.2) — requires simulating actual focus/keydown sequences and observing whether focus can escape. No static markup signal exists for this.
9
+ - **Keyboard-trap detection** (WCAG 2.1.2) — requires driving focus around the page and watching where it lands, which no single read of the DOM can do. A technique that would work has been scoped out (see the keyboard-trap section of [`ACT_RULE_MAPPING.md`](./ACT_RULE_MAPPING.md#keyboard-trap-detection-scoping-notes)), but it would be this engine's first check to mutate the page it is inspecting, so it is not built and would not be part of a normal scan if it were. Nothing in static markup stands in for it in the meantime.
10
10
  - **Reflow / clipping at zoom** (WCAG 1.4.10) — requires real layout measurement (`clientWidth`/`scrollWidth`) at a simulated 320px-equivalent viewport. Unlike some CSS-declaration-based heuristics elsewhere in this engine, there is no static markup proxy for "does content get clipped at 400% zoom" at all.
11
11
  - **Dynamic/post-interaction state** — anything that only exists after a click, hover, or async data load (a modal's contents, a dropdown's options, form-validation error messages) is invisible to a scan of the page's *current* DOM. If your framework renders it eagerly (even off-screen/`hidden`), it's scannable; if it only exists after interaction, it isn't, unless you drive that interaction yourself before scanning (e.g. click the button, *then* scan).
12
12
 
@@ -14,17 +14,19 @@ surea11y is a **static DOM scan**: it reads the DOM tree and computed styles at
14
14
 
15
15
  - **jsdom (Node, no real browser) has no CSS layout engine.** Rules needing real geometry — most notably `target-size-minimum` (WCAG 2.5.8, needs real `getBoundingClientRect()`) — report `notApplicable` under plain jsdom rather than guess. Run under a real browser (Puppeteer/Playwright — see [`INTEGRATION.md`](./INTEGRATION.md) Pattern 2) to get real findings from these rules.
16
16
  - **`<dialog>` and other elements hidden by the default UA stylesheet** (no `open` attribute, `display: none` by spec), along with any other subtree hidden via `display:none`, `visibility:hidden`, `[hidden]`, or closed `<details>`, are **excluded from rule evaluation by default** — matching the visibility-aware behavior of other established engines. This is a deliberate default (`engineOptions.includeHiddenElements: false`), not an oversight: hidden content isn't reachable by assistive technology or keyboard until it's shown, so flagging a markup defect inside it by default would often be noise. Set `engineOptions.includeHiddenElements: true` to evaluate hidden/collapsed subtrees anyway — e.g. to catch a markup defect (like a broken ARIA ID reference) before a dialog ever opens. See [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#engineoptions--the-rest) for the option and exactly which hiding mechanisms it covers.
17
- - **Static markup vs. live/post-hydration DOM state.** The rule logic itself is DOM-source-agnostic — it evaluates whatever DOM it's handed, whether that's jsdom-parsed static HTML (Pattern 1) or an already-loaded, already-hydrated real browser tab (Pattern 2, see [`INTEGRATION.md`](./INTEGRATION.md)). But the CLI (`npx @surea11y/cli scan <url>`) specifically fetches static HTML only, with no JS execution — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md). For a JS-framework-hydrated widget whose server-rendered markup intentionally ships one state before client JS syncs it (e.g. `<input type="checkbox" aria-checked="true">` shipped before client JS sets the native `checked` property to match on hydration — an extremely common, entirely legitimate pattern), a CLI scan only sees the pre-hydration markup. Other engines running inside an actual loaded browser tab see the post-hydration state instead, so the two can disagree on exactly this class of element for reasons that have nothing to do with either engine's rule correctness. `aria-checked-state-mismatch` is deliberately `manual`/`cantTell`-capped for this exact reason rather than a hard `fail`. If you need live-DOM accuracy for hydration-sensitive checks, run the library directly against an already-loaded page via Pattern 2, not the static-fetch CLI.
17
+ - **jsdom's computed `text-shadow` is unreliable on a second read of the same element.** Confirmed in jsdom 29.1.1: reading a computed `text-shadow` value a second time on the same element — through any accessor, from any freshly-requested `CSSStyleDeclaration` for that element, regardless of caching — silently returns a different, wrong "no shadow" value instead of the real declared one. The first read is always correct. This engine works around it internally by reading each element's `text-shadow` exactly once per run and caching the result (see `__textShadowInfoEl` in `src/core/contrast-helpers.js`), so a single scan is unaffected. It only surfaces if you read `getComputedStyle(el).textShadow` yourself, more than once, against the same jsdom-parsed element — a real browser has no such bug.
18
+ - **Static markup vs. live/post-hydration DOM state.** The rule logic itself is DOM-source-agnostic — it evaluates whatever DOM it's handed, whether that's jsdom-parsed static HTML (Pattern 1) or an already-loaded, already-hydrated real browser tab (Pattern 2, see [`INTEGRATION.md`](./INTEGRATION.md)). But the CLI (`npx @surea11y/cli scan <url>`) specifically fetches static HTML only, with no JS execution — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md). For a JS-framework-hydrated widget whose server-rendered markup intentionally ships one state before client JS syncs it (e.g. `<input type="checkbox" aria-checked="true">` shipped before client JS sets the native `checked` property to match on hydration — an extremely common, entirely legitimate pattern), a CLI scan only sees the pre-hydration markup. A scan running inside an actual loaded browser tab sees the post-hydration state instead, so the two can disagree on exactly this class of element for reasons that have nothing to do with rule correctness. That's why `aria-checked-state-mismatch` is capped at `manual`/`cantTell` rather than a hard `fail`. If you need live-DOM accuracy for hydration-sensitive checks, run the library directly against an already-loaded page via Pattern 2, not the static-fetch CLI.
18
19
 
19
- ## Deliberately not attempted — judgment calls, not automatable safely
20
+ ## Not attempted: judgment calls that aren't automatable safely
20
21
 
21
22
  These have no comparably safe heuristic at this engine's confidence bar (`fail` must stay reserved for deterministic, high-confidence violations, full stop). Building them anyway would either catch almost nothing (too narrow to be useful) or risk real false positives (too broad to trust):
22
23
 
23
- - **"Is this heading/label text meaningful?"** — real headings and labels are enormously varied and legitimately short ("FAQ," "Name," "Overview" are all fine). Unlike link text (where a small, well-established "always bad" phrase list exists — see `link-name-quality`), there's no equivalent safe list here.
24
+ - **"Is this heading/label text meaningful?"** — real headings and labels are enormously varied and legitimately short ("FAQ," "Name," "Overview" are all fine), so nothing decides from markup whether a heading describes the section under it or a label describes the field beside it. What *is* decidable is that some strings cannot describe anything: `heading-quality` and `form-control-label-quality` flag leftover placeholders, numbered template slots, filenames and URLs against curated exact-match lists, the same precision-over-recall trade-off `link-name-quality` makes. Both are `manual` rules capped at `cantTell` — they raise a candidate for review, they never assert the text is wrong.
24
25
  - **"Does this error message describe the problem?"** — what triggers a validation error and its content are almost always JS/validation-library-driven, invisible to a static scan in the first place; not just a heuristic-design problem.
25
26
  - **Fine-grained time-based-media sub-checks** (WCAG 1.2.x has ~8 distinct ACT-rule-level cases beyond what's built) — audio/video content itself is fundamentally unverifiable from static markup; the two broadest, safest cases are covered (`media-alternative-transcript-evidence`, `video-caption`), the narrower ones are not, by design.
26
27
  - **Images-of-text content analysis** (WCAG 1.4.5/1.4.9) — would need OCR-equivalent image understanding; out of scope for a static-markup engine.
27
28
  - **Motion-actuation controls** (WCAG 2.5.4) — niche, low real-world incidence; not prioritized, not structurally impossible.
29
+ - **`img-alt-decorative`'s two ACT e88epe edge cases** — an `<img>` whose current network request state isn't "completely available" (still loading, or broken), and a `<canvas>` that is fully transparent (nothing actually drawn on it), are both exempt from ACT's own applicability. Neither is decidable from a static DOM scan (no image decoding, no canvas pixel readback), so both are left in scope rather than exempted — a rare false positive a human reviewer dismisses at a glance, safer than silently under-reporting a real excluded/undecorative element.
28
30
 
29
31
  ## What this means in practice
30
32
 
@@ -15,7 +15,11 @@ This is the exact shape of the object returned by `runDomRulesInPage(...)` / `ru
15
15
 
16
16
  ```ts
17
17
  {
18
- engine: { tag: string, schemaVersion: string },
18
+ engine: {
19
+ tag: string,
20
+ schemaVersion: string,
21
+ locale: { requested: string, resolved: string, reason: string }
22
+ },
19
23
  url: string | null,
20
24
  title: string | null,
21
25
  timestamp: string | null,
@@ -31,6 +35,8 @@ This is the exact shape of the object returned by `runDomRulesInPage(...)` / `ru
31
35
  |---|---|
32
36
  | `engine.tag` | The engine's own identity tag, currently `"a11ycore"`. Every rule (built-in or custom) carries it in `meta.tags` — rule `ruleId`s themselves are bare (no prefix). |
33
37
  | `engine.schemaVersion` | The result-schema version (`"1.0.0"`). Bump-worthy if this document's shape ever changes incompatibly — pin to it if you're parsing output programmatically. See [`API_STABILITY.md`](./API_STABILITY.md) for the full stable/unstable field list and version-bump policy. |
38
+ | `engine.locale` | Which dictionary the run actually used. `requested` is your `engineOptions.locale` after trimming (`"en"` if you passed nothing or a non-string); `resolved` is the locale whose dictionary was used; `reason` explains the pairing. Because locale fallback is graceful and per-string, asking for a language the build does not carry produces English text rather than an error — this field is how you find that out without reading the strings. Reported once per result: a run uses one dictionary throughout. |
39
+ | `engine.locale.reason` | `"ok"` — you got the dictionary you asked for, and it carries every key. `"primary-subtag"` — your code had a subtag with no dictionary of its own, so its base language was used: `"de-DE"` resolves to `"de"`. `"dictionary-not-loaded"` — the project ships that language, but this build does not carry it and none was supplied (the standalone browser bundle, without its locale side file). `"unknown-locale"` — the project has no such translation at all. `"partial-dictionary"` — the dictionary was used but is missing keys English has, so those strings fell back to English. Treat the set as open; later releases can add to it. |
34
40
  | `url` | The `pageUrl` argument you passed in, or `document.location.href` if you passed `null`/omitted it, or `null` if neither is available. |
35
41
  | `title` | `document.title` at scan time, or `null`. |
36
42
  | `timestamp` | **Not auto-generated.** Only set if you pass `engineOptions.timestamp` as a non-empty string — the engine has no built-in clock (deterministic-by-design). If you want a scan timestamp in the result, supply it yourself. |
@@ -97,7 +103,7 @@ Notes:
97
103
 
98
104
  - **`outcome` vs `outcomeNormalized`**: identical except `notApplicable` becomes `"inapplicable"` in `outcomeNormalized`. Both are provided so you can match either your own vocabulary or the engine's internal one.
99
105
  - **`type: "manual"` rules can never report `outcome: "fail"`.** If a manual rule's own logic would have said `fail`, the engine coerces it to `cantTell` and appends an explanatory note to `error` — this is enforced centrally (`policy.coerceManualFailToCantTell`, on by default under the `a11y` policy contract; see [`POLICY.md`](./POLICY.md)), not something each rule has to remember. `fail` is reserved for deterministic, high-confidence, `type: "automatic"` findings only.
100
- - **`meta.normativeMappings`** is how a check result ties back to a WCAG Success Criterion — `[]` for rules with no formal WCAG mapping (other engines call these "Best Practices"; this engine calls them advisory `type: "manual"` rules). See [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md) for how these roll up.
106
+ - **`meta.normativeMappings`** is how a check result ties back to a WCAG Success Criterion — `[]` for rules with no formal WCAG mapping (this engine calls them advisory `type: "manual"` rules). See [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md) for how these roll up.
101
107
  - **`error`**: only present if the rule implementation threw an uncaught exception, or if the manual-fail coercion above fired. A thrown rule always surfaces as `outcome: "cantTell"` with `occurrences: []` and `error` set to the exception message — the engine never lets one broken rule crash the whole scan.
102
108
  - **`engineOptions`** on each result is the *resolved* options object (after locale/contrast defaults were applied), not literally what you passed in — useful for confirming what a given rule actually saw, especially the resolved `locale` and `contrast.mode`/`contrast.rootCanvasFallback`.
103
109
 
@@ -185,7 +191,11 @@ const result = runDomRulesInPage(
185
191
 
186
192
  ```json
187
193
  {
188
- "engine": { "tag": "a11ycore", "schemaVersion": "1.0.0" },
194
+ "engine": {
195
+ "tag": "a11ycore",
196
+ "schemaVersion": "1.0.0",
197
+ "locale": { "requested": "en", "resolved": "en", "reason": "ok" }
198
+ },
189
199
  "url": "https://example.test/",
190
200
  "title": "Example",
191
201
  "timestamp": null,
package/docs/REPORT.md CHANGED
@@ -15,6 +15,8 @@ Open `report.html` directly from disk. Works alongside any other output mode —
15
15
  - **WCAG rollup**: grouped by conformance level (A / AA / AAA), sourced directly from the engine's own `rulesResults[]` composite rollups (`docs/WCAG_CONFORMANCE.md`) — one row per Success Criterion, its outcome, a pass/fail/needs-review/n/a breakdown, and which atomic rules contributed. This is real engine data, not an invented grouping — the same rollup you'd get from the raw JSON's `rulesResults`.
16
16
  - **Full technical data** (collapsed by default): a scorecard (tiles per outcome) and a searchable, filterable (by outcome), paginated table of every individual occurrence across the whole scan.
17
17
 
18
+ The meta bar under the header carries the rule count, occurrence count, engine tag, schema version, and the locale the scan resolved to. Locale fallback is per-string and invisible in the text itself, so a report requested in a language the engine does not carry reads as an ordinary English one — the chip names the requested locale alongside the resolved one when the two differ. See [`I18N.md`](./I18N.md).
19
+
18
20
  ## Library usage
19
21
 
20
22
  ```js
@@ -30,4 +32,4 @@ require('fs').writeFileSync('report.html', html);
30
32
 
31
33
  ## Scope
32
34
 
33
- This is a single-scan report — one point-in-time snapshot, not a dashboard tracking results across many scans over time. Multi-run history/trend tracking is a separate, larger concern (see the project roadmap's "Enterprise/compliance features" — historical trend tracking across scans) and isn't part of this tool.
35
+ This is a single-scan report — one point-in-time snapshot, not a dashboard tracking results across many scans over time. Tracking results across many runs is a separate, larger concern and isn't part of this tool: keep the JSON from each scan and diff it yourself, or feed SARIF to a dashboard that already does history ([`SARIF.md`](./SARIF.md)).
@@ -52,7 +52,16 @@ function runInPage(ctx) { /* see Runtime Contract */ }
52
52
  module.exports = { id, meta, runInPage };
53
53
  ```
54
54
 
55
- No other exports.
55
+ One optional fourth export: `applicability(ctx)`, a predicate the engine calls before
56
+ `runInPage` to decide whether the rule is in scope for this run at all. Fourteen rules
57
+ use it today (see §11.2). Export it alongside the other three when you need it:
58
+
59
+ ```js
60
+ module.exports = { id, meta, runInPage, applicability };
61
+ ```
62
+
63
+ Nothing else. `npm run validate:rules` enforces exactly this set, and rejects a fifth
64
+ export.
56
65
 
57
66
  ---
58
67
 
@@ -78,7 +87,7 @@ Examples observed:
78
87
 
79
88
  ## 4) Meta Contract (all keys used by current rules)
80
89
 
81
- Every rule defines a `meta` object. In the rule set you uploaded, the union of meta keys is:
90
+ Every rule defines a `meta` object. Across the shipped ruleset, the union of meta keys is:
82
91
 
83
92
  ### 4.1 Required top-level keys
84
93
 
@@ -132,6 +141,10 @@ The build/runtime resolves i18n by:
132
141
  2) falling back to `en` if missing,
133
142
  3) falling back to the literal `title`/`description` strings if still missing.
134
143
 
144
+ Add the key and its English text to `src/i18n/en.json`, then run
145
+ `npm run i18n:sync` so every other locale picks it up. `npm test` fails if you
146
+ forget. See [`I18N.md`](./I18N.md).
147
+
135
148
  #### `meta.tags`
136
149
  Tags are used for grouping/filtering. Typical tag families in this ruleset include:
137
150
  - WCAG tagging: `wcag2a`, `wcag111`
@@ -163,6 +176,31 @@ const meta = {
163
176
 
164
177
  ---
165
178
 
179
+ ## 4.3 Reporting an occurrence
180
+
181
+ Build occurrences with `helpers.reportOccurrence(element, { summary, hint, i18n, data })`
182
+ rather than assembling the object by hand:
183
+
184
+ ```js
185
+ occurrences.push(helpers.reportOccurrence(el, { summary: '…', hint: '…' }));
186
+ ```
187
+
188
+ It attaches the element for the engine to finalize, which is how `selector`,
189
+ `html` and `structuralPath` get filled in centrally instead of in each rule.
190
+
191
+ **This is a performance contract, not just a convenience.** Every occurrence
192
+ gets a `structuralPath`. Given the element, the engine computes it directly.
193
+ Given only a hand-built occurrence, it re-finds the element with
194
+ `document.querySelector(selector)` — one DOM query per occurrence. That is
195
+ fine for a rule reporting a single document-level finding, and quadratic for
196
+ one reporting many: `region` hand-built its occurrences and took four minutes
197
+ on a thousand-element page, against under a second afterwards.
198
+
199
+ `perfStats.counters['structuralPath.selectorFallback']` counts how often the
200
+ engine had to re-find an element, so a slow rule can be spotted without
201
+ guessing. `tests/structural-path-fallback.test.js` fails if that count starts
202
+ growing with page size.
203
+
166
204
  ## 5) i18n in occurrences (repo reality)
167
205
 
168
206
  Occurrences also support i18n via keys + params.
@@ -189,6 +227,29 @@ At normalization time, the engine:
189
227
  Translation strings use `{{paramName}}` placeholders.
190
228
  `params` is shallow-copied and passed into interpolation.
191
229
 
230
+ **A param carries a value, never prose.** Element names, roles, attribute names,
231
+ selectors, ids, counts and ratios are values: they read the same in every
232
+ language, because the author will search their own source for them. An English
233
+ word or sentence is not, and passing one means it stays English in every locale
234
+ — with nothing to reveal it, since the key is present everywhere and coverage
235
+ reports look complete.
236
+
237
+ When a message varies by case, give each case its own key rather than
238
+ interpolating the differing text:
239
+
240
+ ```js
241
+ // wrong: the sentence lives in the rule, so no locale can reach it
242
+ i18n: { hintKey: 'myRule_hint_fail', params: { advice: 'Replace it with role="list".' } }
243
+
244
+ // right: one key per case, each translatable on its own
245
+ i18n: { hintKey: 'myRule_hint_fail_directory', params: { role } }
246
+ ```
247
+
248
+ `tests/i18n/i18n-translatable-strings.test.js` fails any dictionary value with
249
+ no translatable text of its own, which catches the `"{{advice}}"` shape above.
250
+ It cannot catch a param carrying prose into an otherwise-normal sentence, so
251
+ that one is on you.
252
+
192
253
  ---
193
254
 
194
255
  ## 6) Helpers contract used by rules (ctx.helpers)
@@ -215,15 +276,19 @@ const nodes = helpers.queryAllSmart
215
276
  : helpers.queryAll('img');
216
277
  ```
217
278
 
218
- Shadow traversal is opt-in via engine option:
279
+ Shadow traversal is on by default. It is the caller who opts out:
219
280
  ```js
220
- engineOptions: { includeShadowDom: true }
281
+ engineOptions: { includeShadowDom: false } // light DOM only
221
282
  ```
283
+ So write the rule assuming open shadow roots are in scope; `queryAllSmart` honours the
284
+ caller's choice for you. Closed roots are unreachable either way.
222
285
 
223
286
  ### 6.2 Reporting note for Shadow DOM
224
287
 
225
- Selectors do not pierce shadow boundaries, so a `selector` may not uniquely locate nodes in Shadow DOM.
226
- Therefore: **always include `html` in occurrences**.
288
+ Selectors do not pierce shadow boundaries, so a `selector` may not uniquely locate a node
289
+ inside a shadow root — which is why `html` matters as the "which element" signal there.
290
+ You get both for free by reporting the element through `helpers.reportOccurrence` (§4.3);
291
+ there is nothing extra to do for shadow DOM specifically.
227
292
 
228
293
  ---
229
294
 
@@ -237,7 +302,8 @@ data: {
237
302
  }
238
303
  ```
239
304
 
240
- This is consistent across your uploaded rule family.
305
+ Pass the `eligInfo` you already computed for the element; the fallback object above is
306
+ for the case where a rule has none to give.
241
307
 
242
308
  ---
243
309
 
@@ -266,34 +332,48 @@ Manual:
266
332
 
267
333
  ---
268
334
 
269
- ## 9) Occurrence object shape (repo reality)
335
+ ## 9) Occurrence object shape
270
336
 
271
- Typical pattern:
337
+ Report the element and let the engine finish the object (§4.3):
272
338
 
273
339
  ```js
274
- occurrences.push({
275
- selector,
276
- html,
277
- summary: '…',
278
- hint: '…',
279
- i18n: { summaryKey: '…', hintKey: '…', params: { element: 'img' } },
280
- data: { visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] } }
281
- });
340
+ occurrences.push(
341
+ helpers.reportOccurrence(el, {
342
+ summary: '…',
343
+ hint: '…',
344
+ i18n: { summaryKey: '…', hintKey: '…', params: { element: 'img' } },
345
+ data: { visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] } }
346
+ })
347
+ );
282
348
  ```
283
349
 
284
- Observed properties:
285
- - `selector` (or sometimes `selectorStr`)
286
- - `html`
350
+ What a rule supplies:
287
351
  - `summary`
288
352
  - `hint`
289
353
  - `i18n` (`summaryKey`, `hintKey`, `params`)
290
354
  - `data` (includes `visibilityFilter`)
291
355
 
356
+ What the engine fills in from the reported element:
357
+ - `selector`
358
+ - `html`
359
+ - `structuralPath`
360
+
361
+ Setting `selector`/`html` yourself still works and still wins — a handful of rules whose
362
+ finding is not a single element (the contrast rules report text runs) do exactly that. It
363
+ is the exception, not the pattern to copy.
364
+
292
365
  ---
293
366
 
294
367
  ## 10) Structured doc comment block
295
368
 
296
- Keep the structured header comment (`@rule`, `@atomic`, `@summary`, `@standard`, `@sc`, `@applicability`, `@expectation`).
369
+ Keep the structured header comment (`@check`, `@atomic`, `@summary`, `@standard`, `@sc`,
370
+ `@applicability`, `@expectation`). The id goes on `@check` — `@rule` is not a tag this
371
+ repo uses. `docs/RULE_TEMPLATE.js` has the full block to copy.
372
+
373
+ `@applicability` and `@expectation` are consumer-facing: `scripts/generate-rule-catalog.js`
374
+ reads them straight from the source and publishes them per rule in
375
+ [`RULE_CATALOG.md`](./RULE_CATALOG.md#rule-reference). Write them for someone deciding
376
+ whether a result applies to their page, and rerun `npm run docs:rule-catalog` after editing them.
297
377
 
298
378
  ---
299
379
 
@@ -399,8 +479,9 @@ After adding or changing any fixture, regenerate the index:
399
479
  npm run fixtures:index
400
480
  ```
401
481
 
402
- This writes `tests/fixtures/INDEX.md` (human-readable) and `tests/fixtures/index.json`
482
+ This writes `tests/fixtures/INDEX.md` (human-readable), `tests/fixtures/index.json`
403
483
  (machine-readable — every rule, its fixture path, and parsed pass/fail/cantTell case
404
- counts, for external tooling to enumerate and load fixtures directly). Commit both
484
+ counts, for external tooling to enumerate and load fixtures directly) and
485
+ `tests/fixtures/index.html` (the same listing as a browsable page). Commit all three
405
486
  alongside the fixture and test changes. A rule shipped without its fixture is treated
406
487
  the same as a rule shipped without tests — not done.