@drawnagency/primitives 0.1.90 → 0.1.92

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.
Files changed (43) hide show
  1. package/dist/{chunk-ZLG7S54V.js → chunk-7QEJDYTG.js} +6 -6
  2. package/dist/{chunk-XUIJHYKI.js → chunk-A2V7BZPD.js} +3 -3
  3. package/dist/{chunk-NV4R6VHN.js → chunk-CI624HRS.js} +1 -1
  4. package/dist/{chunk-SIYQ37QY.js → chunk-IZLXF3CR.js} +1 -1
  5. package/dist/{chunk-XKRQMTE4.js → chunk-LQQY2RW6.js} +2 -2
  6. package/dist/{chunk-KMQZYORS.js → chunk-RUKO2N3F.js} +5 -1
  7. package/dist/{chunk-CZ3YXGK5.js → chunk-SOILPZ7U.js} +27 -3
  8. package/dist/{chunk-AGWE2LJG.js → chunk-WOYGQK2D.js} +1 -1
  9. package/dist/{chunk-KBQNJZUX.js → chunk-YI2HUFE3.js} +1 -1
  10. package/dist/{chunk-MK7YB2A4.js → chunk-Z66TBL4Q.js} +1 -1
  11. package/dist/components/chat/ChatPanel.d.ts +14 -6
  12. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  13. package/dist/components/chat/ChatWidget.d.ts.map +1 -1
  14. package/dist/components/editor/index.js +4 -4
  15. package/dist/components/primitives/index.js +5 -5
  16. package/dist/components/sections/brand-guide.js +5 -5
  17. package/dist/components/sections/register-schemas.js +8 -8
  18. package/dist/components/sections/register.js +8 -8
  19. package/dist/components/shared/Fab.d.ts +37 -0
  20. package/dist/components/shared/Fab.d.ts.map +1 -0
  21. package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
  22. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  23. package/dist/components/shell/ViewerLoginForm.d.ts +1 -0
  24. package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
  25. package/dist/index.js +4 -4
  26. package/dist/lib/index.js +2 -2
  27. package/dist/media/compose-preview.d.ts +9 -0
  28. package/dist/media/compose-preview.d.ts.map +1 -1
  29. package/dist/media/index.js +2 -2
  30. package/dist/media/preview-inputs.d.ts.map +1 -1
  31. package/dist/schemas/index.js +2 -2
  32. package/dist/schemas/site-config.d.ts +1 -0
  33. package/dist/schemas/site-config.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/components/chat/ChatPanel.tsx +51 -19
  36. package/src/components/chat/ChatWidget.tsx +113 -51
  37. package/src/components/shared/Fab.tsx +57 -0
  38. package/src/components/shell/BugReportFAB.tsx +3 -7
  39. package/src/components/shell/EditorShell.tsx +38 -19
  40. package/src/components/shell/ViewerLoginForm.tsx +19 -8
  41. package/src/media/compose-preview.ts +56 -4
  42. package/src/media/preview-inputs.ts +3 -1
  43. package/src/schemas/site-config.ts +4 -0
@@ -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 */}
@@ -10,7 +10,7 @@ import type { SiteIndex, SiteConfig, Page } from "../../schemas/site-config";
10
10
  import type { Audience } from "../../auth/types";
11
11
  import type { MediaManifest } from "../../media/types";
12
12
  import type { QueueItem } from "../../media/queue";
13
- import { composePreviewImage } from "../../media/compose-preview";
13
+ import { composePreviewImage, PREVIEW_COMPOSE_VERSION } from "../../media/compose-preview";
14
14
  import { derivePreviewInputs, servedPreviewSize } from "../../media/preview-inputs";
15
15
  import { resolveMedia } from "../../media/resolve";
16
16
  import { getMediaProvider } from "../../media";
@@ -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";
@@ -263,6 +264,9 @@ export default function EditorShell({
263
264
  // dedupes concurrent runs against the same composition signature.
264
265
  const previewInitializedRef = useRef(false);
265
266
  const lastPreviewSigRef = useRef<string | null>(null);
267
+ // Increments per compose; the async upload callback applies its result only if
268
+ // still current, so a slower earlier upload can't overwrite a newer preview.
269
+ const previewGenRef = useRef(0);
266
270
 
267
271
  // --- Collaboration refs ---
268
272
  // Freshest dirty-section set for the inbound remote-section handler (never
@@ -1375,12 +1379,14 @@ export default function EditorShell({
1375
1379
  };
1376
1380
  const { inputs, signature } = derivePreviewInputs(siteConfig, resolveLogoUrl);
1377
1381
 
1378
- // On first run: if a preview already exists, seed the signature and skip
1379
- // (don't recompose/re-upload on every editor open). If none exists yet,
1380
- // fall through to compose one. Subsequent runs recompose only on change.
1382
+ // On first run: if a preview already exists AND was composed by the current
1383
+ // logic version, seed the signature and skip (don't recompose on every open).
1384
+ // A missing preview, or one stamped with an older version (or 0 for
1385
+ // pre-versioning rasters), falls through to recompose so stale rasters
1386
+ // self-heal. Subsequent runs recompose only on change.
1381
1387
  if (!previewInitializedRef.current) {
1382
1388
  previewInitializedRef.current = true;
1383
- if (siteConfig.previewImage) {
1389
+ if (siteConfig.previewImage && siteConfig.previewImage.version === PREVIEW_COMPOSE_VERSION) {
1384
1390
  lastPreviewSigRef.current = signature;
1385
1391
  return;
1386
1392
  }
@@ -1389,13 +1395,19 @@ export default function EditorShell({
1389
1395
 
1390
1396
  const timer = setTimeout(() => {
1391
1397
  lastPreviewSigRef.current = signature; // guard against duplicate concurrent runs
1398
+ const gen = ++previewGenRef.current;
1392
1399
  (async () => {
1393
1400
  try {
1394
1401
  const blob = await composePreviewImage(inputs);
1395
1402
  const file = new File([blob], "site-preview.webp", { type: "image/webp" });
1396
1403
  const { width, height } = servedPreviewSize(siteConfigRef.current!.media.sizes);
1397
1404
  mediaPipeline.contextValue.uploadFileWithCallback(file, (imageId) => {
1398
- handleSiteConfigChange({ ...siteConfigRef.current!, previewImage: { imageId, width, height } });
1405
+ // Ignore a stale upload: a newer compose has superseded this one.
1406
+ if (gen !== previewGenRef.current) return;
1407
+ handleSiteConfigChange({
1408
+ ...siteConfigRef.current!,
1409
+ previewImage: { imageId, width, height, version: PREVIEW_COMPOSE_VERSION },
1410
+ });
1399
1411
  }, { hidden: true });
1400
1412
  } catch (err) {
1401
1413
  console.error("[EditorShell] preview-image composition failed:", err);
@@ -1548,18 +1560,13 @@ export default function EditorShell({
1548
1560
  scope renders the provider and can't read the context directly),
1549
1561
  hide the FAB so it can't open the draft editor over a history view. */}
1550
1562
  {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
- >
1563
+ <Fab label="Edit as document" column="editor" className="bottom-40" onClick={() => setShowDocumentEditor(true)}>
1557
1564
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
1558
1565
  <rect x="3" y="5" width="18" height="14" rx="1" />
1559
1566
  <path d="M7 10h10" />
1560
1567
  <path d="M7 14h7" />
1561
1568
  </svg>
1562
- </button>
1569
+ </Fab>
1563
1570
  )}
1564
1571
  {showDocumentEditor && resolvedActivePage?.document?.enabled && (
1565
1572
  <Suspense fallback={<DocumentOverlayFallback />}>
@@ -1626,6 +1633,12 @@ export default function EditorShell({
1626
1633
  </div>
1627
1634
  )}
1628
1635
 
1636
+ {/* bp-band-shift: rides the chat dock's FLIP glide (base.css). On
1637
+ /edit it lives HERE, on the content column only — Layout's
1638
+ <main> can't carry it because the toolbar/FABs/chat panel above
1639
+ are position:fixed inside main, and a transformed ancestor
1640
+ would re-anchor them. */}
1641
+ <div className="bp-band-shift">
1629
1642
  <HistoryOrEditorContent
1630
1643
  index={siteIndex}
1631
1644
  activeSections={activeSections}
@@ -1654,6 +1667,7 @@ export default function EditorShell({
1654
1667
  onTakeover={confirmTakeover}
1655
1668
  />
1656
1669
  </HistoryOrEditorContent>
1670
+ </div>
1657
1671
  <GlobalModal />
1658
1672
  <SiteSettingsModal
1659
1673
  isOpen={showSiteSettings}
@@ -2412,8 +2426,12 @@ function EditorToolbar({
2412
2426
  return (
2413
2427
  <>
2414
2428
  {isEditMode && (
2429
+ // Full-width and static on purpose: the docked chat sidebar slides in
2430
+ // BELOW this bar (ChatPanel docks at top-16 on /edit), so the toolbar
2431
+ // never moves during the chat open/close transition. h-16 matches the
2432
+ // panel's top offset (and the body's pt-16) exactly.
2415
2433
  <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">
2434
+ <div className="mx-auto max-w-screen-xl grid h-16 grid-cols-3 items-center px-4">
2417
2435
  <div className="flex items-center gap-2">
2418
2436
  {buttonState === "saveAndPublish" && (
2419
2437
  <SplitButton
@@ -2490,7 +2508,7 @@ function EditorToolbar({
2490
2508
  )}
2491
2509
  {!isEditMode && (
2492
2510
  <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">
2511
+ <div className="mx-auto max-w-screen-xl flex h-16 items-center justify-center px-4">
2494
2512
  <SegmentedControl
2495
2513
  options={[
2496
2514
  { value: "live", label: "Published" },
@@ -2503,10 +2521,11 @@ function EditorToolbar({
2503
2521
  </div>
2504
2522
  )}
2505
2523
  {!historyState && (
2506
- <button
2524
+ <Fab
2525
+ label={isEditMode ? "Switch to view mode" : "Switch to edit mode"}
2526
+ column="editor"
2527
+ className="bottom-16"
2507
2528
  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
2529
  >
2511
2530
  {isEditMode ? (
2512
2531
  <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
@@ -2518,7 +2537,7 @@ function EditorToolbar({
2518
2537
  <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
2538
  </svg>
2520
2539
  )}
2521
- </button>
2540
+ </Fab>
2522
2541
  )}
2523
2542
  </>
2524
2543
  );
@@ -4,7 +4,7 @@ import { Button } from "../shared/Button";
4
4
  import { safeRedirect } from "../../lib/safeRedirect";
5
5
 
6
6
  interface Props {
7
- audiences: Array<{ name: string; displayName: string }>;
7
+ audiences: Array<{ name: string; displayName: string; hasPassword?: boolean }>;
8
8
  next?: string | null;
9
9
  }
10
10
 
@@ -14,12 +14,21 @@ export function ViewerLoginForm({ audiences, next }: Props) {
14
14
  const [error, setError] = useState<string | null>(null);
15
15
  const [loading, setLoading] = useState(false);
16
16
 
17
+ // An open (passwordless) audience needs no password field — enter with one click.
18
+ // Default to requiring a password until an audience is chosen so the field
19
+ // doesn't flash for the "Select audience..." state.
20
+ const selected = audiences.find((a) => a.name === audience);
21
+ const needsPassword = selected ? selected.hasPassword !== false : true;
22
+
17
23
  async function handleSubmit(e: FormEvent) {
18
24
  e.preventDefault();
19
25
  setLoading(true);
20
26
  setError(null);
21
27
 
22
- const body: { audience: string; password: string; next?: string } = { audience, password };
28
+ const body: { audience: string; password: string; next?: string } = {
29
+ audience,
30
+ password: needsPassword ? password : "",
31
+ };
23
32
  if (next) body.next = next;
24
33
 
25
34
  try {
@@ -67,12 +76,14 @@ export function ViewerLoginForm({ audiences, next }: Props) {
67
76
  </select>
68
77
  </div>
69
78
 
70
- <PasswordInput
71
- label="Password"
72
- value={password}
73
- onChange={setPassword}
74
- autoComplete="current-password"
75
- />
79
+ {needsPassword && (
80
+ <PasswordInput
81
+ label="Password"
82
+ value={password}
83
+ onChange={setPassword}
84
+ autoComplete="current-password"
85
+ />
86
+ )}
76
87
 
77
88
  {error && (
78
89
  <p className="text-sm text-red-600">{error}</p>