@luigisbox/luma 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.3.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.2.1) only fixes. Depend on `^0.2.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`,
@@ -352,6 +354,128 @@ On by default unless noted; each falls back to what the page would do anyway.
352
354
  | `_global.prefetchFilters` | a filter, sort or chip is fetched on hover, touch or focus | |
353
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 |
354
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
+
355
479
  ## Browser support
356
480
 
357
481
  Luma targets [Baseline](https://web.dev/baseline) **Widely available**; anything
@@ -369,18 +493,22 @@ integration repository").
369
493
 
370
494
  - **Autocomplete needs the Popover API**: without it (Safari < 17, Firefox
371
495
  < 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.
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`).
377
505
  - **Prerendering** costs the site a page render per unfollowed hover
378
506
  (`speculation: false` turns it off).
379
- - **Early hold**: the snippet carries no client's rules, so the first page
507
+ - **Early hold**: the snippet carries no site's rules, so the first page
380
508
  view is not held.
381
509
  - **Host routers out of reach**: where Luma cannot find the host's router (Vue
382
510
  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
511
+ router can overwrite Luma's history entry or show a stale query in its search
384
512
  box. `inPlaceQuery: false` for such a site.
385
513
 
386
514
  ## Development
@@ -390,7 +518,7 @@ Node 24 or later, Yarn 4 (corepack).
390
518
  ### Seeing an integration on the storefront
391
519
 
392
520
  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.
521
+ site depends on whether the site includes the Luigi's Box loader yet.
394
522
 
395
523
  **The site includes the loader.** Serve the integration and add
396
524
  `?_lbx_env=development` to any page of the site:
@@ -408,7 +536,7 @@ published build (a red banner says so); an edit shows on reload.
408
536
  `LBX_DEV_ORIGINS` (comma-separated) limits which sites may load from it.
409
537
 
410
538
  **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,
539
+ integration injected as the site would include it (the early-hold snippet,
412
540
  then the build):
413
541
 
414
542
  ```bash
@@ -435,8 +563,7 @@ WebKit before it ships.
435
563
  ### Tools for an integration repository
436
564
 
437
565
  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
566
+ all of them, `"luma": "luma"` (the package's command), and its
440
567
  `luma.config.mjs` the storefront's host (`export const host =
441
568
  'www.shop.example'`): a tool Luma adds is there once Luma is updated, nothing to
442
569
  change in the integration. `yarn luma` lists them; `yarn luma tooling --write`
@@ -445,11 +572,11 @@ sets an integration up
445
572
 
446
573
  | Command | What |
447
574
  |---|---|
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)) |
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)) |
449
576
  | `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)) |
577
+ | `yarn luma parity` | compares the site's search and category UI with Luma's, component by component ([`docs/PARITY.md`](docs/PARITY.md)) |
451
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)) |
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)) |
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)) |
453
580
  | `yarn luma baseline` | nothing in `integration/` newer than Baseline Widely available (`--built dist`: the build) |
454
581
 
455
582
  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));
@@ -400,8 +400,7 @@ From the integration repository: `yarn luma a11y`
400
400
  (README, "Tools for an integration repository": the one `luma` script). The
401
401
  storefront's host name is its `luma.config.mjs` `host` (`LBX_HOST` overrides it;
402
402
  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,
403
+ (Playwright, axe-core); the browser is Playwright's Chromium or the installed Chrome,
405
404
  `LBX_CHROMIUM_PATH` otherwise.
406
405
 
407
406
  ```bash
@@ -1,7 +1,7 @@
1
1
  # Integration performance: what to measure, what to optimize
2
2
 
3
3
  A guideline for integration work (an integration repository with Luma as the
4
- `@luigisbox/luma` package, or as the `luma/` submodule). Luma's own budgets are
4
+ `@luigisbox/luma` package). Luma's own budgets are
5
5
  held by its own test suites. An integration can still undo them with heavy tiles, a
6
6
  layout that moves, or a slow install. This page lists the numbers to watch,
7
7
  in priority order, and the levers an integration has.
@@ -187,10 +187,9 @@ slowest type sets the pace (usually products).
187
187
 
188
188
  ## How to measure, step by step
189
189
 
190
- 0. `yarn luma tooling` (with the `luma/` submodule, before the script exists:
191
- `node luma/build/tooling.mjs`):
192
- the integration has the one `luma` script and its storefront's host in
193
- `luma.config.mjs` (`--write` adds them); `yarn luma` lists the tools.
190
+ 0. `yarn luma tooling`: the integration has the one `luma` script and its
191
+ storefront's host in `luma.config.mjs` (`--write` adds them); `yarn luma`
192
+ lists the tools.
194
193
  1. `yarn luma footprint --rtt 100 --runs 5` in the integration repository, before
195
194
  and after the change. Compare the cold and warm `ready`, main thread, scripts,
196
195
  bytes, chain and API requests per scenario. Commit the JSON (`--json`) so
@@ -208,7 +207,7 @@ slowest type sets the pace (usually products).
208
207
  layout (facets and quick types above or beside) differs by width, and the
209
208
  first screen holds more tiles on a tall one.
210
209
 
211
- ## What the customer includes
210
+ ## What the site includes
212
211
 
213
212
  The snippet with the connection hints is in README.md ("What the customer
214
213
  includes").
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luigisbox/luma",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Luma: Luigi's Box frontend integrations (autocomplete, search, category listings, recommenders) on the Luigi's Box AI API. Pre-release.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/parity/hold.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Luma on a page as a customer installs it (README, "What the customer includes"): the
1
+ // Luma on a page as a customer installs it (README, "What the site includes"): the
2
2
  // generic early-hold snippet (snippet/early-hold.min.js) first in <head>,
3
3
  // then the build. `yarn luma parity` (ours in every mode: replay, --record,
4
4
  // --live, --dev) and `yarn luma a11y` put the build in the storefront's own page