@luigisbox/luma 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +90 -0
- package/README.md +488 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +498 -0
- package/chrome/run.mjs +145 -0
- package/docs/ACCESSIBILITY.md +502 -0
- package/docs/INTEGRATION-PERFORMANCE.md +214 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +311 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +120 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +443 -0
- package/src/types.ts +760 -0
- package/src/widgets/autocomplete/index.ts +1367 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1769 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +250 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
# Accessibility
|
|
2
|
+
|
|
3
|
+
For contributors and integrators. What the law asks of the shops Luma runs
|
|
4
|
+
on, what the previous-generation Luma did about it, what Luma guarantees now, what an
|
|
5
|
+
integration's templates must do themselves, the high-contrast mode, how all
|
|
6
|
+
of it is tested, and how an integration checks itself (`yarn luma a11y`, §8).
|
|
7
|
+
Paths are relative to the package root; `core/…`, `widgets/…`, `api/…` are under `src/`.
|
|
8
|
+
|
|
9
|
+
## 1. The requirement
|
|
10
|
+
|
|
11
|
+
Luma runs inside e-commerce sites. Since **28 June 2025** those sites are in
|
|
12
|
+
scope of the **European Accessibility Act**, Directive (EU) 2019/882:
|
|
13
|
+
|
|
14
|
+
| | |
|
|
15
|
+
|---|---|
|
|
16
|
+
| Scope | Art. 2(2)(f): "e-commerce services" are one of the covered services. |
|
|
17
|
+
| Date | Art. 31(2): Member States "shall apply those measures from 28 June 2025". |
|
|
18
|
+
| Obligation | Art. 4(3): every covered service complies with Annex I, Section III. Art. 4(5): micro-enterprises that provide services are exempt. |
|
|
19
|
+
| Websites | Annex I, Section III(c): websites, related online applications and mobile services must be accessible "by making them perceivable, operable, understandable and robust". Section IV adds requirements specific to e-commerce (e.g. identification and payment). |
|
|
20
|
+
| Colour | Annex I, Section I, point 2(d) (products, the same principle): where colour conveys information, indicates an action or identifies elements, an alternative to colour is provided; 2(c): flexible magnification, brightness and contrast. |
|
|
21
|
+
| How to show conformity | Art. 15(1): conforming to harmonised standards cited in the Official Journal gives a presumption of conformity. |
|
|
22
|
+
|
|
23
|
+
The standard is **EN 301 549**. V3.2.1 (2021) applies WCAG 2.1 A and AA to web
|
|
24
|
+
content and is what the Web Accessibility Directive uses. **V4.1.1** (adopted
|
|
25
|
+
24 August 2026, published 2 September 2026) is the first version written for
|
|
26
|
+
the EAA: it moves web content to **WCAG 2.2 A and AA** and maps clauses to the
|
|
27
|
+
Act in its Annex ZB. Its presumption of conformity applies once the Official
|
|
28
|
+
Journal cites it (expected December 2026). Clause 11.7 ("User preferences")
|
|
29
|
+
asks software that is not isolated from its platform to follow the platform's
|
|
30
|
+
colour, contrast, font and focus settings. Web content is assessed under
|
|
31
|
+
WCAG, but a page that follows the OS contrast settings (`prefers-contrast`,
|
|
32
|
+
`forced-colors`) is what that clause asks of software.
|
|
33
|
+
|
|
34
|
+
So the target is **WCAG 2.2 AA**. The criteria Luma's widgets touch:
|
|
35
|
+
|
|
36
|
+
| WCAG 2.2 | What it means here |
|
|
37
|
+
|---|---|
|
|
38
|
+
| 1.3.1 Info and relationships (A) | listbox/option/group, fieldsets, a named region, headings |
|
|
39
|
+
| 1.4.1 Use of colour (A) | a selected row, ticked filter or active sort is not shown by colour alone |
|
|
40
|
+
| 1.4.3 Contrast, minimum (AA) | text at 4.5:1 (3:1 large) |
|
|
41
|
+
| 1.4.4 Resize text, 1.4.10 Reflow (AA) | 200% text, 320 CSS px wide (400% zoom) without horizontal scrolling |
|
|
42
|
+
| 1.4.11 Non-text contrast (AA) | borders of controls, focus rings, selection indicators at 3:1 |
|
|
43
|
+
| 2.1.1 Keyboard, 2.1.2 No keyboard trap (A) | every control by keyboard; the drawer's focus trap releases on Escape and close |
|
|
44
|
+
| 2.3.3 Animation from interactions (AAA) | motion off under `prefers-reduced-motion` (AAA, cheap to honour) |
|
|
45
|
+
| 2.4.3 Focus order (A), 2.4.7 Focus visible (AA), 2.4.11 Focus not obscured (AA) | focus moves into a drawer and back, never lost to `<body>` |
|
|
46
|
+
| 2.5.2 Pointer cancellation (A) | actions on `click` (up event), never on `pointerdown` |
|
|
47
|
+
| 2.5.8 Target size, minimum (AA) | 24 x 24 CSS px, or the spacing exception |
|
|
48
|
+
| 4.1.2 Name, role, value (A) | the combobox on the host's input, `aria-expanded`, `aria-selected`, slider values |
|
|
49
|
+
| 4.1.3 Status messages (AA) | result counts, "No results", errors, announced politely without moving focus |
|
|
50
|
+
|
|
51
|
+
Sources: [Directive (EU) 2019/882](https://eur-lex.europa.eu/eli/dir/2019/882/oj/eng)
|
|
52
|
+
(read via [legislation.gov.uk's copy](https://www.legislation.gov.uk/eudr/2019/882/contents): Art. 2, 4, 15, 31, Annex I),
|
|
53
|
+
[EN 301 549 V4.1.1](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/04.01.01_60/en_301549v040101p.pdf),
|
|
54
|
+
[its publication and OJ timing](https://www.dwt.com/insights/2026/09/european-accessibility-act-ict-standards-update),
|
|
55
|
+
[WCAG 2.2](https://www.w3.org/TR/WCAG22/), [technique G174](https://www.w3.org/WAI/WCAG22/Techniques/general/G174)
|
|
56
|
+
(a control that switches to a sufficient-contrast presentation),
|
|
57
|
+
[WAI-ARIA APG combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/).
|
|
58
|
+
|
|
59
|
+
## 2. The previous-generation high-contrast mode
|
|
60
|
+
|
|
61
|
+
The previous-generation Luma had an opt-in "High-contrast mode" switch: a
|
|
62
|
+
button, visually hidden unless the OS asked for a contrast preference, that
|
|
63
|
+
turned the widget grayscale and forced black text on white, plus automatic
|
|
64
|
+
attribute changes (guessed labels and alt text, `tabindex="0"` and
|
|
65
|
+
`role="button"` on button-like elements). Luma's mode (§4) keeps what was
|
|
66
|
+
right about it (WCAG technique G174 for 1.4.3: a control to a presentation
|
|
67
|
+
with sufficient contrast) and differs where WCAG asks for more: no grayscale (a product's
|
|
68
|
+
colour, stock dots are information: 1.4.1), no focusable invisible switch
|
|
69
|
+
(2.4.7), no blanket roles or tab stops on non-interactive elements (4.1.2), no
|
|
70
|
+
guessed alt text, and forced colours and the OS setting are honoured.
|
|
71
|
+
|
|
72
|
+
## 3. What Luma guarantees
|
|
73
|
+
|
|
74
|
+
### Autocomplete (`widgets/autocomplete/index.ts`)
|
|
75
|
+
|
|
76
|
+
- **The host's input is the combobox** (WAI-ARIA APG, list autocomplete):
|
|
77
|
+
`role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`,
|
|
78
|
+
`aria-controls` (the dropdown, `#lbx-ac-overlay` or the integration's
|
|
79
|
+
`hostId`), and in light DOM `aria-activedescendant` (the selected option's
|
|
80
|
+
id, `lbx-ac-<i>`). Set when the input gets focus, **put back as they were**
|
|
81
|
+
when focus leaves it (`borrow`, `core/a11y.ts`), also when the input is in a
|
|
82
|
+
shadow root and focus moves to a control beside it there (that focusout
|
|
83
|
+
never leaves the root: Luma listens on the input's shadow roots). An attribute the host
|
|
84
|
+
changes meanwhile is the host's current truth and is not overwritten on
|
|
85
|
+
restore: a framework that manages ARIA on its own input keeps it. No
|
|
86
|
+
attribute is set while the input is not focused; the value is never touched.
|
|
87
|
+
- **Shadow DOM:** an ID reference never crosses a shadow boundary. An id
|
|
88
|
+
inside our shadow root (the default dropdown) cannot be referenced from
|
|
89
|
+
anywhere else, so there is no `aria-activedescendant` (never a dangling
|
|
90
|
+
one); the selected option's text is announced through our status region
|
|
91
|
+
instead. A light-DOM dropdown (`dom: 'light'`) has both for an input in the
|
|
92
|
+
document. For a host input inside a shadow root (a web-component header)
|
|
93
|
+
the dropdown's id would name nothing from the input's tree, so the option
|
|
94
|
+
is referenced by element reflection (`input.ariaActiveDescendantElement`,
|
|
95
|
+
feature-detected; the attribute reads `""` then): the
|
|
96
|
+
document encloses the input's shadow root, which reflection allows. An
|
|
97
|
+
engine without it gets the announcement, never an id.
|
|
98
|
+
- **The listbox** (the house design's templates): `role="listbox"` named
|
|
99
|
+
"Search suggestions", one `role="group"` per section named by its heading
|
|
100
|
+
(phrases, each other type, "Top product", "Products"), options that are not
|
|
101
|
+
tab stops (`tabindex="-1"`: focus stays in the input) with `aria-selected`
|
|
102
|
+
following the keyboard and the pointer. "Show all products", "Powered by"
|
|
103
|
+
(`aria-hidden`) and the "No results" message are outside the options; with
|
|
104
|
+
no results, the phrases to try and the links are a listbox of their own,
|
|
105
|
+
the fallback products another, named by their heading and the note that
|
|
106
|
+
they are not results; the help box's phone link is a plain link. "Show all
|
|
107
|
+
products" is a real link (`href`: the
|
|
108
|
+
search page the site's form would submit to) and the open dropdown's one
|
|
109
|
+
tab stop: Tab from the input reaches it, Shift+Tab goes back, Escape closes
|
|
110
|
+
and goes back, Tab moves on to the page's next control after the input.
|
|
111
|
+
- **Status messages**: "Suggestions: N" or "No results for “…”", in a polite
|
|
112
|
+
`role="status"` region inside the dropdown, once typing pauses (700 ms), and
|
|
113
|
+
only when it changes within an opening (not per keystroke).
|
|
114
|
+
- **Keys** as before: arrows move the selection, Enter picks it (or is the
|
|
115
|
+
host's search), Escape closes ours first, as whole gestures. Focus never
|
|
116
|
+
leaves the input on a click or a tap (`pointerdown` on the dropdown is
|
|
117
|
+
prevented); only Tab moves it, to "Show all products".
|
|
118
|
+
- **Colours**: the host's text and accent colours are sampled only where they
|
|
119
|
+
read at 4.5:1 on our white panel (`readable`, `core/a11y.ts`; a dark site's
|
|
120
|
+
light text or a pale brand colour falls back); text on an accent fill is
|
|
121
|
+
white or near-black by the accent's luminance. Headings, the muted text
|
|
122
|
+
(`--lbx-muted`, fg 66% in white) and "No results" are 4.5:1. The selection is
|
|
123
|
+
never a faint colour alone: an accent fill (pills), or the tint with an
|
|
124
|
+
accent ring (cards) or edge (rows). Tokens an integration sets
|
|
125
|
+
(`autocomplete.tokens`, its CSS) are its own to keep at 4.5:1 (`yarn luma a11y`).
|
|
126
|
+
- **The phone sheet** (house design, viewport up to 600 px): its own input is
|
|
127
|
+
the combobox (`aria-controls` the listboxes on screen,
|
|
128
|
+
`aria-activedescendant` the selected option: the same shadow root); the
|
|
129
|
+
site's input is left as it was. The back button is named "Close search",
|
|
130
|
+
the clear button "Clear"; the type tabs are a `tablist` (arrow keys move
|
|
131
|
+
between them); Tab is the browser's; targets are 44 px; Escape and the
|
|
132
|
+
browser's Back close it.
|
|
133
|
+
- **Forced colours**: the selection in `Highlight`/`HighlightText` with an
|
|
134
|
+
outline, the panel (and the sheet) keep a `CanvasText` border, pills,
|
|
135
|
+
chips and "Show all products" an outline.
|
|
136
|
+
|
|
137
|
+
### Listings (`widgets/listing/element.ts`)
|
|
138
|
+
|
|
139
|
+
- **A named region**: `<lbx-search>` / `<lbx-plp>` get `role="region"` and
|
|
140
|
+
`aria-label` ("Search results" / "Products", translations `search.label`,
|
|
141
|
+
`plp.label`), server-rendered too, and `tabindex="-1"` (focus target only).
|
|
142
|
+
- **Status messages** in our own polite region (`[data-lbx-a11y]`, first in
|
|
143
|
+
the element, outside the templates): after every render the visitor caused
|
|
144
|
+
(a filter, a sort, back/forward, a new query in place: "N results for “q”"
|
|
145
|
+
or the route's `results`; show more: "Showing N of M"; an empty result; an
|
|
146
|
+
error). Not on the page's first render, not on a background revalidation.
|
|
147
|
+
Announced once ours is shown (a hidden region is not read). Templates need no
|
|
148
|
+
`aria-live` of their own (the built-in header's was removed: two would speak).
|
|
149
|
+
- **Busy**: `aria-busy="true"` on the element while a fetch runs; it is cleared
|
|
150
|
+
before the render that announces.
|
|
151
|
+
- **Focus is never lost**: "show more" from the keyboard moves focus to the
|
|
152
|
+
first new tile's main control (the tile itself when it is a link, else its
|
|
153
|
+
first control in the Tab order that is not `aria-hidden`: never a picture
|
|
154
|
+
link marked `tabindex="-1"`); a render that removed the focused control (a
|
|
155
|
+
chip, the last "show more") moves it to the region, not `<body>`. A mouse
|
|
156
|
+
click keeps focus where it is.
|
|
157
|
+
- **Menus** (`[data-lbx-menu]` with `[data-lbx-toggle]`, the integration's
|
|
158
|
+
markup): `aria-expanded` on the menu's button (the toggle with
|
|
159
|
+
`aria-haspopup`/`aria-expanded`, else the first toggle) in every state;
|
|
160
|
+
opened from the keyboard, focus goes to its selected item
|
|
161
|
+
(`aria-selected`/`aria-current`/`aria-checked`), else the first; arrows move
|
|
162
|
+
through its items; Escape closes it and focus returns to the button; picking
|
|
163
|
+
an item returns focus to the button; Tab out of it closes it.
|
|
164
|
+
- **The drawer** (`[data-lbx-drawer="open|close"]`): `aria-expanded` on its
|
|
165
|
+
openers. With a panel marked `[data-lbx-drawer-panel]`, while open, the panel
|
|
166
|
+
is a modal dialog (`role="dialog"`, `aria-modal="true"`, named "Filters",
|
|
167
|
+
translation `search.drawer`, unless the markup names it), focus moves to its
|
|
168
|
+
first control, Tab and Shift+Tab stay inside, Escape closes it, and focus
|
|
169
|
+
returns to the opener. Closed, the attributes are removed (on wide screens
|
|
170
|
+
the same markup is a sidebar). A change inside it updates it in place,
|
|
171
|
+
focus staying on the control. With `facetsApply: 'staged'` its
|
|
172
|
+
changes wait for `[data-lbx-drawer="apply"]`; closing it otherwise discards
|
|
173
|
+
them (the scope's `pending` says whether there is anything to apply).
|
|
174
|
+
- **Facet groups** (built-in template): each group's title is a `<button
|
|
175
|
+
aria-expanded aria-controls>` controlling its values (WAI-ARIA APG
|
|
176
|
+
accordion), focus staying on it. A group's search box
|
|
177
|
+
(`facetGroups.search`) is a labelled `type="search"` input ("Search in
|
|
178
|
+
Colour", translation `search.facetSearch`), the count of matches next to
|
|
179
|
+
it (`aria-describedby`) and said in the listing's status region once typing
|
|
180
|
+
pauses; Escape clears it (and does not close the drawer), Enter submits
|
|
181
|
+
nothing.
|
|
182
|
+
- **Range facets** (built-in `search-range-default`): two native range
|
|
183
|
+
inputs (role `slider`, `aria-valuemin`/`max`/`now` from the input itself),
|
|
184
|
+
named "Price: minimum" / "Price: maximum" (`search.rangeMin`,
|
|
185
|
+
`search.rangeMax`), `aria-valuetext` in the facet's unit ("120 €",
|
|
186
|
+
`search.units.<facet>`), inside the group's `fieldset`; the number inputs
|
|
187
|
+
beside them named "Price from" / "Price to" (`search.rangeFrom`,
|
|
188
|
+
`search.rangeTo`). Keys: arrows a step, Page Up / Page Down a tenth, Home /
|
|
189
|
+
End the end a thumb may reach (never past the other thumb), the same on
|
|
190
|
+
every engine; a platform's adjust gesture (VoiceOver's swipe) moves it as
|
|
191
|
+
well. Nothing is requested while a thumb moves: a pause after the keys,
|
|
192
|
+
Enter, focus leaving or the pointer's release commits. The focused thumb
|
|
193
|
+
carries its own ring (both inputs span the track); targets 24 px tall
|
|
194
|
+
(the thumb 24 px by default, `--lbx-range-thumb-size`); forced colours
|
|
195
|
+
map the track, fill and thumbs to system colours.
|
|
196
|
+
- **Chips** (built-in template): a group named "Selected filters", each chip
|
|
197
|
+
a link named "Remove filter: Colour: White".
|
|
198
|
+
- **Enter and Space on `role="button"` markup** (a `div` copied from a site,
|
|
199
|
+
`role="option"` list items, …) inside our element click it, as a button
|
|
200
|
+
would; native controls and links keep their own keys.
|
|
201
|
+
- Built-in templates: the pager is a native "Show more" button with a
|
|
202
|
+
"Showing N of M" line (no numbered pages); tile pictures are decorative
|
|
203
|
+
(`alt=""`: the link reads as its title and price once); targets of at least
|
|
204
|
+
24 px; no fade under reduced motion; tiles reflow to one column at 320 px.
|
|
205
|
+
|
|
206
|
+
### Recommenders (`widgets/recommender/`)
|
|
207
|
+
|
|
208
|
+
- Inline: a `section` named by its title (default "Recommended for you",
|
|
209
|
+
`recommender.title`), tiles are links in a scrolling row (Tab reaches each).
|
|
210
|
+
- Popup: `role="dialog"` named by its title, a 44 px close button named
|
|
211
|
+
"Close", Escape closes it. It is not modal and does not take focus (it
|
|
212
|
+
appears after the shopper's own add-to-cart click; moving focus there would
|
|
213
|
+
pull them away from the host's notice).
|
|
214
|
+
|
|
215
|
+
### Every widget
|
|
216
|
+
|
|
217
|
+
- **Focus ring**: `:focus-visible` gets an outline in our subtrees (`@layer
|
|
218
|
+
lbx`, so the site's own focus styles win); in forced colours and in the
|
|
219
|
+
high-contrast mode a 3 px ring with `!important` (a site's `outline: none`
|
|
220
|
+
reset cannot remove it there).
|
|
221
|
+
- **Reduced motion**: the hold's loading bar stands still, the listing's
|
|
222
|
+
dim while a re-render loads comes without its fade and the new results
|
|
223
|
+
without their fade-in, no smooth scrolling of ours.
|
|
224
|
+
- `.lbx-sr-only`: a class for text that only screen readers read (e.g. "Was"
|
|
225
|
+
before an old price), defined for every widget and in server-rendered CSS.
|
|
226
|
+
|
|
227
|
+
## 4. The high-contrast mode
|
|
228
|
+
|
|
229
|
+
**On automatically** with the visitor's OS setting `prefers-contrast: more`
|
|
230
|
+
(macOS "Increase contrast", Windows/GNOME high-contrast preferences), **in
|
|
231
|
+
CSS**: no script runs for it. **Forced colours** (Windows contrast themes,
|
|
232
|
+
`forced-colors: active`) are the browser's own mode: our CSS keeps state
|
|
233
|
+
visible there (system colours, outlines, borders instead of shadows).
|
|
234
|
+
|
|
235
|
+
**The palette** is a set of custom properties on `:root` (`core/a11y.ts`,
|
|
236
|
+
`CONTRAST_CSS`), defined only while the mode is on:
|
|
237
|
+
|
|
238
|
+
| Property | Value | Used for |
|
|
239
|
+
|---|---|---|
|
|
240
|
+
| `--lbx-c` | `1` | "the mode is on" |
|
|
241
|
+
| `--lbx-c-fg`, `--lbx-c-bg` | `#000`, `#fff` | text, panels |
|
|
242
|
+
| `--lbx-c-line` | `#000` | borders |
|
|
243
|
+
| `--lbx-c-muted` | `#000` | what is normally muted (section headings, "No results") |
|
|
244
|
+
| `--lbx-c-accent` | `#000` | links in brand colour |
|
|
245
|
+
| `--lbx-c-sel-fg`, `--lbx-c-sel-bg` | `#fff`, `#000` | a selected row (inverted) |
|
|
246
|
+
| `--lbx-c-border` | `2px` | border width |
|
|
247
|
+
| `--lbx-c-ring` | `3px` | focus ring width |
|
|
248
|
+
| `--lbx-c-dim` | `1` | no dimming of a busy listing |
|
|
249
|
+
| `--lbx-c-underline` | `underline` | tile titles underlined |
|
|
250
|
+
|
|
251
|
+
Every rule of ours reads them with its normal value as the fallback
|
|
252
|
+
(`color: var(--lbx-c-muted, color-mix(...))`). Unlike the previous-generation mode it does
|
|
253
|
+
not grayscale (product colours are information) and it keeps images.
|
|
254
|
+
|
|
255
|
+
**The toggle** (opt-in, as the previous generation had it): `_global.contrastToggle: true`
|
|
256
|
+
puts a "High-contrast mode" button (`aria-pressed`, translation `contrast`)
|
|
257
|
+
first in every listing. It is visually hidden until it has focus, unless the
|
|
258
|
+
OS asks for a contrast preference or the mode is on (as before, but
|
|
259
|
+
never an invisible focused button). The choice is remembered in
|
|
260
|
+
`localStorage` (`lbx:contrast`: `more`, or `off` to override the OS setting);
|
|
261
|
+
switching back to what the OS says forgets it.
|
|
262
|
+
|
|
263
|
+
**State for integrations' CSS**: once a widget has started, `<html>` carries
|
|
264
|
+
`data-lbx-contrast="more"` while the mode is on (the toggle or the OS
|
|
265
|
+
setting: one selector for both) and `"off"` when the visitor switched it off
|
|
266
|
+
against their OS setting. Without JavaScript (a server-rendered listing before
|
|
267
|
+
its bundle) only the custom properties apply.
|
|
268
|
+
|
|
269
|
+
### Adopting it in an integration
|
|
270
|
+
|
|
271
|
+
An integration's templates copy the site's markup, so Luma's palette does not
|
|
272
|
+
reach their colours. Add rules for the classes the template uses:
|
|
273
|
+
|
|
274
|
+
```css
|
|
275
|
+
/* The mode (OS setting or toggle): their greys and brand colour as black on white,
|
|
276
|
+
states drawn, not only coloured. */
|
|
277
|
+
:root[data-lbx-contrast="more"] lbx-plp .price-old { color: #000 !important; }
|
|
278
|
+
:root[data-lbx-contrast="more"] lbx-plp input:checked ~ .checkmark { background: #000 !important; }
|
|
279
|
+
/* Or read Luma's palette where a value can fall back to the site's: */
|
|
280
|
+
.facet-chip { border-color: var(--lbx-c-line, #ccc); }
|
|
281
|
+
/* Windows contrast themes: backgrounds are dropped, so a custom checkbox loses
|
|
282
|
+
its fill; draw state in system colours. */
|
|
283
|
+
@media (forced-colors: active) {
|
|
284
|
+
lbx-plp input:checked ~ .checkmark { forced-color-adjust: none; background: Highlight; }
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
A production integration's rules typically cover its custom checkboxes, sort
|
|
289
|
+
tabs, chips, range slider and the site's greys.
|
|
290
|
+
|
|
291
|
+
## 5. What integrations must do in their templates
|
|
292
|
+
|
|
293
|
+
Luma owns the input's ARIA, the status messages, focus, keys, menus and the
|
|
294
|
+
drawer. The markup is the integration's, so:
|
|
295
|
+
|
|
296
|
+
- **Autocomplete in light DOM** (`dom: 'light'`): render a `role="listbox"`
|
|
297
|
+
with an `aria-label` around the options, options as `role="option"` with
|
|
298
|
+
`id="lbx-ac-<i>"` (Luma's `aria-activedescendant` points there) and
|
|
299
|
+
`aria-selected`, `tabindex="-1"`; sections as `role="group"` named by their
|
|
300
|
+
heading; wrappers in between `role="presentation"`; nothing but options and
|
|
301
|
+
groups inside the listbox (a pointer-only "show all" duplicate of Enter:
|
|
302
|
+
`aria-hidden="true"`, no tab stop). Decorative pictures `alt=""`.
|
|
303
|
+
- **Listings**: filter groups as `fieldset` + `legend` (or `role="group"`
|
|
304
|
+
with a name); native checkboxes, or links with `role="checkbox"` and
|
|
305
|
+
`aria-checked` (never `aria-checked` on a plain link); chips named by what
|
|
306
|
+
they remove (`aria-label=${t('removeFilter', { label })}`: "Remove filter:
|
|
307
|
+
Acme", the "×" `aria-hidden`); a range's two thumbs named ("Price min" /
|
|
308
|
+
"Price max") with `aria-valuetext` in real units when the input's own scale
|
|
309
|
+
is not; collapsible groups with `aria-expanded` on their buttons.
|
|
310
|
+
- **Menus**: the trigger is a `<button>` with `aria-haspopup`; items are
|
|
311
|
+
focusable (links, buttons, or `role="option"` + `tabindex="0"`) and the
|
|
312
|
+
current one carries `aria-selected`/`aria-current`; a close button has a name.
|
|
313
|
+
- **The drawer**: put `data-lbx-drawer-panel` on the panel; the opener may be
|
|
314
|
+
a `role="button"` div (Luma adds the keys) but a `<button>` is better.
|
|
315
|
+
- **Tiles**: one link name (the picture `alt=""` when the title is in the same
|
|
316
|
+
link); a struck-through old price says so (`<span class="lbx-sr-only">Was
|
|
317
|
+
</span>`), and the current one "Now" next to it.
|
|
318
|
+
- **Colour**: text at 4.5:1, states not by colour alone, 24 px targets; adopt
|
|
319
|
+
the high-contrast mode (§4) for the colours your CSS sets.
|
|
320
|
+
- **Don't** add `aria-live` to counts (Luma announces), set `tabindex="0"` on
|
|
321
|
+
non-interactive elements, or remove focus outlines without a replacement.
|
|
322
|
+
|
|
323
|
+
## 6. Audit (2026-10-01)
|
|
324
|
+
|
|
325
|
+
On the test harness hosts (MPA, the framework suites) and a production
|
|
326
|
+
integration (its templates on its stub page with the site's stylesheet and the
|
|
327
|
+
live Luigi's Box AI API). Severity: **critical** blocks a task for some users,
|
|
328
|
+
**serious** a WCAG AA failure, **moderate** a usability issue.
|
|
329
|
+
|
|
330
|
+
| Widget | Finding | Severity | Status |
|
|
331
|
+
|---|---|---|---|
|
|
332
|
+
| Autocomplete | host input had no combobox semantics (no role, expanded, controls, active option): screen readers did not know a popup opened or what was selected | critical | fixed (Luma) |
|
|
333
|
+
| Autocomplete | listbox without a name; `h3` headings and "See all" inside the listbox (axe `aria-required-children`, critical) | critical | fixed (built-in; the integration's template) |
|
|
334
|
+
| Autocomplete | result count / "No results" never announced | serious | fixed (Luma) |
|
|
335
|
+
| Autocomplete | the sampled host colours could be unreadable on our white panel (dark sites, pale accents); headings at opacity .6 (4.3:1) | serious | fixed (Luma) |
|
|
336
|
+
| Autocomplete | selection by a 12% tint only; invisible in forced colours | serious | fixed (Luma; the integration's own styles in forced colours / contrast mode) |
|
|
337
|
+
| Autocomplete | options were tab stops; an integration's "Show all" was a nested `role=button` pair of tab stops | moderate | fixed |
|
|
338
|
+
| All | an integration's template: a site palette grey under 4.5:1 copied for section headings, tile sub-headers, old prices, empty filter values | serious | contrast mode only; the site's palette (site-wide) |
|
|
339
|
+
| Listings | counts announced only where a template had `aria-live`, and inside a busy region | serious | fixed (Luma status region) |
|
|
340
|
+
| Listings | focus lost to `<body>` when a render removed the focused chip or "show more" | serious | fixed (Luma) |
|
|
341
|
+
| Listings | sort menu and drawer: no `aria-expanded`, no focus management, no Escape; drawer not a dialog, no trap or return | serious | fixed (Luma + `data-lbx-drawer-panel`) |
|
|
342
|
+
| Listings | `role="button"` divs (an integration's filter button, sort list items, sheet items) not operable by keyboard | serious | fixed (Luma keys; sheet items focusable in the integration) |
|
|
343
|
+
| Listings | test integration: `aria-checked` on plain links (axe critical); chips named "Acme ×" | critical | fixed (fixture) |
|
|
344
|
+
| Listings | an integration's template: range inputs 6 px tall (target size); thumb values read as 0-1000 | serious | fixed (integration) |
|
|
345
|
+
| Listings | an integration's template: a filter group's expander a `span` (mouse only); clear-range button named "Close"; chips named by their label only; tile link name doubled by the picture's alt; old price read without "was" | serious | fixed (integration) |
|
|
346
|
+
| Listings | busy fade and transitions ignored `prefers-reduced-motion` | moderate | fixed |
|
|
347
|
+
| Recommender | inline box was not a named region; popup close button 31 px | moderate | fixed |
|
|
348
|
+
| Recommender | an integration's template: arrows' disabled state only as a class; smooth scroll under reduced motion | moderate | fixed (integration) |
|
|
349
|
+
| All | no high-contrast mode; no forced-colours handling | serious (EN 301 549 11.7 spirit, 1.4.3 via G174) | fixed (§4) |
|
|
350
|
+
| Hold | loading bar animation under reduced motion | moderate | fixed |
|
|
351
|
+
|
|
352
|
+
**What remains (the host's, or a design decision):**
|
|
353
|
+
|
|
354
|
+
- A site palette grey under 4.5:1 (headings in the dropdown, tile sub-headers,
|
|
355
|
+
old prices, empty filter values) site-wide; the integration copies it to
|
|
356
|
+
stay 1:1 with the site's pages. The mode fixes it; normal mode needs the
|
|
357
|
+
site's design.
|
|
358
|
+
- A light-DOM dropdown that copies the site's own design can show the keyboard
|
|
359
|
+
selection by colour (a coloured title, a grey pill) in normal mode, as the
|
|
360
|
+
site's own dropdown did (an outline in forced colours and the mode).
|
|
361
|
+
- The recommender popup is not modal and does not take focus (by design).
|
|
362
|
+
- The host's own header, menus and focus resets (`*:focus { outline: none }`)
|
|
363
|
+
outside our nodes; their search input's label.
|
|
364
|
+
- Touch screen readers: VoiceOver iOS and TalkBack navigate the dropdown by
|
|
365
|
+
swiping (it is in the top layer, after the page); not automated here.
|
|
366
|
+
|
|
367
|
+
## 7. How it is tested
|
|
368
|
+
|
|
369
|
+
Luma's own suites (not part of the package). In short:
|
|
370
|
+
|
|
371
|
+
- A browser harness runs the pinned `axe-core` (offline) over our nodes only
|
|
372
|
+
(WCAG 2.0-2.2 A/AA tags) and keeps what fails a test; it computes contrast
|
|
373
|
+
ratios as rendered and reads the input's attributes and focus.
|
|
374
|
+
- Axe on every widget state (dropdown open with a
|
|
375
|
+
selection and with no results, in shadow and light DOM; listings with
|
|
376
|
+
filters and chips, Luma's built-in templates, the drawer open, forced
|
|
377
|
+
colours; recommender inline and popup), the combobox lifecycle and the
|
|
378
|
+
host's own ARIA kept, announcements (typing pauses, filter, show more,
|
|
379
|
+
in-place query, error), focus (show more, removed chip, menu, drawer trap and
|
|
380
|
+
return), media features (`emulateMedia`: forced colours, `contrast: more`,
|
|
381
|
+
reduced motion) by computed styles, contrast ratios, reflow at 320 px,
|
|
382
|
+
target sizes, the toggle.
|
|
383
|
+
- The host contract: a combobox and a listing accessibility check run on
|
|
384
|
+
every host suite (MPA, the SPA template, Angular, Vue, Nuxt, React, …).
|
|
385
|
+
- `yarn luma a11y`'s flows (§8) on a test shop: the test integration passes
|
|
386
|
+
them all, planted faults fail per criterion; its report has its own tests.
|
|
387
|
+
- Unit tests: contrast maths and the readable-colour guard,
|
|
388
|
+
`borrow`'s restore rules, Enter/Space on `role="button"`, the status
|
|
389
|
+
region's timing, the mode's state, the loading bar's reduced-motion rule.
|
|
390
|
+
- By hand, before a release on a client: a screen reader pass (VoiceOver on
|
|
391
|
+
macOS and iOS, NVDA on Windows) through typing, picking a suggestion,
|
|
392
|
+
filtering, the drawer and show more; Windows contrast themes; 400% zoom.
|
|
393
|
+
|
|
394
|
+
## 8. Checking an integration: `yarn luma a11y`
|
|
395
|
+
|
|
396
|
+
For the developer of an integration: one command answers "does it pass
|
|
397
|
+
WCAG 2.2 AA?" (the level EN 301 549 V4.1.1 asks for the European
|
|
398
|
+
Accessibility Act, §1) for Luma's widgets as the integration renders them.
|
|
399
|
+
From the integration repository: `yarn luma a11y`
|
|
400
|
+
(README, "Tools for an integration repository": the one `luma` script). The
|
|
401
|
+
storefront's host name is its `luma.config.mjs` `host` (`LBX_HOST` overrides it;
|
|
402
|
+
the browser token is minted for that origin). Its dependencies come with Luma
|
|
403
|
+
(Playwright, axe-core; with the `luma/` submodule: `yarn --cwd luma install`
|
|
404
|
+
once); the browser is Playwright's Chromium or the installed Chrome,
|
|
405
|
+
`LBX_CHROMIUM_PATH` otherwise.
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
yarn luma a11y # /, the search page, the configured category and product pages
|
|
409
|
+
yarn luma a11y /search?q=shoes /some-category /some-product
|
|
410
|
+
yarn luma a11y --html # + a11y-report.html (self-contained)
|
|
411
|
+
yarn luma a11y --only autocomplete,search --query shoes
|
|
412
|
+
yarn luma a11y --dev # from yarn dev (https://localhost:8069), no build
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
**What it does.** It builds the integration (`yarn build`; `--no-build` uses
|
|
416
|
+
`dist/` as it is), opens Chromium on the storefront's host name with Luma as a customer
|
|
417
|
+
installs it: the early-hold snippet first in `<head>`, then the build as the
|
|
418
|
+
platform's loader imports it (`parity/hold.mjs`; Luigi's Box's CDNs blocked, so
|
|
419
|
+
only the build under test runs), on the integration's stub page
|
|
420
|
+
(`luma.config.mjs` `stubPage`, the site's markup and stylesheet) with the live
|
|
421
|
+
Luigi's Box AI API. `--origin live` (or `LBX_ORIGIN=https://…`) loads the real
|
|
422
|
+
storefront instead; a bot challenge, a block or a rate limit is never worked
|
|
423
|
+
around: the run falls back to the stub page and says so in the report. Then it
|
|
424
|
+
drives the real widgets, by keyboard where a shopper would:
|
|
425
|
+
|
|
426
|
+
| Widget | States |
|
|
427
|
+
|---|---|
|
|
428
|
+
| autocomplete | typed into `_global.inputSelector`: open, two ArrowDowns, forced colours, `prefers-contrast: more`, no results, Escape, focus gone |
|
|
429
|
+
| search | reached through the header's search form; shown, Tab through it, a filter ticked from the keyboard, the sort menu (Enter, Escape), phone width (375 px) with the sort menu and the filter drawer, 320 px, `prefers-contrast: more`, forced colours, reduced motion |
|
|
430
|
+
| category pages | the same, on `luma.config.mjs` `fixtures.category` (or a path given) |
|
|
431
|
+
| recommenders | inline boxes on `luma.config.mjs` `fixtures.product` (scrolled into view): shown, Tab, 320 px, contrast modes, reduced motion. The popup opens on an add-to-cart click, which the command never makes (nor a wishlist or comparison click) |
|
|
432
|
+
|
|
433
|
+
On every state: axe-core (pinned, the same runner as Luma's suites,
|
|
434
|
+
`a11y/axe.mjs`) with the WCAG 2.0, 2.1 and 2.2 A and AA rules, **scoped to
|
|
435
|
+
Luma's nodes** (`lbx-search`, `lbx-plp`, `lbx-reco`, the dropdown); the same
|
|
436
|
+
run over the rest of the page is **the host page's**, reported for information
|
|
437
|
+
only (it never fails the run). Then what axe cannot decide:
|
|
438
|
+
|
|
439
|
+
| Check | WCAG |
|
|
440
|
+
|---|---|
|
|
441
|
+
| the input is a combobox while open (`role`, `aria-expanded`, `aria-autocomplete`), `aria-controls` names the dropdown, one option `aria-selected` and `aria-activedescendant` on it (light DOM; from an input in a shadow root, `ariaActiveDescendantElement`), focus stays in the input, Escape closes, the input's ARIA as the host had it once focus leaves | 4.1.2, 2.1.1, 2.4.3 |
|
|
442
|
+
| the count announced (dropdown; listing after a filter) | 4.1.3 |
|
|
443
|
+
| a filter applied from the keyboard; focus not lost to `<body>` after the re-render | 2.1.1, 2.4.3 |
|
|
444
|
+
| Tab through each widget: focus visible on every stop (its look changes); controls by role that cannot get focus; checkboxes `display:none` behind a drawn box | 2.4.7, 2.1.1 |
|
|
445
|
+
| the sort menu: `aria-expanded`, focus in, Escape back to its button | 4.1.2, 2.4.3 |
|
|
446
|
+
| the drawer: a named modal dialog, focus in and kept, Escape back to the opener | 4.1.2, 2.1.2, 2.4.3 |
|
|
447
|
+
| 24 px targets (axe's `target-size`, with the spacing exception) | 2.5.8 |
|
|
448
|
+
| reflow at 320 CSS px: nothing of ours past the viewport (a scrolling row of its own excepted) | 1.4.10 |
|
|
449
|
+
| `prefers-contrast: more`: axe again, a focus ring; forced colours: the selected suggestion and a ticked custom checkbox still look so, a focus ring | 1.4.3, 1.4.11, 1.4.1, 2.4.7 |
|
|
450
|
+
| `prefers-reduced-motion`: transitions of position, animations, smooth scrolling of ours | advice (2.3.3 is AAA) |
|
|
451
|
+
|
|
452
|
+
**The report**, per success criterion: failed ones with each issue (rule,
|
|
453
|
+
impact, the states it was seen in, up to five nodes with their markup, a
|
|
454
|
+
**hint to the file and line** of the template or style that renders the node,
|
|
455
|
+
and how to fix it in an integration), then the criteria that passed, advice,
|
|
456
|
+
axe's "needs review" items, the host page's issues, and the criteria no
|
|
457
|
+
automated check decides (a screen reader pass covers what is left, §7).
|
|
458
|
+
`--html [file]` writes the same as a page, `--json <file>` as data.
|
|
459
|
+
|
|
460
|
+
**Exit code**: 0 when nothing fails on Luma's widgets, 1 when something does,
|
|
461
|
+
2 when no widget could be exercised (a build that does not boot, a stub page
|
|
462
|
+
without the anchors). In CI: `yarn luma a11y --html`,
|
|
463
|
+
the report as an artifact.
|
|
464
|
+
|
|
465
|
+
**Reading it.** A failure in a node the hint maps to `integration/` is the
|
|
466
|
+
integration's to fix (§5 lists what templates must do); one in Luma's built-in
|
|
467
|
+
templates (`src/widgets/` in the package) or in Luma's own checks with no template
|
|
468
|
+
involved (the combobox, focus, status messages) is Luma's: report it with the
|
|
469
|
+
output. A site-wide palette (a grey the integration copied to stay 1:1 with
|
|
470
|
+
the site's own pages) fails 1.4.3 in normal mode even when the high-contrast
|
|
471
|
+
mode fixes it (§4): that is a design decision for the shop, and the report
|
|
472
|
+
says so rather than passing it. What it does not cover: a screen reader's
|
|
473
|
+
actual speech, touch screen readers, 200 % text zoom with the site's own CSS,
|
|
474
|
+
content the host renders around ours.
|
|
475
|
+
|
|
476
|
+
Example output (illustrative):
|
|
477
|
+
|
|
478
|
+
```
|
|
479
|
+
Checked
|
|
480
|
+
autocomplete: open, keyboard selection, forced colours, prefers-contrast: more, no results, closed, focus left the input
|
|
481
|
+
search: shown, keyboard (Tab through), filter applied (keyboard), sort menu (keyboard), phone width (375 px), sort menu (phone), filter drawer (phone), reflow (320 px), prefers-contrast: more, forced colours, reduced motion
|
|
482
|
+
plp: …the same…
|
|
483
|
+
recommender mlt: shown, keyboard (Tab through), reflow (320 px), prefers-contrast: more, forced colours, reduced motion
|
|
484
|
+
recommender popup: not checked (opens on an add-to-cart click, which this command never makes)
|
|
485
|
+
|
|
486
|
+
Failed: 1 success criteria
|
|
487
|
+
|
|
488
|
+
✗ 1.4.3 Contrast (minimum) (AA)
|
|
489
|
+
color-contrast [serious] Elements must meet minimum color contrast ratio thresholds
|
|
490
|
+
- #lbx-ac-g-brand > .group-title
|
|
491
|
+
→ integration/autocomplete/template-autocomplete.ts:42 (.group-title)
|
|
492
|
+
Element has insufficient color contrast of 2.85 (foreground color: #999999, background color: #ffffff, …)
|
|
493
|
+
…
|
|
494
|
+
Passed: 15 success criteria
|
|
495
|
+
✓ 1.1.1 Non-text content … ✓ 4.1.3 Status messages
|
|
496
|
+
|
|
497
|
+
FAIL: 1 success criteria, 1 issue on Luma's widgets.
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Code: `a11y/run.mjs` (options, the browser, the build), `a11y/audit.mjs`
|
|
501
|
+
(the flows), `a11y/report.mjs` (verdict, hints, text and HTML),
|
|
502
|
+
`a11y/wcag.mjs` (the criteria, axe tags, fix texts). Tests: §7.
|