@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.
@@ -0,0 +1,713 @@
1
+ // The conversational search: a modal dialog where the shopper asks in their
2
+ // own words and Luigi's Box's assistant answers, streamed, with text, products,
3
+ // follow-up suggestions, questions and comparisons. Loaded by the entry on
4
+ // intent (a pointer over or focus on an element with `data-lbx-convo`) and
5
+ // opened by a click on one; a site without integration/convo/ has none of it.
6
+ //
7
+ // - The conversation is the conversation service's (protocol.ts), keyed by
8
+ // the site's tracker id; its products are looked up by their identity on
9
+ // the channel's collection surface (`idField`), so a tile is the catalog's
10
+ // and the integration's template, as every other widget's.
11
+ // - It lasts the tab's session across pages (sessionStorage, `lbx:convo`),
12
+ // about as long as the service keeps it: a product opened from it and Back,
13
+ // or the next page's trigger, shows the same conversation.
14
+ // - Where the site's assistant is off (the eligibility check, a 403, the
15
+ // month's cap), the dialog says so, and the entry hides the triggers on the
16
+ // session's later pages (`lbx:convo:on`).
17
+ // - Our markup lives in a shadow root (the house design, `style-convo.css`,
18
+ // then the integration's): the site's CSS cannot reach into the dialog.
19
+
20
+ import { liveRegion, useContrast } from '../../core/a11y';
21
+ import { visitorId } from '../../api/token';
22
+ import { CONVO_ASSIST, NAMED_FADE } from '../../core/features';
23
+ import type { T } from '../../core/i18n';
24
+ import { mark, reportError } from '../../core/health';
25
+ import { created } from '../../core/own';
26
+ import { first } from '../../core/result';
27
+ import { schedule } from '../../core/scheduler';
28
+ import { onUrlChange } from '../../core/sense';
29
+ import { lru, session } from '../../core/storage';
30
+ import { cspNonce, setCss } from '../../core/styles';
31
+ import { settleImages } from '../../core/support';
32
+ import { byName, view } from '../../core/templates';
33
+ import { watch } from '../../core/tiles';
34
+ import type { WidgetContext } from '../../main';
35
+ import type { ConvoAction, ConvoBlock, ConvoConfigType, ConvoMessage, ConvoScope, Helpers, Result } from '../../types';
36
+ import { answerFor, drop, hold, type Answer } from './assist';
37
+ import { md } from './markdown';
38
+ import { addBlock, contextOf, endpointOf, events, failure, StreamError, turnBody, words, type Event, type Input, type Intent } from './protocol';
39
+ import STRINGS from './strings';
40
+ import CSS from './style-convo.css?raw';
41
+
42
+ export const templates = byName(import.meta.glob('./template-*.ts', { eager: true, import: 'default' }));
43
+
44
+ type Ctx = WidgetContext<ConvoConfigType>;
45
+
46
+ /** The conversation, this tab's (sessionStorage). */
47
+ const KEY = 'lbx:convo';
48
+ /** The site's assistant is on (true) or off (false) this session; unknown until asked. Read by the entry (main.ts). */
49
+ const ON = 'lbx:convo:on';
50
+ /** The service keeps an idle conversation for about 75 minutes: ours is forgotten a quarter of an hour sooner. */
51
+ const TTL = 60 * 60e3;
52
+ /** A turn's stream: no bytes for 30 s, or 2 minutes in all, and it has failed. */
53
+ const IDLE_MS = 30e3;
54
+ const TURN_MS = 120e3;
55
+ /** Messages kept (a question and its answer are two). */
56
+ const MAX = 40;
57
+
58
+ interface Saved {
59
+ id: string;
60
+ /** The service has accepted a turn of it: the next one converses. */
61
+ committed: boolean;
62
+ messages: ConvoMessage[];
63
+ refinements: ConvoScope['refinements'];
64
+ revision: number;
65
+ state: 'ready' | 'ended' | 'final';
66
+ /** The last failed turn, to send again (`retry`). */
67
+ failed: Intent | null;
68
+ at: number;
69
+ }
70
+ const fresh = (): Saved => ({
71
+ id: crypto.randomUUID(),
72
+ committed: false,
73
+ messages: [],
74
+ refinements: [],
75
+ revision: -1,
76
+ state: 'ready',
77
+ failed: null,
78
+ at: Date.now(),
79
+ });
80
+
81
+ /** What sessionStorage holds, if it is a conversation of ours young enough to go on with (storage is never trusted to be ours). */
82
+ const restore = (): Saved => {
83
+ const v = session.get<Saved>(KEY);
84
+ if (!v || typeof v !== 'object' || typeof v.id !== 'string' || !Array.isArray(v.messages) || !(Date.now() - v.at < TTL)) return fresh();
85
+ const messages = v.messages.filter((m) => m && (m.role === 'user' || m.role === 'assistant') && typeof m.text === 'string' && Array.isArray(m.blocks));
86
+ // A turn the last page left on its way did not finish.
87
+ for (const m of messages) if (m.status === 'streaming') Object.assign(m, { status: 'failed', error: 'error', retry: !!v.failed });
88
+ const state = v.state === 'ended' || v.state === 'final' ? v.state : 'ready';
89
+ const failed = v.failed && typeof v.failed.key === 'string' && v.failed.input && typeof v.failed.input === 'object' ? v.failed : null;
90
+ return {
91
+ id: v.id,
92
+ committed: v.committed === true,
93
+ messages,
94
+ refinements: Array.isArray(v.refinements) ? v.refinements : [],
95
+ revision: Number(v.revision) || -1,
96
+ state,
97
+ failed,
98
+ at: v.at,
99
+ };
100
+ };
101
+
102
+ let s: Saved | null = null;
103
+ let busy = false;
104
+ let ctl: AbortController | null = null;
105
+ /** The turn in flight was cut by the shopper's Stop (not a deadline, not the page going away). */
106
+ let stopped = false;
107
+ /** The site's assistant is off this session (`ON`): read once, kept in step with what is learnt. */
108
+ let off = false;
109
+ const setOn = (v: boolean) => {
110
+ off = !v;
111
+ session.set(ON, v);
112
+ };
113
+
114
+ let dialog: HTMLDialogElement | null = null;
115
+ let host: HTMLElement;
116
+ let root: ShadowRoot;
117
+ let box: HTMLElement;
118
+ let v: ReturnType<typeof view>;
119
+ let live: ReturnType<typeof liveRegion>;
120
+ let opener: Element | null = null;
121
+ let t: T;
122
+ let scope: Omit<ConvoScope, 'messages' | 'busy' | 'state' | 'refinements' | 'include'>;
123
+ const NO_ACTIONS: ConvoAction[] = [];
124
+ let draw = (_opening?: boolean) => {};
125
+
126
+ // --- products: the identities the conversation names, as the catalog's results
127
+ const products = lru<Result | null>(500, Infinity);
128
+ const asking = new Set<string>();
129
+ const idOf = (p: any) => String(p?.identity ?? '');
130
+ const shown = () => (s?.messages ?? []).flatMap((m) => m.blocks.flatMap(productsOf));
131
+ const productsOf = (b: ConvoBlock) => (Array.isArray(b.products) ? b.products.map((p) => products.get(idOf(p))).filter((r): r is Result => !!r) : []);
132
+ /** The products of blocks, looked up once each per page: one request per 50 (a conversation read back has many). */
133
+ const hydrate = (ctx: Ctx, list: unknown) => {
134
+ if (!Array.isArray(list)) return;
135
+ const all = [...new Set(list.map(idOf))].filter((id) => id && products.get(id) === undefined && !asking.has(id));
136
+ for (let i = 0; i < all.length; i += 50) ask(ctx, all.slice(i, i + 50));
137
+ };
138
+ const ask = (ctx: Ctx, ids: string[]) => {
139
+ const c = ctx.config;
140
+ for (const id of ids) asking.add(id);
141
+ ctx.api
142
+ .search({
143
+ endpoint: 'collections',
144
+ surface: c.surface,
145
+ type: c.type ?? ctx.global.itemType ?? 'product',
146
+ scope: { [c.idField]: { in: ids } },
147
+ size: ids.length,
148
+ fields: c.fields?.length ? [...new Set([...c.fields, c.idField])] : undefined,
149
+ })
150
+ .then((page) => {
151
+ for (const h of page.hits) products.set(String(first(h[c.idField])), ctx.result(h));
152
+ // Not in this channel's catalog: no tile (the service and the catalog may differ for a while).
153
+ for (const id of ids) if (products.get(id) === undefined) products.set(id, null);
154
+ })
155
+ .catch((e) => reportError('convo products', e))
156
+ .finally(() => {
157
+ for (const id of ids) asking.delete(id);
158
+ draw();
159
+ });
160
+ };
161
+
162
+ const save = () => s && session.set(KEY, s);
163
+
164
+ // --- turns
165
+ const endpoint = (ctx: Ctx) => endpointOf(ctx.config);
166
+
167
+ /** Asked once a session, when the dialog first opens: a site over its month's budget, or without a catalog, says so at once. */
168
+ let checked = false;
169
+ const check = (ctx: Ctx) => {
170
+ if (checked || session.get(ON) != null) return;
171
+ checked = true;
172
+ const c = new AbortController();
173
+ setTimeout(() => c.abort(), 8000);
174
+ fetch(`${endpoint(ctx)}/eligibility_check`, { signal: c.signal })
175
+ .then((r) => (r.ok ? r.json() : r.status === 403 ? { data: { eligible: false } } : null))
176
+ .then((j) => {
177
+ if (j == null) return; // the service's trouble: the first turn will tell
178
+ setOn(j?.data?.eligible === true);
179
+ draw();
180
+ })
181
+ .catch(() => {});
182
+ };
183
+
184
+ const fail = (m: ConvoMessage, error: string, retry: boolean, intent: Intent | null) => {
185
+ m.status = 'failed';
186
+ m.error = error;
187
+ m.retry = retry;
188
+ s!.failed = retry ? intent : null;
189
+ if (error === 'unavailable') setOn(false);
190
+ };
191
+
192
+ /**
193
+ * A turn: the intent sent to /turn, or (`from`) an answer /assist already has
194
+ * (its events replayed and followed: the same conversation, nothing asked
195
+ * again). Resolves once it has ended.
196
+ */
197
+ const turn = (ctx: Ctx, intent: Intent, from?: Answer) => {
198
+ const st = s!;
199
+ const m: ConvoMessage = {
200
+ role: 'assistant',
201
+ text: '',
202
+ blocks: [],
203
+ status: 'streaming',
204
+ };
205
+ st.messages.push(m);
206
+ st.failed = null;
207
+ st.at = Date.now();
208
+ busy = true;
209
+ // The wait, said (a turn may take a while; "Thinking" in the log is not live).
210
+ live.say(t('thinking'));
211
+ stopped = false;
212
+ const c = (ctl = new AbortController());
213
+ let idle = 0;
214
+ const kick = () => {
215
+ clearTimeout(idle);
216
+ idle = window.setTimeout(() => c.abort(), IDLE_MS);
217
+ };
218
+ const total = window.setTimeout(() => c.abort(), TURN_MS);
219
+ const g = ctx.global;
220
+ const cfg = ctx.config;
221
+ const apply = (type: string, p: any) => {
222
+ switch (type) {
223
+ case 'turn.accepted':
224
+ st.committed = true;
225
+ break;
226
+ case 'text.delta':
227
+ case 'render.block':
228
+ hydrate(ctx, addBlock(m.blocks, type, p)?.products);
229
+ break;
230
+ case 'conversation.state':
231
+ // Snapshots may come out of order: an older one is dropped.
232
+ if (Number(p?.revision) >= st.revision) {
233
+ st.revision = Number(p.revision);
234
+ st.refinements = Array.isArray(p.refinements) ? p.refinements : [];
235
+ }
236
+ break;
237
+ case 'turn.limitation':
238
+ m.error = 'limit';
239
+ break;
240
+ case 'turn.completed':
241
+ m.status = 'done';
242
+ break;
243
+ case 'turn.error':
244
+ // The month's cap does not clear by retrying; a rate limit does.
245
+ fail(m, p?.code === 'cap_exceeded' ? 'unavailable' : p?.code === 'rate_limited' ? 'busy' : 'error', p?.code !== 'cap_exceeded' && !!p?.retriable, intent);
246
+ break;
247
+ case 'turn.abandoned':
248
+ fail(m, 'error', false, null);
249
+ break;
250
+ case 'turn.ignored': {
251
+ // A control from an older answer: the question stands, unanswered.
252
+ st.messages.splice(st.messages.indexOf(m), 1);
253
+ const q = st.messages[st.messages.length - 1];
254
+ if (q?.role === 'user') q.status = 'ignored';
255
+ break;
256
+ }
257
+ case 'conversation.terminated':
258
+ st.state = p?.can_start_new_conversation === false ? 'final' : 'ended';
259
+ if (m.status === 'streaming') m.status = 'done';
260
+ break;
261
+ }
262
+ };
263
+ // Respond first: the question is drawn in the click's task, the request goes in the next.
264
+ // /assist's answer replayed (convo.assist): chosen out of the try, where a build flag does not fold.
265
+ const replay = CONVO_ASSIST ? from : undefined;
266
+ // A replayed answer cut (Stop, a deadline): its request goes too.
267
+ const cut = CONVO_ASSIST ? (from ? () => drop(from.query, true) : null) : null;
268
+ return new Promise<void>((done) =>
269
+ setTimeout(async () => {
270
+ try {
271
+ kick();
272
+ let src: AsyncIterable<Event>;
273
+ // Stop and the deadlines reach a replayed answer too (its signal).
274
+ if (replay) src = replay.stream(c.signal);
275
+ else {
276
+ const r = await fetch(`${endpoint(ctx)}/turn`, {
277
+ method: 'POST',
278
+ headers: {
279
+ 'Content-Type': 'application/json',
280
+ Accept: 'text/event-stream',
281
+ },
282
+ body: JSON.stringify(turnBody(st.id, st.committed, intent, contextOf(g, cfg, visitorId()))),
283
+ signal: c.signal,
284
+ });
285
+ if (!r.ok || !r.body) {
286
+ const f = failure(r.status);
287
+ reportError('convo turn', `HTTP ${r.status} ${(await r.text().catch(() => '')).slice(0, 300)}`);
288
+ return fail(m, f.error, f.retry, intent);
289
+ }
290
+ src = events(r.body, kick);
291
+ }
292
+ for await (const e of src) {
293
+ if (s !== st) return; // started over meanwhile
294
+ kick();
295
+ apply(e.event_type, e.payload);
296
+ later();
297
+ }
298
+ } catch (e) {
299
+ // Stop or a deadline (the conversation unchanged): not Start over, which leaves the page's box its answer.
300
+ if (cut && c.signal.aborted && s === st) cut();
301
+ if (s !== st) return;
302
+ if (stopped) fail(m, 'stopped', false, null);
303
+ else {
304
+ fail(m, 'error', true, intent);
305
+ if (e instanceof StreamError) reportError('convo stream', e.message);
306
+ else if (!c.signal.aborted) reportError('convo turn', e);
307
+ }
308
+ } finally {
309
+ clearTimeout(idle);
310
+ clearTimeout(total);
311
+ if (ctl === c) {
312
+ ctl = null;
313
+ busy = false;
314
+ }
315
+ if (s === st) {
316
+ if (m.status === 'streaming') m.status = 'done';
317
+ st.at = Date.now();
318
+ if (st.messages.length > MAX) st.messages.splice(0, st.messages.length - MAX);
319
+ save();
320
+ draw();
321
+ // The answer, said once it is whole (the log is not live: its words come in pieces),
322
+ // as words without the Markdown's marks, cut at a sentence after 500 characters.
323
+ const said = words(m.blocks.map((b) => (b.type === 'text' ? b.text : '')).join('\n\n'));
324
+ const cut = said.length > 500 ? `${said.slice(0, said.lastIndexOf('. ', 500) + 1 || 500)} …` : said;
325
+ const asked = st.messages[st.messages.length - 1];
326
+ live.say(asked?.status === 'ignored' && !st.messages.includes(m) ? t('ignored') : m.error ? `${cut} ${t(m.error)}`.trim() : cut || (m.blocks.some((b) => Array.isArray(b.products)) ? t('products') : ''));
327
+ mark(`convo turn ${m.status}`);
328
+ }
329
+ done();
330
+ }
331
+ }, 0),
332
+ );
333
+ };
334
+
335
+ const send = (ctx: Ctx, input: Input, label: string) => {
336
+ if (busy || !s || s.state !== 'ready' || off || !label.trim()) return;
337
+ // A first turn tried and never accepted (stopped, cut, failed before the stream): the
338
+ // service may hold its id under that turn's key; a new question with another key would be
339
+ // refused for good (400). A new question, then: a new conversation.
340
+ if (!s.committed && s.messages.some((m) => m.role === 'assistant')) ((s.id = crypto.randomUUID()), (s.failed = null));
341
+ s.messages.push({
342
+ role: 'user',
343
+ text: label.trim(),
344
+ blocks: [],
345
+ status: 'done',
346
+ });
347
+ turn(ctx, { input, key: crypto.randomUUID() });
348
+ draw();
349
+ toEnd();
350
+ };
351
+
352
+ /** The log at its end (a column-reverse scroller: 0 is its end), a frame later: never a layout read in the click's task. */
353
+ const toEnd = () =>
354
+ requestAnimationFrame(() => {
355
+ const log = box.querySelector('[data-lbx-convo-log]');
356
+ if (log) log.scrollTop = 0;
357
+ });
358
+
359
+ /** At most one draw a frame while an answer streams in. */
360
+ let queued = false;
361
+ const later = () => {
362
+ if (queued) return;
363
+ queued = true;
364
+ requestAnimationFrame(() => {
365
+ queued = false;
366
+ draw();
367
+ });
368
+ };
369
+
370
+ const close = () => dialog?.close();
371
+
372
+ /**
373
+ * The site's accent may be any colour: text in it made dark enough for 4.5:1
374
+ * on its tint (toward black on a light panel, toward white on a dark one),
375
+ * what stands on it white or black, whichever reads better. The browser
376
+ * resolves the tokens as the host's own colours (the host draws nothing: its
377
+ * shadow content sets its own; `color-mix(… srgb)` reports any colour space
378
+ * as sRGB, `color(srgb r g b)` 0-1, rgb() in older engines), read a frame
379
+ * later, before the first paint. A few lines of its own: core/color.ts shared
380
+ * with the autocomplete would move into lbx-core, every widget's.
381
+ */
382
+ const readable = (el: HTMLElement) => {
383
+ el.style.color = 'color-mix(in srgb, var(--lbx-cv-accent) 100%, transparent)';
384
+ el.style.backgroundColor = 'var(--lbx-cv-bg)';
385
+ requestAnimationFrame(() => {
386
+ const cs = getComputedStyle(el);
387
+ const rgb = (c: string) => {
388
+ const m = /(rgba?\(|srgb\s+)([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/.exec(c);
389
+ return m ? m.slice(2).map((v) => Number(v) * (m[1][0] === 's' ? 255 : 1)) : [];
390
+ };
391
+ const lum = (c: number[]) => c.map((v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)).reduce((l, v, i) => l + v * [0.2126, 0.7152, 0.0722][i], 0);
392
+ const a = rgb(cs.color);
393
+ const bg = rgb(cs.backgroundColor);
394
+ if (a.length < 3 || bg.length < 3) return;
395
+ const mix = (x: number[], y: number[], w: number) => x.map((v, i) => Math.round(v * w + y[i] * (1 - w)));
396
+ const tint = lum(mix(a, bg, 0.1));
397
+ const to = tint < 0.18 ? [255, 255, 255] : [0, 0, 0];
398
+ const ratio = (c: number[]) => {
399
+ const [x, y] = [lum(c), tint].sort((p, q) => q - p);
400
+ return (x + 0.05) / (y + 0.05);
401
+ };
402
+ let text = a;
403
+ for (let k = 0.92; k > 0 && ratio(text) < 4.5; k -= 0.08) text = mix(a, to, k);
404
+ el.style.setProperty('--lbx-cv-accent-text', `rgb(${text})`);
405
+ // White or black, whichever reads better on it: never under 4.58:1 (a fixed cut at ~0.18 picked the worse
406
+ // near it, and a near-black instead of black left a band of accents where neither reached 4.5:1).
407
+ const la = lum(a);
408
+ el.style.setProperty('--lbx-cv-on-accent', 1.05 / (la + 0.05) >= (la + 0.05) / 0.05 ? '#fff' : '#000');
409
+ });
410
+ };
411
+
412
+ const build = (ctx: Ctx) => {
413
+ host = document.createElement('lbx-convo');
414
+ host.setAttribute('data-lbx', 'convo');
415
+ root = host.attachShadow({ mode: 'open' });
416
+ // Elements, not innerHTML: sites enforcing Trusted Types reject string HTML.
417
+ const style = document.createElement('style');
418
+ style.nonce = cspNonce();
419
+ style.textContent = [CSS, ...ctx.styles].join('\n');
420
+ dialog = document.createElement('dialog');
421
+ dialog.className = 'lbx-cv';
422
+ dialog.setAttribute('aria-labelledby', 'lbx-cv-title');
423
+ dialog.setAttribute('aria-describedby', 'lbx-cv-note');
424
+ box = document.createElement('div');
425
+ box.className = 'lbx-cv__box';
426
+ live = liveRegion();
427
+ dialog.append(box, live.el);
428
+ root.append(style, dialog);
429
+ document.body.append(host);
430
+ v = view(box, ctx.templates);
431
+ // The high-contrast mode's palette (--lbx-c-*), as every widget at its start: this may be the page's only one.
432
+ useContrast();
433
+ readable(host);
434
+ const fade = NAMED_FADE ? ctx.global.images?.fade !== false : true;
435
+ // Markdown parsed once per finished text (an answer streaming in is drawn every frame, its text new each time).
436
+ const parsed = lru<unknown>(200, Infinity);
437
+ // The dialog's words (strings.ts) under the site's translations.
438
+ const own = STRINGS[(ctx.global.locale ?? 'en').split('-')[0]] ?? STRINGS.en;
439
+ t = (k, p) => {
440
+ const x = ctx.t(k, p);
441
+ return x === k && own[k] ? own[k] : x;
442
+ };
443
+ const act = (a: ConvoAction) => {
444
+ if (a?.action_ref) send(ctx, { kind: 'action', action_ref: a.action_ref }, String(a.label ?? ''));
445
+ };
446
+ scope = {
447
+ ...(ctx.helpers as unknown as Omit<Helpers, 'include'>),
448
+ t,
449
+ send: (text) => send(ctx, { kind: 'text', text: text.trim() }, text),
450
+ act,
451
+ answer: (ref, text) => send(ctx, { kind: 'answer', text: text.trim(), question_ref: ref }, text),
452
+ retry: () => {
453
+ const f = s?.failed;
454
+ if (busy || !f || !s) return;
455
+ const last = s.messages[s.messages.length - 1];
456
+ if (last?.role === 'assistant' && last.status === 'failed') s.messages.pop();
457
+ turn(ctx, f);
458
+ draw();
459
+ },
460
+ stop: () => {
461
+ stopped = true;
462
+ ctl?.abort();
463
+ },
464
+ startOver: () => {
465
+ ctl?.abort();
466
+ ctl = null;
467
+ busy = false;
468
+ s = fresh();
469
+ save();
470
+ draw();
471
+ box.querySelector<HTMLInputElement>('input')?.focus();
472
+ },
473
+ close,
474
+ products: productsOf,
475
+ actionsOf: (b, r) => {
476
+ const a = Array.isArray(b.products) ? b.products.find((p) => products.get(idOf(p)) === r)?.actions : null;
477
+ return Array.isArray(a) ? a : NO_ACTIONS;
478
+ },
479
+ md: (text) => {
480
+ let out = parsed.get(text);
481
+ if (out === undefined) {
482
+ out = md(text);
483
+ if (!busy) parsed.set(text, out);
484
+ }
485
+ return out;
486
+ },
487
+ };
488
+ // The panel drawn only while it is open (show() draws it as it opens): a box streaming on the search page redraws itself alone.
489
+ draw = (opening = false) => {
490
+ if (CONVO_ASSIST) drawPreview(ctx);
491
+ if (!s || !dialog || !(opening || dialog.open)) return;
492
+ try {
493
+ const had = root.activeElement as HTMLButtonElement | null;
494
+ v.render('convo-box', {
495
+ ...scope,
496
+ messages: s.messages,
497
+ busy,
498
+ state: off ? 'unavailable' : s.state,
499
+ refinements: s.refinements,
500
+ });
501
+ settleImages(box, fade);
502
+ // A control that went with the render (a suggestion used, the input disabled at the end):
503
+ // focus to the log, not to the inert page (no keyboard popping up on a phone either).
504
+ if (had && (!had.isConnected || had.disabled)) box.querySelector<HTMLElement>('[data-lbx-convo-log]')?.focus();
505
+ } catch (e) {
506
+ reportError('convo render', e);
507
+ }
508
+ };
509
+ // Product clicks and add-to-carts on our tiles (core/tiles.ts).
510
+ watch(box, { list: 'lbx_convo', global: ctx.global, results: shown });
511
+ dialog.addEventListener('close', () => {
512
+ setCss('convo:lock', null);
513
+ // Back to the trigger; one of ours hidden meanwhile (the autocomplete's nudge: its dropdown closed as focus
514
+ // left it) gives it to the site's search field. A site's trigger it removed: left to the browser.
515
+ const shown = opener instanceof HTMLElement && opener.isConnected && opener.getClientRects().length;
516
+ const back = shown ? opener : ours(opener) && ctx.global.inputSelector ? [...document.querySelectorAll<HTMLElement>(ctx.global.inputSelector)].find((i) => i.getClientRects().length) : null;
517
+ (back as HTMLElement | null | undefined)?.focus();
518
+ mark('convo close');
519
+ });
520
+ // A press on the backdrop (the dialog itself: its content fills it) closes it; a
521
+ // text selection dragged out of the log onto it (its click's target is the dialog too) does not.
522
+ let down: EventTarget | null = null;
523
+ dialog.addEventListener('pointerdown', (e) => (down = e.target));
524
+ // Not a double click's second (on the trigger: the first opened it, the second lands on the backdrop).
525
+ dialog.addEventListener('click', (e) => e.target === dialog && down === dialog && e.detail < 2 && close());
526
+ onUrlChange(close);
527
+ // A turn on its way keeps the page out of the back/forward cache: cut it (sent again from the next page's dialog).
528
+ addEventListener('pagehide', () => {
529
+ // Its catch runs before the page unloads (every engine, tested): the turn is kept as failed, retriable.
530
+ ctl?.abort();
531
+ save();
532
+ });
533
+ };
534
+
535
+ /** A node Luma created (our sparkle, the autocomplete's dropdown and its templates), across shadow roots, by identity: not the site's markup. */
536
+ const ours = (n: Element | null) => {
537
+ for (; n; n = n.parentElement ?? ((n.getRootNode() as ShadowRoot).host || null)) if (created.has(n)) return true;
538
+ return false;
539
+ };
540
+
541
+ /**
542
+ * Called by the entry on a click on a `data-lbx-convo` element: `prompt` is
543
+ * its value. Asked at once from a trigger of ours (an autocomplete template
544
+ * passing the phrase typed); from the site's markup only put in the input,
545
+ * for the shopper to send: text on the page (a review, a crafted link's
546
+ * phrase) is never asked in their name.
547
+ */
548
+ export const open = (ctx: Ctx, prompt: string, trigger: Element | null) => {
549
+ show(ctx, trigger);
550
+ if (!prompt.trim()) return;
551
+ // The autocomplete's nudge: the answer /assist already has for the phrase, taken over.
552
+ if (CONVO_ASSIST) {
553
+ const a = ours(trigger) ? answerFor(prompt) : undefined;
554
+ if (a && !busy) return void adopt(ctx, a);
555
+ }
556
+ // Not while an answer is on its way: then it waits in the input, as the site's do.
557
+ if (ours(trigger) && !busy) scope.send(prompt);
558
+ else {
559
+ const i = box.querySelector<HTMLInputElement>('input[name="q"]');
560
+ if (i && !i.value) i.value = prompt;
561
+ }
562
+ };
563
+
564
+ /** /assist's answer as a new conversation's first turn (its own id: the panel goes on with it). */
565
+ const adopt = (ctx: Ctx, a: Answer) => {
566
+ if (s?.id === a.id) return Promise.resolve();
567
+ hold(a);
568
+ ctl?.abort();
569
+ s = { ...fresh(), id: a.id };
570
+ s.messages.push({ role: 'user', text: a.query, blocks: [], status: 'done' });
571
+ const done = turn(ctx, { input: { kind: 'text', text: a.query }, key: a.key }, a);
572
+ draw();
573
+ toEnd();
574
+ return done;
575
+ };
576
+
577
+ /** The panel, opened (built the first time, the session's conversation read back). */
578
+ const show = (ctx: Ctx, trigger: Element | null) => {
579
+ const first = !s;
580
+ if (!dialog) build(ctx);
581
+ // A host that swaps <body> (Turbo Drive, Astro's view transitions) took ours with it: back in, closed.
582
+ else if (!host.isConnected) {
583
+ dialog.removeAttribute('open');
584
+ setCss('convo:lock', null);
585
+ document.body.append(host);
586
+ }
587
+ if (!s) {
588
+ s = restore();
589
+ off = session.get(ON) === false;
590
+ }
591
+ opener = trigger;
592
+ draw(true);
593
+ if (!dialog!.open) {
594
+ // The page behind does not scroll (its own scrollbar would move under the dialog).
595
+ setCss('convo:lock', ':root{overflow:hidden!important}');
596
+ dialog!.showModal();
597
+ toEnd();
598
+ mark('convo open');
599
+ }
600
+ // Respond first: the requests (the session's check, a conversation's products read back) in the next task.
601
+ setTimeout(() => {
602
+ check(ctx);
603
+ if (first)
604
+ hydrate(
605
+ ctx,
606
+ s!.messages.flatMap((m) => m.blocks.flatMap((b) => (Array.isArray(b.products) ? b.products : []))),
607
+ );
608
+ }, 0);
609
+ };
610
+
611
+ // --- the search page's box: /assist's answer to the query, above the results (convo.assist)
612
+ let pv: {
613
+ el: HTMLElement;
614
+ box: HTMLElement;
615
+ v: ReturnType<typeof view>;
616
+ a: Answer | null;
617
+ on: string;
618
+ off?: () => void;
619
+ } | null = null;
620
+ const drawPreview = (ctx: Ctx) => {
621
+ if (!pv?.a) return;
622
+ const a = pv.a;
623
+ // An answer that broke off (or was cut as the page was left): no box; focus in it goes on to the listing after it, not to <body>.
624
+ if (a.failed) {
625
+ const next = pv.el.nextElementSibling as HTMLElement | null;
626
+ if (pv.el.shadowRoot!.activeElement && next) {
627
+ if (!next.hasAttribute('tabindex')) next.tabIndex = -1;
628
+ next.focus({ preventScroll: true });
629
+ }
630
+ pv.el.style.display = 'none';
631
+ return;
632
+ }
633
+ pv.el.style.display = '';
634
+ try {
635
+ pv.v.render('convo-preview', {
636
+ ...scope,
637
+ query: a.query,
638
+ blocks: a.blocks,
639
+ done: a.done,
640
+ // "Continue with the assistant": the panel, going on with this conversation; a suggestion: that turn next.
641
+ // Focus goes back to the control pressed when the panel closes.
642
+ open: (then?: ConvoAction, from?: Element | null) => {
643
+ show(ctx, from ?? null);
644
+ if (busy && s?.id !== a.id) return;
645
+ void adopt(ctx, a).then(() => then && scope.act(then));
646
+ },
647
+ });
648
+ settleImages(pv.box, NAMED_FADE ? ctx.global.images?.fade !== false : true);
649
+ } catch (e) {
650
+ reportError('convo preview', e);
651
+ }
652
+ };
653
+
654
+ /**
655
+ * Called by the entry on a search page whose query /assist answered: the box
656
+ * above the listing (`lbx-search`, a placement rule: it waits for the page's
657
+ * framework, the host may re-render around it), drawn as the answer streams in.
658
+ * A new query (in place) brings a new answer or none: the box goes with its URL.
659
+ */
660
+ export const preview = CONVO_ASSIST
661
+ ? (ctx: Ctx, a: Answer, at: string): void => {
662
+ // A late answer to a query the page has moved on from: not shown, nor in the place of the current one's.
663
+ if (at !== location.href) return;
664
+ // An answer kept from the autocomplete comes at once: before <body> exists on a page still parsing.
665
+ if (!document.body) return void addEventListener('DOMContentLoaded', () => preview!(ctx, a, at), { once: true });
666
+ hold(a);
667
+ // The panel's scope (its words, products, Markdown) is the box's too; the conversation is read back when it opens.
668
+ if (!dialog) build(ctx);
669
+ if (!pv) {
670
+ const el = document.createElement('lbx-convo-preview');
671
+ const root = el.attachShadow({ mode: 'open' });
672
+ const style = document.createElement('style');
673
+ style.nonce = cspNonce();
674
+ style.textContent = [CSS, ...ctx.styles].join('\n');
675
+ const b = document.createElement('div');
676
+ b.className = 'lbx-cv lbx-cv--preview';
677
+ root.append(style, b);
678
+ pv = { el, box: b, v: view(b, ctx.templates), a: null, on: '' };
679
+ watch(b, {
680
+ list: 'lbx_convo',
681
+ global: ctx.global,
682
+ results: () => (pv?.a ? pv.a.blocks.flatMap(productsOf) : []),
683
+ });
684
+ const p = pv;
685
+ import('../../core/reconciler')
686
+ .then(({ addRule }) => {
687
+ addRule({
688
+ id: 'convo-preview',
689
+ active: (u) => u.href === p.on,
690
+ anchors: ['lbx-search'],
691
+ position: 'before',
692
+ // Its accent made readable once it is in the page (a detached element has no computed colours).
693
+ create: () => (readable(p.el), p.el),
694
+ });
695
+ schedule();
696
+ })
697
+ .catch((e) => reportError('convo preview', e));
698
+ }
699
+ pv.off?.();
700
+ pv.a = a;
701
+ pv.on = at;
702
+ let seen = 0;
703
+ // Its products, looked up as their blocks arrive.
704
+ const on = () => {
705
+ for (; seen < a.blocks.length; seen++) hydrate(ctx, a.blocks[seen].products);
706
+ later();
707
+ };
708
+ pv.off = a.listen(on);
709
+ on();
710
+ drawPreview(ctx);
711
+ schedule();
712
+ }
713
+ : undefined;