@clivly/chat-widget 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRoot } from "react-dom/client";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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,51 @@ 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/chat-widget.tsx
114
157
  const DELIVERY_LABELS = {
115
158
  failed: "Failed to send",
116
159
  pending: "Sending…",
@@ -169,16 +212,95 @@ const WIDGET_STYLES = `
169
212
  font-size: max(16px, 1em);
170
213
  }
171
214
  }
215
+ /* Floating placements. The insets keep the launcher off a phone's home
216
+ indicator and rounded corners; --clivly-chat-z-index lets a host slot it
217
+ beneath its own dialogs. */
218
+ .clivly-chat-widget[data-clivly-placement="bottom-right"],
219
+ .clivly-chat-widget[data-clivly-placement="bottom-left"] {
220
+ bottom: max(1.25rem, env(safe-area-inset-bottom));
221
+ position: fixed;
222
+ z-index: var(--clivly-chat-z-index, 2147483000);
223
+ }
224
+ .clivly-chat-widget[data-clivly-placement="bottom-right"] {
225
+ right: max(1.25rem, env(safe-area-inset-right));
226
+ }
227
+ .clivly-chat-widget[data-clivly-placement="bottom-left"] {
228
+ left: max(1.25rem, env(safe-area-inset-left));
229
+ }
172
230
  .clivly-chat-widget__launcher {
173
231
  align-items: center;
174
232
  background: var(--clivly-chat-accent);
175
233
  border: 0;
176
234
  border-radius: 999px;
235
+ box-shadow:
236
+ 0 2px 6px rgba(0, 0, 0, 0.18),
237
+ 0 10px 28px -10px var(--clivly-chat-accent);
177
238
  color: var(--clivly-chat-accent-contrast);
178
239
  cursor: pointer;
179
240
  display: inline-flex;
241
+ font-weight: 500;
180
242
  gap: 0.5rem;
181
- padding: 0.85rem 1.1rem;
243
+ justify-content: center;
244
+ min-height: 3rem;
245
+ padding: 0 1.15rem;
246
+ transition: transform 150ms ease-out, filter 150ms ease-out;
247
+ }
248
+ .clivly-chat-widget__launcher:hover {
249
+ filter: brightness(1.06);
250
+ transform: translateY(-1px);
251
+ }
252
+ .clivly-chat-widget__launcher:active {
253
+ transform: scale(0.96);
254
+ }
255
+ .clivly-chat-widget__launcher:focus-visible {
256
+ outline: 2px solid var(--clivly-chat-accent);
257
+ outline-offset: 3px;
258
+ }
259
+ .clivly-chat-widget__launcher-icon {
260
+ flex-shrink: 0;
261
+ height: 1.25rem;
262
+ width: 1.25rem;
263
+ }
264
+ /* Icon-only: a 48px circle — a comfortable touch target that does not cover
265
+ the host's pagination, toolbars or form controls the way a labelled pill
266
+ does on a phone. The label is clipped, not removed, so it stays the
267
+ button's accessible name. */
268
+ .clivly-chat-widget[data-clivly-launcher="icon"] .clivly-chat-widget__launcher {
269
+ height: 3rem;
270
+ padding: 0;
271
+ width: 3rem;
272
+ }
273
+ .clivly-chat-widget[data-clivly-launcher="icon"] .clivly-chat-widget__launcher-label {
274
+ clip-path: inset(50%);
275
+ height: 1px;
276
+ overflow: hidden;
277
+ position: absolute;
278
+ white-space: nowrap;
279
+ width: 1px;
280
+ }
281
+ @media (width < 48rem) {
282
+ .clivly-chat-widget[data-clivly-launcher="auto"] .clivly-chat-widget__launcher {
283
+ height: 3rem;
284
+ padding: 0;
285
+ width: 3rem;
286
+ }
287
+ .clivly-chat-widget[data-clivly-launcher="auto"] .clivly-chat-widget__launcher-label {
288
+ clip-path: inset(50%);
289
+ height: 1px;
290
+ overflow: hidden;
291
+ position: absolute;
292
+ white-space: nowrap;
293
+ width: 1px;
294
+ }
295
+ }
296
+ @media (prefers-reduced-motion: reduce) {
297
+ .clivly-chat-widget__launcher {
298
+ transition: none;
299
+ }
300
+ .clivly-chat-widget__launcher:hover,
301
+ .clivly-chat-widget__launcher:active {
302
+ transform: none;
303
+ }
182
304
  }
183
305
  .clivly-chat-widget__panel {
184
306
  background: var(--clivly-chat-panel);
@@ -194,6 +316,19 @@ const WIDGET_STYLES = `
194
316
  bottom: 4.5rem;
195
317
  width: min(24rem, calc(100vw - 2rem));
196
318
  }
319
+ /* Opens toward the page from whichever corner the launcher is in. */
320
+ .clivly-chat-widget[data-clivly-placement="bottom-left"] .clivly-chat-widget__panel {
321
+ left: 0;
322
+ right: auto;
323
+ }
324
+ /* dvh: on a phone vh includes the collapsed URL bar, so a 75vh panel's top
325
+ edge could slide under it. The 48px launcher sits lower than the pill. */
326
+ @media (width < 48rem) {
327
+ .clivly-chat-widget__panel {
328
+ bottom: 4rem;
329
+ height: min(44rem, calc(100dvh - 6.5rem));
330
+ }
331
+ }
197
332
  .clivly-chat-widget__header {
198
333
  border-bottom: 1px solid var(--clivly-chat-border);
199
334
  display: flex;
@@ -440,9 +575,6 @@ function createLocalMessage(message, delivery = "sent") {
440
575
  delivery
441
576
  };
442
577
  }
443
- function createStorageKey(widgetId) {
444
- return `clivly-chat-widget:${widgetId}:visitor-token`;
445
- }
446
578
  function formatTimestamp(value) {
447
579
  const date = new Date(value);
448
580
  if (Number.isNaN(date.getTime())) return "";
@@ -561,6 +693,45 @@ function automationBadge(message) {
561
693
  if (message.senderType !== "system" || !message.automation) return null;
562
694
  return AUTOMATION_BADGES[message.automation] ?? "Automated";
563
695
  }
696
+ /**
697
+ * The button that opens and closes the panel. Its size and shape come from the
698
+ * root's `data-clivly-launcher` (see WIDGET_STYLES); the label is always its
699
+ * accessible name, and `aria-controls` names the panel only while it exists.
700
+ */
701
+ function Launcher({ label, onToggle, open, panelId }) {
702
+ return /* @__PURE__ */ jsxs("button", {
703
+ "aria-controls": open ? panelId : void 0,
704
+ "aria-expanded": open,
705
+ className: "clivly-chat-widget__launcher",
706
+ onClick: onToggle,
707
+ type: "button",
708
+ children: [/* @__PURE__ */ jsx(LauncherIcon, { open }), /* @__PURE__ */ jsx("span", {
709
+ className: "clivly-chat-widget__launcher-label",
710
+ children: label
711
+ })]
712
+ });
713
+ }
714
+ /**
715
+ * A chat bubble while closed, a cross while open — so the icon-only launcher
716
+ * still tells a sighted visitor what tapping it will do. Decorative: the
717
+ * label and `aria-expanded` carry the meaning for assistive technology.
718
+ * Inline SVG because the script-tag build renders inside a shadow root with no
719
+ * icon font or stylesheet of the host's to borrow.
720
+ */
721
+ function LauncherIcon({ open }) {
722
+ return /* @__PURE__ */ jsx("svg", {
723
+ "aria-hidden": "true",
724
+ className: "clivly-chat-widget__launcher-icon",
725
+ fill: "none",
726
+ focusable: "false",
727
+ stroke: "currentColor",
728
+ strokeLinecap: "round",
729
+ strokeLinejoin: "round",
730
+ strokeWidth: 2,
731
+ viewBox: "0 0 24 24",
732
+ children: open ? /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" }) : /* @__PURE__ */ jsx("path", { d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" })
733
+ });
734
+ }
564
735
  function MessageBubble({ message }) {
565
736
  const badge = automationBadge(message);
566
737
  const author = badge ? (message.author ?? "").replace(AUTHOR_LABEL_SUFFIX, "") : message.author;
@@ -671,9 +842,10 @@ function isCompleteVisitor(visitor) {
671
842
  * the visitor is told to wait for something that will never arrive. The third
672
843
  * string keeps the status honest in all three states.
673
844
  */
674
- function composerStatusText(hasTransport, isReady) {
845
+ function composerStatusText(hasTransport, isReady, reconnecting) {
675
846
  if (!hasTransport) return "Chat is unavailable";
676
- return isReady ? "Conversation ready" : "Connecting…";
847
+ if (isReady) return "Conversation ready";
848
+ return reconnecting ? "Reconnecting…" : "Connecting…";
677
849
  }
678
850
  /**
679
851
  * Guided options under the greeting. Native buttons in a labelled group, so
@@ -739,8 +911,125 @@ function StatusNotice({ assistantThinking, email, showWaitingNotice }) {
739
911
  children: waitingNoticeText(email)
740
912
  });
741
913
  }
742
- function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
914
+ const RECONNECT_BASE_MS = 1e3;
915
+ const RECONNECT_MAX_MS = 3e4;
916
+ /**
917
+ * How long to wait before reconnect attempt `attempt` (0-based): doubling from
918
+ * one second to a 30-second ceiling, ±25% jitter so a fleet of widgets dropped
919
+ * by the same server restart doesn't reconnect in lockstep.
920
+ */
921
+ function reconnectDelayMs(attempt, random = Math.random) {
922
+ const base = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);
923
+ return Math.round(base * (.75 + random() * .5));
924
+ }
925
+ /**
926
+ * Keep the socket for `session` open, and reconnect when it drops — a laptop
927
+ * waking, a network change, a server deploy — instead of leaving the visitor at
928
+ * "Connecting…" until they reload. A reconnect refreshes the session (same
929
+ * conversation, fresh token) and the server's snapshot on connect brings back
930
+ * anything that arrived meanwhile. Returns whether a reconnect is pending.
931
+ */
932
+ function useLiveConnection({ actions, createTransport, refreshSession, session, setIsReady, transportRef }) {
933
+ const [reconnecting, setReconnecting] = useState(false);
934
+ const attemptRef = useRef(0);
935
+ const refreshRef = useRef(refreshSession);
936
+ const actionsRef = useRef(actions);
937
+ useEffect(() => {
938
+ refreshRef.current = refreshSession;
939
+ actionsRef.current = actions;
940
+ });
941
+ useEffect(() => {
942
+ transportRef.current?.close();
943
+ transportRef.current = null;
944
+ setIsReady(false);
945
+ if (!(session && createTransport)) return;
946
+ let disposed = false;
947
+ let timer;
948
+ const retry = () => {
949
+ timer = void 0;
950
+ refreshRef.current(session).catch(() => {
951
+ scheduleReconnect();
952
+ });
953
+ };
954
+ const scheduleReconnect = () => {
955
+ if (disposed || timer !== void 0) return;
956
+ setReconnecting(true);
957
+ timer = setTimeout(retry, reconnectDelayMs(attemptRef.current));
958
+ attemptRef.current += 1;
959
+ };
960
+ const retryNow = () => {
961
+ if (timer !== void 0) {
962
+ clearTimeout(timer);
963
+ retry();
964
+ }
965
+ };
966
+ window.addEventListener("online", retryNow);
967
+ const connection = createTransport({
968
+ handlers: {
969
+ onClose: () => {
970
+ setIsReady(false);
971
+ actionsRef.current.setAssistantThinking(false);
972
+ scheduleReconnect();
973
+ },
974
+ onError: (transportError) => {
975
+ if (attemptRef.current === 0) actionsRef.current.setError(transportError.message);
976
+ },
977
+ onEvent: (event) => {
978
+ applyServerEvent(event, {
979
+ ...actionsRef.current,
980
+ setIsReady
981
+ });
982
+ },
983
+ onOpen: () => {
984
+ if (attemptRef.current > 0) actionsRef.current.setError(null);
985
+ attemptRef.current = 0;
986
+ setReconnecting(false);
987
+ setIsReady(true);
988
+ }
989
+ },
990
+ session
991
+ });
992
+ transportRef.current = connection;
993
+ return () => {
994
+ disposed = true;
995
+ if (timer !== void 0) clearTimeout(timer);
996
+ window.removeEventListener("online", retryNow);
997
+ connection.close();
998
+ if (transportRef.current === connection) transportRef.current = null;
999
+ };
1000
+ }, [createTransport, session]);
1001
+ return reconnecting;
1002
+ }
1003
+ /** Who the host says is signed in: their normalised email, or null. */
1004
+ function hostIdentityOf(defaultVisitor) {
1005
+ return isCompleteVisitor(defaultVisitor) ? normalizeVisitorEmail(defaultVisitor.email) : null;
1006
+ }
1007
+ /**
1008
+ * The host's signed-in visitor changed under an open session — a sign-out, or
1009
+ * a different account signing in without a page reload. The session on
1010
+ * screen is the previous person's: drop it, its transcript and its socket, so
1011
+ * the next open starts as whoever is signed in now (chat widget session
1012
+ * review 2026-10-28, S1). A session the visitor started from the pre-chat
1013
+ * form is theirs and is left alone.
1014
+ */
1015
+ function useResetOnHostIdentityChange({ generationRef, hostIdentity, identityRef, reset, sessionSourceRef }) {
1016
+ const resetRef = useRef(reset);
1017
+ useEffect(() => {
1018
+ resetRef.current = reset;
1019
+ });
1020
+ useEffect(() => {
1021
+ const current = identityRef.current;
1022
+ if (!(current && sessionSourceRef.current === "host")) return;
1023
+ if (hostIdentity === normalizeVisitorEmail(current.email)) return;
1024
+ generationRef.current += 1;
1025
+ identityRef.current = null;
1026
+ sessionSourceRef.current = null;
1027
+ resetRef.current();
1028
+ }, [hostIdentity]);
1029
+ }
1030
+ function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcher = "auto", launcherLabel = "Chat with us", placement = "bottom-right", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
743
1031
  const [isOpen, setIsOpen] = useState(initiallyOpen);
1032
+ const panelId = useId();
744
1033
  const [error, setError] = useState(null);
745
1034
  const [isConnecting, setIsConnecting] = useState(false);
746
1035
  const [isReady, setIsReady] = useState(false);
@@ -755,6 +1044,12 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
755
1044
  });
756
1045
  const transportRef = useRef(null);
757
1046
  const listRef = useRef(null);
1047
+ const identityRef = useRef(null);
1048
+ const sessionSourceRef = useRef(null);
1049
+ const generationRef = useRef(0);
1050
+ const hostIdentity = hostIdentityOf(defaultVisitor);
1051
+ const latestHostIdentityRef = useRef(hostIdentity);
1052
+ latestHostIdentityRef.current = hostIdentity;
758
1053
  useEffect(() => {
759
1054
  if (createTransport) return;
760
1055
  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");
@@ -770,41 +1065,32 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
770
1065
  if (!listRef.current) return;
771
1066
  listRef.current.scrollTop = listRef.current.scrollHeight;
772
1067
  }, [messages]);
773
- useEffect(() => {
774
- transportRef.current?.close();
775
- transportRef.current = null;
776
- setIsReady(false);
777
- if (!(session && createTransport)) return;
778
- const connection = createTransport({
779
- handlers: {
780
- onClose: () => {
781
- setIsReady(false);
782
- setAssistantThinking(false);
783
- },
784
- onError: (transportError) => {
785
- setError(transportError.message);
786
- },
787
- onEvent: (event) => {
788
- applyServerEvent(event, {
789
- setAssistantThinking,
790
- setError,
791
- setIsReady,
792
- setMessages,
793
- setSupportState
794
- });
795
- },
796
- onOpen: () => {
797
- setIsReady(true);
798
- }
799
- },
800
- session
801
- });
802
- transportRef.current = connection;
803
- return () => {
804
- connection.close();
805
- if (transportRef.current === connection) transportRef.current = null;
806
- };
807
- }, [createTransport, session]);
1068
+ const reconnecting = useLiveConnection({
1069
+ actions: {
1070
+ setAssistantThinking,
1071
+ setError,
1072
+ setMessages,
1073
+ setSupportState
1074
+ },
1075
+ createTransport,
1076
+ refreshSession: async (current) => {
1077
+ const identity = identityRef.current;
1078
+ if (!identity) throw new Error("No visitor to reconnect as.");
1079
+ const generation = generationRef.current;
1080
+ const next = await getSession({
1081
+ visitor: identity,
1082
+ visitorToken: current.visitorToken,
1083
+ widgetId
1084
+ });
1085
+ if (generation !== generationRef.current) return;
1086
+ writeVisitorToken(storage, widgetId, identity.email, next.visitorToken);
1087
+ setSupportState(next.support?.state ?? null);
1088
+ setSession(next);
1089
+ },
1090
+ session,
1091
+ setIsReady,
1092
+ transportRef
1093
+ });
808
1094
  useEffect(() => {
809
1095
  if (!(isOpen && isReady)) return;
810
1096
  const markReadIfVisible = () => {
@@ -822,30 +1108,53 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
822
1108
  isReady,
823
1109
  messages
824
1110
  ]);
825
- const startSession = useCallback(async (identity) => {
1111
+ const startSession = useCallback(async (identity, source = "form") => {
826
1112
  setError(null);
827
1113
  setIsConnecting(true);
1114
+ const generation = generationRef.current;
1115
+ const isStale = () => generation !== generationRef.current || source === "host" && normalizeVisitorEmail(identity.email) !== latestHostIdentityRef.current;
828
1116
  try {
829
1117
  const nextSession = await getSession({
830
1118
  visitor: identity,
831
- visitorToken: storage?.getItem(createStorageKey(widgetId)) ?? null,
1119
+ visitorToken: readVisitorToken(storage, widgetId, identity.email),
832
1120
  widgetId
833
1121
  });
834
- storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
1122
+ if (isStale()) return;
1123
+ writeVisitorToken(storage, widgetId, identity.email, nextSession.visitorToken);
1124
+ identityRef.current = identity;
1125
+ sessionSourceRef.current = source;
835
1126
  setSupportState(nextSession.support?.state ?? null);
836
1127
  setSession(nextSession);
837
1128
  setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
838
1129
  } catch (sessionError) {
1130
+ if (isStale()) return;
839
1131
  const resolved = sessionError instanceof Error ? sessionError : /* @__PURE__ */ new Error("Failed to start the conversation.");
840
1132
  setError(resolved.message);
841
1133
  } finally {
842
- setIsConnecting(false);
1134
+ if (generation === generationRef.current) setIsConnecting(false);
843
1135
  }
844
1136
  }, [
845
1137
  getSession,
846
1138
  storage,
847
1139
  widgetId
848
1140
  ]);
1141
+ useResetOnHostIdentityChange({
1142
+ generationRef,
1143
+ hostIdentity,
1144
+ identityRef,
1145
+ reset: () => {
1146
+ setSession(null);
1147
+ setMessages([]);
1148
+ setSupportState(null);
1149
+ setError(null);
1150
+ setIsConnecting(false);
1151
+ setVisitor({
1152
+ email: defaultVisitor?.email ?? "",
1153
+ name: defaultVisitor?.name ?? ""
1154
+ });
1155
+ },
1156
+ sessionSourceRef
1157
+ });
849
1158
  const submitVisitor = async (event) => {
850
1159
  event.preventDefault();
851
1160
  await startSession(visitor);
@@ -856,7 +1165,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
856
1165
  startSession({
857
1166
  email: defaultVisitor.email.trim(),
858
1167
  name: defaultVisitor.name.trim()
859
- }).catch(() => {});
1168
+ }, "host").catch(() => {});
860
1169
  }, [
861
1170
  defaultVisitor,
862
1171
  error,
@@ -909,21 +1218,24 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
909
1218
  const topics = visibleTopics(session, supportState);
910
1219
  return /* @__PURE__ */ jsxs("div", {
911
1220
  className: `clivly-chat-widget ${className ?? ""}`.trim(),
1221
+ "data-clivly-launcher": launcher,
1222
+ "data-clivly-placement": placement,
912
1223
  "data-clivly-theme": resolvedTheme,
913
1224
  style: themeStyle,
914
1225
  children: [
915
1226
  /* @__PURE__ */ jsx("style", { children: WIDGET_STYLES }),
916
- /* @__PURE__ */ jsx("button", {
917
- className: "clivly-chat-widget__launcher",
918
- onClick: () => {
1227
+ /* @__PURE__ */ jsx(Launcher, {
1228
+ label: launcherLabel,
1229
+ onToggle: () => {
919
1230
  setIsOpen((open) => !open);
920
1231
  },
921
- type: "button",
922
- children: /* @__PURE__ */ jsx("span", { children: launcherLabel })
1232
+ open: isOpen,
1233
+ panelId
923
1234
  }),
924
1235
  isOpen ? /* @__PURE__ */ jsxs("section", {
925
1236
  "aria-label": panelTitle,
926
1237
  className: "clivly-chat-widget__panel",
1238
+ id: panelId,
927
1239
  children: [
928
1240
  /* @__PURE__ */ jsxs("header", {
929
1241
  className: "clivly-chat-widget__header",
@@ -996,8 +1308,9 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
996
1308
  }), /* @__PURE__ */ jsxs("div", {
997
1309
  className: "clivly-chat-widget__actions",
998
1310
  children: [/* @__PURE__ */ jsx("span", {
1311
+ "aria-live": "polite",
999
1312
  className: "clivly-chat-widget__subtitle",
1000
- children: composerStatusText(Boolean(createTransport), isReady)
1313
+ children: composerStatusText(Boolean(createTransport), isReady, reconnecting)
1001
1314
  }), /* @__PURE__ */ jsx("button", {
1002
1315
  className: "clivly-chat-widget__button",
1003
1316
  disabled: !(messageDraft.trim() && canSend),
@@ -1096,8 +1409,7 @@ function PreChatForm() {
1096
1409
  function ConversationView() {
1097
1410
  return null;
1098
1411
  }
1099
- //#endregion
1100
- //#region src/transport.ts
1412
+ // src/transport.ts
1101
1413
  function isServerEvent(value) {
1102
1414
  if (typeof value !== "object" || value === null) return false;
1103
1415
  return typeof value.type === "string";
@@ -1199,8 +1511,7 @@ function createLoopbackTransport({ operatorName = "Clivly", operatorReply = (bod
1199
1511
  };
1200
1512
  };
1201
1513
  }
1202
- //#endregion
1203
- //#region src/clivly-transport.ts
1514
+ // src/clivly-transport.ts
1204
1515
  /**
1205
1516
  * The transport a host app should use against a real Clivly backend.
1206
1517
  *
@@ -1217,8 +1528,26 @@ function createClivlyTransport$1(options = {}) {
1217
1528
  }
1218
1529
  });
1219
1530
  }
1220
- //#endregion
1221
- //#region src/session.ts
1531
+ // src/layout.ts
1532
+ const PLACEMENTS = [
1533
+ "bottom-left",
1534
+ "bottom-right",
1535
+ "inline"
1536
+ ];
1537
+ const LAUNCHERS = [
1538
+ "auto",
1539
+ "icon",
1540
+ "pill"
1541
+ ];
1542
+ /** A script tag's `data-placement`; anything unrecognised floats bottom-right. */
1543
+ function parsePlacement(value) {
1544
+ return PLACEMENTS.find((placement) => placement === value) ?? "bottom-right";
1545
+ }
1546
+ /** A script tag's `data-launcher`; anything unrecognised is `"auto"`. */
1547
+ function parseLauncher(value) {
1548
+ return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
1549
+ }
1550
+ // src/session.ts
1222
1551
  function isWidgetSessionPayload(value) {
1223
1552
  return typeof value === "object" && value !== null;
1224
1553
  }
@@ -1240,8 +1569,7 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
1240
1569
  return data;
1241
1570
  };
1242
1571
  }
1243
- //#endregion
1244
- //#region src/browser-mount.tsx
1572
+ // src/browser-mount.tsx
1245
1573
  const MOUNTED_DATA_ATTR = "clivlyMounted";
1246
1574
  /**
1247
1575
  * The identity a script-tag host already knows, read off the tag.
@@ -1315,6 +1643,8 @@ function mountChatWidgetScript$1(script = document.currentScript) {
1315
1643
  const { root, setTheme } = createWidgetMount({
1316
1644
  container,
1317
1645
  defaultVisitor: readScriptVisitor(script.dataset),
1646
+ launcher: parseLauncher(script.dataset.launcher),
1647
+ placement: parsePlacement(script.dataset.placement),
1318
1648
  sessionUrl,
1319
1649
  socketUrlTemplate: script.dataset.socketUrlTemplate,
1320
1650
  theme: parseTheme(script.dataset.theme),
@@ -1331,11 +1661,9 @@ function autoMountChatWidgetScript$1(script = document.currentScript) {
1331
1661
  if (!script || scriptAlreadyMounted(script)) return null;
1332
1662
  return mountChatWidgetScript$1(script);
1333
1663
  }
1334
- //#endregion
1335
- //#region src/index.ts
1664
+ // src/index.ts
1336
1665
  const autoMountChatWidgetScript = autoMountChatWidgetScript$1;
1337
1666
  const createClivlyTransport = createClivlyTransport$1;
1338
1667
  const mountChatWidget = mountChatWidget$1;
1339
1668
  const mountChatWidgetScript = mountChatWidgetScript$1;
1340
- //#endregion
1341
1669
  export { ChatLauncher, ChatPanel, ChatWidget, ConversationView, MessageComposer, PreChatForm, autoMountChatWidgetScript, createClivlyTransport, createLoopbackTransport, createSessionFetcher, createWebSocketTransport, mountChatWidget, mountChatWidgetScript, resolveTheme };