@luigisbox/luma 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -5,6 +5,40 @@ before 1.0, a minor version may break the integration API.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.6.0 — 2026-10-20
9
+
10
+ ### Changed
11
+
12
+ - Phone sheet: a swipe through the results puts the keyboard away. The sheet
13
+ stays open and grows to the full screen. A tap on its input brings the
14
+ keyboard back.
15
+ - Phone sheet: no tab bar when only one type has results.
16
+ - Phone sheet: "Show all products" is at the end of the list, no longer a bar
17
+ pinned above the keyboard.
18
+ - Phone sheet: the list (and the no-results column) runs on behind Safari's
19
+ floating bars and the keyboard, down to the bottom of the screen. Its end
20
+ scrolls clear of them.
21
+ - Staged filter drawer (`facetsApply: 'staged'`): the held selection is
22
+ fetched in the background once the changes pause, so the apply button
23
+ shows the results without waiting. The results still change only on
24
+ apply. Off with `_global.prefetchFilters: false`, Save-Data or 2G.
25
+ - Staged filter drawer: apply marks the swap. The old list fades out, the
26
+ new products fade in, and a status at the top of the listing says
27
+ "Filters applied · 9 products" for 2.5 s. Translation key
28
+ `search.applied`; CSS variables `--lbx-applied-bg`, `--lbx-applied-fg`,
29
+ `--lbx-applied-top`, `--lbx-applied-offset`, or
30
+ `[data-lbx-applied-cue]::part(pill)`;
31
+ `data-lbx-applied` on the listing meanwhile.
32
+
33
+ ### Fixed
34
+
35
+ - Autocomplete "No results": `emptyState` phrases and browse links keep their
36
+ config text and URL when `computedAttributes` override `title` or `url`.
37
+ Before, a browse link could have no URL, and a tap on it did nothing.
38
+ - Autocomplete, phone sheet, no results: each "browse" link is one row again,
39
+ its chevron at the right, not on a line under the title. The links' wrapper
40
+ is now `.e-links` (was `.browse`, the class each link also has).
41
+
8
42
  ## 0.5.0 — 2026-10-19
9
43
 
10
44
  Upgrading from 0.4.0: read **Changed** first.
package/README.md CHANGED
@@ -10,9 +10,9 @@ This package is what an integration is built with. Each integration lives in
10
10
  its own repository and depends on `@luigisbox/luma`
11
11
  ([Integration repository](#integration-repository)).
12
12
 
13
- > **Pre-release.** Until 1.0, a minor version (0.6.0) may change the
13
+ > **Pre-release.** Until 1.0, a minor version (0.7.0) may change the
14
14
  > integration API (config keys, template scope, the build); a patch version
15
- > (0.5.1) only fixes. Depend on `^0.5.0` to take fixes without breaking
15
+ > (0.6.1) only fixes. Depend on `^0.6.0` to take fixes without breaking
16
16
  > changes. [`CHANGELOG.md`](CHANGELOG.md) says what each version changed.
17
17
 
18
18
  ## Core principles
@@ -422,7 +422,7 @@ it sets them; a switch on by default that no file names is built as on.
422
422
  | `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 | |
423
423
  | `prefetchNextPage` | "show more" is fetched as the shopper nears the end | |
424
424
  | `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 |
425
- | `_global.prefetchFilters` | a filter, sort or chip is fetched on hover, touch or focus | |
425
+ | `_global.prefetchFilters` | a filter, sort or chip is fetched on hover, touch or focus; in a drawer with `facetsApply: 'staged'`, the selection it holds is fetched once the changes pause, so its apply button shows the results without waiting | |
426
426
  | `_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 |
427
427
 
428
428
  ### Analytics
package/build/vite.mjs CHANGED
@@ -91,6 +91,8 @@ export const chunkOf = (id) => {
91
91
  if (/\/src\/core\/(reconciler|recommenders)\.ts$/.test(id)) return 'lbx-place';
92
92
  // The listing's high-contrast toggle: only a site that turns it on loads it (widgets/listing/element.ts).
93
93
  if (/\/src\/core\/contrast-toggle\.ts$/.test(id)) return 'lbx-contrast';
94
+ // An Apply's cue in a staged drawer: asked for when such a drawer opens (widgets/listing/element.ts).
95
+ if (/\/src\/widgets\/listing\/applied\.ts$/.test(id)) return 'lbx-applied';
94
96
  // The search page's own: not in lbx-listing, which a category page loads too
95
97
  // (2.1 kB gzip it never ran).
96
98
  if (/\/src\/widgets\/search\/index\.ts$/.test(id)) return 'lbx-search';
@@ -134,7 +134,11 @@ guessed alt text, and forced colours and the OS setting are honoured.
134
134
  `aria-activedescendant` the selected option: the same shadow root); the
135
135
  site's input is left as it was. The back button is named "Close search",
136
136
  the clear button "Clear"; the type tabs are a `tablist` (arrow keys move
137
- between them); Tab is the browser's; targets are 44 px; Escape and the
137
+ between them), shown for two types or more (one type: no tabs, its
138
+ listbox named by the type, "Products"); a finger's vertical swipe
139
+ through the list puts the on-screen keyboard away: focus moves from the
140
+ input to the sheet (`tabindex="-1"`), Escape still closes it, a tap on the
141
+ input brings the keyboard back; Tab is the browser's; targets are 44 px; Escape and the
138
142
  browser's Back close it.
139
143
  - **Forced colours**: the selection in `Highlight`/`HighlightText` with an
140
144
  outline, the panel (and the sheet) keep a `CanvasText` border, pills,
@@ -180,6 +184,18 @@ guessed alt text, and forced colours and the OS setting are honoured.
180
184
  focus staying on the control. With `facetsApply: 'staged'` its
181
185
  changes wait for `[data-lbx-drawer="apply"]`; closing it otherwise discards
182
186
  them (the scope's `pending` says whether there is anything to apply).
187
+ Apply's result is said in Luma's status region as "Filters applied" and
188
+ the count (translation `search.applied`, WCAG 4.1.3), and shown for 2.5 s
189
+ in a pill at the listing's top with the same words (hidden from assistive
190
+ technology: it repeats the status region; white on dark, at least 10:1
191
+ over any page behind it; opaque in the high-contrast mode and in forced
192
+ colours, with a border there; taps go through it; it wraps rather than
193
+ cuts its words at 320 px or 200% text, and follows a larger default font
194
+ size; it moves no content). While the applied results load, the
195
+ old list is dimmed as a busy one is: not at all in the high-contrast
196
+ mode, to 60% in forced colours. The new tiles fade in; they also rise
197
+ unless reduced motion is asked for. The pill may overlap the top of the
198
+ drawer's opener, where focus returns, for its 2.5 s; it never hides it.
183
199
  The build includes the drawer only where a file of the integration's
184
200
  repository (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) has `data-lbx-drawer` (written
185
201
  literally, as in a template).
@@ -213,7 +229,8 @@ guessed alt text, and forced colours and the OS setting are honoured.
213
229
  - Built-in templates: the pager is a native "Show more" button with a
214
230
  "Showing N of M" line (no numbered pages); tile pictures are decorative
215
231
  (`alt=""`: the link reads as its title and price once); targets of at least
216
- 24 px; no fade under reduced motion; tiles reflow to one column at 320 px.
232
+ 24 px; no fade under reduced motion (except a staged drawer's Apply: its
233
+ new tiles fade in, without rising); tiles reflow to one column at 320 px.
217
234
 
218
235
  ### Recommenders (`widgets/recommender/`)
219
236
 
@@ -232,7 +249,9 @@ guessed alt text, and forced colours and the OS setting are honoured.
232
249
  reset cannot remove it there).
233
250
  - **Reduced motion**: the hold's loading bar stands still, the listing's
234
251
  dim while a re-render loads comes without its fade and the new results
235
- without their fade-in, no smooth scrolling of ours.
252
+ without their fade-in, no smooth scrolling of ours. One exception: after
253
+ a staged drawer's Apply the new tiles still fade in (opacity only, nothing
254
+ moves), so the swap the shopper confirmed is seen.
236
255
  - `.lbx-sr-only`: a class for text that only screen readers read (e.g. "Was"
237
256
  before an old price), defined for every widget and in server-rendered CSS.
238
257
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luigisbox/luma",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Luma: Luigi's Box frontend integrations (autocomplete, search, category listings, recommenders) on the Luigi's Box AI API. Pre-release.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -53,6 +53,7 @@
53
53
  "label": "Výsledky vyhledávání",
54
54
  "shown": "Zobrazeno {shown} z {total}",
55
55
  "drawer": "Filtry",
56
+ "applied": "Filtry použity · {results}",
56
57
  "removeFilter": "Odebrat filtr: {label}",
57
58
  "chosen": "Vybrané filtry",
58
59
  "clearAll": "Zrušit vše",
@@ -51,6 +51,7 @@
51
51
  "label": "Suchergebnisse",
52
52
  "shown": "{shown} von {total} angezeigt",
53
53
  "drawer": "Filter",
54
+ "applied": "Filter angewendet · {results}",
54
55
  "removeFilter": "Filter entfernen: {label}",
55
56
  "chosen": "Ausgewählte Filter",
56
57
  "clearAll": "Alle entfernen",
@@ -51,6 +51,7 @@
51
51
  "label": "Search results",
52
52
  "shown": "Showing {shown} of {total}",
53
53
  "drawer": "Filters",
54
+ "applied": "Filters applied · {results}",
54
55
  "removeFilter": "Remove filter: {label}",
55
56
  "chosen": "Selected filters",
56
57
  "clearAll": "Clear all",
package/src/types.ts CHANGED
@@ -80,7 +80,9 @@ export interface GlobalConfigType {
80
80
  * Two in flight, the latest intent wins; 20 per category or query; none
81
81
  * offline, with Save-Data or on 2G; range sliders never. A hover that is
82
82
  * not followed costs one search request (no render, no hook, no analytics
83
- * event). Default true.
83
+ * event). In a drawer that stages (`facetsApply`), the selection it holds
84
+ * is fetched the same way once the changes pause, for its apply button.
85
+ * Default true.
84
86
  */
85
87
  prefetchFilters?: boolean;
86
88
  /**
@@ -333,10 +335,22 @@ export interface ListingConfigType {
333
335
  * How a change inside the open filter drawer (`[data-lbx-drawer-panel]`,
334
336
  * opened by `[data-lbx-drawer="open"]`) applies. 'instant' (default): at
335
337
  * once, as in a sidebar. 'staged': held in the drawer (it shows the pending
336
- * selection, the scope's `pending` is true, nothing is fetched) until its
337
- * apply button (`[data-lbx-drawer="apply"]`) commits them in one request
338
- * and one history entry; closing it otherwise (`[data-lbx-drawer="close"]`,
339
- * Escape) discards them. Per viewport min-width, e.g. `{ "0": "staged",
338
+ * selection, the scope's `pending` is true, the results behind it do not
339
+ * change) until its apply button (`[data-lbx-drawer="apply"]`) commits them
340
+ * in one request and one history entry; closing it otherwise
341
+ * (`[data-lbx-drawer="close"]`, Escape) discards them. Once the changes
342
+ * pause, the held selection's results are fetched in the background
343
+ * (unless `_global.prefetchFilters` is false, with Save-Data or on 2G),
344
+ * so Apply shows them without waiting; nothing is shown and `onRender`
345
+ * does not run before Apply. Apply's swap is marked: the old list fades
346
+ * out, the new tiles come in, and a status at the listing's top says what
347
+ * was applied ("Filters applied · 9 products", translation
348
+ * `search.applied`) for 2.5 s, in `[data-lbx-applied-cue]`'s shadow root
349
+ * (out of reach of the site's resets; themed with `--lbx-applied-bg`,
350
+ * `--lbx-applied-fg`, `--lbx-applied-top` for a sticky header's height,
351
+ * `--lbx-applied-offset` to sit below a heading at the listing's top, or
352
+ * restyled with `[data-lbx-applied-cue]::part(pill)`), with
353
+ * `data-lbx-applied` on the listing meanwhile for the site's own CSS. Per viewport min-width, e.g. `{ "0": "staged",
340
354
  * "768": "instant" }` (staged on phones). Outside an open drawer every
341
355
  * change is instant.
342
356
  */
@@ -66,6 +66,12 @@ const GRID_MAX = 6;
66
66
  export const SHEET_MEDIA = '(max-width: 600px), (hover: none) and (max-height: 500px)';
67
67
  /** The sheet's visible height below which its top product is drawn smaller (the keyboard up): more of the list shows. */
68
68
  const SHEET_SHORT = 480;
69
+ /** How far (px, vertically) a finger moves in the sheet's list before it is a swipe, not a tap: the keyboard goes away. */
70
+ const SWIPE = 10;
71
+ /** A move this far (px), then the browser taking the gesture over (pointercancel), is a swipe too; less is a finger's jitter. */
72
+ const SWIPE_START = 4;
73
+ /** How long (ms) after the browser took a finger's gesture over a scroll of the list is still that finger's. */
74
+ const SWIPE_SCROLL_MS = 200;
69
75
 
70
76
  /** A computed colour that paints nothing (`rgba(0, 0, 0, 0)`, `transparent`, `color(srgb 0 0 0 / 0)`). */
71
77
  const clear = (c: string) => !c || c === 'transparent' || /(?:,|\/)\s*0(?:\.0*)?\)$/.test(c);
@@ -257,7 +263,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
257
263
  let want = '';
258
264
  /** Scrolled lists in the dropdown (the panel's columns, the sheet's list): back to their top for a new result set. */
259
265
  const scrolled = new Set<Element>();
266
+ /** A finger down in the sheet's list (a swipe through it puts the keyboard away: ensureHost); `ours`: the list's next scroll is our reset. */
267
+ let swipe: { list: Element; x: number; y: number; moved: boolean; cancelled?: number; ours?: boolean } | null = null;
260
268
  const toTop = () => {
269
+ // Our scroll, not the finger's: a finger at rest on the list is no swipe, the same finger's drag
270
+ // after it still is. Review (PR 26): a new result set's reset put the keyboard away.
271
+ if (swipe && scrolled.has(swipe.list)) swipe.ours = true;
261
272
  for (const el of scrolled) if (el.isConnected) el.scrollTop = 0;
262
273
  scrolled.clear();
263
274
  };
@@ -363,8 +374,9 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
363
374
  if (!inOverlay(el) || e.ctrlKey || e.metaKey) return;
364
375
  if (sheet) {
365
376
  if (e.key === 'Escape') {
366
- e.preventDefault();
367
- e.stopPropagation();
377
+ // A whole gesture, as from the input: focus is on the sheet after a swipe (or on its buttons),
378
+ // and its keyup reached the host's handlers. Review (PR 26).
379
+ consume(e);
368
380
  leave('back');
369
381
  } else if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && el.getAttribute?.('role') === 'tab') {
370
382
  e.preventDefault();
@@ -400,6 +412,61 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
400
412
  if (!(el instanceof Element)) return;
401
413
  if (el.scrollTop > 0) scrolled.add(el);
402
414
  else scrolled.delete(el);
415
+ if (swipe?.list === el && swipe.ours) swipe.ours = false;
416
+ else if (swipe?.list === el && (!swipe.cancelled || performance.now() - swipe.cancelled < SWIPE_SCROLL_MS)) putKeyboardAway();
417
+ },
418
+ { capture: true, passive: true },
419
+ );
420
+ // A swipe through the sheet's list (or its no-results column) puts the
421
+ // keyboard away, the sheet stays: it grows to the whole screen (position),
422
+ // and a tap on its input brings the keyboard back. Only a drag: a tap on a
423
+ // row, a tab or "Show all" keeps the keyboard and is not swallowed; the
424
+ // pill band's sideways drag is not the list. Focus goes to the sheet
425
+ // (tabindex -1), not <body>: Escape and Tab still work from a hardware
426
+ // keyboard. Bug (iOS 26 Safari): with the keyboard up, its bar and the
427
+ // address bar above it, the sheet kept half the screen.
428
+ const putKeyboardAway = () => {
429
+ swipe = null;
430
+ if (!mirror || focused() !== mirror) return;
431
+ const panel = mirror.closest<HTMLElement>('.sheet[tabindex]');
432
+ if (panel) panel.focus({ preventScroll: true });
433
+ else mirror.blur(); // an integration's own sheet template
434
+ };
435
+ const finger = (e: PointerEvent) => e.pointerType === 'touch' && e.isPrimary;
436
+ box.addEventListener(
437
+ 'pointerdown',
438
+ (ev) => {
439
+ const e = ev as PointerEvent;
440
+ swipe = null;
441
+ if (!sheet || !finger(e)) return;
442
+ const list = e.composedPath().find((x) => x instanceof Element && x.matches('.sh-list, .sheet .empty')) as Element | undefined;
443
+ if (list) swipe = { list, x: e.clientX, y: e.clientY, moved: false };
444
+ },
445
+ { capture: true, passive: true },
446
+ );
447
+ box.addEventListener(
448
+ 'pointermove',
449
+ (ev) => {
450
+ const e = ev as PointerEvent;
451
+ if (!swipe || !finger(e)) return;
452
+ const dy = Math.abs(e.clientY - swipe.y);
453
+ if (dy <= Math.abs(e.clientX - swipe.x)) return;
454
+ if (dy > SWIPE) putKeyboardAway();
455
+ else if (dy >= SWIPE_START) swipe.moved = true;
456
+ },
457
+ { capture: true, passive: true },
458
+ );
459
+ box.addEventListener('pointerup', (e) => finger(e as PointerEvent) && (swipe = null), { capture: true, passive: true });
460
+ // The browser took the gesture over to scroll: after a first move (iOS
461
+ // Safari: a pull at the list's top, 10 px, then pointercancel), a swipe;
462
+ // without one, a swipe if the list scrolls right after (a flick), not a
463
+ // long press (a link's preview: no scroll; a later scroll is ours, toTop).
464
+ box.addEventListener(
465
+ 'pointercancel',
466
+ (e) => {
467
+ if (!swipe || !finger(e as PointerEvent)) return;
468
+ if (swipe.moved) putKeyboardAway();
469
+ else swipe.cancelled = performance.now();
403
470
  },
404
471
  { capture: true, passive: true },
405
472
  );
@@ -465,24 +532,44 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
465
532
  * before this task's writes (an opening: read after showing the popover,
466
533
  * it forced a layout).
467
534
  */
535
+ /** What Safari's bars and the keyboard cover of the sheet (px, --lbx-cover), -1 while it is not open. */
536
+ let cover = -1;
537
+ /** A clamp of the scrolled lists is queued (one for a keyboard going down, not one per frame of it). */
538
+ let clamping = false;
468
539
  const position = (frame = false, m?: ReturnType<typeof measure>) => {
469
540
  if (!open || !input) return;
470
541
  const s = host!.style;
471
542
  if (sheet) {
472
- // Full screen: its content in what shows of the page (the visual
473
- // viewport, above the on-screen keyboard), so the sticky "Show all"
474
- // sits above the keys. The host reaches on down to the layout
475
- // viewport's bottom, painted (openSheet): iOS 26 Safari ends the visual
476
- // viewport above its floating address bar, and between the two the
477
- // page showed through, see-through bars over it.
543
+ // Full screen from the top of what shows of the page (the visual
544
+ // viewport) down to the layout viewport's bottom: iOS 26 Safari ends
545
+ // the visual viewport above its floating bars (the address pill, with
546
+ // the keyboard up its form bar too), and the list runs on behind them,
547
+ // seen through their glass, as the legacy autocomplete's does. What
548
+ // they and the keyboard cover (--lbx-cover) ends the list as space, so
549
+ // its last row and "Show all" scroll clear of them. Bug (iOS 26/27):
550
+ // the list stopped above the bars, a blank strip down to the keys.
478
551
  const vv = window.visualViewport;
479
552
  const top = Math.round(vv?.offsetTop ?? 0);
480
553
  const h = Math.round(vv?.height ?? innerHeight);
554
+ const full = Math.max(h, innerHeight - top);
481
555
  s.top = `${top}px`;
482
556
  s.left = `${Math.round(vv?.offsetLeft ?? 0)}px`;
483
557
  s.width = `${Math.round(vv?.width ?? innerWidth)}px`;
484
- s.height = `${Math.max(h, innerHeight - top)}px`;
485
- s.setProperty('--lbx-vh', `${h}px`);
558
+ s.height = `${full}px`;
559
+ if (full - h !== cover) {
560
+ // Less covered (the keyboard going down): the end space shrinks, and iOS WebKit kept a list
561
+ // scrolled into it there, its rows stuck above a blank strip, not scrollable back (iOS 27
562
+ // Simulator). Its offset back within the list, read a frame later.
563
+ if (full - h < cover && !clamping) {
564
+ clamping = true;
565
+ afterFrame(() => {
566
+ clamping = false;
567
+ scrolled.forEach((el) => el.scrollTop > el.scrollHeight - el.clientHeight && (el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight)));
568
+ });
569
+ }
570
+ cover = full - h;
571
+ s.setProperty('--lbx-cover', `${cover}px`);
572
+ }
486
573
  host!.toggleAttribute('data-lbx-short', h < SHEET_SHORT);
487
574
  raf = requestAnimationFrame(() => position(true));
488
575
  return;
@@ -842,7 +929,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
842
929
  setCss('ac:sheet', null);
843
930
  host!.style.height = '';
844
931
  host!.style.background = 'transparent';
845
- host!.style.removeProperty('--lbx-vh');
932
+ host!.style.removeProperty('--lbx-cover');
933
+ cover = -1;
846
934
  host!.removeAttribute('data-lbx-short');
847
935
  mirror?.blur();
848
936
  if (focused() === input) input?.blur();
@@ -1094,11 +1182,17 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1094
1182
  };
1095
1183
  /** The "No results" layout's options: phrases to try, links to browse, fallback products. */
1096
1184
  const emptyOptions = (fallback: Result[]) => {
1097
- const tag = (r: Result, kind: string) => Object.assign(r, { __empty: kind });
1185
+ // Luma's own options, not catalog hits: their text and link are the
1186
+ // config's, whatever the integration computes for its catalog's.
1187
+ const own = (hit: { '@id': string; '@type': string; '@title': string; url?: string }, kind: string) => {
1188
+ const r = ctx.result(hit);
1189
+ Object.defineProperties(r.c, { title: { value: hit['@title'] }, url: { value: safeUrl(hit.url) } });
1190
+ return Object.assign(r, { __empty: kind });
1191
+ };
1098
1192
  return [
1099
- ...(empty.tryQueries ?? []).filter((q) => typeof q === 'string' && q.trim()).map((q) => tag(ctx.result({ '@id': `try/${q}`, '@type': 'query', '@title': q }), 'try')),
1100
- ...(empty.browse ?? []).filter((b) => b && safeUrl(b.url)).map((b) => tag(ctx.result({ '@id': `browse/${b.url}`, '@type': 'browse', '@title': b.title, url: b.url }), 'browse')),
1101
- ...fallback.map((r) => tag({ ...r }, 'fallback')),
1193
+ ...(empty.tryQueries ?? []).filter((q) => typeof q === 'string' && q.trim()).map((q) => own({ '@id': `try/${q}`, '@type': 'query', '@title': q }, 'try')),
1194
+ ...(empty.browse ?? []).filter((b) => b && safeUrl(b.url)).map((b) => own({ '@id': `browse/${b.url}`, '@type': 'browse', '@title': b.title ?? '', url: b.url }, 'browse')),
1195
+ ...fallback.map((r) => ({ ...r, __empty: 'fallback' })),
1102
1196
  ];
1103
1197
  };
1104
1198
 
@@ -36,9 +36,9 @@
36
36
  --lbx-radius-row: calc(var(--lbx-radius) * .6);
37
37
  --lbx-radius-tile: calc(var(--lbx-radius) * .5);
38
38
  }
39
- /* The render root: the sheet is the visual viewport's height (--lbx-vh, index.ts; the host reaches on
40
- below it, painted); the panel is as tall as it is. */
41
- .lbx-root { height: var(--lbx-vh, 100%); }
39
+ /* The render root: the sheet is the host's height (from the visual viewport's top to the layout
40
+ viewport's bottom, index.ts); the panel is as tall as it is. */
41
+ .lbx-root { height: 100%; }
42
42
  /* What the rules read: the tokens, or the high-contrast palette while it is on. */
43
43
  .box, .sheet {
44
44
  --c-fg: var(--lbx-c-fg, var(--lbx-fg));
@@ -155,12 +155,12 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
155
155
  .e-opts { margin-top: 10px; display: flex; flex-direction: column; gap: 12px; }
156
156
  .grp.inline { flex-direction: row; flex-wrap: wrap; align-items: center; gap: 8px; }
157
157
  .grp.inline h3 { padding: 0; margin-right: 4px; font-size: 14px; }
158
- .tries, .browse { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; }
158
+ .tries, .e-links { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; }
159
159
  .tries { gap: 8px; }
160
160
  .try { height: 34px; padding: 0 14px; gap: 6px; border-radius: 999px; box-shadow: inset 0 0 0 1.5px var(--c-line); font-size: 14px; font-weight: 700; }
161
161
  .try .glass { color: var(--c-accent); }
162
162
  .try[aria-selected="true"] { background: var(--lbx-c-sel-bg, var(--c-tint)); color: var(--lbx-c-sel-fg, inherit); box-shadow: inset 0 0 0 1.5px var(--c-fill); }
163
- .browse .chev { display: none; }
163
+ .opt.browse .chev { display: none; }
164
164
  .opt.browse { padding: 2px 6px; margin: 0 -6px; border-radius: var(--lbx-radius-row); color: var(--c-accent); font-weight: 700; font-size: 14px; }
165
165
  .opt.browse[aria-selected="true"] { background: var(--lbx-c-sel-bg, var(--c-tint)); text-decoration: underline; text-underline-offset: 3px; }
166
166
  .help { width: 300px; flex: none; padding: 18px 20px; border-radius: var(--lbx-radius-row); border: 1.5px dashed var(--lbx-tint-strong); display: flex; gap: 14px; align-items: center; }
@@ -188,6 +188,8 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
188
188
  /* @lbx-part autocomplete-sheet: left out of a build whose integration replaces this template and uses none of its classes (build/vite.mjs) */
189
189
  /* ---- the phone sheet --------------------------------------------------- */
190
190
  .sheet { height: 100%; display: flex; flex-direction: column; overflow: hidden; background: var(--c-bg); border-top: 3px solid var(--c-fill); }
191
+ /* Focused from code only (tabindex -1: a swipe puts the keyboard away, index.ts): the whole screen, no ring. */
192
+ .sheet:focus { outline: none; }
191
193
  .sh-head { height: 60px; flex: none; padding: 0 8px 0 4px; display: flex; align-items: center; gap: 4px; border-bottom: 1px solid var(--c-line); touch-action: none; }
192
194
  .icon { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; border-radius: var(--lbx-radius-row); }
193
195
  .x { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 999px; background: var(--lbx-soft); }
@@ -199,6 +201,12 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
199
201
  .tab { flex: none; padding: 0 14px; font-size: 15px; color: var(--c-muted); white-space: nowrap; }
200
202
  .tab[aria-selected="true"] { color: var(--c-fg); font-weight: 700; box-shadow: inset 0 -3px 0 var(--c-fill); }
201
203
  .sh-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; touch-action: pan-y; }
204
+ /* What Safari's floating bars and the keyboard cover (--lbx-cover, index.ts): the list (and the
205
+ no-results column) runs on behind them, its end scrolls clear of them (as space; a row brought into
206
+ view by the keys or by focus too). A tap on what shows through the bars is Safari's, as on the
207
+ legacy autocomplete. */
208
+ .sh-list, .sheet .empty { scroll-padding-bottom: var(--lbx-cover, 0px); }
209
+ .sh-list::after, .sheet .empty::after { content: ""; flex: none; height: var(--lbx-cover, 0px); }
202
210
  .sh-list .pby { margin: 0; padding: 20px 16px 24px; justify-content: center; font-size: 13px; }
203
211
  .m-hero { padding: 16px; gap: 12px; border-bottom: 1px solid var(--c-line); }
204
212
  .m-hero h3 { padding: 0; font-size: 13px; color: var(--c-muted); }
@@ -222,7 +230,7 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
222
230
  .mrow .t { flex: 1; min-width: 0; }
223
231
  .mrow .chev, .opt.browse .chev { flex: none; color: var(--c-muted); }
224
232
  .mrow[aria-selected="true"] { background: var(--lbx-c-sel-bg, var(--c-tint)); color: var(--lbx-c-sel-fg, inherit); box-shadow: inset 3px 0 0 var(--c-fill); }
225
- .sh-foot { flex: none; padding: 10px 16px 14px; border-top: 1px solid var(--c-line); background: var(--c-bg); touch-action: none; }
233
+ .sh-foot { flex: none; padding: 16px 16px 0; }
226
234
  .sheet .all { height: 48px; font-size: 16px; background: var(--c-fill); color: var(--c-on-fill); box-shadow: none; }
227
235
  /* No results on the phone: one column, its own scroll. */
228
236
  .sheet .empty { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; touch-action: pan-y; }
@@ -232,9 +240,9 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
232
240
  .sheet .e-title { font-size: 21px; }
233
241
  .sheet .grp.inline { flex-direction: column; align-items: stretch; }
234
242
  .sheet .try { height: 40px; font-size: 15px; }
235
- .sheet .browse { flex-direction: column; align-items: stretch; gap: 0; }
243
+ .sheet .e-links { flex-direction: column; align-items: stretch; gap: 0; }
236
244
  .sheet .opt.browse { min-height: 48px; margin: 0 -6px; padding: 0 6px; justify-content: space-between; font-size: 16px; }
237
- .sheet .browse .chev { display: block; }
245
+ .sheet .opt.browse .chev { display: block; }
238
246
  .sheet .help { width: auto; padding: 16px; }
239
247
  .sheet .help-text { flex: 1; }
240
248
  .sheet .help-title { font-size: 15px; }
@@ -34,7 +34,7 @@ export default (s: AutocompleteScope) => {
34
34
  ${tries.length || browse.length
35
35
  ? html`<div class="e-opts" role="listbox" id="lbx-ac-listbox" aria-label=${s.t('label')}>
36
36
  ${tries.length ? group('try', s.t('tryLabel'), html`<div class="tries">${tries.map((x) => opt(x, 'try'))}</div>`, 'inline') : nothing}
37
- ${browse.length ? group('browse', s.t('browseLabel'), html`<div class="browse">${browse.map((x) => opt(x, 'browse'))}</div>`, 'inline') : nothing}
37
+ ${browse.length ? group('browse', s.t('browseLabel'), html`<div class="e-links">${browse.map((x) => opt(x, 'browse'))}</div>`, 'inline') : nothing}
38
38
  </div>`
39
39
  : nothing}
40
40
  </div>
@@ -4,9 +4,11 @@ import { POWERED, group, showAll } from './template-autocomplete-box';
4
4
 
5
5
  // The house design on a phone: a full-screen sheet. Its header
6
6
  // (back, its own input, clear), the query pills on one sideways-scrolling
7
- // band, a tab per type with results (products first; no counts), the open
8
- // tab's options (the top product, then product rows; or 56 px rows), and
9
- // "Show all products" in a bar that stays above the on-screen keyboard.
7
+ // band, a tab per type with results (products first; no counts; no tab bar
8
+ // for a lone type: 48 px of a short sheet, and the options say what they
9
+ // are), the open tab's options (the top product, then product rows; or 56 px rows), and
10
+ // "Show all products" at the end of the list (below the fold with the keyboard
11
+ // up: the list gets the room; a swipe puts the keyboard away, index.ts).
10
12
  // Its input is the combobox (index.ts): it controls both listboxes and
11
13
  // points at the selected option (aria-activedescendant, set by index.ts).
12
14
 
@@ -16,6 +18,8 @@ export default (s: AutocompleteScope) => {
16
18
  const results = s.state === 'results';
17
19
  const onProducts = !!s.hero && s.tabs.find((x) => x.selected)?.kind === s.hero.r.type;
18
20
  const open = s.tabs.find((x) => x.selected);
21
+ // A lone type: no tab bar, no tabpanel; its listbox is named by the type ("Products").
22
+ const tabbed = s.tabs.length > 1;
19
23
  const rows = open && !onProducts ? (s.lists.find((g) => g.kind === open.kind)?.rows ?? []) : [];
20
24
  // What the input controls: the listboxes on screen.
21
25
  const e = s.empty;
@@ -29,7 +33,7 @@ export default (s: AutocompleteScope) => {
29
33
  .filter(Boolean)
30
34
  .join(' ');
31
35
  return html`
32
- <div class="sheet" data-lbx-part="ac-panel">
36
+ <div class="sheet" data-lbx-part="ac-panel" role="group" aria-label=${s.inputLabel} tabindex="-1">
33
37
  <div class="sh-head">
34
38
  <button type="button" class="icon back" aria-label=${s.t('closeSearch')} @click=${() => s.back()}>
35
39
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6"></path></svg>
@@ -48,7 +52,7 @@ export default (s: AutocompleteScope) => {
48
52
  ${s.phrases.length
49
53
  ? html`<div class="sh-phrases" role="listbox" id="lbx-ac-phrases" aria-label=${s.t('phrases')}>${repeat(s.phrases, key, (p) => opt(p, 'pill'))}</div>`
50
54
  : nothing}
51
- ${s.tabs.length
55
+ ${tabbed
52
56
  ? html`<div class="tabs" role="tablist" aria-label=${s.t('tabs')}>
53
57
  ${s.tabs.map(
54
58
  (x) =>
@@ -56,18 +60,18 @@ export default (s: AutocompleteScope) => {
56
60
  )}
57
61
  </div>`
58
62
  : nothing}
59
- <div class="sh-list" role="tabpanel" id="lbx-ac-panel" aria-labelledby=${open ? `lbx-ac-tab-${open.kind}` : nothing}>
63
+ <div class="sh-list" role=${tabbed ? 'tabpanel' : nothing} id="lbx-ac-panel" aria-labelledby=${tabbed ? `lbx-ac-tab-${open!.kind}` : nothing}>
60
64
  ${s.tabs.length
61
- ? html`<div role="listbox" id="lbx-ac-listbox" aria-label=${s.t('label')}>
65
+ ? html`<div role="listbox" id="lbx-ac-listbox" aria-label=${tabbed ? s.t('label') : open!.label}>
62
66
  ${onProducts
63
67
  ? html`${group('hero', s.t('topProduct'), opt(s.hero!, 'mhero'), 'm-hero')}
64
68
  ${s.products.length ? group('products', s.t('products'), repeat(s.products, key, (p) => opt(p, 'mproduct')), 'm-products') : nothing}`
65
69
  : repeat(rows, key, (row) => opt(row, 'mrow'))}
66
70
  </div>`
67
71
  : nothing}
72
+ ${s.hero ? html`<div class="sh-foot">${showAll(s)}</div>` : nothing}
68
73
  ${POWERED(s.t)}
69
- </div>
70
- ${s.hero ? html`<div class="sh-foot">${showAll(s)}</div>` : nothing}`
74
+ </div>`
71
75
  : nothing}
72
76
  </div>
73
77
  `;
@@ -0,0 +1,87 @@
1
+ // Apply in a staged drawer, marked (element.ts swapIn): a change the shopper
2
+ // confirmed with the drawer closing over the list must read as one. On an
3
+ // iPhone the quiet fade of a prefetched result read as a flicker: had the
4
+ // filters been taken? (2026-10-19) The list it replaces dims at the tap
5
+ // (`data-lbx-swap`, as busy's dim: `--lbx-c-dim` holds it in the contrast
6
+ // mode), the new tiles come in, and a status at the listing's top says what
7
+ // was applied. Its own chunk (lbx-applied), asked for when a drawer opens
8
+ // where it stages: a listing page that never applies one does not load it.
9
+ import { cspNonce, layered, setCss } from '../../core/styles';
10
+
11
+ const SWAP_MS = 300;
12
+ export const CSS =
13
+ ':is(lbx-search,lbx-plp)[data-lbx-swap] [data-lbx-list]{opacity:var(--lbx-c-dim,.15)}' +
14
+ '@media (forced-colors:active){:is(lbx-search,lbx-plp)[data-lbx-swap] [data-lbx-list]{opacity:.6}}' +
15
+ // The contrast mode (core/a11y.ts): the pill opaque, nothing of the page through it.
16
+ ':root[data-lbx-contrast="more"] [data-lbx-applied-cue]{--lbx-applied-opaque:1}' +
17
+ '@media (prefers-contrast:more){:root:not([data-lbx-contrast="off"]) [data-lbx-applied-cue]{--lbx-applied-opaque:1}}';
18
+ // The pill is the one element with Luma's own look in the site's light
19
+ // DOM: a site's reset (`div,span{margin:0;padding:0}`, unlayered, so
20
+ // above Luma's layered CSS) took its padding and centring (a production storefront,
21
+ // 2026-10-19). Its wrapper's layout is its inline style (`!important`, as the
22
+ // status region's); the pill is in the wrapper's shadow root, with its own
23
+ // sheet. A site restyles it through `::part(pill)` or the `--lbx-applied-*`
24
+ // properties, which inherit into it.
25
+ // Sticky, 0 px high, the pill out of flow: margins collapse through it, nothing moves (a flex wrapper moved the list 16 px).
26
+ // What else a site's rule on `div` could set and break: clipped (overflow), given height (min-height: the list moves), narrowed (float, width).
27
+ export const WRAP =
28
+ 'display:block!important;position:sticky!important;top:var(--lbx-applied-top,8px)!important;z-index:3!important;height:0!important;min-height:0!important;max-height:none!important;' +
29
+ 'width:auto!important;max-width:none!important;float:none!important;margin:0!important;padding:0!important;border:0!important;overflow:visible!important;transform:none!important;filter:none!important;' +
30
+ 'opacity:1!important;visibility:visible!important;contain:none!important;pointer-events:none!important';
31
+ export const PILL =
32
+ // Wraps (a long query, a long language) rather than cut: its words are shown nowhere else. A tap goes through it
33
+ // (all:initial put pointer-events back: it took taps on what it overlaps). Its font the site's; its size at least the reader's default's 15/16.
34
+ // `--lbx-applied-offset`: below the listing's top by this much (a template whose heading sits there).
35
+ '.p{all:initial;display:block;position:absolute;top:var(--lbx-applied-offset,0px);left:0;right:0;box-sizing:border-box;width:fit-content;max-width:calc(100% - 16px);margin:0 auto;padding:8px 16px;border-radius:18px;pointer-events:none;' +
36
+ 'background:var(--lbx-applied-bg,#1d2433);color:var(--lbx-applied-fg,#fff);font-family:inherit;font-size:max(15px,.9375rem);font-weight:500;line-height:1.3;text-align:center;overflow-wrap:anywhere;box-shadow:0 2px 12px rgb(0 0 0/.25)}' +
37
+ // A little of the page through it, blurred (white text at least 10:1 over any page behind); opaque in the contrast mode (--lbx-applied-opaque).
38
+ '@supports (backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px)){.p{background:var(--lbx-applied-bg,rgb(29 36 51/calc(.88 + .12 * var(--lbx-applied-opaque,0))));' +
39
+ '-webkit-backdrop-filter:blur(calc(8px - 8px * var(--lbx-applied-opaque,0)));backdrop-filter:blur(calc(8px - 8px * var(--lbx-applied-opaque,0)))}}' +
40
+ '@media (forced-colors:active){.p{border:1px solid CanvasText;background:Canvas;color:CanvasText}}';
41
+ let sheet: CSSStyleSheet | null = null;
42
+
43
+ /** Its styles, before the first Apply (the drawer's opening restyles the page anyway). */
44
+ export const prepare = () => setCss('listing-applied', layered(CSS));
45
+
46
+ /**
47
+ * The applied state's first tiles come in (rising row after row, only fading
48
+ * under reduced motion), and the status's pill goes after `after` (Luma's
49
+ * status region, which says the same words: the pill is hidden from
50
+ * assistive technology). Returns what takes the pill away (faded, or at once).
51
+ */
52
+ export const swapIn = (host: HTMLElement, tiles: Element[], say: string, after: Element | null, reduce: boolean) => {
53
+ tiles.forEach((el, i) =>
54
+ el.animate(reduce ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 0, transform: 'translateY(12px)' }, { opacity: 1, transform: 'none' }], {
55
+ duration: SWAP_MS,
56
+ delay: reduce ? 0 : Math.min(i, 7) * 35,
57
+ easing: 'cubic-bezier(.2,0,0,1)',
58
+ fill: 'backwards',
59
+ }),
60
+ );
61
+ const el = document.createElement('div');
62
+ el.setAttribute('data-lbx-applied-cue', '');
63
+ el.setAttribute('aria-hidden', 'true');
64
+ el.style.cssText = WRAP;
65
+ const root = el.attachShadow({ mode: 'open' });
66
+ // Safari before 16.4 has no constructed sheets (its constructor throws): a <style>, with the page's nonce.
67
+ if ('adoptedStyleSheets' in ShadowRoot.prototype) {
68
+ if (!sheet) (sheet = new CSSStyleSheet()), sheet.replaceSync(PILL);
69
+ root.adoptedStyleSheets = [sheet];
70
+ } else {
71
+ const st = root.appendChild(document.createElement('style'));
72
+ st.nonce = cspNonce();
73
+ st.textContent = PILL;
74
+ }
75
+ const pill = root.appendChild(document.createElement('span'));
76
+ pill.className = 'p';
77
+ pill.setAttribute('part', 'pill');
78
+ pill.textContent = `✓ ${say}`;
79
+ if (after) after.after(el);
80
+ else host.prepend(el);
81
+ pill.animate([{ opacity: 0 }, { opacity: 1 }], 200);
82
+ return (fade: boolean) => {
83
+ if (!fade) return el.remove();
84
+ // A cancelled animation rejects `finished`: removed either way.
85
+ pill.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 300, fill: 'forwards' }).finished.then(() => el.remove(), () => el.remove());
86
+ };
87
+ };
@@ -37,7 +37,7 @@ import { facetFields, handsBack, nextState, queryKey, stateOf, type Query, type
37
37
  import { specKey } from '../../core/stash';
38
38
  import { firstRequest, hasMore, listingMore, listingQuery, type ListingConfig, type ListingResult } from './query';
39
39
  import { flowKey, notAbove, onQuickLanded, quickChannel } from './flow';
40
- import { firstPages, targetOf, thrifty, watchIntent } from './intent';
40
+ import { firstPages, SETTLE_MS, targetOf, thrifty, watchIntent } from './intent';
41
41
  import type { Page } from '../../api';
42
42
  import { atWidth, CV_CSS, DIM_MS, LOADING_CSS, listingData, ssrStateId, viewport, type ListingView, type SsrState } from './view';
43
43
  import { dropStash, peekStash, readStash, removeStash, writeStash, type Stash } from './stash';
@@ -47,6 +47,11 @@ import { watch } from '../../core/tiles';
47
47
  /** A re-render answered within DIM_MS (no dim shown) fades its list in from this opacity, over FADE_MS. */
48
48
  const FADE_FROM = 0.6;
49
49
  const FADE_MS = 150;
50
+ /** An Apply's status (./applied.ts) shows this long; its new tiles come in up to SWAP_TILES (those in view). */
51
+ const APPLIED_MS = 2500;
52
+ const SWAP_TILES = 16;
53
+ /** Reduced motion asked for (read when needed: the setting may change). */
54
+ const reduced = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
50
55
  /** A hidden first render in frames: the first STEP tiles, then about BUDGET ms of rendering per frame. */
51
56
  const STEP = 4;
52
57
  /** Tiles drawn (with the facets) before ours shows, per 900 px of viewport height: two rows of four on a wide screen, four of two on a phone. */
@@ -167,6 +172,17 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
167
172
  // it does not download it with every listing.
168
173
  const toggle = ctx.global.contrastToggle ? import('../../core/contrast-toggle') : null;
169
174
  const visitOf = (q: Query) => JSON.stringify([q.q, q.scope]);
175
+ const prefetchFailed = (e: unknown) => reportError(`${route.id} filter prefetch`, e);
176
+ /** What a render says it shows: the count found, or nothing found. */
177
+ const found = (q: Query, res: ListingResult) => (res.total ? t('results', { count: res.total, query: q.title }) : t('empty', { query: q.title }));
178
+ // An Apply's cue (./applied.ts, its own chunk): asked for when a drawer opens where it stages.
179
+ let applied: typeof import('./applied') | null = null;
180
+ let appliedAsked = false;
181
+ const askApplied = () => {
182
+ if (appliedAsked) return;
183
+ appliedAsked = true;
184
+ import('./applied').then((m) => (m.prepare(), (applied = m)), (e) => reportError(`${route.id} applied cue`, e));
185
+ };
170
186
  const earlyKey = (q: Query) => {
171
187
  const r = firstRequest(ctx.global, cfg, route, q);
172
188
  return r && JSON.stringify(r);
@@ -334,6 +350,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
334
350
  */
335
351
  private staged: UiState | null = null;
336
352
  private stagedMode = false;
353
+ /**
354
+ * The held state fetched in the background (prefetchHeld()): the timer
355
+ * waiting for the changes to pause, the held state's URL (a query
356
+ * resolved late is for it only), and the request it started (its key in
357
+ * `early`), aborted by a newer held state while it is out.
358
+ */
359
+ private heldTimer = 0;
360
+ private heldKey: string | null = null;
337
361
  /** Stops watching the `facetsApply` breakpoints of the open drawer (watchApply()). */
338
362
  private unwatchApply: (() => void) | null = null;
339
363
  /** Renders started: a render in chunks stops when a newer one has started. */
@@ -490,6 +514,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
490
514
  this.ahead?.();
491
515
  this.intent?.stop();
492
516
  this.intent = null;
517
+ clearTimeout(this.heldTimer);
518
+ this.heldTimer = 0;
519
+ this.endCue();
520
+ this.removeAttribute('data-lbx-swap');
493
521
  this.sized?.ro.disconnect();
494
522
  this.sized = null;
495
523
  this.unwatchApply?.();
@@ -581,7 +609,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
581
609
  * Where the shopper was in each state of the visit left by a change of
582
610
  * ours or a traversal (canonical href → scrollY), and the one a traversal
583
611
  * goes back to: drawn whole (not in steps) and placed there. Bug (a
584
- * bicycle shop's QA round 4, 2026-10-15): Back after a drawer filter
612
+ * production storefront, 2026-10-15): Back after a drawer filter
585
613
  * drew the list in steps, the browser's restored scroll was clamped to
586
614
  * the first step's short page, and Chromium's scroll anchoring kept the
587
615
  * footer in view as the tiles went in above it.
@@ -607,22 +635,49 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
607
635
  if (!DRAWER) return;
608
636
  const next = this.staged;
609
637
  this.staged = null;
610
- this.setDrawer(false, from, next ? () => (this.navigate(next), (this.topFor = this.wantedHref), this.toListTop()) : undefined);
638
+ // Changes not fetched yet (Apply within SETTLE_MS of the last): their
639
+ // request starts in the next task (the drawer closes first), while the
640
+ // drawer's entry is taken back, and the navigation joins it.
641
+ if (next && this.heldTimer) setTimeout(() => this.prefetchHeld(next), 0);
642
+ // Another state to show: the list it replaces dims now, as the drawer closes (./applied.ts).
643
+ const cue = (this.applyCue = next && canon(route.write(new URL(location.href), next).href) !== this.href ? { href: '' } : null);
644
+ this.toggleAttribute('data-lbx-swap', !!cue);
645
+ this.setDrawer(false, from, next ? () => {
646
+ this.navigate(next);
647
+ this.topFor = this.wantedHref;
648
+ if (cue) cue.href = this.wantedHref ?? '';
649
+ if (cue && !cue.href) this.removeAttribute('data-lbx-swap'), (this.applyCue = null); // nothing to fetch after all
650
+ this.toListTop();
651
+ } : undefined);
611
652
  }
612
653
 
613
654
  /** The state an Apply asked for: its render brings the listing's top into view again (late layout, a smooth scroll cut short). */
614
655
  private topFor: string | null = null;
656
+ /**
657
+ * An Apply's swap (./applied.ts): the state it asked for ('' until its
658
+ * navigation; the old list dimmed meanwhile, `data-lbx-swap`); its words
659
+ * and state while its render puts the new tiles in (swapIn(), from the
660
+ * step that draws them); then the status shown for the state applied,
661
+ * what takes it away, and when.
662
+ */
663
+ private applyCue: { href: string } | null = null;
664
+ private swapping: { say: string; href: string } | null = null;
665
+ private cue: { end: (fade: boolean) => void; href: string; timer: number } | null = null;
666
+ /** What the applied state's render announces (the status's words). */
667
+ private appliedSay: string | null = null;
615
668
  /** The top of the listing in view when it is above the viewport (smooth, unless reduced motion is asked for). */
616
669
  private toListTop() {
617
670
  if (!this.isConnected || this.getBoundingClientRect().top >= 0) return;
618
- const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
619
- this.scrollIntoView({ block: 'start', behavior: (reduce ? 'instant' : 'smooth') as ScrollBehavior });
671
+ this.scrollIntoView({ block: 'start', behavior: (reduced() ? 'instant' : 'smooth') as ScrollBehavior });
620
672
  }
621
673
 
622
674
  /** The drawer's held changes dropped: it shows the applied state again. */
623
675
  private discardStaged() {
624
676
  if (!this.staged) return;
625
677
  this.staged = null;
678
+ // A fetch of what it held, started already, is left to expire (./intent.ts); one not started yet is not.
679
+ clearTimeout(this.heldTimer);
680
+ this.heldTimer = 0;
626
681
  this.redraw();
627
682
  }
628
683
 
@@ -647,6 +702,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
647
702
  if (!open) this.discardStaged();
648
703
  if (open && !was) {
649
704
  this.stagedMode = atWidth(cfg.facetsApply, 'instant', viewport) === 'staged';
705
+ if (this.stagedMode) askApplied();
650
706
  if (this.stagedMode && !this.drawerEntry)
651
707
  try {
652
708
  history.pushState(null, '', location.href);
@@ -696,8 +752,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
696
752
  /**
697
753
  * The open drawer closes when the viewport crosses into another
698
754
  * `facetsApply` mode (a phone rotated, a window widened): the drawer was a
699
- * phone's, and what it held was never confirmed. Regression (a bicycle
700
- * shop, `{ 0: 'staged', 992: 'instant' }`, 2026-10-14): a value ticked in
755
+ * phone's, and what it held was never confirmed. Regression (a production
756
+ * storefront, `{ 0: 'staged', 992: 'instant' }`, 2026-10-14): a value ticked in
701
757
  * the drawer at 820 px stayed ticked in the 992 px sidebar with the
702
758
  * results unfiltered and no apply button there, and rotating back showed
703
759
  * the full-screen drawer again (`data-drawer-open` had stayed). Dropping
@@ -839,12 +895,18 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
839
895
  return w;
840
896
  }
841
897
 
842
- /** UI -> URL. The resulting URL change drives sync(). In a drawer that stages: held there, nothing fetched. */
898
+ /**
899
+ * UI -> URL. The resulting URL change drives sync(). In a drawer that
900
+ * stages: held there, its state fetched in the background once the
901
+ * changes pause (nothing drawn from it until Apply).
902
+ */
843
903
  go(patch: Partial<UiState>) {
844
904
  if (!this.current) return;
845
905
  this.intent?.cancel(); // a dwell still pending: this click's own state is what is fetched now
846
906
  if (this.staging()) {
847
907
  this.staged = nextState(this.staged ?? this.wanted ?? stateOf(this.current), this.shown ?? stateOf(this.current), patch);
908
+ clearTimeout(this.heldTimer);
909
+ if (DRAWER && prefetchFilters) this.heldTimer = window.setTimeout(() => this.staging() && this.staged && this.prefetchHeld(this.staged), SETTLE_MS);
848
910
  return this.redraw();
849
911
  }
850
912
  this.navigate(nextState(this.wanted ?? stateOf(this.current), this.shown ?? stateOf(this.current), patch));
@@ -1027,7 +1089,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1027
1089
  this.message = null;
1028
1090
  // Another state of the visit shown (a filter, a sort): its sidebar
1029
1091
  // stays until the new facets come, as from the intent prefetch.
1030
- // Bug (a bicycle shop's QA round 3, 2026-10-15): drawn with no
1092
+ // Bug (a production storefront, 2026-10-15): drawn with no
1031
1093
  // facets, the sidebar collapsed to one group for ~250 ms and
1032
1094
  // Chromium's scroll anchoring threw the page to its top.
1033
1095
  const last = this.last;
@@ -1099,6 +1161,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1099
1161
  }
1100
1162
  this.images();
1101
1163
  if (tk.state === 'ours') this.watch();
1164
+ if (DRAWER) this.swapIn(); // an Apply's new tiles, if these are they
1102
1165
  }
1103
1166
 
1104
1167
  /** Images a render put in that are there already (cached, persisted tiles) lose their placeholder now: no flash, no fade. */
@@ -1260,25 +1323,89 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1260
1323
  private onIntent(el: Element) {
1261
1324
  const cur = this.current;
1262
1325
  // In a drawer that stages, a control leads nowhere until Apply: nothing to fetch ahead.
1263
- if (this.staging()) return;
1264
- if (!cur || !this.last || this.lastQ !== cur || this.inFlight || this.wanted || tk.state !== 'ours' || thrifty()) return;
1326
+ if (this.staging() || !this.settled(cur)) return;
1265
1327
  // "Show more" (`data-lbx-more`): its next page, from the cursor, as nearing the end does.
1266
- if (el.matches('[data-lbx-more]')) return this.prefetchNext(cur, this.last);
1328
+ if (el.matches('[data-lbx-more]')) return this.prefetchNext(cur, this.last!);
1267
1329
  const raw = targetOf(el);
1268
1330
  if (!raw) return;
1269
1331
  const url = new URL(raw, location.href);
1270
1332
  if (url.origin !== location.origin || url.pathname !== location.pathname || !route.accepts(url)) return;
1333
+ this.prefetchState(url, 'on intent');
1334
+ }
1335
+
1336
+ /**
1337
+ * What the open staged drawer holds, fetched in the background (its
1338
+ * changes paused for SETTLE_MS, or Apply came first): its first page into
1339
+ * `early`, which Apply's sync() takes, done or in flight. Nothing is drawn
1340
+ * from it, no hook runs, no URL is written: the results change on Apply
1341
+ * only. A newer held state aborts the older one still out; back at the
1342
+ * state shown, nothing is fetched. Limits as on intent (thrifty(), the
1343
+ * visit's cap; none with `prefetchFilters` off: no timer is set).
1344
+ */
1345
+ private prefetchHeld(st: UiState) {
1346
+ if (!DRAWER) return;
1347
+ clearTimeout(this.heldTimer);
1348
+ this.heldTimer = 0;
1349
+ const cur = this.current;
1350
+ if (!this.settled(cur)) return;
1351
+ const why = "of the drawer's held state";
1352
+ // The request this held state starts (or finds cached, or none): the older one still out goes.
1353
+ const keyed = (k: string | null) => {
1354
+ const was = this.heldKey;
1355
+ if (was && was !== k) early.drop(was);
1356
+ this.heldKey = k;
1357
+ };
1358
+ // From a timer: a route hook that throws (fromUrl) must not reach the
1359
+ // site. Nothing is fetched; the drawer keeps what it holds, and Apply
1360
+ // reports the hook's failure as any navigation does.
1361
+ try {
1362
+ const url = route.write(new URL(location.href), st);
1363
+ if (canon(url.href) === this.href) return keyed(null);
1364
+ // The query Apply's sync() will resolve from that URL: the same
1365
+ // request, to the order of the values (a value map puts some in the
1366
+ // site's param, the others in ours, read back first). When the route
1367
+ // has it at hand (memoised), that one. Else the state held, at once
1368
+ // (resolving the URL's slugs may ask the category's values first:
1369
+ // two requests in a row), and once resolved, the URL's own query
1370
+ // supersedes it if its request differs (the same: nothing more).
1371
+ const r = route.resolve(url);
1372
+ if (!isPromise(r)) return this.prefetchState(r, why, keyed);
1373
+ this.prefetchState({ ...cur, ...st }, why, keyed);
1374
+ r.then((q) => void (this.staged === st && this.prefetchState(q, why, keyed))).catch(prefetchFailed);
1375
+ } catch (e) {
1376
+ prefetchFailed(e);
1377
+ }
1378
+ }
1379
+
1380
+ /** A listing of ours to fetch ahead from: the current state shown, nothing pending for it, a connection that allows it. */
1381
+ private settled(cur: Query | null): cur is Query {
1382
+ return !!cur && !!this.last && this.lastQ === cur && !this.inFlight && !this.wanted && tk.state === 'ours' && !thrifty();
1383
+ }
1384
+
1385
+ /**
1386
+ * Another state of this visit (a control's URL, or a held state's
1387
+ * query), not cached: its first page, the request sync() starts with
1388
+ * (`firstRequest`), fetched ahead into `early` and never rendered from
1389
+ * here. `keyed`: told the request's key (null: none) before it starts,
1390
+ * also when there is nothing to start (cached).
1391
+ */
1392
+ private prefetchState(to: URL | Query | null, why: string, keyed?: (k: string | null) => void) {
1393
+ const cur = this.current!;
1271
1394
  const from = this.href;
1272
- if (canon(url.href) === from) return;
1273
1395
  const then = (q: Query | null) => {
1274
- if (!q || this.href !== from || this.inFlight || this.wanted || visitOf(q) !== visitOf(cur) || cache.get(keyOf(q))) return;
1396
+ if (!q || this.href !== from || this.inFlight || this.wanted || visitOf(q) !== visitOf(cur)) return;
1275
1397
  const req = firstRequest(ctx.global, cfg, route, q);
1398
+ const k = req && JSON.stringify(req);
1399
+ keyed?.(k);
1400
+ if (!k || cache.get(keyOf(q))) return;
1276
1401
  // Its first images too (`_global.images.warm`, low priority): the click then shows them at once.
1277
1402
  const fetch = (signal: AbortSignal) => ctx.api.search(req!, signal).then((p) => (warm > 0 && warmImages(firstRowImages(imgs, p.hits.slice(0, warm).map(ctx.result), true), warm), p));
1278
- if (req && early.start(JSON.stringify(req), visitOf(q), fetch)) mark(`${route.id} prefetch on intent`);
1403
+ if (early.start(k, visitOf(q), fetch)) mark(`${route.id} prefetch ${why}`);
1279
1404
  };
1280
- const r = route.resolve(url);
1281
- if (isPromise(r)) r.then(then).catch((e) => reportError(`${route.id} filter prefetch`, e));
1405
+ if (!(to instanceof URL)) return then(to);
1406
+ if (canon(to.href) === from) return;
1407
+ const r = route.resolve(to);
1408
+ if (isPromise(r)) r.then(then).catch(prefetchFailed);
1282
1409
  else then(r);
1283
1410
  }
1284
1411
 
@@ -1299,14 +1426,63 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1299
1426
  this.waited = null;
1300
1427
  const was = this.last;
1301
1428
  const quiet = this.quiet;
1429
+ // An Apply's swap (./applied.ts): any render after its navigation ends
1430
+ // it; the applied state's own render, not an error's, says what was
1431
+ // applied, and its first step brings the new tiles in (swapIn()).
1432
+ const href = DRAWER ? canon(url.href) : '';
1433
+ const cue = DRAWER && this.applyCue?.href ? this.applyCue : null;
1434
+ const say = cue && href === cue.href && !this.message ? t('applied', { results: found(q, res) }) : null;
1435
+ if (cue) this.applyCue = null;
1436
+ if (DRAWER && this.cue && this.cue.href !== href) this.endCue();
1437
+ // A render of the same state before the swap's step drew its tiles (the
1438
+ // facets, before a first step a frame late on a phone): it carries the
1439
+ // swap on; it dropped it, and the list stayed dimmed for the visit.
1440
+ if (DRAWER) (this.appliedSay = say), (this.swapping = say && tk.state === 'ours' ? { say, href } : this.swapping?.href === href ? this.swapping : null);
1302
1441
  this.guarded(() => this.draw(q, res, url));
1303
- if (w && !w.dimmed && was && !quiet && tk.state === 'ours' && !sameHits(res, was)) this.fadeIn();
1442
+ if (DRAWER) (this.appliedSay = null), !this.swapping && this.removeAttribute('data-lbx-swap');
1443
+ if (!say && w && !w.dimmed && was && !quiet && tk.state === 'ours' && !sameHits(res, was)) this.fadeIn();
1444
+ }
1445
+
1446
+ /**
1447
+ * The applied state's tiles are in (the render's first step): the old
1448
+ * list's dim ends, those in view come in, and the status says what was
1449
+ * applied for APPLIED_MS (`data-lbx-applied` on the element meanwhile,
1450
+ * for the site's own cue). Without the cue's chunk (not loaded yet, or
1451
+ * failed), only the dim ends: the status region still says it.
1452
+ */
1453
+ private swapIn() {
1454
+ const s = DRAWER ? this.swapping : null;
1455
+ if (!s) return;
1456
+ this.swapping = null;
1457
+ this.removeAttribute('data-lbx-swap');
1458
+ if (!applied) return;
1459
+ this.endCue();
1460
+ const tiles = [...(this.querySelector('[data-lbx-list]')?.children ?? [])].slice(0, SWAP_TILES);
1461
+ let end: (fade: boolean) => void;
1462
+ try {
1463
+ end = applied.swapIn(this, tiles, s.say, this.a11y?.root ?? null, reduced());
1464
+ } catch (e) {
1465
+ // In the render's step: a failure of the cue costs the cue, never the listing (guarded() would hand it back).
1466
+ return reportError(`${route.id} applied cue`, e);
1467
+ }
1468
+ this.setAttribute('data-lbx-applied', '');
1469
+ this.cue = { end, href: s.href, timer: window.setTimeout(() => this.endCue(true), APPLIED_MS) };
1470
+ }
1471
+
1472
+ /** The status of an Apply taken away: faded out once its time is up, at once when another state renders. */
1473
+ private endCue(fade = false) {
1474
+ const c = DRAWER ? this.cue : null;
1475
+ if (!c) return;
1476
+ this.cue = null;
1477
+ clearTimeout(c.timer);
1478
+ this.removeAttribute('data-lbx-applied');
1479
+ c.end(fade);
1304
1480
  }
1305
1481
 
1306
1482
  /** The list fading in from FADE_FROM (Web Animations: no class to restart, no layout read); not under reduced motion. */
1307
1483
  private fadeIn() {
1308
1484
  const list = this.querySelector('[data-lbx-list]');
1309
- if (!list || typeof list.animate !== 'function' || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
1485
+ if (!list || typeof list.animate !== 'function' || reduced()) return;
1310
1486
  this.fade?.cancel();
1311
1487
  this.fade = list.animate([{ opacity: FADE_FROM }, { opacity: 1 }], { duration: FADE_MS, easing: 'cubic-bezier(.2,0,0,1)' });
1312
1488
  }
@@ -1318,6 +1494,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1318
1494
  } catch (e) {
1319
1495
  reportError(`${route.id} render`, e);
1320
1496
  this.setBusy(false);
1497
+ // An Apply's swap under way: the list handed back is not left dimmed.
1498
+ if (DRAWER) (this.swapping = null), this.removeAttribute('data-lbx-swap');
1321
1499
  // The server's markup is still the page's: back it goes (the takeover keeps a listing that is there).
1322
1500
  if (this.server) {
1323
1501
  delete (this as any)._$litPart$;
@@ -1461,7 +1639,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1461
1639
  const before = this.lastQ;
1462
1640
  const grew = !!before && !!this.last && keyOf(q) === keyOf(before) && res.page > this.last.page && res.guid === this.last.guid;
1463
1641
  this.shownBefore = grew ? (this.last?.hits.length ?? 0) : 0;
1464
- const said = this.message ?? (grew ? t('shown', { shown: res.hits.length, total: res.total, count: res.total }) : res.total ? t('results', { count: res.total, query: q.title }) : t('empty', { query: q.title }));
1642
+ const said = this.message ?? this.appliedSay ?? (grew ? t('shown', { shown: res.hits.length, total: res.total, count: res.total }) : found(q, res));
1465
1643
  // A quiet render keeps what is still to be said: the facets' render (said
1466
1644
  // with the products) cut short their redraw in steps, before it spoke,
1467
1645
  // and a filter was never announced (found by Luma's accessibility tests on a phone, 2026-10-11).
@@ -21,6 +21,12 @@
21
21
  // Results are never rendered by the prefetch itself: only a click whose
22
22
  // request is the same takes them.
23
23
  //
24
+ // A drawer that stages (`facetsApply: 'staged'`) has no intent to watch: its
25
+ // controls lead nowhere until Apply. There the held state itself is fetched
26
+ // once the changes pause for SETTLE_MS (three quick ticks: one request), a
27
+ // newer held state aborting the older one still out; Apply takes it as a
28
+ // click does. Closing the drawer otherwise leaves it to expire.
29
+ //
24
30
  // Limits: two in flight (a newer intent aborts the oldest), the state
25
31
  // changing aborts those still out (their controls now lead elsewhere), kept
26
32
  // TTL_MS, PER_VISIT per category or query, none with Save-Data, offline or on
@@ -36,6 +42,8 @@ import { reportError } from '../../core/health';
36
42
 
37
43
  /** Hover or focus this long counts as intent (Chrome's speculation rules wait ~200 ms on hover for a whole page; this is one request). */
38
44
  export const DWELL_MS = 100;
45
+ /** A staged drawer's changes pause this long before its held state is fetched (element.ts `prefetchHeld`). */
46
+ export const SETTLE_MS = 250;
39
47
  const TTL_MS = 30_000;
40
48
  /** First pages kept (done or in flight). */
41
49
  const KEEP = 6;
@@ -181,6 +189,13 @@ export const firstPages = () => {
181
189
  while (m.size > KEEP) m.delete(m.keys().next().value!);
182
190
  return true;
183
191
  },
192
+ /** One still out, no longer wanted (a staged drawer's older held state): aborted. A done one stays until it expires. */
193
+ drop(k: string) {
194
+ const e = m.get(k);
195
+ if (!e || e.settled) return;
196
+ e.ctrl.abort();
197
+ m.delete(k);
198
+ },
184
199
  /** The state changed: requests still out were for controls that now lead elsewhere. */
185
200
  abortInFlight() {
186
201
  for (const [k, e] of m)