oficina / slides Public

feat: SVG encoder and thumbnails on every surface; grid gap fix

internal/slides/svg turns a paint list into SVG 1.1, and every surface
that can show a deck can now serve one per slide. The editor strip, the
presenter's notes window and the phone remote all show pictures instead
of live copies of a slide.

- internal/slides/svg: Encode(Paint, Options) -> []byte. Text as one
  <text> per line with a <tspan> per run, each placed at its own x from
  fonts.Advance so a renderer that kerns cannot drift from the paint
  list; links as <a href> for http/https/mailto only; shapes with fill,
  stroke and radius; arrows as a marker per stroke colour; images as
  <image href> from an inlined data URI or the surface's asset URL, with
  preserveAspectRatio and a clipPath from `fit`. A sanitised SVG asset
  is nested as a document unless it carries a <style>, which in SVG is
  document-wide and would repaint the slide around it. Everything is
  escaped and the characters XML 1.0 cannot carry are dropped, so the
  output is always well-formed — every test parses it back.

- internal/slides/thumbs.go: GET {surface}/slides/{id}.svg on all four
  capabilities (session, share link, presentation, bearer). ETag from
  (seq, id, w) with a 304, `private, max-age=0, must-revalidate`, and a
  CSP of `default-src 'none'`. ?w= is read and bounded and is in the tag
  for a future raster twin. Images are inlined as data URIs, because a
  browser fetches nothing at all for an SVG inside an <img>; only refs
  with a verified DeckAsset row are, for serveAsset's reason.

- slides.css: the template grid moves off the section onto a new
  [slides-frame] inside it. `gap: var(--slides-gap)` was a cqw length
  written ON the query container, and an element is never its own
  container, so the browser measured it against whatever was outside the
  slide: 49.92px on a 1920 screen, 20.27px in a 780px window, for the
  same slide. It is now 2.6% of the section's content box everywhere.
  layout/sizes.go's sectionGap follows (43.68), regions.go's numbers
  move with it, and the browser drive pins both widths.

- The strip is one <img> per slide instead of nine scaled sections;
  labels come from the Y.Doc (stage.mjs's slideHeading) because an image
  has no text to read, and flush() re-points a src carrying the deck's
  seq rather than fetching markup.
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 6c757a40756207c1f4ad40345110a4f6058ffb83 parent 0ca08b0
47 files changed, +2413 −200
  • fixtures/editor-entry.js +19 −4
  • fixtures/stage.mjs +24 −1
  • fixtures/strip.mjs +58 −33
  • fixtures/strip.test.mjs +84 −7
  • internal/slides/api.go +9 −0
  • internal/slides/app.go +3 −0
  • internal/slides/editor.go +22 −12
  • internal/slides/layout/paint.go +0 −7
  • internal/slides/layout/regions_test.go +12 −3
  • internal/slides/layout/sizes.go +21 −20
  • internal/slides/layout/testdata/title-body.golden.json +13 −13
  • internal/slides/layout/testdata/title.golden.json +2 −2
  • internal/slides/layout/testdata/two-column.golden.json +11 −11
  • internal/slides/presentation.go +40 −16
  • internal/slides/render/render.go +19 −1
  • internal/slides/render/testdata/blank-desktop.golden.html +2 −0
  • internal/slides/render/testdata/blank-phone.golden.html +2 −0
  • internal/slides/render/testdata/canvas-reflow-phone.golden.html +2 −0
  • internal/slides/render/testdata/image-full-desktop.golden.html +2 −0
  • internal/slides/render/testdata/image-full-phone.golden.html +2 −0
  • internal/slides/render/testdata/quote-desktop.golden.html +2 −0
  • internal/slides/render/testdata/quote-phone.golden.html +2 −0
  • internal/slides/render/testdata/section-desktop.golden.html +2 −0
  • internal/slides/render/testdata/section-phone.golden.html +2 −0
  • internal/slides/render/testdata/title-body-desktop.golden.html +2 −0
  • internal/slides/render/testdata/title-body-phone.golden.html +2 −0
  • internal/slides/render/testdata/title-desktop.golden.html +2 −0
  • internal/slides/render/testdata/title-phone.golden.html +2 −0
  • internal/slides/render/testdata/two-column-desktop.golden.html +2 −0
  • internal/slides/render/testdata/two-column-phone.golden.html +2 −0
  • internal/slides/static/editor.8282c2af4a08f493.js +0 −16
  • internal/slides/static/editor.c5e9b4881d5c8a54.js +16 −0
  • internal/slides/static/editor.css +15 −16
  • internal/slides/static/slides.css +43 −11
  • internal/slides/svg/svg.go +505 −0
  • internal/slides/svg/svg_test.go +573 −0
  • internal/slides/templates/editor.html +9 −7
  • internal/slides/templates/present_notes.html +13 −0
  • internal/slides/templates/remote.html +13 −0
  • internal/slides/thumbs.go +290 −0
  • internal/slides/thumbs_test.go +170 −0
  • internal/slidestest/assets_test.go +12 −2
  • internal/slidestest/browser_layout_test.go +56 −9
  • internal/slidestest/browser_test.go +27 −3
  • internal/slidestest/editor_test.go +13 −4
  • internal/slidestest/present_test.go +8 −2
  • internal/slidestest/thumbs_test.go +283 −0

Intent

Human

<task-notification> <task-id>affd4ed07fe5411a0</task-id> <tool-use-id>toolu_014mKAuQ2v7RV7nKxruzaBqe</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/affd4ed07fe5411a0.output</output-file> <status>completed</status> <summary>Agent "S3 Task 4: PDF via pdfcore" 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 — `pdfcore` branch promoted to production, not pushed. Commit: `0ca08b0 feat: PDF export through the slides_pdf module` (10 files, scoped by path). Tests: Rust 7/7 (`core/pdf`, incl. 2 new ABI edge tests); Go `pdfcore` 9/9 (fresh instance per render, `Rejected` on malformed SVG, deadline → `DeadlineExceeded`, 200 pages in 668 ms); Go `internal/slides` 5 new (sample deck headings recovered as glyph runs via ToUnicode+Tj, notes ≥ 2×slides, 201 refused/200 rendered in 553 ms, images inlined/downscaled/dropped with warnings, box-filter unit test). `make ci` green; gate's new pdf steps (fmt, clippy, build, `cmp`, `cargo test`) run green by hand; full gate not run (`npm ci` needs the registry). Module: 3,672,407 bytes (3.50 MiB), 0 host imports, reproducible (clean rebuild byte-identical to the committed copy). Concerns: (1) one font subset per page, accepted, follow-up noted; (2) the sample deck has 8 slides, not 9 — test asserts pages = `len(d.Slides)`; (3) WebP is dropped with a warning: neither stdlib nor the svg package decodes it — `golang.org/x/image/webp` is the fix, a go.mod decision; (4) `layout.Warning` carries no slide id for overflow warnings; (5) the PDF runtime is a package-level `sync.OnceValues`, not an `app` field (app.go was off-limits); (6) `pdftotext` absent — the test says so in its log; PyMuPDF used by hand to confirm extraction and rasterised pages look right. Report: /home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-08-slice-3-export/task-4-report.md</result> <usage><subagent_tokens>215238</subagent_tokens><tool_uses>65</tool_uses><duration_ms>988061</duration_ms></usage> </task-notification>

Human

<task-notification> <task-id>ae18bdebab41cd4dc</task-id> <tool-use-id>toolu_0169RtXhVBBtJyyJi3SN7EWd</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/ae18bdebab41cd4dc.output</output-file> <status>completed</status> <summary>Agent "Review slice 3 Task 4 (+spike)" 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>## Spec Compliance Reviewed `core/pdf` (via `b04cccb` + this diff's hardening) and `internal/slides/{export.go,pdfcore/}` against plan Global Constraints, spec §4.3, §1 row 13, and both briefs. - **§4.3 / Task 4 brief, part 1 (crate + host):** `internal/slides/pdfcore` matches the controller decision exactly — `New(ctx, opts...)` compiles once, refuses non-zero host imports, checks ABI version once; `Render(ctx, svgs, fonts)` instantiates fresh per call, runs under a 60 s deadline via `WithCloseOnContextDone`, closes unconditionally, wraps a clean module refusal as `RejectedError`/`Rejected()`, and leaves a trap or deadline as an ordinary (non-`Rejected`) error. `scripts/build-pdf-wasm.sh` unchanged from the spike; the gate rebuilds and `cmp`s. **Independently verified**: I rebuilt `slides_pdf.wasm` from source and `cmp`'d it byte-identical to the committed `internal/slides/pdfcore/testdata/slides_pdf.wasm` (3,672,407 bytes). - **§4.3 / Task 4 brief, part 2 (export pipeline):** `Export(ctx, d, ExportOptions{WithNotes, Assets})` matches the signature exactly; refuses `MaxExportSlides=200` before any layout/render work; never sets `svg.Options.AssetURL` (confirmed against `svg.go`'s `image()`, which draws nothing when neither inline nor `AssetURL` resolve — no external references possible); images capped at 20 MiB *before* `image.DecodeConfig`, then a 50-megapixel cap *before* `image.Decode` (correct decompression-bomb ordering); downscale to 2000 px long edge via a hand-written, unit-tested box filter. - **Spec §1 row 13** was already recorded by Task 1 (`b04cccb`); this diff didn't need to touch it and didn't. - **Named-risk checklist (a)–(h):** all directly verified — see Strengths/Issues below. One item (d) has a real but minor deviation from the literal brief text (font choice/size for notes), noted under Minor. ## Strengths - `core/pdf/src/abi.rs`: `host_slice` fixes real UB (`from_raw_parts` over a null pointer for a `(0,0)` pair); `in_bounds` uses `checked_add` (no overflow panic); `set_result` is documented and actually structured so no `RefCell` borrow is ever held across a `usvg`/`svg2pdf` call — confirmed by reading `render_frames`. `panic = "abort"` is set in `Cargo.toml`. `split_frames`/`render` use `.get()`-bounds slicing and `map_err`/`ok_or_else` throughout — no `unwrap`/`expect`/panicking index found in `lib.rs` or `abi.rs`. - `no_host_imports` test present, correctly gated by `SLIDES_REQUIRE_WASM`, and the gate's new steps (fmt, clippy, build, `cmp`, `cargo test`) mirror the CRDT core's four steps in the same order for the same reason. Ran it myself: `cargo fmt`/native `cargo test` (7/7 pass), and a from-scratch `./scripts/build-pdf-wasm.sh` whose output is byte-identical to the committed artifact. - `TestExportSampleDeck`'s glyph assertion is genuinely a glyph-level check, not a header check: it inflates every `FlateDecode` stream, parses each `Type0` font's `ToUnicode` CMap (`bfchar`/`bfrange`), walks `Tf`/`Tj` operators per form XObject, and recovers every slide heading and every notes paragraph's first word as real Unicode text. `pdftotext`'s absence is handled gracefully (logged, not silently no-op). - Re-ran everything myself and all pass: `internal/slides/pdfcore` (9/9), `internal/slides` export tests (5/5, including 200-slide timing at 546 ms/1.47 MB and image inline/drop behavior), Rust `core/pdf` tests (7/7 native). - `AGENTS.md` updated with the second-crate description and the wasm-staleness trap extended to `slides_pdf.wasm`. ## Issues **Critical:** None found. **Important:** 1. Every `WarnAsset` warning `export.go` emits has an empty `Block` — `layout.Warning` has no slide-id field at all, so a status page can't attribute a dropped image to a slide, only describe it in `Detail` text by asset id. This is disclosed in the task-4-report (concern #4) as intentionally deferred (widening the struct is layout's file, outside this task), but it's real today and worth confirming Task 5 will actually do it rather than letting it silently stay dropped. 2. Neither `internal/slides/pdfcore/pdfcore.go` nor the existing `internal/slides/core/core.go` sets a wazero memory limit (`WithMemoryLimitPages` or similar) on the runtime config — the only ceiling is the 60 s deadline. Not a regression introduced by this diff (matches the CRDT host's existing posture exactly), but it was on the named-risk list and remains open on both hosts. **Minor:** 1. `task-4-brief.md` specifies notes pages in "Liberation Serif 24 pt"; the shipped code uses Liberation Sans at the deck's own `--slides-t-3`/`--slides-body-m` sizes (≈67 pt title / ≈47 pt body at 1920×1080), a deliberate and commented choice to match the deck's own type scale — but it's an undisclosed deviation from the brief's literal text, not called out in the report. 2. Font subsets are embedded per page, not per document (svg2pdf's `to_chunk` has no shared-context API) — 1.7 MB for 200 text-only pages vs. an estimated &lt;100 KB with sharing. Honestly disclosed in both reports as accepted for this slice. 3. WebP is dropped end-to-end with a warning despite being accepted on upload; disclosed as concern #3, needs a `go.mod` decision (`x/image/webp`) out of this task's scope. 4. `pdfRuntime` is a package-level `sync.OnceValues`, not owned by `app` — disclosed (concern #5) as intentional since `app.go` is outside this task's file allowance. ## Assessment **Approved.** The implementation matches the controller's decisions and spec §4.3 precisely, the named-risk checklist holds up under direct source reading and independent test/build verification (including an out-of-band reproducible-build check), and both reports' claims about test rigor (glyph extraction, reproducibility, timing) check out. The two Important items are consciously deferred, disclosed gaps rather than unaddressed defects — worth a short follow-up note (notes-page font deviation from the brief; confirming Task 5 widens `layout.Warning` with a slide id) but not blocking.</result> <usage><subagent_tokens>129731</subagent_tokens><tool_uses>42</tool_uses><duration_ms>317538</duration_ms></usage> </task-notification>

Attributed to this commit: 113 g CO₂e (7463267 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on slice-3-export, 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.

fixtures/editor-entry.js

Loading diff…

fixtures/stage.mjs

Loading diff…

fixtures/strip.mjs

Loading diff…

fixtures/strip.test.mjs

Loading diff…

internal/slides/api.go

Loading diff…

internal/slides/app.go

Loading diff…

internal/slides/editor.go

Loading diff…

internal/slides/layout/paint.go

Loading diff…

internal/slides/layout/regions_test.go

Loading diff…

internal/slides/layout/sizes.go

Loading diff…

internal/slides/layout/testdata/title-body.golden.json

Loading diff…

internal/slides/layout/testdata/title.golden.json

Loading diff…

internal/slides/layout/testdata/two-column.golden.json

Loading diff…

internal/slides/presentation.go

Loading diff…

internal/slides/render/render.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/canvas-reflow-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/render/testdata/quote-desktop.golden.html

Loading diff…

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

Loading diff…

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

Loading diff…

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

Loading diff…

internal/slides/render/testdata/title-body-desktop.golden.html

Loading diff…

internal/slides/render/testdata/title-body-phone.golden.html

Loading diff…

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

Loading diff…

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

Loading diff…

internal/slides/render/testdata/two-column-desktop.golden.html

Loading diff…

internal/slides/render/testdata/two-column-phone.golden.html

Loading diff…

internal/slides/static/editor.8282c2af4a08f493.js

Loading diff…

internal/slides/static/editor.c5e9b4881d5c8a54.js

Loading diff…

internal/slides/static/editor.css

Loading diff…

internal/slides/static/slides.css

Loading diff…

internal/slides/svg/svg.go

Loading diff…

internal/slides/svg/svg_test.go

Loading diff…

internal/slides/templates/editor.html

Loading diff…

internal/slides/templates/present_notes.html

Loading diff…

internal/slides/templates/remote.html

Loading diff…

internal/slides/thumbs.go

Loading diff…

internal/slides/thumbs_test.go

Loading diff…

internal/slidestest/assets_test.go

Loading diff…

internal/slidestest/browser_layout_test.go

Loading diff…

internal/slidestest/browser_test.go

Loading diff…

internal/slidestest/editor_test.go

Loading diff…

internal/slidestest/present_test.go

Loading diff…

internal/slidestest/thumbs_test.go

Loading diff…