@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
@@ -0,0 +1,140 @@
1
+ // rebase.ts — pure-function rebase merge: fold a fresh remote (saved-branch)
2
+ // content snapshot under local unsaved work. Consumed by the 409 auto-recovery
3
+ // path and the live save-sync path (both in core); no I/O here.
4
+ import type { LoadedSection } from "../lib/loader";
5
+ import type { SiteIndex } from "../schemas/site-config";
6
+
7
+ export interface RebaseArgs {
8
+ localSections: LoadedSection[];
9
+ localIndex: SiteIndex;
10
+ localIndexDirty: boolean;
11
+ dirtySectionIds: Set<string>;
12
+ /** Section ids created locally since the last clean baseline (i.e. dirty ids
13
+ * absent from the last-loaded saved-branch index). Callers derive this from
14
+ * the cached clean baseline (Dexie contentCache index), NOT from remoteIndex —
15
+ * remote absence is exactly what this field disambiguates: without it, a
16
+ * locally-added unsaved section is indistinguishable from a section deleted
17
+ * remotely while dirty (both dirty, present locally, absent remotely). */
18
+ locallyAddedIds: Set<string>;
19
+ locallyDeletedIds: Set<string>;
20
+ remoteSections: LoadedSection[];
21
+ remoteIndex: SiteIndex;
22
+ // Sections the remote save changed (from SavedMessage, or a diff on 409
23
+ // recovery). Remote deletions of dirty sections are detected directly from
24
+ // remoteIndex, so callers need not fold deletedSectionIds in — but doing so
25
+ // is harmless.
26
+ remoteChangedIds: Set<string>;
27
+ }
28
+
29
+ export interface RebaseResult {
30
+ sections: LoadedSection[];
31
+ index: SiteIndex;
32
+ // dirty locally AND changed (or deleted) remotely — needs user choice.
33
+ // Never contains locally-added unsaved ids (see locallyAddedIds) or
34
+ // locally-deleted ids.
35
+ conflictIds: string[];
36
+ }
37
+
38
+ /** Merge a fresh remote (saved-branch) snapshot under local unsaved work.
39
+ * Remote wins everywhere the local editor has no unsaved edits; dirty local
40
+ * sections are kept verbatim; overlaps are reported as conflicts for the
41
+ * caller's keep-mine/take-theirs dialog. Implements the 5 merge rules from
42
+ * the collaboration spec ("conflict-free by construction under lock
43
+ * discipline").
44
+ *
45
+ * Invariants: inputs are never mutated (the returned index is a fresh
46
+ * structure in both branches), and the result partitions: every section in
47
+ * `sections` is reachable from `index`; a dirty section deleted remotely is
48
+ * kept, re-linked into the index, and reported in `conflictIds`. */
49
+ export function rebaseContent(args: RebaseArgs): RebaseResult {
50
+ const localById = new Map(args.localSections.map((s) => [s.section.id, s]));
51
+ const remoteIds = new Set(args.remoteSections.map((s) => s.section.id));
52
+
53
+ // Rules 1-3: remote baseline (in remote order), overlay local-dirty
54
+ // sections, drop locally-deleted ids. The append loop also skips locally
55
+ // deleted ids — a section edited THEN deleted locally must stay deleted.
56
+ const merged: LoadedSection[] = args.remoteSections
57
+ .filter((s) => !args.locallyDeletedIds.has(s.section.id))
58
+ .map((s) => (args.dirtySectionIds.has(s.section.id) ? (localById.get(s.section.id) ?? s) : s));
59
+ for (const id of args.dirtySectionIds) {
60
+ if (remoteIds.has(id) || args.locallyDeletedIds.has(id)) continue;
61
+ const local = localById.get(id);
62
+ if (local) merged.push(local); // locally-added, or dirty survivor of a remote deletion
63
+ }
64
+
65
+ // Rule 4: conflicts = dirty ∩ remote-changed, PLUS dirty survivors of a
66
+ // remote deletion (present locally, absent from remoteIndex) — detected
67
+ // here directly; the caller is not trusted to fold remote deletions into
68
+ // remoteChangedIds. Locally-added ids are excluded from the deletion
69
+ // backstop: they are absent remotely because they were never saved, not
70
+ // because anyone deleted them.
71
+ const conflictSet = new Set<string>();
72
+ for (const id of args.dirtySectionIds) {
73
+ if (args.locallyDeletedIds.has(id)) continue; // dropped above; nothing to resolve
74
+ if (args.remoteChangedIds.has(id)) {
75
+ conflictSet.add(id);
76
+ } else if (localById.has(id) && !(id in args.remoteIndex.sections) && !args.locallyAddedIds.has(id)) {
77
+ conflictSet.add(id); // deleted remotely while dirty locally
78
+ }
79
+ }
80
+ const conflictIds = [...conflictSet].sort();
81
+
82
+ // Rule 5: index merge. Both branches build a fresh structure (fresh page
83
+ // objects, fresh order arrays, fresh sections map) so the invariant repair
84
+ // below never mutates an input.
85
+ let index: SiteIndex;
86
+ if (!args.localIndexDirty) {
87
+ index = {
88
+ ...args.remoteIndex,
89
+ pages: args.remoteIndex.pages.map((p) => ({ ...p, order: [...p.order] })),
90
+ sections: { ...args.remoteIndex.sections },
91
+ };
92
+ } else {
93
+ const localOrderIds = new Set(args.localIndex.pages.flatMap((p) => p.order));
94
+
95
+ // Local wins: drop ids that are absent from both the remote index and
96
+ // the local dirty/added set (deleted remotely, not something we're
97
+ // actively editing).
98
+ const pages = args.localIndex.pages.map((p) => ({
99
+ ...p,
100
+ order: p.order.filter((id) => id in args.remoteIndex.sections || args.dirtySectionIds.has(id)),
101
+ }));
102
+ const sections: SiteIndex["sections"] = {};
103
+ for (const [id, meta] of Object.entries(args.localIndex.sections)) {
104
+ if (id in args.remoteIndex.sections || args.dirtySectionIds.has(id)) sections[id] = meta;
105
+ }
106
+
107
+ // Reconcile: append remote-new ids (present remotely, absent locally) to
108
+ // the local page occupying the same page id remotely; fall back to the
109
+ // first local page if that page id doesn't exist locally. Never creates
110
+ // a new page.
111
+ for (const remotePage of args.remoteIndex.pages) {
112
+ for (const id of remotePage.order) {
113
+ if (localOrderIds.has(id) || args.locallyDeletedIds.has(id)) continue;
114
+ const target = pages.find((p) => p.id === remotePage.id) ?? pages[0];
115
+ if (target && !target.order.includes(id)) target.order.push(id);
116
+ sections[id] = args.remoteIndex.sections[id];
117
+ }
118
+ }
119
+
120
+ index = { ...args.localIndex, pages, sections };
121
+ }
122
+
123
+ // Partition invariant (unconditional): every id in `merged` must be
124
+ // reachable from `index`. The only ids that can be missing are dirty
125
+ // survivors of a remote deletion — re-link each where the local index had
126
+ // it (else the first page), with its local index meta, falling back to the
127
+ // validated meta the LoadedSection itself carries.
128
+ for (const s of merged) {
129
+ const id = s.section.id;
130
+ if (id in index.sections) continue;
131
+ const meta = args.localIndex.sections[id] ?? s.meta;
132
+ const localPage = args.localIndex.pages.find((p) => p.order.includes(id));
133
+ const target = (localPage && index.pages.find((p) => p.id === localPage.id)) ?? index.pages[0];
134
+ if (!target) continue;
135
+ if (!target.order.includes(id)) target.order.push(id);
136
+ index.sections[id] = meta;
137
+ }
138
+
139
+ return { sections: merged, index, conflictIds };
140
+ }
@@ -0,0 +1,149 @@
1
+ // recovery.ts — pure functions for the 409 auto-recovery path. The EditorShell
2
+ // keeps only IO glue (fetch, Dexie, setState); everything decision-shaped lives
3
+ // here so it can be unit-tested directly with injected data.
4
+ import type { LoadedSection } from "../lib/loader";
5
+ import type { Section, SectionContent } from "../schemas/sections";
6
+ import type { SiteIndex } from "../schemas/site-config";
7
+
8
+ /** The last CLEAN saved-branch snapshot (no local dirty overlays folded in).
9
+ * Lifecycle: primed by loadContent, re-primed with the committed snapshot on
10
+ * every successful save, and re-primed with the fetched remote snapshot on
11
+ * every recovery — so it is always the editor's last agreed-with-server state. */
12
+ export interface RecoveryBaseline {
13
+ sections: LoadedSection[];
14
+ index: SiteIndex;
15
+ }
16
+
17
+ /** Derive rebaseContent's remoteChangedIds + locallyAddedIds from a clean
18
+ * baseline and the freshly fetched remote snapshot.
19
+ *
20
+ * - remoteChangedIds: sections whose content differs between the clean baseline
21
+ * and the fresh remote. The baseline MUST be clean — diffing against the dirty
22
+ * in-memory view would flag every locally-edited section as remote-changed,
23
+ * turning every dirty section into a false conflict.
24
+ * - locallyAddedIds: dirty ids absent from the clean baseline INDEX — sections
25
+ * created locally and never saved. This is what lets rebase distinguish a
26
+ * local add from a section deleted remotely while dirty.
27
+ *
28
+ * Degraded mode (baseline === null — should be unreachable, since every load,
29
+ * save, and recovery re-primes the cache): fail SAFE, not silent. Every dirty
30
+ * section that still exists remotely is flagged remote-changed (forcing the
31
+ * keep-mine/take-theirs dialog instead of a silent local-wins merge), and every
32
+ * dirty section absent remotely is treated as locally added (kept — a true
33
+ * remote-deletion-while-dirty resurrects instead of conflicting, which loses
34
+ * nothing). This is deliberately NOT derived from the published mainIndex: a
35
+ * main-based baseline would silently local-win every dirty section that already
36
+ * exists on main — the exact lost-update this path exists to prevent. */
37
+ export function deriveRecoveryInputs({ baseline, remote, dirtyRows, deletedIds }: {
38
+ baseline: RecoveryBaseline | null;
39
+ remote: { sections: LoadedSection[]; index: SiteIndex };
40
+ dirtyRows: { sectionId: string }[];
41
+ deletedIds: Set<string>;
42
+ }): { remoteChangedIds: Set<string>; locallyAddedIds: Set<string> } {
43
+ // Locally deleted ids are dropped by rebase regardless; keep them out of both
44
+ // derived sets so a delete never masquerades as an add or a conflict.
45
+ const dirtyIds = new Set(dirtyRows.map((r) => r.sectionId).filter((id) => !deletedIds.has(id)));
46
+
47
+ if (!baseline) {
48
+ return {
49
+ remoteChangedIds: new Set([...dirtyIds].filter((id) => id in remote.index.sections)),
50
+ locallyAddedIds: new Set([...dirtyIds].filter((id) => !(id in remote.index.sections))),
51
+ };
52
+ }
53
+
54
+ const baselineById = new Map(baseline.sections.map((s) => [s.section.id, JSON.stringify(s.section)]));
55
+ const remoteChangedIds = new Set(
56
+ remote.sections
57
+ .filter((s) => baselineById.has(s.section.id) && baselineById.get(s.section.id) !== JSON.stringify(s.section))
58
+ .map((s) => s.section.id),
59
+ );
60
+ // Safety: a dirty id that exists remotely but was never in the baseline means
61
+ // both sides created the same id independently (id collision). Flag it so the
62
+ // overlap goes to the dialog instead of silently keeping one side.
63
+ for (const id of dirtyIds) {
64
+ if (id in remote.index.sections && !(id in baseline.index.sections)) remoteChangedIds.add(id);
65
+ }
66
+ const locallyAddedIds = new Set([...dirtyIds].filter((id) => !(id in baseline.index.sections)));
67
+ return { remoteChangedIds, locallyAddedIds };
68
+ }
69
+
70
+ /** Apply the user's keep-mine/take-theirs choices to a rebase result.
71
+ * Pure — inputs are never mutated. For every conflict the LOCAL copy is
72
+ * returned in `preserved` (the caller snapshots it via preserveEdit so no
73
+ * choice is destructive). "theirs" swaps in the remote section — or, when the
74
+ * remote side DELETED it, removes the section AND strips its id from the index
75
+ * (pages' order + sections map); leaving the dangling index ref would make the
76
+ * server's dangling-ref gate reject every later save. `droppedDirtyIds` lists
77
+ * the "theirs" ids whose local dirty rows must be deleted so the retry doesn't
78
+ * re-send the overridden local content. */
79
+ export function applyConflictChoices({ merged, remoteSections, choices }: {
80
+ merged: { sections: LoadedSection[]; index: SiteIndex };
81
+ remoteSections: LoadedSection[];
82
+ choices: Record<string, "mine" | "theirs">;
83
+ }): {
84
+ sections: LoadedSection[];
85
+ index: SiteIndex;
86
+ droppedDirtyIds: string[];
87
+ preserved: { sectionId: string; section: Section }[];
88
+ } {
89
+ let sections = [...merged.sections];
90
+ const index: SiteIndex = {
91
+ ...merged.index,
92
+ pages: merged.index.pages.map((p) => ({ ...p, order: [...p.order] })),
93
+ sections: { ...merged.index.sections },
94
+ };
95
+ const droppedDirtyIds: string[] = [];
96
+ const preserved: { sectionId: string; section: Section }[] = [];
97
+ const remoteById = new Map(remoteSections.map((s) => [s.section.id, s]));
98
+
99
+ for (const [id, choice] of Object.entries(choices)) {
100
+ const local = sections.find((s) => s.section.id === id);
101
+ if (local) preserved.push({ sectionId: id, section: local.section });
102
+ if (choice !== "theirs") continue;
103
+ droppedDirtyIds.push(id);
104
+ const remote = remoteById.get(id);
105
+ if (remote) {
106
+ sections = sections.map((s) => (s.section.id === id ? remote : s));
107
+ } else {
108
+ // Remote deletion won: drop the section and every index reference to it.
109
+ sections = sections.filter((s) => s.section.id !== id);
110
+ for (const page of index.pages) page.order = page.order.filter((x) => x !== id);
111
+ delete index.sections[id];
112
+ }
113
+ }
114
+
115
+ return { sections, index, droppedDirtyIds, preserved };
116
+ }
117
+
118
+ /** Build the COMMITTED post-save snapshot for re-priming the clean baseline.
119
+ * The committed content is exactly what performSave sent (savedRows), NOT the
120
+ * current view state — edits typed while the request was in flight must not
121
+ * leak into the "clean" baseline (they are still dirty rows and would otherwise
122
+ * read as remote changes on the next recovery). Base = the previous clean
123
+ * baseline when available (post-recovery it is the fetched remote snapshot);
124
+ * fallback = the loaded sections captured at save start. Ids absent from the
125
+ * sent index (deleted this save) are dropped; saved ids absent from the base
126
+ * (locally added) are appended with their meta from the sent index. */
127
+ export function buildCommittedBaseline({ baselineSections, fallbackSections, savedRows, sentIndex }: {
128
+ baselineSections: LoadedSection[] | null;
129
+ fallbackSections: LoadedSection[];
130
+ savedRows: { sectionId: string; content: SectionContent }[];
131
+ sentIndex: SiteIndex;
132
+ }): LoadedSection[] {
133
+ const base = baselineSections ?? fallbackSections;
134
+ const sentById = new Map(savedRows.map((r) => [r.sectionId, r.content]));
135
+
136
+ const out = base
137
+ .filter((s) => s.section.id in sentIndex.sections)
138
+ .map((s) => {
139
+ const sent = sentById.get(s.section.id);
140
+ return sent ? { ...s, section: { ...s.section, ...sent } } : s;
141
+ });
142
+
143
+ const present = new Set(out.map((s) => s.section.id));
144
+ for (const [id, content] of sentById) {
145
+ if (present.has(id) || !(id in sentIndex.sections)) continue;
146
+ out.push({ section: { id, ...content }, meta: sentIndex.sections[id] });
147
+ }
148
+ return out;
149
+ }
@@ -0,0 +1,96 @@
1
+ // types.ts — the whole contract. No Supabase imports anywhere in primitives.
2
+ import { z } from "zod";
3
+
4
+ export const HEARTBEAT_INTERVAL_MS = 15_000;
5
+ export const LOCK_STALE_MS = 45_000;
6
+
7
+ export const CollabPeerSchema = z.object({
8
+ userId: z.string(),
9
+ email: z.string(),
10
+ focusSectionId: z.string().nullable(),
11
+ heldSectionIds: z.array(z.string()),
12
+ });
13
+ export type CollabPeer = z.infer<typeof CollabPeerSchema>;
14
+
15
+ export const SectionUpdateMessageSchema = z.object({
16
+ v: z.literal(1),
17
+ sectionId: z.string(),
18
+ content: z.unknown(),
19
+ userId: z.string(),
20
+ });
21
+ export type SectionUpdateMessage = z.infer<typeof SectionUpdateMessageSchema>;
22
+
23
+ export const IndexUpdateMessageSchema = z.object({
24
+ v: z.literal(1),
25
+ index: z.unknown(),
26
+ userId: z.string(),
27
+ });
28
+ export type IndexUpdateMessage = z.infer<typeof IndexUpdateMessageSchema>;
29
+
30
+ export const TakeoverMessageSchema = z.object({
31
+ v: z.literal(1),
32
+ sectionId: z.string(),
33
+ byUserId: z.string(),
34
+ byEmail: z.string(),
35
+ });
36
+ export type TakeoverMessage = z.infer<typeof TakeoverMessageSchema>;
37
+
38
+ export const SavedMessageSchema = z.object({
39
+ v: z.literal(1),
40
+ newSha: z.string(),
41
+ changedSectionIds: z.array(z.string()),
42
+ deletedSectionIds: z.array(z.string()),
43
+ source: z.enum(["editor", "api"]),
44
+ });
45
+ export type SavedMessage = z.infer<typeof SavedMessageSchema>;
46
+
47
+ export type CollabStatus = "connecting" | "connected" | "offline";
48
+
49
+ /** Opaque server-minted connection blob. Provider-specific keys ride along;
50
+ * primitives only reads the three identity fields. */
51
+ export interface CollabBootstrap {
52
+ siteId: string;
53
+ userId: string;
54
+ email: string;
55
+ [key: string]: unknown;
56
+ }
57
+
58
+ export interface CollabEvents {
59
+ onPeers(peers: CollabPeer[]): void;
60
+ onSectionUpdate(msg: SectionUpdateMessage): void;
61
+ onIndexUpdate(msg: IndexUpdateMessage): void;
62
+ onTakeover(msg: TakeoverMessage): void;
63
+ onSaved(msg: SavedMessage): void;
64
+ onStatus(status: CollabStatus): void;
65
+ }
66
+
67
+ /** A live connection to one site channel. The session owns the held-lock set
68
+ * (heartbeats them every HEARTBEAT_INTERVAL_MS and includes them in its
69
+ * presence payload). acquireLock({steal:true}) also emits the takeover
70
+ * broadcast on success. */
71
+ export interface CollabSession {
72
+ setFocus(sectionId: string | null): void;
73
+ acquireLock(sectionId: string, opts?: { steal?: boolean }): Promise<boolean>;
74
+ releaseLocks(sectionIds: string[]): Promise<void>;
75
+ broadcastSectionUpdate(sectionId: string, content: unknown): void;
76
+ broadcastIndexUpdate(index: unknown): void;
77
+ disconnect(): Promise<void>;
78
+ }
79
+
80
+ export interface SavedBroadcast {
81
+ newSha: string;
82
+ changedSectionIds: string[];
83
+ deletedSectionIds: string[];
84
+ source: "editor" | "api";
85
+ }
86
+
87
+ export interface CollabProvider {
88
+ /** Browser: join the site channel. Resolves null when collaboration is
89
+ * unavailable (no session, join refused) — caller runs solo-mode. */
90
+ connect(bootstrap: CollabBootstrap, events: CollabEvents): Promise<CollabSession | null>;
91
+ /** Server: mint the browser bootstrap for an authenticated editor session,
92
+ * or null when the provider can't support collab (e.g. missing env). */
93
+ getBootstrap(args: { siteId: string; userId: string; email: string }): CollabBootstrap | null;
94
+ /** Server: post-commit fan-out of a successful save to all connected editors. */
95
+ broadcastSaved(siteId: string, event: SavedBroadcast): Promise<void>;
96
+ }
@@ -8,9 +8,11 @@ interface AudienceIndicatorProps {
8
8
  access: string[];
9
9
  audiences: Audience[];
10
10
  onChange: (access: string[]) => void;
11
+ /** Display-only mode — the pill still shows audiences but the picker never opens. */
12
+ disabled?: boolean;
11
13
  }
12
14
 
13
- export function AudienceIndicator({ access, audiences, onChange }: AudienceIndicatorProps) {
15
+ export function AudienceIndicator({ access, audiences, onChange, disabled }: AudienceIndicatorProps) {
14
16
  const selected = audiences.filter((a) => access.includes(a.name));
15
17
  const visibleDots = selected.slice(0, 3);
16
18
 
@@ -35,6 +37,7 @@ export function AudienceIndicator({ access, audiences, onChange }: AudienceIndic
35
37
  dots={pillDots}
36
38
  label={labelText}
37
39
  buttonClassName={selected.length === 0 ? "text-base-contrast-light" : undefined}
40
+ disabled={disabled}
38
41
  clickContent={
39
42
  audiences.length === 0 ? (
40
43
  <div className="px-3 py-2 text-xs text-base-contrast-light whitespace-nowrap">
@@ -0,0 +1,61 @@
1
+ import { useRef, useState } from "react";
2
+ import { Button } from "../shared/Button";
3
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
4
+
5
+ export interface ConflictItem {
6
+ sectionId: string;
7
+ label: string;
8
+ }
9
+
10
+ /** Presented on 409 recovery when a section the editor changed was ALSO changed
11
+ * remotely. The user picks which side wins per section; the losing local copy
12
+ * is always snapshotted to Dexie either way (stored, not user-recoverable via
13
+ * the UI). Defaults every conflict to "mine" so a blind Continue never silently
14
+ * drops local work. */
15
+ export function ConflictDialog({
16
+ conflicts,
17
+ onResolve,
18
+ }: {
19
+ conflicts: ConflictItem[];
20
+ onResolve: (choices: Record<string, "mine" | "theirs">) => void;
21
+ }) {
22
+ const [choices, setChoices] = useState<Record<string, "mine" | "theirs">>(
23
+ Object.fromEntries(conflicts.map((c) => [c.sectionId, "mine" as const])),
24
+ );
25
+ const panelRef = useRef<HTMLDivElement>(null);
26
+ useFocusTrap(panelRef, true);
27
+ return (
28
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" role="dialog" aria-modal="true">
29
+ <div ref={panelRef} className="max-w-md rounded-lg bg-base p-6 shadow-xl">
30
+ <h2 className="mb-2 text-lg font-semibold text-base-contrast">Section changed by someone else</h2>
31
+ <p className="mb-4 text-sm text-base-contrast-light">
32
+ These sections were changed remotely while you edited them. Choose which version to keep — the other is safely stored locally.
33
+ </p>
34
+ <ul className="mb-4 space-y-2">
35
+ {conflicts.map((c) => (
36
+ <li key={c.sectionId} className="flex items-center justify-between gap-4 text-sm">
37
+ <span className="font-medium text-base-contrast">{c.label}</span>
38
+ <span className="flex gap-1">
39
+ {(["mine", "theirs"] as const).map((side) => (
40
+ <button
41
+ key={side}
42
+ type="button"
43
+ onClick={() => setChoices((prev) => ({ ...prev, [c.sectionId]: side }))}
44
+ className={choices[c.sectionId] === side
45
+ ? "rounded bg-primary px-2 py-1 text-primary-contrast"
46
+ : "rounded border border-base-200 px-2 py-1 text-base-contrast hover:bg-base-accent"}
47
+ >
48
+ {side === "mine" ? "Keep mine" : "Take theirs"}
49
+ </button>
50
+ ))}
51
+ </span>
52
+ </li>
53
+ ))}
54
+ </ul>
55
+ <div className="flex justify-end">
56
+ <Button variant="primary" size="md" onClick={() => onResolve(choices)}>Continue</Button>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ );
61
+ }
@@ -16,6 +16,9 @@ interface IndicatorPillProps {
16
16
  className?: string;
17
17
  buttonClassName?: string;
18
18
  ariaLabel?: string;
19
+ /** Display-only mode: dots/label still render but the popovers never open
20
+ * (e.g. while another editor holds the section lock). */
21
+ disabled?: boolean;
19
22
  }
20
23
 
21
24
  export function IndicatorPill({
@@ -26,6 +29,7 @@ export function IndicatorPill({
26
29
  className,
27
30
  buttonClassName,
28
31
  ariaLabel,
32
+ disabled,
29
33
  }: IndicatorPillProps) {
30
34
  const [hoverOpen, setHoverOpen] = useState(false);
31
35
  const [clickOpen, setClickOpen] = useState(false);
@@ -41,7 +45,7 @@ export function IndicatorPill({
41
45
 
42
46
  function handleMouseEnter() {
43
47
  clearHoverTimeout();
44
- if (!clickOpen && hoverContent) {
48
+ if (!disabled && !clickOpen && hoverContent) {
45
49
  setHoverOpen(true);
46
50
  }
47
51
  }
@@ -54,7 +58,7 @@ export function IndicatorPill({
54
58
  }
55
59
 
56
60
  function handleClick() {
57
- if (!clickContent) return;
61
+ if (disabled || !clickContent) return;
58
62
  setHoverOpen(false);
59
63
  clearHoverTimeout();
60
64
  setClickOpen((v) => !v);
@@ -72,15 +76,17 @@ export function IndicatorPill({
72
76
  onClick={handleClick}
73
77
  onMouseEnter={handleMouseEnter}
74
78
  onMouseLeave={handleMouseLeave}
75
- aria-haspopup={clickContent ? "true" : undefined}
76
- aria-expanded={clickContent ? clickOpen : undefined}
79
+ aria-haspopup={!disabled && clickContent ? "true" : undefined}
80
+ aria-expanded={!disabled && clickContent ? clickOpen : undefined}
81
+ aria-disabled={disabled || undefined}
77
82
  aria-label={ariaLabel}
78
83
  className={cn(
79
84
  // align-middle keeps the inline-flex pill centred in its line box regardless of
80
85
  // whether it leads with a status/audience dot — without it, the dot-less variant's
81
86
  // text baseline sits ~2px lower than the dotted variant, misaligning the two pills.
82
- "cursor-pointer inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium align-middle",
83
- "border border-base-200 hover:bg-base-accent",
87
+ "inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium align-middle",
88
+ "border border-base-200",
89
+ disabled ? "cursor-default opacity-60" : "cursor-pointer hover:bg-base-accent",
84
90
  buttonClassName,
85
91
  )}
86
92
  >
@@ -0,0 +1,31 @@
1
+ import { Lock } from "lucide-react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ interface LockBadgeProps {
5
+ email: string;
6
+ onTakeover?: () => void;
7
+ }
8
+
9
+ // Reuses the "modified" status tokens (an amber pair defined in base.css) so the
10
+ // lock badge sits in the same visual family as the other editor status pills
11
+ // rather than introducing raw amber-* literals.
12
+ export function LockBadge({ email, onTakeover }: LockBadgeProps) {
13
+ return (
14
+ <span className="pointer-events-auto inline-flex items-center gap-1.5 rounded-full bg-status-modified-bg px-2 py-0.5 text-xs font-medium text-status-modified-text align-middle">
15
+ <Lock size={12} strokeWidth={2.5} aria-hidden="true" />
16
+ <span className="whitespace-nowrap">{email} is editing</span>
17
+ {onTakeover && (
18
+ <button
19
+ type="button"
20
+ onClick={onTakeover}
21
+ className={cn(
22
+ "cursor-pointer rounded px-1.5 py-0.5 font-semibold",
23
+ "bg-status-modified-text/10 hover:bg-status-modified-text/20",
24
+ )}
25
+ >
26
+ Take over
27
+ </button>
28
+ )}
29
+ </span>
30
+ );
31
+ }
@@ -0,0 +1,53 @@
1
+ import type { CollabPeer, CollabStatus } from "../../collab/types";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ // Vivid -600 fills read as distinct badge colors and keep white text legible
5
+ // in both light and dark editor chrome.
6
+ const AVATAR_COLORS = [
7
+ "bg-sky-600",
8
+ "bg-emerald-600",
9
+ "bg-amber-600",
10
+ "bg-fuchsia-600",
11
+ "bg-rose-600",
12
+ ];
13
+
14
+ function initials(email: string): string {
15
+ return email.slice(0, 2).toUpperCase();
16
+ }
17
+
18
+ function colorFor(userId: string): string {
19
+ let h = 0;
20
+ for (const c of userId) h = (h * 31 + c.charCodeAt(0)) >>> 0;
21
+ return AVATAR_COLORS[h % AVATAR_COLORS.length];
22
+ }
23
+
24
+ interface PresenceAvatarsProps {
25
+ peers: CollabPeer[];
26
+ selfUserId: string | null;
27
+ status: CollabStatus;
28
+ }
29
+
30
+ export function PresenceAvatars({ peers, selfUserId, status }: PresenceAvatarsProps) {
31
+ const others = peers.filter((p) => p.userId !== selfUserId);
32
+ if (status !== "connected" || others.length === 0) return null;
33
+
34
+ return (
35
+ <div className="flex -space-x-2" aria-label="Editors online">
36
+ {others.map((p) => (
37
+ <span
38
+ key={p.userId}
39
+ title={p.focusSectionId ? `${p.email} — editing` : p.email}
40
+ className={cn(
41
+ // border-base-accent matches the toolbar surface behind the row in
42
+ // both themes (white in light, dark panel in dark), giving a clean
43
+ // ring where the avatars overlap.
44
+ "flex h-7 w-7 items-center justify-center rounded-full border-2 border-base-accent text-xs font-semibold text-white",
45
+ colorFor(p.userId),
46
+ )}
47
+ >
48
+ {initials(p.email)}
49
+ </span>
50
+ ))}
51
+ </div>
52
+ );
53
+ }