@luigisbox/luma 0.4.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +76 -9
  3. package/build/baseline.mjs +2 -2
  4. package/build/vite.mjs +255 -20
  5. package/docs/ACCESSIBILITY.md +31 -5
  6. package/package.json +3 -2
  7. package/src/analytics/chain.ts +1 -1
  8. package/src/analytics/pageview.ts +1 -1
  9. package/src/analytics/start.ts +3 -4
  10. package/src/analytics/wire.ts +1 -1
  11. package/src/api/ai.ts +32 -20
  12. package/src/api/token.ts +7 -7
  13. package/src/core/a11y.ts +6 -35
  14. package/src/core/context.ts +2 -0
  15. package/src/core/contrast-toggle.ts +32 -0
  16. package/src/core/default-button.ts +66 -0
  17. package/src/core/early-rules.ts +3 -2
  18. package/src/core/features.ts +109 -0
  19. package/src/core/frameworks-check.ts +32 -0
  20. package/src/core/frameworks.ts +35 -0
  21. package/src/core/image-load.ts +0 -26
  22. package/src/core/image-preload.ts +29 -0
  23. package/src/core/image-tags.ts +7 -1
  24. package/src/core/images.ts +3 -2
  25. package/src/core/integration.ts +42 -26
  26. package/src/core/listings.ts +9 -5
  27. package/src/core/locales.ts +2 -1
  28. package/src/core/own.ts +7 -3
  29. package/src/core/recommenders.ts +2 -2
  30. package/src/core/reconciler.ts +32 -23
  31. package/src/core/routes.ts +7 -6
  32. package/src/core/scheduler.ts +39 -5
  33. package/src/core/speculation.ts +2 -0
  34. package/src/core/stash.ts +15 -6
  35. package/src/core/styles.ts +6 -5
  36. package/src/core/support.ts +1 -1
  37. package/src/core/takeover.ts +6 -5
  38. package/src/core/templates.ts +5 -2
  39. package/src/core/tiles.ts +33 -10
  40. package/src/core/url.ts +6 -3
  41. package/src/core/widget-inits.ts +4 -1
  42. package/src/lit.ts +2 -1
  43. package/src/locales/cs.json +1 -1
  44. package/src/locales/de.json +1 -1
  45. package/src/locales/en.json +1 -1
  46. package/src/main.ts +70 -53
  47. package/src/types.ts +48 -14
  48. package/src/widgets/autocomplete/index.ts +167 -34
  49. package/src/widgets/autocomplete/style-autocomplete.css +16 -8
  50. package/src/widgets/autocomplete/template-autocomplete-empty.ts +1 -1
  51. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +13 -9
  52. package/src/widgets/listing/applied.ts +87 -0
  53. package/src/widgets/listing/element.ts +279 -78
  54. package/src/widgets/listing/intent.ts +15 -0
  55. package/src/widgets/listing/query.ts +6 -3
  56. package/src/widgets/listing/stash.ts +33 -16
  57. package/src/widgets/listing/state.ts +12 -0
  58. package/src/widgets/listing/view.ts +2 -1
  59. package/src/widgets/plp/route.ts +3 -2
  60. package/src/widgets/recommender/index.ts +3 -2
  61. package/src/widgets/search/direct.ts +39 -0
  62. package/src/widgets/search/form-url.ts +38 -34
  63. package/src/widgets/search/in-place.ts +43 -108
  64. package/src/widgets/search/index.ts +7 -2
  65. package/src/widgets/search/speculate.ts +48 -27
  66. package/src/widgets/search/submit.ts +136 -0
@@ -13,14 +13,16 @@
13
13
  // (lit SSR) instead of rebuilding it, so nothing is fetched or repainted on
14
14
  // load. From then on the listing is client-side as usual.
15
15
 
16
- import { borrow, contrastToggle, liveRegion, useContrast } from '../../core/a11y';
16
+ import { borrow, liveRegion, useContrast } from '../../core/a11y';
17
17
  import { activateByKey, byKeyboard, focusables, trapTab } from '../../core/a11y-keys';
18
+ import { CONTENT_VISIBILITY, DRAWER, DROPDOWN, MENU, NAMED_FADE, NAMED_PRECONNECT_IMAGES, NAMED_PREFETCH_FILTERS, NAMED_PREFETCH_NEXT_PAGE, NAMED_PRELOAD, NAMED_RESTORE_ON_BACK, ON_RENDER, SEARCH, SSR } from '../../core/features';
18
19
  import { mark, reportError } from '../../core/health';
19
20
  import { claim } from '../../core/reconciler';
20
21
  import { afterLayout, rememberHeight } from '../../core/hold';
21
22
  import { hasPrefetch, observe, peekPrefetch, peekProducts, productsChannel, putPrefetch, takePrefetch, typicalMs, type Prefetch } from '../../core/prefetch';
22
23
  // Through the support chunk, which every widget loads with its own (src/main.ts load()).
23
- import { firstRowCount, firstRowImages, imageHelpers, imageOf, preloadImages, settleImages, warmImages } from '../../core/support';
24
+ import { preloadImages } from '../../core/image-preload';
25
+ import { firstRowCount, firstRowImages, imageHelpers, imageOf, settleImages, warmImages } from '../../core/support';
24
26
  import { view } from '../../core/templates';
25
27
  import { afterFrame, isPromise } from '../../core/scheduler';
26
28
  import { onUrlChange } from '../../core/sense';
@@ -31,19 +33,25 @@ import { canon, pageKey, writeUrl } from '../../core/url';
31
33
  import { lru } from '../../core/storage';
32
34
  import type { WidgetContext } from '../../main';
33
35
  import type { Link } from '../../types';
34
- import { facetFields, handsBack, nextState, stateOf, type Query, type Route, type UiState } from './state';
36
+ import { facetFields, handsBack, nextState, queryKey, stateOf, type Query, type Route, type UiState } from './state';
37
+ import { specKey } from '../../core/stash';
35
38
  import { firstRequest, hasMore, listingMore, listingQuery, type ListingConfig, type ListingResult } from './query';
36
39
  import { flowKey, notAbove, onQuickLanded, quickChannel } from './flow';
37
- import { firstPages, targetOf, thrifty, watchIntent } from './intent';
40
+ import { firstPages, SETTLE_MS, targetOf, thrifty, watchIntent } from './intent';
38
41
  import type { Page } from '../../api';
39
42
  import { atWidth, CV_CSS, DIM_MS, LOADING_CSS, listingData, ssrStateId, viewport, type ListingView, type SsrState } from './view';
40
- import { dropStash, peekStash, readStash, writeStash, type Stash } from './stash';
43
+ import { dropStash, peekStash, readStash, removeStash, writeStash, type Stash } from './stash';
41
44
  import { adoptScroll, holdScroll, keepViewport, leavingLink, nearEnd, preconnectImages, releaseScroll, restoreScroll, scrollSpot } from './instant';
42
45
  import { watch } from '../../core/tiles';
43
46
 
44
47
  /** A re-render answered within DIM_MS (no dim shown) fades its list in from this opacity, over FADE_MS. */
45
48
  const FADE_FROM = 0.6;
46
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;
47
55
  /** A hidden first render in frames: the first STEP tiles, then about BUDGET ms of rendering per frame. */
48
56
  const STEP = 4;
49
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. */
@@ -88,7 +96,7 @@ const sameUrl = (u: unknown, href: string) => {
88
96
  return false;
89
97
  }
90
98
  };
91
- const keyOf = (q: Query) => JSON.stringify([q.q, q.scope, q.filters, q.flags, q.sort]);
99
+ const keyOf = queryKey;
92
100
 
93
101
  type Hydrate = (v: unknown, c: HTMLElement | ShadowRoot) => void;
94
102
 
@@ -100,7 +108,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
100
108
  useContrast();
101
109
  const query = (q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void) => {
102
110
  const t0 = performance.now();
103
- const p = listingQuery(ctx.api, ctx.global, cfg, route, q, signal, pre, onProducts, quickChannel(route.id));
111
+ const p = listingQuery(ctx.api, ctx.global, cfg, route, q, signal, pre, onProducts, SEARCH ? quickChannel(route.id) : undefined);
104
112
  // A whole listing (not a first page fetched already): how long a fresh one takes here.
105
113
  if (!pre) p.then((r) => !r.partial && observe(performance.now() - t0), () => {});
106
114
  return p;
@@ -158,8 +166,23 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
158
166
  };
159
167
  // Filter clicks that render at once (./intent.ts): first pages fetched on intent, by their request.
160
168
  const early = firstPages();
161
- const prefetchFilters = ctx.global.prefetchFilters !== false;
169
+ const prefetchFilters = (NAMED_PREFETCH_FILTERS ? ctx.global.prefetchFilters !== false : true);
170
+ // The high-contrast toggle (`_global.contrastToggle`, off by default): its
171
+ // own chunk (lbx-contrast), asked for now where it is on, so a site without
172
+ // it does not download it with every listing.
173
+ const toggle = ctx.global.contrastToggle ? import('../../core/contrast-toggle') : null;
162
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
+ };
163
186
  const earlyKey = (q: Query) => {
164
187
  const r = firstRequest(ctx.global, cfg, route, q);
165
188
  return r && JSON.stringify(r);
@@ -190,7 +213,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
190
213
  return false;
191
214
  }
192
215
  };
193
- if (document.querySelector(`${route.tag}[data-lbx-ssr]`)) adopt();
216
+ if (SSR && document.querySelector(`${route.tag}[data-lbx-ssr]`)) adopt();
194
217
 
195
218
  // A result stashed for this page load (./stash.ts): the query typed on the
196
219
  // previous page, or the listing as the shopper left it (Back). Seeded like a
@@ -199,7 +222,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
199
222
  /** The page's templates prepared ahead (`rehearse`), once. */
200
223
  let rehearsed = false;
201
224
  if (!seeded.size) {
202
- stashed = readStash(route.id, location.href);
225
+ // A typed query's stash is under the query: this page's, as the search
226
+ // route reads it (sync for search; a category's lookup is not asked here).
227
+ const here = new URL(location.href);
228
+ const resolved = route.id === 'search' && route.accepts(here) ? route.resolve(here) : null;
229
+ stashed = readStash(route.id, location.href, resolved instanceof Promise ? null : resolved);
203
230
  if (stashed?.sr) adoptScroll();
204
231
  // Too old to render from: where the shopper was only (the results are asked for as on any load).
205
232
  if (stashed?.stale) mark(`${route.id} back: stash too old, its scroll only`);
@@ -209,12 +236,12 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
209
236
  mark(`${route.id} from stash (${stashed.kind}, ${stashed.result.hits.length} hits)`);
210
237
  // A prerendered page reads (and drops) it in a copy of sessionStorage; at
211
238
  // its activation the opener's storage, still holding it, comes back: used once.
212
- const at = location.href;
213
- if (stashed.kind === 'spec' && (document as any).prerendering) document.addEventListener('prerenderingchange', () => dropStash(at), { once: true });
239
+ const key = specKey(route.id, stashed.query.q ?? '');
240
+ if (stashed.kind === 'spec' && (document as any).prerendering) document.addEventListener('prerenderingchange', () => removeStash(key), { once: true });
214
241
  }
215
242
  }
216
- const restoreOn = cfg.restoreOnBack !== false;
217
- const skipOut = cfg.contentVisibility === true;
243
+ const restoreOn = (NAMED_RESTORE_ON_BACK ? cfg.restoreOnBack !== false : true);
244
+ const skipOut = CONTENT_VISIBILITY ? cfg.contentVisibility === true : false;
218
245
 
219
246
  // The request for the URL we loaded on starts now, before the element exists.
220
247
  const bootUrl = new URL(location.href);
@@ -235,7 +262,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
235
262
  // usually arrives while the chunk's templates, the fonts or the anchor are
236
263
  // still on their way. A server-rendered page has them in its HTML (the server renderer).
237
264
  const imgs = ctx.global.images ?? {};
238
- const preloadOn = imgs.preload !== false;
265
+ const preloadOn = (NAMED_PRELOAD ? imgs.preload !== false : true);
239
266
  const warm = imgs.warm ?? 0;
240
267
  const preloadRow = (res: ListingResult | null | undefined) => {
241
268
  if (res?.hits.length) preloadImages(firstRowImages(imgs, res.hits.map(ctx.result)));
@@ -323,6 +350,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
323
350
  */
324
351
  private staged: UiState | null = null;
325
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;
326
361
  /** Stops watching the `facetsApply` breakpoints of the open drawer (watchApply()). */
327
362
  private unwatchApply: (() => void) | null = null;
328
363
  /** Renders started: a render in chunks stops when a newer one has started. */
@@ -398,19 +433,30 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
398
433
  };
399
434
 
400
435
  connectedCallback() {
401
- onQuickLanded(route.id, (r) => this.quickIn(r));
436
+ // The quick types landed in a result shown without them (where they do
437
+ // not sit above the products): drawn now, into the render in steps if it
438
+ // is still going on. No hook, no announcement: the same result. A
439
+ // search page's only (core/features.ts): here, not a method, so a build
440
+ // without search/ leaves it out.
441
+ if (SEARCH)
442
+ onQuickLanded(route.id, (r) => {
443
+ if (this.stepping?.hits === r.hits) return void this.stepping.again();
444
+ if (this.last !== r) return; // not shown (yet): a render of it shows them
445
+ mark(`${route.id} quick types after the products`);
446
+ this.redraw();
447
+ });
402
448
  // Not in the constructor: an element the parser creates after our class
403
449
  // is defined (an async include) is constructed before its attributes are set.
404
450
  if (this.ssr == null) {
405
451
  // Every server element is checked against its own state (a later visit's page is another page).
406
- this.ssr = this.hasAttribute('data-lbx-ssr') && adopt();
452
+ this.ssr = SSR ? this.hasAttribute('data-lbx-ssr') && adopt() : false;
407
453
  // Ours from now on, before it drops its marker (the reconciler keeps it).
408
454
  if (this.ssr) claim(route.id, this);
409
455
  // Server markup whose state we cannot use (another URL: a param the page
410
456
  // stripped, another host name): ours replaces it once it has rendered.
411
457
  // A parser-created element (an async bundle ran first) is still getting
412
458
  // its children: kept once they are all there (sync() awaits that).
413
- else if (this.hasAttribute('data-lbx-ssr')) {
459
+ else if (SSR && this.hasAttribute('data-lbx-ssr')) {
414
460
  claim(route.id, this);
415
461
  const keep = () => void (this.server = [...this.childNodes]);
416
462
  if (document.readyState === 'loading') this.cleared = new Promise<void>((r) => document.addEventListener('DOMContentLoaded', () => (keep(), r()), { once: true }));
@@ -427,20 +473,23 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
427
473
  if (!this.hasAttribute('tabindex')) this.tabIndex = -1;
428
474
  // Moved on within this document (an SPA's navigation): its scroll is the host's again.
429
475
  this.unsub = onUrlChange(() => (releaseScroll(), this.urlMoved(), this.resync()));
430
- this.unBack ??= onOwnBack(this.onBack);
476
+ // The drawer's own history entry: only where a template has a drawer (core/features.ts DRAWER).
477
+ if (DRAWER) this.unBack ??= onOwnBack(this.onBack);
431
478
  // Product clicks and add-to-carts on our tiles: the framework's, never a template's (core/tiles.ts).
432
479
  watch(this, { list: `lbx_${route.id}`, global: ctx.global, results: () => this.last?.hits.map(ctx.result) ?? [] });
433
480
  this.addEventListener('click', this.onClick);
434
481
  this.addEventListener('keydown', this.onKey);
435
482
  this.addEventListener('focusout', this.onFocusOut);
436
- document.addEventListener('click', this.onOutside, true);
437
- document.addEventListener('pointerdown', this.onOutside, true);
483
+ if (MENU || DROPDOWN) {
484
+ document.addEventListener('click', this.onOutside, true);
485
+ document.addEventListener('pointerdown', this.onOutside, true);
486
+ }
438
487
  addEventListener('pagehide', this.onPageHide);
439
488
  addEventListener('pageshow', this.onPageShow);
440
489
  addEventListener('scroll', this.onScroll, { passive: true });
441
490
  if (prefetchFilters) this.intent ??= watchIntent(this, (el) => this.onIntent(el));
442
491
  // Put back by a host re-render with its drawer open: still watched.
443
- if (this.hasAttribute('data-drawer-open') && !this.unwatchApply) this.watchApply();
492
+ if (DRAWER && this.hasAttribute('data-drawer-open') && !this.unwatchApply) this.watchApply();
444
493
  // Microtask: runs before paint (no empty frame) but outside the
445
494
  // reconciler's placement pass.
446
495
  queueMicrotask(() => this.resync());
@@ -454,8 +503,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
454
503
  this.removeEventListener('click', this.onClick);
455
504
  this.removeEventListener('keydown', this.onKey);
456
505
  this.removeEventListener('focusout', this.onFocusOut);
457
- document.removeEventListener('click', this.onOutside, true);
458
- document.removeEventListener('pointerdown', this.onOutside, true);
506
+ if (MENU || DROPDOWN) {
507
+ document.removeEventListener('click', this.onOutside, true);
508
+ document.removeEventListener('pointerdown', this.onOutside, true);
509
+ }
459
510
  removeEventListener('pagehide', this.onPageHide);
460
511
  removeEventListener('pageshow', this.onPageShow);
461
512
  removeEventListener('scroll', this.onScroll);
@@ -463,6 +514,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
463
514
  this.ahead?.();
464
515
  this.intent?.stop();
465
516
  this.intent = null;
517
+ clearTimeout(this.heldTimer);
518
+ this.heldTimer = 0;
519
+ this.endCue();
520
+ this.removeAttribute('data-lbx-swap');
466
521
  this.sized?.ro.disconnect();
467
522
  this.sized = null;
468
523
  this.unwatchApply?.();
@@ -487,7 +542,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
487
542
  // A menu in the host's markup (their dropdown classes): [data-lbx-toggle]
488
543
  // opens/closes its [data-lbx-menu] ancestor by a data-lbx-open attribute
489
544
  // (the integration's CSS maps it onto their open state).
490
- const tg = target.closest?.('[data-lbx-toggle]');
545
+ const tg = MENU ? target.closest?.('[data-lbx-toggle]') : null;
491
546
  if (tg && this.contains(tg)) {
492
547
  e.preventDefault();
493
548
  // Ours alone: host code delegating on the same (their) dropdown classes must not toggle it too.
@@ -497,7 +552,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
497
552
  if (menu) setMenu(menu, !menu.hasAttribute('data-lbx-open'), byKeyboard(e));
498
553
  return;
499
554
  }
500
- const d = target.closest?.('[data-lbx-drawer]');
555
+ const d = DRAWER ? target.closest?.('[data-lbx-drawer]') : null;
501
556
  if (!d || !this.contains(d)) {
502
557
  // A link to another page (a product): remembered for Back.
503
558
  const away = restoreOn && leavingLink(e, this);
@@ -547,14 +602,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
547
602
  if (this.href) this.scrolls.set(this.href, this.y);
548
603
  const y = this.scrolls.get(href);
549
604
  this.restoreY = y == null ? null : { href, y };
550
- if (this.hasAttribute('data-drawer-open') && !this.drawerBack) this.setDrawer(false);
605
+ if (DRAWER && this.hasAttribute('data-drawer-open') && !this.drawerBack) this.setDrawer(false);
551
606
  }
552
607
 
553
608
  /**
554
609
  * Where the shopper was in each state of the visit left by a change of
555
610
  * ours or a traversal (canonical href → scrollY), and the one a traversal
556
611
  * goes back to: drawn whole (not in steps) and placed there. Bug (a
557
- * 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
558
613
  * drew the list in steps, the browser's restored scroll was clamped to
559
614
  * the first step's short page, and Chromium's scroll anchoring kept the
560
615
  * footer in view as the tiles went in above it.
@@ -567,7 +622,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
567
622
 
568
623
  /** Changes go to the drawer's held state instead of the URL: it is open, where it stages. */
569
624
  private staging() {
570
- return this.stagedMode && this.hasAttribute('data-drawer-open');
625
+ return DRAWER ? this.stagedMode && this.hasAttribute('data-drawer-open') : false;
571
626
  }
572
627
 
573
628
  /**
@@ -577,24 +632,52 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
577
632
  * middle, the count and the first tiles above the viewport.
578
633
  */
579
634
  private applyStaged(from?: HTMLElement) {
635
+ if (!DRAWER) return;
580
636
  const next = this.staged;
581
637
  this.staged = null;
582
- 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);
583
652
  }
584
653
 
585
654
  /** The state an Apply asked for: its render brings the listing's top into view again (late layout, a smooth scroll cut short). */
586
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;
587
668
  /** The top of the listing in view when it is above the viewport (smooth, unless reduced motion is asked for). */
588
669
  private toListTop() {
589
670
  if (!this.isConnected || this.getBoundingClientRect().top >= 0) return;
590
- const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
591
- this.scrollIntoView({ block: 'start', behavior: (reduce ? 'instant' : 'smooth') as ScrollBehavior });
671
+ this.scrollIntoView({ block: 'start', behavior: (reduced() ? 'instant' : 'smooth') as ScrollBehavior });
592
672
  }
593
673
 
594
674
  /** The drawer's held changes dropped: it shows the applied state again. */
595
675
  private discardStaged() {
596
676
  if (!this.staged) return;
597
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;
598
681
  this.redraw();
599
682
  }
600
683
 
@@ -610,6 +693,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
610
693
  * takes the entry back first, then runs `then`.
611
694
  */
612
695
  private setDrawer(open: boolean, from?: HTMLElement, then?: () => void) {
696
+ if (!DRAWER) return;
613
697
  const was = this.hasAttribute('data-drawer-open');
614
698
  this.toggleAttribute('data-drawer-open', open);
615
699
  for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(open));
@@ -618,6 +702,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
618
702
  if (!open) this.discardStaged();
619
703
  if (open && !was) {
620
704
  this.stagedMode = atWidth(cfg.facetsApply, 'instant', viewport) === 'staged';
705
+ if (this.stagedMode) askApplied();
621
706
  if (this.stagedMode && !this.drawerEntry)
622
707
  try {
623
708
  history.pushState(null, '', location.href);
@@ -667,14 +752,15 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
667
752
  /**
668
753
  * The open drawer closes when the viewport crosses into another
669
754
  * `facetsApply` mode (a phone rotated, a window widened): the drawer was a
670
- * phone's, and what it held was never confirmed. Regression (a bicycle
671
- * 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
672
757
  * the drawer at 820 px stayed ticked in the 992 px sidebar with the
673
758
  * results unfiltered and no apply button there, and rotating back showed
674
759
  * the full-screen drawer again (`data-drawer-open` had stayed). Dropping
675
760
  * is safer than applying: the shopper never pressed Apply.
676
761
  */
677
762
  private watchApply() {
763
+ if (!DRAWER) return;
678
764
  const v = cfg.facetsApply;
679
765
  if (!v || typeof v !== 'object' || typeof matchMedia !== 'function') return;
680
766
  const mode = atWidth(v, 'instant', viewport);
@@ -693,14 +779,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
693
779
  */
694
780
  private onKey = (e: KeyboardEvent) => {
695
781
  const target = e.target as Element;
696
- const menu = target.closest?.('[data-lbx-menu][data-lbx-open]');
782
+ const menu = MENU ? target.closest?.('[data-lbx-menu][data-lbx-open]') : null;
697
783
  if (e.key === 'Escape') {
698
784
  if (menu && this.contains(menu)) {
699
785
  e.preventDefault();
700
786
  e.stopPropagation(); // ours, not a host modal's
701
787
  setMenu(menu, false);
702
788
  triggerOf(menu)?.focus();
703
- } else if (this.hasAttribute('data-drawer-open')) {
789
+ } else if (DRAWER && this.hasAttribute('data-drawer-open')) {
704
790
  e.preventDefault();
705
791
  e.stopPropagation();
706
792
  this.setDrawer(false);
@@ -716,7 +802,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
716
802
  items[next].focus();
717
803
  return;
718
804
  }
719
- if (e.key === 'Tab' && this.hasAttribute('data-drawer-open')) {
805
+ if (DRAWER && e.key === 'Tab' && this.hasAttribute('data-drawer-open')) {
720
806
  const panel = this.querySelector('[data-lbx-drawer-panel]');
721
807
  if (panel) trapTab(e, panel);
722
808
  return;
@@ -726,7 +812,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
726
812
 
727
813
  /** Focus leaving an open menu (Tab past it) closes it. */
728
814
  private onFocusOut = (e: FocusEvent) => {
729
- const menu = (e.target as Element).closest?.('[data-lbx-menu][data-lbx-open]');
815
+ const menu = MENU ? (e.target as Element).closest?.('[data-lbx-menu][data-lbx-open]') : null;
730
816
  if (menu && !(e.relatedTarget && menu.contains(e.relatedTarget as Node))) setMenu(menu, false);
731
817
  };
732
818
 
@@ -737,12 +823,12 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
737
823
  root.setAttribute('data-lbx-a11y', '');
738
824
  const live = liveRegion();
739
825
  root.append(live.el);
740
- if (ctx.global.contrastToggle) root.append(contrastToggle(t('contrast')));
826
+ toggle?.then((m) => root.append(m.contrastToggle(t('contrast'))), (e) => reportError(`${route.id} contrast toggle`, e));
741
827
  this.a11y = { root, live };
742
828
  }
743
829
  if (this.firstChild !== this.a11y.root) this.prepend(this.a11y.root);
744
- for (const m of this.querySelectorAll('[data-lbx-menu]')) setMenu(m, m.hasAttribute('data-lbx-open'));
745
- for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(this.hasAttribute('data-drawer-open')));
830
+ if (MENU) for (const m of this.querySelectorAll('[data-lbx-menu]')) setMenu(m, m.hasAttribute('data-lbx-open'));
831
+ if (DRAWER) for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(this.hasAttribute('data-drawer-open')));
746
832
  const was = this.focusWas;
747
833
  this.focusWas = null;
748
834
  const tile = this.moreByKey ? this.querySelector('[data-lbx-list]')?.children[this.shownBefore] : null;
@@ -770,8 +856,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
770
856
  * not clickable (a heading, the page's background), only the pointer events.
771
857
  */
772
858
  private onOutside = (e: Event) => {
773
- for (const d of this.querySelectorAll('details[data-lbx-dropdown][open]')) if (!d.contains(e.target as Node)) d.removeAttribute('open');
774
- for (const d of this.querySelectorAll('[data-lbx-menu][data-lbx-open]')) if (!d.contains(e.target as Node)) setMenu(d, false);
859
+ if (DROPDOWN) for (const d of this.querySelectorAll('details[data-lbx-dropdown][open]')) if (!d.contains(e.target as Node)) d.removeAttribute('open');
860
+ if (MENU) for (const d of this.querySelectorAll('[data-lbx-menu][data-lbx-open]')) if (!d.contains(e.target as Node)) setMenu(d, false);
775
861
  };
776
862
 
777
863
  /**
@@ -809,12 +895,18 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
809
895
  return w;
810
896
  }
811
897
 
812
- /** 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
+ */
813
903
  go(patch: Partial<UiState>) {
814
904
  if (!this.current) return;
815
905
  this.intent?.cancel(); // a dwell still pending: this click's own state is what is fetched now
816
906
  if (this.staging()) {
817
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);
818
910
  return this.redraw();
819
911
  }
820
912
  this.navigate(nextState(this.wanted ?? stateOf(this.current), this.shown ?? stateOf(this.current), patch));
@@ -930,7 +1022,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
930
1022
  // A host re-render may destroy and re-create us for the same URL: render
931
1023
  // synchronously from cache so the replacement never shows an empty frame.
932
1024
  const hit = cache.get(key);
933
- if (hit && this.ssr && !this.hydrateWith) {
1025
+ if (SSR && hit && this.ssr && !this.hydrateWith) {
934
1026
  // Hydrate complete markup only: a parser-created element connects before its children are parsed.
935
1027
  if (document.readyState === 'loading') await new Promise((r) => document.addEventListener('DOMContentLoaded', r, { once: true }));
936
1028
  try {
@@ -962,6 +1054,9 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
962
1054
  const ek = !pf && prefetchFilters ? earlyKey(q) : null;
963
1055
  const pre = ek ? early.take(ek) : undefined;
964
1056
  early.abortInFlight();
1057
+ // Server markup still being parsed (a streamed include): decided out
1058
+ // here, not in the try below (Rollup folds no flag in a try).
1059
+ const serverParsing = SSR ? () => !!this.ssr && document.readyState === 'loading' : () => false;
965
1060
  try {
966
1061
  let res: ListingResult;
967
1062
  let shownEarly = false;
@@ -994,7 +1089,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
994
1089
  this.message = null;
995
1090
  // Another state of the visit shown (a filter, a sort): its sidebar
996
1091
  // stays until the new facets come, as from the intent prefetch.
997
- // 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
998
1093
  // facets, the sidebar collapsed to one group for ~250 ms and
999
1094
  // Chromium's scroll anchoring threw the page to its top.
1000
1095
  const last = this.last;
@@ -1013,7 +1108,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1013
1108
  }
1014
1109
  if (shownEarly) this.quiet = true; // said with the products
1015
1110
  // Server markup still being parsed (a streamed include): render once it is complete.
1016
- if (this.ssr && document.readyState === 'loading') {
1111
+ if (serverParsing()) {
1017
1112
  await new Promise((r) => document.addEventListener('DOMContentLoaded', r, { once: true }));
1018
1113
  if (this.href !== href) return;
1019
1114
  }
@@ -1066,11 +1161,12 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1066
1161
  }
1067
1162
  this.images();
1068
1163
  if (tk.state === 'ours') this.watch();
1164
+ if (DRAWER) this.swapIn(); // an Apply's new tiles, if these are they
1069
1165
  }
1070
1166
 
1071
1167
  /** Images a render put in that are there already (cached, persisted tiles) lose their placeholder now: no flash, no fade. */
1072
1168
  private images() {
1073
- settleImages(this, imgs.fade !== false);
1169
+ settleImages(this, (NAMED_FADE ? imgs.fade !== false : true));
1074
1170
  }
1075
1171
 
1076
1172
  /** Render anew, dropping the state lit keeps on the container (or it reuses it and paints nothing). */
@@ -1155,11 +1251,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1155
1251
  this.ahead = null;
1156
1252
  if (skipOut) this.sizeTiles();
1157
1253
  // Not while prerendered: a next page of a page nobody may open is a request for nothing.
1158
- if (cfg.prefetchNextPage !== false && hasMore(res)) {
1254
+ if ((NAMED_PREFETCH_NEXT_PAGE ? cfg.prefetchNextPage !== false : true) && hasMore(res)) {
1159
1255
  const href = this.href;
1160
1256
  whenShown(() => this.href === href && !this.ahead && (this.ahead = nearEnd(this, () => this.prefetchNext(q, res))));
1161
1257
  }
1162
- if (res.hits[0] && ctx.global.preconnectImages !== false) preconnectImages(ctx.result(res.hits[0]).c.image);
1258
+ if (res.hits[0] && (NAMED_PRECONNECT_IMAGES ? ctx.global.preconnectImages !== false : true)) preconnectImages(ctx.result(res.hits[0]).c.image);
1163
1259
  }
1164
1260
 
1165
1261
  /**
@@ -1227,25 +1323,89 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1227
1323
  private onIntent(el: Element) {
1228
1324
  const cur = this.current;
1229
1325
  // In a drawer that stages, a control leads nowhere until Apply: nothing to fetch ahead.
1230
- if (this.staging()) return;
1231
- if (!cur || !this.last || this.lastQ !== cur || this.inFlight || this.wanted || tk.state !== 'ours' || thrifty()) return;
1326
+ if (this.staging() || !this.settled(cur)) return;
1232
1327
  // "Show more" (`data-lbx-more`): its next page, from the cursor, as nearing the end does.
1233
- 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!);
1234
1329
  const raw = targetOf(el);
1235
1330
  if (!raw) return;
1236
1331
  const url = new URL(raw, location.href);
1237
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!;
1238
1394
  const from = this.href;
1239
- if (canon(url.href) === from) return;
1240
1395
  const then = (q: Query | null) => {
1241
- 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;
1242
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;
1243
1401
  // Its first images too (`_global.images.warm`, low priority): the click then shows them at once.
1244
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));
1245
- 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}`);
1246
1404
  };
1247
- const r = route.resolve(url);
1248
- 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);
1249
1409
  else then(r);
1250
1410
  }
1251
1411
 
@@ -1266,14 +1426,63 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1266
1426
  this.waited = null;
1267
1427
  const was = this.last;
1268
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);
1269
1441
  this.guarded(() => this.draw(q, res, url));
1270
- 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);
1271
1480
  }
1272
1481
 
1273
1482
  /** The list fading in from FADE_FROM (Web Animations: no class to restart, no layout read); not under reduced motion. */
1274
1483
  private fadeIn() {
1275
1484
  const list = this.querySelector('[data-lbx-list]');
1276
- if (!list || typeof list.animate !== 'function' || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
1485
+ if (!list || typeof list.animate !== 'function' || reduced()) return;
1277
1486
  this.fade?.cancel();
1278
1487
  this.fade = list.animate([{ opacity: FADE_FROM }, { opacity: 1 }], { duration: FADE_MS, easing: 'cubic-bezier(.2,0,0,1)' });
1279
1488
  }
@@ -1285,6 +1494,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1285
1494
  } catch (e) {
1286
1495
  reportError(`${route.id} render`, e);
1287
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');
1288
1499
  // The server's markup is still the page's: back it goes (the takeover keeps a listing that is there).
1289
1500
  if (this.server) {
1290
1501
  delete (this as any)._$litPart$;
@@ -1349,18 +1560,6 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1349
1560
  }, 600);
1350
1561
  }
1351
1562
 
1352
- /**
1353
- * The quick types landed in a result shown without them (where they do
1354
- * not sit above the products): drawn now, into the render in steps if it
1355
- * is still going on. No hook, no announcement: the same result.
1356
- */
1357
- private quickIn(r: ListingResult) {
1358
- if (this.stepping?.hits === r.hits) return void this.stepping.again();
1359
- if (this.last !== r) return; // not shown (yet): a render of it shows them
1360
- mark(`${route.id} quick types after the products`);
1361
- this.redraw();
1362
- }
1363
-
1364
1563
  /** The last render's data again with the current view state: no fetch, no announcement, focus where it is. */
1365
1564
  private redraw() {
1366
1565
  const q = this.lastQ;
@@ -1388,8 +1587,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1388
1587
  if (!link || m.button > 0 || m.metaKey || m.ctrlKey || m.shiftKey || m.altKey) return; // new tab etc.: the browser's
1389
1588
  e.preventDefault();
1390
1589
  const at = e.currentTarget as Element | null;
1391
- at?.closest('details[data-lbx-dropdown]')?.removeAttribute('open');
1392
- const menu = at?.closest('[data-lbx-menu]');
1590
+ if (DROPDOWN) at?.closest('details[data-lbx-dropdown]')?.removeAttribute('open');
1591
+ const menu = MENU ? at?.closest('[data-lbx-menu]') : null;
1393
1592
  if (menu) {
1394
1593
  setMenu(menu, false);
1395
1594
  // Its item had focus: back to the menu's button, not lost with the closed menu.
@@ -1440,7 +1639,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1440
1639
  const before = this.lastQ;
1441
1640
  const grew = !!before && !!this.last && keyOf(q) === keyOf(before) && res.page > this.last.page && res.guid === this.last.guid;
1442
1641
  this.shownBefore = grew ? (this.last?.hits.length ?? 0) : 0;
1443
- 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));
1444
1643
  // A quiet render keeps what is still to be said: the facets' render (said
1445
1644
  // with the products) cut short their redraw in steps, before it spoke,
1446
1645
  // and a filter was never announced (found by Luma's accessibility tests on a phone, 2026-10-11).
@@ -1468,11 +1667,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1468
1667
  this.drawn = data;
1469
1668
  // Server markup that is not hydrated (no cached result for it: its facets
1470
1669
  // were missing) is replaced, not rendered after.
1471
- if (this.ssr && !this.hydrateWith) {
1670
+ if (SSR && this.ssr && !this.hydrateWith) {
1472
1671
  this.ssr = false;
1473
1672
  this.server ??= [...this.childNodes];
1474
1673
  }
1475
- if (this.ssr && this.hydrateWith) {
1674
+ if (SSR && this.ssr && this.hydrateWith) {
1476
1675
  this.ssr = false;
1477
1676
  const markup = [...this.childNodes];
1478
1677
  try {
@@ -1605,6 +1804,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1605
1804
  if (!res.pending) this.setBusy(false); // the products alone: busy until their facets
1606
1805
  return this.finish(q, res, url, total, fonts, shown);
1607
1806
  }
1807
+ // The next step after a frame (afterFrame: a task soon where frames are stalled; each step's
1808
+ // wait was 500 ms then, five steps 2.5 s of nothing shown, 2026-10-19).
1608
1809
  afterFrame(() => this.renders === n && this.href === href && this.guarded(() => step(false)));
1609
1810
  };
1610
1811
  // The scope and the first step in one task were 17-18 ms on a production storefront at 4x: apart when they would not fit.
@@ -1796,7 +1997,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1796
1997
  // yet: the hook (titles, and often the site's analytics) runs when it
1797
1998
  // is shown, never for a prerender the shopper does not open.
1798
1999
  // The products alone (`pending`): the hook once the facets are there too, once a visit.
1799
- if (!res.pending)
2000
+ if (ON_RENDER && !res.pending)
1800
2001
  whenShown(() => {
1801
2002
  try {
1802
2003
  cfg.onRender?.(info);