| 1 | # Slides slice 2 — the editor and collab — implementation plan |
| 2 | |
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | |
| 5 | **Goal:** A signed-in person edits a deck in the browser with others at once: slide strip, stage, inspector, canvas drag and resize, the desktop/phone switcher, per-person undo, presence, image upload; every change flows through the same live-sync transport Docs uses and lands in the same Yjs document the API and viewer already read. |
| 6 | |
| 7 | **Architecture:** The server keeps the Yjs document as an update log plus periodic snapshots, serves updates by cursor and advisory seqs over SSE, and renders slide HTML as before. The browser holds a Y.Doc (yjs), edits text through ProseMirror bound to the server's own slide HTML, and applies structural operations through a JS `ops` module that mirrors the Rust core's op vocabulary; fixtures hold the two implementations to one result. No framework, no build step at run time: one esbuild bundle committed hashed. |
| 8 | |
| 9 | **Tech Stack:** Go on rastrillo v0.26.0; Rust core unchanged unless a fixture proves a gap; Node 20+ with `yjs 13.6.27`, `y-prosemirror 1.3.7`, `prosemirror-*` at Docs' pins, `esbuild 0.28.2`; rastrillo `blobs` for uploads. |
| 10 | |
| 11 | **Spec:** `docs/superpowers/specs/2026-09-07-slides-design.md` §2 (model), §2.7 (assets), §2.8 (fixtures), §3.1 to §3.3 (editor, switcher, collab), §8 (scriptless floor: the editor is the one surface that needs the bundle). |
| 12 | |
| 13 | ## Global Constraints |
| 14 | |
| 15 | - Everything in slice 1's Global Constraints (`docs/superpowers/plans/2026-09-07-slice-1-live.md`) still binds: CGO_ENABLED=0, seams, 404s, csrf, catalog keys, bare `rst-*` grammar, `slides-*` attributes, WCAG 2.2 AA, actors on every write, nested packages under `internal/slides/`, commit trailers, `make ci` before every commit, `GOCACHE=/home/paulca/.cache/tmp/claude-1001/go-build` in the sandbox. |
| 16 | - **The browser never becomes a second renderer.** The stage shows the server's HTML for the current slide; the client binds ProseMirror to the text blocks in it and re-fetches the slide HTML after any structural change has been saved. Thumbnails come from the server too. |
| 17 | - **One op vocabulary.** `fixtures/ops.mjs` implements exactly the ops in `core/src/ops.rs` (spec §7.2) over a Y.Doc; `fixtures/verify-rust.mjs` proves the Rust view of a JS-produced document equals the JS view for every fixture. A new op is added to both or to neither. |
| 18 | - **Live-sync wire (Docs' design, adopted):** `GET /d/{sqid}/updates?since=N` answers `application/octet-stream`: each update framed as a 4-byte big-endian length then the bytes, in seq order, header `Slides-Seq: <highest seq in the body, or N>`; `since=0` may be answered from the latest snapshot plus the tail (header `Slides-Snapshot: <seq>` when it was). `POST /d/{sqid}/updates` takes one Yjs v1 update body (limit 1 MiB), applies it through the core, stores it with the actor, bumps seq, answers `204` with `Slides-Seq`, then broadcasts. `GET /d/{sqid}/stream` is SSE: `event: update` `data: <seq>` (no bytes), `event: presence` `data: <json>`, `: ping` every 15 s, 30 s write deadline, 5 minute TTL, one `update` event on connect. The client's cursor+1 rule and broadcast-on-success-only hold (Docs live-sync D3, D4). |
| 19 | - **Snapshots:** `deck_snapshots(deck_id, seq, bytes)`; written after every 200 updates by the request that crosses the boundary (best effort, after commit); `openDeck` loads the newest snapshot then the updates after it; a snapshot is derived data (Docs snapshots ruling). |
| 20 | - **Presence is not document state:** held in the deck hub's memory only, dropped after two missed heartbeats (client posts `{slide, block}` every 20 s and on change to `POST /d/{sqid}/presence`). |
| 21 | - **Assets:** bytes go browser-to-bucket by presigned PUT when a store is bound; with no store (this deployment today) the upload endpoints answer `503` with a catalog message and the image block's inspector says so. `blobs.Dir` in development. |
| 22 | - JS: ES modules, no framework, one bundle `editor`; app-owned modules under `fixtures/` tested with `node --test`; 300-line module cap is not enforced by test for the bundle's entry (Docs precedent) but every other module stays under it. |
| 23 | - Reference: Docs' `fixtures/{build-bundles.mjs, editor-entry.js, live-updates.mjs, save-queue.mjs, resync-controller.mjs, generate.mjs, verify-rust.mjs}` and `internal/docs/{handlers.go (updatesGet/updatesPost), stream.go, hub.go, snapshot.go, assets.go}` at `/home/paulca/amadan.net/oficina/docs`. Copy shapes, not comments you do not understand. The live-updates, save-queue and resync-controller modules are copied verbatim with a header naming Docs as the origin: this is the second implementation, and the extraction to a shared module is slice 4. |
| 24 | |
| 25 | --- |
| 26 | |
| 27 | ## File map |
| 28 | |
| 29 | | Path | Responsibility | |
| 30 | |---|---| |
| 31 | | `internal/slides/live.go` | `GET/POST /d/{sqid}/updates`, `GET /d/{sqid}/stream`, `POST /d/{sqid}/presence`; the deck hub (`internal/slides/live/hub.go`, seq and presence events). | |
| 32 | | `internal/slides/snapshot.go` | `deck_snapshots` model use: `latestSnapshot`, `maybeSnapshot`; `openDeck` gains the snapshot path. | |
| 33 | | `internal/slides/editor.go` | `GET /d/{sqid}/edit` (the editor page), `GET /d/{sqid}/slides/{id}.html?vp=` for the stage and thumbnails (already exists on the API; add the session-cookie twin). | |
| 34 | | `internal/slides/assets.go` | `POST /d/{sqid}/uploads` (presigned PUT or 503), `POST /d/{sqid}/assets` (register after upload; verifies the object), `GET /assets/{ref}` (presigned GET redirect or, in development, the file). | |
| 35 | | `internal/slides/templates/editor.html` | The three panes as server-rendered HTML the bundle enhances. | |
| 36 | | `internal/slides/static/editor.<hash>.js`, `internal/slides/static/editor.css` | The committed bundle and its stylesheet. | |
| 37 | | `internal/slides/assets_embed.go` | Docs' `assets.go` pattern: `bundleKinds`, panics at init if the committed bundle is missing. | |
| 38 | | `fixtures/package.json`, `package-lock.json`, `build-bundles.mjs` | Toolchain, pinned. | |
| 39 | | `fixtures/ops.mjs`, `ops.test.mjs` | The op vocabulary over a Y.Doc. | |
| 40 | | `fixtures/schema.mjs` | The constrained ProseMirror schema (§2.6). | |
| 41 | | `fixtures/generate.mjs`, `fixtures/data/*.json`, `fixtures/vectors/*.yjs`, `fixtures/verify-rust.mjs` | Fixtures: JS builds each deck through `ops.mjs`, writes the update; a Go test loads each vector through the core and compares views. | |
| 42 | | `fixtures/live-updates.mjs`, `save-queue.mjs`, `resync-controller.mjs` (+ tests) | Copied from Docs. | |
| 43 | | `fixtures/stage.mjs` | Fetches slide HTML, binds ProseMirror to text blocks, canvas drag/resize/snap. | |
| 44 | | `fixtures/strip.mjs`, `fixtures/inspector.mjs`, `fixtures/switcher.mjs`, `fixtures/presence.mjs`, `fixtures/uploads.mjs` | One concern each, under 300 lines. | |
| 45 | | `fixtures/editor-entry.js` | Wiring only. | |
| 46 | | `.amadan/ci.d/10-gate` | Gains `npm ci`, `node --test`, bundle reproducibility, `verify-rust`. | |
| 47 | |
| 48 | --- |
| 49 | |
| 50 | ### Task 1: Live-sync server: updates, stream, presence, snapshots |
| 51 | |
| 52 | **Files:** |
| 53 | - Create: `internal/slides/live.go`, `internal/slides/live/hub.go`, `internal/slides/live/hub_test.go`, `internal/slides/snapshot.go` |
| 54 | - Modify: `internal/slides/models.go` (`DeckSnapshot{ID; DeckID (index); Seq int64; Bytes []byte; CreatedAt}`), migrations (`0006_create_deck_snapshots.sql`), `internal/slides/decks.go` (`openDeck` reads the snapshot; `applyOps` calls `a.live.Publish(sqid, seq)` after commit and `maybeSnapshot`), `internal/slides/app.go` (routes in the signed-in group) |
| 55 | - Test: `internal/slidestest/live_test.go`, `internal/slides/live/hub_test.go`, `internal/slides/snapshot_test.go` |
| 56 | |
| 57 | **Interfaces:** |
| 58 | - `live.Hub`: `New()`, `Subscribe(deckSqid) (<-chan Event, cancel)`, `PublishSeq(deckSqid, seq)`, `PublishPresence(deckSqid, p Presence)`, `Presence{Client, Name, Colour, Slide, Block string; At time.Time}`, `Event{Seq int64; Presence *PresenceSet}`; `Snapshot(deckSqid) PresenceSet` for the connect event; a sweeper drops presences older than 45 s. |
| 59 | - `(*app).appendUpdate(ctx, d Deck, actor string, update []byte) (seq int64, err error)`: opens the deck, `Apply`s the update through the core (a refusal is 422), inserts `DeckUpdate` and bumps seq in one transaction with the same optimistic lock and `errDeckBusy` retry as `applyOps`, then `maybeSnapshot`. |
| 60 | - `readUpdates(ctx, d Deck, since int64) (frames [][]byte, highest int64, snapshotSeq int64, err error)`. |
| 61 | |
| 62 | - [ ] Failing tests: `TestUpdatesRoundTripThroughTheWire` (POST a JS-shaped update produced by the Go core's `Ops`, then GET `since=0` returns it framed with `Slides-Seq`), `TestUpdatesSinceCursorIsExclusive`, `TestStreamAnnouncesSeqOnConnectAndAfterPost` (bounded by a 10 s context, no sleeps), `TestPresenceIsAnnouncedAndExpires`, `TestSnapshotWrittenAtBoundaryAndUsedOnOpen` (201 updates → one snapshot; `openDeck` replays only the tail; view identical), `TestUpdatesRequireMembershipAndRecordActor`, `TestRefusedUpdateIs422`. |
| 63 | - [ ] Implement; `make ci`; commit `feat: live-sync updates, stream, presence and snapshots`. |
| 64 | |
| 65 | --- |
| 66 | |
| 67 | ### Task 2: Fixtures toolchain, JS ops, and the Rust contract |
| 68 | |
| 69 | **Files:** |
| 70 | - Create: `fixtures/package.json`, `fixtures/package-lock.json`, `fixtures/.gitignore` (node_modules), `fixtures/ops.mjs`, `fixtures/ops.test.mjs`, `fixtures/schema.mjs`, `fixtures/markdown.mjs` (the same subset as `core/src/markdown.rs`), `fixtures/ids.mjs`, `fixtures/generate.mjs`, `fixtures/data/{title-only,every-block,canvas,columns,table}.json`, `fixtures/vectors/*.yjs`, `internal/slides/core/vectors_test.go` |
| 71 | - Test: `fixtures/ops.test.mjs`, `internal/slides/core/vectors_test.go` |
| 72 | |
| 73 | **Interfaces:** |
| 74 | - `ops.mjs`: `export function applyOps(ydoc, ops, origin)` performing every §7.2 op inside one `ydoc.transact`, validating the whole batch first (throw → nothing applied), with the identical layout conversion table as `core/src/layout.rs`; `export function view(ydoc)` returning the same JSON as `core/src/view.rs` (key order irrelevant; compare as parsed JSON). |
| 75 | - `schema.mjs`: ProseMirror `Schema` with `doc > paragraph+`, marks `bold, italic, code, highlight, link{href}`; `toYXml`/`fromYXml` helpers so text lands as `<paragraph>` elements with `XmlText` marks `b,i,c,h,href` exactly as the core writes them. |
| 76 | - `generate.mjs`: for each `data/*.json` (a list of ops), builds a fresh `Y.Doc` with `guid` from the file name and `clientID` 1, applies, writes `vectors/<name>.yjs` (full state as update v1) and `vectors/<name>.view.json` (the JS view). |
| 77 | - `vectors_test.go`: for each vector, `NewDoc`, `Apply`, `JSON`, compare to `<name>.view.json` as parsed JSON. **This is the contract test.** |
| 78 | |
| 79 | - [ ] `npm ci` works offline if the registry is unreachable? Check first; if `npm install` needs the network and the sandbox refuses, report BLOCKED with the exact error (the controller will run it outside the sandbox). |
| 80 | - [ ] Failing tests → implement → `node --test fixtures/` and `go test ./internal/slides/core/` green → commit `feat: fixtures toolchain, JS ops, Rust contract vectors`. |
| 81 | |
| 82 | --- |
| 83 | |
| 84 | ### Task 3: The editor shell: page, bundle, stage text editing, save and live updates, undo |
| 85 | |
| 86 | **Files:** |
| 87 | - Create: `internal/slides/editor.go`, `internal/slides/templates/editor.html`, `internal/slides/assets_embed.go`, `internal/slides/static/editor.css`, `fixtures/build-bundles.mjs`, `fixtures/editor-entry.js`, `fixtures/stage.mjs`, `fixtures/strip.mjs`, `fixtures/live-updates.mjs`(+test), `fixtures/save-queue.mjs`(+test), `fixtures/resync-controller.mjs`(+test), `fixtures/stage.test.mjs` |
| 88 | - Modify: `app.go` (route `GET /d/{sqid}/edit` in the signed-in group; `GET /d/{sqid}/slides/{id}.html`), `viewer.html` (an "Edit" `rst-btn` for owner and members), `locales/en.toml` |
| 89 | |
| 90 | **Interfaces:** |
| 91 | - `GET /d/{sqid}/edit` renders `editor.html`: the strip (an `<ol>` of `<li>` with a small `<iframe>`-free thumbnail: the server-rendered section at `vp=thumb`, scaled by CSS), the stage (the current slide's section), the inspector (empty until Task 4), a status line ("Saved", "Saving…", "Offline: retrying") as visible text, and `<noscript>` explaining that editing needs scripts and linking the viewer. Data attributes: `data-deck`, `data-seq`, `data-slide` (current id), `data-client-name`, `data-client-colour`. |
| 92 | - `stage.mjs`: `mountStage({root, ydoc, deckSqid, slideId, fetchSlide})` binds a ProseMirror `EditorView` per `[slides-block][data-block-id]` that carries text, using `y-prosemirror`'s `ySyncPlugin` on that block's `XmlFragment`, `yUndoPlugin`, keymap (bold/italic/code/highlight/link, Enter → paragraph, Backspace joins); the same for `[slides-notes]`. `refresh()` re-fetches the slide HTML (`?vp=<current>`), swaps it in, re-binds. Structural changes (Task 4) call `refresh()` after their save resolves. |
| 93 | - Save path: `ydoc.on('update', …)` → `save-queue` (250 ms debounce, `Y.mergeUpdates`) → `POST /d/{sqid}/updates`; `live-updates` subscribes to `/d/{sqid}/stream`, fetches `since=cursor` and applies; the resync controller as Docs. |
| 94 | - Undo: `Y.UndoManager` on `[ydoc.getArray('slides'), ydoc.getMap('meta'), ydoc.getMap('assets')]` with `trackedOrigins` = this client; `Cmd/Ctrl+Z`, `Shift+Cmd/Ctrl+Z`. |
| 95 | - Keyboard: `Cmd/Ctrl+Enter` new slide after current, `Cmd/Ctrl+D` duplicate, `Cmd/Ctrl+Shift+F` present (posts the present form). |
| 96 | |
| 97 | - [ ] `build-bundles.mjs` writes `internal/slides/static/editor.<hash>.js` and deletes stale hashes; `assets_embed.go` panics if none; `TestEditorPageLinksTheCommittedBundle` in `slidestest`; `TestEditorRequiresMembership`; a `node --test` for `stage.mjs`'s pure parts (block discovery, id mapping) using a minimal DOM stub, and Docs' three module tests copied. |
| 98 | - [ ] Browser drive (`go test -tags browser ./...` with `RASTRILLO_BROWSER_OPTIONAL` unset in the gate only if Chromium is on the runner; otherwise skip visibly): sign in, open the sample deck's editor, type into the title heading, wait for "Saved", reload, the text persists, and `GET /d/{sqid}` shows it. |
| 99 | - [ ] Commit `feat: editor shell with text editing, live sync and undo`. |
| 100 | |
| 101 | --- |
| 102 | |
| 103 | ### Task 4: Inspector, structure operations, canvas drag and resize, switcher |
| 104 | |
| 105 | **Files:** |
| 106 | - Create: `fixtures/inspector.mjs` (+test), `fixtures/canvas.mjs` (+test: snap maths), `fixtures/switcher.mjs` (+test) |
| 107 | - Modify: `editor.html` (inspector markup as forms with `rst-*` fields the bundle enhances), `editor.css`, `stage.mjs`, `editor-entry.js`, `locales/en.toml` |
| 108 | |
| 109 | **Interfaces:** |
| 110 | - Inspector sections: deck (title, theme select, accent, fonts), slide (layout radio, template select for flow, background, transition, phone behaviour, notes toggle, delete), block (type-specific props; region select or box fields x,y,w,h,z; move up/down; delete; phone overrides: hidden, order, size, box). Every control writes through `applyOps(ydoc, [op], origin)` from `ops.mjs`, then `stage.refresh()` once the save queue reports the seq. Add block: a menu (`<details name="rst-menus">`) listing the eleven types. |
| 111 | - Strip: reorder by drag (HTML5 DnD) and by keyboard (Alt+Up/Down) and by inspector buttons; add slide after; duplicate; delete with confirm; the current slide highlighted with visible text ("Slide 3, current"). |
| 112 | - Canvas: pointer drag moves a block; eight handles resize; snap to thirds and to other blocks' edges within 1% of the frame; the box is written as fractions with four decimals; z from inspector (bring forward/back). Touch works (pointer events). |
| 113 | - Switcher: `desktop | phone | both`; phone renders the stage at 390 px with `?vp=phone`; in phone view, box/order/size/hidden edits go to `set_phone`; the "both" view shows two stages, the phone one read-only; a block with a phone override shows an `rst-status-pill`-style badge with "Phone override" and a "Reset to desktop" button. |
| 114 | |
| 115 | - [ ] Tests: `inspector.test.mjs` (each control produces the right op), `canvas.test.mjs` (snap and clamp), `switcher.test.mjs` (phone-view edits become `set_phone`), `TestEditorInspectorMarkupIsFormsWithLabels` (Go, scans the page), browser drive extended: add a slide, set its template, drag a canvas block, switch to phone, hide a block, reload, all persisted. |
| 116 | - [ ] Commit `feat: inspector, structure ops, canvas editing, desktop/phone switcher`. |
| 117 | |
| 118 | --- |
| 119 | |
| 120 | ### Task 5: Presence |
| 121 | |
| 122 | **Files:** |
| 123 | - Create: `fixtures/presence.mjs` (+test) |
| 124 | - Modify: `live.go` (already serves presence; add `name`/`colour` from the user row on POST), `editor.html`, `editor.css`, `stage.mjs` |
| 125 | |
| 126 | **Interfaces:** |
| 127 | - On connect and every 20 s, and on slide/block change: `POST /d/{sqid}/presence {client, slide, block}`; the server stamps name and colour from the session and publishes the set. The strip shows small avatars (initials in the person's colour, `title` and `aria-label` naming them) on the slide each person is on; the stage outlines the block another person has selected in their colour with their name as a label (never colour alone). |
| 128 | - The presence stream event carries the whole set; the client diffs. |
| 129 | |
| 130 | - [ ] Tests: `presence.test.mjs` (set diffing, own client excluded), `TestPresenceCarriesNameAndColour` (Go), browser drive: two contexts on one deck see each other's avatar. |
| 131 | - [ ] Commit `feat: presence`. |
| 132 | |
| 133 | --- |
| 134 | |
| 135 | ### Task 6: Assets: upload, register, serve, image block |
| 136 | |
| 137 | **Files:** |
| 138 | - Create: `internal/slides/assets.go`, `fixtures/uploads.mjs` (+test) |
| 139 | - Modify: `models.go`? (no: assets live in the Y.Doc `assets` map per spec §2.7), `app.go`, `render.go` (image `src` becomes `/assets/{ref}`), `inspector.mjs` (image block: file picker, alt text required, decorative checkbox, fit, radius; an "unused assets" list on the deck section with remove), `locales/en.toml` |
| 140 | |
| 141 | **Interfaces:** |
| 142 | - `POST /d/{sqid}/uploads {name, size, type}` → `{ref, url, headers}` for a presigned PUT (rastrillo `blobs`), or `503 {"error":"no_store"}` and the catalog message; limits 20 MiB; types png, jpeg, webp, svg. |
| 143 | - `POST /d/{sqid}/assets {ref, name, width, height, alt}` → verifies the object exists and is an image (HEAD through the store; for SVG, sanitise: strip scripts and event handlers, refuse external references) → the client then applies `register_asset` (the server answers 200 with the validated record; the client writes the op so the change flows through the same document path). |
| 144 | - `GET /assets/{ref}` → 302 to a presigned GET (1 h) or the file in development; 404 unknown. |
| 145 | |
| 146 | - [ ] Tests: `TestUploadWithoutStoreIs503`, `TestUploadAndRegisterWithDirStore` (development `blobs.Dir`), `TestSvgIsSanitised`, `TestAssetServeRedirects`; `uploads.test.mjs` (client state machine: choose → PUT → register → op). |
| 147 | - [ ] Commit `feat: image upload and assets`. |
| 148 | |
| 149 | --- |
| 150 | |
| 151 | ### Task 7: Gate, docs, deploy |
| 152 | |
| 153 | - [ ] `.amadan/ci.d/10-gate` adds: `cd fixtures && npm ci`, `node --test`, `node generate.mjs` then `git diff --exit-code fixtures/vectors` (vectors reproducible), `node build-bundles.mjs` then `git diff --exit-code internal/slides/static` (bundle reproducible), and the Go `vectors_test`. |
| 154 | - [ ] `AGENTS.md`, `README.md`, `PRODUCT.md`, `docs/api.md`: the editor exists; how to build the bundle; the live-sync wire; the fixtures rule. |
| 155 | - [ ] `make release`, `carlos deploy`, verify `/api/version`, open the editor on the canary and walk it on desktop and phone width; push; reply on branch discussion 1 (or a new slice-2 discussion) with what is live. |
| 156 | |