oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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 nested under the app package (internal/slides/core, internal/slides/deck, internal/slides/render, internal/slides/present), because rastrillo migration check requires exactly one directory under internal/.
  • 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/slides/core/core.go, internal/slides/core/testdata/slides_core.wasm | wazero host for the Rust core. | | core/ | Rust crate slides-core: schema, ops, JSON view, ABI. | | internal/slides/deck/deck.go | Go types for the JSON view (Deck, Slide, Block, Text), Parse([]byte). | | internal/slides/render/render.go, themes.go | Slide(d, i, vp) template.HTML, ThemeVars(id) template.CSS. | | internal/slides/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; 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/slides/deck/deck.go, internal/slides/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/slides/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/slides/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/slides/render/render.go, internal/slides/render/themes.go, internal/slides/render/render_test.go, internal/slides/render/testdata/*.golden.html, internal/slides/static/slides.css
  • Test: internal/slides/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/slides/present/hub.go, internal/slides/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/slides/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/slides/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.