@luigisbox/luma 0.0.0-stage → 0.1.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 (127) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +488 -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 +498 -0
  16. package/chrome/run.mjs +145 -0
  17. package/docs/ACCESSIBILITY.md +502 -0
  18. package/docs/INTEGRATION-PERFORMANCE.md +214 -0
  19. package/docs/PARITY.md +946 -0
  20. package/footprint/model.mjs +184 -0
  21. package/footprint/run.mjs +331 -0
  22. package/package.json +58 -4
  23. package/parity/alignment.mjs +109 -0
  24. package/parity/archive.mjs +223 -0
  25. package/parity/args.mjs +137 -0
  26. package/parity/behaviour.mjs +379 -0
  27. package/parity/compare.mjs +985 -0
  28. package/parity/engine.mjs +848 -0
  29. package/parity/hints.mjs +132 -0
  30. package/parity/hold.mjs +54 -0
  31. package/parity/init.mjs +703 -0
  32. package/parity/map.mjs +640 -0
  33. package/parity/owner.mjs +170 -0
  34. package/parity/pairing.mjs +110 -0
  35. package/parity/pixels.mjs +33 -0
  36. package/parity/probe.mjs +1239 -0
  37. package/parity/report.mjs +236 -0
  38. package/parity/run.mjs +267 -0
  39. package/snippet/early-hold.js +64 -0
  40. package/snippet/early-hold.min.js +1 -0
  41. package/src/api/ai.ts +98 -0
  42. package/src/api/index.ts +87 -0
  43. package/src/api/token-key.ts +42 -0
  44. package/src/api/token.ts +311 -0
  45. package/src/core/a11y-keys.ts +60 -0
  46. package/src/core/a11y.ts +160 -0
  47. package/src/core/boot-state.ts +6 -0
  48. package/src/core/color.ts +42 -0
  49. package/src/core/context.ts +108 -0
  50. package/src/core/early-rules.ts +96 -0
  51. package/src/core/entries.ts +25 -0
  52. package/src/core/fold.ts +7 -0
  53. package/src/core/health.ts +26 -0
  54. package/src/core/hold.ts +74 -0
  55. package/src/core/i18n.ts +66 -0
  56. package/src/core/image-load.ts +102 -0
  57. package/src/core/image-tags.ts +26 -0
  58. package/src/core/images.ts +170 -0
  59. package/src/core/integration.ts +120 -0
  60. package/src/core/listings.ts +100 -0
  61. package/src/core/locales.ts +14 -0
  62. package/src/core/own.ts +49 -0
  63. package/src/core/page.ts +14 -0
  64. package/src/core/prefetch.ts +88 -0
  65. package/src/core/recommenders.ts +166 -0
  66. package/src/core/reconciler.ts +397 -0
  67. package/src/core/result.ts +73 -0
  68. package/src/core/routes.ts +115 -0
  69. package/src/core/scheduler.ts +83 -0
  70. package/src/core/sense.ts +107 -0
  71. package/src/core/speculation.ts +59 -0
  72. package/src/core/stash.ts +38 -0
  73. package/src/core/storage.ts +45 -0
  74. package/src/core/styles.ts +106 -0
  75. package/src/core/support.ts +12 -0
  76. package/src/core/takeover.ts +220 -0
  77. package/src/core/templates.ts +74 -0
  78. package/src/core/turbo.ts +64 -0
  79. package/src/core/url.ts +164 -0
  80. package/src/core/widget-inits.ts +40 -0
  81. package/src/env.d.ts +3 -0
  82. package/src/lit.ts +11 -0
  83. package/src/locales/cs.json +94 -0
  84. package/src/locales/de.json +88 -0
  85. package/src/locales/en.json +88 -0
  86. package/src/main.ts +443 -0
  87. package/src/types.ts +760 -0
  88. package/src/widgets/autocomplete/index.ts +1367 -0
  89. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  90. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  91. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  92. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  93. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  94. package/src/widgets/listing/element.ts +1769 -0
  95. package/src/widgets/listing/flow.ts +30 -0
  96. package/src/widgets/listing/instant.ts +133 -0
  97. package/src/widgets/listing/intent.ts +193 -0
  98. package/src/widgets/listing/query.ts +298 -0
  99. package/src/widgets/listing/stash.ts +97 -0
  100. package/src/widgets/listing/state.ts +131 -0
  101. package/src/widgets/listing/view.ts +257 -0
  102. package/src/widgets/plp/index.ts +11 -0
  103. package/src/widgets/plp/route.ts +263 -0
  104. package/src/widgets/recommender/index.ts +250 -0
  105. package/src/widgets/recommender/style-recommender.css +26 -0
  106. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  107. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  108. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  109. package/src/widgets/search/assets.ts +7 -0
  110. package/src/widgets/search/first.ts +15 -0
  111. package/src/widgets/search/form-url.ts +59 -0
  112. package/src/widgets/search/in-place.ts +226 -0
  113. package/src/widgets/search/index.ts +16 -0
  114. package/src/widgets/search/range.ts +260 -0
  115. package/src/widgets/search/route.ts +32 -0
  116. package/src/widgets/search/speculate.ts +202 -0
  117. package/src/widgets/search/style-search.css +65 -0
  118. package/src/widgets/search/template-search-box.ts +17 -0
  119. package/src/widgets/search/template-search-facets-default.ts +73 -0
  120. package/src/widgets/search/template-search-header.ts +14 -0
  121. package/src/widgets/search/template-search-pagination.ts +12 -0
  122. package/src/widgets/search/template-search-range-default.ts +48 -0
  123. package/src/widgets/search/template-search-result-default.ts +13 -0
  124. package/src/widgets/search/template-search-results-default.ts +8 -0
  125. package/tasks/model.mjs +116 -0
  126. package/tasks/run.mjs +305 -0
  127. package/tsconfig.json +14 -0
package/src/api/ai.ts ADDED
@@ -0,0 +1,98 @@
1
+ // Luigi's Box AI discovery client: the requests (search, facets, recommend).
2
+ // Authentication (publishable key -> browser token, the visitor id) is
3
+ // ./token.ts, in the entry so that every page mints the token at boot.
4
+ //
5
+ // Every discovery call carries X-Lbx-Visitor-Id: Luma's first-party
6
+ // `_lbx_vid` cookie, so requests and analytics events join on one visitor.
7
+ //
8
+ // Also runs in Node (the edge render service): there the
9
+ // storefront origin is sent explicitly and nothing touches the DOM.
10
+
11
+ import { health } from '../core/health';
12
+ import type { GlobalConfigType } from '../types';
13
+ import type { Api, FacetData, Hit, Page } from '.';
14
+ import { fetchJson, TIMEOUT_MS, tokensFor, untilAborted, visitorId } from './token';
15
+
16
+ export { untilAborted };
17
+
18
+ export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
19
+ const tk = tokensFor(g, origin);
20
+ const { API, originHeaders } = tk;
21
+ const getToken = tk.get;
22
+
23
+ // A 401 re-mints, but not on every request when a fresh token is refused too
24
+ // (an origin the key does not allow): at most once a minute. A request whose
25
+ // token was already replaced (a parallel one re-minted) just retries once.
26
+ let reminted = -Infinity;
27
+ const post = async (path: string, params: Record<string, string>, body: unknown, signal?: AbortSignal, retried = false): Promise<any> => {
28
+ health.apiCalls++;
29
+ // The caller's signal bounds the wait for a token too (a server render's budget).
30
+ const t = await untilAborted(getToken(), signal);
31
+ const qs = new URLSearchParams(params).toString();
32
+ const r = await fetchJson(
33
+ `${API}/discovery/v1/${path}?${qs}`,
34
+ {
35
+ method: 'POST',
36
+ headers: { ...originHeaders, Authorization: `Bearer ${t}`, 'X-Lbx-Visitor-Id': visitorId(), 'Content-Type': 'application/json' },
37
+ body: JSON.stringify(body),
38
+ },
39
+ TIMEOUT_MS,
40
+ signal,
41
+ );
42
+ if (r.status === 401 && !retried) {
43
+ if (tk.current() !== t) return post(path, params, body, signal, true);
44
+ if (Date.now() - reminted > 60e3) {
45
+ // Expired or revoked early: mint a new one.
46
+ reminted = Date.now();
47
+ tk.clear();
48
+ return post(path, params, body, signal, true);
49
+ }
50
+ }
51
+ if (!r.ok) throw new Error(`${path}: HTTP ${r.status} ${r.body.slice(0, 300)}`);
52
+ if (r.json == null) throw new Error(`${path}: not JSON`);
53
+ return r.json;
54
+ };
55
+
56
+ /** Drops undefined fields: unknown or empty ones are a 422. */
57
+ const compact = (o: Record<string, unknown>) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
58
+ const nonEmpty = (f?: Record<string, unknown>) => (f && Object.keys(f).length ? f : undefined);
59
+
60
+ return {
61
+ warm: () => void getToken().catch(() => {}),
62
+
63
+ search: async (r, signal): Promise<Page> => {
64
+ const body = compact({
65
+ type: r.type,
66
+ size: r.size,
67
+ query: r.endpoint === 'search' ? (r.query ?? '') : undefined,
68
+ collection_filters: r.endpoint === 'collections' ? nonEmpty(r.scope) : undefined,
69
+ filters: nonEmpty(r.filters),
70
+ sort: r.sort,
71
+ cursor: r.cursor || undefined,
72
+ return_fields: r.fields?.length ? r.fields : undefined,
73
+ });
74
+ const j = await post(r.endpoint, { channel_id: g.channelId, surface_id: r.surface }, body, signal);
75
+ return { hits: j.hits ?? [], total: j.total ?? j.total_approx ?? 0, guid: j.guid, cursor: j.next_page_cursor ?? null, status: j.pagination_status ?? 'exhausted' };
76
+ },
77
+
78
+ facets: async (guid, facets, signal): Promise<Record<string, FacetData>> =>
79
+ (await post('facets', { channel_id: g.channelId }, { guid, facets: facets.map((f) => compact(f)) }, signal)).facets ?? {},
80
+
81
+ recommend: async (r, signal): Promise<Hit[]> =>
82
+ (
83
+ await post(
84
+ 'recommender',
85
+ { channel_id: g.channelId, surface_id: r.surface },
86
+ compact({
87
+ type: r.type,
88
+ size: r.size,
89
+ anchor_ids: r.anchors.length ? r.anchors.slice(0, 50) : undefined,
90
+ exclude_ids: r.exclude?.length ? r.exclude.slice(0, 50) : undefined,
91
+ filters: nonEmpty(r.filters),
92
+ return_fields: r.fields?.length ? r.fields : undefined,
93
+ }),
94
+ signal,
95
+ )
96
+ ).hits ?? [],
97
+ };
98
+ };
@@ -0,0 +1,87 @@
1
+ // The data API behind every widget: Luigi's Box AI discovery
2
+ // (api.<region>.luigisbox.ai/discovery/v1). Widgets only see this interface;
3
+ // the implementation (./ai.ts) owns authentication (publishable key ->
4
+ // browser token), the visitor id and the wire format.
5
+
6
+ import type { GlobalConfigType } from '../types';
7
+ import { aiApi } from './ai';
8
+
9
+ /** A catalog object as discovery returns it: flat, `@id` and `@type` always present. */
10
+ export interface Hit {
11
+ '@id': string;
12
+ '@type': string;
13
+ [field: string]: any;
14
+ }
15
+
16
+ /** A discovery filter tree: condition and group nodes. */
17
+ export type FilterNode = Record<string, unknown>;
18
+
19
+ export interface Sort {
20
+ field: string;
21
+ direction: 'asc' | 'desc';
22
+ }
23
+
24
+ /** One page of /search or /collections. */
25
+ export interface Page {
26
+ hits: Hit[];
27
+ /** Exact when known, else the API's approximation. */
28
+ total: number;
29
+ /** Identifies the result set; /facets takes it. */
30
+ guid: string;
31
+ /** Cursor of the next page; null when there is none (see status). */
32
+ cursor: string | null;
33
+ /** more | exhausted | unsupported | unavailable */
34
+ status: string;
35
+ }
36
+
37
+ export interface ListingRequest {
38
+ /** search: a query; collections: a scope (collection_filters). */
39
+ endpoint: 'search' | 'collections';
40
+ surface: string;
41
+ type: string;
42
+ query?: string;
43
+ scope?: FilterNode;
44
+ filters?: FilterNode;
45
+ sort?: Sort;
46
+ size: number;
47
+ /** From the previous page; every other field must be unchanged. */
48
+ cursor?: string;
49
+ fields?: string[];
50
+ }
51
+
52
+ export interface FacetData {
53
+ field: string;
54
+ type: 'terms' | 'range' | 'hierarchy' | string;
55
+ values?: any[];
56
+ min?: number;
57
+ max?: number;
58
+ }
59
+
60
+ export interface RecoRequest {
61
+ surface: string;
62
+ type: string;
63
+ /** Typed identities ("product/<id>") the slate is about; [] = none. */
64
+ anchors: string[];
65
+ exclude?: string[];
66
+ size: number;
67
+ filters?: FilterNode;
68
+ fields?: string[];
69
+ }
70
+
71
+ export interface Api {
72
+ search(r: ListingRequest, signal?: AbortSignal): Promise<Page>;
73
+ /** Facets of a result set (its guid): values, no counts. */
74
+ facets(guid: string, facets: { field: string; size?: number }[], signal?: AbortSignal): Promise<Record<string, FacetData>>;
75
+ recommend(r: RecoRequest, signal?: AbortSignal): Promise<Hit[]>;
76
+ /** Mint the browser token now, so the first request does not wait for it. */
77
+ warm(): void;
78
+ }
79
+
80
+ const apis = new Map<string, Api>();
81
+ /** `origin`: the storefront origin, for server-side callers (browsers send their own). */
82
+ export const getApi = (g: GlobalConfigType, origin?: string): Api => {
83
+ const k = `${g.channelId}|${g.region ?? 'eu1'}|${origin ?? ''}`;
84
+ let api = apis.get(k);
85
+ if (!api) apis.set(k, (api = aiApi(g, origin)));
86
+ return api;
87
+ };
@@ -0,0 +1,42 @@
1
+ // Where the browser token is kept (sessionStorage), and whether a fresh one is
2
+ // there: the entry asks this at boot and loads the minting code (./token.ts,
3
+ // its own chunk) only when there is none. No DOM.
4
+
5
+ import { session } from '../core/storage';
6
+ import type { GlobalConfigType } from '../types';
7
+
8
+ export interface StoredToken {
9
+ token: string;
10
+ /** ms epoch; refreshed 30 s early. */
11
+ exp: number;
12
+ }
13
+
14
+ /** Per key too: a rotated publishable key must not keep using a stored token of the old one. */
15
+ export const tokenKey = (g: GlobalConfigType) => `lbx:tok:${g.publishableKey}:${g.channelId}:${g.region ?? 'eu1'}`;
16
+
17
+ /** The stored token of this channel while it is valid. */
18
+ export const storedToken = (g: GlobalConfigType): StoredToken | null => {
19
+ const t = session.get<StoredToken>(tokenKey(g));
20
+ return t && t.exp > Date.now() ? t : null;
21
+ };
22
+
23
+ const challenges = new Map<string, Promise<Response>>();
24
+ /** The browser challenge of this channel's key. */
25
+ export const challengeUrl = (g: GlobalConfigType) => `https://auth.${g.region ?? 'eu1'}.luigisbox.ai/browser-challenge?api_key=${encodeURIComponent(g.publishableKey)}`;
26
+ /**
27
+ * The challenge asked by the entry (main.ts warm()) while the minting code,
28
+ * its own chunk, loads: the mint starts a round trip earlier. Taken once by
29
+ * ./token.ts (a challenge expires in seconds).
30
+ */
31
+ export const askChallenge = (g: GlobalConfigType) => {
32
+ const url = challengeUrl(g);
33
+ if (challenges.has(url)) return;
34
+ const p = fetch(url);
35
+ p.catch(() => {}); // the mint that takes it reports a failure
36
+ challenges.set(url, p);
37
+ };
38
+ export const takeChallenge = (url: string) => {
39
+ const p = challenges.get(url);
40
+ challenges.delete(url);
41
+ return p;
42
+ };
@@ -0,0 +1,311 @@
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.
254
+ audience: [`${API}/discovery`, `${API}/events`],
255
+ }),
256
+ },
257
+ TIMEOUT_MS,
258
+ );
259
+ if (!r.ok) {
260
+ const detail = r.json?.detail;
261
+ if (retries > 0 && detail === 'Nonce expired') return mint(retries - 1);
262
+ throw new Error(`browser-token: HTTP ${r.status} ${typeof detail === 'string' ? detail : ''}`);
263
+ }
264
+ const { access_token, expires_in } = r.json ?? {};
265
+ if (typeof access_token !== 'string' || !(expires_in > 30)) throw new Error('browser-token: unexpected response');
266
+ return { token: access_token, exp: Date.now() + (expires_in - 30) * 1000 };
267
+ };
268
+
269
+ // Tokens live minutes. A server (the edge render service) renders inside a
270
+ // tight budget, so it re-mints ahead of expiry instead of on the next
271
+ // request; a browser mints on demand (an idle tab should not keep solving).
272
+ let refresh: ReturnType<typeof setTimeout> | undefined;
273
+ const later = (ms: number) => {
274
+ clearTimeout(refresh);
275
+ refresh = setTimeout(() => void renew(true).catch(() => {}), ms);
276
+ (refresh as any).unref?.();
277
+ };
278
+ let failed: { at: number; e: unknown } | null = null;
279
+ const renew = (background = false): Promise<string> => {
280
+ if (!background && failed && Date.now() - failed.at < MINT_BACKOFF_MS) return Promise.reject(failed.e);
281
+ return (minting ??= mint()
282
+ .then(
283
+ (t) => {
284
+ token = t;
285
+ failed = null;
286
+ if (browser) session.set(key, t);
287
+ else later(Math.max(10_000, (t.exp - Date.now()) * 0.8)); // the current token keeps serving until the new one is in
288
+ return t.token;
289
+ },
290
+ (e) => {
291
+ failed = { at: Date.now(), e };
292
+ if (!browser) later(MINT_BACKOFF_MS); // a server keeps trying in the background
293
+ throw e;
294
+ },
295
+ )
296
+ .finally(() => (minting = null)));
297
+ };
298
+ const getToken = (): Promise<string> => (token && token.exp > Date.now() ? Promise.resolve(token.token) : renew());
299
+
300
+ return {
301
+ get: getToken,
302
+ current: () => token?.token,
303
+ clear: () => {
304
+ token = null;
305
+ if (browser) session.set(key, undefined);
306
+ },
307
+ AUTH,
308
+ API,
309
+ originHeaders,
310
+ };
311
+ };
@@ -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
+