deepspace 0.3.10 → 0.4.0

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 (120) hide show
  1. package/dist/cli.js +331 -115
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +199 -16
  4. package/dist/index.js +354 -166
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +242 -73
  7. package/dist/server.js +985 -374
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +322 -73
  10. package/dist/worker.js +961 -343
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,1012 @@
1
+ /**
2
+ * DocsEditorPage — Tiptap + Yjs collaborative document editor.
3
+ *
4
+ * Replaces the previous contenteditable + HTML-in-Y.Text approach with
5
+ * ProseMirror-via-Tiptap bound to a Y.XmlFragment. Remote keystrokes are
6
+ * applied as granular PM transactions (no `innerHTML` swap), the local
7
+ * caret is migrated through concurrent edits by PM's position mapping,
8
+ * and remote cursors are painted by `@tiptap/extension-collaboration-caret`.
9
+ *
10
+ * Legacy docs created with the old editor stored HTML in Y.Text('content').
11
+ * On first open by an editor/owner we migrate that into the new XmlFragment
12
+ * via `editor.commands.setContent(html)` and clear the legacy field. The
13
+ * migration is a single Yjs transaction so all peers converge.
14
+ */
15
+
16
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
17
+ import { useParams, useNavigate, useRouteError, isRouteErrorResponse } from 'react-router-dom'
18
+ import {
19
+ useUser,
20
+ useQuery,
21
+ useMutations,
22
+ usePresenceRoom,
23
+ getUserColor,
24
+ type PresencePeerClient,
25
+ } from 'deepspace'
26
+ import { ArrowLeft, AlertTriangle, List as ListIcon, RefreshCw, Share2 } from 'lucide-react'
27
+ import { Badge } from 'deepspace'
28
+ import { type Editor, useEditorState } from '@tiptap/react'
29
+ import { useYjsRoomWithAwareness } from './use-yjs-room-with-awareness'
30
+ import { useDocEditor } from './editor/useDocEditor'
31
+ import { DocEditorSurface, PAGE_HEIGHT_PX, PAGE_WIDTH_PX } from './editor/DocEditorSurface'
32
+ import { DocsTiptapToolbar } from './editor/DocsTiptapToolbar'
33
+ import { DocsOutlinePanel, DOCUMENT_OUTLINE_WIDTH_PX, type OutlineEntry } from './DocsOutlinePanel'
34
+ import { DocsPresence, type DocsPresenceParticipant } from './DocsPresence'
35
+ import { InviteDialog, type InviteAclDiff } from './InviteDialog'
36
+ import './docs-ui.css'
37
+
38
+ interface DocumentFields {
39
+ title: string
40
+ ownerId: string
41
+ collaborators?: string
42
+ editors?: string
43
+ folderId?: string
44
+ }
45
+
46
+ const CANVAS_ZOOM_KEY = 'deepspace-docs-editor-canvas-zoom'
47
+ const OUTLINE_OPEN_KEY = 'deepspace-docs-editor-outline-open'
48
+ const KEYBOARD_ZOOM_STEP = 0.1
49
+ const TYPING_IDLE_MS = 1600
50
+ const TYPING_STALE_MS = 5000
51
+ /** Re-broadcast presence so clients who connect later still see existing viewers. */
52
+ const PRESENCE_HEARTBEAT_MS = 25_000
53
+ const DOC_NOT_FOUND_GRACE_MS = 450
54
+
55
+ function normalizeZoom(z: number): number {
56
+ return Math.min(2, Math.max(0.5, Math.round(z * 1000) / 1000))
57
+ }
58
+
59
+ function parseIdList(raw: string | undefined): string[] {
60
+ if (!raw) return []
61
+ try {
62
+ const parsed = JSON.parse(raw)
63
+ return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
64
+ } catch {
65
+ return []
66
+ }
67
+ }
68
+
69
+ function peerToDocsParticipant(p: PresencePeerClient): DocsPresenceParticipant {
70
+ const state = p.state
71
+ const mode: DocsPresenceParticipant['mode'] = state.mode === 'view' ? 'view' : 'edit'
72
+ const lastTypedAt = typeof state.lastTypedAt === 'number' ? state.lastTypedAt : undefined
73
+ const participant: DocsPresenceParticipant = {
74
+ clientId: 0,
75
+ userId: p.userId,
76
+ name: p.userName?.trim() || p.userEmail?.trim() || 'Guest',
77
+ mode,
78
+ typing: state.typing === true,
79
+ isSelf: false,
80
+ }
81
+ if (p.userEmail) participant.email = p.userEmail
82
+ if (p.userImageUrl) participant.imageUrl = p.userImageUrl
83
+ if (lastTypedAt != null) participant.lastTypedAt = lastTypedAt
84
+ return participant
85
+ }
86
+
87
+ function sortDocsPresenceParticipants(
88
+ a: DocsPresenceParticipant,
89
+ b: DocsPresenceParticipant,
90
+ ): number {
91
+ if (a.isSelf !== b.isSelf) return a.isSelf ? -1 : 1
92
+ if (a.mode !== b.mode) return a.mode === 'edit' ? -1 : 1
93
+ return a.name.localeCompare(b.name)
94
+ }
95
+
96
+ interface DocsPresenceSelfUser {
97
+ id: string
98
+ name?: string | null
99
+ email?: string | null
100
+ imageUrl?: string | null
101
+ }
102
+
103
+ function buildDocsPresence(
104
+ peers: PresencePeerClient[],
105
+ self: DocsPresenceSelfUser | null | undefined,
106
+ presenceAsEditMode: boolean,
107
+ ): DocsPresenceParticipant[] {
108
+ if (!self) {
109
+ return [...peers.map(peerToDocsParticipant)].sort(sortDocsPresenceParticipants)
110
+ }
111
+
112
+ const selfRow: DocsPresenceParticipant = {
113
+ clientId: 0,
114
+ userId: self.id,
115
+ name: self.name?.trim() || self.email?.trim() || 'You',
116
+ mode: presenceAsEditMode ? 'edit' : 'view',
117
+ isSelf: true,
118
+ }
119
+ if (self.email) selfRow.email = self.email
120
+ if (self.imageUrl) selfRow.imageUrl = self.imageUrl
121
+
122
+ const others = peers.filter((p) => p.userId !== self.id).map(peerToDocsParticipant)
123
+ return [selfRow, ...others].sort(sortDocsPresenceParticipants)
124
+ }
125
+
126
+ function InlineTitle({
127
+ title,
128
+ canEdit,
129
+ onSave,
130
+ }: {
131
+ title: string
132
+ canEdit: boolean
133
+ onSave: (next: string) => void
134
+ }) {
135
+ const [editing, setEditing] = useState(false)
136
+ const [value, setValue] = useState(title)
137
+ const inputRef = useRef<HTMLInputElement>(null)
138
+
139
+ useEffect(() => {
140
+ setValue(title)
141
+ }, [title])
142
+
143
+ useEffect(() => {
144
+ if (editing && inputRef.current) {
145
+ inputRef.current.focus()
146
+ inputRef.current.select()
147
+ }
148
+ }, [editing])
149
+
150
+ const commit = () => {
151
+ const t = value.trim()
152
+ if (t && t !== title) onSave(t)
153
+ else setValue(title)
154
+ setEditing(false)
155
+ }
156
+
157
+ if (!canEdit || !editing) {
158
+ return (
159
+ <h1
160
+ className="min-w-0 flex-1 cursor-text truncate rounded px-1 text-lg font-semibold tracking-tight transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06]"
161
+ style={{ color: 'var(--docs-el-text)' }}
162
+ onClick={() => canEdit && setEditing(true)}
163
+ title={canEdit ? 'Click to rename' : undefined}
164
+ >
165
+ {title}
166
+ </h1>
167
+ )
168
+ }
169
+
170
+ return (
171
+ <input
172
+ ref={inputRef}
173
+ value={value}
174
+ onChange={(e) => setValue(e.target.value)}
175
+ onBlur={commit}
176
+ onKeyDown={(e) => {
177
+ if (e.key === 'Enter') commit()
178
+ if (e.key === 'Escape') {
179
+ setValue(title)
180
+ setEditing(false)
181
+ }
182
+ }}
183
+ className="min-w-0 flex-1 rounded border px-2 py-0.5 text-lg font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
184
+ style={{
185
+ borderColor: 'var(--docs-el-line)',
186
+ backgroundColor: 'var(--docs-el-bg)',
187
+ color: 'var(--docs-el-text)',
188
+ }}
189
+ />
190
+ )
191
+ }
192
+
193
+ type AccessChangeKind = 'downgrade' | 'upgrade' | 'revoked'
194
+
195
+ /**
196
+ * Full-screen overlay shown to the second user when the owner changes their
197
+ * permissions mid-session. Three cases:
198
+ *
199
+ * - `downgrade`: editor → viewer. We block the editor surface so an
200
+ * in-flight keystroke can't slip past the role boundary while Yjs and the
201
+ * `documents` record settle on the new permission, and prompt the user to
202
+ * refresh the route. Without this, the Tiptap editor view is rebuilt with
203
+ * a new placeholder/extensions array (because `showReadOnlyDocUx` flips)
204
+ * which races a stale `requestAnimationFrame` in `DocEditorSurface` and
205
+ * throws `Cannot read properties of null (reading 'matchesNode')`.
206
+ *
207
+ * - `upgrade`: viewer → editor. Same editor-rebuild path on the local peer
208
+ * (the placeholder string flips the other way), so a refresh gives the
209
+ * user a clean Tiptap mount with editing enabled and no risk of the
210
+ * stale-rAF crash in the rebuild.
211
+ *
212
+ * - `revoked`: the owner removed this peer from `collaborators`/`editors`
213
+ * entirely. The Yjs server-side auth cache plus the locally-cached
214
+ * `documents` row let the peer keep typing for a few seconds until they
215
+ * reconnect; the overlay locks the UI immediately so no further edits are
216
+ * attempted, and tells the user to refresh to leave.
217
+ */
218
+ function AccessChangedOverlay({
219
+ kind,
220
+ onRefresh,
221
+ }: {
222
+ kind: AccessChangeKind
223
+ onRefresh: () => void
224
+ }) {
225
+ const title =
226
+ kind === 'revoked'
227
+ ? 'Your access has been removed'
228
+ : kind === 'upgrade'
229
+ ? 'You can now edit this document'
230
+ : "You're now view-only"
231
+ const body =
232
+ kind === 'revoked'
233
+ ? 'The owner has removed your access to this document. Refresh to continue.'
234
+ : kind === 'upgrade'
235
+ ? 'The owner gave you editor access. Refresh to reload the document with editing enabled.'
236
+ : 'The owner changed your access to view-only. Refresh to reload the document.'
237
+
238
+ return (
239
+ <div
240
+ role="alertdialog"
241
+ aria-modal="true"
242
+ aria-labelledby="docs-access-change-title"
243
+ className="absolute inset-0 z-[80] flex items-center justify-center px-4"
244
+ style={{ backgroundColor: 'color-mix(in srgb, var(--docs-el-bg) 78%, transparent)' }}
245
+ >
246
+ <div
247
+ className="w-full max-w-sm rounded-xl border p-6 shadow-xl backdrop-blur"
248
+ style={{
249
+ borderColor: 'var(--docs-el-line)',
250
+ backgroundColor: 'var(--docs-el-surface)',
251
+ color: 'var(--docs-el-text)',
252
+ }}
253
+ >
254
+ <div className="flex items-start gap-3">
255
+ <span
256
+ className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
257
+ style={{
258
+ backgroundColor: 'color-mix(in srgb, var(--docs-el-accent) 14%, transparent)',
259
+ color: 'var(--docs-el-accent)',
260
+ }}
261
+ aria-hidden
262
+ >
263
+ <AlertTriangle className="h-4 w-4" strokeWidth={2} />
264
+ </span>
265
+ <div className="min-w-0 flex-1">
266
+ <h2 id="docs-access-change-title" className="text-base font-semibold tracking-tight">
267
+ {title}
268
+ </h2>
269
+ <p className="mt-1.5 text-sm" style={{ color: 'var(--docs-el-muted)' }}>
270
+ {body}
271
+ </p>
272
+ </div>
273
+ </div>
274
+ <div className="mt-5 flex justify-end">
275
+ <button
276
+ type="button"
277
+ autoFocus
278
+ onClick={onRefresh}
279
+ data-testid="docs-access-refresh"
280
+ className="inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium text-white transition-opacity hover:opacity-90"
281
+ style={{ backgroundColor: 'var(--docs-el-accent)' }}
282
+ >
283
+ <RefreshCw className="h-3.5 w-3.5" strokeWidth={2.25} />
284
+ Refresh
285
+ </button>
286
+ </div>
287
+ </div>
288
+ </div>
289
+ )
290
+ }
291
+
292
+ /**
293
+ * Rendered while we don't yet have a Tiptap editor to mount (initial mount,
294
+ * docId switch, or while Yjs is still syncing). Crucially this mirrors the
295
+ * exact geometry of {@link DocEditorSurface} — same canvas, same paged
296
+ * page-frame, same blank paper — so when the real surface takes over the
297
+ * swap is invisible. No `animate-pulse` skeleton bars: those used to flash
298
+ * for a single frame on fast loads, which read as a "flicker".
299
+ */
300
+ function DocsEditorLoadingSurface({ label = 'Opening document...' }: { label?: string }) {
301
+ return (
302
+ <div className="docs-paged-editor-canvas h-full min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain print:block print:!h-auto print:!overflow-visible print:!bg-white">
303
+ <div className="docs-paged-inner pointer-events-auto mx-auto w-full max-w-full px-2 py-3 sm:px-3 sm:py-4 md:py-5">
304
+ <div
305
+ className="docs-paged-page-frame relative mx-auto"
306
+ style={{ width: `${PAGE_WIDTH_PX / 96}in`, maxWidth: '100%' }}
307
+ aria-busy="true"
308
+ aria-label={label}
309
+ >
310
+ <div
311
+ className="docs-paged-page-stack relative"
312
+ style={{ minHeight: `${PAGE_HEIGHT_PX}px` }}
313
+ >
314
+ <div className="docs-paged-page-face docs-editor-paper pointer-events-none absolute left-0 top-0 h-[11in] w-full rounded-sm print:static print:h-auto print:shadow-none print:border-0" />
315
+ </div>
316
+ </div>
317
+ </div>
318
+ </div>
319
+ )
320
+ }
321
+
322
+ /**
323
+ * Walk the ProseMirror doc once per editor update, returning headings.
324
+ * Cheap (linear in node count) and keeps the outline panel in lockstep
325
+ * with what's actually rendered.
326
+ */
327
+ function useOutlineEntries(editor: Editor | null): OutlineEntry[] {
328
+ return (
329
+ useEditorState({
330
+ editor,
331
+ selector: ({ editor: e }) => {
332
+ // Same defensive checks as the toolbar selector — `e.state` is null
333
+ // when the underlying ProseMirror view has been torn down.
334
+ if (!e || e.isDestroyed || !e.view) return [] as OutlineEntry[]
335
+ try {
336
+ const out: OutlineEntry[] = []
337
+ e.state.doc.descendants((node, pos) => {
338
+ if (node.type.name === 'heading') {
339
+ const level = (node.attrs.level as number) ?? 1
340
+ const title = node.textContent.trim()
341
+ if (title) out.push({ level, title, pos })
342
+ return false
343
+ }
344
+ return true
345
+ })
346
+ return out
347
+ } catch {
348
+ return [] as OutlineEntry[]
349
+ }
350
+ },
351
+ }) ?? []
352
+ )
353
+ }
354
+
355
+ export default function DocsEditorPage() {
356
+ const { docId } = useParams<{ docId: string }>()
357
+ const navigate = useNavigate()
358
+ const { user } = useUser()
359
+
360
+ const { records: documents, status } = useQuery<DocumentFields>('documents', {
361
+ orderBy: 'createdAt',
362
+ orderDir: 'desc',
363
+ })
364
+
365
+ const selectedDoc = useMemo(
366
+ () => (docId ? documents.find((d) => d.recordId === docId) : null),
367
+ [documents, docId],
368
+ )
369
+
370
+ const { put } = useMutations<DocumentFields>('documents')
371
+
372
+ /**
373
+ * `'content'` is kept as the field name only so legacy-doc migration can
374
+ * read the old HTML out of Y.Text('content') below. The Tiptap editor
375
+ * itself binds to a separate Y.XmlFragment('default') inside the same
376
+ * Y.Doc — they don't collide.
377
+ */
378
+ const {
379
+ doc,
380
+ text: legacyText,
381
+ synced,
382
+ canWrite,
383
+ writeAuthResolved,
384
+ awareness,
385
+ } = useYjsRoomWithAwareness(docId ?? 'noop', 'content')
386
+
387
+ const presenceScopeId = docId ? `doc:${docId}` : '_'
388
+ const {
389
+ peers: presencePeers,
390
+ connected: presenceConnected,
391
+ updateState: updatePresenceState,
392
+ } = usePresenceRoom(presenceScopeId)
393
+
394
+ const [inviteOpen, setInviteOpen] = useState(false)
395
+ const typingRef = useRef(false)
396
+ const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
397
+
398
+ const [canvasZoom, setCanvasZoom] = useState(() => {
399
+ if (typeof window === 'undefined') return 1
400
+ try {
401
+ const raw = sessionStorage.getItem(CANVAS_ZOOM_KEY)
402
+ const v = raw == null ? 1 : Number.parseFloat(raw)
403
+ return normalizeZoom(Number.isFinite(v) ? v : 1)
404
+ } catch {
405
+ return 1
406
+ }
407
+ })
408
+
409
+ const [outlineOpen, setOutlineOpen] = useState(() => {
410
+ if (typeof window === 'undefined') return true
411
+ try {
412
+ return window.localStorage.getItem(OUTLINE_OPEN_KEY) !== '0'
413
+ } catch {
414
+ return true
415
+ }
416
+ })
417
+
418
+ useEffect(() => {
419
+ try {
420
+ sessionStorage.setItem(CANVAS_ZOOM_KEY, String(canvasZoom))
421
+ } catch {
422
+ /* ignore */
423
+ }
424
+ }, [canvasZoom])
425
+
426
+ useEffect(() => {
427
+ const onKeyDown = (event: KeyboardEvent) => {
428
+ if (!(event.metaKey || event.ctrlKey) || event.altKey) return
429
+ const key = event.key
430
+ const isZoomIn = key === '+' || key === '='
431
+ const isZoomOut = key === '-' || key === '_'
432
+ const isReset = key === '0'
433
+ if (!isZoomIn && !isZoomOut && !isReset) return
434
+
435
+ event.preventDefault()
436
+ event.stopPropagation()
437
+ setCanvasZoom((current) => {
438
+ if (isReset) return 1
439
+ return normalizeZoom(current + (isZoomIn ? KEYBOARD_ZOOM_STEP : -KEYBOARD_ZOOM_STEP))
440
+ })
441
+ }
442
+ window.addEventListener('keydown', onKeyDown, true)
443
+ return () => window.removeEventListener('keydown', onKeyDown, true)
444
+ }, [])
445
+
446
+ useEffect(() => {
447
+ try {
448
+ window.localStorage.setItem(OUTLINE_OPEN_KEY, outlineOpen ? '1' : '0')
449
+ } catch {
450
+ /* ignore */
451
+ }
452
+ }, [outlineOpen])
453
+
454
+ // Permissions ----------------------------------------------------------------
455
+ const isOwner = selectedDoc?.data.ownerId === user?.id
456
+ const collaboratorIds = useMemo(
457
+ () => parseIdList(selectedDoc?.data.collaborators),
458
+ [selectedDoc?.data.collaborators],
459
+ )
460
+ const editorIds = useMemo(
461
+ () => parseIdList(selectedDoc?.data.editors),
462
+ [selectedDoc?.data.editors],
463
+ )
464
+ const effectiveRole: 'owner' | 'editor' | 'viewer' | 'none' = !user
465
+ ? 'none'
466
+ : isOwner
467
+ ? 'owner'
468
+ : editorIds.includes(user.id)
469
+ ? 'editor'
470
+ : collaboratorIds.includes(user.id)
471
+ ? 'viewer'
472
+ : 'none'
473
+ const policyCanEdit = effectiveRole === 'owner' || effectiveRole === 'editor'
474
+
475
+ /**
476
+ * Snapshot of the *first* concrete role this peer saw for this `docId`,
477
+ * plus the wall-clock instant when we captured it. We can't trust
478
+ * `effectiveRole` alone to detect "the owner just removed me" because
479
+ * role transitions (none → editor on initial fetch, editor → viewer on
480
+ * demote, viewer → none on remove) all flow through the same `none`
481
+ * value before/after `selectedDoc` resolves. The role tells us
482
+ * downgrade/upgrade vs. still-loading; the timestamp lets us ignore
483
+ * stale `aclSignal` presence updates from before we joined.
484
+ */
485
+ const [initialRole, setInitialRole] = useState<typeof effectiveRole | null>(null)
486
+ const sessionStartedAtRef = useRef<number | null>(null)
487
+ useEffect(() => {
488
+ setInitialRole(null)
489
+ sessionStartedAtRef.current = null
490
+ }, [docId])
491
+ useEffect(() => {
492
+ if (initialRole) return
493
+ if (!selectedDoc || !user) return
494
+ if (effectiveRole === 'none') return
495
+ setInitialRole(effectiveRole)
496
+ sessionStartedAtRef.current = Date.now()
497
+ }, [initialRole, selectedDoc, user, effectiveRole])
498
+
499
+ /**
500
+ * Latched permission-change event delivered over presence. The owner
501
+ * publishes an `aclSignal` payload after every InviteDialog save; peers
502
+ * watch the owner's presence state and freeze the first signal addressed
503
+ * to them. This is the only mechanism that reaches a *removed* user,
504
+ * since the docs schema's `read: 'collaborator'` rule means the
505
+ * documents-record update with the new collaborators list is filtered
506
+ * out of their RecordRoom subscription — the kicked client would
507
+ * otherwise keep its stale role indefinitely and let the user keep
508
+ * typing until they refresh.
509
+ */
510
+ type DetectedAclEvent = { kind: AccessChangeKind; at: number }
511
+ const [detectedAclEvent, setDetectedAclEvent] = useState<DetectedAclEvent | null>(null)
512
+ useEffect(() => {
513
+ setDetectedAclEvent(null)
514
+ }, [docId])
515
+
516
+ const accessChangeKind: AccessChangeKind | null = useMemo(() => {
517
+ // Latched presence-derived revoke always wins — it's the only signal
518
+ // that reaches a peer whose record subscription was filtered out.
519
+ if (detectedAclEvent?.kind === 'revoked') return 'revoked'
520
+
521
+ // Local-record-derived transitions cover the cases where the
522
+ // documents record still propagates (downgrade / upgrade). They
523
+ // need an initial concrete role to compare against; without one
524
+ // we fall through to whatever presence latched.
525
+ if (initialRole && initialRole !== 'owner') {
526
+ if (effectiveRole === 'none') return 'revoked'
527
+ if (initialRole === 'editor' && effectiveRole === 'viewer') return 'downgrade'
528
+ if (initialRole === 'viewer' && effectiveRole === 'editor') return 'upgrade'
529
+ }
530
+
531
+ return detectedAclEvent?.kind ?? null
532
+ }, [initialRole, effectiveRole, detectedAclEvent])
533
+ const accessLocked = accessChangeKind !== null
534
+
535
+ /**
536
+ * Once the owner revokes/downgrades, we hard-stop further writes from this
537
+ * peer even if Yjs/server auth hasn't caught up yet. Combined with the
538
+ * blocking overlay below, this prevents the "removed user can still type
539
+ * until refresh" window described in the report.
540
+ */
541
+ const writesLockedByAcl = accessLocked && accessChangeKind !== 'upgrade'
542
+ const effectiveCanWrite = canWrite && policyCanEdit && !writesLockedByAcl
543
+ /** Collaborator viewers always see read-only chrome; editors/owners only after Yjs auth resolves and denies write. */
544
+ const showReadOnlyDocUx =
545
+ effectiveRole === 'viewer' || (policyCanEdit && writeAuthResolved && !canWrite)
546
+ const presenceAsEditMode =
547
+ policyCanEdit && !(writeAuthResolved && !canWrite) && !writesLockedByAcl
548
+
549
+ // Tiptap editor --------------------------------------------------------------
550
+ const userName = user?.name?.trim() || user?.email?.trim() || 'Guest'
551
+ const userColor = useMemo(() => getUserColor(user?.id ?? 'anon'), [user?.id])
552
+ const editor = useDocEditor({
553
+ doc,
554
+ awareness,
555
+ userName,
556
+ userColor,
557
+ synced,
558
+ canWrite: effectiveCanWrite,
559
+ placeholder: showReadOnlyDocUx ? 'View only' : 'Start typing — toolbar above for formatting…',
560
+ })
561
+
562
+ // Legacy migration: if doc.getXmlFragment('default') is empty but the
563
+ // legacy Y.Text('content') field has HTML, parse it once into Tiptap.
564
+ // Only the first writer to open does this; viewers never see partial state
565
+ // because both the wipe and the parse run inside a single Yjs transaction.
566
+ const migratedRef = useRef(false)
567
+ useEffect(() => {
568
+ if (migratedRef.current) return
569
+ if (!editor || !synced || !effectiveCanWrite) return
570
+ const fragment = doc.getXmlFragment('default')
571
+ if (fragment.length > 0) {
572
+ migratedRef.current = true
573
+ return
574
+ }
575
+ const yText = doc.getText('content')
576
+ const legacy = (legacyText ?? yText.toString()).trim()
577
+ if (!legacy) {
578
+ migratedRef.current = true
579
+ return
580
+ }
581
+ migratedRef.current = true
582
+ /**
583
+ * Tiptap parses the HTML against its schema, then writes the resulting
584
+ * nodes into the bound Y.XmlFragment. We then clear the legacy field
585
+ * inside the same transaction so peers see one atomic step.
586
+ */
587
+ doc.transact(() => {
588
+ editor.commands.setContent(legacy, { emitUpdate: true })
589
+ if (yText.length > 0) yText.delete(0, yText.length)
590
+ })
591
+ }, [editor, synced, effectiveCanWrite, doc, legacyText])
592
+
593
+ // Presence -------------------------------------------------------------------
594
+ const presenceParticipants = useMemo(
595
+ () => buildDocsPresence(presencePeers, user ?? undefined, presenceAsEditMode),
596
+ [presencePeers, user, presenceAsEditMode],
597
+ )
598
+
599
+ const publishPresence = useCallback(
600
+ (typingFlag: boolean) => {
601
+ if (!selectedDoc || !user || !synced) return
602
+ updatePresenceState({
603
+ mode: presenceAsEditMode ? 'edit' : 'view',
604
+ typing: typingFlag,
605
+ ...(typingFlag ? { lastTypedAt: Date.now() } : {}),
606
+ })
607
+ },
608
+ [presenceAsEditMode, selectedDoc, synced, updatePresenceState, user],
609
+ )
610
+
611
+ /**
612
+ * Permission-change fan-out via presence.
613
+ *
614
+ * The owner publishes a one-shot `aclSignal` payload after every
615
+ * InviteDialog save. The PresenceRoom server merges incoming state into
616
+ * each peer's record, so this field rides alongside the existing
617
+ * `mode`/`typing`/`lastTypedAt` fields without clobbering them. We
618
+ * intentionally route through presence rather than the documents record
619
+ * because the docs schema's `read: 'collaborator'` rule prevents a
620
+ * just-removed user from seeing the new record state — presence is the
621
+ * only channel still wired to them at the moment of revocation.
622
+ */
623
+ const handleAclChange = useCallback(
624
+ (diff: InviteAclDiff) => {
625
+ if (!isOwner) return
626
+ updatePresenceState({
627
+ aclSignal: {
628
+ at: Date.now(),
629
+ removed: diff.removedUserIds,
630
+ demoted: diff.demotedUserIds,
631
+ promoted: diff.promotedUserIds,
632
+ },
633
+ })
634
+ },
635
+ [isOwner, updatePresenceState],
636
+ )
637
+
638
+ /**
639
+ * Latch the first relevant `aclSignal` we see from the owner's presence
640
+ * peer. We compare its `at` against `sessionStartedAtRef` so refreshing
641
+ * into a doc whose owner already published a signal doesn't immediately
642
+ * trip the overlay — only signals emitted *after* this session began
643
+ * count. Once latched we keep the value: even if the owner disconnects
644
+ * and their presence peer vanishes, the overlay must persist.
645
+ */
646
+ useEffect(() => {
647
+ if (!user || !selectedDoc) return
648
+ if (detectedAclEvent) return
649
+ const start = sessionStartedAtRef.current
650
+ if (start == null) return
651
+ const ownerId = selectedDoc.data.ownerId
652
+ if (user.id === ownerId) return
653
+ const ownerPeer = presencePeers.find((p) => p.userId === ownerId)
654
+ if (!ownerPeer) return
655
+
656
+ const rawSignal = (ownerPeer.state as Record<string, unknown>).aclSignal
657
+ if (!rawSignal || typeof rawSignal !== 'object') return
658
+ const signal = rawSignal as {
659
+ at?: unknown
660
+ removed?: unknown
661
+ demoted?: unknown
662
+ promoted?: unknown
663
+ }
664
+ if (typeof signal.at !== 'number' || signal.at <= start) return
665
+
666
+ const includes = (list: unknown): boolean =>
667
+ Array.isArray(list) && list.some((id) => id === user.id)
668
+
669
+ if (includes(signal.removed)) {
670
+ setDetectedAclEvent({ kind: 'revoked', at: signal.at })
671
+ } else if (includes(signal.demoted)) {
672
+ setDetectedAclEvent({ kind: 'downgrade', at: signal.at })
673
+ } else if (includes(signal.promoted)) {
674
+ setDetectedAclEvent({ kind: 'upgrade', at: signal.at })
675
+ }
676
+ }, [presencePeers, user, selectedDoc, detectedAclEvent])
677
+
678
+ // Typing flag — driven by Tiptap's `update` event (debounced idle reset).
679
+ useEffect(() => {
680
+ if (!editor) return
681
+ const onUpdate = () => {
682
+ if (!effectiveCanWrite) return
683
+ typingRef.current = true
684
+ publishPresence(true)
685
+ if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
686
+ typingTimeoutRef.current = setTimeout(() => {
687
+ typingRef.current = false
688
+ publishPresence(false)
689
+ }, TYPING_IDLE_MS)
690
+ }
691
+ editor.on('update', onUpdate)
692
+ return () => {
693
+ editor.off('update', onUpdate)
694
+ if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
695
+ }
696
+ }, [editor, effectiveCanWrite, publishPresence])
697
+
698
+ // Initial presence + heartbeat so late joiners still see existing viewers.
699
+ useEffect(() => {
700
+ publishPresence(typingRef.current)
701
+ }, [publishPresence, presenceConnected])
702
+
703
+ useEffect(() => {
704
+ if (!synced || !selectedDoc || !user) return
705
+ const id = window.setInterval(() => publishPresence(typingRef.current), PRESENCE_HEARTBEAT_MS)
706
+ return () => clearInterval(id)
707
+ }, [synced, selectedDoc, user, publishPresence])
708
+
709
+ // Typing names from presence (stale-aware).
710
+ const typingNames = useMemo(() => {
711
+ const names: string[] = []
712
+ const seen = new Set<string>()
713
+ for (const p of presenceParticipants) {
714
+ if (p.isSelf || !p.typing) continue
715
+ if (p.lastTypedAt != null && Date.now() - p.lastTypedAt >= TYPING_STALE_MS) continue
716
+ if (seen.has(p.name)) continue
717
+ seen.add(p.name)
718
+ names.push(p.name)
719
+ }
720
+ return names
721
+ }, [presenceParticipants])
722
+
723
+ // Outline --------------------------------------------------------------------
724
+ const outlineEntries = useOutlineEntries(editor)
725
+ const jumpToHeading = useCallback(
726
+ (pos: number) => {
727
+ if (!editor) return
728
+ editor.chain().focus().setTextSelection(pos).scrollIntoView().run()
729
+ },
730
+ [editor],
731
+ )
732
+
733
+ // Title save -----------------------------------------------------------------
734
+ const docTitle = selectedDoc?.data.title?.trim() || 'Untitled Document'
735
+ const handleTitleSave = useCallback(
736
+ async (next: string) => {
737
+ if (!selectedDoc) return
738
+ await put(selectedDoc.recordId, { ...selectedDoc.data, title: next }).catch(() => {})
739
+ },
740
+ [put, selectedDoc],
741
+ )
742
+
743
+ /**
744
+ * Loading / not-found are intentionally NOT alternative early-return
745
+ * layouts — swapping the whole page between a skeleton chrome and the real
746
+ * chrome produced a visible flash on create/open. We render one full layout
747
+ * and swap only the editor area for not-found vs paper vs live ProseMirror.
748
+ * The Tiptap instance mounts as soon as it is ready (before Yjs `synced`);
749
+ * the header, toolbar shell, and paper stay stable while the connection
750
+ * finishes and document content hydrates from the room.
751
+ */
752
+ const isLoading = status === 'loading'
753
+ const [showNotFound, setShowNotFound] = useState(false)
754
+
755
+ useEffect(() => {
756
+ if (isLoading || selectedDoc) {
757
+ setShowNotFound(false)
758
+ return
759
+ }
760
+
761
+ const id = window.setTimeout(() => setShowNotFound(true), DOC_NOT_FOUND_GRACE_MS)
762
+ return () => window.clearTimeout(id)
763
+ }, [docId, isLoading, selectedDoc])
764
+
765
+ const docResolutionPending = isLoading || (!selectedDoc && !showNotFound)
766
+ const docMissing = !isLoading && !selectedDoc && showNotFound
767
+
768
+ return (
769
+ <div
770
+ className="docs-feature-scope flex h-full flex-col overflow-hidden print:h-auto print:overflow-visible"
771
+ style={{ backgroundColor: 'var(--docs-el-bg)', color: 'var(--docs-el-text)' }}
772
+ >
773
+ <header
774
+ className="relative z-10 flex shrink-0 flex-wrap items-center gap-2 border-b px-3 py-3 backdrop-blur-sm print:hidden sm:gap-3 sm:px-4"
775
+ style={{
776
+ borderColor: 'var(--docs-el-line)',
777
+ backgroundColor: 'color-mix(in srgb, var(--docs-el-surface) 92%, transparent)',
778
+ }}
779
+ >
780
+ <button
781
+ type="button"
782
+ onClick={() => navigate('/docs')}
783
+ className="rounded-lg p-1.5 transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06]"
784
+ style={{ color: 'var(--docs-el-muted)' }}
785
+ title="Back to documents"
786
+ >
787
+ <ArrowLeft className="h-4 w-4" strokeWidth={2} />
788
+ </button>
789
+
790
+ <InlineTitle title={docTitle} canEdit={isOwner ?? false} onSave={handleTitleSave} />
791
+
792
+ <DocsPresence participants={presenceParticipants} typingNames={typingNames} />
793
+
794
+ <div className="flex shrink-0 items-center gap-3">
795
+ {isOwner ? (
796
+ <button
797
+ type="button"
798
+ onClick={() => setInviteOpen(true)}
799
+ className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06]"
800
+ style={{
801
+ borderColor: 'var(--docs-el-line)',
802
+ color: 'var(--docs-el-muted)',
803
+ backgroundColor: 'transparent',
804
+ }}
805
+ title="Share this document"
806
+ >
807
+ <Share2 className="h-3.5 w-3.5" />
808
+ Share
809
+ </button>
810
+ ) : effectiveRole === 'editor' || effectiveRole === 'viewer' ? (
811
+ <span
812
+ className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium"
813
+ style={{ borderColor: 'var(--docs-el-line)', color: 'var(--docs-el-muted)' }}
814
+ >
815
+ {effectiveRole === 'editor' ? 'Shared editor' : 'Shared viewer'}
816
+ </span>
817
+ ) : null}
818
+ {synced ? (
819
+ <span
820
+ className="flex items-center gap-1.5 text-xs tabular-nums"
821
+ style={{ color: 'var(--docs-el-muted)' }}
822
+ >
823
+ <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden />
824
+ Synced
825
+ </span>
826
+ ) : (
827
+ <span
828
+ className="flex items-center gap-1.5 text-xs tabular-nums"
829
+ style={{ color: 'var(--docs-el-muted)' }}
830
+ >
831
+ <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500" aria-hidden />
832
+ Connecting…
833
+ </span>
834
+ )}
835
+ {effectiveRole === 'owner' ? (
836
+ <Badge variant="success">Owner</Badge>
837
+ ) : effectiveRole === 'editor' ? (
838
+ <Badge variant="success">Editor</Badge>
839
+ ) : effectiveRole === 'viewer' ? (
840
+ <Badge variant="secondary">Viewer</Badge>
841
+ ) : effectiveCanWrite ? (
842
+ <Badge variant="success">Edit</Badge>
843
+ ) : (
844
+ <Badge variant="secondary">View</Badge>
845
+ )}
846
+ </div>
847
+ </header>
848
+
849
+ {showReadOnlyDocUx ? (
850
+ <div
851
+ className="relative z-10 flex shrink-0 items-center gap-2 border-b px-4 py-2 text-sm print:hidden"
852
+ style={{
853
+ borderColor: 'var(--docs-el-line)',
854
+ backgroundColor: 'color-mix(in srgb, var(--docs-el-muted) 12%, transparent)',
855
+ color: 'var(--docs-el-muted)',
856
+ }}
857
+ >
858
+ {effectiveRole === 'viewer'
859
+ ? 'You have view-only access to this document. Ask the owner for editor access.'
860
+ : 'You are viewing this document in read-only mode.'}
861
+ </div>
862
+ ) : null}
863
+
864
+ <DocsTiptapToolbar
865
+ editor={editor}
866
+ disabled={!synced || !effectiveCanWrite}
867
+ canvasZoom={canvasZoom}
868
+ onCanvasZoomChange={(z) => setCanvasZoom(normalizeZoom(z))}
869
+ />
870
+
871
+ <div className="relative flex min-h-0 flex-1 flex-col print:block">
872
+ {outlineOpen ? (
873
+ <DocsOutlinePanel entries={outlineEntries} onJumpTo={jumpToHeading} />
874
+ ) : null}
875
+
876
+ <button
877
+ type="button"
878
+ data-testid="toggle-outline"
879
+ onClick={() => setOutlineOpen((o) => !o)}
880
+ title={outlineOpen ? 'Hide document outline' : 'Show document outline'}
881
+ style={{
882
+ top: 12,
883
+ left: outlineOpen ? DOCUMENT_OUTLINE_WIDTH_PX + 10 : 12,
884
+ borderColor: 'color-mix(in srgb, var(--docs-el-line) 85%, transparent)',
885
+ backgroundColor: 'color-mix(in srgb, var(--docs-el-surface) 92%, transparent)',
886
+ color: 'var(--docs-el-muted)',
887
+ }}
888
+ className={`absolute z-[36] flex h-9 w-9 items-center justify-center rounded-full border shadow-md backdrop-blur-md transition-[left,box-shadow,color] duration-200 ease-out hover:bg-black/[0.06] hover:text-[color:var(--docs-el-text)] hover:shadow-lg print:hidden dark:border-white/12 dark:hover:bg-white/[0.08] ${outlineOpen ? 'text-[color:var(--docs-el-text)]' : ''}`}
889
+ >
890
+ <ListIcon className="h-4 w-4" strokeWidth={2} />
891
+ </button>
892
+
893
+ <div
894
+ data-testid="doc-canvas-zoom"
895
+ className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden print:[zoom:1]"
896
+ style={{
897
+ zoom: canvasZoom,
898
+ paddingLeft: outlineOpen ? DOCUMENT_OUTLINE_WIDTH_PX : 0,
899
+ }}
900
+ >
901
+ {docMissing ? (
902
+ <div className="docs-paged-editor-canvas flex h-full min-h-0 w-full flex-1 flex-col items-center justify-center gap-4 px-6">
903
+ <p className="text-sm" style={{ color: 'var(--docs-el-muted)' }}>
904
+ This document is private. Ask the owner for an invite.
905
+ </p>
906
+ <button
907
+ type="button"
908
+ onClick={() => navigate('/docs')}
909
+ className="rounded-lg px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
910
+ style={{ backgroundColor: 'var(--docs-el-accent)' }}
911
+ >
912
+ Back to documents
913
+ </button>
914
+ </div>
915
+ ) : editor && !docResolutionPending ? (
916
+ <DocEditorSurface editor={editor} />
917
+ ) : (
918
+ <DocsEditorLoadingSurface
919
+ label={isLoading ? 'Opening document...' : 'Resolving document...'}
920
+ />
921
+ )}
922
+ {accessChangeKind ? (
923
+ <AccessChangedOverlay
924
+ kind={accessChangeKind}
925
+ onRefresh={() => window.location.reload()}
926
+ />
927
+ ) : null}
928
+ </div>
929
+ </div>
930
+
931
+ {selectedDoc ? (
932
+ <InviteDialog
933
+ open={inviteOpen}
934
+ onOpenChange={setInviteOpen}
935
+ doc={selectedDoc}
936
+ isOwner={isOwner ?? false}
937
+ onAclChange={handleAclChange}
938
+ />
939
+ ) : null}
940
+ </div>
941
+ )
942
+ }
943
+
944
+ /**
945
+ * Route-level error boundary. Generouted picks up the `ErrorBoundary` export
946
+ * and wires it to the route's `errorElement` prop, so any render-time throw
947
+ * inside this route (notably the ProseMirror `matchesNode` crash when the
948
+ * owner toggles a peer's role mid-session) is contained here instead of
949
+ * blowing up the whole app with the React Router default screen.
950
+ *
951
+ * The recovery path is a hard reload — Yjs/Tiptap state at the moment of
952
+ * the crash is no longer trustworthy, and the new permissions on the doc
953
+ * are already authoritative on the server.
954
+ */
955
+ export function ErrorBoundary() {
956
+ const error = useRouteError()
957
+ const message = isRouteErrorResponse(error)
958
+ ? `${error.status} ${error.statusText}`
959
+ : error instanceof Error
960
+ ? error.message
961
+ : 'Something went wrong while loading this document.'
962
+
963
+ return (
964
+ <div
965
+ data-testid="docs-route-error"
966
+ className="docs-feature-scope flex h-full flex-col items-center justify-center px-6 text-center"
967
+ style={{ backgroundColor: 'var(--docs-el-bg)', color: 'var(--docs-el-text)' }}
968
+ >
969
+ <div
970
+ className="w-full max-w-md rounded-xl border p-6 shadow-md"
971
+ style={{ borderColor: 'var(--docs-el-line)', backgroundColor: 'var(--docs-el-surface)' }}
972
+ >
973
+ <div className="mb-3 flex items-center justify-center">
974
+ <span
975
+ className="inline-flex h-10 w-10 items-center justify-center rounded-full"
976
+ style={{
977
+ backgroundColor: 'color-mix(in srgb, var(--docs-el-accent) 14%, transparent)',
978
+ color: 'var(--docs-el-accent)',
979
+ }}
980
+ aria-hidden
981
+ >
982
+ <AlertTriangle className="h-5 w-5" strokeWidth={2} />
983
+ </span>
984
+ </div>
985
+ <h1 className="text-base font-semibold tracking-tight">This document needs to reload</h1>
986
+ <p className="mx-auto mt-1.5 max-w-xs text-sm" style={{ color: 'var(--docs-el-muted)' }}>
987
+ Your access to this document just changed. Refresh to load the latest version.
988
+ </p>
989
+ <p
990
+ className="mx-auto mt-3 max-w-xs truncate text-xs"
991
+ style={{ color: 'var(--docs-el-muted)' }}
992
+ title={message}
993
+ >
994
+ {message}
995
+ </p>
996
+ <div className="mt-5 flex justify-center">
997
+ <button
998
+ type="button"
999
+ autoFocus
1000
+ onClick={() => window.location.reload()}
1001
+ data-testid="docs-route-error-refresh"
1002
+ className="inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium text-white transition-opacity hover:opacity-90"
1003
+ style={{ backgroundColor: 'var(--docs-el-accent)' }}
1004
+ >
1005
+ <RefreshCw className="h-3.5 w-3.5" strokeWidth={2.25} />
1006
+ Refresh
1007
+ </button>
1008
+ </div>
1009
+ </div>
1010
+ </div>
1011
+ )
1012
+ }