fix: canvas blocks never clip their text; taller sample boxes
Canvas blocks were centred and overflow: hidden, so a heading taller than its box lost its first and last lines (seen on the sample deck's canvas slide). Text blocks now start at the top of their box and run past it; shapes and images keep clipping. The sample's canvas headings and text get boxes their type actually fits.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
internal/slides/sample.go+6 −6internal/slides/static/slides.css+10 −2
CI passed — run details
Intent
<task-notification> <task-id>ad696429713efc3ab</task-id> <tool-use-id>toolu_01W5FuBertrup8ub8Por3iJe</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/ad696429713efc3ab.output</output-file> <status>completed</status> <summary>Agent "Final-review fix wave" 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, all five items plus the four one-liners fixed, committed, not pushed. Commit: `3e2b142` — fix: final review — conflict 409, set_meta row sync, token off the URL, presentation cascade, copy (both trailers present, tree clean). Tests: TDD, each red first. New/updated — `TestConcurrentWritersConflictThenRetry` (package-internal, failed with "duplicated key not allowed"), `TestApiOpsConflictIs409` (reachable without sleeps by staging the race: row put one seq behind its log), `TestApiSetMetaUpdatesTheDeckRow`, `TestAgentTokenShownOnceAndHashed` (POST 200 + no-store, token in no URL; `mintAgent` helper updated), `TestDeletingADeckEndsItsPresentations` (real SSE stream must hear `"ended":true`; failed on deadline before), trailing-bytes cases in `TestApiRefusesBodiesItCannotRead`. `make ci` green: vet, gofmt, all packages ok, "models and migrations agree". Report at `.superpowers/sdd/2026-09-07-slice-1-live/final-fix-report.md`. Concerns: (1) item 2 uses the batch pre-scan rather than the resulting view — the view only exists after the commit, so deriving from it would hold SQLite's writer inside the transaction; `set_meta` is the only op touching meta, so the two agree. An agent setting `title: ""` now blanks the row title as well as the document's, which is the row mirroring the document by design. (2) `docs/api.md`'s new present paragraph reflects `presenterNotes`' `holder && presenter` gate: an agent's session shows no code/QR on any page, so the JSON is the only source. (3) The token-page change breaks POST-redirect-GET on `/agents`; refresh re-POSTs and mints another agent (visible and revocable) — documented in the handler comment. (4) `share.go` still redirects its share-link token in a URL; the review scoped this fix to the agent token, so I left it. (5) Full `.amadan/ci.d/10-gate` not re-run — nothing under `core/` changed.</result> <usage><subagent_tokens>162668</subagent_tokens><tool_uses>123</tool_uses><duration_ms>839182</duration_ms></usage> </task-notification>
<task-notification> <task-id>a0695b0075bc1c9ef</task-id> <tool-use-id>toolu_01ADTgMHazBiK3tjZ8GETvZh</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a0695b0075bc1c9ef.output</output-file> <status>completed</status> <summary>Agent "Re-review final fix wave" 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>Confirmed correct, all good. ### Findings 1. **Write-conflict retry (409)**: ADDRESSED — `internal/slides/decks.go:283-287` maps `gorm.ErrDuplicatedKey` from the `DeckUpdate` insert to `errDeckBusy`, with a comment explaining the two-lock relationship. `TestConcurrentWritersConflictThenRetry` (decks_test.go:611) and `TestApiOpsConflictIs409` (api_test.go:565) both pass. 2. **`set_meta` syncs `decks.title`/`theme`**: ADDRESSED — `metaRowUpdates` (api.go:570-403) pre-scans the batch, last-write-wins, theme gated by `validTheme` (decks.go:548), passed into `applyOps`' single transaction (decks.go:294-298). `TestApiSetMetaUpdatesTheDeckRow` passes. 3. **Token off the GET URL**: ADDRESSED — `createAgent` now renders the page directly with `Cache-Control: no-store` (agents.go:508, 224-226) instead of redirecting; `agents` GET handler no longer reads `?new=` (agents.go:374). `TestAgentTokenShownOnceAndHashed` passes and explicitly checks a replay via `/agents?new=<token>` shows nothing. 4. **`deleteDeck` cascades presentations**: ADDRESSED — live sessions read and rows deleted inside the transaction (decks.go:528-543), `hub.Publish(Ended: true)` fires only after the transaction commits successfully (decks.go:556-561). `TestDeletingADeckEndsItsPresentations` passes. 5. **Copy fixes**: ADDRESSED — `slides.viewer.empty.body` reworded (en.toml:707), no mention of "editor"; `PRODUCT.md` now says 143 keys, matching `grep -cE '^[A-Za-z0-9_.-]+ *=' internal/slides/locales/en.toml` = 143. One-liners: all ADDRESSED — `createPresentation` uses `errors.Is(err, gorm.ErrDuplicatedKey)` (presentation.go:349-353), `isUniqueViolation` removed, `strings` import still used elsewhere so no unused-import breakage; `readJSON` rejects trailing bytes via a second `Decode` into `json.RawMessage`, checking `errors.Is(err, io.EOF)` so trailing whitespace/newline still passes (api.go:290-321, verified by test at api_test.go:504-505 which POSTs a trailing `\n` and expects 200); `share.html` Revoke buttons carry `aria-label` (share.html:865-866, share.go:834); `docs/api.md` documents the agent-session pairing gate (docs/api.md:118-127), consistent with `presenterNotes`' `holder && presenter` gate. Named-risk checks: - (a) Pre-scan correctness: verified by code inspection — two `set_meta` ops in one batch: map assignment overwrites, last wins; `set_meta` without `title`: `has` guard leaves that column untouched; a refused batch (h.Ops validation failure) returns before the `Create`/`Updates` calls, so the transaction never runs and the row is untouched — reinforced by the pre-existing `TestSettingsRowAndDocumentChangeTogether` which proves a mid-transaction row-update failure leaves the row unchanged. No test exercises the two-set_meta-in-one-batch or omitted-title cases directly, but the logic is straightforward and covered by reading. - (b) 200-on-POST: `no-store` set before render (agents.go:224-226), confirmed by test; the re-POST-on-refresh tradeoff is explicitly documented in the handler comment and docs/api.md, not specially handled in code — acceptable per the report's own framing. - (c) `deleteDeck` publish ordering: confirmed after-commit only, code at decks.go:552-561 (loop is after the `if err != nil { return }` check following `Transaction(...)`). - (d) 409 test honesty: `TestConcurrentWritersConflictThenRetry` runs a genuine two-handle sequential race (no sleep/goroutine needed since it's deterministic ordering); `TestApiOpsConflictIs409` stages via direct SQL decrement, documented as approximating "already lost" state, both attempts in `applyAgentOps` genuinely re-read the row (decks.go / api.go:543) — confirmed by trace-through. - (e) `readJSON` trailing-whitespace: confirmed `io.EOF` correctly matches trailing whitespace/newline; test at api_test.go:504-505 explicitly asserts trailing `\n` still returns 200. ### New Breakage in Fix Diff (Critical/Important only) None found. `go build ./...`, `go vet ./...`, and `go test ./...` all pass clean on 3e2b142. ### Out-of-Scope Observations - `share.go:124` still reads a `new` token from the query string for share-link creation (a different, pre-existing feature, unrelated to the agent-token GET-URL fix) — not a regression, just noting it wasn't touched and isn't in scope. ### Verdict All findings addressed.</result> <usage><subagent_tokens>83499</subagent_tokens><tool_uses>20</tool_uses><duration_ms>129452</duration_ms></usage> </task-notification>
[Image #1] Oops
Looking good for a first slice. Let's keep going with the plan.
diff --git a/internal/slides/sample.go b/internal/slides/sample.go| index 9fc6f83..db195eb 100644 |
| --- a/internal/slides/sample.go |
| +++ b/internal/slides/sample.go |
| @@ -245,13 +245,13 @@ func sampleCanvasSlide() ([]op, error) { |
| } |
| return []op{ |
| addCanvas(s), |
| - addBoxed(s, b[0], "heading", 0.06, 0.08, 0.88, 0.16, 3, "Canvas: put it where you want it", map[string]any{"level": 1}), |
| + addBoxed(s, b[0], "heading", 0.06, 0.06, 0.88, 0.28, 3, "Canvas: put it where you want it", map[string]any{"level": 1}), |
| addBoxed(s, b[1], "shape", 0.08, 0.34, 0.36, 0.44, 1, "", |
| map[string]any{"kind": "rect", "fill": "#e8e2d6", "stroke": "#8a7f6a", "strokeWidth": 2, "radius": 12}), |
| addBoxed(s, b[2], "shape", 0.26, 0.46, 0.30, 0.30, 2, "", |
| map[string]any{"kind": "ellipse", "fill": "#cfd9e8", "stroke": "#5a6f8a", "strokeWidth": 2}), |
| - addBoxed(s, b[3], "text", 0.58, 0.36, 0.36, 0.40, 4, |
| - "Every box is a fraction of the 16:9 frame, so a canvas slide scales to any screen without reflowing. `z` decides what sits on top — the ellipse overlaps the rectangle because its z is higher.", nil), |
| + addBoxed(s, b[3], "text", 0.58, 0.38, 0.36, 0.54, 4, |
| + "Every box is a fraction of the 16:9 frame, so a canvas slide scales to any screen without reflowing. `z` decides what sits on top — the ellipse overlaps the rectangle because its z is higher.", map[string]any{"size": "s"}), |
| notes(s, "A **canvas** slide. Blocks carry `box` (x, y, w, h as fractions of the frame) and `z` instead of a region. The template is ignored here."), |
| }, nil |
| } |
| @@ -267,10 +267,10 @@ func samplePhoneReflowSlide() ([]op, error) { |
| return []op{ |
| addCanvas(s), |
| op{"op": "set_slide", "id": s, "phone": "reflow", "transition": "fade"}, |
| - addBoxed(s, b[0], "heading", 0.06, 0.10, 0.88, 0.16, 1, "On a phone this one reflows", map[string]any{"level": 1}), |
| - addBoxed(s, b[1], "text", 0.06, 0.32, 0.52, 0.44, 2, |
| + addBoxed(s, b[0], "heading", 0.06, 0.06, 0.88, 0.28, 1, "On a phone this one reflows", map[string]any{"level": 1}), |
| + addBoxed(s, b[1], "text", 0.06, 0.38, 0.52, 0.54, 2, |
| "A canvas slide normally scales to fit a phone, text and all. Setting the slide's `phone` to `reflow` makes the renderer lay its blocks out as a single column instead.", nil), |
| - addBoxed(s, b[2], "shape", 0.64, 0.32, 0.30, 0.44, 3, "Desktop only", |
| + addBoxed(s, b[2], "shape", 0.64, 0.38, 0.30, 0.44, 3, "Desktop only", |
| map[string]any{"kind": "rect", "fill": "#e0d7e8", "stroke": "#75608a", "strokeWidth": 2, "radius": 12}), |
| op{"op": "set_phone", "slide": s, "id": b[2], "phone": map[string]any{"hidden": true}}, |
| notes(s, "The same canvas slide with `phone: reflow`, and a phone override that hides the shape on a small screen. The phone layer only ever adds to the desktop layout; it never replaces it."), |
diff --git a/internal/slides/static/slides.css b/internal/slides/static/slides.css| index b3748d2..05da017 100644 |
| --- a/internal/slides/static/slides.css |
| +++ b/internal/slides/static/slides.css |
| @@ -471,16 +471,24 @@ |
| position: absolute; |
| display: flex; |
| flex-direction: column; |
| - justify-content: center; |
| - overflow: hidden; |
| + /* Top-aligned and never clipped: a box is where text STARTS, not a |
| + viewport onto it. Centring plus overflow: hidden cut the first and |
| + last lines of any block taller than its box (seen on the sample |
| + deck's canvas heading, 2026-09-07) — the same WCAG 1.4.10 failure |
| + the slide-level rule above refuses. Text that outgrows its box |
| + runs past it; the person shrinks the type or grows the box. */ |
| + justify-content: flex-start; |
| + overflow: visible; |
| } |
| [slides-slide][data-layout="canvas"] > [data-block="shape"] { |
| display: block; |
| block-size: 100%; |
| min-block-size: 0; |
| + overflow: hidden; |
| } |
| [slides-slide][data-layout="canvas"] > [data-block="image"] { |
| display: block; |
| + overflow: hidden; |
| } |
| /* ── The phone layer ───────────────────────────────────────────────── |