@drawnagency/primitives 0.1.93 → 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 (84) hide show
  1. package/dist/{chunk-UFOWXOG6.js → chunk-ABV7OZB3.js} +6 -0
  2. package/dist/{chunk-LQQY2RW6.js → chunk-DJAJRBPD.js} +1 -0
  3. package/dist/{chunk-677NTHAZ.js → chunk-PTOLUMGV.js} +3 -0
  4. package/dist/{chunk-RBKBNQGB.js → chunk-QQBBYQYD.js} +7 -6
  5. package/dist/{chunk-YI2HUFE3.js → chunk-RQPHPAT2.js} +1 -1
  6. package/dist/chunk-VAOW4WXQ.js +15 -0
  7. package/dist/{chunk-XYFZ57AS.js → chunk-YUUUCDXN.js} +59 -5
  8. package/dist/{chunk-HLKAQLTV.js → chunk-ZKGMLLUQ.js} +1 -1
  9. package/dist/components/brandguide/Colors.d.ts +2 -2
  10. package/dist/components/brandguide/Colors.d.ts.map +1 -1
  11. package/dist/components/brandguide/ColorsEditable.d.ts +2 -2
  12. package/dist/components/brandguide/ColorsEditable.d.ts.map +1 -1
  13. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  14. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  15. package/dist/components/editor/index.js +4 -3
  16. package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
  17. package/dist/components/primitives/index.js +2 -2
  18. package/dist/components/sections/Container/Container.d.ts +2 -2
  19. package/dist/components/sections/Container/Container.d.ts.map +1 -1
  20. package/dist/components/sections/Video/VideoBlock.d.ts.map +1 -1
  21. package/dist/components/sections/brand-guide.js +2 -2
  22. package/dist/components/sections/register-schemas.js +5 -5
  23. package/dist/components/sections/register.js +5 -5
  24. package/dist/components/shared/HistoryChangesModal.d.ts +39 -0
  25. package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -0
  26. package/dist/components/shared/HistoryPopover.d.ts +3 -1
  27. package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
  28. package/dist/components/shared/Navigation.d.ts.map +1 -1
  29. package/dist/components/shared/Popover.d.ts +10 -1
  30. package/dist/components/shared/Popover.d.ts.map +1 -1
  31. package/dist/components/shell/EditorModal.d.ts +6 -1
  32. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  33. package/dist/components/shell/EditorModalContext.d.ts +3 -2
  34. package/dist/components/shell/EditorModalContext.d.ts.map +1 -1
  35. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  36. package/dist/hooks/useEditorPublish.d.ts +5 -1
  37. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  38. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  39. package/dist/index.js +9 -12
  40. package/dist/lib/events.d.ts +18 -0
  41. package/dist/lib/events.d.ts.map +1 -1
  42. package/dist/lib/history-buckets.d.ts +30 -0
  43. package/dist/lib/history-buckets.d.ts.map +1 -0
  44. package/dist/lib/index.d.ts +1 -1
  45. package/dist/lib/index.d.ts.map +1 -1
  46. package/dist/lib/index.js +4 -2
  47. package/dist/lib/media-folders.d.ts +13 -0
  48. package/dist/lib/media-folders.d.ts.map +1 -1
  49. package/dist/lib/media-folders.js +172 -0
  50. package/dist/lib/registry.d.ts +7 -1
  51. package/dist/lib/registry.d.ts.map +1 -1
  52. package/dist/lib/theme-vars.d.ts +19 -4
  53. package/dist/lib/theme-vars.d.ts.map +1 -1
  54. package/dist/storage/index.d.ts +1 -1
  55. package/dist/storage/index.d.ts.map +1 -1
  56. package/dist/storage/index.js +9 -0
  57. package/dist/storage/types.d.ts +14 -0
  58. package/dist/storage/types.d.ts.map +1 -1
  59. package/package.json +9 -1
  60. package/src/components/brandguide/Colors.tsx +2 -1
  61. package/src/components/brandguide/ColorsEditable.tsx +2 -1
  62. package/src/components/editor/SectionWrapper.tsx +1 -0
  63. package/src/components/editor/SettingsForm.tsx +5 -2
  64. package/src/components/primitives/ResolvedMedia.tsx +6 -0
  65. package/src/components/sections/Container/Container.tsx +5 -4
  66. package/src/components/sections/Video/VideoBlock.tsx +9 -1
  67. package/src/components/shared/HistoryChangesModal.tsx +256 -0
  68. package/src/components/shared/HistoryPopover.tsx +255 -76
  69. package/src/components/shared/Navigation.tsx +30 -9
  70. package/src/components/shared/Popover.tsx +95 -2
  71. package/src/components/shell/EditorModal.tsx +26 -4
  72. package/src/components/shell/EditorModalContext.tsx +5 -4
  73. package/src/components/shell/EditorShell.tsx +51 -2
  74. package/src/components/shell/SiteSettingsModal.tsx +1 -1
  75. package/src/hooks/useEditorPublish.ts +15 -6
  76. package/src/hooks/useMediaPipeline.ts +4 -0
  77. package/src/lib/events.ts +11 -0
  78. package/src/lib/history-buckets.ts +190 -0
  79. package/src/lib/index.ts +1 -1
  80. package/src/lib/media-folders.ts +16 -0
  81. package/src/lib/registry.ts +9 -1
  82. package/src/lib/theme-vars.ts +25 -4
  83. package/src/storage/index.ts +1 -1
  84. package/src/storage/types.ts +15 -0
@@ -1,104 +1,283 @@
1
- import { useState, useCallback, useEffect } from "react";
1
+ import { useState, useCallback, useEffect, useMemo, lazy, Suspense } from "react";
2
2
  import { cn } from "../../lib/cn";
3
- import { formatDateTime } from "../../lib/timestamp";
4
- import { Button } from "./Button";
5
-
6
- interface CommitItem {
7
- sha: string;
8
- date: string;
9
- message: string;
10
- }
3
+ import { historyRestoreEvent, historyRestoreFailedEvent } from "../../lib/events";
4
+ import {
5
+ buildHistoryTree, maxNodeCount,
6
+ type HistoryCommit, type HistoryGroup, type HistoryNode,
7
+ } from "../../lib/history-buckets";
8
+ // Lazy: Navigation is a client:load island on VIEWER pages too, and this pulls
9
+ // in EditorModal. The modal only renders behind the edit-mode gate, so those
10
+ // bytes shouldn't sit in every viewer's bundle.
11
+ const HistoryChangesModal = lazy(() =>
12
+ import("./HistoryChangesModal").then((m) => ({ default: m.HistoryChangesModal })));
11
13
 
12
14
  interface HistoryPopoverProps {
13
15
  onSelectCommit: (sha: string, date: string) => void;
16
+ /** Editor holds unsaved local changes; restoring would discard them. */
17
+ hasUnsavedChanges?: boolean;
14
18
  }
15
19
 
16
- export function HistoryPopover({ onSelectCommit }: HistoryPopoverProps) {
17
- const [commits, setCommits] = useState<CommitItem[]>([]);
20
+ const PER_PAGE = 100;
21
+ const MAX_PAGES = 10; // 1000-commit cap
22
+
23
+ /** One entry per zoom level. The root level has no node. */
24
+ interface Frame {
25
+ label: string;
26
+ node: HistoryNode | null;
27
+ }
28
+
29
+ const Chevron = () => (
30
+ <svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor"
31
+ strokeWidth="1.7" aria-hidden="true">
32
+ <path d="M3.5 1.5L7 5l-3.5 3.5" />
33
+ </svg>
34
+ );
35
+
36
+ const BackArrow = () => (
37
+ <svg width="11" height="11" viewBox="0 0 10 10" fill="none" stroke="currentColor"
38
+ strokeWidth="1.7" aria-hidden="true">
39
+ <path d="M6.5 1.5L3 5l3.5 3.5" />
40
+ </svg>
41
+ );
42
+
43
+ /** Browse-to-this-version: a pointer cursor — "go look at the site as it was". */
44
+ const BrowseCursor = () => (
45
+ <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
46
+ <path d="M3.6 1.9a.4.4 0 0 1 .66-.3l8 6.7a.4.4 0 0 1-.23.71l-3.4.2 1.94 3.5a.4.4 0 0 1-.17.54l-1.4.75a.4.4 0 0 1-.54-.18l-1.9-3.55-2.3 2.2a.4.4 0 0 1-.68-.29z" />
47
+ </svg>
48
+ );
49
+
50
+ function Volume({ count, max }: { count: number; max: number }) {
51
+ // sqrt so a 200-commit year doesn't flatten every other bar to nothing.
52
+ const pct = count > 0 ? Math.round(Math.min(1, Math.sqrt(count / max)) * 100) : 0;
53
+ return (
54
+ <span className="flex items-center gap-1.5" aria-hidden="true">
55
+ <span className="block h-[3px] w-9 rounded-sm bg-base-accent">
56
+ <span className="block h-[3px] rounded-sm bg-primary" style={{ width: `${pct}%` }} />
57
+ </span>
58
+ <span className="min-w-[20px] text-right text-[10.5px] tabular-nums text-base-contrast-light">
59
+ {count}
60
+ </span>
61
+ </span>
62
+ );
63
+ }
64
+
65
+ export function HistoryPopover({ onSelectCommit, hasUnsavedChanges = false }: HistoryPopoverProps) {
66
+ const [commits, setCommits] = useState<HistoryCommit[]>([]);
18
67
  const [loading, setLoading] = useState(true);
19
- const [loadingMore, setLoadingMore] = useState(false);
20
- const [page, setPage] = useState(1);
21
- const [hasMore, setHasMore] = useState(true);
22
68
  const [error, setError] = useState<string | null>(null);
69
+ const [stack, setStack] = useState<Frame[]>([{ label: "History", node: null }]);
70
+ const [info, setInfo] = useState<HistoryNode | null>(null);
71
+ // Restore ends by reloading the page, so this only has to hold long enough to
72
+ // block a second dispatch during the load + save window.
73
+ const [restoring, setRestoring] = useState(false);
74
+ const [restoreError, setRestoreError] = useState<string | null>(null);
23
75
 
24
- const fetchPage = useCallback(async (pageNum: number) => {
25
- const isFirst = pageNum === 1;
26
- if (isFirst) setLoading(true);
27
- else setLoadingMore(true);
28
-
29
- try {
30
- const res = await fetch(`/api/history?page=${pageNum}&per_page=20`);
31
- if (!res.ok) throw new Error(`Failed to load history: ${res.status}`);
32
- const data = await res.json();
33
- const items = data.items as CommitItem[];
34
- setCommits((prev) => isFirst ? items : [...prev, ...items]);
35
- setHasMore(items.length === 20);
36
- setPage(pageNum);
37
- } catch (err) {
38
- setError(err instanceof Error ? err.message : "Failed to load history");
39
- } finally {
40
- setLoading(false);
41
- setLoadingMore(false);
42
- }
43
- }, []);
76
+ // A successful restore reloads, so only failures ever come back.
77
+ useEffect(() => historyRestoreFailedEvent.listen(({ message }) => {
78
+ setRestoring(false);
79
+ setRestoreError(message);
80
+ }), []);
44
81
 
45
82
  useEffect(() => {
46
- fetchPage(1);
47
- }, [fetchPage]);
83
+ let live = true;
84
+ (async () => {
85
+ const all: HistoryCommit[] = [];
86
+ try {
87
+ for (let page = 1; page <= MAX_PAGES; page++) {
88
+ const res = await fetch(`/api/history?page=${page}&per_page=${PER_PAGE}`);
89
+ if (!res.ok) throw new Error(`Failed to load history: ${res.status}`);
90
+ const data = (await res.json()) as { items: HistoryCommit[] };
91
+ all.push(...data.items);
92
+ if (data.items.length < PER_PAGE) break;
93
+ }
94
+ if (live) setCommits(all);
95
+ } catch (err) {
96
+ if (live) setError(err instanceof Error ? err.message : "Failed to load history");
97
+ } finally {
98
+ if (live) setLoading(false);
99
+ }
100
+ })();
101
+ return () => { live = false; };
102
+ }, []);
103
+
104
+ const groups = useMemo(() => buildHistoryTree(commits, new Date()), [commits]);
105
+ const max = useMemo(() => maxNodeCount(groups), [groups]);
106
+
107
+ const dateOf = useCallback(
108
+ (sha: string | null) => commits.find((c) => c.sha === sha)?.date ?? "",
109
+ [commits],
110
+ );
111
+
112
+ const browse = useCallback((node: HistoryNode) => {
113
+ if (!node.newestSha) return;
114
+ onSelectCommit(node.newestSha, dateOf(node.newestSha));
115
+ }, [onSelectCommit, dateOf]);
116
+
117
+ const zoomTo = useCallback((depth: number) => {
118
+ setStack((s) => s.slice(0, depth + 1));
119
+ }, []);
120
+
121
+ const current = stack[stack.length - 1];
122
+ const atRoot = stack.length === 1;
48
123
 
49
124
  if (loading) {
50
- return (
51
- <div className="p-4 text-center text-xs text-base-contrast-light">
52
- Loading history...
53
- </div>
54
- );
125
+ return <div className="p-4 text-center text-xs text-base-contrast-light">Loading history…</div>;
55
126
  }
56
-
57
127
  if (error) {
58
- return (
59
- <div className="p-4 text-center text-xs text-red-600">
60
- {error}
61
- </div>
62
- );
128
+ return <div className="p-4 text-center text-xs text-red-600">{error}</div>;
63
129
  }
64
-
65
130
  if (commits.length === 0) {
66
- return (
67
- <div className="p-4 text-center text-xs text-base-contrast-light">
68
- No history found
69
- </div>
70
- );
131
+ return <div className="p-4 text-center text-xs text-base-contrast-light">No history found</div>;
71
132
  }
72
133
 
73
- return (
74
- <div className="max-h-64 overflow-y-auto">
75
- <ul className="py-1">
76
- {commits.map((commit) => (
77
- <li key={commit.sha}>
134
+ // Empty buckets are omitted by buildHistoryTree, so every node here has edits.
135
+ const row = (node: HistoryNode) => {
136
+ const expandable = node.children.length > 0;
137
+ const full = node.sublabel ? `${node.label} ${node.sublabel}` : node.label;
138
+ return (
139
+ <li key={node.id} className="group flex items-center pr-1.5 hover:bg-base-accent">
140
+ <span className="flex w-6 shrink-0 items-center justify-center self-stretch">
141
+ {expandable ? (
78
142
  <button
79
- onClick={() => onSelectCommit(commit.sha, commit.date)}
80
- className={cn(
81
- "w-full cursor-pointer px-4 py-2 text-left text-xs transition-colors",
82
- "hover:bg-base-accent text-base-contrast-light hover:text-base-contrast",
83
- )}
143
+ type="button"
144
+ aria-label={`Open ${node.label}`}
145
+ onClick={() => setStack((s) => [...s, { label: node.label, node }])}
146
+ className="flex h-[22px] w-5 cursor-pointer items-center justify-center rounded text-base-contrast-light hover:bg-base-accent hover:text-primary"
84
147
  >
85
- {formatDateTime(commit.date)}
148
+ <Chevron />
86
149
  </button>
87
- </li>
88
- ))}
89
- </ul>
90
- {hasMore && (
91
- <div className="border-t border-base-200 p-2 text-center">
92
- <Button
93
- variant="ghost"
94
- size="sm"
95
- onClick={() => fetchPage(page + 1)}
96
- disabled={loadingMore}
150
+ ) : (
151
+ <span aria-hidden="true" className="text-[9px] text-base-contrast-light">·</span>
152
+ )}
153
+ </span>
154
+
155
+ {/* Primary action is "what changed here?" — the summary is what an editor
156
+ is usually after; browsing is the deliberate second step. The edit
157
+ count rides in the label because the volume bar is decorative and
158
+ hidden from screen readers. */}
159
+ <button
160
+ type="button"
161
+ aria-label={`${full} — ${node.count} edit${node.count === 1 ? "" : "s"} — what changed`}
162
+ onClick={() => setInfo(node)}
163
+ className="min-w-0 flex-1 cursor-pointer truncate py-1.5 text-left text-xs text-base-contrast"
164
+ >
165
+ {node.label}
166
+ {node.sublabel && (
167
+ <span className="ml-1.5 text-[11px] text-base-contrast-light">{node.sublabel}</span>
168
+ )}
169
+ </button>
170
+
171
+ <Volume count={node.count} max={max} />
172
+
173
+ {node.newestSha && (
174
+ <button
175
+ type="button"
176
+ aria-label={`Browse ${full}`}
177
+ onClick={() => browse(node)}
178
+ // focus-visible as well as group-hover: keyboard users never trigger
179
+ // a hover, so without it this control is invisible to them.
180
+ className="ml-1.5 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-base-contrast-light opacity-50 hover:bg-base-accent hover:text-primary focus-visible:opacity-100 group-hover:opacity-100"
97
181
  >
98
- {loadingMore ? "Loading..." : "Load more"}
99
- </Button>
182
+ <BrowseCursor />
183
+ </button>
184
+ )}
185
+ </li>
186
+ );
187
+ };
188
+
189
+ return (
190
+ <div className="flex h-full flex-col overflow-hidden">
191
+ {!atRoot && current.node && (
192
+ <div className="sticky top-0 z-10 flex items-center gap-1.5 border-b border-base-200 bg-base-accent/40 px-1.5 py-1.5">
193
+ <button
194
+ type="button"
195
+ aria-label="Back"
196
+ onClick={() => zoomTo(stack.length - 2)}
197
+ className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-base-contrast-light hover:bg-base-accent hover:text-base-contrast"
198
+ >
199
+ <BackArrow />
200
+ </button>
201
+
202
+ <nav
203
+ aria-label="History trail"
204
+ className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden text-[11.5px] whitespace-nowrap"
205
+ >
206
+ {stack.map((f, i) =>
207
+ i === stack.length - 1 ? (
208
+ <span key={`${f.label}-${i}`} className="truncate font-semibold text-base-contrast">
209
+ {f.label}
210
+ </span>
211
+ ) : (
212
+ <span key={`${f.label}-${i}`} className="flex shrink-0 items-center gap-1">
213
+ <button
214
+ type="button"
215
+ onClick={() => zoomTo(i)}
216
+ className="cursor-pointer rounded px-0.5 text-primary hover:bg-base-accent"
217
+ >
218
+ {f.label}
219
+ </button>
220
+ <span aria-hidden="true" className="text-base-contrast-light">›</span>
221
+ </span>
222
+ ),
223
+ )}
224
+ </nav>
225
+
226
+ {/* Volume only. Acting on the level you're inside means going back a
227
+ step and using its row — the bar stays a "where am I", not a
228
+ second set of controls competing with the rows below it. */}
229
+ <span className="shrink-0">
230
+ <Volume count={current.node.count} max={max} />
231
+ </span>
100
232
  </div>
101
233
  )}
234
+
235
+ <div className="flex-1 overflow-y-auto py-1">
236
+ {atRoot ? (
237
+ groups.map((g: HistoryGroup) => (
238
+ <section key={g.id} role="group" aria-label={g.label}>
239
+ <h3 className="px-3 pt-2.5 pb-1 text-[9.5px] font-medium tracking-[0.07em] text-base-contrast-light uppercase">
240
+ {g.label}
241
+ </h3>
242
+ {g.nodes.length === 0 ? (
243
+ <p className="px-3 pb-1 text-[11px] text-base-contrast-light">No edits</p>
244
+ ) : (
245
+ <ul className="list-none p-0">{g.nodes.map(row)}</ul>
246
+ )}
247
+ </section>
248
+ ))
249
+ ) : (
250
+ <ul className="list-none p-0">{(current.node?.children ?? []).map(row)}</ul>
251
+ )}
252
+ </div>
253
+
254
+ {info && (
255
+ <Suspense fallback={null}>
256
+ <HistoryChangesModal
257
+ isOpen
258
+ onClose={() => { setInfo(null); setRestoreError(null); }}
259
+ title={info.sublabel ? `${info.label} · ${info.sublabel}` : info.label}
260
+ base={info.baseSha}
261
+ head={info.newestSha ?? ""}
262
+ commitCount={info.count}
263
+ hasUnsavedChanges={hasUnsavedChanges}
264
+ isRestoring={restoring}
265
+ restoreError={restoreError}
266
+ onBrowse={() => { browse(info); setInfo(null); }}
267
+ onRestore={() => {
268
+ // Block a second dispatch while the first is still loading and
269
+ // saving — that would be two racing writes to the same content.
270
+ if (restoring || !info.newestSha) return;
271
+ setRestoreError(null);
272
+ setRestoring(true);
273
+ historyRestoreEvent.dispatch({
274
+ sha: info.newestSha,
275
+ date: dateOf(info.newestSha),
276
+ });
277
+ }}
278
+ />
279
+ </Suspense>
280
+ )}
102
281
  </div>
103
282
  );
104
283
  }
@@ -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(() => {