@luigisbox/luma 0.7.0 → 0.8.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ before 1.0, a minor version may break the integration API.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ### Added
9
+
10
+ - Conversational search: a `convo/` folder adds a dialog where the shopper
11
+ asks in their own words and Luigi's Box's assistant answers with text and
12
+ products. Opened by a click on any element with `data-lbx-convo`; its
13
+ code loads only when the shopper goes near one. Products come from the
14
+ Luigi's Box AI catalog (`surface`, `idField`); the conversation from
15
+ Luigi's Box's conversation service (`trackerId`).
16
+ - `convo.trigger`: Luma's own assistant button in the site's search field.
17
+ - `convo.assist`: for sites Luigi's Box has set up for it, the assistant's
18
+ answer to a phrase in the autocomplete (a row on top) and on the search
19
+ page (a box above the results); either continues in the panel.
20
+
21
+ ## 0.7.1 — 2026-11-02
22
+
23
+ ### Fixed
24
+
25
+ - iOS Safari: swiping Back from a product to a listing showed a blank page
26
+ for a moment; the swipe now shows the listing.
27
+
8
28
  ## 0.7.0 — 2026-10-29
9
29
 
10
30
  ### Added
package/README.md CHANGED
@@ -12,7 +12,7 @@ its own repository and depends on `@luigisbox/luma`
12
12
 
13
13
  > **Pre-release.** Until 1.0, a minor version (0.8.0) may change the
14
14
  > integration API (config keys, template scope, the build); a patch version
15
- > (0.7.1) only fixes. Depend on `^0.7.0` to take fixes without breaking
15
+ > (0.7.2) only fixes. Depend on `^0.7.1` to take fixes without breaking
16
16
  > changes. [`CHANGELOG.md`](CHANGELOG.md) says what each version changed.
17
17
 
18
18
  ## Core principles
@@ -308,6 +308,7 @@ integration/
308
308
  search/ _config.ts _translations.ts style-host.css template-search-*.ts
309
309
  plp/ _config.ts _init.ts style-host.css (templates fall back to search/)
310
310
  recommender <name>/ _config.ts [_init.ts] _translations.ts
311
+ convo/ _config.ts [_init.ts] [style-host.css] [style-convo.css] [template-convo-*.ts]
311
312
  ```
312
313
 
313
314
  - **`_config.ts` is JSON** (checked at boot in development: `yarn dev`);
@@ -317,7 +318,7 @@ integration/
317
318
  key is typed and documented in `src/types.ts`.
318
319
  - **A widget is its folder.** The build carries only the widgets the
319
320
  integration has folders for (`autocomplete/`, `search/`, `plp/`,
320
- `recommender <name>/`) and analytics only where a folder has an
321
+ `recommender <name>/`, `convo/`) and analytics only where a folder has an
321
322
  `_analytics.ts`: an integration with an autocomplete only loads about half
322
323
  as much on every page as one with every widget. A config `_global/_init.ts`
323
324
  creates for a widget without its folder is ignored. The development server
@@ -343,7 +344,114 @@ integration/
343
344
  `search-facets-default`, `search-results-default`, `search-result-default`,
344
345
  `search-pagination`, `autocomplete-box`, `autocomplete-result-default`,
345
346
  `autocomplete-sheet`, `autocomplete-empty`, `recommender-box-default`,
346
- `recommender-result-default`, `recommender-popup`.
347
+ `recommender-result-default`, `recommender-popup`, `convo-box`,
348
+ `convo-result-default`, and with `convo.assist`, `autocomplete-assist` and
349
+ `convo-preview`.
350
+
351
+ ### Conversational search
352
+
353
+ The shopper asks in their own words, in a dialog, and Luigi's Box's shopping
354
+ assistant answers: streamed text, the products it picked, follow-up
355
+ suggestions, questions with choices, comparisons. Only an integration with a
356
+ `convo/` folder has it, and a page loads its code only when the shopper goes
357
+ near one of its triggers (about 11 kB gzip, once).
358
+
359
+ A click on any element with `data-lbx-convo` opens the dialog: the site's
360
+ own button, or one in a template. Its value, when not empty, is a question:
361
+ from the autocomplete's templates (passing the phrase typed,
362
+ `data-lbx-convo=${s.query}`) it is asked at once; from anything else (the
363
+ site's markup, a listing's or recommender's tile) it is put in the dialog's
364
+ input for the shopper to send (text on a page never speaks in the
365
+ shopper's name).
366
+
367
+ ```html
368
+ <button type="button" data-lbx-convo>Ask our shopping assistant</button>
369
+ ```
370
+
371
+ Or Luma's own sparkle in the site's search field: `trigger: { anchor,
372
+ position, label }` places a 32 px round button (`[data-lbx="convo-trigger"]`,
373
+ `position` as a listing's anchor, default `append`; named "Ask the shopping
374
+ assistant" in English, Czech or German by `_global.locale`, or `label`)
375
+ that opens the dialog with the phrase typed in the site's search input
376
+ (`_global.inputSelector`). In flow by default; the folder's
377
+ `style-host.css` (applied on every page with the folder, before the button
378
+ is placed) lays it over the field and keeps the field's text clear of it.
379
+ It is in the build only where `convo/_config.ts` names `trigger` (about
380
+ 0.8 kB gzip on every page, loaded after the page's own scripts).
381
+
382
+ ```css
383
+ /* integration/convo/style-host.css */
384
+ .search-form { position: relative; }
385
+ .search-form > [data-lbx="convo-trigger"] { position: absolute; top: 6px; right: 6px; color: #7b2cbf; background: #f3eafb; border-color: #b48ad9; border-radius: 50%; }
386
+ .search-form:has(> [data-lbx="convo-trigger"]) input[name="q"] { padding-right: 44px; }
387
+ ```
388
+
389
+ ```ts
390
+ // integration/convo/_config.ts
391
+ export default {
392
+ trackerId: '1234-5678', // the site in Luigi's Box's conversation service
393
+ surface: 'lbs_collection_main', // the collection surface its products are looked up on
394
+ idField: 'id', // the catalog field holding the identity the assistant names a product by
395
+ fields: ['@title', 'web_url', 'lbx:image', 'lbx:price'],
396
+ locale: 'de', // the language it answers in (default: _global.locale)
397
+ trigger: { anchor: 'header form.search' }, // optional: Luma's sparkle in the site's search field
398
+ assist: true, // optional: its answer in the autocomplete and on the search page
399
+ };
400
+ ```
401
+
402
+ - **The conversation** is Luigi's Box's conversation service
403
+ (`conversational-proxy.luigisbox.com`, `endpoint` to change it), keyed by
404
+ `trackerId`. A site with a strict Content Security Policy allows that host
405
+ in `connect-src`. The assistant answers in `en`, `sk`, `cs`, `pl`, `de` or
406
+ `hu`: set `locale` to the shop's language when the interface's differs.
407
+ - **Its products** are the catalog's: the identities the assistant names are
408
+ looked up on `surface` by `idField`, and drawn with the
409
+ `convo-result-default` template (the integration's, like every other tile:
410
+ `r.c.*`, `img`, `price`). A product the channel does not serve gets no
411
+ tile.
412
+ - **It lasts the tab's session**: a product opened from the dialog and Back,
413
+ or a trigger on the next page, shows the same conversation, for about an
414
+ hour. "Start over" begins a new one.
415
+ - **Where the site's assistant is off** (over its monthly budget, no
416
+ catalog), the dialog says so, and the triggers are hidden on the session's
417
+ later pages.
418
+ - **Its look**: a panel from the right over the dimmed page (the whole
419
+ screen on a phone), in a shadow root (the site's CSS does not reach in).
420
+ `style-convo.css` in `convo/` styles it, starting with its tokens: the
421
+ site's brand colour is enough, the tints of the shopper's bubbles, chips
422
+ and the assistant's mark come from it, and Luma darkens the text drawn in
423
+ it until it reads (4.5:1) and picks white or black for what stands
424
+ on it: `:host { --lbx-cv-accent: #8740bf;
425
+ --lbx-cv-font: 'Inter', sans-serif; }` (`--lbx-cv-fg`, `-bg`,
426
+ `-muted`, `-line`, `-field`, `-soft`, `-width`). The `convo-box` template draws the whole dialog
427
+ (`ConvoScope`). Its words come in English, Czech and German with its code;
428
+ `_translations.ts` overrides them under `convo` (`{ sk: { convo: { title:
429
+ 'Nákupný asistent' } } }`).
430
+ - **The assistant's answer where the shopper searches** (`assist: true`,
431
+ for a site Luigi's Box has set up for it; elsewhere nothing is shown and
432
+ Luma stops asking for the session): Luigi's Box decides per phrase whether
433
+ the assistant serves it better than the results, and answers it when so.
434
+ The autocomplete asks about a phrase of 3 or more words once the shopper
435
+ pauses (600 ms), and shows a row on top with the answer's first words
436
+ (`autocomplete-assist`; in the site's own `autocomplete-box`,
437
+ `s.assist`: a button with `data-lbx-convo=${s.assist.query}` and
438
+ `data-lbx-assist`). The search page asks about its query (3 or more
439
+ words, for a search made on the site: a search page reached from another
440
+ site shows only an answer the tab already has, never asks in the
441
+ shopper's name) and shows a box above the listing with the answer, three
442
+ of its products, its suggestions and "Continue with the assistant"
443
+ (`convo-preview`, `ConvoPreviewScope`). An answer that ends in an error
444
+ shows nothing.
445
+ Either opens the panel with that answer as the conversation's first turn:
446
+ one answer per phrase, kept for the tab's session, so a phrase answered in
447
+ the autocomplete and then searched is not asked again (one still streaming
448
+ is asked again with its own conversation, which Luigi's Box replays). Its
449
+ code is a chunk of its own (about 2.7 kB gzip), loaded when a phrase
450
+ settles or on a search page; the panel's only once there is an answer. The search box is placed
451
+ before Luma's listing (`lbx-search`): CSS of the integration's that hides
452
+ the rest of the listing's container lets `lbx-convo-preview` through.
453
+ - **Not yet**: adding products to a comparison from their tiles (the
454
+ assistant's comparisons are shown), feedback on an answer.
347
455
 
348
456
  ### Templates
349
457
 
@@ -474,7 +582,7 @@ built as on.
474
582
  | `search.prefetchWhileTyping` | the search result is fetched once the typed phrase settles, so Enter shows it at once | |
475
583
  | `search.pageWhileTyping` (`'prefetch'`, `'prerender'`, `false`) | the search page of the typed phrase is fetched ahead (Chrome, Edge; Firefox and Safari as they ship it), so Enter from another page skips the site's server render; `'prerender'` renders it ahead as well (Chrome, Edge) | the site cannot take a document request per typed phrase; `'prerender'` only where its tags wait for `prerenderingchange` |
476
584
  | `search.queryUrl` (an `_init.ts` hook, off unless set) | where the site's server redirects its search form to the query's page (`/search?q=x` to `/search/x/`), names that page: Enter, the autocomplete's "show all" and a picked query load it directly, without the redirect, and the result fetched while typing is shown there | the form's other fields decide the page, or the site must handle every search itself |
477
- | `restoreOnBack` | Back from a product restores the listing (every page shown) and the followed tile's position, as does the page loaded again within a minute of following a link (a reload, a link whose server redirects back to it); Luma turns the browser's own scroll restoration (`history.scrollRestoration`) off for the listing's history entry until it has placed the page, unless the site set it | |
585
+ | `restoreOnBack` | Back from a product restores the listing (every page shown) and the followed tile's position, as does the page loaded again within a minute of following a link (a reload, a link whose server redirects back to it); Luma turns the browser's own scroll restoration (`history.scrollRestoration`) off for the listing's history entry until it has placed the page (not while the page is in the browser's back/forward cache, which restores it from memory), unless the site set it | |
478
586
  | `prefetchNextPage` | "show more" is fetched as the shopper nears the end | |
479
587
  | `contentVisibility` (opt-in) | tiles out of view skip style, layout and paint, each at its own measured size: a long list's re-render about halves | keep it off where a tile draws outside its box (a hover panel, a badge hanging out) or holds a `position: fixed` element |
480
588
  | `variants: { fields }` (opt-in) | each tile's result gets its variant group (`r.variants`, "Templates" above): one request to Luigi's Box per page of results, never waited for | the surface does not collapse variants (every group is a group of one) |
@@ -648,6 +756,9 @@ integration repository").
648
756
  while typing a document per settled phrase (`search.pageWhileTyping: false`).
649
757
  - **Early hold**: the snippet carries no site's rules, so the first page
650
758
  view is not held.
759
+ - **Conversational search** runs on Luigi's Box's conversation service, keyed
760
+ by the site's tracker id, until Luigi's Box AI serves conversations; its
761
+ products are already the Luigi's Box AI catalog's.
651
762
  - **Host routers out of reach**: where Luma cannot find the host's router (Vue
652
763
  Router 4 when its app is not discoverable, Angular in production), the
653
764
  router can overwrite Luma's history entry or show a stale query in its search
package/build/vite.mjs CHANGED
@@ -22,7 +22,7 @@ const FRAMEWORK = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
22
22
  // (soleUser: the listing's contrast toggle, fold, …)
23
23
  // lbx-listing search results and category pages (one listing: plp is
24
24
  // a route of it) with the integration's search/ templates
25
- // lbx-autocomplete lbx-recommender each widget with its templates
25
+ // lbx-autocomplete lbx-recommender lbx-convo each widget with its templates
26
26
  // lbx-plp lbx-place lbx-search lbx-search-in-place lbx-speculate lbx-token
27
27
  // the plp widget and its templates, placement and
28
28
  // recommender triggers, the search page's own, a new query
@@ -46,7 +46,7 @@ const FRAMEWORK = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
46
46
  // entry needs no change here. _global/ files named after a widget
47
47
  // (template-recommender-*.ts) go with that widget only. Integration folders
48
48
  // are named after their widget (`recommender similar`): the first word says which.
49
- const KIND_CHUNK = { autocomplete: 'lbx-autocomplete', search: 'lbx-listing', plp: 'lbx-plp', recommender: 'lbx-recommender' };
49
+ const KIND_CHUNK = { autocomplete: 'lbx-autocomplete', search: 'lbx-listing', plp: 'lbx-plp', recommender: 'lbx-recommender', convo: 'lbx-convo' };
50
50
  const NAV = /\/src\/(widgets\/listing\/(stash|query|state|flow)|widgets\/search\/(form-url|route|first)|core\/(url|listings|takeover|turbo|prefetch|stash|page))\.ts$/;
51
51
  const WIDGET = /\/src\/widgets\/([a-z]+)\//;
52
52
  const CORE = /\/src\/(lit\.ts|core\/|api\/)|\/node_modules\/lit-html\//;
@@ -86,9 +86,10 @@ export const chunkOf = (id) => {
86
86
  // Hydration of server-rendered listings: only those pages load it.
87
87
  // lit-html's private SSR support is the hydrator's alone (not every widget page's lbx-core).
88
88
  if (/\/node_modules\/(@lit-labs\/ssr-client\/|lit-html\/private-ssr-support\.js)/.test(id)) return 'lbx-hydrate';
89
- // Placing our elements (core/reconciler.ts) and the recommenders' triggers:
90
- // pages with a listing or a recommender, in parallel with their other chunks.
91
- if (/\/src\/core\/(reconciler|recommenders)\.ts$/.test(id)) return 'lbx-place';
89
+ // Placing our elements (core/reconciler.ts), the recommenders' triggers and the
90
+ // conversation's sparkle (widgets/convo/trigger.ts): pages with a listing, a
91
+ // recommender or the sparkle, in parallel with their other chunks.
92
+ if (/\/src\/(core\/(reconciler|recommenders)|widgets\/convo\/trigger)\.ts$/.test(id)) return 'lbx-place';
92
93
  // The listing's high-contrast toggle: only a site that turns it on loads it (widgets/listing/element.ts).
93
94
  if (/\/src\/core\/contrast-toggle\.ts$/.test(id)) return 'lbx-contrast';
94
95
  // An Apply's cue in a staged drawer: asked for when such a drawer opens (widgets/listing/element.ts).
@@ -132,9 +133,14 @@ const soleUser = (id, getModuleInfo, entry, seen = new Set([id])) => {
132
133
  }
133
134
  return users;
134
135
  };
136
+ // The assistant's answer to what is typed (widgets/convo/assist.ts) and the wire it shares with the
137
+ // panel (protocol.ts): their own chunk where the build has it (the autocomplete and the search page
138
+ // load it without the panel), else in the panel's.
139
+ const ASSIST = /\/src\/widgets\/convo\/(assist|protocol)\.ts$/;
135
140
  const manualChunks = (id, { getModuleInfo, getModuleIds }) => {
136
141
  const entry = entryGraph(getModuleInfo, getModuleIds);
137
142
  if (entry.has(id)) return 'integration';
143
+ if (ASSIST.test(id)) return [...getModuleIds()].some((m) => /\/src\/widgets\/convo\/assist\.ts$/.test(m) && getModuleInfo(m)?.isIncluded) ? 'lbx-assist' : 'lbx-convo';
138
144
  const c = chunkOf(id);
139
145
  if (c !== 'lbx-core') return c;
140
146
  const users = soleUser(id, getModuleInfo, entry);
@@ -224,7 +230,9 @@ const rawCss = () => ({
224
230
  * `integration`: { folder: { name: source } }; `builtIns`: { widget: { name: source } }.
225
231
  * Returns { widget: [built-in names to leave out] }.
226
232
  */
227
- export const ENTRY_TEMPLATES = { autocomplete: ['autocomplete-box'], search: ['search-box'], recommender: ['recommender-box-default', 'recommender-popup'] };
233
+ /** Built-in templates a feature of the integration renders (core/features.ts): left out of a build without it. */
234
+ export const FEATURE_TEMPLATES = { autocomplete: { 'autocomplete-assist': '__LBX_CONVO_ASSIST__' }, convo: { 'convo-preview': '__LBX_CONVO_ASSIST__' } };
235
+ export const ENTRY_TEMPLATES = { autocomplete: ['autocomplete-box'], search: ['search-box'], recommender: ['recommender-box-default', 'recommender-popup'], convo: ['convo-box', 'convo-preview'] };
228
236
  const includes = (src) => {
229
237
  const calls = [...src.matchAll(/\binclude\(\s*(?:(['"])([\w-]+)\1)?/g)];
230
238
  return calls.some((m) => !m[2]) ? null : calls.map((m) => m[2]);
@@ -240,6 +248,7 @@ export const unrenderedBuiltIns = (integration, builtIns) => {
240
248
  ...(folders.includes('plp') ? [{ folders: ['_global', 'search', 'plp'], kinds: ['search', 'plp'] }] : []),
241
249
  ],
242
250
  recommender: folders.filter((f) => f.split(/\s+/)[0] === 'recommender').map((f) => ({ folders: ['_global', f], kinds: ['recommender'] })),
251
+ convo: folders.includes('convo') ? [{ folders: ['_global', 'convo'], kinds: ['convo'] }] : [],
243
252
  };
244
253
  const out = {};
245
254
  for (const [widget, instances] of Object.entries(users)) {
@@ -285,7 +294,7 @@ const templateSources = (dir) => {
285
294
  }
286
295
  return out;
287
296
  };
288
- const BUILT_INS = /\/src\/widgets\/(autocomplete|search|recommender)\/(?:index|assets)\.ts$/;
297
+ const BUILT_INS = /\/src\/widgets\/(autocomplete|search|recommender|convo)\/(?:index|assets)\.ts$/;
289
298
 
290
299
  /**
291
300
  * A built-in template's own rules in its widget's stylesheet, marked
@@ -319,6 +328,10 @@ const unusedBuiltIns = (root) => {
319
328
  const itg = templateSources(resolve(root, 'integration'));
320
329
  const builtIns = templateSources(resolve(FRAMEWORK, 'widgets'));
321
330
  drop = unrenderedBuiltIns(itg, builtIns);
331
+ // Those of a feature the integration does not name (an assistant's nudge without `assist`).
332
+ const defines = featureDefines(resolve(root, 'integration'));
333
+ for (const [widget, names] of Object.entries(FEATURE_TEMPLATES))
334
+ if (drop[widget]) for (const [name, flag] of Object.entries(names)) if (defines[flag] === 'false' && !drop[widget].includes(name)) drop[widget].push(name);
322
335
  // The left-out templates' stylesheet parts no template of the widget's folders still uses.
323
336
  cssDrop = {};
324
337
  for (const [widget, names] of Object.entries(drop)) {
@@ -551,12 +564,18 @@ export const featureDefines = (integrationDir) => {
551
564
  readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
552
565
  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
566
  );
567
+ // A key in a folder's _config.ts (a word alone is common: a catalog field, a comment).
568
+ const configText = (f) => (existsSync(resolve(integrationDir, f, '_config.ts')) ? readFileSync(resolve(integrationDir, f, '_config.ts'), 'utf8') : '');
554
569
  const inFiles = (...needles) => ((text ??= read(dirname(integrationDir)).join('\n')), needles.some((n) => text.includes(n)));
555
570
  return {
556
571
  __LBX_AUTOCOMPLETE__: has((d) => d.includes('autocomplete')),
557
572
  __LBX_SEARCH__: has((d) => d.includes('search')),
558
573
  __LBX_PLP__: has((d) => d.includes('plp')),
559
574
  __LBX_RECOMMENDER__: has((d) => d.some((f) => f.split(/\s+/)[0] === 'recommender')),
575
+ __LBX_CONVO__: has((d) => d.includes('convo')),
576
+ // Its sparkle in the site's search field: the key in convo/_config.ts (the word alone is common).
577
+ __LBX_CONVO_TRIGGER__: has((d) => d.includes('convo') && /\btrigger\b["']?\s*:/.test(configText('convo'))),
578
+ __LBX_CONVO_ASSIST__: has((d) => d.includes('convo') && /\bassist\b["']?\s*:/.test(configText('convo'))),
560
579
  __LBX_ANALYTICS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_analytics.ts')))),
561
580
  __LBX_WIDGET_INITS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_widget.ts')))),
562
581
  __LBX_HOST_STYLES__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, 'style-host.css')))),
@@ -579,13 +598,13 @@ export const featureDefines = (integrationDir) => {
579
598
  __LBX_PRERENDER_TYPING__: has(() => inFiles('prerenderWhileTyping', 'pageWhileTyping')),
580
599
  __LBX_CONTENT_VISIBILITY__: has(() => inFiles('contentVisibility')),
581
600
  // A listing's variant groups: the key in a folder's _config.ts (the word alone is common: a catalog field, a comment).
582
- __LBX_VARIANTS__: has((d) => d.some((f) => /\bvariants\b["']?\s*[:,}]/.test(existsSync(resolve(integrationDir, f, '_config.ts')) ? readFileSync(resolve(integrationDir, f, '_config.ts'), 'utf8') : ''))),
601
+ __LBX_VARIANTS__: has((d) => d.some((f) => /\bvariants\b["']?\s*[:,}]/.test(configText(f)))),
583
602
  // Options on by default: one no file names is on.
584
603
  ...Object.fromEntries(ON_BY_DEFAULT.map((k) => [`__LBX_NAMED_${k.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}__`, has(() => inFiles(k))])),
585
604
  };
586
605
  };
587
606
  /** Luma's dictionaries' namespaces (src/locales/*.json) a build without the widget leaves out: plp reads search's too. */
588
- export const LOCALE_NAMESPACES = { autocomplete: ['AUTOCOMPLETE'], search: ['SEARCH', 'PLP'], plp: ['PLP'], recommender: ['RECOMMENDER'] };
607
+ export const LOCALE_NAMESPACES = { autocomplete: ['AUTOCOMPLETE'], search: ['SEARCH', 'PLP'], plp: ['PLP'], recommender: ['RECOMMENDER'], assist: ['CONVO_ASSIST'] };
589
608
  export const pruneDictionary = (json, defines) => {
590
609
  const dict = JSON.parse(json);
591
610
  for (const [ns, flags] of Object.entries(LOCALE_NAMESPACES)) if (flags.every((f) => defines[`__LBX_${f}__`] === 'false')) delete dict[ns];
@@ -120,7 +120,8 @@ guessed alt text, and forced colours and the OS setting are honoured.
120
120
  - **Keys** as before: arrows move the selection, Enter picks it (or is the
121
121
  host's search), Escape closes ours first, as whole gestures. Focus never
122
122
  leaves the input on a click or a tap (`pointerdown` on the dropdown is
123
- prevented); only Tab moves it, to "Show all products".
123
+ prevented); only Tab moves it, to "Show all products" (or first to the
124
+ assistant's row, when there is one: below).
124
125
  - **Colours**: the host's text and accent colours are sampled only where they
125
126
  read at 4.5:1 on our white panel (`readable`, `core/a11y.ts`; a dark site's
126
127
  light text or a pale brand colour falls back); text on an accent fill is
@@ -251,6 +252,60 @@ guessed alt text, and forced colours and the OS setting are honoured.
251
252
  appears after the shopper's own add-to-cart click; moving focus there would
252
253
  pull them away from the host's notice).
253
254
 
255
+ ### The conversational search (`widgets/convo/`)
256
+
257
+ - A modal `<dialog>` (`showModal()`, a panel from the right) named by its
258
+ title ("Shopping assistant", `convo.title`): the page behind is inert, so
259
+ Tab and the screen reader's cursor stay in it; Escape, the 40 px close
260
+ button named "Close" and a press on the backdrop close it, and focus goes
261
+ back to the trigger. Focus starts in its input (named "Ask anything…").
262
+ Its icon buttons (Start over, Close, Send / Stop) are named and titled;
263
+ the log is a region named "Conversation"; Enter in the input while an
264
+ answer streams does nothing (only the Stop button stops it).
265
+ - The answer streams in word by word: the log is not a live region (it
266
+ would be read in pieces) but `aria-busy` while it streams. "Thinking" is
267
+ said as the turn starts, the answer once, politely, when it is done: its
268
+ words without the Markdown's marks, up to the sentence ending past 500
269
+ characters (an error after it). Each question and answer starts with a
270
+ visually hidden "You:" / "Assistant:"; a question the assistant ignored
271
+ says "not answered", and that is said too.
272
+ - Focus never falls to the page: Send and Stop are one button, and a
273
+ control that goes away with a render (a suggestion used, a retry, the
274
+ input disabled when the conversation ends) gives focus to the log.
275
+ The fields' edges are 3:1, their placeholder (their visible label) 4.5:1.
276
+ - Luma's sparkle in the site's search field (`trigger`) is a 32 px button
277
+ named "Ask the shopping assistant" in the site's language (`label`), with
278
+ a focus ring of its own that a site's outline reset cannot remove (3 px
279
+ Highlight in forced colours).
280
+ - The assistant's answer in the autocomplete (`assist`) is a button above
281
+ the dropdown's results, named by its title ("The assistant has an
282
+ answer"), its first words (up to 160 characters, not the whole answer)
283
+ and "Continue"; Tab from the search input reaches it first, then "Show
284
+ all products" (Shift+Tab back), and its title is said once, politely,
285
+ when it appears, with the result count if that is still to be said. The
286
+ arrow keys' last stop is still "Show all products". Its words are in the
287
+ text colour (the muted grey and the accent can fall under 4.5:1 on the
288
+ accent's tint). Closing the dialog opened from it (its dropdown gone)
289
+ gives focus to the site's search field. Its one-time shimmer and slide-in run only without reduced
290
+ motion; until the answer's first words come, three dots (hidden from
291
+ assistive technology) bounce, or only fade in turn under reduced motion,
292
+ and keep the text colour in forced colours.
293
+ - The search page's answer box is a section named by its heading
294
+ ("Shopping assistant"), `aria-busy` while the answer streams (not a live
295
+ region: the results are the page's main content); its suggestions and
296
+ "Continue with the assistant" are buttons that open the dialog, focus
297
+ going back to the one pressed when it closes. An answer that breaks off
298
+ removes the box; focus in it goes on to the listing after it. Its accent is made readable
299
+ as the dialog's (white or black on it, whichever reads better: at least
300
+ 4.58:1 on any accent).
301
+ - Every control is a named button of at least 24 px (suggestions, choices,
302
+ a product's "Tell me more: <product>", each refinement's remove control
303
+ named by the assistant, Try again, Stop, Start over). A comparison table
304
+ scrolls in its own focusable region with row and column headers.
305
+ - On a phone (or a short touch screen: a phone in landscape) the dialog is the whole screen. Forced colours keep the
306
+ dialog's, the bubbles' and the controls' edges; under reduced motion the
307
+ "thinking" dots stand still.
308
+
254
309
  ### Every widget
255
310
 
256
311
  - **Focus ring**: `:focus-visible` gets an outline in our subtrees (`@layer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luigisbox/luma",
3
- "version": "0.7.0",
3
+ "version": "0.8.0-beta.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",
package/src/core/a11y.ts CHANGED
@@ -12,7 +12,9 @@ export const SR_ONLY =
12
12
  /**
13
13
  * A polite status region (WCAG 4.1.3). `say(text)` announces it once the
14
14
  * caller has been quiet for `delay` ms (a burst of renders is one message);
15
- * the same text twice is announced twice (it is cleared first).
15
+ * the same text twice is announced twice (it is cleared first). `keep`: a
16
+ * message a later one does not replace but is said with (two pieces of news
17
+ * that come together: the autocomplete's results and the assistant's answer).
16
18
  */
17
19
  export const liveRegion = () => {
18
20
  const el = document.createElement('div');
@@ -21,17 +23,22 @@ export const liveRegion = () => {
21
23
  el.setAttribute('aria-atomic', 'true');
22
24
  el.style.cssText = SR_ONLY;
23
25
  let timer = 0;
26
+ let plain = '';
27
+ let kept = '';
24
28
  return {
25
29
  el,
26
- say(text: string, delay = 150) {
30
+ say(text: string, delay = 150, keep = false) {
31
+ if (keep) kept = text;
32
+ else plain = text;
33
+ const out = plain && kept ? `${plain.replace(/[.\s]+$/, '')}. ${kept}` : plain || kept;
27
34
  clearTimeout(timer);
28
35
  el.textContent = '';
29
36
  // In a later task: a region whose text is replaced in the same task as it appears is not announced.
30
- timer = window.setTimeout(() => void (el.textContent = text), delay);
37
+ timer = window.setTimeout(() => void ((el.textContent = out), (plain = kept = '')), delay);
31
38
  },
32
39
  clear() {
33
40
  clearTimeout(timer);
34
- el.textContent = '';
41
+ plain = kept = el.textContent = '';
35
42
  },
36
43
  };
37
44
  };
@@ -6,7 +6,7 @@
6
6
  import type { Templates } from './templates';
7
7
  import type { LumaConfigType } from '../types';
8
8
  import type { WidgetContext } from '../main';
9
- import { QUERY_URL } from './features';
9
+ import { CONVO_ASSIST, QUERY_URL } from './features';
10
10
  import { nameOf, slot } from './integration';
11
11
 
12
12
  type Support = typeof import('./support');
@@ -106,5 +106,6 @@ export const widgetContext = (
106
106
  // Images: a listing's first row is eager; elsewhere (recommenders, the autocomplete) every image is lazy.
107
107
  helpers: { t, price, plain: s.plain, first: s.first, ...s.imageHelpers(g.images, w.id !== 'search' && w.id !== 'plp') },
108
108
  queryUrl: QUERY_URL ? config.search?.queryUrl : undefined,
109
+ assist: CONVO_ASSIST && config.convo?.assist && !config.convo.disabled ? config.convo : undefined,
109
110
  };
110
111
  };
@@ -1,5 +1,5 @@
1
1
  // The widgets an integration has (build/vite.mjs `features`): a widget is its
2
- // folder (integration/autocomplete/, search/, plp/, recommender <name>/; an
2
+ // folder (integration/autocomplete/, search/, plp/, recommender <name>/, convo/; an
3
3
  // _analytics.ts or _widget.ts in any folder); a feature only the
4
4
  // integration's own files can ask for (a template's attribute, a hook its
5
5
  // _init.ts sets) by that text in them. The build defines these from the folders, so
@@ -11,6 +11,9 @@ declare const __LBX_AUTOCOMPLETE__: boolean | undefined;
11
11
  declare const __LBX_SEARCH__: boolean | undefined;
12
12
  declare const __LBX_PLP__: boolean | undefined;
13
13
  declare const __LBX_RECOMMENDER__: boolean | undefined;
14
+ declare const __LBX_CONVO__: boolean | undefined;
15
+ declare const __LBX_CONVO_TRIGGER__: boolean | undefined;
16
+ declare const __LBX_CONVO_ASSIST__: boolean | undefined;
14
17
  declare const __LBX_ANALYTICS__: boolean | undefined;
15
18
  declare const __LBX_SSR__: boolean | undefined;
16
19
  declare const __LBX_WIDGET_INITS__: boolean | undefined;
@@ -50,6 +53,12 @@ export const SEARCH = typeof __LBX_SEARCH__ === 'boolean' ? __LBX_SEARCH__ : tru
50
53
  export const PLP = typeof __LBX_PLP__ === 'boolean' ? __LBX_PLP__ : true;
51
54
  /** An integration/recommender <name>/. */
52
55
  export const RECOMMENDER = typeof __LBX_RECOMMENDER__ === 'boolean' ? __LBX_RECOMMENDER__ : true;
56
+ /** integration/convo/: the conversational search (widgets/convo). */
57
+ export const CONVO = typeof __LBX_CONVO__ === 'boolean' ? __LBX_CONVO__ : true;
58
+ /** The conversational search's sparkle in the site's search field: `trigger` in convo/_config.ts (main.ts). */
59
+ export const CONVO_TRIGGER = typeof __LBX_CONVO_TRIGGER__ === 'boolean' ? __LBX_CONVO_TRIGGER__ : true;
60
+ /** The assistant asked about what is typed (/assist, lean trackers): `assist` in convo/_config.ts (widgets/convo/assist.ts). */
61
+ export const CONVO_ASSIST = typeof __LBX_CONVO_ASSIST__ === 'boolean' ? __LBX_CONVO_ASSIST__ : true;
53
62
  /** An _analytics.ts in any folder (without one, analytics is off: main.ts). */
54
63
  export const ANALYTICS = typeof __LBX_ANALYTICS__ === 'boolean' ? __LBX_ANALYTICS__ : true;
55
64
  /** A _widget.ts in any folder (without one, core/widget-inits.ts has nothing to run). */
@@ -2,7 +2,8 @@
2
2
  // - load marks: `data-lbx-loaded` on an `img[data-lbx-img]` and its
3
3
  // `[data-lbx-ph]` box once it has loaded (or failed: the alt text shows), so
4
4
  // the placeholder goes and a lazy image fades in (style-search.css). One
5
- // capture listener on the document for every widget; an image already
5
+ // capture listener on the document for every widget (and on a widget's
6
+ // shadow root: the conversation's dialog); an image already
6
7
  // complete when a render puts it in (the memory cache, a persisted tile, a
7
8
  // server-rendered one loaded before hydration) is marked in that task:
8
9
  // no placeholder flash, no fade;
@@ -12,6 +13,7 @@
12
13
  // filter), at low priority, behind a cap.
13
14
  // Browser only, in the widgets' chunks: never in the entry or the server renderer.
14
15
 
16
+ import { CONVO } from './features';
15
17
  import type { ImageInfo } from './images';
16
18
 
17
19
  const SEL = 'img[data-lbx-img]:not([data-lbx-loaded])';
@@ -24,18 +26,18 @@ const loaded = (img: Element, how: '' | 'fade') => {
24
26
  img.closest('[data-lbx-ph]')?.setAttribute('data-lbx-loaded', how);
25
27
  };
26
28
 
27
- let watching = false;
28
- const watch = () => {
29
- if (watching) return;
30
- watching = true;
31
- const on = (e: Event) => {
32
- const t = e.target as Element;
33
- if (t?.nodeType !== 1 || t.localName !== 'img' || !t.hasAttribute('data-lbx-img') || t.hasAttribute('data-lbx-loaded')) return;
34
- loaded(t, performance.now() - renderedAt < CACHED_MS ? '' : 'fade');
35
- };
36
- // load and error do not bubble: capture sees every image of the document.
37
- document.addEventListener('load', on, true);
38
- document.addEventListener('error', on, true);
29
+ const on = (e: Event) => {
30
+ const t = e.target as Element;
31
+ if (t?.nodeType !== 1 || t.localName !== 'img' || !t.hasAttribute('data-lbx-img') || t.hasAttribute('data-lbx-loaded')) return;
32
+ loaded(t, performance.now() - renderedAt < CACHED_MS ? '' : 'fade');
33
+ };
34
+ const watching = new WeakSet<Node>();
35
+ /** load and error do not bubble: capture sees every image of the document, or of a shadow root (theirs never leave it). */
36
+ const watch = (root: Node) => {
37
+ if (watching.has(root)) return;
38
+ watching.add(root);
39
+ root.addEventListener('load', on, true);
40
+ root.addEventListener('error', on, true);
39
41
  };
40
42
 
41
43
  /**
@@ -44,7 +46,12 @@ const watch = () => {
44
46
  * when `fade` is on. Server-rendered markup without JavaScript has no fade.
45
47
  */
46
48
  export const settleImages = (root: Element, fade = true) => {
47
- watch();
49
+ watch(document);
50
+ // Only the conversation's dialog renders images in a shadow root (core/features.ts).
51
+ if (CONVO) {
52
+ const sr = root.getRootNode();
53
+ if (sr instanceof ShadowRoot) watch(sr);
54
+ }
48
55
  renderedAt = performance.now();
49
56
  for (const img of root.querySelectorAll<HTMLImageElement>(SEL)) if (img.complete && img.getAttribute('src')) loaded(img, '');
50
57
  if (fade && !root.hasAttribute('data-lbx-fade')) root.setAttribute('data-lbx-fade', '');
@@ -5,13 +5,13 @@
5
5
  // templates, translations and helpers the bundle would.
6
6
 
7
7
  import type { LumaApi, LumaConfigType, LumaInitType } from '../types';
8
- import { AUTOCOMPLETE, HOST_STYLES, INITS, PLP, RECOMMENDER, SEARCH, TRANSLATIONS, VARIANTS } from './features';
8
+ import { AUTOCOMPLETE, CONVO, HOST_STYLES, INITS, PLP, RECOMMENDER, SEARCH, TRANSLATIONS, VARIANTS } from './features';
9
9
 
10
10
  export type Lazy<T> = Record<string, () => Promise<T>>;
11
11
 
12
12
  export const folderOf = (path: string) => path.split('/').slice(-2)[0];
13
13
  export const nameOf = (path: string) => path.split('/').pop()!.replace(/^(template|style)-/, '').replace(/\.\w+$/, '');
14
- const KINDS = ['autocomplete', 'search', 'plp', 'recommender'];
14
+ const KINDS = ['autocomplete', 'search', 'plp', 'recommender', 'convo'];
15
15
 
16
16
  /**
17
17
  * Each listing's folders (template and style precedence) and translation
@@ -104,7 +104,7 @@ export const loadConfig = async (files: Pick<IntegrationFiles, 'configs' | 'init
104
104
  const Luma: LumaApi = { version, config };
105
105
  if (globalInit && (await globalInit(config, Luma)) === false) return null;
106
106
  if (config._global.disabled || !config._global.publishableKey || !config._global.channelId) return null;
107
- const apps = [AUTOCOMPLETE ? config.autocomplete : null, SEARCH ? config.search : null, PLP ? config.plp : null, ...(RECOMMENDER ? Object.values(config.recommenders ?? {}) : [])];
107
+ const apps = [AUTOCOMPLETE ? config.autocomplete : null, SEARCH ? config.search : null, PLP ? config.plp : null, CONVO ? config.convo : null, ...(RECOMMENDER ? Object.values(config.recommenders ?? {}) : [])];
108
108
  if (INITS) for (const app of apps) if (app && !app.disabled && app.initialize && (await (app.initialize as any)(app, Luma)) === false) app.disabled = true;
109
109
  // `variants` comes into a build from a folder's _config.ts only (build/vite.mjs
110
110
  // featureDefines): set by an _init.ts, a build would leave it out unsaid.
package/src/core/own.ts CHANGED
@@ -5,6 +5,15 @@ import { DOCUMENT_REACT } from './frameworks';
5
5
 
6
6
  export const OWN_ATTR = 'data-lbx';
7
7
 
8
+ /**
9
+ * Elements Luma created, by identity (an attribute, `data-lbx`, can be put on
10
+ * the page by anyone who can put markup there: a review keeping data-*):
11
+ * what is inside one of them is ours (a template's), what is not is the
12
+ * site's. Read by the conversational search: a question asked at once only
13
+ * from a trigger of ours (widgets/convo).
14
+ */
15
+ export const created = new WeakSet<Element>();
16
+
8
17
  /**
9
18
  * Whether a node of ours the host removes is put back before the next paint
10
19
  * (core/sense.ts, scheduleNow): from when we have something to put back, a
@@ -90,5 +90,9 @@
90
90
  "close": "Zavřít",
91
91
  "title": "Doporučujeme"
92
92
  },
93
- "contrast": "Režim vysokého kontrastu"
93
+ "contrast": "Režim vysokého kontrastu",
94
+ "assist": {
95
+ "title": "Asistent má odpověď",
96
+ "continue": "Pokračovat"
97
+ }
94
98
  }
@@ -84,5 +84,9 @@
84
84
  "close": "Schließen",
85
85
  "title": "Empfehlungen für Sie"
86
86
  },
87
- "contrast": "Hoher Kontrast"
87
+ "contrast": "Hoher Kontrast",
88
+ "assist": {
89
+ "title": "Der Assistent hat eine Antwort",
90
+ "continue": "Weiter"
91
+ }
88
92
  }
@@ -84,5 +84,9 @@
84
84
  "close": "Close",
85
85
  "title": "Recommended for you"
86
86
  },
87
- "contrast": "High-contrast mode"
87
+ "contrast": "High-contrast mode",
88
+ "assist": {
89
+ "title": "The assistant has an answer",
90
+ "continue": "Continue"
91
+ }
88
92
  }