@drawnagency/primitives 0.1.76 → 0.1.77

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 (78) hide show
  1. package/dist/chunk-2G2S36SY.js +13 -0
  2. package/dist/{chunk-7T2WQSWF.js → chunk-63BQQNZO.js} +9 -9
  3. package/dist/chunk-REM2NH7K.js +44 -0
  4. package/dist/{chunk-SWEVWC5K.js → chunk-RO4E5UJI.js} +21 -21
  5. package/dist/{chunk-NKVZGPR6.js → chunk-YP7IHMC3.js} +1 -1
  6. package/dist/collab/provider-registry.d.ts +4 -0
  7. package/dist/collab/provider-registry.d.ts.map +1 -0
  8. package/dist/collab/provider-registry.js +9 -0
  9. package/dist/collab/rebase.d.ts +37 -0
  10. package/dist/collab/rebase.d.ts.map +1 -0
  11. package/dist/collab/rebase.js +64 -0
  12. package/dist/collab/recovery.d.ts +89 -0
  13. package/dist/collab/recovery.d.ts.map +1 -0
  14. package/dist/collab/types.d.ts +93 -0
  15. package/dist/collab/types.d.ts.map +1 -0
  16. package/dist/collab/types.js +19 -0
  17. package/dist/components/editor/AudienceIndicator.d.ts +3 -1
  18. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
  19. package/dist/components/editor/ConflictDialog.d.ts +14 -0
  20. package/dist/components/editor/ConflictDialog.d.ts.map +1 -0
  21. package/dist/components/editor/IndicatorPill.d.ts +4 -1
  22. package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
  23. package/dist/components/editor/LockBadge.d.ts +7 -0
  24. package/dist/components/editor/LockBadge.d.ts.map +1 -0
  25. package/dist/components/editor/PresenceAvatars.d.ts +9 -0
  26. package/dist/components/editor/PresenceAvatars.d.ts.map +1 -0
  27. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  28. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  29. package/dist/components/editor/StatusIndicator.d.ts +3 -1
  30. package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
  31. package/dist/components/editor/index.js +92 -44
  32. package/dist/components/primitives/index.js +3 -3
  33. package/dist/components/sections/brand-guide.js +3 -3
  34. package/dist/components/sections/register-schemas.js +9 -9
  35. package/dist/components/sections/register.js +9 -9
  36. package/dist/components/shell/EditorShell.d.ts +3 -1
  37. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  38. package/dist/hooks/useCollabSession.d.ts +23 -0
  39. package/dist/hooks/useCollabSession.d.ts.map +1 -0
  40. package/dist/hooks/useEditorPersistence.d.ts +21 -1
  41. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  42. package/dist/hooks/useEditorPublish.d.ts +39 -2
  43. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +44 -22
  47. package/dist/lib/dexie.d.ts +12 -0
  48. package/dist/lib/dexie.d.ts.map +1 -1
  49. package/dist/lib/dexie.js +27 -0
  50. package/dist/lib/index.js +12 -12
  51. package/dist/lib/registry.d.ts +10 -0
  52. package/dist/lib/registry.d.ts.map +1 -1
  53. package/dist/schemas/index.js +1 -1
  54. package/dist/types/database.d.ts +70 -0
  55. package/dist/types/database.d.ts.map +1 -1
  56. package/package.json +13 -1
  57. package/src/collab/provider-registry.ts +14 -0
  58. package/src/collab/rebase.ts +140 -0
  59. package/src/collab/recovery.ts +149 -0
  60. package/src/collab/types.ts +96 -0
  61. package/src/components/editor/AudienceIndicator.tsx +4 -1
  62. package/src/components/editor/ConflictDialog.tsx +61 -0
  63. package/src/components/editor/IndicatorPill.tsx +12 -6
  64. package/src/components/editor/LockBadge.tsx +31 -0
  65. package/src/components/editor/PresenceAvatars.tsx +53 -0
  66. package/src/components/editor/SectionWrapper.tsx +60 -29
  67. package/src/components/editor/StatusIndicator.tsx +4 -0
  68. package/src/components/shell/EditorShell.tsx +561 -18
  69. package/src/hooks/useCollabSession.ts +220 -0
  70. package/src/hooks/useEditorPersistence.ts +104 -1
  71. package/src/hooks/useEditorPublish.ts +205 -63
  72. package/src/index.ts +2 -0
  73. package/src/lib/dexie.ts +44 -0
  74. package/src/lib/registry.ts +8 -0
  75. package/src/types/database.ts +55 -0
  76. package/dist/{chunk-Q3T23HVW.js → chunk-B2DLHZKC.js} +4 -4
  77. package/dist/{chunk-T7MP7ANP.js → chunk-UUCOJL7O.js} +0 -0
  78. package/dist/{chunk-ZRWGV5O6.js → chunk-XFDEUK37.js} +3 -3
@@ -10,7 +10,7 @@ import type { SiteIndex, SiteConfig, Page } from "../../schemas/site-config";
10
10
  import type { Audience } from "../../auth/types";
11
11
  import type { MediaManifest } from "../../media/types";
12
12
  import type { QueueItem } from "../../media/queue";
13
- import { SiteConfigSchema } from "../../schemas/site-config";
13
+ import { SiteConfigSchema, IndexSchema } from "../../schemas/site-config";
14
14
  import { ensureSanitizer } from "../../lib/sanitize";
15
15
  import { EditorProvider, useEditorContext } from "./EditorContext";
16
16
  import { makeBlockMoveDispatch } from "./blockMoveDispatch";
@@ -27,7 +27,7 @@ import { ProcessingIndicator } from "./ProcessingIndicator";
27
27
  import { SectionSkeleton } from "./SectionSkeleton";
28
28
  import { SectionTypePicker } from "./SectionTypePicker";
29
29
  import { ensureSectionsRegistered } from "../sections/register";
30
- import { getSection, getAllSections } from "../../lib/registry";
30
+ import { getSection, getAllSections, getSchema } from "../../lib/registry";
31
31
  import { childInsertableTypes, insertChildAt } from "../../lib/container-ops";
32
32
  import { buildInsertMenuOptions, resolveInsertedContent } from "../../lib/insert-menu";
33
33
 
@@ -49,8 +49,18 @@ import {
49
49
  getPendingMediaItems,
50
50
  getPendingMediaBlobs,
51
51
  getPendingMediaDeletions,
52
+ getDirtySectionRows,
53
+ deleteSectionRows,
54
+ preserveEdit,
52
55
  } from "../../lib/dexie";
56
+ import { rebaseContent } from "../../collab/rebase";
57
+ import { deriveRecoveryInputs, applyConflictChoices } from "../../collab/recovery";
58
+ import { getCollabProvider } from "../../collab/provider-registry";
59
+ import type { CollabBootstrap, CollabPeer, CollabStatus, SavedMessage, TakeoverMessage } from "../../collab/types";
60
+ import { ConflictDialog, type ConflictItem } from "../editor/ConflictDialog";
61
+ import { PresenceAvatars } from "../editor/PresenceAvatars";
53
62
  import { useEditorPersistence } from "../../hooks/useEditorPersistence";
63
+ import { useCollabSession, type UseCollabSessionResult } from "../../hooks/useCollabSession";
54
64
  import { useEditorPublish } from "../../hooks/useEditorPublish";
55
65
  import { useContentLifecycle } from "../../hooks/useContentLifecycle";
56
66
  import { useBuildStatus } from "../../hooks/useBuildStatus";
@@ -89,6 +99,18 @@ type ShellState =
89
99
  | { phase: "recovery"; latestTimestamp: string }
90
100
  | { phase: "ready" };
91
101
 
102
+ // The parsed shape of GET /api/content?branch=saved (the fresh draft snapshot).
103
+ interface SavedContent {
104
+ sha: string;
105
+ sections: LoadedSection[];
106
+ index: SiteIndex;
107
+ siteConfig: Record<string, unknown>;
108
+ savedBranchSha: string | null;
109
+ changedSectionIds: string[];
110
+ mainIndex: SiteIndex;
111
+ mediaManifest?: MediaManifest;
112
+ }
113
+
92
114
  interface Props {
93
115
  headSha: string;
94
116
  // Head of the branch the editor actually loads (draft `saved` if it exists,
@@ -110,6 +132,11 @@ interface Props {
110
132
  // Controls the configurator's banner + test panel; the config UI itself
111
133
  // always works so editors can prepare before activation.
112
134
  chatbotPlatformEnabled?: boolean;
135
+ // Server-minted collaboration bootstrap (Task 10 wires this through the edit
136
+ // page). Null/absent → solo mode: the provider registry has no provider, or
137
+ // this session can't join, so useCollabSession stays offline and every
138
+ // lock/broadcast call is a no-op.
139
+ collabBootstrap?: CollabBootstrap | null;
113
140
  }
114
141
 
115
142
  export default function EditorShell({
@@ -120,6 +147,7 @@ export default function EditorShell({
120
147
  capabilities,
121
148
  currentUser,
122
149
  chatbotPlatformEnabled = false,
150
+ collabBootstrap,
123
151
  }: Props) {
124
152
  const [shellState, setShellState] = useState<ShellState>({ phase: "loading-content" });
125
153
  const [sections, setSections] = useState<LoadedSection[]>([]);
@@ -146,6 +174,13 @@ export default function EditorShell({
146
174
  }, [savedSha]);
147
175
  const [changedSectionIds, setChangedSectionIds] = useState<Set<string>>(new Set());
148
176
  const [mainIndex, setMainIndex] = useState<SiteIndex | null>(null);
177
+ // Ref mirrors of the diff state, so save-time callbacks (which run inside a
178
+ // stale handleSave closure) and recoverConflict's synchronous post-rebase
179
+ // bookkeeping always read/write the latest values.
180
+ const changedSectionIdsRef = useRef<Set<string>>(new Set());
181
+ useEffect(() => { changedSectionIdsRef.current = changedSectionIds; }, [changedSectionIds]);
182
+ const mainIndexRef = useRef<SiteIndex | null>(null);
183
+ useEffect(() => { mainIndexRef.current = mainIndex; }, [mainIndex]);
149
184
  const [viewSections, setViewSections] = useState<LoadedSection[] | null>(null);
150
185
  const [showRestoreModal, setShowRestoreModal] = useState(false);
151
186
  const [showOrderingModal, setShowOrderingModal] = useState(false);
@@ -159,6 +194,16 @@ export default function EditorShell({
159
194
  const [uploadErrorMessage, setUploadErrorMessage] = useState<string | null>(null);
160
195
  const [activePageId, setActivePageId] = useState<string | null>(null);
161
196
  const [movingSectionId, setMovingSectionId] = useState<string | null>(null);
197
+ // Set by recoverConflict when a rebase surfaces overlapping edits; drives the
198
+ // keep-mine/take-theirs dialog. `resolve` is the promise resolver recovery
199
+ // awaits, so the dialog's Continue hands the choices straight back.
200
+ const [conflictState, setConflictState] = useState<
201
+ { items: ConflictItem[]; resolve: (choices: Record<string, "mine" | "theirs">) => void } | null
202
+ >(null);
203
+ // Collaboration: transient toast ("Drew took over Hero …") and the section a
204
+ // takeover confirmation is pending for.
205
+ const [remoteNotice, setRemoteNotice] = useState<string | null>(null);
206
+ const [pendingTakeoverId, setPendingTakeoverId] = useState<string | null>(null);
162
207
 
163
208
  const siteIndexRef = useRef<SiteIndex>({ siteId, pages: [], sections: {} });
164
209
  // Ids of sections that exist on the remote (loaded from server, or persisted by
@@ -171,7 +216,51 @@ export default function EditorShell({
171
216
  useEffect(() => { siteIndexRef.current = siteIndex; }, [siteIndex]);
172
217
  useEffect(() => { void ensureSanitizer(); }, []);
173
218
 
174
- const persistence = useEditorPersistence(siteIndexRef);
219
+ // --- Collaboration refs ---
220
+ // Freshest dirty-section set for the inbound remote-section handler (never
221
+ // apply a peer's edit over our own unsaved work).
222
+ const dirtySectionIdsRef = useRef<Set<string>>(new Set());
223
+ useEffect(() => { dirtySectionIdsRef.current = dirtySectionIds; }, [dirtySectionIds]);
224
+ // Toggled by useEditorPublish around a save; the remote saved-event handler
225
+ // defers while it is true (our own save's response path owns the state).
226
+ const inFlightSaveRef = useRef(false);
227
+ // Mirror of the live collab session, read by handlers/callbacks defined before
228
+ // useCollabSession is instantiated (breaks the definition-order cycle).
229
+ const collabRef = useRef<UseCollabSessionResult | null>(null);
230
+ const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
231
+ useEffect(() => () => { if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current); }, []);
232
+ // Per-section memoized onEditIntent closures — SectionWrapper re-binds its
233
+ // focus listeners whenever onEditIntent's identity changes, so each section
234
+ // must get a STABLE function instance across renders.
235
+ const editIntentByIdRef = useRef(new Map<string, () => Promise<boolean>>());
236
+ // The section the local user last expressed edit intent on. Drives the
237
+ // focus-move lock release: when intent moves to a new section, the previous
238
+ // one's lock is released if it carries no unsaved work.
239
+ const focusedSectionIdRef = useRef<string | null>(null);
240
+
241
+ // Transient collaboration toast (single slot, 8s auto-dismiss).
242
+ const showRemoteNotice = useCallback((text: string) => {
243
+ setRemoteNotice(text);
244
+ if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
245
+ noticeTimerRef.current = setTimeout(() => setRemoteNotice(null), 8000);
246
+ }, []);
247
+
248
+ // Broadcast piggyback: after a debounced flush, forward held-lock section
249
+ // edits + index changes to peers. Only sections THIS editor holds are
250
+ // broadcast (peers ignore updates for sections they don't see us holding).
251
+ const onFlush = useCallback(
252
+ (entries: { sectionId: string; content: SectionContent }[], indexFlushed: boolean) => {
253
+ const collab = collabRef.current;
254
+ if (!collab) return;
255
+ for (const { sectionId, content } of entries) {
256
+ if (collab.heldByMe.has(sectionId)) collab.broadcastSectionUpdate(sectionId, content);
257
+ }
258
+ if (indexFlushed) collab.broadcastIndexUpdate(siteIndexRef.current);
259
+ },
260
+ [],
261
+ );
262
+
263
+ const persistence = useEditorPersistence(siteIndexRef, { onFlush });
175
264
 
176
265
  const sectionsById = useMemo(() => {
177
266
  const map: Record<string, LoadedSection> = {};
@@ -232,6 +321,349 @@ export default function EditorShell({
232
321
 
233
322
  const buildStatus = useBuildStatus();
234
323
 
324
+ // Fetch + parse the fresh saved-branch snapshot. Shared by the initial
325
+ // loadContent and by recoverConflict (409 auto-recovery) so both read the
326
+ // draft head the same way.
327
+ const fetchSavedContent = useCallback(async (): Promise<SavedContent> => {
328
+ const response = await fetch("/api/content?branch=saved");
329
+ if (!response.ok) throw new Error(`Failed to load content: ${response.status}`);
330
+ const data = await response.json();
331
+ return {
332
+ sha: data.sha,
333
+ sections: data.sections as LoadedSection[],
334
+ index: data.index as SiteIndex,
335
+ siteConfig: data.siteConfig as Record<string, unknown>,
336
+ savedBranchSha: data.savedBranchSha ?? null,
337
+ changedSectionIds: (data.changedSectionIds ?? []) as string[],
338
+ mainIndex: (data.mainIndex ?? data.index) as SiteIndex,
339
+ mediaManifest: data.mediaManifest as MediaManifest | undefined,
340
+ };
341
+ }, []);
342
+
343
+ // 409 auto-recovery: another editor moved the draft head. Re-fetch the fresh
344
+ // saved branch, rebase local unsaved work on top, resolve any true overlaps
345
+ // via the keep-mine/take-theirs dialog, refresh the optimistic-concurrency
346
+ // base, and report success so the save retries once. The decision logic lives
347
+ // in the pure collab/recovery + collab/rebase modules; this function is the
348
+ // IO glue (fetch, Dexie, dialog, setState).
349
+ const recoverConflict = useCallback(async (): Promise<boolean> => {
350
+ try {
351
+ const remote = await fetchSavedContent();
352
+ const dirtyRows = await getDirtySectionRows();
353
+ const dirtyIds = new Set(dirtyRows.map((r) => r.sectionId));
354
+
355
+ // Clean baseline = the last agreed-with-server snapshot in the Dexie
356
+ // contentCache — NOT sectionsRef.current, which carries local dirty edits.
357
+ // The cache is re-primed at every lifecycle point (load, successful save
358
+ // via onBaselineCommitted, and below on every recovery), so it is present
359
+ // whenever a 409 can happen; if it is somehow missing,
360
+ // deriveRecoveryInputs degrades conservatively (dialog, never a silent
361
+ // local-wins merge).
362
+ const cached = await getCachedContent();
363
+ const { remoteChangedIds, locallyAddedIds } = deriveRecoveryInputs({
364
+ baseline: cached ? { sections: cached.sections, index: cached.index } : null,
365
+ remote: { sections: remote.sections, index: remote.index },
366
+ dirtyRows,
367
+ deletedIds: deletedSections,
368
+ });
369
+
370
+ const result = rebaseContent({
371
+ localSections: sectionsRef.current,
372
+ localIndex: siteIndexRef.current,
373
+ localIndexDirty: true, // conservative: preserves any local structural work
374
+ dirtySectionIds: dirtyIds,
375
+ locallyAddedIds,
376
+ locallyDeletedIds: deletedSections,
377
+ remoteSections: remote.sections,
378
+ remoteIndex: remote.index,
379
+ // rebaseContent self-detects remote deletions of dirty sections, so we
380
+ // only fold in remote CONTENT changes here (per Task 4 contract).
381
+ remoteChangedIds,
382
+ });
383
+
384
+ let mergedSections = result.sections;
385
+ let mergedIndex = result.index;
386
+ if (result.conflictIds.length > 0) {
387
+ const choices = await new Promise<Record<string, "mine" | "theirs">>((resolve) => {
388
+ setConflictState({
389
+ items: result.conflictIds.map((id) => ({
390
+ sectionId: id,
391
+ label: getSection(siteIndexRef.current.sections[id]?.type ?? "")?.label ?? id,
392
+ })),
393
+ resolve,
394
+ });
395
+ });
396
+ setConflictState(null);
397
+ const applied = applyConflictChoices({
398
+ merged: { sections: mergedSections, index: mergedIndex },
399
+ remoteSections: remote.sections,
400
+ choices,
401
+ });
402
+ mergedSections = applied.sections;
403
+ mergedIndex = applied.index;
404
+ for (const p of applied.preserved) {
405
+ // The local copy is always snapshotted — no choice is destructive.
406
+ await preserveEdit(p.sectionId, p.section as unknown as SectionContent, "conflict");
407
+ }
408
+ // Drop the dirty rows "theirs" overrode so the retry doesn't re-send them.
409
+ if (applied.droppedDirtyIds.length > 0) await deleteSectionRows(applied.droppedDirtyIds);
410
+ }
411
+
412
+ setSections(mergedSections);
413
+ setSiteIndex(mergedIndex);
414
+ setSavedSha(remote.savedBranchSha);
415
+ setChangedSectionIds(new Set(remote.changedSectionIds));
416
+ setMainIndex(remote.mainIndex);
417
+ // Refresh the refs synchronously: getBaseVersion reads savedShaRef and
418
+ // performSave reads siteIndexRef + the dirty rows on the IMMEDIATE retry
419
+ // (and onBaselineCommitted reads the diff refs right after it), before
420
+ // React flushes the setState calls above into their mirror effects.
421
+ savedShaRef.current = remote.savedBranchSha;
422
+ sectionsRef.current = mergedSections;
423
+ siteIndexRef.current = mergedIndex;
424
+ changedSectionIdsRef.current = new Set(remote.changedSectionIds);
425
+ mainIndexRef.current = remote.mainIndex;
426
+ remoteSectionIdsRef.current = new Set(mergedIndex.pages.flatMap((p) => p.order));
427
+ // C2: the cache is the CLEAN baseline — always the fetched remote
428
+ // snapshot, never the rebased result. Caching the rebase (local dirty
429
+ // overlays folded in) would poison the next recovery's diff: dirty
430
+ // sections would read as remote-unchanged (silent local-wins) and a
431
+ // locally-added section would read as "deleted remotely".
432
+ await cacheContent(
433
+ remote.sha ?? remote.savedBranchSha ?? headSha,
434
+ remote.sections,
435
+ remote.index,
436
+ remote.siteConfig,
437
+ { savedBranchSha: remote.savedBranchSha, changedSectionIds: remote.changedSectionIds, mainIndex: remote.mainIndex },
438
+ );
439
+ return true;
440
+ } catch (err) {
441
+ console.error("Conflict recovery failed:", err);
442
+ return false;
443
+ }
444
+ }, [fetchSavedContent, deletedSections, headSha]);
445
+
446
+ // --- Collaboration: inbound saved-event rebase + takeover ---
447
+
448
+ // A peer committed a save to the draft branch. Re-fetch the fresh saved
449
+ // snapshot and rebase local unsaved work under it — the SAME machinery as the
450
+ // 409 recovery, but from a background event, so it NEVER opens the conflict
451
+ // dialog: local dirty work wins for any conflictIds (rebaseContent keeps the
452
+ // local copies), and the next interactive save's 409 recovery arbitrates.
453
+ const handleRemoteSaved = useCallback(async (msg: SavedMessage): Promise<void> => {
454
+ // Bail conditions, re-checked after EVERY await: (a) our own save is in
455
+ // flight — its response path owns state, and a stale refetch landing
456
+ // mid-save would regress savedShaRef/baseline and force spurious 409s over
457
+ // the user's own work; (b) own-save echo — the server fans our save back to
458
+ // us, but savedShaRef already points at msg.newSha, so there is nothing to
459
+ // sync (and refetching would be wasted work racing the post-save cleanup).
460
+ const stale = () => inFlightSaveRef.current || savedShaRef.current === msg.newSha;
461
+ if (stale()) return;
462
+ try {
463
+ const remote = await fetchSavedContent();
464
+ if (stale()) return;
465
+ const dirtyRows = await getDirtySectionRows();
466
+ if (stale()) return;
467
+ // Clean baseline from the content cache (never sectionsRef, which carries
468
+ // dirty overlays). deriveRecoveryInputs computes BOTH remoteChangedIds and
469
+ // locallyAddedIds; rebaseContent self-detects remote deletions.
470
+ const cached = await getCachedContent();
471
+ // Last await above — one final check guards the whole synchronous
472
+ // setState + cacheContent block below.
473
+ if (stale()) return;
474
+ // Dirty = Dexie rows ∪ the synchronously-maintained state mirror ∪ held
475
+ // locks: a just-typed edit can lag the Dexie rows by up to the 500ms
476
+ // flush debounce, and anything we hold must never be overwritten by a
477
+ // background sync. The rebase reads content from sectionsRef, which
478
+ // already has the freshest keystrokes.
479
+ const dirtyIds = new Set([
480
+ ...dirtyRows.map((r) => r.sectionId),
481
+ ...dirtySectionIdsRef.current,
482
+ ...(collabRef.current?.heldByMe ?? []),
483
+ ]);
484
+ const { remoteChangedIds, locallyAddedIds } = deriveRecoveryInputs({
485
+ baseline: cached ? { sections: cached.sections, index: cached.index } : null,
486
+ remote: { sections: remote.sections, index: remote.index },
487
+ dirtyRows: [...dirtyIds].map((sectionId) => ({ sectionId })),
488
+ deletedIds: deletedSections,
489
+ });
490
+ const result = rebaseContent({
491
+ localSections: sectionsRef.current,
492
+ localIndex: siteIndexRef.current,
493
+ // Durable unsaved-structure signal — NOT isIndexDirty(), which clears
494
+ // ~500ms after the edit (flush-pending only) and would hand a remote
495
+ // save the local structural work on a plate.
496
+ localIndexDirty: persistence.hasUnsavedIndex(),
497
+ dirtySectionIds: dirtyIds,
498
+ locallyAddedIds,
499
+ locallyDeletedIds: deletedSections,
500
+ remoteSections: remote.sections,
501
+ remoteIndex: remote.index,
502
+ remoteChangedIds,
503
+ });
504
+
505
+ // The rebased result goes into React state (live view)...
506
+ setSections(result.sections);
507
+ setSiteIndex(result.index);
508
+ setSavedSha(remote.savedBranchSha);
509
+ setChangedSectionIds(new Set(remote.changedSectionIds));
510
+ setMainIndex(remote.mainIndex);
511
+ sectionsRef.current = result.sections;
512
+ siteIndexRef.current = result.index;
513
+ savedShaRef.current = remote.savedBranchSha;
514
+ changedSectionIdsRef.current = new Set(remote.changedSectionIds);
515
+ mainIndexRef.current = remote.mainIndex;
516
+ remoteSectionIdsRef.current = new Set(result.index.pages.flatMap((p) => p.order));
517
+
518
+ // ...but the CLEAN fetched remote snapshot is what gets cached as the
519
+ // baseline (amendment 1: caching the rebased result would poison the next
520
+ // 409 recovery — dirty sections would read as remote-unchanged and a ghost
521
+ // race would never surface).
522
+ await cacheContent(
523
+ remote.sha ?? remote.savedBranchSha ?? headSha,
524
+ remote.sections,
525
+ remote.index,
526
+ remote.siteConfig,
527
+ { savedBranchSha: remote.savedBranchSha, changedSectionIds: remote.changedSectionIds, mainIndex: remote.mainIndex },
528
+ );
529
+ } catch (err) {
530
+ console.error("Remote saved-event sync failed:", err);
531
+ }
532
+ }, [fetchSavedContent, deletedSections, persistence, headSha]);
533
+
534
+ // A peer stole this section's lock. Snapshot the local edit (recoverable),
535
+ // drop the dirty row so it isn't re-sent, and surface a toast.
536
+ const handleTakenOver = useCallback(async (msg: TakeoverMessage): Promise<void> => {
537
+ const local = sectionsRef.current.find((s) => s.section.id === msg.sectionId);
538
+ if (local) await preserveEdit(msg.sectionId, local.section as unknown as SectionContent, "takeover");
539
+ // Drop BOTH stores of the victim's edit: the persisted Dexie row AND the
540
+ // un-flushed in-memory pending entry — otherwise a keystroke typed just
541
+ // before the takeover re-persists on the next debounce flush and the next
542
+ // save re-sends stale content for a section we no longer hold.
543
+ persistence.dropPendingSection(msg.sectionId);
544
+ await deleteSectionRows([msg.sectionId]);
545
+ setDirtySectionIds((prev) => {
546
+ const next = new Set(prev);
547
+ next.delete(msg.sectionId);
548
+ return next;
549
+ });
550
+ const label = getSection(siteIndexRef.current.sections[msg.sectionId]?.type ?? "")?.label
551
+ ?? siteIndexRef.current.sections[msg.sectionId]?.type
552
+ ?? msg.sectionId;
553
+ showRemoteNotice(`${msg.byEmail} took over "${label}" — your unsaved edits to it were safely stored.`);
554
+ }, [persistence, showRemoteNotice]);
555
+
556
+ const collab = useCollabSession({
557
+ provider: getCollabProvider(),
558
+ bootstrap: collabBootstrap ?? null,
559
+ onRemoteSectionUpdate: (msg) => {
560
+ // Ephemeral live view: never touch Dexie, never apply over own dirty work
561
+ // or a section we hold.
562
+ if (dirtySectionIdsRef.current.has(msg.sectionId)) return;
563
+ if (collabRef.current?.heldByMe.has(msg.sectionId)) return;
564
+ const raw = msg.content;
565
+ // z.unknown() passes when the key is absent — guard undefined/null/non-object.
566
+ if (raw === undefined || raw === null || typeof raw !== "object") return;
567
+ const type = (raw as { type?: unknown }).type;
568
+ if (typeof type !== "string") return;
569
+ const schema = getSchema(type);
570
+ if (!schema) return;
571
+ const parsed = schema.safeParse(raw);
572
+ if (!parsed.success) return;
573
+ const validated = parsed.data as Record<string, unknown>;
574
+ setSections((prev) => prev.map((s) =>
575
+ s.section.id === msg.sectionId ? { ...s, section: { ...s.section, ...validated } } : s,
576
+ ));
577
+ },
578
+ onRemoteIndexUpdate: (msg) => {
579
+ // Ignore inbound indexes until the initial load settles: before
580
+ // loadContent finishes, sectionsRef.current is [] so the prune below would
581
+ // strip every order to empty and momentarily install an empty-order index.
582
+ if (shellState.phase !== "ready") return;
583
+ // While we have unsaved structural work, the save-time rebase resolves
584
+ // the overlap — don't clobber local structure with the peer's. Durable
585
+ // signal (survives the flush debounce), not isIndexDirty().
586
+ if (persistence.hasUnsavedIndex()) return;
587
+ const parsed = IndexSchema.safeParse(msg.index);
588
+ if (!parsed.success) return;
589
+ // PRUNE ids we hold no content for before applying. An id in the peer's
590
+ // index with no counterpart in our local sections state is either (a) the
591
+ // peer's unsaved ADD — its section file is not on the branch yet, so
592
+ // letting it into siteIndexRef would poison OUR next save with a dangling
593
+ // index ref the server rejects (live new-section appearance is deliberately
594
+ // deferred to the peer's save, which arrives via the saved-event sync); or
595
+ // (b) a section WE deleted unsaved — our deletion stands until save-time
596
+ // arbitration.
597
+ const localIds = new Set(sectionsRef.current.map((s) => s.section.id));
598
+ const pruned: SiteIndex = {
599
+ ...parsed.data,
600
+ pages: parsed.data.pages.map((p) => ({ ...p, order: p.order.filter((id) => localIds.has(id)) })),
601
+ sections: Object.fromEntries(
602
+ Object.entries(parsed.data.sections).filter(([id]) => localIds.has(id)),
603
+ ),
604
+ };
605
+ setSiteIndex(pruned);
606
+ siteIndexRef.current = pruned;
607
+ },
608
+ onRemoteSaved: (msg) => { void handleRemoteSaved(msg); },
609
+ onTakenOver: (msg) => { void handleTakenOver(msg); },
610
+ });
611
+ useEffect(() => { collabRef.current = collab; }, [collab]);
612
+
613
+ // Stable per-section onEditIntent: focus + lock request. The cached closure
614
+ // always calls the LIVE session via collabRef, so it needn't change identity.
615
+ const getEditIntent = useCallback((id: string): (() => Promise<boolean>) => {
616
+ const cache = editIntentByIdRef.current;
617
+ let fn = cache.get(id);
618
+ if (!fn) {
619
+ fn = async () => {
620
+ const session = collabRef.current;
621
+ // Focus-move release: browse-clicking must not accumulate locks that
622
+ // block peers on sections we never edited. Release the previously
623
+ // focused section's lock IF it carries no unsaved work — dirty state,
624
+ // an un-flushed keystroke, or a local add all keep the lock held.
625
+ const prev = focusedSectionIdRef.current;
626
+ if (
627
+ prev && prev !== id && session?.heldByMe.has(prev) &&
628
+ !dirtySectionIdsRef.current.has(prev) &&
629
+ !persistence.hasPendingSection(prev)
630
+ ) {
631
+ session.releaseAfterSave([prev]);
632
+ }
633
+ focusedSectionIdRef.current = id;
634
+ session?.setFocus(id);
635
+ const ok = (await session?.requestEdit(id)) ?? true;
636
+ // A denial with no visible lock badge is the ghost-lock window: the
637
+ // holder's presence dropped but their editor_locks row is still fresh
638
+ // (<45s). Tell the user instead of failing silently; no further
639
+ // blocking — the row goes stale within 45s and the save-time 409
640
+ // arbitration remains the safety net.
641
+ if (!ok) showRemoteNotice("Someone else is editing this section — try again in a moment.");
642
+ return ok;
643
+ };
644
+ cache.set(id, fn);
645
+ }
646
+ return fn;
647
+ }, [persistence, showRemoteNotice]);
648
+
649
+ const confirmTakeover = useCallback((id: string) => setPendingTakeoverId(id), []);
650
+ const performTakeover = useCallback(async () => {
651
+ const id = pendingTakeoverId;
652
+ setPendingTakeoverId(null);
653
+ if (!id) return;
654
+ const ok = await collabRef.current?.takeover(id);
655
+ if (!ok) return;
656
+ // The stolen section's latest broadcast content is already in local state
657
+ // (applied as remote-ephemeral). Mark it dirty so it persists and the
658
+ // lock-hold broadcast rule applies.
659
+ const local = sectionsRef.current.find((s) => s.section.id === id);
660
+ if (!local) return;
661
+ const { id: _omit, ...content } = local.section;
662
+ persistence.markSectionDirty(id, content as SectionContent);
663
+ setLocalChangesExist(true);
664
+ setDirtySectionIds((prev) => new Set(prev).add(id));
665
+ }, [pendingTakeoverId, persistence]);
666
+
235
667
  const { publishAction, publishFeedback, saveProgress, handleSave, handlePublish, handleSaveAndPublish } = useEditorPublish({
236
668
  flushNow: persistence.flushNow,
237
669
  cancelPendingFlush: persistence.cancelPendingFlush,
@@ -241,12 +673,37 @@ export default function EditorShell({
241
673
  siteConfig,
242
674
  sections,
243
675
  deletedSectionIds: Array.from(deletedSections),
244
- onSuccess: () => {
245
- setDirtySectionIds(new Set());
246
- setDeletedSections(new Set());
247
- setLocalChangesExist(false);
676
+ onSuccess: (savedSectionIds: string[], { editToken, sentDeletedIds }) => {
677
+ // Generation-guarded post-save clears: any edit made WHILE the save was
678
+ // in flight (generation > the token captured at save begin) was NOT
679
+ // carried by it and must survive every clear below — the in-memory
680
+ // mirror of discardSavedChanges' updatedAt compare-and-clear.
681
+ setDirtySectionIds((prev) =>
682
+ new Set([...prev].filter((id) => persistence.sectionDirtiedAfter(id, editToken))));
683
+ // Clear only the deletions this save actually sent; one scheduled
684
+ // mid-save must ride the next save.
685
+ const sentDeleted = new Set(sentDeletedIds);
686
+ setDeletedSections((prev) => new Set([...prev].filter((id) => !sentDeleted.has(id))));
687
+ // The committed index is no longer unsaved structure (mirrors
688
+ // discardSavedChanges clearing the Dexie siteIndex row just before this)
689
+ // — UNLESS a structural edit landed mid-save: then the clear no-ops and
690
+ // we re-mark, because discardSavedChanges wiped the Dexie siteIndex row
691
+ // wholesale and the surviving live structure must re-persist.
692
+ persistence.clearUnsavedIndex(editToken);
693
+ if (persistence.hasUnsavedIndex()) persistence.markIndexDirty();
694
+ const survivorsExist =
695
+ [...dirtySectionIdsRef.current].some((id) => persistence.sectionDirtiedAfter(id, editToken)) ||
696
+ persistence.hasUnsavedIndex();
697
+ setLocalChangesExist(survivorsExist);
248
698
  // Everything in the index now exists remotely (it was just saved).
249
699
  remoteSectionIdsRef.current = new Set(siteIndexRef.current.pages.flatMap((p) => p.order));
700
+ // Release the locks we held on the committed sections — EXCEPT any
701
+ // re-dirtied mid-save (they carry unsent work; keep holding). The server
702
+ // fans the saved-event out to peers (Task 10), so no client broadcast here.
703
+ collabRef.current?.releaseAfterSave(
704
+ savedSectionIds.filter((id) => !persistence.sectionDirtiedAfter(id, editToken)));
705
+ collabRef.current?.setFocus(null);
706
+ focusedSectionIdRef.current = null;
250
707
  },
251
708
  mediaManifest,
252
709
  manifestDirty: mediaPipeline.manifestDirty,
@@ -289,6 +746,27 @@ export default function EditorShell({
289
746
  },
290
747
  onPublishComplete: buildStatus.startTracking,
291
748
  getBaseVersion: () => savedShaRef.current,
749
+ recoverConflict,
750
+ // C1: re-prime the clean content-cache baseline after every successful plain
751
+ // save (discardSavedChanges just cleared it). The hook hands us the COMMITTED
752
+ // snapshot (sent content + sent index); we add the diff bookkeeping: the
753
+ // just-saved sections now differ from main, so fold them into
754
+ // changedSectionIds (state + ref + cache stay consistent, and a reload that
755
+ // hits this cache shows the same badges the server would report).
756
+ onBaselineCommitted: async ({ sha, sections: baselineSections, index, savedIds }) => {
757
+ const changed = new Set(
758
+ [...changedSectionIdsRef.current, ...savedIds].filter((id) => id in index.sections),
759
+ );
760
+ changedSectionIdsRef.current = changed;
761
+ setChangedSectionIds(changed);
762
+ await cacheContent(sha, baselineSections, index, (siteConfig ?? {}) as Record<string, unknown>, {
763
+ savedBranchSha: sha,
764
+ changedSectionIds: [...changed],
765
+ mainIndex: mainIndexRef.current,
766
+ });
767
+ },
768
+ savingRef: inFlightSaveRef,
769
+ getEditGeneration: persistence.currentGeneration,
292
770
  });
293
771
 
294
772
  const { buttonState } = useContentLifecycle({
@@ -417,25 +895,23 @@ export default function EditorShell({
417
895
  setChangedSectionIds(new Set(cached.changedSectionIds ?? []));
418
896
  setMainIndex(cached.mainIndex ?? loadedIndex);
419
897
  } else {
420
- const response = await fetch("/api/content?branch=saved");
421
- if (!response.ok) throw new Error(`Failed to load content: ${response.status}`);
422
- const data = await response.json();
898
+ const data = await fetchSavedContent();
423
899
  loadedSections = data.sections;
424
900
  loadedIndex = data.index;
425
901
  loadedConfig = SiteConfigSchema.parse(data.siteConfig);
426
902
  await cacheContent(data.sha, data.sections, data.index, data.siteConfig, {
427
- savedBranchSha: data.savedBranchSha ?? null,
428
- changedSectionIds: data.changedSectionIds ?? [],
429
- mainIndex: data.mainIndex ?? data.index,
903
+ savedBranchSha: data.savedBranchSha,
904
+ changedSectionIds: data.changedSectionIds,
905
+ mainIndex: data.mainIndex,
430
906
  });
431
907
  if (data.mediaManifest) {
432
- loadedManifest = data.mediaManifest as MediaManifest;
908
+ loadedManifest = data.mediaManifest;
433
909
  await persistMediaManifest(loadedManifest);
434
910
  }
435
- setSavedSha(data.savedBranchSha ?? null);
911
+ setSavedSha(data.savedBranchSha);
436
912
  setMainSha(headSha);
437
- setChangedSectionIds(new Set(data.changedSectionIds ?? []));
438
- setMainIndex(data.mainIndex ?? data.index);
913
+ setChangedSectionIds(new Set(data.changedSectionIds));
914
+ setMainIndex(data.mainIndex);
439
915
  }
440
916
 
441
917
  if (cancelled) return;
@@ -490,7 +966,7 @@ export default function EditorShell({
490
966
  setShellState({ phase: "error", message: err instanceof Error ? err.message : "Failed to load content" });
491
967
  });
492
968
  return () => { cancelled = true; };
493
- }, [headSha, draftHeadSha, siteId, applySiteConfigPreview]);
969
+ }, [headSha, draftHeadSha, siteId, applySiteConfigPreview, fetchSavedContent]);
494
970
 
495
971
  // --- Recovery handlers ---
496
972
 
@@ -525,6 +1001,10 @@ export default function EditorShell({
525
1001
  [...remoteSectionIdsRef.current].filter((id) => !restoredIds.has(id)),
526
1002
  );
527
1003
  setDeletedSections(rediscoveredDeletions);
1004
+ // A restored Dexie siteIndex row IS unsaved structural work from the
1005
+ // prior session — prime the durable signal even though no markIndexDirty
1006
+ // ran this session.
1007
+ persistence.noteUnsavedIndex();
528
1008
  } else {
529
1009
  setSections((prev) =>
530
1010
  prev.map((loaded) => {
@@ -578,6 +1058,9 @@ export default function EditorShell({
578
1058
  const savedManifest = await getMediaManifest();
579
1059
  setMediaManifest(savedManifest ?? { images: {} });
580
1060
  persistence.clearConfigDirty();
1061
+ // Discard wiped the Dexie siteIndex row — no unsaved structure remains.
1062
+ // Tokenless: discard is unconditional, unlike the save-success clear.
1063
+ persistence.clearUnsavedIndex();
581
1064
  setShellState({ phase: "ready" });
582
1065
  }, [applySiteConfigPreview, persistence]);
583
1066
 
@@ -623,6 +1106,12 @@ export default function EditorShell({
623
1106
  persistence.markIndexDirty();
624
1107
  setLocalChangesExist(true);
625
1108
  setDirtySectionIds((prev) => new Set(prev).add(id));
1109
+ // Spec: new sections are creator-locked. Acquiring the lock here (not on
1110
+ // first focus) protects the fresh id against concurrent MCP writes via
1111
+ // the locks table and lets its content ride the held-lock broadcast
1112
+ // (peers drop it until our save lands — live new-section appearance is
1113
+ // save-time sync by design).
1114
+ void collabRef.current?.requestEdit(id);
626
1115
  },
627
1116
  [persistence, resolvedActivePage],
628
1117
  );
@@ -902,6 +1391,9 @@ export default function EditorShell({
902
1391
  onOrderingClick={() => setShowOrderingModal(true)}
903
1392
  onPagesClick={() => setShowPagesModal(true)}
904
1393
  onChatbotClick={() => setShowChatbotConfig(true)}
1394
+ collabPeers={collab.peers}
1395
+ collabSelfId={collabBootstrap?.userId ?? null}
1396
+ collabStatus={collab.status}
905
1397
  />
906
1398
 
907
1399
  {/* Bug reports persist via Supabase. On the password-only (zero-Supabase)
@@ -932,6 +1424,15 @@ export default function EditorShell({
932
1424
  </div>
933
1425
  )}
934
1426
 
1427
+ {remoteNotice && (
1428
+ <div
1429
+ role="status"
1430
+ className="fixed left-1/2 top-14 z-50 -translate-x-1/2 rounded-md border border-base-200 bg-base px-4 py-2 text-xs font-medium text-base-content shadow-lg"
1431
+ >
1432
+ {remoteNotice}
1433
+ </div>
1434
+ )}
1435
+
935
1436
  <HistoryOrEditorContent
936
1437
  index={siteIndex}
937
1438
  activeSections={activeSections}
@@ -954,6 +1455,9 @@ export default function EditorShell({
954
1455
  mainIndex={mainIndex}
955
1456
  changedSectionIds={changedSectionIds}
956
1457
  viewSections={viewSections}
1458
+ locks={collab.locks}
1459
+ getEditIntent={getEditIntent}
1460
+ onTakeover={confirmTakeover}
957
1461
  />
958
1462
  </HistoryOrEditorContent>
959
1463
  <GlobalModal />
@@ -1084,6 +1588,27 @@ export default function EditorShell({
1084
1588
  isRestoring={isRestoring}
1085
1589
  onRestore={handleHistoryRestore}
1086
1590
  />
1591
+ {conflictState && (
1592
+ <ConflictDialog conflicts={conflictState.items} onResolve={conflictState.resolve} />
1593
+ )}
1594
+ <EditorModal
1595
+ isOpen={pendingTakeoverId !== null}
1596
+ onClose={() => setPendingTakeoverId(null)}
1597
+ title="Take over section"
1598
+ >
1599
+ <p className="mb-4 text-sm text-base-contrast-light">
1600
+ Another editor is currently editing this section. Take over to edit it yourself?
1601
+ Their unsaved edits will be safely stored.
1602
+ </p>
1603
+ <div className="flex justify-end gap-3">
1604
+ <Button variant="secondary" size="md" onClick={() => setPendingTakeoverId(null)}>
1605
+ Cancel
1606
+ </Button>
1607
+ <Button variant="primary" size="md" onClick={performTakeover}>
1608
+ Take over
1609
+ </Button>
1610
+ </div>
1611
+ </EditorModal>
1087
1612
  </div>
1088
1613
  </MediaLibraryContext.Provider>
1089
1614
  </EditorModalProvider>
@@ -1228,6 +1753,9 @@ function EditorContent({
1228
1753
  mainIndex,
1229
1754
  changedSectionIds,
1230
1755
  viewSections,
1756
+ locks,
1757
+ getEditIntent,
1758
+ onTakeover,
1231
1759
  }: {
1232
1760
  sections: LoadedSection[];
1233
1761
  activePageSectionIds: string[];
@@ -1245,6 +1773,11 @@ function EditorContent({
1245
1773
  mainIndex: SiteIndex | null;
1246
1774
  changedSectionIds: Set<string>;
1247
1775
  viewSections: LoadedSection[] | null;
1776
+ // Collaboration: peers' held locks (keyed by sectionId, self excluded), a
1777
+ // stable per-section edit-intent factory, and the takeover confirm trigger.
1778
+ locks: Map<string, CollabPeer>;
1779
+ getEditIntent: (id: string) => () => Promise<boolean>;
1780
+ onTakeover: (id: string) => void;
1248
1781
  }) {
1249
1782
  const { isEditMode, viewBranch } = useEditorContext();
1250
1783
  const { openModal, closeModal } = useEditorModal();
@@ -1351,6 +1884,9 @@ function EditorContent({
1351
1884
  mainStatus={mainIndex?.sections[section.id]?.status ?? null}
1352
1885
  contentDiffersFromMain={changedSectionIds.has(section.id) || dirtySectionIds.has(section.id)}
1353
1886
  isLocalOnly={dirtySectionIds.has(section.id) && !changedSectionIds.has(section.id)}
1887
+ lockedBy={locks.get(section.id) ? { email: locks.get(section.id)!.email } : null}
1888
+ onTakeover={() => onTakeover(section.id)}
1889
+ onEditIntent={getEditIntent(section.id)}
1354
1890
  options={{
1355
1891
  ...(section.content as Record<string, unknown>),
1356
1892
  ...("options" in section ? (section.options as Record<string, unknown>) : {}),
@@ -1568,6 +2104,9 @@ function EditorToolbar({
1568
2104
  onOrderingClick,
1569
2105
  onPagesClick,
1570
2106
  onChatbotClick,
2107
+ collabPeers,
2108
+ collabSelfId,
2109
+ collabStatus,
1571
2110
  }: {
1572
2111
  buttonState: "synced" | "publish" | "saveAndPublish";
1573
2112
  localChangesExist: boolean;
@@ -1588,6 +2127,9 @@ function EditorToolbar({
1588
2127
  onOrderingClick: () => void;
1589
2128
  onPagesClick: () => void;
1590
2129
  onChatbotClick: () => void;
2130
+ collabPeers: CollabPeer[];
2131
+ collabSelfId: string | null;
2132
+ collabStatus: CollabStatus;
1591
2133
  }) {
1592
2134
  const { isEditMode, viewBranch, setViewBranch, toggleEditMode, historyState, setHistoryState } = useEditorContext();
1593
2135
 
@@ -1640,6 +2182,7 @@ function EditorToolbar({
1640
2182
  Discard Changes
1641
2183
  </Button>
1642
2184
  )}
2185
+ <PresenceAvatars peers={collabPeers} selfUserId={collabSelfId} status={collabStatus} />
1643
2186
  </div>
1644
2187
  <div className="flex items-center justify-center">
1645
2188
  <StatusText