@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.
- package/README.md +208 -76
- package/build/vite.mjs +9 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +10 -5
- package/docs/ACCESSIBILITY.md +9 -5
- package/docs/INTEGRATION-PERFORMANCE.md +5 -6
- package/package.json +1 -1
- package/parity/hold.mjs +1 -1
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/token.ts +3 -2
- package/src/core/entries.ts +10 -6
- package/src/core/integration.ts +6 -0
- package/src/core/kept.ts +25 -0
- package/src/core/stash.ts +24 -4
- package/src/core/tiles.ts +137 -0
- package/src/main.ts +54 -4
- package/src/types.ts +29 -2
- package/src/widgets/autocomplete/index.ts +206 -31
- package/src/widgets/autocomplete/style-autocomplete.css +11 -5
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +1 -1
- package/src/widgets/listing/element.ts +212 -21
- package/src/widgets/listing/instant.ts +83 -23
- package/src/widgets/listing/stash.ts +31 -8
- package/src/widgets/listing/view.ts +12 -2
- package/src/widgets/recommender/index.ts +15 -4
- package/src/widgets/search/range.ts +40 -18
- package/src/widgets/search/style-search.css +2 -2
package/README.md
CHANGED
|
@@ -1,53 +1,57 @@
|
|
|
1
1
|
# Luma
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
results, category listings (PLP) and recommenders on the Luigi's Box AI
|
|
5
|
-
for sites that can only include
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
22
|
-
were there first and keep changing.
|
|
23
|
-
anything.
|
|
24
|
-
2. **
|
|
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
|
|
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
|
|
30
|
-
|
|
31
|
-
5. **Measured, not claimed.**
|
|
32
|
-
|
|
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
|
|
38
|
-
never throws into the page, every network call has a deadline, and
|
|
39
|
-
of
|
|
40
|
-
|
|
41
|
-
missing browser feature: the site's own UI comes back as
|
|
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
|
-
|
|
45
|
-
Astro only the router's own bookkeeping keys);
|
|
46
|
-
Anything
|
|
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
|
|
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,
|
|
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
|
|
69
|
-
Its height is reserved from the last visit. Target: zero layout shift
|
|
70
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
112
|
+
### D. How Luma is built
|
|
109
113
|
|
|
110
|
-
- **One framework, many integrations.** Nothing site-specific in
|
|
111
|
-
|
|
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
|
-
|
|
123
|
+
The trade-offs that come with this: [Known limitations](#known-limitations).
|
|
120
124
|
|
|
121
125
|
|
|
122
|
-
##
|
|
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
|
|
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
|
|
137
|
-
`@luigisbox/luma` package
|
|
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
|
|
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
|
|
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
|
|
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`:
|
|
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).
|
|
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
|
-
|
|
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
|
|
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
|
|
225
|
-
|
|
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).
|
|
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
|
|
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
|
|
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
|
|
373
|
-
on a hard load.
|
|
374
|
-
- **Analytics**:
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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` |
|
|
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.
|
package/chrome/block.mjs
ADDED
|
@@ -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(
|
|
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));
|
package/docs/ACCESSIBILITY.md
CHANGED
|
@@ -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
|
|
110
|
-
|
|
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;
|
|
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
|