@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
@@ -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
+
@@ -0,0 +1,108 @@
1
+ // A widget's context (config, translator, templates, helpers) once its chunk
2
+ // has loaded. In the core chunk (core/support.ts re-exports it), not the
3
+ // entry: nothing on a page without a widget needs it. No DOM: the edge render
4
+ // service builds a server-rendered listing's context with it too.
5
+
6
+ import type { Templates } from './templates';
7
+ import type { LumaConfigType } from '../types';
8
+ import type { WidgetContext } from '../main';
9
+ import { nameOf, slot } from './integration';
10
+
11
+ type Support = typeof import('./support');
12
+
13
+
14
+ export const widgetContext = (
15
+ s: Support,
16
+ config: LumaConfigType,
17
+ w: { id: string; folders: string[]; app: any; scopes: string[] },
18
+ builtIn: Templates,
19
+ tpls: readonly (readonly [string, any])[],
20
+ css: readonly (readonly [string, any])[],
21
+ dict: any,
22
+ /** Server-side renders: the storefront origin the API calls are made for. */
23
+ origin?: string,
24
+ ): WidgetContext => {
25
+ const g = config._global;
26
+ const locale = g.locale ?? 'en';
27
+ // Precedence: the widget's built-in templates, then _global/, then its folders in order.
28
+ const templates: Templates = { ...builtIn };
29
+ for (const [path, t] of tpls) templates[nameOf(path)] = t;
30
+ const t = s.translator(dict, locale, [g.translations, ...w.folders.map((f) => (f === '_global' ? undefined : slot(config, f).translations))], w.scopes);
31
+ // One formatter, made again only if the currency or its format changes (an
32
+ // _init.ts may set them per page): a new Intl.NumberFormat per call was the
33
+ // costliest call of a listing's render (a production storefront, 4x CPU: 125 ms of 670 for
34
+ // a page of tiles, each step of the first render calling it again per tile).
35
+ let fmt: Intl.NumberFormat | null = null;
36
+ let fmtFor: [unknown, unknown] = [undefined, undefined];
37
+ // And each amount once: a render re-renders its tiles, each with its prices.
38
+ const formatted = new Map<number, string>();
39
+ // Whether this widget's templates print prices with the helper, from the
40
+ // last page with it (localStorage; unknown on a first visit).
41
+ const USED = `lbx:price:${w.id}`;
42
+ let called = false;
43
+ const price = (n: unknown) => {
44
+ if (!called) {
45
+ called = true;
46
+ try {
47
+ localStorage.setItem(USED, '1');
48
+ } catch {}
49
+ }
50
+ const x = s.first(n);
51
+ if (x == null || x === '') return ''; // Number('') would be 0
52
+ const v = Number(x);
53
+ if (!Number.isFinite(v)) return '';
54
+ if (!fmt || fmtFor[0] !== g.currency || fmtFor[1] !== g.priceFormat) {
55
+ fmt = new Intl.NumberFormat(locale, { ...(g.currency ? { style: 'currency', currency: g.currency } : {}), ...g.priceFormat });
56
+ fmtFor = [g.currency, g.priceFormat];
57
+ formatted.clear();
58
+ }
59
+ let out = formatted.get(v);
60
+ if (out === undefined) {
61
+ if (formatted.size >= 1000) formatted.clear();
62
+ formatted.set(v, (out = fmt.format(v)));
63
+ }
64
+ return out;
65
+ };
66
+ // The page's first Intl.NumberFormat loads the locale's data (~50 ms at 4x
67
+ // CPU, a production storefront): made while the browser is idle (a widget's first request
68
+ // is out), not in the render that shows its first results. Not when the
69
+ // last page with this widget never called the helper: a storefront that formats its
70
+ // own prices loaded the locale's data in one long idle task for nothing on
71
+ // every page. Built-in templates that use it may be in the
72
+ // widget's set unused, so it is what the page did, not what the templates say.
73
+ if (typeof requestIdleCallback === 'function') {
74
+ let used: string | null = null;
75
+ try {
76
+ used = localStorage.getItem(USED);
77
+ } catch {}
78
+ if (used !== '0')
79
+ requestIdleCallback(() => {
80
+ const was = called;
81
+ called = true; // warming is not a use
82
+ price(0);
83
+ called = was;
84
+ });
85
+ globalThis.addEventListener?.(
86
+ 'pagehide',
87
+ () => {
88
+ if (called) return;
89
+ try {
90
+ localStorage.setItem(USED, '0');
91
+ } catch {}
92
+ },
93
+ { once: true },
94
+ );
95
+ }
96
+ return {
97
+ id: w.id,
98
+ config: w.app,
99
+ global: g,
100
+ api: s.getApi(g, origin),
101
+ t,
102
+ templates,
103
+ styles: css.map(([, c]) => c),
104
+ result: s.resultFactory({ ...g.computedAttributes, ...w.app.computedAttributes }),
105
+ // Images: a listing's first row is eager; elsewhere (recommenders, the autocomplete) every image is lazy.
106
+ helpers: { t, price, plain: s.plain, first: s.first, ...s.imageHelpers(g.images, w.id !== 'search' && w.id !== 'plp') },
107
+ };
108
+ };
@@ -0,0 +1,96 @@
1
+ // Rules for the inline early-hold snippet (snippet/early-hold.js).
2
+ //
3
+ // The snippet is generic and identical for every client: inline in <head>, it
4
+ // reads these rules from localStorage and, on a matching URL, adds the route's
5
+ // CSS before first paint (the host's listing hidden, a loading bar), with a
6
+ // timeout; the bundle claims that hold at boot (core/takeover.ts). The
7
+ // bundle writes the rules on every page view (only when they changed), so they
8
+ // always match the build that is running; the first page view in a browser
9
+ // has none (no hold: the async behaviour).
10
+ //
11
+ // The matchers are a conservative copy of the routes (path, required and
12
+ // allowed params, no values): a URL they accept that the bundle does not is
13
+ // dropped at boot. The CSS must only hit elements specific to the page type
14
+ // (on sites where category and product URLs look alike).
15
+
16
+ import { holdCss } from './hold';
17
+ import { P, TRACKING } from './routes';
18
+ import type { PlpConfigType, SearchConfigType } from '../types';
19
+
20
+ const EARLY_KEY = 'lbx:early';
21
+ // Short: when the bundle stops running (switched off, blocked), holds end soon after.
22
+ const TTL = 2 * 24 * 3600e3;
23
+
24
+ export interface EarlyRoute {
25
+ id: string;
26
+ /** Pathname regex. */
27
+ path: string;
28
+ /** Params that must be present and non-empty. */
29
+ need?: string[];
30
+ /** Param-name regex every non-empty param must match. */
31
+ allow?: string;
32
+ css: string;
33
+ }
34
+
35
+ const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+ // reserveSelector: set by loadConfig (default: the first anchor).
37
+
38
+ /** Only what the snippet can express: a path regex and a query param (no custom readQuery/active). */
39
+ export const searchRule = (c: SearchConfigType, hostCss: string): EarlyRoute | null =>
40
+ c.urlMatch && c.queryParam && !c.readQuery && !c.active ? { id: 'search', path: c.urlMatch, need: [c.queryParam], css: holdCss(hostCss, c.reserveSelector!) } : null;
41
+
42
+ /**
43
+ * Param names the category route can express (values are checked by the bundle).
44
+ * An _init.ts `active` cannot be expressed: the rule then matches on urlMatch
45
+ * alone, so it also holds pages `active` rejects (a production storefront's product pages share
46
+ * the category URL shape). That is harmless where style-host.css only hits
47
+ * elements of the category page (nothing is hidden there) and the bundle drops
48
+ * the unclaimed hold at boot; `earlyHold: false` opts out where it is not.
49
+ */
50
+ export const plpRule = (c: PlpConfigType, hostCss: string): EarlyRoute | null => {
51
+ if (c.earlyHold === false) return null;
52
+ const pm = c.params ?? {};
53
+ // Their page param too: their page 1 is ours. A later page of theirs is
54
+ // theirs (plpAccepts), a value the snippet does not check: held until the
55
+ // bundle boots and drops the hold.
56
+ const names = [
57
+ pm.page,
58
+ pm.sort?.param,
59
+ ...(pm.flags ?? []).map((f) => f.param),
60
+ ...(pm.ranges ?? []).flatMap((r) => [r.min, r.max]),
61
+ ...Object.values(pm.facetParams ?? {}).map((f) => f.param),
62
+ ].filter((n): n is string => !!n);
63
+ const allow = `(?:${pm.ignore ?? TRACKING})|^${esc(P)}${names.length ? `|^(?:${[...new Set(names)].map(esc).join('|')})$` : ''}`;
64
+ return { id: 'plp', path: c.urlMatch, allow, css: holdCss(hostCss, c.reserveSelector!) };
65
+ };
66
+
67
+ /** Store the rules for the snippet's next hard page load (only when they changed, or to renew them). */
68
+ export const writeEarlyRules = (routes: EarlyRoute[], ms: number) => {
69
+ try {
70
+ if (!routes.length) return clearEarlyRules();
71
+ const rules = { v: 1, ms, routes };
72
+ const old = JSON.parse(localStorage.getItem(EARLY_KEY) || 'null');
73
+ // Unchanged and renewed within the last day: nothing to write.
74
+ if (old && JSON.stringify({ v: old.v, ms: old.ms, routes: old.routes }) === JSON.stringify(rules) && old.exp - Date.now() > TTL - 24 * 3600e3) return;
75
+ localStorage.setItem(EARLY_KEY, JSON.stringify({ ...rules, exp: Date.now() + TTL }));
76
+ } catch {}
77
+ };
78
+
79
+ /** The integration is off: the snippet holds nothing any more. */
80
+ export const clearEarlyRules = () => {
81
+ try {
82
+ localStorage.removeItem(EARLY_KEY);
83
+ } catch {}
84
+ };
85
+
86
+ /** An early hold the inline snippet started on this page view: what the bundle claims or drops. */
87
+ export interface EarlyHold {
88
+ t: number;
89
+ route: string;
90
+ snippet?: 1;
91
+ claim(id: string): boolean;
92
+ drop(): void;
93
+ }
94
+
95
+ /** The snippet's early hold, if it started one: `window.__lbx.early` (Luma's one global). */
96
+ export const earlyHold = (): EarlyHold | undefined => (window as any).__lbx?.early;
@@ -0,0 +1,25 @@
1
+ // Which pages of this document are ours: small enough for the entry (the
2
+ // takeover needs it on every URL change), shared with core/url.ts.
3
+
4
+ /** A URL as we compare it: routers re-encode (`|` or `%7C`) and reorder the query; the hash is not ours. */
5
+ export const canon = (href: string) => {
6
+ const u = new URL(href);
7
+ const ps = [...u.searchParams].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
8
+ return `${u.origin}${u.pathname}?${new URLSearchParams(ps)}`;
9
+ };
10
+
11
+ /** Pages of this document whose history entry we created (writeUrl). */
12
+ const own = new Set<string>();
13
+ export const markOwnEntry = (href: string) => void own.add(canon(href));
14
+ /** Did we create this page's history entry (our listing's state, a new query in place)? */
15
+ export const isOwnEntry = (href: string) => own.has(canon(href));
16
+
17
+ /**
18
+ * A history entry of ours that only stands for something open (the
19
+ * autocomplete's phone sheet): Back closes it, and no router of the host's
20
+ * hears that popstate (the URL is the same). `back` returns true when the
21
+ * popstate was that entry's (set by the widget that pushed it).
22
+ */
23
+ let back: (() => boolean) | null = null;
24
+ export const onOwnBack = (f: () => boolean) => void (back = f);
25
+ export const ownBack = () => !!back?.();
@@ -0,0 +1,7 @@
1
+ // Text folding for matching typed text (a facet group's search): case- and
2
+ // accent-insensitive, offsets kept.
3
+
4
+ /** Letters with a stroke, which NFD keeps whole: Polish ł, Croatian đ, Danish ø (one unit for one). */
5
+ const STROKE: Record<string, string> = { ł: 'l', Ł: 'l', đ: 'd', Đ: 'd', ø: 'o', Ø: 'o', ħ: 'h', Ħ: 'h' };
6
+ /** Lowercase without diacritics, one UTF-16 unit per unit (so offsets stay valid): "Łąka" -> "laka", "Žluťoučký" -> "zlutoucky". */
7
+ export const fold = (s: string) => s.split('').map((c) => STROKE[c] ?? c.normalize('NFD')[0].toLowerCase()[0]).join('');
@@ -0,0 +1,26 @@
1
+ // Self-telemetry, exposed on window.__lbx.health (debugging, test harnesses):
2
+ // placement counters, API calls, errors and a timeline of what happened when.
3
+
4
+ export const health = {
5
+ reconciles: 0,
6
+ inserts: {} as Record<string, number>,
7
+ anchorMisses: {} as Record<string, number>,
8
+ fights: {} as Record<string, number>,
9
+ apiCalls: 0,
10
+ errors: [] as string[],
11
+ timeline: [] as [number, string][],
12
+ };
13
+
14
+ export const mark = (what: string) => {
15
+ if (health.timeline.length < 200) health.timeline.push([Math.round(performance.now()), what]);
16
+ };
17
+
18
+ export const bump = (bucket: Record<string, number>, key: string) => {
19
+ bucket[key] = (bucket[key] || 0) + 1;
20
+ };
21
+
22
+ export const reportError = (where: string, e: unknown) => {
23
+ if (health.errors.length < 50) health.errors.push(`${where}: ${e instanceof Error ? e.message : String(e)}`);
24
+ // Never throw into the host page.
25
+ console.warn('[lbx]', where, e);
26
+ };
@@ -0,0 +1,74 @@
1
+ // Hold-state helpers: remembered heights and the loading bar.
2
+
3
+ import { session } from './storage';
4
+
5
+ // Remembered heights: a per-viewer convenience only (storage may be unavailable).
6
+ const recall = (k: string) => Number(session.get(k)) || 0;
7
+ const remember = (k: string, h: number) => h > 0 && session.set(k, Math.round(h));
8
+
9
+ const heightKey = () => `lbx:h:${location.pathname}${location.search}`;
10
+ export const recallHeight = () => recall(heightKey());
11
+ /** The height ours had last time on this URL, reserved on `sel` (the browser restores scroll on reload/back before our results exist). */
12
+ export const reserveCss = (sel: string) => {
13
+ const h = recallHeight();
14
+ return h ? `\n${sel} { min-height: ${h}px !important; }` : '';
15
+ };
16
+ export const rememberHeight = (h: number) => remember(heightKey(), h);
17
+
18
+ /**
19
+ * `f(height)` of `el` once the browser has laid it out anyway: a ResizeObserver
20
+ * reports after the frame's own layout, where reading offsetHeight right after
21
+ * a render would force style and layout of the whole page (tens of ms on a
22
+ * large host DOM). Once: the first report (its size then, 0 while hidden).
23
+ */
24
+ export const afterLayout = (el: Element, f: (h: number) => void) => {
25
+ const ro = new ResizeObserver((es) => {
26
+ ro.disconnect();
27
+ f(es[0]?.borderBoxSize?.[0]?.blockSize ?? es[0]?.contentRect.height ?? 0);
28
+ });
29
+ ro.observe(el);
30
+ };
31
+
32
+ /**
33
+ * The host CSS (`style-host.css`) as a hold applies it: what it hides with
34
+ * `display: none` is hidden with `visibility: hidden` instead, so it keeps
35
+ * its box and the page keeps its height until ours renders (then the CSS as
36
+ * written applies, in the same frame ours shows). Regression (two shops,
37
+ * 2026-10-02): the early hold collapsed the page (their listing gone, the
38
+ * footer up), then ours pushed it down: CLS 0.48 and 0.52. The cost: their
39
+ * lazy images in view may load meanwhile; nothing of theirs is seen,
40
+ * reachable or hit-testable (unless their own CSS makes a descendant
41
+ * `visibility: visible`).
42
+ */
43
+ export const holding = (hostCss: string) => hostCss.replace(/display\s*:\s*none\s*(?:!\s*important)?/gi, 'visibility: hidden !important');
44
+
45
+ // Written minified, a rule per line (the build joins the lines): in the entry, every page.
46
+ export const LOADING = (sel: string) =>
47
+ `\n${sel}::before{content:"";display:block;height:3px;margin-bottom:-3px;opacity:.4;` +
48
+ 'background:linear-gradient(90deg,transparent,currentColor,transparent) no-repeat;' +
49
+ 'background-size:40% 100%;animation:lbx-loading 1.1s linear infinite}' +
50
+ '@keyframes lbx-loading{from{background-position:-60% 0}to{background-position:160% 0}}' +
51
+ `@media (prefers-reduced-motion:reduce){${sel}::before{animation:none;background-size:100% 100%}}`;
52
+
53
+ /** First paint already happened? (Paint Timing; readyState where unsupported.) */
54
+ export const painted = () =>
55
+ PerformanceObserver.supportedEntryTypes?.includes('paint')
56
+ ? performance.getEntriesByType('paint').some((e) => e.name === 'first-contentful-paint')
57
+ : document.readyState !== 'loading';
58
+
59
+ /**
60
+ * Visits in a row that failed (timed out, errored) before a listing route
61
+ * stops holding: theirs shows at once. Counted per session, so a multi-page
62
+ * site stops too, not only an SPA.
63
+ */
64
+ export const MAX_FAILURES = 2;
65
+ export const failKey = (id: string) => `lbx:fail:${id}`;
66
+ export const failures = (id: string) => recall(failKey(id));
67
+
68
+ /** The hold's CSS of a listing route: their listing kept in place but hidden, a loading bar on the reserve. */
69
+ export const holdCss = (hostCss: string, reserveSelector: string) => `${holding(hostCss)}\n${LOADING(reserveSelector)}`;
70
+
71
+ /** Height a widget measured last time at this viewport width (per session). */
72
+ const widgetKey = (id: string) => `lbx:rh:${id}:${Math.round(innerWidth / 100)}`;
73
+ export const recallWidgetHeight = (id: string) => recall(widgetKey(id));
74
+ export const rememberWidgetHeight = (id: string, h: number) => remember(widgetKey(id), h);
@@ -0,0 +1,66 @@
1
+ // Translations: JSON dictionaries per locale (src/locales/<locale>.json, loaded
2
+ // on demand, only the site's locale), overridden by the integration's
3
+ // `_translations.ts` files: `_global` for every widget, a widget folder's own
4
+ // for that widget. Keys are dotted paths, placeholders `{name}`.
5
+ //
6
+ // Plural forms: a key whose value is an object of `Intl.PluralRules`
7
+ // categories (`zero`, `one`, `two`, `few`, `many`, `other`; `other` required)
8
+ // picks the form for the `count` param in the dictionary's locale:
9
+ // "results": { "one": "{count} produkt", "few": "{count} produkty", "many": "{count} produktów", "other": "{count} produktu" }
10
+ // t('results', { count: 83 }) -> "83 produkty" in pl, t('results', { count: 85 }) -> "85 produktów".
11
+ // Without a numeric `count`, `other`. An override replaces a plural (or a
12
+ // string) as a whole, never one form of it.
13
+
14
+ import type { Dictionary, Translations } from '../types';
15
+
16
+ export { loadDictionary } from './locales';
17
+
18
+ const CATEGORIES = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
19
+ /** A plural: an object of plural categories with `other`, every form a string. */
20
+ export const isPlural = (v: unknown): v is Record<string, string> =>
21
+ !!v && typeof v === 'object' && typeof (v as Dictionary).other === 'string' && Object.entries(v).every(([k, s]) => CATEGORIES.has(k) && typeof s === 'string');
22
+
23
+ const merge = (a: Dictionary, b?: Dictionary): Dictionary => {
24
+ if (!b) return a;
25
+ const out: Dictionary = { ...a };
26
+ for (const [k, v] of Object.entries(b)) out[k] = typeof v === 'object' && typeof a[k] === 'object' && !isPlural(v) && !isPlural(a[k]) ? merge(a[k] as Dictionary, v) : v;
27
+ return out;
28
+ };
29
+
30
+ export type T = (key: string, params?: Record<string, unknown>) => string;
31
+
32
+ const rules = new Map<string, Intl.PluralRules>();
33
+ const pluralOf = (locale: string) => {
34
+ let r = rules.get(locale);
35
+ if (!r) {
36
+ try {
37
+ r = new Intl.PluralRules(locale);
38
+ } catch {
39
+ r = new Intl.PluralRules('en'); // a locale tag Intl does not know
40
+ }
41
+ rules.set(locale, r);
42
+ }
43
+ return r;
44
+ };
45
+
46
+ /**
47
+ * A translate function. `scopes` are tried in order as key prefixes, then the
48
+ * bare key: with ['plp', 'search'], t('sort') reads plp.sort, then search.sort.
49
+ */
50
+ export const translator = (base: Dictionary, locale: string, overrides: (Translations | undefined)[], scopes: string[] = []): T => {
51
+ const dict = overrides.reduce<Dictionary>((d, o) => merge(d, o?.[locale]), base);
52
+ const get = (path: string, params?: Record<string, unknown>) => {
53
+ let v: unknown = dict;
54
+ for (const k of path.split('.')) v = v && typeof v === 'object' ? (v as Dictionary)[k] : undefined;
55
+ if (typeof v === 'string') return v;
56
+ if (!isPlural(v)) return undefined;
57
+ const n = Number(params?.count);
58
+ return (params?.count != null && Number.isFinite(n) && v[pluralOf(locale).select(n)]) || v.other;
59
+ };
60
+ return (key, params) => {
61
+ let s: string | undefined;
62
+ for (const p of scopes) if ((s = get(`${p}.${key}`, params)) !== undefined) break;
63
+ s ??= get(key, params) ?? key;
64
+ return params ? s.replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m)) : s;
65
+ };
66
+ };