@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.
- package/LICENSE.md +90 -0
- package/README.md +615 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +507 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +150 -0
- package/docs/ACCESSIBILITY.md +501 -0
- package/docs/INTEGRATION-PERFORMANCE.md +213 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +312 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +126 -0
- package/src/core/kept.ts +25 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/tiles.ts +137 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +493 -0
- package/src/types.ts +778 -0
- package/src/widgets/autocomplete/index.ts +1387 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1772 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +261 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- package/tsconfig.json +14 -0
package/src/api/token.ts
ADDED
|
@@ -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
|
+
|
package/src/core/a11y.ts
ADDED
|
@@ -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
|
+
|