@luigisbox/luma 0.1.0 → 0.3.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 (41) hide show
  1. package/README.md +208 -76
  2. package/build/vite.mjs +9 -0
  3. package/chrome/block.mjs +8 -0
  4. package/chrome/run.mjs +10 -5
  5. package/docs/ACCESSIBILITY.md +9 -5
  6. package/docs/INTEGRATION-PERFORMANCE.md +5 -6
  7. package/package.json +1 -1
  8. package/parity/hold.mjs +1 -1
  9. package/src/analytics/chain.ts +137 -0
  10. package/src/analytics/collect.ts +64 -0
  11. package/src/analytics/datalayer.ts +67 -0
  12. package/src/analytics/from-ga4.ts +68 -0
  13. package/src/analytics/from-luma.ts +21 -0
  14. package/src/analytics/index.ts +24 -0
  15. package/src/analytics/outbox.ts +118 -0
  16. package/src/analytics/page.ts +46 -0
  17. package/src/analytics/pageview.ts +82 -0
  18. package/src/analytics/ref.ts +19 -0
  19. package/src/analytics/setup.ts +41 -0
  20. package/src/analytics/start.ts +53 -0
  21. package/src/analytics/triggers.ts +239 -0
  22. package/src/analytics/user.ts +12 -0
  23. package/src/analytics/wire.ts +67 -0
  24. package/src/api/token.ts +3 -2
  25. package/src/core/entries.ts +10 -6
  26. package/src/core/integration.ts +6 -0
  27. package/src/core/kept.ts +25 -0
  28. package/src/core/stash.ts +24 -4
  29. package/src/core/tiles.ts +137 -0
  30. package/src/main.ts +54 -4
  31. package/src/types.ts +29 -2
  32. package/src/widgets/autocomplete/index.ts +206 -31
  33. package/src/widgets/autocomplete/style-autocomplete.css +11 -5
  34. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +1 -1
  35. package/src/widgets/listing/element.ts +212 -21
  36. package/src/widgets/listing/instant.ts +83 -23
  37. package/src/widgets/listing/stash.ts +31 -8
  38. package/src/widgets/listing/view.ts +12 -2
  39. package/src/widgets/recommender/index.ts +15 -4
  40. package/src/widgets/search/range.ts +40 -18
  41. package/src/widgets/search/style-search.css +2 -2
package/README.md CHANGED
@@ -1,53 +1,57 @@
1
1
  # Luma
2
2
 
3
- The framework for Luigi's Box frontend integrations: autocomplete, search
4
- results, category listings (PLP) and recommenders on the Luigi's Box AI API,
5
- for sites that can only include our script. Optionally, listings arrive
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
6
  server-rendered through the site's own cache, from a rendering service Luigi's
7
7
  Box runs.
8
8
 
9
- > **Pre-release.** Until 1.0, a minor version (0.2.0) may change the
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.4.0) may change the
10
14
  > integration API (config keys, template scope, the build); a patch version
11
- > (0.1.1) only fixes. Depend on `^0.1.0` to take fixes without breaking
15
+ > (0.3.1) only fixes. Depend on `^0.3.0` to take fixes without breaking
12
16
  > changes.
13
17
 
14
18
  ## Core principles
15
19
 
16
20
  ### The problem
17
21
 
18
- Luma runs on storefronts we don't own, often included as a single script tag.
19
- That sets the constraints:
22
+ Luma runs on storefronts it doesn't own, often included as a single script
23
+ tag. That sets the constraints:
20
24
 
21
- 1. **The page is someone else's.** Their framework, router, CSS and scripts
22
- were there first and keep changing. We work around them without breaking
23
- anything.
24
- 2. **We are judged by how the page feels.** If search or listings feel slower,
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,
25
29
  flicker or jump compared with the site's own, the integration is a
26
30
  regression, however good the results are.
27
- 3. **The shopper should not notice us.** Results look like the site's own UI
31
+ 3. **The shopper should not notice Luma.** Results look like the site's own UI
28
32
  and are usable by everyone.
29
- 4. **One framework, many integrations.** Every change to `src/` ships to every
30
- client, so the rules must hold in general, not for one site.
31
- 5. **Measured, not claimed.** We cannot check every client's site by hand, so
32
- each principle below has a test, a gate or a tool behind it.
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.
33
37
 
34
38
  ### A. A guest on the page
35
39
 
36
40
  - **Do no harm, always fall back.** Luma adds only its own nodes
37
- (`data-lbx`), hides the host's UI with CSS and never removes their nodes. It
38
- never throws into the page, every network call has a deadline, and nothing
39
- of ours keeps a page out of the back/forward cache. A failed request, a URL
40
- we cannot serve, a listing not rendered within 4 s, a non-HTTPS page or a
41
- missing browser feature: the site's own UI comes back as if we were never
42
- there.
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.
43
47
  - **Navigation stays the host's.** The site's router owns the URL and history.
44
- Our history entries carry no state of ours (null, or for SvelteKit and
45
- Astro only the router's own bookkeeping keys); we never rewrite the host's.
46
- Anything we cannot do exactly as the browser would (POST, a new window, a
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
47
51
  modifier key) stays the browser's. The one exception: a new query from the
48
- site's search form while on our search page is shown in place.
52
+ site's search form while on Luma's search page is shown in place.
49
53
  - **Sense and reconcile, not imperative patching.** Host signals (URL changes,
50
- DOM mutations, our nodes removed) only mean "reconcile soon". At most once
54
+ DOM mutations, Luma's nodes removed) only mean "reconcile soon". At most once
51
55
  per frame the reconciler makes the page match declarative rules: it removes
52
56
  nodes whose rule is inactive, puts the same instance back if the host
53
57
  removed it, and waits for the anchor to settle. A missed signal is harmless:
@@ -65,24 +69,24 @@ That sets the constraints:
65
69
  ### B. Faster than the site it replaces
66
70
 
67
71
  - **No flicker, no layout shift.** With the inline snippet in `<head>`, the
68
- host's listing is held before first paint and swapped for ours in one frame.
69
- Its height is reserved from the last visit. Target: zero layout shift after
70
- ours is shown.
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.
71
75
  - **A small entry, one round trip.** The entry is at most 14 kB brotli (one TCP
72
76
  initial congestion window). Each widget's code, templates, styles and
73
77
  dictionary are 2–7 kB chunks loaded only when that widget can run: a site
74
78
  without recommenders never downloads that code.
75
- - **Never block the main thread.** Target: no task of ours over 16 ms (a frame
79
+ - **Never block the main thread.** Target: no task of Luma's over 16 ms (a frame
76
80
  at 60 fps) at 4x CPU; the test gate is 30 ms. Typing in autocomplete reaches
77
81
  the next paint within 50 ms (INP). Visual feedback in the handler; fetches
78
- and history writes in later tasks. No layout reads after our DOM writes in
82
+ and history writes in later tasks. No layout reads after Luma's DOM writes in
79
83
  the same task. Large listings render in steps that each fit a frame.
80
84
  - **Instant.** Results are prefetched while the shopper types or hovers a
81
85
  filter, Back restores the listing at the tile they followed, product pages
82
86
  are prerendered on intent (Speculation Rules, Chromium).
83
87
  - **Server-side rendering, same code.** The same integration and templates
84
- render in the browser and on our servers. The site can serve our listing in
85
- its own HTML through SSI/ESI in its web server or CDN; the browser takes the
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
86
90
  server-rendered listing over. The server renderer and everything it imports
87
91
  stay free of DOM access.
88
92
 
@@ -105,10 +109,10 @@ That sets the constraints:
105
109
  feature-detected and the page works without it. `yarn luma baseline`
106
110
  enforces it ([Browser support](#browser-support)).
107
111
 
108
- ### D. How we work
112
+ ### D. How Luma is built
109
113
 
110
- - **One framework, many integrations.** Nothing site-specific in this
111
- repository; each integration has its own, with Luma as the `@luigisbox/luma`
114
+ - **One framework, many integrations.** Nothing site-specific in Luma itself;
115
+ each integration has its own repository, with Luma as the `@luigisbox/luma`
112
116
  package. `_config.ts` is JSON; code goes in `_init.ts`.
113
117
  - **Measured, not claimed.** Every principle has a gate: budgets and
114
118
  main-thread limits, request counts, the bfcache test, the baseline lint,
@@ -116,16 +120,16 @@ That sets the constraints:
116
120
  Chromium, Firefox and WebKit, desktop and phones, on every change. Every fix
117
121
  lands with a test that fails without it.
118
122
 
119
- What we accept in exchange: [Known limitations](#known-limitations).
123
+ The trade-offs that come with this: [Known limitations](#known-limitations).
120
124
 
121
125
 
122
- ## Repository layout
126
+ ## What's in the package
123
127
 
124
128
  | Path | What |
125
129
  |---|---|
126
130
  | `src/` | the framework: config, sensing and placement, takeover, templates, API client, widgets |
127
131
  | `build/vite.mjs` | the Vite config every integration builds with |
128
- | `snippet/` | the inline early-hold snippet (identical for every client) |
132
+ | `snippet/` | the inline early-hold snippet (identical for every site) |
129
133
  | `a11y/` | `yarn luma a11y`: an integration's WCAG 2.2 AA check |
130
134
  | `parity/` | `yarn luma parity`: an integration compared with the site it replaces |
131
135
  | `bin/` | `yarn luma <tool>`: every tool below, from an integration repository, with one script |
@@ -133,17 +137,18 @@ What we accept in exchange: [Known limitations](#known-limitations).
133
137
  | `chrome/` | `yarn luma chrome`: the live storefront in your Chrome with an integration on it, before the site includes it |
134
138
  | `tasks/` | `yarn luma tasks`: an integration's main-thread tasks over a frame's budget on the storefront, by source line |
135
139
 
136
- Integrations do not live here: each has its own repository with Luma as the
137
- `@luigisbox/luma` package (older ones: the `luma/` submodule;
138
- [Integration repository](#integration-repository)).
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)).
139
143
 
140
- ## What the customer includes
144
+ ## What the site includes
141
145
 
142
146
  Everything goes in the site's HTML, in `<head>`, on every page.
143
- `<distribution id>` is the site's distribution (`lbdr_…`).
147
+ `<distribution id>` is the site's distribution (`lbdr_…`), provided by
148
+ Luigi's Box.
144
149
 
145
150
  **Loader only** (a tag manager is fine): the site's own listing may flash
146
- before ours on a hard load.
151
+ before Luma's on a hard load.
147
152
 
148
153
  ```html
149
154
  <script async src="https://cdn.eu1.luigisbox.ai/b/<distribution id>/loader.js"></script>
@@ -167,7 +172,7 @@ first and the snippet as the last element of `<head>`.
167
172
  ```
168
173
 
169
174
  **Connection hints** (recommended, first in `<head>`, before the snippet): the
170
- connections our requests need, opened while the HTML is still arriving instead
175
+ connections Luma's requests need, opened while the HTML is still arriving instead
171
176
  of after the loader and the entry have run (the entry adds the same hints, but
172
177
  only once `<body>` starts). On a first visit they take the TCP and TLS setup
173
178
  (one to three round trips per host) off the path to the listing. Use the
@@ -185,7 +190,7 @@ catalog's product images.
185
190
  - The CDN twice: the loader is a classic script (no CORS) and the chunks are
186
191
  modules loaded with CORS. A browser does not share connections between the two
187
192
  modes. Drop the plain one if the Network panel shows a single connection.
188
- - The API and auth hosts with `crossorigin`: our requests are CORS `fetch()`es.
193
+ - The API and auth hosts with `crossorigin`: Luma's requests are CORS `fetch()`es.
189
194
  A preconnect without it opens a connection they cannot use.
190
195
  - The image host without `crossorigin` (images are no-CORS). Leave it out if
191
196
  it is the site's own host (connected already).
@@ -193,7 +198,7 @@ catalog's product images.
193
198
  connections, and an unused one is closed after about 10 s.
194
199
  - On listing and search pages, `fetchpriority="high"` on the loader's tag moves
195
200
  it ahead of the page's images (an `async` script is otherwise fetched at low
196
- priority). Our listing is those pages' main content. Measure it with
201
+ priority). Luma's listing is those pages' main content. Measure it with
197
202
  `yarn luma footprint` and DevTools before keeping it.
198
203
 
199
204
  The whole `<head>` start of a listing page, in order:
@@ -215,15 +220,15 @@ What to measure, and the numbers to optimize for:
215
220
  [docs/INTEGRATION-PERFORMANCE.md](docs/INTEGRATION-PERFORMANCE.md).
216
221
 
217
222
  **Server-side includes**: the listing arrives in the site's HTML, through
218
- their web server or CDN config, with the loader as above. The site adds an
223
+ the site's web server or CDN config, with the loader as above. The site adds an
219
224
  include (nginx SSI or Varnish/CDN ESI) and a marker at its listing, and a short
220
- snippet to its server config; Luigi's Box provides both for the site. The
225
+ snippet to its server config; Luigi's Box provides both. The
221
226
  include sits outside any root the site's own framework hydrates, and the site's
222
227
  own listing stays the fallback.
223
228
 
224
- If our listing does not render within 4 s, the request fails or the URL is one
225
- we cannot serve, the site's own listing is shown and the page stays theirs
226
- until the next visit.
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.
227
232
 
228
233
  ## Integration repository
229
234
 
@@ -244,18 +249,15 @@ Its `tsconfig.json` extends `@luigisbox/luma/tsconfig.json`, includes
244
249
  `integration/**/*` and `node_modules/@luigisbox/luma/src/**/*`, and maps `@/*`
245
250
  to `./node_modules/@luigisbox/luma/src/*`. Its `package.json` depends on
246
251
  `@luigisbox/luma`, `vite` 7, `typescript` and `vite-plugin-mkcert` (Luma brings
247
- `lit-html`, `@lit-labs/ssr-client` and its tools' dependencies). An older
248
- repository has Luma as the `luma/` submodule instead (`./luma/…` for
249
- `@luigisbox/luma/…`, and `yarn --cwd luma install` for the tools); both build
250
- the same.
252
+ `lit-html`, `@lit-labs/ssr-client` and its tools' dependencies).
251
253
 
252
254
  The package holds what an integration uses: the framework's source, the build
253
- config, `yarn luma` and the client tools, and their docs.
255
+ config, `yarn luma` and its tools, and their docs.
254
256
 
255
257
  ```
256
258
  integration/
257
259
  __integration.ts hands the folder to the framework (startIntegration)
258
- _global/ _config.ts _init.ts [_widget.ts] _translations.ts style-*.css
260
+ _global/ _config.ts _init.ts [_widget.ts] [_analytics.ts] _translations.ts style-*.css
259
261
  template-*.ts shared by every widget (a tile, …)
260
262
  autocomplete/ _config.ts [_init.ts] [_widget.ts] [style-host.css] [template-autocomplete-*.ts]
261
263
  search/ _config.ts _translations.ts style-host.css template-search-*.ts
@@ -277,7 +279,7 @@ integration/
277
279
  context is made, `_global/_widget.ts` first. `__integration.ts` hands it
278
280
  over as a lazy glob: `widgetInits: import.meta.glob('./*/_widget.ts', { import: 'default' })`.
279
281
  - **`style-host.css`** hides the host's own listing or suggestions, only while
280
- the page is ours. Other `style-*.css` load with their widget. Luma's built-in
282
+ Luma has the page. Other `style-*.css` load with their widget. Luma's built-in
281
283
  CSS sits in `@layer lbx`, so the integration's and the site's CSS win.
282
284
  - **Templates override by name**: a widget's built-in templates, then
283
285
  `_global/`, then the widget's own folder. Names: `search-box`, `search-header`,
@@ -329,6 +331,11 @@ export default ({ r, i, img, price }: SearchResultScope) => html`
329
331
  `_global.images` (srcset, sizes, the eager first row, placeholders).
330
332
  - **Pagination** is "show more" only: templates get `results`, `more` and
331
333
  `showMore`; there are no numbered pages.
334
+ - **No results under filters**: the scope still holds the selected groups,
335
+ their `chips` and `clear`. A template that leaves the facets out when
336
+ `total` is 0 must keep them (or at least `clear`) while `chips.length` is
337
+ not 0: otherwise, where no drawer or chips show elsewhere, Back is the
338
+ shopper's only way to undo the filter.
332
339
  - **Translations** are JSON dictionaries per locale (`src/locales/<locale>.json`,
333
340
  `{name}` placeholders, plural forms by `Intl.PluralRules` category); only the
334
341
  site's locale is loaded. Override keys in `_translations.ts`, keyed by locale:
@@ -346,12 +353,134 @@ On by default unless noted; each falls back to what the page would do anyway.
346
353
  |---|---|---|
347
354
  | `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 |
348
355
  | `search.prefetchWhileTyping` | the search result is fetched once the typed phrase settles, so Enter shows it at once | |
349
- | `restoreOnBack` | Back from a product restores the listing and the followed tile's position | |
356
+ | `restoreOnBack` | Back from a product restores the listing (every page shown) and the followed tile's position, as does the page loaded again within a minute of following a link (a reload, a link whose server redirects back to it); Luma turns the browser's own scroll restoration (`history.scrollRestoration`) off for the listing's history entry until it has placed the page, unless the site set it | |
350
357
  | `prefetchNextPage` | "show more" is fetched as the shopper nears the end | |
351
358
  | `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 |
352
359
  | `_global.prefetchFilters` | a filter, sort or chip is fetched on hover, touch or focus | |
353
360
  | `_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 |
354
361
 
362
+ ### Analytics
363
+
364
+ Luma sends analytics events to the Luigi's Box AI events API
365
+ ([docs](https://docs.luigisbox.ai/analytics/overview/)) as the integration's
366
+ `_global/_analytics.ts` describes them. Every event sent is one
367
+ `collect.<event>` line in that file, and nothing else is sent: reading the file
368
+ top to bottom tells you what the site collects. Without the file there is no
369
+ analytics at all (no code loaded, nothing sent).
370
+
371
+ `__integration.ts` hands the file over as a lazy glob:
372
+ `analytics: import.meta.glob('./*/_analytics.ts', { import: 'default' })`.
373
+ `analytics: false` in `_global/_config.ts` turns analytics off whatever the
374
+ file says. Luma does not check consent: whatever includes Luma on the page
375
+ (the Luigi's Box loader) decides that, and once Luma runs, events are sent.
376
+
377
+ Each line names the event, when it happens and how it is read:
378
+
379
+ ```ts
380
+ collect.addToCart // the event sent: an add-to-cart
381
+ .on(dl('add_to_cart')) // when: each dataLayer push with event 'add_to_cart'
382
+ .read(fromGa4.addToCart) // how: the push read as GA4 ecommerce
383
+ ```
384
+
385
+ `.read()` takes a reader (`fromGa4.…`, `fromLuma.…`) or a function of the
386
+ trigger's context that returns what the event sends (`null`: nothing).
387
+ `.when(f)`, `.throttle(ms)` and `.once()` (once per page) go before `.read()`.
388
+ A typical integration:
389
+
390
+ ```ts
391
+ // integration/_global/_analytics.ts
392
+ import { collect, dl, fromGa4, fromLuma, lumaCarts, lumaClicks, lumaPages, ref } from '@/analytics';
393
+
394
+ ref.product.by('sku'); // product references by the catalog's `sku` field: product/sku:<value>
395
+
396
+ collect.pageView.on(lumaPages()).read(fromLuma.pageView);
397
+ collect.click.on(lumaClicks()).read(fromLuma.click);
398
+ collect.addToCart.on(lumaCarts()).read(fromLuma.addToCart);
399
+ collect.addToCart.on(dl('add_to_cart')).read(fromGa4.addToCart);
400
+ collect.purchase.on(dl('purchase')).read(fromGa4.purchase);
401
+ ```
402
+
403
+ **Events**
404
+
405
+ | Event | What `.read()` returns |
406
+ |---|---|
407
+ | `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 |
408
+ | `click` | a product click: a reference, or `{ reference }` |
409
+ | `addToCart` | `{ reference, count?, price? }`, or a list of them |
410
+ | `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 |
411
+ | `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) |
412
+
413
+ **Triggers**
414
+
415
+ | Trigger | When | Context |
416
+ |---|---|---|
417
+ | `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 }` |
418
+ | `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 }` |
419
+ | `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? }` |
420
+ | `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 }` |
421
+ | `domClick(selector)` | a click (or middle click) on an element of the site's that matches `selector` | `{ element, event }` |
422
+ | `urlChange()` | a navigation in a single-page app | `{ url, previousUrl }` |
423
+ | `mutationOf(selector, { root? })` | the site renders that element into the page, or renders into it; read once the browser is idle | `{ element }` |
424
+ | `xhrMatching(({ url, method }) => …)` | a response to one of the site's own requests (XMLHttpRequest or fetch) that the test matches | `{ url, method, response }` |
425
+ | `manual()` | `fire(data)` is called: `const { trigger, fire } = manual()` | `{ data }` |
426
+
427
+ **Readers**
428
+
429
+ | Reader | Reads |
430
+ |---|---|
431
+ | `fromLuma.pageView`, `.click`, `.addToCart` | what Luma's own triggers saw (`lumaPages()`, `lumaClicks()`, `lumaCarts()`). Products are referenced by their catalog `@id` |
432
+ | `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` |
433
+
434
+ Each reader belongs to one event: `collect.purchase…read(fromGa4.addToCart)`
435
+ is a type error, and throws when the file runs.
436
+
437
+ **References** follow `<type>/<field>:<value>`
438
+ ([docs](https://docs.luigisbox.ai/analytics/references/)).
439
+ `ref.product(value)` uses the field set by `ref.product.by(field)` (default
440
+ `@id`); `ref.product(field, value)` names another field. `ref.variant`,
441
+ `ref.category` and `ref.brand` work the same way. `user.identify(fn)` sends
442
+ the signed-in shopper's own id as `user_id`.
443
+
444
+ **Nothing is lost when the page changes.** A click or add-to-cart on a Luma
445
+ product is recorded the moment it happens, and dataLayer pushes on a page the
446
+ shopper leaves before analytics has loaded (a thank-you page closed at once)
447
+ are kept. The next page sends them, dated as they happened; a purchase is still
448
+ sent once.
449
+
450
+ **Size.** Analytics loads as one chunk, after the page has loaded, holding
451
+ only what the file uses: every event, trigger and reader is a separate export
452
+ (`fromGa4.purchase` brings that reader alone). Gzip sizes:
453
+
454
+ | `_analytics.ts` | Size |
455
+ |---|---|
456
+ | none | nothing loaded |
457
+ | pageviews only | 2.2 kB |
458
+ | pageviews, add-to-carts and purchases from GA4 | 3.6 kB |
459
+ | + Luma's own clicks and add-to-carts (`lumaClicks()`, `lumaCarts()`) | +0.2 kB |
460
+ | + `domClick()` | +0.2 kB |
461
+ | + a `fromGa4` list reader (`.search`, `.autocomplete`, `.recommender`) | +0.1 kB |
462
+ | + `mutationOf()`, `xhrMatching()` | +0.3, +0.4 kB |
463
+ | + `user.identify()`, a page reference, a list read by a function of yours, `.once()`, `.throttle()` | under 0.1 kB each |
464
+
465
+ **A site's own search and suggestions** (where Luma does not render them) are
466
+ read with functions of your own, for example:
467
+
468
+ ```ts
469
+ import { collect, dl, domClick, fromGa4, mutationOf, ref, xhrMatching } from '@/analytics';
470
+
471
+ // the site's search results, rendered into #results
472
+ collect.search.on(mutationOf('#results')).once().read(({ element }) => ({
473
+ query: { string: new URLSearchParams(location.search).get('q') ?? '' },
474
+ items: [...element.querySelectorAll('[data-sku]')].map((tile, n) => ({ title: tile.textContent ?? '', type: 'product', reference: ref.product(tile.getAttribute('data-sku')!), position: n + 1 })),
475
+ }));
476
+ // clicks on those results
477
+ collect.click.on(domClick('#results [data-sku] a')).read(({ element }) => ref.product(element.closest('[data-sku]')!.getAttribute('data-sku')!));
478
+ // the site's suggestions, from its own API
479
+ collect.autocomplete.on(xhrMatching(({ url }) => url.includes('/suggest'))).read(({ response }) => ({ items: /* … */ [] }));
480
+ // the product page's product, from GA4's view_item
481
+ collect.pageView.on(dl('view_item')).read(fromGa4.pageView);
482
+ ```
483
+
355
484
  ## Browser support
356
485
 
357
486
  Luma targets [Baseline](https://web.dev/baseline) **Widely available**; anything
@@ -369,18 +498,22 @@ integration repository").
369
498
 
370
499
  - **Autocomplete needs the Popover API**: without it (Safari < 17, Firefox
371
500
  < 125) the site keeps its own suggestions.
372
- - **Loader without the snippet**: the site's own listing may flash before ours
373
- on a hard load.
374
- - **Analytics**: Luma sends no tracking events. A search shown in place fires no page
375
- view of the host's. Speculative requests (prefetches, prerenders) count in
376
- the API's request numbers, though never as searches or impressions.
501
+ - **Loader without the snippet**: the site's own listing may flash before
502
+ Luma's on a hard load.
503
+ - **Analytics**: a search shown in place fires no page view in the site's own
504
+ tags (Luma's `lumaPages()` counts it). Prefetches and prerenders count in the
505
+ API's request numbers, though never as searches or impressions. Analytics
506
+ loads after the page has: a visitor who leaves before that sends no pageview
507
+ and no click on the site's own elements (`domClick()`); clicks and
508
+ add-to-carts on Luma's products and dataLayer pushes are kept for the next
509
+ page. A/B tests are not supported yet (`listing_ab_test` is always `false`).
377
510
  - **Prerendering** costs the site a page render per unfollowed hover
378
511
  (`speculation: false` turns it off).
379
- - **Early hold**: the snippet carries no client's rules, so the first page
512
+ - **Early hold**: the snippet carries no site's rules, so the first page
380
513
  view is not held.
381
514
  - **Host routers out of reach**: where Luma cannot find the host's router (Vue
382
515
  Router 4 when its app is not discoverable, Angular in production), the
383
- router can overwrite our history entry or show a stale query in its search
516
+ router can overwrite Luma's history entry or show a stale query in its search
384
517
  box. `inPlaceQuery: false` for such a site.
385
518
 
386
519
  ## Development
@@ -390,7 +523,7 @@ Node 24 or later, Yarn 4 (corepack).
390
523
  ### Seeing an integration on the storefront
391
524
 
392
525
  An integration is developed in its own repository. How you see it on the live
393
- site depends on whether the site includes our loader yet.
526
+ site depends on whether the site includes the Luigi's Box loader yet.
394
527
 
395
528
  **The site includes the loader.** Serve the integration and add
396
529
  `?_lbx_env=development` to any page of the site:
@@ -408,7 +541,7 @@ published build (a red banner says so); an edit shows on reload.
408
541
  `LBX_DEV_ORIGINS` (comma-separated) limits which sites may load from it.
409
542
 
410
543
  **The site does not include the loader yet.** Open the storefront with the
411
- integration injected as a customer would install it (the early-hold snippet,
544
+ integration injected as the site would include it (the early-hold snippet,
412
545
  then the build):
413
546
 
414
547
  ```bash
@@ -435,8 +568,7 @@ WebKit before it ships.
435
568
  ### Tools for an integration repository
436
569
 
437
570
  An integration repository's `package.json` has one script for
438
- all of them, `"luma": "luma"` (the package's command; with the submodule,
439
- `"node luma/bin/luma.mjs"` after `yarn --cwd luma install`), and its
571
+ all of them, `"luma": "luma"` (the package's command), and its
440
572
  `luma.config.mjs` the storefront's host (`export const host =
441
573
  'www.shop.example'`): a tool Luma adds is there once Luma is updated, nothing to
442
574
  change in the integration. `yarn luma` lists them; `yarn luma tooling --write`
@@ -445,11 +577,11 @@ sets an integration up
445
577
 
446
578
  | Command | What |
447
579
  |---|---|
448
- | `yarn luma chrome [path]` | the live storefront in your Chrome with the integration on it as a customer installs 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)) |
580
+ | `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)) |
449
581
  | `yarn luma a11y` | WCAG 2.2 AA check of the integration's widgets, by keyboard too ([`docs/ACCESSIBILITY.md`](docs/ACCESSIBILITY.md) §8) |
450
- | `yarn luma parity` | compares the site's search and category UI with ours, component by component ([`docs/PARITY.md`](docs/PARITY.md)) |
582
+ | `yarn luma parity` | compares the site's search and category UI with Luma's, component by component ([`docs/PARITY.md`](docs/PARITY.md)) |
451
583
  | `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)) |
452
- | `yarn luma tasks` | our 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 our calls apart ([`tasks/run.mjs`](tasks/run.mjs)) |
584
+ | `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)) |
453
585
  | `yarn luma baseline` | nothing in `integration/` newer than Baseline Widely available (`--built dist`: the build) |
454
586
 
455
587
  The integration's settings for the tools are its `luma.config.mjs`
package/build/vite.mjs CHANGED
@@ -30,6 +30,9 @@ const FRAMEWORK = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
30
30
  // building, stash, in-place query, URL writing): typing on
31
31
  // any page and the listing need it, a page with only a
32
32
  // recommender does not
33
+ // lbx-analytics pageviews, our tiles' clicks and add-to-carts, the
34
+ // site's GA4 events, the integration's _analytics.ts and
35
+ // what it imports (tree-shaken): every page once loaded, and widgets
33
36
  // lbx-locale-* lbx-hydrate only where needed (the site's
34
37
  // locale, server-rendered pages)
35
38
  // A module the entry imports statically (health, sense, the listing's query
@@ -60,6 +63,10 @@ const entryGraph = (getModuleInfo, getModuleIds) => {
60
63
  return seen;
61
64
  };
62
65
  export const chunkOf = (id) => {
66
+ // An integration's _analytics.ts (its analytics setup) goes with the analytics
67
+ // chunk that reads it (no request of its own), with what it imports from
68
+ // @/analytics: the rest of src/analytics is tree-shaken out of it.
69
+ if (/\/integration\/[^/]+\/_analytics\.[jt]s$/.test(id)) return 'lbx-analytics';
63
70
  // Any other module of an integration folder that only something lazy imports
64
71
  // (its lazy _translations.ts, code an _init.ts hook imports on demand) goes
65
72
  // with its folder's templates: no chunk, no request of its own. An eager one
@@ -86,6 +93,8 @@ export const chunkOf = (id) => {
86
93
  if (/\/src\/widgets\/search\/(index|in-place)\.ts$/.test(id)) return 'lbx-search';
87
94
  // Search while typing (main.ts loads it once the shopper types): not in lbx-nav, which a listing page loads at once.
88
95
  if (/\/src\/widgets\/search\/speculate\.ts$/.test(id)) return 'lbx-speculate';
96
+ // Analytics (src/analytics): every page once loaded (main.ts), widgets import it for their tiles' clicks.
97
+ if (/\/src\/analytics\//.test(id)) return 'lbx-analytics';
89
98
  // Minting the browser token: a session's first page loads it at boot (main.ts), lbx-core imports it.
90
99
  if (/\/src\/api\/token\.ts$/.test(id)) return 'lbx-token';
91
100
  // Only the site's locale is ever loaded.
@@ -0,0 +1,8 @@
1
+ // The Luigi's Box scripts `yarn luma chrome` keeps off the storefront (a
2
+ // platform loader, a previous integration: never with ours), but the hosts
3
+ // `--allow` names: a previous generation's search or autocomplete the
4
+ // integration's analytics observes (mutationOf, xhrMatching).
5
+ export const LBX_SCRIPTS = /^https:\/\/(cdn\.luigisbox\.(com|tech)|cdn\.eu1\.luigisbox\.ai|scripts\.luigisbox\.(com|tech))\//;
6
+
7
+ /** Whether `url` is blocked, `allow` the host names left alone. */
8
+ export const blocked = (url, allow = []) => LBX_SCRIPTS.test(url) && !allow.includes(new URL(url).hostname);
package/chrome/run.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  // yarn luma chrome the storefront's home page (luma.config.mjs `host`, or LBX_HOST)
8
8
  // yarn luma chrome /some-category another page
9
9
  // yarn luma chrome --dev from the dev server (yarn dev): an edit shows on reload, no build
10
+ // yarn luma chrome --allow cdn.luigisbox.tech that host's Luigi's Box scripts left on (their previous search, say)
10
11
  //
11
12
  // Nothing else of the site changes: Luigi's Box's scripts it already loads (a
12
13
  // platform loader, a previous integration) are blocked, so ours is the only
@@ -20,6 +21,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } fr
20
21
  import { homedir, tmpdir } from 'node:os';
21
22
  import { extname, join, normalize, resolve } from 'node:path';
22
23
  import { inside } from '../bin/paths.mjs';
24
+ import { blocked } from './block.mjs';
23
25
 
24
26
  const USAGE = `usage: yarn luma chrome [path] [options]
25
27
 
@@ -30,10 +32,12 @@ const USAGE = `usage: yarn luma chrome [path] [options]
30
32
  --no-snippet the build alone, without the early-hold snippet in <head>
31
33
  --channel NAME chrome (default: your installed Chrome) or chromium (Playwright's)
32
34
  --profile DIR the browser profile (default ~/.lbx-chrome/<host>)
33
- --site-dir DIR the integration repository (default: the current directory, LBX_SITE_DIR)`;
35
+ --site-dir DIR the integration repository (default: the current directory, LBX_SITE_DIR)
36
+ --allow HOST leave that host's Luigi's Box scripts on (repeatable): a previous search or
37
+ autocomplete the integration's analytics observes (cdn.luigisbox.tech)`;
34
38
 
35
39
  const args = process.argv.slice(2);
36
- const opt = { path: '/', check: false, dev: false, build: true, snippet: true, channel: 'chrome', profile: '', siteDir: '' };
40
+ const opt = { path: '/', check: false, dev: false, build: true, snippet: true, channel: 'chrome', profile: '', siteDir: '', allow: [] };
37
41
  const die = (msg) => {
38
42
  console.error(msg);
39
43
  process.exit(2);
@@ -55,6 +59,7 @@ for (let i = 0; i < args.length; i++) {
55
59
  else if (a === '--channel') opt.channel = next();
56
60
  else if (a === '--profile') opt.profile = next();
57
61
  else if (a === '--site-dir') opt.siteDir = next();
62
+ else if (a === '--allow') opt.allow.push(next());
58
63
  else if (!a.startsWith('--')) opt.path = a.startsWith('/') ? a : `/${a}`;
59
64
  else die(`unknown option ${a}\n\n${USAGE}`);
60
65
  }
@@ -115,8 +120,8 @@ for (const channel of [opt.channel, ...(opt.channel === 'chrome' ? ['chromium']
115
120
  if (channel === 'chromium') die(`No browser to open: install Chrome, or \`yarn playwright-core install chromium\`.\n${e.message.split('\n')[0]}`);
116
121
  }
117
122
  }
118
- // Luigi's Box's scripts the site loads already: never with ours.
119
- await ctx.route(/^https:\/\/(cdn\.luigisbox\.(com|tech)|cdn\.eu1\.luigisbox\.ai|scripts\.luigisbox\.(com|tech))\//, (r) => r.abort('blockedbyclient'));
123
+ // Luigi's Box's scripts the site loads already: never with ours (but the hosts --allow names).
124
+ await ctx.route((u) => blocked(u.href, opt.allow), (r) => r.abort('blockedbyclient'));
120
125
  // The build, from the storefront's own origin (as a platform loader serves it): fresh on every reload.
121
126
  await ctx.route(`${ORIGIN}${PREFIX}**`, (route) => {
122
127
  const file = normalize(join(DIST, decodeURIComponent(new URL(route.request().url()).pathname.slice(PREFIX.length))));
@@ -139,7 +144,7 @@ if (opt.check) {
139
144
  await ctx.close();
140
145
  process.exit(!s || s.error || s.errors?.length ? 1 : 0);
141
146
  }
142
- console.log(`${ORIGIN}${opt.path} with ${opt.dev ? 'the dev server (yarn dev)' : opt.build ? 'this build' : 'dist/'}${opt.snippet ? ' and the early-hold snippet' : ''}, Luigi's Box's own scripts blocked.`);
147
+ console.log(`${ORIGIN}${opt.path} with ${opt.dev ? 'the dev server (yarn dev)' : opt.build ? 'this build' : 'dist/'}${opt.snippet ? ' and the early-hold snippet' : ''}, Luigi's Box's own scripts blocked${opt.allow.length ? ` but ${opt.allow.join(', ')}` : ''}.`);
143
148
  console.log(opt.dev ? 'Edit, then reload the page. ' : opt.build ? 'A change: run this again (or --dev with yarn dev). ' : 'A change: yarn build, then reload. ');
144
149
  console.log(`window.__lbx in the console: Luma's state. Close the window to stop (profile: ${profile}).`);
145
150
  await new Promise((r) => ctx.on('close', r));
@@ -106,8 +106,13 @@ guessed alt text, and forced colours and the OS setting are honoured.
106
106
  they are not results; the help box's phone link is a plain link. "Show all
107
107
  products" is a real link (`href`: the
108
108
  search page the site's form would submit to) and the open dropdown's one
109
- tab stop: Tab from the input reaches it, Shift+Tab goes back, Escape closes
110
- and goes back, Tab moves on to the page's next control after the input.
109
+ tab stop: Tab from the input reaches it (the options' selection cleared:
110
+ one thing lit), Shift+Tab goes back, Escape closes and goes back, Tab moves
111
+ on to the page's next control after the input. It is also the arrow keys'
112
+ last stop after the options (Arrow Up from the input reaches it first),
113
+ lit as when focused and referenced as the active descendant (it is in the
114
+ combobox's popup; in our shadow root, said by the status region instead),
115
+ Enter its search; focus stays in the input.
111
116
  - **Status messages**: "Suggestions: N" or "No results for “…”", in a polite
112
117
  `role="status"` region inside the dropdown, once typing pauses (700 ms), and
113
118
  only when it changes within an opening (not per keystroke).
@@ -123,7 +128,7 @@ guessed alt text, and forced colours and the OS setting are honoured.
123
128
  never a faint colour alone: an accent fill (pills), or the tint with an
124
129
  accent ring (cards) or edge (rows). Tokens an integration sets
125
130
  (`autocomplete.tokens`, its CSS) are its own to keep at 4.5:1 (`yarn luma a11y`).
126
- - **The phone sheet** (house design, viewport up to 600 px): its own input is
131
+ - **The phone sheet** (house design, viewport up to 600 px, or a short touch screen: a phone in landscape): its own input is
127
132
  the combobox (`aria-controls` the listboxes on screen,
128
133
  `aria-activedescendant` the selected option: the same shadow root); the
129
134
  site's input is left as it was. The back button is named "Close search",
@@ -400,8 +405,7 @@ From the integration repository: `yarn luma a11y`
400
405
  (README, "Tools for an integration repository": the one `luma` script). The
401
406
  storefront's host name is its `luma.config.mjs` `host` (`LBX_HOST` overrides it;
402
407
  the browser token is minted for that origin). Its dependencies come with Luma
403
- (Playwright, axe-core; with the `luma/` submodule: `yarn --cwd luma install`
404
- once); the browser is Playwright's Chromium or the installed Chrome,
408
+ (Playwright, axe-core); the browser is Playwright's Chromium or the installed Chrome,
405
409
  `LBX_CHROMIUM_PATH` otherwise.
406
410
 
407
411
  ```bash