@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,312 @@
1
+ // The browser token of the Luigi's Box AI discovery client (./ai.ts). Its own
2
+ // chunk (lbx-token): the entry loads it at boot only when sessionStorage has
3
+ // no valid token (./token-key.ts), i.e. on a session's first page; the widgets'
4
+ // requests (lbx-core) import it.
5
+ //
6
+ // Auth: the publishable key is exchanged for a short-lived, origin-bound
7
+ // browser token (GET /browser-challenge -> proof of work -> POST
8
+ // /browser-token). The token is shared by every widget and kept in
9
+ // sessionStorage, so a multi-page site solves the challenge once per session,
10
+ // not once per page. The proof of work runs in a Web Worker (a blob worker;
11
+ // on the main thread if the site's CSP forbids that). Minting starts at boot
12
+ // (warm()), not on the first keystroke.
13
+ //
14
+ // Every discovery call carries X-Lbx-Visitor-Id: Luma's first-party
15
+ // `_lbx_vid` cookie, so requests and analytics events join on one visitor.
16
+ //
17
+ // Also runs in Node (the edge render service): there the
18
+ // storefront origin is sent explicitly and nothing touches the DOM.
19
+
20
+ import { health } from '../core/health';
21
+ import { session } from '../core/storage';
22
+ import { challengeUrl, storedToken, takeChallenge, tokenKey, type StoredToken } from './token-key';
23
+ import type { GlobalConfigType } from '../types';
24
+
25
+ export const browser = typeof document !== 'undefined';
26
+
27
+ // --- visitor id: a first-party cookie
28
+ const VID = '_lbx_vid';
29
+ let vid = '';
30
+ const newId = () =>
31
+ crypto.randomUUID?.() ?? // secure contexts only
32
+ [...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, '0')).join('');
33
+ export const visitorId = () => {
34
+ if (vid) return vid;
35
+ if (!browser) return (vid = 'lbx-ssr'); // server renders are shared, not personal
36
+ // Only an id we could have written: anything else (a cookie set by a sibling
37
+ // subdomain) is replaced, and could not even be sent as a header.
38
+ vid = /^[\w-]{1,64}$/.exec(document.cookie.match(/(?:^|;\s*)_lbx_vid=([^;]+)/)?.[1] ?? '')?.[0] ?? '';
39
+ if (!vid) {
40
+ vid = newId();
41
+ document.cookie = `${VID}=${vid}; Max-Age=${60 * 60 * 24 * 365}; Path=/; SameSite=Lax${location.protocol === 'https:' ? '; Secure' : ''}`;
42
+ }
43
+ return vid;
44
+ };
45
+
46
+ // --- proof of work: the smallest n with SHA-256(nonce + n) starting with `difficulty` zero bits
47
+ // Self-contained (no imports, no closure): its source also becomes the worker.
48
+ // Gives up at `until` (ms epoch): an abandoned solve must not keep a core busy.
49
+ // SHA-256 here, not crypto.subtle: a digest awaited per attempt costs a task
50
+ // each (a typical difficulty takes tens of milliseconds, on a listing's
51
+ // critical path at the first visit). The nonce's whole blocks are hashed once, each attempt hashes
52
+ // only its last one or two: challenge to token request 290 -> 170 ms in
53
+ // Chrome (medians of 10 cold loads, 2026-10-10; Node: 15-20 times faster).
54
+ // `slice` (on the main thread, where no worker runs): it yields every `slice` ms.
55
+ export async function solveHere(nonce: string, difficulty: number, until: number, slice = 0): Promise<number> {
56
+ // FIPS 180-4's constants, derived as it defines them: the fractional parts
57
+ // of the first primes' square roots (H) and cube roots (K).
58
+ const K = new Int32Array(64);
59
+ const H = new Int32Array(8);
60
+ for (let p = 2, i = 0; i < 64; p++) {
61
+ let q = 2;
62
+ while (q * q <= p && p % q) q++;
63
+ if (q * q <= p) continue;
64
+ if (i < 8) H[i] = (Math.sqrt(p) % 1) * 2 ** 32;
65
+ K[i++] = (Math.cbrt(p) % 1) * 2 ** 32;
66
+ }
67
+ const W = new Int32Array(64);
68
+ const r = (x: number, n: number) => (x >>> n) | (x << (32 - n));
69
+ const block = (st: Int32Array, m: Uint8Array, o: number) => {
70
+ for (let i = 0; i < 16; i++) W[i] = (m[o++] << 24) | (m[o++] << 16) | (m[o++] << 8) | m[o++];
71
+ for (let i = 16; i < 64; i++) {
72
+ const x = W[i - 15];
73
+ const y = W[i - 2];
74
+ W[i] = (r(x, 7) ^ r(x, 18) ^ (x >>> 3)) + W[i - 7] + (r(y, 17) ^ r(y, 19) ^ (y >>> 10)) + W[i - 16];
75
+ }
76
+ let [a, b, c, d, e, f, g, h] = st;
77
+ for (let i = 0; i < 64; i++) {
78
+ const t = (h + (r(e, 6) ^ r(e, 11) ^ r(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + W[i]) | 0;
79
+ const u = (r(a, 2) ^ r(a, 13) ^ r(a, 22)) + ((a & b) ^ (a & c) ^ (b & c));
80
+ h = g;
81
+ g = f;
82
+ f = e;
83
+ e = (d + t) | 0;
84
+ d = c;
85
+ c = b;
86
+ b = a;
87
+ a = (t + u) | 0;
88
+ }
89
+ st.set([st[0] + a, st[1] + b, st[2] + c, st[3] + d, st[4] + e, st[5] + f, st[6] + g, st[7] + h]);
90
+ };
91
+ const pre = new TextEncoder().encode(nonce);
92
+ const whole = pre.length & ~63;
93
+ const mid = H.slice();
94
+ for (let o = 0; o < whole; o += 64) block(mid, pre, o);
95
+ const rest = pre.length - whole;
96
+ const tail = new Uint8Array(192);
97
+ tail.set(pre.subarray(whole));
98
+ const st = new Int32Array(8);
99
+ let t0 = Date.now();
100
+ for (let n = 0; ; n++) {
101
+ if ((n & 1023) === 0 && Date.now() - t0 > (slice || 50)) {
102
+ if (Date.now() > until) throw new Error('proof of work: no answer in time');
103
+ if (slice) await new Promise((r) => setTimeout(r));
104
+ t0 = Date.now();
105
+ }
106
+ // nonce + n, padded: 0x80, zeros, its length in bits (big-endian) at the end of a block.
107
+ const digits = String(n);
108
+ let len = rest;
109
+ for (let i = 0; i < digits.length; i++) tail[len++] = digits.charCodeAt(i);
110
+ const end = (len + 72) & ~63;
111
+ tail[len] = 0x80;
112
+ tail.fill(0, len + 1, end - 4);
113
+ const bits = (pre.length + digits.length) * 8;
114
+ tail[end - 4] = bits >>> 24;
115
+ tail[end - 3] = bits >>> 16;
116
+ tail[end - 2] = bits >>> 8;
117
+ tail[end - 1] = bits;
118
+ st.set(mid);
119
+ for (let o = 0; o < end; o += 64) block(st, tail, o);
120
+ let zeros = 0;
121
+ for (let i = 0; i < 8 && zeros === 32 * i; i++) zeros += Math.clz32(st[i]);
122
+ if (zeros >= difficulty) return n;
123
+ }
124
+ }
125
+ const POW_TIMEOUT_MS = 20_000;
126
+ const solve = (nonce: string, difficulty: number): Promise<number> => {
127
+ const until = Date.now() + POW_TIMEOUT_MS;
128
+ if (!browser || typeof Worker === 'undefined') return solveHere(nonce, difficulty, until, browser ? 8 : 0);
129
+ return new Promise((resolve, reject) => {
130
+ let w: Worker | undefined;
131
+ let timer = 0;
132
+ const src = `const solve = ${solveHere.toString()};\nonmessage = async (e) => { try { postMessage(await solve(e.data[0], e.data[1], e.data[2])); } catch { postMessage(null); } };`;
133
+ const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
134
+ // Revoked once the worker is done, not right after new Worker(): WebKit
135
+ // fetches the worker's script later, and a revoked URL fails it
136
+ // (WebKitBlobResource error 1), so every Safari solved on the main thread.
137
+ const done = () => (clearTimeout(timer), w?.terminate(), URL.revokeObjectURL(url));
138
+ const here = () => (done(), solveHere(nonce, difficulty, until, 8).then(resolve, reject));
139
+ try {
140
+ w = new Worker(url);
141
+ } catch {
142
+ return here(); // CSP worker-src
143
+ }
144
+ // A worker that never answers is stopped.
145
+ timer = window.setTimeout(() => (done(), reject(new Error('proof of work: no answer in time'))), POW_TIMEOUT_MS);
146
+ w.onmessage = (e) => (done(), e.data == null ? reject(new Error('proof of work failed')) : resolve(e.data));
147
+ w.onerror = here;
148
+ w.postMessage([nonce, difficulty, until]);
149
+ });
150
+ };
151
+
152
+ type Token = StoredToken;
153
+
154
+ /** A challenge harder than this is not one we solve (it would spin a core for minutes). */
155
+ const MAX_DIFFICULTY = 24;
156
+ export const TIMEOUT_MS = 8000;
157
+ /** After a failed mint, requests fail at once for this long instead of each starting a new one. */
158
+ const MINT_BACKOFF_MS = 15_000;
159
+
160
+ /** `signal` plus a deadline: no request may leave a widget, or a server render, waiting forever. */
161
+ const within = (ms: number, signal?: AbortSignal) => {
162
+ const c = new AbortController();
163
+ const timer = setTimeout(() => c.abort(new DOMException(`no answer in ${ms} ms`, 'TimeoutError')), ms);
164
+ (timer as any).unref?.();
165
+ const stop = () => c.abort(signal!.reason);
166
+ if (signal?.aborted) stop();
167
+ else signal?.addEventListener('abort', stop, { once: true });
168
+ return {
169
+ signal: c.signal,
170
+ done: () => {
171
+ clearTimeout(timer);
172
+ signal?.removeEventListener('abort', stop);
173
+ },
174
+ };
175
+ };
176
+
177
+ /** A promise, or the abort of `signal`, whichever comes first. */
178
+ export const untilAborted = <T>(p: Promise<T>, signal?: AbortSignal) =>
179
+ !signal
180
+ ? p
181
+ : new Promise<T>((resolve, reject) => {
182
+ if (signal.aborted) return reject(signal.reason);
183
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
184
+ p.then(resolve, reject);
185
+ });
186
+
187
+ /** fetch + JSON within a deadline (the body is read under the same deadline). `started`: its fetch, already asked. */
188
+ export const fetchJson = async (url: string, init: RequestInit, ms: number, signal?: AbortSignal, started?: Promise<Response>) => {
189
+ const d = within(ms, signal);
190
+ try {
191
+ const r = await (started ? untilAborted(started, d.signal) : fetch(url, { ...init, signal: d.signal }));
192
+ const body = await untilAborted(r.text(), d.signal);
193
+ let json: any = null;
194
+ try {
195
+ json = body ? JSON.parse(body) : null;
196
+ } catch {}
197
+ return { status: r.status, ok: r.ok, json, body };
198
+ } finally {
199
+ d.done();
200
+ }
201
+ };
202
+
203
+ /** One channel's token (per key, region and server origin): shared by every request, and by main.ts's warm(). */
204
+ export interface Tokens {
205
+ get(): Promise<string>;
206
+ /** The token held now (a request's 401 compares it with the one it sent). */
207
+ current(): string | undefined;
208
+ /** Drop it (expired or revoked early): the next get() mints anew. */
209
+ clear(): void;
210
+ AUTH: string;
211
+ API: string;
212
+ originHeaders: Record<string, string>;
213
+ }
214
+ const all = new Map<string, Tokens>();
215
+ /** `origin`: the storefront origin, for server-side callers (browsers send their own). */
216
+ export const tokensFor = (g: GlobalConfigType, origin?: string): Tokens => {
217
+ const k = `${g.channelId}|${g.region ?? 'eu1'}|${origin ?? ''}`;
218
+ let t = all.get(k);
219
+ if (!t) all.set(k, (t = tokens(g, origin)));
220
+ return t;
221
+ };
222
+ /** Mint the browser token now, so the first request does not wait for it. */
223
+ export const warmToken = (g: GlobalConfigType) => void tokensFor(g).get().catch(() => {});
224
+
225
+ const tokens = (g: GlobalConfigType, origin?: string): Tokens => {
226
+ const region = g.region ?? 'eu1';
227
+ const AUTH = `https://auth.${region}.luigisbox.ai`;
228
+ const API = `https://api.${region}.luigisbox.ai`;
229
+ // Browsers set Origin themselves (and forbid setting it); a server must say which storefront it renders.
230
+ const originHeaders: Record<string, string> = !browser && origin ? { Origin: origin, Referer: `${origin}/` } : {};
231
+ const key = tokenKey(g);
232
+ let token: Token | null = browser ? storedToken(g) : null;
233
+ let minting: Promise<string> | null = null;
234
+
235
+ const mint = async (retries = 1): Promise<Token> => {
236
+ const url = challengeUrl(g);
237
+ // The one the entry asked while this chunk loaded (the first try only: a retry needs a new one).
238
+ const ch = await fetchJson(url, { headers: originHeaders }, TIMEOUT_MS, undefined, browser && retries ? takeChallenge(url) : undefined);
239
+ if (!ch.ok) throw new Error(`browser-challenge: HTTP ${ch.status}`);
240
+ const { nonce, difficulty } = ch.json ?? {};
241
+ if (typeof nonce !== 'string' || !(difficulty >= 0 && difficulty <= MAX_DIFFICULTY)) throw new Error('browser-challenge: unexpected challenge');
242
+ const solution = await solve(nonce, difficulty);
243
+ const r = await fetchJson(
244
+ `${AUTH}/browser-token`,
245
+ {
246
+ method: 'POST',
247
+ headers: { ...originHeaders, 'Content-Type': 'application/json' },
248
+ body: JSON.stringify({
249
+ api_key: g.publishableKey,
250
+ pow_nonce: nonce,
251
+ pow_solution: solution,
252
+ channel_id: g.channelId,
253
+ // One token for discovery and analytics events. Any other audience mints a token without
254
+ // permissions (docs.luigisbox.ai/authentication/browser-tokens): `/events` did, every event a 403.
255
+ audience: [`${API}/discovery`, `${API}/analytics`],
256
+ }),
257
+ },
258
+ TIMEOUT_MS,
259
+ );
260
+ if (!r.ok) {
261
+ const detail = r.json?.detail;
262
+ if (retries > 0 && detail === 'Nonce expired') return mint(retries - 1);
263
+ throw new Error(`browser-token: HTTP ${r.status} ${typeof detail === 'string' ? detail : ''}`);
264
+ }
265
+ const { access_token, expires_in } = r.json ?? {};
266
+ if (typeof access_token !== 'string' || !(expires_in > 30)) throw new Error('browser-token: unexpected response');
267
+ return { token: access_token, exp: Date.now() + (expires_in - 30) * 1000 };
268
+ };
269
+
270
+ // Tokens live minutes. A server (the edge render service) renders inside a
271
+ // tight budget, so it re-mints ahead of expiry instead of on the next
272
+ // request; a browser mints on demand (an idle tab should not keep solving).
273
+ let refresh: ReturnType<typeof setTimeout> | undefined;
274
+ const later = (ms: number) => {
275
+ clearTimeout(refresh);
276
+ refresh = setTimeout(() => void renew(true).catch(() => {}), ms);
277
+ (refresh as any).unref?.();
278
+ };
279
+ let failed: { at: number; e: unknown } | null = null;
280
+ const renew = (background = false): Promise<string> => {
281
+ if (!background && failed && Date.now() - failed.at < MINT_BACKOFF_MS) return Promise.reject(failed.e);
282
+ return (minting ??= mint()
283
+ .then(
284
+ (t) => {
285
+ token = t;
286
+ failed = null;
287
+ if (browser) session.set(key, t);
288
+ else later(Math.max(10_000, (t.exp - Date.now()) * 0.8)); // the current token keeps serving until the new one is in
289
+ return t.token;
290
+ },
291
+ (e) => {
292
+ failed = { at: Date.now(), e };
293
+ if (!browser) later(MINT_BACKOFF_MS); // a server keeps trying in the background
294
+ throw e;
295
+ },
296
+ )
297
+ .finally(() => (minting = null)));
298
+ };
299
+ const getToken = (): Promise<string> => (token && token.exp > Date.now() ? Promise.resolve(token.token) : renew());
300
+
301
+ return {
302
+ get: getToken,
303
+ current: () => token?.token,
304
+ clear: () => {
305
+ token = null;
306
+ if (browser) session.set(key, undefined);
307
+ },
308
+ AUTH,
309
+ API,
310
+ originHeaders,
311
+ };
312
+ };
@@ -0,0 +1,60 @@
1
+ // Keyboard helpers of the listing (docs/ACCESSIBILITY.md): focus traps,
2
+ // activation by key, keyboard-or-pointer. Only the listing uses them: in its
3
+ // chunk (build/vite.mjs), not lbx-core, which every widget loads.
4
+
5
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"]), [contenteditable=""], [contenteditable=true]';
6
+
7
+ /**
8
+ * What Tab reaches in `root`, in order. Reads computed visibility: only in
9
+ * response to the visitor (a key, opening a drawer), never after our renders.
10
+ */
11
+ export const focusables = (root: Element) =>
12
+ [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => !el.closest('[inert], [hidden]') && (!el.checkVisibility || el.checkVisibility({ checkVisibilityCSS: true } as any)));
13
+
14
+ /**
15
+ * Tab and Shift+Tab cycle inside `root` (a modal drawer): true when the key
16
+ * was ours. Every step is ours, not only the wrap at the ends: the browser's
17
+ * own next stop may lie outside `root`. Safari's Tab, by default, skips links,
18
+ * buttons and checkboxes (Option+Tab reaches them), so from the drawer's close
19
+ * button it went to the next text field of the page, out of the dialog.
20
+ */
21
+ export const trapTab = (e: KeyboardEvent, root: Element) => {
22
+ if (e.key !== 'Tab') return false;
23
+ e.preventDefault();
24
+ const all = focusables(root);
25
+ if (!all.length) return true;
26
+ const n = all.length;
27
+ const i = all.indexOf(document.activeElement as HTMLElement);
28
+ all[i < 0 ? (e.shiftKey ? n - 1 : 0) : (i + (e.shiftKey ? n - 1 : 1)) % n].focus();
29
+ return true;
30
+ };
31
+
32
+ /**
33
+ * Widget roles a template may give an element that is not natively
34
+ * activatable (a `<div role=button tabindex=0>` copied from a site's markup):
35
+ * Enter and Space then click it, as they would a button (WCAG 2.1.1). A link
36
+ * keeps its Enter; only Space is added.
37
+ */
38
+ const ACTIVATABLE = '[role=button], [role=checkbox], [role=radio], [role=switch], [role=option], [role=menuitem], [role=menuitemradio], [role=menuitemcheckbox], [role=tab]';
39
+ export const activateByKey = (e: KeyboardEvent, within: Element) => {
40
+ if ((e.key !== 'Enter' && e.key !== ' ') || e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return false;
41
+ const el = (e.target as Element).closest?.(ACTIVATABLE);
42
+ if (!el || !within.contains(el) || el !== e.target) return false;
43
+ const tag = el.localName;
44
+ if (tag === 'button' || tag === 'input' || tag === 'select' || tag === 'textarea' || tag === 'summary') return false;
45
+ if (tag === 'a' && el.hasAttribute('href') && e.key === 'Enter') return false;
46
+ e.preventDefault();
47
+ (el as HTMLElement).click();
48
+ return true;
49
+ };
50
+
51
+ /**
52
+ * A click that came from the keyboard (Enter or Space on a link or button,
53
+ * or our activateByKey). `detail` is 0 for those in Chromium and WebKit, but
54
+ * Firefox reports 1 for Enter on a link: there the click is a PointerEvent
55
+ * whose pointerType is '' when no pointer made it (a mouse, pen or touch
56
+ * names itself). A browser whose click is a plain MouseEvent has no
57
+ * pointerType: `detail` alone.
58
+ */
59
+ export const byKeyboard = (e: Event) => (e as MouseEvent).detail === 0 || (e as PointerEvent).pointerType === '';
60
+
@@ -0,0 +1,160 @@
1
+ // Accessibility helpers shared by the widgets (docs/ACCESSIBILITY.md): live
2
+ // regions, attributes borrowed on the host's elements, focus moves and traps,
3
+ // colour contrast, and the high-contrast mode. Widget chunks only: never the
4
+ // entry, never the server renderer.
5
+
6
+ import { layered, setCss } from './styles';
7
+
8
+ /** Visually hidden, still read by screen readers (inline: no stylesheet needed). */
9
+ export const SR_ONLY =
10
+ 'position:absolute!important;width:1px!important;height:1px!important;margin:-1px!important;padding:0!important;border:0!important;overflow:hidden!important;clip-path:inset(50%)!important;white-space:nowrap!important;';
11
+
12
+ /**
13
+ * A polite status region (WCAG 4.1.3). `say(text)` announces it once the
14
+ * caller has been quiet for `delay` ms (a burst of renders is one message);
15
+ * the same text twice is announced twice (it is cleared first).
16
+ */
17
+ export const liveRegion = () => {
18
+ const el = document.createElement('div');
19
+ el.setAttribute('role', 'status');
20
+ el.setAttribute('aria-live', 'polite');
21
+ el.setAttribute('aria-atomic', 'true');
22
+ el.style.cssText = SR_ONLY;
23
+ let timer = 0;
24
+ return {
25
+ el,
26
+ say(text: string, delay = 150) {
27
+ clearTimeout(timer);
28
+ el.textContent = '';
29
+ // In a later task: a region whose text is replaced in the same task as it appears is not announced.
30
+ timer = window.setTimeout(() => void (el.textContent = text), delay);
31
+ },
32
+ clear() {
33
+ clearTimeout(timer);
34
+ el.textContent = '';
35
+ },
36
+ };
37
+ };
38
+
39
+ /**
40
+ * Attributes we put on an element that is not ours (the host's search input).
41
+ * `restore()` puts back what was there, except where the host has changed an
42
+ * attribute since we set it: then the host's value is the current truth and
43
+ * stays (a framework that manages its own ARIA keeps it).
44
+ */
45
+ export const borrow = (el: Element) => {
46
+ const saved = new Map<string, { was: string | null; ours: string | null }>();
47
+ return {
48
+ el,
49
+ set(name: string, value: string | null) {
50
+ const now = el.getAttribute(name);
51
+ const s = saved.get(name);
52
+ if (!s) saved.set(name, { was: now, ours: value });
53
+ else s.ours = value;
54
+ if (value == null) now != null && el.removeAttribute(name);
55
+ else if (now !== value) el.setAttribute(name, value);
56
+ },
57
+ restore() {
58
+ for (const [name, { was, ours }] of saved) {
59
+ if (el.getAttribute(name) !== ours) continue; // the host's own write since ours
60
+ if (was == null) el.removeAttribute(name);
61
+ else el.setAttribute(name, was);
62
+ }
63
+ saved.clear();
64
+ },
65
+ };
66
+ };
67
+
68
+ /**
69
+ * Its palette, as custom properties on the root: every rule of ours reads
70
+ * them with its normal value as the fallback (`var(--lbx-c-line, #ccc)`), and
71
+ * so can an integration's CSS. On with the visitor's OS setting
72
+ * (`prefers-contrast: more`), unless they switched ours off, or with our
73
+ * toggle (`data-lbx-contrast` on <html>). Forced colours (Windows contrast
74
+ * themes) are the browser's: our CSS only keeps state visible there (system
75
+ * colours, outlines). Pure CSS: no script runs for the OS setting.
76
+ */
77
+ // Written minified, a rule per line (the build joins the lines): every widget's.
78
+ const PALETTE =
79
+ '--lbx-c:1;--lbx-c-fg:#000;--lbx-c-bg:#fff;--lbx-c-line:#000;--lbx-c-muted:#000;--lbx-c-accent:#000;' +
80
+ '--lbx-c-sel-fg:#fff;--lbx-c-sel-bg:#000;--lbx-c-ring:3px;--lbx-c-border:2px;--lbx-c-dim:1;--lbx-c-underline:underline';
81
+ const OURS = ':where(lbx-search,lbx-plp,lbx-reco,lbx-reco-pop,[data-lbx="ac-overlay"]) :focus-visible';
82
+ export const CONTRAST_CSS =
83
+ `\n@media (prefers-contrast:more){:root:not([data-lbx-contrast="off"]){${PALETTE}}}` +
84
+ `:root[data-lbx-contrast="more"]{${PALETTE}}` +
85
+ `.lbx-sr-only:not(:focus):not(:focus-within){${SR_ONLY}}` +
86
+ '.lbx-contrast-toggle{font:inherit;margin:0 0 8px;padding:4px 10px;min-height:24px;border:2px solid currentColor;border-radius:4px;background:Canvas;color:CanvasText;cursor:pointer}' +
87
+ `@media (prefers-contrast:no-preference){:root:not([data-lbx-contrast="more"]) .lbx-contrast-toggle:not(:focus){${SR_ONLY}}}` +
88
+ `${OURS}{outline:var(--lbx-c-ring,2px) solid;outline-offset:2px}` +
89
+ `@media (forced-colors:active){${OURS}{outline:3px solid Highlight!important;outline-offset:2px!important}}` +
90
+ `@media (prefers-contrast:more){:root:not([data-lbx-contrast="off"]) ${OURS}{outline:3px solid!important;outline-offset:2px!important}}` +
91
+ `:root[data-lbx-contrast="more"] ${OURS}{outline:3px solid!important;outline-offset:2px!important}\n`;
92
+
93
+ const KEY = 'lbx:contrast';
94
+ const stored = () => {
95
+ try {
96
+ const v = localStorage.getItem(KEY);
97
+ return v === 'more' || v === 'off' ? v : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ };
102
+ const more = () => (typeof matchMedia === 'function' ? matchMedia('(prefers-contrast: more)') : null);
103
+ const prefersMore = () => !!more()?.matches;
104
+
105
+ /** The mode's state: the visitor's toggle, else their OS setting ('more'), else none. */
106
+ const state = () => stored() ?? (prefersMore() ? 'more' : null);
107
+ /** The high-contrast mode is on. */
108
+ export const contrastOn = () => state() === 'more';
109
+
110
+ /**
111
+ * On <html>, for integrations' CSS: `data-lbx-contrast="more"` while the mode
112
+ * is on (the toggle, or the OS setting: one selector for both), `"off"` when
113
+ * the visitor switched it off against their OS setting.
114
+ */
115
+ const apply = () => {
116
+ const v = state();
117
+ const html = document.documentElement;
118
+ if (!v) html.removeAttribute('data-lbx-contrast');
119
+ else if (html.getAttribute('data-lbx-contrast') !== v) html.setAttribute('data-lbx-contrast', v);
120
+ for (const b of document.querySelectorAll('.lbx-contrast-toggle')) b.setAttribute('aria-pressed', String(v === 'more'));
121
+ };
122
+ let watching = false;
123
+ /** The mode's sheet and state, applied (every widget, at its start), and kept in step with the OS setting. */
124
+ export const useContrast = () => {
125
+ setCss('lbx:contrast', layered(CONTRAST_CSS));
126
+ if (!watching) {
127
+ watching = true;
128
+ more()?.addEventListener?.('change', apply);
129
+ }
130
+ apply();
131
+ };
132
+
133
+ /** Switch it (our toggle): remembered for this browser; back to "as the OS says" when that matches. */
134
+ export const setContrast = (on: boolean) => {
135
+ const v = on === prefersMore() ? null : on ? 'more' : 'off';
136
+ try {
137
+ if (v) localStorage.setItem(KEY, v);
138
+ else localStorage.removeItem(KEY);
139
+ } catch {}
140
+ apply();
141
+ };
142
+
143
+ /**
144
+ * Modelled on the previous-generation Luma's switch (`_global.contrastToggle`): a button that turns the
145
+ * mode on and off. Visually hidden until focused unless the visitor's OS asks
146
+ * for a contrast preference or the mode is on (as that one was), so a
147
+ * keyboard user who reaches it always sees it.
148
+ */
149
+ export const contrastToggle = (label: string) => {
150
+ const b = document.createElement('button');
151
+ b.type = 'button';
152
+ b.className = 'lbx-contrast-toggle';
153
+ b.textContent = label;
154
+ b.setAttribute('aria-pressed', String(contrastOn()));
155
+ b.addEventListener('click', (e) => {
156
+ e.stopPropagation();
157
+ setContrast(!contrastOn());
158
+ });
159
+ return b;
160
+ };
@@ -0,0 +1,6 @@
1
+ // The page's state when the entry ran, for code that arrives later in a chunk
2
+ // (core/reconciler.ts, in lbx-place): a chunk's own module code runs when the
3
+ // chunk arrives, which may be after `load` even where the entry was not.
4
+
5
+ /** Booting after `load` (a script the host injected late): the framework has hydrated already. */
6
+ export const lateBoot = typeof document !== 'undefined' && document.readyState === 'complete';
@@ -0,0 +1,42 @@
1
+ // Colour contrast (WCAG 1.4.3 / 1.4.11): the autocomplete's house design reads
2
+ // the site's colours with it. Only the autocomplete uses it: in its chunk
3
+ // (build/vite.mjs), not lbx-core, which every widget loads.
4
+
5
+ /** rgb()/rgba() (as getComputedStyle reports colours) or #rgb/#rrggbb → [r, g, b, a]; null otherwise. */
6
+ export const parseColor = (c: string): [number, number, number, number] | null => {
7
+ const s = c.trim().toLowerCase();
8
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(s);
9
+ if (hex) {
10
+ const h = hex[1].length === 3 ? [...hex[1]].map((x) => x + x).join('') : hex[1];
11
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), 1];
12
+ }
13
+ const m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)$/.exec(s);
14
+ if (!m) return null;
15
+ const a = m[4] == null ? 1 : m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
16
+ return [+m[1], +m[2], +m[3], a];
17
+ };
18
+
19
+ const luminance = ([r, g, b]: number[]) => {
20
+ const ch = (v: number) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
21
+ return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b);
22
+ };
23
+
24
+ /** A colour's relative luminance (0 black .. 1 white); null for one we cannot read. */
25
+ export const luminanceOf = (c: string) => {
26
+ const p = parseColor(c);
27
+ return p ? luminance(p) : null;
28
+ };
29
+
30
+ /** WCAG contrast ratio of two colours (a translucent foreground over the background); 1 for one we cannot read. */
31
+ export const contrastRatio = (fg: string, bg: string) => {
32
+ const f = parseColor(fg);
33
+ const b = parseColor(bg);
34
+ if (!f || !b) return 1;
35
+ const over = f.slice(0, 3).map((x, i) => x * f[3] + b[i] * (1 - f[3]));
36
+ const [l1, l2] = [luminance(over), luminance(b)].sort((x, y) => y - x);
37
+ return (l1 + 0.05) / (l2 + 0.05);
38
+ };
39
+
40
+ /** `color` when it reads on `bg` (at least `min`: 4.5 for text), else `fallback`. */
41
+ export const readable = (color: string, bg: string, fallback: string, min = 4.5) => (contrastRatio(color, bg) >= min ? color : fallback);
42
+