@luigisbox/luma 0.0.0-stage → 0.2.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 (145) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +615 -2
  3. package/a11y/audit.mjs +735 -0
  4. package/a11y/axe.mjs +73 -0
  5. package/a11y/report.mjs +347 -0
  6. package/a11y/run.mjs +256 -0
  7. package/a11y/wcag.mjs +233 -0
  8. package/bin/config.mjs +60 -0
  9. package/bin/luma.mjs +99 -0
  10. package/bin/paths.mjs +11 -0
  11. package/bin/tools.mjs +43 -0
  12. package/build/baseline.d.mts +8 -0
  13. package/build/baseline.mjs +167 -0
  14. package/build/tooling.mjs +102 -0
  15. package/build/vite.mjs +507 -0
  16. package/chrome/block.mjs +8 -0
  17. package/chrome/run.mjs +150 -0
  18. package/docs/ACCESSIBILITY.md +501 -0
  19. package/docs/INTEGRATION-PERFORMANCE.md +213 -0
  20. package/docs/PARITY.md +946 -0
  21. package/footprint/model.mjs +184 -0
  22. package/footprint/run.mjs +331 -0
  23. package/package.json +58 -4
  24. package/parity/alignment.mjs +109 -0
  25. package/parity/archive.mjs +223 -0
  26. package/parity/args.mjs +137 -0
  27. package/parity/behaviour.mjs +379 -0
  28. package/parity/compare.mjs +985 -0
  29. package/parity/engine.mjs +848 -0
  30. package/parity/hints.mjs +132 -0
  31. package/parity/hold.mjs +54 -0
  32. package/parity/init.mjs +703 -0
  33. package/parity/map.mjs +640 -0
  34. package/parity/owner.mjs +170 -0
  35. package/parity/pairing.mjs +110 -0
  36. package/parity/pixels.mjs +33 -0
  37. package/parity/probe.mjs +1239 -0
  38. package/parity/report.mjs +236 -0
  39. package/parity/run.mjs +267 -0
  40. package/snippet/early-hold.js +64 -0
  41. package/snippet/early-hold.min.js +1 -0
  42. package/src/analytics/chain.ts +137 -0
  43. package/src/analytics/collect.ts +64 -0
  44. package/src/analytics/datalayer.ts +67 -0
  45. package/src/analytics/from-ga4.ts +68 -0
  46. package/src/analytics/from-luma.ts +21 -0
  47. package/src/analytics/index.ts +24 -0
  48. package/src/analytics/outbox.ts +118 -0
  49. package/src/analytics/page.ts +46 -0
  50. package/src/analytics/pageview.ts +82 -0
  51. package/src/analytics/ref.ts +19 -0
  52. package/src/analytics/setup.ts +41 -0
  53. package/src/analytics/start.ts +53 -0
  54. package/src/analytics/triggers.ts +239 -0
  55. package/src/analytics/user.ts +12 -0
  56. package/src/analytics/wire.ts +67 -0
  57. package/src/api/ai.ts +98 -0
  58. package/src/api/index.ts +87 -0
  59. package/src/api/token-key.ts +42 -0
  60. package/src/api/token.ts +312 -0
  61. package/src/core/a11y-keys.ts +60 -0
  62. package/src/core/a11y.ts +160 -0
  63. package/src/core/boot-state.ts +6 -0
  64. package/src/core/color.ts +42 -0
  65. package/src/core/context.ts +108 -0
  66. package/src/core/early-rules.ts +96 -0
  67. package/src/core/entries.ts +25 -0
  68. package/src/core/fold.ts +7 -0
  69. package/src/core/health.ts +26 -0
  70. package/src/core/hold.ts +74 -0
  71. package/src/core/i18n.ts +66 -0
  72. package/src/core/image-load.ts +102 -0
  73. package/src/core/image-tags.ts +26 -0
  74. package/src/core/images.ts +170 -0
  75. package/src/core/integration.ts +126 -0
  76. package/src/core/kept.ts +25 -0
  77. package/src/core/listings.ts +100 -0
  78. package/src/core/locales.ts +14 -0
  79. package/src/core/own.ts +49 -0
  80. package/src/core/page.ts +14 -0
  81. package/src/core/prefetch.ts +88 -0
  82. package/src/core/recommenders.ts +166 -0
  83. package/src/core/reconciler.ts +397 -0
  84. package/src/core/result.ts +73 -0
  85. package/src/core/routes.ts +115 -0
  86. package/src/core/scheduler.ts +83 -0
  87. package/src/core/sense.ts +107 -0
  88. package/src/core/speculation.ts +59 -0
  89. package/src/core/stash.ts +38 -0
  90. package/src/core/storage.ts +45 -0
  91. package/src/core/styles.ts +106 -0
  92. package/src/core/support.ts +12 -0
  93. package/src/core/takeover.ts +220 -0
  94. package/src/core/templates.ts +74 -0
  95. package/src/core/tiles.ts +137 -0
  96. package/src/core/turbo.ts +64 -0
  97. package/src/core/url.ts +164 -0
  98. package/src/core/widget-inits.ts +40 -0
  99. package/src/env.d.ts +3 -0
  100. package/src/lit.ts +11 -0
  101. package/src/locales/cs.json +94 -0
  102. package/src/locales/de.json +88 -0
  103. package/src/locales/en.json +88 -0
  104. package/src/main.ts +493 -0
  105. package/src/types.ts +778 -0
  106. package/src/widgets/autocomplete/index.ts +1387 -0
  107. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  108. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  109. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  110. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  111. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  112. package/src/widgets/listing/element.ts +1772 -0
  113. package/src/widgets/listing/flow.ts +30 -0
  114. package/src/widgets/listing/instant.ts +133 -0
  115. package/src/widgets/listing/intent.ts +193 -0
  116. package/src/widgets/listing/query.ts +298 -0
  117. package/src/widgets/listing/stash.ts +97 -0
  118. package/src/widgets/listing/state.ts +131 -0
  119. package/src/widgets/listing/view.ts +257 -0
  120. package/src/widgets/plp/index.ts +11 -0
  121. package/src/widgets/plp/route.ts +263 -0
  122. package/src/widgets/recommender/index.ts +261 -0
  123. package/src/widgets/recommender/style-recommender.css +26 -0
  124. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  125. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  126. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  127. package/src/widgets/search/assets.ts +7 -0
  128. package/src/widgets/search/first.ts +15 -0
  129. package/src/widgets/search/form-url.ts +59 -0
  130. package/src/widgets/search/in-place.ts +226 -0
  131. package/src/widgets/search/index.ts +16 -0
  132. package/src/widgets/search/range.ts +260 -0
  133. package/src/widgets/search/route.ts +32 -0
  134. package/src/widgets/search/speculate.ts +202 -0
  135. package/src/widgets/search/style-search.css +65 -0
  136. package/src/widgets/search/template-search-box.ts +17 -0
  137. package/src/widgets/search/template-search-facets-default.ts +73 -0
  138. package/src/widgets/search/template-search-header.ts +14 -0
  139. package/src/widgets/search/template-search-pagination.ts +12 -0
  140. package/src/widgets/search/template-search-range-default.ts +48 -0
  141. package/src/widgets/search/template-search-result-default.ts +13 -0
  142. package/src/widgets/search/template-search-results-default.ts +8 -0
  143. package/tasks/model.mjs +116 -0
  144. package/tasks/run.mjs +305 -0
  145. package/tsconfig.json +14 -0
package/chrome/run.mjs ADDED
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // yarn luma chrome [path]: the live storefront in your Chrome with this
3
+ // integration on it as a customer installs it (the early-hold snippet first
4
+ // in <head>, then the build: parity/hold.mjs), whether or not the site
5
+ // includes it yet. Run from the integration repository:
6
+ //
7
+ // yarn luma chrome the storefront's home page (luma.config.mjs `host`, or LBX_HOST)
8
+ // yarn luma chrome /some-category another page
9
+ // yarn luma chrome --dev from the dev server (yarn dev): an edit shows on reload, no build
10
+ // yarn luma chrome --allow cdn.luigisbox.tech that host's Luigi's Box scripts left on (their previous search, say)
11
+ //
12
+ // Nothing else of the site changes: Luigi's Box's scripts it already loads (a
13
+ // platform loader, a previous integration) are blocked, so ours is the only
14
+ // one; its own scripts and consent dialog stay as they are. The
15
+ // profile is kept per storefront (its cookies, a consent given once), apart
16
+ // from your own Chrome profile. No rig, no Docker, no DNS: the storefront is
17
+ // the real one.
18
+ // The window stays until you close it.
19
+ import { execFileSync } from 'node:child_process';
20
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
21
+ import { homedir, tmpdir } from 'node:os';
22
+ import { extname, join, normalize, resolve } from 'node:path';
23
+ import { inside } from '../bin/paths.mjs';
24
+ import { blocked } from './block.mjs';
25
+
26
+ const USAGE = `usage: yarn luma chrome [path] [options]
27
+
28
+ path the storefront page to open (default /)
29
+ --dev load the integration from the dev server (https://localhost:8069, yarn dev): no build
30
+ --check open, wait for Luma, print its state (started, what it inserted, errors), close: exit 1 on an error
31
+ --no-build serve dist/ as it is (yarn build yourself, then reload)
32
+ --no-snippet the build alone, without the early-hold snippet in <head>
33
+ --channel NAME chrome (default: your installed Chrome) or chromium (Playwright's)
34
+ --profile DIR the browser profile (default ~/.lbx-chrome/<host>)
35
+ --site-dir DIR the integration repository (default: the current directory, LBX_SITE_DIR)
36
+ --allow HOST leave that host's Luigi's Box scripts on (repeatable): a previous search or
37
+ autocomplete the integration's analytics observes (cdn.luigisbox.tech)`;
38
+
39
+ const args = process.argv.slice(2);
40
+ const opt = { path: '/', check: false, dev: false, build: true, snippet: true, channel: 'chrome', profile: '', siteDir: '', allow: [] };
41
+ const die = (msg) => {
42
+ console.error(msg);
43
+ process.exit(2);
44
+ };
45
+ for (let i = 0; i < args.length; i++) {
46
+ const a = args[i];
47
+ const next = () => {
48
+ const v = args[++i];
49
+ if (v === undefined || v.startsWith('--')) die(`${a} needs a value\n\n${USAGE}`);
50
+ return v;
51
+ };
52
+ if (a === '--help' || a === '-h') {
53
+ console.log(USAGE);
54
+ process.exit(0);
55
+ } else if (a === '--dev') opt.dev = true;
56
+ else if (a === '--check') opt.check = true;
57
+ else if (a === '--no-build') opt.build = false;
58
+ else if (a === '--no-snippet') opt.snippet = false;
59
+ else if (a === '--channel') opt.channel = next();
60
+ else if (a === '--profile') opt.profile = next();
61
+ else if (a === '--site-dir') opt.siteDir = next();
62
+ else if (a === '--allow') opt.allow.push(next());
63
+ else if (!a.startsWith('--')) opt.path = a.startsWith('/') ? a : `/${a}`;
64
+ else die(`unknown option ${a}\n\n${USAGE}`);
65
+ }
66
+ const SITE_DIR = resolve(opt.siteDir || process.env.LBX_SITE_DIR || process.cwd());
67
+ if (!existsSync(join(SITE_DIR, 'integration'))) die(`${SITE_DIR} has no integration/ folder: run it from the integration repository (or --site-dir).`);
68
+ process.env.LBX_SITE_DIR = SITE_DIR;
69
+
70
+ let chromium;
71
+ try {
72
+ ({ chromium } = await import('playwright-core'));
73
+ } catch {
74
+ die("Luma's dependencies are not installed (playwright-core is missing): run `yarn install`.");
75
+ }
76
+ const { loadConfig } = await import('../bin/config.mjs');
77
+ const config = await loadConfig(SITE_DIR);
78
+ const { SNIPPET, holdInit } = await import('../parity/hold.mjs');
79
+
80
+ const HOST = config.hostKnown ? config.host : '';
81
+ if (!HOST) die("No storefront host: `export const host = 'www.shop.example'` in luma.config.mjs (yarn luma tooling --write --host <name>), or LBX_HOST.");
82
+ const ORIGIN = `https://${HOST}`;
83
+ const PREFIX = '/__lbx/chrome/';
84
+ const DEV = 'https://localhost:8069/integration/__integration.ts';
85
+
86
+ // ------------------------------------------------------------------ the build (a folder of its own: dist/ is left as it is)
87
+
88
+ let DIST = join(SITE_DIR, 'dist');
89
+ let temp = '';
90
+ if (!opt.dev && opt.build) {
91
+ temp = DIST = mkdtempSync(join(tmpdir(), 'lbx-chrome-'));
92
+ process.stderr.write('building… ');
93
+ try {
94
+ execFileSync('yarn', ['vite', 'build', '--outDir', DIST, '--emptyOutDir'], { cwd: SITE_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
95
+ process.stderr.write('done\n');
96
+ } catch (e) {
97
+ process.stderr.write('failed\n');
98
+ die(`the build failed:\n${e.stdout ?? ''}${e.stderr ?? ''}`);
99
+ }
100
+ }
101
+ if (!opt.dev && !existsSync(join(DIST, 'luma-integration.js'))) die(`${DIST}/luma-integration.js is missing: run yarn build (or drop --no-build).`);
102
+ const cleanup = () => temp && rmSync(temp, { recursive: true, force: true });
103
+ process.on('exit', cleanup);
104
+ for (const s of ['SIGINT', 'SIGTERM']) process.on(s, () => process.exit(130));
105
+
106
+ // ------------------------------------------------------------------ the browser
107
+
108
+ const profile = resolve(opt.profile || join(homedir(), '.lbx-chrome', HOST));
109
+ mkdirSync(profile, { recursive: true });
110
+ const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.map': 'application/json' };
111
+ let ctx;
112
+ for (const channel of [opt.channel, ...(opt.channel === 'chrome' ? ['chromium'] : [])]) {
113
+ try {
114
+ ctx = await chromium.launchPersistentContext(profile, { channel, headless: false, viewport: null, ignoreHTTPSErrors: opt.dev, serviceWorkers: 'block' });
115
+ if (channel !== opt.channel) console.error(`(no installed Chrome: Playwright's Chromium instead)`);
116
+ break;
117
+ } catch (e) {
118
+ // The profile is one browser's at a time: a window of this command still open has it.
119
+ if (/already in use|existing browser session|ProcessSingleton/i.test(e.message)) die(`The profile ${profile} is in use (a yarn luma chrome window is open): close it, or --profile <another folder>.`);
120
+ if (channel === 'chromium') die(`No browser to open: install Chrome, or \`yarn playwright-core install chromium\`.\n${e.message.split('\n')[0]}`);
121
+ }
122
+ }
123
+ // Luigi's Box's scripts the site loads already: never with ours (but the hosts --allow names).
124
+ await ctx.route((u) => blocked(u.href, opt.allow), (r) => r.abort('blockedbyclient'));
125
+ // The build, from the storefront's own origin (as a platform loader serves it): fresh on every reload.
126
+ await ctx.route(`${ORIGIN}${PREFIX}**`, (route) => {
127
+ const file = normalize(join(DIST, decodeURIComponent(new URL(route.request().url()).pathname.slice(PREFIX.length))));
128
+ if (!inside(DIST, file) || !existsSync(file) || !statSync(file).isFile()) return route.fulfill({ status: 404, body: '' });
129
+ return route.fulfill({ status: 200, body: readFileSync(file), headers: { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream', 'cache-control': 'no-store' } });
130
+ });
131
+ await ctx.addInitScript(holdInit, { origin: ORIGIN, bundle: opt.dev ? DEV : `${ORIGIN}${PREFIX}luma-integration.js`, snippet: opt.snippet ? SNIPPET : '', tag: 'chrome' });
132
+
133
+ const page = ctx.pages()[0] ?? (await ctx.newPage());
134
+ await page.goto(`${ORIGIN}${opt.path}`, { waitUntil: 'domcontentloaded', timeout: 60000 }).catch((e) => console.error(`${ORIGIN}${opt.path}: ${e.message.split('\n')[0]}`));
135
+ if (opt.check) {
136
+ // Luma's own record (window.__lbx.health): did it start, what did it take over, what failed.
137
+ await page.waitForFunction(() => !!(window).__lbx?.health, null, { timeout: 20000 }).catch(() => {});
138
+ await new Promise((r) => setTimeout(r, 6000));
139
+ const s = await page.evaluate(() => {
140
+ const l = (window).__lbx;
141
+ return l ? { version: l.version, snippet: !!l.hold?.snippet, inserts: l.health?.inserts ?? null, marks: (l.health?.timeline ?? []).slice(0, 12).map(([t, w]) => `${t} ${w}`), errors: l.health?.errors ?? [] } : null;
142
+ }).catch((e) => ({ error: e.message }));
143
+ console.log(JSON.stringify({ url: page.url(), luma: s }, null, 1));
144
+ await ctx.close();
145
+ process.exit(!s || s.error || s.errors?.length ? 1 : 0);
146
+ }
147
+ console.log(`${ORIGIN}${opt.path} with ${opt.dev ? 'the dev server (yarn dev)' : opt.build ? 'this build' : 'dist/'}${opt.snippet ? ' and the early-hold snippet' : ''}, Luigi's Box's own scripts blocked${opt.allow.length ? ` but ${opt.allow.join(', ')}` : ''}.`);
148
+ console.log(opt.dev ? 'Edit, then reload the page. ' : opt.build ? 'A change: run this again (or --dev with yarn dev). ' : 'A change: yarn build, then reload. ');
149
+ console.log(`window.__lbx in the console: Luma's state. Close the window to stop (profile: ${profile}).`);
150
+ await new Promise((r) => ctx.on('close', r));
@@ -0,0 +1,501 @@
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); the browser is Playwright's Chromium or the installed Chrome,
404
+ `LBX_CHROMIUM_PATH` otherwise.
405
+
406
+ ```bash
407
+ yarn luma a11y # /, the search page, the configured category and product pages
408
+ yarn luma a11y /search?q=shoes /some-category /some-product
409
+ yarn luma a11y --html # + a11y-report.html (self-contained)
410
+ yarn luma a11y --only autocomplete,search --query shoes
411
+ yarn luma a11y --dev # from yarn dev (https://localhost:8069), no build
412
+ ```
413
+
414
+ **What it does.** It builds the integration (`yarn build`; `--no-build` uses
415
+ `dist/` as it is), opens Chromium on the storefront's host name with Luma as a customer
416
+ installs it: the early-hold snippet first in `<head>`, then the build as the
417
+ platform's loader imports it (`parity/hold.mjs`; Luigi's Box's CDNs blocked, so
418
+ only the build under test runs), on the integration's stub page
419
+ (`luma.config.mjs` `stubPage`, the site's markup and stylesheet) with the live
420
+ Luigi's Box AI API. `--origin live` (or `LBX_ORIGIN=https://…`) loads the real
421
+ storefront instead; a bot challenge, a block or a rate limit is never worked
422
+ around: the run falls back to the stub page and says so in the report. Then it
423
+ drives the real widgets, by keyboard where a shopper would:
424
+
425
+ | Widget | States |
426
+ |---|---|
427
+ | autocomplete | typed into `_global.inputSelector`: open, two ArrowDowns, forced colours, `prefers-contrast: more`, no results, Escape, focus gone |
428
+ | 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 |
429
+ | category pages | the same, on `luma.config.mjs` `fixtures.category` (or a path given) |
430
+ | 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) |
431
+
432
+ On every state: axe-core (pinned, the same runner as Luma's suites,
433
+ `a11y/axe.mjs`) with the WCAG 2.0, 2.1 and 2.2 A and AA rules, **scoped to
434
+ Luma's nodes** (`lbx-search`, `lbx-plp`, `lbx-reco`, the dropdown); the same
435
+ run over the rest of the page is **the host page's**, reported for information
436
+ only (it never fails the run). Then what axe cannot decide:
437
+
438
+ | Check | WCAG |
439
+ |---|---|
440
+ | 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 |
441
+ | the count announced (dropdown; listing after a filter) | 4.1.3 |
442
+ | a filter applied from the keyboard; focus not lost to `<body>` after the re-render | 2.1.1, 2.4.3 |
443
+ | 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 |
444
+ | the sort menu: `aria-expanded`, focus in, Escape back to its button | 4.1.2, 2.4.3 |
445
+ | the drawer: a named modal dialog, focus in and kept, Escape back to the opener | 4.1.2, 2.1.2, 2.4.3 |
446
+ | 24 px targets (axe's `target-size`, with the spacing exception) | 2.5.8 |
447
+ | reflow at 320 CSS px: nothing of ours past the viewport (a scrolling row of its own excepted) | 1.4.10 |
448
+ | `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 |
449
+ | `prefers-reduced-motion`: transitions of position, animations, smooth scrolling of ours | advice (2.3.3 is AAA) |
450
+
451
+ **The report**, per success criterion: failed ones with each issue (rule,
452
+ impact, the states it was seen in, up to five nodes with their markup, a
453
+ **hint to the file and line** of the template or style that renders the node,
454
+ and how to fix it in an integration), then the criteria that passed, advice,
455
+ axe's "needs review" items, the host page's issues, and the criteria no
456
+ automated check decides (a screen reader pass covers what is left, §7).
457
+ `--html [file]` writes the same as a page, `--json <file>` as data.
458
+
459
+ **Exit code**: 0 when nothing fails on Luma's widgets, 1 when something does,
460
+ 2 when no widget could be exercised (a build that does not boot, a stub page
461
+ without the anchors). In CI: `yarn luma a11y --html`,
462
+ the report as an artifact.
463
+
464
+ **Reading it.** A failure in a node the hint maps to `integration/` is the
465
+ integration's to fix (§5 lists what templates must do); one in Luma's built-in
466
+ templates (`src/widgets/` in the package) or in Luma's own checks with no template
467
+ involved (the combobox, focus, status messages) is Luma's: report it with the
468
+ output. A site-wide palette (a grey the integration copied to stay 1:1 with
469
+ the site's own pages) fails 1.4.3 in normal mode even when the high-contrast
470
+ mode fixes it (§4): that is a design decision for the shop, and the report
471
+ says so rather than passing it. What it does not cover: a screen reader's
472
+ actual speech, touch screen readers, 200 % text zoom with the site's own CSS,
473
+ content the host renders around ours.
474
+
475
+ Example output (illustrative):
476
+
477
+ ```
478
+ Checked
479
+ autocomplete: open, keyboard selection, forced colours, prefers-contrast: more, no results, closed, focus left the input
480
+ 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
481
+ plp: …the same…
482
+ recommender mlt: shown, keyboard (Tab through), reflow (320 px), prefers-contrast: more, forced colours, reduced motion
483
+ recommender popup: not checked (opens on an add-to-cart click, which this command never makes)
484
+
485
+ Failed: 1 success criteria
486
+
487
+ ✗ 1.4.3 Contrast (minimum) (AA)
488
+ color-contrast [serious] Elements must meet minimum color contrast ratio thresholds
489
+ - #lbx-ac-g-brand > .group-title
490
+ → integration/autocomplete/template-autocomplete.ts:42 (.group-title)
491
+ Element has insufficient color contrast of 2.85 (foreground color: #999999, background color: #ffffff, …)
492
+ …
493
+ Passed: 15 success criteria
494
+ ✓ 1.1.1 Non-text content … ✓ 4.1.3 Status messages
495
+
496
+ FAIL: 1 success criteria, 1 issue on Luma's widgets.
497
+ ```
498
+
499
+ Code: `a11y/run.mjs` (options, the browser, the build), `a11y/audit.mjs`
500
+ (the flows), `a11y/report.mjs` (verdict, hints, text and HTML),
501
+ `a11y/wcag.mjs` (the criteria, axe tags, fix texts). Tests: §7.