oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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, 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.
  • 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.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.
  • 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).
  • 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).
  • 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).
  • 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.
  • 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.
  • 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.

---

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 (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)
  • 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) PresenceSet for 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), inserts DeckUpdate and bumps seq in one transaction with the same optimistic lock and errDeckBusy retry as applyOps, then maybeSnapshot.
  • 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'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.
  • [ ] Implement; make ci; commit feat: 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 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/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 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).
  • 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.
  • 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).
  • vectors_test.go: for each vector, NewDoc, Apply, JSON, compare to <name>.view.json as parsed JSON. This is the contract test.
  • [ ] 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).
  • [ ] Failing tests → implement → node --test fixtures/ and go test ./internal/slides/core/ green → commit feat: 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 (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

Interfaces:

  • 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.
  • 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.
  • 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.
  • Undo: Y.UndoManager on [ydoc.getArray('slides'), ydoc.getMap('meta'), ydoc.getMap('assets')] with trackedOrigins = this client; Cmd/Ctrl+Z, Shift+Cmd/Ctrl+Z.
  • Keyboard: Cmd/Ctrl+Enter new slide after current, Cmd/Ctrl+D duplicate, Cmd/Ctrl+Shift+F present (posts the present form).
  • [ ] 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.
  • [ ] 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.
  • [ ] 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 with rst-* 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) 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.
  • 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 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.
  • [ ] 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.
  • [ ] 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; add name/colour from 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, 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).
  • 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.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

Interfaces:

  • 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.
  • 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).
  • GET /assets/{ref} → 302 to a presigned GET (1 h) or the file in development; 404 unknown.
  • [ ] Tests: TestUploadWithoutStoreIs503, TestUploadAndRegisterWithDirStore (development blobs.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-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.
  • [ ] 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.