docs: slice 2 in AGENTS, README, PRODUCT, api.md; gate runs the JS toolchain
The gate gains npm ci, node --test, and freshness checks on the fixture vectors and the committed editor bundle, between the Rust tests and make ci. Both freshness checks require git diff AND git status to be clean, because a changed content hash writes a new file beside the old one, and both restore the tree on failure. The four docs described a repo with no browser editor. They now describe the editor, the live-sync wire, the fixtures rule, the href shape ruling (spec ruling 12), and the seams a new session needs: appendUpdate, live.Hub, serveAsset and render.SlideWith's asset base. PRODUCT.md's catalog count recounted: 318 keys.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
.amadan/README.md+72 −11.amadan/ci.d/10-gate+93 −1AGENTS.md+213 −39PRODUCT.md+104 −38README.md+77 −18docs/api.md+111 −0fixtures/package.json+1 −1
CI passed — run details
Intent
<task-notification> <task-id>a09be88f863807c6e</task-id> <tool-use-id>toolu_01WpvaRr1TS18RJ3FmE5H6qv</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a09be88f863807c6e.output</output-file> <status>completed</status> <summary>Agent "S2 Task 6: assets and uploads" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Status: done. Tree clean, gate green at HEAD. **Commit** `1533194` — `feat: image upload and assets` **Tests** - `make ci` green; node `128 pass / 0 fail` (new: `uploads.test.mjs` 10, `assets.test.mjs` 5). - New Go tests: `TestSvgIsSanitised` (unit), `TestUploadWithoutStoreIs503`, `TestUploadAndRegisterWithDirStore`, `TestUploadLimits`, `TestSvgUploadIsSanitisedBeforeItIsServed`, `TestAssetServeRedirects` (fake presigning store), `TestAssetIsKeyedByTheDeckThatHoldsIt`, `TestSharedDeckServesItsOwnAssets`, `TestPresentedDeckServesItsOwnAssets`, plus two renderer tests. Four goldens regenerated. - **Browser drive RAN**: `TestBrowserUploadsAnImage` and the whole suite (7 drives) green in 38.7 s, with the sandbox off — Chromium's singleton `socket()` is denied inside it, as in tasks 3–5. - Bundle reproducible: `editor.d193d83bc4c2adaa.js`, same hash on two builds. **Concerns**: `serveAsset` runs an `openDeck` per image request (wasm + log replay) to check the ref against the deck's assets map — wants a `(deck, seq)` cache. Presigned PUT is untested against a real bucket; the CORS note is in `docs/api.md` and the README. `crypto.subtle` needs a secure context, so plain-http non-localhost fails at the hash step with a vague message. The drive found a real bug: nothing had ever observed the Y.Doc's `assets` root, so asset changes never refreshed the UI — now observed in `editor-entry.js`. **Report**: `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-07-slice-2-editor/task-6-report.md`</result> <usage><subagent_tokens>333483</subagent_tokens><tool_uses>212</tool_uses><duration_ms>2137880</duration_ms></usage> </task-notification>
<task-notification> <task-id>a5190df1c0b6496d2</task-id> <tool-use-id>toolu_01UcYRfYJ99nFbQbUaFo43K3</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a5190df1c0b6496d2.output</output-file> <status>completed</status> <summary>Agent "Review slice 2 Task 6" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>This matches what I've reviewed. I have sufficient evidence to write the review. --- ## Output ### Spec Compliance Matches §2.7 and §2.4 fully, and follows the plan's "Assets" global constraint and controller decisions precisely: - `POST /d/{sqid}/uploads` signs a presigned PUT or answers `503 {"error":"no_store"}`; enforces 20 MiB and the four types (png/jpeg/webp/svg). - `POST /d/{sqid}/assets` verifies the object exists and is an image by sniffing bytes (not trusting the client's declared content-type), sanitises SVG, mints a fresh id, and answers the validated record — it does **not** write the Y.Doc. The client applies `register_asset` + `set_block` as one batch (`inspector-ops.mjs:registerAssetOps`), confirmed by `fixtures/uploads.test.mjs` ("one finished upload is register_asset plus the block that uses it, in one batch"). - Asset serving is deck-keyed on all three surfaces (`/d/{sqid}/assets/{ref}`, `/s/{token}/assets/{ref}`, `/p/{session}/assets/{ref}`), one `serveAsset` body, 302 to presigned GET or the file in dev, unknown/unregistered → 404. `render.Options.AssetBase` threaded through all render call sites; asset id never leaks into a URL; unregistered asset → `data-missing-asset`, never a broken `<img>`. - Inspector: labelled file input, required alt unless decorative (enforced both client — `uploads.mjs`'s `altfirst` — and server — `alt_required`), fit/radius carried over from task 4, progress/errors as `role="status"` text, store-missing state server-rendered (`HasStore`/`AcceptTypes`). - "Unused assets" list with Remove, reconciled by id like the Blocks list (focus-preserving). - `uploads.mjs` state machine `choose → sign → put → register → op`, fully seamed/fakeable; all listed tests present (`TestUploadWithoutStoreIs503`, `TestUploadAndRegisterWithDirStore`, `TestSvgIsSanitised`, `TestAssetServeRedirects`, `uploads.test.mjs`). - Dev-only same-origin PUT and CORS operator note both present in `docs/api.md`. - Exactly one bundle file committed (`editor.d193d83bc4c2adaa.js`); the stale `editor.626eebe67341d5d8.js` is deleted in the same diff. ### Strengths - **SVG sanitiser is a real, two-pass implementation**, not regex soup: pass 1 is a genuine `encoding/xml` parse that refuses any `DOCTYPE`/`ENTITY` directive or non-`xml` processing instruction outright (`checkSVGWellFormed`), which forecloses XXE and billion-laughs — Go's `xml.Decoder` has no entity map configured, so an undeclared entity is already an error, and a document that needs one declared is refused before that even matters. `svg_test.go` explicitly covers the `<!DOCTYPE svg [<!ENTITY x "boom">]>` case. Pass 2 drops `<script>`/`<foreignObject>` subtrees whole, strips `on*` attributes and `javascript:`/`vbscript:`/`data:text/html` schemes (including the entity-obfuscated `java&#115;cript:` case, tested), and *refuses the whole document* (not silently drops) on any external `href`/`xlink:href`/`src`, `<use>` reference, `@import`, or `url()` reaching outside the document. A final self-containment pass re-checks `<style>` CSS after rewriting. This is stricter than "drop the bad bit" — a document that can't be made fully safe is refused, never half-cleaned. - **Sanitised bytes are what's stored**, confirmed end-to-end by `TestSvgUploadIsSanitisedBeforeItIsServed`: dirty SVG registers at a new address, the served bytes have no `script`/`onload`/`onclick`, and the original dirty address is verified unreachable (404) because `serveAsset` only answers for refs the deck's own `assets` map holds. - **Cross-deck asset isolation is directly tested**: `TestAssetIsKeyedByTheDeckThatHoldsIt` proves a ref registered on deck A 404s on deck B via both the session route and a share link scoped to deck B, plus an unknown share token 404s too. Revocation itself reuses `deckForShareLink`, which already treats a revoked link the same as a missing one (404, not 410) — shared by the viewer page and the asset route by design, so there's one lookup rather than two that could disagree. - **Content-type on serve is derived from sniffed bytes**, never the client's claim (`sniffImage(raw)` in `serveAsset`), with `X-Content-Type-Options: nosniff`, `Content-Disposition: inline`, and `Content-Security-Policy: default-src 'none'; sandbox` on every served asset — belt-and-braces given SVGs are served from the app's own origin. - **Dev-only PUT is capability-gated, not env-gated, but the two are behaviorally equivalent here**: `cmd/slides/main.go:newBlobs` only ever binds `*blobs.S3` (production) or `blobs.Dir` (only when `env == development`) or `nil`; `putUpload` refuses 404 whenever the bound store implements `presigner` (i.e., is S3). So production never reaches the app-process PUT path even though the guard isn't a literal `Env ==` check. - **20 MiB is enforced in all three places it needs to be**: the sign endpoint refuses an oversize claim, `putUpload` wraps the body in `http.MaxBytesReader(w, r.Body, maxAssetBytes)`, and `registerAsset`'s `readAsset` re-checks the object actually read back is within bound (since a presigned PUT bypasses the app entirely) — `TestUploadLimits` covers both directions. - No English literals in JS: `uploads.mjs` and `inspector-image.mjs` carry only internal state/error codes; every user-facing string comes from `labels`/catalog data attributes, verified against `en.toml`'s `slides.assets.*` and `slides.editor.block.image.*` keys — all present, complete, and consistent with the codes the Go and JS sides emit. - Width/height are explicitly *not* verified against the decoded object server-side (only bounded to a sane range) — this matches the spec's own text ("verifies the object exists and is an image"; no mention of dimension verification), and is documented as advisory in the code comment. - The one bug the report says the browser drive found — nothing observed `doc.getMap('assets')`, so registering/removing an image left the Unused list and slide stale — is genuinely fixed: `watchDoc` now observes the assets map and triggers `refreshAfterSave`/`refreshStages`/`fillSoon()` exactly like the `meta` map, and the map is also added to `Y.UndoManager`'s tracked roots so an asset op undoes with everything else. - Go tests, node tests, `go vet`, and `gofmt -l` all pass clean when re-run independently in this review (`internal/slides/...`, `internal/slidestest/...` asset/upload/svg subset, `fixtures/uploads.test.mjs` + `assets.test.mjs`). ### Issues #### Critical None found. #### Important None found. #### Minor - `serveAsset` performs a full `openDeck` (wasm replay) per asset request on an unauthenticated surface (`/s/{token}/assets/{ref}`, `/p/{session}/assets/{ref}`); a slide with several images is several replays per page load, and nothing bounds a share-link holder hitting this in a loop. The report already flags this and defers a `(deck id, seq)` cache to a later task — reasonable given no rate limiting exists anywhere else in this app either, but worth carrying forward explicitly rather than only in a prior report. - A presigned PUT is untested against a real bucket (acknowledged in the report); the CORS note in `docs/api.md` is the only mitigation until a real deployment exercises it. - `crypto.subtle` requires a secure context; a non-https, non-localhost deployment fails uploads at the hash step with a generic `nohash` message rather than something more specific. Low risk since real deployments are https, per the report. - `.amadan/ci.d/10-gate` still doesn't run `npm ci`/`node --test`/the bundle-hash check (carried over, not this task's regression); node tests and bundle reproducibility were verified by hand rather than by CI here, same as prior tasks in this slice. ### Assessment **Task quality:** Approved **Reasoning:** Every controller decision in the brief is implemented and independently verified against the diff and a live re-run of the relevant tests (Go: `TestUploadWithoutStoreIs503`, `TestUploadAndRegisterWithDirStore`, `TestUploadLimits`, `TestSvgUploadIsSanitisedBeforeItIsServed`, `TestAssetServeRedirects`, `TestAssetIsKeyedByTheDeckThatHoldsIt`, `TestSharedDeckServesItsOwnAssets`, `TestPresentedDeckServesItsOwnAssets`, `TestSvgIsSanitised`, plus the render-package tests; Node: `uploads.test.mjs`, `assets.test.mjs` — all green; `go vet` and `gofmt -l` clean). The named-risk areas hold up under direct inspection: the SVG sanitiser is a real XML-parse-then-scan implementation that refuses (not silently patches) XXE/entity vectors and every external-reference class named in the brief, sanitised bytes are what gets stored and re-served, cross-deck asset isolation is both implemented and tested, content-type on serve is sniffed rather than trusted, security headers are present on every served asset, and the dev-only PUT path is unreachable in any realistic production configuration given how this app's own `newBlobs` binds stores. No critical or important issues found; the minor items are pre-existing or already self-disclosed concerns for follow-up work, not defects in this task.</result> <usage><subagent_tokens>135559</subagent_tokens><tool_uses>56</tool_uses><duration_ms>255301</duration_ms></usage> </task-notification>
Attributed to this commit: 39.2 g CO₂e (4932922 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on slice-2-editor, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/.amadan/README.md b/.amadan/README.md| index 1833d8c..11d77d0 100644 |
| --- a/.amadan/README.md |
| +++ b/.amadan/README.md |
| @@ -8,17 +8,78 @@ version of it, which is what the four scaffold steps here used to be. |
| It needs a Rust toolchain with the `wasm32-unknown-unknown` target and the |
| `rustfmt` and `clippy` components (`rust-toolchain.toml` pins 1.98.0 and |
| -asks for all three), and Go. There is no Node in this gate: slice 1 ships |
| -no JS bundle. `CGO_ENABLED=0` is exported for every step. The gate sources |
| -`~/.cargo/env` itself and checks every tool up front, so a missing |
| -dependency fails on the first line with its own name rather than fifty |
| -lines in as `cargo: command not found`. |
| - |
| -Nothing in it reaches the network once the Cargo registry and Go module |
| -caches are warm. `make migration-check` runs the rastrillo CLI through |
| -`go run` off `go.mod` rather than needing a binary on `PATH` — a bare |
| -`rastrillo migration check` is what failed a sibling repo's very first CI |
| -run, with nothing in the scaffold saying what to install. |
| +asks for all three), Go, and — since slice 2 — **Node 20 or newer with |
| +`npm`**, because the editor's bundle and the fixture vectors are both |
| +built by JS this repo owns. `CGO_ENABLED=0` is exported for every step. |
| +The gate sources `~/.cargo/env` itself and checks every tool up front, so |
| +a missing dependency fails on the first line with its own name rather |
| +than fifty lines in as `cargo: command not found`. |
| + |
| +`make migration-check` runs the rastrillo CLI through `go run` off |
| +`go.mod` rather than needing a binary on `PATH` — a bare `rastrillo |
| +migration check` is what failed a sibling repo's very first CI run, with |
| +nothing in the scaffold saying what to install. |
| + |
| +**Known trade-off: `npm ci` needs the npm registry reachable at run |
| +time.** The Rust and Go steps are offline once their caches are warm; the |
| +Node step is not. It is that way for a reason — the runner clones a fresh |
| +worktree per branch, so `fixtures/node_modules` never carries over, and |
| +`npm ci` against the committed `fixtures/package-lock.json` is what makes |
| +the installed `yjs` exactly the pinned 13.6.27 rather than whatever a |
| +bare `npm install` resolves this week. Docs made the same call and |
| +documents the same trade-off. If the registry is unreachable from the |
| +runner, that is the failure this line produces; it is not a bug in the |
| +gate. The cache directory is the one part of it that differs by machine, |
| +so the step honours `NPM_CACHE` and falls back to `$HOME/.npm`: the |
| +runner's HOME is writable and needs nothing, a sandboxed local run may |
| +need `NPM_CACHE` pointed somewhere it can write, or npm fails on its |
| +cache rather than on anything to do with the install. |
| + |
| +## The JS half |
| + |
| +Four steps, in this order, between the Rust tests and `make ci`. |
| + |
| +- **`(cd fixtures && npm ci --cache "${NPM_CACHE:-$HOME/.npm}")`** — the |
| + trade-off above, and the reason for it. |
| +- **`(cd fixtures && node --test *.test.mjs)`** — the JS side's own |
| + tests: the op vocabulary, the ProseMirror schema, the save queue, the |
| + live-update cursor, the resync controller, the inspector's ops, the |
| + canvas maths, the switcher, uploads. Most of what the editor gets wrong |
| + it gets wrong in a browser with nothing in a log, which is the argument |
| + for running them here rather than by hand. |
| + |
| + Run from `fixtures/` with a glob, never as `node --test fixtures/`. |
| + Node 24 takes a directory argument as if it were a test file, runs |
| + nothing, and exits 0 — a green-looking way to run no tests at all. |
| +- **`(cd fixtures && node generate.mjs)` then a clean-tree check on |
| + `fixtures/vectors`** — the vectors are the contract between the JS ops |
| + and the Rust core (spec §2.8). `internal/slides/core/vectors_test.go`, |
| + under `make ci` below, loads each committed vector through the core and |
| + requires the same view the JS side produced. A vector nobody |
| + regenerated makes that test compare the core against a JS |
| + implementation that no longer exists, so **it still passes** while the |
| + two sides have already drifted. This step is what turns that into a |
| + failure: `vectors are stale: run node fixtures/generate.mjs and commit`. |
| +- **`node fixtures/build-bundles.mjs` then a clean-tree check on |
| + `internal/slides/static`** — the editor bundle is committed, hashed, |
| + and built by nothing at run time, so a stale one is served perfectly |
| + happily by a server that cannot tell. Same check as the wasm `cmp` |
| + below, and safe for the same reason: the build is byte-reproducible (no |
| + sourcemap, no banner, no timestamp, no absolute paths, and esbuild's |
| + work happens in the pinned 0.28.2 native binary, so the Node running it |
| + does not enter into the result). |
| + |
| +The last two check `git diff` **and** `git status --porcelain`, because |
| +each catches half of it. `git diff` sees a tracked file that changed or |
| +was deleted; it never sees the new file a changed content hash writes |
| +beside the old one. A stale hashed bundle left behind is not a cosmetic |
| +problem — `assets_embed.go` requires exactly one `editor.<hash>.js` and |
| +panics at boot when it finds two. |
| + |
| +Both restore the tree on failure (`git checkout`, then `git clean` for |
| +the untracked half). They mutated a committed directory as a side effect |
| +of reading it, and leaving that behind would let a second run of the same |
| +script pass with nobody having committed anything. |
| ## Two things that fail silently |
diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate| index 5c22dcf..98b547c 100755 |
| --- a/.amadan/ci.d/10-gate |
| +++ b/.amadan/ci.d/10-gate |
| @@ -15,7 +15,7 @@ cd "$(dirname "$0")/../.." |
| # got it right — a gate that only works on one machine is not a gate. |
| [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" |
| -for tool in cargo rustc rustfmt go; do |
| +for tool in cargo rustc rustfmt go node npm; do |
| command -v "$tool" >/dev/null || { echo "gate: $tool not on PATH" >&2; exit 1; } |
| done |
| rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown \ |
| @@ -26,6 +26,39 @@ rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown \ |
| # ships to an arm64 instance. |
| export CGO_ENABLED=0 |
| +# Two of the steps below regenerate a committed artifact and then require |
| +# the tree to be unchanged. Both have the same shape, so they share one |
| +# definition: |
| +# |
| +# git diff alone is not enough. It sees a tracked file that changed or |
| +# was deleted, and never the NEW file a changed content hash writes |
| +# beside the old one — so a rebuild whose bytes moved leaves both a |
| +# deletion and an untracked file, and checking only one of them lets |
| +# half of that through. |
| +# |
| +# On failure the tree is put back. The check mutated a committed |
| +# directory as a side effect of reading it, and leaving that behind |
| +# would make a second run of this same script pass with nobody having |
| +# committed anything — the worst possible outcome for a freshness |
| +# check. |
| +require_committed() { |
| + local path=$1 fix=$2 ok=1 |
| + # --name-status, not a content diff: both directories hold minified |
| + # or binary artifacts, and the useful answer is which file moved, not |
| + # 371 KB of bundle in the runner's log. |
| + git diff --exit-code --name-status -- "$path" || ok=0 |
| + if [ -n "$(git status --porcelain -- "$path")" ]; then |
| + git status --porcelain -- "$path" >&2 |
| + ok=0 |
| + fi |
| + if [ "$ok" -eq 0 ]; then |
| + echo "gate: $fix" >&2 |
| + git checkout -- "$path" |
| + git clean -fdq -- "$path" |
| + exit 1 |
| + fi |
| +} |
| + |
| echo "=== rust: fmt" |
| # --manifest-path, because there is no workspace at the repo root: the |
| # crate lives in core/ and a bare `cargo fmt` here finds no Cargo.toml. |
| @@ -67,6 +100,65 @@ echo "=== rust: test" |
| # build first. |
| SLIDES_REQUIRE_WASM=1 cargo test --manifest-path core/Cargo.toml |
| +echo "=== fixtures: node dependencies" |
| +# The versions are printed rather than pinned. What Node is only matters |
| +# as a floor — 20+, for `node --test` and the ESM loading this tree |
| +# assumes — because yjs and the rest come from the lockfile and esbuild's |
| +# actual work happens inside a pinned native binary. |
| +node --version |
| +npm --version |
| + |
| +# The runner clones a fresh worktree per branch, so fixtures/node_modules |
| +# (gitignored, like any node_modules) never carries over from a previous |
| +# run. npm ci reads fixtures/package-lock.json rather than resolving from |
| +# package.json, so the installed yjs is exactly the pinned 13.6.27 and not |
| +# whatever a bare `npm install` would pick today. |
| +# |
| +# NPM_CACHE exists because the cache directory is the one part of this |
| +# that is not the same everywhere. The runner's HOME is writable and |
| +# $HOME/.npm is right there; a sandboxed local run may have neither, and |
| +# npm then fails with an EROFS on its cache rather than with anything to |
| +# do with the install. Point NPM_CACHE somewhere writable in that case. |
| +echo "=== fixtures: npm ci (needs the registry — see .amadan/README.md)" |
| +(cd fixtures && npm ci --cache "${NPM_CACHE:-$HOME/.npm}") |
| + |
| +echo "=== fixtures: the JS side's own tests" |
| +# From fixtures/ with an explicit glob, never `node --test fixtures/`: |
| +# Node 24 takes the directory argument as a test file, runs nothing and |
| +# exits 0, which is a green-looking way to run no tests at all. |
| +(cd fixtures && node --test *.test.mjs) |
| + |
| +echo "=== the vectors must match a fresh generation" |
| +# fixtures/vectors is the contract between the two implementations of the |
| +# op vocabulary (spec §2.8): generate.mjs builds each deck through |
| +# ops.mjs and writes the Y update plus the JS side's view of it, and |
| +# internal/slides/core/vectors_test.go — run by `make ci` below — loads |
| +# the same bytes through the Rust core and requires the same view. |
| +# |
| +# A vector nobody regenerated is the failure this catches, and it is a |
| +# silent one: the Go test still passes, because it is comparing the core |
| +# against a JS implementation that no longer exists. Both sides change or |
| +# neither does. |
| +(cd fixtures && node generate.mjs) |
| +require_committed fixtures/vectors \ |
| + "vectors are stale: run node fixtures/generate.mjs and commit" |
| + |
| +echo "=== the editor bundle must match a fresh build" |
| +# build-bundles.mjs is byte-reproducible: no sourcemap, no banner, no |
| +# timestamp, no absolute path in the output, and esbuild's work happens |
| +# in the pinned 0.28.2 native binary rather than in interpreted JS, so |
| +# the Node running it does not enter into the result either. |
| +# |
| +# This is the same check as the wasm cmp above and it is here for the |
| +# same reason: nothing builds the bundle at run time, the artifact is |
| +# committed, and a server handed a stale one serves it perfectly happily. |
| +# assets_embed.go's exactly-one rule makes a leftover hashed file a panic |
| +# at boot rather than a wrong editor, which is why the untracked half of |
| +# require_committed matters here too. |
| +node fixtures/build-bundles.mjs |
| +require_committed internal/slides/static \ |
| + "the editor bundle is stale: run node fixtures/build-bundles.mjs and commit internal/slides/static" |
| + |
| echo "=== go: vet, gofmt, test, migration check" |
| # The Makefile's own ci target, so the Go half of this gate and the |
| # `make ci` you run between edits cannot come apart. |
diff --git a/AGENTS.md b/AGENTS.md| index f75a9b3..dfbd8bb 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -15,20 +15,22 @@ made over there, this repo consumes it rather than restating it. |
| Design: `docs/superpowers/specs/2026-09-07-slides-design.md` (revision 1, |
| still unreviewed — §12 puts the review pass after slice 1 ships, so the |
| -reviewer gets a running instance instead of a document). Plan: |
| -`docs/superpowers/plans/2026-09-07-slice-1-live.md`. Read the spec section |
| -behind whatever you touch. Read rastrillo's own `SKILL.md` (repo root, or |
| -in the module cache) before writing handlers or models — it is the whole |
| -app story in about 15KB, and you should assume none of it is in your |
| -training data. |
| +reviewer gets a running instance instead of a document; ruling 12 was |
| +added during slice 2 and §2.6 rewritten with it). Plans: |
| +`docs/superpowers/plans/2026-09-07-slice-1-live.md` and |
| +`2026-09-07-slice-2-editor.md`. Read the spec section behind whatever you |
| +touch. Read rastrillo's own `SKILL.md` (repo root, or in the module |
| +cache) before writing handlers or models — it is the whole app story in |
| +about 15KB, and you should assume none of it is in your training data. |
| ## The one rule |
| **A slide is one block tree, rendered many ways** (spec §0.1). The tree is |
| the only truth. The viewer renders it, the phone reflows it, the presenter |
| -shows it, the drive API patches it, and the editor — when it exists — will |
| -edit that same tree. There is never a desktop version and a mobile version |
| -of a deck, only one deck and a viewport. |
| +shows it, the drive API patches it, and the editor edits that same tree |
| +through the same operations — its stage is the server's own HTML for the |
| +slide, not a second rendering of it. There is never a desktop version and |
| +a mobile version of a deck, only one deck and a viewport. |
| Everything else follows. `flow` and `canvas` are two ways of placing the |
| same blocks, not two kinds of slide (§2.3). The phone overrides are entries |
| @@ -38,10 +40,9 @@ representation of a deck, you have almost certainly taken a wrong turn. |
| ## What is actually built |
| -This is slice 1, and the honest summary is that **there is no browser |
| -editor yet**. A deck is built through the drive API (`docs/api.md`) or it |
| -arrives as the sample deck seeded on first sign-in. Everything downstream |
| -of the tree works: |
| +Slices 1 and 2. A deck is edited in the browser at `/d/{sqid}/edit`, or |
| +driven through the API (`docs/api.md`), or it arrives as the sample deck |
| +seeded on first sign-in. |
| - **Sign-in** is a magic link (`internal/slides/auth.go`), gated by |
| `SLIDES_ALLOWED_ADDRESSES` in front of `POST /signin` as well as in |
| @@ -62,12 +63,32 @@ of the tree works: |
| session with a pairing code, the fullscreen surface, the presenter view |
| with notes and a clock, the phone remote, and audience follow from a |
| share link. |
| - |
| -Not in this slice, and don't write around their absence: PDF export and |
| -the SVG layout engine, images and the object store, the collaborative |
| -editor, presence, and the desktop/phone switcher. Those are slices 2 and 3 |
| -(spec §10). `docs/api.md` is the reference for the API, written for a |
| -person and an agent both. |
| +- **The editor** (`editor.go`, `templates/editor.html`, |
| + `static/editor.css`, and the committed bundle built from `fixtures/`): |
| + the strip of server-rendered thumbnails, the stage, the inspector for |
| + the deck, the slide and the selected block, text edited in place through |
| + ProseMirror bound to the server's own HTML, canvas drag and resize with |
| + snapping, the desktop/phone switcher whose phone-view edits become |
| + `set_phone` overrides, undo scoped to the person who made the change, |
| + presence, and image upload when a store is bound. |
| +- **Live sync** (`live.go`, `live/hub.go`, `snapshot.go`): the update log |
| + served by cursor, an SSE stream of advisory seqs and whole presence |
| + sets, and a snapshot every 200 updates so opening a long-lived deck is |
| + not a full replay. |
| +- **Assets** (`assets.go`, `svg.go`): presigned PUT browser-to-bucket, |
| + registration that re-reads and sniffs what actually landed, SVG |
| + sanitised, and three capability-checked routes serving the bytes back. |
| + |
| +Not built, and don't write around their absence: PDF export and the SVG |
| +layout engine, charts from Sheets, video and embeds, an MCP adapter, |
| +aspect ratios other than 16:9, comments, and version history. Those are |
| +slice 3 and the triggers in spec §9. `docs/api.md` is the reference for |
| +the API, written for a person and an agent both. |
| + |
| +**The deployment has no object store bound today** (`carlos store create` |
| +is the fix, and nobody has run it). The upload endpoints answer |
| +`503` there and the editor's image field says so, server-rendered, rather |
| +than letting somebody pick a file and find out afterwards. |
| ## Layout |
| @@ -75,11 +96,20 @@ person and an agent both. |
| `migrations.go` plus `migrations/`, `app.go`, `handlers.go`, `render.go` |
| plus `templates/` — with one file per screen area beside them (`auth.go`, |
| `decks.go`, `viewer.go`, `share.go`, `presentation.go`, `sample.go`, |
| -`live.go`, `editor.go`, `snapshot.go`) and one package per concern nested |
| -underneath: `core` (the wazero host), `deck` (Go types for the core's JSON |
| -view), `render` (slide HTML), `present` (the presentation SSE hub), `live` |
| -(the deck's own hub: seqs and presence). `core/` at the repo root is the Rust crate. |
| -`manifest/` and `gen/` are rastrillo's declarative path, unused here. |
| +`live.go`, `editor.go`, `snapshot.go`, `assets.go`, `svg.go`, |
| +`assets_embed.go`) and one package per concern nested underneath: `core` |
| +(the wazero host), `deck` (Go types for the core's JSON view), `render` |
| +(slide HTML), `present` (the presentation SSE hub), `live` (the deck's own |
| +hub: seqs and presence). `internal/slides/static/` holds the vendored |
| +stylesheets and the committed editor bundle. `core/` at the repo root is |
| +the Rust crate. `manifest/` and `gen/` are rastrillo's declarative path, |
| +unused here. |
| + |
| +`fixtures/` at the repo root is the browser half and the contract that |
| +keeps it honest: the editor's ES modules, their `node --test` tests, |
| +`build-bundles.mjs` (esbuild, output committed), and `generate.mjs` with |
| +`data/` and `vectors/`. It is a Node package with a committed lockfile; |
| +`node_modules/` is not. |
| ## The seams |
| @@ -120,10 +150,33 @@ bytes off the wire, so a refusal is ordinary and comes back as a 422. |
| Nothing else may append to `deck_updates`. |
| `render.Slide(d, index, viewport)` is the only place slide HTML is |
| -produced, for every surface. It builds markup by hand rather than |
| -templating it, because `html/template` cannot take a dynamic tag or |
| -attribute name; read the package doc comment for the four invariants that |
| -make that safe before adding a block type. |
| +produced, for every surface — the viewer, the presenter, the API's |
| +`.html` twin, the editor's stage and its thumbnails. It builds markup by |
| +hand rather than templating it, because `html/template` cannot take a |
| +dynamic tag or attribute name; read the package doc comment for the four |
| +invariants that make that safe before adding a block type. |
| + |
| +`render.SlideWith(..., render.Options{AssetBase})` is that function when |
| +the slide has pictures on it, and **every real surface has to pass its |
| +own base**: `/d/{sqid}/assets` signed in, `/s/{token}/assets` over a share |
| +link, `/p/{session}/assets` while presenting. One slide is rendered on |
| +surfaces authenticated four different ways, and the `<img>` has to be |
| +fetchable by whoever is looking at it. `Slide` stays for callers that have |
| +no pictures to worry about (a test, a fixture) and falls back to |
| +`render.DefaultAssetBase`, which is deliberately a path nothing is mounted |
| +at: forget to say which surface you are and you get visibly broken images |
| +rather than a URL that quietly works from the wrong place. Note also that |
| +the `<img src>` carries the asset's **ref**, never its id — the id is the |
| +deck's own name for it and stays out of URLs. |
| + |
| +`serveAsset` (`assets.go`) is the one body behind all three of those |
| +routes, and the check inside it is the security property, not a |
| +formality: a ref is a content address, so knowing one is knowing the |
| +SHA-256 of somebody's file. It resolves the deck and confirms *this* |
| +deck's `assets` map refers to *this* ref before it serves a byte; |
| +without that, any capability for any deck would fetch any object in the |
| +bucket. It costs an `openDeck` per image request, which is known and |
| +noted rather than forgotten. |
| `present.Hub` is the in-memory fan-out for a live presentation. Its events |
| are whole state, never deltas, so a subscriber that falls behind is dropped |
| @@ -136,6 +189,105 @@ advisory ("there is something at least this new" — the bytes come from |
| Presence lives there and nowhere else — it is not document state, so it |
| never reaches the log, is never snapshotted, and is gone with the process. |
| +## The wire the editor speaks |
| + |
| +Docs' live-sync design, adopted as written (spec §3.3). Four routes, all |
| +in the signed-in group, all behind `deckFor`, all 404 for a stranger. |
| + |
| +`GET /d/{sqid}/updates?since=N` answers `application/octet-stream`: every |
| +update after seq N, each framed as a 4-byte big-endian length then the |
| +bytes, in seq order. `Slides-Seq` is the highest seq in the body, or N |
| +when the body is empty — derived from the bytes in hand, never from a |
| +second `MAX(seq)` query, which could see an update this response does not |
| +carry and leave the client holding a cursor for bytes it never received. |
| +At `since=0` the answer may start from the newest snapshot, and |
| +`Slides-Snapshot: <seq>` says when it did. |
| + |
| +`POST /d/{sqid}/updates` takes one Yjs v1 update, 1 MiB cap. It goes |
| +through `appendUpdate`, is stored with the poster's actor, and answers |
| +`204` with `Slides-Seq`. A refusal from the core is a 422 and an ordinary |
| +event: these are bytes off the wire, not a batch this app wrote. |
| + |
| +`GET /d/{sqid}/stream` is SSE and carries no document bytes at all: |
| +`event: update` with `data: <seq>` (advisory — go and fetch), `event: |
| +presence` with the whole set as JSON, `: ping` every 15 s, one of each on |
| +connect, 5 minute TTL. Whole state rather than deltas, so a subscriber |
| +that falls behind is dropped and reconnects. |
| + |
| +`POST /d/{sqid}/presence` takes `{client, slide, block}` every 20 s and on |
| +change. **The server stamps the name and the colour** from the `users` |
| +row; a client that sends its own is ignored, or presence becomes a place |
| +to write somebody else's name. Two missed heartbeats and the sweeper |
| +drops it. |
| + |
| +## The fixtures rule |
| + |
| +`fixtures/ops.mjs` and `core/src/ops.rs` are two implementations of one op |
| +vocabulary (spec §7.2), because the browser writes the document the core |
| +reads. `fixtures/data/*.json` are op batches; `node fixtures/generate.mjs` |
| +runs each through the JS side and writes `vectors/<name>.yjs` (the Y |
| +update) and `vectors/<name>.view.json` (the JS view of it). |
| +`internal/slides/core/vectors_test.go` loads the same bytes through the |
| +core and requires the same view, and also runs the same op batch through |
| +the core's own `Ops` and compares again. |
| + |
| +So: **an op changes on both sides or on neither.** Regenerate in the same |
| +commit as the change. Never edit a vector to make a test pass — a vector |
| +that was quietly adjusted leaves the Go test comparing the core against a |
| +JS implementation that no longer exists, and it passes while the two sides |
| +drift. The gate regenerates and diffs. |
| + |
| +It has already caught something real. **`href` is stored as the one-key map |
| +`{"href": url}`, and the ProseMirror marks are named `b`, `i`, `c`, `h`, |
| +`href`** — spec §1 ruling 12, §2.6. y-prosemirror 1.3.7 has no hook to map |
| +mark names to Y attribute names: it writes |
| +`attributes[mark.type.name] = mark.attrs` and reads |
| +`schema.mark(attrName, attrValue)`. So the mark's name has to be the |
| +attribute's name, and a plain-string `href` throws inside `schema.mark` — |
| +and y-prosemirror deletes the `XmlText` when a read throws, which loses |
| +the text and not just the link. Naming the marks after the attributes |
| +makes the mapping the identity and keeps mapping code out of the editor. |
| +The writer is exact; both readers are tolerant, taking any truthy value |
| +for `b`/`i`/`c`/`h` (y-prosemirror writes `{}`) and either the map or a |
| +plain string for `href` (documents written before the ruling). |
| + |
| +## How the editor is put together |
| + |
| +Read this before changing anything under `fixtures/`. |
| + |
| +The browser is not a second renderer. The stage shows the server's own |
| +HTML for the current slide, fetched from |
| +`GET /d/{sqid}/slides/{id}.html?vp=desktop|phone|thumb`, and the client |
| +binds a ProseMirror `EditorView` into each text block already in it. After |
| +any structural change lands, the slide is re-fetched. Thumbnails come from |
| +the server too. If you find yourself building slide markup in JS, stop. |
| + |
| +Structural edits go through `fixtures/ops.mjs`'s `applyOps` — the same |
| +vocabulary the API takes — inside one Y transaction, and reach the server |
| +as ordinary Yjs updates through the save queue. Undo is a single |
| +`Y.UndoManager` over the three roots with this client's origins tracked, |
| +driven directly rather than through `yUndoPlugin` (whose view hook |
| +destroys a manager shared by many views the moment the first view goes). |
| +`commit()` calls `stopCapturing()` first, so each batch is its own undo |
| +step. |
| + |
| +`editor-entry.js` is wiring and nothing else; every other module under |
| +`fixtures/` is one concern and stays under 300 lines. `live-updates.mjs`, |
| +`save-queue.mjs` and `resync-controller.mjs` are Docs' modules copied |
| +verbatim with a header saying so — this is the second implementation, and |
| +extracting them to something shared is slice 4's problem, not yours. |
| + |
| +The bundle is built by hand and committed: |
| + |
| +```sh |
| +cd fixtures && npm ci && node build-bundles.mjs |
| +``` |
| + |
| +`assets_embed.go` resolves `editor.<16 hex>.js` out of the embedded |
| +`static/` at package init and **panics unless there is exactly one**, so a |
| +build that stopped emitting one, or a merge that left two, refuses to boot |
| +rather than serving an editor that disagrees with the server. |
| + |
| ## Traps |
| - **Never call `sessions.UserID`.** Under rastrillo/auth the Subject is |
| @@ -149,19 +301,34 @@ never reaches the log, is never snapshotted, and is gone with the process. |
| (`clippy.toml`) — both draw entropy this wasm build has no safe source |
| for. Use `Options::with_guid_and_client_id`. A doc comment alone was got |
| around more than once, which is why it is a lint. |
| +- **The committed editor bundle is not rebuilt for you either.** Change a |
| + `.mjs` under `fixtures/` and `internal/slides/static/editor.<hash>.js` |
| + is stale until you run `node fixtures/build-bundles.mjs` and commit what |
| + it writes. Nothing builds it at run time and CI does not build it for |
| + you — the gate rebuilds and diffs, which is a check, not a fix. |
| +- **A stale vector passes.** `vectors_test.go` compares the core against |
| + whatever is committed under `fixtures/vectors`, so a vector nobody |
| + regenerated makes it compare the core against a JS implementation that |
| + no longer exists. Run `node fixtures/generate.mjs` in the same commit as |
| + any op change. |
| - **Exactly one directory under `internal/`.** `rastrillo migration check` |
| requires it, which is why every package is nested under |
| - `internal/slides/` (`core`, `deck`, `render`, `present`) rather than |
| - sitting beside it. |
| + `internal/slides/` (`core`, `deck`, `render`, `present`, `live`) rather |
| + than sitting beside it. |
| - **A hardcoded English string is a bug.** Every user-visible string is a |
| key in `internal/slides/locales/en.toml`, resolved through `rastrillo.T` |
| at render time. Twelve locales are the suite target; this slice ships |
| `en` and leaves the keys ready. Deck titles and timestamps are data, not |
| copy, and are correctly not keys. |
| -- **Every screen works with scripts off** (spec §8) — including the |
| - remote, where each button is a form that POSTs and redirects back. |
| - JavaScript is an enhancement on the presenting surfaces and nothing |
| - else depends on it. |
| +- **Every screen but the editor works with scripts off** (spec §8) — |
| + including the remote, where each button is a form that POSTs and |
| + redirects back. The editor is the one stated exception, and it says so |
| + in a `<noscript>` that links the viewer. Everywhere else JavaScript is |
| + an enhancement and nothing depends on it. |
| +- **Every user-facing string the bundle shows is still a catalog key.** |
| + It reaches the browser as a `data-text-*` attribute the template |
| + renders from `en.toml`; an English literal in a `.mjs` file is the same |
| + bug it would be in Go. |
| - **`cargo fmt --check` covers `core/`.** It was not in the gate until |
| 2026-09-07 and three files had drifted by then; they were reformatted in |
| the same commit that added the check. |
| @@ -216,11 +383,18 @@ to the amadan remote, and it is the same script you run locally: |
| ``` |
| It does rustfmt, clippy with the disallowed-methods lint, the wasm build, |
| -the `cmp` against the committed artifact, the Rust tests, then `make ci` |
| -(vet, gofmt, Go tests, migration check). `make ci` alone is the fast loop |
| -between edits; the full gate is what you run before pushing. See |
| -`.amadan/README.md` for what it needs installed and for the runner's own |
| -two traps. |
| +the `cmp` against the committed artifact, the Rust tests; then the JS half |
| +— `npm ci`, `node --test`, the vectors regenerated and the bundle rebuilt |
| +and both diffed against what is committed; then `make ci` (vet, gofmt, Go |
| +tests, migration check). `make ci` alone is the fast loop between edits; |
| +the full gate is what you run before pushing. |
| + |
| +Since slice 2 it needs Node 20+ and `npm`, and `npm ci` wants the registry |
| +reachable while it runs — the one part of this gate that is not offline |
| +once its caches are warm. `.amadan/README.md` says why that trade is worth |
| +making, what each step catches, and the runner's own two traps. In a |
| +sandbox, point `NPM_CACHE` somewhere writable or npm fails on its cache |
| +rather than on the install. |
| ## Deploying |
diff --git a/PRODUCT.md b/PRODUCT.md| index 733cbf3..35fd4c0 100644 |
| --- a/PRODUCT.md |
| +++ b/PRODUCT.md |
| @@ -4,10 +4,12 @@ |
| > **How this file was written.** Impeccable's `init` normally interviews |
| > the user before writing anything here. This one was written from |
| -> repository evidence instead, during slice 1: the design |
| +> repository evidence instead, during slice 1 and brought up to date at |
| +> the end of slice 2: the design |
| > (`docs/superpowers/specs/2026-09-07-slides-design.md`, revision 1), the |
| -> plan (`docs/superpowers/plans/2026-09-07-slice-1-live.md`), `AGENTS.md`, |
| -> and the shipped code in `internal/slides/`. Facts traceable to a spec |
| +> plans (`docs/superpowers/plans/2026-09-07-slice-1-live.md` and |
| +> `2026-09-07-slice-2-editor.md`), `AGENTS.md`, and the shipped code in |
| +> `internal/slides/` and `fixtures/`. Facts traceable to a spec |
| > ruling or to code are cited. Anything that is a reading rather than a |
| > citation is marked **[inferred — confirm]** and should be corrected |
| > rather than trusted. |
| @@ -25,8 +27,10 @@ Sharing is therefore membership plus a link capability, not a permission |
| matrix. |
| **Secondary: an agent, driving a deck on a person's behalf.** Not a |
| -metaphor and not a later feature — in this slice the agent surface is the |
| -only way to build a deck at all. An agent is its own principal, |
| +metaphor and not a later feature — it was the only way to build a deck |
| +before the editor existed, and it is still a peer of it: an agent's ops |
| +and a person's keystrokes land in one document, in one order. An agent is |
| +its own principal, |
| `<name>~<operator email>`, minted by a signed-in person and named in every |
| write it makes (spec §1 ruling 7, §7.1). It never borrows a person's |
| session, and the API never reads a session cookie. |
| @@ -65,8 +69,10 @@ rebuilding its foundations: |
| placements of the same blocks, and the phone layer is entries in the |
| same tree (§2.3, §2.5). `internal/slides/render` is the only code that |
| turns a slide into HTML, and it serves the viewer, the presenter view, |
| - the remote and — when it exists — the editor's stage (§4.1). A product |
| - with a mobile view bolted on cannot make that claim afterwards. |
| + the remote, and the editor's stage and thumbnails (§4.1) — the browser |
| + fetches that HTML rather than drawing its own. A product with a mobile |
| + view bolted on cannot make that claim afterwards, and neither can one |
| + whose editor renders slides a second time in JavaScript. |
| 2. **An agent is a first-class author, badged as itself.** Every write |
| records `person:<id>` or `agent:<id>` (`DeckUpdate.Actor`, |
| `internal/slides/models.go`; spec §6.2), and the agent's writes are |
| @@ -80,11 +86,13 @@ rebuilding its foundations: |
| where presenting is just a viewer with no session, which is the |
| rejected alternative recorded in §1 ruling 5. |
| -**What is not shipped and must not be described as though it were:** the |
| -collaborative editor, presence, PDF export, images. Slice 1 has no browser |
| -editor; a deck is built through the drive API (`docs/api.md`) or seeded as |
| -the sample deck (`internal/slides/sample.go`). Spec §10 puts the editor in |
| -slice 2 and export in slice 3. |
| +**What is not shipped and must not be described as though it were:** PDF |
| +export and the SVG layout engine, comments, version history, charts from |
| +Sheets, video and embeds, an MCP adapter, aspect ratios other than 16:9. |
| +Spec §10 puts export in slice 3; §9 gives each of the rest a trigger |
| +rather than a date. And **the first deployment has no object store |
| +bound**, so image upload — which is built, and works against a bucket or |
| +a directory — answers `503` there and says so on the page. |
| ## Operating Context |
| @@ -98,20 +106,29 @@ slice 2 and export in slice 3. |
| adopted rather than reinvented. |
| - **Routes shipped today** (`internal/slides/app.go`): sign-in and the |
| deck list, `/decks/new`, `/d/{sqid}` and its notes, settings, share and |
| - delete pages; `/s/{token}` for a share link; `/p/{session}` and its |
| - notes, stream, remote and command; `/r/{code}` to pair a phone. The |
| - share, present and remote routes sit outside the session group on |
| - purpose — the projector, the phone and the audience each carry their own |
| + delete pages; the editor at `/d/{sqid}/edit` with its slide HTML twin, |
| + its update log, stream and presence, and its upload, register and asset |
| + routes; `/s/{token}` for a share link; `/p/{session}` and its notes, |
| + stream, remote and command; `/r/{code}` to pair a phone. The share, |
| + present and remote routes sit outside the session group on purpose — |
| + the projector, the phone and the audience each carry their own |
| capability and have no session at all. |
| +- **The browser holds a Yjs document too.** The editor's bundle is |
| + committed, hashed, and built by hand from `fixtures/` |
| + (`node build-bundles.mjs`); the app builds nothing at run time and CI |
| + builds nothing for it. `fixtures/` is also a second implementation of |
| + the operation vocabulary, held to the Rust one by generated vectors |
| + (spec §2.8) — an operation changes on both sides or on neither. |
| - **Local development** is `./scripts/run-dev.sh`: `localhost:8091`, a |
| scratch SQLite database that starts empty every run, magic links logged |
| to the terminal rather than mailed. |
| - **The scriptless floor is a product requirement, not a fallback** (spec |
| §8). Signing in, listing, creating, viewing, presenting, reading notes, |
| managing sharing and using the remote all work with JavaScript off; each |
| - remote button is a form that POSTs and redirects. Editing slide content |
| - is the one surface that will need a bundle, and that boundary is stated |
| - rather than discovered. |
| + remote button is a form that POSTs and redirects. The editor is the one |
| + stated exception, and it says so in a `<noscript>` that links the |
| + viewer — a boundary declared in the spec before it was built, not |
| + discovered afterwards. |
| - **Work happens on a worktree branch and reaches `main` only by squash |
| merge**, after review and with the gate green. The review record is an |
| amadan branch discussion, not a GitHub PR — this repo is hosted on an |
| @@ -128,11 +145,22 @@ viewer and the notes page; read-only share links, hashed and revocable; |
| presentation sessions with the fullscreen surface, presenter view, phone |
| remote and audience follow; the drive API for creating and driving a deck. |
| -**Deliberately not built yet** (spec §9, §10): the browser editor, live |
| -collaboration and presence, PDF export and the SVG layout engine, images |
| -and the object store, charts from Sheets, video and embeds, an MCP |
| -adapter, 4:3 and custom aspect ratios, comments, and version history. Each |
| -has a named trigger in §9 rather than a date. |
| +**Shipped in slice 2:** the browser editor at `/d/{sqid}/edit` — slide |
| +strip, stage, inspector for the deck, the slide and the selected block, |
| +text edited in place, canvas drag and resize with snapping, the |
| +desktop/phone switcher whose phone-view edits become overrides rather than |
| +changes everyone sees, undo scoped to the person who made the change, and |
| +presence. Under it: the live-sync wire (an update log served by cursor, an |
| +SSE stream of advisory seqs and whole presence sets, periodic snapshots so |
| +opening a long-lived deck is not a full replay), image upload |
| +browser-to-bucket with SVG sanitising and a deck-scoped check on every |
| +byte served, and the JS half of the operation vocabulary with the vectors |
| +that hold it to the Rust one. |
| + |
| +**Deliberately not built yet** (spec §9, §10): PDF export and the SVG |
| +layout engine, charts from Sheets, video and embeds, an MCP adapter, 4:3 |
| +and custom aspect ratios, comments, and version history. Each has a named |
| +trigger in §9 rather than a date. |
| **Binding technical constraints:** |
| @@ -148,10 +176,25 @@ has a named trigger in §9 rather than a date. |
| - **Only the SHA-256 of a share link is stored**, so the URL is shown |
| exactly once and the page says so at that moment (`share.go`, §5). |
| - **Every user-visible string is a catalog key** resolved through |
| - `rastrillo.T` (`internal/slides/locales/en.toml`, 143 keys today). |
| + `rastrillo.T` (`internal/slides/locales/en.toml`, 318 keys today). |
| Twelve base locales are the suite target; one, `en`, exists. A |
| - hardcoded English string is a bug. Deck titles and timestamps are data, |
| + hardcoded English string is a bug — including in the editor's |
| + JavaScript, which reads its strings off `data-text-*` attributes the |
| + template renders from the catalog. Deck titles and timestamps are data, |
| not authored copy, and are correctly not keys. |
| +- **The document is the log, and presence is not in it.** Every write is |
| + a row with an actor; a snapshot is a shortcut and never the truth. |
| + Presence lives in memory, reaches no log, is in no snapshot, and is |
| + gone when the process restarts (spec §3.3). |
| +- **The browser is not a second renderer.** The editor's stage shows the |
| + server's HTML for the current slide and rebinds after a structural |
| + change; building slide markup in JavaScript would recreate exactly the |
| + divergence §0.1 exists to prevent. |
| +- **`href` is stored as the one-key map `{"href": url}` and the marks are |
| + named `b`, `i`, `c`, `h`, `href`** (§1 ruling 12, §2.6). Forced by |
| + y-prosemirror, which has no hook to map mark names to attribute names |
| + and deletes a text node when a read throws — so the wrong shape loses |
| + the words, not just the link. |
| - **Markup is the bare `rst-*` attribute grammar**, never `class="rst-*"`; |
| this app's own attributes are `slides-*`. |
| - **Themes are a built-in set of token bundles**, six of them (`paper`, |
| @@ -197,9 +240,12 @@ has a named trigger in §9 rather than a date. |
| 1, drafted 2026-09-07 from a brainstorm with Paul the same day. It is |
| **unreviewed**: §12 records that the review pass is the first item after |
| slice 1 ships, so the reviewer has a running instance rather than a |
| - document. Treat its rulings as settled and its prose as unaudited. |
| -- **Plan:** `docs/superpowers/plans/2026-09-07-slice-1-live.md`, eight |
| - tasks, this slice only. |
| + document. Treat its rulings as settled and its prose as unaudited. One |
| + amendment has landed since: ruling 12 and the rewritten §2.6, added |
| + during slice 2 when the fixtures proved the old `href` shape did not |
| + survive y-prosemirror. |
| +- **Plans:** `docs/superpowers/plans/2026-09-07-slice-1-live.md` (eight |
| + tasks) and `2026-09-07-slice-2-editor.md` (seven), one slice each. |
| - **Consumed rulings:** the suite sharing doctrine (`oficina/suite`, |
| revision 8) by number — 1, 4, 5, 6, 9, 12, 15 — never restated here |
| (§0.2). Docs' live-sync design is adopted as written for slice 2 (§3.3). |
| @@ -208,20 +254,33 @@ has a named trigger in §9 rather than a date. |
| - **Sample content:** `internal/slides/sample.go` seeds one deck on first |
| sign-in, covering each flow template, a canvas slide and a phone-reflow |
| slide — which is also the fastest way to see what the renderer does. |
| +- **The contract between the two implementations:** `fixtures/data/*.json` |
| + are operation batches, `fixtures/vectors/` is what the JS side makes of |
| + them, and `internal/slides/core/vectors_test.go` requires the Rust core |
| + to agree. Five vectors between them exercise every operation in §7.2. |
| - **The gate:** `.amadan/ci.d/10-gate`, run by CI and by hand, and |
| - `.amadan/README.md` for what it needs. |
| + `.amadan/README.md` for what it needs. Since slice 2 it also runs the JS |
| + toolchain — `npm ci`, `node --test`, the vectors regenerated and the |
| + bundle rebuilt, both diffed against what is committed — which is what |
| + makes the two claims above checkable rather than remembered. |
| +- **The browser drive:** `internal/slidestest/browser_test.go` walks the |
| + editor in a real Chromium and fails on any console error, failed request |
| + or JS value that reached the screen. It is deliberately outside the |
| + gate: this project's sandbox will not start a Chromium, and a gate that |
| + ran it would report "could not see" as green. |
| ## Product Principles |
| 1. **State the degradation rather than let it be discovered.** The spec |
| refuses to claim pixel identity between the browser and the PDF and |
| - says where they may differ (§0.3, §4.2); the README says in its second |
| - paragraph that there is no editor yet; the share page says the URL will |
| - not be shown again at the moment it is shown. |
| + says where they may differ (§0.3, §4.2); the share page says the URL |
| + will not be shown again at the moment it is shown; the editor's image |
| + field, on an instance with no object store, says so on the page instead |
| + of waiting for somebody to choose a file and hit a 503. |
| 2. **One tree, one renderer.** Any second representation of a deck — a |
| - mobile layout, a print document, a snapshot — is a design mistake |
| - before it is a maintenance cost, because the two will diverge and the |
| - divergence will reach a reader. |
| + mobile layout, a print document, an editor's own idea of a slide — is |
| + a design mistake before it is a maintenance cost, because the two will |
| + diverge and the divergence will reach a reader. |
| 3. **A capability is a capability, and nothing more.** A share link never |
| edits (suite ruling 15). An edit link does not exist, because an edit |
| capability is a sign-in that mints a guest grant, and that waits on the |
| @@ -245,8 +304,15 @@ requirements bind concrete decisions here: |
| sized for — it is a control someone uses in the dark, one-handed, while |
| talking. |
| - **320px reflow.** The one allowance is estilo's: the canvas stage may |
| - scroll inside its own region in the editor, when the editor exists (§8). |
| - Nothing in the shipped viewer uses it. |
| + scroll inside its own region in the editor (§8). Nothing in the viewer |
| + uses it. Below 1100px the editor's three panes stack — the strip |
| + becomes a horizontal filmstrip and the inspector a `<details>` at the |
| + foot — rather than scrolling the page sideways. |
| +- **Canvas resize handles are pointer-only, and the keyboard has a real |
| + equivalent** rather than a smaller version of the same thing: the |
| + selected block's overlay is focusable and named, arrows nudge, Shift |
| + nudges further, Alt resizes, and the inspector's Left/Top/Width/Height |
| + fields do it by number. |
| - **Colour never carries meaning alone**, so a deck's sharing rung shows |
| a text label beside its tone and never a bare colour. |
| - **`alt` on every image.** The model makes it structural rather than |
diff --git a/README.md b/README.md| index 1be37e9..8fb2969 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -6,15 +6,32 @@ it as a read-only link, present it fullscreen with speaker notes, and drive |
| the talk from your phone while the room follows along. One instance per |
| team, on the CARLOS platform. |
| -**This is slice 1, and there is no browser editor in it.** You build a deck |
| -through the drive API — every screen and every operation is in |
| -[`docs/api.md`](docs/api.md) — or you start from the sample deck that gets |
| -seeded the first time you sign in. Everything downstream of the deck works: |
| -the paged viewer on both viewports, share links, presenting with the |
| -presenter view and a phone remote, and audience follow. PDF export, images |
| -and editing in the browser are the next two slices. Every screen works with |
| -JavaScript switched off, and every string a person can read is a catalog |
| -key. |
| +You edit a deck in the browser. Press **Edit** on a deck and |
| +`/d/{sqid}/edit` gives you a slide strip down the side, the stage in the |
| +middle, and an inspector for the deck's theme, the slide's layout and |
| +whichever block you have selected. You type into the slide itself. Canvas |
| +blocks drag and resize, snapping to the thirds and to each other. A |
| +switcher flips the stage between desktop and phone widths, and what you |
| +change in phone view becomes an override for phones rather than a change |
| +everyone sees. Several people can be in a deck at once: you see where they |
| +are, and undo only ever takes back your own work. Pictures upload straight |
| +to the object store when one is bound. |
| + |
| +The stage is not a second renderer. It shows the HTML the viewer would |
| +serve for that slide, fetched from the server, with the text blocks bound |
| +for editing in place — which is what keeps a deck the same deck wherever |
| +it is seen. |
| + |
| +The editor cannot export a PDF, hold a comment, or show you an older |
| +version. And the first deployment has no object store bound, so uploads |
| +there answer `503` and the image field says so on the page. |
| + |
| +A deck can also be built entirely by a program. The drive API in |
| +[`docs/api.md`](docs/api.md) has every endpoint and every operation, and an |
| +agent is a principal of its own with its own token — its writes are badged |
| +as the agent's, never laundered into a person's name. Every screen but the |
| +editor works with JavaScript switched off, and every string a person can |
| +read is a catalog key. |
| Sign-in is a magic link, and the instance only mails one to an address in |
| `SLIDES_ALLOWED_ADDRESSES`. |
| @@ -43,17 +60,58 @@ with the `Content-Type` header. Without it uploads fail in the browser |
| with nothing in this app's log — the request never reaches it. |
| [`docs/api.md`](docs/api.md) has the details. |
| +## The editor's bundle |
| + |
| +The editor is the one screen that needs JavaScript. Its bundle is built by |
| +hand and committed: |
| + |
| +```sh |
| +cd fixtures && npm ci && node build-bundles.mjs |
| +``` |
| + |
| +That writes `internal/slides/static/editor.<hash>.js`, deletes the |
| +previous hashed file, and the Go binary embeds whatever it finds there. |
| +Nothing builds it at run time and CI does not build it for you, so the |
| +moment you change a `.mjs` under `fixtures/` the committed bundle is out |
| +of date until you rebuild and commit it. The gate rebuilds and diffs, so |
| +you find out before the runner does. |
| + |
| +## The fixtures rule |
| + |
| +`fixtures/` also holds a second implementation of the deck's operations, |
| +in JavaScript, because the browser has to write the same document the Rust |
| +core reads. `fixtures/data/*.json` are op batches; `node generate.mjs` |
| +runs each one through the JS ops and writes the resulting Yjs update and |
| +the JS view of it into `fixtures/vectors/`. |
| +`internal/slides/core/vectors_test.go` loads those same bytes through the |
| +core and requires the same view. |
| + |
| +So change an op and you change both sides, or neither: |
| + |
| +```sh |
| +node fixtures/generate.mjs # then commit what moves |
| +``` |
| + |
| +Never edit a vector to make a test pass. A vector that was quietly |
| +adjusted leaves the Go test comparing the core against a JS implementation |
| +that no longer exists — it passes, and the two sides have already drifted. |
| + |
| ## The gate |
| ```sh |
| ./.amadan/ci.d/10-gate |
| ``` |
| -One script, run by CI and by you — rustfmt, clippy, the wasm build and a |
| -`cmp` against the committed artifact, the Rust tests, then `make ci` (vet, |
| -gofmt, Go tests, migration check). `make ci` on its own is the fast loop |
| -while you work; run the whole gate before you push. |
| -[`.amadan/README.md`](.amadan/README.md) says what it needs installed. |
| +One script, run by CI and by you: rustfmt, clippy, the wasm build and a |
| +`cmp` against the committed artifact, the Rust tests, then the JS half |
| +(`npm ci`, `node --test`, the vectors regenerated and the bundle rebuilt, |
| +both diffed against what is committed), then `make ci` (vet, gofmt, Go |
| +tests, migration check). `make ci` on its own is the fast loop while you |
| +work; run the whole gate before you push. |
| + |
| +It needs Node 20 or newer with `npm`, and `npm ci` wants the registry |
| +reachable while it runs. [`.amadan/README.md`](.amadan/README.md) says why |
| +that trade is worth making and what each step catches. |
| ## The browser drive |
| @@ -72,7 +130,8 @@ It is not part of the plain suite or of the gate. |
| ## Where the reasoning lives |
| -The design is `docs/superpowers/specs/2026-09-07-slides-design.md` and the |
| -plan for this slice is `docs/superpowers/plans/2026-09-07-slice-1-live.md`. |
| -`AGENTS.md` is the working file: the seams, the traps, how to deploy. Read |
| -it before you change anything here. |
| +The design is `docs/superpowers/specs/2026-09-07-slides-design.md`. The |
| +plans are `docs/superpowers/plans/2026-09-07-slice-1-live.md` (sign-in, |
| +viewer, sharing, presenting) and `2026-09-07-slice-2-editor.md` (the |
| +editor and collaboration). `AGENTS.md` is the working file: the seams, the |
| +traps, how to deploy. Read it before you change anything here. |
diff --git a/docs/api.md b/docs/api.md| index 6c2e4df..9e7fecd 100644 |
| --- a/docs/api.md |
| +++ b/docs/api.md |
| @@ -5,6 +5,13 @@ made, and put it on a projector. A person's browser never touches it — |
| the screens are a separate thing, and neither one accepts the other's |
| credential. |
| +They do write the same document, though. Since slice 2 there is a |
| +browser editor at `/d/{sqid}/edit`, and an op you post here and a |
| +keystroke somebody types there land in one Yjs document as one ordered |
| +log. Your `seq` moves when they type; theirs moves when you post. If you |
| +want to know how a person's tab keeps up, "The live-sync wire" below |
| +describes what the editor speaks. |
| + |
| Everything below assumes the instance is at `https://slides.example`. |
| Replace it with your own. |
| @@ -485,6 +492,110 @@ PUT is same-origin. |
| --- |
| +## The live-sync wire |
| + |
| +Four more routes on the signed-in surface, which is what the browser |
| +editor is built on. You do not need them — `/api/v1/decks/{sqid}/ops` |
| +is the supported way for a program to write, and polling the deck with |
| +`If-None-Match` is the supported way to watch. They are documented |
| +because they are how the same deck is edited by a person while your |
| +agent is holding it, and because the framing is easy to get subtly |
| +wrong if you ever do speak it. They take a session cookie, not a bearer, |
| +and the two POSTs among them sit behind the same origin check every |
| +other screen does — see "A note on CSRF" at the foot of this file. |
| + |
| +The document underneath is a Yjs document. `POST /ops` is a batch of |
| +operations the server turns into one Yjs update; these routes carry |
| +Yjs updates directly. Both end up as rows in the same log, in one |
| +order, each stamped with the actor that wrote it. |
| + |
| +### `GET /d/{sqid}/updates?since=N` — the bytes after seq N |
| + |
| +`application/octet-stream`. Every update after seq `N`, in seq order, |
| +each one framed as a **4-byte big-endian length followed by that many |
| +bytes**. There is no other structure — no header, no trailer, no count. |
| + |
| +`Slides-Seq` on the response is the highest seq in the body, or `N` |
| +itself when the body is empty. Take your cursor from that header and |
| +never from a separate query: it is derived from the bytes you were |
| +actually handed, so a cursor read from it can never be ahead of what |
| +you received. |
| + |
| +At `since=0` the answer may begin from a periodic snapshot rather than |
| +from the first update ever written, in which case `Slides-Snapshot: |
| +<seq>` says which seq that first frame brings you to. A snapshot is |
| +derived data — it is a shortcut, never the truth — so a deck opens |
| +identically with it and without it. |
| + |
| +A `since` that is missing, negative or not a number is a `400`. |
| + |
| +### `POST /d/{sqid}/updates` — one update |
| + |
| +The body is one Yjs v1 update, 1 MiB at most. The answer is `204` with |
| +`Slides-Seq` for the row it wrote. Then it is broadcast, so every other |
| +open tab learns about it. |
| + |
| +A `422` here means the core read your bytes and refused them, which is |
| +an ordinary answer rather than a server fault: these arrive from |
| +browsers. A `413` is the size cap. A `409` means two writers landed at |
| +once, twice — reread and decide what your update now means. |
| + |
| +### `GET /d/{sqid}/stream` — server-sent events |
| + |
| +``` |
| +event: update |
| +data: 41 |
| + |
| +event: presence |
| +data: [{"client":"…","name":"Ana","colour":"#6b4","slide":"…","block":"…"}] |
| + |
| +: ping |
| +``` |
| + |
| +One of each on connect, a `: ping` every 15 seconds, and the connection |
| +closes after five minutes for you to reopen. |
| + |
| +**`update` carries a seq and no bytes.** It is advisory — "there is |
| +something at least this new" — and the bytes come from `GET /updates`. |
| +`presence` carries the whole set every time, never a delta, so a |
| +subscriber that falls behind is dropped and reconnects rather than |
| +having events silently skipped. |
| + |
| +### `POST /d/{sqid}/presence` — where you are |
| + |
| +```json |
| +{"client": "9f3c…", "slide": "hq3ktna7vc2m", "block": "blkheading22"} |
| +``` |
| + |
| +`client` identifies the tab, not the person, and is required. Post it |
| +every 20 seconds and whenever the person moves; two missed heartbeats |
| +and the server drops you from the set. |
| + |
| +The name and the colour are **stamped by the server** from your user |
| +row. Sending your own does nothing — otherwise presence would be a |
| +place to write somebody else's name onto somebody else's screen. |
| +Presence is never document state: it lives in memory, reaches no log, |
| +is in no snapshot, and is gone when the process restarts. |
| + |
| +### One note if you write Yjs updates by hand |
| + |
| +Text is an `XmlFragment` of `<paragraph>` elements whose formatting |
| +attributes are the marks, and the marks are named after the attributes: |
| +`b`, `i`, `c`, `h`, `href`. **`href` is stored as the one-key map |
| +`{"href": "https://…"}`, not as a plain string** (spec §1 ruling 12). |
| +That shape is y-prosemirror's, and it is not cosmetic: a plain string |
| +throws inside its reader, and it deletes the text node when a read |
| +throws — so the wrong shape loses the words, not just the link. |
| + |
| +Readers on both sides are tolerant where writers are exact. `b`, `i`, |
| +`c` and `h` are written `true` and read as set on any truthy value; an |
| +`href` is read as either the map or a plain string. The deck's JSON |
| +view — what `GET /api/v1/decks/{sqid}` hands you — flattens all of that |
| +into runs with a plain `href` string, so none of this reaches you |
| +unless you are writing updates yourself. |
| + |
| +--- |
| + |
| ## A worked example |
| Build a two-slide deck from nothing: |
diff --git a/fixtures/package.json b/fixtures/package.json| index 2c5defa..571c08d 100644 |
| --- a/fixtures/package.json |
| +++ b/fixtures/package.json |
| @@ -4,7 +4,7 @@ |
| "type": "module", |
| "description": "The JS side of the Slides deck contract: the op vocabulary over a Y.Doc (ops.mjs), the constrained ProseMirror schema (schema.mjs), and the generator of the vectors the Rust core must reproduce (generate.mjs writes vectors/, internal/slides/core/vectors_test.go checks them).", |
| "scripts": { |
| - "test": "node --test", |
| + "test": "node --test *.test.mjs", |
| "generate": "node generate.mjs" |
| }, |
| "dependencies": { |