oficina / slides Public

feat: image upload and assets

Bytes go browser-to-bucket by presigned PUT and come back through the
deck's own capability. Three endpoints: sign an upload, verify what
landed, serve it. rastrillo's blobs are content-addressed, so the ref IS
the sha256 — the client computes it before signing (a presigned PUT has
to name its key), and the server re-reads and re-hashes what arrived
before the deck ever refers to it.

Registration does not write the Y.Doc. It answers with a validated
record and the client applies register_asset with it, so an upload
reaches the document through the same op path, the same update log and
the same undo step as every other change.

An SVG is sanitised on the way in: encoding/xml proves it is a
well-formed <svg> with no doctype, then a raw rewrite drops <script>,
<foreignObject>, on* handlers and javascript: hrefs. Anything still
pointing outside the document is refused rather than half-cleaned. A
sanitised SVG has different bytes, so it comes back at a different
address; the record says which.

Serving is keyed by the deck and reachable from every surface that
renders a slide — /d/{sqid}/assets, /s/{token}/assets, /p/{session}/assets
— and each checks the ref against THAT deck's assets map first. A ref is
a content address, so without the check any capability for any deck
would fetch any object in the bucket. render.SlideWith takes the base;
the asset id never appears in a URL.

With no object store bound the endpoints answer 503 and the editor says
so with its file input disabled, server-rendered, rather than finding
out after somebody has chosen a file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 15331948c0ac70d12b27738b0a4aac5d872a91f3 parent b7f3d61
33 files changed, +3358 −92
  • README.md +13 −0
  • docs/api.md +110 −4
  • fixtures/assets.test.mjs +119 −0
  • fixtures/editor-entry.js +59 −1
  • fixtures/inspector-image.mjs +188 −0
  • fixtures/inspector-ops.mjs +64 −0
  • fixtures/inspector.mjs +24 −4
  • fixtures/uploads.mjs +197 −0
  • fixtures/uploads.test.mjs +155 −0
  • internal/slides/api.go +6 −1
  • internal/slides/app.go +15 −0
  • internal/slides/assets.go +623 −0
  • internal/slides/editor.go +24 −6
  • internal/slides/locales/en.toml +36 −1
  • internal/slides/presentation.go +7 −3
  • internal/slides/render/assets_test.go +101 −0
  • internal/slides/render/render.go +85 −16
  • internal/slides/render/render_test.go +7 −2
  • internal/slides/render/testdata/blank-desktop.golden.html +2 −2
  • internal/slides/render/testdata/blank-phone.golden.html +2 −2
  • internal/slides/render/testdata/image-full-desktop.golden.html +2 −2
  • internal/slides/render/testdata/image-full-phone.golden.html +2 −2
  • internal/slides/share.go +44 −28
  • internal/slides/static/editor.626eebe67341d5d8.js +0 −16
  • internal/slides/static/editor.css +48 −0
  • internal/slides/static/editor.d193d83bc4c2adaa.js +16 −0
  • internal/slides/svg.go +525 −0
  • internal/slides/svg_test.go +105 −0
  • internal/slides/templates/editor.html +38 −1
  • internal/slides/viewer.go +6 −1
  • internal/slidestest/assets_test.go +545 −0
  • internal/slidestest/browser_assets_test.go +172 −0
  • internal/slidestest/browser_test.go +18 −0

CI passed — run details

Intent

Human

<task-notification> <task-id>a324ff6c99c5c1ea9</task-id> <tool-use-id>toolu_01QPcyeZbDJdHATuVzMLhYhi</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a324ff6c99c5c1ea9.output</output-file> <status>completed</status> <summary>Agent "Finish editor fix round, then presence" 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: Both parts complete and committed on `slice-2-editor`. Commits: - `cdea342` fix: editor review round 1 — hidden reset, resync destroy, blocks list reconcile, meta gating - `b7f3d61` feat: presence Tests: `node --test *.test.mjs` 112/112 green; bundle rebuilt twice reproducibly (`editor.626eebe67341d5d8.js`, exactly one committed); `make ci` green; `go test -tags browser ./internal/slidestest/...` **ran** (sandbox blocked Chromium's socket on first try as anticipated, ran again with sandbox off) — all green including new `TestBrowserPresenceShowsEachOther` (two real tabs, mutual avatars, aligned block outline+chip, announcement). Concerns: phone-override-on-stage-block badge (Task 4 minor) not implemented — flagged rather than risked against canvas positioning; nudge coalescing added but has no dedicated test (no harness exists yet for `mountCanvas`'s interactive path); both presence-drive tabs are the same signed-in user (harness only allows one address), so name/colour distinctness between people is covered server-side by `TestPresencePostReachesTheStream`, not visually in the browser drive. Report: `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-07-slice-2-editor/task-5-report.md`</result> <usage><subagent_tokens>284044</subagent_tokens><tool_uses>166</tool_uses><duration_ms>1311771</duration_ms></usage> </task-notification>

Human

continue

Human

<task-notification> <task-id>a8739deea858943c0</task-id> <tool-use-id>toolu_01WNAeD5YcfHAvnNwnd5LL6Q</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a8739deea858943c0.output</output-file> <status>completed</status> <summary>Agent "Review editor fixes + presence" 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>## Review: Task 4 fix round (`cdea342`) + Task 5 presence (`b7f3d61`) **Base:** a863fb6 **Head:** b7f3d61 (current HEAD; working tree clean, matches head — verified before relying on any live-file reads) ### Fix round verdicts | Finding | Verdict | File:line | Covering test | |---|---|---|---| | CRITICAL: `[hidden]` reset | **ADDRESSED** | `internal/slides/static/editor.css:33-35` (`[slides-editor] [hidden] { display: none !important; }`) | `TestBrowserInspectorAndCanvas` (`internal/slidestest/browser_test.go:480-485`, `checkVisibility()===false` on block form and confirm), `TestBrowserDeleteToEmptyThenNewSlide` (:643,649) | | `mountStrip`/`mountSelection`/`mountInspector` `destroy()` called by `attemptResync`; no duplicated listeners | **ADDRESSED** | All three use `AbortController` (`fixtures/strip.mjs:148`, `fixtures/selection.mjs:122`, `fixtures/inspector.mjs:201`); `attemptResync` destroys all six mounts (stage/phoneStage/canvas/strip/selection/inspector) before remounting (`fixtures/editor-entry.js:397-404`) | `strip.test.mjs:18`, `selection.test.mjs:22`, `inspector.test.mjs` (AbortController-based) — node tests pass | | `fillBlocksList` reconciles by id; focus survives | **ADDRESSED** | `fixtures/inspector.mjs:42-78` (`fillBlocks`, `have`/`want` Map diff, same elements moved not replaced, text/attr written only when changed) | `inspector.test.mjs:205` "the Blocks list reconciles by id and keeps its buttons" — passes | | `commitUpdate` gates meta mirroring instead of parsing whole deck per update inside the transaction | **ADDRESSED** | `internal/slides/live.go:314-364` (`commitUpdate`, no deck read inside `Transaction`); `mirrorMeta` (:389-419) runs after `PublishSeq` outside the transaction, gated by `metaRowChanges` (:431-443, returns nil when nothing differs) | `TestMetaRowChangesWritesOnlyWhatDiffers` (`internal/slides/live_test.go:19`) — ran, green; `TestPostedMetaReachesTheDeckRow` (`internal/slidestest/live_test.go:539`) — ran, green | **Minors, verified against the claims:** - Nudge coalescing (`fixtures/canvas.mjs:277-311`, `NUDGE_COALESCE_MS` timer, `flushNudge()` on `destroy()`) — implemented, correctly untested at the interactive level (report's admission stands). - `setValue` focus guard (`fixtures/inspector-block.mjs:26-39`) — guards SELECT/text/number/url/color while focused. Confirmed. - ETag never set on 404 (`internal/slides/editor.go:306-343`, header set only on 304/200 paths). Confirmed. - Dead copy `slides.editor.canvas.deselect` removed from `en.toml` in `cdea342`. Confirmed. - `[slides-inspector-empty]` shown/hidden correctly (`fixtures/inspector.mjs:141-142`). Confirmed. - `localStorage` accessors wrapped in try/catch at both call sites (`editor-entry.js:346,352,503-508`) and inside `switcher.mjs:66,75`. Confirmed. - **Stage phone-override badge: confirmed NOT done.** Only the pre-existing inspector Block-section badge (`[slides-phone-badge]` in `editor.html:310`) exists; no on-canvas/stage badge was added. The report's admission is accurate. ### Presence: Spec Compliance All named risks checked directly against source, not just the report's claims: - **(a) name/colour server-only:** `deckPresence` (`internal/slides/live.go:588-632`) decodes only `{client, slide, block}` from the body — no `Name`/`Colour` field exists in the struct — and stamps `name`/`colour` from `u.Name`/`u.Email`/`u.Colour`. `presence.mjs`'s `post()` call (`editor-entry.js:154-159`) sends only `client/slide/block`. **Confirmed.** - **(b) aria-live only on arrival/departure:** `presence-view.mjs:182-192` `announce()` builds its message only from `added`/`removed`; `moved` is computed by `diffSets` but never fed to the live region. **Confirmed.** - **(c) outline keyed by block id, survives `refresh()`:** `place()` (`presence-view.mjs:144-163`) re-queries `[slides-block][data-id="..."]` fresh each call; `refresh()` (`stage.mjs:246`) is `() =&gt; load(current)`, and `load` fires `onLoaded`, which calls `presenceView.place()` (`editor-entry.js:231`). **Confirmed.** - **(d) posting stops/resumes on visibility change:** `visibilitychange` handler calls `presence.noteHidden()`/`noteVisible()` (`editor-entry.js:570-578`); mount-time also checks `document.hidden` immediately after `presence.start()` (:493-494). **Confirmed.** - **(e) no English literals in JS:** all presence strings (`slide/block/more/joined/left`) are read from `data-text-presence-*` attributes (`editor.html:50-54`, sourced from `en.toml:224-228`); JS only interpolates via `format()`. **Confirmed.** - **(f) `live-updates.mjs` untouched:** `git diff a863fb6..b7f3d61 -- fixtures/live-updates.mjs` is empty. Presence is wired as a listener directly on the one `EventSource` in `editor-entry.js:464-470` (`es.addEventListener('presence', ...)`), a thin wrapper as specified. **Confirmed.** Other spec points verified: `createPresence({post, clientId, getState})` posts on connect / every 20s (`HEARTBEAT_MS`) / on change with 200ms debounce (`CHANGE_MS`) — `fixtures/presence.mjs:31,36,136-247`. `applySet` diffs excluding self via `toMap(set, mine)` (:88-96). Avatars capped at 3 then "+N" (`MAX_AVATARS = 3`, `presence-view.mjs:30,106-107`), initials in colour with `title`+`aria-label` from the catalog. Name is never colour-only: the block chip always renders `p.name` as text (`presence-view.mjs:136`), not just in a hover tooltip. Nothing touches the Y.Doc (verified — `presence.mjs`/`presence-view.mjs` have no `Y.` references). ### Strengths - The fix round's meta-gating fix is a genuinely good design: gating on a diff (`metaRowChanges`) rather than an unconditional write is both cheaper and correct under the seq-guard race. - Presence's client-side design cleanly separates timing/diffing (`presence.mjs`, pure and fake-clock-testable) from DOM (`presence-view.mjs`), and the server never trusts client-supplied identity — a real security-relevant property, not just spec box-ticking. - `TestBrowserPresenceShowsEachOther` is a strong end-to-end test: two real chromedp tabs, real POSTs, real SSE, pixel-rect alignment check against the actual block, and an explicit WCAG 1.4.1 assertion (non-empty `aria-label`). - Destroy/reconcile fixes are systematic — the same `AbortController` pattern applied uniformly across strip/selection/inspector rather than three different fixes. ### Issues (Critical / Important / Minor) **Critical:** none found. **Important:** none found. Every claim in both reports was checked against the actual diff/source and holds. **Minor:** - Keyboard nudge coalescing (`canvas.mjs`) still has no dedicated unit test for the interactive path — acknowledged by the implementer, correctly flagged as a gap rather than hidden. - The stage-level phone-override badge remains unimplemented (Task 4 review minor, correctly disclosed as not done in the Task 5 report rather than silently dropped). - `mirrorMeta`'s eventual-consistency window (row can lag the document by one update on a failure) is accepted-and-documented, not a defect, but worth keeping in mind for a future audit of the deck list's freshness guarantees. ### Assessment: **Approved** Both commits deliver exactly what their reports claim, with no undisclosed gaps found. The one CRITICAL and three Important items from the Task 4 review are all genuinely fixed with meaningful covering tests (2 focused Go tests run green in this review: `TestPostedMetaReachesTheDeckRow`, `TestMetaRowChangesWritesOnlyWhatDiffers`; full node suite run green: 112/112). Presence matches its brief's interface and all six named risks precisely, including the subtle ones (self-exclusion, move-vs-arrival distinction in the live region, name never colour-alone). The two admitted gaps (nudge test coverage, stage phone badge) are minor and honestly disclosed rather than papered over — that disclosure itself is a point in favor of trusting this implementer's other claims, which independent verification bore out.</result> <usage><subagent_tokens>111996</subagent_tokens><tool_uses>77</tool_uses><duration_ms>220707</duration_ms></usage> </task-notification>

Attributed to this commit: 24.3 g CO₂e (3016018 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.

README.md

Loading diff…

docs/api.md

Loading diff…

fixtures/assets.test.mjs

Loading diff…

fixtures/editor-entry.js

Loading diff…

fixtures/inspector-image.mjs

Loading diff…

fixtures/inspector-ops.mjs

Loading diff…

fixtures/inspector.mjs

Loading diff…

fixtures/uploads.mjs

Loading diff…

fixtures/uploads.test.mjs

Loading diff…

internal/slides/api.go

Loading diff…

internal/slides/app.go

Loading diff…

internal/slides/assets.go

Loading diff…

internal/slides/editor.go

Loading diff…

internal/slides/locales/en.toml

Loading diff…

internal/slides/presentation.go

Loading diff…

internal/slides/render/assets_test.go

Loading diff…

internal/slides/render/render.go

Loading diff…

internal/slides/render/render_test.go

Loading diff…

internal/slides/render/testdata/blank-desktop.golden.html

Loading diff…

internal/slides/render/testdata/blank-phone.golden.html

Loading diff…

internal/slides/render/testdata/image-full-desktop.golden.html

Loading diff…

internal/slides/render/testdata/image-full-phone.golden.html

Loading diff…

internal/slides/share.go

Loading diff…

internal/slides/static/editor.626eebe67341d5d8.js

Loading diff…

internal/slides/static/editor.css

Loading diff…

internal/slides/static/editor.d193d83bc4c2adaa.js

Loading diff…

internal/slides/svg.go

Loading diff…

internal/slides/svg_test.go

Loading diff…

internal/slides/templates/editor.html

Loading diff…

internal/slides/viewer.go

Loading diff…

internal/slidestest/assets_test.go

Loading diff…

internal/slidestest/browser_assets_test.go

Loading diff…

internal/slidestest/browser_test.go

Loading diff…