Slice 1 implementation plan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
1 file changed,
+258
−0
docs/superpowers/plans/2026-09-07-slice-1-live.md+258 −0
diff --git a/docs/superpowers/plans/2026-09-07-slice-1-live.md b/docs/superpowers/plans/2026-09-07-slice-1-live.md| new file mode 100644 |
| index 0000000..f5ce616 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-07-slice-1-live.md |
| @@ -0,0 +1,258 @@ |
| +# Slides slice 1 — live at slides.oficina.works — implementation plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** A signed-in person or an agent can create a deck, build it through the drive API, view it on desktop and phone, share it by read-only link, present it fullscreen with speaker notes, and drive it from a phone remote, on a live instance at `slides.oficina.works`. |
| + |
| +**Architecture:** One Go binary on rastrillo. The deck is a Yjs document held by a Rust `yrs` core compiled to wasm and run under wazero; the Go side persists updates in SQLite, asks the core for a JSON view, and renders that view to HTML with one renderer used by every surface. Presentation sessions are rows plus an in-memory SSE hub. There is no browser editor in this slice; the drive API is the editor. |
| + |
| +**Tech Stack:** Go 1.24+, rastrillo v0.26.0 (`amadan.net/rastrillo/rastrillo`), chi, GORM over modernc SQLite, wazero, Rust 1.98 with `yrs 0.23.5` on `wasm32-unknown-unknown`, `github.com/skip2/go-qrcode` for the pairing QR. No JS beyond rastrillo's own in this slice. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-09-07-slides-design.md` (revision 1). Read §2 (model), §3.4 (presenting), §4.1 (renderer), §5, §6, §7, §8 before your task. |
| + |
| +## Global Constraints |
| + |
| +- `CGO_ENABLED=0` everywhere; never import `github.com/glebarez/*` or `gorm.io/driver/sqlite`. |
| +- Module path `amadan.net/oficina/slides`; app package `internal/slides`; the five-file shape plus one package per concern (`internal/core`, `internal/deck`, `internal/render`, `internal/present`). |
| +- Every owned query goes through the `owned(r)` seam; not-found and not-yours both 404. |
| +- Every state-changing route is a POST behind `csrf.Protect`; signed-in routes sit in a chi group with `authPlugin.RequireSession`; `/s/{token}`, `/p/*`, `/r/*` and `/api/*` are mounted outside that group. |
| +- Under `rastrillo/auth` the Subject is the email. Never call `sessions.UserID`; map the address to the `users` row first. |
| +- Every user-visible string is a catalog key through `rastrillo.T` with an English value in `locales/en.toml`. (Twelve locales are the suite target; this slice ships English and leaves the keys ready.) |
| +- Markup is the bare `rst-*` attribute grammar (`<a rst-btn="primary">`), never `class="rst-*"`. App-own attributes are `slides-*`. |
| +- WCAG 2.2 AA: 24×24 targets, 320 px reflow, colour never the only carrier of state, `alt` on every image. |
| +- Every screen works with scripts off (spec §8). Every remote button is a form. |
| +- Every write records an actor: `person:<id>` or `agent:<id>` (spec §6.2). |
| +- Commit after every task with the trailers `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>` and `Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE`. Run `make ci` before each commit; `GOCACHE` may need to point at a writable directory in a sandbox (`export GOCACHE=$TMPDIR/go-build`). |
| +- Reference implementations to copy from, by path: `../../../../docs` is `oficina/docs` (`internal/docs/auth.go`, `internal/core/core.go`, `core/src/abi.rs`, `core/src/lib.rs`, `core/.cargo/config.toml`, `core/vendor/`, `scripts/build-wasm.sh`, `internal/docs/stream.go`, `internal/docs/hub.go`, `.amadan/ci.d/10-gate`). Read them; do not copy prose comments you do not understand. |
| + |
| +--- |
| + |
| +## File map |
| + |
| +| Path | Responsibility | |
| +|---|---| |
| +| `internal/slides/auth.go` | Magic-link sign-in, allowlist, `users` mapping, `currentUser(r)`. Port of Docs' `auth.go`. | |
| +| `internal/slides/config.go` | `Config` struct read by `cmd/slides/main.go`: Origin, Env, InstanceKey, SMTP, AllowedAddresses, StoreFromEnv. | |
| +| `internal/slides/models.go` | GORM models: `User`, `Deck`, `DeckUpdate`, `DeckGrant`, `ShareLink`, `Presentation`, `Agent`. | |
| +| `internal/slides/migrations/0001_init.sql` | Generated from the models. | |
| +| `internal/slides/decks.go` | Deck list, create, rename, delete, rung handlers; `loadDeck` (updates → core → view); sample deck seeding. | |
| +| `internal/slides/viewer.go` | `GET /d/{sqid}` paged viewer, `/d/{sqid}/notes`, share-link viewer `GET /s/{token}`. | |
| +| `internal/slides/share.go` | Share links create/revoke, grants. | |
| +| `internal/slides/presentation.go` | Presentation sessions: create, `/p/{session}`, `/p/{session}/notes`, `/p/{session}/command`, `/p/{session}/stream`, `/r/{code}`. | |
| +| `internal/slides/api.go` | `/api/v1/*`: bearer middleware, decks list/create/get, ops. | |
| +| `internal/slides/agents.go` | Agent tokens: settings page, mint, revoke; `actorFrom(r)`. | |
| +| `internal/slides/templates/*.html` | One file per page. | |
| +| `internal/slides/static/slides.css` | Slide layout CSS: templates, canvas frame, phone reflow, themes. | |
| +| `internal/slides/locales/en.toml` | Every string. | |
| +| `internal/core/core.go`, `internal/core/testdata/slides_core.wasm` | wazero host for the Rust core. | |
| +| `core/` | Rust crate `slides-core`: schema, ops, JSON view, ABI. | |
| +| `internal/deck/deck.go` | Go types for the JSON view (`Deck`, `Slide`, `Block`, `Text`), `Parse([]byte)`. | |
| +| `internal/render/render.go`, `themes.go` | `Slide(d, i, vp) template.HTML`, `ThemeVars(id) template.CSS`. | |
| +| `internal/present/hub.go` | In-memory SSE hub keyed by session id. | |
| +| `scripts/build-wasm.sh`, `scripts/run-dev.sh` | Build the core; run locally with a logged magic link. | |
| +| `.amadan/ci.d/10-gate` | The one gate: cargo test, wasm rebuild and `cmp`, go vet/test/fmt, migration check. | |
| + |
| +--- |
| + |
| +### Task 1: Sign-in, users, config |
| + |
| +**Files:** |
| +- Create: `internal/slides/auth.go`, `internal/slides/config.go`, `internal/slides/locales/en.toml`, `internal/slides/templates/signin.html`, `scripts/run-dev.sh` |
| +- Modify: `internal/slides/app.go`, `internal/slides/models.go`, `internal/slides/migrations.go`, `internal/slides/render.go`, `internal/slides/handlers.go`, `cmd/slides/main.go`, `internal/slides/templates/layout.html`, `internal/slides/templates/index.html` |
| +- Test: `internal/slidestest/signin_test.go`, `internal/slidestest/harness_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `slides.Config{Origin, Env, InstanceKey string; Mailer mail.Sender; AllowedAddresses []string; Blobs blobs.Store; Logger *slog.Logger}` and `slides.New(d *db.DB, cfg Config) (*http.ServeMux, error)` (replaces the scaffold's `App`). `(*app).currentUser(r) (User, bool)` maps `sessions.Current(r).Subject` to a `users` row, creating it on first sign-in. `signedIn(t, h)` test helper that performs the magic-link flow against the in-memory mailer and returns the session cookie. |
| +- `User{ID int64; Email string; Name string; Colour string; CreatedAt time.Time}` with unique index on `Email`. |
| + |
| +- [ ] **Step 1: Read Docs' `internal/docs/auth.go` and `cmd/docs/main.go` (`chooseEnv`, `newMailer`) in full.** Note `gateBegin`, `newAuthPlugin`, the fail-closed startup refusals and the `TestSignIn` shape in `docs/internal/docstest`. |
| +- [ ] **Step 2: Write the failing tests** in `internal/slidestest/signin_test.go`: |
| + - `TestSignedOutIsRedirectedToSignin`: `GET /` → 303 with `Location` starting `/signin`. |
| + - `TestSigninMailsAllowedAddressOnly`: `POST /signin` with `email=paul@example.test` (allowed) records one message in the fake mailer; with `someone@else.test` records none; both answer `303 /signin?sent=1`. |
| + - `TestVerifyMintsSessionAndUserRow`: follow the link from the fake mailer, then `GET /` is 200 and the `users` table has one row with that email. |
| + - `TestDevelopmentDefaultsRefusedInProduction`: `slides.New` with `Env: "production"` and empty `InstanceKey` returns an error. |
| +- [ ] **Step 3: Run** `go test ./internal/slidestest/ -run 'Signin|SignedOut|Verify|Development' -v` → FAIL (no `New`, no `Config`). |
| +- [ ] **Step 4: Implement** `config.go`, `auth.go` (rastrillo `auth.New` with `Begin/Callback/Verify/Signout`, `gateBegin`, `Authorize`, `rauth.Schema` merged into `BootSchema` after `sessions.Schema`), the `users` model, `currentUser`, `signin.html` (an `rst-box` with a form: email field, submit; on `?sent=1` a callout), routes exactly as Docs mounts them, and the signed-in `/` placeholder listing nothing yet. `main.go` reads `SLIDES_ORIGIN`, `SLIDES_ENV` plus `-env` flag, `SLIDES_INSTANCE_KEY`, `SLIDES_SMTP_HOST/PORT/USER/PASS/FROM`, `SLIDES_ALLOWED_ADDRESSES`, and `blobs.S3FromEnv()` falling back to `blobs.Dir` in development. Copy Docs' `chooseEnv` rule: `development` only from the flag. |
| +- [ ] **Step 5: Run migration generation**: `go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration generate` after replacing `Note` with `User` in `Models`; read the SQL; keep the file as `0001_init.sql` (delete the scaffold's). |
| +- [ ] **Step 6: Run** the tests → PASS; `make ci` → green. |
| +- [ ] **Step 7: `scripts/run-dev.sh`**: builds and runs with `-env development`, port `8091` (override `SLIDES_DEV_PORT`), a scratch DB in `$TMPDIR`, logs the magic link. |
| +- [ ] **Step 8: Commit** `feat: magic-link sign-in with allowlist, users`. |
| + |
| +--- |
| + |
| +### Task 2: The Rust core and its wazero host |
| + |
| +**Files:** |
| +- Create: `core/Cargo.toml`, `core/.cargo/config.toml`, `core/vendor/` (copied from Docs), `core/src/lib.rs`, `core/src/abi.rs`, `core/src/schema.rs`, `core/src/ops.rs`, `core/src/view.rs`, `core/src/markdown.rs`, `core/tests/ops.rs`, `core/tests/no_host_imports.rs`, `scripts/build-wasm.sh`, `internal/core/core.go`, `internal/core/core_test.go`, `internal/core/testdata/slides_core.wasm`, `rust-toolchain.toml`, `clippy.toml` |
| +- Test: `core/tests/ops.rs`, `internal/core/core_test.go` |
| + |
| +**Interfaces:** |
| +- Rust ABI (all `extern "C"`, `#[no_mangle]`), handles are `u32` slab indices as in Docs: |
| + - `slides_core_abi_version() -> u32` (returns 1) |
| + - `slides_core_alloc(len: u32) -> u32`, `slides_core_free(ptr: u32, len: u32)` |
| + - `slides_core_doc_new(guid_ptr, guid_len, client_id: u64) -> u32` (0 on failure) |
| + - `slides_core_doc_free(h)` |
| + - `slides_core_apply(h, ptr, len) -> i32` — apply a Yjs v1 update; negative error codes as Docs (`ERR_NO_DOC=-1`, `ERR_DECODE=-2`, `ERR_APPLY=-3`, `ERR_BUFFER_TOO_SMALL=-4`, `ERR_INVALID_LENGTH=-5`, `ERR_OPS=-6`) |
| + - `slides_core_state_len(h) -> i32`, `slides_core_state_read(h, ptr, cap) -> i32` — full state as a v1 update (`encode_state_as_update_v1(&StateVector::default())`) |
| + - `slides_core_json_len(h) -> i32`, `slides_core_json_read(h, ptr, cap) -> i32` — the view (below) |
| + - `slides_core_ops(h, ptr, len) -> i32` — apply a JSON array of operations in one transaction; on success the resulting update is retrievable by `slides_core_out_len(h)` / `slides_core_out_read(h, ptr, cap)`; on failure `ERR_OPS` and the message by `slides_core_err_len(h)` / `slides_core_err_read(h, ptr, cap)`. |
| +- The view JSON (`view.rs`), exactly: |
| + ```json |
| + {"meta":{"title":"","theme":"paper","accent":null,"fonts":null,"aspect":"16:9"}, |
| + "slides":[{"id":"abc...","layout":"flow","template":"title","background":{"kind":"theme","value":""}, |
| + "phone":"inherit","transition":"none", |
| + "notes":[[{"t":"Say hello"}]], |
| + "blocks":[{"id":"...","type":"heading","props":{"level":1},"region":"heading","box":null,"z":0, |
| + "phone":{},"text":[[{"t":"Hello","b":true,"href":null}]]}]}], |
| + "assets":{"id":{"ref":"...","name":"","width":0,"height":0,"alt":""}}} |
| + ``` |
| + Text is an array of paragraphs; a paragraph is an array of runs `{t, b?, i?, c?, h?, href?}` (omit false/null keys). Notes use the same shape. Table cells: `props.cells` is a row-major array of texts. |
| +- Operations JSON (`ops.rs`), each `{"op": "...", ...}`; unknown op or missing id is an error and nothing is applied: |
| + - `set_meta {title?, theme?, accent?, fonts?}` |
| + - `add_slide {after?: slideId|null, layout: "flow"|"canvas", template?: string, id?: string}` → assigns id if absent (12 chars from `abcdefghijklmnopqrstuvwxyz234567`, from a host-supplied seed: the op carries `"id"` always from Go, which generates it; the core never draws entropy) |
| + - `remove_slide {id}`, `move_slide {id, after: slideId|null}`, `duplicate_slide {id, new_id, new_block_ids: [..]}` |
| + - `set_slide {id, layout?, template?, background?, phone?, transition?}` — a layout change performs the §2.3 conversion. |
| + - `set_notes {id, markdown}` |
| + - `add_block {slide, id, type, props?, region?, box?, z?, after?, markdown?}` |
| + - `remove_block {slide, id}`, `move_block {slide, id, after: blockId|null}` |
| + - `set_block {slide, id, props?, region?, box?, z?}` — props merge key by key. |
| + - `set_text {slide, id, markdown}`, `set_phone {slide, id, phone: {hidden?, order?, size?, box?}}` (an empty object clears) |
| + - `register_asset {id, ref, name, width, height, alt}`, `remove_asset {id}` |
| +- Markdown subset (`markdown.rs`): paragraphs split on blank lines; `**bold**`, `*italic*`, `` `code` ``, `==highlight==`, `[text](url)`; a line starting `- ` or `1. ` is its own paragraph (used by `list`); no escapes beyond `\*`. |
| +- Go host `internal/core`: `New(ctx) (*Runtime, error)`, `(*Runtime).NewDoc(ctx, guid string, clientID uint64) (*Handle, error)`, `(*Handle).Apply(ctx, update []byte) error`, `(*Handle).State(ctx) ([]byte, error)`, `(*Handle).JSON(ctx) ([]byte, error)`, `(*Handle).Ops(ctx, ops []byte) (update []byte, err error)` where an ops error is `*core.RejectedError` carrying the core's message, `(*Handle).Close(ctx) error`. Copy Docs' trap containment (`rebuild`, `Recovered`, `Rejected`) as is. |
| + |
| +- [ ] **Step 1: Copy Docs' `core/.cargo/config.toml`, `core/vendor/`, `rust-toolchain.toml`, `clippy.toml`, `scripts/build-wasm.sh`** (rename `docs_core` → `slides_core`, `/docs-core` → `/slides-core`), and `core/tests/no_host_imports.rs`. `Cargo.toml` as Docs' with `name = "slides-core"`, add `serde = {version="1", features=["derive"]}` and `serde_json = "1"` pinned to exact versions present in `~/.cargo/registry` (run `ls ~/.cargo/registry/cache/*/ | grep -E '^serde'` and pin what is there; the sandbox may have no network). |
| +- [ ] **Step 2: Write failing Rust tests** in `core/tests/ops.rs`: |
| + - `add_slide_then_view_lists_it`: new doc, ops `[{"op":"set_meta","title":"T"},{"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow","template":"title"}]`, view has one slide with that id and template. |
| + - `add_block_with_markdown_produces_runs`: add block `heading` with markdown `Hello **world**` → text `[[{"t":"Hello "},{"t":"world","b":true}]]`. |
| + - `ops_are_atomic`: a batch whose second op is unknown leaves the doc unchanged and returns an error. |
| + - `layout_switch_assigns_boxes`: flow slide with two blocks in `heading` and `body` regions; `set_slide layout=canvas` → both blocks have non-null boxes; back to flow → boxes null, regions assigned. |
| + - `update_roundtrip`: ops on doc A, take `out`, apply to doc B, views equal. |
| +- [ ] **Step 3: `cargo test --manifest-path core/Cargo.toml`** → FAIL to compile. |
| +- [ ] **Step 4: Implement** `schema.rs` (constructors and accessors over `yrs` `MapRef`/`ArrayRef`/`XmlFragmentRef`; the text schema is an `XmlFragment` of `<paragraph>` elements containing `XmlText` with attributes `b`,`i`,`c`,`h`,`href`), `markdown.rs`, `ops.rs` (one `fn apply_ops(doc: &Doc, ops: &str) -> Result<Vec<u8>, CoreError>` in a single `transact_mut()` with origin `b"ops"`; validation before mutation so failure leaves nothing), `view.rs` (`fn view_json(doc: &Doc) -> String`), `abi.rs` following Docs' bounds-checking and no-borrow-across-yrs rules, `lib.rs` with the `__getrandom_v03_custom` hook and the `Doc::with_options(Options::with_guid_and_client_id(..))` rule. |
| +- [ ] **Step 5: `cargo test`** → PASS; `cargo clippy --all-targets -- -D warnings -D clippy::disallowed_methods` clean. |
| +- [ ] **Step 6: `scripts/build-wasm.sh`** → copy the artefact to `internal/core/testdata/slides_core.wasm`. |
| +- [ ] **Step 7: Write failing Go tests** `internal/core/core_test.go`: `TestOpsProduceUpdateAndView` (NewDoc, Ops add_slide, JSON contains the id), `TestApplyStateRoundtrip` (State of A applied to B, JSON equal), `TestRejectedOpsSurfaceMessage` (`errors.As(err, &core.RejectedError)`), `TestABIVersion`. |
| +- [ ] **Step 8: Implement `internal/core/core.go`** from Docs' `core.go`, renaming exports and adding `State` and `Ops`. |
| +- [ ] **Step 9: `go test ./internal/core/`** → PASS. Commit `feat: Rust yrs core with ops, view and wazero host`. |
| + |
| +--- |
| + |
| +### Task 3: Decks: persistence, list, create, sample deck |
| + |
| +**Files:** |
| +- Create: `internal/slides/decks.go`, `internal/slides/sample.go`, `internal/slides/templates/decks.html`, `internal/slides/templates/deck_new.html`, `internal/slides/templates/deck_settings.html`, `internal/deck/deck.go`, `internal/deck/deck_test.go` |
| +- Modify: `internal/slides/models.go`, `internal/slides/migrations/` (generate `0002_decks.sql`), `internal/slides/app.go`, `internal/slides/render.go`, `internal/slides/locales/en.toml` |
| +- Test: `internal/slidestest/decks_test.go` |
| + |
| +**Interfaces:** |
| +- Models: `Deck{ID int64; Sqid string (unique); Title string; OwnerID int64 (index); Rung string ("private"|"team"); Theme string; Seq int64; CreatedAt, UpdatedAt}`, `DeckUpdate{ID int64; DeckID int64 (index); Seq int64; Actor string; Bytes []byte; CreatedAt}` with unique `(deck_id, seq)`, `DeckGrant{ID; DeckID (index); UserID (index); Actor string; CreatedAt}`. |
| +- `internal/deck`: `type Deck struct{Meta Meta; Slides []Slide; Assets map[string]Asset}`, `Slide{ID, Layout, Template string; Background Background; Phone, Transition string; Notes Text; Blocks []Block}`, `Block{ID, Type string; Props map[string]any; Region string; Box *Box; Z int; Phone PhoneOverride; Text Text}`, `Text [][]Run`, `Run{T string; B, I, C, H bool; Href string}`, `Box{X, Y, W, H float64}`, `PhoneOverride{Hidden bool; Order *int; Size string; Box *Box}`; `Parse(b []byte) (*Deck, error)`; `(Text).Plain() string`. |
| +- `decks.go`: `(*app).openDeck(ctx, deckID) (*core.Handle, *deck.Deck, error)` loads all `DeckUpdate` rows in seq order into a fresh handle (one wazero module per request, closed by the caller); `(*app).applyOps(ctx, d *Deck, actor string, ops []byte) (*deck.Deck, error)` runs `Ops`, inserts `DeckUpdate{Seq: d.Seq+1}` and bumps `Deck.Seq` in one transaction, and notifies the deck hub (Task 6 adds the hub; here a no-op func field `onUpdate func(deckID int64, seq int64)`). |
| +- `visibleDecks(r) *gorm.DB` — decks where owner is me, or rung is `team`, or a grant names me. |
| +- Sqids: use `github.com/sqids/sqids-go` with a fixed alphabet and min length 8, from the deck's `ID`, as Docs does (check Docs' `sqid.go` for the exact alphabet and reuse it). |
| +- New ids for slides and blocks are minted in Go: `newID() string` = 12 chars from `crypto/rand` over the base32 alphabet in the spec. |
| + |
| +- [ ] **Step 1: Failing tests** `decks_test.go`: `TestNewDeckAppearsInList` (signed in, `POST /decks` title+theme → 303 to `/d/{sqid}`; `GET /` lists it), `TestSampleDeckSeededOnFirstSignin` (after verify, `GET /` shows "Welcome to Slides"), `TestForeignPrivateDeckIs404`, `TestTeamDeckVisibleToOthers`, `TestRenameAndDelete` (POST `/d/{sqid}/settings` title; POST `/d/{sqid}/delete` → gone), and `deck_test.go`: `TestParseViewFixture` over a JSON literal matching Task 2's shape. |
| +- [ ] **Step 2: Run** → FAIL. |
| +- [ ] **Step 3: Implement** models, `migration generate` → `0002_decks.sql`, `internal/deck`, `decks.go` handlers: `GET /` (list: an `rst-list` of decks with title, slide count, updated, rung pill), `GET /decks/new`, `POST /decks`, `GET /d/{sqid}/settings` (title, theme select, rung radio, delete), `POST /d/{sqid}/settings`, `POST /d/{sqid}/delete`. `sample.go`: on first sign-in (users row just created) build "Welcome to Slides", eight slides that exercise every template, a canvas slide with shapes and a phone-reflow canvas slide, notes on each, via `applyOps` with actor `person:<id>`. |
| +- [ ] **Step 4: Tests PASS, `make ci` green. Commit** `feat: decks with persistence, list, settings and a sample deck`. |
| + |
| +--- |
| + |
| +### Task 4: The HTML renderer and themes |
| + |
| +**Files:** |
| +- Create: `internal/render/render.go`, `internal/render/themes.go`, `internal/render/render_test.go`, `internal/render/testdata/*.golden.html`, `internal/slides/static/slides.css` |
| +- Test: `internal/render/render_test.go` |
| + |
| +**Interfaces:** |
| +- `render.Viewport` is `Desktop` or `Phone`. `render.Slide(d *deck.Deck, index int, vp Viewport) (template.HTML, error)` returns one `<section slides-slide data-layout=.. data-template=.. data-index=..>` with the theme's variables inline as `style="--slides-bg:..;--slides-fg:..;--slides-accent:..;--slides-font-heading:..;--slides-font-body:.."`, blocks in order, canvas blocks positioned with `style="left:%;top:%;width:%;height:%;z-index"`, phone overrides as `data-phone-hidden`, `data-phone-order`, `data-phone-size`, `data-phone-box` and, when `vp == Phone` and the slide is `phone: reflow`, rendered as flow. |
| +- `render.Text(t deck.Text) template.HTML` — paragraphs to `<p>` with `<strong>`, `<em>`, `<code>`, `<mark>`, `<a href rel="noopener">`; hrefs limited to `http`, `https`, `mailto`. |
| +- `render.Themes() []Theme{ID, Name string}` and `render.ThemeVars(id string) (map[string]string, bool)`. Six themes: `paper` (warm white, near-black, ochre accent, serif headings), `ink` (near-black, off-white, cyan accent), `sunset` (deep plum, cream, coral), `forest` (dark green, pale, lime), `slate` (cool grey-blue, white, amber), `candy` (white, charcoal, pink and violet). Fonts are system stacks: headings `"Iowan Old Style", "Palatino Linotype", Georgia, serif` for paper and `system-ui` elsewhere; body `system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`. Every pair passes 4.5:1 on body and 3:1 on accent-on-bg; assert it in a test with a small contrast function. |
| +- `slides.css`: `[slides-slide]` is `aspect-ratio: 16/9; container-type: inline-size` with a type scale in `cqw` units so text scales with the slide; templates are grids (`title`: centred heading over body; `title-body`; `two-column`: heading spanning, body and aside side by side; `section`: heading only, accent bar; `image-full`: media covering, heading overlaid bottom-left; `quote`; `blank`: single body region); `@container (max-width: 600px)` collapses grids to one column, honours `data-phone-*`, and drops `aspect-ratio` for flow slides (they become as tall as their content). Canvas: `[data-layout=canvas]` keeps `aspect-ratio` and positions `[slides-block]` absolutely; on phones the section scales as a whole because sizes are in `cqw`. |
| + |
| +- [ ] **Step 1: Golden tests**: for every template and every block type, both viewports, `render.Slide` output equals `testdata/<template>-<vp>.golden.html`; regenerate with `-update`. Plus `TestHrefSchemesAreFiltered`, `TestThemeContrast`, `TestPhoneReflowOfCanvasSlideOrdersByZThenPosition`. |
| +- [ ] **Step 2: Run** → FAIL. **Step 3: Implement.** **Step 4: PASS**, `make ci` green. Commit `feat: HTML slide renderer, six themes, slides.css`. |
| + |
| +--- |
| + |
| +### Task 5: Viewer, notes page and share links |
| + |
| +**Files:** |
| +- Create: `internal/slides/viewer.go`, `internal/slides/share.go`, `internal/slides/templates/viewer.html`, `internal/slides/templates/share.html` |
| +- Modify: `models.go` (add `ShareLink{ID; DeckID (index); TokenHash string (unique); Actor string; Views int64; CreatedAt; RevokedAt *time.Time}`), migrations (`0003_share_links.sql`), `app.go`, `decks.html` (open, present, share links) |
| +- Test: `internal/slidestest/viewer_test.go`, `internal/slidestest/share_test.go` |
| + |
| +**Interfaces:** |
| +- `GET /d/{sqid}?slide=N` renders slide N (1-based, default 1) full width in the shell with previous/next links, a slide counter "3 of 8", the notes under it in a `<details>` when the viewer is the owner or a grantee, and a filmstrip of slide titles as links. `?vp=phone` forces the phone viewport (used by the settings "preview on phone" link). `GET /d/{sqid}/notes` is every slide's title and notes on one page. |
| +- `POST /d/{sqid}/share` mints a link: 32 random bytes base64url, stored as SHA-256 hex, the URL shown once on `share.html` with a copy field; `POST /d/{sqid}/share/{id}/revoke`. `GET /s/{token}` (outside the session group) resolves by hash, 404 if missing or revoked, increments `Views`, renders the viewer without notes or edit affordances; `?present=1` renders the fullscreen viewer of Task 6 in scriptless form (one slide per page). |
| + |
| +- [ ] **Step 1: Failing tests**: `TestViewerPagesThroughSlides` (slide=2 shows the second slide's heading, next link points to 3), `TestViewerOnPhoneViewportReflows` (`?vp=phone` output contains `data-vp="phone"`), `TestShareLinkShownOnceAndOpensViewer`, `TestRevokedShareLinkIs404`, `TestShareLinkStoresOnlyHash` (query the table, no plaintext), `TestShareLinkHidesNotes`. |
| +- [ ] **Step 2: FAIL. Step 3: Implement. Step 4: PASS**, commit `feat: paged viewer, notes page, read-only share links`. |
| + |
| +--- |
| + |
| +### Task 6: Presentation sessions, presenter view, phone remote |
| + |
| +**Files:** |
| +- Create: `internal/present/hub.go`, `internal/present/hub_test.go`, `internal/slides/presentation.go`, `internal/slides/templates/present.html`, `internal/slides/templates/present_notes.html`, `internal/slides/templates/remote.html`, `internal/slides/templates/remote_pair.html`, `internal/slides/static/present.js` (under 120 lines: keyboard, swipe, SSE apply; the page is complete without it) |
| +- Modify: `models.go` (`Presentation{ID; Sqid string (unique); DeckID (index); Actor string; Slide int; Blackout bool; Code string (unique among live); RemoteTokenHash string; StartedAt; EndedAt *time.Time}`), migrations (`0004_presentations.sql`), `app.go`, `viewer.html` (a "Present" button), `decks.html` |
| +- Test: `internal/slidestest/present_test.go`, `internal/present/hub_test.go` |
| + |
| +**Interfaces:** |
| +- `present.Hub`: `New() *Hub`, `(*Hub).Subscribe(key string) (ch <-chan Event, cancel func())`, `(*Hub).Publish(key string, e Event)`, `Event{Slide int; Blackout bool; Ended bool}`. Buffered channels of 8; a slow subscriber is dropped. Copy the ping/deadline/TTL recipe from Docs' `stream.go` (15 s `: ping`, 30 s write deadline, 5 minute TTL then the client reconnects). |
| +- Routes: signed-in `POST /d/{sqid}/present` creates the row (code: 6 chars from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, unique among live sessions; remote token: 32 random bytes, hash stored) and 303s to `/p/{session}/notes?remote=<token>` which shows the remote URL and its QR once. Outside the session group: `GET /p/{session}` (fullscreen; `?slide=N` scriptless paging; the presenter's keyboard posts through the JS; a `<noscript>` next/prev form pair), `GET /p/{session}/notes` (needs the presenter's session cookie or the remote token as `?t=`), `GET /p/{session}/stream` (SSE), `POST /p/{session}/command` (`op`, `slide`, `t` as form fields or bearer; validates the token hash, applies, publishes, answers 303 back to the `Referer` or 204 for bearer callers), `GET /r/{code}` (pair page: the token from the QR is in the URL as `?t=`; a code typed by hand shows a field for it; on success 303 to `/p/{session}/remote?t=`), `GET /p/{session}/remote` (the remote: current and next slide rendered small, notes, four big buttons each a form). A session past 12 hours or with `EndedAt` set answers 410 with a message. |
| +- Audience follow: `GET /s/{token}` when a live session exists for the deck offers "Follow the presenter" → `/s/{token}?follow=1` which renders the fullscreen viewer subscribed to the session stream, read-only; scriptless it is `?slide=N` of the session's current slide with a `<meta http-equiv="refresh" content="5">`. |
| + |
| +- [ ] **Step 1: Failing tests**: `hub_test.go` `TestPublishReachesEverySubscriber`, `TestSlowSubscriberIsDropped`; `present_test.go` `TestPresentCreatesSessionWithCodeAndRemoteUrl`, `TestRemoteCommandAdvancesSlideAndPublishes` (subscribe via the stream handler using a context with timeout, post `next` with the token, read `event: slide` `data: {"slide":2,...}` within 2 s), `TestWrongRemoteTokenIs404`, `TestEndedSessionIs410`, `TestScriptlessRemoteButtonsAreForms` (four `<form method="post">`), `TestFollowOfferedOnlyWhileLive`. |
| +- [ ] **Step 2: FAIL. Step 3: Implement** (QR via `github.com/skip2/go-qrcode` rendered as an inline SVG `<img src="data:image/svg+xml;base64,...">` with alt text naming the code). **Step 4: PASS**, commit `feat: presentation sessions, presenter view, phone remote, audience follow`. |
| + |
| +--- |
| + |
| +### Task 7: The drive API and agent tokens |
| + |
| +**Files:** |
| +- Create: `internal/slides/api.go`, `internal/slides/agents.go`, `internal/slides/templates/agents.html`, `docs/api.md` |
| +- Modify: `models.go` (`Agent{ID; Name string; OperatorID int64 (index); TokenHash string (unique); CreatedAt; RevokedAt *time.Time}`), migrations (`0005_agents.sql`), `app.go`, `layout.html` (account menu: Agents, Sign out) |
| +- Test: `internal/slidestest/api_test.go`, `internal/slidestest/agents_test.go` |
| + |
| +**Interfaces:** |
| +- `GET /agents` lists the person's agents; `POST /agents` with `name` (lowercase letters, digits, hyphen, 2 to 32 chars) mints a token `sl_` + 40 base62 chars, stores SHA-256, shows it once; `POST /agents/{id}/revoke`. The principal string is `name~operatorEmail`; the actor recorded is `agent:<id>`. |
| +- `bearerActor` middleware for `/api/v1/*`: reads `Authorization: Bearer`, looks the hash up among unrevoked agents, sets the actor and operator in the context; missing or bad → 401 JSON `{"error":"unauthorized"}`. The API never reads a session cookie. |
| +- Endpoints, JSON in and out, `Content-Type: application/json`: |
| + - `GET /api/v1/decks` → `{"decks":[{"sqid","title","slides","rung","updated_at","seq"}]}` visible to the operator. |
| + - `POST /api/v1/decks {title, theme?}` → 201 `{"sqid"}`; creates a deck owned by the operator with actor `agent:<id>`. |
| + - `GET /api/v1/decks/{sqid}` → the view JSON plus `"sqid"` and `"seq"`, `ETag: "<seq>"`, honours `If-None-Match` with 304. |
| + - `POST /api/v1/decks/{sqid}/ops` body `{"ops":[...]}`; ids for `add_slide`/`add_block`/`duplicate_slide` may be omitted and are minted server-side and returned: 200 `{"seq", "ids": {"<index of op>": "<id>"}}`; a rejected batch → 422 `{"error": "<core message>", "op": <index>}` and nothing applied. |
| + - `GET /api/v1/decks/{sqid}/slides/{id}.html?vp=phone` → the rendered section (so an agent can look at what it made before the SVG thumbnails exist in slice 3). |
| + - `POST /api/v1/decks/{sqid}/present` → `{"session","present_url","notes_url","remote_url"}` so an agent can start a talk. |
| +- `docs/api.md`: every endpoint and every op with one example each, written for a person and an agent. |
| + |
| +- [ ] **Step 1: Failing tests**: `TestAgentTokenShownOnceAndHashed`, `TestRevokedTokenIs401`, `TestApiCreateDeckRecordsAgentActor` (the `DeckUpdate.Actor` is `agent:<id>` and the deck list shows the robot badge with `claude~paul@example.test`), `TestApiOpsMintIdsAndReturnThem`, `TestApiRejectedBatchAppliesNothing`, `TestApiGetHonoursEtag`, `TestApiNeverReadsCookie` (a request with the session cookie and no bearer is 401). |
| +- [ ] **Step 2: FAIL. Step 3: Implement. Step 4: PASS**, commit `feat: drive API with agent principals`. |
| + |
| +--- |
| + |
| +### Task 8: AGENTS.md, README, PRODUCT.md, CI gate |
| + |
| +**Files:** |
| +- Modify: `AGENTS.md` (the app's own rules: one rule, settled decisions pointer, the seams, how to run, how to deploy), `README.md` (what it is, first paragraph honest about slice 1: no browser editor yet, the API is the editor), `CLAUDE.md` stays a pointer |
| +- Create: `PRODUCT.md` (impeccable product-schema 1, the ten sections, claims cited to the spec), `.amadan/ci.d/10-gate` replacing the four scaffold steps: source `~/.cargo/env`, check tools, `cargo test`, `cargo clippy`, `scripts/build-wasm.sh` then `cmp` against `internal/core/testdata/slides_core.wasm`, `make ci` |
| +- Test: `make ci` and `.amadan/ci.d/10-gate` both green locally. |
| + |
| +- [ ] **Step 1: Write the three documents.** Load `humane-docs` for README. **Step 2: Gate green. Commit** `docs: AGENTS, README, PRODUCT and the one CI gate`. |
| + |
| +--- |
| + |
| +### Task 9: Deploy to slides.oficina.works |
| + |
| +**Files:** none in the repo beyond `Makefile` if needed. |
| + |
| +- [ ] **Step 1: Store and email**: `carlos store create --app slides --account oficina`; `carlos email enable --app slides --account oficina --from noreply@slides.bds.oncarlos.com` and read which env vars it delivers; map them to `SLIDES_SMTP_*` in `main.go` if the prefix differs (a code change → commit). |
| +- [ ] **Step 2: Config**: `carlos env set --app slides --account oficina SLIDES_ORIGIN=https://slides.oficina.works SLIDES_ALLOWED_ADDRESSES=paul@keymail.dev,paul@rslw.com`; `carlos secrets set --app slides --account oficina SLIDES_INSTANCE_KEY=$(carlos secrets genkey)`. |
| +- [ ] **Step 3: Build**: `make release` (linux-arm64, stamped). **Deploy**: `carlos deploy --app slides --account oficina --channel edge --timeout 5m ./releases/slides-linux-arm64`. |
| +- [ ] **Step 4: Domain**: `carlos domains attach --app slides --account oficina slides.oficina.works`; wait for `live`. |
| +- [ ] **Step 5: Verify**: `curl -s https://slides.oficina.works/api/version` reports the stamped version; `GET /` 303s to `/signin`; `carlos email test --app slides --to paul@keymail.dev` delivers. Then sign in for real and walk: sample deck, viewer on phone width, share link, present, remote from a phone. |
| +- [ ] **Step 6: CI runner**: add `-repo oficina/slides` to `~/.config/systemd/user/amadan-runner.service` and restart it (needs a shell outside the sandbox); `amadan ci status -branch worktree-slice-1` exit 0. |
| +- [ ] **Step 7: Push the branch, open the branch discussion** as `claude~paul` naming what is live and what is not (no editor, no PDF), and hand over. |