@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/index.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",
@@ -1313,8 +1755,7 @@ function PreChatForm() {
1313
1755
  function ConversationView() {
1314
1756
  return null;
1315
1757
  }
1316
- //#endregion
1317
- //#region src/transport.ts
1758
+ // src/transport.ts
1318
1759
  function isServerEvent(value) {
1319
1760
  if (typeof value !== "object" || value === null) return false;
1320
1761
  return typeof value.type === "string";
@@ -1416,8 +1857,7 @@ function createLoopbackTransport({ operatorName = "Clivly", operatorReply = (bod
1416
1857
  };
1417
1858
  };
1418
1859
  }
1419
- //#endregion
1420
- //#region src/clivly-transport.ts
1860
+ // src/clivly-transport.ts
1421
1861
  /**
1422
1862
  * The transport a host app should use against a real Clivly backend.
1423
1863
  *
@@ -1434,8 +1874,7 @@ function createClivlyTransport$1(options = {}) {
1434
1874
  }
1435
1875
  });
1436
1876
  }
1437
- //#endregion
1438
- //#region src/layout.ts
1877
+ // src/layout.ts
1439
1878
  const PLACEMENTS = [
1440
1879
  "bottom-left",
1441
1880
  "bottom-right",
@@ -1454,8 +1893,7 @@ function parsePlacement(value) {
1454
1893
  function parseLauncher(value) {
1455
1894
  return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
1456
1895
  }
1457
- //#endregion
1458
- //#region src/session.ts
1896
+ // src/session.ts
1459
1897
  function isWidgetSessionPayload(value) {
1460
1898
  return typeof value === "object" && value !== null;
1461
1899
  }
@@ -1477,8 +1915,7 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
1477
1915
  return data;
1478
1916
  };
1479
1917
  }
1480
- //#endregion
1481
- //#region src/browser-mount.tsx
1918
+ // src/browser-mount.tsx
1482
1919
  const MOUNTED_DATA_ATTR = "clivlyMounted";
1483
1920
  /**
1484
1921
  * The identity a script-tag host already knows, read off the tag.
@@ -1570,11 +2007,9 @@ function autoMountChatWidgetScript$1(script = document.currentScript) {
1570
2007
  if (!script || scriptAlreadyMounted(script)) return null;
1571
2008
  return mountChatWidgetScript$1(script);
1572
2009
  }
1573
- //#endregion
1574
- //#region src/index.ts
2010
+ // src/index.ts
1575
2011
  const autoMountChatWidgetScript = autoMountChatWidgetScript$1;
1576
2012
  const createClivlyTransport = createClivlyTransport$1;
1577
2013
  const mountChatWidget = mountChatWidget$1;
1578
2014
  const mountChatWidgetScript = mountChatWidgetScript$1;
1579
- //#endregion
1580
2015
  export { ChatLauncher, ChatPanel, ChatWidget, ConversationView, MessageComposer, PreChatForm, autoMountChatWidgetScript, createClivlyTransport, createLoopbackTransport, createSessionFetcher, createWebSocketTransport, mountChatWidget, mountChatWidgetScript, resolveTheme };