@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
@@ -0,0 +1,1239 @@
1
+ // The in-page side of `yarn luma parity`: functions handed to page.evaluate /
2
+ // addInitScript (each one self-contained: Playwright sends its source, not
3
+ // its closure). Reading values follows the visual-review method: computed
4
+ // styles and boxes from the live DOM, every value whole, never a value read
5
+ // from a screenshot.
6
+
7
+ /**
8
+ * Before any script of the page (addInitScript): Math.random from a fixed
9
+ * seed (a carousel's random start, a shuffled banner), so two runs lay the
10
+ * page out alike. Luma's ids come from crypto, which stays random: nothing
11
+ * measured depends on them.
12
+ * @param {number} seed
13
+ */
14
+ export function seedRandom(seed) {
15
+ let s = seed >>> 0;
16
+ Math.random = () => {
17
+ s = (s + 0x6d2b79f5) >>> 0;
18
+ let t = s;
19
+ t = Math.imul(t ^ (t >>> 15), t | 1);
20
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
21
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Before any script of the page (addInitScript): `window.__parityDeep`, the
27
+ * selector engine of every other probe here, which sees into open shadow
28
+ * roots. Luma's autocomplete renders in one (`[data-lbx="ac-overlay"]` and
29
+ * its `shadowRoot`; `dom: 'light'` is the opt-out), and a shop's header can be
30
+ * a web component. A selector matches across the boundaries on its path:
31
+ * `[data-lbx="ac-overlay"] [data-lbx-part~="ac-row"]` finds the rows inside
32
+ * the overlay's shadow root (descendant and child combinators go from an
33
+ * element in a shadow root to its host; sibling ones stay in one tree), and
34
+ * a part's selector finds it inside its root's shadow trees too.
35
+ *
36
+ * Deterministic: the elements of a scope in one order, the composed tree's
37
+ * (an element, then its shadow tree, then its light children). With no open
38
+ * shadow root under the document, it is `querySelectorAll` as it was.
39
+ * all(scope, selector) -> Element[] one(scope, selector) -> Element | null
40
+ * text(el, hidden) -> string what renders: shadow trees and slots' assigned nodes, the flat tree
41
+ * parent(el) -> Element | null the composed parent (a shadow root's host)
42
+ * Closed shadow roots stay closed: nothing in them is measured.
43
+ */
44
+ export function deepInit() {
45
+ if (window.__parityDeep) return;
46
+ const parent = (e) => e.parentElement ?? (e.parentNode instanceof ShadowRoot ? e.parentNode.host : null);
47
+ /** Top-level splits: a selector list into selectors, a selector into compounds and combinators. */
48
+ const scan = (s, at) => {
49
+ const out = [];
50
+ let depth = 0;
51
+ let quote = '';
52
+ let start = 0;
53
+ for (let i = 0; i < s.length; i++) {
54
+ const c = s[i];
55
+ if (quote) {
56
+ if (c === '\\') i++;
57
+ else if (c === quote) quote = '';
58
+ } else if (c === '\\') i++;
59
+ else if (c === '"' || c === "'") quote = c;
60
+ else if (c === '(' || c === '[') depth++;
61
+ else if (c === ')' || c === ']') depth--;
62
+ else if (!depth) {
63
+ const n = at(s, i);
64
+ if (n) {
65
+ out.push(s.slice(start, i), n.op);
66
+ i += n.len - 1;
67
+ start = i + 1;
68
+ }
69
+ }
70
+ }
71
+ out.push(s.slice(start));
72
+ return out;
73
+ };
74
+ const comma = (s, i) => (s[i] === ',' ? { op: ',', len: 1 } : null);
75
+ const combinator = (s, i) => {
76
+ if (!/[\s>+~]/.test(s[i])) return null;
77
+ let j = i;
78
+ let op = ' ';
79
+ while (j < s.length && /[\s>+~]/.test(s[j])) {
80
+ if (s[j] !== ' ' && !/\s/.test(s[j])) op = s[j];
81
+ j++;
82
+ }
83
+ return { op, len: j - i };
84
+ };
85
+ const cache = new Map();
86
+ /** A selector as [compound, combinator, compound, …] lists, one per selector of the list. */
87
+ const parse = (sel) => {
88
+ let p = cache.get(sel);
89
+ if (!p) {
90
+ p = scan(sel, comma)
91
+ .filter((_, i) => i % 2 === 0)
92
+ .map((x) => scan(x.trim(), combinator));
93
+ cache.set(sel, p);
94
+ }
95
+ return p;
96
+ };
97
+ const is = (el, compound) => {
98
+ try {
99
+ return el.matches(compound);
100
+ } catch {
101
+ return false;
102
+ }
103
+ };
104
+ /** el matches the selector's compound at k, with the compounds before it on its composed ancestors (or siblings). */
105
+ const from = (el, t, k) => {
106
+ if (!is(el, t[k])) return false;
107
+ if (k === 0) return true;
108
+ const op = t[k - 1];
109
+ if (op === '>') {
110
+ const a = parent(el);
111
+ return !!a && from(a, t, k - 2);
112
+ }
113
+ if (op === '+') {
114
+ const a = el.previousElementSibling;
115
+ return !!a && from(a, t, k - 2);
116
+ }
117
+ if (op === '~') {
118
+ for (let a = el.previousElementSibling; a; a = a.previousElementSibling) if (from(a, t, k - 2)) return true;
119
+ return false;
120
+ }
121
+ for (let a = parent(el); a; a = parent(a)) if (from(a, t, k - 2)) return true;
122
+ return false;
123
+ };
124
+ /** Every element under a scope (document, element, shadow root) in composed-tree order; whether any is in a shadow tree. */
125
+ const walk = (scope, out) => {
126
+ const kids = scope.nodeType === 1 && scope.shadowRoot ? [scope.shadowRoot, ...scope.children] : [...scope.children];
127
+ for (const k of kids) {
128
+ if (k instanceof ShadowRoot) {
129
+ out.shadow = true;
130
+ walk(k, out);
131
+ continue;
132
+ }
133
+ out.push(k);
134
+ walk(k, out);
135
+ }
136
+ return out;
137
+ };
138
+ const all = (scope, sel) => {
139
+ const els = walk(scope, Object.assign([], { shadow: false }));
140
+ if (!els.shadow && scope.getRootNode() === document) {
141
+ try {
142
+ return [...scope.querySelectorAll(sel)];
143
+ } catch {
144
+ return [];
145
+ }
146
+ }
147
+ const list = parse(sel);
148
+ return els.filter((el) => list.some((t) => t.length && from(el, t, t.length - 1)));
149
+ };
150
+ const one = (scope, sel) => all(scope, sel)[0] ?? null;
151
+ /** The text a shopper sees in el (the flat tree), elements `hidden(e)` says are hidden left out. */
152
+ const text = (el, hidden) => {
153
+ const parts = [];
154
+ const visit = (e) => {
155
+ if (hidden(e)) return;
156
+ const kids = e.shadowRoot ? e.shadowRoot.childNodes : e instanceof HTMLSlotElement && e.assignedNodes().length ? e.assignedNodes() : e.childNodes;
157
+ for (const n of kids) {
158
+ if (n.nodeType === 3) parts.push(n.textContent);
159
+ else if (n.nodeType === 1) visit(n);
160
+ }
161
+ };
162
+ visit(el);
163
+ return parts.join('').replace(/\s+/g, ' ').trim();
164
+ };
165
+ Object.defineProperty(window, '__parityDeep', { value: { all, one, text, parent }, enumerable: false });
166
+ }
167
+
168
+ /**
169
+ * Ours, before any script (addInitScript): the products their page shows,
170
+ * in their order, in our listing. Luma's product request (search or
171
+ * collections, `type` = the products' type) goes out as it is (its total,
172
+ * cursor and guid, so facets and the pager are the real query's); then
173
+ * requests for exactly their products (`field` in their ids: no scope or
174
+ * filter of our page, so their category listing's products reach our search
175
+ * page as well as our category page), and the answer Luma gets carries
176
+ * those hits in their order. A next page asked for gets none (the
177
+ * comparison is of the first page). `window.__parity.aligned` says what was
178
+ * asked and found, and with which queries; `window.__parity.hits` the
179
+ * catalog's records shown, per request ({ id: their id, hit }), in our order.
180
+ *
181
+ * The id requests (parity/pairing.mjs): with a collections `surface`, by id
182
+ * alone first (collections take no query); then the search endpoint with
183
+ * each of `queries` in turn (many channels answer an empty query with zero
184
+ * hits: their products are asked for with a query that finds them, the
185
+ * page's, their category's name, their titles' words), each asking only for
186
+ * the ids the ones before did not find, until every one is found or the
187
+ * queries are spent.
188
+ *
189
+ * Pairing (their id to our hit) never depends on the fields the build asks
190
+ * for: when the build lists `return_fields` without the pairing field, the
191
+ * id requests (only they) ask for that field too, and the hits Luma gets
192
+ * leave it out again, so what renders is what the build renders. With
193
+ * `pair: 'url'` their ids are links (relative, absolute, another host) and
194
+ * our hit's is its `url` (else `web_url`, else `field`), both compared as
195
+ * the decoded path without a trailing slash; the request asks for `field` in
196
+ * each id as found and in its path and absolute forms, so a catalog storing
197
+ * either form is found.
198
+ * @param {{ ids: string[], field: string, type: string, surface?: string, numeric?: boolean, pair?: 'field' | 'url', queries?: string[] }} cfg
199
+ */
200
+ export function alignInit(cfg) {
201
+ const real = window.fetch;
202
+ const state = (window.__parity = window.__parity || { aligned: [] });
203
+ if (!cfg.ids.length) return;
204
+ const byUrl = cfg.pair === 'url';
205
+ const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/json' } });
206
+ const first = (v) => (Array.isArray(v) ? v[0] : v);
207
+ const path = (v) => {
208
+ try {
209
+ let p = new URL(String(v), location.href).pathname;
210
+ try {
211
+ p = decodeURI(p);
212
+ } catch {}
213
+ return p.length > 1 ? p.replace(/\/+$/, '') : p;
214
+ } catch {
215
+ return '';
216
+ }
217
+ };
218
+ const keyOfId = (id) => (byUrl ? path(id) : String(id));
219
+ const valueOf = (h) => {
220
+ const v = byUrl ? (first(h.url) ?? first(h.web_url) ?? first(h[cfg.field])) : first(h[cfg.field]);
221
+ return v == null ? '' : byUrl ? path(v) : String(v);
222
+ };
223
+ /** The filter's values for some ids: the ids; for links, each one's path and absolute forms too. */
224
+ const values = (list) => {
225
+ if (!byUrl) return list.map((x) => (cfg.numeric ? Number(x) : String(x)));
226
+ const out = [];
227
+ for (const id of list) {
228
+ const p = path(id);
229
+ for (const v of [String(id), p, p && `${location.origin}${p}`]) if (v && !out.includes(v)) out.push(v);
230
+ }
231
+ return out;
232
+ };
233
+ const queries = Array.isArray(cfg.queries) && cfg.queries.length ? cfg.queries : [''];
234
+ window.fetch = async function (input, init) {
235
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
236
+ const m = /\/discovery\/v1\/(search|collections)\?/.exec(url);
237
+ let body = null;
238
+ try {
239
+ body = m && init && typeof init.body === 'string' ? JSON.parse(init.body) : null;
240
+ } catch {
241
+ body = null;
242
+ }
243
+ if (!body || body.type !== cfg.type) return real.call(this, input, init);
244
+ if (body.cursor) return json({ hits: [], total: 0, next_page_cursor: null, pagination_status: 'exhausted' });
245
+ const orig = await real.call(this, input, init);
246
+ if (!orig.ok) return orig;
247
+ const j = await orig.json();
248
+ // The pairing field, for the id requests only: the build's own request and what it renders stay as they are.
249
+ // A field the API returns unasked (`@id`, `@type`, `@title`, or one the build's own answer carries) stays.
250
+ const asked = Array.isArray(body.return_fields) ? body.return_fields : null;
251
+ const added = asked && !asked.includes(cfg.field) ? [cfg.field] : [];
252
+ const strip = added.filter((f) => !f.startsWith('@') && !(j.hits || []).some((h) => h && f in h));
253
+ const fields = added.length ? [...asked, ...added] : asked;
254
+ /** The id requests, in order: by id on a collections surface (no query), then the search endpoint with each query. */
255
+ const plan = [];
256
+ if (cfg.surface) plan.push({ endpoint: 'collections', surface: cfg.surface, query: null });
257
+ else if (m[1] === 'collections') plan.push({ endpoint: 'collections', surface: new URL(url).searchParams.get('surface_id'), query: null });
258
+ for (const q of queries) plan.push({ endpoint: 'search', surface: m[1] === 'search' ? new URL(url).searchParams.get('surface_id') : null, query: q });
259
+ const by = new Map();
260
+ const tried = [];
261
+ for (const step of plan) {
262
+ const wanted = cfg.ids.filter((id) => !by.has(keyOfId(id)));
263
+ if (!wanted.length) break;
264
+ const ids = values(wanted);
265
+ const u = new URL(url);
266
+ u.pathname = u.pathname.replace(/\/(search|collections)$/, `/${step.endpoint}`);
267
+ if (step.surface) u.searchParams.set('surface_id', step.surface);
268
+ else u.searchParams.delete('surface_id');
269
+ let b2;
270
+ if (step.endpoint === 'collections') {
271
+ b2 = { type: body.type, size: Math.min(wanted.length, 100), collection_filters: { [cfg.field]: { in: ids } } };
272
+ if (cfg.surface == null) b2 = { ...body, size: Math.min(wanted.length, 100), filters: { [cfg.field]: { in: ids } } };
273
+ if (cfg.surface == null) delete b2.collection_filters;
274
+ } else {
275
+ // Exactly their products, whatever our page asks (not our scope, filters or sort), with a query that finds them.
276
+ b2 = m[1] === 'search' ? { ...body } : { type: body.type };
277
+ Object.assign(b2, { size: Math.min(wanted.length, 100), query: step.query, filters: { [cfg.field]: { in: ids } } });
278
+ delete b2.collection_filters;
279
+ }
280
+ delete b2.sort;
281
+ delete b2.cursor;
282
+ if (fields) b2.return_fields = fields;
283
+ else delete b2.return_fields;
284
+ let hits = [];
285
+ try {
286
+ const r2 = await real.call(this, u.href, { ...init, body: JSON.stringify(b2) });
287
+ if (r2.ok) hits = (await r2.json()).hits || [];
288
+ } catch {
289
+ hits = [];
290
+ }
291
+ const before = by.size;
292
+ for (const h of hits) {
293
+ const k = valueOf(h);
294
+ if (k && !by.has(k) && wanted.some((id) => keyOfId(id) === k)) by.set(k, h);
295
+ }
296
+ tried.push({ endpoint: step.endpoint, query: step.query, asked: wanted.length, found: by.size - before });
297
+ }
298
+ const used = new Set();
299
+ const ordered = [];
300
+ const records = [];
301
+ for (const id of cfg.ids) {
302
+ const h = by.get(keyOfId(id));
303
+ if (!h || used.has(h)) continue;
304
+ used.add(h);
305
+ records.push({ id, hit: h });
306
+ ordered.push(strip.length ? Object.fromEntries(Object.entries(h).filter(([k]) => !strip.includes(k))) : h);
307
+ }
308
+ // The catalog's records, in the order Luma shows them: the evidence a text difference is the data's (owner.mjs).
309
+ (state.hits = state.hits || []).push(records);
310
+ state.aligned.push({ endpoint: m[1], pair: byUrl ? 'url' : cfg.field, added, asked: cfg.ids.length, found: ordered.length, missing: cfg.ids.filter((id) => !by.has(keyOfId(id))), queries: tried });
311
+ return json({ ...j, hits: ordered });
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Settles the page for measuring: the hidden regions out (`hide`: cookie
317
+ * banners, chat widgets), images in the measured roots loaded eagerly and
318
+ * decoded, webfonts loaded, every CSS animation and transition finished
319
+ * (infinite ones cancelled), two frames. Transitions are finished, not
320
+ * switched off: their computed `transition` is measured too.
321
+ * @param {{ roots: string[], hide: string[] }} arg
322
+ */
323
+ export async function stabilize(arg) {
324
+ const D = window.__parityDeep;
325
+ const qa = (scope, sel) => {
326
+ if (D) return D.all(scope, sel);
327
+ try {
328
+ return [...scope.querySelectorAll(sel)];
329
+ } catch {
330
+ return [];
331
+ }
332
+ };
333
+ if (arg.hide.length && !document.querySelector('style[data-parity="hide"]')) {
334
+ const st = document.createElement('style');
335
+ st.setAttribute('data-parity', 'hide');
336
+ st.textContent = `${arg.hide.join(',\n')} { display: none !important; }`;
337
+ (document.head || document.documentElement).appendChild(st);
338
+ }
339
+ // A hidden region inside a shadow root (a web-component header's banner): the document's style does not reach it.
340
+ if (D) for (const sel of arg.hide) for (const el of qa(document, sel)) if (el.getRootNode() !== document) el.style.setProperty('display', 'none', 'important');
341
+ const within = (ms, p) => Promise.race([p, new Promise((r) => setTimeout(r, ms))]);
342
+ const imgs = new Set();
343
+ for (const sel of arg.roots) for (const r of qa(document, sel)) for (const img of qa(r, 'img')) imgs.add(img);
344
+ for (const img of imgs) if (img.loading === 'lazy') img.loading = 'eager';
345
+ await within(
346
+ 8000,
347
+ Promise.all(
348
+ [...imgs].map((img) =>
349
+ (img.complete ? Promise.resolve() : new Promise((r) => (img.addEventListener('load', r, { once: true }), img.addEventListener('error', r, { once: true }))))
350
+ .then(() => (img.naturalWidth ? img.decode().catch(() => {}) : undefined)),
351
+ ),
352
+ ),
353
+ );
354
+ await within(8000, document.fonts.ready);
355
+ for (const a of document.getAnimations()) {
356
+ try {
357
+ const t = a.effect && a.effect.getComputedTiming();
358
+ if (t && t.endTime === Infinity) a.cancel();
359
+ else a.finish();
360
+ } catch {
361
+ try {
362
+ a.cancel();
363
+ } catch {}
364
+ }
365
+ }
366
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
367
+ }
368
+
369
+ /**
370
+ * The boxes of every root of `selectors` (rounded to 1/64 px, as layout is):
371
+ * equal on two reads a moment apart = the layout has settled.
372
+ * @param {string[]} selectors
373
+ */
374
+ export function boxes(selectors) {
375
+ const D = window.__parityDeep;
376
+ const qa = (scope, sel) => {
377
+ if (D) return D.all(scope, sel);
378
+ try {
379
+ return [...scope.querySelectorAll(sel)];
380
+ } catch {
381
+ return [];
382
+ }
383
+ };
384
+ const out = [];
385
+ for (const sel of selectors) {
386
+ for (const el of qa(document, sel).slice(0, 60)) {
387
+ const r = el.getBoundingClientRect();
388
+ out.push([r.x, r.y + scrollY, r.width, r.height].map((v) => Math.round(v * 64) / 64).join(','));
389
+ }
390
+ }
391
+ return out.join(';');
392
+ }
393
+
394
+ /**
395
+ * The keys their page lists its products under (ids for the API), in their
396
+ * order, visible elements only, each once.
397
+ * @param {{ selector: string, attr: string, child?: string, transform?: 'path' | 'absolute' | null, max: number }} arg
398
+ */
399
+ export function collectIds(arg) {
400
+ const D = window.__parityDeep;
401
+ const qa = (scope, sel) => {
402
+ if (D) return D.all(scope, sel);
403
+ try {
404
+ return [...scope.querySelectorAll(sel)];
405
+ } catch {
406
+ return [];
407
+ }
408
+ };
409
+ const out = [];
410
+ for (const el of qa(document, arg.selector)) {
411
+ const r = el.getBoundingClientRect();
412
+ if (!r.width || !r.height) continue;
413
+ const src = arg.child ? (qa(el, arg.child)[0] ?? null) : el;
414
+ let v = src ? src.getAttribute(arg.attr) : null;
415
+ if (!v) continue;
416
+ if (arg.transform === 'path' || arg.transform === 'absolute') {
417
+ try {
418
+ const u = new URL(v, location.href);
419
+ v = arg.transform === 'path' ? u.pathname : `${u.origin}${u.pathname}`;
420
+ } catch {
421
+ continue;
422
+ }
423
+ }
424
+ v = v.trim();
425
+ if (v && !out.includes(v)) out.push(v);
426
+ if (out.length >= arg.max) break;
427
+ }
428
+ return out;
429
+ }
430
+
431
+ /**
432
+ * One component on the page: its roots (all of them for a repeated one,
433
+ * else the first visible), each root's box and computed style, its parts
434
+ * (relative selectors) with boxes relative to the root, their visible text,
435
+ * the grid when the component is one (columns per row, gaps, item width).
436
+ * Elements are kept in `window.__parityEls` (by `ref`) for the rendered-font
437
+ * lookup (CDP), no attribute is written into the page.
438
+ * @param {{ root: string, parts: Record<string, string>, key: { attr: string, child?: string, transform?: 'path' | 'absolute' } | null, all: boolean, max: number, grid: { item: string } | null, props: string[], ref?: number }} arg
439
+ */
440
+ export function measure(arg) {
441
+ const els = (window.__parityEls = window.__parityEls || []);
442
+ const COLOR = /color$/;
443
+ const canvas = document.createElement('canvas');
444
+ canvas.width = canvas.height = 1;
445
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
446
+ const cache = new Map();
447
+ /** The browser's own sRGB reading of a colour in any space (oklch, lab, color-mix). */
448
+ const rgba = (v) => {
449
+ if (/^rgba?\(/.test(v) || v === 'transparent') return undefined;
450
+ if (cache.has(v)) return cache.get(v);
451
+ let out;
452
+ try {
453
+ ctx.clearRect(0, 0, 1, 1);
454
+ ctx.fillStyle = '#000';
455
+ ctx.fillStyle = v;
456
+ ctx.fillRect(0, 0, 1, 1);
457
+ const d = ctx.getImageData(0, 0, 1, 1).data;
458
+ out = [d[0], d[1], d[2], Math.round((d[3] / 255) * 1000) / 1000];
459
+ } catch {
460
+ out = undefined;
461
+ }
462
+ cache.set(v, out);
463
+ return out;
464
+ };
465
+ const visible = (el) => {
466
+ if (!el) return false;
467
+ const r = el.getBoundingClientRect();
468
+ if (!(r.width > 0 && r.height > 0)) return false;
469
+ return getComputedStyle(el).visibility !== 'hidden';
470
+ };
471
+ const D = window.__parityDeep;
472
+ const qa = (scope, sel) => {
473
+ if (D) return D.all(scope, sel);
474
+ try {
475
+ return [...scope.querySelectorAll(sel)];
476
+ } catch {
477
+ return [];
478
+ }
479
+ };
480
+ const q = (root, sel, all) => (all ? qa(root, sel) : (qa(root, sel)[0] ?? null));
481
+ /** Text a sighted shopper reads: visually hidden text (sr-only: a 1 px clipped box) and hidden elements left out. */
482
+ const textOf = (el) => {
483
+ const parts = [];
484
+ const hidden = new Map();
485
+ const up = (e) => (D ? D.parent(e) : e.parentElement);
486
+ const isHidden = (node) => {
487
+ for (let e = node; e; e = up(e)) {
488
+ if (hidden.has(e)) return hidden.get(e);
489
+ const s = getComputedStyle(e);
490
+ const r = e.getBoundingClientRect();
491
+ const h = s.display === 'none' || s.visibility === 'hidden' || ((r.width <= 1 || r.height <= 1) && (s.overflow !== 'visible' || s.clip !== 'auto' || s.clipPath !== 'none'));
492
+ if (h) {
493
+ hidden.set(e, true);
494
+ return true;
495
+ }
496
+ if (e === el) break;
497
+ }
498
+ return false;
499
+ };
500
+ // Open shadow trees and slots too (the flat tree): what renders.
501
+ if (D) return D.text(el, isHidden);
502
+ const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
503
+ for (let n = w.nextNode(); n; n = w.nextNode()) {
504
+ if (isHidden(n.parentElement)) continue;
505
+ parts.push(n.textContent);
506
+ }
507
+ return parts.join('').replace(/\s+/g, ' ').trim();
508
+ };
509
+ const styleOf = (el) => {
510
+ const s = getComputedStyle(el);
511
+ const out = {};
512
+ for (const p of arg.props) {
513
+ const v = s.getPropertyValue(p);
514
+ out[p] = COLOR.test(p) ? { raw: v, rgba: rgba(v) } : v;
515
+ if (out[p] && out[p].rgba === undefined && typeof out[p] === 'object') delete out[p].rgba;
516
+ }
517
+ return out;
518
+ };
519
+ const ref = (el) => {
520
+ els.push(el);
521
+ return els.length - 1;
522
+ };
523
+ /**
524
+ * The controls in a part, by kind (docs/PARITY.md, "Parts of parts"): a slider (input[type=range],
525
+ * role=slider, jQuery UI's, noUiSlider's, ion.rangeSlider's), text inputs, selects, checkboxes, radios,
526
+ * buttons. Rendered ones (a visually hidden checkbox counts), not display: none.
527
+ */
528
+ const controlsOf = (el) => {
529
+ const SLIDER = '[data-lbx-part~="range-slider"], input[type=range], [role=slider], .ui-slider, .noUi-target, .irs, .rc-slider, .MuiSlider-root';
530
+ const kinds = { slider: 0, input: 0, select: 0, checkbox: 0, radio: 0, button: 0 };
531
+ const all = [el, ...qa(el, '*')].filter((e) => e.getClientRects().length > 0);
532
+ const sliders = all.filter((e) => e.matches(SLIDER));
533
+ kinds.slider = sliders.filter((e) => !sliders.some((s) => s !== e && s.contains(e))).length;
534
+ for (const e of all) {
535
+ if (sliders.some((s) => s.contains(e))) continue;
536
+ const t = (e.getAttribute('type') || '').toLowerCase();
537
+ if (e.tagName === 'SELECT') kinds.select++;
538
+ else if (e.tagName === 'INPUT' && t === 'checkbox') kinds.checkbox++;
539
+ else if (e.tagName === 'INPUT' && t === 'radio') kinds.radio++;
540
+ else if (e.tagName === 'INPUT' && !/^(hidden|submit|button|reset|image|range)$/.test(t)) kinds.input++;
541
+ else if (e.tagName === 'TEXTAREA') kinds.input++;
542
+ else if (e.tagName === 'BUTTON' || (e.tagName === 'INPUT' && /^(submit|button|reset|image)$/.test(t))) kinds.button++;
543
+ }
544
+ return kinds;
545
+ };
546
+ /**
547
+ * Consecutive siblings of the first of `list` (the ones of its composed parent, in order, at most 12),
548
+ * as page boxes [x, y, width, height]: the space between one and the next (compare.mjs layoutOf).
549
+ * Null when it has none (one element, or the others elsewhere).
550
+ */
551
+ const siblingsOf = (list) => {
552
+ if (list.length < 2) return null;
553
+ const up = (e) => (D ? D.parent(e) : e.parentElement);
554
+ const p = up(list[0]);
555
+ const same = list.filter((e) => up(e) === p).slice(0, 12);
556
+ if (same.length < 2) return null;
557
+ return same.map((e) => {
558
+ const r = e.getBoundingClientRect();
559
+ return [r.x + scrollX, r.y + scrollY, r.width, r.height].map((v) => Math.round(v * 64) / 64);
560
+ });
561
+ };
562
+ const one = (root) => {
563
+ const rb = root.getBoundingClientRect();
564
+ const box = (el) => {
565
+ const r = el.getBoundingClientRect();
566
+ return { x: r.x - rb.x, y: r.y - rb.y, width: r.width, height: r.height };
567
+ };
568
+ const parts = {};
569
+ for (const [name, sel] of Object.entries(arg.parts)) {
570
+ const all = q(root, sel, true).filter(visible);
571
+ const el = all[0] ?? null;
572
+ parts[name] = el ? { present: true, box: box(el), style: styleOf(el), text: textOf(el), ref: ref(el), font: null, siblings: siblingsOf(all), controls: controlsOf(el) } : { present: false };
573
+ }
574
+ let key = null;
575
+ if (arg.key) {
576
+ const src = arg.key.child ? q(root, arg.key.child, false) : root;
577
+ key = src ? src.getAttribute(arg.key.attr) : null;
578
+ if (key && arg.key.transform)
579
+ try {
580
+ const u = new URL(key, location.href);
581
+ key = arg.key.transform === 'path' ? u.pathname : `${u.origin}${u.pathname}`;
582
+ } catch {}
583
+ }
584
+ let grid = null;
585
+ if (arg.grid) {
586
+ const items = q(root, arg.grid.item, true).filter(visible).map((e) => e.getBoundingClientRect());
587
+ const s = getComputedStyle(root);
588
+ grid = { columns: 0, columnGap: null, rowGap: null, itemWidth: null, items: items.length, padding: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'].map((p) => parseFloat(s.getPropertyValue(p)) || 0) };
589
+ if (items.length) {
590
+ const top = items[0].top;
591
+ const row1 = items.filter((r) => Math.abs(r.top - top) < 2);
592
+ grid.columns = row1.length;
593
+ grid.itemWidth = items[0].width;
594
+ if (row1.length > 1) grid.columnGap = row1[1].left - row1[0].right;
595
+ const next = items.find((r) => r.top > top + 2);
596
+ if (next) grid.rowGap = next.top - Math.max(...row1.map((r) => r.bottom));
597
+ }
598
+ }
599
+ return { present: true, key, box: { x: 0, y: 0, width: rb.width, height: rb.height }, style: styleOf(root), text: textOf(root), ref: ref(root), font: null, parts, grid };
600
+ };
601
+ if (arg.ref != null) return [els[arg.ref]].filter(visible).map(one);
602
+ const roots = q(document, arg.root, true).filter(visible);
603
+ const chosen = arg.all ? roots.slice(0, arg.max) : roots.slice(0, 1);
604
+ const out = chosen.map(one);
605
+ // A repeated component's instances side by side (tiles in a row, facet groups in a column): their gaps, their columns.
606
+ if (arg.all && out.length) out[0].siblings = siblingsOf(roots);
607
+ return out;
608
+ }
609
+
610
+ /**
611
+ * Their page, for the id requests' queries (parity/pairing.mjs): its
612
+ * heading (their category's name: the first visible h1, else the
613
+ * breadcrumb's last item) and their tiles' titles (a heading or an element
614
+ * named title/name in each tile, else its longest link text).
615
+ * @param {{ selector: string | null }} arg
616
+ */
617
+ export function theirHints(arg) {
618
+ const D = window.__parityDeep;
619
+ const qa = (scope, sel) => {
620
+ if (D) return D.all(scope, sel);
621
+ try {
622
+ return [...scope.querySelectorAll(sel)];
623
+ } catch {
624
+ return [];
625
+ }
626
+ };
627
+ const vis = (el) => {
628
+ const r = el.getBoundingClientRect();
629
+ return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
630
+ };
631
+ const text = (el) => (el?.textContent ?? '').replace(/\s+/g, ' ').trim();
632
+ const h1 = qa(document, 'h1').find(vis);
633
+ const crumb = qa(document, '[aria-label*="breadcrumb" i] li, .breadcrumb li, .breadcrumbs li, [itemtype*="BreadcrumbList"] [itemprop="itemListElement"]').filter(vis).pop();
634
+ const heading = text(h1) || text(crumb) || '';
635
+ const titles = [];
636
+ for (const t of arg.selector ? qa(document, arg.selector).filter(vis).slice(0, 24) : []) {
637
+ const named = qa(t, 'h1, h2, h3, h4, h5, h6, [itemprop="name"], [class*="title" i], [class*="name" i]').find(vis);
638
+ const link = qa(t, 'a[href]').map(text).sort((a, b) => b.length - a.length)[0];
639
+ const v = text(named) || link || text(t);
640
+ if (v) titles.push(v.slice(0, 200));
641
+ }
642
+ return { heading: heading.length <= 80 ? heading : '', titles };
643
+ }
644
+
645
+ /**
646
+ * Before any script (addInitScript): the page's storage set as the map says
647
+ * (`pages.<name>.storage`: a view mode their page reads), and every storage
648
+ * key the page reads recorded (`window.__parityStorage`): a listing whose
649
+ * view mode (grid or list, its columns) comes from storage says so in the
650
+ * report.
651
+ * @param {{ set: { localStorage?: Record<string, string>, sessionStorage?: Record<string, string> } | null }} cfg
652
+ */
653
+ export function storageInit(cfg) {
654
+ const reads = (window.__parityStorage = window.__parityStorage || []);
655
+ try {
656
+ for (const area of ['localStorage', 'sessionStorage']) for (const [k, v] of Object.entries(cfg.set?.[area] ?? {})) window[area].setItem(k, v);
657
+ } catch {}
658
+ try {
659
+ const get = Storage.prototype.getItem;
660
+ Storage.prototype.getItem = function (k) {
661
+ const v = get.call(this, k);
662
+ try {
663
+ const area = this === window.localStorage ? 'localStorage' : 'sessionStorage';
664
+ if (!reads.some((r) => r.area === area && r.key === String(k))) reads.push({ area, key: String(k), value: v });
665
+ } catch {}
666
+ return v;
667
+ };
668
+ } catch {}
669
+ }
670
+
671
+ /**
672
+ * Before any script (addInitScript): the page's layout shifts (the Layout
673
+ * Instability API; shifts right after an input left out, as CLS does), each
674
+ * with the elements that moved and where they were: `window.__parityCls`.
675
+ * `blocksOf` counts the ones of their elements above our components.
676
+ */
677
+ export function clsInit() {
678
+ const out = (window.__parityCls = { entries: [] });
679
+ const rect = (r) => (r ? { x: r.x, y: r.y + scrollY, width: r.width, height: r.height } : null);
680
+ try {
681
+ new PerformanceObserver((list) => {
682
+ for (const e of list.getEntries()) {
683
+ if (e.hadRecentInput || out.entries.length >= 200) continue;
684
+ const sources = (e.sources ?? []).filter((s) => s.node && s.node.nodeType === 1).map((s) => ({ node: s.node, before: rect(s.previousRect) }));
685
+ out.entries.push({ value: e.value, sources });
686
+ }
687
+ }).observe({ type: 'layout-shift', buffered: true });
688
+ } catch {}
689
+ }
690
+
691
+ /**
692
+ * The collateral check's view of a page (docs/PARITY.md, "The collateral
693
+ * check"): every block of the page outside the regions (our components',
694
+ * Luma's own elements, the map's `collateral.ignore`), by its path from the
695
+ * body, in page coordinates. The walk goes down only into the ancestors of a
696
+ * region (`container`: the body, a column wrapping the listing), so a block
697
+ * is the largest element beside a region: their header, a sidebar, a
698
+ * banner, the footer (`outside`, with its direct children, for their
699
+ * presence). A path is tag, id and classes (Luma's `lbx-` ones aside), with
700
+ * the occurrence among alike siblings: the same element on their page
701
+ * without us and on ours. Elements are kept in `window.__parityEls` for the
702
+ * crops; `top` is the first region's top (what is above it keeps its place).
703
+ * @param {{ regions: string[], ignore: string[] }} arg
704
+ */
705
+ export function blocksOf(arg) {
706
+ const els = (window.__parityEls = window.__parityEls || []);
707
+ const D = window.__parityDeep;
708
+ const qa = (scope, sel) => {
709
+ if (D) return D.all(scope, sel);
710
+ try {
711
+ return [...scope.querySelectorAll(sel)];
712
+ } catch {
713
+ return [];
714
+ }
715
+ };
716
+ const up = (e) => (D ? D.parent(e) : e.parentElement);
717
+ const vis = (el) => {
718
+ const r = el.getBoundingClientRect();
719
+ return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
720
+ };
721
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'NOSCRIPT', 'TEMPLATE', 'BR', 'BASE', 'TITLE']);
722
+ const luma = (el) => el.tagName.startsWith('LBX-') || el.hasAttribute('data-lbx') || el.hasAttribute('data-lbx-part');
723
+ const regions = new Set();
724
+ for (const sel of arg.regions) for (const el of qa(document, sel)) regions.add(el);
725
+ for (const el of document.querySelectorAll('body *')) if (luma(el)) regions.add(el);
726
+ const ignored = new Set();
727
+ for (const sel of arg.ignore) for (const el of qa(document, sel)) ignored.add(el);
728
+ // The ancestors of every region: walked into; what is beside them is a block.
729
+ const holds = new Set();
730
+ let top = null;
731
+ for (const r of regions) {
732
+ // A hidden region (an overlay not open) holds nothing: its ancestors stay blocks.
733
+ if (!vis(r)) continue;
734
+ const y = r.getBoundingClientRect().y + scrollY;
735
+ top = top == null ? y : Math.min(top, y);
736
+ for (let a = up(r); a; a = up(a)) holds.add(a);
737
+ }
738
+ const r64 = (v) => Math.round(v * 64) / 64;
739
+ const boxOf = (el) => {
740
+ const r = el.getBoundingClientRect();
741
+ return { x: r64(r.x + scrollX), y: r64(r.y + scrollY), width: r64(r.width), height: r64(r.height) };
742
+ };
743
+ const sig = (el) => {
744
+ const id = el.id && !/\d{3,}|^lbx/i.test(el.id) ? `#${el.id}` : '';
745
+ const cls = [...el.classList].filter((c) => !/^lbx/i.test(c)).sort().slice(0, 3).map((c) => `.${c}`).join('');
746
+ return `${el.tagName.toLowerCase()}${id}${cls}`;
747
+ };
748
+ /** An element's children with their paths (the occurrence of a signature among its siblings: `:2`). */
749
+ const kids = (el, path) => {
750
+ const seen = new Map();
751
+ const out = [];
752
+ for (const k of el.children) {
753
+ if (SKIP.has(k.tagName)) continue;
754
+ const s = sig(k);
755
+ const n = (seen.get(s) ?? 0) + 1;
756
+ seen.set(s, n);
757
+ out.push([k, `${path} > ${s}${n > 1 ? `:${n}` : ''}`]);
758
+ }
759
+ return out;
760
+ };
761
+ const blocks = [];
762
+ const walk = (el, path, depth) => {
763
+ for (const [k, p] of kids(el, path)) {
764
+ if (blocks.length >= 80) return;
765
+ if (regions.has(k) || ignored.has(k)) continue;
766
+ const present = vis(k);
767
+ if (holds.has(k)) {
768
+ blocks.push({ path: p, kind: 'container', present, box: present ? boxOf(k) : null, ref: null, children: [] });
769
+ if (depth < 14) walk(k, p, depth + 1);
770
+ continue;
771
+ }
772
+ if (!present) {
773
+ blocks.push({ path: p, kind: 'outside', present: false, box: null, ref: null, children: [] });
774
+ continue;
775
+ }
776
+ els.push(k);
777
+ const children = kids(k, p)
778
+ .filter(([c]) => !regions.has(c) && !ignored.has(c) && vis(c) && c.getBoundingClientRect().width >= 4 && c.getBoundingClientRect().height >= 4)
779
+ .map(([, cp]) => cp);
780
+ blocks.push({ path: p, kind: 'outside', present: true, box: boxOf(k), ref: els.length - 1, children });
781
+ }
782
+ };
783
+ walk(document.body, 'body', 0);
784
+ // The layout shifts of their elements above our components (clsInit): what our render moved of their page.
785
+ // Below our components an element moves with their height (compared as theirs); in them, it is ours.
786
+ const inRegion = (n) => {
787
+ for (let e = n; e; e = up(e)) if (regions.has(e)) return true;
788
+ return false;
789
+ };
790
+ const name = (n) => `${n.tagName.toLowerCase()}${n.id ? `#${n.id}` : ''}${[...n.classList].slice(0, 2).map((c) => `.${c}`).join('')}`;
791
+ let cls = null;
792
+ if (window.__parityCls) {
793
+ cls = { value: 0, shifts: [] };
794
+ for (const e of window.__parityCls.entries) {
795
+ const theirs = e.sources.filter((x) => x.node.isConnected !== false && !inRegion(x.node) && x.before && (top == null || x.before.y + x.before.height <= top + 1));
796
+ if (!theirs.length) continue;
797
+ cls.value += e.value;
798
+ if (cls.shifts.length < 10) cls.shifts.push({ value: Math.round(e.value * 10000) / 10000, nodes: theirs.map((x) => name(x.node)) });
799
+ }
800
+ cls.value = Math.round(cls.value * 10000) / 10000;
801
+ }
802
+ return { blocks, top: top == null ? null : r64(top), cls };
803
+ }
804
+
805
+ /**
806
+ * The vertical layout around the listing (docs/PARITY.md, "The layout around
807
+ * the listing"): the listing's box (the union of the regions shown: the
808
+ * map's components, Luma's `lbx-search` / `lbx-plp`), the nearest element
809
+ * that renders something above it (`above`: its bottom, the heading, a
810
+ * toolbar) and below it (`below`: its top, the next block, the footer), the
811
+ * gap to each; the elements filling each gap (`fillers`, outermost, with
812
+ * their min-height, height, padding and margin: the fix hint); and the
813
+ * elements between the listing and what follows it that render nothing yet
814
+ * take more than `empty` px (`empty`: an empty column a theme gives a
815
+ * min-height). Elements are named by their path from the body, as
816
+ * `blocksOf` names them. Their elements only: never in a region, never
817
+ * Luma's, never fixed (a cookie bar), never in `ignore`.
818
+ * @param {{ regions: string[], ignore: string[], empty?: number }} arg
819
+ */
820
+ export function aroundOf(arg) {
821
+ const D = window.__parityDeep;
822
+ const qa = (scope, sel) => {
823
+ if (D) return D.all(scope, sel);
824
+ try {
825
+ return [...scope.querySelectorAll(sel)];
826
+ } catch {
827
+ return [];
828
+ }
829
+ };
830
+ const up = (e) => (D ? D.parent(e) : e.parentElement);
831
+ const shown = (el) => {
832
+ const r = el.getBoundingClientRect();
833
+ if (!(r.width > 0 && r.height > 0)) return false;
834
+ if (el.checkVisibility) return el.checkVisibility({ opacityProperty: true, visibilityProperty: true });
835
+ return getComputedStyle(el).visibility !== 'hidden';
836
+ };
837
+ const r64 = (v) => Math.round(v * 64) / 64;
838
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'NOSCRIPT', 'TEMPLATE', 'BR', 'BASE', 'TITLE', 'HEAD']);
839
+ const MEDIA = new Set(['IMG', 'SVG', 'svg', 'VIDEO', 'CANVAS', 'IFRAME', 'INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'PICTURE', 'OBJECT', 'EMBED', 'HR']);
840
+ const luma = (el) => el.tagName.startsWith('LBX-') || el.hasAttribute('data-lbx') || el.hasAttribute('data-lbx-part');
841
+ const regions = new Set();
842
+ for (const sel of arg.regions) for (const el of qa(document, sel)) regions.add(el);
843
+ // The listing: the map's regions and Luma's listing hosts (their "show more" and pager are in them).
844
+ const listing = [...regions, ...document.querySelectorAll('lbx-search, lbx-plp')].filter((el) => el.isConnected && shown(el));
845
+ if (!listing.length) return null;
846
+ let top = Infinity;
847
+ let bottom = -Infinity;
848
+ for (const el of listing) {
849
+ const r = el.getBoundingClientRect();
850
+ top = Math.min(top, r.top + scrollY);
851
+ bottom = Math.max(bottom, r.bottom + scrollY);
852
+ }
853
+ const ignored = new Set();
854
+ for (const sel of arg.ignore) for (const el of qa(document, sel)) ignored.add(el);
855
+ // Not theirs to measure: in a region, Luma's, ignored by the map.
856
+ const ours = (el) => {
857
+ for (let e = el; e && e !== document.body; e = up(e)) if (regions.has(e) || luma(e) || ignored.has(e)) return true;
858
+ return false;
859
+ };
860
+ const holds = new Set();
861
+ for (const el of listing) for (let a = up(el); a; a = up(a)) holds.add(a);
862
+ const fixed = (el) => {
863
+ for (let e = el; e && e !== document.body; e = up(e)) if (getComputedStyle(e).position === 'fixed') return true;
864
+ return false;
865
+ };
866
+ const alpha = (c) => {
867
+ const m = /rgba?\(([^)]+)\)/.exec(c || '');
868
+ if (!m) return c && c !== 'transparent' ? 1 : 0;
869
+ const p = m[1].split(/[\s,/]+/).filter(Boolean);
870
+ return p.length > 3 ? Number(p[3]) : 1;
871
+ };
872
+ /** What the element itself draws: its own text, a medium, a background, a border, a shadow, an outline, a pseudo-element's content. */
873
+ const paints = (el) => {
874
+ if (MEDIA.has(el.tagName)) return true;
875
+ for (const n of el.childNodes) if (n.nodeType === 3 && n.textContent.trim()) return true;
876
+ const s = getComputedStyle(el);
877
+ if (s.backgroundImage !== 'none' || alpha(s.backgroundColor) > 0) return true;
878
+ for (const side of ['Top', 'Right', 'Bottom', 'Left']) if (parseFloat(s[`border${side}Width`]) > 0 && s[`border${side}Style`] !== 'none' && s[`border${side}Style`] !== 'hidden' && alpha(s[`border${side}Color`]) > 0) return true;
879
+ if (s.boxShadow !== 'none') return true;
880
+ if (s.outlineStyle !== 'none' && parseFloat(s.outlineWidth) > 0) return true;
881
+ for (const p of ['::before', '::after']) {
882
+ const c = getComputedStyle(el, p).content;
883
+ if (c && c !== 'none' && c !== 'normal' && c !== '""') return true;
884
+ }
885
+ return false;
886
+ };
887
+ const sig = (el) => {
888
+ const id = el.id && !/\d{3,}|^lbx/i.test(el.id) ? `#${el.id}` : '';
889
+ const cls = [...el.classList].filter((c) => !/^lbx/i.test(c)).sort().slice(0, 3).map((c) => `.${c}`).join('');
890
+ return `${el.tagName.toLowerCase()}${id}${cls}`;
891
+ };
892
+ /** Its path from the body, as blocksOf names a block (`:2` the second alike sibling). */
893
+ const pathOf = (el) => {
894
+ const segs = [];
895
+ for (let e = el; e && e !== document.body && e.parentElement; e = e.parentElement) {
896
+ const s = sig(e);
897
+ let n = 0;
898
+ for (const k of e.parentElement.children) {
899
+ if (SKIP.has(k.tagName) || sig(k) !== s) continue;
900
+ n++;
901
+ if (k === e) break;
902
+ }
903
+ segs.unshift(`${s}${n > 1 ? `:${n}` : ''}`);
904
+ }
905
+ return segs.join(' > ');
906
+ };
907
+ const px = (v) => Math.round(parseFloat(v) * 100) / 100 || 0;
908
+ const styleOf = (el) => {
909
+ const s = getComputedStyle(el);
910
+ const r = el.getBoundingClientRect();
911
+ return { height: r64(r.height), minHeight: s.minHeight, paddingTop: px(s.paddingTop), paddingBottom: px(s.paddingBottom), marginTop: px(s.marginTop), marginBottom: px(s.marginBottom) };
912
+ };
913
+ // Their elements, in document order, with their boxes in page coordinates.
914
+ const all = [];
915
+ for (const el of document.body.querySelectorAll('*')) {
916
+ if (SKIP.has(el.tagName) || regions.has(el) || holds.has(el) || ours(el) || !shown(el)) continue;
917
+ const r = el.getBoundingClientRect();
918
+ all.push({ el, top: r.top + scrollY, bottom: r.bottom + scrollY });
919
+ }
920
+ let above = null;
921
+ let below = null;
922
+ for (const x of all) {
923
+ if (x.top >= bottom - 1 && (!below || x.top < below.top) && paints(x.el) && !fixed(x.el)) below = x;
924
+ if (x.bottom <= top + 1 && (!above || x.bottom > above.bottom) && paints(x.el) && !fixed(x.el)) above = x;
925
+ }
926
+ /** The outermost elements in a band (y from a to b), the tallest first. */
927
+ const within = (a, b) => {
928
+ const out = [];
929
+ for (const x of all) {
930
+ if (x.top < a - 1 || x.bottom > b + 1 || x.bottom - x.top < 1 || fixed(x.el)) continue;
931
+ if (out.some((o) => o.el.contains(x.el))) continue;
932
+ out.push(x);
933
+ }
934
+ return out.sort((p, q) => q.bottom - q.top - (p.bottom - p.top) || p.top - q.top).slice(0, 4).map((x) => ({ path: pathOf(x.el), ...styleOf(x.el) }));
935
+ };
936
+ // Renders nothing: no text shown, nothing in it (itself included) that paints.
937
+ const blank = (el) => !(el.innerText ?? '').trim() && !paints(el) && ![...el.querySelectorAll('*')].some((k) => shown(k) && paints(k));
938
+ const limit = arg.empty ?? 100;
939
+ const edge = below ? below.top : Infinity;
940
+ const empty = [];
941
+ const seen = [];
942
+ for (const x of all) {
943
+ if (x.top < bottom - 1 || x.bottom > edge + 1 || x.bottom - x.top <= limit || seen.some((s) => s.contains(x.el))) continue;
944
+ if (!blank(x.el) || fixed(x.el)) continue;
945
+ seen.push(x.el);
946
+ empty.push({ path: pathOf(x.el), ...styleOf(x.el) });
947
+ }
948
+ const end = (x, at) => (x ? { path: pathOf(x.el), at: r64(at), ...styleOf(x.el) } : null);
949
+ return {
950
+ listing: { top: r64(top), bottom: r64(bottom) },
951
+ above: above ? { ...end(above, above.bottom), gap: r64(top - above.bottom) } : null,
952
+ below: below ? { ...end(below, below.top), gap: r64(below.top - bottom) } : null,
953
+ fillers: { above: above ? within(above.bottom, top) : [], below: below ? within(bottom, below.top) : [] },
954
+ empty,
955
+ };
956
+ }
957
+
958
+ /**
959
+ * Before any script (addInitScript): what each frame of the page painted
960
+ * (docs/PARITY.md, "The hold"), the technique of Luma's
961
+ * browser tests: a ResizeObserver on a 1 px probe whose
962
+ * width flips every frame samples each frame after its layout and before its
963
+ * paint. A frame shows `theirs` when one of their listing's elements
964
+ * (`theirs`, their components' selectors) is visible outside Luma's own
965
+ * elements, `ours` when Luma's listing shows content. Consecutive equal
966
+ * frames are counted: `window.__parityFrames` [{ n, theirs, ours }].
967
+ * @param {{ theirs: string[] }} spec
968
+ */
969
+ export function framesInit(spec) {
970
+ if (window.__parityFrames || window.top !== window) return;
971
+ const F = (window.__parityFrames = []);
972
+ const luma = (el) => !!el.closest('lbx-search, lbx-plp, [data-lbx]');
973
+ const vis = (el) => {
974
+ if (!el.isConnected) return false;
975
+ const r = el.getBoundingClientRect();
976
+ if (!(r.width > 0 && r.height > 0)) return false;
977
+ return el.checkVisibility ? el.checkVisibility({ opacityProperty: true, visibilityProperty: true }) : getComputedStyle(el).visibility !== 'hidden';
978
+ };
979
+ const q = (sel) => {
980
+ try {
981
+ return [...document.querySelectorAll(sel)];
982
+ } catch {
983
+ return [];
984
+ }
985
+ };
986
+ const sample = () => {
987
+ const theirs = spec.theirs.some((s) => q(s).some((el) => !luma(el) && vis(el)));
988
+ const ours = q('lbx-search, lbx-plp').some((el) => vis(el) && !!el.querySelector('[data-lbx-list] > *, [data-lbx-part~="tile"]'));
989
+ const last = F[F.length - 1];
990
+ if (last && last.theirs === theirs && last.ours === ours) last.n++;
991
+ else if (F.length < 400) F.push({ n: 1, theirs, ours });
992
+ };
993
+ let probe = null;
994
+ let flip = false;
995
+ const ro = new ResizeObserver(() => sample());
996
+ const tick = () => {
997
+ if (!probe && document.documentElement) {
998
+ probe = document.createElement('lbx-parity-probe');
999
+ probe.style.cssText = 'position:fixed;top:0;left:0;height:1px;width:1px;pointer-events:none;opacity:0;contain:strict;';
1000
+ document.documentElement.appendChild(probe);
1001
+ ro.observe(probe);
1002
+ }
1003
+ if (probe) probe.style.width = (flip = !flip) ? '2px' : '1px';
1004
+ requestAnimationFrame(tick);
1005
+ };
1006
+ requestAnimationFrame(tick);
1007
+ }
1008
+
1009
+ /**
1010
+ * The boxes the alignment compares (docs/PARITY.md, "Alignment"), in page
1011
+ * coordinates: per item, the first visible match of `root` (and of `part`
1012
+ * inside it), else null. An item with `field` is a search field: its visible
1013
+ * box is the wrapper that draws it (the outermost ancestor of the input, or
1014
+ * the input, with a border, a background or a shadow, no taller than the
1015
+ * input by more than 24 px and no wider than 3 times it), not the input
1016
+ * inset in it.
1017
+ * @param {{ items: { key: string, root?: string, part?: string | null, field?: string }[] }} arg
1018
+ */
1019
+ export function edgesOf(arg) {
1020
+ const D = window.__parityDeep;
1021
+ const qa = (scope, sel) => {
1022
+ if (D) return D.all(scope, sel);
1023
+ try {
1024
+ return [...scope.querySelectorAll(sel)];
1025
+ } catch {
1026
+ return [];
1027
+ }
1028
+ };
1029
+ const up = (e) => (D ? D.parent(e) : e.parentElement);
1030
+ const vis = (el) => {
1031
+ const r = el.getBoundingClientRect();
1032
+ return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
1033
+ };
1034
+ const r64 = (v) => Math.round(v * 64) / 64;
1035
+ const boxOf = (el) => {
1036
+ const r = el.getBoundingClientRect();
1037
+ return { x: r64(r.x + scrollX), y: r64(r.y + scrollY), width: r64(r.width), height: r64(r.height) };
1038
+ };
1039
+ const alpha = (c) => {
1040
+ const m = /rgba?\(([^)]+)\)/.exec(c || '');
1041
+ if (!m) return c && c !== 'transparent' ? 1 : 0;
1042
+ const p = m[1].split(/[\s,/]+/).filter(Boolean);
1043
+ return p.length > 3 ? Number(p[3]) : 1;
1044
+ };
1045
+ const draws = (el) => {
1046
+ const s = getComputedStyle(el);
1047
+ for (const side of ['Top', 'Right', 'Bottom', 'Left']) if (parseFloat(s[`border${side}Width`]) > 0 && s[`border${side}Style`] !== 'none' && alpha(s[`border${side}Color`]) > 0) return true;
1048
+ return s.boxShadow !== 'none' || alpha(s.backgroundColor) > 0 || s.backgroundImage !== 'none';
1049
+ };
1050
+ /** The box a shopper sees as the field: the outermost wrapper close around the input that draws something. */
1051
+ const fieldOf = (input) => {
1052
+ const ri = input.getBoundingClientRect();
1053
+ let best = draws(input) ? input : null;
1054
+ for (let e = up(input); e && e !== document.body; e = up(e)) {
1055
+ const r = e.getBoundingClientRect();
1056
+ if (r.height > ri.height + 24 || r.width > ri.width * 3 + 8) break;
1057
+ if (draws(e)) best = e;
1058
+ }
1059
+ return best ?? input;
1060
+ };
1061
+ const out = {};
1062
+ for (const it of arg.items) {
1063
+ let el = null;
1064
+ if (it.field) {
1065
+ const input = qa(document, it.field).find(vis);
1066
+ el = input ? fieldOf(input) : null;
1067
+ } else if (it.root) {
1068
+ const root = qa(document, it.root).find(vis) ?? null;
1069
+ el = root && it.part ? (qa(root, it.part).find(vis) ?? null) : root;
1070
+ }
1071
+ out[it.key] = el ? boxOf(el) : null;
1072
+ }
1073
+ return out;
1074
+ }
1075
+
1076
+ /**
1077
+ * Before any script (addInitScript): every DOM change of the page counted
1078
+ * (`window.__parityMutations.n`: children, attributes, text), the frame
1079
+ * probe's aside: what an action changed when it did not navigate
1080
+ * (docs/PARITY.md, "Behaviour").
1081
+ */
1082
+ export function mutationsInit() {
1083
+ if (window.__parityMutations) return;
1084
+ const m = (window.__parityMutations = { n: 0 });
1085
+ const mine = (n) => !!n && n.nodeType === 1 && n.tagName === 'LBX-PARITY-PROBE';
1086
+ const start = () => {
1087
+ new MutationObserver((list) => {
1088
+ for (const r of list) {
1089
+ if (mine(r.target)) continue;
1090
+ if (r.type === 'childList' && [...r.addedNodes, ...r.removedNodes].every(mine)) continue;
1091
+ m.n++;
1092
+ }
1093
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
1094
+ };
1095
+ if (document.documentElement) start();
1096
+ else
1097
+ new MutationObserver((_, o) => {
1098
+ if (!document.documentElement) return;
1099
+ o.disconnect();
1100
+ start();
1101
+ }).observe(document, { childList: true });
1102
+ }
1103
+
1104
+ /**
1105
+ * The element an interaction acts on (docs/PARITY.md, "Behaviour"): the
1106
+ * component's instance showing the paired product (`value`: its key
1107
+ * attribute's value; else the first visible instance), the part in it (else
1108
+ * the root itself), and in that the control (a label's checkbox, a
1109
+ * wrapper's select). Kept as `window.__parityTarget`; returns what it is and
1110
+ * the ids the instance carries (its data attributes' values: the product's
1111
+ * ids on that side).
1112
+ * @param {{ root: string, part: string | null, key: { attr: string, child?: string } | null, value: string | null }} arg
1113
+ */
1114
+ export function targetOf(arg) {
1115
+ const D = window.__parityDeep;
1116
+ const qa = (scope, sel) => {
1117
+ if (D) return D.all(scope, sel);
1118
+ try {
1119
+ return [...scope.querySelectorAll(sel)];
1120
+ } catch {
1121
+ return [];
1122
+ }
1123
+ };
1124
+ const vis = (el) => {
1125
+ const r = el.getBoundingClientRect();
1126
+ return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
1127
+ };
1128
+ const keyOf = (r) => {
1129
+ if (!arg.key) return null;
1130
+ const src = arg.key.child ? (qa(r, arg.key.child)[0] ?? null) : r;
1131
+ return src ? src.getAttribute(arg.key.attr) : null;
1132
+ };
1133
+ const roots = qa(document, arg.root).filter(vis);
1134
+ const root = (arg.value != null ? roots.find((r) => keyOf(r) === arg.value) : null) ?? roots[0] ?? null;
1135
+ window.__parityTarget = null;
1136
+ if (!root) return { found: false, why: `no ${arg.root} on the page` };
1137
+ const el = arg.part ? (qa(root, arg.part).find((e) => e.getClientRects().length > 0) ?? null) : root;
1138
+ if (!el) return { found: false, why: `no ${arg.part} in ${arg.root}` };
1139
+ const CONTROL = 'select, input:not([type=hidden]), button, a[href], [role=checkbox], [role=button], [role=slider]';
1140
+ const control = el.matches(CONTROL) ? el : (qa(el, CONTROL)[0] ?? el);
1141
+ window.__parityTarget = control;
1142
+ const ids = [];
1143
+ for (const e of [root, ...qa(root, '*')].slice(0, 200))
1144
+ for (const a of e.attributes) if (/^data-/.test(a.name) && a.value && a.value.length <= 64 && /\d/.test(a.value) && !ids.includes(a.value)) ids.push(a.value);
1145
+ return {
1146
+ found: true,
1147
+ key: keyOf(root),
1148
+ tag: control.tagName.toLowerCase(),
1149
+ type: control.getAttribute('type'),
1150
+ href: control.getAttribute('href'),
1151
+ checked: 'checked' in control ? !!control.checked : null,
1152
+ options: control.tagName === 'SELECT' ? [...control.options].map((o) => ({ value: o.value, label: o.textContent.trim(), selected: o.selected })) : null,
1153
+ ids: ids.slice(0, 20),
1154
+ };
1155
+ }
1156
+
1157
+ /**
1158
+ * The state an interaction's watches read (docs/PARITY.md, "Behaviour"): per
1159
+ * watch, how many elements match, the first rendered one's text, its checked
1160
+ * state (a checkbox's, aria-checked, aria-pressed) and value; the target's
1161
+ * own checked state and value; the DOM changes so far (mutationsInit).
1162
+ * @param {{ watch: { name: string, sel: string | null }[] }} arg
1163
+ */
1164
+ export function readWatches(arg) {
1165
+ const D = window.__parityDeep;
1166
+ const qa = (sel) => {
1167
+ if (D) return D.all(document, sel);
1168
+ try {
1169
+ return [...document.querySelectorAll(sel)];
1170
+ } catch {
1171
+ return [];
1172
+ }
1173
+ };
1174
+ const checked = (e) => {
1175
+ if (!e) return null;
1176
+ if (e.tagName === 'INPUT' && /^(checkbox|radio)$/i.test(e.type)) return !!e.checked;
1177
+ if (e.hasAttribute('aria-checked')) return e.getAttribute('aria-checked') === 'true';
1178
+ if (e.hasAttribute('aria-pressed')) return e.getAttribute('aria-pressed') === 'true';
1179
+ return null;
1180
+ };
1181
+ const value = (e) => (e && (e.tagName === 'INPUT' || e.tagName === 'SELECT' || e.tagName === 'TEXTAREA') ? e.value : null);
1182
+ const out = {};
1183
+ for (const w of arg.watch) {
1184
+ if (!w.sel) {
1185
+ out[w.name] = null;
1186
+ continue;
1187
+ }
1188
+ const all = qa(w.sel);
1189
+ const e = all.find((x) => x.getClientRects().length > 0) ?? all[0] ?? null;
1190
+ out[w.name] = { count: all.length, text: e ? (e.innerText ?? e.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 200) : null, checked: checked(e), value: value(e) };
1191
+ }
1192
+ const t = window.__parityTarget;
1193
+ return { watches: out, target: t && t.isConnected ? { checked: checked(t), value: value(t) } : null, mutations: window.__parityMutations ? window.__parityMutations.n : null };
1194
+ }
1195
+
1196
+ /**
1197
+ * Our controls without a counterpart (docs/PARITY.md, "Behaviour"): in the
1198
+ * instance of each root (a component's: the paired product's, else the
1199
+ * first), the links, buttons, checkboxes, radios and selects that are none of
1200
+ * the mapped elements (`mapped`: ours of the parts theirs has too), hold none
1201
+ * and are in none. Each as a path from its root, to find it again on a fresh
1202
+ * page.
1203
+ * @param {{ items: { component: string, root: string, mapped: string[], key: { attr: string } | null, value: string | null }[], max?: number }} arg
1204
+ */
1205
+ export function strayControls(arg) {
1206
+ const D = window.__parityDeep;
1207
+ const qa = (scope, sel) => {
1208
+ if (D) return D.all(scope, sel);
1209
+ try {
1210
+ return [...scope.querySelectorAll(sel)];
1211
+ } catch {
1212
+ return [];
1213
+ }
1214
+ };
1215
+ const vis = (el) => {
1216
+ const r = el.getBoundingClientRect();
1217
+ return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
1218
+ };
1219
+ const CONTROL = 'a[href], button, [role=button], input[type=checkbox], input[type=radio], [role=checkbox], select';
1220
+ const out = [];
1221
+ for (const it of arg.items) {
1222
+ const roots = qa(document, it.root).filter(vis);
1223
+ const root = (it.value != null && it.key ? roots.find((r) => r.getAttribute(it.key.attr) === it.value) : null) ?? roots[0];
1224
+ if (!root) continue;
1225
+ const mapped = it.mapped.flatMap((s) => qa(root, s));
1226
+ const covered = (e) => mapped.some((m) => m === e || m.contains(e) || e.contains(m));
1227
+ const controls = qa(root, CONTROL).filter((e) => e.getClientRects().length > 0 && !covered(e));
1228
+ for (const e of controls.slice(0, arg.max ?? 6)) {
1229
+ const path = [];
1230
+ for (let x = e; x && x !== root; x = x.parentElement) {
1231
+ const same = x.parentElement ? [...x.parentElement.children].filter((y) => y.tagName === x.tagName) : [x];
1232
+ path.unshift(`${x.tagName.toLowerCase()}${same.length > 1 ? `:nth-of-type(${same.indexOf(x) + 1})` : ''}`);
1233
+ }
1234
+ const label = (e.getAttribute('aria-label') || e.textContent || e.getAttribute('title') || e.getAttribute('href') || '').replace(/\s+/g, ' ').trim().slice(0, 60);
1235
+ out.push({ component: it.component, path: `:scope > ${path.join(' > ')}`, tag: e.tagName.toLowerCase(), label, href: e.getAttribute('href') });
1236
+ }
1237
+ }
1238
+ return out;
1239
+ }