oficina / slides Public
Slides slice 2 — the editor and collab — implementation plan
> 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.
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.
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.
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.
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).
Global Constraints
- 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, barerst-*grammar,slides-*attributes, WCAG 2.2 AA, actors on every write, nested packages underinternal/slides/, commit trailers,make cibefore every commit,GOCACHE=/home/paulca/.cache/tmp/claude-1001/go-buildin the sandbox. - 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.
- One op vocabulary.
fixtures/ops.mjsimplements exactly the ops incore/src/ops.rs(spec §7.2) over a Y.Doc;fixtures/verify-rust.mjsproves 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. - Live-sync wire (Docs' design, adopted):
GET /d/{sqid}/updates?since=Nanswersapplication/octet-stream: each update framed as a 4-byte big-endian length then the bytes, in seq order, headerSlides-Seq: <highest seq in the body, or N>;since=0may be answered from the latest snapshot plus the tail (headerSlides-Snapshot: <seq>when it was).POST /d/{sqid}/updatestakes one Yjs v1 update body (limit 1 MiB), applies it through the core, stores it with the actor, bumps seq, answers204withSlides-Seq, then broadcasts.GET /d/{sqid}/streamis SSE:event: updatedata: <seq>(no bytes),event: presencedata: <json>,: pingevery 15 s, 30 s write deadline, 5 minute TTL, oneupdateevent on connect. The client's cursor+1 rule and broadcast-on-success-only hold (Docs live-sync D3, D4). - Snapshots:
deck_snapshots(deck_id, seq, bytes); written after every 200 updates by the request that crosses the boundary (best effort, after commit);openDeckloads the newest snapshot then the updates after it; a snapshot is derived data (Docs snapshots ruling). - 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 toPOST /d/{sqid}/presence). - Assets: bytes go browser-to-bucket by presigned PUT when a store is bound; with no store (this deployment today) the upload endpoints answer
503with a catalog message and the image block's inspector says so.blobs.Dirin development. - JS: ES modules, no framework, one bundle
editor; app-owned modules underfixtures/tested withnode --test; 300-line module cap is not enforced by test for the bundle's entry (Docs precedent) but every other module stays under it. - Reference: Docs'
fixtures/{build-bundles.mjs, editor-entry.js, live-updates.mjs, save-queue.mjs, resync-controller.mjs, generate.mjs, verify-rust.mjs}andinternal/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.
---
File map
| Path | Responsibility | |---|---| | 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). | | internal/slides/snapshot.go | deck_snapshots model use: latestSnapshot, maybeSnapshot; openDeck gains the snapshot path. | | 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). | | 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). | | internal/slides/templates/editor.html | The three panes as server-rendered HTML the bundle enhances. | | internal/slides/static/editor.<hash>.js, internal/slides/static/editor.css | The committed bundle and its stylesheet. | | internal/slides/assets_embed.go | Docs' assets.go pattern: bundleKinds, panics at init if the committed bundle is missing. | | fixtures/package.json, package-lock.json, build-bundles.mjs | Toolchain, pinned. | | fixtures/ops.mjs, ops.test.mjs | The op vocabulary over a Y.Doc. | | fixtures/schema.mjs | The constrained ProseMirror schema (§2.6). | | 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. | | fixtures/live-updates.mjs, save-queue.mjs, resync-controller.mjs (+ tests) | Copied from Docs. | | fixtures/stage.mjs | Fetches slide HTML, binds ProseMirror to text blocks, canvas drag/resize/snap. | | fixtures/strip.mjs, fixtures/inspector.mjs, fixtures/switcher.mjs, fixtures/presence.mjs, fixtures/uploads.mjs | One concern each, under 300 lines. | | fixtures/editor-entry.js | Wiring only. | | .amadan/ci.d/10-gate | Gains npm ci, node --test, bundle reproducibility, verify-rust. |
---
Task 1: Live-sync server: updates, stream, presence, snapshots
Files:
- Create:
internal/slides/live.go,internal/slides/live/hub.go,internal/slides/live/hub_test.go,internal/slides/snapshot.go - Modify:
internal/slides/models.go(DeckSnapshot{ID; DeckID (index); Seq int64; Bytes []byte; CreatedAt}), migrations (0006_create_deck_snapshots.sql),internal/slides/decks.go(openDeckreads the snapshot;applyOpscallsa.live.Publish(sqid, seq)after commit andmaybeSnapshot),internal/slides/app.go(routes in the signed-in group) - Test:
internal/slidestest/live_test.go,internal/slides/live/hub_test.go,internal/slides/snapshot_test.go
Interfaces:
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) PresenceSetfor the connect event; a sweeper drops presences older than 45 s.(*app).appendUpdate(ctx, d Deck, actor string, update []byte) (seq int64, err error): opens the deck,Applys the update through the core (a refusal is 422), insertsDeckUpdateand bumps seq in one transaction with the same optimistic lock anderrDeckBusyretry asapplyOps, thenmaybeSnapshot.readUpdates(ctx, d Deck, since int64) (frames [][]byte, highest int64, snapshotSeq int64, err error).
- [ ] Failing tests:
TestUpdatesRoundTripThroughTheWire(POST a JS-shaped update produced by the Go core'sOps, then GETsince=0returns it framed withSlides-Seq),TestUpdatesSinceCursorIsExclusive,TestStreamAnnouncesSeqOnConnectAndAfterPost(bounded by a 10 s context, no sleeps),TestPresenceIsAnnouncedAndExpires,TestSnapshotWrittenAtBoundaryAndUsedOnOpen(201 updates → one snapshot;openDeckreplays only the tail; view identical),TestUpdatesRequireMembershipAndRecordActor,TestRefusedUpdateIs422. - [ ] Implement;
make ci; commitfeat: live-sync updates, stream, presence and snapshots.
---
Task 2: Fixtures toolchain, JS ops, and the Rust contract
Files:
- Create:
fixtures/package.json,fixtures/package-lock.json,fixtures/.gitignore(nodemodules),fixtures/ops.mjs,fixtures/ops.test.mjs,fixtures/schema.mjs,fixtures/markdown.mjs(the same subset ascore/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/vectorstest.go` - Test:
fixtures/ops.test.mjs,internal/slides/core/vectors_test.go
Interfaces:
ops.mjs:export function applyOps(ydoc, ops, origin)performing every §7.2 op inside oneydoc.transact, validating the whole batch first (throw → nothing applied), with the identical layout conversion table ascore/src/layout.rs;export function view(ydoc)returning the same JSON ascore/src/view.rs(key order irrelevant; compare as parsed JSON).schema.mjs: ProseMirrorSchemawithdoc > paragraph+, marksbold, italic, code, highlight, link{href};toYXml/fromYXmlhelpers so text lands as<paragraph>elements withXmlTextmarksb,i,c,h,hrefexactly as the core writes them.generate.mjs: for eachdata/*.json(a list of ops), builds a freshY.Docwithguidfrom the file name andclientID1, applies, writesvectors/<name>.yjs(full state as update v1) andvectors/<name>.view.json(the JS view).vectors_test.go: for each vector,NewDoc,Apply,JSON, compare to<name>.view.jsonas parsed JSON. This is the contract test.
- [ ]
npm ciworks offline if the registry is unreachable? Check first; ifnpm installneeds the network and the sandbox refuses, report BLOCKED with the exact error (the controller will run it outside the sandbox). - [ ] Failing tests → implement →
node --test fixtures/andgo test ./internal/slides/core/green → commitfeat: fixtures toolchain, JS ops, Rust contract vectors.
---
Task 3: The editor shell: page, bundle, stage text editing, save and live updates, undo
Files:
- 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 - Modify:
app.go(routeGET /d/{sqid}/editin the signed-in group;GET /d/{sqid}/slides/{id}.html),viewer.html(an "Edit"rst-btnfor owner and members),locales/en.toml
Interfaces:
GET /d/{sqid}/editrenderseditor.html: the strip (an<ol>of<li>with a small<iframe>-free thumbnail: the server-rendered section atvp=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.stage.mjs:mountStage({root, ydoc, deckSqid, slideId, fetchSlide})binds a ProseMirrorEditorViewper[slides-block][data-block-id]that carries text, usingy-prosemirror'sySyncPluginon that block'sXmlFragment,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) callrefresh()after their save resolves.- Save path:
ydoc.on('update', …)→save-queue(250 ms debounce,Y.mergeUpdates) →POST /d/{sqid}/updates;live-updatessubscribes to/d/{sqid}/stream, fetchessince=cursorand applies; the resync controller as Docs. - Undo:
Y.UndoManageron[ydoc.getArray('slides'), ydoc.getMap('meta'), ydoc.getMap('assets')]withtrackedOrigins= this client;Cmd/Ctrl+Z,Shift+Cmd/Ctrl+Z. - Keyboard:
Cmd/Ctrl+Enternew slide after current,Cmd/Ctrl+Dduplicate,Cmd/Ctrl+Shift+Fpresent (posts the present form).
- [ ]
build-bundles.mjswritesinternal/slides/static/editor.<hash>.jsand deletes stale hashes;assets_embed.gopanics if none;TestEditorPageLinksTheCommittedBundleinslidestest;TestEditorRequiresMembership; anode --testforstage.mjs's pure parts (block discovery, id mapping) using a minimal DOM stub, and Docs' three module tests copied. - [ ] Browser drive (
go test -tags browser ./...withRASTRILLO_BROWSER_OPTIONALunset 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, andGET /d/{sqid}shows it. - [ ] Commit
feat: editor shell with text editing, live sync and undo.
---
Task 4: Inspector, structure operations, canvas drag and resize, switcher
Files:
- Create:
fixtures/inspector.mjs(+test),fixtures/canvas.mjs(+test: snap maths),fixtures/switcher.mjs(+test) - Modify:
editor.html(inspector markup as forms withrst-*fields the bundle enhances),editor.css,stage.mjs,editor-entry.js,locales/en.toml
Interfaces:
- 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)fromops.mjs, thenstage.refresh()once the save queue reports the seq. Add block: a menu (<details name="rst-menus">) listing the eleven types. - 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").
- 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).
- Switcher:
desktop | phone | both; phone renders the stage at 390 px with?vp=phone; in phone view, box/order/size/hidden edits go toset_phone; the "both" view shows two stages, the phone one read-only; a block with a phone override shows anrst-status-pill-style badge with "Phone override" and a "Reset to desktop" button.
- [ ] Tests:
inspector.test.mjs(each control produces the right op),canvas.test.mjs(snap and clamp),switcher.test.mjs(phone-view edits becomeset_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. - [ ] Commit
feat: inspector, structure ops, canvas editing, desktop/phone switcher.
---
Task 5: Presence
Files:
- Create:
fixtures/presence.mjs(+test) - Modify:
live.go(already serves presence; addname/colourfrom the user row on POST),editor.html,editor.css,stage.mjs
Interfaces:
- 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,titleandaria-labelnaming 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). - The presence stream event carries the whole set; the client diffs.
- [ ] Tests:
presence.test.mjs(set diffing, own client excluded),TestPresenceCarriesNameAndColour(Go), browser drive: two contexts on one deck see each other's avatar. - [ ] Commit
feat: presence.
---
Task 6: Assets: upload, register, serve, image block
Files:
- Create:
internal/slides/assets.go,fixtures/uploads.mjs(+test) - Modify:
models.go? (no: assets live in the Y.Docassetsmap per spec §2.7),app.go,render.go(imagesrcbecomes/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
Interfaces:
POST /d/{sqid}/uploads {name, size, type}→{ref, url, headers}for a presigned PUT (rastrilloblobs), or503 {"error":"no_store"}and the catalog message; limits 20 MiB; types png, jpeg, webp, svg.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 appliesregister_asset(the server answers 200 with the validated record; the client writes the op so the change flows through the same document path).GET /assets/{ref}→ 302 to a presigned GET (1 h) or the file in development; 404 unknown.
- [ ] Tests:
TestUploadWithoutStoreIs503,TestUploadAndRegisterWithDirStore(developmentblobs.Dir),TestSvgIsSanitised,TestAssetServeRedirects;uploads.test.mjs(client state machine: choose → PUT → register → op). - [ ] Commit
feat: image upload and assets.
---
Task 7: Gate, docs, deploy
- [ ]
.amadan/ci.d/10-gateadds:cd fixtures && npm ci,node --test,node generate.mjsthengit diff --exit-code fixtures/vectors(vectors reproducible),node build-bundles.mjsthengit diff --exit-code internal/slides/static(bundle reproducible), and the Govectors_test. - [ ]
AGENTS.md,README.md,PRODUCT.md,docs/api.md: the editor exists; how to build the bundle; the live-sync wire; the fixtures rule. - [ ]
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.