@clivly/chat-widget 0.16.0 → 0.17.1

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/dist/browser.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRoot } from "react-dom/client";
2
2
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
3
3
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
- //#region src/theme.ts
4
+ // src/theme.ts
5
5
  const DARK_MEDIA_QUERY = "(prefers-color-scheme: dark)";
6
6
  /** The attribute set by next-themes, daisyUI, and most `data-*` theme setups. */
7
7
  const THEME_ATTRIBUTE = "data-theme";
@@ -109,8 +109,88 @@ function parseTheme(value) {
109
109
  if (value === "auto" || value === "dark" || value === "light") return value;
110
110
  return "auto";
111
111
  }
112
- //#endregion
113
- //#region src/chat-widget.tsx
112
+ // src/visitor-token-storage.ts
113
+ /** Normalised the way the server matches contacts: trimmed, lower-case. */
114
+ function normalizeVisitorEmail(email) {
115
+ return email.trim().toLowerCase();
116
+ }
117
+ const HASH_MODULUS = 4294967291;
118
+ /**
119
+ * A polynomial string hash, reduced mod a prime below 2^32: stable,
120
+ * synchronous, and exact in doubles (every intermediate stays under 2^53).
121
+ * Not a security primitive.
122
+ */
123
+ function shortHash(value) {
124
+ let hash = 5381;
125
+ for (const char of value) hash = (hash * 33 + (char.codePointAt(0) ?? 0)) % HASH_MODULUS;
126
+ return hash.toString(16).padStart(8, "0");
127
+ }
128
+ function visitorTokenKey(widgetId, email) {
129
+ return `clivly-chat-widget:${widgetId}:visitor-token:${shortHash(normalizeVisitorEmail(email))}`;
130
+ }
131
+ /** The single per-widget key every widget before this change wrote. */
132
+ function legacyVisitorTokenKey(widgetId) {
133
+ return `clivly-chat-widget:${widgetId}:visitor-token`;
134
+ }
135
+ /**
136
+ * The token to offer for this person. Falls back to the legacy per-widget
137
+ * token once, so visitors who chatted before this change still resume — the
138
+ * server only honours it if they are the identity that thread belongs to.
139
+ */
140
+ function readVisitorToken(storage, widgetId, email) {
141
+ if (!storage) return null;
142
+ try {
143
+ return storage.getItem(visitorTokenKey(widgetId, email)) ?? storage.getItem(legacyVisitorTokenKey(widgetId));
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+ /** Store this person's token and retire the legacy shared one. */
149
+ function writeVisitorToken(storage, widgetId, email, token) {
150
+ if (!storage) return;
151
+ try {
152
+ storage.setItem(visitorTokenKey(widgetId, email), token);
153
+ storage.removeItem?.(legacyVisitorTokenKey(widgetId));
154
+ } catch {}
155
+ }
156
+ // src/widget-presentation-cache.ts
157
+ function presentationKey(widgetId) {
158
+ return `clivly-chat-widget:${widgetId}:presentation`;
159
+ }
160
+ function isPresentation(value) {
161
+ if (typeof value !== "object" || value === null) return false;
162
+ const candidate = value;
163
+ return typeof candidate.name === "string" && typeof candidate.config === "object" && candidate.config !== null && Array.isArray(candidate.topics);
164
+ }
165
+ function readPresentation(storage, widgetId) {
166
+ if (!storage) return null;
167
+ try {
168
+ const raw = storage.getItem(presentationKey(widgetId));
169
+ if (!raw) return null;
170
+ const parsed = JSON.parse(raw);
171
+ return isPresentation(parsed) ? parsed : null;
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+ function presentationOf(session) {
177
+ return {
178
+ config: {
179
+ brandColor: session.widget.config.brandColor ?? null,
180
+ greeting: session.widget.config.greeting ?? null
181
+ },
182
+ name: session.widget.name,
183
+ supportState: session.support?.state ?? null,
184
+ topics: session.support?.topics ?? []
185
+ };
186
+ }
187
+ function writePresentation(storage, widgetId, presentation) {
188
+ if (!storage) return;
189
+ try {
190
+ storage.setItem(presentationKey(widgetId), JSON.stringify(presentation));
191
+ } catch {}
192
+ }
193
+ // src/chat-widget.tsx
114
194
  const DELIVERY_LABELS = {
115
195
  failed: "Failed to send",
116
196
  pending: "Sending…",
@@ -264,9 +344,9 @@ const WIDGET_STYLES = `
264
344
  border: 1px solid var(--clivly-chat-border);
265
345
  border-radius: 1rem;
266
346
  box-shadow: 0 24px 80px var(--clivly-chat-shadow);
267
- display: grid;
268
- grid-template-rows: auto 1fr auto;
269
- height: min(44rem, 75vh);
347
+ display: flex;
348
+ flex-direction: column;
349
+ height: min(40rem, 75vh);
270
350
  overflow: hidden;
271
351
  position: absolute;
272
352
  right: 0;
@@ -283,20 +363,56 @@ const WIDGET_STYLES = `
283
363
  @media (width < 48rem) {
284
364
  .clivly-chat-widget__panel {
285
365
  bottom: 4rem;
286
- height: min(44rem, calc(100dvh - 6.5rem));
366
+ height: min(40rem, calc(100dvh - 6.5rem));
367
+ }
368
+ }
369
+ /* On a phone a floating card leaves a strip of page above it and puts the
370
+ launcher over its own composer, so the panel becomes a full-screen sheet
371
+ instead — the close button in its header takes over from the launcher,
372
+ which is hidden while it is open. */
373
+ @media (width < 30rem) {
374
+ .clivly-chat-widget__panel {
375
+ border: 0;
376
+ border-radius: 0;
377
+ bottom: 0;
378
+ height: 100dvh;
379
+ left: 0;
380
+ padding-bottom: env(safe-area-inset-bottom);
381
+ padding-top: env(safe-area-inset-top);
382
+ position: fixed;
383
+ right: 0;
384
+ top: 0;
385
+ width: 100vw;
386
+ }
387
+ .clivly-chat-widget[data-clivly-open] .clivly-chat-widget__launcher {
388
+ display: none;
287
389
  }
288
390
  }
289
391
  .clivly-chat-widget__header {
392
+ align-items: center;
290
393
  border-bottom: 1px solid var(--clivly-chat-border);
291
394
  display: flex;
395
+ flex: 0 0 auto;
292
396
  justify-content: space-between;
293
- gap: 1rem;
294
- padding: 1rem;
397
+ gap: 0.75rem;
398
+ padding: 0.75rem 0.75rem 0.75rem 1rem;
399
+ }
400
+ .clivly-chat-widget__heading {
401
+ min-width: 0;
295
402
  }
296
403
  .clivly-chat-widget__title {
297
404
  font-size: 1rem;
298
405
  font-weight: 600;
299
406
  margin: 0;
407
+ overflow: hidden;
408
+ text-overflow: ellipsis;
409
+ white-space: nowrap;
410
+ }
411
+ .clivly-chat-widget__header .clivly-chat-widget__subtitle {
412
+ margin-top: 0.125rem;
413
+ overflow: hidden;
414
+ text-overflow: ellipsis;
415
+ white-space: nowrap;
300
416
  }
301
417
  .clivly-chat-widget__subtitle {
302
418
  color: var(--clivly-chat-muted);
@@ -304,19 +420,50 @@ const WIDGET_STYLES = `
304
420
  margin: 0.25rem 0 0;
305
421
  }
306
422
  .clivly-chat-widget__close {
423
+ align-items: center;
307
424
  background: transparent;
308
425
  border: 0;
426
+ border-radius: 999px;
309
427
  color: var(--clivly-chat-muted);
310
428
  cursor: pointer;
429
+ display: inline-flex;
430
+ flex: 0 0 auto;
431
+ height: 2.25rem;
432
+ justify-content: center;
311
433
  padding: 0;
434
+ width: 2.25rem;
435
+ }
436
+ .clivly-chat-widget__close:hover,
437
+ .clivly-chat-widget__close:focus-visible {
438
+ background: var(--clivly-chat-operator);
439
+ color: var(--clivly-chat-text);
440
+ }
441
+ .clivly-chat-widget__close:focus-visible {
442
+ outline: 2px solid var(--clivly-chat-accent);
443
+ outline-offset: 2px;
444
+ }
445
+ .clivly-chat-widget__close svg {
446
+ height: 1.25rem;
447
+ width: 1.25rem;
312
448
  }
313
449
  .clivly-chat-widget__body {
314
450
  display: flex;
451
+ flex: 1 1 auto;
315
452
  flex-direction: column;
316
453
  gap: 0.75rem;
454
+ min-height: 0;
317
455
  overflow-y: auto;
456
+ overscroll-behavior: contain;
318
457
  padding: 1rem;
319
458
  }
459
+ .clivly-chat-widget__visually-hidden {
460
+ clip-path: inset(50%);
461
+ height: 1px;
462
+ overflow: hidden;
463
+ position: absolute;
464
+ white-space: nowrap;
465
+ width: 1px;
466
+ }
320
467
  .clivly-chat-widget__message {
321
468
  border-radius: 1rem;
322
469
  max-width: 85%;
@@ -394,6 +541,11 @@ const WIDGET_STYLES = `
394
541
  .clivly-chat-widget__powered a:focus-visible {
395
542
  text-decoration: underline;
396
543
  }
544
+ .clivly-chat-widget__powered,
545
+ .clivly-chat-widget__composer,
546
+ .clivly-chat-widget__form {
547
+ flex: 0 0 auto;
548
+ }
397
549
  .clivly-chat-widget__composer,
398
550
  .clivly-chat-widget__form {
399
551
  border-top: 1px solid var(--clivly-chat-border);
@@ -401,6 +553,62 @@ const WIDGET_STYLES = `
401
553
  gap: 0.75rem;
402
554
  padding: 1rem;
403
555
  }
556
+ .clivly-chat-widget__composer {
557
+ gap: 0.375rem;
558
+ padding: 0.75rem;
559
+ }
560
+ /* With topics docked above it, the composer has no border of its own: the
561
+ strip and the field read as one input area. */
562
+ .clivly-chat-widget__topics + .clivly-chat-widget__composer {
563
+ border-top: 0;
564
+ }
565
+ .clivly-chat-widget__composer-row {
566
+ align-items: flex-end;
567
+ display: flex;
568
+ gap: 0.5rem;
569
+ }
570
+ .clivly-chat-widget__composer-field {
571
+ flex: 1 1 auto;
572
+ min-width: 0;
573
+ }
574
+ .clivly-chat-widget__composer .clivly-chat-widget__textarea {
575
+ line-height: 1.4;
576
+ max-height: 8rem;
577
+ min-height: 2.75rem;
578
+ overflow-y: auto;
579
+ padding: 0.625rem 0.75rem;
580
+ resize: none;
581
+ }
582
+ .clivly-chat-widget__send {
583
+ align-items: center;
584
+ border-radius: 999px;
585
+ display: inline-flex;
586
+ height: 2.75rem;
587
+ justify-content: center;
588
+ padding: 0;
589
+ width: 2.75rem;
590
+ }
591
+ .clivly-chat-widget__send svg {
592
+ height: 1.25rem;
593
+ width: 1.25rem;
594
+ }
595
+ .clivly-chat-widget__composer .clivly-chat-widget__actions {
596
+ padding: 0 0.25rem;
597
+ }
598
+ .clivly-chat-widget__composer .clivly-chat-widget__subtitle {
599
+ font-size: 0.75rem;
600
+ margin: 0;
601
+ }
602
+ /* Once the conversation is ready the status has nothing to say to a sighted
603
+ visitor; it stays in the accessibility tree so the change is announced. */
604
+ .clivly-chat-widget__actions[data-ready] {
605
+ clip-path: inset(50%);
606
+ height: 1px;
607
+ overflow: hidden;
608
+ position: absolute;
609
+ white-space: nowrap;
610
+ width: 1px;
611
+ }
404
612
  .clivly-chat-widget__field {
405
613
  display: grid;
406
614
  gap: 0.35rem;
@@ -481,13 +689,42 @@ const WIDGET_STYLES = `
481
689
  font-size: 0.6875rem;
482
690
  padding: 0 0.4rem;
483
691
  }
692
+ /* Docked above the composer. Wide screens wrap up to about two rows and
693
+ scroll beyond that; narrow or touch screens get one sideways-scrolling row
694
+ whose right edge fades to show there is more. */
484
695
  .clivly-chat-widget__topics {
696
+ border-top: 1px solid var(--clivly-chat-border);
485
697
  display: flex;
698
+ flex: 0 0 auto;
486
699
  flex-wrap: wrap;
487
- gap: 0.5rem;
488
- margin: 0;
489
- padding: 0;
700
+ gap: 0.375rem;
490
701
  list-style: none;
702
+ margin: 0;
703
+ max-height: 5.25rem;
704
+ overflow-y: auto;
705
+ padding: 0.625rem 0.75rem 0;
706
+ scrollbar-width: thin;
707
+ }
708
+ .clivly-chat-widget__topics > li {
709
+ flex: 0 0 auto;
710
+ scroll-snap-align: start;
711
+ }
712
+ @media (width < 48rem), (any-pointer: coarse) {
713
+ .clivly-chat-widget__topics {
714
+ flex-wrap: nowrap;
715
+ -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent);
716
+ mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent);
717
+ max-height: none;
718
+ overflow-x: auto;
719
+ overflow-y: hidden;
720
+ padding-right: 1.5rem;
721
+ scroll-padding-left: 0.75rem;
722
+ scroll-snap-type: x proximity;
723
+ scrollbar-width: none;
724
+ }
725
+ .clivly-chat-widget__topics::-webkit-scrollbar {
726
+ display: none;
727
+ }
491
728
  }
492
729
  .clivly-chat-widget__topic {
493
730
  background: var(--clivly-chat-panel);
@@ -495,9 +732,11 @@ const WIDGET_STYLES = `
495
732
  border-radius: 999px;
496
733
  color: var(--clivly-chat-text);
497
734
  cursor: pointer;
498
- font-size: 0.875rem;
499
- min-height: 2.25rem;
500
- padding: 0.4rem 0.85rem;
735
+ font-size: 0.8125rem;
736
+ line-height: 1.2;
737
+ min-height: 2rem;
738
+ padding: 0.375rem 0.75rem;
739
+ white-space: nowrap;
501
740
  }
502
741
  .clivly-chat-widget__topic:hover,
503
742
  .clivly-chat-widget__topic:focus-visible {
@@ -532,9 +771,6 @@ function createLocalMessage(message, delivery = "sent") {
532
771
  delivery
533
772
  };
534
773
  }
535
- function createStorageKey(widgetId) {
536
- return `clivly-chat-widget:${widgetId}:visitor-token`;
537
- }
538
774
  function formatTimestamp(value) {
539
775
  const date = new Date(value);
540
776
  if (Number.isNaN(date.getTime())) return "";
@@ -576,15 +812,44 @@ function markMessageFailed(messages, clientMessageId) {
576
812
  function sortMessages(messages) {
577
813
  return [...messages].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
578
814
  }
579
- function useThemeStyle(session, style) {
815
+ const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
816
+ /** Relative luminance above which white text stops being readable. */
817
+ const LIGHT_ACCENT_LUMINANCE = .45;
818
+ /**
819
+ * Text colour for a button filled with `hex`: white on dark brands, near-black
820
+ * on light ones, so a yellow or pastel brand colour does not leave the
821
+ * launcher's icon and the Send label white-on-white. Null for anything that is
822
+ * not a plain hex colour, which keeps the stylesheet's own contrast colour.
823
+ */
824
+ function accentContrastFor(hex) {
825
+ if (!HEX_COLOR.test(hex)) return null;
826
+ const digits = hex.length === 4 ? [...hex.slice(1)].map((digit) => digit + digit).join("") : hex.slice(1);
827
+ const [red = 0, green = 0, blue = 0] = [
828
+ 0,
829
+ 2,
830
+ 4
831
+ ].map((offset) => {
832
+ const value = Number.parseInt(digits.slice(offset, offset + 2), 16) / 255;
833
+ return value <= .03928 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4;
834
+ });
835
+ return .2126 * red + .7152 * green + .0722 * blue > LIGHT_ACCENT_LUMINANCE ? "#111827" : "#ffffff";
836
+ }
837
+ /** The session's brand colour, or the remembered one until a session lands. */
838
+ function brandColorOf(session, cached) {
839
+ if (session) return session.widget.config.brandColor ?? null;
840
+ return cached?.config.brandColor ?? null;
841
+ }
842
+ function useThemeStyle(brandColor, style) {
580
843
  return useMemo(() => {
581
- const brandColor = session?.widget.config.brandColor ?? null;
582
- if (!brandColor) return style;
844
+ const hostSetsAccent = Boolean(style && "--clivly-chat-accent" in style);
845
+ if (!brandColor || hostSetsAccent) return style;
846
+ const contrast = accentContrastFor(brandColor);
583
847
  return {
584
848
  ["--clivly-chat-accent"]: brandColor,
849
+ ...contrast ? { ["--clivly-chat-accent-contrast"]: contrast } : {},
585
850
  ...style
586
851
  };
587
- }, [session?.widget.config.brandColor, style]);
852
+ }, [brandColor, style]);
588
853
  }
589
854
  /**
590
855
  * Track the theme the widget should paint.
@@ -658,12 +923,21 @@ function automationBadge(message) {
658
923
  * root's `data-clivly-launcher` (see WIDGET_STYLES); the label is always its
659
924
  * accessible name, and `aria-controls` names the panel only while it exists.
660
925
  */
661
- function Launcher({ label, onToggle, open, panelId }) {
926
+ function Launcher({ label, onIntent, onToggle, open, panelId }) {
662
927
  return /* @__PURE__ */ jsxs("button", {
663
928
  "aria-controls": open ? panelId : void 0,
664
929
  "aria-expanded": open,
665
930
  className: "clivly-chat-widget__launcher",
666
931
  onClick: onToggle,
932
+ onFocus: () => {
933
+ onIntent("likely");
934
+ },
935
+ onPointerDown: () => {
936
+ onIntent("certain");
937
+ },
938
+ onPointerEnter: (event) => {
939
+ if (event.pointerType === "mouse") onIntent("likely");
940
+ },
667
941
  type: "button",
668
942
  children: [/* @__PURE__ */ jsx(LauncherIcon, { open }), /* @__PURE__ */ jsx("span", {
669
943
  className: "clivly-chat-widget__launcher-label",
@@ -808,9 +1082,14 @@ function composerStatusText(hasTransport, isReady, reconnecting) {
808
1082
  return reconnecting ? "Reconnecting…" : "Connecting…";
809
1083
  }
810
1084
  /**
811
- * Guided options under the greeting. Native buttons in a labelled group, so
812
- * they are reachable with Tab and operable with Enter/Space; free text stays
813
- * available beside them the whole time.
1085
+ * Guided options, docked above the composer rather than inside the transcript.
1086
+ * In the transcript they sat below the greeting and the scroll-to-newest
1087
+ * pinned them there, so on a phone six wrapped chips pushed the greeting and
1088
+ * the answer above the fold. Docked, they are one compact strip (scrolling
1089
+ * sideways on narrow screens) and the conversation keeps the space.
1090
+ *
1091
+ * Native buttons in a labelled group, so they are reachable with Tab and
1092
+ * operable with Enter/Space; free text stays available beside them.
814
1093
  */
815
1094
  function SupportTopics({ disabled, onSelect, topics }) {
816
1095
  return /* @__PURE__ */ jsx("ul", {
@@ -827,9 +1106,64 @@ function SupportTopics({ disabled, onSelect, topics }) {
827
1106
  }) }, topic.id))
828
1107
  });
829
1108
  }
830
- /** Topics are offered only while automation is answering this conversation. */
831
- function visibleTopics(session, state) {
832
- return state === "automated" ? session?.support?.topics ?? [] : [];
1109
+ /**
1110
+ * Topics are offered only while automation is answering this conversation.
1111
+ * Before the session lands, the remembered presentation stands in so the
1112
+ * strip is there on the first frame; it shows only if this browser last saw
1113
+ * them offered, so a visitor already talking to a person does not see them
1114
+ * flash up and vanish.
1115
+ */
1116
+ function visibleTopics(session, state, cached) {
1117
+ if (session) return state === "automated" ? session.support?.topics ?? [] : [];
1118
+ return cached?.supportState === "automated" ? cached.topics : [];
1119
+ }
1120
+ /**
1121
+ * What the open panel shows, derived from the session or — before it lands —
1122
+ * from the host's identity and the remembered presentation.
1123
+ *
1124
+ * A host-identified visitor never sees the form: while their session loads the
1125
+ * panel already shows the conversation shell — title, topics and a composer
1126
+ * they can type into — so opening the widget is not a blank wait. A failed
1127
+ * bootstrap sets `error` and falls back to the form, the recoverable state.
1128
+ */
1129
+ function panelView({ cachedPresentation, defaultVisitor, error, session, supportState }) {
1130
+ const awaitingHostSession = !session && isCompleteVisitor(defaultVisitor) && !error;
1131
+ const showComposer = Boolean(session) || awaitingHostSession;
1132
+ const greeting = session?.widget.config.greeting ?? cachedPresentation?.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
1133
+ return {
1134
+ awaitingHostSession,
1135
+ greeting,
1136
+ panelTitle: session?.widget.name ?? cachedPresentation?.name ?? "Start a conversation",
1137
+ sessionSummary: awaitingHostSession && defaultVisitor ? `${defaultVisitor.name?.trim()} • ${defaultVisitor.email?.trim()}` : sessionSummaryText(session, greeting),
1138
+ showComposer,
1139
+ topics: showComposer ? visibleTopics(session, supportState, cachedPresentation) : []
1140
+ };
1141
+ }
1142
+ /**
1143
+ * Who to start a session for ahead of the click, or null to wait for it. Only
1144
+ * a host-identified visitor; and on a mere hover or focus only one with a
1145
+ * stored thread to resume.
1146
+ */
1147
+ function prefetchIdentity(intent, { defaultVisitor, settled }, storedToken) {
1148
+ if (settled || !isCompleteVisitor(defaultVisitor)) return null;
1149
+ const identity = {
1150
+ email: defaultVisitor.email.trim(),
1151
+ name: defaultVisitor.name.trim()
1152
+ };
1153
+ if (intent === "likely" && !storedToken(identity.email)) return null;
1154
+ return identity;
1155
+ }
1156
+ function startSessionQuietly(startSession, identity) {
1157
+ if (!identity) return;
1158
+ startSession(identity, "host").catch(() => {});
1159
+ }
1160
+ /** The composer grows with its text up to this height, then scrolls. */
1161
+ const COMPOSER_MAX_HEIGHT_PX = 128;
1162
+ /** Size a textarea to its content, between its CSS min-height and the cap. */
1163
+ function fitTextarea(textarea) {
1164
+ if (!textarea) return;
1165
+ textarea.style.height = "auto";
1166
+ textarea.style.height = `${Math.min(textarea.scrollHeight, COMPOSER_MAX_HEIGHT_PX)}px`;
833
1167
  }
834
1168
  function composerPlaceholder(hasTopics) {
835
1169
  return hasTopics ? "Or type your question" : "Write your message";
@@ -960,6 +1294,33 @@ function useLiveConnection({ actions, createTransport, refreshSession, session,
960
1294
  }, [createTransport, session]);
961
1295
  return reconnecting;
962
1296
  }
1297
+ /** Who the host says is signed in: their normalised email, or null. */
1298
+ function hostIdentityOf(defaultVisitor) {
1299
+ return isCompleteVisitor(defaultVisitor) ? normalizeVisitorEmail(defaultVisitor.email) : null;
1300
+ }
1301
+ /**
1302
+ * The host's signed-in visitor changed under an open session — a sign-out, or
1303
+ * a different account signing in without a page reload. The session on
1304
+ * screen is the previous person's: drop it, its transcript and its socket, so
1305
+ * the next open starts as whoever is signed in now (chat widget session
1306
+ * review 2026-10-28, S1). A session the visitor started from the pre-chat
1307
+ * form is theirs and is left alone.
1308
+ */
1309
+ function useResetOnHostIdentityChange({ generationRef, hostIdentity, identityRef, reset, sessionSourceRef }) {
1310
+ const resetRef = useRef(reset);
1311
+ useEffect(() => {
1312
+ resetRef.current = reset;
1313
+ });
1314
+ useEffect(() => {
1315
+ const current = identityRef.current;
1316
+ if (!(current && sessionSourceRef.current === "host")) return;
1317
+ if (hostIdentity === normalizeVisitorEmail(current.email)) return;
1318
+ generationRef.current += 1;
1319
+ identityRef.current = null;
1320
+ sessionSourceRef.current = null;
1321
+ resetRef.current();
1322
+ }, [hostIdentity]);
1323
+ }
963
1324
  function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcher = "auto", launcherLabel = "Chat with us", placement = "bottom-right", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
964
1325
  const [isOpen, setIsOpen] = useState(initiallyOpen);
965
1326
  const panelId = useId();
@@ -971,19 +1332,32 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
971
1332
  const [session, setSession] = useState(null);
972
1333
  const [supportState, setSupportState] = useState(null);
973
1334
  const [assistantThinking, setAssistantThinking] = useState(false);
1335
+ const [cachedPresentation, setCachedPresentation] = useState(null);
1336
+ useEffect(() => {
1337
+ setCachedPresentation(readPresentation(storage, widgetId));
1338
+ }, [storage, widgetId]);
974
1339
  const [visitor, setVisitor] = useState({
975
1340
  email: defaultVisitor?.email ?? "",
976
1341
  name: defaultVisitor?.name ?? ""
977
1342
  });
978
1343
  const transportRef = useRef(null);
979
1344
  const listRef = useRef(null);
1345
+ const composerRef = useRef(null);
1346
+ useEffect(() => {
1347
+ fitTextarea(composerRef.current);
1348
+ }, [messageDraft]);
980
1349
  const identityRef = useRef(null);
1350
+ const sessionSourceRef = useRef(null);
1351
+ const generationRef = useRef(0);
1352
+ const hostIdentity = hostIdentityOf(defaultVisitor);
1353
+ const latestHostIdentityRef = useRef(hostIdentity);
1354
+ latestHostIdentityRef.current = hostIdentity;
981
1355
  useEffect(() => {
982
1356
  if (createTransport) return;
983
1357
  console.warn("[clivly] <ChatWidget> was given no createTransport, so messages cannot be sent. Pass createTransport={createClivlyTransport()} — see https://www.npmjs.com/package/@clivly/chat-widget#five-minute-quick-start");
984
1358
  }, []);
985
1359
  const showWaitingNotice = useWaitingNotice(messages);
986
- const themeStyle = useThemeStyle(session, style);
1360
+ const themeStyle = useThemeStyle(brandColorOf(session, cachedPresentation), style);
987
1361
  const resolvedTheme = useResolvedTheme(theme);
988
1362
  useEffect(() => () => {
989
1363
  transportRef.current?.close();
@@ -1004,12 +1378,15 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1004
1378
  refreshSession: async (current) => {
1005
1379
  const identity = identityRef.current;
1006
1380
  if (!identity) throw new Error("No visitor to reconnect as.");
1381
+ const generation = generationRef.current;
1007
1382
  const next = await getSession({
1008
1383
  visitor: identity,
1009
1384
  visitorToken: current.visitorToken,
1010
1385
  widgetId
1011
1386
  });
1012
- storage?.setItem(createStorageKey(widgetId), next.visitorToken);
1387
+ if (generation !== generationRef.current) return;
1388
+ writeVisitorToken(storage, widgetId, identity.email, next.visitorToken);
1389
+ writePresentation(storage, widgetId, presentationOf(next));
1013
1390
  setSupportState(next.support?.state ?? null);
1014
1391
  setSession(next);
1015
1392
  },
@@ -1034,31 +1411,54 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1034
1411
  isReady,
1035
1412
  messages
1036
1413
  ]);
1037
- const startSession = useCallback(async (identity) => {
1414
+ const startSession = useCallback(async (identity, source = "form") => {
1038
1415
  setError(null);
1039
1416
  setIsConnecting(true);
1417
+ const generation = generationRef.current;
1418
+ const isStale = () => generation !== generationRef.current || source === "host" && normalizeVisitorEmail(identity.email) !== latestHostIdentityRef.current;
1040
1419
  try {
1041
1420
  const nextSession = await getSession({
1042
1421
  visitor: identity,
1043
- visitorToken: storage?.getItem(createStorageKey(widgetId)) ?? null,
1422
+ visitorToken: readVisitorToken(storage, widgetId, identity.email),
1044
1423
  widgetId
1045
1424
  });
1046
- storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
1425
+ if (isStale()) return;
1426
+ writeVisitorToken(storage, widgetId, identity.email, nextSession.visitorToken);
1427
+ writePresentation(storage, widgetId, presentationOf(nextSession));
1047
1428
  identityRef.current = identity;
1429
+ sessionSourceRef.current = source;
1048
1430
  setSupportState(nextSession.support?.state ?? null);
1049
1431
  setSession(nextSession);
1050
1432
  setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
1051
1433
  } catch (sessionError) {
1434
+ if (isStale()) return;
1052
1435
  const resolved = sessionError instanceof Error ? sessionError : /* @__PURE__ */ new Error("Failed to start the conversation.");
1053
1436
  setError(resolved.message);
1054
1437
  } finally {
1055
- setIsConnecting(false);
1438
+ if (generation === generationRef.current) setIsConnecting(false);
1056
1439
  }
1057
1440
  }, [
1058
1441
  getSession,
1059
1442
  storage,
1060
1443
  widgetId
1061
1444
  ]);
1445
+ useResetOnHostIdentityChange({
1446
+ generationRef,
1447
+ hostIdentity,
1448
+ identityRef,
1449
+ reset: () => {
1450
+ setSession(null);
1451
+ setMessages([]);
1452
+ setSupportState(null);
1453
+ setError(null);
1454
+ setIsConnecting(false);
1455
+ setVisitor({
1456
+ email: defaultVisitor?.email ?? "",
1457
+ name: defaultVisitor?.name ?? ""
1458
+ });
1459
+ },
1460
+ sessionSourceRef
1461
+ });
1062
1462
  const submitVisitor = async (event) => {
1063
1463
  event.preventDefault();
1064
1464
  await startSession(visitor);
@@ -1069,7 +1469,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1069
1469
  startSession({
1070
1470
  email: defaultVisitor.email.trim(),
1071
1471
  name: defaultVisitor.name.trim()
1072
- }).catch(() => {});
1472
+ }, "host").catch(() => {});
1073
1473
  }, [
1074
1474
  defaultVisitor,
1075
1475
  error,
@@ -1078,6 +1478,13 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1078
1478
  session,
1079
1479
  startSession
1080
1480
  ]);
1481
+ const prefetchSession = (intent) => {
1482
+ const identity = prefetchIdentity(intent, {
1483
+ defaultVisitor,
1484
+ settled: isOpen || Boolean(session) || isConnecting || Boolean(error)
1485
+ }, (email) => readVisitorToken(storage, widgetId, email));
1486
+ startSessionQuietly(startSession, identity);
1487
+ };
1081
1488
  /**
1082
1489
  * Show the visitor's message at once and hand it to the transport. A topic
1083
1490
  * click goes as `topic.select` when the transport supports it, so the
@@ -1116,13 +1523,17 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1116
1523
  sendVisitorMessage(body);
1117
1524
  };
1118
1525
  const canSend = Boolean(createTransport) && isReady;
1119
- const greeting = session?.widget.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
1120
- const panelTitle = session?.widget.name ?? "Start a conversation";
1121
- const sessionSummary = sessionSummaryText(session, greeting);
1122
- const topics = visibleTopics(session, supportState);
1526
+ const { awaitingHostSession, greeting, panelTitle, sessionSummary, showComposer, topics } = panelView({
1527
+ cachedPresentation,
1528
+ defaultVisitor,
1529
+ error,
1530
+ session,
1531
+ supportState
1532
+ });
1123
1533
  return /* @__PURE__ */ jsxs("div", {
1124
1534
  className: `clivly-chat-widget ${className ?? ""}`.trim(),
1125
1535
  "data-clivly-launcher": launcher,
1536
+ "data-clivly-open": isOpen || void 0,
1126
1537
  "data-clivly-placement": placement,
1127
1538
  "data-clivly-theme": resolvedTheme,
1128
1539
  style: themeStyle,
@@ -1130,6 +1541,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1130
1541
  /* @__PURE__ */ jsx("style", { children: WIDGET_STYLES }),
1131
1542
  /* @__PURE__ */ jsx(Launcher, {
1132
1543
  label: launcherLabel,
1544
+ onIntent: prefetchSession,
1133
1545
  onToggle: () => {
1134
1546
  setIsOpen((open) => !open);
1135
1547
  },
@@ -1143,23 +1555,36 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1143
1555
  children: [
1144
1556
  /* @__PURE__ */ jsxs("header", {
1145
1557
  className: "clivly-chat-widget__header",
1146
- children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h2", {
1147
- className: "clivly-chat-widget__title",
1148
- children: panelTitle
1149
- }), /* @__PURE__ */ jsx("p", {
1150
- className: "clivly-chat-widget__subtitle",
1151
- children: sessionSummary
1152
- })] }), /* @__PURE__ */ jsx("button", {
1558
+ children: [/* @__PURE__ */ jsxs("div", {
1559
+ className: "clivly-chat-widget__heading",
1560
+ children: [/* @__PURE__ */ jsx("h2", {
1561
+ className: "clivly-chat-widget__title",
1562
+ children: panelTitle
1563
+ }), /* @__PURE__ */ jsx("p", {
1564
+ className: "clivly-chat-widget__subtitle",
1565
+ children: sessionSummary
1566
+ })]
1567
+ }), /* @__PURE__ */ jsx("button", {
1153
1568
  "aria-label": "Close chat",
1154
1569
  className: "clivly-chat-widget__close",
1155
1570
  onClick: () => {
1156
1571
  setIsOpen(false);
1157
1572
  },
1158
1573
  type: "button",
1159
- children: "Close"
1574
+ children: /* @__PURE__ */ jsx("svg", {
1575
+ "aria-hidden": "true",
1576
+ fill: "none",
1577
+ focusable: "false",
1578
+ stroke: "currentColor",
1579
+ strokeLinecap: "round",
1580
+ strokeWidth: 2,
1581
+ viewBox: "0 0 24 24",
1582
+ children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" })
1583
+ })
1160
1584
  })]
1161
1585
  }),
1162
1586
  /* @__PURE__ */ jsxs("div", {
1587
+ "aria-busy": awaitingHostSession,
1163
1588
  "aria-label": "Conversation",
1164
1589
  className: "clivly-chat-widget__body",
1165
1590
  ref: listRef,
@@ -1174,13 +1599,6 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1174
1599
  hasSession: Boolean(session),
1175
1600
  messages
1176
1601
  }),
1177
- topics.length > 0 ? /* @__PURE__ */ jsx(SupportTopics, {
1178
- disabled: !canSend,
1179
- onSelect: (topic) => {
1180
- sendVisitorMessage(topic.label, topic);
1181
- },
1182
- topics
1183
- }) : null,
1184
1602
  session ? /* @__PURE__ */ jsx(StatusNotice, {
1185
1603
  assistantThinking,
1186
1604
  email: session.contact?.email,
@@ -1188,39 +1606,63 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1188
1606
  }) : null
1189
1607
  ]
1190
1608
  }),
1191
- session ? /* @__PURE__ */ jsxs("form", {
1609
+ topics.length > 0 ? /* @__PURE__ */ jsx(SupportTopics, {
1610
+ disabled: !canSend,
1611
+ onSelect: (topic) => {
1612
+ sendVisitorMessage(topic.label, topic);
1613
+ },
1614
+ topics
1615
+ }) : null,
1616
+ showComposer ? /* @__PURE__ */ jsxs("form", {
1192
1617
  className: "clivly-chat-widget__composer",
1193
1618
  onSubmit: submitMessage,
1194
- children: [/* @__PURE__ */ jsxs("label", {
1195
- className: "clivly-chat-widget__field",
1196
- children: [/* @__PURE__ */ jsx("span", {
1197
- className: "clivly-chat-widget__label",
1198
- children: "Message"
1199
- }), /* @__PURE__ */ jsx("textarea", {
1200
- className: "clivly-chat-widget__textarea",
1201
- onChange: (event) => {
1202
- setMessageDraft(event.target.value);
1203
- },
1204
- onKeyDown: (event) => {
1205
- if (!isSendKey(event)) return;
1206
- event.preventDefault();
1207
- if (messageDraft.trim() && canSend) submitMessage();
1208
- },
1209
- placeholder: composerPlaceholder(topics.length > 0),
1210
- value: messageDraft
1619
+ children: [/* @__PURE__ */ jsxs("div", {
1620
+ className: "clivly-chat-widget__composer-row",
1621
+ children: [/* @__PURE__ */ jsxs("label", {
1622
+ className: "clivly-chat-widget__field clivly-chat-widget__composer-field",
1623
+ children: [/* @__PURE__ */ jsx("span", {
1624
+ className: "clivly-chat-widget__label clivly-chat-widget__visually-hidden",
1625
+ children: "Message"
1626
+ }), /* @__PURE__ */ jsx("textarea", {
1627
+ className: "clivly-chat-widget__textarea",
1628
+ onChange: (event) => {
1629
+ setMessageDraft(event.target.value);
1630
+ },
1631
+ onKeyDown: (event) => {
1632
+ if (!isSendKey(event)) return;
1633
+ event.preventDefault();
1634
+ if (messageDraft.trim() && canSend) submitMessage();
1635
+ },
1636
+ placeholder: composerPlaceholder(topics.length > 0),
1637
+ ref: composerRef,
1638
+ rows: 1,
1639
+ value: messageDraft
1640
+ })]
1641
+ }), /* @__PURE__ */ jsx("button", {
1642
+ "aria-label": "Send",
1643
+ className: "clivly-chat-widget__button clivly-chat-widget__send",
1644
+ disabled: !(messageDraft.trim() && canSend),
1645
+ type: "submit",
1646
+ children: /* @__PURE__ */ jsx("svg", {
1647
+ "aria-hidden": "true",
1648
+ fill: "none",
1649
+ focusable: "false",
1650
+ stroke: "currentColor",
1651
+ strokeLinecap: "round",
1652
+ strokeLinejoin: "round",
1653
+ strokeWidth: 2,
1654
+ viewBox: "0 0 24 24",
1655
+ children: /* @__PURE__ */ jsx("path", { d: "M12 19V5M5 12l7-7 7 7" })
1656
+ })
1211
1657
  })]
1212
- }), /* @__PURE__ */ jsxs("div", {
1658
+ }), /* @__PURE__ */ jsx("div", {
1213
1659
  className: "clivly-chat-widget__actions",
1214
- children: [/* @__PURE__ */ jsx("span", {
1660
+ "data-ready": canSend || void 0,
1661
+ children: /* @__PURE__ */ jsx("span", {
1215
1662
  "aria-live": "polite",
1216
1663
  className: "clivly-chat-widget__subtitle",
1217
1664
  children: composerStatusText(Boolean(createTransport), isReady, reconnecting)
1218
- }), /* @__PURE__ */ jsx("button", {
1219
- className: "clivly-chat-widget__button",
1220
- disabled: !(messageDraft.trim() && canSend),
1221
- type: "submit",
1222
- children: "Send"
1223
- })]
1665
+ })
1224
1666
  })]
1225
1667
  }) : /* @__PURE__ */ jsxs("form", {
1226
1668
  className: "clivly-chat-widget__form",
@@ -1298,8 +1740,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1298
1740
  ]
1299
1741
  });
1300
1742
  }
1301
- //#endregion
1302
- //#region src/transport.ts
1743
+ // src/transport.ts
1303
1744
  function isServerEvent(value) {
1304
1745
  if (typeof value !== "object" || value === null) return false;
1305
1746
  return typeof value.type === "string";
@@ -1344,8 +1785,7 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
1344
1785
  };
1345
1786
  };
1346
1787
  }
1347
- //#endregion
1348
- //#region src/clivly-transport.ts
1788
+ // src/clivly-transport.ts
1349
1789
  /**
1350
1790
  * The transport a host app should use against a real Clivly backend.
1351
1791
  *
@@ -1362,8 +1802,7 @@ function createClivlyTransport(options = {}) {
1362
1802
  }
1363
1803
  });
1364
1804
  }
1365
- //#endregion
1366
- //#region src/layout.ts
1805
+ // src/layout.ts
1367
1806
  const PLACEMENTS = [
1368
1807
  "bottom-left",
1369
1808
  "bottom-right",
@@ -1382,8 +1821,7 @@ function parsePlacement(value) {
1382
1821
  function parseLauncher(value) {
1383
1822
  return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
1384
1823
  }
1385
- //#endregion
1386
- //#region src/session.ts
1824
+ // src/session.ts
1387
1825
  function isWidgetSessionPayload(value) {
1388
1826
  return typeof value === "object" && value !== null;
1389
1827
  }
@@ -1405,8 +1843,7 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
1405
1843
  return data;
1406
1844
  };
1407
1845
  }
1408
- //#endregion
1409
- //#region src/browser-mount.tsx
1846
+ // src/browser-mount.tsx
1410
1847
  const MOUNTED_DATA_ATTR = "clivlyMounted";
1411
1848
  /**
1412
1849
  * The identity a script-tag host already knows, read off the tag.
@@ -1498,11 +1935,9 @@ function autoMountChatWidgetScript$1(script = document.currentScript) {
1498
1935
  if (!script || scriptAlreadyMounted(script)) return null;
1499
1936
  return mountChatWidgetScript$1(script);
1500
1937
  }
1501
- //#endregion
1502
- //#region src/browser.tsx
1938
+ // src/browser.tsx
1503
1939
  const mountChatWidget = mountChatWidget$1;
1504
1940
  const mountChatWidgetScript = mountChatWidgetScript$1;
1505
1941
  const autoMountChatWidgetScript = autoMountChatWidgetScript$1;
1506
1942
  if (typeof document !== "undefined") autoMountChatWidgetScript();
1507
- //#endregion
1508
1943
  export { autoMountChatWidgetScript, mountChatWidget, mountChatWidgetScript };