@drawnagency/primitives 0.1.87 → 0.1.89

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 (87) hide show
  1. package/dist/{chunk-PTVY3Q7P.js → chunk-2QXJ2KGB.js} +1 -1
  2. package/dist/{chunk-DRCPDOW3.js → chunk-CZ3YXGK5.js} +59 -0
  3. package/dist/{chunk-NEDOJHHR.js → chunk-J75XGXYD.js} +2 -2
  4. package/dist/{chunk-6VOIZP2O.js → chunk-L2MAJKKC.js} +220 -207
  5. package/dist/{chunk-VY5V4HBV.js → chunk-MVX4ULYE.js} +6 -6
  6. package/dist/{chunk-EO7K45GV.js → chunk-NV4R6VHN.js} +1 -1
  7. package/dist/{chunk-XDG2K6UT.js → chunk-QFV5TSPO.js} +1 -1
  8. package/dist/{chunk-OEL57NYV.js → chunk-VK5KNH73.js} +25 -1
  9. package/dist/{chunk-XPUBUR64.js → chunk-WCBYXNAG.js} +27 -2
  10. package/dist/{chunk-WA3HFN54.js → chunk-Y24T75HT.js} +1 -1
  11. package/dist/components/brandguide/Colors.d.ts.map +1 -1
  12. package/dist/components/brandguide/ColorsEditable.d.ts +20 -0
  13. package/dist/components/brandguide/ColorsEditable.d.ts.map +1 -0
  14. package/dist/components/brandguide/ColorsView.d.ts +9 -0
  15. package/dist/components/brandguide/ColorsView.d.ts.map +1 -0
  16. package/dist/components/brandguide/colors-shared.d.ts +4 -0
  17. package/dist/components/brandguide/colors-shared.d.ts.map +1 -0
  18. package/dist/components/chat/ChatPanel.d.ts +20 -3
  19. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  20. package/dist/components/chat/ChatWidget.d.ts +7 -1
  21. package/dist/components/chat/ChatWidget.d.ts.map +1 -1
  22. package/dist/components/chat/chat-history.d.ts +24 -0
  23. package/dist/components/chat/chat-history.d.ts.map +1 -0
  24. package/dist/components/editor/index.js +4 -4
  25. package/dist/components/primitives/index.js +5 -5
  26. package/dist/components/sections/Colors/index.d.ts.map +1 -1
  27. package/dist/components/sections/SectionIsland.d.ts +8 -0
  28. package/dist/components/sections/SectionIsland.d.ts.map +1 -0
  29. package/dist/components/sections/brand-guide.js +5 -5
  30. package/dist/components/sections/register-schemas.js +8 -8
  31. package/dist/components/sections/register.js +8 -8
  32. package/dist/components/shared/Navigation.d.ts +6 -1
  33. package/dist/components/shared/Navigation.d.ts.map +1 -1
  34. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  35. package/dist/components/shell/MediaLibraryContext.d.ts +3 -1
  36. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  37. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  38. package/dist/hooks/useCollabSession.d.ts.map +1 -1
  39. package/dist/hooks/useMediaPipeline.d.ts +5 -1
  40. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  41. package/dist/index.js +14 -4
  42. package/dist/lib/chatbot.d.ts +10 -0
  43. package/dist/lib/chatbot.d.ts.map +1 -0
  44. package/dist/lib/contrast.d.ts +5 -0
  45. package/dist/lib/contrast.d.ts.map +1 -1
  46. package/dist/lib/index.d.ts +2 -1
  47. package/dist/lib/index.d.ts.map +1 -1
  48. package/dist/lib/index.js +10 -2
  49. package/dist/lib/registry.d.ts +7 -0
  50. package/dist/lib/registry.d.ts.map +1 -1
  51. package/dist/media/compose-preview.d.ts +23 -0
  52. package/dist/media/compose-preview.d.ts.map +1 -0
  53. package/dist/media/index.d.ts +1 -0
  54. package/dist/media/index.d.ts.map +1 -1
  55. package/dist/media/index.js +4 -2
  56. package/dist/media/preview-inputs.d.ts +31 -0
  57. package/dist/media/preview-inputs.d.ts.map +1 -0
  58. package/dist/schemas/index.js +2 -2
  59. package/dist/schemas/media.d.ts +2 -0
  60. package/dist/schemas/media.d.ts.map +1 -1
  61. package/dist/schemas/site-config.d.ts +12 -0
  62. package/dist/schemas/site-config.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/components/brandguide/Colors.tsx +2 -275
  65. package/src/components/brandguide/ColorsEditable.tsx +155 -0
  66. package/src/components/brandguide/ColorsView.tsx +100 -0
  67. package/src/components/brandguide/colors-shared.ts +17 -0
  68. package/src/components/chat/ChatPanel.tsx +437 -65
  69. package/src/components/chat/ChatWidget.tsx +90 -32
  70. package/src/components/chat/chat-history.ts +105 -0
  71. package/src/components/sections/Colors/index.tsx +1 -0
  72. package/src/components/sections/SectionIsland.tsx +21 -0
  73. package/src/components/shared/Navigation.tsx +18 -5
  74. package/src/components/shell/EditorShell.tsx +71 -1
  75. package/src/components/shell/MediaLibraryContext.tsx +1 -1
  76. package/src/components/shell/SiteSettingsChatbot.tsx +47 -0
  77. package/src/hooks/useCollabSession.ts +27 -0
  78. package/src/hooks/useMediaPipeline.ts +7 -3
  79. package/src/lib/chatbot.ts +30 -0
  80. package/src/lib/contrast.ts +12 -0
  81. package/src/lib/index.ts +2 -1
  82. package/src/lib/registry.ts +7 -0
  83. package/src/media/compose-preview.ts +90 -0
  84. package/src/media/index.ts +1 -0
  85. package/src/media/preview-inputs.ts +77 -0
  86. package/src/schemas/media.ts +3 -0
  87. package/src/schemas/site-config.ts +26 -1
@@ -1,65 +1,123 @@
1
- // Chat launcher FAB. Renders ONLY the floating button until first open; the
2
- // panel (and its code) loads lazily so viewers who never click pay almost
3
- // nothing. Browser-safe: no node imports, no process.env.
1
+ // Chat launcher + shell chooser. Browser-safe: no node imports, no process.env.
4
2
  //
5
- // Two placements:
6
- // - "viewer" (default): bottom-right corner of viewer pages.
7
- // - "editor": BOTTOM of the /edit route's FAB column (edit-mode toggle at
8
- // bottom-16, bug report at bottom-28 above it), aligned to the content edge
9
- // via fab-container-right like its neighbors.
10
- import { lazy, Suspense, useState } from "react";
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.
13
+ import { lazy, Suspense, useEffect, useRef, useState } from "react";
11
14
  import { cn } from "../../lib/cn";
12
15
 
13
16
  const ChatPanel = lazy(() => import("./ChatPanel"));
14
17
 
18
+ const OPEN_KEY = "bp-chat-open";
19
+ const DOCK_QUERY = "(min-width: 768px)";
20
+
15
21
  export interface ChatWidgetProps {
16
22
  siteName: string;
23
+ /** Resolved assistant display name (per-audience, resolved server-side). */
24
+ botName: string;
25
+ /** Configured waiting phrases ("…" appended in the panel). */
26
+ thinkingPhrases?: string[];
17
27
  /** Already audience-filtered server-side — never pass unfiltered config tasks. */
18
28
  tasks: { id: string; label: string }[];
19
29
  isEditor?: boolean;
20
30
  /** Editor-only: chat against the current (unsaved) chatbot config. */
21
31
  draftChatbot?: unknown;
22
32
  variant?: "viewer" | "editor";
33
+ /** Viewer's audience (null for editor sessions) — tags local chat history. */
34
+ audience?: string | null;
23
35
  }
24
36
 
25
- export default function ChatWidget({ siteName, tasks, isEditor, draftChatbot, variant = "viewer" }: ChatWidgetProps) {
37
+ export default function ChatWidget({ siteName, botName, thinkingPhrases, tasks, isEditor, draftChatbot, variant = "viewer", audience }: ChatWidgetProps) {
26
38
  const [open, setOpen] = useState(false);
27
39
  const [everOpened, setEverOpened] = useState(false);
40
+ const [docked, setDocked] = useState(false);
41
+ const [userOpened, setUserOpened] = useState(false);
42
+ const fabRef = useRef<HTMLButtonElement>(null);
43
+ const isViewer = variant === "viewer";
44
+
45
+ // Post-hydration restore. The server renders only the FAB; Layout.astro's
46
+ // pre-paint script has already reserved the docked column and CSS hides the
47
+ // FAB under html.bp-chat-docked, so this state sync causes no layout jump.
48
+ useEffect(() => {
49
+ if (!isViewer || typeof window.matchMedia !== "function") return;
50
+ const mq = window.matchMedia(DOCK_QUERY);
51
+ const apply = () => setDocked(mq.matches);
52
+ apply();
53
+ mq.addEventListener("change", apply);
54
+ let storedOpen = true; // default: sidebar open
55
+ try { storedOpen = window.sessionStorage.getItem(OPEN_KEY) !== "0"; } catch {}
56
+ if (mq.matches && storedOpen) { setOpen(true); setEverOpened(true); }
57
+ return () => mq.removeEventListener("change", apply);
58
+ }, [isViewer]);
59
+
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.
63
+ useEffect(() => {
64
+ if (!isViewer) return;
65
+ document.documentElement.classList.toggle("bp-chat-docked", open && docked);
66
+ if (docked) {
67
+ try { window.sessionStorage.setItem(OPEN_KEY, open ? "1" : "0"); } catch {}
68
+ }
69
+ return () => document.documentElement.classList.remove("bp-chat-docked");
70
+ }, [open, docked, isViewer]);
71
+
72
+ // Closing the sheet/sidebar returns focus to the launcher.
73
+ useEffect(() => {
74
+ if (isViewer && !open && everOpened) fabRef.current?.focus();
75
+ }, [open, everOpened, isViewer]);
76
+
77
+ const showFab = !isViewer || !open;
28
78
 
29
79
  return (
30
80
  <>
31
- <button
32
- type="button"
33
- aria-label={open ? "Collapse chat" : `Chat with ${siteName}`}
34
- aria-expanded={open}
35
- onClick={() => { setOpen((o) => !o); setEverOpened(true); }}
36
- className={cn(
37
- "fixed z-40 flex items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity",
38
- variant === "editor"
39
- ? "bottom-4 right-4 lg:right-auto h-10 w-10 fab-container-right"
40
- : "bottom-4 right-4 h-12 w-12",
41
- )}
42
- >
43
- {open ? (
44
- <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">
45
- <path d="m6 9 6 6 6-6" />
46
- </svg>
47
- ) : (
48
- <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">
49
- <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
50
- </svg>
51
- )}
52
- </button>
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
+ )}
53
106
  {everOpened && (
54
107
  <Suspense fallback={null}>
55
108
  <ChatPanel
56
109
  open={open}
57
110
  onClose={() => setOpen(false)}
58
111
  siteName={siteName}
112
+ botName={botName}
113
+ thinkingPhrases={thinkingPhrases}
59
114
  tasks={tasks}
60
115
  isEditor={isEditor}
61
116
  draftChatbot={draftChatbot}
62
117
  variant={variant}
118
+ mode={isViewer ? (docked ? "docked" : "sheet") : "floating"}
119
+ focusComposer={userOpened}
120
+ audience={audience}
63
121
  />
64
122
  </Suspense>
65
123
  )}
@@ -0,0 +1,105 @@
1
+ // Local-only chat history (localStorage). The ACTIVE conversation lives in
2
+ // sessionStorage (per-tab, survives same-tab navigation — see ChatPanel);
3
+ // this module is the durable archive behind the panel's history list.
4
+ //
5
+ // Retention: pinned conversations are kept forever and sort to the top;
6
+ // unpinned ones expire after HISTORY_RETENTION_DAYS and are capped at
7
+ // HISTORY_MAX_UNPINNED overall (not per audience — one browser is one
8
+ // viewer in practice). Every entry is tagged with the audience it belonged
9
+ // to, and the list only ever shows the current audience's conversations —
10
+ // localStorage outlives the 24h audience cookie, so an expired session must
11
+ // not expose another audience's transcripts on a shared machine.
12
+ import type { ChatMessage } from "./ChatPanel";
13
+
14
+ const HISTORY_KEY = "bp-chat-history";
15
+ export const HISTORY_RETENTION_DAYS = 30;
16
+ export const HISTORY_MAX_UNPINNED = 20;
17
+ const MESSAGE_CAP = 40;
18
+ const TITLE_MAX = 60;
19
+
20
+ export interface StoredConversation {
21
+ id: string;
22
+ title: string;
23
+ /** Audience tag ("editor" for editor sessions on viewer pages). */
24
+ audience: string;
25
+ pinned: boolean;
26
+ updatedAt: number;
27
+ messages: ChatMessage[];
28
+ }
29
+
30
+ function isMessage(m: unknown): m is ChatMessage {
31
+ return !!m && typeof m === "object" &&
32
+ ((m as ChatMessage).role === "user" || (m as ChatMessage).role === "assistant") &&
33
+ typeof (m as ChatMessage).content === "string";
34
+ }
35
+
36
+ function isEntry(e: unknown): e is StoredConversation {
37
+ if (!e || typeof e !== "object") return false;
38
+ const c = e as StoredConversation;
39
+ return typeof c.id === "string" && typeof c.title === "string" &&
40
+ typeof c.audience === "string" && typeof c.pinned === "boolean" &&
41
+ typeof c.updatedAt === "number" && Array.isArray(c.messages) && c.messages.every(isMessage);
42
+ }
43
+
44
+ function readAll(): StoredConversation[] {
45
+ try {
46
+ const parsed: unknown = JSON.parse(window.localStorage.getItem(HISTORY_KEY) ?? "[]");
47
+ return Array.isArray(parsed) ? parsed.filter(isEntry) : [];
48
+ } catch {
49
+ return [];
50
+ }
51
+ }
52
+
53
+ function writeAll(entries: StoredConversation[]): void {
54
+ try {
55
+ window.localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
56
+ } catch { /* storage unavailable/full — history is best-effort */ }
57
+ }
58
+
59
+ function prune(entries: StoredConversation[], now: number): StoredConversation[] {
60
+ const cutoff = now - HISTORY_RETENTION_DAYS * 86_400_000;
61
+ const pinned = entries.filter((e) => e.pinned);
62
+ const unpinned = entries
63
+ .filter((e) => !e.pinned && e.updatedAt >= cutoff)
64
+ .sort((a, b) => b.updatedAt - a.updatedAt)
65
+ .slice(0, HISTORY_MAX_UNPINNED);
66
+ return [...pinned, ...unpinned];
67
+ }
68
+
69
+ /** Current audience's conversations: pinned first, then newest first. */
70
+ export function loadHistory(audience: string, now = Date.now()): StoredConversation[] {
71
+ return readAll()
72
+ .filter((e) => e.audience === audience)
73
+ .filter((e) => e.pinned || e.updatedAt >= now - HISTORY_RETENTION_DAYS * 86_400_000)
74
+ .sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.updatedAt - a.updatedAt);
75
+ }
76
+
77
+ /** Create/refresh a conversation's archive entry (preserves its pinned flag)
78
+ * and apply retention. No-ops on empty transcripts. */
79
+ export function upsertConversation(
80
+ input: { id: string; audience: string; messages: ChatMessage[] },
81
+ now = Date.now(),
82
+ ): void {
83
+ const messages = input.messages.filter((m) => m.content !== "").slice(-MESSAGE_CAP);
84
+ if (messages.length === 0) return;
85
+ const all = readAll();
86
+ const existing = all.find((e) => e.id === input.id);
87
+ const firstUser = messages.find((m) => m.role === "user");
88
+ const entry: StoredConversation = {
89
+ id: input.id,
90
+ audience: input.audience,
91
+ pinned: existing?.pinned ?? false,
92
+ updatedAt: now,
93
+ title: (firstUser ?? messages[0]).content.slice(0, TITLE_MAX),
94
+ messages,
95
+ };
96
+ writeAll(prune([...all.filter((e) => e.id !== input.id), entry], now));
97
+ }
98
+
99
+ export function setConversationPinned(id: string, pinned: boolean): void {
100
+ writeAll(readAll().map((e) => (e.id === id ? { ...e, pinned } : e)));
101
+ }
102
+
103
+ export function removeConversation(id: string): void {
104
+ writeAll(readAll().filter((e) => e.id !== id));
105
+ }
@@ -24,6 +24,7 @@ const schema = z.object({
24
24
 
25
25
  export default defineSection({
26
26
  type: "colors",
27
+ interactive: true,
27
28
  category: "brand-guide",
28
29
  label: "Colors",
29
30
  icon: <Palette size={18} />,
@@ -0,0 +1,21 @@
1
+ import { ensureSectionsRegistered } from "./register";
2
+ import { getSection } from "../../lib/registry";
3
+
4
+ // Registering on import means the registry is populated both during SSR of the
5
+ // island and after it hydrates client-side (register runs again in the browser
6
+ // bundle; the Symbol.for-keyed singleton dedupes).
7
+ ensureSectionsRegistered();
8
+
9
+ interface Props {
10
+ type: string;
11
+ content: unknown;
12
+ options?: Record<string, unknown>;
13
+ }
14
+
15
+ export default function SectionIsland({ type, content, options }: Props) {
16
+ const def = getSection(type);
17
+ if (!def) return null;
18
+ const Component = def.component;
19
+ // Viewer mode: no onChange / openModal callbacks -> the view (not editable) path.
20
+ return <Component content={content} options={options} isEditMode={false} />;
21
+ }
@@ -18,9 +18,21 @@ interface Props {
18
18
  // Optional same-island handler (tests). In production the editor nav is a
19
19
  // separate island from EditorShell, so a click dispatches pageSelectEvent.
20
20
  onPageSelect?: (pageId: string) => void;
21
+ /** Viewport width where the drawer becomes a fixed sidebar. Pages that dock
22
+ * the chat sidebar pass "xl" so tablet widths keep the hamburger and give
23
+ * the freed column to chat. Static per page render — deliberately keyed to
24
+ * chat being ENABLED, not open, so the nav never flaps when chat toggles. */
25
+ desktopBreakpoint?: "lg" | "xl";
21
26
  }
22
27
 
23
- export default function Navigation({ siteNav: initialNav, siteName, darkMode, lastUpdated, logo, onPageSelect }: Props) {
28
+ // Both branches are literal class strings so Tailwind's scanner sees them.
29
+ const BP_CLASSES = {
30
+ lg: { hidden: "lg:hidden", leftAuto: "lg:left-auto", translate: "lg:translate-x-0", block: "lg:block" },
31
+ xl: { hidden: "xl:hidden", leftAuto: "xl:left-auto", translate: "xl:translate-x-0", block: "xl:block" },
32
+ } as const;
33
+
34
+ export default function Navigation({ siteNav: initialNav, siteName, darkMode, lastUpdated, logo, onPageSelect, desktopBreakpoint = "lg" }: Props) {
35
+ const bp = BP_CLASSES[desktopBreakpoint];
24
36
  const [isOpen, setIsOpen] = useState(false);
25
37
  const [isEditMode, setIsEditMode] = useState(false);
26
38
  const [currentDarkMode, setCurrentDarkMode] = useState(darkMode);
@@ -205,7 +217,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
205
217
 
206
218
  return (
207
219
  <>
208
- <header className="fixed top-0 left-0 right-0 z-50 bg-base lg:hidden">
220
+ <header className={cn("fixed top-0 left-0 right-0 z-50 bg-base", bp.hidden)}>
209
221
  <div className="mx-auto max-w-screen-xl flex h-16 items-center justify-between px-4">
210
222
  <div className="flex flex-col items-start gap-1">
211
223
  {logoMark}
@@ -221,11 +233,12 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
221
233
  </div>
222
234
  </header>
223
235
 
224
- {isOpen && <div className="fixed inset-0 z-40 bg-black/50 lg:hidden" onClick={() => setIsOpen(false)} />}
236
+ {isOpen && <div className={cn("fixed inset-0 z-40 bg-black/50", bp.hidden)} onClick={() => setIsOpen(false)} />}
225
237
 
226
- <nav className={cn("fixed top-0 left-0 lg:left-auto z-40 h-full w-64 flex flex-col overflow-y-auto border-r border-base-200 bg-base pt-16 transition-transform lg:translate-x-0 nav-sidebar",
238
+ <nav className={cn("fixed top-0 left-0 z-40 h-full w-64 flex flex-col overflow-y-auto border-r border-base-200 bg-base pt-16 transition-transform nav-sidebar",
239
+ bp.leftAuto, bp.translate,
227
240
  isOpen ? "translate-x-0" : "-translate-x-full")}>
228
- <div className="hidden px-4 py-4 lg:block">
241
+ <div className={cn("hidden px-4 py-4", bp.block)}>
229
242
  {logoMark}
230
243
  <span className="text-lg font-bold text-primary">{currentSiteName}</span>
231
244
  </div>
@@ -10,12 +10,17 @@ 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";
14
+ import { derivePreviewInputs, servedPreviewSize } from "../../media/preview-inputs";
15
+ import { resolveMedia } from "../../media/resolve";
16
+ import { getMediaProvider } from "../../media";
13
17
  // Sanctioned outside->inside import: document/ is a sealed subtree, but the
14
18
  // Document mode toggle wiring needs the patch shape it edits (mirrors the same
15
19
  // sanctioned import in schemas/site-config.ts and lib/pages.ts).
16
20
  import type { DocumentOptions } from "../../document/engine/settings";
17
21
  import { SiteConfigSchema, IndexSchema } from "../../schemas/site-config";
18
22
  import { ensureSanitizer } from "../../lib/sanitize";
23
+ import { resolveChatbotName } from "../../lib/chatbot";
19
24
  import { EditorProvider, useEditorContext, type HistoryState } from "./EditorContext";
20
25
  import { makeBlockMoveDispatch } from "./blockMoveDispatch";
21
26
  import { ROOT_CONTAINER_ID, resolveDropIndex } from "../../lib/block-dnd";
@@ -240,6 +245,15 @@ export default function EditorShell({
240
245
  useEffect(() => { sectionsRef.current = sections; }, [sections]);
241
246
  useEffect(() => { siteIndexRef.current = siteIndex; }, [siteIndex]);
242
247
  useEffect(() => { void ensureSanitizer(); }, []);
248
+ // Mirrors siteConfig so the async preview-image upload callback (below)
249
+ // writes onto the latest config instead of a stale closure.
250
+ const siteConfigRef = useRef<SiteConfig | null>(siteConfig);
251
+ useEffect(() => { siteConfigRef.current = siteConfig; }, [siteConfig]);
252
+ // Guards the debounced preview-image composition effect (below): skips
253
+ // recomposing on the very first run when a preview already exists, and
254
+ // dedupes concurrent runs against the same composition signature.
255
+ const previewInitializedRef = useRef(false);
256
+ const lastPreviewSigRef = useRef<string | null>(null);
243
257
 
244
258
  // --- Collaboration refs ---
245
259
  // Freshest dirty-section set for the inbound remote-section handler (never
@@ -1332,6 +1346,60 @@ export default function EditorShell({
1332
1346
  setLocalChangesExist(true);
1333
1347
  }, [applySiteConfigPreview, persistence]);
1334
1348
 
1349
+ // Recompose the social-preview raster (siteConfig.previewImage) when brand
1350
+ // inputs change. Debounced so text edits don't compose per keystroke. The
1351
+ // new previewImage rides the next normal save like any other config change.
1352
+ useEffect(() => {
1353
+ if (!siteConfig) return;
1354
+ const resolveLogoUrl = (imageId: string): string | null => {
1355
+ const pending = mediaPipeline.pendingLocalUrls[imageId];
1356
+ if (pending) return pending;
1357
+ const resolved = resolveMedia({ imageId }, mediaManifest, [1200], getMediaProvider());
1358
+ return resolved?.src ?? null;
1359
+ };
1360
+ const { inputs, signature } = derivePreviewInputs(siteConfig, resolveLogoUrl);
1361
+
1362
+ // On first run: if a preview already exists, seed the signature and skip
1363
+ // (don't recompose/re-upload on every editor open). If none exists yet,
1364
+ // fall through to compose one. Subsequent runs recompose only on change.
1365
+ if (!previewInitializedRef.current) {
1366
+ previewInitializedRef.current = true;
1367
+ if (siteConfig.previewImage) {
1368
+ lastPreviewSigRef.current = signature;
1369
+ return;
1370
+ }
1371
+ }
1372
+ if (signature === lastPreviewSigRef.current) return;
1373
+
1374
+ const timer = setTimeout(() => {
1375
+ lastPreviewSigRef.current = signature; // guard against duplicate concurrent runs
1376
+ (async () => {
1377
+ try {
1378
+ const blob = await composePreviewImage(inputs);
1379
+ const file = new File([blob], "site-preview.webp", { type: "image/webp" });
1380
+ const { width, height } = servedPreviewSize(siteConfigRef.current!.media.sizes);
1381
+ mediaPipeline.contextValue.uploadFileWithCallback(file, (imageId) => {
1382
+ handleSiteConfigChange({ ...siteConfigRef.current!, previewImage: { imageId, width, height } });
1383
+ }, { hidden: true });
1384
+ } catch (err) {
1385
+ console.error("[EditorShell] preview-image composition failed:", err);
1386
+ // Composition/upload failed — un-mark the signature so the next effect
1387
+ // run (next brand-input change, or this same one on remount) retries
1388
+ // instead of being permanently skipped as "already done".
1389
+ lastPreviewSigRef.current = null;
1390
+ }
1391
+ })();
1392
+ }, 900);
1393
+ return () => clearTimeout(timer);
1394
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1395
+ }, [
1396
+ siteConfig?.logo,
1397
+ siteConfig?.primaryColor,
1398
+ siteConfig?.siteName,
1399
+ siteConfig?.headingFont,
1400
+ siteConfig?.darkMode,
1401
+ ]);
1402
+
1335
1403
  const handleHistoryRestore = useCallback(async (historyContent: {
1336
1404
  sections: LoadedSection[];
1337
1405
  index: SiteIndex;
@@ -1512,6 +1580,8 @@ export default function EditorShell({
1512
1580
  {chatbotPlatformEnabled && siteConfig && (
1513
1581
  <ChatWidget
1514
1582
  siteName={siteConfig.siteName}
1583
+ botName={resolveChatbotName(siteConfig.chatbot, null)}
1584
+ thinkingPhrases={siteConfig.chatbot.thinkingPhrases}
1515
1585
  tasks={siteConfig.chatbot.tasks.map((t) => ({ id: t.id, label: t.label }))}
1516
1586
  isEditor
1517
1587
  draftChatbot={siteConfig.chatbot}
@@ -1628,7 +1698,7 @@ export default function EditorShell({
1628
1698
  items={[
1629
1699
  ...Object.values(mediaManifest.images),
1630
1700
  ...mediaPipeline.pendingMediaItems,
1631
- ].filter((item) => !mediaPipeline.pendingDeletions.includes(item.id))}
1701
+ ].filter((item) => !mediaPipeline.pendingDeletions.includes(item.id) && !item.hidden)}
1632
1702
  onSelect={(id) => {
1633
1703
  if (mediaSelectCallback) {
1634
1704
  mediaSelectCallback(id);
@@ -5,7 +5,7 @@ import type { SiteConfig } from "../../schemas/site-config";
5
5
  export interface MediaLibraryContextValue {
6
6
  openSelectModal: (onSelect: (imageId: string) => void) => void;
7
7
  uploadFile: (file: File) => void;
8
- uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void) => string;
8
+ uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void, opts?: { hidden?: boolean }) => string;
9
9
  reportRejectedUploads: (files: { name: string; reason: "size" | "type" }[]) => void;
10
10
  manifest: MediaManifest;
11
11
  pendingItems: MediaItem[];
@@ -5,6 +5,17 @@ import ChatPanel from "../chat/ChatPanel";
5
5
  import type { SiteConfig, ChatbotConfig, ChatbotTask } from "../../schemas/site-config";
6
6
  import type { Audience } from "../../auth/types";
7
7
  import { audiencePasses } from "../../lib/pages";
8
+ import { resolveChatbotName, DEFAULT_CHATBOT_NAME } from "../../lib/chatbot";
9
+
10
+ /** Comma-separated textarea → schema list: trimmed, blanks dropped, clamped
11
+ * to the schema caps (30 phrases × 80 chars). */
12
+ function parseThinkingPhrases(text: string): string[] {
13
+ return text
14
+ .split(",")
15
+ .map((s) => s.trim().slice(0, 80))
16
+ .filter(Boolean)
17
+ .slice(0, 30);
18
+ }
8
19
 
9
20
  interface Props {
10
21
  siteConfig: SiteConfig;
@@ -38,6 +49,9 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
38
49
  const [newTaskLabel, setNewTaskLabel] = useState("");
39
50
  const [showTest, setShowTest] = useState(false);
40
51
  const [previewAudience, setPreviewAudience] = useState("");
52
+ // Local text mirror of thinkingPhrases: parsing on every keystroke while
53
+ // round-tripping through the array would fight trailing commas/spaces.
54
+ const [thinkingText, setThinkingText] = useState(() => (chatbot.thinkingPhrases ?? []).join(", "));
41
55
 
42
56
  // A deselected audience can no longer chat — never keep simulating it.
43
57
  useEffect(() => {
@@ -77,6 +91,10 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
77
91
  const v = chatbot.audiencePrompts[name];
78
92
  return typeof v === "string" ? v : "";
79
93
  };
94
+ const audienceNameValue = (name: string) => {
95
+ const v = chatbot.audienceNames?.[name];
96
+ return typeof v === "string" ? v : "";
97
+ };
80
98
 
81
99
  return (
82
100
  <div className="flex flex-col gap-4">
@@ -94,12 +112,39 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
94
112
  Show the chat widget to viewers
95
113
  </label>
96
114
 
115
+ <div>
116
+ <label htmlFor="chatbot-name" className="mb-1 block text-sm font-medium text-base-contrast">Assistant name</label>
117
+ <input id="chatbot-name" aria-label="Assistant name" maxLength={60} value={chatbot.name}
118
+ onChange={(e) => patch({ name: e.target.value })}
119
+ placeholder={DEFAULT_CHATBOT_NAME}
120
+ className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
121
+ <p className="mt-1 text-xs text-base-contrast-light">Shown in the widget header. Leave blank for “{DEFAULT_CHATBOT_NAME}”. Audiences can override it below.</p>
122
+ </div>
123
+
124
+ <div>
125
+ <label htmlFor="chatbot-thinking-phrases" className="mb-1 block text-sm font-medium text-base-contrast">Thinking phrases</label>
126
+ <textarea id="chatbot-thinking-phrases" aria-label="Thinking phrases" rows={2}
127
+ value={thinkingText}
128
+ onChange={(e) => { setThinkingText(e.target.value); patch({ thinkingPhrases: parseThinkingPhrases(e.target.value) }); }}
129
+ placeholder="Mixing the palette, Checking the guide, Consulting the brand book"
130
+ className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
131
+ <p className="mt-1 text-xs text-base-contrast-light">Comma-separated; one is shown at random while the assistant thinks, with “…” added automatically. Leave blank for “Thinking…”.</p>
132
+ </div>
133
+
97
134
  <div>
98
135
  <p className="mb-1 text-sm font-medium text-base-contrast">Audiences</p>
99
136
  <p className="mb-2 text-xs text-base-contrast-light">Who sees the widget — only the selected audiences; none selected means no one.</p>
100
137
  <AudienceChips audiences={audiences} selected={chatbot.audiences} onToggle={(n) => patch({ audiences: toggleName(chatbot.audiences, n) })} />
101
138
  {chatbot.audiences.map((name) => (
102
139
  <div key={name} className="mt-2">
140
+ <label htmlFor={`audience-name-${name}`} className="mb-1 block text-xs font-medium text-base-contrast">
141
+ Assistant name for {name}
142
+ </label>
143
+ <input id={`audience-name-${name}`} aria-label={`Assistant name for ${name}`} maxLength={60}
144
+ value={audienceNameValue(name)}
145
+ onChange={(e) => patch({ audienceNames: { ...chatbot.audienceNames, [name]: e.target.value } })}
146
+ placeholder={resolveChatbotName(chatbot, null)}
147
+ className="mb-1 w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
103
148
  <label htmlFor={`audience-prompt-${name}`} className="mb-1 block text-xs font-medium text-base-contrast">
104
149
  Additional guidance for {name}
105
150
  </label>
@@ -175,6 +220,8 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
175
220
  {showTest && platformEnabled && (
176
221
  <ChatPanel
177
222
  open onClose={() => setShowTest(false)} siteName={siteConfig.siteName}
223
+ botName={resolveChatbotName(chatbot, previewAudience || null)}
224
+ thinkingPhrases={chatbot.thinkingPhrases}
178
225
  tasks={chatbot.tasks
179
226
  .filter((t) => previewAudience === "" || audiencePasses(t.access, previewAudience))
180
227
  .map((t) => ({ id: t.id, label: t.label }))}
@@ -5,6 +5,13 @@ import type {
5
5
  } from "../collab/types";
6
6
 
7
7
  const REJOIN_DELAYS_MS = [5_000, 15_000, 30_000, 60_000];
8
+ // Circuit breaker: after this many consecutive failed rejoins (~3.5 min of
9
+ // backoff), stop the auto-rejoin loop and stay in solo mode rather than retry
10
+ // forever — an unbounded reconnect loop is itself a way to hammer the backend.
11
+ // Refocusing the tab (visibilitychange → visible) resets the breaker and
12
+ // retries, so a user returning to a long-idle/offline tab reconnects without a
13
+ // manual reload.
14
+ const MAX_REJOIN_ATTEMPTS = 6;
8
15
 
9
16
  export interface UseCollabSessionArgs {
10
17
  provider: CollabProvider | null;
@@ -129,14 +136,34 @@ export function useCollabSession(args: UseCollabSessionArgs): UseCollabSessionRe
129
136
  // race two concurrent joins into two sessions, and the orphaned timer
130
137
  // would survive unmount (the cleanup only clears the tracked one).
131
138
  if (timer) clearTimeout(timer);
139
+ // Circuit breaker: give up after a capped number of consecutive failures
140
+ // rather than rejoin forever. onVisible() below rearms it on refocus.
141
+ if (attempt >= MAX_REJOIN_ATTEMPTS) {
142
+ console.warn(`Collab: stopped auto-rejoining after ${attempt} consecutive failures; staying solo until the tab is refocused.`);
143
+ return;
144
+ }
132
145
  const delay = REJOIN_DELAYS_MS[Math.min(attempt, REJOIN_DELAYS_MS.length - 1)];
133
146
  attempt += 1;
134
147
  timer = setTimeout(() => { timer = null; void join(); }, delay);
135
148
  }
136
149
 
150
+ // Rearm the breaker (and reconnect immediately) when the user tabs back to
151
+ // a disconnected editor — the common recovery from a laptop-sleep / network
152
+ // blip that exhausted the rejoin budget. No-op while already live/connecting.
153
+ function onVisible() {
154
+ if (cancelled) return;
155
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
156
+ if (sessionRef.current || joining) return;
157
+ if (timer) { clearTimeout(timer); timer = null; }
158
+ attempt = 0;
159
+ void join();
160
+ }
161
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
162
+
137
163
  void join();
138
164
  return () => {
139
165
  cancelled = true;
166
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
140
167
  if (timer) clearTimeout(timer);
141
168
  void sessionRef.current?.disconnect().catch(() => {});
142
169
  sessionRef.current = null;
@@ -76,6 +76,7 @@ export function useMediaPipeline({
76
76
  // Display-folder target per in-flight upload, keyed by content hash. Set at
77
77
  // enqueue time, consumed when the processing queue finalizes the item.
78
78
  const uploadTargetsRef = useRef<Map<string, string | null>>(new Map());
79
+ const hiddenUploadsRef = useRef<Map<string, boolean>>(new Map());
79
80
  const destroyedRef = useRef(false);
80
81
 
81
82
  // Shared persistence path for every pending media item — the processing
@@ -171,10 +172,12 @@ export function useMediaPipeline({
171
172
  alt: "",
172
173
  storage: "github",
173
174
  ...(targetFolderId ? { folderId: targetFolderId } : {}),
175
+ ...(hiddenUploadsRef.current.get(event.item.hash) ? { hidden: true } : {}),
174
176
  };
175
177
 
176
178
  const persisted = await finalizePendingMedia(item, localUrls, blobsMap);
177
179
  uploadTargetsRef.current.delete(event.item.hash);
180
+ hiddenUploadsRef.current.delete(event.item.hash);
178
181
  if (!persisted) return;
179
182
 
180
183
  const callback = uploadCallbacksRef.current.get(event.item.hash);
@@ -603,7 +606,7 @@ export function useMediaPipeline({
603
606
 
604
607
  // --- Unified enqueue ---
605
608
 
606
- const enqueueFile = useCallback((file: File, onComplete?: (imageId: string) => void) => {
609
+ const enqueueFile = useCallback((file: File, onComplete?: (imageId: string) => void, opts?: { hidden?: boolean }) => {
607
610
  if (!siteConfig) return;
608
611
  const mediaConfig = siteConfig.media;
609
612
  if (file.size > mediaConfig.maxFileSize) return;
@@ -627,6 +630,7 @@ export function useMediaPipeline({
627
630
  if (onComplete) {
628
631
  uploadCallbacksRef.current.set(hash, onComplete);
629
632
  }
633
+ if (opts?.hidden) hiddenUploadsRef.current.set(hash, true);
630
634
 
631
635
  let kind: "image" | "animated" | "video" = "image";
632
636
  if (file.type === "image/gif" || file.type === "image/apng") kind = "animated";
@@ -646,12 +650,12 @@ export function useMediaPipeline({
646
650
  // Placeholder — EditorShell overrides this
647
651
  },
648
652
  uploadFile: (file: File) => handleMediaUpload([file]),
649
- uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void) => {
653
+ uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void, opts?: { hidden?: boolean }) => {
650
654
  const blobUrl = URL.createObjectURL(file);
651
655
  enqueueFile(file, (imageId) => {
652
656
  URL.revokeObjectURL(blobUrl);
653
657
  onComplete(imageId);
654
- });
658
+ }, opts);
655
659
  return blobUrl;
656
660
  },
657
661
  reportRejectedUploads: () => {