@luigisbox/luma 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +488 -2
  3. package/a11y/audit.mjs +735 -0
  4. package/a11y/axe.mjs +73 -0
  5. package/a11y/report.mjs +347 -0
  6. package/a11y/run.mjs +256 -0
  7. package/a11y/wcag.mjs +233 -0
  8. package/bin/config.mjs +60 -0
  9. package/bin/luma.mjs +99 -0
  10. package/bin/paths.mjs +11 -0
  11. package/bin/tools.mjs +43 -0
  12. package/build/baseline.d.mts +8 -0
  13. package/build/baseline.mjs +167 -0
  14. package/build/tooling.mjs +102 -0
  15. package/build/vite.mjs +498 -0
  16. package/chrome/run.mjs +145 -0
  17. package/docs/ACCESSIBILITY.md +502 -0
  18. package/docs/INTEGRATION-PERFORMANCE.md +214 -0
  19. package/docs/PARITY.md +946 -0
  20. package/footprint/model.mjs +184 -0
  21. package/footprint/run.mjs +331 -0
  22. package/package.json +58 -4
  23. package/parity/alignment.mjs +109 -0
  24. package/parity/archive.mjs +223 -0
  25. package/parity/args.mjs +137 -0
  26. package/parity/behaviour.mjs +379 -0
  27. package/parity/compare.mjs +985 -0
  28. package/parity/engine.mjs +848 -0
  29. package/parity/hints.mjs +132 -0
  30. package/parity/hold.mjs +54 -0
  31. package/parity/init.mjs +703 -0
  32. package/parity/map.mjs +640 -0
  33. package/parity/owner.mjs +170 -0
  34. package/parity/pairing.mjs +110 -0
  35. package/parity/pixels.mjs +33 -0
  36. package/parity/probe.mjs +1239 -0
  37. package/parity/report.mjs +236 -0
  38. package/parity/run.mjs +267 -0
  39. package/snippet/early-hold.js +64 -0
  40. package/snippet/early-hold.min.js +1 -0
  41. package/src/api/ai.ts +98 -0
  42. package/src/api/index.ts +87 -0
  43. package/src/api/token-key.ts +42 -0
  44. package/src/api/token.ts +311 -0
  45. package/src/core/a11y-keys.ts +60 -0
  46. package/src/core/a11y.ts +160 -0
  47. package/src/core/boot-state.ts +6 -0
  48. package/src/core/color.ts +42 -0
  49. package/src/core/context.ts +108 -0
  50. package/src/core/early-rules.ts +96 -0
  51. package/src/core/entries.ts +25 -0
  52. package/src/core/fold.ts +7 -0
  53. package/src/core/health.ts +26 -0
  54. package/src/core/hold.ts +74 -0
  55. package/src/core/i18n.ts +66 -0
  56. package/src/core/image-load.ts +102 -0
  57. package/src/core/image-tags.ts +26 -0
  58. package/src/core/images.ts +170 -0
  59. package/src/core/integration.ts +120 -0
  60. package/src/core/listings.ts +100 -0
  61. package/src/core/locales.ts +14 -0
  62. package/src/core/own.ts +49 -0
  63. package/src/core/page.ts +14 -0
  64. package/src/core/prefetch.ts +88 -0
  65. package/src/core/recommenders.ts +166 -0
  66. package/src/core/reconciler.ts +397 -0
  67. package/src/core/result.ts +73 -0
  68. package/src/core/routes.ts +115 -0
  69. package/src/core/scheduler.ts +83 -0
  70. package/src/core/sense.ts +107 -0
  71. package/src/core/speculation.ts +59 -0
  72. package/src/core/stash.ts +38 -0
  73. package/src/core/storage.ts +45 -0
  74. package/src/core/styles.ts +106 -0
  75. package/src/core/support.ts +12 -0
  76. package/src/core/takeover.ts +220 -0
  77. package/src/core/templates.ts +74 -0
  78. package/src/core/turbo.ts +64 -0
  79. package/src/core/url.ts +164 -0
  80. package/src/core/widget-inits.ts +40 -0
  81. package/src/env.d.ts +3 -0
  82. package/src/lit.ts +11 -0
  83. package/src/locales/cs.json +94 -0
  84. package/src/locales/de.json +88 -0
  85. package/src/locales/en.json +88 -0
  86. package/src/main.ts +443 -0
  87. package/src/types.ts +760 -0
  88. package/src/widgets/autocomplete/index.ts +1367 -0
  89. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  90. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  91. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  92. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  93. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  94. package/src/widgets/listing/element.ts +1769 -0
  95. package/src/widgets/listing/flow.ts +30 -0
  96. package/src/widgets/listing/instant.ts +133 -0
  97. package/src/widgets/listing/intent.ts +193 -0
  98. package/src/widgets/listing/query.ts +298 -0
  99. package/src/widgets/listing/stash.ts +97 -0
  100. package/src/widgets/listing/state.ts +131 -0
  101. package/src/widgets/listing/view.ts +257 -0
  102. package/src/widgets/plp/index.ts +11 -0
  103. package/src/widgets/plp/route.ts +263 -0
  104. package/src/widgets/recommender/index.ts +250 -0
  105. package/src/widgets/recommender/style-recommender.css +26 -0
  106. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  107. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  108. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  109. package/src/widgets/search/assets.ts +7 -0
  110. package/src/widgets/search/first.ts +15 -0
  111. package/src/widgets/search/form-url.ts +59 -0
  112. package/src/widgets/search/in-place.ts +226 -0
  113. package/src/widgets/search/index.ts +16 -0
  114. package/src/widgets/search/range.ts +260 -0
  115. package/src/widgets/search/route.ts +32 -0
  116. package/src/widgets/search/speculate.ts +202 -0
  117. package/src/widgets/search/style-search.css +65 -0
  118. package/src/widgets/search/template-search-box.ts +17 -0
  119. package/src/widgets/search/template-search-facets-default.ts +73 -0
  120. package/src/widgets/search/template-search-header.ts +14 -0
  121. package/src/widgets/search/template-search-pagination.ts +12 -0
  122. package/src/widgets/search/template-search-range-default.ts +48 -0
  123. package/src/widgets/search/template-search-result-default.ts +13 -0
  124. package/src/widgets/search/template-search-results-default.ts +8 -0
  125. package/tasks/model.mjs +116 -0
  126. package/tasks/run.mjs +305 -0
  127. package/tsconfig.json +14 -0
package/LICENSE.md ADDED
@@ -0,0 +1,90 @@
1
+ LUIGI'S BOX COMMERCIAL SOURCE LICENSE
2
+
3
+ Copyright (c) 2026 Luigi's Box s.r.o., registered in Slovakia.
4
+ All rights reserved.
5
+
6
+ 1. GRANT OF LICENSE
7
+
8
+ This software and associated documentation files (the "Software") are
9
+ proprietary to Luigi's Box s.r.o. ("Licensor"). Subject to the terms of
10
+ a valid, current commercial agreement between the Licensor and the
11
+ licensee ("Agreement"), the Licensor grants the licensee a limited,
12
+ non-exclusive, non-transferable, non-sublicensable license to use the
13
+ Software solely for the purposes defined in the Agreement.
14
+
15
+ 2. CONDITIONS OF USE
16
+
17
+ Use of the Software is permitted only:
18
+ (a) by a party that has entered into a valid Agreement with the Licensor;
19
+ (b) for the duration of that Agreement; and
20
+ (c) solely for the purposes expressly permitted under that Agreement.
21
+
22
+ 3. RESTRICTIONS
23
+
24
+ The licensee may not:
25
+ (a) copy, distribute, sublicense, sell, or transfer the Software or any
26
+ portion thereof to any third party;
27
+ (b) modify, adapt, translate, reverse engineer, decompile, or create
28
+ derivative works based on the core Software library. For the
29
+ avoidance of doubt, the licensee is permitted to write, modify,
30
+ and maintain integration code that interfaces with the Software
31
+ through its published public API, provided such integration code
32
+ does not incorporate, derive from, or replicate any portion of
33
+ the Software's internal implementation
34
+ (c) remove or alter any copyright, trademark, or proprietary notices
35
+ contained in the Software.
36
+ (d) use the Software, or any integration code built against it, to
37
+ communicate with, connect to, or interoperate with any backend
38
+ API, service, or infrastructure other than those operated by
39
+ Luigi's Box s.r.o. The Software is licensed exclusively for use
40
+ with Luigi's Box services. Any attempt to adapt, reconfigure, or
41
+ redirect the Software to function with a third-party service is
42
+ a material breach of this license and the Agreement.
43
+
44
+ 4. TERMINATION AND RETURN OF SOURCE CODE
45
+
46
+ This license is effective until terminated. It terminates automatically,
47
+ without notice, upon expiry or termination of the Agreement. Upon
48
+ termination, the licensee must immediately cease all use of the Software
49
+ and permanently destroy all copies of the Software in its possession or
50
+ control, including source code, compiled versions, and any backups.
51
+ Upon request, the licensee shall provide the Licensor with written
52
+ confirmation of such destruction within 14 days.
53
+
54
+ 5. DISCLAIMER OF WARRANTIES
55
+
56
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
57
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
58
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
59
+ THE LICENSOR DOES NOT WARRANT THAT THE SOFTWARE WILL BE ERROR-FREE OR
60
+ UNINTERRUPTED.
61
+
62
+ 6. LIMITATION OF LIABILITY
63
+
64
+ TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL
65
+ THE LICENSOR BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL,
66
+ EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
67
+ LOSS OF PROFITS, DATA, OR BUSINESS INTERRUPTION) ARISING OUT OF OR IN
68
+ CONNECTION WITH THE USE OR INABILITY TO USE THE SOFTWARE, EVEN IF
69
+ ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
70
+
71
+ 7. GOVERNING LAW AND JURISDICTION
72
+
73
+ This license shall be governed by and construed in accordance with the
74
+ laws of the Slovak Republic, without regard to its conflict of law
75
+ provisions. Any disputes arising under this license shall be subject
76
+ to the exclusive jurisdiction of the courts of Bratislava, Slovakia.
77
+
78
+ 8. SEVERABILITY
79
+
80
+ If any provision of this license is found to be unenforceable or invalid
81
+ under applicable law, that provision shall be limited or eliminated to
82
+ the minimum extent necessary, and the remaining provisions shall continue
83
+ in full force and effect.
84
+
85
+ 9. ENTIRE AGREEMENT
86
+
87
+ This license, together with the Agreement, constitutes the entire
88
+ agreement between the parties with respect to the Software and
89
+ supersedes all prior or contemporaneous understandings regarding
90
+ such subject matter.
package/README.md CHANGED
@@ -1,3 +1,489 @@
1
- # Temporary Holding Version
1
+ # Luma
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 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
6
+ server-rendered through the site's own cache, from a rendering service Luigi's
7
+ Box runs.
8
+
9
+ > **Pre-release.** Until 1.0, a minor version (0.2.0) may change the
10
+ > 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
12
+ > changes.
13
+
14
+ ## Core principles
15
+
16
+ ### The problem
17
+
18
+ Luma runs on storefronts we don't own, often included as a single script tag.
19
+ That sets the constraints:
20
+
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
+ flicker or jump compared with the site's own, the integration is a
26
+ regression, however good the results are.
27
+ 3. **The shopper should not notice us.** Results look like the site's own UI
28
+ 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
+
34
+ ### A. A guest on the page
35
+
36
+ - **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.
43
+ - **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
47
+ 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.
49
+ - **Sense and reconcile, not imperative patching.** Host signals (URL changes,
50
+ DOM mutations, our nodes removed) only mean "reconcile soon". At most once
51
+ per frame the reconciler makes the page match declarative rules: it removes
52
+ nodes whose rule is inactive, puts the same instance back if the host
53
+ removed it, and waits for the anchor to settle. A missed signal is harmless:
54
+ the next reconcile computes everything from scratch.
55
+ - **Works with any frontend stack.** React/Next.js, Remix/React Router 7,
56
+ Vue/Nuxt, Angular, SvelteKit, Astro, Turbo and htmx, with no code change on
57
+ the site. Inside a server-rendered root, Luma waits for that framework's own
58
+ hydration signal before inserting anything. Every framework host has a
59
+ contract suite on Chromium, Firefox and WebKit.
60
+ - **Listen at the window, not on host elements.** Input, submit and keydown are
61
+ window-capture listeners matched against selectors; inputs in shadow roots
62
+ are read from the composed path. Nothing is bound to the host's input or
63
+ form, so a re-created input keeps working.
64
+
65
+ ### B. Faster than the site it replaces
66
+
67
+ - **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.
71
+ - **A small entry, one round trip.** The entry is at most 14 kB brotli (one TCP
72
+ initial congestion window). Each widget's code, templates, styles and
73
+ dictionary are 2–7 kB chunks loaded only when that widget can run: a site
74
+ without recommenders never downloads that code.
75
+ - **Never block the main thread.** Target: no task of ours over 16 ms (a frame
76
+ at 60 fps) at 4x CPU; the test gate is 30 ms. Typing in autocomplete reaches
77
+ 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
79
+ the same task. Large listings render in steps that each fit a frame.
80
+ - **Instant.** Results are prefetched while the shopper types or hovers a
81
+ filter, Back restores the listing at the tile they followed, product pages
82
+ are prerendered on intent (Speculation Rules, Chromium).
83
+ - **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
86
+ server-rendered listing over. The server renderer and everything it imports
87
+ stay free of DOM access.
88
+
89
+ ### C. Looks native, works for everyone
90
+
91
+ - **The site's own look.** Listing templates reuse the site's markup and CSS
92
+ classes. Luma's built-in CSS sits in `@layer lbx`, so the site's CSS always
93
+ wins. `yarn luma parity` measures how close the port is.
94
+ - **Web components, DOM mode per widget.** Custom elements are the unit of
95
+ placement and lifecycle. Listings are light DOM, so the site's CSS applies.
96
+ Autocomplete is shadow DOM by default: a robust house design isolated from
97
+ host CSS, with a light-DOM mode for sites that want their own styling.
98
+ - **Accessible by design.** WCAG 2.2 AA (EN 301 549, the European
99
+ Accessibility Act). Combobox ARIA on the input (`aria-expanded`,
100
+ `aria-controls`, `aria-activedescendant`), modal dialogs with managed focus,
101
+ `aria-busy` while a listing renders. `yarn luma a11y` checks an integration.
102
+ - **The modern platform, no polyfills.** Baseline Widely available (custom
103
+ elements, shadow DOM, cascade layers, `<dialog>`, `inert`, `color-mix()`).
104
+ Anything newer (Popover API, Navigation API, Speculation Rules) is
105
+ feature-detected and the page works without it. `yarn luma baseline`
106
+ enforces it ([Browser support](#browser-support)).
107
+
108
+ ### D. How we work
109
+
110
+ - **One framework, many integrations.** Nothing site-specific in this
111
+ repository; each integration has its own, with Luma as the `@luigisbox/luma`
112
+ package. `_config.ts` is JSON; code goes in `_init.ts`.
113
+ - **Measured, not claimed.** Every principle has a gate: budgets and
114
+ main-thread limits, request counts, the bfcache test, the baseline lint,
115
+ framework contracts, the accessibility and parity checks. The suites run on
116
+ Chromium, Firefox and WebKit, desktop and phones, on every change. Every fix
117
+ lands with a test that fails without it.
118
+
119
+ What we accept in exchange: [Known limitations](#known-limitations).
120
+
121
+
122
+ ## Repository layout
123
+
124
+ | Path | What |
125
+ |---|---|
126
+ | `src/` | the framework: config, sensing and placement, takeover, templates, API client, widgets |
127
+ | `build/vite.mjs` | the Vite config every integration builds with |
128
+ | `snippet/` | the inline early-hold snippet (identical for every client) |
129
+ | `a11y/` | `yarn luma a11y`: an integration's WCAG 2.2 AA check |
130
+ | `parity/` | `yarn luma parity`: an integration compared with the site it replaces |
131
+ | `bin/` | `yarn luma <tool>`: every tool below, from an integration repository, with one script |
132
+ | `footprint/` | `yarn luma footprint`: what Luma itself costs each page type of an integration |
133
+ | `chrome/` | `yarn luma chrome`: the live storefront in your Chrome with an integration on it, before the site includes it |
134
+ | `tasks/` | `yarn luma tasks`: an integration's main-thread tasks over a frame's budget on the storefront, by source line |
135
+
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)).
139
+
140
+ ## What the customer includes
141
+
142
+ Everything goes in the site's HTML, in `<head>`, on every page.
143
+ `<distribution id>` is the site's distribution (`lbdr_…`).
144
+
145
+ **Loader only** (a tag manager is fine): the site's own listing may flash
146
+ before ours on a hard load.
147
+
148
+ ```html
149
+ <script async src="https://cdn.eu1.luigisbox.ai/b/<distribution id>/loader.js"></script>
150
+ ```
151
+
152
+ **Snippet + loader** (recommended): the inline snippet hides the site's listing
153
+ before first paint, from rules the bundle stored on earlier page views; it makes
154
+ no request and gives up after a timeout. The first page view in a browser has
155
+ no rules yet. Put both first in `<head>`, before the site's own scripts and
156
+ stylesheets (not through a tag manager: too late). With a strict CSP, give the
157
+ snippet's tag the page's `nonce` or allow its hash. On a site whose React
158
+ hydrates the whole document (Remix, React Router 7, Hydrogen), put the loader
159
+ first and the snippet as the last element of `<head>`.
160
+
161
+ <!-- lbx-snippet: the minified form is snippet/early-hold.min.js (generated from snippet/early-hold.js); this copy must not differ from it -->
162
+ ```html
163
+ <script>
164
+ "use strict";(function(){try{if(window.__lbx&&window.__lbx.early)return;var n=JSON.parse(localStorage.getItem("lbx:early")||"null");if(!n||n.v!==1||!(n.exp>Date.now())||performance.getEntriesByName&&performance.getEntriesByName("first-contentful-paint").length)return;for(var o=new URL(location.href),i=0;i<n.routes.length;i++){var e=n.routes[i];if(new RegExp(e.path).test(o.pathname)){for(var r=!0,l=0;e.need&&l<e.need.length;l++)(o.searchParams.get(e.need[l])||"").trim()||(r=!1);if(r&&e.allow){var d=new RegExp(e.allow);o.searchParams.forEach(function(a,p){a&&!d.test(p)&&(r=!1)})}if(r){var t=document.createElement("style"),c=document.currentScript;c&&c.nonce&&(t.nonce=c.nonce),t.setAttribute("data-lbx","early"),t.textContent=e.css,(document.head||document.documentElement).appendChild(t);var f=function(){clearTimeout(u),t.parentNode&&t.parentNode.removeChild(t)},u=setTimeout(f,n.ms||1500),m=window.__lbx=window.__lbx||{};m.early={t:performance.now(),route:e.id,snippet:1,claim:function(a){return!t.parentNode||a!==e.id?!1:(clearTimeout(u),!0)},drop:f};return}}}}catch(a){}})();
165
+ </script>
166
+ <script async src="https://cdn.eu1.luigisbox.ai/b/<distribution id>/loader.js"></script>
167
+ ```
168
+
169
+ **Connection hints** (recommended, first in `<head>`, before the snippet): the
170
+ connections our requests need, opened while the HTML is still arriving instead
171
+ of after the loader and the entry have run (the entry adds the same hints, but
172
+ only once `<body>` starts). On a first visit they take the TCP and TLS setup
173
+ (one to three round trips per host) off the path to the listing. Use the
174
+ region of the site's distribution (`eu1` here) and the host of the
175
+ catalog's product images.
176
+
177
+ ```html
178
+ <link rel="preconnect" href="https://cdn.eu1.luigisbox.ai">
179
+ <link rel="preconnect" href="https://cdn.eu1.luigisbox.ai" crossorigin>
180
+ <link rel="preconnect" href="https://api.eu1.luigisbox.ai" crossorigin>
181
+ <link rel="preconnect" href="https://auth.eu1.luigisbox.ai" crossorigin>
182
+ <link rel="preconnect" href="https://<product image host>">
183
+ ```
184
+
185
+ - The CDN twice: the loader is a classic script (no CORS) and the chunks are
186
+ modules loaded with CORS. A browser does not share connections between the two
187
+ 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.
189
+ A preconnect without it opens a connection they cannot use.
190
+ - The image host without `crossorigin` (images are no-CORS). Leave it out if
191
+ it is the site's own host (connected already).
192
+ - At most these five. More preconnects compete with the page's own
193
+ connections, and an unused one is closed after about 10 s.
194
+ - On listing and search pages, `fetchpriority="high"` on the loader's tag moves
195
+ 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
197
+ `yarn luma footprint` and DevTools before keeping it.
198
+
199
+ The whole `<head>` start of a listing page, in order:
200
+
201
+ ```html
202
+ <head>
203
+ <meta charset="utf-8">
204
+ <link rel="preconnect" href="https://cdn.eu1.luigisbox.ai">
205
+ <link rel="preconnect" href="https://cdn.eu1.luigisbox.ai" crossorigin>
206
+ <link rel="preconnect" href="https://api.eu1.luigisbox.ai" crossorigin>
207
+ <link rel="preconnect" href="https://auth.eu1.luigisbox.ai" crossorigin>
208
+ <link rel="preconnect" href="https://<product image host>">
209
+ <script>/* the early-hold snippet above, verbatim */</script>
210
+ <script async fetchpriority="high" src="https://cdn.eu1.luigisbox.ai/b/<distribution id>/loader.js"></script>
211
+ <!-- the site's own stylesheets and scripts -->
212
+ ```
213
+
214
+ What to measure, and the numbers to optimize for:
215
+ [docs/INTEGRATION-PERFORMANCE.md](docs/INTEGRATION-PERFORMANCE.md).
216
+
217
+ **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
219
+ 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
221
+ include sits outside any root the site's own framework hydrates, and the site's
222
+ own listing stays the fallback.
223
+
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.
227
+
228
+ ## Integration repository
229
+
230
+ An integration repository has the shape of the distribution platform's
231
+ scaffold: Luma as the `@luigisbox/luma` package (a devDependency, in
232
+ `node_modules/@luigisbox/luma/`), the integration in `integration/`, a Vite
233
+ build that emits `dist/luma-integration.js` (what the platform's loader
234
+ imports) plus on-demand chunks. Its `vite.config.ts`:
235
+
236
+ ```ts
237
+ import mkcert from 'vite-plugin-mkcert';
238
+ import { clientConfig } from '@luigisbox/luma/build/vite.mjs';
239
+
240
+ export default clientConfig(import.meta.url, { plugins: [mkcert()], server: { host: 'localhost', https: {} } });
241
+ ```
242
+
243
+ Its `tsconfig.json` extends `@luigisbox/luma/tsconfig.json`, includes
244
+ `integration/**/*` and `node_modules/@luigisbox/luma/src/**/*`, and maps `@/*`
245
+ to `./node_modules/@luigisbox/luma/src/*`. Its `package.json` depends on
246
+ `@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.
251
+
252
+ The package holds what an integration uses: the framework's source, the build
253
+ config, `yarn luma` and the client tools, and their docs.
254
+
255
+ ```
256
+ integration/
257
+ __integration.ts hands the folder to the framework (startIntegration)
258
+ _global/ _config.ts _init.ts [_widget.ts] _translations.ts style-*.css
259
+ template-*.ts shared by every widget (a tile, …)
260
+ autocomplete/ _config.ts [_init.ts] [_widget.ts] [style-host.css] [template-autocomplete-*.ts]
261
+ search/ _config.ts _translations.ts style-host.css template-search-*.ts
262
+ plp/ _config.ts _init.ts style-host.css (templates fall back to search/)
263
+ recommender <name>/ _config.ts [_init.ts] _translations.ts
264
+ ```
265
+
266
+ - **`_config.ts` is JSON** (checked at boot); anything that needs code goes in
267
+ `_init.ts`. `_global/_init.ts` runs first and can change any config or return
268
+ `false` to stop; a widget's `_init.ts` gets that widget's config. Every config
269
+ key is typed and documented in `src/types.ts`.
270
+ - **`_init.ts` ships and runs on every page; `_widget.ts` loads with its
271
+ widget.** Keep in `_init.ts` only what decides whether and where a widget
272
+ runs (`disabled`, `active`, `readQuery`, `items`, `intent`, selectors set in
273
+ code, switching the site's own widgets off). Hooks only a widget calls
274
+ (`computedAttributes`, `toFilter`, `facetsFor`, `onRender`, `onOpen`, …) go
275
+ in the folder's optional `_widget.ts`, same signature (its return value is
276
+ ignored): it is loaded with the widget's chunk and run once before its
277
+ context is made, `_global/_widget.ts` first. `__integration.ts` hands it
278
+ over as a lazy glob: `widgetInits: import.meta.glob('./*/_widget.ts', { import: 'default' })`.
279
+ - **`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
281
+ CSS sits in `@layer lbx`, so the integration's and the site's CSS win.
282
+ - **Templates override by name**: a widget's built-in templates, then
283
+ `_global/`, then the widget's own folder. Names: `search-box`, `search-header`,
284
+ `search-facets-default`, `search-results-default`, `search-result-default`,
285
+ `search-pagination`, `autocomplete-box`, `autocomplete-result-default`,
286
+ `autocomplete-sheet`, `autocomplete-empty`, `recommender-box-default`,
287
+ `recommender-result-default`, `recommender-popup`.
288
+
289
+ ### Templates
290
+
291
+ Templates are [lit-html](https://lit.dev/docs/templates/overview/): each
292
+ `template-<name>.ts` default-exports a function of the template's scope that
293
+ returns `` html`…` ``. Only changed bindings touch the DOM, so images, focus and
294
+ open `<details>` survive re-renders; no `eval`, CSP-safe. Import lit from `@/lit`
295
+ and the scope types from `@/types` (`SearchScope`, `SearchResultScope`,
296
+ `AutocompleteScope`, `RecommenderScope`, …).
297
+
298
+ ```ts
299
+ // integration/search/template-search-result-default.ts
300
+ import { html, nothing } from '@/lit';
301
+ import type { SearchResultScope } from '@/types';
302
+
303
+ export default ({ r, i, img, price }: SearchResultScope) => html`
304
+ <a class="tile" href=${r.c.url}>
305
+ ${img(r, i)}
306
+ <span>${r.c.title}</span>
307
+ ${r.c.price ? html`<b>${price(r.c.price)}</b>` : nothing}
308
+ </a>`;
309
+ ```
310
+
311
+ | lit | |
312
+ |---|---|
313
+ | `${expr}` | text (escaped) |
314
+ | `` ${cond ? html`…` : nothing} `` | conditions |
315
+ | `` ${repeat(list, (x) => x.id, (x, i) => html`…`)} `` | keyed lists (`list.map(…)` for unkeyed) |
316
+ | `attr=${v}`, `?disabled=${b}`, `.checked=${b}` | attribute, boolean attribute, property |
317
+ | `@click=${s.showMore}`, `@change=${() => s.toggleFacet(f.name, v.value)}` | event handlers |
318
+ | `${include('product-tile', { extra })}` | another template of the widget, with this scope plus props |
319
+
320
+ - **Results**: `r.a` is the hit's attributes, `r.c` its computed attributes
321
+ (functions in `_global/_init.ts`, `computedAttributes`). `r.c.url` is always a
322
+ web link; a `javascript:` or `data:` value becomes `''`. Links you bind from
323
+ `r.a` or your own computed attributes, and `unsafeHTML`, get no such check:
324
+ keep catalog data out of them, or check it. With Trusted Types enforced, the
325
+ site's `trusted-types` directive must allow the `lit-html` policy.
326
+ - **Helpers** in every scope: `t(key, params)`, `price(n)`, `plain()`,
327
+ `first()`, `include()`, and `img(r, i)` (a complete `<img>` in its placeholder
328
+ box) / `image(r, i)` (its attributes, for your own markup), configured by
329
+ `_global.images` (srcset, sizes, the eager first row, placeholders).
330
+ - **Pagination** is "show more" only: templates get `results`, `more` and
331
+ `showMore`; there are no numbered pages.
332
+ - **Translations** are JSON dictionaries per locale (`src/locales/<locale>.json`,
333
+ `{name}` placeholders, plural forms by `Intl.PluralRules` category); only the
334
+ site's locale is loaded. Override keys in `_translations.ts`, keyed by locale:
335
+ `_global` for all widgets, a widget's own for that widget. Give
336
+ `__integration.ts` their glob without `eager`
337
+ (`translations: import.meta.glob('./*/_translations.ts', { import: 'default' })`)
338
+ and they load with their widget instead of on every page; `_init.ts` then
339
+ does not see them.
340
+
341
+ ### Behaviour switches
342
+
343
+ On by default unless noted; each falls back to what the page would do anyway.
344
+
345
+ | Config | What it does | Turn it off when |
346
+ |---|---|---|
347
+ | `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
+ | `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 | |
350
+ | `prefetchNextPage` | "show more" is fetched as the shopper nears the end | |
351
+ | `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
+ | `_global.prefetchFilters` | a filter, sort or chip is fetched on hover, touch or focus | |
353
+ | `_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
+
355
+ ## Browser support
356
+
357
+ Luma targets [Baseline](https://web.dev/baseline) **Widely available**; anything
358
+ newer (Popover API, Navigation API, Speculation Rules) is
359
+ feature-detected and optional. **HTTPS only**: on a plain-HTTP page Luma does
360
+ not start and the site keeps its own search. Tested on Chromium, Firefox and
361
+ WebKit, desktop and phone, on every change.
362
+
363
+ An integration repository runs the same check on its `integration/` folder:
364
+ `yarn luma baseline`, and `yarn luma
365
+ baseline --built dist` over a build's output (below, "Tools for an
366
+ integration repository").
367
+
368
+ ## Known limitations
369
+
370
+ - **Autocomplete needs the Popover API**: without it (Safari < 17, Firefox
371
+ < 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.
377
+ - **Prerendering** costs the site a page render per unfollowed hover
378
+ (`speculation: false` turns it off).
379
+ - **Early hold**: the snippet carries no client's rules, so the first page
380
+ view is not held.
381
+ - **Host routers out of reach**: where Luma cannot find the host's router (Vue
382
+ 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
384
+ box. `inPlaceQuery: false` for such a site.
385
+
386
+ ## Development
387
+
388
+ Node 24 or later, Yarn 4 (corepack).
389
+
390
+ ### Seeing an integration on the storefront
391
+
392
+ 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.
394
+
395
+ **The site includes the loader.** Serve the integration and add
396
+ `?_lbx_env=development` to any page of the site:
397
+
398
+ ```bash
399
+ yarn dev # serves the integration on https://localhost:8069
400
+ ```
401
+
402
+ ```
403
+ https://www.shop.example/some-category?_lbx_env=development
404
+ ```
405
+
406
+ The loader then imports the integration from your dev server instead of the
407
+ published build (a red banner says so); an edit shows on reload.
408
+ `LBX_DEV_ORIGINS` (comma-separated) limits which sites may load from it.
409
+
410
+ **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,
412
+ then the build):
413
+
414
+ ```bash
415
+ yarn luma chrome # builds, opens the home page in your Chrome
416
+ yarn luma chrome /some-category # another page
417
+ yarn luma chrome --dev # from yarn dev: an edit shows on reload, no build
418
+ ```
419
+
420
+ It needs the storefront's host in `luma.config.mjs` (`export const host =
421
+ 'www.shop.example'`). The site's own Luigi's Box scripts are blocked, nothing
422
+ else of the site changes, and the browser profile is kept per storefront
423
+ (`~/.lbx-chrome/<host>`), apart from your own. `--check` prints what Luma did
424
+ and exits; `--help` lists the rest ([`chrome/run.mjs`](chrome/run.mjs)).
425
+
426
+ Either way, `window.__lbx` in the console is Luma's state (`health.errors`,
427
+ `health.timeline`). `yarn build` emits `dist/`.
428
+
429
+ ### Working on Luma itself
430
+
431
+ Luma is developed by Luigi's Box. Every change is type-checked and runs the
432
+ unit and browser suites and the performance gates on Chromium, Firefox and
433
+ WebKit before it ships.
434
+
435
+ ### Tools for an integration repository
436
+
437
+ 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
440
+ `luma.config.mjs` the storefront's host (`export const host =
441
+ 'www.shop.example'`): a tool Luma adds is there once Luma is updated, nothing to
442
+ change in the integration. `yarn luma` lists them; `yarn luma tooling --write`
443
+ sets an integration up
444
+ ([`bin/tools.mjs`](bin/tools.mjs), [`build/tooling.mjs`](build/tooling.mjs)).
445
+
446
+ | Command | What |
447
+ |---|---|
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)) |
449
+ | `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)) |
451
+ | `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)) |
453
+ | `yarn luma baseline` | nothing in `integration/` newer than Baseline Widely available (`--built dist`: the build) |
454
+
455
+ The integration's settings for the tools are its `luma.config.mjs`
456
+ ([`bin/config.mjs`](bin/config.mjs) loads it, with Luma's defaults). Every
457
+ export is optional:
458
+
459
+ ```js
460
+ // The storefront's host name: the tools open it (LBX_HOST overrides it). Without
461
+ // it they use the stub page (`yarn luma chrome` needs it).
462
+ export const host = 'www.shop.example';
463
+ // The site's name (LBX_SITE overrides it); default: package.json's name.
464
+ export const site = 'shop';
465
+ // A page in the site's own markup and stylesheet, for the tools' runs on the
466
+ // stub page (a string, or a promise of one).
467
+ export const stubPage = (url) => `<!DOCTYPE html>…`;
468
+ // What the tools' scenarios load: a category page, a search, a product page,
469
+ // the header search input.
470
+ export const fixtures = { category: '/some-category', query: 'shoes', product: '/some-product', input: 'input#search' };
471
+ // The pages footprint and tasks measure, { label: path }; default: home,
472
+ // fixtures.category, a search for fixtures.query, fixtures.product.
473
+ export const perf = { pages: { home: '/', category: '/some-category', search: '/search?q=shoes' } };
474
+ ```
475
+
476
+ Without a `stubPage`: a neutral page with a header search form (`input
477
+ name=q`), a search anchor `<div id="lbx-search-anchor">` on `/search`, and
478
+ elsewhere a category listing `<div id="lbx-listing">`. Older integrations kept
479
+ these settings in `edge/rig.mjs`: the tools refuse that name, `yarn luma
480
+ tooling --write` moves it.
481
+
482
+ How to make an integration fast and light, and which tool measures what:
483
+ [`docs/INTEGRATION-PERFORMANCE.md`](docs/INTEGRATION-PERFORMANCE.md).
484
+
485
+ ## Documentation
486
+
487
+ - [`docs/INTEGRATION-PERFORMANCE.md`](docs/INTEGRATION-PERFORMANCE.md): what to measure in integration work, and the metrics to optimize.
488
+ - [`docs/ACCESSIBILITY.md`](docs/ACCESSIBILITY.md): what Luma guarantees, what templates must do.
489
+ - [`docs/PARITY.md`](docs/PARITY.md): the parity check.