@clivly/chat-widget 0.15.0 → 0.16.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
@@ -170,16 +170,95 @@ const WIDGET_STYLES = `
170
170
  font-size: max(16px, 1em);
171
171
  }
172
172
  }
173
+ /* Floating placements. The insets keep the launcher off a phone's home
174
+ indicator and rounded corners; --clivly-chat-z-index lets a host slot it
175
+ beneath its own dialogs. */
176
+ .clivly-chat-widget[data-clivly-placement="bottom-right"],
177
+ .clivly-chat-widget[data-clivly-placement="bottom-left"] {
178
+ bottom: max(1.25rem, env(safe-area-inset-bottom));
179
+ position: fixed;
180
+ z-index: var(--clivly-chat-z-index, 2147483000);
181
+ }
182
+ .clivly-chat-widget[data-clivly-placement="bottom-right"] {
183
+ right: max(1.25rem, env(safe-area-inset-right));
184
+ }
185
+ .clivly-chat-widget[data-clivly-placement="bottom-left"] {
186
+ left: max(1.25rem, env(safe-area-inset-left));
187
+ }
173
188
  .clivly-chat-widget__launcher {
174
189
  align-items: center;
175
190
  background: var(--clivly-chat-accent);
176
191
  border: 0;
177
192
  border-radius: 999px;
193
+ box-shadow:
194
+ 0 2px 6px rgba(0, 0, 0, 0.18),
195
+ 0 10px 28px -10px var(--clivly-chat-accent);
178
196
  color: var(--clivly-chat-accent-contrast);
179
197
  cursor: pointer;
180
198
  display: inline-flex;
199
+ font-weight: 500;
181
200
  gap: 0.5rem;
182
- padding: 0.85rem 1.1rem;
201
+ justify-content: center;
202
+ min-height: 3rem;
203
+ padding: 0 1.15rem;
204
+ transition: transform 150ms ease-out, filter 150ms ease-out;
205
+ }
206
+ .clivly-chat-widget__launcher:hover {
207
+ filter: brightness(1.06);
208
+ transform: translateY(-1px);
209
+ }
210
+ .clivly-chat-widget__launcher:active {
211
+ transform: scale(0.96);
212
+ }
213
+ .clivly-chat-widget__launcher:focus-visible {
214
+ outline: 2px solid var(--clivly-chat-accent);
215
+ outline-offset: 3px;
216
+ }
217
+ .clivly-chat-widget__launcher-icon {
218
+ flex-shrink: 0;
219
+ height: 1.25rem;
220
+ width: 1.25rem;
221
+ }
222
+ /* Icon-only: a 48px circle — a comfortable touch target that does not cover
223
+ the host's pagination, toolbars or form controls the way a labelled pill
224
+ does on a phone. The label is clipped, not removed, so it stays the
225
+ button's accessible name. */
226
+ .clivly-chat-widget[data-clivly-launcher="icon"] .clivly-chat-widget__launcher {
227
+ height: 3rem;
228
+ padding: 0;
229
+ width: 3rem;
230
+ }
231
+ .clivly-chat-widget[data-clivly-launcher="icon"] .clivly-chat-widget__launcher-label {
232
+ clip-path: inset(50%);
233
+ height: 1px;
234
+ overflow: hidden;
235
+ position: absolute;
236
+ white-space: nowrap;
237
+ width: 1px;
238
+ }
239
+ @media (width < 48rem) {
240
+ .clivly-chat-widget[data-clivly-launcher="auto"] .clivly-chat-widget__launcher {
241
+ height: 3rem;
242
+ padding: 0;
243
+ width: 3rem;
244
+ }
245
+ .clivly-chat-widget[data-clivly-launcher="auto"] .clivly-chat-widget__launcher-label {
246
+ clip-path: inset(50%);
247
+ height: 1px;
248
+ overflow: hidden;
249
+ position: absolute;
250
+ white-space: nowrap;
251
+ width: 1px;
252
+ }
253
+ }
254
+ @media (prefers-reduced-motion: reduce) {
255
+ .clivly-chat-widget__launcher {
256
+ transition: none;
257
+ }
258
+ .clivly-chat-widget__launcher:hover,
259
+ .clivly-chat-widget__launcher:active {
260
+ transform: none;
261
+ }
183
262
  }
184
263
  .clivly-chat-widget__panel {
185
264
  background: var(--clivly-chat-panel);
@@ -195,6 +274,19 @@ const WIDGET_STYLES = `
195
274
  bottom: 4.5rem;
196
275
  width: min(24rem, calc(100vw - 2rem));
197
276
  }
277
+ /* Opens toward the page from whichever corner the launcher is in. */
278
+ .clivly-chat-widget[data-clivly-placement="bottom-left"] .clivly-chat-widget__panel {
279
+ left: 0;
280
+ right: auto;
281
+ }
282
+ /* dvh: on a phone vh includes the collapsed URL bar, so a 75vh panel's top
283
+ edge could slide under it. The 48px launcher sits lower than the pill. */
284
+ @media (width < 48rem) {
285
+ .clivly-chat-widget__panel {
286
+ bottom: 4rem;
287
+ height: min(44rem, calc(100dvh - 6.5rem));
288
+ }
289
+ }
198
290
  .clivly-chat-widget__header {
199
291
  border-bottom: 1px solid var(--clivly-chat-border);
200
292
  display: flex;
@@ -562,6 +654,45 @@ function automationBadge(message) {
562
654
  if (message.senderType !== "system" || !message.automation) return null;
563
655
  return AUTOMATION_BADGES[message.automation] ?? "Automated";
564
656
  }
657
+ /**
658
+ * The button that opens and closes the panel. Its size and shape come from the
659
+ * root's `data-clivly-launcher` (see WIDGET_STYLES); the label is always its
660
+ * accessible name, and `aria-controls` names the panel only while it exists.
661
+ */
662
+ function Launcher({ label, onToggle, open, panelId }) {
663
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
664
+ "aria-controls": open ? panelId : void 0,
665
+ "aria-expanded": open,
666
+ className: "clivly-chat-widget__launcher",
667
+ onClick: onToggle,
668
+ type: "button",
669
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LauncherIcon, { open }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
670
+ className: "clivly-chat-widget__launcher-label",
671
+ children: label
672
+ })]
673
+ });
674
+ }
675
+ /**
676
+ * A chat bubble while closed, a cross while open — so the icon-only launcher
677
+ * still tells a sighted visitor what tapping it will do. Decorative: the
678
+ * label and `aria-expanded` carry the meaning for assistive technology.
679
+ * Inline SVG because the script-tag build renders inside a shadow root with no
680
+ * icon font or stylesheet of the host's to borrow.
681
+ */
682
+ function LauncherIcon({ open }) {
683
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
684
+ "aria-hidden": "true",
685
+ className: "clivly-chat-widget__launcher-icon",
686
+ fill: "none",
687
+ focusable: "false",
688
+ stroke: "currentColor",
689
+ strokeLinecap: "round",
690
+ strokeLinejoin: "round",
691
+ strokeWidth: 2,
692
+ viewBox: "0 0 24 24",
693
+ 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" })
694
+ });
695
+ }
565
696
  function MessageBubble({ message }) {
566
697
  const badge = automationBadge(message);
567
698
  const author = badge ? (message.author ?? "").replace(AUTHOR_LABEL_SUFFIX, "") : message.author;
@@ -672,9 +803,10 @@ function isCompleteVisitor(visitor) {
672
803
  * the visitor is told to wait for something that will never arrive. The third
673
804
  * string keeps the status honest in all three states.
674
805
  */
675
- function composerStatusText(hasTransport, isReady) {
806
+ function composerStatusText(hasTransport, isReady, reconnecting) {
676
807
  if (!hasTransport) return "Chat is unavailable";
677
- return isReady ? "Conversation ready" : "Connecting…";
808
+ if (isReady) return "Conversation ready";
809
+ return reconnecting ? "Reconnecting…" : "Connecting…";
678
810
  }
679
811
  /**
680
812
  * Guided options under the greeting. Native buttons in a labelled group, so
@@ -740,8 +872,98 @@ function StatusNotice({ assistantThinking, email, showWaitingNotice }) {
740
872
  children: waitingNoticeText(email)
741
873
  });
742
874
  }
743
- function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
875
+ const RECONNECT_BASE_MS = 1e3;
876
+ const RECONNECT_MAX_MS = 3e4;
877
+ /**
878
+ * How long to wait before reconnect attempt `attempt` (0-based): doubling from
879
+ * one second to a 30-second ceiling, ±25% jitter so a fleet of widgets dropped
880
+ * by the same server restart doesn't reconnect in lockstep.
881
+ */
882
+ function reconnectDelayMs(attempt, random = Math.random) {
883
+ const base = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);
884
+ return Math.round(base * (.75 + random() * .5));
885
+ }
886
+ /**
887
+ * Keep the socket for `session` open, and reconnect when it drops — a laptop
888
+ * waking, a network change, a server deploy — instead of leaving the visitor at
889
+ * "Connecting…" until they reload. A reconnect refreshes the session (same
890
+ * conversation, fresh token) and the server's snapshot on connect brings back
891
+ * anything that arrived meanwhile. Returns whether a reconnect is pending.
892
+ */
893
+ function useLiveConnection({ actions, createTransport, refreshSession, session, setIsReady, transportRef }) {
894
+ const [reconnecting, setReconnecting] = (0, react.useState)(false);
895
+ const attemptRef = (0, react.useRef)(0);
896
+ const refreshRef = (0, react.useRef)(refreshSession);
897
+ const actionsRef = (0, react.useRef)(actions);
898
+ (0, react.useEffect)(() => {
899
+ refreshRef.current = refreshSession;
900
+ actionsRef.current = actions;
901
+ });
902
+ (0, react.useEffect)(() => {
903
+ transportRef.current?.close();
904
+ transportRef.current = null;
905
+ setIsReady(false);
906
+ if (!(session && createTransport)) return;
907
+ let disposed = false;
908
+ let timer;
909
+ const retry = () => {
910
+ timer = void 0;
911
+ refreshRef.current(session).catch(() => {
912
+ scheduleReconnect();
913
+ });
914
+ };
915
+ const scheduleReconnect = () => {
916
+ if (disposed || timer !== void 0) return;
917
+ setReconnecting(true);
918
+ timer = setTimeout(retry, reconnectDelayMs(attemptRef.current));
919
+ attemptRef.current += 1;
920
+ };
921
+ const retryNow = () => {
922
+ if (timer !== void 0) {
923
+ clearTimeout(timer);
924
+ retry();
925
+ }
926
+ };
927
+ window.addEventListener("online", retryNow);
928
+ const connection = createTransport({
929
+ handlers: {
930
+ onClose: () => {
931
+ setIsReady(false);
932
+ actionsRef.current.setAssistantThinking(false);
933
+ scheduleReconnect();
934
+ },
935
+ onError: (transportError) => {
936
+ if (attemptRef.current === 0) actionsRef.current.setError(transportError.message);
937
+ },
938
+ onEvent: (event) => {
939
+ applyServerEvent(event, {
940
+ ...actionsRef.current,
941
+ setIsReady
942
+ });
943
+ },
944
+ onOpen: () => {
945
+ if (attemptRef.current > 0) actionsRef.current.setError(null);
946
+ attemptRef.current = 0;
947
+ setReconnecting(false);
948
+ setIsReady(true);
949
+ }
950
+ },
951
+ session
952
+ });
953
+ transportRef.current = connection;
954
+ return () => {
955
+ disposed = true;
956
+ if (timer !== void 0) clearTimeout(timer);
957
+ window.removeEventListener("online", retryNow);
958
+ connection.close();
959
+ if (transportRef.current === connection) transportRef.current = null;
960
+ };
961
+ }, [createTransport, session]);
962
+ return reconnecting;
963
+ }
964
+ 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
965
  const [isOpen, setIsOpen] = (0, react.useState)(initiallyOpen);
966
+ const panelId = (0, react.useId)();
745
967
  const [error, setError] = (0, react.useState)(null);
746
968
  const [isConnecting, setIsConnecting] = (0, react.useState)(false);
747
969
  const [isReady, setIsReady] = (0, react.useState)(false);
@@ -756,6 +978,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
756
978
  });
757
979
  const transportRef = (0, react.useRef)(null);
758
980
  const listRef = (0, react.useRef)(null);
981
+ const identityRef = (0, react.useRef)(null);
759
982
  (0, react.useEffect)(() => {
760
983
  if (createTransport) return;
761
984
  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 +994,30 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
771
994
  if (!listRef.current) return;
772
995
  listRef.current.scrollTop = listRef.current.scrollHeight;
773
996
  }, [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]);
997
+ const reconnecting = useLiveConnection({
998
+ actions: {
999
+ setAssistantThinking,
1000
+ setError,
1001
+ setMessages,
1002
+ setSupportState
1003
+ },
1004
+ createTransport,
1005
+ refreshSession: async (current) => {
1006
+ const identity = identityRef.current;
1007
+ if (!identity) throw new Error("No visitor to reconnect as.");
1008
+ const next = await getSession({
1009
+ visitor: identity,
1010
+ visitorToken: current.visitorToken,
1011
+ widgetId
1012
+ });
1013
+ storage?.setItem(createStorageKey(widgetId), next.visitorToken);
1014
+ setSupportState(next.support?.state ?? null);
1015
+ setSession(next);
1016
+ },
1017
+ session,
1018
+ setIsReady,
1019
+ transportRef
1020
+ });
809
1021
  (0, react.useEffect)(() => {
810
1022
  if (!(isOpen && isReady)) return;
811
1023
  const markReadIfVisible = () => {
@@ -833,6 +1045,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
833
1045
  widgetId
834
1046
  });
835
1047
  storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
1048
+ identityRef.current = identity;
836
1049
  setSupportState(nextSession.support?.state ?? null);
837
1050
  setSession(nextSession);
838
1051
  setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
@@ -910,21 +1123,24 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
910
1123
  const topics = visibleTopics(session, supportState);
911
1124
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
912
1125
  className: `clivly-chat-widget ${className ?? ""}`.trim(),
1126
+ "data-clivly-launcher": launcher,
1127
+ "data-clivly-placement": placement,
913
1128
  "data-clivly-theme": resolvedTheme,
914
1129
  style: themeStyle,
915
1130
  children: [
916
1131
  /* @__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: () => {
1132
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Launcher, {
1133
+ label: launcherLabel,
1134
+ onToggle: () => {
920
1135
  setIsOpen((open) => !open);
921
1136
  },
922
- type: "button",
923
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: launcherLabel })
1137
+ open: isOpen,
1138
+ panelId
924
1139
  }),
925
1140
  isOpen ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
926
1141
  "aria-label": panelTitle,
927
1142
  className: "clivly-chat-widget__panel",
1143
+ id: panelId,
928
1144
  children: [
929
1145
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
930
1146
  className: "clivly-chat-widget__header",
@@ -997,8 +1213,9 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
997
1213
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
998
1214
  className: "clivly-chat-widget__actions",
999
1215
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1216
+ "aria-live": "polite",
1000
1217
  className: "clivly-chat-widget__subtitle",
1001
- children: composerStatusText(Boolean(createTransport), isReady)
1218
+ children: composerStatusText(Boolean(createTransport), isReady, reconnecting)
1002
1219
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1003
1220
  className: "clivly-chat-widget__button",
1004
1221
  disabled: !(messageDraft.trim() && canSend),
@@ -1147,6 +1364,26 @@ function createClivlyTransport(options = {}) {
1147
1364
  });
1148
1365
  }
1149
1366
  //#endregion
1367
+ //#region src/layout.ts
1368
+ const PLACEMENTS = [
1369
+ "bottom-left",
1370
+ "bottom-right",
1371
+ "inline"
1372
+ ];
1373
+ const LAUNCHERS = [
1374
+ "auto",
1375
+ "icon",
1376
+ "pill"
1377
+ ];
1378
+ /** A script tag's `data-placement`; anything unrecognised floats bottom-right. */
1379
+ function parsePlacement(value) {
1380
+ return PLACEMENTS.find((placement) => placement === value) ?? "bottom-right";
1381
+ }
1382
+ /** A script tag's `data-launcher`; anything unrecognised is `"auto"`. */
1383
+ function parseLauncher(value) {
1384
+ return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
1385
+ }
1386
+ //#endregion
1150
1387
  //#region src/session.ts
1151
1388
  function isWidgetSessionPayload(value) {
1152
1389
  return typeof value === "object" && value !== null;
@@ -1244,6 +1481,8 @@ function mountChatWidgetScript$1(script = document.currentScript) {
1244
1481
  const { root, setTheme } = createWidgetMount({
1245
1482
  container,
1246
1483
  defaultVisitor: readScriptVisitor(script.dataset),
1484
+ launcher: parseLauncher(script.dataset.launcher),
1485
+ placement: parsePlacement(script.dataset.placement),
1247
1486
  sessionUrl,
1248
1487
  socketUrlTemplate: script.dataset.socketUrlTemplate,
1249
1488
  theme: parseTheme(script.dataset.theme),
@@ -1,5 +1,26 @@
1
1
  import { Root } from "react-dom/client";
2
2
  import { CSSProperties } from "react";
3
+ //#region src/layout.d.ts
4
+ /**
5
+ * Where the widget sits on the host page, and what its launcher looks like.
6
+ *
7
+ * Until 0.16 the widget rendered in normal flow wherever it was mounted — the
8
+ * script tag inserted it directly after itself, so most embeds showed a
9
+ * full-width-label pill at the end of the page rather than a floating button.
10
+ * Floating in a corner is now the default; `"inline"` keeps the old behaviour
11
+ * for a host that positions the widget itself.
12
+ */
13
+ type ChatWidgetPlacement = "bottom-left" | "bottom-right" | "inline";
14
+ /**
15
+ * - `"auto"` (default): the labelled pill on wide screens, a 48px icon-only
16
+ * button below 48rem, where a pill covers the host's own controls.
17
+ * - `"pill"`: always the labelled pill.
18
+ * - `"icon"`: always the icon-only button.
19
+ *
20
+ * The label stays the button's accessible name in every variant.
21
+ */
22
+ type ChatWidgetLauncher = "auto" | "icon" | "pill";
23
+ //#endregion
3
24
  //#region src/theme.d.ts
4
25
  /**
5
26
  * Theme resolution for the widget.
@@ -177,7 +198,17 @@ interface ChatWidgetProps {
177
198
  defaultVisitor?: Partial<ChatWidgetVisitor>;
178
199
  getSession: GetSession;
179
200
  initiallyOpen?: boolean;
201
+ /** Launcher shape; see {@link ChatWidgetLauncher}. Defaults to `"auto"`. */
202
+ launcher?: ChatWidgetLauncher;
203
+ /** The launcher's visible text, and always its accessible name. */
180
204
  launcherLabel?: string;
205
+ /**
206
+ * Where the widget sits; see {@link ChatWidgetPlacement}. Defaults to
207
+ * `"bottom-right"`, floating clear of the device's safe-area insets. Stacks
208
+ * at `--clivly-chat-z-index` (default 2147483000) — lower it through
209
+ * `style` to sit beneath the host's own dialogs.
210
+ */
211
+ placement?: ChatWidgetPlacement;
181
212
  storage?: Pick<Storage, "getItem" | "setItem">;
182
213
  /**
183
214
  * Inline styles on the widget root. The intended use is overriding palette
@@ -1,5 +1,26 @@
1
1
  import { Root } from "react-dom/client";
2
2
  import { CSSProperties } from "react";
3
+ //#region src/layout.d.ts
4
+ /**
5
+ * Where the widget sits on the host page, and what its launcher looks like.
6
+ *
7
+ * Until 0.16 the widget rendered in normal flow wherever it was mounted — the
8
+ * script tag inserted it directly after itself, so most embeds showed a
9
+ * full-width-label pill at the end of the page rather than a floating button.
10
+ * Floating in a corner is now the default; `"inline"` keeps the old behaviour
11
+ * for a host that positions the widget itself.
12
+ */
13
+ type ChatWidgetPlacement = "bottom-left" | "bottom-right" | "inline";
14
+ /**
15
+ * - `"auto"` (default): the labelled pill on wide screens, a 48px icon-only
16
+ * button below 48rem, where a pill covers the host's own controls.
17
+ * - `"pill"`: always the labelled pill.
18
+ * - `"icon"`: always the icon-only button.
19
+ *
20
+ * The label stays the button's accessible name in every variant.
21
+ */
22
+ type ChatWidgetLauncher = "auto" | "icon" | "pill";
23
+ //#endregion
3
24
  //#region src/theme.d.ts
4
25
  /**
5
26
  * Theme resolution for the widget.
@@ -177,7 +198,17 @@ interface ChatWidgetProps {
177
198
  defaultVisitor?: Partial<ChatWidgetVisitor>;
178
199
  getSession: GetSession;
179
200
  initiallyOpen?: boolean;
201
+ /** Launcher shape; see {@link ChatWidgetLauncher}. Defaults to `"auto"`. */
202
+ launcher?: ChatWidgetLauncher;
203
+ /** The launcher's visible text, and always its accessible name. */
180
204
  launcherLabel?: string;
205
+ /**
206
+ * Where the widget sits; see {@link ChatWidgetPlacement}. Defaults to
207
+ * `"bottom-right"`, floating clear of the device's safe-area insets. Stacks
208
+ * at `--clivly-chat-z-index` (default 2147483000) — lower it through
209
+ * `style` to sit beneath the host's own dialogs.
210
+ */
211
+ placement?: ChatWidgetPlacement;
181
212
  storage?: Pick<Storage, "getItem" | "setItem">;
182
213
  /**
183
214
  * Inline styles on the widget root. The intended use is overriding palette