Skip to content

feat(editor): add real-time collaborative editing - #305

Draft
DavidBabinec wants to merge 51 commits into
mainfrom
feat/collab-crdt-coediting
Draft

feat(editor): add real-time collaborative editing#305
DavidBabinec wants to merge 51 commits into
mainfrom
feat/collab-crdt-coediting

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

Summary

  • replace the Site editor save pipeline with Yjs CRDT documents, per-user undo, continuous relay persistence, reconnect/reset safety, and live peer presence
  • persist authoritative CRDT state plus derived Site JSON on both SQLite and PostgreSQL, with capability-guarded writes and automatic retry after transient database failures
  • flush pending relay persistence before publish and headless MCP reads so server-side consumers observe the latest accepted edits
  • remove manual save UI and update editor, server, architecture, and feature documentation for continuous synchronization

Why

Concurrent admins and AI/browser actors should merge edits instead of racing whole-document saves. The relay becomes the single authoritative write path for an open Site workspace while existing out-of-relay repository writes explicitly reset affected CRDT documents.

User and developer impact

  • multiple admins can co-edit pages, components, layouts, settings, and styles with cursors, selections, avatars, and character-level text carets
  • undo remains local to each author
  • offline, connecting, backlogged, refused, stale-lineage, and reset states fail visibly instead of presenting edits that cannot be delivered
  • normal shutdown, last-client release, reads, and publish flush pending persistence; a hard process crash retains a bounded recovery point of at most the default 800 ms debounce

Verification

  • bun run lint
  • bun run build
  • bun test: 6,419 passed, 0 failed
  • real-WebSocket two-client convergence and reconnect/reset integration suite
  • disposable PostgreSQL 16 smoke: fresh migrations, CRDT blob plus derived JSON persistence, two-client convergence
  • React Doctor diff reviewed; its sole warning is the documented repository-approved memo bailout on the hot recursive NodeRenderer

Scope boundary

This PR keeps only the MCP consistency seam required by collaboration: pending relay writes flush before headless reads and publish. It intentionally does not upgrade the MCP SDK/protocol or add headless Site mutation tools; those follow after this collaboration foundation lands.

DavidBabinec and others added 30 commits July 12, 2026 10:26
…ion banner

Two admins editing the same site could silently overwrite each other:
saves were last-writer-wins per row with no detection. This is level A of
the multi-admin live-sync plan — conflict SAFETY on top of the
transactional save.

Server:
- The incremental save body gains `baseSeqs` (rowId → last-synchronized
  seq, covering changed AND deleted rows) and `shellBaseSeq`. Inside the
  save transaction — after allocateSiteSeq, whose counter-row lock
  serializes concurrent saves, making the check exact — any shipped row
  stored NEWER than its base (or with no base entry at all) throws
  SaveConflictError, rolling everything back into a 409 with
  `{ conflicts: [{ table, rowId, seq }] }`. Nothing is written.
  Soft-deleted rows are visible to the check (a remote delete conflicts
  with a stale edit). Replace-mode saves skip it (imports bulldoze by
  design).
- The shell write + seq stamp now happen ONLY when shell content actually
  changed (shellsEqual) — an unconditional stamp would 409 every
  concurrent save pair on the always-shipped shell.
- DataRow exposes `seq` (optional in the schema for old bundle archives);
  GET /site returns the shell seq; the three collection GETs accept
  `?id=<rowId>` for the banner's single-row "Load theirs" fetch.

Client:
- The editor store tracks baseSeqs/shellBaseSeq (seeded at load, bumped to
  the response seq on every successful save — deleted rows KEEP entries so
  undo-resurrection carries the right base) and the pending saveConflicts
  list. Autosave is suppressed while conflicts pend.
- SaveConflictBanner (lazy-mounted in AdminCanvasLayout) offers per-target
  resolution: Load theirs (swap the remote version in without undo
  history, clear the target's dirty marks, clear undo history, propagate
  VC slot re-sync / ref cascades into consumer pages WITH dirty marks) or
  Keep mine (bump the base seq — the overwrite becomes a stated decision).
  Resolving the last conflict flushes the surviving edits.
- The Spotlight Save command now routes through the editor save queue
  (flushEditorSave) instead of a raw replace-mode adapter save that
  bypassed the single-flight queue and dirty tracking.
- Settings modal + onboarding shell saves carry their own shell base seq.

Known v1 blind spot (documented): writers outside the transactional save
(plugin pack installs, data-workspace edits) don't stamp seqs and
coordinate via requestCmsSiteReload() instead — widening stamping is
phase B scope.

Co-Authored-By: Claude Fable 5 <[email protected]>
…ditors

Level B of the multi-admin live-sync plan, on top of the conflict-safety
substrate: two admins in the editor now SEE each other's saves land,
instead of silently diverging until a 409.

Server:
- server/events/siteEvents.ts — in-process bus over Bun's native pub/sub
  (correct for the single-process-by-definition deployment; multi-process
  is documented out of scope). The transactional save publishes post-commit
  hints: rows-changed / rows-deleted / shell-changed, ONE site-reloaded for
  replace-mode saves, and the publish flow emits `published`. Events carry
  ids + seqs, never payloads (@core/persistence/syncEvents).
- server/events/siteSocket.ts — GET /admin/api/cms/site-socket upgrades at
  the Bun.serve boundary (the router stays request/response). Gated by
  `site.read` AND originAllowed (browsers always send Origin on WS
  handshakes — closes cross-origin WebSocket hijacking). On
  `{ kind: 'subscribe', cursor }` the server replies with the missed delta
  synthesized from `rows where seq > cursor` (soft-deleted included), so
  live events are hints and the delta is truth — dropped frames can never
  cause drift.
- Vite dev proxy forwards the upgrade (ws: true).

Client:
- useSiteSocket (AdminCanvasLayout) lazy-imports the transport
  (siteSocketClient.ts, backoff reconnect, strict in-order processing) so
  the sync machinery stays out of the route-shell chunk.
- siteSyncMerge.ts — the merge policy: base seq ≥ event seq → skip (own
  echo); dirty target → pending conflict (the SAME banner as a 409, one
  resolution UX for levels A+B; dirtiness re-checked after the fetch);
  clean → fetch + applyRemoteSnapshot. Aligned deletions (both sides
  deleted the row) merge silently. Undo history resets ONLY when remote
  content genuinely replaced local state — a toast explains exactly then;
  a remote deletion of the open page navigates home with a toast.
- applyRemoteSnapshot (generalized from the banner's Load-theirs) now
  deep-equal-skips identical content, so own-save echoes never clear
  history; DirtyMarks gains a `shell` flag so remote shell changes apply
  silently when the local shell is untouched.
- Fixed a latent phase A hole: the editor bumps site.updatedAt on EVERY
  mutation, which would have made every save look like a shell change.
  shellsEqual (now shared in @core/persistence/shellsEqual) and the dirty
  tracker deliberately ignore updatedAt — the shell seq stays an honest
  conflict signal.

Tests: WS integration (real Bun.serve round-trip: gating, delta, live
fan-out), save-emission shapes, delta computation, and 12 merge-policy
scenarios via an injected snapshot fetcher.

Co-Authored-By: Claude Fable 5 <[email protected]>
Patches identify targets; values write from the post-mutation site
(idempotent final-state writes). Inline-text props splice Y.Text, children
arrays splice via prefix/suffix diff, rosters derive from pre/post id-set
diffs — the granular-merge substrate for co-editing.

Co-Authored-By: Claude Fable 5 <[email protected]>
…l persistence, reset protocol

The relay hydrates/seeds one Y.Doc per collab document, fans updates out,
and persists both the CRDT blob and derived JSON (publisher untouched).
Out-of-relay row/shell writes reset affected docs via the new
rowWriteEvents seam; the transactional save notifies post-commit.

Co-Authored-By: Claude Fable 5 <[email protected]>
…t live pull

One WebSocket multiplexes every collab doc (binary frames: docId + type +
y-protocols payload) with a site-wide awareness channel and reset
broadcasts. Upgrade gating unchanged (session + site.read + originAllowed);
write capability resolved once at upgrade — update frames from read-only
connections are dropped. Deletes the phase-B hint-event stack (events bus,
delta computation, syncEvents schemas, client transport + merge policy) —
CRDT sync subsumes it.

Co-Authored-By: Claude Fable 5 <[email protected]>
…live persistence via relay

Store rewiring (Tasks 9-12 of the collab plan):
- collabBinding.ts glues the editor store to the Y docs: local mutation
  patches translate into Y ops (LOCAL_ORIGIN), remote/undo changes project
  back; per-doc Y.UndoManager with coalesceKey-compatible capture breaks;
  multi-doc undo groups (one Cmd+Z reverts convert-to-component & friends
  across page + site + component docs); detached-mode alignment guard
  rebuilds docs when tests hand-assemble the store via setState.
- runHistoricMutation drops patch-pair history + dirty tracking; undo/redo
  delegate to the binding; lifecycle actions reset the doc world.
- usePersistence slims to HTTP-load-for-first-paint + lazy provider connect
  + status mapping; autosave, Cmd+S, beforeunload flush, dirty snapshots,
  baseSeq bookkeeping and the save queue are gone (the relay persists).
- Deleted: saveTrackingSlice, dirtyTracking, conflictActions,
  SaveConflictBanner, remoteSnapshot, editorSaveRef, editor.save command +
  keybinding, EDITOR_SAVE_REQUEST_EVENT, cms loadSiteShell/loadSiteRow and
  the ?id= single-row filter.
- Publish flushes the collab relay server-side (flushCollabDocs plumb) so
  the baked snapshot includes edits inside the debounce window.
- applyChildrenDiff falls back to a wholesale rewrite when the Y array
  drifted from the pre-state instead of throwing mid-transaction.

Co-Authored-By: Claude Fable 5 <[email protected]>
awarenessState.ts publishes one typed presence per editor (identity with a
deterministic HSL color, active doc, selection, inline-edit flag, throttled
pointer) and validates every peer state with TypeBox at the wire boundary.
PeerPresenceOverlay renders peers' selection rings, name tags (✎ while
inline-editing) and pointer dots per breakpoint frame, reusing the selection
overlay's measure-session + RAF architecture and the --peer-color inline
custom-property pattern.

Co-Authored-By: Claude Fable 5 <[email protected]>
Real Bun.serve running the relay + socket layer, exercised by the actual
client provider over real WebSockets: concurrent different-node edits
converge on both peers; the relay persists the blob and derived row JSON
after its debounce; read-only connections' update frames are dropped;
out-of-relay row writes trigger the reset protocol and the rebind carries
the external change; a disconnected client catches up via state vectors on
its provider's own reconnect.

Co-Authored-By: Claude Fable 5 <[email protected]>
- site-shell.md: the save endpoint is reframed as the standalone HTTP
  writers' path; the level-B live-pull section is replaced by the CRDT
  co-editing architecture (doc model, write path, relay, wire, presence).
- server.md: boot sequence shows the relay + socket layer; repositories
  table gains collabDocuments + rowWriteEvents.
- architecture.md: events row → real-time co-editing row.
- editor-history.md: rewritten for per-doc Y.UndoManager history.
- capabilities.md: known v1 coarse-write-gate regression note for the
  relay path.
- CLAUDE.md: co-editing stack bullet.
- Retired the auto-save preferences (catalog entries, helpers, settings
  toggles) — the relay persists continuously, nothing consumed them.

Co-Authored-By: Claude Fable 5 <[email protected]>
A page/component/layout created by a PEER arrives as a roster entry whose
row doc this client never bound — the site projection used to skip it, so
the new row never appeared until a reload (caught in the live two-tab
smoke). The roster assembly now binds missing row docs through the
provider; once their first sync lands, the site re-projects and the row
appears.

Co-Authored-By: Claude Fable 5 <[email protected]>
Two presence gaps caught in live two-account testing:

1. The local-pointer publisher attached its mousemove listener to the
   canvas iframe's document ONCE at mount — but frames boot via srcDoc,
   which replaces the initial about:blank document on load, so the
   listener sat on a dead document and cursor positions never published.
   The publisher now attaches to the current document and re-attaches on
   every iframe load. Verified end-to-end: a real browser's mousemove now
   reaches a second WebSocket peer.

2. Presence was only visible when a peer SELECTED something — no ambient
   indication of who's connected. Added PeerAvatarStack to the editor
   toolbar (one identity-colored avatar per admin, deduped across their
   tabs, dimmed with a tooltip hint when they're viewing another page)
   via a new useSitePeers hook, and put the peer's name on the cursor dot
   (Figma-style) so a moving cursor identifies its owner without a
   selection.

Co-Authored-By: Claude Fable 5 <[email protected]>
…eer stack

Root cause of "I don't see the cursor": the overlay positioning helpers
hide elements with INLINE display:none and show them by CLEARING the
inline value — but PeerPresenceOverlay.module.css declared display:none
as the stylesheet DEFAULT for .ring/.nameTag/.pointer, so every cleared
element fell back to hidden forever. All peer canvas chrome (selection
rings, name tags, cursors) rendered in the DOM but never painted. Removed
the defaults (presence chrome starts visible; the first RAF tick places
or hides it — the same contract the selection overlay uses) and added a
source-scan regression test. Verified visually end-to-end: a second
WebSocket peer's animated cursor + name chip now paints on the canvas.

Avatar consistency: presence identity now carries avatarUrl +
gravatarHash, and PeerAvatarStack renders the shared UserAvatar
(upload → Gravatar → initials, same precedence as every other admin
surface) inside an identity-color ring that matches the peer's canvas
ring and cursor label.

Co-Authored-By: Claude Fable 5 <[email protected]>
The rendered cursor no longer snaps to each received sample — the RAF tick
keeps a per-peer animated position that eases toward the last target with
a frame-rate-independent exponential (tau 90ms; snap on oversized jumps
and re-entries, per-frame state dropped when a cursor leaves the frame).
Motion is glassy regardless of network cadence, which pays for itself on
the wire: the publisher drops from 15 Hz to 10 Hz, skips sub-2px tremor
(deadband), and adds a trailing flush so the final resting position always
ships despite the throttle.

Verified live: ~130 unique rendered positions per 1.5s from ~5 published
targets — a continuous glide between sparse samples.

Co-Authored-By: Claude Fable 5 <[email protected]>
- The peer cursor is now the editor's own pixel-art cursor glyph
  (CursorMinimalSolidIcon) tinted with the identity color, hotspot-offset
  so the arrow tip lands on the published coordinate.
- Name tag and cursor label share ONE chip recipe: inline-flex centering,
  line-height 1, symmetric padding, flex-gap ✎ — consistent spacing on
  both badges.
- Peer selection rings lose their border radius — they trace the selected
  element's box exactly, like the local selection chrome.
- The invisible-chrome regression gate now strips CSS comments before
  scanning (it caught its own documentation).

Co-Authored-By: Claude Fable 5 <[email protected]>
The name tag on top of a peer's selection ring now uses the design
system's notch shape (same radial-gradient concave-corner recipe as
CanvasNotch): bigger rounding on the two top corners, a square
bottom-left corner flush with the ring's corner, and an inverse
bottom-right corner flaring into the ring's top edge. The ✎ editing
marker moves from CSS content into markup (the flare owns ::after now),
and the free-floating cursor label picks up the matching bigger radius.

Co-Authored-By: Claude Fable 5 <[email protected]>
…tion tag

The floating name next to the cursor is gone — the cursor now carries a
small identity-ringed avatar (18px, the same look as the toolbar stack),
riding just under the arrow tip. Hovering it shows the admin's name
through the shared Tooltip primitive (the avatar is the one piece of
presence chrome with pointer-events re-enabled). The selection name tag
gains a tiny leading avatar (14px, ringless — the tag is already the
identity color).

Extracted PeerAvatar (@site/collab) as THE presence avatar: shared
UserAvatar (upload → Gravatar → initials) inside the identity-color ring,
with rest-prop spreading so Tooltip composes onto it. PeerAvatarStack now
renders it too and swaps its native title for the same Tooltip.

Co-Authored-By: Claude Fable 5 <[email protected]>
- Cursors are now opacity-driven, not mount-driven: the pointer element
  is always rendered per frame and the RAF tick flips a data-visible
  attribute, so a cursor entering a frame fades in and one leaving fades
  out (220ms) instead of popping. A 250ms exit linger holds the frozen
  position first — skimming a frame edge no longer flickers, and a quick
  return cancels the fade and glides from where it stopped. The hover
  hit-target is disabled while invisible so a faded-out avatar can't
  ghost-trigger tooltips. The RAF loop now stays alive while any peer is
  on the doc so fades always complete (still stops when alone).
- The cursor avatar hugs the arrow tip (space-3xs/-2xs offsets).
- The selection tag padding drops one step (space-3xs / space-2xs).
- De-raced the read-only integration test: its happened-after marker now
  writes a different Y.Map key — asserting on the same key made the test
  depend on Yjs' concurrent-set clientID tiebreak, which is random per
  run (it started failing legitimately, unrelated to this change).

Co-Authored-By: Claude Fable 5 <[email protected]>
…y presence

Relay writes now obey the SAME structure/content/style rules as the HTTP
save. Full site-writers skip validation (the common fast path); every
update frame from a PARTIAL writer runs through the new update guard:
fork the authoritative doc, apply the update to the fork, project both
sides back to JSON, and reuse validateSiteWriteDiff/validatePageWriteDiff
— component/layout changes and roster changes are structural wholesale,
and both non-step1 sync message types are guarded so a hand-crafted step2
can't smuggle an update past the check. A rejected update never touches
the authoritative doc; the sender receives a TARGETED reset frame that
makes its client reseed from the server, reverting the forbidden change
everywhere including its own screen.

Read-only connections' awareness frames now relay — presence is not a doc
write, so viewers appear in the avatar stack and presence chrome.

Closes the documented v1 coarse-write-gate regression (capabilities.md,
site-shell.md updated). Integration-tested end-to-end: content-only
editor's text edit applies, their structural insert is rejected +
reset, and a viewer's presence reaches other peers.

Co-Authored-By: Claude Fable 5 <[email protected]>
During an inline text-edit session, presence now carries the caret and
selection as Y.Text RELATIVE positions (collab/caretPositions.ts) —
pinned to the CRDT items around the caret, so peers resolve the exact
spot even while concurrent edits shift the surrounding text (a plain
character index would drift; unit-tested against a concurrent insert).

Capture: the presence overlay listens to selectionchange on its frame's
iframe document while that frame owns the inline session, converts the
DOM selection to text offsets, encodes them against the live collab doc,
and publishes via a dedicated awareness field.

Render: the RAF tick resolves each peer's relative positions to current
offsets, maps them to DOM ranges inside the rendered node, and paints a
blinking identity-colored caret line plus 25%-opacity selection
highlight rects — riding the same measure-session/iframe-coordinate
machinery as the rest of the presence chrome.

Verified live end-to-end: a peer's published range renders as a
highlighted span + caret at the right characters, and a real DOM
selection during a session publishes an encoded caret.

Co-Authored-By: Claude Fable 5 <[email protected]>
…lisher hooks

Security + maintenance pass over the co-editing foundation:

- Move validateSiteWriteDiff/validatePageWriteDiff out of
  server/handlers/cms/ into a new server/writePolicy/ module — they are
  pure per-capability policy shared by BOTH write transports (HTTP save
  handler and the collab relay's update guard), not handler code.
- Harden server/collab/socket.ts: per-frame payload caps (64 KB awareness,
  4 MB sync, transport maxPayloadLength) and awareness identity
  enforcement — frames claiming another user's identity are decoded,
  checked against the session, and dropped, so presence can't be spoofed.
- Extract the per-frame pointer + caret presence publishers from
  PeerPresenceOverlay into @site/collab/framePresencePublishers with a
  shared attach-across-iframe-loads helper; the overlay is render-only
  (537 → ~400 lines).
- Relay integration test: viewer presence resolves the real user id from
  the db and asserts spoofed states never relay.
- Docs track the moves (site-shell.md, server.md, deepEqual header).

Co-Authored-By: Claude Fable 5 <[email protected]>
Critical:
- collabBinding: the DocSet.ensure path adopted a provider doc with a
  synced:false gate but never wired whenSynced, so a locally-created doc
  froze the gate forever and applyLocalSitePatches then rejected every
  subsequent local edit. Route through bindDocThroughProvider.
- integrity: orphan healing appended every unclaimed node flat under root,
  double-parenting the descendants of a multi-node orphaned subtree (they
  landed in both root.children and their orphan parent). Re-attach only
  orphan SUBTREE ROOTS and heal-walk into them; break orphan-only cycles
  deterministically by lowest id.
- relay: persistDerivedJson INSERTed via createDataRow when getDataRow
  returned null, but getDataRow filters soft-deleted rows — an undo-of-delete
  hit a permanent primary-key conflict. New upsertDataRowDraft repo helper
  resurrects the soft-deleted row in place instead.
- socket: the async message handler had no try/catch, so a malformed frame
  or a projection crash in the guard escaped as an unhandled rejection.
  Wrap it; a failed sync-write frame now sends a targeted FRAME_RESET.

High:
- socket: full-identity presence check (id + name + avatar + gravatar), not
  id alone — a peer could keep its own id but paint another admin's
  name/avatar. Null (clear) states are gated to the connection's own
  clientIDs so a peer can't erase others' presence. Boundary now validated
  with TypeBox, not an `as` cast.
- server: SIGTERM/SIGINT flush the relay before exit (relay.destroy) so a
  redeploy mid-debounce no longer drops un-persisted edits.
- collabProvider: unbind settles whenSynced so continuations chained on a
  never-synced doc (FRAME_RESET) don't hang forever.
- socket: claim boundDocs synchronously before awaiting retain so two
  concurrent frames for one unbound doc can't double-increment refs.
- usePersistence: only connect the provider when the store holds a site (no
  zero-doc reconnect loop on hard load failure); a live connection now
  supersedes a stale load error in saveStatus instead of showing a
  permanent "Sync failed" over working sync.

Tests: orphan-subtree + cycle reconcile, whenSynced-settles-on-unbind,
upsertDataRowDraft resurrect, full-identity presence spoof rejection.

Co-Authored-By: Claude Fable 5 <[email protected]>
The relay persists on an ~800 ms debounce, so any path that reads rows to
publish can bake stale content. Only the full-publish HTTP route flushed
(via runtime-threaded flushCollabDocs); per-row publish, the scheduled tick,
and plugin-host publish did not.

Replace the bolted-on runtime plumbing with one publish-flush seam
(server/publish/publishFlush.ts): the relay registers its flushAll at boot,
and publishDraftSite + publishDataRow both await runPublishFlush() before
reading. Headless MCP reads flush too (cheap — a clean doc's flush is a
no-op). Removes flushCollabDocs from the runtime/router/handler options.

Client: PublishButton is disabled until the client is synced — offline edits
live only in the client's Y docs, and the server flush can't bake what it
never received. The existing status chip states the reason inline.

Co-Authored-By: Claude Fable 5 <[email protected]>
… sweeps

Two safe scaling wins from the review:
- usePeerPresences bailed on no awareness change: a peer moving their pointer
  on ANY doc re-rendered every overlay. Add the useSitePeers-style structural
  key so a change on another doc is a no-op here; same-doc pointer moves still
  re-render (the canvas RAF reads pointer positions from the array).
- The relay's roster-deletion sweep ran three full-table scans on every
  site-doc persist, including shell-field-only edits. Skip it when the roster
  key is unchanged since the last sweep; cleared on site-doc reset so an
  out-of-relay change still reconciles.

The three architectural scaling boundaries (guard fork+project per keystroke,
connect-binds-all-docs, whole-row reproject on remote edits) are documented in
the design doc as tracked follow-ups — each needs before/after measurement
rather than a blind rewrite of a live-tested hot path.

Co-Authored-By: Claude Fable 5 <[email protected]>
Cleanliness pass from the review:
- pageDiff.ts dropped its private byte-identical deepEqual for the shared
  @core/utils/deepEqual its sibling siteDiff.ts already imports.
- usePersistence dropped its private errorMessage for the canonical
  getErrorMessage.
- collabBinding + applyPatches build doc-ids through encodeCollabDocId /
  SITE_DOC_ID instead of `page:${id}` / 'site:default' string literals.
- SHELL_PER_ENTRY_KEYS (the shell doc's Y-shape) lives once in schema.ts,
  imported by seed + applyPatches instead of two copies that must stay in
  lockstep.
- applyPatches: removed a literal no-op statement and an if/else with two
  byte-identical branches (the preNode lookup + condition were dead).
- Removed dead barrel surface (buildNodeMap/buildPropsMap/projectNodeMap/
  inlineTextPropOf were only used intra-module) and the fully-dead
  RECONCILE_ORIGIN constant (integrity reconciles run as pure JSON, never a
  tagged Y transaction).
- Deleted the stale 'editor.save' spotlight shortcut + fixed the editor
  command header (no Save command — the relay persists continuously).
- PeerPresenceOverlay hides caret + selection highlights in the no-iframe
  tick branch so a peer's caret doesn't freeze on a frame detach.

Left deliberately: project.ts's Y→object casts (internal reconstruction, not
an untyped external boundary; architecture gates pass), and the vestigial
per-row baseSeqs save protocol (a separate persistence-layer refactor).

Co-Authored-By: Claude Fable 5 <[email protected]>
DavidBabinec and others added 21 commits July 12, 2026 10:35
bagToDeclarations did `Object.entries(bag)` on the rule's `styles` bag, which
threw on a non-object bag and took the entire ClassStyleInjector / canvas down
with a white screen. A corrupt or legacy rule (bad import, plugin write, older
data — e.g. a packageJson-shaped object wrongly stored under a style-rule key)
must degrade to emitting nothing, not crash the whole stylesheet.

Guard the shared serializer core (base styles, context styles, and inline
styles all funnel through it): a non-object bag yields no declarations, every
other rule still renders and publishes.

Co-Authored-By: Claude Fable 5 <[email protected]>
…tore

The site-doc projection spread `projected.shell as Partial<SiteDocument>`
into the store unvalidated — an `as` cast at a wire boundary. A malformed
styleRules entry in the live doc (a runtime/packageJson-shaped value stored
under a rule key) crashed the SelectorsPanel/ClassPicker on `.name` access
the moment an element was selected, and the corruption could never self-heal
because only the relay's persist path ran validateSite.

Run the projection through validateSite exactly like the HTTP load path and
the relay's persist path — one validation vocabulary at all three shell
boundaries. validateSite is per-entry tolerant (parseStyleRuleRegistry drops
malformed rules, keeps the rest), so one corrupt entry degrades to being
dropped instead of taking the editor down. An incoherent mid-sync shell
skips the projection tick; the next projection re-runs.

Test: seeds a shell, injects a packageJson-shaped object under a style-rule
key in the Y map, asserts the projection is faithful but validateSite drops
exactly the bad entry while the good rule survives.

Co-Authored-By: Claude Fable 5 <[email protected]>
The collab projection carried its own hand-rolled selection cleanup — a
weaker duplicate of `pruneCanvasSelectionDraft`. It only inspected the
`selectedNodeId` anchor, so a multi-selection kept phantom ids for deleted
nodes and lost its survivors wholesale when the anchor died; it never
closed an inline-edit session whose node had vanished (a peer deleting the
node you were typing in left a dangling session); and the site/roster
branch pruned nothing at all when a whole page disappeared.

The duplicate existed because `initCollabBinding` typed the store as a bare
`StoreApi<EditorStore>`, which erases the zustand-mutative augmentation of
`setState`. Draft-mutating helpers were therefore uncallable and a subset of
one got reimplemented inline as a partial patch. The same missing type had
already forced a cast in the plugin runtime.

Type the store api once (`EditorStoreApi`, via zustand's `Mutate` with the
`zustand/mutative` mutator) and the workarounds delete themselves:

- collabBinding: all three projection exits (site/roster, row-removed,
  row-updated) now call `pruneCanvasSelectionDraft`. One implementation
  serves local deletes, undo/redo, and remote peer edits alike.
- plugins/runtime: both casts and the comment excusing them are gone.

Selection is still not undoable — it is reconciled. Documented in
docs/reference/editor-history.md, which previously left that contract out.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
… racing

The shared ContentPage fetch mock answered every `GET /tables/posts/rows`
with an empty list — even after a `POST` had created `entry_1`. That is not
a shape a real server can produce, and it made the whole file's
create-then-assert tests depend on request ordering.

`useContentWorkspace.loadEntries` treats a list response as authoritative
unless the selected row changed *during* that request. That is correct: a GET
issued after a POST returns the new row. But when scheduling happened to issue
the initial list GET after the create, the mock's bogus empty list wiped the
just-created row, and `findByText('Untitled')` failed. Which request won that
race was luck — main sits on the winning side of it, so the flake never
surfaced there.

Model the endpoint instead: rows created by POST are visible to later GETs,
and a DELETE removes them. Both orderings now hold.

Verified: full suite 5/5 green (previously failed ~40% of runs).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
`awarenessUpdateFromSession` returned one boolean for two unrelated outcomes,
so the socket logged every refusal as `dropped awareness frame with spoofed
identity`. Most of those are not attacks: every y-protocols client runs
`checkOutdatedAwarenessStates` and broadcasts a removal for any peer whose
heartbeat it stopped hearing, so clients routinely try to clear clientIDs they
do not own. A live three-admin session produced four of these warnings in a
couple of minutes — a production log that cries wolf teaches operators to
ignore the one warning that matters.

Replace the boolean with a typed `AwarenessRefusal` ('impersonation' |
'foreignClear' | 'malformed'). All three are still refused — a peer must never
erase another's presence for everyone — but only the two that mean something
are logged.

Also gate the two collab behaviours the live smoke exercised and the suite did
not cover at all:

- a peer cannot erase another peer's presence for everyone (the foreignClear
  branch had zero coverage; deleting the guard was silent),
- `runPublishFlush` drains the persist debounce, so publish bakes the edit an
  admin made seconds earlier instead of losing it to the debounce window.

Both are mutation-checked: removing the guard fails the first, removing the
flush call fails the second.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Mechanical integration of origin/main (0.0.13) with no behavioral change.

Three conflicts, resolved as follows:

- server/db/migrations-{pg,sqlite}.ts — both sides appended an id `021`.
  main's `021_mcp_oauth` shipped in 0.0.13, so live installations have
  already recorded it; it stays first and byte-identical. This branch's
  entry is renumbered to `022_collab_documents` at the same index in both
  dialect files, keeping migration-parity.test.ts satisfied.
- docs/features/mcp-connectors.md — main rewrote the document wholesale
  (MCP Connectors → MCP connections, hosted OAuth flow). Took main's
  version and re-applied this branch's CRDT claim on top: relayed edits
  need no post-tool save step, and headless reads/publish flush the relay
  server-side rather than waiting on a client-side draft flush.

bun.lock regenerated (sharp ^0.35.0 from main + yjs/y-protocols/lib0 from
this branch); the textual auto-merge is not a valid lockfile for the union.

Verified: migration-parity + cmsMigrations green before committing.
…e ws proxy

The co-editing socket never connected under `bun run dev`: the toolbar sat on
"Connecting" forever and, since this branch removes every other save path,
each keystroke was silently discarded. Reconnect attempts then killed the dev
server outright.

Root cause is a runtime swap, not the collab code. Until #202 (fdfcb15),
scripts/dev.ts launched Vite through `node_modules/.bin/vite`, whose
`#!/usr/bin/env node` shebang ran it under Node, and the ws proxy worked —
which is why this was hand-verified green on 2026-07-03. #202 introduced
`viteCommand()`, which spawns `bun node_modules/vite/bin/vite.js` and bypasses
the shebang, so Vite now runs inside Bun. Bun's `node:http` ClientRequest
never emits 'upgrade', so the backend's 101 reaches Vite's proxy as an
ordinary response and takes the non-upgrade fallback: the browser socket hangs
in readyState 0 — never opening, never closing, so the provider's reconnect
path is never even reached — and when that connection later ends, the proxy's
`socket.destroySoon()` call (absent on Bun's socket) throws uncaught and takes
down Vite and the CMS server together. Vite itself was never bumped; 8.0.10
has been pinned since the initial import.

Fix: take Vite out of the WebSocket path entirely.

- `collabSocketUrl` dials the CMS port directly under `import.meta.env.DEV`,
  preserving `location.hostname` and swapping only the port. The session
  cookie is SameSite=Lax and a handshake is not a top-level navigation, so a
  third-party handshake would drop it and 401 into an endless reconnect —
  ports don't affect that judgement but hosts do, and dev serves Vite on
  127.0.0.1 while developers type localhost. Production stays same-origin.
- The port is inlined from the same `process.env.PORT` the proxy target uses,
  so the two cannot drift, and no operator-facing env var is added.
- scripts/dev.ts now passes PORT to the Vite child explicitly; it previously
  worked only because CMS_PORT's default matched vite.config.ts's.
- `ws: true` is removed from the proxy, which structurally disarms the crash.

Verified: 101 on the new dev target with a real session cookie and a
localhost:5173 Origin; an upgrade against the old proxy path now leaves both
dev processes alive; the define folds into the dev module as VITE_CMS_DEV_PORT.
socketUrl.test.ts pins both branches, devWorkflow.test.ts gates `ws` forwarding
against returning. bun test 6325 pass / 0 fail, bun run build, bun run lint.
… doc

The site editor would not load once the collab socket actually connected:
every projection threw and was skipped, so the store never updated and the
canvas stayed on its loading skeleton.

    [collabBinding] projected shell failed validation — projection skipped:
    TypeError: Cannot assign to read only property 'tokens' of object

Yjs stores plain JS values BY REFERENCE — `Y.Map.get()` returns the very
object living inside the CRDT — and the projection handed those references
straight out. The editor store and the doc therefore shared mutable state,
which broke in both directions:

  - `validateSite` normalizes in place (`normalizeFrameworkColors` assigns
    `colors.tokens`, and `parseSiteSettings` passes `framework` through by
    reference), so merely PROJECTING a shell rewrote the colour tokens held
    inside the doc — an untracked mutation outside any transaction that no
    peer ever sees.
  - The editor store runs Mutative with `enableAutoFreeze: true`. The first
    projection to land in the store deep-froze everything reachable from it,
    including the objects the doc still held, so the SECOND projection threw
    on assignment. Every projection after that failed identically, which is
    why the editor never rendered.

Fixed at the boundary rather than at the crash site: `own()` takes ownership
of every plain value leaving a Y container (`projectSiteDoc` shell fields and
per-entry maps, `projectNodeMap` props and breakpoint overrides, `projectMeta`,
the layout snapshot). Primitives — the overwhelming majority of node props —
pass through untouched, so only genuinely shared object graphs are cloned. Y
types are returned as-is; they only reach `own()` from a malformed doc, and
`structuredClone` would throw on their internals.

Verified: the reproduction test fails 3/3 without the fix and passes with it;
the editor loads a 212 KB page with a full layer tree and a clean console;
site rows and collab blobs are byte-intact. bun test 6328 pass / 0 fail,
bun run build, bun run lint.
…ession

The main merge reintroduced, one layer up, the hazard commit 2722c72 fixed:
`ruleHasAuthoredDeclarations` read `Object.keys(rule.styles)` and
`Object.values(rule.contextStyles)` unguarded. Those bags are the wide
persistence type — a corrupt or legacy rule (bad import, plugin write, older
data) can carry a non-object bag, and `Object.keys(null)` throws.

This runs BEFORE the shared serializer that already guards exactly this
(`bagToDeclarations` in @core/publisher/classCss), so one malformed rule threw
here and took the whole canvas stylesheet down with it — the white screen that
fix was written to prevent. It merged cleanly because the two guards live in
different files, which is why the test now pins the canvas layer too.

A malformed bag is treated as unauthored: that rule emits no suppression and
every other rule still renders.
…ence

The Playwright suite was dead against `src/`: `saveDraft` clicked
`toolbar-save-draft-action`, a testid this branch removed along with the
client save pipeline. 16 spec files imported it, so every test that touched
the site editor failed on a 10s locator timeout.

There is nothing to replace it with, because there is nothing to replace:
edits stream to the relay as they land, publish flushes the relay before it
reads rows, and a reload re-syncs from the relay's authoritative doc. The
helper and all 40 call sites are simply gone. The guarantees the callers
actually depended on still hold, and the tests now prove the CRDT path rather
than the deleted one — the three capability personas, for instance, still
edit, reload, and assert their change survived, which now exercises the
relay's per-category update guard end to end.

Also fixed here, because retiring saveDraft is what exposed it: scheduling a
publish 404'd with "Data row not found" for any page created in the editor.
`handleRowSchedulePost` read the row straight from the DB, but a freshly
created page lives in the relay's in-memory doc until the persist debounce
elapses. `publishDataRow` already flushes for exactly this reason; scheduling
is "publish later" and now does the same. The old saveDraft call had been
hiding the race by forcing a write first.

PAGE-004 and SAVE-001 retitled — "unsaved draft" is not a state that exists in
the site editor any more, and the status chip reads "Draft synced". The
Content workspace keeps its own save model, so its `Unsaved draft` assertions
are untouched. docs/e2e/feature-validation.tsv updated for both, plus the
SITE-017/SITE-018 rows that still pointed at deleted modules
(saveTrackingSlice.ts, site/dirtyTracking.ts) and claimed Save Draft persists
template state.

Verified: page-management 11/11; visual-builder + capabilities +
core-owner-lifecycle + preview-live 52 passed with one unrelated pre-existing
failure (see below); the new server test fails without the schedule flush and
passes with it. bun test 6331 pass / 0 fail, bun run build, bun run lint.

NOT fixed here, deliberately: CAP-005 "AI provider manager can access
provider/default tabs" fails on origin/main today. Commit 76f15fa
"feat(ai): redesign settings and add MCP OAuth (#245)" replaced the AI
workspace tablist with a sidebar nav and never updated its specs, and
tests/e2e/ai.e2e.ts has the same drift. Different reason to change; it needs
its own PR against main.
… race-free

The reset seam is how every out-of-relay write — a Settings save, Super
Import, a plugin pack install, a data-workspace row edit — reaches connected
editors. Three races let a reset silently do nothing, or take live data with
it.

1. `evict({persist:false})` did not await `persistChain`. A persist already
   past its `dirty` check would resolve AFTER `deleteCollabDocuments` and
   re-insert the blob by upsert, resurrecting the generation the reset had
   just deleted — so clients rebound onto stale state and the out-of-relay
   write was invisible. `evict` now awaits the chain whether or not it is
   persisting, re-checks the timer after the await, and `openDoc` waits on a
   per-doc `settling` promise so it can neither hand back a doc that is being
   destroyed nor hydrate from a blob the reset is still deleting.

2. `resetDocs` reseeds the site doc's rosters from `listDataRowIdSlugs`, so a
   page that existed only inside another doc's persist debounce was absent
   from the DB, vanished from the reseeded roster, and would then be
   soft-deleted by the next sweep. Dirty docs that are NOT being reset are now
   flushed first. The reset docs themselves are still deliberately not
   flushed: the out-of-relay write that triggered the reset already committed
   and must win.

3. Eviction dropped the ref count, so after a reset `openDoc` restarted at 0
   while N connections still listed the doc in `boundDocs` — the first close
   drove refs negative and evicted a doc other editors were writing. The
   counts are now re-registered, and `retain` no longer asserts on a registry
   entry a concurrent reset may have removed (it re-opens until the doc it
   returns is the one whose refs it incremented).

Also: `persistDerivedJson` now reports its outcome, and a shell that fails
`validateSite` marks the doc dirty again. The blob was being written while the
derived JSON stayed stale, and a reset reseeds from that JSON — which is how
an accepted page could silently disappear. `incomplete` (a doc not assembled
yet) still does not retry, so there is no spin.

Both new tests fail without this change and pass with it. The first holds a
blob write mid-flight through an injected DbClient gate — the only way to
reproduce the window deterministically rather than hoping a timer lands.
A reset deletes the doc's blob and the relay reseeds it at the FIXED
`SEED_CLIENT_ID`, so generation N+1 occupies the identical
`(client 1, clock 0..K)` coordinates as generation N. FRAME_RESET is a topic
broadcast, so a client that was offline never hears about it — and on plain
reconnect its `syncStep1` makes the server answer with
`encodeStateAsUpdate(doc, staleSV)`, which omits exactly the structs the stale
client "already has" at those colliding coordinates and ships the full delete
set. Both ends then diverge silently, and the divergence persists into
published content. This is reachable today with no new code; it is why this
lands before any resync work.

The frame envelope gains the doc's lineage id:

  frame := varString docId | varString generation | varUint frameType | payload

The server refuses a cross-lineage frame BEFORE `readSyncMessage`, before the
write-policy guard, and before any `applyUpdate` — answering a stale step1 is
itself enough to corrupt. `''` means "I hold no server state for this doc":
always fine for a step1, and fine for a write only while the server's doc is
still empty, which is precisely the client-created-row flow that populates a
doc at bind time before any inbound frame. Presence and reset frames carry
`''`.

Generations are minted with `nanoid()` on seed and persisted immediately
rather than through the debounce — a doc that hydrated cleanly is not dirty,
so a mint riding the debounce would never reach the DB and the next open would
mint a different id, resetting every bound client for a byte-identical
lineage. Migration 023 adds the column additively; rows written by 022 carry
`''` and get one minted on their next open.

FRAME_RESET now carries a reason, because the client cannot otherwise tell a
routine reseed from losing its work. `rewritten` (another admin saved a
setting, a plugin installed, the data workspace edited a row) stays silent;
`stale`, `refused` and `oversize` raise a toast naming what happened. A reset
also prunes that doc from the undo routing — its UndoManager is rebuilt empty,
so a stale routing entry made Cmd+Z a silent no-op that consumed a step.

Oversize sync frames stop being dropped with a bare `return`. Silence there
diverges the client permanently: it holds structs the server will never
receive, every later update queues behind them as pending, and the screen
shows edits that can never publish. It now answers `FRAME_RESET('oversize')`.

Also removes `CollabRelay.applyUpdate`, which had no production caller.

Verified: the forged-dead-lineage frame is rejected (test fails when the check
is disabled); codec round-trips generation and reason, degrading an unknown
reason to the routine one rather than throwing on wire data. bun test 6338
pass / 0 fail, bun run build, bun run lint.
…ble"

The editor is about to refuse edits it cannot deliver, which makes the
definition of "connected" load-bearing. `readyState` cannot carry that weight:
on a black-holed path — VPN drop, captive portal, sleep/wake, mobile handover
— the browser keeps the socket OPEN until the TCP retransmission timeout,
which is minutes. Throughout that window every `send()` succeeds into a buffer
nobody drains, the toolbar reads "Draft synced", and the work is already lost.
Bun's protocol-level pings cannot help: the browser answers them without
telling JavaScript, so they prove nothing to the side that has to decide.

A FRAME_PING / FRAME_PONG round trip on a 10s interval bounds that window to
~2.5 intervals. Missing it publishes `offline` SYNCHRONOUSLY rather than
waiting for a close that may never arrive — the gate must not keep accepting
edits while the chip still claims everything is fine. Pings are answered
before the frame reaches `parseCollabDocId` or the relay, and are deliberately
ungated: a read-only viewer needs to know its socket is alive exactly as much
as a writer does.

`canSend()` becomes the single "can this edit reach the relay" predicate,
shared by `sendFrame` and (next commit) the write gate, because it is one fact
rather than two. It also refuses once `bufferedAmount` exceeds 512 KB, which
catches the slower failure the heartbeat misses: the socket is genuinely open
but the bytes are not moving.

`reconnectNow()` escapes the backoff, which reaches 30s. When the user tells
us the network is back, making them watch a timer is the wrong answer.

Two honesty fixes that came out of this:
- the provider's docstring claimed "no flush window to lose on unload". That
  is false — `send()` only enqueues, and the browser discards `bufferedAmount`
  on unload. The comment now says so, and `beforeunload` calls
  `preventDefault()` while bytes are queued. A large paste is one
  multi-hundred-KB frame and there is no client-side save path behind it.
- the ping interval is injectable, because a test that proves the timeout
  works should not spend 31 seconds doing it. The suite version runs in 255ms.

bun test 6342 pass / 0 fail (twice), bun run build, bun run lint.

Unrelated: `contentAdmin.test.tsx` "does not reopen the settings preference
when the last selected entry is cleared" failed once during this work and
passed in isolation, in both pairwise combinations, and in two consecutive
full runs. It is an intermittent timing flake in the Content workspace, not a
regression from this change.
…nt edits

The client's step1 only ever pulled the SERVER's delta. Nothing pulled the
client's, and the server never originated a step1 of its own — so any edit
committed locally while the socket was down never reached the relay, even
after a clean reconnect. Worse, later edits depend on those structs, so Yjs
held them as pending server-side too: the session diverged permanently while
the toolbar showed everything synced.

The fix is one frame. y-protocols already answers a step1 with exactly the
missing delta, and the provider already ships whatever its encoder produced,
so originating a step1 from the server needs ZERO client changes. Sending our
own state vector on a connection's first sync frame per doc is the whole
mechanism.

Gated on `fullSiteWriter`, deliberately and unhappily. A partial writer's
reply is a single update carrying its entire missing state, and
`validateGuardedUpdate` accepts or rejects that as ONE unit — a single
forbidden op inside the batch would discard every legitimate edit alongside
it. Losing a content-only editor's recovery window is bad; silently destroying
the rest of their session while trying to save it is worse. Closing that
properly needs a splittable guarded update, which is a follow-up.

`probedDocs` is per-connection rather than per-doc, because a reconnect brings
a fresh socket and that is precisely when the probe has to run again.

This lands AFTER the generation stamp for a reason: asking a client for its
state is exactly what makes a missed reset dangerous, and the lineage check is
what makes the answer safe to merge.

The new test fails without the probe and passes with it — it kills the
transport, edits, asserts the relay has NOT seen the change, reconnects, and
then asserts the edit reaches both the authoritative doc and `data_rows`. It
is the mirror of the existing "a reconnecting client catches up on edits it
missed", which only ever covered server → client.

bun test 6343 pass / 0 fail, bun run build, bun run lint.
…en reverts

The stress test (five concurrent role-scoped editors) surfaced a read-only
viewer whose local doc permanently diverged while the toolbar still read
"Draft synced". Every other peer had the correct converged state; only the
viewer showed its own rejected edits, merged into a text node by the Y.Text
CRDT and never healed.

Cause: the socket dispatch special-cased read-only connections. A non-step1
frame from a connection lacking every site-write capability was dropped at a
bespoke `canWrite` gate (`... && !ws.data.canWrite) return`) with NO reset —
so the sender kept its optimistic local change forever. Partial writers never
had this problem: their forbidden writes go through the category guard, which
sends a targeted reset that reverts the change on their own screen too.

A read-only viewer is just a partial writer with zero write capabilities.
Remove the special case (and the now-dead `canWrite` field) and let read-only
connections ride the same guard path: the guard refuses any real change (→
reset → the viewer's doc reseeds from the server and reverts), while a
viewer's empty handshake step2 diffs to nothing and passes as a no-op. One
enforcement path, no divergent screens.

The existing "drops update frames from a read-only connection" test only
checked that other peers never saw the edit — it never pinned the viewer's own
divergence, which is why this shipped. Strengthened to assert the viewer
receives a reset and its rebound doc no longer holds the edit; mutation-checked
(restoring the silent drop fails it).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…user

There is no client-side save pipeline behind the relay: an edit that cannot be
delivered is an edit that is gone. Until now a local mutation applied to the
store optimistically even while the socket was down or a doc had not finished
its first sync, so the user kept typing into work the server would never accept
and was told nothing — silence was how their edits disappeared.

`applyLocalSitePatches` now returns a `LocalPatchOutcome` instead of a bare
boolean. When the transport can't send (`transportBlockReason`), the mutation
is refused wholesale — `mutateActiveTree` reports it as not-accepted, the
node-insert action returns '' rather than a phantom id, and the user gets one
toast per block EPISODE (offline / connecting / syncing), not one per
keystroke. The first notice of an episode also ends the inline-edit session so
the contentEditable snaps back to the last value the relay actually took.

Finishing this pushed collabBinding.ts past the 700-line god-file ceiling, so
the block/reset policy and its user-facing copy move to a sibling
collabNotices.ts (transportBlockReason, collabBlockToast, clearCollabBlockNotice,
collabResetToast, the copy maps, and the BlockedReason/LocalPatchOutcome
types). collabBinding keeps only the thin notifyCollabBlocked wrapper that pairs
the toast with the store side effect. Behavior-preserving: the collab provider
and editor-store suites stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…racters

The inline-edit contentEditable was seeded once per session and never
received remote Y.Text changes; every keystroke committed the element's
WHOLE string through the snapshot diff (applyInlineEditValue →
applyTextDiff). With a peer's characters in the doc but absent from the
frozen surface, the next local keystroke's diff read them as a local
deletion and removed them from the CRDT — every replica converged to text
missing the peer's edit. Reproduced end-to-end on a canvas mount: peer
typed " world" into "hello", one local "!" keystroke converged BOTH
replicas to "hello!".

Fix at source: attachInlineEditRemoteMerge observes the edited prop's
Y.Text for the session's lifetime and folds every non-local change into
the DOM through the same seeding writer, restoring the local caret at an
index transformed through the Yjs delta (insert-at-caret pushes right,
matching relative-position association). IME composition defers the
rewrite to compositionend, with the caret transformed through a
synthesized single-splice delta. Wired in NodeRenderer's session layout
effect; nodeTextOf deduped out of caretPositions into @core/collab.

Mutation check: with the NodeRenderer wiring reverted, both canvas
behavioral tests fail (surface frozen at "hello" while the store shows
"hello world") and the wiring source gate fails; restored, all green.
Verified: bun run build, bun test (6353 pass ×2), bun run lint.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Records the co-typing fix as done, pins what this run verified solid
(Y-level merge granularity, canvas co-typing end-to-end, the microtask
ordering that protects the store-level diff), and ranks the open
findings for the next run — applyTextDiff's missing drift guard first.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
dazzakiller pushed a commit to dazzakiller/Instatic that referenced this pull request Jul 29, 2026
…ict resolution

Per maintainer guidance (issue #N): check collab PR CoreBunch#305 for conflicts and
resolve before opening the visitor-auth PR. PR CoreBunch#305 is based on the same
commit (d97cac2) so no rebase skew; 17 files overlapped, only 6 conflicted,
all resolved additively (no behaviour change on either side):

- migrations: PR CoreBunch#305 adds 022_collab_documents + 023_collab_document_generation;
  our visitor migrations renumber 022-027 -> 024-029 so both coexist (021 mcp_oauth
  -> 022/023 collab -> 024-029 visitor). Both dialects.
- loops/sources/index.ts: keep both EntryFieldSource (PR CoreBunch#305) + visitor sources.
- DataPage.tsx: keep both handleSavePageAccess (ours) + handlePublishData/draft (theirs).
- RowDetail.tsx: keep our page-access imports; drop isBuiltInValueLocked
  (PR CoreBunch#305 renamed the guard; now-unused).
- shared.ts: union the doc comments.
- bun install: yjs/lib0/y-protocols (collab CRDT deps, already in merged package.json).

Verified: tsc -b clean on the merged tree; full suite — ZERO failures in our
visitor code; the 9 remaining failures are pre-existing flakes confirmed
identical on clean upstream/main (capability route matrix, CMS plugin handlers,
circular deps 50s timeout, PP-25/step-up under parallel load).

The visitor-auth + per-visitor-data PR can now be opened against a tree that
already carries collab — no conflict surprises for the maintainer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant