oficina / slides Public
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=0everywhere; never importgithub.com/glebarez/*orgorm.io/driver/sqlite.- Module path
amadan.net/oficina/slides; app packageinternal/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), becauserastrillo migration checkrequires exactly one directory underinternal/. - 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 withauthPlugin.RequireSession;/s/{token},/p/*,/r/*and/api/*are mounted outside that group. - Under
rastrillo/auththe Subject is the email. Never callsessions.UserID; map the address to theusersrow first. - Every user-visible string is a catalog key through
rastrillo.Twith an English value inlocales/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">), neverclass="rst-*". App-own attributes areslides-*. - WCAG 2.2 AA: 24×24 targets, 320 px reflow, colour never the only carrier of state,
alton every image. - Every screen works with scripts off (spec §8). Every remote button is a form.
- Every write records an actor:
person:<id>oragent:<id>(spec §6.2). - Commit after every task with the trailers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>andClaude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE. Runmake cibefore each commit;GOCACHEmay need to point at a writable directory in a sandbox (export GOCACHE=$TMPDIR/go-build). - Reference implementations to copy from, by path:
../../../../docsisoficina/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}andslides.New(d *db.DB, cfg Config) (*http.ServeMux, error)(replaces the scaffold'sApp).(*app).currentUser(r) (User, bool)mapssessions.Current(r).Subjectto ausersrow, 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 onEmail.
- [ ] Step 1: Read Docs'
internal/docs/auth.goandcmd/docs/main.go(chooseEnv,newMailer) in full. NotegateBegin,newAuthPlugin, the fail-closed startup refusals and theTestSignInshape indocs/internal/docstest. - [ ] Step 2: Write the failing tests in
internal/slidestest/signin_test.go: -TestSignedOutIsRedirectedToSignin:GET /→ 303 withLocationstarting/signin. -TestSigninMailsAllowedAddressOnly:POST /signinwithemail=paul@example.test(allowed) records one message in the fake mailer; withsomeone@else.testrecords none; both answer303 /signin?sent=1. -TestVerifyMintsSessionAndUserRow: follow the link from the fake mailer, thenGET /is 200 and theuserstable has one row with that email. -TestDevelopmentDefaultsRefusedInProduction:slides.NewwithEnv: "production"and emptyInstanceKeyreturns an error. - [ ] Step 3: Run
go test ./internal/slidestest/ -run 'Signin|SignedOut|Verify|Development' -v→ FAIL (noNew, noConfig). - [ ] Step 4: Implement
config.go,auth.go(rastrilloauth.NewwithBegin/Callback/Verify/Signout,gateBegin,Authorize,rauth.Schemamerged intoBootSchemaaftersessions.Schema), theusersmodel,currentUser,signin.html(anrst-boxwith a form: email field, submit; on?sent=1a callout), routes exactly as Docs mounts them, and the signed-in/placeholder listing nothing yet.main.goreadsSLIDES_ORIGIN,SLIDES_ENVplus-envflag,SLIDES_INSTANCE_KEY,SLIDES_SMTP_HOST/PORT/USER/PASS/FROM,SLIDES_ALLOWED_ADDRESSES, andblobs.S3FromEnv()falling back toblobs.Dirin development. Copy Docs'chooseEnvrule:developmentonly from the flag. - [ ] Step 5: Run migration generation:
go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration generateafter replacingNotewithUserinModels; read the SQL; keep the file as0001_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, port8091(overrideSLIDES_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 areu32slab 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 byslides_core_out_len(h)/slides_core_out_read(h, ptr, cap); on failureERR_OPSand the message byslides_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 fromabcdefghijklmnopqrstuvwxyz234567, 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-or1.is its own paragraph (used bylist); 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.RejectedErrorcarrying 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(renamedocs_core→slides_core,/docs-core→/slides-core), andcore/tests/no_host_imports.rs.Cargo.tomlas Docs' withname = "slides-core", addserde = {version="1", features=["derive"]}andserde_json = "1"pinned to exact versions present in~/.cargo/registry(runls ~/.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 blockheadingwith markdownHello **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 inheadingandbodyregions;set_slide layout=canvas→ both blocks have non-null boxes; back to flow → boxes null, regions assigned. -update_roundtrip: ops on doc A, takeout, 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 overyrsMapRef/ArrayRef/XmlFragmentRef; the text schema is anXmlFragmentof<paragraph>elements containingXmlTextwith attributesb,i,c,h,href),markdown.rs,ops.rs(onefn apply_ops(doc: &Doc, ops: &str) -> Result<Vec<u8>, CoreError>in a singletransact_mut()with originb"ops"; validation before mutation so failure leaves nothing),view.rs(fn view_json(doc: &Doc) -> String),abi.rsfollowing Docs' bounds-checking and no-borrow-across-yrs rules,lib.rswith the__getrandom_v03_customhook and theDoc::with_options(Options::with_guid_and_client_id(..))rule. - [ ] Step 5:
cargo test→ PASS;cargo clippy --all-targets -- -D warnings -D clippy::disallowed_methodsclean. - [ ] Step 6:
scripts/build-wasm.sh→ copy the artefact tointernal/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.gofrom Docs'core.go, renaming exports and addingStateandOps. - [ ] Step 9:
go test ./internal/core/→ PASS. Commitfeat: 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/(generate0002_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 allDeckUpdaterows 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)runsOps, insertsDeckUpdate{Seq: d.Seq+1}and bumpsDeck.Seqin one transaction, and notifies the deck hub (Task 6 adds the hub; here a no-op func fieldonUpdate func(deckID int64, seq int64)).visibleDecks(r) *gorm.DB— decks where owner is me, or rung isteam, or a grant names me.- Sqids: use
github.com/sqids/sqids-gowith a fixed alphabet and min length 8, from the deck'sID, as Docs does (check Docs'sqid.gofor the exact alphabet and reuse it). - New ids for slides and blocks are minted in Go:
newID() string= 12 chars fromcrypto/randover the base32 alphabet in the spec.
- [ ] Step 1: Failing tests
decks_test.go:TestNewDeckAppearsInList(signed in,POST /deckstitle+theme → 303 to/d/{sqid};GET /lists it),TestSampleDeckSeededOnFirstSignin(after verify,GET /shows "Welcome to Slides"),TestForeignPrivateDeckIs404,TestTeamDeckVisibleToOthers,TestRenameAndDelete(POST/d/{sqid}/settingstitle; POST/d/{sqid}/delete→ gone), anddeck_test.go:TestParseViewFixtureover 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.gohandlers:GET /(list: anrst-listof 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, viaapplyOpswith actorperson:<id>. - [ ] Step 4: Tests PASS,
make cigreen. Commitfeat: 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.ViewportisDesktoporPhone.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 asstyle="--slides-bg:..;--slides-fg:..;--slides-accent:..;--slides-font-heading:..;--slides-font-body:..", blocks in order, canvas blocks positioned withstyle="left:%;top:%;width:%;height:%;z-index", phone overrides asdata-phone-hidden,data-phone-order,data-phone-size,data-phone-boxand, whenvp == Phoneand the slide isphone: 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 tohttp,https,mailto.render.Themes() []Theme{ID, Name string}andrender.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, seriffor paper andsystem-uielsewhere; bodysystem-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]isaspect-ratio: 16/9; container-type: inline-sizewith a type scale incqwunits 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, honoursdata-phone-*, and dropsaspect-ratiofor flow slides (they become as tall as their content). Canvas:[data-layout=canvas]keepsaspect-ratioand positions[slides-block]absolutely; on phones the section scales as a whole because sizes are incqw.
- [ ] Step 1: Golden tests: for every template and every block type, both viewports,
render.Slideoutput equalstestdata/<template>-<vp>.golden.html; regenerate with-update. PlusTestHrefSchemesAreFiltered,TestThemeContrast,TestPhoneReflowOfCanvasSlideOrdersByZThenPosition. - [ ] Step 2: Run → FAIL. Step 3: Implement. Step 4: PASS,
make cigreen. Commitfeat: 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(addShareLink{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=Nrenders 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=phoneforces the phone viewport (used by the settings "preview on phone" link).GET /d/{sqid}/notesis every slide's title and notes on one page.POST /d/{sqid}/sharemints a link: 32 random bytes base64url, stored as SHA-256 hex, the URL shown once onshare.htmlwith a copy field;POST /d/{sqid}/share/{id}/revoke.GET /s/{token}(outside the session group) resolves by hash, 404 if missing or revoked, incrementsViews, renders the viewer without notes or edit affordances;?present=1renders 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=phoneoutput containsdata-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}/presentcreates the row (code: 6 chars fromABCDEFGHJKLMNPQRSTUVWXYZ23456789, 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=Nscriptless 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,tas form fields or bearer; validates the token hash, applies, publishes, answers 303 back to theRefereror 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 withEndedAtset 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=1which renders the fullscreen viewer subscribed to the session stream, read-only; scriptless it is?slide=Nof the session's current slide with a<meta http-equiv="refresh" content="5">.
- [ ] Step 1: Failing tests:
hub_test.goTestPublishReachesEverySubscriber,TestSlowSubscriberIsDropped;present_test.goTestPresentCreatesSessionWithCodeAndRemoteUrl,TestRemoteCommandAdvancesSlideAndPublishes(subscribe via the stream handler using a context with timeout, postnextwith the token, readevent: slidedata: {"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-qrcoderendered as an inline SVG<img src="data:image/svg+xml;base64,...">with alt text naming the code). Step 4: PASS, commitfeat: 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 /agentslists the person's agents;POST /agentswithname(lowercase letters, digits, hyphen, 2 to 32 chars) mints a tokensl_+ 40 base62 chars, stores SHA-256, shows it once;POST /agents/{id}/revoke. The principal string isname~operatorEmail; the actor recorded isagent:<id>.bearerActormiddleware for/api/v1/*: readsAuthorization: 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 actoragent:<id>. -GET /api/v1/decks/{sqid}→ the view JSON plus"sqid"and"seq",ETag: "<seq>", honoursIf-None-Matchwith 304. -POST /api/v1/decks/{sqid}/opsbody{"ops":[...]}; ids foradd_slide/add_block/duplicate_slidemay 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(theDeckUpdate.Actorisagent:<id>and the deck list shows the robot badge withclaude~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.mdstays a pointer - Create:
PRODUCT.md(impeccable product-schema 1, the ten sections, claims cited to the spec),.amadan/ci.d/10-gatereplacing the four scaffold steps: source~/.cargo/env, check tools,cargo test,cargo clippy,scripts/build-wasm.shthencmpagainstinternal/slides/core/testdata/slides_core.wasm,make ci - Test:
make ciand.amadan/ci.d/10-gateboth green locally.
- [ ] Step 1: Write the three documents. Load
humane-docsfor README. Step 2: Gate green. Commitdocs: 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.comand read which env vars it delivers; map them toSLIDES_SMTP_*inmain.goif 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 forlive. - [ ] Step 5: Verify:
curl -s https://slides.oficina.works/api/versionreports the stamped version;GET /303s to/signin;carlos email test --app slides --to paul@keymail.devdelivers. 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/slidesto~/.config/systemd/user/amadan-runner.serviceand restart it (needs a shell outside the sandbox);amadan ci status -branch worktree-slice-1exit 0. - [ ] Step 7: Push the branch, open the branch discussion as
claude~paulnaming what is live and what is not (no editor, no PDF), and hand over.