@luigisbox/luma 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +615 -2
  3. package/a11y/audit.mjs +735 -0
  4. package/a11y/axe.mjs +73 -0
  5. package/a11y/report.mjs +347 -0
  6. package/a11y/run.mjs +256 -0
  7. package/a11y/wcag.mjs +233 -0
  8. package/bin/config.mjs +60 -0
  9. package/bin/luma.mjs +99 -0
  10. package/bin/paths.mjs +11 -0
  11. package/bin/tools.mjs +43 -0
  12. package/build/baseline.d.mts +8 -0
  13. package/build/baseline.mjs +167 -0
  14. package/build/tooling.mjs +102 -0
  15. package/build/vite.mjs +507 -0
  16. package/chrome/block.mjs +8 -0
  17. package/chrome/run.mjs +150 -0
  18. package/docs/ACCESSIBILITY.md +501 -0
  19. package/docs/INTEGRATION-PERFORMANCE.md +213 -0
  20. package/docs/PARITY.md +946 -0
  21. package/footprint/model.mjs +184 -0
  22. package/footprint/run.mjs +331 -0
  23. package/package.json +58 -4
  24. package/parity/alignment.mjs +109 -0
  25. package/parity/archive.mjs +223 -0
  26. package/parity/args.mjs +137 -0
  27. package/parity/behaviour.mjs +379 -0
  28. package/parity/compare.mjs +985 -0
  29. package/parity/engine.mjs +848 -0
  30. package/parity/hints.mjs +132 -0
  31. package/parity/hold.mjs +54 -0
  32. package/parity/init.mjs +703 -0
  33. package/parity/map.mjs +640 -0
  34. package/parity/owner.mjs +170 -0
  35. package/parity/pairing.mjs +110 -0
  36. package/parity/pixels.mjs +33 -0
  37. package/parity/probe.mjs +1239 -0
  38. package/parity/report.mjs +236 -0
  39. package/parity/run.mjs +267 -0
  40. package/snippet/early-hold.js +64 -0
  41. package/snippet/early-hold.min.js +1 -0
  42. package/src/analytics/chain.ts +137 -0
  43. package/src/analytics/collect.ts +64 -0
  44. package/src/analytics/datalayer.ts +67 -0
  45. package/src/analytics/from-ga4.ts +68 -0
  46. package/src/analytics/from-luma.ts +21 -0
  47. package/src/analytics/index.ts +24 -0
  48. package/src/analytics/outbox.ts +118 -0
  49. package/src/analytics/page.ts +46 -0
  50. package/src/analytics/pageview.ts +82 -0
  51. package/src/analytics/ref.ts +19 -0
  52. package/src/analytics/setup.ts +41 -0
  53. package/src/analytics/start.ts +53 -0
  54. package/src/analytics/triggers.ts +239 -0
  55. package/src/analytics/user.ts +12 -0
  56. package/src/analytics/wire.ts +67 -0
  57. package/src/api/ai.ts +98 -0
  58. package/src/api/index.ts +87 -0
  59. package/src/api/token-key.ts +42 -0
  60. package/src/api/token.ts +312 -0
  61. package/src/core/a11y-keys.ts +60 -0
  62. package/src/core/a11y.ts +160 -0
  63. package/src/core/boot-state.ts +6 -0
  64. package/src/core/color.ts +42 -0
  65. package/src/core/context.ts +108 -0
  66. package/src/core/early-rules.ts +96 -0
  67. package/src/core/entries.ts +25 -0
  68. package/src/core/fold.ts +7 -0
  69. package/src/core/health.ts +26 -0
  70. package/src/core/hold.ts +74 -0
  71. package/src/core/i18n.ts +66 -0
  72. package/src/core/image-load.ts +102 -0
  73. package/src/core/image-tags.ts +26 -0
  74. package/src/core/images.ts +170 -0
  75. package/src/core/integration.ts +126 -0
  76. package/src/core/kept.ts +25 -0
  77. package/src/core/listings.ts +100 -0
  78. package/src/core/locales.ts +14 -0
  79. package/src/core/own.ts +49 -0
  80. package/src/core/page.ts +14 -0
  81. package/src/core/prefetch.ts +88 -0
  82. package/src/core/recommenders.ts +166 -0
  83. package/src/core/reconciler.ts +397 -0
  84. package/src/core/result.ts +73 -0
  85. package/src/core/routes.ts +115 -0
  86. package/src/core/scheduler.ts +83 -0
  87. package/src/core/sense.ts +107 -0
  88. package/src/core/speculation.ts +59 -0
  89. package/src/core/stash.ts +38 -0
  90. package/src/core/storage.ts +45 -0
  91. package/src/core/styles.ts +106 -0
  92. package/src/core/support.ts +12 -0
  93. package/src/core/takeover.ts +220 -0
  94. package/src/core/templates.ts +74 -0
  95. package/src/core/tiles.ts +137 -0
  96. package/src/core/turbo.ts +64 -0
  97. package/src/core/url.ts +164 -0
  98. package/src/core/widget-inits.ts +40 -0
  99. package/src/env.d.ts +3 -0
  100. package/src/lit.ts +11 -0
  101. package/src/locales/cs.json +94 -0
  102. package/src/locales/de.json +88 -0
  103. package/src/locales/en.json +88 -0
  104. package/src/main.ts +493 -0
  105. package/src/types.ts +778 -0
  106. package/src/widgets/autocomplete/index.ts +1387 -0
  107. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  108. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  109. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  110. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  111. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  112. package/src/widgets/listing/element.ts +1772 -0
  113. package/src/widgets/listing/flow.ts +30 -0
  114. package/src/widgets/listing/instant.ts +133 -0
  115. package/src/widgets/listing/intent.ts +193 -0
  116. package/src/widgets/listing/query.ts +298 -0
  117. package/src/widgets/listing/stash.ts +97 -0
  118. package/src/widgets/listing/state.ts +131 -0
  119. package/src/widgets/listing/view.ts +257 -0
  120. package/src/widgets/plp/index.ts +11 -0
  121. package/src/widgets/plp/route.ts +263 -0
  122. package/src/widgets/recommender/index.ts +261 -0
  123. package/src/widgets/recommender/style-recommender.css +26 -0
  124. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  125. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  126. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  127. package/src/widgets/search/assets.ts +7 -0
  128. package/src/widgets/search/first.ts +15 -0
  129. package/src/widgets/search/form-url.ts +59 -0
  130. package/src/widgets/search/in-place.ts +226 -0
  131. package/src/widgets/search/index.ts +16 -0
  132. package/src/widgets/search/range.ts +260 -0
  133. package/src/widgets/search/route.ts +32 -0
  134. package/src/widgets/search/speculate.ts +202 -0
  135. package/src/widgets/search/style-search.css +65 -0
  136. package/src/widgets/search/template-search-box.ts +17 -0
  137. package/src/widgets/search/template-search-facets-default.ts +73 -0
  138. package/src/widgets/search/template-search-header.ts +14 -0
  139. package/src/widgets/search/template-search-pagination.ts +12 -0
  140. package/src/widgets/search/template-search-range-default.ts +48 -0
  141. package/src/widgets/search/template-search-result-default.ts +13 -0
  142. package/src/widgets/search/template-search-results-default.ts +8 -0
  143. package/tasks/model.mjs +116 -0
  144. package/tasks/run.mjs +305 -0
  145. package/tsconfig.json +14 -0
package/README.md CHANGED
@@ -1,3 +1,616 @@
1
- # Temporary Holding Version
1
+ # Luma
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Luma is the framework behind Luigi's Box frontend integrations: autocomplete,
4
+ search results, category listings (PLP) and recommenders on the Luigi's Box AI
5
+ API, for sites that can only include a script. Optionally, listings arrive
6
+ server-rendered through the site's own cache, from a rendering service Luigi's
7
+ Box runs.
8
+
9
+ This package is what an integration is built with. Each integration lives in
10
+ its own repository and depends on `@luigisbox/luma`
11
+ ([Integration repository](#integration-repository)).
12
+
13
+ > **Pre-release.** Until 1.0, a minor version (0.3.0) may change the
14
+ > integration API (config keys, template scope, the build); a patch version
15
+ > (0.2.1) only fixes. Depend on `^0.2.0` to take fixes without breaking
16
+ > changes.
17
+
18
+ ## Core principles
19
+
20
+ ### The problem
21
+
22
+ Luma runs on storefronts it doesn't own, often included as a single script
23
+ tag. That sets the constraints:
24
+
25
+ 1. **The page belongs to the site.** The site's framework, router, CSS and
26
+ scripts were there first and keep changing. Luma works around them without
27
+ breaking anything.
28
+ 2. **Luma is judged by how the page feels.** If search or listings feel slower,
29
+ flicker or jump compared with the site's own, the integration is a
30
+ regression, however good the results are.
31
+ 3. **The shopper should not notice Luma.** Results look like the site's own UI
32
+ and are usable by everyone.
33
+ 4. **One framework, many integrations.** Every change to Luma ships to every
34
+ site that uses it, so the rules must hold in general, not for one site.
35
+ 5. **Measured, not claimed.** No one can check every site by hand, so each
36
+ principle below has a test, a gate or a tool behind it.
37
+
38
+ ### A. A guest on the page
39
+
40
+ - **Do no harm, always fall back.** Luma adds only its own nodes
41
+ (`data-lbx`), hides the host's UI with CSS and never removes the host's
42
+ nodes. It never throws into the page, every network call has a deadline, and
43
+ nothing of Luma's keeps a page out of the back/forward cache. A failed
44
+ request, a URL Luma cannot serve, a listing not rendered within 4 s, a
45
+ non-HTTPS page or a missing browser feature: the site's own UI comes back as
46
+ if Luma were never there.
47
+ - **Navigation stays the host's.** The site's router owns the URL and history.
48
+ Luma's history entries carry no state of its own (null, or for SvelteKit and
49
+ Astro only the router's own bookkeeping keys); it never rewrites the host's.
50
+ Anything Luma cannot do exactly as the browser would (POST, a new window, a
51
+ modifier key) stays the browser's. The one exception: a new query from the
52
+ site's search form while on Luma's search page is shown in place.
53
+ - **Sense and reconcile, not imperative patching.** Host signals (URL changes,
54
+ DOM mutations, Luma's nodes removed) only mean "reconcile soon". At most once
55
+ per frame the reconciler makes the page match declarative rules: it removes
56
+ nodes whose rule is inactive, puts the same instance back if the host
57
+ removed it, and waits for the anchor to settle. A missed signal is harmless:
58
+ the next reconcile computes everything from scratch.
59
+ - **Works with any frontend stack.** React/Next.js, Remix/React Router 7,
60
+ Vue/Nuxt, Angular, SvelteKit, Astro, Turbo and htmx, with no code change on
61
+ the site. Inside a server-rendered root, Luma waits for that framework's own
62
+ hydration signal before inserting anything. Every framework host has a
63
+ contract suite on Chromium, Firefox and WebKit.
64
+ - **Listen at the window, not on host elements.** Input, submit and keydown are
65
+ window-capture listeners matched against selectors; inputs in shadow roots
66
+ are read from the composed path. Nothing is bound to the host's input or
67
+ form, so a re-created input keeps working.
68
+
69
+ ### B. Faster than the site it replaces
70
+
71
+ - **No flicker, no layout shift.** With the inline snippet in `<head>`, the
72
+ host's listing is held before first paint and swapped for Luma's in one
73
+ frame. Its height is reserved from the last visit. Target: zero layout shift
74
+ after Luma's listing is shown.
75
+ - **A small entry, one round trip.** The entry is at most 14 kB brotli (one TCP
76
+ initial congestion window). Each widget's code, templates, styles and
77
+ dictionary are 2–7 kB chunks loaded only when that widget can run: a site
78
+ without recommenders never downloads that code.
79
+ - **Never block the main thread.** Target: no task of Luma's over 16 ms (a frame
80
+ at 60 fps) at 4x CPU; the test gate is 30 ms. Typing in autocomplete reaches
81
+ the next paint within 50 ms (INP). Visual feedback in the handler; fetches
82
+ and history writes in later tasks. No layout reads after Luma's DOM writes in
83
+ the same task. Large listings render in steps that each fit a frame.
84
+ - **Instant.** Results are prefetched while the shopper types or hovers a
85
+ filter, Back restores the listing at the tile they followed, product pages
86
+ are prerendered on intent (Speculation Rules, Chromium).
87
+ - **Server-side rendering, same code.** The same integration and templates
88
+ render in the browser and on Luigi's Box servers. The site can serve Luma's
89
+ listing in its own HTML through SSI/ESI in its web server or CDN; the browser takes the
90
+ server-rendered listing over. The server renderer and everything it imports
91
+ stay free of DOM access.
92
+
93
+ ### C. Looks native, works for everyone
94
+
95
+ - **The site's own look.** Listing templates reuse the site's markup and CSS
96
+ classes. Luma's built-in CSS sits in `@layer lbx`, so the site's CSS always
97
+ wins. `yarn luma parity` measures how close the port is.
98
+ - **Web components, DOM mode per widget.** Custom elements are the unit of
99
+ placement and lifecycle. Listings are light DOM, so the site's CSS applies.
100
+ Autocomplete is shadow DOM by default: a robust house design isolated from
101
+ host CSS, with a light-DOM mode for sites that want their own styling.
102
+ - **Accessible by design.** WCAG 2.2 AA (EN 301 549, the European
103
+ Accessibility Act). Combobox ARIA on the input (`aria-expanded`,
104
+ `aria-controls`, `aria-activedescendant`), modal dialogs with managed focus,
105
+ `aria-busy` while a listing renders. `yarn luma a11y` checks an integration.
106
+ - **The modern platform, no polyfills.** Baseline Widely available (custom
107
+ elements, shadow DOM, cascade layers, `<dialog>`, `inert`, `color-mix()`).
108
+ Anything newer (Popover API, Navigation API, Speculation Rules) is
109
+ feature-detected and the page works without it. `yarn luma baseline`
110
+ enforces it ([Browser support](#browser-support)).
111
+
112
+ ### D. How Luma is built
113
+
114
+ - **One framework, many integrations.** Nothing site-specific in Luma itself;
115
+ each integration has its own repository, with Luma as the `@luigisbox/luma`
116
+ package. `_config.ts` is JSON; code goes in `_init.ts`.
117
+ - **Measured, not claimed.** Every principle has a gate: budgets and
118
+ main-thread limits, request counts, the bfcache test, the baseline lint,
119
+ framework contracts, the accessibility and parity checks. The suites run on
120
+ Chromium, Firefox and WebKit, desktop and phones, on every change. Every fix
121
+ lands with a test that fails without it.
122
+
123
+ The trade-offs that come with this: [Known limitations](#known-limitations).
124
+
125
+
126
+ ## What's in the package
127
+
128
+ | Path | What |
129
+ |---|---|
130
+ | `src/` | the framework: config, sensing and placement, takeover, templates, API client, widgets |
131
+ | `build/vite.mjs` | the Vite config every integration builds with |
132
+ | `snippet/` | the inline early-hold snippet (identical for every site) |
133
+ | `a11y/` | `yarn luma a11y`: an integration's WCAG 2.2 AA check |
134
+ | `parity/` | `yarn luma parity`: an integration compared with the site it replaces |
135
+ | `bin/` | `yarn luma <tool>`: every tool below, from an integration repository, with one script |
136
+ | `footprint/` | `yarn luma footprint`: what Luma itself costs each page type of an integration |
137
+ | `chrome/` | `yarn luma chrome`: the live storefront in your Chrome with an integration on it, before the site includes it |
138
+ | `tasks/` | `yarn luma tasks`: an integration's main-thread tasks over a frame's budget on the storefront, by source line |
139
+
140
+ Integrations are not part of the package: each has its own repository with
141
+ Luma as the `@luigisbox/luma` package
142
+ ([Integration repository](#integration-repository)).
143
+
144
+ ## What the site includes
145
+
146
+ Everything goes in the site's HTML, in `<head>`, on every page.
147
+ `<distribution id>` is the site's distribution (`lbdr_…`), provided by
148
+ Luigi's Box.
149
+
150
+ **Loader only** (a tag manager is fine): the site's own listing may flash
151
+ before Luma's on a hard load.
152
+
153
+ ```html
154
+ <script async src="https://cdn.eu1.luigisbox.ai/b/<distribution id>/loader.js"></script>
155
+ ```
156
+
157
+ **Snippet + loader** (recommended): the inline snippet hides the site's listing
158
+ before first paint, from rules the bundle stored on earlier page views; it makes
159
+ no request and gives up after a timeout. The first page view in a browser has
160
+ no rules yet. Put both first in `<head>`, before the site's own scripts and
161
+ stylesheets (not through a tag manager: too late). With a strict CSP, give the
162
+ snippet's tag the page's `nonce` or allow its hash. On a site whose React
163
+ hydrates the whole document (Remix, React Router 7, Hydrogen), put the loader
164
+ first and the snippet as the last element of `<head>`.
165
+
166
+ <!-- lbx-snippet: the minified form is snippet/early-hold.min.js (generated from snippet/early-hold.js); this copy must not differ from it -->
167
+ ```html
168
+ <script>
169
+ "use strict";(function(){try{if(window.__lbx&&window.__lbx.early)return;var n=JSON.parse(localStorage.getItem("lbx:early")||"null");if(!n||n.v!==1||!(n.exp>Date.now())||performance.getEntriesByName&&performance.getEntriesByName("first-contentful-paint").length)return;for(var o=new URL(location.href),i=0;i<n.routes.length;i++){var e=n.routes[i];if(new RegExp(e.path).test(o.pathname)){for(var r=!0,l=0;e.need&&l<e.need.length;l++)(o.searchParams.get(e.need[l])||"").trim()||(r=!1);if(r&&e.allow){var d=new RegExp(e.allow);o.searchParams.forEach(function(a,p){a&&!d.test(p)&&(r=!1)})}if(r){var t=document.createElement("style"),c=document.currentScript;c&&c.nonce&&(t.nonce=c.nonce),t.setAttribute("data-lbx","early"),t.textContent=e.css,(document.head||document.documentElement).appendChild(t);var f=function(){clearTimeout(u),t.parentNode&&t.parentNode.removeChild(t)},u=setTimeout(f,n.ms||1500),m=window.__lbx=window.__lbx||{};m.early={t:performance.now(),route:e.id,snippet:1,claim:function(a){return!t.parentNode||a!==e.id?!1:(clearTimeout(u),!0)},drop:f};return}}}}catch(a){}})();
170
+ </script>
171
+ <script async src="https://cdn.eu1.luigisbox.ai/b/<distribution id>/loader.js"></script>
172
+ ```
173
+
174
+ **Connection hints** (recommended, first in `<head>`, before the snippet): the
175
+ connections Luma's requests need, opened while the HTML is still arriving instead
176
+ of after the loader and the entry have run (the entry adds the same hints, but
177
+ only once `<body>` starts). On a first visit they take the TCP and TLS setup
178
+ (one to three round trips per host) off the path to the listing. Use the
179
+ region of the site's distribution (`eu1` here) and the host of the
180
+ catalog's product images.
181
+
182
+ ```html
183
+ <link rel="preconnect" href="https://cdn.eu1.luigisbox.ai">
184
+ <link rel="preconnect" href="https://cdn.eu1.luigisbox.ai" crossorigin>
185
+ <link rel="preconnect" href="https://api.eu1.luigisbox.ai" crossorigin>
186
+ <link rel="preconnect" href="https://auth.eu1.luigisbox.ai" crossorigin>
187
+ <link rel="preconnect" href="https://<product image host>">
188
+ ```
189
+
190
+ - The CDN twice: the loader is a classic script (no CORS) and the chunks are
191
+ modules loaded with CORS. A browser does not share connections between the two
192
+ modes. Drop the plain one if the Network panel shows a single connection.
193
+ - The API and auth hosts with `crossorigin`: Luma's requests are CORS `fetch()`es.
194
+ A preconnect without it opens a connection they cannot use.
195
+ - The image host without `crossorigin` (images are no-CORS). Leave it out if
196
+ it is the site's own host (connected already).
197
+ - At most these five. More preconnects compete with the page's own
198
+ connections, and an unused one is closed after about 10 s.
199
+ - On listing and search pages, `fetchpriority="high"` on the loader's tag moves
200
+ it ahead of the page's images (an `async` script is otherwise fetched at low
201
+ priority). Luma's listing is those pages' main content. Measure it with
202
+ `yarn luma footprint` and DevTools before keeping it.
203
+
204
+ The whole `<head>` start of a listing page, in order:
205
+
206
+ ```html
207
+ <head>
208
+ <meta charset="utf-8">
209
+ <link rel="preconnect" href="https://cdn.eu1.luigisbox.ai">
210
+ <link rel="preconnect" href="https://cdn.eu1.luigisbox.ai" crossorigin>
211
+ <link rel="preconnect" href="https://api.eu1.luigisbox.ai" crossorigin>
212
+ <link rel="preconnect" href="https://auth.eu1.luigisbox.ai" crossorigin>
213
+ <link rel="preconnect" href="https://<product image host>">
214
+ <script>/* the early-hold snippet above, verbatim */</script>
215
+ <script async fetchpriority="high" src="https://cdn.eu1.luigisbox.ai/b/<distribution id>/loader.js"></script>
216
+ <!-- the site's own stylesheets and scripts -->
217
+ ```
218
+
219
+ What to measure, and the numbers to optimize for:
220
+ [docs/INTEGRATION-PERFORMANCE.md](docs/INTEGRATION-PERFORMANCE.md).
221
+
222
+ **Server-side includes**: the listing arrives in the site's HTML, through
223
+ the site's web server or CDN config, with the loader as above. The site adds an
224
+ include (nginx SSI or Varnish/CDN ESI) and a marker at its listing, and a short
225
+ snippet to its server config; Luigi's Box provides both. The
226
+ include sits outside any root the site's own framework hydrates, and the site's
227
+ own listing stays the fallback.
228
+
229
+ If Luma's listing does not render within 4 s, the request fails or the URL is
230
+ one Luma cannot serve, the site's own listing is shown and the page stays the
231
+ site's until the next visit.
232
+
233
+ ## Integration repository
234
+
235
+ An integration repository has the shape of the distribution platform's
236
+ scaffold: Luma as the `@luigisbox/luma` package (a devDependency, in
237
+ `node_modules/@luigisbox/luma/`), the integration in `integration/`, a Vite
238
+ build that emits `dist/luma-integration.js` (what the platform's loader
239
+ imports) plus on-demand chunks. Its `vite.config.ts`:
240
+
241
+ ```ts
242
+ import mkcert from 'vite-plugin-mkcert';
243
+ import { clientConfig } from '@luigisbox/luma/build/vite.mjs';
244
+
245
+ export default clientConfig(import.meta.url, { plugins: [mkcert()], server: { host: 'localhost', https: {} } });
246
+ ```
247
+
248
+ Its `tsconfig.json` extends `@luigisbox/luma/tsconfig.json`, includes
249
+ `integration/**/*` and `node_modules/@luigisbox/luma/src/**/*`, and maps `@/*`
250
+ to `./node_modules/@luigisbox/luma/src/*`. Its `package.json` depends on
251
+ `@luigisbox/luma`, `vite` 7, `typescript` and `vite-plugin-mkcert` (Luma brings
252
+ `lit-html`, `@lit-labs/ssr-client` and its tools' dependencies).
253
+
254
+ The package holds what an integration uses: the framework's source, the build
255
+ config, `yarn luma` and its tools, and their docs.
256
+
257
+ ```
258
+ integration/
259
+ __integration.ts hands the folder to the framework (startIntegration)
260
+ _global/ _config.ts _init.ts [_widget.ts] [_analytics.ts] _translations.ts style-*.css
261
+ template-*.ts shared by every widget (a tile, …)
262
+ autocomplete/ _config.ts [_init.ts] [_widget.ts] [style-host.css] [template-autocomplete-*.ts]
263
+ search/ _config.ts _translations.ts style-host.css template-search-*.ts
264
+ plp/ _config.ts _init.ts style-host.css (templates fall back to search/)
265
+ recommender <name>/ _config.ts [_init.ts] _translations.ts
266
+ ```
267
+
268
+ - **`_config.ts` is JSON** (checked at boot); anything that needs code goes in
269
+ `_init.ts`. `_global/_init.ts` runs first and can change any config or return
270
+ `false` to stop; a widget's `_init.ts` gets that widget's config. Every config
271
+ key is typed and documented in `src/types.ts`.
272
+ - **`_init.ts` ships and runs on every page; `_widget.ts` loads with its
273
+ widget.** Keep in `_init.ts` only what decides whether and where a widget
274
+ runs (`disabled`, `active`, `readQuery`, `items`, `intent`, selectors set in
275
+ code, switching the site's own widgets off). Hooks only a widget calls
276
+ (`computedAttributes`, `toFilter`, `facetsFor`, `onRender`, `onOpen`, …) go
277
+ in the folder's optional `_widget.ts`, same signature (its return value is
278
+ ignored): it is loaded with the widget's chunk and run once before its
279
+ context is made, `_global/_widget.ts` first. `__integration.ts` hands it
280
+ over as a lazy glob: `widgetInits: import.meta.glob('./*/_widget.ts', { import: 'default' })`.
281
+ - **`style-host.css`** hides the host's own listing or suggestions, only while
282
+ Luma has the page. Other `style-*.css` load with their widget. Luma's built-in
283
+ CSS sits in `@layer lbx`, so the integration's and the site's CSS win.
284
+ - **Templates override by name**: a widget's built-in templates, then
285
+ `_global/`, then the widget's own folder. Names: `search-box`, `search-header`,
286
+ `search-facets-default`, `search-results-default`, `search-result-default`,
287
+ `search-pagination`, `autocomplete-box`, `autocomplete-result-default`,
288
+ `autocomplete-sheet`, `autocomplete-empty`, `recommender-box-default`,
289
+ `recommender-result-default`, `recommender-popup`.
290
+
291
+ ### Templates
292
+
293
+ Templates are [lit-html](https://lit.dev/docs/templates/overview/): each
294
+ `template-<name>.ts` default-exports a function of the template's scope that
295
+ returns `` html`…` ``. Only changed bindings touch the DOM, so images, focus and
296
+ open `<details>` survive re-renders; no `eval`, CSP-safe. Import lit from `@/lit`
297
+ and the scope types from `@/types` (`SearchScope`, `SearchResultScope`,
298
+ `AutocompleteScope`, `RecommenderScope`, …).
299
+
300
+ ```ts
301
+ // integration/search/template-search-result-default.ts
302
+ import { html, nothing } from '@/lit';
303
+ import type { SearchResultScope } from '@/types';
304
+
305
+ export default ({ r, i, img, price }: SearchResultScope) => html`
306
+ <a class="tile" href=${r.c.url}>
307
+ ${img(r, i)}
308
+ <span>${r.c.title}</span>
309
+ ${r.c.price ? html`<b>${price(r.c.price)}</b>` : nothing}
310
+ </a>`;
311
+ ```
312
+
313
+ | lit | |
314
+ |---|---|
315
+ | `${expr}` | text (escaped) |
316
+ | `` ${cond ? html`…` : nothing} `` | conditions |
317
+ | `` ${repeat(list, (x) => x.id, (x, i) => html`…`)} `` | keyed lists (`list.map(…)` for unkeyed) |
318
+ | `attr=${v}`, `?disabled=${b}`, `.checked=${b}` | attribute, boolean attribute, property |
319
+ | `@click=${s.showMore}`, `@change=${() => s.toggleFacet(f.name, v.value)}` | event handlers |
320
+ | `${include('product-tile', { extra })}` | another template of the widget, with this scope plus props |
321
+
322
+ - **Results**: `r.a` is the hit's attributes, `r.c` its computed attributes
323
+ (functions in `_global/_init.ts`, `computedAttributes`). `r.c.url` is always a
324
+ web link; a `javascript:` or `data:` value becomes `''`. Links you bind from
325
+ `r.a` or your own computed attributes, and `unsafeHTML`, get no such check:
326
+ keep catalog data out of them, or check it. With Trusted Types enforced, the
327
+ site's `trusted-types` directive must allow the `lit-html` policy.
328
+ - **Helpers** in every scope: `t(key, params)`, `price(n)`, `plain()`,
329
+ `first()`, `include()`, and `img(r, i)` (a complete `<img>` in its placeholder
330
+ box) / `image(r, i)` (its attributes, for your own markup), configured by
331
+ `_global.images` (srcset, sizes, the eager first row, placeholders).
332
+ - **Pagination** is "show more" only: templates get `results`, `more` and
333
+ `showMore`; there are no numbered pages.
334
+ - **Translations** are JSON dictionaries per locale (`src/locales/<locale>.json`,
335
+ `{name}` placeholders, plural forms by `Intl.PluralRules` category); only the
336
+ site's locale is loaded. Override keys in `_translations.ts`, keyed by locale:
337
+ `_global` for all widgets, a widget's own for that widget. Give
338
+ `__integration.ts` their glob without `eager`
339
+ (`translations: import.meta.glob('./*/_translations.ts', { import: 'default' })`)
340
+ and they load with their widget instead of on every page; `_init.ts` then
341
+ does not see them.
342
+
343
+ ### Behaviour switches
344
+
345
+ On by default unless noted; each falls back to what the page would do anyway.
346
+
347
+ | Config | What it does | Turn it off when |
348
+ |---|---|---|
349
+ | `search.inPlaceQuery` | a new query on the search page renders in place, without a page load | the host's page-view analytics or server-rendered parts of the search page must follow every query |
350
+ | `search.prefetchWhileTyping` | the search result is fetched once the typed phrase settles, so Enter shows it at once | |
351
+ | `restoreOnBack` | Back from a product restores the listing and the followed tile's position | |
352
+ | `prefetchNextPage` | "show more" is fetched as the shopper nears the end | |
353
+ | `contentVisibility` (opt-in) | tiles out of view skip style, layout and paint, each at its own measured size: a long list's re-render about halves | keep it off where a tile draws outside its box (a hover panel, a badge hanging out) or holds a `position: fixed` element |
354
+ | `_global.prefetchFilters` | a filter, sort or chip is fetched on hover, touch or focus | |
355
+ | `_global.speculation` | product pages are prerendered on hover (`'prerender'`, `'prefetch'`, `false`) | the site's server or its product-page scripts can't take a render per unfollowed hover |
356
+
357
+ ### Analytics
358
+
359
+ Luma sends analytics events to the Luigi's Box AI events API
360
+ ([docs](https://docs.luigisbox.ai/analytics/overview/)) as the integration's
361
+ `_global/_analytics.ts` describes them. Every event sent is one
362
+ `collect.<event>` line in that file, and nothing else is sent: reading the file
363
+ top to bottom tells you what the site collects. Without the file there is no
364
+ analytics at all (no code loaded, nothing sent).
365
+
366
+ `__integration.ts` hands the file over as a lazy glob:
367
+ `analytics: import.meta.glob('./*/_analytics.ts', { import: 'default' })`.
368
+ `analytics: false` in `_global/_config.ts` turns analytics off whatever the
369
+ file says. Luma does not check consent: whatever includes Luma on the page
370
+ (the Luigi's Box loader) decides that, and once Luma runs, events are sent.
371
+
372
+ Each line names the event, when it happens and how it is read:
373
+
374
+ ```ts
375
+ collect.addToCart // the event sent: an add-to-cart
376
+ .on(dl('add_to_cart')) // when: each dataLayer push with event 'add_to_cart'
377
+ .read(fromGa4.addToCart) // how: the push read as GA4 ecommerce
378
+ ```
379
+
380
+ `.read()` takes a reader (`fromGa4.…`, `fromLuma.…`) or a function of the
381
+ trigger's context that returns what the event sends (`null`: nothing).
382
+ `.when(f)`, `.throttle(ms)` and `.once()` (once per page) go before `.read()`.
383
+ A typical integration:
384
+
385
+ ```ts
386
+ // integration/_global/_analytics.ts
387
+ import { collect, dl, fromGa4, fromLuma, lumaCarts, lumaClicks, lumaPages, ref } from '@/analytics';
388
+
389
+ ref.product.by('sku'); // product references by the catalog's `sku` field: product/sku:<value>
390
+
391
+ collect.pageView.on(lumaPages()).read(fromLuma.pageView);
392
+ collect.click.on(lumaClicks()).read(fromLuma.click);
393
+ collect.addToCart.on(lumaCarts()).read(fromLuma.addToCart);
394
+ collect.addToCart.on(dl('add_to_cart')).read(fromGa4.addToCart);
395
+ collect.purchase.on(dl('purchase')).read(fromGa4.purchase);
396
+ ```
397
+
398
+ **Events**
399
+
400
+ | Event | What `.read()` returns |
401
+ |---|---|
402
+ | `pageView` | the page's reference, or `null` for a page without one. On `lumaPages()`, one pageview per page, sent at once. On any other trigger, one per page that waits for its reference from that trigger (`.timeout(ms)`, default 1000 ms) and is sent without one after that. One `pageView` line per file |
403
+ | `click` | a product click: a reference, or `{ reference }` |
404
+ | `addToCart` | `{ reference, count?, price? }`, or a list of them |
405
+ | `purchase` | `{ id?, currency?, items: [{ reference, title, count, total_price, was_discounted? }] }`; sent once per `id`, so a reloaded thank-you page does not count twice |
406
+ | `search`, `autocomplete`, `recommender(name)` | a list from the site's own service: `{ query?, items, filters? }`. Not sent on a page where Luma runs that service itself (the API records Luma's own results) |
407
+
408
+ **Triggers**
409
+
410
+ | Trigger | When | Context |
411
+ |---|---|---|
412
+ | `lumaPages()` | each page: the first, the next page of a single-page app, a page restored from the back/forward cache, a prerendered page once it is shown. Filtering or sorting a Luma listing is not a new page | `{ url, referrer }` |
413
+ | `lumaClicks()` | a click on a product in a Luma widget: any link to it, middle and modifier clicks included, and a pick in the autocomplete | `{ reference, result, index, list }` |
414
+ | `lumaCarts()` | a click on a `[data-lbx-cart]` button in a Luma product tile (its value: the quantity). `_global.addToCart(result, { count, el, event })` adds the product to the site's cart, with or without analytics | `{ reference, result, index, list, count, price? }` |
415
+ | `dl(event)` | a dataLayer push with that event name, object or `gtag()` form, including pushes made before Luma loaded. `_global.dataLayer` in `_config.ts` names another layer | `{ event, eventType, data, ecommerce }` |
416
+ | `domClick(selector)` | a click (or middle click) on an element of the site's that matches `selector` | `{ element, event }` |
417
+ | `urlChange()` | a navigation in a single-page app | `{ url, previousUrl }` |
418
+ | `mutationOf(selector, { root? })` | the site renders that element into the page, or renders into it; read once the browser is idle | `{ element }` |
419
+ | `xhrMatching(({ url, method }) => …)` | a response to one of the site's own requests (XMLHttpRequest or fetch) that the test matches | `{ url, method, response }` |
420
+ | `manual()` | `fire(data)` is called: `const { trigger, fire } = manual()` | `{ data }` |
421
+
422
+ **Readers**
423
+
424
+ | Reader | Reads |
425
+ |---|---|
426
+ | `fromLuma.pageView`, `.click`, `.addToCart` | what Luma's own triggers saw (`lumaPages()`, `lumaClicks()`, `lumaCarts()`). Products are referenced by their catalog `@id` |
427
+ | `fromGa4.addToCart`, `.purchase`, `.click`, `.pageView`, `.search`, `.autocomplete`, `.recommender` | GA4 ecommerce: `add_to_cart`, `purchase`, `select_item`, `view_item`, `view_item_list`. The data is taken from the push's `ecommerce` (or `gtag()`'s parameters), or from the push itself when its `items` are there. Items are referenced by `ref.product(item_id)`; purchases get line totals, and `was_discounted` from `discount` |
428
+
429
+ Each reader belongs to one event: `collect.purchase…read(fromGa4.addToCart)`
430
+ is a type error, and throws when the file runs.
431
+
432
+ **References** follow `<type>/<field>:<value>`
433
+ ([docs](https://docs.luigisbox.ai/analytics/references/)).
434
+ `ref.product(value)` uses the field set by `ref.product.by(field)` (default
435
+ `@id`); `ref.product(field, value)` names another field. `ref.variant`,
436
+ `ref.category` and `ref.brand` work the same way. `user.identify(fn)` sends
437
+ the signed-in shopper's own id as `user_id`.
438
+
439
+ **Nothing is lost when the page changes.** A click or add-to-cart on a Luma
440
+ product is recorded the moment it happens, and dataLayer pushes on a page the
441
+ shopper leaves before analytics has loaded (a thank-you page closed at once)
442
+ are kept. The next page sends them, dated as they happened; a purchase is still
443
+ sent once.
444
+
445
+ **Size.** Analytics loads as one chunk, after the page has loaded, holding
446
+ only what the file uses: every event, trigger and reader is a separate export
447
+ (`fromGa4.purchase` brings that reader alone). Gzip sizes:
448
+
449
+ | `_analytics.ts` | Size |
450
+ |---|---|
451
+ | none | nothing loaded |
452
+ | pageviews only | 2.2 kB |
453
+ | pageviews, add-to-carts and purchases from GA4 | 3.6 kB |
454
+ | + Luma's own clicks and add-to-carts (`lumaClicks()`, `lumaCarts()`) | +0.2 kB |
455
+ | + `domClick()` | +0.2 kB |
456
+ | + a `fromGa4` list reader (`.search`, `.autocomplete`, `.recommender`) | +0.1 kB |
457
+ | + `mutationOf()`, `xhrMatching()` | +0.3, +0.4 kB |
458
+ | + `user.identify()`, a page reference, a list read by a function of yours, `.once()`, `.throttle()` | under 0.1 kB each |
459
+
460
+ **A site's own search and suggestions** (where Luma does not render them) are
461
+ read with functions of your own, for example:
462
+
463
+ ```ts
464
+ import { collect, dl, domClick, fromGa4, mutationOf, ref, xhrMatching } from '@/analytics';
465
+
466
+ // the site's search results, rendered into #results
467
+ collect.search.on(mutationOf('#results')).once().read(({ element }) => ({
468
+ query: { string: new URLSearchParams(location.search).get('q') ?? '' },
469
+ items: [...element.querySelectorAll('[data-sku]')].map((tile, n) => ({ title: tile.textContent ?? '', type: 'product', reference: ref.product(tile.getAttribute('data-sku')!), position: n + 1 })),
470
+ }));
471
+ // clicks on those results
472
+ collect.click.on(domClick('#results [data-sku] a')).read(({ element }) => ref.product(element.closest('[data-sku]')!.getAttribute('data-sku')!));
473
+ // the site's suggestions, from its own API
474
+ collect.autocomplete.on(xhrMatching(({ url }) => url.includes('/suggest'))).read(({ response }) => ({ items: /* … */ [] }));
475
+ // the product page's product, from GA4's view_item
476
+ collect.pageView.on(dl('view_item')).read(fromGa4.pageView);
477
+ ```
478
+
479
+ ## Browser support
480
+
481
+ Luma targets [Baseline](https://web.dev/baseline) **Widely available**; anything
482
+ newer (Popover API, Navigation API, Speculation Rules) is
483
+ feature-detected and optional. **HTTPS only**: on a plain-HTTP page Luma does
484
+ not start and the site keeps its own search. Tested on Chromium, Firefox and
485
+ WebKit, desktop and phone, on every change.
486
+
487
+ An integration repository runs the same check on its `integration/` folder:
488
+ `yarn luma baseline`, and `yarn luma
489
+ baseline --built dist` over a build's output (below, "Tools for an
490
+ integration repository").
491
+
492
+ ## Known limitations
493
+
494
+ - **Autocomplete needs the Popover API**: without it (Safari < 17, Firefox
495
+ < 125) the site keeps its own suggestions.
496
+ - **Loader without the snippet**: the site's own listing may flash before
497
+ Luma's on a hard load.
498
+ - **Analytics**: a search shown in place fires no page view in the site's own
499
+ tags (Luma's `lumaPages()` counts it). Prefetches and prerenders count in the
500
+ API's request numbers, though never as searches or impressions. Analytics
501
+ loads after the page has: a visitor who leaves before that sends no pageview
502
+ and no click on the site's own elements (`domClick()`); clicks and
503
+ add-to-carts on Luma's products and dataLayer pushes are kept for the next
504
+ page. A/B tests are not supported yet (`listing_ab_test` is always `false`).
505
+ - **Prerendering** costs the site a page render per unfollowed hover
506
+ (`speculation: false` turns it off).
507
+ - **Early hold**: the snippet carries no site's rules, so the first page
508
+ view is not held.
509
+ - **Host routers out of reach**: where Luma cannot find the host's router (Vue
510
+ Router 4 when its app is not discoverable, Angular in production), the
511
+ router can overwrite Luma's history entry or show a stale query in its search
512
+ box. `inPlaceQuery: false` for such a site.
513
+
514
+ ## Development
515
+
516
+ Node 24 or later, Yarn 4 (corepack).
517
+
518
+ ### Seeing an integration on the storefront
519
+
520
+ An integration is developed in its own repository. How you see it on the live
521
+ site depends on whether the site includes the Luigi's Box loader yet.
522
+
523
+ **The site includes the loader.** Serve the integration and add
524
+ `?_lbx_env=development` to any page of the site:
525
+
526
+ ```bash
527
+ yarn dev # serves the integration on https://localhost:8069
528
+ ```
529
+
530
+ ```
531
+ https://www.shop.example/some-category?_lbx_env=development
532
+ ```
533
+
534
+ The loader then imports the integration from your dev server instead of the
535
+ published build (a red banner says so); an edit shows on reload.
536
+ `LBX_DEV_ORIGINS` (comma-separated) limits which sites may load from it.
537
+
538
+ **The site does not include the loader yet.** Open the storefront with the
539
+ integration injected as the site would include it (the early-hold snippet,
540
+ then the build):
541
+
542
+ ```bash
543
+ yarn luma chrome # builds, opens the home page in your Chrome
544
+ yarn luma chrome /some-category # another page
545
+ yarn luma chrome --dev # from yarn dev: an edit shows on reload, no build
546
+ ```
547
+
548
+ It needs the storefront's host in `luma.config.mjs` (`export const host =
549
+ 'www.shop.example'`). The site's own Luigi's Box scripts are blocked, nothing
550
+ else of the site changes, and the browser profile is kept per storefront
551
+ (`~/.lbx-chrome/<host>`), apart from your own. `--check` prints what Luma did
552
+ and exits; `--help` lists the rest ([`chrome/run.mjs`](chrome/run.mjs)).
553
+
554
+ Either way, `window.__lbx` in the console is Luma's state (`health.errors`,
555
+ `health.timeline`). `yarn build` emits `dist/`.
556
+
557
+ ### Working on Luma itself
558
+
559
+ Luma is developed by Luigi's Box. Every change is type-checked and runs the
560
+ unit and browser suites and the performance gates on Chromium, Firefox and
561
+ WebKit before it ships.
562
+
563
+ ### Tools for an integration repository
564
+
565
+ An integration repository's `package.json` has one script for
566
+ all of them, `"luma": "luma"` (the package's command), and its
567
+ `luma.config.mjs` the storefront's host (`export const host =
568
+ 'www.shop.example'`): a tool Luma adds is there once Luma is updated, nothing to
569
+ change in the integration. `yarn luma` lists them; `yarn luma tooling --write`
570
+ sets an integration up
571
+ ([`bin/tools.mjs`](bin/tools.mjs), [`build/tooling.mjs`](build/tooling.mjs)).
572
+
573
+ | Command | What |
574
+ |---|---|
575
+ | `yarn luma chrome [path]` | the live storefront in your Chrome with the integration on it as the site would include it (the early-hold snippet, then the build), whether or not the site includes it yet; the site's own Luigi's Box scripts blocked, nothing else changed; `--dev` from `yarn dev`, `--check` prints what Luma did and exits ([`chrome/run.mjs`](chrome/run.mjs)) |
576
+ | `yarn luma a11y` | WCAG 2.2 AA check of the integration's widgets, by keyboard too ([`docs/ACCESSIBILITY.md`](docs/ACCESSIBILITY.md) §8) |
577
+ | `yarn luma parity` | compares the site's search and category UI with Luma's, component by component ([`docs/PARITY.md`](docs/PARITY.md)) |
578
+ | `yarn luma footprint` | what Luma itself costs per page type: the entry's size and round trips, scripts and bytes loaded, the script chain, main-thread and compile time, API requests, time to ready; on the integration's stub page ([`footprint/run.mjs`](footprint/run.mjs)) |
579
+ | `yarn luma tasks` | Luma's main-thread tasks per scenario (pages, a state change, show more, Back, typing) at 4x CPU on the storefront: the worst task's own time against a 16 ms budget, its functions by source line, the site's code under Luma's calls apart ([`tasks/run.mjs`](tasks/run.mjs)) |
580
+ | `yarn luma baseline` | nothing in `integration/` newer than Baseline Widely available (`--built dist`: the build) |
581
+
582
+ The integration's settings for the tools are its `luma.config.mjs`
583
+ ([`bin/config.mjs`](bin/config.mjs) loads it, with Luma's defaults). Every
584
+ export is optional:
585
+
586
+ ```js
587
+ // The storefront's host name: the tools open it (LBX_HOST overrides it). Without
588
+ // it they use the stub page (`yarn luma chrome` needs it).
589
+ export const host = 'www.shop.example';
590
+ // The site's name (LBX_SITE overrides it); default: package.json's name.
591
+ export const site = 'shop';
592
+ // A page in the site's own markup and stylesheet, for the tools' runs on the
593
+ // stub page (a string, or a promise of one).
594
+ export const stubPage = (url) => `<!DOCTYPE html>…`;
595
+ // What the tools' scenarios load: a category page, a search, a product page,
596
+ // the header search input.
597
+ export const fixtures = { category: '/some-category', query: 'shoes', product: '/some-product', input: 'input#search' };
598
+ // The pages footprint and tasks measure, { label: path }; default: home,
599
+ // fixtures.category, a search for fixtures.query, fixtures.product.
600
+ export const perf = { pages: { home: '/', category: '/some-category', search: '/search?q=shoes' } };
601
+ ```
602
+
603
+ Without a `stubPage`: a neutral page with a header search form (`input
604
+ name=q`), a search anchor `<div id="lbx-search-anchor">` on `/search`, and
605
+ elsewhere a category listing `<div id="lbx-listing">`. Older integrations kept
606
+ these settings in `edge/rig.mjs`: the tools refuse that name, `yarn luma
607
+ tooling --write` moves it.
608
+
609
+ How to make an integration fast and light, and which tool measures what:
610
+ [`docs/INTEGRATION-PERFORMANCE.md`](docs/INTEGRATION-PERFORMANCE.md).
611
+
612
+ ## Documentation
613
+
614
+ - [`docs/INTEGRATION-PERFORMANCE.md`](docs/INTEGRATION-PERFORMANCE.md): what to measure in integration work, and the metrics to optimize.
615
+ - [`docs/ACCESSIBILITY.md`](docs/ACCESSIBILITY.md): what Luma guarantees, what templates must do.
616
+ - [`docs/PARITY.md`](docs/PARITY.md): the parity check.