@drawnagency/primitives 0.1.90 → 0.1.91

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.
@@ -3,9 +3,9 @@ export interface ChatMessage {
3
3
  content: string;
4
4
  }
5
5
  /** How the conversation is framed on screen:
6
- * - "floating": the compact anchored panel (/edit FAB + settings test chat)
7
- * - "docked": full-height right sidebar (viewer, >=768px)
8
- * - "sheet": bottom sheet (viewer, phones) */
6
+ * - "floating": the compact anchored panel (settings test chat only)
7
+ * - "docked": full-height right sidebar (viewer + /edit, >=768px)
8
+ * - "sheet": bottom sheet (viewer + /edit, phones) */
9
9
  export type ChatPanelMode = "floating" | "docked" | "sheet";
10
10
  /** Assistant markdown → sanitized HTML. Callers MUST gate on the sanitizer
11
11
  * having loaded (`mdReady`) — before then sanitizeHtml passes raw HTML
@@ -26,8 +26,15 @@ export declare function streamChat(body: {
26
26
  error: string;
27
27
  retryAfterMs?: number;
28
28
  }>;
29
- export default function ChatPanel({ open, onClose, siteName, botName, thinkingPhrases, tasks, isEditor, draftChatbot, simulateAudience, variant, mode, focusComposer, audience }: {
29
+ export default function ChatPanel({ open, closing, animateEnter, onClose, siteName, botName, thinkingPhrases, tasks, isEditor, draftChatbot, simulateAudience, variant, mode, focusComposer, audience }: {
30
30
  open: boolean;
31
+ /** Viewer modes: keeps the panel mounted through its exit slide (ChatWidget
32
+ * flips this off once the transition has run). */
33
+ closing?: boolean;
34
+ /** Animate the entrance (explicit user open). False for the docked restore
35
+ * on page load, where the column is already reserved pre-paint and a slide
36
+ * would replay on every navigation. */
37
+ animateEnter?: boolean;
31
38
  onClose: () => void;
32
39
  siteName: string;
33
40
  /** Resolved assistant display name (per-audience, server-side). */
@@ -43,8 +50,9 @@ export default function ChatPanel({ open, onClose, siteName, botName, thinkingPh
43
50
  draftChatbot?: unknown;
44
51
  /** Configurator test panel only: preview this audience's prompt overlay. */
45
52
  simulateAudience?: string;
46
- /** Floating mode only — anchors the panel above its launcher: viewer corner
47
- * FAB vs /edit FAB column. */
53
+ /** Surface the chat serves: "editor" (/edit docked chat) keeps internal
54
+ * cross-page links in a new tab (unsaved editor state) and sizes the
55
+ * docked panel against the editor toolbar instead of the viewer nav. */
48
56
  variant?: "viewer" | "editor";
49
57
  mode?: ChatPanelMode;
50
58
  /** Focus the composer when opened (explicit user open on desktop). Never
@@ -1 +1 @@
1
- {"version":3,"file":"ChatPanel.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatPanel.tsx"],"names":[],"mappings":"AAWA,MAAM,WAAW,WAAW;IAAG,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AAE5E;;;kDAGkD;AAClD,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,CAAC;AA8C5D;;kEAEkE;AAClE,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEhD;AAcD,8EAA8E;AAC9E,wBAAsB,UAAU,CAC9B,IAAI,EAAE;IAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,OAAO,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAE,EACxH,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,EAC9B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA6CjG;AAED,MAAM,CAAC,OAAO,UAAU,SAAS,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,eAAe,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,gBAAgB,EAAE,OAAkB,EAAE,IAAiB,EAAE,aAAa,EAAE,QAAQ,EAAE,EAAE;IACxM,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;mCAC+B;IAC/B,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC9B,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB;kFAC8E;IAC9E,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;+DAC2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B,kDA8dA"}
1
+ {"version":3,"file":"ChatPanel.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatPanel.tsx"],"names":[],"mappings":"AAWA,MAAM,WAAW,WAAW;IAAG,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AAE5E;;;0DAG0D;AAC1D,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,CAAC;AA+C5D;;kEAEkE;AAClE,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEhD;AAcD,8EAA8E;AAC9E,wBAAsB,UAAU,CAC9B,IAAI,EAAE;IAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,OAAO,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAE,EACxH,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,EAC9B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA6CjG;AAED,MAAM,CAAC,OAAO,UAAU,SAAS,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,eAAe,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,gBAAgB,EAAE,OAAkB,EAAE,IAAiB,EAAE,aAAa,EAAE,QAAQ,EAAE,EAAE;IAC/N,IAAI,EAAE,OAAO,CAAC;IACd;uDACmD;IACnD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;4CAEwC;IACxC,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;6EAEyE;IACzE,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC9B,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB;kFAC8E;IAC9E,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;+DAC2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B,kDAqfA"}
@@ -1 +1 @@
1
- {"version":3,"file":"ChatWidget.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatWidget.tsx"],"names":[],"mappings":"AAoBA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,OAAO,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,kFAAkF;IAClF,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC9B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,eAAe,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,QAAQ,EAAE,EAAE,eAAe,2CAyFtJ"}
1
+ {"version":3,"file":"ChatWidget.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatWidget.tsx"],"names":[],"mappings":"AAyBA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,OAAO,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,kFAAkF;IAClF,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC9B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAWD,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,eAAe,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,QAAQ,EAAE,EAAE,eAAe,2CAyItJ"}
@@ -0,0 +1,37 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
2
+ /** Floating action button — the round primary buttons floating over the page.
3
+ *
4
+ * The `column` is both the placement and the behavior hook: base.css hides
5
+ * every member of both columns (shrink + fade) while the chat is open, keyed
6
+ * on the column class, so a new FAB participates in the chat open/close
7
+ * choreography just by rendering through this component. Positions are
8
+ * static on purpose — FABs are only visible while the chat is closed
9
+ * (--chat-space: 0), so they never need to track the docked sidebar.
10
+ *
11
+ * - "viewer": viewport corner stack (z-40, h-12). Rendered on SSR viewer
12
+ * pages too — keep this component hook-free so it stays server-renderable
13
+ * without hydration.
14
+ * - "editor": the /edit FAB column (z-50, h-10), content-edge aligned at lg+
15
+ * via fab-container-right.
16
+ *
17
+ * Renders an <a> when `href` is set, else a <button type="button">. The
18
+ * caller supplies the vertical slot (bottom-*) via className and the icon as
19
+ * children.
20
+ */
21
+ interface FabCommon {
22
+ /** Accessible name — FABs are icon-only. */
23
+ label: string;
24
+ column: "viewer" | "editor";
25
+ /** Vertical slot (bottom-*) plus any per-FAB extras. */
26
+ className?: string;
27
+ children: ReactNode;
28
+ }
29
+ export type FabProps = ({
30
+ href: string;
31
+ } & FabCommon & Omit<ComponentPropsWithoutRef<"a">, "children" | "className" | "aria-label">) | ({
32
+ href?: undefined;
33
+ ref?: Ref<HTMLButtonElement>;
34
+ } & FabCommon & Omit<ComponentPropsWithoutRef<"button">, "children" | "className" | "aria-label" | "type">);
35
+ export default function Fab({ label, column, className, children, ...rest }: FabProps): import("react/jsx-runtime").JSX.Element;
36
+ export {};
37
+ //# sourceMappingURL=Fab.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Fab.d.ts","sourceRoot":"","sources":["../../../src/components/shared/Fab.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC;AAGtE;;;;;;;;;;;;;;;;;;GAkBG;AACH,UAAU,SAAS;IACjB,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC5B,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED,MAAM,MAAM,QAAQ,GAChB,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GAAG,IAAI,CAAC,wBAAwB,CAAC,GAAG,CAAC,EAAE,UAAU,GAAG,WAAW,GAAG,YAAY,CAAC,CAAC,GAC7G,CAAC;IAAE,IAAI,CAAC,EAAE,SAAS,CAAC;IAAC,GAAG,CAAC,EAAE,GAAG,CAAC,iBAAiB,CAAC,CAAA;CAAE,GAAG,SAAS,GAC7D,IAAI,CAAC,wBAAwB,CAAC,QAAQ,CAAC,EAAE,UAAU,GAAG,WAAW,GAAG,YAAY,GAAG,MAAM,CAAC,CAAC,CAAC;AAElG,MAAM,CAAC,OAAO,UAAU,GAAG,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,EAAE,QAAQ,2CAoBpF"}
@@ -1 +1 @@
1
- {"version":3,"file":"BugReportFAB.d.ts","sourceRoot":"","sources":["../../../src/components/shell/BugReportFAB.tsx"],"names":[],"mappings":"AA6BA,wBAAgB,YAAY,mDA0T3B"}
1
+ {"version":3,"file":"BugReportFAB.d.ts","sourceRoot":"","sources":["../../../src/components/shell/BugReportFAB.tsx"],"names":[],"mappings":"AA8BA,wBAAgB,YAAY,mDAqT3B"}
@@ -1 +1 @@
1
- {"version":3,"file":"EditorShell.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorShell.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAcjD,OAAO,EAAoC,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAuDtF,OAAO,KAAK,EAAE,eAAe,EAA2D,MAAM,oBAAoB,CAAC;AAuCnH,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAoBxD,UAAU,KAAK;IACb,OAAO,EAAE,MAAM,CAAC;IAIhB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;IAIhE,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAKjC,eAAe,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;CAC1C;AAED,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,OAAO,EACP,YAAY,EACZ,MAAM,EACN,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EACZ,WAAW,EACX,sBAA8B,EAC9B,eAAe,GAChB,EAAE,KAAK,2CA0mDP;AAuED;6EAC6E;AAC7E,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,WAAW,CAAC,YAAY,CAAC,EACvC,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,eAAe,EAAE,MAAM,GAAG,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAK/B"}
1
+ {"version":3,"file":"EditorShell.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorShell.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAcjD,OAAO,EAAoC,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAuDtF,OAAO,KAAK,EAAE,eAAe,EAA2D,MAAM,oBAAoB,CAAC;AAwCnH,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAoBxD,UAAU,KAAK;IACb,OAAO,EAAE,MAAM,CAAC;IAIhB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;IAIhE,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAKjC,eAAe,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;CAC1C;AAED,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,OAAO,EACP,YAAY,EACZ,MAAM,EACN,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EACZ,WAAW,EACX,sBAA8B,EAC9B,eAAe,GAChB,EAAE,KAAK,2CA4mDP;AAuED;6EAC6E;AAC7E,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,WAAW,CAAC,YAAY,CAAC,EACvC,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,eAAe,EAAE,MAAM,GAAG,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAK/B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawnagency/primitives",
3
- "version": "0.1.90",
3
+ "version": "0.1.91",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -12,9 +12,9 @@ import { RemoveIcon } from "../shared/icons";
12
12
  export interface ChatMessage { role: "user" | "assistant"; content: string }
13
13
 
14
14
  /** How the conversation is framed on screen:
15
- * - "floating": the compact anchored panel (/edit FAB + settings test chat)
16
- * - "docked": full-height right sidebar (viewer, >=768px)
17
- * - "sheet": bottom sheet (viewer, phones) */
15
+ * - "floating": the compact anchored panel (settings test chat only)
16
+ * - "docked": full-height right sidebar (viewer + /edit, >=768px)
17
+ * - "sheet": bottom sheet (viewer + /edit, phones) */
18
18
  export type ChatPanelMode = "floating" | "docked" | "sheet";
19
19
 
20
20
  const SESSION_KEY = "bp-chat-session";
@@ -35,9 +35,10 @@ function sessionId(): string {
35
35
  return id;
36
36
  }
37
37
 
38
- /** Conversation persistence (viewer modes only): internal links open in the
39
- * same tab, so the transcript must survive page navigation. sessionStorage —
40
- * per-tab, cleared when the visit ends. */
38
+ /** Conversation persistence (docked/sheet modes): viewer internal links open
39
+ * in the same tab, so the transcript must survive page navigation — and it
40
+ * carries across same-tab hops between /edit and the live site. sessionStorage
41
+ * — per-tab, cleared when the visit ends. */
41
42
  function loadStoredMessages(): ChatMessage[] {
42
43
  try {
43
44
  const parsed: unknown = JSON.parse(window.sessionStorage.getItem(MESSAGES_KEY) ?? "[]");
@@ -132,8 +133,15 @@ export async function streamChat(
132
133
  }
133
134
  }
134
135
 
135
- export default function ChatPanel({ open, onClose, siteName, botName, thinkingPhrases, tasks, isEditor, draftChatbot, simulateAudience, variant = "viewer", mode = "floating", focusComposer, audience }: {
136
+ export default function ChatPanel({ open, closing, animateEnter, onClose, siteName, botName, thinkingPhrases, tasks, isEditor, draftChatbot, simulateAudience, variant = "viewer", mode = "floating", focusComposer, audience }: {
136
137
  open: boolean;
138
+ /** Viewer modes: keeps the panel mounted through its exit slide (ChatWidget
139
+ * flips this off once the transition has run). */
140
+ closing?: boolean;
141
+ /** Animate the entrance (explicit user open). False for the docked restore
142
+ * on page load, where the column is already reserved pre-paint and a slide
143
+ * would replay on every navigation. */
144
+ animateEnter?: boolean;
137
145
  onClose: () => void;
138
146
  siteName: string;
139
147
  /** Resolved assistant display name (per-audience, server-side). */
@@ -146,8 +154,9 @@ export default function ChatPanel({ open, onClose, siteName, botName, thinkingPh
146
154
  draftChatbot?: unknown;
147
155
  /** Configurator test panel only: preview this audience's prompt overlay. */
148
156
  simulateAudience?: string;
149
- /** Floating mode only — anchors the panel above its launcher: viewer corner
150
- * FAB vs /edit FAB column. */
157
+ /** Surface the chat serves: "editor" (/edit docked chat) keeps internal
158
+ * cross-page links in a new tab (unsaved editor state) and sizes the
159
+ * docked panel against the editor toolbar instead of the viewer nav. */
151
160
  variant?: "viewer" | "editor";
152
161
  mode?: ChatPanelMode;
153
162
  /** Focus the composer when opened (explicit user open on desktop). Never
@@ -331,14 +340,15 @@ export default function ChatPanel({ open, onClose, siteName, botName, thinkingPh
331
340
  return;
332
341
  }
333
342
  if (url.origin === window.location.origin) {
334
- if (persistent) {
343
+ if (persistent && variant !== "editor") {
335
344
  // Internal cross-page citation, viewer chat: navigate in place — the
336
345
  // sessionStorage transcript (and docked-open state) survive the load.
337
346
  window.location.assign(url.href);
338
347
  return;
339
348
  }
340
- // Editor surfaces (floating mode): same-tab navigation would destroy
341
- // unsaved editor state — keep internal links in a new tab there.
349
+ // Editor surfaces (/edit docked chat, settings test panel): same-tab
350
+ // navigation would destroy unsaved editor state — keep internal links
351
+ // in a new tab there.
342
352
  window.open(url.href, "_blank", "noopener");
343
353
  return;
344
354
  }
@@ -442,14 +452,21 @@ export default function ChatPanel({ open, onClose, siteName, botName, thinkingPh
442
452
  setHistory(loadHistory(historyAudience));
443
453
  }
444
454
 
445
- if (!open) return null;
455
+ if (!open && !closing) return null;
446
456
 
447
457
  const lastIndex = messages.length - 1;
448
458
 
449
459
  return (
450
460
  <>
451
461
  {mode === "sheet" && (
452
- <div className="fixed inset-0 z-50 bg-black/50" onClick={onClose} aria-hidden="true" />
462
+ <div
463
+ className={cn(
464
+ "fixed inset-0 z-50 bg-black/50 motion-safe:transition-opacity motion-safe:duration-300",
465
+ open ? "opacity-100 starting:opacity-0" : "pointer-events-none opacity-0",
466
+ )}
467
+ onClick={onClose}
468
+ aria-hidden="true"
469
+ />
453
470
  )}
454
471
  <div
455
472
  ref={panelRef}
@@ -460,12 +477,27 @@ export default function ChatPanel({ open, onClose, siteName, botName, thinkingPh
460
477
  onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
461
478
  className={cn(
462
479
  "flex flex-col bg-base focus:outline-none",
463
- mode === "floating" && cn(
464
- "fixed z-40 h-[28rem] w-80 rounded-outer border border-base-200 shadow-xl",
465
- variant === "editor" ? "bottom-4 fab-panel-left max-w-[calc(100vw-5.25rem)]" : "bottom-20 right-4",
480
+ mode === "floating" && "fixed z-40 h-[28rem] w-80 rounded-outer border border-base-200 shadow-xl bottom-20 right-4",
481
+ // Enter/exit slides pair with base.css's 300ms band shift (same
482
+ // duration + default curve); under reduced motion there's no
483
+ // transition and @starting-style has no effect — instant show/hide.
484
+ // Top offset clears the fixed top bar spanning the panel's column:
485
+ // the viewer nav header exists below xl (chat-enabled pages dock
486
+ // the nav at xl); /edit keeps its full-width h-16 toolbar at every
487
+ // width, so the panel always tucks below it — the toolbar never
488
+ // moves during the open/close slide.
489
+ mode === "docked" && cn(
490
+ "fixed right-0 bottom-0 z-40 w-[var(--chat-w)] border-l border-base-200",
491
+ variant === "editor" ? "top-16" : "top-16 xl:top-0",
492
+ "motion-safe:transition-transform motion-safe:duration-300",
493
+ open ? "translate-x-0" : "translate-x-full",
494
+ open && animateEnter && "starting:translate-x-full",
495
+ ),
496
+ mode === "sheet" && cn(
497
+ "fixed inset-x-0 bottom-0 z-50 h-[85dvh] rounded-t-outer border-t border-base-200 shadow-xl",
498
+ "motion-safe:transition-transform motion-safe:duration-300",
499
+ open ? "translate-y-0 starting:translate-y-full" : "translate-y-full",
466
500
  ),
467
- mode === "docked" && "fixed right-0 bottom-0 top-16 xl:top-0 z-40 w-[var(--chat-w)] border-l border-base-200",
468
- mode === "sheet" && "fixed inset-x-0 bottom-0 z-50 h-[85dvh] rounded-t-outer border-t border-base-200 shadow-xl",
469
501
  )}
470
502
  style={mode === "sheet" ? { bottom: "var(--kb-offset, 0px)", maxHeight: "calc(var(--vvh, 100dvh) - 2.5rem)" } : undefined}
471
503
  >
@@ -1,17 +1,22 @@
1
1
  // Chat launcher + shell chooser. Browser-safe: no node imports, no process.env.
2
2
  //
3
- // Placements:
4
- // - "viewer" (default): >=768px the conversation docks as a full-height right
5
- // sidebar (html.bp-chat-docked shifts the page band left — see base.css);
6
- // below 768px it opens as a bottom sheet. The FAB shows only while closed.
7
- // Docked-open is the default and the preference persists per tab
8
- // (sessionStorage, mirrored by Layout.astro's pre-paint script so the
9
- // column is reserved before hydration).
10
- // - "editor": unchanged floating panel at the BOTTOM of the /edit route's FAB
11
- // column (edit-mode toggle at bottom-16, bug report at bottom-28 above it),
12
- // aligned to the content edge via fab-container-right like its neighbors.
3
+ // Both variants share the same conversation experience: >=768px it docks as a
4
+ // full-height right sidebar (html.bp-chat-docked shifts the page band left —
5
+ // see base.css); below 768px it opens as a bottom sheet. Open/close is
6
+ // animated (sidebar slides in as the band shifts, sheet slides up, and ALL
7
+ // page FABs shrink/fade away); prefers-reduced-motion keeps everything
8
+ // instant. FABs show only while closed. Docked-open is the default and the
9
+ // preference persists per tab (sessionStorage, mirrored by Layout.astro's
10
+ // pre-paint script so the column is reserved before hydration) — shared
11
+ // across viewer pages and /edit.
12
+ //
13
+ // The variants differ only in the launcher's placement:
14
+ // - "viewer" (default): viewport corner (viewer-fab tracks the sidebar edge).
15
+ // - "editor": BOTTOM of the /edit route's FAB column (edit-mode toggle at
16
+ // bottom-16, bug report at bottom-28 above it), aligned to the content edge
17
+ // via fab-container-right like its neighbors.
13
18
  import { lazy, Suspense, useEffect, useRef, useState } from "react";
14
- import { cn } from "../../lib/cn";
19
+ import Fab from "../shared/Fab";
15
20
 
16
21
  const ChatPanel = lazy(() => import("./ChatPanel"));
17
22
 
@@ -34,19 +39,79 @@ export interface ChatWidgetProps {
34
39
  audience?: string | null;
35
40
  }
36
41
 
42
+ /** Matches the 300ms slide transitions in base.css / ChatPanel. */
43
+ const CLOSE_MS = 300;
44
+ /** How long html.bp-chat-animating stays on: the 300ms glide plus a timer-
45
+ * jitter buffer. The class gates the band/nav FLIP transforms in base.css —
46
+ * it must outlive the transition (or the glide would cut off early) but
47
+ * MUST come off afterwards, because a resting transform re-anchors
48
+ * position:fixed descendants. */
49
+ const ANIM_MS = 350;
50
+
37
51
  export default function ChatWidget({ siteName, botName, thinkingPhrases, tasks, isEditor, draftChatbot, variant = "viewer", audience }: ChatWidgetProps) {
38
52
  const [open, setOpen] = useState(false);
39
53
  const [everOpened, setEverOpened] = useState(false);
40
54
  const [docked, setDocked] = useState(false);
41
55
  const [userOpened, setUserOpened] = useState(false);
56
+ // Close is animated: the panel stays mounted while it slides out.
57
+ const [closing, setClosing] = useState(false);
58
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
59
+ const animTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
42
60
  const fabRef = useRef<HTMLButtonElement>(null);
43
- const isViewer = variant === "viewer";
61
+
62
+ useEffect(() => () => {
63
+ if (closeTimer.current) clearTimeout(closeTimer.current);
64
+ if (animTimer.current) clearTimeout(animTimer.current);
65
+ document.documentElement.classList.remove("bp-chat-animating");
66
+ }, []);
67
+
68
+ function clearCloseTimer() {
69
+ if (closeTimer.current) clearTimeout(closeTimer.current);
70
+ closeTimer.current = null;
71
+ }
72
+
73
+ function motionOk(): boolean {
74
+ return typeof window.matchMedia === "function" && !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
75
+ }
76
+
77
+ /** Open the FLIP window for a docked toggle: while bp-chat-animating is on
78
+ * <html>, base.css glides the band/nav via transform between the snapped
79
+ * layouts (see the @property note there). User-driven toggles only — the
80
+ * page-load restore must not replay the glide. */
81
+ function beginShiftWindow() {
82
+ if (!docked || !motionOk()) return;
83
+ document.documentElement.classList.add("bp-chat-animating");
84
+ if (animTimer.current) clearTimeout(animTimer.current);
85
+ animTimer.current = setTimeout(() => {
86
+ document.documentElement.classList.remove("bp-chat-animating");
87
+ animTimer.current = null;
88
+ }, ANIM_MS);
89
+ }
90
+
91
+ function openPanel() {
92
+ clearCloseTimer();
93
+ beginShiftWindow();
94
+ setClosing(false);
95
+ setOpen(true);
96
+ setEverOpened(true);
97
+ }
98
+
99
+ function closePanel() {
100
+ clearCloseTimer();
101
+ beginShiftWindow();
102
+ setOpen(false);
103
+ // Reduced motion unmounts immediately — the pre-animation behavior.
104
+ if (motionOk()) {
105
+ setClosing(true);
106
+ closeTimer.current = setTimeout(() => { setClosing(false); closeTimer.current = null; }, CLOSE_MS);
107
+ }
108
+ }
44
109
 
45
110
  // Post-hydration restore. The server renders only the FAB; Layout.astro's
46
111
  // pre-paint script has already reserved the docked column and CSS hides the
47
112
  // FAB under html.bp-chat-docked, so this state sync causes no layout jump.
48
113
  useEffect(() => {
49
- if (!isViewer || typeof window.matchMedia !== "function") return;
114
+ if (typeof window.matchMedia !== "function") return;
50
115
  const mq = window.matchMedia(DOCK_QUERY);
51
116
  const apply = () => setDocked(mq.matches);
52
117
  apply();
@@ -55,59 +120,56 @@ export default function ChatWidget({ siteName, botName, thinkingPhrases, tasks,
55
120
  try { storedOpen = window.sessionStorage.getItem(OPEN_KEY) !== "0"; } catch {}
56
121
  if (mq.matches && storedOpen) { setOpen(true); setEverOpened(true); }
57
122
  return () => mq.removeEventListener("change", apply);
58
- }, [isViewer]);
123
+ }, []);
59
124
 
60
- // Reflect docked-open on <html> (drives the band shift + FAB hiding) and
61
- // persist the preference. Only the docked state persists — the phone sheet
62
- // always starts closed.
125
+ // Reflect open state on <html>: bp-chat-docked drives the band shift (and
126
+ // the /edit toolbar/FAB-column shift), bp-chat-open (any mode) drives the
127
+ // document FAB's hide/handoff — both animated in base.css. Persist the
128
+ // preference; only the docked state persists — the phone sheet always
129
+ // starts closed.
63
130
  useEffect(() => {
64
- if (!isViewer) return;
65
131
  document.documentElement.classList.toggle("bp-chat-docked", open && docked);
132
+ document.documentElement.classList.toggle("bp-chat-open", open);
66
133
  if (docked) {
67
134
  try { window.sessionStorage.setItem(OPEN_KEY, open ? "1" : "0"); } catch {}
68
135
  }
69
- return () => document.documentElement.classList.remove("bp-chat-docked");
70
- }, [open, docked, isViewer]);
136
+ return () => {
137
+ document.documentElement.classList.remove("bp-chat-docked");
138
+ document.documentElement.classList.remove("bp-chat-open");
139
+ };
140
+ }, [open, docked]);
71
141
 
72
142
  // Closing the sheet/sidebar returns focus to the launcher.
73
143
  useEffect(() => {
74
- if (isViewer && !open && everOpened) fabRef.current?.focus();
75
- }, [open, everOpened, isViewer]);
76
-
77
- const showFab = !isViewer || !open;
144
+ if (!open && everOpened) fabRef.current?.focus();
145
+ }, [open, everOpened]);
78
146
 
147
+ // The launcher stays mounted while the chat is open so it can shrink and
148
+ // fade out (and reverse back in): base.css hides ALL FABs (.viewer-fab and
149
+ // the /edit .fab-container-right column, this button included) under
150
+ // html.bp-chat-open/.bp-chat-docked, with visibility dropped after the fade
151
+ // so no invisible button can take focus or clicks.
79
152
  return (
80
153
  <>
81
- {showFab && (
82
- <button
83
- ref={fabRef}
84
- type="button"
85
- aria-label={open ? "Collapse chat" : `Chat with ${botName}`}
86
- aria-expanded={open}
87
- onClick={() => { setOpen((o) => !o); setEverOpened(true); setUserOpened(true); }}
88
- className={cn(
89
- "fixed z-40 flex items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity",
90
- variant === "editor"
91
- ? "bottom-4 right-4 lg:right-auto h-10 w-10 fab-container-right"
92
- : "bottom-4 h-12 w-12 viewer-fab bp-chat-fab",
93
- )}
94
- >
95
- {open ? (
96
- <svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
97
- <path d="m6 9 6 6 6-6" />
98
- </svg>
99
- ) : (
100
- <svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
101
- <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
102
- </svg>
103
- )}
104
- </button>
105
- )}
154
+ <Fab
155
+ ref={fabRef}
156
+ label={`Chat with ${botName}`}
157
+ column={variant === "editor" ? "editor" : "viewer"}
158
+ className="bottom-4"
159
+ aria-expanded={open}
160
+ onClick={() => { setUserOpened(true); if (open) closePanel(); else openPanel(); }}
161
+ >
162
+ <svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
163
+ <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
164
+ </svg>
165
+ </Fab>
106
166
  {everOpened && (
107
167
  <Suspense fallback={null}>
108
168
  <ChatPanel
109
169
  open={open}
110
- onClose={() => setOpen(false)}
170
+ closing={closing}
171
+ animateEnter={userOpened}
172
+ onClose={closePanel}
111
173
  siteName={siteName}
112
174
  botName={botName}
113
175
  thinkingPhrases={thinkingPhrases}
@@ -115,7 +177,7 @@ export default function ChatWidget({ siteName, botName, thinkingPhrases, tasks,
115
177
  isEditor={isEditor}
116
178
  draftChatbot={draftChatbot}
117
179
  variant={variant}
118
- mode={isViewer ? (docked ? "docked" : "sheet") : "floating"}
180
+ mode={docked ? "docked" : "sheet"}
119
181
  focusComposer={userOpened}
120
182
  audience={audience}
121
183
  />
@@ -0,0 +1,57 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ /** Floating action button — the round primary buttons floating over the page.
5
+ *
6
+ * The `column` is both the placement and the behavior hook: base.css hides
7
+ * every member of both columns (shrink + fade) while the chat is open, keyed
8
+ * on the column class, so a new FAB participates in the chat open/close
9
+ * choreography just by rendering through this component. Positions are
10
+ * static on purpose — FABs are only visible while the chat is closed
11
+ * (--chat-space: 0), so they never need to track the docked sidebar.
12
+ *
13
+ * - "viewer": viewport corner stack (z-40, h-12). Rendered on SSR viewer
14
+ * pages too — keep this component hook-free so it stays server-renderable
15
+ * without hydration.
16
+ * - "editor": the /edit FAB column (z-50, h-10), content-edge aligned at lg+
17
+ * via fab-container-right.
18
+ *
19
+ * Renders an <a> when `href` is set, else a <button type="button">. The
20
+ * caller supplies the vertical slot (bottom-*) via className and the icon as
21
+ * children.
22
+ */
23
+ interface FabCommon {
24
+ /** Accessible name — FABs are icon-only. */
25
+ label: string;
26
+ column: "viewer" | "editor";
27
+ /** Vertical slot (bottom-*) plus any per-FAB extras. */
28
+ className?: string;
29
+ children: ReactNode;
30
+ }
31
+
32
+ export type FabProps =
33
+ | ({ href: string } & FabCommon & Omit<ComponentPropsWithoutRef<"a">, "children" | "className" | "aria-label">)
34
+ | ({ href?: undefined; ref?: Ref<HTMLButtonElement> } & FabCommon &
35
+ Omit<ComponentPropsWithoutRef<"button">, "children" | "className" | "aria-label" | "type">);
36
+
37
+ export default function Fab({ label, column, className, children, ...rest }: FabProps) {
38
+ const classes = cn(
39
+ "cursor-pointer fixed flex items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity",
40
+ column === "viewer"
41
+ ? "z-40 h-12 w-12 right-4 viewer-fab"
42
+ : "z-50 h-10 w-10 right-4 lg:right-auto fab-container-right",
43
+ className,
44
+ );
45
+ if (rest.href !== undefined) {
46
+ return (
47
+ <a aria-label={label} className={classes} {...(rest as ComponentPropsWithoutRef<"a">)}>
48
+ {children}
49
+ </a>
50
+ );
51
+ }
52
+ return (
53
+ <button type="button" aria-label={label} className={classes} {...(rest as ComponentPropsWithoutRef<"button"> & { ref?: Ref<HTMLButtonElement> })}>
54
+ {children}
55
+ </button>
56
+ );
57
+ }
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
2
2
  import { EditorModal } from "./EditorModal";
3
3
  import { useEditorContext } from "./EditorContext";
4
4
  import { cn } from "../../lib/cn";
5
+ import Fab from "../shared/Fab";
5
6
 
6
7
  type Category = "Visual" | "Unexpected Behavior" | "Media / Media Library" | "Save / Publish" | "Feature Request" | "Other";
7
8
 
@@ -186,17 +187,12 @@ export function BugReportFAB() {
186
187
  <>
187
188
  {/* FAB button */}
188
189
  {visible && (
189
- <button
190
- type="button"
191
- onClick={handleOpen}
192
- aria-label="Report a bug"
193
- className="cursor-pointer fixed bottom-28 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
194
- >
190
+ <Fab label="Report a bug" column="editor" className="bottom-28" onClick={handleOpen}>
195
191
  {/* Bug / beetle SVG icon */}
196
192
  <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
197
193
  <path d="M19 8h-1.26A6.003 6.003 0 0012 4a6.003 6.003 0 00-5.74 4H5a1 1 0 000 2h1.05A6.04 6.04 0 006 12H5a1 1 0 000 2h1c0 .34.03.67.08 1H5a1 1 0 000 2h1.26a6 6 0 0011.48 0H19a1 1 0 000-2h-1.08c.05-.33.08-.66.08-1h1a1 1 0 000-2h-1a6.04 6.04 0 00-.05-2H19a1 1 0 000-2zM12 6c1.7 0 3.16.88 4 2.19V8a1 1 0 10-2 0v.04A3.97 3.97 0 0012 8a3.97 3.97 0 00-2 .04V8a1 1 0 10-2 0v.19A4.69 4.69 0 018 6a4 4 0 014 0zm0 14a4 4 0 01-4-4v-4a4 4 0 018 0v4a4 4 0 01-4 4z"/>
198
194
  </svg>
199
- </button>
195
+ </Fab>
200
196
  )}
201
197
 
202
198
  {/* Modal */}
@@ -101,6 +101,7 @@ import { UploadRejectionAlert, formatRejections } from "../shared/UploadRejectio
101
101
  import { Button } from "../shared/Button";
102
102
  import { SplitButton } from "../shared/SplitButton";
103
103
  import { IconButton } from "../shared/IconButton";
104
+ import Fab from "../shared/Fab";
104
105
  import { Tooltip } from "../shared/Tooltip";
105
106
  import { SegmentedControl } from "../shared/SegmentedControl";
106
107
  import { computeDisplaySections } from "./view-branch";
@@ -1548,18 +1549,13 @@ export default function EditorShell({
1548
1549
  scope renders the provider and can't read the context directly),
1549
1550
  hide the FAB so it can't open the draft editor over a history view. */}
1550
1551
  {resolvedActivePage?.document?.enabled && !historyActive && (
1551
- <button
1552
- type="button"
1553
- onClick={() => setShowDocumentEditor(true)}
1554
- aria-label="Edit as document"
1555
- className="cursor-pointer fixed bottom-40 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
1556
- >
1552
+ <Fab label="Edit as document" column="editor" className="bottom-40" onClick={() => setShowDocumentEditor(true)}>
1557
1553
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
1558
1554
  <rect x="3" y="5" width="18" height="14" rx="1" />
1559
1555
  <path d="M7 10h10" />
1560
1556
  <path d="M7 14h7" />
1561
1557
  </svg>
1562
- </button>
1558
+ </Fab>
1563
1559
  )}
1564
1560
  {showDocumentEditor && resolvedActivePage?.document?.enabled && (
1565
1561
  <Suspense fallback={<DocumentOverlayFallback />}>
@@ -1626,6 +1622,12 @@ export default function EditorShell({
1626
1622
  </div>
1627
1623
  )}
1628
1624
 
1625
+ {/* bp-band-shift: rides the chat dock's FLIP glide (base.css). On
1626
+ /edit it lives HERE, on the content column only — Layout's
1627
+ <main> can't carry it because the toolbar/FABs/chat panel above
1628
+ are position:fixed inside main, and a transformed ancestor
1629
+ would re-anchor them. */}
1630
+ <div className="bp-band-shift">
1629
1631
  <HistoryOrEditorContent
1630
1632
  index={siteIndex}
1631
1633
  activeSections={activeSections}
@@ -1654,6 +1656,7 @@ export default function EditorShell({
1654
1656
  onTakeover={confirmTakeover}
1655
1657
  />
1656
1658
  </HistoryOrEditorContent>
1659
+ </div>
1657
1660
  <GlobalModal />
1658
1661
  <SiteSettingsModal
1659
1662
  isOpen={showSiteSettings}
@@ -2412,8 +2415,12 @@ function EditorToolbar({
2412
2415
  return (
2413
2416
  <>
2414
2417
  {isEditMode && (
2418
+ // Full-width and static on purpose: the docked chat sidebar slides in
2419
+ // BELOW this bar (ChatPanel docks at top-16 on /edit), so the toolbar
2420
+ // never moves during the chat open/close transition. h-16 matches the
2421
+ // panel's top offset (and the body's pt-16) exactly.
2415
2422
  <div className="fixed top-0 right-0 left-0 z-50 border-b border-base-200 bg-base">
2416
- <div className="mx-auto max-w-screen-xl grid grid-cols-3 items-center px-4 py-2">
2423
+ <div className="mx-auto max-w-screen-xl grid h-16 grid-cols-3 items-center px-4">
2417
2424
  <div className="flex items-center gap-2">
2418
2425
  {buttonState === "saveAndPublish" && (
2419
2426
  <SplitButton
@@ -2490,7 +2497,7 @@ function EditorToolbar({
2490
2497
  )}
2491
2498
  {!isEditMode && (
2492
2499
  <div className="fixed top-0 right-0 left-0 z-50 border-b border-base-200 bg-base">
2493
- <div className="mx-auto max-w-screen-xl flex items-center justify-center px-4 py-2">
2500
+ <div className="mx-auto max-w-screen-xl flex h-16 items-center justify-center px-4">
2494
2501
  <SegmentedControl
2495
2502
  options={[
2496
2503
  { value: "live", label: "Published" },
@@ -2503,10 +2510,11 @@ function EditorToolbar({
2503
2510
  </div>
2504
2511
  )}
2505
2512
  {!historyState && (
2506
- <button
2513
+ <Fab
2514
+ label={isEditMode ? "Switch to view mode" : "Switch to edit mode"}
2515
+ column="editor"
2516
+ className="bottom-16"
2507
2517
  onClick={toggleEditMode}
2508
- className="cursor-pointer fixed bottom-16 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
2509
- aria-label={isEditMode ? "Switch to view mode" : "Switch to edit mode"}
2510
2518
  >
2511
2519
  {isEditMode ? (
2512
2520
  <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
@@ -2518,7 +2526,7 @@ function EditorToolbar({
2518
2526
  <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
2519
2527
  </svg>
2520
2528
  )}
2521
- </button>
2529
+ </Fab>
2522
2530
  )}
2523
2531
  </>
2524
2532
  );