@drawnagency/primitives 0.1.94 → 0.1.95

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 (65) hide show
  1. package/dist/{chunk-UFOWXOG6.js → chunk-ABV7OZB3.js} +6 -0
  2. package/dist/{chunk-677NTHAZ.js → chunk-PTOLUMGV.js} +3 -0
  3. package/dist/{chunk-GDLH3LVH.js → chunk-QQBBYQYD.js} +3 -2
  4. package/dist/{chunk-XYFZ57AS.js → chunk-YUUUCDXN.js} +59 -5
  5. package/dist/components/brandguide/Colors.d.ts +2 -2
  6. package/dist/components/brandguide/Colors.d.ts.map +1 -1
  7. package/dist/components/brandguide/ColorsEditable.d.ts +2 -2
  8. package/dist/components/brandguide/ColorsEditable.d.ts.map +1 -1
  9. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  10. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  11. package/dist/components/editor/index.js +4 -3
  12. package/dist/components/sections/Container/Container.d.ts +2 -2
  13. package/dist/components/sections/Container/Container.d.ts.map +1 -1
  14. package/dist/components/sections/register-schemas.js +2 -2
  15. package/dist/components/sections/register.js +2 -2
  16. package/dist/components/shared/HistoryChangesModal.d.ts +39 -0
  17. package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -0
  18. package/dist/components/shared/HistoryPopover.d.ts +3 -1
  19. package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
  20. package/dist/components/shared/Navigation.d.ts.map +1 -1
  21. package/dist/components/shared/Popover.d.ts +10 -1
  22. package/dist/components/shared/Popover.d.ts.map +1 -1
  23. package/dist/components/shell/EditorModal.d.ts +6 -1
  24. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  25. package/dist/components/shell/EditorModalContext.d.ts +3 -2
  26. package/dist/components/shell/EditorModalContext.d.ts.map +1 -1
  27. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  28. package/dist/hooks/useEditorPublish.d.ts +5 -1
  29. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  30. package/dist/index.js +8 -6
  31. package/dist/lib/events.d.ts +18 -0
  32. package/dist/lib/events.d.ts.map +1 -1
  33. package/dist/lib/history-buckets.d.ts +30 -0
  34. package/dist/lib/history-buckets.d.ts.map +1 -0
  35. package/dist/lib/index.d.ts +1 -1
  36. package/dist/lib/index.d.ts.map +1 -1
  37. package/dist/lib/index.js +4 -2
  38. package/dist/lib/media-folders.d.ts +13 -0
  39. package/dist/lib/media-folders.d.ts.map +1 -1
  40. package/dist/lib/media-folders.js +172 -0
  41. package/dist/lib/registry.d.ts +7 -1
  42. package/dist/lib/registry.d.ts.map +1 -1
  43. package/dist/lib/theme-vars.d.ts +19 -4
  44. package/dist/lib/theme-vars.d.ts.map +1 -1
  45. package/package.json +5 -1
  46. package/src/components/brandguide/Colors.tsx +2 -1
  47. package/src/components/brandguide/ColorsEditable.tsx +2 -1
  48. package/src/components/editor/SectionWrapper.tsx +1 -0
  49. package/src/components/editor/SettingsForm.tsx +5 -2
  50. package/src/components/sections/Container/Container.tsx +5 -4
  51. package/src/components/shared/HistoryChangesModal.tsx +256 -0
  52. package/src/components/shared/HistoryPopover.tsx +255 -76
  53. package/src/components/shared/Navigation.tsx +30 -9
  54. package/src/components/shared/Popover.tsx +95 -2
  55. package/src/components/shell/EditorModal.tsx +26 -4
  56. package/src/components/shell/EditorModalContext.tsx +5 -4
  57. package/src/components/shell/EditorShell.tsx +51 -2
  58. package/src/components/shell/SiteSettingsModal.tsx +1 -1
  59. package/src/hooks/useEditorPublish.ts +15 -6
  60. package/src/lib/events.ts +11 -0
  61. package/src/lib/history-buckets.ts +190 -0
  62. package/src/lib/index.ts +1 -1
  63. package/src/lib/media-folders.ts +16 -0
  64. package/src/lib/registry.ts +9 -1
  65. package/src/lib/theme-vars.ts +25 -4
@@ -3,7 +3,7 @@ import { ChevronRight } from "lucide-react";
3
3
  import { cn } from "../../lib/cn";
4
4
  import { Toggle } from "./Toggle";
5
5
  import type { SiteNav, NavItem } from "../../lib/nav";
6
- import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent } from "../../lib/events";
6
+ import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, editorDirtyEvent } from "../../lib/events";
7
7
  import { useActiveHeadings } from "../../hooks/useActiveHeadings";
8
8
  import { formatDate } from "../../lib/timestamp";
9
9
  import { Popover } from "./Popover";
@@ -35,21 +35,28 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
35
35
  const bp = BP_CLASSES[desktopBreakpoint];
36
36
  const [isOpen, setIsOpen] = useState(false);
37
37
  const [isEditMode, setIsEditMode] = useState(false);
38
+ // Published by EditorShell (a separate island) so the history navigator can
39
+ // warn that restoring discards unsaved drafts.
40
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
38
41
  const [currentDarkMode, setCurrentDarkMode] = useState(darkMode);
39
42
  const [currentSiteName, setCurrentSiteName] = useState(siteName);
40
43
  const [isDark, setIsDark] = useState(false);
41
44
  const [siteNav, setSiteNav] = useState<SiteNav>(initialNav);
42
45
  const [showHistory, setShowHistory] = useState(false);
43
46
  const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
44
- const historyButtonRef = useRef<HTMLButtonElement>(null);
47
+ // Anchors the history panel to the "Last updated" block rather than the button
48
+ // inside it: the block's top edge is the divider the panel sits above, and its
49
+ // mx-4 matches the nav links' px-4, so the panel lines up with them.
50
+ const historyAnchorRef = useRef<HTMLDivElement>(null);
45
51
 
46
52
  useEffect(() => {
47
53
  setIsEditMode(window.location.pathname.startsWith("/edit"));
48
54
  const unlistenEdit = editModeEvent.listen(({ isEditMode }) => setIsEditMode(isEditMode));
55
+ const unlistenDirty = editorDirtyEvent.listen((d) => setHasUnsavedChanges(d.hasUnsavedChanges));
49
56
  const unlistenNav = siteNavChangeEvent.listen((n) => setSiteNav(n));
50
57
  const unlistenDark = darkModeEvent.listen((mode) => setCurrentDarkMode(mode as typeof darkMode));
51
58
  const unlistenName = siteNameChangeEvent.listen((name) => setCurrentSiteName(name));
52
- return () => { unlistenEdit(); unlistenNav(); unlistenDark(); unlistenName(); };
59
+ return () => { unlistenEdit(); unlistenDirty(); unlistenNav(); unlistenDark(); unlistenName(); };
53
60
  }, []);
54
61
 
55
62
  useEffect(() => {
@@ -261,16 +268,30 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
261
268
  </div>
262
269
  )}
263
270
  {lastUpdated && (
264
- <div className={cn(currentDarkMode !== "optional" && "border-t border-base-200", "mx-4 py-4 text-center")}>
271
+ <div ref={historyAnchorRef} className={cn(currentDarkMode !== "optional" && "border-t border-base-200", "mx-4 py-4 text-center")}>
265
272
  {isEditMode ? (
266
273
  <div className="relative">
267
- <button ref={historyButtonRef} onClick={() => setShowHistory((p) => !p)}
268
- className="cursor-pointer text-xs text-base-contrast-light hover:text-primary transition-colors" aria-label="View history">
274
+ <button onClick={() => setShowHistory((p) => !p)}
275
+ aria-label="View history" aria-expanded={showHistory}
276
+ className="inline-flex cursor-pointer items-center gap-1 text-xs text-base-contrast-light transition-colors hover:text-primary">
269
277
  Last updated {formatDate(lastUpdated)}
278
+ {/* The panel opens upward, so the caret points up when closed
279
+ and flips when open — the affordance that this is a toggle
280
+ rather than a label. */}
281
+ <svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor"
282
+ strokeWidth="1.7" aria-hidden="true"
283
+ className={cn("transition-transform", showHistory && "rotate-180")}>
284
+ <path d="M1.5 6.5L5 3l3.5 3.5" strokeLinecap="round" strokeLinejoin="round" />
285
+ </svg>
270
286
  </button>
271
- <Popover isOpen={showHistory} onClose={() => setShowHistory(false)} anchorRef={historyButtonRef}
272
- className="w-56 !bottom-full !top-auto !mb-1 !mt-0">
273
- <HistoryPopover onSelectCommit={(sha, date) => { setShowHistory(false); historySelectEvent.dispatch({ sha, date }); }} />
287
+ {/* Portalled: the sidebar sets overflow-y-auto, which computes
288
+ overflow-x to auto, so a panel wider than the 16rem sidebar
289
+ would be clipped. placement="top" opens it upward from the
290
+ anchor block, landing it just above the divider. */}
291
+ <Popover isOpen={showHistory} onClose={() => setShowHistory(false)} anchorRef={historyAnchorRef}
292
+ portal placement="top"
293
+ className="w-[22rem] h-[min(34rem,calc(100vh-7rem))] overflow-hidden">
294
+ <HistoryPopover hasUnsavedChanges={hasUnsavedChanges} onSelectCommit={(sha, date) => { setShowHistory(false); historySelectEvent.dispatch({ sha, date }); }} />
274
295
  </Popover>
275
296
  </div>
276
297
  ) : (
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
2
+ import { createPortal } from "react-dom";
2
3
  import { useFocusTrap } from "../../hooks/useFocusTrap";
3
4
  import { cn } from "../../lib/cn";
4
5
 
@@ -9,6 +10,15 @@ interface PopoverProps {
9
10
  children: ReactNode;
10
11
  align?: "start" | "end" | "auto";
11
12
  className?: string;
13
+ /**
14
+ * Render into document.body with fixed positioning. Required when the anchor
15
+ * sits inside a scroll container: the nav sidebar sets `overflow-y-auto`,
16
+ * which per CSS computes `overflow-x` to `auto`, so an absolutely-positioned
17
+ * panel wider than the sidebar is clipped and adds a horizontal scrollbar.
18
+ */
19
+ portal?: boolean;
20
+ /** Which side of the anchor to open toward. Only used with `portal`. */
21
+ placement?: "bottom" | "top";
12
22
  }
13
23
 
14
24
  export function Popover({
@@ -18,13 +28,62 @@ export function Popover({
18
28
  children,
19
29
  align = "auto",
20
30
  className,
31
+ portal = false,
32
+ placement = "bottom",
21
33
  }: PopoverProps) {
22
34
  const panelRef = useRef<HTMLDivElement>(null);
23
35
  const [resolvedAlign, setResolvedAlign] = useState<"start" | "end">("start");
36
+ const [fixedPos, setFixedPos] = useState<{ left: number; top?: number; bottom?: number } | null>(null);
24
37
 
25
- useFocusTrap(panelRef, isOpen);
38
+ // In portal mode the panel isn't rendered until it's been measured, so the
39
+ // trap must activate on that second commit — useFocusTrap bails on a null ref
40
+ // and only re-runs when `active` changes.
41
+ useFocusTrap(panelRef, isOpen && (!portal || fixedPos !== null));
26
42
 
43
+ // Track the anchor while portalled: the panel is out of normal flow, so it
44
+ // has to be repositioned on resize and on any ancestor's scroll.
27
45
  useLayoutEffect(() => {
46
+ if (!portal) return;
47
+ // Drop the stale position on close so the next open re-measures — the
48
+ // anchor can move while the popover is shut (resize, nav scroll), and a
49
+ // retained position would also mount the panel before it's been re-measured.
50
+ if (!isOpen) {
51
+ setFixedPos(null);
52
+ return;
53
+ }
54
+ const anchor = anchorRef.current;
55
+ if (!anchor) return;
56
+ const update = () => {
57
+ const r = anchor.getBoundingClientRect();
58
+ const next = placement === "top"
59
+ ? { left: r.left, bottom: window.innerHeight - r.top + 4 }
60
+ : { left: r.left, top: r.bottom + 4 };
61
+ // The capture-phase scroll listener also fires for scrolling *inside* the
62
+ // panel, so bail when nothing moved rather than re-rendering the subtree.
63
+ setFixedPos((prev) =>
64
+ prev && prev.left === next.left && prev.top === next.top && prev.bottom === next.bottom
65
+ ? prev
66
+ : next,
67
+ );
68
+ };
69
+ update();
70
+ window.addEventListener("resize", update);
71
+ window.addEventListener("scroll", update, true);
72
+ return () => {
73
+ window.removeEventListener("resize", update);
74
+ window.removeEventListener("scroll", update, true);
75
+ };
76
+ }, [isOpen, portal, placement, anchorRef]);
77
+
78
+ useLayoutEffect(() => {
79
+ // Alignment is only consulted by the non-portal return below. In portal
80
+ // mode this effect is not merely useless but harmful: it writes directly to
81
+ // panel.style.left to measure, and on a reopen (when the panel mounts on the
82
+ // first commit because fixedPos is still populated) that wipes the inline
83
+ // left React set. The follow-up measure computes the same value, so React's
84
+ // per-key style diff sees no change and never rewrites it — leaving the
85
+ // panel pinned to the viewport edge.
86
+ if (portal) return;
28
87
  if (!isOpen || align !== "auto") {
29
88
  if (!isOpen) setResolvedAlign("start");
30
89
  return;
@@ -46,12 +105,22 @@ export function Popover({
46
105
  useEffect(() => {
47
106
  if (!isOpen) return;
48
107
  function onKeyDown(e: KeyboardEvent) {
49
- if (e.key === "Escape") onClose();
108
+ if (e.key !== "Escape") return;
109
+ // A modal opened from inside this popover also closes on Escape. Without
110
+ // this the one keypress dismisses both, losing the navigator's zoom stack
111
+ // — inconsistent with the X button, and with the mousedown exemption
112
+ // below that exists for exactly this stacking.
113
+ if (document.querySelector("[data-modal-layer]")) return;
114
+ onClose();
50
115
  }
51
116
  function onMouseDown(e: MouseEvent) {
52
117
  const target = e.target as Node;
53
118
  if (panelRef.current?.contains(target)) return;
54
119
  if (anchorRef.current?.contains(target)) return;
120
+ // A modal opened from inside this popover portals to <body>, so it is
121
+ // "outside" by DOM containment. Closing on its mousedowns would unmount
122
+ // the modal along with the popover, and would break text selection in it.
123
+ if (target instanceof Element && target.closest("[data-modal-layer]")) return;
55
124
  onClose();
56
125
  }
57
126
  document.addEventListener("keydown", onKeyDown);
@@ -66,6 +135,30 @@ export function Popover({
66
135
 
67
136
  const effectiveAlign = align === "auto" ? resolvedAlign : align;
68
137
 
138
+ if (portal) {
139
+ // Render only once measured. The layout effect above runs after mutation
140
+ // but before paint, so this costs no visible delay — and it avoids painting
141
+ // the panel at the top-left corner for a frame before it's positioned.
142
+ if (!fixedPos) return null;
143
+ return createPortal(
144
+ <div
145
+ ref={panelRef}
146
+ role="dialog"
147
+ aria-modal="false"
148
+ style={{
149
+ position: "fixed",
150
+ left: `${fixedPos.left}px`,
151
+ ...(fixedPos.top !== undefined ? { top: `${fixedPos.top}px` } : {}),
152
+ ...(fixedPos.bottom !== undefined ? { bottom: `${fixedPos.bottom}px` } : {}),
153
+ }}
154
+ className={cn("z-50 rounded-md border border-base-200 bg-base shadow-lg", className)}
155
+ >
156
+ {children}
157
+ </div>,
158
+ document.body,
159
+ );
160
+ }
161
+
69
162
  return (
70
163
  <div
71
164
  ref={panelRef}
@@ -12,10 +12,15 @@ interface EditorModalProps {
12
12
  blocking?: boolean;
13
13
  size?: "standard" | "large" | "settings";
14
14
  noPadding?: boolean;
15
+ /**
16
+ * The content is a tab set: lock the panel to one height per size so switching
17
+ * tabs never resizes the dialog. Tab panels scroll internally instead.
18
+ */
19
+ tabbed?: boolean;
15
20
  children: ReactNode;
16
21
  }
17
22
 
18
- export function EditorModal({ isOpen, onClose, title, blocking = false, size = "standard", noPadding = false, children }: EditorModalProps) {
23
+ export function EditorModal({ isOpen, onClose, title, blocking = false, size = "standard", noPadding = false, tabbed = false, children }: EditorModalProps) {
19
24
  const panelRef = useRef<HTMLDivElement>(null);
20
25
  const titleId = useId();
21
26
 
@@ -44,6 +49,12 @@ export function EditorModal({ isOpen, onClose, title, blocking = false, size = "
44
49
  return createPortal(
45
50
  <div
46
51
  data-testid="modal-backdrop"
52
+ // Marks the whole modal layer (backdrop + panel) so surfaces underneath —
53
+ // notably Popover's outside-click handler — can tell "a dialog above me is
54
+ // being used" from "the user clicked away". Without it, any mousedown in a
55
+ // modal opened from a popover would dismiss the popover and take the modal
56
+ // with it, and text selection inside the modal would be impossible.
57
+ data-modal-layer=""
47
58
  className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
48
59
  onMouseDown={
49
60
  blocking
@@ -63,8 +74,16 @@ export function EditorModal({ isOpen, onClose, title, blocking = false, size = "
63
74
  size === "large"
64
75
  ? "max-w-[1400px] max-h-[900px] h-[90vh]"
65
76
  : size === "settings"
66
- ? "max-w-lg min-h-[min(32rem,calc(100vh-4rem))] max-h-[calc(100vh-4rem)]"
67
- : "max-w-md max-h-[calc(100vh-4rem)]",
77
+ ? cn(
78
+ "max-w-lg",
79
+ tabbed
80
+ ? "h-[min(48rem,calc(100vh-4rem))]"
81
+ : "min-h-[min(32rem,calc(100vh-4rem))] max-h-[calc(100vh-4rem)]",
82
+ )
83
+ : cn(
84
+ "max-w-md",
85
+ tabbed ? "h-[min(32rem,calc(100vh-4rem))]" : "max-h-[calc(100vh-4rem)]",
86
+ ),
68
87
  )}
69
88
  >
70
89
  <div className="flex shrink-0 items-center justify-between border-b border-base-200 px-6 py-4">
@@ -81,7 +100,10 @@ export function EditorModal({ isOpen, onClose, title, blocking = false, size = "
81
100
  </div>
82
101
  <div className={cn(
83
102
  !noPadding && "px-6 py-4",
84
- "flex-1 overflow-y-auto",
103
+ "flex-1",
104
+ // Tabbed: the body itself never scrolls, so the tab bar stays pinned
105
+ // and each panel scrolls within the locked height.
106
+ tabbed ? "flex min-h-0 flex-col overflow-hidden" : "overflow-y-auto",
85
107
  size === "settings" && "flex flex-col",
86
108
  )}>
87
109
  {children}
@@ -1,13 +1,14 @@
1
1
  import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
2
+ import type { ModalOptions, OpenModal } from "../../lib/registry";
2
3
 
3
- interface ModalState {
4
+ interface ModalState extends ModalOptions {
4
5
  title: string;
5
6
  content: ReactNode;
6
7
  }
7
8
 
8
9
  interface EditorModalContextValue {
9
10
  modalState: ModalState | null;
10
- openModal: (title: string, content: ReactNode) => void;
11
+ openModal: OpenModal;
11
12
  closeModal: () => void;
12
13
  }
13
14
 
@@ -16,8 +17,8 @@ const EditorModalContext = createContext<EditorModalContextValue | null>(null);
16
17
  export function EditorModalProvider({ children }: { children: ReactNode }) {
17
18
  const [modalState, setModalState] = useState<ModalState | null>(null);
18
19
 
19
- const openModal = useCallback((title: string, content: ReactNode) => {
20
- setModalState({ title, content });
20
+ const openModal = useCallback((title: string, content: ReactNode, options?: ModalOptions) => {
21
+ setModalState({ title, content, ...options });
21
22
  }, []);
22
23
 
23
24
  const closeModal = useCallback(() => {
@@ -37,7 +37,8 @@ import { SectionTypePicker } from "./SectionTypePicker";
37
37
  import { ensureSectionsRegistered } from "../sections/register";
38
38
  import { getSection, getAllSections, getSchema } from "../../lib/registry";
39
39
  import { cornerRadiusValue } from "../../lib/corner-radius";
40
- import { primaryVarValue, accentVarValue } from "../../lib/theme-vars";
40
+ import { primaryVarValue, accentVarValue, onPrimaryVarValue } from "../../lib/theme-vars";
41
+ import { rebaseMediaManifest } from "../../lib/media-folders";
41
42
  import { childInsertableTypes, insertChildAt } from "../../lib/container-ops";
42
43
  import { buildInsertMenuOptions, resolveInsertedContent } from "../../lib/insert-menu";
43
44
 
@@ -88,7 +89,7 @@ import { useBuildStatus } from "../../hooks/useBuildStatus";
88
89
  import { useMediaPipeline } from "../../hooks/useMediaPipeline";
89
90
  import { formatTimestamp } from "../../lib/timestamp";
90
91
  import { generateSiteNav } from "../../lib/nav";
91
- import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent } from "../../lib/events";
92
+ import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, historyRestoreEvent, historyRestoreFailedEvent, editorDirtyEvent } from "../../lib/events";
92
93
  import {
93
94
  homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
94
95
  reorderSectionInPage, audiencePasses,
@@ -260,6 +261,10 @@ export default function EditorShell({
260
261
  // writes onto the latest config instead of a stale closure.
261
262
  const siteConfigRef = useRef<SiteConfig | null>(siteConfig);
262
263
  useEffect(() => { siteConfigRef.current = siteConfig; }, [siteConfig]);
264
+ // Mirrors mediaManifest so recoverConflict can rebase it synchronously and the
265
+ // immediate 409 retry assembles the saved manifest against the rebased copy.
266
+ const mediaManifestRef = useRef<MediaManifest>(mediaManifest);
267
+ useEffect(() => { mediaManifestRef.current = mediaManifest; }, [mediaManifest]);
263
268
  // Guards the debounced preview-image composition effect (below): skips
264
269
  // recomposing on the very first run when a preview already exists, and
265
270
  // dedupes concurrent runs against the same composition signature.
@@ -487,6 +492,11 @@ export default function EditorShell({
487
492
  if (applied.droppedDirtyIds.length > 0) await deleteSectionRows(applied.droppedDirtyIds);
488
493
  }
489
494
 
495
+ const rebasedManifest = remote.mediaManifest
496
+ ? rebaseMediaManifest(mediaManifestRef.current, remote.mediaManifest)
497
+ : null;
498
+ if (rebasedManifest) setMediaManifest(rebasedManifest);
499
+
490
500
  setSections(mergedSections);
491
501
  setSiteIndex(mergedIndex);
492
502
  setSavedSha(remote.savedBranchSha);
@@ -497,6 +507,7 @@ export default function EditorShell({
497
507
  // (and onBaselineCommitted reads the diff refs right after it), before
498
508
  // React flushes the setState calls above into their mirror effects.
499
509
  savedShaRef.current = remote.savedBranchSha;
510
+ if (rebasedManifest) mediaManifestRef.current = rebasedManifest;
500
511
  sectionsRef.current = mergedSections;
501
512
  siteIndexRef.current = mergedIndex;
502
513
  changedSectionIdsRef.current = new Set(remote.changedSectionIds);
@@ -581,6 +592,11 @@ export default function EditorShell({
581
592
  });
582
593
 
583
594
  // The rebased result goes into React state (live view)...
595
+ if (remote.mediaManifest) {
596
+ const rebasedManifest = rebaseMediaManifest(mediaManifestRef.current, remote.mediaManifest);
597
+ setMediaManifest(rebasedManifest);
598
+ mediaManifestRef.current = rebasedManifest;
599
+ }
584
600
  setSections(result.sections);
585
601
  setSiteIndex(result.index);
586
602
  setSavedSha(remote.savedBranchSha);
@@ -778,6 +794,7 @@ export default function EditorShell({
778
794
  focusedSectionIdRef.current = null;
779
795
  },
780
796
  mediaManifest,
797
+ mediaManifestRef,
781
798
  manifestDirty: mediaPipeline.manifestDirty,
782
799
  clearManifestDirty: () => mediaPipeline.setManifestDirty(false),
783
800
  pendingMediaItems: mediaPipeline.pendingMediaItems,
@@ -890,6 +907,7 @@ export default function EditorShell({
890
907
  const accentVar = accentVarValue(config);
891
908
  root.style.setProperty("--color-primary", primaryVar);
892
909
  root.style.setProperty("--color-primary-contrast", accentVar);
910
+ root.style.setProperty("--color-on-primary", onPrimaryVarValue(config));
893
911
  root.style.setProperty("--font-heading", `${config.headingFont}, system-ui, sans-serif`);
894
912
  root.style.setProperty("--font-body", `${config.bodyFont}, system-ui, sans-serif`);
895
913
  root.style.setProperty("--heading-text-transform", config.uppercaseHeadings ? "uppercase" : "none");
@@ -1456,12 +1474,42 @@ export default function EditorShell({
1456
1474
  if (!response.ok) throw new Error(`Restore failed: ${response.status}`);
1457
1475
  await discardLocalChanges();
1458
1476
  window.location.reload();
1477
+ return true;
1459
1478
  } catch (err) {
1460
1479
  console.error("Failed to restore:", err);
1461
1480
  setIsRestoring(false);
1481
+ return false;
1462
1482
  }
1463
1483
  }, []);
1464
1484
 
1485
+ // Restore requested straight from the history navigator. The navigator has
1486
+ // already confirmed with the user, so this loads the content at that sha and
1487
+ // runs the same restore path the HistoryToolbar uses.
1488
+ useEffect(() => {
1489
+ return historyRestoreEvent.listen(async ({ sha }) => {
1490
+ try {
1491
+ const res = await fetch(`/api/content/history?sha=${sha}`);
1492
+ if (!res.ok) throw new Error(`Failed to load version: ${res.status}`);
1493
+ const data = await res.json();
1494
+ const ok = await handleHistoryRestore({
1495
+ sections: data.sections,
1496
+ index: data.index,
1497
+ siteConfig: data.siteConfig,
1498
+ });
1499
+ // On success the page reloads, so only a failure ever reaches here.
1500
+ if (!ok) historyRestoreFailedEvent.dispatch({ message: "Couldn't publish the restore." });
1501
+ } catch (err) {
1502
+ console.error("Failed to restore from history:", err);
1503
+ historyRestoreFailedEvent.dispatch({ message: "Couldn't load that version." });
1504
+ }
1505
+ });
1506
+ }, [handleHistoryRestore]);
1507
+
1508
+ // Publish unsaved-draft state to the other islands (see editorDirtyEvent).
1509
+ useEffect(() => {
1510
+ editorDirtyEvent.dispatch({ hasUnsavedChanges: dirtySectionIds.size > 0 });
1511
+ }, [dirtySectionIds]);
1512
+
1465
1513
  // --- Render ---
1466
1514
 
1467
1515
  if (shellState.phase === "loading-content") {
@@ -2269,6 +2317,7 @@ function GlobalModal() {
2269
2317
  isOpen={modalState !== null}
2270
2318
  onClose={closeModal}
2271
2319
  title={modalState?.title ?? ""}
2320
+ tabbed={modalState?.tabbed}
2272
2321
  >
2273
2322
  {modalState?.content}
2274
2323
  </EditorModal>
@@ -106,7 +106,7 @@ export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigCha
106
106
  const tabItems = allTabs.filter((t) => t.show).map(({ id, label, content }) => ({ id, label, content }));
107
107
 
108
108
  return (
109
- <EditorModal isOpen={isOpen} onClose={onClose} title="Site Settings" size="settings" noPadding>
109
+ <EditorModal isOpen={isOpen} onClose={onClose} title="Site Settings" size="settings" noPadding tabbed>
110
110
  <div className="flex flex-1 flex-col overflow-hidden">
111
111
  <Tabs tabs={tabItems} />
112
112
 
@@ -70,6 +70,10 @@ interface PublishDeps {
70
70
  * clears only those (a deletion scheduled mid-save must stay scheduled). */
71
71
  onSuccess: (savedSectionIds: string[], meta: { editToken: number; sentDeletedIds: string[] }) => void;
72
72
  mediaManifest: MediaManifest;
73
+ /** Optional ref mirror of `mediaManifest`, read at save time. recoverConflict
74
+ * refreshes it synchronously, so the immediate 409 retry assembles against
75
+ * the rebased manifest instead of this render's pre-conflict copy. */
76
+ mediaManifestRef?: React.RefObject<MediaManifest>;
73
77
  manifestDirty: boolean;
74
78
  clearManifestDirty: () => void;
75
79
  pendingMediaItems: MediaItem[];
@@ -126,6 +130,7 @@ export function useEditorPublish({
126
130
  deletedSectionIds,
127
131
  onSuccess,
128
132
  mediaManifest,
133
+ mediaManifestRef,
129
134
  manifestDirty,
130
135
  clearManifestDirty,
131
136
  pendingMediaItems,
@@ -201,11 +206,12 @@ export function useEditorPublish({
201
206
  if (hasMediaChanges) {
202
207
  // Preserves folders (and any future manifest keys) and heals dangling
203
208
  // folder refs — never rebuild the manifest as a bare { images }.
204
- // KNOWN LIMITATION (deferred): the assembly starts from THIS editor's
205
- // view of the manifest, so concurrent media changes by another editor
206
- // are last-writer-wins — a manifest entry they added since our load is
207
- // dropped from the write (their files remain; the entry orphans). Fixing
208
- // this needs a merge against the remote manifest at save time.
209
+ // The assembly starts from THIS editor's view, so what we send can be
210
+ // missing entries another writer added since we loaded. That is no longer
211
+ // destructive: applyContentWrite folds this into the manifest already on
212
+ // the branch, so a save can only add, update, or explicitly delete. The
213
+ // client side of the same story is rebaseMediaManifest, which refreshes
214
+ // our view on 409 recovery and on a peer's saved-event.
209
215
  updatedManifest = assembleSavedManifest(
210
216
  manifest,
211
217
  mediaUploads.map((u) => u.item),
@@ -390,7 +396,10 @@ export function useEditorPublish({
390
396
  return performSave({
391
397
  siteConfig: siteConfig!,
392
398
  siteIndexRef, deletedSectionIds, isConfigDirty,
393
- pendingMediaItems, pendingMediaDeletions, mediaManifest, manifestDirty,
399
+ pendingMediaItems, pendingMediaDeletions, manifestDirty,
400
+ // Read through the ref (when the shell supplies one) so the 409 retry
401
+ // below sees recoverConflict's rebased manifest, not this render's copy.
402
+ mediaManifest: mediaManifestRef?.current ?? mediaManifest,
394
403
  saveChunkBudget,
395
404
  });
396
405
  }
package/src/lib/events.ts CHANGED
@@ -23,5 +23,16 @@ export const navChangeEvent = createEvent<NavItem[]>("sitenavchange");
23
23
  export const darkModeEvent = createEvent<string>("sitedarkmode");
24
24
  export const siteNameChangeEvent = createEvent<string>("sitenamechange");
25
25
  export const historySelectEvent = createEvent<{ sha: string; date: string }>("history-select");
26
+ /** Restore straight from the history navigator, skipping the browse-then-restore
27
+ * detour. The shell loads the content at `sha` and runs the same restore path
28
+ * HistoryToolbar uses. Confirmation happens before this is dispatched. */
29
+ export const historyRestoreEvent = createEvent<{ sha: string; date: string }>("history-restore");
30
+ /** Restore failed. The navigator is a separate island from the shell that does
31
+ * the writing, so without this the failure is invisible and the UI sits on
32
+ * "Restoring…" forever. */
33
+ export const historyRestoreFailedEvent = createEvent<{ message: string }>("history-restore-failed");
34
+ /** Unsaved-draft state, published by the shell for other islands (the history
35
+ * navigator warns before restoring, which discards drafts irrecoverably). */
36
+ export const editorDirtyEvent = createEvent<{ hasUnsavedChanges: boolean }>("editor-dirty");
26
37
  export const siteNavChangeEvent = createEvent<SiteNav>("sitenavchange-v2");
27
38
  export const pageSelectEvent = createEvent<string>("pageselect");