@drawnagency/primitives 0.1.96 → 0.1.98

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 (87) hide show
  1. package/dist/{chunk-CI624HRS.js → chunk-26ALJO4K.js} +1 -1
  2. package/dist/{chunk-SOILPZ7U.js → chunk-3COSXTOY.js} +1 -1
  3. package/dist/{chunk-RHWWQV3J.js → chunk-BGHWUJFV.js} +1 -0
  4. package/dist/{chunk-RQPHPAT2.js → chunk-EWNDBKYK.js} +2 -2
  5. package/dist/{chunk-3HJDMFTB.js → chunk-FOY5TF7D.js} +1 -1
  6. package/dist/{chunk-YUUUCDXN.js → chunk-GYCBWGVW.js} +1 -1
  7. package/dist/chunk-HJSE6SK5.js +27 -0
  8. package/dist/{chunk-CSVGCAIH.js → chunk-HKHEAH5F.js} +7 -7
  9. package/dist/{chunk-IFCQWC3G.js → chunk-KP4DWTPR.js} +3 -3
  10. package/dist/{chunk-DJAJRBPD.js → chunk-MUTHF2GT.js} +2 -2
  11. package/dist/{chunk-CS2ECR3U.js → chunk-NA5EJM6Z.js} +24 -6
  12. package/dist/chunk-TGNS7D4F.js +200 -0
  13. package/dist/collab/rebase.d.ts +20 -0
  14. package/dist/collab/rebase.d.ts.map +1 -1
  15. package/dist/collab/rebase.js +17 -0
  16. package/dist/components/editor/DropEdgeIndicator.d.ts +6 -1
  17. package/dist/components/editor/DropEdgeIndicator.d.ts.map +1 -1
  18. package/dist/components/editor/NavGroupRow.d.ts +21 -0
  19. package/dist/components/editor/NavGroupRow.d.ts.map +1 -0
  20. package/dist/components/editor/PagesModal.d.ts +7 -1
  21. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  22. package/dist/components/editor/index.js +5 -5
  23. package/dist/components/primitives/index.js +7 -7
  24. package/dist/components/sections/brand-guide.js +6 -6
  25. package/dist/components/sections/register-schemas.js +10 -10
  26. package/dist/components/sections/register.js +10 -10
  27. package/dist/components/shared/Navigation.d.ts.map +1 -1
  28. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  29. package/dist/hooks/useEditorPersistence.d.ts +7 -0
  30. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  31. package/dist/hooks/useSortableNavRow.d.ts +27 -0
  32. package/dist/hooks/useSortableNavRow.d.ts.map +1 -0
  33. package/dist/index.js +26 -8
  34. package/dist/lib/dexie.d.ts.map +1 -1
  35. package/dist/lib/dexie.js +9 -2
  36. package/dist/lib/index-ops.d.ts +138 -2
  37. package/dist/lib/index-ops.d.ts.map +1 -1
  38. package/dist/lib/index-ops.js +176 -8
  39. package/dist/lib/index.d.ts +1 -0
  40. package/dist/lib/index.d.ts.map +1 -1
  41. package/dist/lib/index.js +17 -6
  42. package/dist/lib/loader.d.ts +2 -2
  43. package/dist/lib/loader.d.ts.map +1 -1
  44. package/dist/lib/nav-tree.d.ts +63 -0
  45. package/dist/lib/nav-tree.d.ts.map +1 -0
  46. package/dist/lib/nav.d.ts +13 -2
  47. package/dist/lib/nav.d.ts.map +1 -1
  48. package/dist/lib/pages.d.ts +0 -1
  49. package/dist/lib/pages.d.ts.map +1 -1
  50. package/dist/lib/reorder.d.ts +24 -0
  51. package/dist/lib/reorder.d.ts.map +1 -1
  52. package/dist/media/index.js +3 -3
  53. package/dist/media/provider.d.ts +0 -1
  54. package/dist/media/provider.d.ts.map +1 -1
  55. package/dist/schemas/index.d.ts +1 -0
  56. package/dist/schemas/index.d.ts.map +1 -1
  57. package/dist/schemas/index.js +10 -2
  58. package/dist/schemas/nav.d.ts +40 -0
  59. package/dist/schemas/nav.d.ts.map +1 -0
  60. package/dist/schemas/site-config.d.ts +12 -1
  61. package/dist/schemas/site-config.d.ts.map +1 -1
  62. package/dist/types/database.d.ts +0 -3
  63. package/dist/types/database.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/collab/rebase.ts +35 -0
  66. package/src/components/editor/DropEdgeIndicator.tsx +15 -8
  67. package/src/components/editor/NavGroupRow.tsx +126 -0
  68. package/src/components/editor/PagesModal.tsx +150 -29
  69. package/src/components/shared/Navigation.tsx +51 -13
  70. package/src/components/shell/EditorShell.tsx +67 -18
  71. package/src/hooks/useEditorPersistence.ts +14 -0
  72. package/src/hooks/useSortableNavRow.ts +139 -0
  73. package/src/lib/dexie.ts +12 -2
  74. package/src/lib/index-ops.ts +261 -7
  75. package/src/lib/index.ts +1 -0
  76. package/src/lib/loader.ts +31 -4
  77. package/src/lib/nav-tree.ts +192 -0
  78. package/src/lib/nav.ts +43 -10
  79. package/src/lib/pages.ts +14 -11
  80. package/src/lib/reorder.ts +56 -0
  81. package/src/media/provider.ts +0 -1
  82. package/src/schemas/index.ts +1 -0
  83. package/src/schemas/nav.ts +36 -0
  84. package/src/schemas/site-config.ts +12 -2
  85. package/src/types/database.ts +0 -3
  86. package/dist/chunk-YBVK254P.js +0 -14
  87. /package/dist/{chunk-53DKVAB6.js → chunk-72CG6AA2.js} +0 -0
@@ -11,8 +11,10 @@ import { Input } from "../shared/Input";
11
11
  import { Toggle } from "../shared/Toggle";
12
12
  import { AudienceIndicator } from "./AudienceIndicator";
13
13
  import { DragHandle } from "./DragHandle";
14
- import { useSortableRow } from "../../hooks/useSortableRow";
14
+ import { useSortableNavRow } from "../../hooks/useSortableNavRow";
15
15
  import { DropEdgeIndicator } from "./DropEdgeIndicator";
16
+ import { navEntries, type NavLocation } from "../../lib/nav-tree";
17
+ import { NavGroupRow } from "./NavGroupRow";
16
18
  import { slugifyPageSlug, uniquePageSlug, pageDisplayTitle } from "../../lib/pages";
17
19
  import { pluralize } from "../../lib/text";
18
20
  import { RESERVED_SLUGS } from "../../schemas/site-config";
@@ -21,9 +23,14 @@ import { cn } from "../../lib/cn";
21
23
  export interface PagesModalProps {
22
24
  index: SiteIndex;
23
25
  audiences: Audience[];
24
- onReorder: (fromIndex: number, toIndex: number) => void;
25
26
  /** Creates the page and returns its id so the modal can expand the new row. */
26
27
  onAddPage: () => string;
28
+ /** Creates the group and returns its id so the modal can open its settings. */
29
+ onAddGroup: () => string;
30
+ onRenameGroup: (groupId: string, label: string) => void;
31
+ onSetGroupStartCollapsed: (groupId: string, v: boolean) => void;
32
+ onDeleteGroup: (groupId: string) => void;
33
+ onMoveNavEntry: (from: NavLocation, to: NavLocation) => void;
27
34
  onSetHome: (pageId: string) => void;
28
35
  onSetArchived: (pageId: string, archived: boolean) => void;
29
36
  onSetFields: (pageId: string, patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>) => void;
@@ -39,13 +46,20 @@ export function PagesModal(props: PagesModalProps) {
39
46
  // The page created via "+ Add page" this session: its title gets auto-focus and
40
47
  // its slug follows the title until the user edits the slug by hand.
41
48
  const [newPageId, setNewPageId] = useState<string | null>(null);
49
+ // The group created via "+ Add group" this session: its settings panel opens
50
+ // with the label input focused, mirroring newPageId for pages.
51
+ const [newGroupId, setNewGroupId] = useState<string | null>(null);
52
+ // The group currently being dragged. Its members unmount for the duration, so
53
+ // the drag reads as moving the whole block rather than just the header.
54
+ // Groups are otherwise always expanded — there is no editor-side collapse, so
55
+ // no collapsed-group rescue (spring-load, reveal-on-drop) is needed.
56
+ const [draggingGroupId, setDraggingGroupId] = useState<string | null>(null);
42
57
 
43
58
  if (deletingId) {
44
59
  const page = index.pages.find((p) => p.id === deletingId);
45
60
  if (page) return <DeleteConfirm page={page} onCancel={() => setDeletingId(null)} onConfirm={() => { props.onConfirmDelete(page.id); setDeletingId(null); }} />;
46
61
  }
47
62
 
48
- const live = index.pages.filter((p) => p.status === "live");
49
63
  const archived = index.pages.filter((p) => p.status === "archived");
50
64
 
51
65
  // Collapsing the new row — or expanding any other — ends its "new page"
@@ -56,7 +70,9 @@ export function PagesModal(props: PagesModalProps) {
56
70
  if (newPageId !== null && next !== newPageId) setNewPageId(null);
57
71
  };
58
72
 
59
- const renderPage = (page: Page, dragIndex?: number) => (
73
+ // `location` is optional: the archived flat block renders inert rows with no
74
+ // nav position, exactly as it did when draggability keyed off `dragIndex`.
75
+ const renderPage = (page: Page, location?: NavLocation, entryIndex?: number) => (
60
76
  <PageRow
61
77
  key={page.id}
62
78
  page={page}
@@ -64,8 +80,9 @@ export function PagesModal(props: PagesModalProps) {
64
80
  expanded={expandedId === page.id}
65
81
  isNew={newPageId === page.id}
66
82
  otherSlugs={index.pages.filter((p) => p.id !== page.id).map((p) => p.slug)}
67
- dragIndex={dragIndex}
68
- onReorder={props.onReorder}
83
+ location={location}
84
+ entryIndex={entryIndex}
85
+ onMove={props.onMoveNavEntry}
69
86
  onToggleExpand={() => toggleExpand(page.id)}
70
87
  onSetHome={() => props.onSetHome(page.id)}
71
88
  onSetArchived={(v) => {
@@ -86,20 +103,78 @@ export function PagesModal(props: PagesModalProps) {
86
103
  return (
87
104
  <div className="flex flex-col gap-2 overflow-y-auto">
88
105
  <div className="flex flex-col gap-2">
89
- {live.map((page) => renderPage(page, index.pages.indexOf(page)))}
106
+ {navEntries(index).map((entry, entryIndex) => {
107
+ if (entry.kind === "page") {
108
+ const page = index.pages.find((p) => p.id === entry.pageId);
109
+ if (!page || page.status !== "live") return null;
110
+ return renderPage(page, { groupId: null, index: entryIndex }, entryIndex);
111
+ }
112
+
113
+ // CRITICAL: carry the ORIGINAL pageIds index (`pi`) through the filter.
114
+ // moveNavEntry splices `group.pageIds`, which retains ARCHIVED member
115
+ // ids — archiving only flips `status`, and reconcileNav keeps any id
116
+ // that still exists in pages[]. Renumbering members 0..n over the
117
+ // live-filtered list would make the visible row for a member claim the
118
+ // archived member's slot, so dragging it would move the wrong page.
119
+ const members = entry.pageIds
120
+ .map((id, pi) => ({ page: index.pages.find((p) => p.id === id), pi }))
121
+ .filter((m): m is { page: Page; pi: number } => !!m.page && m.page.status === "live");
122
+ const expanded = draggingGroupId !== entry.id;
123
+
124
+ return (
125
+ <div key={entry.id} className="flex flex-col gap-2">
126
+ <NavGroupRow
127
+ group={entry}
128
+ entryIndex={entryIndex}
129
+ memberCount={members.length}
130
+ autoOpenSettings={newGroupId === entry.id}
131
+ onRename={(label) => props.onRenameGroup(entry.id, label)}
132
+ onSetStartCollapsed={(v) => props.onSetGroupStartCollapsed(entry.id, v)}
133
+ onDelete={() => props.onDeleteGroup(entry.id)}
134
+ onMove={props.onMoveNavEntry}
135
+ onDragStateChange={(dragging) => setDraggingGroupId(dragging ? entry.id : null)}
136
+ />
137
+ {expanded && (
138
+ <div data-testid={`nav-group-members-${entry.id}`} className="ml-6 flex flex-col gap-2">
139
+ {members.length === 0
140
+ ? <EmptyGroupDropzone groupId={entry.id} onMove={props.onMoveNavEntry} />
141
+ : members.map(({ page, pi }) => renderPage(page, { groupId: entry.id, index: pi }, entryIndex))}
142
+ </div>
143
+ )}
144
+ </div>
145
+ );
146
+ })}
147
+ </div>
148
+
149
+ {/* The only target for "after the last entry" when that entry is a group:
150
+ every edge of a trailing group resolves to a position inside it. */}
151
+ <TailDropzone entryCount={navEntries(index).length} onMove={props.onMoveNavEntry} />
152
+
153
+ <div className="mt-2 flex gap-2">
154
+ <Button
155
+ variant="secondary"
156
+ size="md"
157
+ onClick={() => {
158
+ const id = props.onAddPage();
159
+ setNewPageId(id);
160
+ setExpandedId(id);
161
+ }}
162
+ >
163
+ + Add page
164
+ </Button>
165
+ <Button
166
+ variant="secondary"
167
+ size="md"
168
+ onClick={() => {
169
+ // Keep the returned id — it drives autoOpenSettings so the new
170
+ // group lands with its label input focused, mirroring + Add page.
171
+ const id = props.onAddGroup();
172
+ setNewGroupId(id);
173
+ }}
174
+ >
175
+ + Add group
176
+ </Button>
90
177
  </div>
91
- <Button
92
- variant="secondary"
93
- size="md"
94
- onClick={() => {
95
- const id = props.onAddPage();
96
- setNewPageId(id);
97
- setExpandedId(id);
98
- }}
99
- className="mt-2 self-start"
100
- >
101
- + Add page
102
- </Button>
103
178
  {archived.length > 0 && (
104
179
  <div className="mt-4 border-t border-base-200 pt-3">
105
180
  <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-base-contrast/70">Archive</p>
@@ -111,23 +186,22 @@ export function PagesModal(props: PagesModalProps) {
111
186
  }
112
187
 
113
188
  function PageRow({
114
- page, audiences, expanded, isNew, otherSlugs, dragIndex, onReorder, onToggleExpand, onSetHome, onSetArchived, onSetFields, onSetAudience, onSetDocument, onRequestDelete,
189
+ page, audiences, expanded, isNew, otherSlugs, location, entryIndex, onMove, onToggleExpand, onSetHome, onSetArchived, onSetFields, onSetAudience, onSetDocument, onRequestDelete,
115
190
  }: {
116
191
  page: Page; audiences: Audience[]; expanded: boolean;
117
192
  isNew: boolean; otherSlugs: string[];
118
- dragIndex?: number; onReorder: (fromIndex: number, toIndex: number) => void;
193
+ location?: NavLocation; entryIndex?: number;
194
+ onMove: (from: NavLocation, to: NavLocation) => void;
119
195
  onToggleExpand: () => void; onSetHome: () => void; onSetArchived: (v: boolean) => void;
120
196
  onSetFields: (patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>) => void;
121
197
  onSetAudience: (access: string[]) => void;
122
198
  onSetDocument: (patch: Partial<DocumentOptions>) => void;
123
199
  onRequestDelete: () => void;
124
200
  }) {
125
- const draggable = dragIndex !== undefined;
126
- const { rowRef, handleRef, isDragging, closestEdge } = useSortableRow<HTMLDivElement, HTMLButtonElement>({
127
- index: dragIndex ?? 0,
128
- dragType: "page-ordering-row",
129
- onReorder,
130
- enabled: draggable,
201
+ const draggable = location !== undefined;
202
+ const { rowRef, handleRef, isDragging, closestEdge } = useSortableNavRow<HTMLDivElement, HTMLButtonElement>({
203
+ row: { kind: "page", pageId: page.id, groupId: location?.groupId ?? null, index: location?.index ?? 0 },
204
+ entryIndex: entryIndex ?? 0, kind: "page", onMove, enabled: draggable,
131
205
  });
132
206
 
133
207
  // The slug input edits a local draft; only valid values commit to the index,
@@ -205,21 +279,31 @@ function PageRow({
205
279
  return (
206
280
  <div
207
281
  ref={rowRef}
282
+ // Drag-and-drop has no jsdom coverage (atlaskit does not run there), so the
283
+ // Playwright pass in e2e/page-groups.spec.ts is its only automated check —
284
+ // and it needs a row anchor that outlives styling changes. Same reason for
285
+ // the group, member-list and dropzone testids below.
286
+ data-testid={`nav-row-page-${page.id}`}
287
+ data-nav-group={location?.groupId ?? ""}
208
288
  className={cn("relative flex items-start gap-1", isDragging && "opacity-50")}
209
289
  >
210
- <DropEdgeIndicator edge={closestEdge} />
290
+ <DropEdgeIndicator edge={closestEdge} inset={location?.groupId != null} />
211
291
  {draggable && (
212
292
  <div className="flex shrink-0 items-center py-2 [&_[role=tooltip]]:hidden">
213
293
  <DragHandle ref={handleRef} />
214
294
  </div>
215
295
  )}
216
296
  <div className={cn("min-w-0 flex-1 rounded-md border border-base-200 bg-base", isDragging && "outline outline-2 outline-primary/50")}>
297
+ {/* w-full, not flex-1: the card wrapper is a block, so flex-1 is inert
298
+ here. A button's `width: auto` is fit-content (form controls size
299
+ intrinsically), which shrink-wraps the hover target and caret to the
300
+ text instead of spanning the row. */}
217
301
  <button
218
302
  type="button"
219
303
  aria-label={`Edit settings for ${pageDisplayTitle(page.title)}`}
220
304
  aria-expanded={expanded}
221
305
  onClick={handleHeaderClick}
222
- className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded py-2 pr-3 pl-4 text-left hover:bg-base-accent"
306
+ className="flex w-full min-w-0 cursor-pointer items-center gap-2 rounded py-2 pr-3 pl-4 text-left hover:bg-base-accent"
223
307
  >
224
308
  <div className="min-w-0 flex-1">
225
309
  <div className="flex items-center gap-2">
@@ -319,6 +403,43 @@ function PageRow({
319
403
  );
320
404
  }
321
405
 
406
+ function TailDropzone({ entryCount, onMove }: { entryCount: number; onMove: (from: NavLocation, to: NavLocation) => void }) {
407
+ const { rowRef, isDropTarget } = useSortableNavRow<HTMLDivElement, HTMLButtonElement>({
408
+ // entryIndex IS the top-level entry count for a tail row — see
409
+ // computeNavDropTarget's tail branch.
410
+ row: { kind: "tail" }, entryIndex: entryCount, kind: "page", onMove,
411
+ });
412
+ return (
413
+ <div
414
+ ref={rowRef}
415
+ aria-hidden="true"
416
+ data-testid="nav-tail-dropzone"
417
+ className={cn(
418
+ "h-6 rounded-md border border-dashed transition-colors",
419
+ isDropTarget ? "border-primary" : "border-transparent",
420
+ )}
421
+ />
422
+ );
423
+ }
424
+
425
+ function EmptyGroupDropzone({ groupId, onMove }: { groupId: string; onMove: (from: NavLocation, to: NavLocation) => void }) {
426
+ const { rowRef, isDropTarget } = useSortableNavRow<HTMLDivElement, HTMLButtonElement>({
427
+ row: { kind: "empty-group", groupId }, entryIndex: 0, kind: "page", onMove,
428
+ });
429
+ return (
430
+ <div
431
+ ref={rowRef}
432
+ data-testid={`nav-empty-group-${groupId}`}
433
+ className={cn(
434
+ "rounded-md border border-dashed px-3 py-4 text-center text-xs transition-colors",
435
+ isDropTarget ? "border-primary text-primary" : "border-base-200 text-base-contrast/60",
436
+ )}
437
+ >
438
+ Drag pages here
439
+ </div>
440
+ );
441
+ }
442
+
322
443
  function DeleteConfirm({ page, onCancel, onConfirm }: { page: Page; onCancel: () => void; onConfirm: () => void }) {
323
444
  const [typed, setTyped] = useState("");
324
445
  const n = page.order.length;
@@ -2,7 +2,8 @@ import { useState, useCallback, useEffect, useRef } from "react";
2
2
  import { ChevronRight } from "lucide-react";
3
3
  import { cn } from "../../lib/cn";
4
4
  import { Toggle } from "./Toggle";
5
- import type { SiteNav, NavItem } from "../../lib/nav";
5
+ import type { SiteNav, NavItem, GroupNavItem, PageNavItem } from "../../lib/nav";
6
+ import { groupDisplayLabel } from "../../lib/nav-tree";
6
7
  import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, editorDirtyEvent } from "../../lib/events";
7
8
  import { useActiveHeadings } from "../../hooks/useActiveHeadings";
8
9
  import { formatDate } from "../../lib/timestamp";
@@ -43,6 +44,9 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
43
44
  const [isDark, setIsDark] = useState(false);
44
45
  const [siteNav, setSiteNav] = useState<SiteNav>(initialNav);
45
46
  const [showHistory, setShowHistory] = useState(false);
47
+ // Keyed by group id for authored groups and by label for the derived editor
48
+ // buckets ("Hidden from Menu", "Archive"). Generated ids never collide with
49
+ // those two literals, so one record safely serves both.
46
50
  const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
47
51
  // Anchors the history panel to the "Last updated" block rather than the button
48
52
  // inside it: the block's top edge is the divider the panel sits above, and its
@@ -77,8 +81,9 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
77
81
 
78
82
  // The active page can live in any tier (editors navigate to hidden and
79
83
  // archived pages via the Pages modal).
84
+ const treePages = siteNav.entries.flatMap((e) => (e.kind === "group" ? e.pages : [e]));
80
85
  const activePage =
81
- [...siteNav.pages, ...siteNav.hidden, ...siteNav.archive].find((p) => p.isActive) ?? null;
86
+ [...treePages, ...siteNav.hidden, ...siteNav.archive].find((p) => p.isActive) ?? null;
82
87
  const headings: NavItem[] = activePage?.headings ?? [];
83
88
 
84
89
  const parentIds = headings.map((h) => h.href.replace("#", ""));
@@ -178,7 +183,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
178
183
  // a same-island handler is supplied; otherwise a real <a> for the viewer.
179
184
  const asButton = isEditMode || !!onPageSelect;
180
185
 
181
- const renderPage = (page: SiteNav["pages"][number]) => (
186
+ const renderPage = (page: PageNavItem) => (
182
187
  <li key={page.id}>
183
188
  {asButton ? (
184
189
  <button
@@ -201,23 +206,54 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
201
206
  </li>
202
207
  );
203
208
 
204
- const renderPageGroup = (label: string, pages: SiteNav["pages"]) => {
205
- if (pages.length === 0) return null;
206
- // Closed by default; auto-open when it holds the active page so the
207
- // editor's current location (and its heading tree) stays visible.
208
- const isOpen = openGroups[label] ?? pages.some((p) => p.isActive);
209
+ // The one collapsible-section body, shared by authored groups and the derived
210
+ // editor buckets so the two can never drift apart visually. Callers supply the
211
+ // wrapper element, because authored groups are interleaved <li>s inside the
212
+ // main nav list while the buckets are their own blocks below it.
213
+ const renderGroupBody = (key: string, label: string, pages: PageNavItem[], defaultOpen: boolean) => {
214
+ const isOpen = openGroups[key] ?? defaultOpen;
209
215
  return (
210
- <div className="mx-4 mt-2 border-t border-base-200 pt-2">
216
+ <>
211
217
  <button
212
218
  type="button"
213
219
  aria-expanded={isOpen}
214
- onClick={() => setOpenGroups((g) => ({ ...g, [label]: !isOpen }))}
220
+ onClick={() => setOpenGroups((g) => ({ ...g, [key]: !isOpen }))}
215
221
  className="flex w-full cursor-pointer items-center justify-between rounded-inner px-3 py-1 text-xs font-semibold uppercase tracking-wide text-base-contrast/70 transition-colors hover:text-primary"
216
222
  >
217
- <span>{`${label} (${pages.length})`}</span>
223
+ <span>{label}</span>
218
224
  <ChevronRight size={14} aria-hidden="true" className={cn("transition-transform", isOpen && "rotate-90")} />
219
225
  </button>
220
226
  {isOpen && <ul className="space-y-1">{pages.map(renderPage)}</ul>}
227
+ </>
228
+ );
229
+ };
230
+
231
+ const renderAuthoredGroup = (group: GroupNavItem) => (
232
+ // No mx-4: the enclosing list is already padded, unlike the bucket blocks.
233
+ <li key={group.id} className="mt-2 border-t border-base-200 pt-2">
234
+ {renderGroupBody(
235
+ group.id,
236
+ groupDisplayLabel(group.label),
237
+ group.pages,
238
+ // Force-open when the group holds the active page, so the current
239
+ // location and its heading tree are never hidden behind a collapsed
240
+ // label. Otherwise the authored startCollapsed default applies.
241
+ group.pages.some((p) => p.isActive) || !group.startCollapsed,
242
+ )}
243
+ </li>
244
+ );
245
+
246
+ // `showCount` is Archive-only: it is the one bucket where how much is in there
247
+ // is the point. Every other section header — Hidden from Menu and authored
248
+ // groups alike — reads as a plain label.
249
+ const renderPageGroup = (label: string, pages: PageNavItem[], showCount = false) => {
250
+ // Unlike an authored group, an empty bucket is pure noise — there is no
251
+ // user-created thing to keep on screen.
252
+ if (pages.length === 0) return null;
253
+ return (
254
+ <div className="mx-4 mt-2 border-t border-base-200 pt-2">
255
+ {/* Closed by default; auto-open when it holds the active page. */}
256
+ {renderGroupBody(label, showCount ? `${label} (${pages.length})` : label, pages, pages.some((p) => p.isActive))}
221
257
  </div>
222
258
  );
223
259
  };
@@ -251,11 +287,13 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
251
287
  </div>
252
288
 
253
289
  <ul className="space-y-1 px-4 py-2">
254
- {siteNav.pages.map(renderPage)}
290
+ {siteNav.entries.map((entry) =>
291
+ entry.kind === "group" ? renderAuthoredGroup(entry) : renderPage(entry),
292
+ )}
255
293
  </ul>
256
294
 
257
295
  {isEditMode && renderPageGroup("Hidden from Menu", siteNav.hidden)}
258
- {isEditMode && renderPageGroup("Archive", siteNav.archive)}
296
+ {isEditMode && renderPageGroup("Archive", siteNav.archive, true)}
259
297
 
260
298
  <div className="mt-auto">
261
299
  {currentDarkMode === "optional" && (
@@ -18,7 +18,7 @@ import { getMediaProvider } from "../../media";
18
18
  // Document mode toggle wiring needs the patch shape it edits (mirrors the same
19
19
  // sanctioned import in schemas/site-config.ts and lib/pages.ts).
20
20
  import type { DocumentOptions } from "../../document/engine/settings";
21
- import { SiteConfigSchema, IndexSchema } from "../../schemas/site-config";
21
+ import { SiteConfigSchema } from "../../schemas/site-config";
22
22
  import { ensureSanitizer } from "../../lib/sanitize";
23
23
  import { resolveChatbotName } from "../../lib/chatbot";
24
24
  import { EditorProvider, useEditorContext, type HistoryState } from "./EditorContext";
@@ -75,7 +75,7 @@ import {
75
75
  deleteSectionRows,
76
76
  preserveEdit,
77
77
  } from "../../lib/dexie";
78
- import { rebaseContent, reconcileRemoteIndex } from "../../collab/rebase";
78
+ import { rebaseContent, applyRemoteIndexUpdate } from "../../collab/rebase";
79
79
  import { deriveRecoveryInputs, applyConflictChoices } from "../../collab/recovery";
80
80
  import { getCollabProvider } from "../../collab/provider-registry";
81
81
  import type { CollabBootstrap, CollabPeer, CollabStatus, SavedMessage, TakeoverMessage } from "../../collab/types";
@@ -89,11 +89,12 @@ import { useBuildStatus } from "../../hooks/useBuildStatus";
89
89
  import { useMediaPipeline } from "../../hooks/useMediaPipeline";
90
90
  import { formatTimestamp } from "../../lib/timestamp";
91
91
  import { generateSiteNav } from "../../lib/nav";
92
+ import { pagesInNavOrder, moveNavEntry, addGroup, renameGroup, setGroupStartCollapsed, deleteGroup, type NavLocation } from "../../lib/nav-tree";
92
93
  import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, historyRestoreEvent, historyRestoreFailedEvent, editorDirtyEvent } from "../../lib/events";
93
94
  import {
94
95
  homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
95
96
  reorderSectionInPage, audiencePasses,
96
- setHomePage, setPageArchived, setPageFields, setPageAudience, setPageDocument, deletePage, addPage, reorderPages,
97
+ setHomePage, setPageArchived, setPageFields, setPageAudience, setPageDocument, deletePage, addPage,
97
98
  moveSection as moveSectionReducer,
98
99
  } from "../../lib/pages";
99
100
  import { PagesContext } from "../shared/PagesContext";
@@ -195,6 +196,13 @@ export default function EditorShell({
195
196
  const [mediaModalMode, setMediaModalMode] = useState<"select" | "manage">("manage");
196
197
  const [mediaSelectCallback, setMediaSelectCallback] = useState<((id: string) => void) | null>(null);
197
198
  const [deletedSections, setDeletedSections] = useState<Set<string>>(new Set());
199
+ // Ref mirror so the debounced Dexie flush can persist the deletion set. Without
200
+ // it the snapshot records the index minus the deleted sections but not the
201
+ // INTENT, and the restore path has to re-derive deletions by diffing the stale
202
+ // snapshot against a fresh remote read — which mistakes anything a third party
203
+ // (the MCP connector, a peer editor) added since for a local deletion.
204
+ const deletedSectionsRef = useRef<Set<string>>(deletedSections);
205
+ useEffect(() => { deletedSectionsRef.current = deletedSections; }, [deletedSections]);
198
206
  const [pendingDeleteSectionId, setPendingDeleteSectionId] = useState<string | null>(null);
199
207
  const [savedSha, setSavedSha] = useState<string | null>(null);
200
208
  const [mainSha, setMainSha] = useState<string | null>(null);
@@ -318,7 +326,7 @@ export default function EditorShell({
318
326
  [],
319
327
  );
320
328
 
321
- const persistence = useEditorPersistence(siteIndexRef, { onFlush });
329
+ const persistence = useEditorPersistence(siteIndexRef, { onFlush, deletedSectionsRef });
322
330
 
323
331
  const sectionsById = useMemo(() => {
324
332
  const map: Record<string, LoadedSection> = {};
@@ -678,8 +686,6 @@ export default function EditorShell({
678
686
  // the overlap — don't clobber local structure with the peer's. Durable
679
687
  // signal (survives the flush debounce), not isIndexDirty().
680
688
  if (persistence.hasUnsavedIndex()) return;
681
- const parsed = IndexSchema.safeParse(msg.index);
682
- if (!parsed.success) return;
683
689
  // Withhold ids our current index doesn't know before applying the peer's:
684
690
  // (a) the peer's unsaved ADD — its file isn't on the branch yet, so letting
685
691
  // it into siteIndexRef would poison OUR next save with a dangling index ref
@@ -689,7 +695,11 @@ export default function EditorShell({
689
695
  // loaded section bodies: a live section whose body the loader skipped
690
696
  // (schema/registry miss, depth trip) is still indexed and must survive —
691
697
  // guaranteed because we only reach here with no unsaved index work.
692
- const pruned = reconcileRemoteIndex(parsed.data, siteIndexRef.current);
698
+ // Parses, preserves the peer's nav across IndexSchema's key strip, and
699
+ // prunes — see applyRemoteIndexUpdate. Null means the payload wasn't a
700
+ // valid index.
701
+ const pruned = applyRemoteIndexUpdate(msg.index, siteIndexRef.current);
702
+ if (!pruned) return;
693
703
  setSiteIndex(pruned);
694
704
  siteIndexRef.current = pruned;
695
705
  },
@@ -1086,15 +1096,27 @@ export default function EditorShell({
1086
1096
  }).filter((s): s is LoadedSection => s !== null),
1087
1097
  );
1088
1098
  setSiteIndex(restoredIndex);
1089
- // Re-derive pending deletions: any remotely-existing section absent from the
1090
- // restored index was deleted locally before this reload. The deletion intent
1091
- // isn't persisted directly, so reconstruct it here — otherwise the section's
1092
- // GitHub file would be orphaned (index omits it, but it's never deleted).
1099
+ // Pending deletions come from the snapshot's own persisted intent, and are
1100
+ // intersected with what the branch still has so an already-applied deletion
1101
+ // can't be replayed.
1102
+ //
1103
+ // They are deliberately NOT re-derived by diffing the restored index against
1104
+ // a fresh remote read. That inference cannot distinguish "this editor deleted
1105
+ // it" from "someone else added it after this snapshot was taken" — every
1106
+ // section the MCP connector or a peer created in the meantime is absent from
1107
+ // the stale local index, so it would be queued for deletion and destroyed by
1108
+ // the next save. Deleting real content is far worse than the failure mode the
1109
+ // re-derivation guarded against (a section's file left orphaned when the
1110
+ // snapshot predates persisted intent, which validate_site reports and
1111
+ // pruneOrphans cleans up).
1093
1112
  const restoredIds = new Set(restoredIndex.pages.flatMap((p) => p.order));
1094
- const rediscoveredDeletions = new Set(
1095
- [...remoteSectionIdsRef.current].filter((id) => !restoredIds.has(id)),
1113
+ setDeletedSections(
1114
+ new Set(
1115
+ restored.deletedSections.filter(
1116
+ (id) => remoteSectionIdsRef.current.has(id) && !restoredIds.has(id),
1117
+ ),
1118
+ ),
1096
1119
  );
1097
- setDeletedSections(rediscoveredDeletions);
1098
1120
  // A restored Dexie siteIndex row IS unsaved structural work from the
1099
1121
  // prior session — prime the durable signal even though no markIndexDirty
1100
1122
  // ran this session.
@@ -1292,8 +1314,31 @@ export default function EditorShell({
1292
1314
  return id;
1293
1315
  }, [persistence]);
1294
1316
 
1295
- const handleReorderPages = useCallback((from: number, to: number) => {
1296
- setSiteIndex((prev) => reorderPages(prev, from, to));
1317
+ const handleMoveNavEntry = useCallback((from: NavLocation, to: NavLocation) => {
1318
+ setSiteIndex((prev) => moveNavEntry(prev, from, to));
1319
+ persistence.markIndexDirty(); setLocalChangesExist(true);
1320
+ }, [persistence]);
1321
+
1322
+ const handleAddGroup = useCallback(() => {
1323
+ // Mirror handleAddPage's id scheme, including its non-crypto fallback.
1324
+ const id = typeof crypto !== "undefined" && crypto.randomUUID ? `grp_${crypto.randomUUID()}` : `grp_${Date.now()}`;
1325
+ setSiteIndex((prev) => addGroup(prev, id));
1326
+ persistence.markIndexDirty(); setLocalChangesExist(true);
1327
+ return id;
1328
+ }, [persistence]);
1329
+
1330
+ const handleRenameGroup = useCallback((groupId: string, label: string) => {
1331
+ setSiteIndex((prev) => renameGroup(prev, groupId, label));
1332
+ persistence.markIndexDirty(); setLocalChangesExist(true);
1333
+ }, [persistence]);
1334
+
1335
+ const handleSetGroupStartCollapsed = useCallback((groupId: string, v: boolean) => {
1336
+ setSiteIndex((prev) => setGroupStartCollapsed(prev, groupId, v));
1337
+ persistence.markIndexDirty(); setLocalChangesExist(true);
1338
+ }, [persistence]);
1339
+
1340
+ const handleDeleteGroup = useCallback((groupId: string) => {
1341
+ setSiteIndex((prev) => deleteGroup(prev, groupId));
1297
1342
  persistence.markIndexDirty(); setLocalChangesExist(true);
1298
1343
  }, [persistence]);
1299
1344
 
@@ -1826,8 +1871,12 @@ export default function EditorShell({
1826
1871
  <PagesModal
1827
1872
  index={siteIndex}
1828
1873
  audiences={audiences}
1829
- onReorder={handleReorderPages}
1830
1874
  onAddPage={handleAddPage}
1875
+ onAddGroup={handleAddGroup}
1876
+ onRenameGroup={handleRenameGroup}
1877
+ onSetGroupStartCollapsed={handleSetGroupStartCollapsed}
1878
+ onDeleteGroup={handleDeleteGroup}
1879
+ onMoveNavEntry={handleMoveNavEntry}
1831
1880
  onSetHome={handleSetHome}
1832
1881
  onSetArchived={handleSetArchived}
1833
1882
  onSetFields={handleSetPageFields}
@@ -1839,7 +1888,7 @@ export default function EditorShell({
1839
1888
  <EditorModal isOpen={movingSectionId !== null} onClose={() => setMovingSectionId(null)} title="Move to page">
1840
1889
  {movingSectionId && resolvedActivePage && (
1841
1890
  <MoveSectionModal
1842
- pages={siteIndex.pages.map((p) => ({ id: p.id, title: pageDisplayTitle(p.title) }))}
1891
+ pages={pagesInNavOrder(siteIndex).map((p) => ({ id: p.id, title: pageDisplayTitle(p.title) }))}
1843
1892
  currentPageId={resolvedActivePage.id}
1844
1893
  onMove={(destId, position) => handleMoveSection(movingSectionId, destId, position)}
1845
1894
  onCancel={() => setMovingSectionId(null)}
@@ -34,6 +34,13 @@ interface PersistenceOptions {
34
34
  * updates + index updates to peers from here. Stored in a ref so its identity
35
35
  * can churn without recreating the debounce machinery. */
36
36
  onFlush?: (entries: { sectionId: string; content: SectionContent }[], indexFlushed: boolean) => void;
37
+ /** Ref mirror of the shell's pending-deletion set, read at flush time and
38
+ * persisted alongside the index snapshot. Deletion is INTENT that cannot be
39
+ * inferred back from the snapshot: the restore path would otherwise diff the
40
+ * stale local index against a fresh remote read and treat sections a third
41
+ * party added in the meantime (MCP connector, peer editor) as local
42
+ * deletions — then delete them on the next save. */
43
+ deletedSectionsRef?: React.RefObject<Set<string>>;
37
44
  }
38
45
 
39
46
  export function useEditorPersistence(
@@ -52,6 +59,8 @@ export function useEditorPersistence(
52
59
  const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
53
60
  const onFlushRef = useRef(options?.onFlush);
54
61
  useEffect(() => { onFlushRef.current = options?.onFlush; });
62
+ const deletedSectionsRef = useRef(options?.deletedSectionsRef);
63
+ useEffect(() => { deletedSectionsRef.current = options?.deletedSectionsRef; });
55
64
 
56
65
  const flushToDexie = useCallback(async () => {
57
66
  const s = state.current;
@@ -68,9 +77,14 @@ export function useEditorPersistence(
68
77
  s.indexDirty = false;
69
78
 
70
79
  try {
80
+ // The deletion set is cumulative in shell state (cleared only on save or
81
+ // discard), so every flush rewrites the whole current set — a later flush
82
+ // can never narrow an earlier record.
83
+ const pendingDeletions = deletedSectionsRef.current?.current;
71
84
  await persistAll(
72
85
  entries,
73
86
  wasIndexDirty ? siteIndexRef.current : undefined,
87
+ pendingDeletions && pendingDeletions.size > 0 ? [...pendingDeletions] : undefined,
74
88
  );
75
89
  // Fire only after a successful persist — broadcasting an edit that failed
76
90
  // to persist would let peers diverge from what this editor can recover.