@luigisbox/luma 0.7.1 → 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 +13 -0
- package/README.md +113 -2
- package/build/vite.mjs +28 -9
- package/docs/ACCESSIBILITY.md +56 -1
- package/package.json +1 -1
- package/src/core/a11y.ts +11 -4
- package/src/core/context.ts +2 -1
- package/src/core/features.ts +10 -1
- package/src/core/image-load.ts +21 -14
- package/src/core/integration.ts +3 -3
- package/src/core/own.ts +9 -0
- package/src/locales/cs.json +5 -1
- package/src/locales/de.json +5 -1
- package/src/locales/en.json +5 -1
- package/src/main.ts +87 -2
- package/src/types.ts +124 -0
- package/src/widgets/autocomplete/index.ts +64 -8
- package/src/widgets/autocomplete/style-autocomplete.css +31 -0
- package/src/widgets/autocomplete/template-autocomplete-assist.ts +16 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +2 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +2 -0
- package/src/widgets/convo/assist.ts +216 -0
- package/src/widgets/convo/icons.ts +8 -0
- package/src/widgets/convo/index.ts +713 -0
- package/src/widgets/convo/markdown.ts +76 -0
- package/src/widgets/convo/protocol.ts +154 -0
- package/src/widgets/convo/strings.ts +93 -0
- package/src/widgets/convo/style-convo.css +173 -0
- package/src/widgets/convo/template-convo-box.ts +125 -0
- package/src/widgets/convo/template-convo-preview.ts +37 -0
- package/src/widgets/convo/template-convo-result-default.ts +13 -0
- package/src/widgets/convo/trigger.ts +44 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The assistant's Markdown as lit markup: paragraphs, headings, lists, tables,
|
|
2
|
+
// **strong**, *emphasis*, `code` and links. Every piece of text is bound as
|
|
3
|
+
// text (lit's `${}`), never parsed as HTML: whatever the model writes stays
|
|
4
|
+
// words. A link is a link only to a page of this site (the model may be
|
|
5
|
+
// talked into writing any URL); any other stays its text. Rendered again as
|
|
6
|
+
// the answer streams in: a mark not closed yet shows as typed until it is.
|
|
7
|
+
|
|
8
|
+
import { html } from '../../lit';
|
|
9
|
+
|
|
10
|
+
const INLINE = /\*\*(.+?)\*\*|\*([^*\s](?:[^*]*[^*\s])?)\*|`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
11
|
+
|
|
12
|
+
const link = (text: unknown, href: string) => {
|
|
13
|
+
try {
|
|
14
|
+
const u = new URL(href, location.href);
|
|
15
|
+
if (u.origin === location.origin && /^https?:$/.test(u.protocol)) return html`<a href=${u.href}>${text}</a>`;
|
|
16
|
+
} catch {}
|
|
17
|
+
return text;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const inline = (s: string): unknown[] => {
|
|
21
|
+
const out: unknown[] = [];
|
|
22
|
+
let last = 0;
|
|
23
|
+
for (const m of s.matchAll(INLINE)) {
|
|
24
|
+
if (m.index! > last) out.push(s.slice(last, m.index));
|
|
25
|
+
out.push(m[1] != null ? html`<strong>${inline(m[1])}</strong>` : m[2] != null ? html`<em>${inline(m[2])}</em>` : m[3] != null ? html`<code>${m[3]}</code>` : link(inline(m[4]), m[5]));
|
|
26
|
+
last = m.index! + m[0].length;
|
|
27
|
+
}
|
|
28
|
+
if (last < s.length) out.push(s.slice(last));
|
|
29
|
+
return out;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const ITEM = /^\s*([-*+]|\d+[.)])\s+(.*)$/;
|
|
33
|
+
const ROW = /^\s*\|/;
|
|
34
|
+
const cells = (l: string) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
|
|
35
|
+
|
|
36
|
+
export const md = (text: string) => {
|
|
37
|
+
const out: unknown[] = [];
|
|
38
|
+
const lines = text.split('\n');
|
|
39
|
+
for (let i = 0; i < lines.length; ) {
|
|
40
|
+
const l = lines[i];
|
|
41
|
+
let m: RegExpExecArray | null;
|
|
42
|
+
if (!l.trim()) i++;
|
|
43
|
+
else if ((m = /^\s*#{1,6}\s+(.*)$/.exec(l))) {
|
|
44
|
+
out.push(html`<h3>${inline(m[1])}</h3>`);
|
|
45
|
+
i++;
|
|
46
|
+
} else if (ITEM.test(l)) {
|
|
47
|
+
const items: string[] = [];
|
|
48
|
+
const ordered = /\d/.test(ITEM.exec(l)![1]);
|
|
49
|
+
// An indented line that is not an item carries on the item before it.
|
|
50
|
+
for (; i < lines.length && (ITEM.test(lines[i]) || (/^\s+\S/.test(lines[i]) && items.length)); i++) {
|
|
51
|
+
const it = ITEM.exec(lines[i]);
|
|
52
|
+
if (it) items.push(it[2]);
|
|
53
|
+
else items[items.length - 1] += ` ${lines[i].trim()}`;
|
|
54
|
+
}
|
|
55
|
+
const lis = items.map((t) => html`<li>${inline(t)}</li>`);
|
|
56
|
+
out.push(ordered ? html`<ol>${lis}</ol>` : html`<ul>${lis}</ul>`);
|
|
57
|
+
} else if (ROW.test(l)) {
|
|
58
|
+
const rows: string[][] = [];
|
|
59
|
+
for (; i < lines.length && ROW.test(lines[i]); i++) if (!/^[\s|:-]+$/.test(lines[i])) rows.push(cells(lines[i]));
|
|
60
|
+
const [head, ...body] = rows;
|
|
61
|
+
// Separator rows alone (a table's first lines still streaming in): nothing yet.
|
|
62
|
+
if (head)
|
|
63
|
+
out.push(
|
|
64
|
+
html`<div class="lbx-cv__scroll" tabindex="0" role="region" aria-label=${head.join(', ')}><table>
|
|
65
|
+
<thead><tr>${head.map((c) => html`<th scope="col">${inline(c)}</th>`)}</tr></thead>
|
|
66
|
+
<tbody>${body.map((r) => html`<tr>${r.map((c) => html`<td>${inline(c)}</td>`)}</tr>`)}</tbody>
|
|
67
|
+
</table></div>`,
|
|
68
|
+
);
|
|
69
|
+
} else {
|
|
70
|
+
const para: string[] = [];
|
|
71
|
+
for (; i < lines.length && lines[i].trim() && !ITEM.test(lines[i]) && !ROW.test(lines[i]) && !/^\s*#{1,6}\s/.test(lines[i]); i++) para.push(lines[i].trim());
|
|
72
|
+
out.push(html`<p>${inline(para.join(' '))}</p>`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The conversation service's wire contract (CS-RTP 1.0): one POST per turn
|
|
2
|
+
// to `{endpoint}/conversation/{trackerId}/turn`, answered by a stream of
|
|
3
|
+
// server-sent events. Luma renders what it is sent, in its order, and echoes
|
|
4
|
+
// the opaque references (`action_ref`, `question_ref`) back; it never reads
|
|
5
|
+
// meaning into a block. No DOM: unit-tested as it is.
|
|
6
|
+
|
|
7
|
+
const PROTOCOL = '1.0';
|
|
8
|
+
|
|
9
|
+
import type { ConvoBlock, ConvoConfigType, GlobalConfigType } from '../../types';
|
|
10
|
+
|
|
11
|
+
/** The conversation service's routes for the site (`/turn`, `/assist`, `/eligibility_check` under it). */
|
|
12
|
+
export const endpointOf = (cv: ConvoConfigType) => `${(cv.endpoint ?? 'https://conversational-proxy.luigisbox.com/api/v2').replace(/\/+$/, '')}/conversation/${encodeURIComponent(cv.trackerId)}`;
|
|
13
|
+
|
|
14
|
+
export type Input = { kind: 'text'; text: string } | { kind: 'answer'; text: string; question_ref: string } | { kind: 'action'; action_ref: string };
|
|
15
|
+
|
|
16
|
+
/** One thing the shopper asked: a retry sends it again with the same key, so the service replays it rather than answering twice. */
|
|
17
|
+
export interface Intent {
|
|
18
|
+
input: Input;
|
|
19
|
+
key: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Event {
|
|
23
|
+
event_type: string;
|
|
24
|
+
event_seq: number;
|
|
25
|
+
protocol_version: string;
|
|
26
|
+
payload: any;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A turn ends with one of these (a terminated conversation may follow one, or come alone). */
|
|
30
|
+
const CLOSERS = new Set(['turn.completed', 'turn.error', 'turn.abandoned', 'turn.ignored', 'conversation.terminated']);
|
|
31
|
+
|
|
32
|
+
/** The stream broke the contract, or ended before its turn did: the turn failed (retriable). */
|
|
33
|
+
export class StreamError extends Error {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The events of a turn's stream, checked: each a JSON `data:` record, in
|
|
37
|
+
* `event_seq` order (a gap is lost data), of protocol 1.x (an unknown event
|
|
38
|
+
* type of a later 1.x is skipped); the stream must end with a closer.
|
|
39
|
+
* `onChunk` is told of every chunk (the caller's idle deadline).
|
|
40
|
+
*/
|
|
41
|
+
export async function* events(body: ReadableStream<Uint8Array>, onChunk?: () => void): AsyncGenerator<Event> {
|
|
42
|
+
const reader = body.getReader();
|
|
43
|
+
const text = new TextDecoder();
|
|
44
|
+
let buf = '';
|
|
45
|
+
let seq = 0;
|
|
46
|
+
let closed = false;
|
|
47
|
+
const parse = (record: string): Event | null => {
|
|
48
|
+
const data = record
|
|
49
|
+
.split('\n')
|
|
50
|
+
.filter((l) => l.startsWith('data:'))
|
|
51
|
+
.map((l) => l.slice(5).replace(/^ /, ''))
|
|
52
|
+
.join('\n');
|
|
53
|
+
if (!data) return null;
|
|
54
|
+
let e: Event;
|
|
55
|
+
try {
|
|
56
|
+
e = JSON.parse(data);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new StreamError(`not JSON: ${data.slice(0, 100)}`);
|
|
59
|
+
}
|
|
60
|
+
if (typeof e?.event_type !== 'string' || e.event_seq !== ++seq) throw new StreamError(`event ${seq}: ${e?.event_seq} ${e?.event_type}`);
|
|
61
|
+
if (String(e.protocol_version).split('.')[0] !== PROTOCOL.split('.')[0]) throw new StreamError(`protocol ${e.protocol_version}`);
|
|
62
|
+
if (CLOSERS.has(e.event_type)) closed = true;
|
|
63
|
+
return e;
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
for (;;) {
|
|
67
|
+
const { done, value } = await reader.read();
|
|
68
|
+
if (done) break;
|
|
69
|
+
onChunk?.();
|
|
70
|
+
// CRLF as LF, on the whole buffer: a pair split between two chunks too.
|
|
71
|
+
buf = (buf + text.decode(value, { stream: true })).replace(/\r\n/g, '\n');
|
|
72
|
+
let at: number;
|
|
73
|
+
while ((at = buf.indexOf('\n\n')) >= 0) {
|
|
74
|
+
const e = parse(buf.slice(0, at));
|
|
75
|
+
buf = buf.slice(at + 2);
|
|
76
|
+
if (e) yield e;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const e = parse(buf);
|
|
80
|
+
if (e) yield e;
|
|
81
|
+
} finally {
|
|
82
|
+
reader.releaseLock();
|
|
83
|
+
}
|
|
84
|
+
if (!closed) throw new StreamError('ended before its turn did');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The service's identity of a shopper: 10 to 25 digits, from Luma's visitor id (stable across pages). */
|
|
88
|
+
export const deviceId = (visitor: string) =>
|
|
89
|
+
[...visitor]
|
|
90
|
+
.map((c) => c.charCodeAt(0) % 10)
|
|
91
|
+
.join('')
|
|
92
|
+
.slice(0, 25)
|
|
93
|
+
.padEnd(10, '0');
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* An event's part in an answer's blocks: text deltas joined into their text
|
|
97
|
+
* block, in its place among the others (a block id of its own each), a
|
|
98
|
+
* rendered block kept as sent (returned: its products are looked up).
|
|
99
|
+
*/
|
|
100
|
+
export const addBlock = (blocks: ConvoBlock[], type: string, p: any): ConvoBlock | undefined => {
|
|
101
|
+
const last = blocks[blocks.length - 1];
|
|
102
|
+
if (type === 'text.delta') {
|
|
103
|
+
if (last?.type === 'text' && last.block_id === String(p?.block_id)) last.text += String(p?.delta ?? '');
|
|
104
|
+
else blocks.push({ block_id: String(p?.block_id), type: 'text', text: String(p?.delta ?? '') });
|
|
105
|
+
} else if (type === 'render.block' && p && typeof p.type === 'string' && p.type !== 'text') return (blocks.push(p), p);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Who asks and in which language, as the first turn (`initiate`, /assist's too) says it. */
|
|
109
|
+
export const contextOf = (g: GlobalConfigType, cv: ConvoConfigType, visitor: string) => ({
|
|
110
|
+
shop: cv.trackerId,
|
|
111
|
+
locale: cv.locale ?? g.locale ?? 'en',
|
|
112
|
+
device: deviceId(visitor),
|
|
113
|
+
currency: g.currency,
|
|
114
|
+
priceField: cv.priceField,
|
|
115
|
+
availabilityField: cv.availabilityField,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** A turn's request body. The first turn (`initiate`) says who and in which language. */
|
|
119
|
+
export const turnBody = (conversation: string, committed: boolean, intent: Intent, ctx: ReturnType<typeof contextOf>) => ({
|
|
120
|
+
protocol_version: PROTOCOL,
|
|
121
|
+
op: committed ? 'converse' : 'initiate',
|
|
122
|
+
conversation_id: conversation,
|
|
123
|
+
idempotency_key: intent.key,
|
|
124
|
+
request_id: crypto.randomUUID(),
|
|
125
|
+
input: intent.input,
|
|
126
|
+
...(committed
|
|
127
|
+
? {}
|
|
128
|
+
: {
|
|
129
|
+
client_context: { locale: ctx.locale, shop: ctx.shop },
|
|
130
|
+
...(ctx.currency ? { currency: ctx.currency } : {}),
|
|
131
|
+
...(ctx.priceField ? { price_field: ctx.priceField } : {}),
|
|
132
|
+
...(ctx.availabilityField ? { availability_field: ctx.availabilityField } : {}),
|
|
133
|
+
}),
|
|
134
|
+
device_user_id: ctx.device,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* An HTTP failure before the stream, as what the shopper is told (a key of
|
|
139
|
+
* the widget's translations) and whether sending it again may help: 403 the
|
|
140
|
+
* site's assistant is off (no catalog), 404 the conversation expired (about
|
|
141
|
+
* an hour on the service), 429 too many turns, 5xx the service.
|
|
142
|
+
*/
|
|
143
|
+
export const failure = (status: number): { error: string; retry: boolean } =>
|
|
144
|
+
status === 403 ? { error: 'unavailable', retry: false } : status === 404 ? { error: 'gone', retry: false } : status === 429 ? { error: 'busy', retry: true } : { error: 'error', retry: status >= 500 };
|
|
145
|
+
|
|
146
|
+
/** An answer's Markdown as plain words (what a screen reader is told; the autocomplete's nudge shows them). */
|
|
147
|
+
export const words = (text: string) =>
|
|
148
|
+
text
|
|
149
|
+
.replace(/\[([^\]]+)\]\([^)\s]+\)/g, '$1')
|
|
150
|
+
.replace(/\*\*|`|^\s*(#{1,6}|[-*+]|\d+[.)])\s+|^[\s|:-]+$/gm, '')
|
|
151
|
+
.replace(/\s*\|\s*/g, ' ')
|
|
152
|
+
.replace(/\*([^*\s][^*]*)\*/g, '$1')
|
|
153
|
+
.replace(/\s+/g, ' ')
|
|
154
|
+
.trim();
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The dialog's own words, in the languages Luma has dictionaries for (the
|
|
2
|
+
// site's _translations.ts overrides any: `{ de: { convo: { title: '…' } } }`).
|
|
3
|
+
// In the widget's chunk, not Luma's shared dictionary: every widget loads
|
|
4
|
+
// that one, and only a shopper who opens the dialog reads these.
|
|
5
|
+
export default {
|
|
6
|
+
en: {
|
|
7
|
+
continue: 'Continue with the assistant',
|
|
8
|
+
disclaimer: 'AI answers: check the details on the product page',
|
|
9
|
+
log: 'Conversation',
|
|
10
|
+
ignored: 'Not answered: the assistant moved on',
|
|
11
|
+
title: 'Shopping assistant',
|
|
12
|
+
close: 'Close',
|
|
13
|
+
startOver: 'Start over',
|
|
14
|
+
intro: 'Describe what you are looking for in your own words, and the assistant will suggest products.',
|
|
15
|
+
placeholder: 'Ask anything…',
|
|
16
|
+
send: 'Send',
|
|
17
|
+
stop: 'Stop',
|
|
18
|
+
retry: 'Try again',
|
|
19
|
+
you: 'You',
|
|
20
|
+
assistant: 'Assistant',
|
|
21
|
+
thinking: 'Thinking',
|
|
22
|
+
products: 'Products found',
|
|
23
|
+
sources: 'Sources',
|
|
24
|
+
compare: 'Comparison',
|
|
25
|
+
refinements: 'What the assistant goes by',
|
|
26
|
+
error: 'Something went wrong.',
|
|
27
|
+
busy: 'The assistant is busy. Try again in a moment.',
|
|
28
|
+
gone: 'This conversation has expired. Start over to ask again.',
|
|
29
|
+
unavailable: 'The assistant is not available right now.',
|
|
30
|
+
limit: 'The assistant cannot answer this in full.',
|
|
31
|
+
stopped: 'Stopped.',
|
|
32
|
+
ended: 'This conversation has ended.',
|
|
33
|
+
final: 'This conversation has ended and cannot continue.',
|
|
34
|
+
},
|
|
35
|
+
cs: {
|
|
36
|
+
continue: 'Pokračovat s asistentem',
|
|
37
|
+
disclaimer: 'Odpovídá AI: podrobnosti ověřte na stránce produktu',
|
|
38
|
+
log: 'Konverzace',
|
|
39
|
+
ignored: 'Bez odpovědi: asistent už pokračoval',
|
|
40
|
+
title: 'Nákupní asistent',
|
|
41
|
+
close: 'Zavřít',
|
|
42
|
+
startOver: 'Začít znovu',
|
|
43
|
+
intro: 'Popište vlastními slovy, co hledáte, a asistent vám doporučí produkty.',
|
|
44
|
+
placeholder: 'Zeptejte se na cokoli…',
|
|
45
|
+
send: 'Odeslat',
|
|
46
|
+
stop: 'Zastavit',
|
|
47
|
+
retry: 'Zkusit znovu',
|
|
48
|
+
you: 'Vy',
|
|
49
|
+
assistant: 'Asistent',
|
|
50
|
+
thinking: 'Přemýšlím',
|
|
51
|
+
products: 'Nalezené produkty',
|
|
52
|
+
sources: 'Zdroje',
|
|
53
|
+
compare: 'Porovnání',
|
|
54
|
+
refinements: 'Čím se asistent řídí',
|
|
55
|
+
error: 'Něco se pokazilo.',
|
|
56
|
+
busy: 'Asistent je zaneprázdněn. Zkuste to za chvíli.',
|
|
57
|
+
gone: 'Tato konverzace vypršela. Začněte znovu.',
|
|
58
|
+
unavailable: 'Asistent teď není k dispozici.',
|
|
59
|
+
limit: 'Asistent na tohle nemůže odpovědět celé.',
|
|
60
|
+
stopped: 'Zastaveno.',
|
|
61
|
+
ended: 'Tato konverzace skončila.',
|
|
62
|
+
final: 'Tato konverzace skončila a nelze v ní pokračovat.',
|
|
63
|
+
},
|
|
64
|
+
de: {
|
|
65
|
+
continue: 'Mit dem Assistenten weiter',
|
|
66
|
+
disclaimer: 'KI-Antworten: Details auf der Produktseite prüfen',
|
|
67
|
+
log: 'Gespräch',
|
|
68
|
+
ignored: 'Nicht beantwortet: der Assistent ist weiter',
|
|
69
|
+
title: 'Einkaufsassistent',
|
|
70
|
+
close: 'Schließen',
|
|
71
|
+
startOver: 'Neu beginnen',
|
|
72
|
+
intro: 'Beschreiben Sie mit eigenen Worten, was Sie suchen, und der Assistent schlägt Produkte vor.',
|
|
73
|
+
placeholder: 'Fragen Sie etwas…',
|
|
74
|
+
send: 'Senden',
|
|
75
|
+
stop: 'Stopp',
|
|
76
|
+
retry: 'Erneut versuchen',
|
|
77
|
+
you: 'Sie',
|
|
78
|
+
assistant: 'Assistent',
|
|
79
|
+
thinking: 'Denkt nach',
|
|
80
|
+
products: 'Gefundene Produkte',
|
|
81
|
+
sources: 'Quellen',
|
|
82
|
+
compare: 'Vergleich',
|
|
83
|
+
refinements: 'Woran sich der Assistent hält',
|
|
84
|
+
error: 'Etwas ist schiefgelaufen.',
|
|
85
|
+
busy: 'Der Assistent ist ausgelastet. Versuchen Sie es gleich noch einmal.',
|
|
86
|
+
gone: 'Dieses Gespräch ist abgelaufen. Beginnen Sie neu.',
|
|
87
|
+
unavailable: 'Der Assistent ist gerade nicht verfügbar.',
|
|
88
|
+
limit: 'Der Assistent kann das nicht vollständig beantworten.',
|
|
89
|
+
stopped: 'Gestoppt.',
|
|
90
|
+
ended: 'Dieses Gespräch ist beendet.',
|
|
91
|
+
final: 'Dieses Gespräch ist beendet und kann nicht fortgesetzt werden.',
|
|
92
|
+
},
|
|
93
|
+
} as Record<string, Record<string, string>>;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/* The conversation's panel: lives in a shadow root (index.ts), so the site's
|
|
2
|
+
CSS cannot reach it and ours cannot leak. A panel from the right over the
|
|
3
|
+
dimmed page (the whole screen on a phone). Tokens on :host, each
|
|
4
|
+
replaceable by the integration's style-convo.css (`:host { --lbx-cv-accent: #8740bf; }`):
|
|
5
|
+
--lbx-cv-font, --lbx-cv-fg, --lbx-cv-bg, --lbx-cv-accent (the site's brand colour),
|
|
6
|
+
--lbx-cv-muted, --lbx-cv-line, --lbx-cv-field (inputs' edge, 3:1), --lbx-cv-soft, --lbx-cv-width
|
|
7
|
+
(--lbx-cv-accent-text and --lbx-cv-on-accent are Luma's, set by index.ts for the accent)
|
|
8
|
+
Derived from the accent: --tint (a 10% wash: the shopper's bubbles, the
|
|
9
|
+
avatar), --tint-line (chips' edges), --accent-text (accent text on white
|
|
10
|
+
and on the tint, 4.5:1 for a mid-tone accent).
|
|
11
|
+
The --lbx-c-* properties are the high-contrast mode's palette (core/a11y.ts),
|
|
12
|
+
inherited from the document: unset, every rule keeps its normal value. */
|
|
13
|
+
:host {
|
|
14
|
+
--lbx-cv-font: inherit;
|
|
15
|
+
--lbx-cv-fg: #1a1a1a;
|
|
16
|
+
--lbx-cv-bg: #fff;
|
|
17
|
+
--lbx-cv-accent: #1a1a1a;
|
|
18
|
+
--lbx-cv-on-accent: #fff;
|
|
19
|
+
--lbx-cv-muted: #5c5c5c;
|
|
20
|
+
--lbx-cv-line: #e6e6e6;
|
|
21
|
+
--lbx-cv-field: #8a8a8a;
|
|
22
|
+
--lbx-cv-soft: #f6f6f7;
|
|
23
|
+
--lbx-cv-width: 440px;
|
|
24
|
+
}
|
|
25
|
+
.lbx-cv {
|
|
26
|
+
--fg: var(--lbx-c-fg, var(--lbx-cv-fg));
|
|
27
|
+
--bg: var(--lbx-c-bg, var(--lbx-cv-bg));
|
|
28
|
+
--accent: var(--lbx-c-sel-bg, var(--lbx-cv-accent));
|
|
29
|
+
--on-accent: var(--lbx-c-sel-fg, var(--lbx-cv-on-accent));
|
|
30
|
+
/* Made 4.5:1 on the tint for the site's accent by index.ts (--lbx-cv-accent-text); this until then. */
|
|
31
|
+
--accent-text: var(--lbx-c-fg, var(--lbx-cv-accent-text, color-mix(in srgb, var(--lbx-cv-accent) 82%, #000)));
|
|
32
|
+
--tint: var(--lbx-c-bg, color-mix(in srgb, var(--lbx-cv-accent) 10%, var(--lbx-cv-bg)));
|
|
33
|
+
--tint-line: var(--lbx-c-line, color-mix(in srgb, var(--lbx-cv-accent) 40%, var(--lbx-cv-bg)));
|
|
34
|
+
--muted: var(--lbx-c-muted, var(--lbx-cv-muted));
|
|
35
|
+
--line: var(--lbx-c-line, var(--lbx-cv-line));
|
|
36
|
+
--field: var(--lbx-c-line, var(--lbx-cv-field));
|
|
37
|
+
--soft: var(--lbx-cv-soft);
|
|
38
|
+
box-sizing: border-box; margin: 0 0 0 auto; padding: 0; border: 0; border-inline-start: var(--lbx-c-border, 0) solid var(--line);
|
|
39
|
+
width: min(var(--lbx-cv-width), 100%); height: 100vh; height: 100dvh; max-width: none; max-height: none;
|
|
40
|
+
overflow: hidden; background: var(--bg); color: var(--fg);
|
|
41
|
+
font-family: var(--lbx-cv-font); font-size: 15px; line-height: 1.45; text-align: start;
|
|
42
|
+
box-shadow: -12px 0 40px rgb(0 0 0 / .18);
|
|
43
|
+
}
|
|
44
|
+
.lbx-cv::backdrop { background: rgb(0 0 0 / .4); }
|
|
45
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
46
|
+
.lbx-cv[open] { animation: lbx-cv-in .22s ease-out; }
|
|
47
|
+
.lbx-cv[open]::backdrop { animation: lbx-cv-fade .22s ease-out; }
|
|
48
|
+
}
|
|
49
|
+
@keyframes lbx-cv-in { from { transform: translateX(32px); opacity: 0; } }
|
|
50
|
+
@keyframes lbx-cv-fade { from { opacity: 0; } }
|
|
51
|
+
/* A phone (or a short touch screen: a phone in landscape): the whole screen. */
|
|
52
|
+
@media (max-width: 600px), (max-height: 500px) and (pointer: coarse) {
|
|
53
|
+
.lbx-cv { width: 100%; box-shadow: none; }
|
|
54
|
+
}
|
|
55
|
+
/* A very short window (400% zoom): the log keeps the room. */
|
|
56
|
+
@media (max-height: 400px) {
|
|
57
|
+
.lbx-cv__titles p { display: none; }
|
|
58
|
+
}
|
|
59
|
+
/* Resets at no specificity: every class rule below wins over them (a reset of
|
|
60
|
+
ours outranking `.lbx-cv__send` drew its label dark on the dark accent). */
|
|
61
|
+
:where(.lbx-cv) *, :where(.lbx-cv) *::before, :where(.lbx-cv) *::after { box-sizing: border-box; }
|
|
62
|
+
:where(.lbx-cv) :is(h2, h3, p) { margin: 0; }
|
|
63
|
+
:where(.lbx-cv) :is(button, input) { font: inherit; color: inherit; }
|
|
64
|
+
:where(.lbx-cv) button { cursor: pointer; }
|
|
65
|
+
.lbx-cv svg { display: block; width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|
66
|
+
.lbx-cv__box { display: flex; flex-direction: column; height: 100%; }
|
|
67
|
+
.lbx-cv button:disabled { cursor: default; opacity: .55; }
|
|
68
|
+
.lbx-cv :focus-visible { outline: var(--lbx-c-ring, 2px) solid var(--accent); outline-offset: 2px; }
|
|
69
|
+
.lbx-cv__sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
70
|
+
|
|
71
|
+
/* The assistant's mark: a sparkle on the tint. */
|
|
72
|
+
.lbx-cv__avatar { flex: none; display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: var(--tint); color: var(--accent-text); }
|
|
73
|
+
.lbx-cv__avatar svg { width: 18px; height: 18px; fill: currentColor; stroke: none; }
|
|
74
|
+
|
|
75
|
+
.lbx-cv__head { display: flex; align-items: center; gap: 10px; padding: 12px 8px 12px 16px; border-bottom: 1px solid var(--line); }
|
|
76
|
+
.lbx-cv__titles { flex: 1; min-width: 0; }
|
|
77
|
+
.lbx-cv__titles h2 { font-size: 16px; font-weight: 600; line-height: 1.25; }
|
|
78
|
+
.lbx-cv__titles p { font-size: 12px; color: var(--muted); }
|
|
79
|
+
.lbx-cv__icon { flex: none; display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border: 0; border-radius: 50%; background: none; color: var(--muted); }
|
|
80
|
+
.lbx-cv__icon:hover { background: var(--soft); color: var(--fg); }
|
|
81
|
+
|
|
82
|
+
/* Its end at the bottom, and kept there as an answer grows (column-reverse), unless the shopper scrolled up. */
|
|
83
|
+
.lbx-cv__log { flex: 1; display: flex; flex-direction: column-reverse; overflow: auto; padding: 16px; }
|
|
84
|
+
.lbx-cv__log:focus { outline: none; }
|
|
85
|
+
.lbx-cv__msgs { display: flex; flex-direction: column; gap: 18px; }
|
|
86
|
+
.lbx-cv__intro { color: var(--muted); padding: 8px 0; }
|
|
87
|
+
.lbx-cv__q { align-self: flex-end; max-width: 85%; padding: 8px 14px; border: var(--lbx-c-border, 0) solid var(--line); border-radius: 18px 18px 4px 18px; background: var(--tint); color: var(--accent-text); overflow-wrap: anywhere; }
|
|
88
|
+
.lbx-cv__q[data-status="ignored"] { opacity: .7; text-decoration: line-through; }
|
|
89
|
+
.lbx-cv__a { display: flex; gap: 10px; align-items: flex-start; }
|
|
90
|
+
.lbx-cv__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12px; padding-top: 4px; }
|
|
91
|
+
.lbx-cv__text { display: flex; flex-direction: column; gap: 8px; overflow-wrap: anywhere; }
|
|
92
|
+
.lbx-cv__text h3 { font-size: inherit; font-weight: 600; }
|
|
93
|
+
.lbx-cv__text ul, .lbx-cv__text ol { margin: 0; padding-inline-start: 20px; }
|
|
94
|
+
.lbx-cv__text li + li { margin-top: 2px; }
|
|
95
|
+
.lbx-cv__text a { color: var(--accent-text); text-decoration: underline; }
|
|
96
|
+
.lbx-cv__text code { font-family: monospace; font-size: .9em; padding: 1px 4px; border-radius: 4px; background: var(--soft); }
|
|
97
|
+
.lbx-cv__label { font-weight: 600; }
|
|
98
|
+
.lbx-cv__wait, .lbx-cv__sources { color: var(--muted); font-size: 13px; }
|
|
99
|
+
.lbx-cv__notice { padding: 10px 14px; border-radius: 10px; background: var(--soft); border-inline-start: 3px solid var(--line); }
|
|
100
|
+
.lbx-cv__notice[data-variant="warning"], .lbx-cv__notice[data-variant="error"] { border-inline-start-color: #b3261e; }
|
|
101
|
+
.lbx-cv__notice[data-variant="success"] { border-inline-start-color: #1f7a35; }
|
|
102
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
103
|
+
.lbx-cv__wait::after { content: '…'; display: inline-block; animation: lbx-cv-wait 1.2s steps(4) infinite; width: 0; overflow: hidden; vertical-align: bottom; }
|
|
104
|
+
}
|
|
105
|
+
@keyframes lbx-cv-wait { to { width: 1.2em; } }
|
|
106
|
+
|
|
107
|
+
.lbx-cv__chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
108
|
+
.lbx-cv__chip { min-height: 32px; padding: 4px 14px; border: 1px solid var(--tint-line); border-radius: 999px; background: var(--bg); color: var(--accent-text); font-size: 14px; text-align: start; }
|
|
109
|
+
.lbx-cv__chip:hover:not(:disabled) { background: var(--tint); border-color: var(--accent); }
|
|
110
|
+
.lbx-cv__question { display: flex; flex-direction: column; gap: 8px; }
|
|
111
|
+
.lbx-cv__answer { display: flex; gap: 8px; }
|
|
112
|
+
.lbx-cv__answer input { flex: 1; min-width: 0; min-height: 36px; padding: 4px 14px; border: 1px solid var(--field); border-radius: 999px; background: var(--bg); font-size: 16px; }
|
|
113
|
+
|
|
114
|
+
/* Products: rows, the picture left, the title and price beside it, its own controls under. */
|
|
115
|
+
.lbx-cv__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
116
|
+
.lbx-cv__list > li { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid var(--line); border-radius: 12px; background: var(--bg); }
|
|
117
|
+
.lbx-cv__list > li:hover { border-color: var(--tint-line); }
|
|
118
|
+
.lbx-cv__tile { display: flex; gap: 12px; align-items: center; color: inherit; text-decoration: var(--lbx-c-underline, none); }
|
|
119
|
+
.lbx-cv__tile:hover .lbx-cv__title { text-decoration: underline; }
|
|
120
|
+
.lbx-cv__info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
121
|
+
.lbx-cv__title { font-size: 14px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
122
|
+
.lbx-cv__price { font-weight: 600; font-size: 15px; }
|
|
123
|
+
.lbx-cv__more { align-self: flex-start; margin-inline-start: 76px; min-height: 28px; padding: 2px 12px; border: 1px solid var(--tint-line); border-radius: 999px; background: none; color: var(--accent-text); font-size: 13px; }
|
|
124
|
+
.lbx-cv__more:hover { background: var(--tint); }
|
|
125
|
+
.lbx-cv .lbx-img { flex: none; display: block; width: 64px; height: 64px; border-radius: 8px; overflow: hidden; background: var(--soft); }
|
|
126
|
+
.lbx-cv .lbx-img > img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
|
127
|
+
.lbx-cv [data-lbx-ph]:not([data-lbx-loaded]) { background: var(--lbx-ph, var(--lbx-c-ph, #efefef)); }
|
|
128
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
129
|
+
[data-lbx-fade] img[data-lbx-img][loading="lazy"]:not([data-lbx-loaded]) { opacity: 0; }
|
|
130
|
+
[data-lbx-fade] img[data-lbx-loaded="fade"] { animation: lbx-img-in .25s ease-out; }
|
|
131
|
+
}
|
|
132
|
+
@keyframes lbx-img-in { from { opacity: 0; } }
|
|
133
|
+
|
|
134
|
+
.lbx-cv__scroll { overflow-x: auto; }
|
|
135
|
+
.lbx-cv table { border-collapse: collapse; font-size: 13px; }
|
|
136
|
+
.lbx-cv th, .lbx-cv td { padding: 6px 10px; border: 1px solid var(--line); text-align: start; vertical-align: top; }
|
|
137
|
+
.lbx-cv thead th { font-weight: 600; }
|
|
138
|
+
.lbx-cv td[data-presence="missing"] { color: var(--muted); }
|
|
139
|
+
|
|
140
|
+
/* One row, scrolling sideways: many refinements never squeeze the log (a short screen, 400% zoom). */
|
|
141
|
+
.lbx-cv__refine { list-style: none; margin: 0; padding: 8px 16px 0; display: flex; gap: 6px; overflow-x: auto; border-top: 1px solid var(--line); }
|
|
142
|
+
.lbx-cv__refine li { flex: none; display: inline-flex; align-items: center; gap: 2px; padding-inline-start: 10px; border: var(--lbx-c-border, 0) solid var(--line); border-radius: 999px; background: var(--tint); color: var(--accent-text); font-size: 13px; white-space: nowrap; }
|
|
143
|
+
.lbx-cv__refine button { min-width: 28px; min-height: 28px; border: 0; background: none; color: inherit; }
|
|
144
|
+
.lbx-cv__form { display: flex; align-items: center; gap: 8px; padding: 12px 16px 16px; }
|
|
145
|
+
.lbx-cv__form input { flex: 1; min-width: 0; min-height: 46px; padding: 8px 18px; border: 1px solid var(--field); border-radius: 999px; background: var(--bg); font-size: 16px; }
|
|
146
|
+
.lbx-cv__form input:focus-visible { outline-offset: 0; border-color: var(--accent); }
|
|
147
|
+
.lbx-cv input::placeholder { color: var(--muted); opacity: 1; }
|
|
148
|
+
.lbx-cv__send { flex: none; display: flex; align-items: center; justify-content: center; width: 46px; height: 46px; padding: 0; border: 0; border-radius: 50%; background: var(--accent); color: var(--on-accent); }
|
|
149
|
+
.lbx-cv__send svg { width: 22px; height: 22px; stroke-width: 2.2; }
|
|
150
|
+
|
|
151
|
+
/* Windows contrast themes: the panel, the controls and the bubbles keep their edges. */
|
|
152
|
+
@media (forced-colors: active) {
|
|
153
|
+
.lbx-cv { border-inline-start: 2px solid CanvasText; }
|
|
154
|
+
.lbx-cv__q, .lbx-cv__send, .lbx-cv__icon, .lbx-cv__notice, .lbx-cv__avatar { border: 1px solid CanvasText; }
|
|
155
|
+
.lbx-cv :focus-visible { outline: 3px solid Highlight; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* @lbx-part convo-preview: the search page's box (convo.assist, <lbx-convo-preview>): the answer
|
|
159
|
+
to the query above the results, in flow with the page (not the panel's dialog rules). */
|
|
160
|
+
:host { display: block; }
|
|
161
|
+
.lbx-cv--preview { width: auto; height: auto; margin: 0 0 20px; padding: 16px 18px; border: 1px solid var(--tint-line); border-radius: 16px; box-shadow: 0 2px 10px rgb(0 0 0 / .05); overflow: visible; }
|
|
162
|
+
.lbx-cv__pv { display: flex; flex-direction: column; gap: 12px; }
|
|
163
|
+
.lbx-cv__pv-head { display: flex; align-items: center; gap: 10px; }
|
|
164
|
+
/* Its first lines: the rest fades out (the panel has it all). */
|
|
165
|
+
.lbx-cv__pv-text { position: relative; max-height: 7.3em; overflow: hidden; }
|
|
166
|
+
/* The fade over the last lines, from the top: only text that reaches the cut is faded (a short answer is clear). */
|
|
167
|
+
.lbx-cv__pv-text::after { content: ''; position: absolute; inset: 5.1em 0 auto; height: 2.2em; background: linear-gradient(transparent, var(--bg)); }
|
|
168
|
+
.lbx-cv__pv-products { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); gap: 8px; }
|
|
169
|
+
.lbx-cv__pv-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; }
|
|
170
|
+
.lbx-cv__continue { display: inline-flex; align-items: center; gap: 8px; min-height: 42px; margin-inline-start: auto; padding: 0 20px; border: 0; border-radius: 999px; background: var(--accent); color: var(--on-accent); font-weight: 600; }
|
|
171
|
+
.lbx-cv__continue svg { width: 18px; height: 18px; stroke-width: 2.2; }
|
|
172
|
+
@media (forced-colors: active) { .lbx-cv--preview, .lbx-cv__continue { border: 2px solid CanvasText; } }
|
|
173
|
+
/* @lbx-part-end */
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { html, nothing, repeat } from '../../lit';
|
|
2
|
+
import type { ConvoAction, ConvoBlock, ConvoMessage, ConvoScope } from '../../types';
|
|
3
|
+
import { avatar, icon } from './icons';
|
|
4
|
+
|
|
5
|
+
const chips = (s: ConvoScope, actions: ConvoAction[] | undefined, live: boolean) =>
|
|
6
|
+
live && actions?.some((a) => a.action_ref)
|
|
7
|
+
? html`<div class="lbx-cv__chips">${actions.map((a) => (a.action_ref ? html`<button type="button" class="lbx-cv__chip" @click=${() => s.act(a)}>${a.label}</button>` : nothing))}</div>`
|
|
8
|
+
: nothing;
|
|
9
|
+
|
|
10
|
+
const block = (s: ConvoScope, b: ConvoBlock, live: boolean): unknown => {
|
|
11
|
+
switch (b.type) {
|
|
12
|
+
case 'text':
|
|
13
|
+
return html`<div class="lbx-cv__text">${s.md(b.text)}</div>`;
|
|
14
|
+
case 'suggestions':
|
|
15
|
+
return html`${b.heading ? html`<p class="lbx-cv__label">${b.heading}</p>` : nothing}${chips(s, b.actions, live)}`;
|
|
16
|
+
case 'question':
|
|
17
|
+
return html`<div class="lbx-cv__question">
|
|
18
|
+
<p>${b.text}</p>
|
|
19
|
+
${chips(s, b.choices, live)}
|
|
20
|
+
${live && b.text_input?.enabled
|
|
21
|
+
? html`<form class="lbx-cv__answer" @submit=${(e: SubmitEvent) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
const i = (e.currentTarget as HTMLFormElement).elements.namedItem('a') as HTMLInputElement;
|
|
24
|
+
s.answer(b.question_ref, i.value);
|
|
25
|
+
}}>
|
|
26
|
+
<input name="a" autocomplete="off" aria-label=${b.text} placeholder=${b.text_input.placeholder ?? ''}>
|
|
27
|
+
<button type="submit" class="lbx-cv__chip">${s.t('send')}</button>
|
|
28
|
+
</form>`
|
|
29
|
+
: nothing}
|
|
30
|
+
</div>`;
|
|
31
|
+
case 'product_grid':
|
|
32
|
+
case 'product_preview': {
|
|
33
|
+
const rs = s.products(b);
|
|
34
|
+
return rs.length
|
|
35
|
+
? html`${b.heading ? html`<p class="lbx-cv__label">${b.heading}</p>` : nothing}
|
|
36
|
+
<ul class="lbx-cv__list" data-type=${b.type} data-lbx-list>
|
|
37
|
+
${repeat(rs, (r) => r.id, (r, i) => html`<li>${s.include('convo-result-default', { r, i, actions: live ? s.actionsOf(b, r) : NONE })}</li>`)}
|
|
38
|
+
</ul>`
|
|
39
|
+
: nothing;
|
|
40
|
+
}
|
|
41
|
+
case 'comparison':
|
|
42
|
+
return html`${b.heading ? html`<p class="lbx-cv__label">${b.heading}</p>` : nothing}
|
|
43
|
+
<div class="lbx-cv__scroll" tabindex="0" role="region" aria-label=${b.heading || s.t('compare')}>
|
|
44
|
+
<table>
|
|
45
|
+
<thead><tr><td></td>${b.products.map((p: any) => html`<th scope="col">${p.title}</th>`)}</tr></thead>
|
|
46
|
+
<tbody>${b.rows.map((row: any) => html`<tr><th scope="row">${row.label}</th>${row.values.map((c: any) => html`<td data-presence=${c.presence}>${c.display_value}</td>`)}</tr>`)}</tbody>
|
|
47
|
+
</table>
|
|
48
|
+
</div>`;
|
|
49
|
+
case 'notice':
|
|
50
|
+
case 'support':
|
|
51
|
+
return html`<div class="lbx-cv__notice" data-variant=${b.variant ?? 'info'}>
|
|
52
|
+
${b.title ? html`<p class="lbx-cv__label">${b.title}</p>` : nothing}
|
|
53
|
+
${s.md(b.text)}
|
|
54
|
+
${chips(s, b.actions, live)}
|
|
55
|
+
</div>`;
|
|
56
|
+
case 'provenance':
|
|
57
|
+
return b.items?.length ? html`<p class="lbx-cv__sources">${b.title || s.t('sources')}: ${b.items.map((x: any) => x.label).join(', ')}</p>` : nothing;
|
|
58
|
+
default:
|
|
59
|
+
return nothing;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const NONE: ConvoAction[] = [];
|
|
63
|
+
|
|
64
|
+
const message = (s: ConvoScope, m: ConvoMessage, last: boolean) =>
|
|
65
|
+
m.role === 'user'
|
|
66
|
+
? html`<div class="lbx-cv__q" data-status=${m.status}><span class="lbx-cv__sr">${s.t('you')}: </span>${m.text}${m.status === 'ignored' ? html`<span class="lbx-cv__sr"> (${s.t('ignored')})</span>` : nothing}</div>`
|
|
67
|
+
: html`<div class="lbx-cv__a" data-status=${m.status}>
|
|
68
|
+
${avatar}
|
|
69
|
+
<div class="lbx-cv__body">
|
|
70
|
+
<span class="lbx-cv__sr">${s.t('assistant')}: </span>
|
|
71
|
+
${m.blocks.map((b) => block(s, b, last && !s.busy && s.state === 'ready'))}
|
|
72
|
+
${m.status === 'streaming' && !m.blocks.length ? html`<p class="lbx-cv__wait">${s.t('thinking')}</p>` : nothing}
|
|
73
|
+
${m.error
|
|
74
|
+
? html`<p class="lbx-cv__error">${s.t(m.error)}${m.retry && last && !s.busy ? html` <button type="button" class="lbx-cv__chip" @click=${s.retry}>${s.t('retry')}</button>` : nothing}</p>`
|
|
75
|
+
: nothing}
|
|
76
|
+
</div>
|
|
77
|
+
</div>`;
|
|
78
|
+
|
|
79
|
+
export default (s: ConvoScope) => html`
|
|
80
|
+
<header class="lbx-cv__head">
|
|
81
|
+
${avatar}
|
|
82
|
+
<div class="lbx-cv__titles">
|
|
83
|
+
<h2 id="lbx-cv-title">${s.t('title')}</h2>
|
|
84
|
+
<p id="lbx-cv-note">${s.t('disclaimer')}</p>
|
|
85
|
+
</div>
|
|
86
|
+
${s.messages.length && s.state !== 'final' && s.state !== 'unavailable'
|
|
87
|
+
? html`<button type="button" class="lbx-cv__icon" aria-label=${s.t('startOver')} title=${s.t('startOver')} @click=${s.startOver}>${icon('M4 12a8 8 0 1 0 2.4-5.7M4 4v4.5h4.5')}</button>`
|
|
88
|
+
: nothing}
|
|
89
|
+
<button type="button" class="lbx-cv__icon" aria-label=${s.t('close')} title=${s.t('close')} @click=${s.close}>${icon('M6 6l12 12M18 6L6 18')}</button>
|
|
90
|
+
</header>
|
|
91
|
+
<div class="lbx-cv__log" data-lbx-convo-log tabindex="-1" role="region" aria-label=${s.t('log')} aria-busy=${s.busy ? 'true' : 'false'}>
|
|
92
|
+
<div class="lbx-cv__msgs">
|
|
93
|
+
${s.messages.length ? nothing : html`<p class="lbx-cv__intro">${s.t('intro')}</p>`}
|
|
94
|
+
${repeat(s.messages, (m) => m, (m, i) => message(s, m, i === s.messages.length - 1))}
|
|
95
|
+
${s.state === 'unavailable'
|
|
96
|
+
? html`<p class="lbx-cv__notice">${s.t('unavailable')}</p>`
|
|
97
|
+
: s.state === 'ended' || s.state === 'final'
|
|
98
|
+
? html`<p class="lbx-cv__notice">${s.t(s.state)}${s.state === 'ended' ? html` <button type="button" class="lbx-cv__chip" @click=${s.startOver}>${s.t('startOver')}</button>` : nothing}</p>`
|
|
99
|
+
: nothing}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
${s.refinements.length
|
|
103
|
+
? html`<ul class="lbx-cv__refine" tabindex="0" aria-label=${s.t('refinements')}>
|
|
104
|
+
${s.refinements.map(
|
|
105
|
+
(f) => html`<li>${f.label}${f.remove?.action_ref
|
|
106
|
+
? html`<button type="button" aria-label=${f.remove.accessible_label} ?disabled=${s.busy || s.state !== 'ready'} @click=${() => s.act({ label: f.remove!.accessible_label, action_ref: f.remove!.action_ref })}>×</button>`
|
|
107
|
+
: nothing}</li>`,
|
|
108
|
+
)}
|
|
109
|
+
</ul>`
|
|
110
|
+
: nothing}
|
|
111
|
+
<form class="lbx-cv__form" @submit=${(e: SubmitEvent) => {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
// Enter while an answer streams: nothing (the question waits in the input); only the button stops it.
|
|
114
|
+
if (s.busy) return;
|
|
115
|
+
const i = (e.currentTarget as HTMLFormElement).elements.namedItem('q') as HTMLInputElement;
|
|
116
|
+
if (!i.value.trim()) return;
|
|
117
|
+
s.send(i.value);
|
|
118
|
+
i.value = '';
|
|
119
|
+
}}>
|
|
120
|
+
<input name="q" autofocus autocomplete="off" enterkeyhint="send" aria-label=${s.t('placeholder')} placeholder=${s.t('placeholder')} ?disabled=${s.state !== 'ready'}>
|
|
121
|
+
<button type=${s.busy ? 'button' : 'submit'} class="lbx-cv__send" aria-label=${s.t(s.busy ? 'stop' : 'send')} title=${s.t(s.busy ? 'stop' : 'send')} ?disabled=${!s.busy && s.state !== 'ready'} @click=${() => s.busy && s.stop()}>
|
|
122
|
+
${s.busy ? icon('M8 8h8v8H8z') : icon('M12 19V5M5.5 11.5L12 5l6.5 6.5')}
|
|
123
|
+
</button>
|
|
124
|
+
</form>
|
|
125
|
+
`;
|