@drawnagency/primitives 0.1.89 → 0.1.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/{chunk-75P6HWPN.js → chunk-5BM2Y3NJ.js} +5 -3
  2. package/dist/{chunk-VK5KNH73.js → chunk-AGWE2LJG.js} +2 -2
  3. package/dist/{chunk-2QXJ2KGB.js → chunk-KBQNJZUX.js} +1 -1
  4. package/dist/{chunk-WCBYXNAG.js → chunk-KMQZYORS.js} +1 -0
  5. package/dist/{chunk-Y24T75HT.js → chunk-MK7YB2A4.js} +1 -1
  6. package/dist/{chunk-QFV5TSPO.js → chunk-SIYQ37QY.js} +12 -4
  7. package/dist/{chunk-J75XGXYD.js → chunk-XKRQMTE4.js} +9 -0
  8. package/dist/{chunk-L2MAJKKC.js → chunk-XUIJHYKI.js} +2 -2
  9. package/dist/{chunk-MVX4ULYE.js → chunk-ZLG7S54V.js} +241 -82
  10. package/dist/components/chat/ChatPanel.d.ts +14 -6
  11. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  12. package/dist/components/chat/ChatWidget.d.ts.map +1 -1
  13. package/dist/components/editor/AudienceIndicator.d.ts +6 -0
  14. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
  15. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -1
  16. package/dist/components/editor/ColSpanControl.d.ts.map +1 -1
  17. package/dist/components/editor/DragHandle.d.ts +3 -1
  18. package/dist/components/editor/DragHandle.d.ts.map +1 -1
  19. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  20. package/dist/components/editor/StatusIndicator.d.ts +7 -0
  21. package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
  22. package/dist/components/editor/index.js +98 -76
  23. package/dist/components/primitives/index.js +3 -3
  24. package/dist/components/sections/Container/Container.d.ts +3 -2
  25. package/dist/components/sections/Container/Container.d.ts.map +1 -1
  26. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +4 -2
  27. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -1
  28. package/dist/components/sections/Container/index.d.ts +8 -2
  29. package/dist/components/sections/Container/index.d.ts.map +1 -1
  30. package/dist/components/sections/SectionLayout.d.ts +3 -1
  31. package/dist/components/sections/SectionLayout.d.ts.map +1 -1
  32. package/dist/components/sections/ViewRenderer.d.ts +3 -1
  33. package/dist/components/sections/ViewRenderer.d.ts.map +1 -1
  34. package/dist/components/sections/all-sections.d.ts +8 -2
  35. package/dist/components/sections/all-sections.d.ts.map +1 -1
  36. package/dist/components/sections/brand-guide.js +3 -3
  37. package/dist/components/sections/core-sections.d.ts +8 -2
  38. package/dist/components/sections/core-sections.d.ts.map +1 -1
  39. package/dist/components/sections/register-schemas.js +6 -6
  40. package/dist/components/sections/register.js +6 -6
  41. package/dist/components/shared/Fab.d.ts +37 -0
  42. package/dist/components/shared/Fab.d.ts.map +1 -0
  43. package/dist/components/shared/Tooltip.d.ts +8 -1
  44. package/dist/components/shared/Tooltip.d.ts.map +1 -1
  45. package/dist/components/shell/AudiencePreviewMenu.d.ts +7 -0
  46. package/dist/components/shell/AudiencePreviewMenu.d.ts.map +1 -0
  47. package/dist/components/shell/AuthoringDefaultsMenu.d.ts +10 -0
  48. package/dist/components/shell/AuthoringDefaultsMenu.d.ts.map +1 -0
  49. package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
  50. package/dist/components/shell/EditorContext.d.ts +3 -0
  51. package/dist/components/shell/EditorContext.d.ts.map +1 -1
  52. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  53. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  54. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  55. package/dist/index.js +6 -4
  56. package/dist/lib/authoring-defaults.d.ts +17 -0
  57. package/dist/lib/authoring-defaults.d.ts.map +1 -0
  58. package/dist/lib/container-grid.d.ts +6 -0
  59. package/dist/lib/container-grid.d.ts.map +1 -1
  60. package/dist/lib/index.js +3 -3
  61. package/dist/lib/migrate-sections-transform.js +1 -1
  62. package/dist/media/index.js +1 -1
  63. package/dist/schemas/index.js +4 -2
  64. package/dist/schemas/site-config.d.ts +6 -0
  65. package/dist/schemas/site-config.d.ts.map +1 -1
  66. package/package.json +1 -1
  67. package/src/components/chat/ChatPanel.tsx +51 -19
  68. package/src/components/chat/ChatWidget.tsx +113 -51
  69. package/src/components/editor/AudienceIndicator.tsx +50 -37
  70. package/src/components/editor/ChildBlockWrapper.tsx +8 -7
  71. package/src/components/editor/ColSpanControl.tsx +14 -11
  72. package/src/components/editor/DragHandle.tsx +5 -2
  73. package/src/components/editor/PagesModal.tsx +10 -10
  74. package/src/components/editor/SectionWrapper.tsx +32 -21
  75. package/src/components/editor/StatusIndicator.tsx +37 -24
  76. package/src/components/sections/Container/Container.tsx +185 -43
  77. package/src/components/sections/Container/ContainerSettingsForm.tsx +64 -24
  78. package/src/components/sections/Container/index.tsx +45 -20
  79. package/src/components/sections/SectionLayout.tsx +13 -3
  80. package/src/components/sections/ViewRenderer.tsx +11 -2
  81. package/src/components/shared/Fab.tsx +57 -0
  82. package/src/components/shared/Tooltip.tsx +18 -2
  83. package/src/components/shell/AudiencePreviewMenu.tsx +73 -0
  84. package/src/components/shell/AuthoringDefaultsMenu.tsx +54 -0
  85. package/src/components/shell/BugReportFAB.tsx +3 -7
  86. package/src/components/shell/EditorContext.tsx +5 -1
  87. package/src/components/shell/EditorShell.tsx +80 -25
  88. package/src/components/shell/SiteSettingsChatbot.tsx +2 -1
  89. package/src/hooks/useBuildStatus.ts +35 -4
  90. package/src/lib/authoring-defaults.ts +42 -0
  91. package/src/lib/container-grid.ts +14 -0
  92. package/src/lib/migrate-sections-transform.ts +5 -3
  93. package/src/schemas/site-config.ts +1 -1
@@ -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
+ }
@@ -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
+ }
@@ -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 */}
@@ -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
  );
@@ -90,7 +90,7 @@ import { generateSiteNav } from "../../lib/nav";
90
90
  import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent } from "../../lib/events";
91
91
  import {
92
92
  homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
93
- reorderSectionInPage,
93
+ reorderSectionInPage, audiencePasses,
94
94
  setHomePage, setPageArchived, setPageFields, setPageAudience, setPageDocument, deletePage, addPage, reorderPages,
95
95
  moveSection as moveSectionReducer,
96
96
  } from "../../lib/pages";
@@ -101,6 +101,8 @@ 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";
105
+ import { Tooltip } from "../shared/Tooltip";
104
106
  import { SegmentedControl } from "../shared/SegmentedControl";
105
107
  import { computeDisplaySections } from "./view-branch";
106
108
  import { SettingsIcon } from "../shared/icons";
@@ -110,6 +112,9 @@ import { HistoryToolbar } from "./HistoryToolbar";
110
112
  import { RestoreModal } from "./RestoreModal";
111
113
  import { EditorLoading } from "./EditorLoading";
112
114
  import ViewRenderer from "../sections/ViewRenderer";
115
+ import { AuthoringDefaultsMenu } from "./AuthoringDefaultsMenu";
116
+ import { AudiencePreviewMenu } from "./AudiencePreviewMenu";
117
+ import { loadAuthoringDefaults, saveAuthoringDefaults, filterAccessToKnown, type AuthoringDefaults } from "../../lib/authoring-defaults";
113
118
 
114
119
  export { useMediaLibrary } from "./MediaLibraryContext";
115
120
 
@@ -173,6 +178,11 @@ export default function EditorShell({
173
178
  const [sections, setSections] = useState<LoadedSection[]>([]);
174
179
  const [siteIndex, setSiteIndex] = useState<SiteIndex>({ siteId, pages: [], sections: {} });
175
180
  const [audiences, setAudiences] = useState<Audience[]>(initialAudiences);
181
+ const [authoringDefaults, setAuthoringDefaults] = useState<AuthoringDefaults>(loadAuthoringDefaults);
182
+ const onAuthoringDefaultsChange = useCallback((next: AuthoringDefaults) => {
183
+ setAuthoringDefaults(next);
184
+ saveAuthoringDefaults(next);
185
+ }, []);
176
186
  const [localChangesExist, setLocalChangesExist] = useState(false);
177
187
  const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
178
188
  const [showSiteSettings, setShowSiteSettings] = useState(false);
@@ -1157,7 +1167,14 @@ export default function EditorShell({
1157
1167
  const content = resolveInsertedContent(selection, getAllSections(), newId);
1158
1168
  if (!content) return;
1159
1169
  const id = newId();
1160
- const meta = { type: content.type, status: "draft" as const, access: [] as string[] };
1170
+ // Access is constrained to the audiences this page can offer (all site
1171
+ // audiences on an unrestricted page), so stale or out-of-scope default
1172
+ // names never land in a section's access list.
1173
+ const meta = {
1174
+ type: content.type,
1175
+ status: authoringDefaults.status,
1176
+ access: filterAccessToKnown(authoringDefaults.access, pageAudiences),
1177
+ };
1161
1178
  const newSection: LoadedSection = { section: { id, ...content } as LoadedSection["section"], meta };
1162
1179
 
1163
1180
  setSections((prev) => [...prev, newSection]);
@@ -1173,7 +1190,7 @@ export default function EditorShell({
1173
1190
  // save-time sync by design).
1174
1191
  void collabRef.current?.requestEdit(id);
1175
1192
  },
1176
- [persistence, resolvedActivePage],
1193
+ [persistence, resolvedActivePage, authoringDefaults, pageAudiences],
1177
1194
  );
1178
1195
 
1179
1196
  // Runs after the confirmation modal is accepted. Removes the section from the
@@ -1512,6 +1529,9 @@ export default function EditorShell({
1512
1529
  collabPeers={collab.peers}
1513
1530
  collabSelfId={collabBootstrap?.userId ?? null}
1514
1531
  collabStatus={collab.status}
1532
+ audiences={audiences}
1533
+ authoringDefaults={authoringDefaults}
1534
+ onAuthoringDefaultsChange={onAuthoringDefaultsChange}
1515
1535
  />
1516
1536
 
1517
1537
  {/* Bug reports persist via Supabase. On the password-only (zero-Supabase)
@@ -1529,18 +1549,13 @@ export default function EditorShell({
1529
1549
  scope renders the provider and can't read the context directly),
1530
1550
  hide the FAB so it can't open the draft editor over a history view. */}
1531
1551
  {resolvedActivePage?.document?.enabled && !historyActive && (
1532
- <button
1533
- type="button"
1534
- onClick={() => setShowDocumentEditor(true)}
1535
- aria-label="Edit as document"
1536
- 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"
1537
- >
1552
+ <Fab label="Edit as document" column="editor" className="bottom-40" onClick={() => setShowDocumentEditor(true)}>
1538
1553
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
1539
1554
  <rect x="3" y="5" width="18" height="14" rx="1" />
1540
1555
  <path d="M7 10h10" />
1541
1556
  <path d="M7 14h7" />
1542
1557
  </svg>
1543
- </button>
1558
+ </Fab>
1544
1559
  )}
1545
1560
  {showDocumentEditor && resolvedActivePage?.document?.enabled && (
1546
1561
  <Suspense fallback={<DocumentOverlayFallback />}>
@@ -1607,6 +1622,12 @@ export default function EditorShell({
1607
1622
  </div>
1608
1623
  )}
1609
1624
 
1625
+ {/* bp-band-shift: rides the chat dock's FLIP glide (base.css). On
1626
+ /edit it lives HERE, on the content column only — Layout's
1627
+ <main> can't carry it because the toolbar/FABs/chat panel above
1628
+ are position:fixed inside main, and a transformed ancestor
1629
+ would re-anchor them. */}
1630
+ <div className="bp-band-shift">
1610
1631
  <HistoryOrEditorContent
1611
1632
  index={siteIndex}
1612
1633
  activeSections={activeSections}
@@ -1635,6 +1656,7 @@ export default function EditorShell({
1635
1656
  onTakeover={confirmTakeover}
1636
1657
  />
1637
1658
  </HistoryOrEditorContent>
1659
+ </div>
1638
1660
  <GlobalModal />
1639
1661
  <SiteSettingsModal
1640
1662
  isOpen={showSiteSettings}
@@ -1889,7 +1911,7 @@ function HistoryOrEditorContent({
1889
1911
  activeSections: LoadedSection[];
1890
1912
  activePageId: string | null;
1891
1913
  }) {
1892
- const { historyState, historyPageId, setHistoryPageId } = useEditorContext();
1914
+ const { historyState, historyPageId, setHistoryPageId, previewAudience } = useEditorContext();
1893
1915
  const wasInHistory = useRef(false);
1894
1916
 
1895
1917
  const historyPage = historyState ? resolveHistoryPage(historyState, historyPageId, activePageId) : null;
@@ -1922,7 +1944,7 @@ function HistoryOrEditorContent({
1922
1944
  const pageSections = historyState.sections
1923
1945
  .filter((s) => position.has(s.section.id))
1924
1946
  .sort((a, b) => (position.get(a.section.id) ?? 0) - (position.get(b.section.id) ?? 0));
1925
- return <ViewRenderer sections={pageSections} />;
1947
+ return <ViewRenderer sections={pageSections} previewAudience={previewAudience} />;
1926
1948
  }
1927
1949
  return <>{children}</>;
1928
1950
  }
@@ -2001,7 +2023,7 @@ function EditorContent({
2001
2023
  getEditIntent: (id: string) => () => Promise<boolean>;
2002
2024
  onTakeover: (id: string) => void;
2003
2025
  }) {
2004
- const { isEditMode, viewBranch } = useEditorContext();
2026
+ const { isEditMode, viewBranch, previewAudience } = useEditorContext();
2005
2027
  const { openModal, closeModal } = useEditorModal();
2006
2028
  const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
2007
2029
  const dismissPendingInsert = useCallback(() => setPendingInsertIndex(null), []);
@@ -2092,7 +2114,12 @@ function EditorContent({
2092
2114
  )}
2093
2115
 
2094
2116
  <ErrorBoundary label={`${definition.label} (${section.type})`}>
2095
- <SectionLayout type={section.type} status={meta.status} dimNonPublished={!isEditMode}>
2117
+ <SectionLayout
2118
+ type={section.type}
2119
+ status={meta.status}
2120
+ dimNonPublished={!isEditMode}
2121
+ dimmed={previewAudience !== null && !audiencePasses(meta.access, previewAudience)}
2122
+ >
2096
2123
  <SectionWrapper
2097
2124
  sectionId={section.id}
2098
2125
  sectionType={section.type}
@@ -2340,6 +2367,9 @@ function EditorToolbar({
2340
2367
  collabPeers,
2341
2368
  collabSelfId,
2342
2369
  collabStatus,
2370
+ audiences,
2371
+ authoringDefaults,
2372
+ onAuthoringDefaultsChange,
2343
2373
  }: {
2344
2374
  buttonState: "synced" | "publish" | "saveAndPublish";
2345
2375
  localChangesExist: boolean;
@@ -2362,6 +2392,9 @@ function EditorToolbar({
2362
2392
  collabPeers: CollabPeer[];
2363
2393
  collabSelfId: string | null;
2364
2394
  collabStatus: CollabStatus;
2395
+ audiences: Audience[];
2396
+ authoringDefaults: AuthoringDefaults;
2397
+ onAuthoringDefaultsChange: (next: AuthoringDefaults) => void;
2365
2398
  }) {
2366
2399
  const { isEditMode, viewBranch, setViewBranch, toggleEditMode, historyState, setHistoryState, historyPageId, setHistoryPageId } = useEditorContext();
2367
2400
 
@@ -2382,8 +2415,12 @@ function EditorToolbar({
2382
2415
  return (
2383
2416
  <>
2384
2417
  {isEditMode && (
2418
+ // Full-width and static on purpose: the docked chat sidebar slides in
2419
+ // BELOW this bar (ChatPanel docks at top-16 on /edit), so the toolbar
2420
+ // never moves during the chat open/close transition. h-16 matches the
2421
+ // panel's top offset (and the body's pt-16) exactly.
2385
2422
  <div className="fixed top-0 right-0 left-0 z-50 border-b border-base-200 bg-base">
2386
- <div className="mx-auto max-w-screen-xl grid grid-cols-3 items-center px-4 py-2">
2423
+ <div className="mx-auto max-w-screen-xl grid h-16 grid-cols-3 items-center px-4">
2387
2424
  <div className="flex items-center gap-2">
2388
2425
  {buttonState === "saveAndPublish" && (
2389
2426
  <SplitButton
@@ -2432,18 +2469,35 @@ function EditorToolbar({
2432
2469
  </div>
2433
2470
  <div className="flex items-center justify-end gap-2">
2434
2471
  <ProcessingIndicator items={processingItems} />
2435
- <IconButton icon={<ImageIcon size={16} />} label="Media library" size="md" onClick={onMediaClick} className="border border-base-200 bg-base-accent" />
2436
- <IconButton icon={<ListOrderedIcon size={16} />} label="Reorder sections" size="md" onClick={onOrderingClick} className="border border-base-200 bg-base-accent" />
2437
- <IconButton icon={<FilesIcon size={16} />} label="Pages" size="md" onClick={onPagesClick} className="border border-base-200 bg-base-accent" />
2438
- <ShowAllChromeToggle />
2439
- <IconButton icon={<SettingsIcon size={16} />} label="Site settings" size="md" onClick={onSettingsClick} className="border border-base-200 bg-base-accent" />
2472
+ <Tooltip side="bottom" content="Authoring defaults">
2473
+ <AuthoringDefaultsMenu defaults={authoringDefaults} audiences={audiences} onChange={onAuthoringDefaultsChange} />
2474
+ </Tooltip>
2475
+ <Tooltip side="bottom" content="Preview as audience">
2476
+ <AudiencePreviewMenu audiences={audiences} />
2477
+ </Tooltip>
2478
+ <div aria-hidden="true" className="mx-1 h-6 w-px self-center bg-base-200" />
2479
+ <Tooltip side="bottom" content="Media library">
2480
+ <IconButton icon={<ImageIcon size={16} />} label="Media library" size="md" onClick={onMediaClick} className="border border-base-200 bg-base-accent" />
2481
+ </Tooltip>
2482
+ <Tooltip side="bottom" content="Reorder sections">
2483
+ <IconButton icon={<ListOrderedIcon size={16} />} label="Reorder sections" size="md" onClick={onOrderingClick} className="border border-base-200 bg-base-accent" />
2484
+ </Tooltip>
2485
+ <Tooltip side="bottom" content="Pages">
2486
+ <IconButton icon={<FilesIcon size={16} />} label="Pages" size="md" onClick={onPagesClick} className="border border-base-200 bg-base-accent" />
2487
+ </Tooltip>
2488
+ <Tooltip side="bottom" content="Toggle controls">
2489
+ <ShowAllChromeToggle />
2490
+ </Tooltip>
2491
+ <Tooltip side="bottom-clamp" content="Site settings">
2492
+ <IconButton icon={<SettingsIcon size={16} />} label="Site settings" size="md" onClick={onSettingsClick} className="border border-base-200 bg-base-accent" />
2493
+ </Tooltip>
2440
2494
  </div>
2441
2495
  </div>
2442
2496
  </div>
2443
2497
  )}
2444
2498
  {!isEditMode && (
2445
2499
  <div className="fixed top-0 right-0 left-0 z-50 border-b border-base-200 bg-base">
2446
- <div className="mx-auto max-w-screen-xl flex items-center justify-center px-4 py-2">
2500
+ <div className="mx-auto max-w-screen-xl flex h-16 items-center justify-center px-4">
2447
2501
  <SegmentedControl
2448
2502
  options={[
2449
2503
  { value: "live", label: "Published" },
@@ -2456,10 +2510,11 @@ function EditorToolbar({
2456
2510
  </div>
2457
2511
  )}
2458
2512
  {!historyState && (
2459
- <button
2513
+ <Fab
2514
+ label={isEditMode ? "Switch to view mode" : "Switch to edit mode"}
2515
+ column="editor"
2516
+ className="bottom-16"
2460
2517
  onClick={toggleEditMode}
2461
- 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"
2462
- aria-label={isEditMode ? "Switch to view mode" : "Switch to edit mode"}
2463
2518
  >
2464
2519
  {isEditMode ? (
2465
2520
  <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
@@ -2471,7 +2526,7 @@ function EditorToolbar({
2471
2526
  <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
2472
2527
  </svg>
2473
2528
  )}
2474
- </button>
2529
+ </Fab>
2475
2530
  )}
2476
2531
  </>
2477
2532
  );
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { Button } from "../shared/Button";
3
3
  import { cn } from "../../lib/cn";
4
+ import { contrastColor } from "../../lib/contrast";
4
5
  import ChatPanel from "../chat/ChatPanel";
5
6
  import type { SiteConfig, ChatbotConfig, ChatbotTask } from "../../schemas/site-config";
6
7
  import type { Audience } from "../../auth/types";
@@ -35,7 +36,7 @@ function AudienceChips({ audiences, selected, onToggle }: { audiences: Audience[
35
36
  <button key={a.name} type="button" onClick={() => onToggle(a.name)}
36
37
  className={cn("rounded-full border px-2 py-0.5 text-xs",
37
38
  selected.includes(a.name) ? "border-primary bg-primary text-primary-contrast" : "border-base-200 bg-base-accent text-base-contrast")}
38
- style={a.color && selected.includes(a.name) ? { backgroundColor: a.color, borderColor: a.color } : undefined}>
39
+ style={a.color && selected.includes(a.name) ? { backgroundColor: a.color, borderColor: a.color, color: contrastColor(a.color) } : undefined}>
39
40
  {a.displayName}
40
41
  </button>
41
42
  ))}
@@ -23,6 +23,14 @@ const FADE_DURATION = 1000;
23
23
  // whose deploy was never confirmed (e.g. superseded by a later build) — show
24
24
  // idle rather than a phantom in-progress publish.
25
25
  const STALE_BUILD_MS = 15 * 60 * 1000;
26
+ // Hard cap on an ACTIVE tracking session. Completion normally arrives via a terminal
27
+ // poll (ready/error), which the Netlify webhook writes to the deploy-status row. If
28
+ // that signal is lost/delayed (webhook dropped or unconfigured) the row stays
29
+ // "building" forever and the elapsed counter would climb indefinitely. This backstop
30
+ // stops the runaway counter and hides the indicator after the cap — the same "a build
31
+ // shouldn't run longer than this" invariant as STALE_BUILD_MS, applied to the live
32
+ // session rather than only on mount.
33
+ const MAX_TRACKING_MS = STALE_BUILD_MS;
26
34
 
27
35
  export function useBuildStatus(): BuildStatusResult {
28
36
  const [state, setState] = useState<BuildState>("idle");
@@ -31,6 +39,7 @@ export function useBuildStatus(): BuildStatusResult {
31
39
  const clearRef = useRef<ReturnType<typeof setTimeout> | null>(null);
32
40
  const fadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
33
41
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
42
+ const giveUpRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34
43
  const isPolling = useRef(false);
35
44
  const mountedRef = useRef(true);
36
45
 
@@ -52,6 +61,23 @@ export function useBuildStatus(): BuildStatusResult {
52
61
  isPolling.current = false;
53
62
  }, []);
54
63
 
64
+ const clearGiveUp = useCallback(() => {
65
+ if (giveUpRef.current) { clearTimeout(giveUpRef.current); giveUpRef.current = null; }
66
+ }, []);
67
+
68
+ // Arm the runaway backstop: if the cap elapses before a terminal signal, stop
69
+ // polling + the timer and drop back to idle (hide the indicator). `elapsedMs` lets a
70
+ // build already in progress at mount count against the same absolute cap.
71
+ const armGiveUp = useCallback((elapsedMs = 0) => {
72
+ clearGiveUp();
73
+ giveUpRef.current = setTimeout(() => {
74
+ if (!mountedRef.current) return;
75
+ stopPolling();
76
+ stopTimer();
77
+ setState("idle");
78
+ }, Math.max(0, MAX_TRACKING_MS - elapsedMs));
79
+ }, [clearGiveUp, stopPolling, stopTimer]);
80
+
55
81
  const fetchStatus = useCallback(async (): Promise<BuildStatusResponse | null> => {
56
82
  try {
57
83
  const res = await fetch("/api/build-status");
@@ -86,6 +112,7 @@ export function useBuildStatus(): BuildStatusResult {
86
112
  if (data.state === "ready" || data.state === "error") {
87
113
  stopPolling();
88
114
  stopTimer();
115
+ clearGiveUp();
89
116
  if (data.state === "ready") {
90
117
  clearRef.current = setTimeout(() => {
91
118
  if (!mountedRef.current) return;
@@ -98,7 +125,7 @@ export function useBuildStatus(): BuildStatusResult {
98
125
  }
99
126
  }
100
127
  },
101
- [stopPolling, stopTimer],
128
+ [stopPolling, stopTimer, clearGiveUp],
102
129
  );
103
130
 
104
131
  const startPolling = useCallback(() => {
@@ -130,6 +157,7 @@ export function useBuildStatus(): BuildStatusResult {
130
157
  // counts real elapsed time instead of sitting frozen at 0:00.
131
158
  startTimer(Number.isFinite(ageMs) ? Math.max(0, Math.floor(ageMs / 1000)) : 0);
132
159
  startPolling();
160
+ armGiveUp(Number.isFinite(ageMs) ? ageMs : 0);
133
161
  return;
134
162
  }
135
163
 
@@ -143,24 +171,27 @@ export function useBuildStatus(): BuildStatusResult {
143
171
  mountedRef.current = false;
144
172
  stopPolling();
145
173
  stopTimer();
174
+ clearGiveUp();
146
175
  if (clearRef.current) clearTimeout(clearRef.current);
147
176
  if (fadeRef.current) clearTimeout(fadeRef.current);
148
177
  };
149
- }, [fetchStatus, handleStatusUpdate, startPolling, startTimer, stopPolling, stopTimer]);
178
+ }, [fetchStatus, handleStatusUpdate, startPolling, startTimer, stopPolling, stopTimer, armGiveUp, clearGiveUp]);
150
179
 
151
180
  const startTracking = useCallback(() => {
152
181
  if (clearRef.current) { clearTimeout(clearRef.current); clearRef.current = null; }
153
182
  setState("building");
154
183
  startTimer();
155
184
  startPolling();
156
- }, [startPolling, startTimer]);
185
+ armGiveUp(0);
186
+ }, [startPolling, startTimer, armGiveUp]);
157
187
 
158
188
  const dismiss = useCallback(() => {
159
189
  if (clearRef.current) { clearTimeout(clearRef.current); clearRef.current = null; }
160
190
  setState("idle");
161
191
  stopPolling();
162
192
  stopTimer();
163
- }, [stopPolling, stopTimer]);
193
+ clearGiveUp();
194
+ }, [stopPolling, stopTimer, clearGiveUp]);
164
195
 
165
196
  return { state, elapsedSeconds, startTracking, dismiss };
166
197
  }