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