@drawnagency/primitives 0.1.89 → 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 (83) 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/editor/AudienceIndicator.d.ts +6 -0
  11. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
  12. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -1
  13. package/dist/components/editor/ColSpanControl.d.ts.map +1 -1
  14. package/dist/components/editor/DragHandle.d.ts +3 -1
  15. package/dist/components/editor/DragHandle.d.ts.map +1 -1
  16. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  17. package/dist/components/editor/StatusIndicator.d.ts +7 -0
  18. package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
  19. package/dist/components/editor/index.js +98 -76
  20. package/dist/components/primitives/index.js +3 -3
  21. package/dist/components/sections/Container/Container.d.ts +3 -2
  22. package/dist/components/sections/Container/Container.d.ts.map +1 -1
  23. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +4 -2
  24. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -1
  25. package/dist/components/sections/Container/index.d.ts +8 -2
  26. package/dist/components/sections/Container/index.d.ts.map +1 -1
  27. package/dist/components/sections/SectionLayout.d.ts +3 -1
  28. package/dist/components/sections/SectionLayout.d.ts.map +1 -1
  29. package/dist/components/sections/ViewRenderer.d.ts +3 -1
  30. package/dist/components/sections/ViewRenderer.d.ts.map +1 -1
  31. package/dist/components/sections/all-sections.d.ts +8 -2
  32. package/dist/components/sections/all-sections.d.ts.map +1 -1
  33. package/dist/components/sections/brand-guide.js +3 -3
  34. package/dist/components/sections/core-sections.d.ts +8 -2
  35. package/dist/components/sections/core-sections.d.ts.map +1 -1
  36. package/dist/components/sections/register-schemas.js +6 -6
  37. package/dist/components/sections/register.js +6 -6
  38. package/dist/components/shared/Tooltip.d.ts +8 -1
  39. package/dist/components/shared/Tooltip.d.ts.map +1 -1
  40. package/dist/components/shell/AudiencePreviewMenu.d.ts +7 -0
  41. package/dist/components/shell/AudiencePreviewMenu.d.ts.map +1 -0
  42. package/dist/components/shell/AuthoringDefaultsMenu.d.ts +10 -0
  43. package/dist/components/shell/AuthoringDefaultsMenu.d.ts.map +1 -0
  44. package/dist/components/shell/EditorContext.d.ts +3 -0
  45. package/dist/components/shell/EditorContext.d.ts.map +1 -1
  46. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  47. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  48. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  49. package/dist/index.js +6 -4
  50. package/dist/lib/authoring-defaults.d.ts +17 -0
  51. package/dist/lib/authoring-defaults.d.ts.map +1 -0
  52. package/dist/lib/container-grid.d.ts +6 -0
  53. package/dist/lib/container-grid.d.ts.map +1 -1
  54. package/dist/lib/index.js +3 -3
  55. package/dist/lib/migrate-sections-transform.js +1 -1
  56. package/dist/media/index.js +1 -1
  57. package/dist/schemas/index.js +4 -2
  58. package/dist/schemas/site-config.d.ts +6 -0
  59. package/dist/schemas/site-config.d.ts.map +1 -1
  60. package/package.json +1 -1
  61. package/src/components/editor/AudienceIndicator.tsx +50 -37
  62. package/src/components/editor/ChildBlockWrapper.tsx +8 -7
  63. package/src/components/editor/ColSpanControl.tsx +14 -11
  64. package/src/components/editor/DragHandle.tsx +5 -2
  65. package/src/components/editor/PagesModal.tsx +10 -10
  66. package/src/components/editor/SectionWrapper.tsx +32 -21
  67. package/src/components/editor/StatusIndicator.tsx +37 -24
  68. package/src/components/sections/Container/Container.tsx +185 -43
  69. package/src/components/sections/Container/ContainerSettingsForm.tsx +64 -24
  70. package/src/components/sections/Container/index.tsx +45 -20
  71. package/src/components/sections/SectionLayout.tsx +13 -3
  72. package/src/components/sections/ViewRenderer.tsx +11 -2
  73. package/src/components/shared/Tooltip.tsx +18 -2
  74. package/src/components/shell/AudiencePreviewMenu.tsx +73 -0
  75. package/src/components/shell/AuthoringDefaultsMenu.tsx +54 -0
  76. package/src/components/shell/EditorContext.tsx +5 -1
  77. package/src/components/shell/EditorShell.tsx +59 -12
  78. package/src/components/shell/SiteSettingsChatbot.tsx +2 -1
  79. package/src/hooks/useBuildStatus.ts +35 -4
  80. package/src/lib/authoring-defaults.ts +42 -0
  81. package/src/lib/container-grid.ts +14 -0
  82. package/src/lib/migrate-sections-transform.ts +5 -3
  83. package/src/schemas/site-config.ts +1 -1
@@ -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
  );
@@ -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,7 @@ import { UploadRejectionAlert, formatRejections } from "../shared/UploadRejectio
101
101
  import { Button } from "../shared/Button";
102
102
  import { SplitButton } from "../shared/SplitButton";
103
103
  import { IconButton } from "../shared/IconButton";
104
+ import { Tooltip } from "../shared/Tooltip";
104
105
  import { SegmentedControl } from "../shared/SegmentedControl";
105
106
  import { computeDisplaySections } from "./view-branch";
106
107
  import { SettingsIcon } from "../shared/icons";
@@ -110,6 +111,9 @@ import { HistoryToolbar } from "./HistoryToolbar";
110
111
  import { RestoreModal } from "./RestoreModal";
111
112
  import { EditorLoading } from "./EditorLoading";
112
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";
113
117
 
114
118
  export { useMediaLibrary } from "./MediaLibraryContext";
115
119
 
@@ -173,6 +177,11 @@ export default function EditorShell({
173
177
  const [sections, setSections] = useState<LoadedSection[]>([]);
174
178
  const [siteIndex, setSiteIndex] = useState<SiteIndex>({ siteId, pages: [], sections: {} });
175
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
+ }, []);
176
185
  const [localChangesExist, setLocalChangesExist] = useState(false);
177
186
  const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
178
187
  const [showSiteSettings, setShowSiteSettings] = useState(false);
@@ -1157,7 +1166,14 @@ export default function EditorShell({
1157
1166
  const content = resolveInsertedContent(selection, getAllSections(), newId);
1158
1167
  if (!content) return;
1159
1168
  const id = newId();
1160
- 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
+ };
1161
1177
  const newSection: LoadedSection = { section: { id, ...content } as LoadedSection["section"], meta };
1162
1178
 
1163
1179
  setSections((prev) => [...prev, newSection]);
@@ -1173,7 +1189,7 @@ export default function EditorShell({
1173
1189
  // save-time sync by design).
1174
1190
  void collabRef.current?.requestEdit(id);
1175
1191
  },
1176
- [persistence, resolvedActivePage],
1192
+ [persistence, resolvedActivePage, authoringDefaults, pageAudiences],
1177
1193
  );
1178
1194
 
1179
1195
  // Runs after the confirmation modal is accepted. Removes the section from the
@@ -1512,6 +1528,9 @@ export default function EditorShell({
1512
1528
  collabPeers={collab.peers}
1513
1529
  collabSelfId={collabBootstrap?.userId ?? null}
1514
1530
  collabStatus={collab.status}
1531
+ audiences={audiences}
1532
+ authoringDefaults={authoringDefaults}
1533
+ onAuthoringDefaultsChange={onAuthoringDefaultsChange}
1515
1534
  />
1516
1535
 
1517
1536
  {/* Bug reports persist via Supabase. On the password-only (zero-Supabase)
@@ -1889,7 +1908,7 @@ function HistoryOrEditorContent({
1889
1908
  activeSections: LoadedSection[];
1890
1909
  activePageId: string | null;
1891
1910
  }) {
1892
- const { historyState, historyPageId, setHistoryPageId } = useEditorContext();
1911
+ const { historyState, historyPageId, setHistoryPageId, previewAudience } = useEditorContext();
1893
1912
  const wasInHistory = useRef(false);
1894
1913
 
1895
1914
  const historyPage = historyState ? resolveHistoryPage(historyState, historyPageId, activePageId) : null;
@@ -1922,7 +1941,7 @@ function HistoryOrEditorContent({
1922
1941
  const pageSections = historyState.sections
1923
1942
  .filter((s) => position.has(s.section.id))
1924
1943
  .sort((a, b) => (position.get(a.section.id) ?? 0) - (position.get(b.section.id) ?? 0));
1925
- return <ViewRenderer sections={pageSections} />;
1944
+ return <ViewRenderer sections={pageSections} previewAudience={previewAudience} />;
1926
1945
  }
1927
1946
  return <>{children}</>;
1928
1947
  }
@@ -2001,7 +2020,7 @@ function EditorContent({
2001
2020
  getEditIntent: (id: string) => () => Promise<boolean>;
2002
2021
  onTakeover: (id: string) => void;
2003
2022
  }) {
2004
- const { isEditMode, viewBranch } = useEditorContext();
2023
+ const { isEditMode, viewBranch, previewAudience } = useEditorContext();
2005
2024
  const { openModal, closeModal } = useEditorModal();
2006
2025
  const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
2007
2026
  const dismissPendingInsert = useCallback(() => setPendingInsertIndex(null), []);
@@ -2092,7 +2111,12 @@ function EditorContent({
2092
2111
  )}
2093
2112
 
2094
2113
  <ErrorBoundary label={`${definition.label} (${section.type})`}>
2095
- <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
+ >
2096
2120
  <SectionWrapper
2097
2121
  sectionId={section.id}
2098
2122
  sectionType={section.type}
@@ -2340,6 +2364,9 @@ function EditorToolbar({
2340
2364
  collabPeers,
2341
2365
  collabSelfId,
2342
2366
  collabStatus,
2367
+ audiences,
2368
+ authoringDefaults,
2369
+ onAuthoringDefaultsChange,
2343
2370
  }: {
2344
2371
  buttonState: "synced" | "publish" | "saveAndPublish";
2345
2372
  localChangesExist: boolean;
@@ -2362,6 +2389,9 @@ function EditorToolbar({
2362
2389
  collabPeers: CollabPeer[];
2363
2390
  collabSelfId: string | null;
2364
2391
  collabStatus: CollabStatus;
2392
+ audiences: Audience[];
2393
+ authoringDefaults: AuthoringDefaults;
2394
+ onAuthoringDefaultsChange: (next: AuthoringDefaults) => void;
2365
2395
  }) {
2366
2396
  const { isEditMode, viewBranch, setViewBranch, toggleEditMode, historyState, setHistoryState, historyPageId, setHistoryPageId } = useEditorContext();
2367
2397
 
@@ -2432,11 +2462,28 @@ function EditorToolbar({
2432
2462
  </div>
2433
2463
  <div className="flex items-center justify-end gap-2">
2434
2464
  <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" />
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>
2440
2487
  </div>
2441
2488
  </div>
2442
2489
  </div>
@@ -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
  }
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+ import { StatusSchema } from "../schemas/site-config";
3
+ import type { Audience } from "../auth/types";
4
+
5
+ export const AuthoringDefaultsSchema = z.object({
6
+ status: StatusSchema,
7
+ access: z.array(z.string()),
8
+ });
9
+
10
+ export type AuthoringDefaults = z.infer<typeof AuthoringDefaultsSchema>;
11
+
12
+ export const FALLBACK_AUTHORING_DEFAULTS: AuthoringDefaults = { status: "draft", access: [] };
13
+
14
+ // Per-origin key — every client site is its own domain, so the preference is
15
+ // automatically per-site.
16
+ const STORAGE_KEY = "portal-authoring-defaults";
17
+
18
+ export function loadAuthoringDefaults(): AuthoringDefaults {
19
+ if (typeof localStorage === "undefined") return FALLBACK_AUTHORING_DEFAULTS;
20
+ try {
21
+ const raw = localStorage.getItem(STORAGE_KEY);
22
+ if (!raw) return FALLBACK_AUTHORING_DEFAULTS;
23
+ return AuthoringDefaultsSchema.parse(JSON.parse(raw));
24
+ } catch {
25
+ return FALLBACK_AUTHORING_DEFAULTS;
26
+ }
27
+ }
28
+
29
+ export function saveAuthoringDefaults(defaults: AuthoringDefaults): void {
30
+ if (typeof localStorage === "undefined") return;
31
+ try {
32
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
33
+ } catch {
34
+ // Quota/private-mode failures just lose the preference.
35
+ }
36
+ }
37
+
38
+ // Stored default audiences can outlive the audiences themselves — drop any
39
+ // name the site no longer has before stamping it onto a new section's access.
40
+ export function filterAccessToKnown(access: string[], audiences: Audience[]): string[] {
41
+ return access.filter((name) => audiences.some((a) => a.name === name));
42
+ }
@@ -42,6 +42,20 @@ export const gapClass: Record<string, string> = {
42
42
  lg: "gap-8",
43
43
  };
44
44
 
45
+ /**
46
+ * Column count -> `columns-N` classes for masonry (CSS multi-column), mirroring
47
+ * `containerGridClass`'s collapse curve (`grid-cols-`→`columns-`). Full static
48
+ * strings so Tailwind's source scan generates them.
49
+ */
50
+ export const masonryColumnsClass: Record<number, string> = {
51
+ 1: "columns-1",
52
+ 2: "columns-1 @sm:columns-2",
53
+ 3: "columns-1 @sm:columns-2 @lg:columns-3",
54
+ 4: "columns-1 @xs:columns-2 @lg:columns-4",
55
+ 5: "columns-1 @xs:columns-2 @md:columns-3 @xl:columns-5",
56
+ 6: "columns-1 @xs:columns-2 @md:columns-3 @2xl:columns-6",
57
+ };
58
+
45
59
  /**
46
60
  * Per-column-count container-query classes that HIDE a spacer cell once the container
47
61
  * collapses to a single column (mobile). Mirrors `containerGridClass`'s collapse curve:
@@ -99,7 +99,8 @@ function upgradeGrid(section: Obj): Obj {
99
99
  type: "container",
100
100
  content: {
101
101
  columns: content.columns,
102
- flow: "row",
102
+ layout: "grid",
103
+ equalizeRowHeight: false,
103
104
  children: media.map((item, i) => mapRefItem(item, id, i)),
104
105
  },
105
106
  };
@@ -126,7 +127,7 @@ function upgradeSplitContent(section: Obj): Obj {
126
127
  };
127
128
  const children = options.imagePosition === "right" ? [proseChild, mediaChild] : [mediaChild, proseChild];
128
129
 
129
- const out: Obj = { id, type: "container", content: { columns: 2, flow: "row", children } };
130
+ const out: Obj = { id, type: "container", content: { columns: 2, layout: "grid", equalizeRowHeight: false, children } };
130
131
  if (section.layout !== undefined) out.layout = section.layout;
131
132
  return out;
132
133
  }
@@ -163,7 +164,8 @@ function upgradeDoDont(section: Obj): Obj {
163
164
  type: "container",
164
165
  content: {
165
166
  columns: 2,
166
- flow: "row",
167
+ layout: "grid",
168
+ equalizeRowHeight: false,
167
169
  children: [
168
170
  dodontChild(doItems, id, 0, "do", childOptions),
169
171
  dodontChild(dontItems, id, 1, "dont", childOptions),
@@ -7,7 +7,7 @@ import { CORNER_RADIUS_OPTIONS } from "../lib/corner-radius";
7
7
  // document options block onto PageSchema.
8
8
  import { documentOptionsSchema } from "../document/engine/settings";
9
9
 
10
- const StatusSchema = z.enum(["draft", "live", "archived", "published"]).transform(
10
+ export const StatusSchema = z.enum(["draft", "live", "archived", "published"]).transform(
11
11
  (val) => val === "published" ? "live" as const : val,
12
12
  );
13
13