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