@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/a11y/axe.mjs ADDED
@@ -0,0 +1,73 @@
1
+ // axe-core in a Playwright page, shared by Luma's browser
2
+ // tests and `yarn luma a11y` (a11y/run.mjs).
3
+ //
4
+ // axe-core is a pinned dependency injected from node_modules (no network,
5
+ // no CDN). Scoped to Luma's own roots, so the host page's issues never fail
6
+ // us; the same run with those roots excluded is the host's (informational).
7
+ import { readFileSync } from 'node:fs';
8
+ import { createRequire } from 'node:module';
9
+ import { WCAG_TAGS } from './wcag.mjs';
10
+
11
+ /** Luma's own subtrees: listings, recommenders, the dropdown (a shadow host or light DOM). */
12
+ export const ROOTS = ['lbx-search', 'lbx-plp', 'lbx-reco', 'lbx-reco-pop', '[data-lbx="ac-overlay"]'];
13
+
14
+ let source = '';
15
+ const axeSource = () => (source ||= readFileSync(createRequire(import.meta.url).resolve('axe-core/axe.min.js'), 'utf8'));
16
+
17
+ /**
18
+ * @typedef {{ target: unknown[], html: string, failureSummary?: string }} AxeNode
19
+ * @typedef {{ id: string, impact: string | null, help: string, helpUrl: string, tags: string[], nodes: AxeNode[] }} AxeRule
20
+ * @typedef {{ violations: AxeRule[], incomplete: AxeRule[], passes: { id: string, tags: string[] }[] }} AxeResult
21
+ */
22
+
23
+ /**
24
+ * axe-core over `include` (selectors; a shadow host includes its shadow
25
+ * tree), or over the whole document minus `exclude`. `rules` turns rules on
26
+ * or off for this run. Throws when none of `include` is on the page.
27
+ * @param {import('playwright-core').Page} page
28
+ * @param {{ include?: string[], exclude?: string[], rules?: Record<string, boolean>, tags?: string[] }} opts
29
+ * @returns {Promise<AxeResult>}
30
+ */
31
+ export const runAxe = async (page, { include, exclude = [], rules = {}, tags = WCAG_TAGS }) => {
32
+ if (!(await page.evaluate(() => !!(/** @type {any} */ (window).axe)))) await page.addScriptTag({ content: axeSource() });
33
+ return page.evaluate(
34
+ async ({ include, exclude, tags, rules }) => {
35
+ const a = /** @type {any} */ (window).axe;
36
+ /** @type {any} */
37
+ let context;
38
+ if (include) {
39
+ const present = include.filter((s) => document.querySelector(s));
40
+ if (!present.length) throw new Error(`axe: none of ${include.join(', ')} is on the page`);
41
+ context = { include: present.map((s) => [s]) };
42
+ } else {
43
+ const out = exclude.filter((s) => document.querySelector(s));
44
+ context = out.length ? { exclude: out.map((s) => [s]) } : document;
45
+ }
46
+ // A listing's re-render fades its list in (and back from its dim): text
47
+ // mid-fade is paler than it will be, colour-contrast would read that.
48
+ // Ours only (a host's endless animation would hold this); at most 2 s.
49
+ const fading = document.getAnimations().filter((an) => /** @type {any} */ (an.effect)?.target?.matches?.('[data-lbx-list]'));
50
+ await Promise.race([Promise.all(fading.map((an) => an.finished.catch(() => {}))), new Promise((r) => setTimeout(r, 2000))]);
51
+ const r = await a.run(context, {
52
+ runOnly: { type: 'tag', values: tags },
53
+ resultTypes: ['violations', 'incomplete'],
54
+ rules: Object.fromEntries(Object.entries(rules).map(([k, v]) => [k, { enabled: v }])),
55
+ });
56
+ /** @param {any} v */
57
+ const rule = (v) => ({
58
+ id: v.id,
59
+ impact: v.impact,
60
+ help: v.help,
61
+ helpUrl: v.helpUrl,
62
+ tags: v.tags,
63
+ nodes: v.nodes.map((/** @type {any} */ n) => ({ target: n.target, html: String(n.html).slice(0, 300), failureSummary: n.failureSummary })),
64
+ });
65
+ return {
66
+ violations: r.violations.map(rule),
67
+ incomplete: r.incomplete.map(rule),
68
+ passes: r.passes.map((/** @type {any} */ v) => ({ id: v.id, tags: v.tags })),
69
+ };
70
+ },
71
+ { include, exclude, tags, rules },
72
+ );
73
+ };
@@ -0,0 +1,347 @@
1
+ // What `yarn luma a11y` found, as a verdict per WCAG 2.2 success criterion: the
2
+ // findings on Luma's nodes (they fail the run), the host page's own
3
+ // (informational), axe's "needs review" items, and the criteria no automated
4
+ // check can decide. Pure: no browser, no file system (the integration's files
5
+ // are passed in for the template hints), so it is unit-tested offline
6
+ // (Luma's own tests).
7
+ import { ADVISORY, CHECK_SC, CRITERIA, DOC, FIX, bySc, scOfTags } from './wcag.mjs';
8
+
9
+ /**
10
+ * @typedef {{ target: string, html: string, summary?: string, hint?: Hint | null }} Node
11
+ * @typedef {{ file: string, line: number, token: string }} Hint
12
+ * @typedef {{ widget: string, state: string, page: string }} Ctx
13
+ * @typedef {{ check: string, rule: string, sc: string[], impact: string, help: string, helpUrl?: string, fix: string,
14
+ * advisory: boolean, widgets: string[], states: string[], pages: string[], nodes: Node[] }} Finding
15
+ * (`states` as "widget: state")
16
+ * @typedef {{ rule: string, impact: string, help: string, helpUrl?: string, sc: string[], nodes: number, pages: string[] }} HostFinding
17
+ * @typedef {{ path: string, text: string }} SourceFile
18
+ */
19
+
20
+ const IMPACT = { critical: 0, serious: 1, moderate: 2, minor: 3 };
21
+ const rank = (i) => IMPACT[i] ?? 4;
22
+ const uniq = (xs) => [...new Set(xs)];
23
+ /** axe's target (a selector list, nested for shadow roots) as one readable selector. */
24
+ export const targetOf = (t) => (Array.isArray(t) ? t.map((x) => (Array.isArray(x) ? x.join(' >>> ') : String(x))).join(' >>> ') : String(t));
25
+
26
+ /** Collects the outcome of every check of a run. */
27
+ export class Results {
28
+ constructor() {
29
+ /** @type {Map<string, Finding>} */
30
+ this.found = new Map();
31
+ /** @type {Map<string, Finding>} axe "needs review" (incomplete) on our nodes */
32
+ this.review = new Map();
33
+ /** @type {Map<string, HostFinding>} */
34
+ this.hostFound = new Map();
35
+ /** @type {Map<string, Set<string>>} SC -> states where a check of it ran */
36
+ this.covered = new Map();
37
+ /** @type {{ widget: string, state: string, page: string }[]} */
38
+ this.states = [];
39
+ /** @type {{ widget: string, reason: string }[]} */
40
+ this.skipped = [];
41
+ /** @type {string[]} */
42
+ this.notes = [];
43
+ }
44
+
45
+ /** @param {Ctx} ctx */
46
+ state(ctx) {
47
+ if (!this.states.some((s) => s.widget === ctx.widget && s.state === ctx.state && s.page === ctx.page)) this.states.push({ ...ctx });
48
+ }
49
+
50
+ /** @param {string[]} scs @param {Ctx} ctx */
51
+ cover(scs, ctx) {
52
+ this.state(ctx);
53
+ for (const sc of scs) {
54
+ if (!CRITERIA[sc]) continue;
55
+ if (!this.covered.has(sc)) this.covered.set(sc, new Set());
56
+ this.covered.get(sc).add(`${ctx.widget}: ${ctx.state}`);
57
+ }
58
+ }
59
+
60
+ /** One of the command's own checks passed. @param {string} check @param {Ctx} ctx */
61
+ pass(check, ctx) {
62
+ this.cover(CHECK_SC[check] ?? [], ctx);
63
+ }
64
+
65
+ /**
66
+ * One of the command's own checks failed, on `nodes` (or the widget as a whole).
67
+ * @param {string} check @param {Ctx} ctx @param {string} help
68
+ * @param {{ target?: string, html?: string, summary?: string }[]} [nodes]
69
+ */
70
+ fail(check, ctx, help, nodes = []) {
71
+ const sc = CHECK_SC[check] ?? [];
72
+ this.cover(sc, ctx);
73
+ this.add(this.found, {
74
+ check,
75
+ rule: check,
76
+ sc,
77
+ impact: ADVISORY.has(check) ? 'minor' : 'serious',
78
+ help,
79
+ fix: FIX[check] ?? '',
80
+ advisory: ADVISORY.has(check),
81
+ widgets: [ctx.widget],
82
+ states: [`${ctx.widget}: ${ctx.state}`],
83
+ pages: [ctx.page],
84
+ nodes: (nodes.length ? nodes : [{ target: ctx.widget }]).map((n) => ({ target: n.target ?? ctx.widget, html: n.html ?? '', summary: n.summary })),
85
+ });
86
+ }
87
+
88
+ /**
89
+ * axe on our roots in one state: violations are findings, incomplete are
90
+ * "needs review", passes cover their criteria.
91
+ * @param {import('./axe.mjs').AxeResult} r @param {Ctx} ctx
92
+ */
93
+ axe(r, ctx) {
94
+ this.state(ctx);
95
+ for (const p of r.passes) this.cover(scOfTags(p.tags), ctx);
96
+ for (const [list, into] of [[r.violations, this.found], [r.incomplete, this.review]]) {
97
+ for (const v of list) {
98
+ const sc = scOfTags(v.tags);
99
+ this.cover(sc, ctx);
100
+ if (!sc.length) continue; // best practice or AAA only: not this command's verdict
101
+ this.add(into, {
102
+ check: `axe:${v.id}`,
103
+ rule: v.id,
104
+ sc,
105
+ impact: v.impact ?? 'moderate',
106
+ help: v.help,
107
+ helpUrl: v.helpUrl,
108
+ fix: FIX[v.id] ?? '',
109
+ advisory: false,
110
+ widgets: [ctx.widget],
111
+ states: [`${ctx.widget}: ${ctx.state}`],
112
+ pages: [ctx.page],
113
+ nodes: v.nodes.map((n) => ({ target: targetOf(n.target), html: n.html, summary: n.failureSummary })),
114
+ });
115
+ }
116
+ }
117
+ }
118
+
119
+ /** axe on the host page (our roots excluded): informational. @param {import('./axe.mjs').AxeResult} r @param {string} page */
120
+ host(r, page) {
121
+ for (const v of r.violations) {
122
+ const sc = scOfTags(v.tags);
123
+ if (!sc.length) continue;
124
+ const h = this.hostFound.get(v.id);
125
+ if (h) {
126
+ h.nodes = Math.max(h.nodes, v.nodes.length);
127
+ h.pages = uniq([...h.pages, page]);
128
+ } else this.hostFound.set(v.id, { rule: v.id, impact: v.impact ?? 'moderate', help: v.help, helpUrl: v.helpUrl, sc, nodes: v.nodes.length, pages: [page] });
129
+ }
130
+ }
131
+
132
+ /** A widget the run could not exercise, and why. */
133
+ skip(widget, reason) {
134
+ this.skipped.push({ widget, reason });
135
+ }
136
+
137
+ note(text) {
138
+ this.notes.push(text);
139
+ }
140
+
141
+ /** Merges a finding into `into`: one per check, nodes merged by target, states and pages accumulated. @param {Map<string, Finding>} into @param {Finding} f */
142
+ add(into, f) {
143
+ const had = into.get(f.check);
144
+ if (!had) return void into.set(f.check, f);
145
+ had.widgets = uniq([...had.widgets, ...f.widgets]);
146
+ had.states = uniq([...had.states, ...f.states]);
147
+ had.pages = uniq([...had.pages, ...f.pages]);
148
+ if (rank(f.impact) < rank(had.impact)) had.impact = f.impact;
149
+ for (const n of f.nodes) if (!had.nodes.some((m) => m.target === n.target)) had.nodes.push(n);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Where a node's markup comes from in the integration: the file and line of
155
+ * the rarest class (or id, data-lbx-* attribute) of its start tag among the
156
+ * integration's templates and styles (templates win a tie). Null when none
157
+ * of its tokens is in any file (markup the framework or the host made).
158
+ * @param {Node} node @param {SourceFile[]} files @returns {Hint | null}
159
+ */
160
+ export const hintFor = (node, files) => {
161
+ const tag = /^<[^>]*>/.exec(node.html ?? '')?.[0] ?? '';
162
+ const tokens = [];
163
+ for (const m of tag.matchAll(/\bclass\s*=\s*"([^"]*)"/g)) tokens.push(...m[1].split(/\s+/));
164
+ for (const m of tag.matchAll(/\b(data-lbx[\w-]*)/g)) tokens.push(m[1]);
165
+ // Luma's generic hooks and the state classes that every template shares are no clue to the file.
166
+ const words = uniq(tokens.filter((t) => t && t.length > 2 && !/^(active|selected|checked|disabled|open|hidden|show|is-\w+|data-lbx)$/.test(t) && !t.includes('${')));
167
+ if (!words.length) return null;
168
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
169
+ let best = null;
170
+ for (const w of words) {
171
+ const re = new RegExp(`(^|[^\\w-])${esc(w)}($|[^\\w-])`);
172
+ const hits = [];
173
+ for (const f of files) {
174
+ const lines = f.text.split('\n');
175
+ for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) hits.push({ file: f.path, line: i + 1 });
176
+ }
177
+ if (!hits.length) continue;
178
+ hits.sort((a, b) => Number(/\.css$/.test(a.file)) - Number(/\.css$/.test(b.file)));
179
+ if (!best || hits.length < best.n) best = { n: hits.length, file: hits[0].file, line: hits[0].line, token: w };
180
+ }
181
+ return best && { file: best.file, line: best.line, token: best.token };
182
+ };
183
+
184
+ /**
185
+ * The run's verdict per success criterion.
186
+ * @param {Results} r
187
+ * @param {{ files?: SourceFile[] }} [opts]
188
+ */
189
+ export const summarize = (r, { files = [] } = {}) => {
190
+ const order = (a, b) => rank(a.impact) - rank(b.impact) || bySc(a.sc[0] ?? '9', b.sc[0] ?? '9');
191
+ /** @param {Finding} f @returns {Finding} */
192
+ const withHints = (f) => ({ ...f, nodes: f.nodes.map((n) => ({ ...n, hint: n.hint === undefined ? hintFor(n, files) : n.hint })) });
193
+ const findings = [...r.found.values()].map(withHints).sort(order);
194
+ const failing = findings.filter((f) => !f.advisory);
195
+ const advisories = findings.filter((f) => f.advisory);
196
+ const review = [...r.review.values()].map(withHints).sort(order);
197
+ const criteria = Object.entries(CRITERIA)
198
+ .map(([sc, c]) => {
199
+ const fails = failing.filter((f) => f.sc.includes(sc));
200
+ const states = [...(r.covered.get(sc) ?? [])];
201
+ const status = fails.length ? 'fail' : states.length ? 'pass' : c.auto ? 'not-applicable' : 'manual';
202
+ return { sc, ...c, status, findings: fails, states };
203
+ })
204
+ .sort((a, b) => bySc(a.sc, b.sc));
205
+ const exercised = uniq(r.states.map((s) => s.widget));
206
+ const verdict = failing.length ? 'fail' : exercised.length ? 'pass' : 'incomplete';
207
+ return {
208
+ verdict,
209
+ exitCode: verdict === 'pass' ? 0 : verdict === 'fail' ? 1 : 2,
210
+ criteria,
211
+ findings: failing,
212
+ advisories,
213
+ review,
214
+ host: [...r.hostFound.values()].sort((a, b) => rank(a.impact) - rank(b.impact)),
215
+ states: r.states,
216
+ exercised,
217
+ skipped: r.skipped,
218
+ notes: r.notes,
219
+ };
220
+ };
221
+
222
+ const C = { red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', dim: '\x1b[2m', bold: '\x1b[1m', off: '\x1b[0m' };
223
+
224
+ /**
225
+ * The report for a terminal (or a CI log).
226
+ * @param {ReturnType<typeof summarize>} s
227
+ * @param {{ color?: boolean, meta?: Record<string, string> }} [opts]
228
+ */
229
+ export const formatText = (s, { color = false, meta = {} } = {}) => {
230
+ const c = (k, t) => (color ? `${C[k]}${t}${C.off}` : t);
231
+ const out = [];
232
+ out.push(c('bold', 'WCAG 2.2 AA (EN 301 549 V4.1.1, the European Accessibility Act): Luma widgets'));
233
+ for (const [k, v] of Object.entries(meta)) out.push(c('dim', ` ${k}: ${v}`));
234
+ out.push('');
235
+ out.push(c('bold', 'Checked'));
236
+ const byWidget = {};
237
+ for (const st of s.states) (byWidget[st.widget] ??= []).push(st.state);
238
+ for (const [w, sts] of Object.entries(byWidget)) out.push(` ${w}: ${uniq(sts).join(', ')}`);
239
+ for (const k of s.skipped) out.push(c('yellow', ` ${k.widget}: not checked (${k.reason})`));
240
+ out.push('');
241
+ const fail = s.criteria.filter((x) => x.status === 'fail');
242
+ const pass = s.criteria.filter((x) => x.status === 'pass');
243
+ if (fail.length) {
244
+ out.push(c('bold', `Failed: ${fail.length} success criteria`));
245
+ for (const cr of fail) {
246
+ out.push('');
247
+ out.push(c('red', `✗ ${cr.sc} ${cr.title} (${cr.level})`));
248
+ for (const f of cr.findings) {
249
+ if (f.sc[0] !== cr.sc && fail.some((x) => x.sc === f.sc[0])) {
250
+ out.push(c('dim', ` ${f.rule}: see ${f.sc[0]}`));
251
+ continue;
252
+ }
253
+ out.push(` ${c('bold', f.rule)} [${f.impact}] ${f.help}`);
254
+ out.push(c('dim', ` where: ${f.states.join('; ')}`));
255
+ for (const n of f.nodes.slice(0, 5)) {
256
+ out.push(` - ${n.target}`);
257
+ if (n.html) out.push(c('dim', ` ${n.html.replace(/\s+/g, ' ').slice(0, 160)}`));
258
+ if (n.hint) out.push(` ${c('yellow', `→ ${n.hint.file}:${n.hint.line}`)} ${c('dim', `(.${n.hint.token})`)}`);
259
+ if (n.summary) out.push(c('dim', ` ${n.summary.replace(/\s+/g, ' ').slice(0, 240)}`));
260
+ }
261
+ if (f.nodes.length > 5) out.push(c('dim', ` … and ${f.nodes.length - 5} more`));
262
+ if (f.fix) out.push(` fix: ${f.fix}`);
263
+ if (f.helpUrl) out.push(c('dim', ` ${f.helpUrl}`));
264
+ }
265
+ }
266
+ out.push('');
267
+ }
268
+ out.push(c('bold', `Passed: ${pass.length} success criteria`));
269
+ out.push(` ${pass.map((x) => c('green', `✓ ${x.sc}`) + ` ${x.title}`).join('\n ')}`);
270
+ if (s.advisories.length) {
271
+ out.push('');
272
+ out.push(c('bold', 'Advice (beyond AA, does not fail the run)'));
273
+ for (const f of s.advisories) {
274
+ out.push(` ${f.rule}: ${f.help} (${f.states.join(', ')})`);
275
+ for (const n of f.nodes.slice(0, 3)) out.push(c('dim', ` - ${n.target}${n.summary ? `: ${n.summary}` : ''}`));
276
+ if (f.fix) out.push(` fix: ${f.fix}`);
277
+ }
278
+ }
279
+ if (s.review.length) {
280
+ out.push('');
281
+ out.push(c('bold', 'Needs review (axe could not decide; check by eye)'));
282
+ for (const f of s.review) out.push(` ${f.sc.join(', ')} ${f.rule}: ${f.help} (${f.nodes.length} node${f.nodes.length > 1 ? 's' : ''}, ${f.states.join(', ')})`);
283
+ }
284
+ if (s.host.length) {
285
+ out.push('');
286
+ out.push(c('bold', 'The host page\'s own issues (outside Luma\'s nodes; informational, they do not fail the run)'));
287
+ for (const h of s.host) out.push(c('dim', ` ${h.sc.join(', ')} ${h.rule} [${h.impact}] ${h.help} (${h.nodes} node${h.nodes > 1 ? 's' : ''})`));
288
+ }
289
+ const manual = s.criteria.filter((x) => x.status === 'manual' || x.status === 'not-applicable');
290
+ out.push('');
291
+ out.push(c('dim', `Not decided automatically (${manual.length}): ${manual.map((x) => x.sc).join(', ')}. A screen reader pass (VoiceOver, NVDA) through typing, a suggestion, a filter, the drawer and "show more" covers what is left for these widgets (${DOC} §7).`));
292
+ for (const n of s.notes) out.push(c('yellow', `Note: ${n}`));
293
+ out.push('');
294
+ const failed = s.findings.length;
295
+ out.push(
296
+ s.verdict === 'pass'
297
+ ? c('green', c('bold', `PASS: no WCAG 2.2 A/AA failure on Luma's widgets (${pass.length} criteria checked).`))
298
+ : s.verdict === 'fail'
299
+ ? c('red', c('bold', `FAIL: ${fail.length} success criteria, ${failed} issue${failed > 1 ? 's' : ''} on Luma's widgets.`))
300
+ : c('yellow', c('bold', 'INCOMPLETE: no Luma widget could be exercised.')),
301
+ );
302
+ return out.join('\n');
303
+ };
304
+
305
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
306
+
307
+ /**
308
+ * A self-contained HTML report (no external resources).
309
+ * @param {ReturnType<typeof summarize>} s
310
+ * @param {{ meta?: Record<string, string> }} [opts]
311
+ */
312
+ export const formatHtml = (s, { meta = {} } = {}) => {
313
+ const badge = (st) => `<span class="b ${st}">${{ fail: 'fail', pass: 'pass', manual: 'by hand', 'not-applicable': 'not exercised' }[st] ?? st}</span>`;
314
+ const node = (n) => `<li><code>${esc(n.target)}</code>${n.hint ? ` <span class="hint">→ ${esc(n.hint.file)}:${n.hint.line}</span>` : ''}${n.html ? `<pre>${esc(n.html)}</pre>` : ''}${n.summary ? `<div class="sum">${esc(n.summary)}</div>` : ''}</li>`;
315
+ const finding = (f) => `<div class="f"><h4>${esc(f.rule)} <small>${esc(f.impact)}</small></h4><p>${esc(f.help)}</p>
316
+ <p class="where">${esc(f.states.join('; '))}</p><ul>${f.nodes.map(node).join('')}</ul>${f.fix ? `<p class="fix"><b>Fix:</b> ${esc(f.fix)}</p>` : ''}${f.helpUrl ? `<p><a href="${esc(f.helpUrl)}">${esc(f.helpUrl)}</a></p>` : ''}</div>`;
317
+ const rows = s.criteria
318
+ .map((c) => `<tr class="${c.status}"><td>${c.sc}</td><td>${esc(c.title)}</td><td>${c.level}</td><td>${badge(c.status)}</td><td>${c.status === 'fail' ? c.findings.map(finding).join('') : c.status === 'pass' ? `<span class="dim">${esc(c.states.slice(0, 6).join(', '))}${c.states.length > 6 ? ', …' : ''}</span>` : ''}</td></tr>`)
319
+ .join('\n');
320
+ const verdict = s.verdict === 'pass' ? 'PASS' : s.verdict === 'fail' ? 'FAIL' : 'INCOMPLETE';
321
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Accessibility report</title>
322
+ <style>
323
+ :root { --fg: #1a1a1a; --bg: #fff; --dim: #555; --line: #ccc; --fail: #b00020; --pass: #0a6b2d; --warn: #7a5200; color-scheme: light dark; }
324
+ @media (prefers-color-scheme: dark) { :root { --fg: #eee; --bg: #161616; --dim: #aaa; --line: #444; --fail: #ff7b8b; --pass: #6fd38f; --warn: #f0c060; } }
325
+ body { font: 15px/1.5 system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0 auto; max-width: 1100px; padding: 16px; }
326
+ h1 { font-size: 1.4rem; } .verdict { font-size: 1.2rem; font-weight: 700; } .verdict.fail { color: var(--fail); } .verdict.pass { color: var(--pass); } .verdict.incomplete { color: var(--warn); }
327
+ table { border-collapse: collapse; width: 100%; } td, th { border-bottom: 1px solid var(--line); padding: 6px; text-align: left; vertical-align: top; }
328
+ .b { display: inline-block; padding: 0 6px; border: 1px solid; border-radius: 4px; font-size: .8rem; } .b.fail { color: var(--fail); } .b.pass { color: var(--pass); } .b.manual, .b.not-applicable { color: var(--dim); }
329
+ pre { white-space: pre-wrap; word-break: break-all; font-size: .8rem; margin: 2px 0; color: var(--dim); } code { word-break: break-all; }
330
+ .dim, .where, .sum { color: var(--dim); font-size: .85rem; } .hint { color: var(--warn); font-family: ui-monospace, monospace; font-size: .85rem; }
331
+ .f { border-left: 3px solid var(--fail); padding-left: 8px; margin: 6px 0; } .f h4 { margin: 0; } .fix { margin: 4px 0; }
332
+ a { color: inherit; }
333
+ </style></head><body>
334
+ <h1>WCAG 2.2 AA: Luma widgets</h1>
335
+ <p class="verdict ${s.verdict}">${verdict}</p>
336
+ <ul class="dim">${Object.entries(meta).map(([k, v]) => `<li>${esc(k)}: ${esc(v)}</li>`).join('')}</ul>
337
+ <h2>Checked</h2><ul>${s.states.map((st) => `<li>${esc(st.widget)}: ${esc(st.state)} <span class="dim">${esc(st.page)}</span></li>`).join('')}${s.skipped.map((k) => `<li class="dim">${esc(k.widget)}: not checked (${esc(k.reason)})</li>`).join('')}</ul>
338
+ <h2>Success criteria</h2>
339
+ <table><thead><tr><th>SC</th><th>Title</th><th>Level</th><th>Result</th><th>Details</th></tr></thead><tbody>
340
+ ${rows}
341
+ </tbody></table>
342
+ ${s.advisories.length ? `<h2>Advice (beyond AA)</h2>${s.advisories.map(finding).join('')}` : ''}
343
+ ${s.review.length ? `<h2>Needs review</h2><ul>${s.review.map((f) => `<li>${esc(f.sc.join(', '))} <b>${esc(f.rule)}</b>: ${esc(f.help)} <span class="dim">(${f.nodes.length} nodes; ${esc(f.states.join(', '))})</span></li>`).join('')}</ul>` : ''}
344
+ ${s.host.length ? `<h2>The host page's own issues (informational)</h2><ul>${s.host.map((h) => `<li>${esc(h.sc.join(', '))} <b>${esc(h.rule)}</b> [${esc(h.impact)}] ${esc(h.help)} <span class="dim">(${h.nodes} nodes)</span></li>`).join('')}</ul>` : ''}
345
+ ${s.notes.length ? `<h2>Notes</h2><ul>${s.notes.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>` : ''}
346
+ </body></html>`;
347
+ };