{"_id":"@actuate-media/realtime","_rev":"4-3876178f672b30338ab1336bca483086","name":"@actuate-media/realtime","dist-tags":{"latest":"0.1.2"},"versions":{"0.1.0":{"name":"@actuate-media/realtime","version":"0.1.0","_id":"@actuate-media/realtime@0.1.0","maintainers":[{"name":"actuate_media","email":"strategize@actuatemedia.com"}],"homepage":"https://github.com/actuate-media/actuatecms#readme","bugs":{"url":"https://github.com/actuate-media/actuatecms/issues"},"dist":{"shasum":"a76760a43ac6d1bf71dd98d3f58cffeb68b7d34f","tarball":"https://registry.npmjs.org/@actuate-media/realtime/-/realtime-0.1.0.tgz","fileCount":19,"integrity":"sha512-GaTAZC4WD/gaEEzgR9+gaXRm8LkgeaCM1mC8+BeKidmJyUC9zHsleDCaXr17oPNpfPgTZUuDQRjUkCs2gJlAPQ==","signatures":[{"sig":"MEYCIQD/jA+HAe7j31tNqr6UhEcHVqKQuChidaHBCALACBWu+AIhALSAJQUPjPt4hX4acA8Cp/fDmjfvZ3kkDx7ehQILeJHT","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":66763},"main":"./dist/index.js","type":"module","_from":"file:actuate-media-realtime-0.1.0.tgz","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","default":"./dist/index.js"},"./room":{"types":"./dist/room.d.ts","import":"./dist/room.js","default":"./dist/room.js"},"./protocol":{"types":"./dist/protocol.d.ts","import":"./dist/protocol.js","default":"./dist/protocol.js"},"./persistence":{"types":"./dist/persistence.d.ts","import":"./dist/persistence.js","default":"./dist/persistence.js"}},"scripts":{"dev":"tsc --project tsconfig.json --watch --preserveWatchOutput","test":"vitest run","build":"tsc --project tsconfig.json","clean":"rm -rf dist .turbo","type-check":"tsc --noEmit"},"_npmUser":{"name":"actuate_media","email":"strategize@actuatemedia.com"},"_resolved":"/tmp/0cdd2de2ea6bcd0e5c8b94e2bc0d0003/actuate-media-realtime-0.1.0.tgz","_integrity":"sha512-GaTAZC4WD/gaEEzgR9+gaXRm8LkgeaCM1mC8+BeKidmJyUC9zHsleDCaXr17oPNpfPgTZUuDQRjUkCs2gJlAPQ==","repository":{"url":"git+https://github.com/actuate-media/actuatecms.git","type":"git","directory":"packages/realtime"},"_npmVersion":"10.9.8","description":"Transport-agnostic Yjs sync + awareness primitives that power Actuate CMS real-time collaboration.","directories":{},"_nodeVersion":"22.22.3","dependencies":{"yjs":"^13.6.0","lib0":"^0.2.99","y-protocols":"^1.0.6"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^3.0.0","typescript":"^5.7.0","@types/node":"^22.0.0"},"_npmOperationalInternal":{"tmp":"tmp/realtime_0.1.0_1779853154644_0.3881659369177972","host":"s3://npm-registry-packages-npm-production"},"deprecated":"Renamed to @prenta/realtime — see https://github.com/actuate-media/prenta"},"0.1.1":{"name":"@actuate-media/realtime","version":"0.1.1","_id":"@actuate-media/realtime@0.1.1","maintainers":[{"name":"actuate_media","email":"strategize@actuatemedia.com"}],"homepage":"https://github.com/actuate-media/actuatecms#readme","bugs":{"url":"https://github.com/actuate-media/actuatecms/issues"},"dist":{"shasum":"a85b5924817c6bc7e14904e67485af7c92887982","tarball":"https://registry.npmjs.org/@actuate-media/realtime/-/realtime-0.1.1.tgz","fileCount":20,"integrity":"sha512-DFDF7I9txh1zElg4XMjqZUMgOKVS5ZkwgacjOueL5I1ogg356wNf1q+L8Yyqc1b8NWEKlIb1kO7eaSoVkk20fg==","signatures":[{"sig":"MEUCIHGoCDXm4Hl9G2gqq0eDAGNYtqIqK3ZCaqjf8wPEUqetAiEAuKASdQ/tZ/m5so0Vk0PxW7IRoENctRBXkh4aIKnatAA=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":67674},"main":"./dist/index.js","type":"module","_from":"file:actuate-media-realtime-0.1.1.tgz","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","default":"./dist/index.js"},"./room":{"types":"./dist/room.d.ts","import":"./dist/room.js","default":"./dist/room.js"},"./protocol":{"types":"./dist/protocol.d.ts","import":"./dist/protocol.js","default":"./dist/protocol.js"},"./persistence":{"types":"./dist/persistence.d.ts","import":"./dist/persistence.js","default":"./dist/persistence.js"}},"scripts":{"dev":"tsc --project tsconfig.json --watch --preserveWatchOutput","test":"vitest run","build":"tsc --project tsconfig.json","clean":"rm -rf dist .turbo","type-check":"tsc --noEmit"},"_npmUser":{"name":"actuate_media","email":"strategize@actuatemedia.com"},"_resolved":"/tmp/3b947097e3a0618ed69fa4b15d0e69e1/actuate-media-realtime-0.1.1.tgz","_integrity":"sha512-DFDF7I9txh1zElg4XMjqZUMgOKVS5ZkwgacjOueL5I1ogg356wNf1q+L8Yyqc1b8NWEKlIb1kO7eaSoVkk20fg==","repository":{"url":"git+https://github.com/actuate-media/actuatecms.git","type":"git","directory":"packages/realtime"},"_npmVersion":"10.9.8","description":"Transport-agnostic Yjs sync + awareness primitives that power Actuate CMS real-time collaboration.","directories":{},"_nodeVersion":"22.22.3","dependencies":{"yjs":"^13.6.0","lib0":"^0.2.99","y-protocols":"^1.0.6"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^4.1.0","typescript":"^5.7.0","@types/node":"^22.0.0"},"_npmOperationalInternal":{"tmp":"tmp/realtime_0.1.1_1781106990571_0.1213973981170493","host":"s3://npm-registry-packages-npm-production"},"deprecated":"Renamed to @prenta/realtime — see https://github.com/actuate-media/prenta"},"0.1.2":{"name":"@actuate-media/realtime","version":"0.1.2","license":"MIT","_id":"@actuate-media/realtime@0.1.2","maintainers":[{"name":"actuate_media","email":"strategize@actuatemedia.com"}],"homepage":"https://github.com/actuate-media/actuatecms#readme","bugs":{"url":"https://github.com/actuate-media/actuatecms/issues"},"dist":{"shasum":"321321e0fc8d6ae829e43489eae723ca47e6a775","tarball":"https://registry.npmjs.org/@actuate-media/realtime/-/realtime-0.1.2.tgz","fileCount":20,"integrity":"sha512-V3jJUFtCATSMa3Zd36HENQrr0APIGOsPnjbWLfL/ZgYnN1Gd8U/wUHO2/BLZjrK/OKmDqdPDvF9hbQ4AMzuFDw==","signatures":[{"sig":"MEQCIHg+1WJtZ4dznqz7bNO44QvpOJBl1M2vGbUxVFmjNQmtAiBL9YCVdmbo+iPCqfkl1DOZIcXJviU24Nwx1GbRJiuskA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":67918},"main":"./dist/index.js","type":"module","_from":"file:actuate-media-realtime-0.1.2.tgz","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","default":"./dist/index.js"},"./room":{"types":"./dist/room.d.ts","import":"./dist/room.js","default":"./dist/room.js"},"./protocol":{"types":"./dist/protocol.d.ts","import":"./dist/protocol.js","default":"./dist/protocol.js"},"./persistence":{"types":"./dist/persistence.d.ts","import":"./dist/persistence.js","default":"./dist/persistence.js"}},"scripts":{"dev":"tsc --project tsconfig.json --watch --preserveWatchOutput","test":"vitest run","build":"tsc --project tsconfig.json","clean":"rm -rf dist .turbo","type-check":"tsc --noEmit"},"_npmUser":{"name":"actuate_media","email":"strategize@actuatemedia.com"},"_resolved":"/tmp/1a537c4fb70dd67ddf48c64f90c37810/actuate-media-realtime-0.1.2.tgz","_integrity":"sha512-V3jJUFtCATSMa3Zd36HENQrr0APIGOsPnjbWLfL/ZgYnN1Gd8U/wUHO2/BLZjrK/OKmDqdPDvF9hbQ4AMzuFDw==","repository":{"url":"git+https://github.com/actuate-media/actuatecms.git","type":"git","directory":"packages/realtime"},"_npmVersion":"10.9.8","description":"Transport-agnostic Yjs sync + awareness primitives that power Actuate CMS real-time collaboration.","directories":{},"_nodeVersion":"22.23.1","dependencies":{"yjs":"^13.6.0","lib0":"^0.2.99","y-protocols":"^1.0.6"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^4.1.0","typescript":"^5.7.0","@types/node":"^22.0.0"},"_npmOperationalInternal":{"tmp":"tmp/realtime_0.1.2_1783301983757_0.8867615944986258","host":"s3://npm-registry-packages-npm-production"},"deprecated":"Renamed to @prenta/realtime — see https://github.com/actuate-media/prenta"}},"time":{"created":"2026-05-27T03:39:14.478Z","modified":"2026-07-30T20:44:32.537Z","0.1.0":"2026-05-27T03:39:14.765Z","0.1.1":"2026-06-10T15:56:30.708Z","0.1.2":"2026-07-06T01:39:43.951Z"},"bugs":{"url":"https://github.com/actuate-media/actuatecms/issues"},"license":"MIT","homepage":"https://github.com/actuate-media/actuatecms#readme","repository":{"url":"git+https://github.com/actuate-media/actuatecms.git","type":"git","directory":"packages/realtime"},"description":"Transport-agnostic Yjs sync + awareness primitives that power Actuate CMS real-time collaboration.","maintainers":[{"name":"actuate_media","email":"strategize@actuatemedia.com"}],"readme":"# @actuate-media/realtime\n\nTransport-agnostic Yjs collaboration primitives that power Actuate CMS'\nreal-time editing surface (Phase 3 of the [roadmap](../../docs/roadmap-status.md)).\n\nThe package is intentionally narrow: it owns the **wire protocol**, the\n**room state machine**, and the **persistence contract**. It knows nothing\nabout HTTP, WebSocket, or any specific server framework — those concerns\nlive in `@actuate-media/cms-core` (the `/realtime/sync` endpoint) and the\nadmin TipTap collaboration extension.\n\n## What you get\n\n- **Protocol layer (`./protocol`).** Pure encode/decode helpers for the\n  binary envelope used by `y-websocket`. Read inbound frames with\n  `readMessage`; produce outbound ones with `encodeSyncStep1`,\n  `encodeSyncStep2`, `encodeUpdateMessage`, `encodeAwarenessUpdate`,\n  `encodeQueryAwareness`. The envelope is byte-compatible with the\n  upstream y-websocket provider, so standard browser clients (including\n  `@tiptap/extension-collaboration`'s default transport) work without any\n  custom wire shimming.\n- **Room (`./room`).** `createRoom({ documentId, initialState })` returns\n  a `Room` that owns a `Y.Doc` and an `Awareness` instance and routes\n  bytes between attached `Connection`s. The transport layer creates\n  one `Connection` per peer (a thin wrapper around a WebSocket / fetch\n  stream / postMessage channel) and calls `addConnection`,\n  `handleMessage`, and `removeConnection`. The room handles the sync\n  handshake, broadcast fan-out, and awareness propagation.\n- **Persistence (`./persistence`).** A narrow `DocumentPersistence`\n  interface (`load` / `save`) plus `bindPersistence(room, persistence)`,\n  which debounces writes off the room's `update` event. A reference\n  in-memory implementation (`createMemoryPersistence`) ships in the box\n  and is used by the test suite.\n\n## Quick start\n\n```ts\nimport {\n  createRoom,\n  bindPersistence,\n  createMemoryPersistence,\n  type Connection,\n} from '@actuate-media/realtime'\n\nconst persistence = createMemoryPersistence()\n\n// Bootstrap a room with whatever state we have on disk.\nconst initialState = (await persistence.load('doc-1')) ?? undefined\nconst room = createRoom({ documentId: 'doc-1', initialState })\nconst { flush, unbind } = bindPersistence(room, persistence)\n\n// In your WebSocket onConnection handler:\nconst conn: Connection = {\n  id: socketId,\n  send: (bytes) => socket.send(bytes),\n  close: () => socket.close(),\n}\nroom.addConnection(conn)\nsocket.on('message', (bytes) => room.handleMessage(conn.id, bytes))\nsocket.on('close', () => room.removeConnection(conn.id))\n\n// On graceful shutdown:\nawait flush()\nunbind()\nroom.destroy()\n```\n\nThe room sends a `SyncStep1` immediately on `addConnection` and an\nawareness snapshot if any peers are already present, so the client only\nneeds to reply with its own state — the standard y-websocket handshake.\n\n## Persistence model\n\nThe companion Prisma model is named `DocumentCRDT` (see\n`packages/cms-core/prisma/cms-schema.prisma`). The schema fragment lives\nin cms-core so consumers wire it into a single migration. The shape:\n\n| Field             | Type     | Purpose                                                |\n| ----------------- | -------- | ------------------------------------------------------ |\n| `documentId`      | String   | PK — references `Document.id` at the app layer         |\n| `state`           | Bytes    | `Y.encodeStateAsUpdate(doc)` blob                      |\n| `stateVector`     | Bytes    | `Y.encodeStateVector(doc)` — speeds up cold late joins |\n| `version`         | Int      | Monotonic counter (optimistic locking)                 |\n| `lastUpdatedAt`   | DateTime | Wall-clock of most recent flush                        |\n| `lastUpdatedById` | String?  | Editor id if known                                     |\n| `createdAt`       | DateTime | First-write timestamp                                  |\n\n`@actuate-media/realtime` is storage-agnostic — the cms-core layer\nimplements `DocumentPersistence` against Prisma, but you can plug\nin S3, Redis, or anything else by implementing two methods.\n\n## Wire-protocol compatibility\n\nThe envelope follows the standard y-websocket layout:\n\n```\n[messageType: varUint][payload: bytes]\n\nmessageType 0 → MESSAGE_SYNC            (y-protocols sync sub-message)\nmessageType 1 → MESSAGE_AWARENESS       (varUint8Array awareness update)\nmessageType 3 → MESSAGE_QUERY_AWARENESS (no payload; reply with snapshot)\n```\n\nKeeping this stable means any client that already speaks y-websocket\n(e.g. browsers using `@tiptap/extension-collaboration` with the default\nprovider) can connect to the Actuate realtime endpoint without custom\nclient-side code.\n\n## Test coverage\n\n42 unit tests across four files:\n\n| File               | Coverage                                                          |\n| ------------------ | ----------------------------------------------------------------- |\n| `protocol.test`    | envelope shape, sync roundtrip, awareness, query, malformed input |\n| `room.test`        | handshake, two-client convergence, disconnect, awareness fan-out  |\n| `persistence.test` | debounce, flush, error handling, round trip with the memory impl  |\n| `index.test`       | barrel exports, constant stability                                |\n\nRun them with `pnpm --filter @actuate-media/realtime test`.\n\n## Status\n\n- **Slice 1 (this package, foundation)** — ✅ shipped.\n- **Slice 2 (cms-core WebSocket gateway + Prisma adapter)** — ✅ shipped.\n  See `@actuate-media/cms-core/realtime` for the consumer-facing\n  `createRealtimeGateway` and `createPrismaDocumentPersistence` helpers.\n- **Slice 3 (admin TipTap collaboration extension)** — ✅ shipped.\n  See `@actuate-media/cms-admin` — `createCollaborationProvider`,\n  `<PresenceChips />`, and the `collaboration` prop on `<TipTapEditor />`.\n- **Slice 4 (`DocumentComment` model + REST endpoints)** — ✅ shipped.\n  See `@actuate-media/cms-core/realtime` for the service helpers\n  (`createComment`, `listComments`, `resolveComment`, …) and the HTTP\n  endpoints under `/api/cms/documents/:id/comments` and\n  `/api/cms/comments/:id`.\n- **Slice 5 (comments side panel + anchor binding in admin)** — ✅ shipped.\n  See `@actuate-media/cms-admin` — the `comments` prop on `<TipTapEditor />`,\n  the `CommentSidePanel` component, the `CommentMark` TipTap mark, and\n  the anchor helpers in `lib/comment-anchor.ts`.\n- **Slice 6 (offline drafts + notifications)** — ✅ shipped.\n  See `@actuate-media/cms-admin` — `<OfflineStatus />`, the `offline`\n  flag on `createCollaborationProvider`, and `<NotificationBell />`.\n  Server side: `DocumentNotification` Prisma model + REST endpoints\n  under `/api/cms/notifications` in `@actuate-media/cms-core`.\n\n## Wiring the gateway (slice 2 cheat sheet)\n\nThe cms-core gateway is transport-agnostic — it takes a `WebSocketLike`\nadapter so it works with `ws`, `uWebSockets.js`, Bun, Cloudflare Workers,\nor anything else. A minimal `ws` integration looks like this:\n\n```ts\nimport { createServer } from 'node:http'\nimport { WebSocketServer } from 'ws'\nimport {\n  createRealtimeGateway,\n  createPrismaDocumentPersistence,\n  type WebSocketLike,\n} from '@actuate-media/cms-core/realtime'\n\nconst httpServer = createServer()\nconst wss = new WebSocketServer({ noServer: true })\n\nconst gateway = createRealtimeGateway({\n  persistence: createPrismaDocumentPersistence(prisma),\n  authenticate: async (req) => {\n    const session = await verifySessionFromHeaders(req.headers)\n    if (!session) return null\n    const url = new URL(`http://x${req.url}`)\n    return {\n      documentId: url.searchParams.get('documentId')!,\n      connectionId: req.headers['sec-websocket-key']!,\n      userId: session.userId,\n    }\n  },\n})\n\nhttpServer.on('upgrade', (req, socket, head) => {\n  wss.handleUpgrade(req, socket, head, (ws) => {\n    const adapter: WebSocketLike = {\n      send: (d) => ws.send(d),\n      close: (code, reason) => ws.close(code, reason),\n      on: (event, handler) => {\n        if (event === 'message')\n          ws.on('message', (d: Buffer) => (handler as (b: Uint8Array) => void)(new Uint8Array(d)))\n        else if (event === 'close') ws.on('close', () => (handler as () => void)())\n        else if (event === 'error') ws.on('error', (e: Error) => (handler as (e: Error) => void)(e))\n      },\n    }\n    void gateway.handleConnection(adapter, {\n      url: req.url ?? '/',\n      headers: req.headers as Record<string, string | undefined>,\n    })\n  })\n})\n\nprocess.on('SIGTERM', () => {\n  void gateway.shutdown()\n})\n```\n\nThe gateway handles per-document room creation, debounced persistence,\nidle reaping, and graceful shutdown automatically.\n\n## Wiring the editor (slice 3 cheat sheet)\n\nThe admin editor accepts an optional `collaboration` prop. When set, the\n`TipTapEditor` swaps StarterKit's history for the Yjs-driven history,\nmounts the collaboration + collaboration-cursor extensions, and renders\n`<PresenceChips />` above the toolbar.\n\n```tsx\nimport { TipTapEditor } from '@actuate-media/cms-admin/components/TipTapEditor'\n;<TipTapEditor\n  content={initialHtml}\n  onChange={() => {\n    /* still fires for autosave; gateway is source of truth */\n  }}\n  collaboration={{\n    documentId: doc.id,\n    url: 'wss://your.app/api/cms/realtime/sync',\n    user: { id: currentUser.id, name: currentUser.name, color: '#22c55e' },\n    // Optional: extra query params (e.g. auth tokens for cross-origin deploys).\n    params: { token: previewToken },\n  }}\n/>\n```\n\nBehind the scenes:\n\n1. `createCollaborationProvider({ documentId, url, user })` builds a\n   `Y.Doc`, opens a `WebsocketProvider`, and seeds `Awareness.localState`\n   with the user info.\n2. The editor wires `Collaboration.configure({ document })` and\n   `CollaborationCursor.configure({ provider, user })`, and disables\n   StarterKit's history (Yjs owns undo/redo when collaboration is active).\n3. `PresenceChips` subscribes to the awareness instance and renders an\n   avatar strip with overflow + connection status.\n\nThe provider exposes `status` (`connecting | connected | disconnected`)\nand lifecycle callbacks (`onStatusChange`, `onError`) so callers can\nsurface their own connection chrome if needed.\n\n## Comments REST API (slice 4 cheat sheet)\n\nComments live in a separate REST surface (not the Yjs wire) so plugins,\nmobile clients, and notification workers can consume them without\nspeaking the CRDT protocol. The Prisma model is `DocumentComment`\n(threaded, anchored, soft-deletable); the service helpers in\n`@actuate-media/cms-core/realtime` own validation and permissions, and\nthe HTTP handlers in `cms-core` map those to status codes.\n\n### Endpoints (under `/api/cms`)\n\n| Method | Path                              | Purpose                                  | Auth                |\n| ------ | --------------------------------- | ---------------------------------------- | ------------------- |\n| POST   | `/documents/:documentId/comments` | Create a top-level comment or a reply    | write role          |\n| GET    | `/documents/:documentId/comments` | List active comments (filters available) | any auth            |\n| PATCH  | `/comments/:id`                   | Edit a comment body                      | author or admin     |\n| POST   | `/comments/:id/resolve`           | Mark a thread as resolved                | write role / author |\n| POST   | `/comments/:id/reopen`            | Re-open a resolved thread                | write role / author |\n| DELETE | `/comments/:id`                   | Soft-delete a comment                    | author or admin     |\n\nGET supports `?includeResolved=true` (anyone) and `?includeDeleted=true`\n(admin only). Responses are always envelope `{ data: CommentDTO }` or\n`{ data: CommentDTO[] }`; errors map cleanly: 400 (validation), 403\n(forbidden), 404 (not found), 409 (conflict).\n\n### Anchor format\n\nThe `anchor` field is opaque to the API. The client encodes a pair of\nYjs relative positions with `Y.encodeRelativePosition(...)` and Base64s\neach side:\n\n```ts\nimport * as Y from 'yjs'\n\nfunction makeAnchor(doc: Y.Doc, from: number, to: number) {\n  const type = doc.getXmlFragment('default') // or whichever ytype your editor binds to\n  const relFrom = Y.createRelativePositionFromTypeIndex(type, from)\n  const relTo = Y.createRelativePositionFromTypeIndex(type, to)\n  return {\n    from: Buffer.from(Y.encodeRelativePosition(relFrom)).toString('base64'),\n    to: Buffer.from(Y.encodeRelativePosition(relTo)).toString('base64'),\n  }\n}\n```\n\nSlice 5 reverses the operation in the comments side panel to paint\nlive highlights that survive concurrent edits — see the next section.\n\n### Service layer (server-side)\n\nApps that need to manage comments outside the HTTP layer (e.g. a\nbackground worker that emits notifications on resolve) can call the\nservice helpers directly:\n\n```ts\nimport { createComment, resolveComment, type CommentsDB } from '@actuate-media/cms-core/realtime'\n\nconst db: CommentsDB = prisma // PrismaClient satisfies the narrow type\nconst created = await createComment(db, {\n  documentId: 'doc-1',\n  userId: 'user-1',\n  body: 'Looks great!',\n})\nif (!created.ok) throw new Error(created.error.message)\n\nawait resolveComment(db, created.value.id, {\n  userId: 'editor-1',\n  canResolve: true,\n})\n```\n\nThe service is intentionally storage-agnostic — `CommentsDB` is the\nexact subset of Prisma we use, so tests can substitute an in-memory\nfake (see `comments.test.ts`).\n\n## Comments side panel (slice 5 cheat sheet)\n\nSlice 5 layers a typed REST client, anchor helpers, a TipTap mark, and\na side-panel React component on top of slice 4 so editors can comment\non selections directly inside the admin.\n\n```tsx\nimport { TipTapEditor } from '@actuate-media/cms-admin/components/TipTapEditor'\n;<TipTapEditor\n  content={initialHtml}\n  onChange={onAutoSave}\n  collaboration={{\n    documentId: doc.id,\n    url: 'wss://your.app/api/cms/realtime/sync',\n    user: { id: currentUser.id, name: currentUser.name, color: '#22c55e' },\n  }}\n  comments={{\n    documentId: doc.id,\n    currentUserId: currentUser.id,\n    isAdmin: currentUser.role === 'ADMIN',\n    onError: (msg) => toast.error(msg),\n  }}\n/>\n```\n\nThe editor reacts to the `comments` prop by:\n\n1. Loading the `CommentMark` extension so saved comment ranges are\n   highlighted in the document (yellow band on hover, dotted underline\n   when resolved, outlined when the panel selects them).\n2. Mounting `<CommentSidePanel />` next to the editor pane. The panel\n   talks to the slice-4 REST API through `lib/comments-client.ts` and\n   shows threads, replies, edit/resolve/delete affordances, and an\n   includes-resolved toggle.\n3. Encoding the active editor selection into a CRDT-relative anchor\n   when \"Comment\" is submitted, so the highlight survives concurrent\n   edits. Multi-paragraph selections fall back to a doc-level comment\n   (anchor `null`).\n\n### Composing anchors manually\n\n`lib/comment-anchor.ts` is the single place that interprets the wire\nformat. Consumers building their own UI can use it directly:\n\n```ts\nimport * as Y from 'yjs'\nimport { encodeAnchor, decodeAnchor } from '@actuate-media/cms-admin/lib/comment-anchor'\n\nconst yText = paragraph.firstChild as Y.XmlText\nconst anchor = encodeAnchor({ doc, yType: yText, from: 6, to: 11 })\n// → POST /documents/:id/comments { anchor }\n\n// Later, after sync:\nconst resolved = decodeAnchor({ doc, anchor })\n// → { yType, from, to } or null when the anchored text was deleted.\n```\n\n### Driving the panel from a custom shell\n\n`<CommentSidePanel />` is exported standalone — the editor wires it\nthrough the `comments` prop for convenience, but a different host shell\n(e.g. an inbox-style review queue) can render the same component:\n\n```tsx\nimport { CommentSidePanel } from '@actuate-media/cms-admin/components/CommentSidePanel'\n;<CommentSidePanel\n  documentId={doc.id}\n  currentUserId={user.id}\n  isAdmin={user.role === 'ADMIN'}\n  onComposeAnchor={() => null /* doc-level only */}\n  onError={notify}\n/>\n```\n\nAll actions (create, reply, edit, resolve / reopen, delete) flow\nthrough the same `cmsApi` client that powers the rest of the admin, so\nCSRF tokens, locale headers, and credential handling are inherited\nwithout extra wiring.\n\n## Offline drafts + notifications (slice 6 cheat sheet)\n\nSlice 6 closes Phase 3 with two ergonomics features that ride on top of\nslices 3-5: per-browser offline drafts via IndexedDB, and a top-bar\nnotification bell that reacts to comment events.\n\n### Offline drafts\n\nOpt-in via the new `offline` flag on `createCollaborationProvider` (or\nthe `collaboration.offline` shape on `<TipTapEditor />`):\n\n```ts\nimport { createCollaborationProvider } from '@actuate-media/cms-admin/lib/collaboration-provider'\n\nconst collab = createCollaborationProvider({\n  documentId: doc.id,\n  url: 'wss://your.app/api/cms/realtime/sync',\n  user: { id: currentUser.id, name: currentUser.name, color: '#22c55e' },\n  offline: true,\n  onOfflineStatus: (status) => {\n    // 'unsupported' | 'loading' | 'ready' | 'error' | 'pending'\n  },\n})\n```\n\nThe provider lazy-loads `y-indexeddb` (so the dependency stays out of\nthe bundle when offline drafts are disabled), seeds the local doc from\nthe IndexedDB snapshot before the socket opens, and writes every Yjs\nupdate through to disk. On reconnect the y-websocket transport flushes\nthe pending operations and the in-memory doc stays authoritative.\n\n`<OfflineStatus connection={status} offline={offlineStatus} />` renders\nthe merged state as a single pill — \"Saved & synced\", \"Saved\nlocally — reconnecting…\", or \"Local drafts failed\". The editor mounts\nit next to the presence chips automatically when `collaboration` is\nset.\n\n### Notification bell\n\nServer side, a new `DocumentNotification` Prisma model captures\nper-user events (`comment_reply`, `comment_resolved`, `comment_mention`).\nThe comments lifecycle hooks fan out into notifications:\n\n- Replying to a comment notifies the **root author** unless the\n  replier authored the root.\n- Resolving a comment notifies the **root author** unless the resolver\n  authored the root.\n- `@mentions` (currently parsed from comment bodies as `@user-id`) emit\n  a `comment_mention` row.\n\nThe REST surface (under `/api/cms`):\n\n| Method | Path                          | Purpose                    | Auth     |\n| ------ | ----------------------------- | -------------------------- | -------- |\n| GET    | `/notifications`              | List the caller's rows     | any auth |\n| GET    | `/notifications/unread-count` | Cheap badge counter        | any auth |\n| POST   | `/notifications/:id/read`     | Mark one notification read | owner    |\n| POST   | `/notifications/read-all`     | Mark every unread row read | any auth |\n\nClient side, drop `<NotificationBell />` into the admin top bar:\n\n```tsx\nimport { NotificationBell } from '@actuate-media/cms-admin'\n;<NotificationBell\n  onSelect={(notification) => navigateToDocument(notification.documentId)}\n  onError={(msg) => toast.error(msg)}\n/>\n```\n\nThe bell polls `/notifications/unread-count` every 30s by default\n(`pollIntervalMs={0}` disables polling) and re-fetches the full list\nwhen the dropdown opens. Clicks are optimistically marked read with\nautomatic rollback on server failures.\n","readmeFilename":"README.md"}