@drawnagency/primitives 0.1.94 → 0.1.96

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 (82) hide show
  1. package/dist/{chunk-UFOWXOG6.js → chunk-CS2ECR3U.js} +9 -3
  2. package/dist/{chunk-GDLH3LVH.js → chunk-CSVGCAIH.js} +7 -6
  3. package/dist/chunk-EUSWEVOA.js +29 -0
  4. package/dist/{chunk-677NTHAZ.js → chunk-PTOLUMGV.js} +3 -0
  5. package/dist/{chunk-XYFZ57AS.js → chunk-YUUUCDXN.js} +59 -5
  6. package/dist/components/brandguide/Colors.d.ts +2 -2
  7. package/dist/components/brandguide/Colors.d.ts.map +1 -1
  8. package/dist/components/brandguide/ColorsEditable.d.ts +2 -2
  9. package/dist/components/brandguide/ColorsEditable.d.ts.map +1 -1
  10. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  11. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  12. package/dist/components/editor/index.js +4 -3
  13. package/dist/components/primitives/index.js +1 -1
  14. package/dist/components/sections/Container/Container.d.ts +2 -2
  15. package/dist/components/sections/Container/Container.d.ts.map +1 -1
  16. package/dist/components/sections/brand-guide.js +2 -2
  17. package/dist/components/sections/register-schemas.js +4 -4
  18. package/dist/components/sections/register.js +4 -4
  19. package/dist/components/shared/HistoryChangesModal.d.ts +39 -0
  20. package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -0
  21. package/dist/components/shared/HistoryPopover.d.ts +3 -1
  22. package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
  23. package/dist/components/shared/Navigation.d.ts.map +1 -1
  24. package/dist/components/shared/Popover.d.ts +10 -1
  25. package/dist/components/shared/Popover.d.ts.map +1 -1
  26. package/dist/components/shell/EditorModal.d.ts +6 -1
  27. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  28. package/dist/components/shell/EditorModalContext.d.ts +3 -2
  29. package/dist/components/shell/EditorModalContext.d.ts.map +1 -1
  30. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  31. package/dist/hooks/useEditorPublish.d.ts +5 -1
  32. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  33. package/dist/index.js +45 -37
  34. package/dist/lib/events.d.ts +18 -0
  35. package/dist/lib/events.d.ts.map +1 -1
  36. package/dist/lib/history-buckets.d.ts +30 -0
  37. package/dist/lib/history-buckets.d.ts.map +1 -0
  38. package/dist/lib/index-ops.d.ts +77 -0
  39. package/dist/lib/index-ops.d.ts.map +1 -0
  40. package/dist/lib/index-ops.js +223 -0
  41. package/dist/lib/index.d.ts +1 -1
  42. package/dist/lib/index.d.ts.map +1 -1
  43. package/dist/lib/index.js +6 -4
  44. package/dist/lib/media-folders.d.ts +13 -0
  45. package/dist/lib/media-folders.d.ts.map +1 -1
  46. package/dist/lib/media-folders.js +172 -0
  47. package/dist/lib/registry.d.ts +7 -1
  48. package/dist/lib/registry.d.ts.map +1 -1
  49. package/dist/lib/theme-vars.d.ts +19 -4
  50. package/dist/lib/theme-vars.d.ts.map +1 -1
  51. package/dist/schemas/index.js +1 -1
  52. package/dist/storage/index.d.ts +1 -1
  53. package/dist/storage/index.d.ts.map +1 -1
  54. package/dist/storage/index.js +9 -3
  55. package/dist/storage/types.d.ts +44 -0
  56. package/dist/storage/types.d.ts.map +1 -1
  57. package/package.json +9 -1
  58. package/src/components/brandguide/Colors.tsx +2 -1
  59. package/src/components/brandguide/ColorsEditable.tsx +2 -1
  60. package/src/components/editor/SectionWrapper.tsx +1 -0
  61. package/src/components/editor/SettingsForm.tsx +5 -2
  62. package/src/components/sections/Container/Container.tsx +5 -4
  63. package/src/components/shared/HistoryChangesModal.tsx +256 -0
  64. package/src/components/shared/HistoryPopover.tsx +255 -76
  65. package/src/components/shared/Navigation.tsx +30 -9
  66. package/src/components/shared/Popover.tsx +95 -2
  67. package/src/components/shell/EditorModal.tsx +26 -4
  68. package/src/components/shell/EditorModalContext.tsx +5 -4
  69. package/src/components/shell/EditorShell.tsx +51 -2
  70. package/src/components/shell/SiteSettingsModal.tsx +1 -1
  71. package/src/hooks/useEditorPublish.ts +15 -6
  72. package/src/lib/events.ts +11 -0
  73. package/src/lib/history-buckets.ts +190 -0
  74. package/src/lib/index-ops.ts +309 -0
  75. package/src/lib/index.ts +1 -1
  76. package/src/lib/media-folders.ts +16 -0
  77. package/src/lib/registry.ts +9 -1
  78. package/src/lib/theme-vars.ts +25 -4
  79. package/src/storage/index.ts +1 -1
  80. package/src/storage/types.ts +55 -0
  81. package/dist/chunk-VAOW4WXQ.js +0 -15
  82. package/dist/{chunk-ZKGMLLUQ.js → chunk-IFCQWC3G.js} +3 -3
@@ -0,0 +1,256 @@
1
+ import { useEffect, useState } from "react";
2
+ import { EditorModal } from "../shell/EditorModal";
3
+ import { Button } from "./Button";
4
+
5
+ export interface ChangedSection {
6
+ id: string;
7
+ /** The section's heading, or null when it has none / wasn't resolved. */
8
+ title: string | null;
9
+ type: string;
10
+ page: string | null;
11
+ }
12
+
13
+ /** "icon_list" → "Icon list". */
14
+ function humanizeType(type: string) {
15
+ const spaced = type.replace(/[-_]/g, " ");
16
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
17
+ }
18
+
19
+ /**
20
+ * Section ids are UUIDs, and plenty of section types (media, spacer) have no
21
+ * heading to show. Fall back to the type name alone — the page shown alongside
22
+ * it carries enough context, and an id fragment reads as noise. Two headless
23
+ * sections of the same type on one page will look alike; that's accepted.
24
+ */
25
+ function displayName(s: ChangedSection) {
26
+ return s.title ?? humanizeType(s.type);
27
+ }
28
+
29
+ export interface ChangeSummary {
30
+ edited: ChangedSection[];
31
+ added: ChangedSection[];
32
+ removed: ChangedSection[];
33
+ structure: string[];
34
+ /** Too many sections changed to resolve real titles — ids shown instead. */
35
+ truncated: boolean;
36
+ }
37
+
38
+ interface HistoryChangesModalProps {
39
+ isOpen: boolean;
40
+ onClose: () => void;
41
+ /** Node label, e.g. "Friday · Jul 24" or "Edit at 4:47 PM". */
42
+ title: string;
43
+ base: string | null;
44
+ head: string;
45
+ commitCount: number;
46
+ onBrowse: () => void;
47
+ onRestore: () => void;
48
+ /**
49
+ * True when the editor holds unsaved local changes. Restoring discards them,
50
+ * and unlike published content they are NOT recoverable from history — so the
51
+ * confirmation has to say so rather than reassure.
52
+ */
53
+ hasUnsavedChanges?: boolean;
54
+ /** Disables the restore controls while a restore is in flight. */
55
+ isRestoring?: boolean;
56
+ /** Set when a dispatched restore came back failed. */
57
+ restoreError?: string | null;
58
+ }
59
+
60
+ // Commit ranges are immutable, so a fetched summary stays valid for the session.
61
+ const cache = new Map<string, ChangeSummary>();
62
+
63
+ /**
64
+ * Bump whenever the response shape or the meaning of a field changes.
65
+ *
66
+ * The endpoint sends `immutable` with a one-year max-age — correct for the
67
+ * *content* of a commit range, which genuinely never changes, but it means a
68
+ * browser that cached an older shape would keep serving it and no reload could
69
+ * dislodge it. Versioning the URL makes a shape change a different cache entry.
70
+ * v2: `title` became nullable (was the section id, which is a UUID).
71
+ */
72
+ const API_VERSION = 2;
73
+
74
+ function queryFor(head: string, base: string | null) {
75
+ const q = new URLSearchParams({ v: String(API_VERSION), head });
76
+ if (base) q.set("base", base);
77
+ return `/api/history/changes?${q.toString()}`;
78
+ }
79
+
80
+ function Group({ label, items, sign }: { label: string; items: ChangedSection[]; sign: string }) {
81
+ if (items.length === 0) return null;
82
+ return (
83
+ <>
84
+ <p className="mb-1.5 text-[10px] font-medium tracking-wider text-base-contrast-light uppercase">
85
+ {label} ({items.length})
86
+ </p>
87
+ <ul className="mb-4 list-none p-0">
88
+ {items.map((s) => (
89
+ <li
90
+ key={s.id}
91
+ className="flex items-baseline gap-2 border-b border-base-200 py-1 last:border-b-0"
92
+ >
93
+ <span aria-hidden="true" className="w-3 shrink-0 text-center text-xs font-bold text-base-contrast-light">
94
+ {sign}
95
+ </span>
96
+ <span className="text-sm text-base-contrast">{displayName(s)}</span>
97
+ {s.page && <span className="text-xs text-base-contrast-light">· {s.page}</span>}
98
+ <span className="ml-auto shrink-0 pl-2 text-xs text-base-contrast-light">{s.type}</span>
99
+ </li>
100
+ ))}
101
+ </ul>
102
+ </>
103
+ );
104
+ }
105
+
106
+ export function HistoryChangesModal({
107
+ isOpen, onClose, title, base, head, commitCount, onBrowse, onRestore,
108
+ hasUnsavedChanges = false, isRestoring = false, restoreError = null,
109
+ }: HistoryChangesModalProps) {
110
+ const [summary, setSummary] = useState<ChangeSummary | null>(null);
111
+ const [error, setError] = useState(false);
112
+ const [confirmingRestore, setConfirmingRestore] = useState(false);
113
+
114
+ // Reopening on a different range must not inherit the previous confirm state.
115
+ useEffect(() => {
116
+ if (!isOpen) setConfirmingRestore(false);
117
+ }, [isOpen]);
118
+
119
+ useEffect(() => {
120
+ if (!isOpen) return;
121
+ const url = queryFor(head, base);
122
+ const hit = cache.get(url);
123
+ if (hit) {
124
+ setSummary(hit);
125
+ setError(false);
126
+ return;
127
+ }
128
+ let live = true;
129
+ setSummary(null);
130
+ setError(false);
131
+ (async () => {
132
+ try {
133
+ const res = await fetch(url);
134
+ if (!res.ok) throw new Error(String(res.status));
135
+ const data = (await res.json()) as ChangeSummary;
136
+ cache.set(url, data);
137
+ if (live) setSummary(data);
138
+ } catch {
139
+ if (live) setError(true);
140
+ }
141
+ })();
142
+ return () => { live = false; };
143
+ }, [isOpen, head, base]);
144
+
145
+ const total = summary
146
+ ? summary.edited.length + summary.added.length + summary.removed.length
147
+ : 0;
148
+
149
+ return (
150
+ // `tabbed` locks the dialog to one height so it doesn't jump when the
151
+ // summary arrives, and gives an overflow-hidden flex column — which is what
152
+ // lets the body scroll under a pinned footer. `noPadding` because the
153
+ // scrolling region owns its own padding.
154
+ <EditorModal
155
+ isOpen={isOpen}
156
+ onClose={onClose}
157
+ title={confirmingRestore ? "Restore this version?" : title}
158
+ size="standard"
159
+ tabbed
160
+ noPadding
161
+ >
162
+ <div className="flex min-h-0 flex-1 flex-col">
163
+ <div className="flex-1 overflow-y-auto px-6 py-4">
164
+ {confirmingRestore ? (
165
+ <>
166
+ <p className="text-sm text-base-contrast-light">
167
+ This will publish the content from this version as a new update.
168
+ Your currently published content stays available in the history.
169
+ </p>
170
+ {restoreError && (
171
+ <p role="alert" className="mt-3 text-sm font-medium text-red-600">
172
+ {restoreError} Nothing was published — you can try again.
173
+ </p>
174
+ )}
175
+ {hasUnsavedChanges && (
176
+ <p className="mt-3 text-sm font-medium text-red-600">
177
+ You have unsaved changes. Restoring discards them, and unlike
178
+ published content they cannot be recovered from history.
179
+ </p>
180
+ )}
181
+ </>
182
+ ) : (
183
+ <>
184
+ {error && (
185
+ <p className="py-6 text-center text-sm text-base-contrast-light">
186
+ Couldn&rsquo;t load the change summary.
187
+ </p>
188
+ )}
189
+
190
+ {!error && !summary && (
191
+ <p className="py-6 text-center text-sm text-base-contrast-light">
192
+ Loading changes&hellip;
193
+ </p>
194
+ )}
195
+
196
+ {summary && (
197
+ <>
198
+ <p className="mb-4 text-xs text-base-contrast-light">
199
+ {commitCount === 1 ? "Single edit" : `${commitCount} edits`}
200
+ {" · "}
201
+ {total} section{total === 1 ? "" : "s"} affected
202
+ </p>
203
+
204
+ <Group label="Edited" items={summary.edited} sign="~" />
205
+ <Group label="Added" items={summary.added} sign="+" />
206
+ <Group label="Removed" items={summary.removed} sign="−" />
207
+
208
+ {summary.structure.length > 0 && (
209
+ <>
210
+ <p className="mb-1.5 text-[10px] font-medium tracking-wider text-base-contrast-light uppercase">
211
+ Page structure
212
+ </p>
213
+ <ul className="mb-4 list-none p-0">
214
+ {summary.structure.map((s) => (
215
+ <li key={s} className="py-1 text-sm text-base-contrast">{s}</li>
216
+ ))}
217
+ </ul>
218
+ </>
219
+ )}
220
+
221
+ {summary.truncated && (
222
+ <p className="mb-4 text-xs text-base-contrast-light">
223
+ Too many sections changed to resolve names &mdash; showing section
224
+ types instead.
225
+ </p>
226
+ )}
227
+ </>
228
+ )}
229
+ </>
230
+ )}
231
+ </div>
232
+
233
+ <div className="flex shrink-0 justify-end gap-2 border-t border-base-200 px-6 py-3">
234
+ {confirmingRestore ? (
235
+ <>
236
+ <Button variant="secondary" size="sm" disabled={isRestoring}
237
+ onClick={() => setConfirmingRestore(false)}>
238
+ Cancel
239
+ </Button>
240
+ <Button variant="destructive" size="sm" disabled={isRestoring} onClick={onRestore}>
241
+ {isRestoring ? "Restoring…" : "Restore"}
242
+ </Button>
243
+ </>
244
+ ) : (
245
+ <>
246
+ <Button variant="destructive" size="sm" onClick={() => setConfirmingRestore(true)}>
247
+ Restore&hellip;
248
+ </Button>
249
+ <Button size="sm" onClick={onBrowse}>Browse this version</Button>
250
+ </>
251
+ )}
252
+ </div>
253
+ </div>
254
+ </EditorModal>
255
+ );
256
+ }
@@ -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
  }