@luigisbox/luma 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +615 -2
  3. package/a11y/audit.mjs +735 -0
  4. package/a11y/axe.mjs +73 -0
  5. package/a11y/report.mjs +347 -0
  6. package/a11y/run.mjs +256 -0
  7. package/a11y/wcag.mjs +233 -0
  8. package/bin/config.mjs +60 -0
  9. package/bin/luma.mjs +99 -0
  10. package/bin/paths.mjs +11 -0
  11. package/bin/tools.mjs +43 -0
  12. package/build/baseline.d.mts +8 -0
  13. package/build/baseline.mjs +167 -0
  14. package/build/tooling.mjs +102 -0
  15. package/build/vite.mjs +507 -0
  16. package/chrome/block.mjs +8 -0
  17. package/chrome/run.mjs +150 -0
  18. package/docs/ACCESSIBILITY.md +501 -0
  19. package/docs/INTEGRATION-PERFORMANCE.md +213 -0
  20. package/docs/PARITY.md +946 -0
  21. package/footprint/model.mjs +184 -0
  22. package/footprint/run.mjs +331 -0
  23. package/package.json +58 -4
  24. package/parity/alignment.mjs +109 -0
  25. package/parity/archive.mjs +223 -0
  26. package/parity/args.mjs +137 -0
  27. package/parity/behaviour.mjs +379 -0
  28. package/parity/compare.mjs +985 -0
  29. package/parity/engine.mjs +848 -0
  30. package/parity/hints.mjs +132 -0
  31. package/parity/hold.mjs +54 -0
  32. package/parity/init.mjs +703 -0
  33. package/parity/map.mjs +640 -0
  34. package/parity/owner.mjs +170 -0
  35. package/parity/pairing.mjs +110 -0
  36. package/parity/pixels.mjs +33 -0
  37. package/parity/probe.mjs +1239 -0
  38. package/parity/report.mjs +236 -0
  39. package/parity/run.mjs +267 -0
  40. package/snippet/early-hold.js +64 -0
  41. package/snippet/early-hold.min.js +1 -0
  42. package/src/analytics/chain.ts +137 -0
  43. package/src/analytics/collect.ts +64 -0
  44. package/src/analytics/datalayer.ts +67 -0
  45. package/src/analytics/from-ga4.ts +68 -0
  46. package/src/analytics/from-luma.ts +21 -0
  47. package/src/analytics/index.ts +24 -0
  48. package/src/analytics/outbox.ts +118 -0
  49. package/src/analytics/page.ts +46 -0
  50. package/src/analytics/pageview.ts +82 -0
  51. package/src/analytics/ref.ts +19 -0
  52. package/src/analytics/setup.ts +41 -0
  53. package/src/analytics/start.ts +53 -0
  54. package/src/analytics/triggers.ts +239 -0
  55. package/src/analytics/user.ts +12 -0
  56. package/src/analytics/wire.ts +67 -0
  57. package/src/api/ai.ts +98 -0
  58. package/src/api/index.ts +87 -0
  59. package/src/api/token-key.ts +42 -0
  60. package/src/api/token.ts +312 -0
  61. package/src/core/a11y-keys.ts +60 -0
  62. package/src/core/a11y.ts +160 -0
  63. package/src/core/boot-state.ts +6 -0
  64. package/src/core/color.ts +42 -0
  65. package/src/core/context.ts +108 -0
  66. package/src/core/early-rules.ts +96 -0
  67. package/src/core/entries.ts +25 -0
  68. package/src/core/fold.ts +7 -0
  69. package/src/core/health.ts +26 -0
  70. package/src/core/hold.ts +74 -0
  71. package/src/core/i18n.ts +66 -0
  72. package/src/core/image-load.ts +102 -0
  73. package/src/core/image-tags.ts +26 -0
  74. package/src/core/images.ts +170 -0
  75. package/src/core/integration.ts +126 -0
  76. package/src/core/kept.ts +25 -0
  77. package/src/core/listings.ts +100 -0
  78. package/src/core/locales.ts +14 -0
  79. package/src/core/own.ts +49 -0
  80. package/src/core/page.ts +14 -0
  81. package/src/core/prefetch.ts +88 -0
  82. package/src/core/recommenders.ts +166 -0
  83. package/src/core/reconciler.ts +397 -0
  84. package/src/core/result.ts +73 -0
  85. package/src/core/routes.ts +115 -0
  86. package/src/core/scheduler.ts +83 -0
  87. package/src/core/sense.ts +107 -0
  88. package/src/core/speculation.ts +59 -0
  89. package/src/core/stash.ts +38 -0
  90. package/src/core/storage.ts +45 -0
  91. package/src/core/styles.ts +106 -0
  92. package/src/core/support.ts +12 -0
  93. package/src/core/takeover.ts +220 -0
  94. package/src/core/templates.ts +74 -0
  95. package/src/core/tiles.ts +137 -0
  96. package/src/core/turbo.ts +64 -0
  97. package/src/core/url.ts +164 -0
  98. package/src/core/widget-inits.ts +40 -0
  99. package/src/env.d.ts +3 -0
  100. package/src/lit.ts +11 -0
  101. package/src/locales/cs.json +94 -0
  102. package/src/locales/de.json +88 -0
  103. package/src/locales/en.json +88 -0
  104. package/src/main.ts +493 -0
  105. package/src/types.ts +778 -0
  106. package/src/widgets/autocomplete/index.ts +1387 -0
  107. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  108. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  109. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  110. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  111. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  112. package/src/widgets/listing/element.ts +1772 -0
  113. package/src/widgets/listing/flow.ts +30 -0
  114. package/src/widgets/listing/instant.ts +133 -0
  115. package/src/widgets/listing/intent.ts +193 -0
  116. package/src/widgets/listing/query.ts +298 -0
  117. package/src/widgets/listing/stash.ts +97 -0
  118. package/src/widgets/listing/state.ts +131 -0
  119. package/src/widgets/listing/view.ts +257 -0
  120. package/src/widgets/plp/index.ts +11 -0
  121. package/src/widgets/plp/route.ts +263 -0
  122. package/src/widgets/recommender/index.ts +261 -0
  123. package/src/widgets/recommender/style-recommender.css +26 -0
  124. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  125. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  126. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  127. package/src/widgets/search/assets.ts +7 -0
  128. package/src/widgets/search/first.ts +15 -0
  129. package/src/widgets/search/form-url.ts +59 -0
  130. package/src/widgets/search/in-place.ts +226 -0
  131. package/src/widgets/search/index.ts +16 -0
  132. package/src/widgets/search/range.ts +260 -0
  133. package/src/widgets/search/route.ts +32 -0
  134. package/src/widgets/search/speculate.ts +202 -0
  135. package/src/widgets/search/style-search.css +65 -0
  136. package/src/widgets/search/template-search-box.ts +17 -0
  137. package/src/widgets/search/template-search-facets-default.ts +73 -0
  138. package/src/widgets/search/template-search-header.ts +14 -0
  139. package/src/widgets/search/template-search-pagination.ts +12 -0
  140. package/src/widgets/search/template-search-range-default.ts +48 -0
  141. package/src/widgets/search/template-search-result-default.ts +13 -0
  142. package/src/widgets/search/template-search-results-default.ts +8 -0
  143. package/tasks/model.mjs +116 -0
  144. package/tasks/run.mjs +305 -0
  145. package/tsconfig.json +14 -0
@@ -0,0 +1,202 @@
1
+ // Search results ready on Enter. While the shopper types in the site's search
2
+ // input (any page), once the phrase settles, the search page's result for the
3
+ // URL their form would submit to is fetched, exactly as the listing would
4
+ // fetch it (listingQuery, page 1, the default sort, the facets and quick
5
+ // types: the listing's first request, nothing more):
6
+ // - in memory (core/prefetch.ts): Enter on the search page (a new query in
7
+ // place, ./in-place.ts) renders it without a request in the task of the
8
+ // history write, or waits for the one in flight (never much longer than a
9
+ // fresh request would take: ../listing/element.ts fromPrefetch);
10
+ // - in sessionStorage (../listing/stash.ts, `spec`): Enter on another page
11
+ // loads the search page, whose listing renders it at boot, like a
12
+ // server-rendered state;
13
+ // - optionally (`prerenderWhileTyping`) the search page itself is prerendered
14
+ // once that result is stashed and the phrase is still the one typed: Enter
15
+ // then activates a page that is already rendered (it renders from the
16
+ // stash: no request of its own).
17
+ //
18
+ // Only what is likely: the settled phrase (not every keystroke), 2+
19
+ // characters, not the page we are on, none offline, with Save-Data or on 2G.
20
+ // One request in flight (a newer phrase aborts it); the latest two results
21
+ // kept, for `prefetchMaxAgeMs` (2 min); identical ones reused meanwhile.
22
+ // Enter, a query suggestion handed to the form or a page change cancel a
23
+ // pending phrase: the page load or the listing asks itself, nothing is
24
+ // fetched for a page that is going away. Loaded by the entry on the first
25
+ // input into `_global.inputSelector`; `search.prefetchWhileTyping: false` turns it off.
26
+ //
27
+ // Analytics: nothing here renders or reports a search. A result is shown (and
28
+ // the integration's onRender runs) only when the shopper asks for it; in a
29
+ // prerendered page, onRender waits for its activation (../listing/element.ts).
30
+ // The API cannot tell these requests from the listing's own.
31
+
32
+ import { getApi } from '../../api';
33
+ import { firstRowImages, resultFactory, warmImages } from '../../core/support';
34
+ import { mark, reportError } from '../../core/health';
35
+ import { pageKey } from '../../core/page';
36
+ import { dropPrefetch, hasPrefetch, putPrefetch } from '../../core/prefetch';
37
+ import { onUrlChange } from '../../core/sense';
38
+ import type { GlobalConfigType, SearchConfigType } from '../../types';
39
+ import { listingQuery, type ListingResult } from '../listing/query';
40
+ import type { Query, Route } from '../listing/state';
41
+ import { writeStash } from '../listing/stash';
42
+ import { formUrl } from './form-url';
43
+ import { searchRoute } from './route';
44
+
45
+ const MIN_CHARS = 2;
46
+ /** Speculative results kept at once (the phrase before the latest one: a shopper backspacing). */
47
+ const KEEP = 2;
48
+
49
+ const thrifty = () => {
50
+ const c = (navigator as any).connection;
51
+ return navigator.onLine === false || !!c?.saveData || /(^|-)2g$/.test(c?.effectiveType ?? '');
52
+ };
53
+
54
+ export const startSpeculation = (g: GlobalConfigType, cfg: SearchConfigType, first?: HTMLInputElement) => {
55
+ const api = getApi(g);
56
+ const route = searchRoute(cfg);
57
+ const sel = g.inputSelector!;
58
+ const delay = cfg.prefetchDelayMs ?? 300;
59
+ const ttl = cfg.prefetchMaxAgeMs ?? 2 * 60e3;
60
+ let timer = 0;
61
+ let flight: { key: string; ctrl: AbortController; p: Promise<unknown>; href: string } | null = null;
62
+ /** Page -> when its result arrived (reused for ttl). */
63
+ const done = new Map<string, number>();
64
+ // The first images of a result fetched ahead (`_global.images.warm`, off by default): its tiles show at once on Enter.
65
+ // core/support is lbx-core's, which lbx-nav imports anyway (the API client's requests).
66
+ const warm = g.images?.warm ?? 0;
67
+ const warmFor = (res: ListingResult) => {
68
+ try {
69
+ warmImages(firstRowImages(g.images, res.hits.slice(0, warm).map(resultFactory({ ...g.computedAttributes, ...cfg.computedAttributes })), true), warm);
70
+ } catch {}
71
+ };
72
+ /** Ours in the prefetch map, oldest first. */
73
+ const kept: { href: string; p: Promise<unknown> }[] = [];
74
+
75
+ /** The search input an event is about: the input itself, or another control of its form (a filter the form submits). */
76
+ const inputOf = (e: Event) => {
77
+ const t = e.composedPath?.()[0] ?? e.target;
78
+ if (t instanceof HTMLInputElement && t.matches(sel)) return t;
79
+ const form = (t as HTMLInputElement | null)?.form;
80
+ return form instanceof HTMLFormElement ? ([...form.elements].find((el) => el instanceof HTMLInputElement && el.matches(sel)) as HTMLInputElement | undefined) ?? null : null;
81
+ };
82
+
83
+ const prerender = startPrerender(cfg, route);
84
+
85
+ const run = (i: HTMLInputElement) => {
86
+ clearTimeout(timer);
87
+ if (thrifty() || !i.form || !i.isConnected) return;
88
+ const url = formUrl(i.form, null);
89
+ if (!url || url.origin !== location.origin || !route.accepts(url)) return;
90
+ const key = pageKey(url.href);
91
+ if (key === pageKey(location.href)) return; // the page we are on
92
+ const q = route.resolve(url) as Query | null;
93
+ if (!q?.q || q.q.length < MIN_CHARS) return;
94
+ prerender?.keep(key);
95
+ if (flight?.key === key || hasPrefetch(url.href) || performance.now() - (done.get(key) ?? -Infinity) < ttl) return;
96
+ if (flight) {
97
+ flight.ctrl.abort(); // a newer phrase: the older result is not what Enter will ask for
98
+ dropPrefetch(flight.href, flight.p);
99
+ }
100
+ const ctrl = new AbortController();
101
+ mark(`search prefetch: ${q.q}`);
102
+ const p: Promise<ListingResult | null> = listingQuery(api, g, cfg, route, q, ctrl.signal).then(
103
+ (res) => {
104
+ if (flight?.ctrl === ctrl) flight = null;
105
+ done.set(key, performance.now());
106
+ // For a page load of it: the whole result, unless something optional failed.
107
+ if (!res.partial && writeStash(url.href, { kind: 'spec', route: route.id, query: q, result: res, ttl })) {
108
+ // Still the phrase in the form: that page, rendered ahead (it renders from the stash).
109
+ const now = i.isConnected && i.form ? formUrl(i.form, null) : null;
110
+ if (now && pageKey(now.href) === key) prerender?.start(url.href, key);
111
+ }
112
+ if (warm > 0) warmFor(res);
113
+ return res;
114
+ },
115
+ (e) => {
116
+ if (flight?.ctrl === ctrl) flight = null;
117
+ if ((e as Error)?.name !== 'AbortError') reportError('search prefetch', e);
118
+ return null; // the listing asks itself
119
+ },
120
+ );
121
+ flight = { key, ctrl, p, href: url.href };
122
+ putPrefetch(url.href, p, { ttl, spec: true, abort: () => ctrl.abort() });
123
+ // Memory: the latest few phrases only (each a page of hits, facets and quick types).
124
+ kept.push({ href: url.href, p });
125
+ for (const old of kept.splice(0, Math.max(0, kept.length - KEEP))) dropPrefetch(old.href, old.p);
126
+ };
127
+
128
+ const onInput = (e: Event) => {
129
+ const i = inputOf(e);
130
+ if (!i) return;
131
+ clearTimeout(timer);
132
+ timer = window.setTimeout(() => {
133
+ try {
134
+ run(i);
135
+ } catch (err) {
136
+ reportError('search prefetch', err);
137
+ }
138
+ }, delay);
139
+ };
140
+ // Enter (or the autocomplete's hand-off, a synthetic Enter) before the
141
+ // phrase settled: not fetched from here. A page load asks itself (a request
142
+ // started now would die with this page); in place, the listing asks itself.
143
+ const onKey = (e: KeyboardEvent) => {
144
+ if (e.key === 'Enter' && !e.isComposing && inputOf(e)) clearTimeout(timer);
145
+ };
146
+ addEventListener('input', onInput, true);
147
+ addEventListener('change', onInput, true);
148
+ addEventListener('keydown', onKey, true);
149
+ // Another page (a query in place, an SPA visit): a pending phrase is moot,
150
+ // and a flight for another page than this one is no longer needed.
151
+ onUrlChange((u) => {
152
+ clearTimeout(timer);
153
+ if (flight && flight.key !== pageKey(u.href)) {
154
+ flight.ctrl.abort();
155
+ dropPrefetch(flight.href, flight.p);
156
+ flight = null;
157
+ }
158
+ });
159
+ if (first) onInput({ target: first, composedPath: () => [first] } as unknown as Event);
160
+ };
161
+
162
+ /**
163
+ * The search page prerendered while the shopper types (`prerenderWhileTyping`,
164
+ * Speculation Rules: Chrome, Edge): one list rule for one URL, the phrase whose
165
+ * result was just stashed. A newer phrase replaces it (removing a rule cancels
166
+ * its prerender): one prerendered search page at most. Not on the search page
167
+ * itself when queries there are in place (no page load to save).
168
+ *
169
+ * The cost: a host document rendered on their server per settled phrase the
170
+ * shopper may never submit, and their scripts run in it (Chromium runs a
171
+ * prerendered page's scripts; only APIs with visible effects wait for the
172
+ * activation: tags that defer to `prerenderingchange`, as Google's do, count
173
+ * nothing until then, others count a page view). Ours renders from the stash
174
+ * (no request) and defers onRender to the activation. Off by default.
175
+ */
176
+ const startPrerender = (cfg: SearchConfigType, route: Route) => {
177
+ if (!cfg.prerenderWhileTyping || !(HTMLScriptElement as any).supports?.('speculationrules')) return null;
178
+ let el: HTMLScriptElement | null = null;
179
+ let at = '';
180
+ const stop = () => {
181
+ el?.remove();
182
+ el = null;
183
+ at = '';
184
+ };
185
+ return {
186
+ /** The phrase now settled: a prerender of another page goes. */
187
+ keep: (key: string) => void (at && at !== key && stop()),
188
+ start: (href: string, key: string) => {
189
+ if (at === key) return;
190
+ // A search page in place has no page load to save.
191
+ if (cfg.inPlaceQuery !== false && route.accepts(new URL(location.href))) return;
192
+ stop();
193
+ el = document.createElement('script');
194
+ el.type = 'speculationrules';
195
+ el.setAttribute('data-lbx', 'search-prerender');
196
+ el.textContent = JSON.stringify({ prerender: [{ source: 'list', urls: [href], eagerness: cfg.prerenderEagerness ?? 'immediate' }] });
197
+ document.head.append(el);
198
+ at = key;
199
+ mark(`search prerender: ${href}`);
200
+ },
201
+ };
202
+ };
@@ -0,0 +1,65 @@
1
+ lbx-search, lbx-plp { display: block; }
2
+ /* Text for screen readers only, for templates ("Was" before an old price): here too for server-rendered pages. */
3
+ .lbx-sr-only:not(:focus):not(:focus-within) { position: absolute !important; width: 1px !important; height: 1px !important; margin: -1px !important; padding: 0 !important; border: 0 !important; overflow: hidden !important; clip-path: inset(50%) !important; white-space: nowrap !important; }
4
+ /* The region takes focus only from script (a render removed the focused control): no ring on the whole listing. */
5
+ lbx-search:focus, lbx-plp:focus { outline: none; }
6
+ .lbx-s__head { display: flex; flex-wrap: wrap; gap: 12px; justify-content: space-between; align-items: center; margin: 16px 0; }
7
+ .lbx-s__body { display: grid; grid-template-columns: minmax(180px, 240px) 1fr; gap: 24px; align-items: start; }
8
+ .lbx-s__facets fieldset { border: 0; padding: 0; margin: 0 0 16px; }
9
+ .lbx-s__facets legend { font-weight: 600; margin-bottom: 6px; }
10
+ /* Targets of at least 24 px (WCAG 2.5.8). */
11
+ .lbx-s__facets label { display: flex; gap: 8px; align-items: center; min-height: 24px; margin: 4px 0; cursor: pointer; }
12
+ .lbx-s__facets summary { cursor: pointer; font-weight: 600; margin-bottom: 8px; min-height: 24px; }
13
+ .lbx-s__sort select, .lbx-s__more { min-height: 24px; min-width: 24px; }
14
+ .lbx-s__main { min-width: 0; }
15
+ .lbx-s__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(180px, 100%), 1fr)); gap: 16px; }
16
+ .lbx-s__tile { display: flex; flex-direction: column; gap: 6px; color: inherit; text-decoration: var(--lbx-c-underline, none); }
17
+ /* Images (src/core/images.ts): the box keeps its size before the image arrives; its placeholder (the catalog's --lbx-ph, else a neutral skeleton) until it has loaded; lazy images fade in, never the first row, not under reduced motion. */
18
+ .lbx-img { display: block; }
19
+ .lbx-img > img { display: block; width: 100%; height: 100%; object-fit: contain; }
20
+ [data-lbx-ph]:not([data-lbx-loaded]) { background: var(--lbx-ph, var(--lbx-c-ph, #efefef)); }
21
+ @media (prefers-reduced-motion: no-preference) {
22
+ [data-lbx-fade] img[data-lbx-img][loading="lazy"]:not([data-lbx-loaded]) { opacity: 0; }
23
+ [data-lbx-fade] img[data-lbx-loaded="fade"] { animation: lbx-img-in .25s ease-out; }
24
+ }
25
+ @keyframes lbx-img-in { from { opacity: 0; } }
26
+ .lbx-s__tile-price { font-weight: 600; }
27
+ .lbx-s__pager { display: flex; flex-direction: column; gap: 8px; align-items: center; margin: 24px 0; }
28
+ .lbx-s__shown { margin: 0; }
29
+ .lbx-s__msg { padding: 32px 0; text-align: center; }
30
+ @media (max-width: 767px) { .lbx-s__body { grid-template-columns: 1fr; } }
31
+ /* Facet groups as an accordion (facetGroups.open): the title is the button. */
32
+ .lbx-s__group-toggle { display: flex; width: 100%; justify-content: space-between; align-items: center; gap: 8px; min-height: 24px; padding: 0; border: 0; background: none; font: inherit; font-weight: 600; color: inherit; text-align: start; cursor: pointer; }
33
+ .lbx-s__group-toggle::after { content: ""; flex: none; width: .45em; height: .45em; margin-inline-end: 4px; border: solid currentColor; border-width: 0 2px 2px 0; transform: rotate(45deg); }
34
+ .lbx-s__group-toggle[aria-expanded="true"]::after { transform: rotate(-135deg); }
35
+ .lbx-s__group-n { font-weight: 400; }
36
+ /* The active filters ("Selected filters: Colour: Red ×"). */
37
+ .lbx-s__chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 16px; }
38
+ .lbx-s__chips-title { flex-basis: 100%; font-weight: 600; }
39
+ .lbx-s__chip, .lbx-s__clear { display: inline-flex; align-items: center; min-height: 24px; color: inherit; }
40
+ .lbx-s__chip { padding: 2px 10px; border: 1px solid currentColor; border-radius: 12px; text-decoration: none; }
41
+ /* A group's search box (facetGroups.search). */
42
+ .lbx-s__fsearch { margin: 4px 0 8px; }
43
+ .lbx-s__fsearch-input { box-sizing: border-box; width: 100%; min-height: 24px; font: inherit; }
44
+ .lbx-s__fsearch-n { display: block; min-height: 1.2em; font-size: .875em; }
45
+ /* A range facet's slider: its theming variables in template-search-range-default.ts. */
46
+ .lbx-s__range-slider { --lbx-rs: var(--lbx-range-thumb-size, 24px); --lbx-rt: var(--lbx-range-thumb, #fff); --lbx-rb: var(--lbx-range-thumb-border, currentColor); --lbx-rr: var(--lbx-range-ring, currentColor); position: relative; height: max(24px, var(--lbx-rs)); margin: 4px 0 8px; }
47
+ .lbx-s__range-track { position: absolute; top: 50%; left: calc(var(--lbx-rs) / 2); right: calc(var(--lbx-rs) / 2); height: var(--lbx-range-height, 4px); transform: translateY(-50%); border-radius: 4px; background: var(--lbx-range-track, #c4c4c4); }
48
+ .lbx-s__range-fill { position: absolute; top: 0; bottom: 0; left: var(--lbx-from, 0%); right: calc(100% - var(--lbx-to, 100%)); border-radius: inherit; background: var(--lbx-range-fill, currentColor); }
49
+ [dir="rtl"] .lbx-s__range-fill { left: calc(100% - var(--lbx-to, 100%)); right: var(--lbx-from, 0%); }
50
+ .lbx-s__range-thumb { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; background: none; color: inherit; font: inherit; appearance: none; pointer-events: none; }
51
+ .lbx-s__range-thumb[data-top] { z-index: 1; }
52
+ .lbx-s__range-thumb:focus-visible { outline: none; }
53
+ .lbx-s__range-thumb::-webkit-slider-runnable-track { height: 100%; border: 0; background: none; }
54
+ .lbx-s__range-thumb::-moz-range-track { height: 100%; border: 0; background: none; }
55
+ .lbx-s__range-thumb::-webkit-slider-thumb { box-sizing: border-box; width: var(--lbx-rs); height: var(--lbx-rs); margin-top: calc((max(24px, var(--lbx-rs)) - var(--lbx-rs)) / 2); border: 2px solid var(--lbx-rb); border-radius: 50%; background: var(--lbx-rt); cursor: grab; pointer-events: auto; appearance: none; }
56
+ .lbx-s__range-thumb::-moz-range-thumb { box-sizing: border-box; width: var(--lbx-rs); height: var(--lbx-rs); border: 2px solid var(--lbx-rb); border-radius: 50%; background: var(--lbx-rt); cursor: grab; pointer-events: auto; }
57
+ .lbx-s__range-thumb:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 2px var(--lbx-rt), 0 0 0 4px var(--lbx-rr); }
58
+ .lbx-s__range-thumb:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 2px var(--lbx-rt), 0 0 0 4px var(--lbx-rr); }
59
+ .lbx-s__range-inputs { display: flex; gap: 8px; align-items: center; }
60
+ .lbx-s__range-num { box-sizing: border-box; flex: 1 1 0; min-width: 0; min-height: 24px; font: inherit; }
61
+ @media (forced-colors: active) {
62
+ .lbx-s__range-slider { --lbx-rt: ButtonFace; --lbx-rb: ButtonText; --lbx-rr: Highlight; }
63
+ .lbx-s__range-track { background: GrayText; }
64
+ .lbx-s__range-fill { background: Highlight; }
65
+ }
@@ -0,0 +1,17 @@
1
+ import { html, nothing } from '../../lit';
2
+ import type { SearchScope } from '../../types';
3
+
4
+ export default (s: SearchScope) => html`
5
+ <div class="lbx-s">
6
+ ${s.showTitle ? html`<h1 class="lbx-s__title">${s.title}</h1>` : nothing}
7
+ ${s.include('search-header')}
8
+ <div class="lbx-s__body">
9
+ ${s.include('search-facets-default')}
10
+ <div class="lbx-s__main">
11
+ ${s.include('search-results-default')}
12
+ ${s.message ? html`<div class="lbx-s__msg" data-lbx-part="empty">${s.message}</div>` : nothing}
13
+ ${s.include('search-pagination')}
14
+ </div>
15
+ </div>
16
+ </div>
17
+ `;
@@ -0,0 +1,73 @@
1
+ import { html, nothing, repeat } from '../../lit';
2
+ import type { SearchScope } from '../../types';
3
+
4
+ type Facet = SearchScope['facets'][number];
5
+
6
+ // A group's values: its search box first (`facetGroups.search`: a labelled
7
+ // input filtering the values as typed, how many match next to it), then the
8
+ // values that match. data-lbx-href: the state a row's checkbox leads to
9
+ // (intent prefetches it, _global.prefetchFilters).
10
+ const values = (s: SearchScope, f: Facet) => html`
11
+ ${f.search
12
+ ? html`<div class="lbx-s__fsearch">
13
+ <input type="search" class="lbx-s__fsearch-input" .value=${f.search.text} aria-label=${s.t('facetSearch', { facet: f.label })} placeholder=${s.t('facetSearchPlaceholder')} aria-describedby=${`${f.id}-n`} autocomplete="off" enterkeyhint="search"
14
+ @input=${(e: Event) => f.search!.set((e.target as HTMLInputElement).value)}
15
+ @keydown=${(e: KeyboardEvent) => {
16
+ if (e.key === 'Enter') e.preventDefault(); // nothing to submit: the values are filtered as typed
17
+ if (e.key === 'Escape' && f.search!.text) (e.preventDefault(), e.stopPropagation(), f.search!.set('')); // clears it (not the drawer's Escape)
18
+ }} />
19
+ <span class="lbx-s__fsearch-n" id=${`${f.id}-n`}>${f.search.text.trim() ? s.t('facetMatches', { count: f.search.matches, facet: f.label }) : ''}</span>
20
+ </div>`
21
+ : nothing}
22
+ ${repeat(f.values.filter((v) => v.match), (v) => v.value, (v) => html`
23
+ <label data-lbx-part="facet-value" data-lbx-href=${v.href}>
24
+ <input type="checkbox" data-facet=${f.name} value=${v.value} .checked=${v.checked} @change=${() => s.toggleFacet(f.name, v.value)} />
25
+ <span>${v.label}</span>
26
+ </label>`)}
27
+ `;
28
+
29
+ // Collapsible groups (`facetGroups.open`): the title is a button with
30
+ // aria-expanded controlling the group's values (WAI-ARIA APG accordion).
31
+ const group = (s: SearchScope, g: { id: string; name: string; label: string; open: boolean; toggle: () => void; selected?: number }, body: () => unknown) => html`
32
+ <fieldset class="lbx-s__group" data-lbx-part="facet-group" data-facet=${g.name}>
33
+ <legend data-lbx-part="facet-title">
34
+ <button type="button" class="lbx-s__group-toggle" aria-expanded=${g.open ? 'true' : 'false'} aria-controls=${g.id} @click=${g.toggle}>
35
+ <span>${g.label}</span>${g.selected ? html` <span class="lbx-s__group-n">(${g.selected})</span>` : nothing}
36
+ </button>
37
+ </legend>
38
+ <div class="lbx-s__group-body" id=${g.id} ?hidden=${!g.open}>${g.open ? body() : nothing}</div>
39
+ </fieldset>
40
+ `;
41
+
42
+ // Text and range groups in the sidebar's order (`layout`); a range is a
43
+ // dual-thumb slider over two inputs (template-search-range-default.ts).
44
+ const groups = (s: SearchScope): (Facet | SearchScope['ranges'][number])[] => {
45
+ const by = new Map<string, Facet | SearchScope['ranges'][number]>([...s.facets, ...s.ranges].map((g) => [g.name, g]));
46
+ const out = s.layout.map((n) => by.get(n)).filter((g) => !!g);
47
+ return [...out, ...[...by.values()].filter((g) => !out.includes(g))];
48
+ };
49
+
50
+ export default (s: SearchScope) => html`
51
+ <details class="lbx-s__facets" data-lbx-part="facets" ?open=${s.facetsOpen}>
52
+ <summary>${s.t('filters')}</summary>
53
+ <div class="lbx-s__fs">
54
+ ${s.chips.length
55
+ ? html`<div class="lbx-s__chips" role="group" aria-labelledby="lbx-chips-title">
56
+ <span class="lbx-s__chips-title" id="lbx-chips-title">${s.t('chosen')}</span>
57
+ ${s.chips.map((c) => {
58
+ const text = c.value ? `${c.title}: ${c.value}` : c.title;
59
+ return html`<a class="lbx-s__chip" data-lbx-part="chip" href=${c.href} aria-label=${s.t('removeFilter', { label: text })} @click=${s.go(c)}>${c.value ? html`<span class="lbx-s__chip-title">${c.title}:</span> ${c.value}` : c.title}<span aria-hidden="true"> ×</span></a>`;
60
+ })}
61
+ ${s.clear ? html`<a class="lbx-s__clear" href=${s.clear.href} @click=${s.go(s.clear)}>${s.t('clearAll')}</a>` : nothing}
62
+ </div>`
63
+ : nothing}
64
+ ${s.flags.length
65
+ ? html`<fieldset>
66
+ ${repeat(s.flags, (f) => f.id, (f) => html`
67
+ <label data-lbx-href=${f.href}><input type="checkbox" data-flag=${f.id} .checked=${f.checked} @change=${() => s.toggleFlag(f.id)} /> <span>${f.label}</span></label>`)}
68
+ </fieldset>`
69
+ : nothing}
70
+ ${repeat(groups(s), (g) => g.name, (g) => ('values' in g ? group(s, g, () => values(s, g)) : group(s, { ...g, selected: g.active ? 1 : 0 }, () => s.include('search-range-default', { range: g }))))}
71
+ </div>
72
+ </details>
73
+ `;
@@ -0,0 +1,14 @@
1
+ import { html } from '../../lit';
2
+ import type { SearchScope } from '../../types';
3
+
4
+ export default (s: SearchScope) => html`
5
+ <div class="lbx-s__head">
6
+ <p class="lbx-s__count" data-lbx-part="count">${s.t('results', { count: s.total, query: s.query })}</p>
7
+ <label class="lbx-s__sort" data-lbx-part="sort">
8
+ ${s.t('sort')}
9
+ <select @change=${(e: Event) => s.setSort((e.target as HTMLSelectElement).value)}>
10
+ ${s.sorts.map((o) => html`<option value=${o.value} .selected=${o.selected}>${o.label}</option>`)}
11
+ </select>
12
+ </label>
13
+ </div>
14
+ `;
@@ -0,0 +1,12 @@
1
+ import { html, nothing } from '../../lit';
2
+ import type { SearchScope } from '../../types';
3
+
4
+ // "Show more": the next page appended to the results (no numbered pages: a
5
+ // listing opens on its first page and grows). `data-lbx-more`: intent on it
6
+ // prefetches that page. From the keyboard, focus moves on to the first new tile.
7
+ export default (s: SearchScope) => html`
8
+ <div class="lbx-s__pager" data-lbx-part="pagination">
9
+ ${s.results.length && s.total ? html`<p class="lbx-s__shown">${s.t('shown', { shown: s.results.length, total: s.total, count: s.total })}</p>` : nothing}
10
+ ${s.more ? html`<button type="button" class="lbx-s__more" data-lbx-part="more" data-lbx-more @click=${s.showMore}>${s.t('more')}</button>` : nothing}
11
+ </div>
12
+ `;
@@ -0,0 +1,48 @@
1
+ import { html, nothing, styleMap } from '../../lit';
2
+ import type { SearchRangeScope } from '../../types';
3
+ import { percent, rangeHandlers, valueText } from './range';
4
+
5
+ // A range facet (a price "0 – 500"): a dual-thumb slider over the two
6
+ // number inputs, kept in sync as either moves, committed once on release
7
+ // (range.ts). `include('search-range-default', { range })` from a facets
8
+ // template; an integration's file of this name replaces it.
9
+ //
10
+ // Parts: `range-slider` (the track and both thumbs), `range-min` /
11
+ // `range-max` (the inputs). Theming (style-search.css; any site rule wins:
12
+ // @layer lbx), CSS variables set anywhere around it: --lbx-range-track,
13
+ // --lbx-range-fill (default currentColor), --lbx-range-thumb (its fill),
14
+ // --lbx-range-thumb-border, --lbx-range-ring (the focused thumb's ring: both
15
+ // inputs span the track, so an outline would not say which has focus),
16
+ // --lbx-range-height (the track's), --lbx-range-thumb-size (px, default
17
+ // 24 px: the target size; the control is never under 24 px tall).
18
+ //
19
+ // Values are bound as properties (`.value`: what a control shows once it was
20
+ // moved; the server renderer writes them as the `value` attribute), plainly:
21
+ // lit leaves a control alone until the state it drives agrees (src/lit.ts).
22
+ export default (s: SearchRangeScope) => {
23
+ const r = s.range;
24
+ const h = rangeHandlers(r);
25
+ const name = (key: string) => s.t(key, { facet: r.label });
26
+ const top = percent(r.from, r) > 50;
27
+ return html`
28
+ <div class="lbx-s__range" data-lbx-range=${r.name}>
29
+ <div class="lbx-s__range-slider" data-lbx-part="range-slider" style=${styleMap({ '--lbx-from': `${percent(r.from, r)}%`, '--lbx-to': `${percent(r.to, r)}%` })} @pointerdown=${h.press}>
30
+ <span class="lbx-s__range-track" aria-hidden="true"><span class="lbx-s__range-fill"></span></span>
31
+ <input type="range" class="lbx-s__range-thumb" data-lbx-thumb="min" ?data-top=${top} min=${r.min} max=${r.max} step=${r.step} .value=${String(r.from)}
32
+ aria-label=${name('rangeMin')} aria-valuetext=${valueText(r.from, r)}
33
+ @input=${h.thumbInput} @change=${h.thumbChange} @keydown=${h.thumbKey} @blur=${h.blur} />
34
+ <input type="range" class="lbx-s__range-thumb" data-lbx-thumb="max" min=${r.min} max=${r.max} step=${r.step} .value=${String(r.to)}
35
+ aria-label=${name('rangeMax')} aria-valuetext=${valueText(r.to, r)}
36
+ @input=${h.thumbInput} @change=${h.thumbChange} @keydown=${h.thumbKey} @blur=${h.blur} />
37
+ </div>
38
+ <div class="lbx-s__range-inputs">
39
+ <input type="number" class="lbx-s__range-num" data-lbx-part="range-min" inputmode="decimal" min=${r.min} max=${r.max} step=${r.step} .value=${String(r.from)}
40
+ aria-label=${name('rangeFrom')} @input=${h.numberInput} @change=${h.numberChange} @keydown=${h.numberKey} @blur=${h.blur} />
41
+ <span class="lbx-s__range-sep">${s.t('rangeSeparator')}</span>
42
+ <input type="number" class="lbx-s__range-num" data-lbx-part="range-max" inputmode="decimal" min=${r.min} max=${r.max} step=${r.step} .value=${String(r.to)}
43
+ aria-label=${name('rangeTo')} @input=${h.numberInput} @change=${h.numberChange} @keydown=${h.numberKey} @blur=${h.blur} />
44
+ ${r.unit ? html`<span class="lbx-s__range-unit">${r.unit}</span>` : nothing}
45
+ </div>
46
+ </div>
47
+ `;
48
+ };
@@ -0,0 +1,13 @@
1
+ import { html, nothing } from '../../lit';
2
+ import type { SearchResultScope } from '../../types';
3
+
4
+ // The image from `_global.images` (img(): srcset, the first row eager with
5
+ // high priority, width/height, its placeholder box). Decorative next to the
6
+ // title: alt="".
7
+ export default ({ r, i, img, plain, price }: SearchResultScope) => html`
8
+ <a class="lbx-s__tile" data-lbx-part="tile" href=${r.c.url}>
9
+ ${r.c.image ? img(r, i) : nothing}
10
+ <span class="lbx-s__tile-title" data-lbx-part="title">${plain(r.c.title)}</span>
11
+ ${r.c.price ? html`<span class="lbx-s__tile-price" data-lbx-part="price">${price(r.c.price)}</span>` : nothing}
12
+ </a>
13
+ `;
@@ -0,0 +1,8 @@
1
+ import { html, repeat } from '../../lit';
2
+ import type { SearchScope } from '../../types';
3
+
4
+ export default (s: SearchScope) => html`
5
+ <div class="lbx-s__list" data-lbx-list data-lbx-part="list">
6
+ ${repeat(s.results, (r) => r.c.id, (r, i) => s.include('search-result-default', { r, i }))}
7
+ </div>
8
+ `;
@@ -0,0 +1,116 @@
1
+ // `yarn luma tasks` (tasks/run.mjs): the main-thread tasks that run Luma's code, from
2
+ // a CPU profile of the page's isolate and a trace of its tasks taken together.
3
+ // The browser side needs Chromium; this part runs offline (and is unit-tested).
4
+ //
5
+ // A task is a trace event `ThreadControllerImpl::RunTask` (category
6
+ // `toplevel`) on the page's main thread. A sample is ours when a frame of
7
+ // ours is on its stack (`ours`), and our own when the innermost frame with a
8
+ // script is ours (`own`): the site's code a call of ours runs (its
9
+ // history.pushState hooks, an integration's hook calling into the page) is
10
+ // ours but not our own. The budget applies to our own time in a task.
11
+
12
+ /** A trace event that is a task of the main loop. */
13
+ export const isTask = (e) => e.ph === 'X' && /(^|::)RunTask$/.test(e.name ?? '');
14
+
15
+ /** The absolute time (µs, the trace's clock) of each sample, and its length (the gap to the next one). */
16
+ export const sampleTimes = (profile) => {
17
+ const at = [];
18
+ let t = profile.startTime;
19
+ for (const d of profile.timeDeltas) at.push((t += d));
20
+ const len = at.map((t, i) => (at[i + 1] ?? t) - t);
21
+ return { at, len };
22
+ };
23
+
24
+ /** The task holding time `t` in `tasks` (sorted by ts), or null. */
25
+ export const taskAt = (tasks, t) => {
26
+ let lo = 0;
27
+ let hi = tasks.length - 1;
28
+ while (lo <= hi) {
29
+ const m = (lo + hi) >> 1;
30
+ const e = tasks[m];
31
+ if (t < e.ts) hi = m - 1;
32
+ else if (t > e.ts + e.dur) lo = m + 1;
33
+ else return e;
34
+ }
35
+ return null;
36
+ };
37
+
38
+ /** The page's main thread: the one whose tasks hold most of the profile's samples. Its tasks, sorted. */
39
+ export const mainThreadTasks = (traceEvents, at) => {
40
+ const byThread = new Map();
41
+ for (const e of traceEvents) if (isTask(e)) byThread.set(`${e.pid}:${e.tid}`, [...(byThread.get(`${e.pid}:${e.tid}`) ?? []), e]);
42
+ let best = [];
43
+ let most = -1;
44
+ for (const list of byThread.values()) {
45
+ list.sort((a, b) => a.ts - b.ts);
46
+ let hits = 0;
47
+ for (let i = 0; i < at.length; i += 5) if (taskAt(list, at[i])) hits++;
48
+ if (hits > most) (most = hits), (best = list);
49
+ }
50
+ return best;
51
+ };
52
+
53
+ /**
54
+ * Our time per task: `ours(callFrame)` says whether a frame is ours, `name(callFrame)`
55
+ * names it (source-mapped by the caller). Per task: its length, our time, our own
56
+ * time, and our own time per function (the innermost frame of ours; the browser's
57
+ * native calls it makes count as its own). Tasks without a sample of ours are left out.
58
+ */
59
+ export const attribute = (profile, tasks, { ours, name = (f) => f.functionName || '(anonymous)' }) => {
60
+ const nodes = new Map(profile.nodes.map((n) => [n.id, n]));
61
+ for (const n of profile.nodes) for (const c of n.children ?? []) if (nodes.has(c)) nodes.get(c).parent ??= n.id;
62
+ const { at, len } = sampleTimes(profile);
63
+ const per = new Map();
64
+ profile.samples.forEach((id, i) => {
65
+ const task = taskAt(tasks, at[i]);
66
+ if (!task) return;
67
+ let any = false;
68
+ let own = null;
69
+ let mine = null;
70
+ const leaf = nodes.get(id)?.callFrame;
71
+ for (let n = nodes.get(id); n; n = nodes.get(n.parent)) {
72
+ const f = n.callFrame;
73
+ if (ours(f)) {
74
+ any = true;
75
+ mine ??= f;
76
+ }
77
+ if (own === null && f.url) own = ours(f);
78
+ }
79
+ if (!any) return;
80
+ const r = per.get(task) ?? { start: task.ts, ms: task.dur / 1000, ours: 0, own: 0, fns: new Map() };
81
+ r.ours += len[i] / 1000;
82
+ if (own) {
83
+ r.own += len[i] / 1000;
84
+ // The browser's own work it called (no script: pushState, querySelectorAll, a forced layout's getter) named with it.
85
+ const k = leaf && leaf !== mine && !leaf.url && leaf.functionName && !/^\(/.test(leaf.functionName) ? `${name(mine)} → ${leaf.functionName}` : name(mine);
86
+ r.fns.set(k, (r.fns.get(k) ?? 0) + len[i] / 1000);
87
+ }
88
+ per.set(task, r);
89
+ });
90
+ return [...per.values()].sort((a, b) => a.start - b.start);
91
+ };
92
+
93
+ /** A scenario's verdict from its runs (each the attributed tasks of one run): the worst task by own time, per run, and their median. */
94
+ export const summarize = (runs, budget) => {
95
+ const worst = runs.map((tasks) => tasks.reduce((w, t) => (t.own > (w?.own ?? -1) ? t : w), null));
96
+ const owns = worst.map((t) => t?.own ?? 0).sort((a, b) => a - b);
97
+ const median = owns.length ? owns[Math.floor((owns.length - 1) / 2)] : 0;
98
+ return { median, worst: worst.filter(Boolean).sort((a, b) => b.own - a.own)[0] ?? null, over: median > budget, runs: owns };
99
+ };
100
+
101
+ const ms = (v) => `${v.toFixed(1)} ms`;
102
+ /** The report: per scenario, the median of each run's worst task (our own time), and that task's breakdown. */
103
+ export const formatReport = (results, { budget, cpu, meta = {} }) => {
104
+ const lines = [];
105
+ for (const [k, v] of Object.entries(meta)) lines.push(`${k}: ${v}`);
106
+ lines.push(`budget: ${budget} ms of our own code per task at ${cpu}x CPU`, '');
107
+ const w = Math.max(...results.map((r) => r.name.length), 8);
108
+ lines.push(`${'scenario'.padEnd(w)} worst own (median) runs verdict`);
109
+ for (const r of results) lines.push(`${r.name.padEnd(w)} ${ms(r.median).padStart(18)} ${r.runs.map((x) => x.toFixed(1)).join(' / ').padEnd(20)} ${r.over ? 'OVER' : 'ok'}`);
110
+ for (const r of results.filter((r) => r.worst)) {
111
+ const t = r.worst;
112
+ lines.push('', `${r.name}: its worst task ${ms(t.ms)}, ours ${ms(t.ours)}, our own ${ms(t.own)} (the rest of ours: the site's code under our calls)`);
113
+ for (const [f, v] of [...t.fns].sort((a, b) => b[1] - a[1]).slice(0, 6)) lines.push(` ${ms(v).padStart(9)} ${f}`);
114
+ }
115
+ return lines.join('\n');
116
+ };