@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
package/build/vite.mjs CHANGED
@@ -17,17 +17,21 @@ const FRAMEWORK = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
17
17
  // One lazy chunk per widget plus what they share, so a page loads few files:
18
18
  // lbx-core lit, the template registry, a11y, core/support (i18n,
19
19
  // images, speculation rules) and the integration's shared
20
- // _global templates (the product tile): every widget's
20
+ // _global templates (the product tile): every widget's;
21
+ // a core module one chunk alone reaches goes with it
22
+ // (soleUser: the listing's contrast toggle, fold, …)
21
23
  // lbx-listing search results and category pages (one listing: plp is
22
24
  // a route of it) with the integration's search/ templates
23
25
  // lbx-autocomplete lbx-recommender each widget with its templates
24
- // lbx-plp lbx-place lbx-search lbx-speculate lbx-token the plp widget and its templates,
25
- // placement and recommender triggers, the search page's own,
26
- // search while typing, minting the token (chunkOf)
26
+ // lbx-plp lbx-place lbx-search lbx-search-in-place lbx-speculate lbx-token
27
+ // the plp widget and its templates, placement and
28
+ // recommender triggers, the search page's own, a new query
29
+ // in place, search while typing (and Enter straight to the
30
+ // query's page), minting the token (chunkOf)
27
31
  // lbx-nav what search while typing (lbx-speculate) needs, a
28
32
  // search page's first request (widgets/search/first.ts)
29
33
  // and what they share with the listing (the route, query
30
- // building, stash, in-place query, URL writing): typing on
34
+ // building, stash, a form's URL, URL writing): typing on
31
35
  // any page and the listing need it, a page with only a
32
36
  // recommender does not
33
37
  // lbx-analytics pageviews, our tiles' clicks and add-to-carts, the
@@ -82,17 +86,21 @@ export const chunkOf = (id) => {
82
86
  // Hydration of server-rendered listings: only those pages load it.
83
87
  // lit-html's private SSR support is the hydrator's alone (not every widget page's lbx-core).
84
88
  if (/\/node_modules\/(@lit-labs\/ssr-client\/|lit-html\/private-ssr-support\.js)/.test(id)) return 'lbx-hydrate';
85
- // Helpers one widget alone uses go with it, not into lbx-core (every widget's).
86
- if (/\/src\/core\/color\.ts$/.test(id)) return 'lbx-autocomplete';
87
- if (/\/src\/core\/a11y-keys\.ts$/.test(id)) return 'lbx-listing';
88
89
  // Placing our elements (core/reconciler.ts) and the recommenders' triggers:
89
90
  // pages with a listing or a recommender, in parallel with their other chunks.
90
91
  if (/\/src\/core\/(reconciler|recommenders)\.ts$/.test(id)) return 'lbx-place';
91
- // The search page's own (its start, a new query in place): not in lbx-listing,
92
- // which a category page loads too (2.1 kB gzip it never ran).
93
- if (/\/src\/widgets\/search\/(index|in-place)\.ts$/.test(id)) return 'lbx-search';
94
- // Search while typing (main.ts loads it once the shopper types): not in lbx-nav, which a listing page loads at once.
95
- if (/\/src\/widgets\/search\/speculate\.ts$/.test(id)) return 'lbx-speculate';
92
+ // The listing's high-contrast toggle: only a site that turns it on loads it (widgets/listing/element.ts).
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';
96
+ // The search page's own: not in lbx-listing, which a category page loads too
97
+ // (2.1 kB gzip it never ran).
98
+ if (/\/src\/widgets\/search\/index\.ts$/.test(id)) return 'lbx-search';
99
+ // A new query in place, and taking a submit of the site's search form (submit.ts, direct.ts's too: a
100
+ // category page never takes one): asked for as lbx-search runs, not waited for (widgets/search/index.ts).
101
+ if (/\/src\/widgets\/search\/(in-place|submit)\.ts$/.test(id)) return 'lbx-search-in-place';
102
+ // Search while typing, and Enter straight to the query's page (main.ts loads both once the shopper types): not in lbx-nav, which a listing page loads at once.
103
+ if (/\/src\/widgets\/search\/(speculate|direct)\.ts$/.test(id)) return 'lbx-speculate';
96
104
  // Analytics (src/analytics): every page once loaded (main.ts), widgets import it for their tiles' clicks.
97
105
  if (/\/src\/analytics\//.test(id)) return 'lbx-analytics';
98
106
  // Minting the browser token: a session's first page loads it at boot (main.ts), lbx-core imports it.
@@ -108,7 +116,31 @@ export const chunkOf = (id) => {
108
116
  // The entry's own modules are named into the entry chunk (rollupOptions.input's
109
117
  // key) rather than left out: Rollup would pull a module a manual chunk depends
110
118
  // on into that chunk, entry or not.
111
- const manualChunks = (id, { getModuleInfo, getModuleIds }) => (entryGraph(getModuleInfo, getModuleIds).has(id) ? 'integration' : chunkOf(id));
119
+ //
120
+ // A module bound for lbx-core (every widget's) that one other chunk alone
121
+ // reaches (directly, or through other lbx-core modules) goes with that chunk:
122
+ // a listing's helper is not on an autocomplete page (core/fold.ts, a11y-keys,
123
+ // color, the contrast toggle).
124
+ const soleUser = (id, getModuleInfo, entry, seen = new Set([id])) => {
125
+ const users = new Set();
126
+ for (const by of [...(getModuleInfo(id)?.importers ?? []), ...(getModuleInfo(id)?.dynamicImporters ?? [])]) {
127
+ if (seen.has(by)) continue;
128
+ seen.add(by);
129
+ const c = entry.has(by) ? 'integration' : chunkOf(by);
130
+ if (c === 'lbx-core') for (const u of soleUser(by, getModuleInfo, entry, seen)) users.add(u);
131
+ else users.add(c);
132
+ }
133
+ return users;
134
+ };
135
+ const manualChunks = (id, { getModuleInfo, getModuleIds }) => {
136
+ const entry = entryGraph(getModuleInfo, getModuleIds);
137
+ if (entry.has(id)) return 'integration';
138
+ const c = chunkOf(id);
139
+ if (c !== 'lbx-core') return c;
140
+ const users = soleUser(id, getModuleInfo, entry);
141
+ const [only] = users;
142
+ return users.size === 1 && only && only !== 'integration' ? only : c;
143
+ };
112
144
 
113
145
  /**
114
146
  * Template scope members Luma no longer has, and what replaces each. A
@@ -356,7 +388,7 @@ export const dedentTemplates = (ts, code, file) => {
356
388
  for (const [a, b, s] of edits.sort((x, y) => y[0] - x[0])) out = out.slice(0, a) + s + out.slice(b);
357
389
  return out;
358
390
  };
359
- const templateText = (root) => {
391
+ export const templateText = (root) => {
360
392
  let ts;
361
393
  // Module ids are real paths: an integration/ reached through a symlink too.
362
394
  const dir = resolve(root, 'integration');
@@ -377,6 +409,50 @@ const templateText = (root) => {
377
409
  };
378
410
  };
379
411
 
412
+ /**
413
+ * lit-html's directives (`@/lit`) as free of side effects: each module only
414
+ * defines its directive, and one no template uses is left out. Without this
415
+ * one assigning its class's static fields (unsafeHTML: `directiveName`,
416
+ * `resultType`) stays in lbx-core though no template uses it (0.6 kB, found
417
+ * 2026-10-17); lit-html's package.json declares no `sideEffects`.
418
+ */
419
+ const pureDirectives = () => ({
420
+ name: 'lbx-pure-directives',
421
+ apply: 'build',
422
+ transform(code, id) {
423
+ if (!/\/node_modules\/lit-html\/directives\/[\w-]+\.js$/.test(id.split('?')[0])) return null;
424
+ return { code, map: null, moduleSideEffects: false };
425
+ },
426
+ });
427
+
428
+ /**
429
+ * Chunks nothing loads, left out: a widget the integration has no folder for
430
+ * (src/core/features.ts) leaves its modules tree-shaken to nothing, and the
431
+ * chunk they are named into (manualChunks) would still be emitted, empty
432
+ * but for imports. A chunk is loaded when another one imports it, statically
433
+ * or dynamically, or names its file (a preload list); the entry always.
434
+ */
435
+ const unreachable = () => ({
436
+ name: 'lbx-unreachable-chunks',
437
+ apply: 'build',
438
+ enforce: 'post',
439
+ generateBundle(_, bundle) {
440
+ for (let pruned = true; pruned; ) {
441
+ pruned = false;
442
+ const chunks = Object.values(bundle).filter((c) => c.type === 'chunk');
443
+ for (const c of chunks) {
444
+ if (c.isEntry) continue;
445
+ const base = c.fileName.split('/').pop();
446
+ const used = chunks.some((o) => o !== c && (o.imports.includes(c.fileName) || o.dynamicImports.includes(c.fileName) || o.code.includes(base)));
447
+ if (!used) {
448
+ delete bundle[c.fileName];
449
+ pruned = true;
450
+ }
451
+ }
452
+ }
453
+ },
454
+ });
455
+
380
456
  /**
381
457
  * A second minifier after Vite's esbuild pass: terser (two compress passes,
382
458
  * mangled top-level names in each module) finds what esbuild leaves (~3 %
@@ -419,6 +495,14 @@ const terse = () => ({
419
495
  * handles CSS dependencies, a <meta> nonce, a `preload` fallback and a
420
496
  * vite:preloadError event, none of which a Luma build uses: ~1 kB gzip of the
421
497
  * entry. A failed preload is the import's failure (it fetches the same file).
498
+ *
499
+ * `deps` starts with the imported chunk itself, even when it is the only one
500
+ * (resolveDependencies below; Vite's is [] then), so that it is marked: no
501
+ * later import's link preloads a chunk already imported or in flight. WebKit
502
+ * fetches such a link's chunk again and never uses the response (lbx-token
503
+ * and lbx-place, imported by the entry, then a listing's dependencies: twice
504
+ * per document, 2026-10-15). Its own link stays: Firefox holds the page's
505
+ * load event for a chunk an import() fetches, not for one a link does.
422
506
  */
423
507
  export const PRELOAD_HELPER = `const seen = {};
424
508
  export const __vitePreload = (load, deps, base) => {
@@ -444,10 +528,159 @@ const preloadHelper = () => ({
444
528
  },
445
529
  });
446
530
 
531
+ /**
532
+ * The widgets the integration has (src/core/features.ts): a widget is its
533
+ * folder of integration/, analytics an _analytics.ts in any. Defined for a
534
+ * build from the folders (the entry leaves out a missing widget's triggers);
535
+ * the dev server sees folders come and go: everything there.
536
+ */
537
+ /** Options on by default whose reads fold where no file of the integration names them (src/core/features.ts NAMED_*). */
538
+ export const ON_BY_DEFAULT = ['preconnectImages', 'prefetchFilters', 'preload', 'fade', 'restoreOnBack', 'prefetchNextPage', 'inPlaceQuery', 'prefetchWhileTyping', 'earlyHold', 'speculation'];
539
+
540
+ export const featureDefines = (integrationDir) => {
541
+ const folders = existsSync(integrationDir) ? readdirSync(integrationDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) : null;
542
+ // A recommender folder is `recommender <name>` (src/core/integration.ts slot: checked there in development only).
543
+ for (const f of folders ?? []) if (f.trim() === 'recommender') throw new Error(`integration/${f}: a recommender folder needs a name, e.g. "recommender home"`);
544
+ const has = (f) => String(!folders || f(folders));
545
+ // The integration repository's code and markup (integration/ and anything
546
+ // it imports from beside it; not node_modules, builds or hidden folders),
547
+ // read once: a feature only its own files can ask for (a template's
548
+ // attribute, a hook _init.ts sets).
549
+ let text;
550
+ const read = (dir) =>
551
+ readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
552
+ e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'dist' ? [] : e.isDirectory() ? read(resolve(dir, e.name)) : /\.(ts|tsx|js|jsx|mts|mjs|cts|cjs|html)$/.test(e.name) ? [readFileSync(resolve(dir, e.name), 'utf8')] : [],
553
+ );
554
+ const inFiles = (...needles) => ((text ??= read(dirname(integrationDir)).join('\n')), needles.some((n) => text.includes(n)));
555
+ return {
556
+ __LBX_AUTOCOMPLETE__: has((d) => d.includes('autocomplete')),
557
+ __LBX_SEARCH__: has((d) => d.includes('search')),
558
+ __LBX_PLP__: has((d) => d.includes('plp')),
559
+ __LBX_RECOMMENDER__: has((d) => d.some((f) => f.split(/\s+/)[0] === 'recommender')),
560
+ __LBX_ANALYTICS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_analytics.ts')))),
561
+ __LBX_WIDGET_INITS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_widget.ts')))),
562
+ __LBX_HOST_STYLES__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, 'style-host.css')))),
563
+ __LBX_TRANSLATIONS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_translations.ts')))),
564
+ __LBX_INITS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_init.ts')))),
565
+ // Only an integration's templates make a cart button, a filter drawer or a menu (Luma's own have none).
566
+ __LBX_CART__: has(() => inFiles('data-lbx-cart')),
567
+ __LBX_DRAWER__: has(() => inFiles('data-lbx-drawer')),
568
+ __LBX_MENU__: has(() => inFiles('data-lbx-menu')),
569
+ __LBX_DROPDOWN__: has(() => inFiles('data-lbx-dropdown')),
570
+ // Hooks only the integration's code sets (_init.ts, _widget.ts: functions, never in _config.ts).
571
+ __LBX_AC_HOOKS__: has(() => inFiles('onOpen', 'onClose', 'onSelect', 'onReady')),
572
+ __LBX_ON_RENDER__: has(() => inFiles('onRender')),
573
+ __LBX_READ_QUERY__: has(() => inFiles('readQuery')),
574
+ __LBX_FACETS_FOR__: has(() => inFiles('facetsFor')),
575
+ __LBX_QUERY_URL__: has(() => inFiles('queryUrl')),
576
+ // Options off by default: a key no file names keeps its default.
577
+ __LBX_EMPTY_VALUES__: has(() => inFiles('emptyValues')),
578
+ __LBX_PRERENDER_TYPING__: has(() => inFiles('prerenderWhileTyping', 'pageWhileTyping')),
579
+ __LBX_CONTENT_VISIBILITY__: has(() => inFiles('contentVisibility')),
580
+ // Options on by default: one no file names is on.
581
+ ...Object.fromEntries(ON_BY_DEFAULT.map((k) => [`__LBX_NAMED_${k.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}__`, has(() => inFiles(k))])),
582
+ };
583
+ };
584
+ /** Luma's dictionaries' namespaces (src/locales/*.json) a build without the widget leaves out: plp reads search's too. */
585
+ export const LOCALE_NAMESPACES = { autocomplete: ['AUTOCOMPLETE'], search: ['SEARCH', 'PLP'], plp: ['PLP'], recommender: ['RECOMMENDER'] };
586
+ export const pruneDictionary = (json, defines) => {
587
+ const dict = JSON.parse(json);
588
+ for (const [ns, flags] of Object.entries(LOCALE_NAMESPACES)) if (flags.every((f) => defines[`__LBX_${f}__`] === 'false')) delete dict[ns];
589
+ return JSON.stringify(dict);
590
+ };
591
+ const features = (root) => {
592
+ let defines = null;
593
+ return {
594
+ name: 'lbx-features',
595
+ enforce: 'pre',
596
+ config(_, { command }) {
597
+ if (command !== 'build') return undefined;
598
+ defines = featureDefines(resolve(root, 'integration'));
599
+ return { define: defines };
600
+ },
601
+ // Before Vite's JSON plugin: the dictionary as JSON.
602
+ transform(code, id) {
603
+ if (!defines || !id.startsWith(FRAMEWORK) || !/\/locales\/[\w-]+\.json$/.test(id.split('?')[0])) return null;
604
+ return { code: pruneDictionary(code, defines), map: null };
605
+ },
606
+ };
607
+ };
608
+
609
+ /**
610
+ * The locales the site uses (`locales` of clientConfig: a list), so the
611
+ * entry's map of Luma's dictionaries (src/core/locales.ts, a glob) has those
612
+ * and English only: ~20 B gzip on every page per locale left out. A locale not
613
+ * listed reads English (loadDictionary's fallback). Not declared: every one.
614
+ */
615
+ export const LOCALES = readdirSync(resolve(FRAMEWORK, 'locales')).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5));
616
+ export const localeGlob = (locales) => {
617
+ if (locales === undefined) return null;
618
+ if (!Array.isArray(locales) || !locales.length) throw new Error(`clientConfig: locales is a list of locales (${LOCALES.join(', ')}), not ${JSON.stringify(locales)}`);
619
+ for (const l of locales) if (!LOCALES.includes(l)) throw new Error(`clientConfig: Luma has no dictionary for locale ${JSON.stringify(l)}: one of ${LOCALES.join(', ')}`);
620
+ return [...new Set(['en', ...locales])].sort().map((l) => `../locales/${l}.json`);
621
+ };
622
+ const onlyLocales = (locales) => {
623
+ const glob = localeGlob(locales);
624
+ return {
625
+ name: 'lbx-only-locales',
626
+ enforce: 'pre',
627
+ transform(code, id) {
628
+ if (!glob || id.split('?')[0] !== resolve(FRAMEWORK, 'core/locales.ts')) return null;
629
+ const from = "import.meta.glob<Dictionary>('../locales/*.json',";
630
+ if (!code.includes(from)) return this.error('src/core/locales.ts: its dictionaries glob changed; update onlyLocales');
631
+ return { code: code.replace(from, `import.meta.glob<Dictionary>(${JSON.stringify(glob)},`), map: null };
632
+ },
633
+ };
634
+ };
635
+
636
+ /**
637
+ * Luma's support for the site's framework, in parts (src/core/frameworks.ts):
638
+ * react React roots and islands (hydration, Suspense)
639
+ * documentReact React hydrating the whole document (the entry's checks)
640
+ * next Next.js pages router (its router, its hydration measure)
641
+ * vue Vue 2 / 3 and Nuxt (mount, router)
642
+ * angular Angular hydration
643
+ * sveltekit SvelteKit (its root, its history entries)
644
+ * astro Astro (islands, ClientRouter history entries)
645
+ * turbo Turbo Drive / Turbolinks (snapshots, history, prefetch)
646
+ * A build has all of them unless the integration declares its framework(s)
647
+ * (`framework` of clientConfig: a name or a list): then only theirs, the rest
648
+ * left out of every chunk. Measured on the test fixture 2026-10-17: see
649
+ * test/browser/specs/budgets.spec.ts.
650
+ */
651
+ export const SUPPORTS = ['react', 'documentReact', 'next', 'vue', 'angular', 'sveltekit', 'astro', 'turbo'];
652
+ export const FRAMEWORKS = {
653
+ next: ['react', 'documentReact', 'next'],
654
+ remix: ['react', 'documentReact'],
655
+ 'react-router': ['react', 'documentReact'],
656
+ react: ['react'],
657
+ vue: ['vue'],
658
+ nuxt: ['vue'],
659
+ angular: ['angular'],
660
+ svelte: [],
661
+ sveltekit: ['sveltekit'],
662
+ astro: ['astro'],
663
+ turbo: ['turbo'],
664
+ none: [],
665
+ };
666
+
667
+ /** The defines of a declaration (undefined: everything). */
668
+ export const frameworkDefines = (framework) => {
669
+ const names = framework === undefined ? null : [framework].flat();
670
+ for (const f of names ?? [])
671
+ if (typeof f !== 'string' || !Object.hasOwn(FRAMEWORKS, f))
672
+ throw new Error(`clientConfig: unknown framework ${JSON.stringify(f)}: one of ${Object.keys(FRAMEWORKS).join(', ')}, a list of them, or leave it out`);
673
+ if (names && !names.length) throw new Error('clientConfig: framework is an empty list: name the site\'s framework(s), none, or leave it out');
674
+ const has = new Set(names ? names.flatMap((f) => FRAMEWORKS[f]) : SUPPORTS);
675
+ return Object.fromEntries(SUPPORTS.map((k) => [`__LBX_${k.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}__`, String(has.has(k))]));
676
+ };
677
+
447
678
  /**
448
679
  * `overrides` are merged into the config; `plugins` are appended and `server`
449
680
  * is merged, so a client repo can add e.g. HTTPS for the loader's development
450
- * mode without losing ours.
681
+ * mode without losing ours. `framework` (FRAMEWORKS), `serverRendering`
682
+ * (false: the site has no server-rendered listings, src/core/features.ts SSR)
683
+ * and `locales` (onlyLocales) are Luma's, not Vite's.
451
684
  */
452
685
  export const clientConfig = (configUrl, overrides = {}) => {
453
686
  const root = dirname(fileURLToPath(configUrl));
@@ -456,11 +689,12 @@ export const clientConfig = (configUrl, overrides = {}) => {
456
689
  // may read it. Unset: any origin (convenient, but any page open in the
457
690
  // developer's browser could read the source while the server runs).
458
691
  const devOrigins = process.env.LBX_DEV_ORIGINS?.split(',').map((o) => o.trim()).filter(Boolean);
459
- const { plugins = [], server = {}, ...rest } = overrides;
692
+ const { plugins = [], server = {}, framework, serverRendering, locales, ...rest } = overrides;
693
+ if (serverRendering !== undefined && typeof serverRendering !== 'boolean') throw new Error(`clientConfig: serverRendering is true or false, not ${JSON.stringify(serverRendering)}`);
460
694
  return {
461
695
  root,
462
696
  resolve: { alias: { '@': FRAMEWORK } },
463
- define: { __VERSION__: JSON.stringify(new Date().toISOString()) },
697
+ define: { __VERSION__: JSON.stringify(new Date().toISOString()), ...frameworkDefines(framework), __LBX_SSR__: String(serverRendering !== false) },
464
698
  // css.postcss: {} stops Vite from picking up a PostCSS config from parent dirs.
465
699
  css: { postcss: {} },
466
700
  // Class fields are fields (tsconfig.json's ES2022), wherever Luma is: Vite
@@ -471,7 +705,7 @@ export const clientConfig = (configUrl, overrides = {}) => {
471
705
  // pre-bundled as a dependency: installed under node_modules/, Vite would.
472
706
  optimizeDeps: { exclude: ['@'] },
473
707
  base: './',
474
- plugins: [preloadHelper(), scopeCheck(), unusedBuiltIns(root), rawCss(), templateText(root), terse(), ...plugins],
708
+ plugins: [preloadHelper(), scopeCheck(), features(root), onlyLocales(locales), unusedBuiltIns(root), rawCss(), templateText(root), pureDirectives(), terse(), unreachable(), ...plugins],
475
709
  server: {
476
710
  port: 8069,
477
711
  strictPort: true,
@@ -488,7 +722,8 @@ export const clientConfig = (configUrl, overrides = {}) => {
488
722
  // Syntax only: APIs are not polyfilled, which `yarn luma baseline` checks.
489
723
  target: 'baseline-widely-available',
490
724
  // Preload a lazy chunk's own dependencies in parallel with it.
491
- modulePreload: { polyfill: false },
725
+ // Each import's own chunk first, even alone (Vite's []): the helper marks it.
726
+ modulePreload: { polyfill: false, resolveDependencies: (file, deps) => [file, ...deps.filter((d) => d !== file)] },
492
727
  rollupOptions: {
493
728
  input: { integration: resolve(root, 'integration/__integration.ts') },
494
729
  output: {
@@ -105,7 +105,8 @@ guessed alt text, and forced colours and the OS setting are honoured.
105
105
  the fallback products another, named by their heading and the note that
106
106
  they are not results; the help box's phone link is a plain link. "Show all
107
107
  products" is a real link (`href`: the
108
- search page the site's form would submit to) and the open dropdown's one
108
+ search page the site's form would submit to, or with `search.queryUrl` the
109
+ query's page, the one Enter loads) and the open dropdown's one
109
110
  tab stop: Tab from the input reaches it (the options' selection cleared:
110
111
  one thing lit), Shift+Tab goes back, Escape closes and goes back, Tab moves
111
112
  on to the page's next control after the input. It is also the arrow keys'
@@ -133,7 +134,11 @@ guessed alt text, and forced colours and the OS setting are honoured.
133
134
  `aria-activedescendant` the selected option: the same shadow root); the
134
135
  site's input is left as it was. The back button is named "Close search",
135
136
  the clear button "Clear"; the type tabs are a `tablist` (arrow keys move
136
- 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
137
142
  browser's Back close it.
138
143
  - **Forced colours**: the selection in `Highlight`/`HighlightText` with an
139
144
  outline, the panel (and the sheet) keep a `CanvasText` border, pills,
@@ -165,7 +170,10 @@ guessed alt text, and forced colours and the OS setting are honoured.
165
170
  opened from the keyboard, focus goes to its selected item
166
171
  (`aria-selected`/`aria-current`/`aria-checked`), else the first; arrows move
167
172
  through its items; Escape closes it and focus returns to the button; picking
168
- an item returns focus to the button; Tab out of it closes it.
173
+ an item returns focus to the button; Tab out of it closes it. The build
174
+ includes menus only where a file of the integration's repository (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) has `data-lbx-menu` (written literally, as in a template). A
175
+ `<details data-lbx-dropdown>` menu closes on a press outside it and on
176
+ picking an item; the same holds for it with `data-lbx-dropdown`.
169
177
  - **The drawer** (`[data-lbx-drawer="open|close"]`): `aria-expanded` on its
170
178
  openers. With a panel marked `[data-lbx-drawer-panel]`, while open, the panel
171
179
  is a modal dialog (`role="dialog"`, `aria-modal="true"`, named "Filters",
@@ -176,6 +184,21 @@ guessed alt text, and forced colours and the OS setting are honoured.
176
184
  focus staying on the control. With `facetsApply: 'staged'` its
177
185
  changes wait for `[data-lbx-drawer="apply"]`; closing it otherwise discards
178
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.
199
+ The build includes the drawer only where a file of the integration's
200
+ repository (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) has `data-lbx-drawer` (written
201
+ literally, as in a template).
179
202
  - **Facet groups** (built-in template): each group's title is a `<button
180
203
  aria-expanded aria-controls>` controlling its values (WAI-ARIA APG
181
204
  accordion), focus staying on it. A group's search box
@@ -206,7 +229,8 @@ guessed alt text, and forced colours and the OS setting are honoured.
206
229
  - Built-in templates: the pager is a native "Show more" button with a
207
230
  "Showing N of M" line (no numbered pages); tile pictures are decorative
208
231
  (`alt=""`: the link reads as its title and price once); targets of at least
209
- 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.
210
234
 
211
235
  ### Recommenders (`widgets/recommender/`)
212
236
 
@@ -225,7 +249,9 @@ guessed alt text, and forced colours and the OS setting are honoured.
225
249
  reset cannot remove it there).
226
250
  - **Reduced motion**: the hold's loading bar stands still, the listing's
227
251
  dim while a re-render loads comes without its fade and the new results
228
- 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.
229
255
  - `.lbx-sr-only`: a class for text that only screen readers read (e.g. "Was"
230
256
  before an old price), defined for every widget and in server-rendered CSS.
231
257
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luigisbox/luma",
3
- "version": "0.4.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",
@@ -29,7 +29,8 @@
29
29
  "snippet/early-hold.min.js",
30
30
  "docs/ACCESSIBILITY.md",
31
31
  "docs/INTEGRATION-PERFORMANCE.md",
32
- "docs/PARITY.md"
32
+ "docs/PARITY.md",
33
+ "CHANGELOG.md"
33
34
  ],
34
35
  "publishConfig": {
35
36
  "access": "public"
@@ -71,7 +71,7 @@ export const reader = <E extends EventName, C>(event: E, f: (ctx: C) => Reads[E]
71
71
  /** `.read(r)` on event `name`: `r`, or an error when it is a reader of another event. */
72
72
  export const checked = (name: string, r: { event?: string }) => {
73
73
  const e = name.startsWith('recommender:') ? 'recommender' : name;
74
- if (r.event && r.event !== e) throw new Error(`analytics: collect.${e}.read(): a reader of ${r.event}`);
74
+ if (r.event && r.event !== e) throw Error(`analytics: collect.${e}.read(): a reader of ${r.event}`);
75
75
  };
76
76
 
77
77
  export interface Chain<E extends EventName, C> {
@@ -42,7 +42,7 @@ export const pageView = {
42
42
  },
43
43
  read(r) {
44
44
  checked('pageView', r);
45
- if (chained) throw new Error('analytics: one collect.pageView chain per setup');
45
+ if (chained) throw Error('analytics: one collect.pageView chain per setup');
46
46
  chained = true;
47
47
  const ref = (ctx: C) => {
48
48
  try {
@@ -33,10 +33,9 @@ export const start = async (o: StartOptions) => {
33
33
  if (started) return;
34
34
  started = true;
35
35
  state.g = o.config._global;
36
- env.owns = o.owns;
37
- env.visit = o.visit;
38
- env.onUrlChange = o.onUrlChange;
39
- env.kept = o.kept;
36
+ // One call, not a read per field: a setup that uses none of them leaves
37
+ // Rollup no property reads to keep (it keeps a read: it may be a getter).
38
+ Object.assign(env, o);
40
39
  // The setup (`_global` first). Its chains register as it runs.
41
40
  for (const [p, f] of Object.entries(o.files).sort(([a], [b]) => (a.includes('/_global/') ? -1 : b.includes('/_global/') ? 1 : 0)))
42
41
  try {
@@ -51,7 +51,7 @@ export const readDl = (x: any): DlEntry | null => {
51
51
  return null;
52
52
  };
53
53
 
54
- const IGNORE = new RegExp(`${TRACKING}|^${P.replace('.', '\\.')}`);
54
+ const IGNORE = RegExp(`${TRACKING}|^${P.replace('.', '\\.')}`);
55
55
  /**
56
56
  * The page a URL shows, for pageviews: without tracking params, the hash and
57
57
  * our own `lbx.*` (a filter, a sort on our listing is the same page). A
package/src/api/ai.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // Also runs in Node (the edge render service): there the
9
9
  // storefront origin is sent explicitly and nothing touches the DOM.
10
10
 
11
+ import { PLP, RECOMMENDER, SEARCH } from '../core/features';
11
12
  import { health } from '../core/health';
12
13
  import type { GlobalConfigType } from '../types';
13
14
  import type { Api, FacetData, Hit, Page } from '.';
@@ -15,6 +16,11 @@ import { fetchJson, TIMEOUT_MS, tokensFor, untilAborted, visitorId } from './tok
15
16
 
16
17
  export { untilAborted };
17
18
 
19
+ /** A method of a widget the build has no folder for (never called). */
20
+ const unbuilt = (): never => {
21
+ throw Error('not in this build');
22
+ };
23
+
18
24
  export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
19
25
  const tk = tokensFor(g, origin);
20
26
  const { API, originHeaders } = tk;
@@ -48,8 +54,8 @@ export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
48
54
  return post(path, params, body, signal, true);
49
55
  }
50
56
  }
51
- if (!r.ok) throw new Error(`${path}: HTTP ${r.status} ${r.body.slice(0, 300)}`);
52
- if (r.json == null) throw new Error(`${path}: not JSON`);
57
+ if (!r.ok) throw Error(`${path}: HTTP ${r.status} ${r.body.slice(0, 300)}`);
58
+ if (r.json == null) throw Error(`${path}: not JSON`);
53
59
  return r.json;
54
60
  };
55
61
 
@@ -75,24 +81,30 @@ export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
75
81
  return { hits: j.hits ?? [], total: j.total ?? j.total_approx ?? 0, guid: j.guid, cursor: j.next_page_cursor ?? null, status: j.pagination_status ?? 'exhausted' };
76
82
  },
77
83
 
78
- facets: async (guid, facets, signal): Promise<Record<string, FacetData>> =>
79
- (await post('facets', { channel_id: g.channelId }, { guid, facets: facets.map((f) => compact(f)) }, signal)).facets ?? {},
84
+ // A listing's and a recommender's (core/features.ts): not in a build without one.
85
+ facets:
86
+ SEARCH || PLP
87
+ ? async (guid, facets, signal): Promise<Record<string, FacetData>> =>
88
+ (await post('facets', { channel_id: g.channelId }, { guid, facets: facets.map((f) => compact(f)) }, signal)).facets ?? {}
89
+ : unbuilt,
80
90
 
81
- recommend: async (r, signal): Promise<Hit[]> =>
82
- (
83
- await post(
84
- 'recommender',
85
- { channel_id: g.channelId, surface_id: r.surface },
86
- compact({
87
- type: r.type,
88
- size: r.size,
89
- anchor_ids: r.anchors.length ? r.anchors.slice(0, 50) : undefined,
90
- exclude_ids: r.exclude?.length ? r.exclude.slice(0, 50) : undefined,
91
- filters: nonEmpty(r.filters),
92
- return_fields: r.fields?.length ? r.fields : undefined,
93
- }),
94
- signal,
95
- )
96
- ).hits ?? [],
91
+ recommend: RECOMMENDER
92
+ ? async (r, signal): Promise<Hit[]> =>
93
+ (
94
+ await post(
95
+ 'recommender',
96
+ { channel_id: g.channelId, surface_id: r.surface },
97
+ compact({
98
+ type: r.type,
99
+ size: r.size,
100
+ anchor_ids: r.anchors.length ? r.anchors.slice(0, 50) : undefined,
101
+ exclude_ids: r.exclude?.length ? r.exclude.slice(0, 50) : undefined,
102
+ filters: nonEmpty(r.filters),
103
+ return_fields: r.fields?.length ? r.fields : undefined,
104
+ }),
105
+ signal,
106
+ )
107
+ ).hits ?? []
108
+ : unbuilt,
97
109
  };
98
110
  };
package/src/api/token.ts CHANGED
@@ -99,7 +99,7 @@ export async function solveHere(nonce: string, difficulty: number, until: number
99
99
  let t0 = Date.now();
100
100
  for (let n = 0; ; n++) {
101
101
  if ((n & 1023) === 0 && Date.now() - t0 > (slice || 50)) {
102
- if (Date.now() > until) throw new Error('proof of work: no answer in time');
102
+ if (Date.now() > until) throw Error('proof of work: no answer in time');
103
103
  if (slice) await new Promise((r) => setTimeout(r));
104
104
  t0 = Date.now();
105
105
  }
@@ -142,8 +142,8 @@ const solve = (nonce: string, difficulty: number): Promise<number> => {
142
142
  return here(); // CSP worker-src
143
143
  }
144
144
  // A worker that never answers is stopped.
145
- timer = window.setTimeout(() => (done(), reject(new Error('proof of work: no answer in time'))), POW_TIMEOUT_MS);
146
- w.onmessage = (e) => (done(), e.data == null ? reject(new Error('proof of work failed')) : resolve(e.data));
145
+ timer = window.setTimeout(() => (done(), reject(Error('proof of work: no answer in time'))), POW_TIMEOUT_MS);
146
+ w.onmessage = (e) => (done(), e.data == null ? reject(Error('proof of work failed')) : resolve(e.data));
147
147
  w.onerror = here;
148
148
  w.postMessage([nonce, difficulty, until]);
149
149
  });
@@ -236,9 +236,9 @@ const tokens = (g: GlobalConfigType, origin?: string): Tokens => {
236
236
  const url = challengeUrl(g);
237
237
  // The one the entry asked while this chunk loaded (the first try only: a retry needs a new one).
238
238
  const ch = await fetchJson(url, { headers: originHeaders }, TIMEOUT_MS, undefined, browser && retries ? takeChallenge(url) : undefined);
239
- if (!ch.ok) throw new Error(`browser-challenge: HTTP ${ch.status}`);
239
+ if (!ch.ok) throw Error(`browser-challenge: HTTP ${ch.status}`);
240
240
  const { nonce, difficulty } = ch.json ?? {};
241
- if (typeof nonce !== 'string' || !(difficulty >= 0 && difficulty <= MAX_DIFFICULTY)) throw new Error('browser-challenge: unexpected challenge');
241
+ if (typeof nonce !== 'string' || !(difficulty >= 0 && difficulty <= MAX_DIFFICULTY)) throw Error('browser-challenge: unexpected challenge');
242
242
  const solution = await solve(nonce, difficulty);
243
243
  const r = await fetchJson(
244
244
  `${AUTH}/browser-token`,
@@ -260,10 +260,10 @@ const tokens = (g: GlobalConfigType, origin?: string): Tokens => {
260
260
  if (!r.ok) {
261
261
  const detail = r.json?.detail;
262
262
  if (retries > 0 && detail === 'Nonce expired') return mint(retries - 1);
263
- throw new Error(`browser-token: HTTP ${r.status} ${typeof detail === 'string' ? detail : ''}`);
263
+ throw Error(`browser-token: HTTP ${r.status} ${typeof detail === 'string' ? detail : ''}`);
264
264
  }
265
265
  const { access_token, expires_in } = r.json ?? {};
266
- if (typeof access_token !== 'string' || !(expires_in > 30)) throw new Error('browser-token: unexpected response');
266
+ if (typeof access_token !== 'string' || !(expires_in > 30)) throw Error('browser-token: unexpected response');
267
267
  return { token: access_token, exp: Date.now() + (expires_in - 30) * 1000 };
268
268
  };
269
269