@drawnagency/primitives 0.1.88 → 0.1.90

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 (124) hide show
  1. package/dist/{chunk-75P6HWPN.js → chunk-5BM2Y3NJ.js} +5 -3
  2. package/dist/{chunk-OEL57NYV.js → chunk-AGWE2LJG.js} +26 -2
  3. package/dist/{chunk-DRCPDOW3.js → chunk-CZ3YXGK5.js} +59 -0
  4. package/dist/{chunk-PTVY3Q7P.js → chunk-KBQNJZUX.js} +1 -1
  5. package/dist/{chunk-XPUBUR64.js → chunk-KMQZYORS.js} +28 -2
  6. package/dist/{chunk-WA3HFN54.js → chunk-MK7YB2A4.js} +1 -1
  7. package/dist/{chunk-EO7K45GV.js → chunk-NV4R6VHN.js} +1 -1
  8. package/dist/{chunk-XDG2K6UT.js → chunk-SIYQ37QY.js} +13 -5
  9. package/dist/{chunk-NEDOJHHR.js → chunk-XKRQMTE4.js} +11 -2
  10. package/dist/{chunk-SK6GGTTO.js → chunk-XUIJHYKI.js} +3 -3
  11. package/dist/{chunk-H2FENAQH.js → chunk-ZLG7S54V.js} +242 -83
  12. package/dist/components/chat/ChatPanel.d.ts +20 -3
  13. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  14. package/dist/components/chat/ChatWidget.d.ts +7 -1
  15. package/dist/components/chat/ChatWidget.d.ts.map +1 -1
  16. package/dist/components/chat/chat-history.d.ts +24 -0
  17. package/dist/components/chat/chat-history.d.ts.map +1 -0
  18. package/dist/components/editor/AudienceIndicator.d.ts +6 -0
  19. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
  20. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -1
  21. package/dist/components/editor/ColSpanControl.d.ts.map +1 -1
  22. package/dist/components/editor/DragHandle.d.ts +3 -1
  23. package/dist/components/editor/DragHandle.d.ts.map +1 -1
  24. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  25. package/dist/components/editor/StatusIndicator.d.ts +7 -0
  26. package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
  27. package/dist/components/editor/index.js +100 -78
  28. package/dist/components/primitives/index.js +5 -5
  29. package/dist/components/sections/Container/Container.d.ts +3 -2
  30. package/dist/components/sections/Container/Container.d.ts.map +1 -1
  31. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +4 -2
  32. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -1
  33. package/dist/components/sections/Container/index.d.ts +8 -2
  34. package/dist/components/sections/Container/index.d.ts.map +1 -1
  35. package/dist/components/sections/SectionLayout.d.ts +3 -1
  36. package/dist/components/sections/SectionLayout.d.ts.map +1 -1
  37. package/dist/components/sections/ViewRenderer.d.ts +3 -1
  38. package/dist/components/sections/ViewRenderer.d.ts.map +1 -1
  39. package/dist/components/sections/all-sections.d.ts +8 -2
  40. package/dist/components/sections/all-sections.d.ts.map +1 -1
  41. package/dist/components/sections/brand-guide.js +5 -5
  42. package/dist/components/sections/core-sections.d.ts +8 -2
  43. package/dist/components/sections/core-sections.d.ts.map +1 -1
  44. package/dist/components/sections/register-schemas.js +8 -8
  45. package/dist/components/sections/register.js +8 -8
  46. package/dist/components/shared/Navigation.d.ts +6 -1
  47. package/dist/components/shared/Navigation.d.ts.map +1 -1
  48. package/dist/components/shared/Tooltip.d.ts +8 -1
  49. package/dist/components/shared/Tooltip.d.ts.map +1 -1
  50. package/dist/components/shell/AudiencePreviewMenu.d.ts +7 -0
  51. package/dist/components/shell/AudiencePreviewMenu.d.ts.map +1 -0
  52. package/dist/components/shell/AuthoringDefaultsMenu.d.ts +10 -0
  53. package/dist/components/shell/AuthoringDefaultsMenu.d.ts.map +1 -0
  54. package/dist/components/shell/EditorContext.d.ts +3 -0
  55. package/dist/components/shell/EditorContext.d.ts.map +1 -1
  56. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  57. package/dist/components/shell/MediaLibraryContext.d.ts +3 -1
  58. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  59. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  60. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  61. package/dist/hooks/useMediaPipeline.d.ts +5 -1
  62. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  63. package/dist/index.js +17 -5
  64. package/dist/lib/authoring-defaults.d.ts +17 -0
  65. package/dist/lib/authoring-defaults.d.ts.map +1 -0
  66. package/dist/lib/chatbot.d.ts +10 -0
  67. package/dist/lib/chatbot.d.ts.map +1 -0
  68. package/dist/lib/container-grid.d.ts +6 -0
  69. package/dist/lib/container-grid.d.ts.map +1 -1
  70. package/dist/lib/contrast.d.ts +5 -0
  71. package/dist/lib/contrast.d.ts.map +1 -1
  72. package/dist/lib/index.d.ts +2 -1
  73. package/dist/lib/index.d.ts.map +1 -1
  74. package/dist/lib/index.js +11 -3
  75. package/dist/lib/migrate-sections-transform.js +1 -1
  76. package/dist/media/compose-preview.d.ts +23 -0
  77. package/dist/media/compose-preview.d.ts.map +1 -0
  78. package/dist/media/index.d.ts +1 -0
  79. package/dist/media/index.d.ts.map +1 -1
  80. package/dist/media/index.js +4 -2
  81. package/dist/media/preview-inputs.d.ts +31 -0
  82. package/dist/media/preview-inputs.d.ts.map +1 -0
  83. package/dist/schemas/index.js +4 -2
  84. package/dist/schemas/media.d.ts +2 -0
  85. package/dist/schemas/media.d.ts.map +1 -1
  86. package/dist/schemas/site-config.d.ts +18 -0
  87. package/dist/schemas/site-config.d.ts.map +1 -1
  88. package/package.json +1 -1
  89. package/src/components/chat/ChatPanel.tsx +437 -65
  90. package/src/components/chat/ChatWidget.tsx +90 -32
  91. package/src/components/chat/chat-history.ts +105 -0
  92. package/src/components/editor/AudienceIndicator.tsx +50 -37
  93. package/src/components/editor/ChildBlockWrapper.tsx +8 -7
  94. package/src/components/editor/ColSpanControl.tsx +14 -11
  95. package/src/components/editor/DragHandle.tsx +5 -2
  96. package/src/components/editor/PagesModal.tsx +10 -10
  97. package/src/components/editor/SectionWrapper.tsx +32 -21
  98. package/src/components/editor/StatusIndicator.tsx +37 -24
  99. package/src/components/sections/Container/Container.tsx +185 -43
  100. package/src/components/sections/Container/ContainerSettingsForm.tsx +64 -24
  101. package/src/components/sections/Container/index.tsx +45 -20
  102. package/src/components/sections/SectionLayout.tsx +13 -3
  103. package/src/components/sections/ViewRenderer.tsx +11 -2
  104. package/src/components/shared/Navigation.tsx +18 -5
  105. package/src/components/shared/Tooltip.tsx +18 -2
  106. package/src/components/shell/AudiencePreviewMenu.tsx +73 -0
  107. package/src/components/shell/AuthoringDefaultsMenu.tsx +54 -0
  108. package/src/components/shell/EditorContext.tsx +5 -1
  109. package/src/components/shell/EditorShell.tsx +130 -13
  110. package/src/components/shell/MediaLibraryContext.tsx +1 -1
  111. package/src/components/shell/SiteSettingsChatbot.tsx +49 -1
  112. package/src/hooks/useBuildStatus.ts +35 -4
  113. package/src/hooks/useMediaPipeline.ts +7 -3
  114. package/src/lib/authoring-defaults.ts +42 -0
  115. package/src/lib/chatbot.ts +30 -0
  116. package/src/lib/container-grid.ts +14 -0
  117. package/src/lib/contrast.ts +12 -0
  118. package/src/lib/index.ts +2 -1
  119. package/src/lib/migrate-sections-transform.ts +5 -3
  120. package/src/media/compose-preview.ts +90 -0
  121. package/src/media/index.ts +1 -0
  122. package/src/media/preview-inputs.ts +77 -0
  123. package/src/schemas/media.ts +3 -0
  124. package/src/schemas/site-config.ts +27 -2
@@ -3,13 +3,16 @@ import { getSection } from "../../lib/registry";
3
3
 
4
4
  ensureSectionsRegistered();
5
5
  import { SectionLayout } from "./SectionLayout";
6
+ import { audiencePasses } from "../../lib/pages";
6
7
  import type { LoadedSection } from "../../lib/loader";
7
8
 
8
9
  interface Props {
9
10
  sections: LoadedSection[];
11
+ /** Audience-preview mode: dim sections this audience can't access. */
12
+ previewAudience?: string | null;
10
13
  }
11
14
 
12
- export default function ViewRenderer({ sections }: Props) {
15
+ export default function ViewRenderer({ sections, previewAudience = null }: Props) {
13
16
  return (
14
17
  <>
15
18
  {sections.map(({ section, meta }) => {
@@ -17,7 +20,13 @@ export default function ViewRenderer({ sections }: Props) {
17
20
  if (!def) return null;
18
21
  const Component = def.component;
19
22
  return (
20
- <SectionLayout key={section.id} type={section.type} status={meta.status} dimNonPublished>
23
+ <SectionLayout
24
+ key={section.id}
25
+ type={section.type}
26
+ status={meta.status}
27
+ dimNonPublished
28
+ dimmed={previewAudience !== null && !audiencePasses(meta.access, previewAudience)}
29
+ >
21
30
  <Component
22
31
  content={section}
23
32
  options={"options" in section ? (section.options as Record<string, unknown>) : undefined}
@@ -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>
@@ -5,14 +5,30 @@ interface TooltipProps {
5
5
  content: ReactNode;
6
6
  children: ReactNode;
7
7
  className?: string;
8
+ /** Where the bubble sits relative to the trigger:
9
+ * - "bottom" (default): below, centered.
10
+ * - "right": to the right (used by the container child rail, a narrow left gutter).
11
+ * - "bottom-clamp": below, centered on wide viewports but right-aligned under `xl`
12
+ * (where the top toolbar hits the viewport width) so a far-right button's bubble
13
+ * can't overflow off the right edge of the screen. */
14
+ side?: "bottom" | "right" | "bottom-clamp";
8
15
  }
9
16
 
10
- export function Tooltip({ content, children, className }: TooltipProps) {
17
+ const sideClasses: Record<NonNullable<TooltipProps["side"]>, string> = {
18
+ bottom: "left-1/2 top-full mt-1.5 -translate-x-1/2",
19
+ right: "left-full top-1/2 ml-1.5 -translate-y-1/2",
20
+ "bottom-clamp": "top-full mt-1.5 left-1/2 -translate-x-1/2 max-xl:left-auto max-xl:right-0 max-xl:translate-x-0",
21
+ };
22
+
23
+ export function Tooltip({ content, children, className, side = "bottom" }: TooltipProps) {
11
24
  return (
12
25
  <div className={cn("group/tooltip relative inline-flex", className)}>
13
26
  {children}
14
27
  <span
15
- className="pointer-events-none absolute left-1/2 top-full z-50 mt-1.5 -translate-x-1/2 opacity-0 transition-opacity delay-300 group-hover/tooltip:opacity-100"
28
+ className={cn(
29
+ "pointer-events-none absolute z-50 opacity-0 transition-opacity delay-300 group-hover/tooltip:opacity-100",
30
+ sideClasses[side],
31
+ )}
16
32
  role="tooltip"
17
33
  >
18
34
  <span className="block whitespace-nowrap rounded bg-tooltip px-2 py-1 text-center text-xs leading-relaxed text-tooltip-muted">
@@ -0,0 +1,73 @@
1
+ import { useRef, useState } from "react";
2
+ import { Check, UsersIcon } from "lucide-react";
3
+ import { IconButton } from "../shared/IconButton";
4
+ import { Popover } from "../shared/Popover";
5
+ import { PopoverItem } from "../shared/PopoverItem";
6
+ import { useEditorContext } from "./EditorContext";
7
+ import type { Audience } from "../../auth/types";
8
+
9
+ interface AudiencePreviewMenuProps {
10
+ audiences: Audience[];
11
+ }
12
+
13
+ export function AudiencePreviewMenu({ audiences }: AudiencePreviewMenuProps) {
14
+ const { previewAudience, setPreviewAudience } = useEditorContext();
15
+ const [open, setOpen] = useState(false);
16
+ const wrapperRef = useRef<HTMLDivElement>(null);
17
+ // A previewed audience can be deleted mid-session; the badge falls back grey.
18
+ const active = previewAudience !== null ? audiences.find((a) => a.name === previewAudience) : undefined;
19
+
20
+ function select(name: string | null) {
21
+ setPreviewAudience(name);
22
+ setOpen(false);
23
+ }
24
+
25
+ return (
26
+ <div ref={wrapperRef} className="relative">
27
+ <IconButton
28
+ icon={<UsersIcon size={16} />}
29
+ label="Preview as audience"
30
+ size="md"
31
+ onClick={() => setOpen((v) => !v)}
32
+ aria-pressed={previewAudience !== null}
33
+ aria-haspopup="true"
34
+ aria-expanded={open}
35
+ className="border border-base-200 bg-base-accent"
36
+ />
37
+ {previewAudience !== null && (
38
+ <span
39
+ aria-hidden="true"
40
+ data-testid="audience-preview-badge"
41
+ className="pointer-events-none absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-base"
42
+ style={{ backgroundColor: active?.color ?? "#9ca3af" }}
43
+ />
44
+ )}
45
+ <Popover isOpen={open} onClose={() => setOpen(false)} anchorRef={wrapperRef} className="w-48">
46
+ <ul role="radiogroup" aria-label="Preview as audience" className="w-full overflow-hidden py-1">
47
+ <li>
48
+ <PopoverItem role="radio" aria-checked={previewAudience === null} onClick={() => select(null)}>
49
+ <span className="flex-1 font-medium text-base-contrast">All audiences</span>
50
+ {previewAudience === null && <Check size={14} strokeWidth={3} className="text-primary" />}
51
+ </PopoverItem>
52
+ </li>
53
+ {audiences.map((a) => {
54
+ const checked = previewAudience === a.name;
55
+ return (
56
+ <li key={a.name}>
57
+ <PopoverItem role="radio" aria-checked={checked} onClick={() => select(a.name)}>
58
+ <span
59
+ aria-hidden="true"
60
+ className="h-3 w-3 shrink-0 rounded-full border border-base-200"
61
+ style={{ backgroundColor: a.color ?? "#9ca3af" }}
62
+ />
63
+ <span className="flex-1 font-medium text-base-contrast">{a.displayName}</span>
64
+ {checked && <Check size={14} strokeWidth={3} className="text-primary" />}
65
+ </PopoverItem>
66
+ </li>
67
+ );
68
+ })}
69
+ </ul>
70
+ </Popover>
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,54 @@
1
+ import { useRef, useState } from "react";
2
+ import { SlidersHorizontalIcon } from "lucide-react";
3
+ import { IconButton } from "../shared/IconButton";
4
+ import { Popover } from "../shared/Popover";
5
+ import { StatusRadioList } from "../editor/StatusIndicator";
6
+ import { AudienceChecklist } from "../editor/AudienceIndicator";
7
+ import type { AuthoringDefaults } from "../../lib/authoring-defaults";
8
+ import type { Audience } from "../../auth/types";
9
+
10
+ interface AuthoringDefaultsMenuProps {
11
+ defaults: AuthoringDefaults;
12
+ audiences: Audience[];
13
+ onChange: (next: AuthoringDefaults) => void;
14
+ }
15
+
16
+ export function AuthoringDefaultsMenu({ defaults, audiences, onChange }: AuthoringDefaultsMenuProps) {
17
+ const [open, setOpen] = useState(false);
18
+ const wrapperRef = useRef<HTMLDivElement>(null);
19
+
20
+ function toggleAudience(name: string) {
21
+ const access = defaults.access.includes(name)
22
+ ? defaults.access.filter((n) => n !== name)
23
+ : [...defaults.access, name];
24
+ onChange({ ...defaults, access });
25
+ }
26
+
27
+ return (
28
+ <div ref={wrapperRef} className="relative">
29
+ <IconButton
30
+ icon={<SlidersHorizontalIcon size={16} />}
31
+ label="Authoring defaults"
32
+ size="md"
33
+ onClick={() => setOpen((v) => !v)}
34
+ aria-haspopup="true"
35
+ aria-expanded={open}
36
+ className="border border-base-200 bg-base-accent"
37
+ />
38
+ <Popover isOpen={open} onClose={() => setOpen(false)} anchorRef={wrapperRef} className="w-56">
39
+ <div className="px-3 pt-2 pb-1 text-xs font-semibold text-base-contrast-light">
40
+ New sections start as
41
+ </div>
42
+ <StatusRadioList
43
+ status={defaults.status}
44
+ onChange={(status) => onChange({ ...defaults, status })}
45
+ ariaLabel="Default status for new sections"
46
+ />
47
+ <div className="border-t border-base-200 px-3 pt-2 pb-1 text-xs font-semibold text-base-contrast-light">
48
+ Default audiences
49
+ </div>
50
+ <AudienceChecklist access={defaults.access} audiences={audiences} onToggle={toggleAudience} />
51
+ </Popover>
52
+ </div>
53
+ );
54
+ }
@@ -17,11 +17,14 @@ interface EditorContextValue {
17
17
  viewBranch: "saved" | "live";
18
18
  historyState: HistoryState;
19
19
  historyPageId: string | null;
20
+ /** Audience-preview mode: dim sections this audience can't access. null = off. */
21
+ previewAudience: string | null;
20
22
  toggleEditMode: () => void;
21
23
  toggleShowAllChrome: () => void;
22
24
  setViewBranch: (branch: "saved" | "live") => void;
23
25
  setHistoryState: (state: HistoryState) => void;
24
26
  setHistoryPageId: (pageId: string | null) => void;
27
+ setPreviewAudience: (audience: string | null) => void;
25
28
  }
26
29
 
27
30
  const EditorContext = createContext<EditorContextValue | null>(null);
@@ -36,6 +39,7 @@ export function EditorProvider({ children }: EditorProviderProps) {
36
39
  const [viewBranch, setViewBranchState] = useState<"saved" | "live">("saved");
37
40
  const [historyState, setHistoryStateRaw] = useState<HistoryState>(null);
38
41
  const [historyPageId, setHistoryPageId] = useState<string | null>(null);
42
+ const [previewAudience, setPreviewAudience] = useState<string | null>(null);
39
43
  const setHistoryState = useCallback((state: HistoryState) => {
40
44
  // null = "auto": resolved to the current page (or home) at render time
41
45
  setHistoryPageId(null);
@@ -54,7 +58,7 @@ export function EditorProvider({ children }: EditorProviderProps) {
54
58
  const setViewBranch = useCallback((b: "saved" | "live") => setViewBranchState(b), []);
55
59
 
56
60
  return (
57
- <EditorContext.Provider value={{ isEditMode, showAllChrome, viewBranch, historyState, historyPageId, toggleEditMode, toggleShowAllChrome, setViewBranch, setHistoryState, setHistoryPageId }}>
61
+ <EditorContext.Provider value={{ isEditMode, showAllChrome, viewBranch, historyState, historyPageId, previewAudience, toggleEditMode, toggleShowAllChrome, setViewBranch, setHistoryState, setHistoryPageId, setPreviewAudience }}>
58
62
  {children}
59
63
  </EditorContext.Provider>
60
64
  );
@@ -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";
@@ -85,7 +90,7 @@ import { generateSiteNav } from "../../lib/nav";
85
90
  import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent } from "../../lib/events";
86
91
  import {
87
92
  homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
88
- reorderSectionInPage,
93
+ reorderSectionInPage, audiencePasses,
89
94
  setHomePage, setPageArchived, setPageFields, setPageAudience, setPageDocument, deletePage, addPage, reorderPages,
90
95
  moveSection as moveSectionReducer,
91
96
  } from "../../lib/pages";
@@ -96,6 +101,7 @@ import { UploadRejectionAlert, formatRejections } from "../shared/UploadRejectio
96
101
  import { Button } from "../shared/Button";
97
102
  import { SplitButton } from "../shared/SplitButton";
98
103
  import { IconButton } from "../shared/IconButton";
104
+ import { Tooltip } from "../shared/Tooltip";
99
105
  import { SegmentedControl } from "../shared/SegmentedControl";
100
106
  import { computeDisplaySections } from "./view-branch";
101
107
  import { SettingsIcon } from "../shared/icons";
@@ -105,6 +111,9 @@ import { HistoryToolbar } from "./HistoryToolbar";
105
111
  import { RestoreModal } from "./RestoreModal";
106
112
  import { EditorLoading } from "./EditorLoading";
107
113
  import ViewRenderer from "../sections/ViewRenderer";
114
+ import { AuthoringDefaultsMenu } from "./AuthoringDefaultsMenu";
115
+ import { AudiencePreviewMenu } from "./AudiencePreviewMenu";
116
+ import { loadAuthoringDefaults, saveAuthoringDefaults, filterAccessToKnown, type AuthoringDefaults } from "../../lib/authoring-defaults";
108
117
 
109
118
  export { useMediaLibrary } from "./MediaLibraryContext";
110
119
 
@@ -168,6 +177,11 @@ export default function EditorShell({
168
177
  const [sections, setSections] = useState<LoadedSection[]>([]);
169
178
  const [siteIndex, setSiteIndex] = useState<SiteIndex>({ siteId, pages: [], sections: {} });
170
179
  const [audiences, setAudiences] = useState<Audience[]>(initialAudiences);
180
+ const [authoringDefaults, setAuthoringDefaults] = useState<AuthoringDefaults>(loadAuthoringDefaults);
181
+ const onAuthoringDefaultsChange = useCallback((next: AuthoringDefaults) => {
182
+ setAuthoringDefaults(next);
183
+ saveAuthoringDefaults(next);
184
+ }, []);
171
185
  const [localChangesExist, setLocalChangesExist] = useState(false);
172
186
  const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
173
187
  const [showSiteSettings, setShowSiteSettings] = useState(false);
@@ -240,6 +254,15 @@ export default function EditorShell({
240
254
  useEffect(() => { sectionsRef.current = sections; }, [sections]);
241
255
  useEffect(() => { siteIndexRef.current = siteIndex; }, [siteIndex]);
242
256
  useEffect(() => { void ensureSanitizer(); }, []);
257
+ // Mirrors siteConfig so the async preview-image upload callback (below)
258
+ // writes onto the latest config instead of a stale closure.
259
+ const siteConfigRef = useRef<SiteConfig | null>(siteConfig);
260
+ useEffect(() => { siteConfigRef.current = siteConfig; }, [siteConfig]);
261
+ // Guards the debounced preview-image composition effect (below): skips
262
+ // recomposing on the very first run when a preview already exists, and
263
+ // dedupes concurrent runs against the same composition signature.
264
+ const previewInitializedRef = useRef(false);
265
+ const lastPreviewSigRef = useRef<string | null>(null);
243
266
 
244
267
  // --- Collaboration refs ---
245
268
  // Freshest dirty-section set for the inbound remote-section handler (never
@@ -1143,7 +1166,14 @@ export default function EditorShell({
1143
1166
  const content = resolveInsertedContent(selection, getAllSections(), newId);
1144
1167
  if (!content) return;
1145
1168
  const id = newId();
1146
- const meta = { type: content.type, status: "draft" as const, access: [] as string[] };
1169
+ // Access is constrained to the audiences this page can offer (all site
1170
+ // audiences on an unrestricted page), so stale or out-of-scope default
1171
+ // names never land in a section's access list.
1172
+ const meta = {
1173
+ type: content.type,
1174
+ status: authoringDefaults.status,
1175
+ access: filterAccessToKnown(authoringDefaults.access, pageAudiences),
1176
+ };
1147
1177
  const newSection: LoadedSection = { section: { id, ...content } as LoadedSection["section"], meta };
1148
1178
 
1149
1179
  setSections((prev) => [...prev, newSection]);
@@ -1159,7 +1189,7 @@ export default function EditorShell({
1159
1189
  // save-time sync by design).
1160
1190
  void collabRef.current?.requestEdit(id);
1161
1191
  },
1162
- [persistence, resolvedActivePage],
1192
+ [persistence, resolvedActivePage, authoringDefaults, pageAudiences],
1163
1193
  );
1164
1194
 
1165
1195
  // Runs after the confirmation modal is accepted. Removes the section from the
@@ -1332,6 +1362,60 @@ export default function EditorShell({
1332
1362
  setLocalChangesExist(true);
1333
1363
  }, [applySiteConfigPreview, persistence]);
1334
1364
 
1365
+ // Recompose the social-preview raster (siteConfig.previewImage) when brand
1366
+ // inputs change. Debounced so text edits don't compose per keystroke. The
1367
+ // new previewImage rides the next normal save like any other config change.
1368
+ useEffect(() => {
1369
+ if (!siteConfig) return;
1370
+ const resolveLogoUrl = (imageId: string): string | null => {
1371
+ const pending = mediaPipeline.pendingLocalUrls[imageId];
1372
+ if (pending) return pending;
1373
+ const resolved = resolveMedia({ imageId }, mediaManifest, [1200], getMediaProvider());
1374
+ return resolved?.src ?? null;
1375
+ };
1376
+ const { inputs, signature } = derivePreviewInputs(siteConfig, resolveLogoUrl);
1377
+
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.
1381
+ if (!previewInitializedRef.current) {
1382
+ previewInitializedRef.current = true;
1383
+ if (siteConfig.previewImage) {
1384
+ lastPreviewSigRef.current = signature;
1385
+ return;
1386
+ }
1387
+ }
1388
+ if (signature === lastPreviewSigRef.current) return;
1389
+
1390
+ const timer = setTimeout(() => {
1391
+ lastPreviewSigRef.current = signature; // guard against duplicate concurrent runs
1392
+ (async () => {
1393
+ try {
1394
+ const blob = await composePreviewImage(inputs);
1395
+ const file = new File([blob], "site-preview.webp", { type: "image/webp" });
1396
+ const { width, height } = servedPreviewSize(siteConfigRef.current!.media.sizes);
1397
+ mediaPipeline.contextValue.uploadFileWithCallback(file, (imageId) => {
1398
+ handleSiteConfigChange({ ...siteConfigRef.current!, previewImage: { imageId, width, height } });
1399
+ }, { hidden: true });
1400
+ } catch (err) {
1401
+ console.error("[EditorShell] preview-image composition failed:", err);
1402
+ // Composition/upload failed — un-mark the signature so the next effect
1403
+ // run (next brand-input change, or this same one on remount) retries
1404
+ // instead of being permanently skipped as "already done".
1405
+ lastPreviewSigRef.current = null;
1406
+ }
1407
+ })();
1408
+ }, 900);
1409
+ return () => clearTimeout(timer);
1410
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1411
+ }, [
1412
+ siteConfig?.logo,
1413
+ siteConfig?.primaryColor,
1414
+ siteConfig?.siteName,
1415
+ siteConfig?.headingFont,
1416
+ siteConfig?.darkMode,
1417
+ ]);
1418
+
1335
1419
  const handleHistoryRestore = useCallback(async (historyContent: {
1336
1420
  sections: LoadedSection[];
1337
1421
  index: SiteIndex;
@@ -1444,6 +1528,9 @@ export default function EditorShell({
1444
1528
  collabPeers={collab.peers}
1445
1529
  collabSelfId={collabBootstrap?.userId ?? null}
1446
1530
  collabStatus={collab.status}
1531
+ audiences={audiences}
1532
+ authoringDefaults={authoringDefaults}
1533
+ onAuthoringDefaultsChange={onAuthoringDefaultsChange}
1447
1534
  />
1448
1535
 
1449
1536
  {/* Bug reports persist via Supabase. On the password-only (zero-Supabase)
@@ -1512,6 +1599,8 @@ export default function EditorShell({
1512
1599
  {chatbotPlatformEnabled && siteConfig && (
1513
1600
  <ChatWidget
1514
1601
  siteName={siteConfig.siteName}
1602
+ botName={resolveChatbotName(siteConfig.chatbot, null)}
1603
+ thinkingPhrases={siteConfig.chatbot.thinkingPhrases}
1515
1604
  tasks={siteConfig.chatbot.tasks.map((t) => ({ id: t.id, label: t.label }))}
1516
1605
  isEditor
1517
1606
  draftChatbot={siteConfig.chatbot}
@@ -1628,7 +1717,7 @@ export default function EditorShell({
1628
1717
  items={[
1629
1718
  ...Object.values(mediaManifest.images),
1630
1719
  ...mediaPipeline.pendingMediaItems,
1631
- ].filter((item) => !mediaPipeline.pendingDeletions.includes(item.id))}
1720
+ ].filter((item) => !mediaPipeline.pendingDeletions.includes(item.id) && !item.hidden)}
1632
1721
  onSelect={(id) => {
1633
1722
  if (mediaSelectCallback) {
1634
1723
  mediaSelectCallback(id);
@@ -1819,7 +1908,7 @@ function HistoryOrEditorContent({
1819
1908
  activeSections: LoadedSection[];
1820
1909
  activePageId: string | null;
1821
1910
  }) {
1822
- const { historyState, historyPageId, setHistoryPageId } = useEditorContext();
1911
+ const { historyState, historyPageId, setHistoryPageId, previewAudience } = useEditorContext();
1823
1912
  const wasInHistory = useRef(false);
1824
1913
 
1825
1914
  const historyPage = historyState ? resolveHistoryPage(historyState, historyPageId, activePageId) : null;
@@ -1852,7 +1941,7 @@ function HistoryOrEditorContent({
1852
1941
  const pageSections = historyState.sections
1853
1942
  .filter((s) => position.has(s.section.id))
1854
1943
  .sort((a, b) => (position.get(a.section.id) ?? 0) - (position.get(b.section.id) ?? 0));
1855
- return <ViewRenderer sections={pageSections} />;
1944
+ return <ViewRenderer sections={pageSections} previewAudience={previewAudience} />;
1856
1945
  }
1857
1946
  return <>{children}</>;
1858
1947
  }
@@ -1931,7 +2020,7 @@ function EditorContent({
1931
2020
  getEditIntent: (id: string) => () => Promise<boolean>;
1932
2021
  onTakeover: (id: string) => void;
1933
2022
  }) {
1934
- const { isEditMode, viewBranch } = useEditorContext();
2023
+ const { isEditMode, viewBranch, previewAudience } = useEditorContext();
1935
2024
  const { openModal, closeModal } = useEditorModal();
1936
2025
  const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
1937
2026
  const dismissPendingInsert = useCallback(() => setPendingInsertIndex(null), []);
@@ -2022,7 +2111,12 @@ function EditorContent({
2022
2111
  )}
2023
2112
 
2024
2113
  <ErrorBoundary label={`${definition.label} (${section.type})`}>
2025
- <SectionLayout type={section.type} status={meta.status} dimNonPublished={!isEditMode}>
2114
+ <SectionLayout
2115
+ type={section.type}
2116
+ status={meta.status}
2117
+ dimNonPublished={!isEditMode}
2118
+ dimmed={previewAudience !== null && !audiencePasses(meta.access, previewAudience)}
2119
+ >
2026
2120
  <SectionWrapper
2027
2121
  sectionId={section.id}
2028
2122
  sectionType={section.type}
@@ -2270,6 +2364,9 @@ function EditorToolbar({
2270
2364
  collabPeers,
2271
2365
  collabSelfId,
2272
2366
  collabStatus,
2367
+ audiences,
2368
+ authoringDefaults,
2369
+ onAuthoringDefaultsChange,
2273
2370
  }: {
2274
2371
  buttonState: "synced" | "publish" | "saveAndPublish";
2275
2372
  localChangesExist: boolean;
@@ -2292,6 +2389,9 @@ function EditorToolbar({
2292
2389
  collabPeers: CollabPeer[];
2293
2390
  collabSelfId: string | null;
2294
2391
  collabStatus: CollabStatus;
2392
+ audiences: Audience[];
2393
+ authoringDefaults: AuthoringDefaults;
2394
+ onAuthoringDefaultsChange: (next: AuthoringDefaults) => void;
2295
2395
  }) {
2296
2396
  const { isEditMode, viewBranch, setViewBranch, toggleEditMode, historyState, setHistoryState, historyPageId, setHistoryPageId } = useEditorContext();
2297
2397
 
@@ -2362,11 +2462,28 @@ function EditorToolbar({
2362
2462
  </div>
2363
2463
  <div className="flex items-center justify-end gap-2">
2364
2464
  <ProcessingIndicator items={processingItems} />
2365
- <IconButton icon={<ImageIcon size={16} />} label="Media library" size="md" onClick={onMediaClick} className="border border-base-200 bg-base-accent" />
2366
- <IconButton icon={<ListOrderedIcon size={16} />} label="Reorder sections" size="md" onClick={onOrderingClick} className="border border-base-200 bg-base-accent" />
2367
- <IconButton icon={<FilesIcon size={16} />} label="Pages" size="md" onClick={onPagesClick} className="border border-base-200 bg-base-accent" />
2368
- <ShowAllChromeToggle />
2369
- <IconButton icon={<SettingsIcon size={16} />} label="Site settings" size="md" onClick={onSettingsClick} className="border border-base-200 bg-base-accent" />
2465
+ <Tooltip side="bottom" content="Authoring defaults">
2466
+ <AuthoringDefaultsMenu defaults={authoringDefaults} audiences={audiences} onChange={onAuthoringDefaultsChange} />
2467
+ </Tooltip>
2468
+ <Tooltip side="bottom" content="Preview as audience">
2469
+ <AudiencePreviewMenu audiences={audiences} />
2470
+ </Tooltip>
2471
+ <div aria-hidden="true" className="mx-1 h-6 w-px self-center bg-base-200" />
2472
+ <Tooltip side="bottom" content="Media library">
2473
+ <IconButton icon={<ImageIcon size={16} />} label="Media library" size="md" onClick={onMediaClick} className="border border-base-200 bg-base-accent" />
2474
+ </Tooltip>
2475
+ <Tooltip side="bottom" content="Reorder sections">
2476
+ <IconButton icon={<ListOrderedIcon size={16} />} label="Reorder sections" size="md" onClick={onOrderingClick} className="border border-base-200 bg-base-accent" />
2477
+ </Tooltip>
2478
+ <Tooltip side="bottom" content="Pages">
2479
+ <IconButton icon={<FilesIcon size={16} />} label="Pages" size="md" onClick={onPagesClick} className="border border-base-200 bg-base-accent" />
2480
+ </Tooltip>
2481
+ <Tooltip side="bottom" content="Toggle controls">
2482
+ <ShowAllChromeToggle />
2483
+ </Tooltip>
2484
+ <Tooltip side="bottom-clamp" content="Site settings">
2485
+ <IconButton icon={<SettingsIcon size={16} />} label="Site settings" size="md" onClick={onSettingsClick} className="border border-base-200 bg-base-accent" />
2486
+ </Tooltip>
2370
2487
  </div>
2371
2488
  </div>
2372
2489
  </div>
@@ -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[];