oficina / slides Public

docs: AGENTS, README, PRODUCT and the one CI gate

AGENTS.md gets the app's real seams and traps, README.md leads with what
slice 1 actually is (no browser editor; the drive API builds the deck),
and PRODUCT.md is the impeccable product-schema written from the spec and
the shipped code, with every reading marked.

The four scaffold steps in .amadan/ci.d/ covered only the Go half, so
nothing in CI ever looked at the Rust core or the wasm artifact the host
embeds. One 10-gate replaces them — rustfmt, clippy, the wasm build, a cmp
against the committed artifact, the Rust tests, then make ci — and
.amadan/ci execs it so a runner without step support runs the same thing.

It found two things on its first run, both fixed here: core/src/layout.rs,
core/src/ops.rs and core/tests/ops.rs had drifted from rustfmt, and the
committed slides_core.wasm was already stale before that reformat moved
the panic locations baked into it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 3c966859975db425959f3f052c1896e26e462ccd parent d1fbbdb
14 files changed, +781 −103
  • .amadan/README.md +75 −0
  • .amadan/ci +7 −4
  • .amadan/ci.d/10-gate +75 −0
  • .amadan/ci.d/10-vet +0 −2
  • .amadan/ci.d/20-fmt +0 −2
  • .amadan/ci.d/30-test +0 −2
  • .amadan/ci.d/40-migration-check +0 −2
  • AGENTS.md +237 −60
  • PRODUCT.md +263 −0
  • README.md +58 −15
  • core/src/layout.rs +51 −9
  • core/src/ops.rs +14 −5
  • core/tests/ops.rs +1 −2
  • internal/slides/core/testdata/slides_core.wasm binary

Intent

Human

<task-notification> <task-id>a034240109b0be266</task-id> <tool-use-id>toolu_01YZ5s1qjySR9ArqvcCqXS2w</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a034240109b0be266.output</output-file> <status>completed</status> <summary>Agent "Implement Task 6: presenting, remote" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Status: done — TDD red→green, `make ci` green (vet, gofmt, tests, migration check), committed, not pushed. Commit: `d1fbbdb` feat: presentation sessions, presenter view, phone remote, audience follow (23 files, +2810). Tests: 2 hub tests + 6 named integration tests all pass (SSE asserted against a real `httptest` server under a context deadline; `-race` clean); full suite green. Concerns: - Pressing Present twice reuses the session but mints a fresh remote token, retiring a paired phone (only the hash is stored; the pairing copy says so). - A bearer caller must still send `Origin` — `csrf.Protect` is app-wide by design. - `?slide=N` on the surface is local browsing; prev/next are command forms only when the request carries the remote token (a sqid-only watcher must not drive the room). - `decks.html` left untouched (brief listed it): the row already links to the deck, where Present lives. - Copy not run through the copy-review skill — no interactive user in this session; every string is a key in `en.toml`. Report: `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-07-slice-1-live/task-6-report.md`</result> <usage><subagent_tokens>265073</subagent_tokens><tool_uses>105</tool_uses><duration_ms>1504799</duration_ms></usage> </task-notification>

Human

<task-notification> <task-id>afa97200ca1369963</task-id> <tool-use-id>toolu_01S7SKLBeLMEkKDgQB4xBXaA</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/afa97200ca1369963.output</output-file> <status>completed</status> <summary>Agent "Review Task 6 (spec + quality)" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>This confirms the visible-text-plus-`&lt;progress&gt;` pattern from spec §3.4 (counter text carries the semantics via a screen-reader-visible span, `&lt;progress&gt;` is `aria-hidden` decoration). All checks are complete. ### Spec Compliance **Verdict: Compliant.** All routes, the model shape, the hub semantics, the token/code/sqid capability split, the 12-hour/ended 410 handling, the scriptless floor, and audience follow all match spec §3.4/§5/§8 and the controller decisions. - `Presentation` model matches the brief field-for-field: `internal/slides/models.go:197-217`. - `present.Hub` matches interface exactly (`New`, `Subscribe(key) (&lt;-chan Event, cancel)`, `Publish`, `Event{Slide,Blackout,Ended}`, buffer 8, slow-subscriber dropped): `internal/slides/present/hub.go`. - `POST /d/{sqid}/present` via `deckFor`→`visibleDecks` (not owner-only), reuses a live session by same actor, mints a fresh token, 303s to `/p/{session}/notes?t=&lt;token&gt;`: `internal/slides/presentation.go:270-320` (matches the controller decision's `?t=`, overriding the brief's stray `?remote=`). - Routes outside the session group all present and capability-addressed: `internal/slides/app.go:73-87`. - SSE: `event: slide`, one event on connect, 15s ping, 30s write deadline, 5min TTL, `r.Context().Done()` handled: `internal/slides/presentation.go:987-1074` — exactly the constants named in the brief (`streamWriteDeadline`, `streamHeartbeat`, `streamTTL`). - `POST /p/{session}/command`: `op` next/prev/goto/blackout/end, `t` form or Bearer, constant-time compare (`subtle.ConstantTimeCompare`, `presentation.go:121`), `UPDATE ... WHERE id=? AND ended_at IS NULL` inside a transaction (`presentation.go:1170-1180`), publishes, 303 to same-origin Referer else 204 (`presentation.go:1200-1210`, using `a.sameOrigin`). - `GET /r/{code}` pair, dead code 404; `GET /p/{session}/remote?t=` four big forms (56px buttons). - Ended/&gt;12h → 410 via `ErrorPage`-equivalent `a.gone`, applied consistently through `livePresentations` (scoping query) and `presentationOver` (single-row check) — both use the same `presentTTL` constant. - Audience follow lives in `sharedDeck` (`share.go:350-368`) with `?follow=1`, scriptless `&lt;meta refresh 5&gt;`, script optional; correctly falls back to a plain redirect when the session isn't live (including mid-view — tested). - `present.js` is 117 lines (verified by `wc -l`), page complete without it, `data-remote-token` only rendered for the token holder (`present.html:65`, `present_notes.html`, `remote.html`). - QR via `github.com/skip2/go-qrcode`, inline SVG, `role="img"`, `aria-label` naming the code, black-on-white fixed: `presentation.go:qrSVG`. - Every write records an actor; every visible string is a catalog key (`en.toml` diff has matching keys for every `rastrillo.T`/`Tf` call used). ### Strengths - The hub's departure from Docs' advisory-seq pattern (drop-and-close instead of skip) is well-reasoned and directly tested (`TestSlowSubscriberIsDropped`), and the SSE handler's `case e, ok := &lt;-ch: if !ok { return }` correctly turns that into a clean reconnect. - `commandChanges` centralizes the write-shape as a `map[string]any` allowlist (rastrillo SKILL.md §4 discipline), never binding the form directly onto the model. - Three-tier capability model (sqid view / token drive / code name) is consistently enforced everywhere a session is resolved — `presentationFor`, `remote`, `remotePair`, `presenterNotes` all agree on who may see what. - `presentCommand`'s Referer redirect is checked against the app's resolved origin (`a.sameOrigin`), so it cannot become an open redirect — verified by reading `sameOrigin`'s implementation. - No token appears in any log line (`a.fail` logs only `what`/`err`/`ref`; the one `logger.Info` call logs session/op/slide/actor, not `t`), and rastrillo's own framework logging (`serve.go`) doesn't log full request URLs either. - Test coverage is unusually thorough and exercises the exact edge cases named in this review's brief: wrong/absent token → 404 (`TestWrongRemoteTokenIs404`), command on an ended session → 410 (`TestEndedSessionIs410`), follow ending mid-session → redirect back to deck (`TestFollowOfferedOnlyWhileLive`), scriptless forms (`TestScriptlessRemoteButtonsAreForms`), and `next` past the last slide clamping rather than erroring. - `go build ./internal/slides/...` compiles clean. ### Issues #### Critical None found. #### Important None found. #### Minor - **`decks.html` left untouched** despite being listed in the brief's file list. The report gives a reasoned justification (avoiding a second "Present" entry point per SKILL.md §7's anti-stacking rule), and `viewer.html` does carry the Present form — but this is a deviation from the brief that a controller should explicitly bless rather than have judged for them by the implementer alone. - **Two simultaneous live presenters on one deck**: `liveSessionFor` (used by both the audience-follow offer and by nothing else) picks only the most-recently-started session via `Order("started_at DESC")`. An audience member could be silently switched to a different presenter's session if a second person starts presenting the same deck while the first is still live. Documented in a comment, not tested, and a plausible-but-unverified edge case (spec doesn't rule on this scenario either). - **`goto` out-of-range** is handled correctly (`commandChanges` returns `errBadCommand` → 422) but has no dedicated test — only in-range `goto` and past-the-end `next` are exercised in `TestRemoteCommandAdvancesSlideAndPublishes`. Behavior is correct on inspection; only test coverage is thin here. - Bearer-caller-must-send-Origin friction (report's own flagged concern #2) is real and confirmed by reading `csrf.Protect`/`SameOrigin` — a native remote with no browser-style headers would be refused with 403 today. This is a known, accepted trade-off per the report, not a defect, but worth the controller's awareness for the "native remote" future spec mentions. ### Assessment **Task quality:** Approved **Reasoning:** Every route, model field, capability rule, and scriptless requirement in the brief and spec §3.4/§8 is implemented and matches; the three named-risk areas I traced by hand (SSE cleanup/hibernation timing, hub non-blocking Publish, token non-leakage, Referer open-redirect, 12-hour consistency, QR escaping, present.js line count and token-gating) all check out on direct inspection of the diff and, for the line count and compile-cleanliness, direct verification against the working tree. Test coverage for the request edge cases named in this review (wrong token, ended session, follow-mid-session) is present and exercises the real code paths (real SSE stream, real hub, real transaction). The only findings are minor: one undocumented-to-the-controller file-list deviation (justified but should be surfaced), a documented-but-untested multi-presenter edge case, and thin test coverage for one already-correct branch (goto out of range).</result> <usage><subagent_tokens>135894</subagent_tokens><tool_uses>49</tool_uses><duration_ms>253080</duration_ms></usage> </task-notification>

Attributed to this commit: 13.3 g CO₂e (1640033 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on worktree-slice-1, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

diff --git a/.amadan/README.md b/.amadan/README.md
new file mode 100644
index 0000000..1833d8c
--- /dev/null
+++ b/.amadan/README.md
@@ -0,0 +1,75 @@
+# CI
+
+`ci.d/10-gate` is the only CI this repo has. It runs on every push to the
+amadan remote, and it is the same script you can run locally — one
+definition rather than two that drift. `ci` execs it, so a runner with no
+step support runs exactly the same thing rather than a thinner Go-only
+version of it, which is what the four scaffold steps here used to be.
+
+It needs a Rust toolchain with the `wasm32-unknown-unknown` target and the
+`rustfmt` and `clippy` components (`rust-toolchain.toml` pins 1.98.0 and
+asks for all three), and Go. There is no Node in this gate: slice 1 ships
+no JS bundle. `CGO_ENABLED=0` is exported for every step. The gate sources
+`~/.cargo/env` itself and checks every tool up front, so a missing
+dependency fails on the first line with its own name rather than fifty
+lines in as `cargo: command not found`.
+
+Nothing in it reaches the network once the Cargo registry and Go module
+caches are warm. `make migration-check` runs the rastrillo CLI through
+`go run` off `go.mod` rather than needing a binary on `PATH` — a bare
+`rastrillo migration check` is what failed a sibling repo's very first CI
+run, with nothing in the scaffold saying what to install.
+
+## Two things that fail silently
+
+- **The gate fails if `internal/slides/core/testdata/slides_core.wasm`
+ differs from a fresh build.** `go:embed` will otherwise embed a stale
+ module and every Go test will pass against it. When it fails, rebuild
+ and commit the artifact:
+
+ ```sh
+ ./scripts/build-wasm.sh
+ cp core/target/wasm32-unknown-unknown/release/slides_core.wasm \
+ internal/slides/core/testdata/slides_core.wasm
+ ```
+
+ That comparison only works because `scripts/build-wasm.sh` remaps the
+ registry and workspace path prefixes rustc would otherwise bake into the
+ binary. Without it the artifact reproduces byte-for-byte only on the
+ machine, and under the `$HOME`, that built it, and the `cmp` reports a
+ stale artifact everywhere else.
+
+- **`cargo fmt --check` covers `core/`.** It was not in the gate before
+ 2026-09-07, and by then three files had drifted; they were reformatted in
+ the commit that added the check.
+
+## The runner
+
+Jobs are claimed by an `amadan runner` on the machine that has one. Two
+things about it are wrong by default, and both fail in ways that read like
+something else:
+
+- **The runner must be told to serve this repo.** `amadan runner -repo …`
+ is an allowlist, and a repo nobody listed gets `amadan ci status` exit 3
+ forever — "no status recorded", which reads like the gate has not run
+ yet rather than like nothing will ever run it.
+ `~/.config/systemd/user/amadan-runner.service` was edited to list
+ `-repo oficina/slides` on 2026-09-07.
+- **The runner's `PATH` is the systemd unit's, not your shell's**, and
+ `~/.cargo/bin` is not on it. The gate sources `~/.cargo/env` rather than
+ depending on that being fixed.
+
+A restart abandons any job already running, so check the runner is idle
+before restarting it.
+
+## The Go half
+
+`make ci` is the last step, so the Go half of the gate and the `make ci`
+you run between edits are the same definition: `go vet ./...`, a gofmt
+check, `go test ./...`, and `rastrillo migration check`, which fails the
+moment `models.go` and `migrations/` disagree instead of at boot on
+whatever machine notices next.
+
+The browser drive (`go test -tags browser ./...`) is deliberately outside
+all of this. It needs a Chromium, and this project's sandbox will not start
+one, so a gate that ran it would be reporting "could not see" as green.
diff --git a/.amadan/ci b/.amadan/ci
index 8c547ee..97ec977 100755
--- a/.amadan/ci
+++ b/.amadan/ci
@@ -1,6 +1,9 @@
#!/bin/sh
-# amadan CI entry (single-script fallback for runners without step
-# support). The steps in ci.d/ are the same targets, reported one by
-# one. Must stay executable: a non-executable script resolves "skipped".
+# amadan CI entry, for a runner with no step support. It runs the same
+# script `ci.d/10-gate` is rather than being a second definition of the
+# gate — the four scaffold steps that used to live in ci.d/ each ran one
+# make target, and between them covered only the Go half of this repo.
+#
+# Must stay executable: a non-executable script resolves "skipped".
set -e
-exec make ci
+exec "$(dirname "$0")/ci.d/10-gate"
diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate
new file mode 100755
index 0000000..5c22dcf
--- /dev/null
+++ b/.amadan/ci.d/10-gate
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+# The only CI this repo has. Everything a branch must pass before it can
+# squash-merge, in one script, so what the runner executes and what you
+# run before pushing are one definition rather than two that drift.
+set -euo pipefail
+
+# Run from the repo root whatever the caller's working directory is:
+# .amadan/ci execs this file, an amadan runner with step support runs it
+# directly, and you will run it by hand from wherever you happen to be.
+cd "$(dirname "$0")/../.."
+
+# rustup installs to ~/.cargo/bin, which is on neither a systemd user
+# unit's PATH nor a login shell's when rustup ran with --no-modify-path.
+# Source it here rather than trusting whoever set the runner up to have
+# got it right — a gate that only works on one machine is not a gate.
+[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
+
+for tool in cargo rustc rustfmt go; do
+ command -v "$tool" >/dev/null || { echo "gate: $tool not on PATH" >&2; exit 1; }
+done
+rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown \
+ || { echo "gate: wasm32-unknown-unknown target missing — rustup target add wasm32-unknown-unknown" >&2; exit 1; }
+
+# The stack is cgo-free by design, here as in scripts/run-dev.sh and the
+# Makefile. A build that quietly used cgo would not be the binary that
+# ships to an arm64 instance.
+export CGO_ENABLED=0
+
+echo "=== rust: fmt"
+# --manifest-path, because there is no workspace at the repo root: the
+# crate lives in core/ and a bare `cargo fmt` here finds no Cargo.toml.
+cargo fmt --manifest-path core/Cargo.toml --check
+
+echo "=== rust: clippy"
+# clippy.toml's disallowed-methods is the real enforcement of the
+# Doc-construction rule that core/src/lib.rs documents: Doc::new() and
+# Doc::with_client_id() both draw entropy this build has no safe source
+# for, and a doc comment alone has been got around before.
+#
+# scripts/build-wasm.sh below runs this same invocation, flag for flag,
+# before it builds. That is deliberate: it fails here in a few seconds
+# rather than forty seconds into the wasm build, and because the two
+# spellings are identical there is no looser check to sail through —
+# the drift Docs' gate hit when its two clippy runs disagreed.
+cargo clippy --manifest-path core/Cargo.toml --all-targets -- -D warnings -D clippy::disallowed_methods
+
+echo "=== wasm: the artifact the host embeds"
+# Before cargo test, not after. core/tests/no_host_imports.rs asserts the
+# module imports nothing from the host by reading the wasm32 artifact off
+# disk, and that file only exists once this line has run. Run the tests
+# first and the assertion's own presence becomes a race it reports as a
+# skip.
+./scripts/build-wasm.sh
+
+echo "=== the embedded wasm must match a fresh build"
+if ! cmp -s core/target/wasm32-unknown-unknown/release/slides_core.wasm internal/slides/core/testdata/slides_core.wasm; then
+ echo "gate: internal/slides/core/testdata/slides_core.wasm does not match a fresh build — rebuild and commit the wasm:" >&2
+ echo " ./scripts/build-wasm.sh && cp core/target/wasm32-unknown-unknown/release/slides_core.wasm internal/slides/core/testdata/slides_core.wasm" >&2
+ exit 1
+fi
+
+echo "=== rust: test"
+# SLIDES_REQUIRE_WASM=1 turns no_host_imports.rs's "artifact missing"
+# branch from a skip into a failure. Safe to set unconditionally here
+# because the build above just produced it; left unset, a bare local
+# `cargo test` on a fresh clone still skips rather than forcing a wasm
+# build first.
+SLIDES_REQUIRE_WASM=1 cargo test --manifest-path core/Cargo.toml
+
+echo "=== go: vet, gofmt, test, migration check"
+# The Makefile's own ci target, so the Go half of this gate and the
+# `make ci` you run between edits cannot come apart.
+make ci
+
+echo "gate: ok"
diff --git a/.amadan/ci.d/10-vet b/.amadan/ci.d/10-vet
deleted file mode 100755
index 139a2a2..0000000
--- a/.amadan/ci.d/10-vet
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/sh
-exec make vet
diff --git a/.amadan/ci.d/20-fmt b/.amadan/ci.d/20-fmt
deleted file mode 100755
index 5884a7b..0000000
--- a/.amadan/ci.d/20-fmt
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/sh
-exec make fmt-check
diff --git a/.amadan/ci.d/30-test b/.amadan/ci.d/30-test
deleted file mode 100755
index 758ffe2..0000000
--- a/.amadan/ci.d/30-test
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/sh
-exec make test
diff --git a/.amadan/ci.d/40-migration-check b/.amadan/ci.d/40-migration-check
deleted file mode 100755
index 81db093..0000000
--- a/.amadan/ci.d/40-migration-check
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/sh
-exec make migration-check
diff --git a/AGENTS.md b/AGENTS.md
index ba0d0eb..fd18a60 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,72 +1,250 @@
-# slides
+# AGENTS.md
-A [rastrillo](https://amadan.net/rastrillo/rastrillo) app. Read the
-framework's own SKILL.md (repo root, or in the module cache) before
-writing app code — it is the whole app story in ~15KB. The conventions
-below are load-bearing; the framework enforces most of them
-mechanically.
+Guidance for AI coding agents working in this repository. This is the
+canonical agent-instructions file; `CLAUDE.md` points here and duplicates
+nothing, because two copies drift and the drift is silent.
+
+Slides is a presentation editor for the Oficina suite: a deck that looks
+like a Keynote deck on a desktop, reads like a document on a phone,
+presents from a laptop and is driven from a phone. One instance per team
+on the CARLOS platform, at `slides.oficina.works` for the first team.
+It is built beside [Docs](https://amadan.net/oficina/docs),
+[Sheets](https://amadan.net/oficina/sheets) and the suite
+[doctrine](https://amadan.net/oficina/suite); where a decision is already
+made over there, this repo consumes it rather than restating it.
+
+Design: `docs/superpowers/specs/2026-09-07-slides-design.md` (revision 1,
+still unreviewed — §12 puts the review pass after slice 1 ships, so the
+reviewer gets a running instance instead of a document). Plan:
+`docs/superpowers/plans/2026-09-07-slice-1-live.md`. Read the spec section
+behind whatever you touch. Read rastrillo's own `SKILL.md` (repo root, or
+in the module cache) before writing handlers or models — it is the whole
+app story in about 15KB, and you should assume none of it is in your
+training data.
+
+## The one rule
+
+**A slide is one block tree, rendered many ways** (spec §0.1). The tree is
+the only truth. The viewer renders it, the phone reflows it, the presenter
+shows it, the drive API patches it, and the editor — when it exists — will
+edit that same tree. There is never a desktop version and a mobile version
+of a deck, only one deck and a viewport.
+
+Everything else follows. `flow` and `canvas` are two ways of placing the
+same blocks, not two kinds of slide (§2.3). The phone overrides are entries
+in the tree, not a second file (§2.5). The drive API is the inspector's
+operations with a JSON body (§7). If a change you are making needs a second
+representation of a deck, you have almost certainly taken a wrong turn.
+
+## What is actually built
+
+This is slice 1, and the honest summary is that **there is no browser
+editor yet**. A deck is built through the drive API (`docs/api.md`) or it
+arrives as the sample deck seeded on first sign-in. Everything downstream
+of the tree works:
+
+- **Sign-in** is a magic link (`internal/slides/auth.go`), gated by
+ `SLIDES_ALLOWED_ADDRESSES` in front of `POST /signin` as well as in
+ rastrillo/auth's `Authorize`. The gate in front matters more, because
+ that is the one that stops the mail; a non-permitted address gets
+ byte-for-byte the same `303 ?sent=1` a permitted one gets, or the
+ endpoint becomes a membership oracle.
+- **Decks**: list, create, settings, delete, three rungs (private,
+ granted, team) per suite ruling 15.
+- **The viewer** (`internal/slides/viewer.go`): one slide per page,
+ previous and next as ordinary links, notes in a `<details>`. The owner,
+ a team member and a stranger holding a share link all get the same page
+ from the same template with `mode` as a field, because the day those
+ three drift is the day one of them leaks the speaker notes.
+- **Share links** (`share.go`): a read-only capability, SHA-256 stored,
+ URL shown once, revocable. A missing token and a revoked one 404 alike.
+- **Presenting** (`presentation.go`, `present/hub.go`): a server-side
+ session with a pairing code, the fullscreen surface, the presenter view
+ with notes and a clock, the phone remote, and audience follow from a
+ share link.
+
+Not in this slice, and don't write around their absence: PDF export and
+the SVG layout engine, images and the object store, the collaborative
+editor, presence, and the desktop/phone switcher. Those are slices 2 and 3
+(spec §10). `docs/api.md` is the reference for the API, written for a
+person and an agent both.
## Layout
-- `internal/<app>/` — the app: `models.go` (plain GORM structs
- + Models), `migrations.go` + `migrations/` (Schema, BootSchema),
- `app.go` (migrate.Apply + chi router), `handlers.go`,
- `render.go` + `templates/`. This is SKILL.md's five-file shape.
-- `manifest/` — the declarative path, optional and equal: TOML or .go
- Resource manifests, each generating a full screen set under `gen/`
- (see manifest/README.md for the mounting recipe). A hand-written
- action file at a generated path (carrying `//go:build rastrillo_actions`)
- takes that one screen over, by existence.
-- `gen/` — generated when manifests are used; never edit. Regenerate
- with `rastrillo generate`.
+`internal/slides/` is the app in SKILL.md's five-file shape — `models.go`,
+`migrations.go` plus `migrations/`, `app.go`, `handlers.go`, `render.go`
+plus `templates/` — with one file per screen area beside them (`auth.go`,
+`decks.go`, `viewer.go`, `share.go`, `presentation.go`, `sample.go`) and
+one package per concern nested underneath: `core` (the wazero host),
+`deck` (Go types for the core's JSON view), `render` (slide HTML),
+`present` (the SSE hub). `core/` at the repo root is the Rust crate.
+`manifest/` and `gen/` are rastrillo's declarative path, unused here.
+
+## The seams
+
+A handful of functions carry most of the app's invariants. Go through them
+rather than around them; a new handler that reaches past one is the usual
+way a rule stops holding.
+
+`currentUser(r)` (`auth.go`) maps `sessions.Current(r).Subject` — an email
+address under rastrillo/auth, never a user id — to the `users` row, making
+it on first sign-in. `actorFor(u)` turns that row into the `person:<id>`
+string every write records (§6.2); the API's bearer middleware produces
+`agent:<id>` the same way.
+
+`visibleDecks(ctx, viewer)` is the read scope: owner, team rung, or a
+grant. Its `OR` chain is parenthesised inside the string on purpose —
+every caller chains another `Where` on, and an unparenthesised `OR` widens
+it to "or this deck is on the team rung", defeating the whole seam by
+operator precedence. `deckFor` is a deck the person may read; `ownedDeckFor`
+is one they own. Both answer **404, never 403**, so a stranger holding a
+sqid cannot learn whether it is real.
+
+`openDeck` instantiates the Y.Doc: a fresh wasm module, every stored update
+replayed in seq order, and the JSON view it produces. The handle is the
+caller's to close. There are no snapshots, so this replays the whole log
+every time — fine at today's sizes, and the thing to fix somewhere around a
+thousand updates on one deck.
+
+`applyOps` is the only write path into a document. It puts the core's
+update row, the `seq` bump and any deck-row columns in one transaction with
+the seq as an optimistic lock, so the row and the log commit or roll back
+together. `editDeck` wraps it with the handle's whole life and one retry on
+`errDeckBusy`. Nothing else may append to `deck_updates`.
+
+`render.Slide(d, index, viewport)` is the only place slide HTML is
+produced, for every surface. It builds markup by hand rather than
+templating it, because `html/template` cannot take a dynamic tag or
+attribute name; read the package doc comment for the four invariants that
+make that safe before adding a block type.
+
+`present.Hub` is the in-memory fan-out for a live presentation. Its events
+are whole state, never deltas, so a subscriber that falls behind is dropped
+and reconnects rather than having events skipped — a skipped event leaves a
+projector showing slide 4 for the rest of a talk with nothing to notice it.
+
+## Traps
+
+- **Never call `sessions.UserID`.** Under rastrillo/auth the Subject is
+ the email address, so it answers `(0, false)` and would scope every
+ owned query to `user_id = 0`. Map the address to the row first.
+- **The committed wasm is not rebuilt for you.** `go:embed` will happily
+ embed a stale `internal/slides/core/testdata/slides_core.wasm` and the
+ Go tests will pass against it. The gate rebuilds and `cmp`s; when it
+ fails, rebuild and commit the artifact rather than arguing with it.
+- **`Doc::new()` and `Doc::with_client_id()` are banned in the Rust core**
+ (`clippy.toml`) — both draw entropy this wasm build has no safe source
+ for. Use `Options::with_guid_and_client_id`. A doc comment alone was got
+ around more than once, which is why it is a lint.
+- **Exactly one directory under `internal/`.** `rastrillo migration check`
+ requires it, which is why every package is nested under
+ `internal/slides/` (`core`, `deck`, `render`, `present`) rather than
+ sitting beside it.
+- **A hardcoded English string is a bug.** Every user-visible string is a
+ key in `internal/slides/locales/en.toml`, resolved through `rastrillo.T`
+ at render time. Twelve locales are the suite target; this slice ships
+ `en` and leaves the keys ready. Deck titles and timestamps are data, not
+ copy, and are correctly not keys.
+- **Every screen works with scripts off** (spec §8) — including the
+ remote, where each button is a form that POSTs and redirects back.
+ JavaScript is an enhancement on the presenting surfaces and nothing
+ else depends on it.
+- **`cargo fmt --check` covers `core/`.** It was not in the gate until
+ 2026-09-07 and three files had drifted by then; they were reformatted in
+ the same commit that added the check.
## Rules the family holds hard
-- Every query touching user-owned rows goes through `scope.Owned` —
- reads AND writes, transactions included. A row that isn't yours 404s,
- never 403s.
-- Never bind a request body onto a GORM model: explicit
- `map[string]any` + `.Select` allowlist.
+- Every query touching user-owned rows goes through the seams above —
+ reads and writes, transactions included.
+- Never bind a request body onto a GORM model: explicit `map[string]any`
+ plus a `.Select` allowlist.
- Migrations apply at boot, once each, and are never re-run or reversed.
-- Schema changes: edit models.go, then run `rastrillo migration generate`
- and read the SQL it writes — it may be a full table rebuild, not just
- an `ADD COLUMN`. Never edit a migration that has shipped.
- Renames are hand-written (`rastrillo migration new rename_x`) because
- a rename is indistinguishable from a drop plus an add.
-- Money is integer cents. A float never touches a value a person will
- be held to.
-- Screens work with JavaScript disabled; destructive actions get their
- own confirm-page URL.
-- The gate is `make ci` (vet + gofmt + tests) — the same definition
- CI runs. Run it before every push. `CGO_ENABLED=0` throughout: the
- stack is cgo-free by design.
-- `/` is behind sign-in (`internal/slides/auth.go`), so `/signin` and
- `/static/*` are the only screens a signed-out request reaches. The
- scaffold's two placeholder-index tests were repointed there when
- sign-in landed; `signedIn(t, app, address)` in
- `internal/slidestest/harness_test.go` is how a test gets a session —
- it runs the real magic-link flow against a fake mailer, never a
- hand-minted cookie.
-- Under `rastrillo/auth` the session Subject is the **email address**,
- not a user id. Never call `sessions.UserID`: it answers `(0, false)`
- and would scope every owned query to `user_id = 0`. Map the address
- to a `users` row first — `(*app).currentUser(r)`.
-
-## Serving
-
-`rastrillo.Resolve` + `Serve` speak the CARLOS activation contract
-(socket/addr/db argv, LISTEN_FDS, $STATE_DIRECTORY, `sidecar run`) —
-the golden deployment target, not a requirement: `./app -addr :8080`
-serves anywhere. `GET /healthz` and `GET /api/version` are answered
-by the framework, outside app middleware.
+ Change `models.go`, then run `rastrillo migration generate` and *read
+ the SQL* — it may be a full table rebuild rather than an `ADD COLUMN`.
+ Never edit a migration that has shipped. Renames are hand-written
+ (`rastrillo migration new rename_x`), because a rename is
+ indistinguishable from a drop plus an add.
+- Every state-changing route is a POST behind `csrf.Protect`. Destructive
+ actions get their own confirm-page URL, never a modal.
+- Markup is the bare `rst-*` attribute grammar (`<a rst-btn="primary">`),
+ never `class="rst-*"`. This app's own attributes are `slides-*`.
+- WCAG 2.2 AA with no exemptions: 24×24 targets, 320px reflow, colour
+ never the only carrier of state, `alt` on every image.
+- `CGO_ENABLED=0` throughout. The stack is cgo-free by design and the
+ binary that ships is `linux/arm64`.
-## UX conventions (seeded from profile: considered)
+## Running it locally
+
+`./scripts/run-dev.sh` builds and runs the app on `localhost:8091`
+(`$SLIDES_DEV_PORT` to move it) against a scratch database under `$TMPDIR`
+that starts empty every run. Sign in with any address; the magic link is
+logged to the terminal instead of being mailed, and it is a real link
+through the real flow.
+
+Development mode is the only place four settings default — a fixed public
+instance key, mail to the terminal, an allowlist that admits everyone, and
+a directory-backed object store — and it can only be selected by the `-env
+development` flag. `$SLIDES_ENV` cannot reach it, deliberately: platform
+config *is* environment variables, so a stray `SLIDES_ENV=development`
+beside the real ones would otherwise be a production-reachable path to a
+public instance key and to every magic link landing in the instance log.
+Anywhere but development, a missing `SLIDES_ORIGIN`,
+`SLIDES_INSTANCE_KEY`, `SLIDES_SMTP_*` or `SLIDES_ALLOWED_ADDRESSES` is a
+startup refusal rather than a default.
+
+## The gate
+
+`.amadan/ci.d/10-gate` is the only CI this repo has, it runs on every push
+to the amadan remote, and it is the same script you run locally:
+
+```sh
+./.amadan/ci.d/10-gate
+```
-Accessible by construction, progressively enhanced, and never dependent on JavaScript to be usable.
+It does rustfmt, clippy with the disallowed-methods lint, the wasm build,
+the `cmp` against the committed artifact, the Rust tests, then `make ci`
+(vet, gofmt, Go tests, migration check). `make ci` alone is the fast loop
+between edits; the full gate is what you run before pushing. See
+`.amadan/README.md` for what it needs installed and for the runner's own
+two traps.
+
+## Deploying
+
+One static binary, stamped: `make release` (refuses an empty version or a
+dirty tree — a `-dirty` stamp names a commit plus changes nobody can name,
+so two different binaries can carry the same string). Then `carlos deploy
+--app slides --account oficina`, with `carlos store create` for the object
+store and `carlos domains attach slides.oficina.works`.
+`SLIDES_INSTANCE_KEY` and `SLIDES_SMTP_PASS` go in via `carlos secrets`,
+the rest via `carlos env`, mirroring Docs' set (spec §6.3).
+
+Verify against `x-carlos-version` on the real host or let `carlos deploy`
+watch the URL. `carlos channels` reports which release a channel *points
+at*, which is not the question of what the box is *serving* — adoption runs
+on a timer and the two can disagree for up to an hour, with nothing saying
+"not live yet".
+
+## Working through amadan
+
+This repo is hosted on an amadan hub, not GitHub: no `gh`, no pull request,
+no GitHub Actions. Post as the agent account (`<name>~<operator keymail>`),
+never as Paul. Work happens on a worktree branch and reaches `main` only by
+squash merge, after review and with the gate green — `amadan ci status
+-branch <n>` exit 0 is the merge gate; exit 2 is pending and exit 3 means
+nothing ran, and neither is green.
+
+**Every branch gets a discussion, opened before the merge, and it is the
+review record.** A squash merge flattens the history that carried the
+branch's reasoning; the discussion is where that survives. If it does not
+exist before the merge, the reasoning is not scattered, it is gone.
+
+## UX conventions (seeded from profile: considered)
-These are rastrillo's own conventions, and the profile is named for what it does rather than after anyone else's work. For wider reading on interface quality, impeccable.style, the WAI-ARIA Authoring Practices and Inclusive Components are all worth your time — none of them is endorsed by, or authoritative for, this framework.
+Accessible by construction, progressively enhanced, and never dependent on
+JavaScript to be usable.
-- [x] Icons — lucide, inline — `internal/<app>/icons`
+- [x] Icons — lucide, inline — `internal/slides/icons`
- [ ] Screen flow — one job per screen — a link, then a page for that action alone, then a confirmation; never two ways to start work side by side
- [x] Fields — label, hint, help and error in one envelope, wired with aria-describedby — `ui/field, ui/field-select, ui/field-text`
- [x] Destructive actions — their own confirm-page URL, never a modal — `ui/confirm-form`
@@ -80,6 +258,5 @@ equivalent. `[ ]` is guidance to apply by hand. A convention absent from
this list is one rastrillo has no opinion about.
Edit any line. **This file is the source of truth, not the profile name.**
-The profile only seeded this list at scaffold time; nothing re-reads it, so
-changing a line here changes what the app does. A convention deleted from
-this file is not a convention to reinstate.
+Nothing re-reads the profile, so changing a line here changes what the app
+does, and a convention deleted from this file is not one to reinstate.
diff --git a/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 0000000..d3bade2
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,263 @@
+# Product
+
+<!-- impeccable:product-schema 1 -->
+
+> **How this file was written.** Impeccable's `init` normally interviews
+> the user before writing anything here. This one was written from
+> repository evidence instead, during slice 1: the design
+> (`docs/superpowers/specs/2026-09-07-slides-design.md`, revision 1), the
+> plan (`docs/superpowers/plans/2026-09-07-slice-1-live.md`), `AGENTS.md`,
+> and the shipped code in `internal/slides/`. Facts traceable to a spec
+> ruling or to code are cited. Anything that is a reading rather than a
+> citation is marked **[inferred — confirm]** and should be corrected
+> rather than trusted.
+
+## Platform
+
+web
+
+## Users
+
+**Primary: a member of one team, making and giving a talk.** The instance
+boundary *is* the team — one deployed instance per team, not one
+multi-tenant app with teams inside it (spec §0.2, suite rulings 1 and 5).
+Sharing is therefore membership plus a link capability, not a permission
+matrix.
+
+**Secondary: an agent, driving a deck on a person's behalf.** Not a
+metaphor and not a later feature — in this slice the agent surface is the
+only way to build a deck at all. An agent is its own principal,
+`<name>~<operator email>`, minted by a signed-in person and named in every
+write it makes (spec §1 ruling 7, §7.1). It never borrows a person's
+session, and the API never reads a session cookie.
+
+**Third: someone holding a share link**, who has no account and gets a
+read-only deck (spec §5). If a talk is live, that same link offers "follow
+the presenter" (§3.4).
+
+Access today is a magic link gated by `SLIDES_ALLOWED_ADDRESSES`
+(`internal/slides/auth.go`) — a per-instance allowlist, exactly Docs'
+shape, chosen because the suite home that will own identity is a skeleton
+(spec §1 ruling 8). Guest grants and edit links wait on that home (§5, §9).
+
+**[inferred — confirm]** The team is assumed small enough that an address
+allowlist is the right admission mechanism. Nothing in the repo states a
+target team size.
+
+## Product Purpose
+
+Slides is a **presentation editor** for the Oficina suite: a deck that
+looks like a Keynote deck on a desktop, reads like a document on a phone,
+is presented from a laptop and driven from a phone, exports to PDF, and is
+driveable by an agent through the same operations a person has (spec
+preamble).
+
+Success is a deck that is genuinely the same deck everywhere it is seen —
+one block tree, rendered for a viewport rather than authored twice (§0.1).
+A phone does not get a cut-down deck; it gets the same deck reflowed.
+
+## Positioning
+
+Three things a neighbouring presentation tool could not copy without
+rebuilding its foundations:
+
+1. **One tree, one renderer, no second layout.** The layout modes are two
+ placements of the same blocks, and the phone layer is entries in the
+ same tree (§2.3, §2.5). `internal/slides/render` is the only code that
+ turns a slide into HTML, and it serves the viewer, the presenter view,
+ the remote and — when it exists — the editor's stage (§4.1). A product
+ with a mobile view bolted on cannot make that claim afterwards.
+2. **An agent is a first-class author, badged as itself.** Every write
+ records `person:<id>` or `agent:<id>` (`DeckUpdate.Actor`,
+ `internal/slides/models.go`; spec §6.2), and the agent's writes are
+ attributed to the agent with its operator named, never laundered into
+ the person's name. Slides is the first Oficina app to build that
+ surface (§0.2).
+3. **Presenting is a server-side session, so the room is one system.** The
+ presenter view, the projector, the phone remote and every audience
+ follower read and write one record (§3.4, `internal/slides/
+ presentation.go`). A phone remote is not possible at all in a design
+ where presenting is just a viewer with no session, which is the
+ rejected alternative recorded in §1 ruling 5.
+
+**What is not shipped and must not be described as though it were:** the
+collaborative editor, presence, PDF export, images. Slice 1 has no browser
+editor; a deck is built through the drive API (`docs/api.md`) or seeded as
+the sample deck (`internal/slides/sample.go`). Spec §10 puts the editor in
+slice 2 and export in slice 3.
+
+## Operating Context
+
+- **One Go binary per instance**, `CGO_ENABLED=0`, cross-compiled to
+ `linux/arm64`, on the CARLOS platform, at `slides.oficina.works` for the
+ first team (spec §6.3, `Makefile`).
+- **The deck is a Yjs document held by a Rust `yrs` core compiled to wasm
+ and run under wazero** (spec §1 ruling 3; `core/`,
+ `internal/slides/core`). The Go side persists updates in SQLite, asks
+ the core for a JSON view, and renders that view. This is Docs' pattern,
+ adopted rather than reinvented.
+- **Routes shipped today** (`internal/slides/app.go`): sign-in and the
+ deck list, `/decks/new`, `/d/{sqid}` and its notes, settings, share and
+ delete pages; `/s/{token}` for a share link; `/p/{session}` and its
+ notes, stream, remote and command; `/r/{code}` to pair a phone. The
+ share, present and remote routes sit outside the session group on
+ purpose — the projector, the phone and the audience each carry their own
+ capability and have no session at all.
+- **Local development** is `./scripts/run-dev.sh`: `localhost:8091`, a
+ scratch SQLite database that starts empty every run, magic links logged
+ to the terminal rather than mailed.
+- **The scriptless floor is a product requirement, not a fallback** (spec
+ §8). Signing in, listing, creating, viewing, presenting, reading notes,
+ managing sharing and using the remote all work with JavaScript off; each
+ remote button is a form that POSTs and redirects. Editing slide content
+ is the one surface that will need a bundle, and that boundary is stated
+ rather than discovered.
+- **Work happens on a worktree branch and reaches `main` only by squash
+ merge**, after review and with the gate green. The review record is an
+ amadan branch discussion, not a GitHub PR — this repo is hosted on an
+ amadan hub (`AGENTS.md`).
+
+## Capabilities and Constraints
+
+**Shipped in slice 1:** magic-link sign-in with an admission allowlist and
+a `users` row per person; deck list, create, settings, delete, three
+sharing rungs; the Rust core with the deck schema, the operation set and
+the JSON view; the HTML renderer with eleven block types, seven flow
+templates, the canvas frame, the phone layer and six themes; the paged
+viewer and the notes page; read-only share links, hashed and revocable;
+presentation sessions with the fullscreen surface, presenter view, phone
+remote and audience follow; the drive API for creating and driving a deck.
+
+**Deliberately not built yet** (spec §9, §10): the browser editor, live
+collaboration and presence, PDF export and the SVG layout engine, images
+and the object store, charts from Sheets, video and embeds, an MCP
+adapter, 4:3 and custom aspect ratios, comments, and version history. Each
+has a named trigger in §9 rather than a date.
+
+**Binding technical constraints:**
+
+- **A slide is one block tree, rendered many ways** (§0.1). Nothing that
+ matters to what a reader sees may live outside the tree.
+- **Every write names its real actor**, `person:<id>` or `agent:<id>`
+ (§6.2). An agent's write names the agent and its operator, never the
+ person (suite ruling 9, plus Paul's 2026-09-07 ruling that agents drive
+ every Oficina app as their own identified principal).
+- **Not-found and not-yours both answer 404, never 403**
+ (`internal/slides/decks.go`), so a stranger holding a sqid cannot learn
+ whether it is real.
+- **Only the SHA-256 of a share link is stored**, so the URL is shown
+ exactly once and the page says so at that moment (`share.go`, §5).
+- **Every user-visible string is a catalog key** resolved through
+ `rastrillo.T` (`internal/slides/locales/en.toml`, 121 keys today).
+ Twelve base locales are the suite target; one, `en`, exists. A
+ hardcoded English string is a bug. Deck titles and timestamps are data,
+ not authored copy, and are correctly not keys.
+- **Markup is the bare `rst-*` attribute grammar**, never `class="rst-*"`;
+ this app's own attributes are `slides-*`.
+- **Themes are a built-in set of token bundles**, six of them (`paper`,
+ `ink`, `sunset`, `forest`, `slate`, `candy` —
+ `internal/slides/render/themes.go`). A deck may override accent and the
+ two typefaces; there are no per-block fonts, which §1 ruling 9 calls the
+ fastest route to an ugly deck.
+- **`CGO_ENABLED=0` everywhere**, and the committed wasm artifact must
+ match a fresh build or the gate fails.
+
+## Brand Commitments
+
+- **Name:** Slides, in the `oficina` namespace at
+ `amadan.net/oficina/slides`, hosted at `slides.oficina.works` for the
+ first team (§1 ruling 11). English, not Spanish — the suite names
+ generic apps in English.
+- **The design system is rastrillo's**, with estilo
+ (`amadan.net/oficina/estilo`) as the suite variant. Vendor tokens rather
+ than copying files by hand.
+- **A slide is not themed chrome.** The deck carries its own theme tokens
+ as CSS custom properties on each `<section slides-slide>`
+ (`render/render.go`); the app's chrome follows the design system and the
+ slide does not follow the reader's preferences. **[inferred — confirm]**
+ The spec does not spell this out for Slides the way Docs' §10.2 does for
+ the page canvas; it is the reading the renderer implements.
+- **The empty state is a designed screen, not a blank page**, and says
+ what the screen is for: "A deck is a set of slides you write, present
+ and share. Start one and it will show up here."
+ (`slides.decks.empty.body`).
+- **Voice, as evidenced by the shipped catalog:** plain, unhedged, second
+ person, no exclamation marks, no product cheer. Headings carry no full
+ stop ("No decks yet"); errors state what happened and what helps ("That
+ link has expired or was already used. Enter your email below for a new
+ one."); a warning goes in front of the button rather than in a help page
+ ("Copy this now — it is not shown again").
+- **What does not exist and must not be invented:** no customers, no
+ testimonials, no benchmarks, no pricing, no usage numbers, no launch
+ date, no press. There is one instance and it is not live yet.
+
+## Evidence on Hand
+
+- **Spec:** `docs/superpowers/specs/2026-09-07-slides-design.md`, revision
+ 1, drafted 2026-09-07 from a brainstorm with Paul the same day. It is
+ **unreviewed**: §12 records that the review pass is the first item after
+ slice 1 ships, so the reviewer has a running instance rather than a
+ document. Treat its rulings as settled and its prose as unaudited.
+- **Plan:** `docs/superpowers/plans/2026-09-07-slice-1-live.md`, eight
+ tasks, this slice only.
+- **Consumed rulings:** the suite sharing doctrine (`oficina/suite`,
+ revision 8) by number — 1, 4, 5, 6, 9, 12, 15 — never restated here
+ (§0.2). Docs' live-sync design is adopted as written for slice 2 (§3.3).
+- **API reference:** `docs/api.md`, every endpoint and every operation
+ with an example, written for a person and an agent both.
+- **Sample content:** `internal/slides/sample.go` seeds one deck on first
+ sign-in, covering each flow template, a canvas slide and a phone-reflow
+ slide — which is also the fastest way to see what the renderer does.
+- **The gate:** `.amadan/ci.d/10-gate`, run by CI and by hand, and
+ `.amadan/README.md` for what it needs.
+
+## Product Principles
+
+1. **State the degradation rather than let it be discovered.** The spec
+ refuses to claim pixel identity between the browser and the PDF and
+ says where they may differ (§0.3, §4.2); the README says in its second
+ paragraph that there is no editor yet; the share page says the URL will
+ not be shown again at the moment it is shown.
+2. **One tree, one renderer.** Any second representation of a deck — a
+ mobile layout, a print document, a snapshot — is a design mistake
+ before it is a maintenance cost, because the two will diverge and the
+ divergence will reach a reader.
+3. **A capability is a capability, and nothing more.** A share link never
+ edits (suite ruling 15). An edit link does not exist, because an edit
+ capability is a sign-in that mints a guest grant, and that waits on the
+ home (§5).
+4. **An agent is a principal, never a costume.** It has its own token, its
+ own name, its own revocation, and its own badge in the activity list
+ (§7.1). The alternative — an agent borrowing a person's session — is
+ recorded as rejected, not overlooked.
+5. **The scriptless floor is the floor, not a fallback.** A screen that
+ only works with JavaScript is a screen that has not been finished
+ (§8).
+6. **A string a person can read is a catalog key.** Localisation is a
+ structural property of the app, not a phase.
+
+## Accessibility & Inclusion
+
+**WCAG 2.2 AA, no exemptions** (spec §8, restated in `AGENTS.md`). Four
+requirements bind concrete decisions here:
+
+- **24×24 minimum target size**, which the phone remote's buttons are
+ sized for — it is a control someone uses in the dark, one-handed, while
+ talking.
+- **320px reflow.** The one allowance is estilo's: the canvas stage may
+ scroll inside its own region in the editor, when the editor exists (§8).
+ Nothing in the shipped viewer uses it.
+- **Colour never carries meaning alone**, so a deck's sharing rung shows
+ a text label beside its tone and never a bare colour.
+- **`alt` on every image.** The model makes it structural rather than
+ advisory: `alt` is a required prop on an `image` block and may be empty
+ only when `decorative` is true (§2.4).
+
+The phone is not a degraded case. Reflow for flow slides and scale-to-fit
+for canvas slides, with a per-slide switch to reflow a canvas slide in
+reading order (§1 ruling 2), exists so that reading a deck on a phone is a
+supported way to read it.
+
+Twelve base locales are the target set; today exactly one exists (`en`).
+That is a commitment, not a shipped state, and this file should not be read
+as though eleven more catalogs are sitting in the repo.
diff --git a/README.md b/README.md
index 0dac123..db7f345 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,65 @@
-# slides
+# Slides
-A [rastrillo](https://amadan.net/rastrillo/rastrillo) app. `make ci`
-is the gate — vet, gofmt, tests, migration check, one definition for
-CI and for you. AGENTS.md carries the working conventions.
+Slides is the presentation app for the Oficina suite. A deck looks like a
+Keynote deck on a desktop and reads like a document on a phone; you share
+it as a read-only link, present it fullscreen with speaker notes, and drive
+the talk from your phone while the room follows along. One instance per
+team, on the CARLOS platform.
-## Browser drive
+**This is slice 1, and there is no browser editor in it.** You build a deck
+through the drive API — every screen and every operation is in
+[`docs/api.md`](docs/api.md) — or you start from the sample deck that gets
+seeded the first time you sign in. Everything downstream of the deck works:
+the paged viewer on both viewports, share links, presenting with the
+presenter view and a phone remote, and audience follow. PDF export, images
+and editing in the browser are the next two slices. Every screen works with
+JavaScript switched off, and every string a person can read is a catalog
+key.
-`internal/slidestest/browser_test.go` walks the app in a real
-Chromium — loud on any console error, failed request, or JS value that
-leaked to the screen:
+Sign-in is a magic link, and the instance only mails one to an address in
+`SLIDES_ALLOWED_ADDRESSES`.
+
+## Running it
+
+```sh
+./scripts/run-dev.sh # http://localhost:8091
+```
+
+That builds the binary and runs it against a scratch database that starts
+empty every time. Sign in with any address you like: the magic link is
+logged to the terminal rather than mailed, but it is the real flow, not a
+stand-in.
+
+## The gate
+
+```sh
+./.amadan/ci.d/10-gate
+```
+
+One script, run by CI and by you — rustfmt, clippy, the wasm build and a
+`cmp` against the committed artifact, the Rust tests, then `make ci` (vet,
+gofmt, Go tests, migration check). `make ci` on its own is the fast loop
+while you work; run the whole gate before you push.
+[`.amadan/README.md`](.amadan/README.md) says what it needs installed.
+
+## The browser drive
+
+`internal/slidestest/browser_test.go` walks the app in a real Chromium and
+is loud about any console error, failed request, or JS value that leaked to
+the screen:
```sh
-go test -tags browser ./... # real browser, loud on any console error
- # — not part of the plain suite
+go test -tags browser ./...
```
-It needs a Chromium: on PATH, via `RASTRILLO_CHROME`, or in a
-Playwright cache. **A skip is not a pass:** with no browser it fails,
-unless `RASTRILLO_BROWSER_OPTIONAL=1` makes the skip a deliberate,
-visible choice. `make ci` runs the plain suite only — whether CI
-also runs the browser drive, and on what runner, is this app's call.
+It needs a Chromium on `PATH`, at `$RASTRILLO_CHROME`, or in a Playwright
+cache, and **a skip is not a pass**: with no browser it fails, unless
+`RASTRILLO_BROWSER_OPTIONAL=1` makes the skip a deliberate, visible choice.
+It is not part of the plain suite or of the gate.
+
+## Where the reasoning lives
+
+The design is `docs/superpowers/specs/2026-09-07-slides-design.md` and the
+plan for this slice is `docs/superpowers/plans/2026-09-07-slice-1-live.md`.
+`AGENTS.md` is the working file: the seams, the traps, how to deploy. Read
+it before you change anything here.
diff --git a/core/src/layout.rs b/core/src/layout.rs
index 1104550..bb1828b 100644
--- a/core/src/layout.rs
+++ b/core/src/layout.rs
@@ -261,7 +261,13 @@ mod tests {
&mut txn,
&root,
vec![
- ("b1", "heading", None, Some((0.06, 0.08, 0.88, 0.18)), Some(0.0)),
+ (
+ "b1",
+ "heading",
+ None,
+ Some((0.06, 0.08, 0.88, 0.18)),
+ Some(0.0),
+ ),
("b2", "image", None, Some((0.5, 0.5, 0.2, 0.2)), Some(1.0)),
("b3", "text", None, Some((0.06, 0.6, 0.5, 0.3)), Some(2.0)),
],
@@ -269,9 +275,18 @@ mod tests {
to_flow(&mut txn, &slide);
let blocks = schema::get_array(&txn, &slide, "blocks").unwrap();
let maps = schema::maps_of(&txn, &blocks);
- assert_eq!(schema::get_string(&txn, &maps[0].1, "region").as_deref(), Some("heading"));
- assert_eq!(schema::get_string(&txn, &maps[1].1, "region").as_deref(), Some("media"));
- assert_eq!(schema::get_string(&txn, &maps[2].1, "region").as_deref(), Some("body"));
+ assert_eq!(
+ schema::get_string(&txn, &maps[0].1, "region").as_deref(),
+ Some("heading")
+ );
+ assert_eq!(
+ schema::get_string(&txn, &maps[1].1, "region").as_deref(),
+ Some("media")
+ );
+ assert_eq!(
+ schema::get_string(&txn, &maps[2].1, "region").as_deref(),
+ Some("body")
+ );
for (_, m) in &maps {
assert!(schema::get_object(&txn, m, "box").is_none());
assert!(schema::get_number(&txn, m, "z").is_none());
@@ -288,10 +303,34 @@ mod tests {
&mut txn,
&root,
vec![
- ("bottom-right", "text", None, Some((0.6, 0.6, 0.2, 0.2)), Some(0.0)),
- ("top-right", "text", None, Some((0.6, 0.1, 0.2, 0.2)), Some(1.0)),
- ("top-left", "text", None, Some((0.1, 0.1, 0.2, 0.2)), Some(2.0)),
- ("bottom-left", "text", None, Some((0.1, 0.6, 0.2, 0.2)), Some(3.0)),
+ (
+ "bottom-right",
+ "text",
+ None,
+ Some((0.6, 0.6, 0.2, 0.2)),
+ Some(0.0),
+ ),
+ (
+ "top-right",
+ "text",
+ None,
+ Some((0.6, 0.1, 0.2, 0.2)),
+ Some(1.0),
+ ),
+ (
+ "top-left",
+ "text",
+ None,
+ Some((0.1, 0.1, 0.2, 0.2)),
+ Some(2.0),
+ ),
+ (
+ "bottom-left",
+ "text",
+ None,
+ Some((0.1, 0.6, 0.2, 0.2)),
+ Some(3.0),
+ ),
],
);
to_flow(&mut txn, &slide);
@@ -300,7 +339,10 @@ mod tests {
.iter()
.map(|(_, m)| schema::get_string(&txn, m, "id").unwrap())
.collect();
- assert_eq!(ids, vec!["top-left", "top-right", "bottom-left", "bottom-right"]);
+ assert_eq!(
+ ids,
+ vec!["top-left", "top-right", "bottom-left", "bottom-right"]
+ );
}
#[test]
diff --git a/core/src/ops.rs b/core/src/ops.rs
index 23b16cb..18ac0c4 100644
--- a/core/src/ops.rs
+++ b/core/src/ops.rs
@@ -271,8 +271,7 @@ impl Model {
schema::maps_of(txn, &a)
.into_iter()
.map(|(_, b)| {
- let kind =
- schema::get_string(txn, &b, "type").unwrap_or_default();
+ let kind = schema::get_string(txn, &b, "type").unwrap_or_default();
let children = (kind == "columns").then(|| {
schema::get_object(txn, &b, "props")
.and_then(|p| p.get("children").cloned())
@@ -603,7 +602,11 @@ fn block_index(s: &SlideModel, id: &str) -> Result<usize, CoreError> {
/// A `columns` block's `props.children` (spec §2.4): an array of block
/// ids, each naming a block on `s` other than `self_id`. `null` clears
/// (an absent-props-key removal), so it validates to no children.
-fn validate_children(s: &SlideModel, self_id: &str, value: &Value) -> Result<Vec<String>, CoreError> {
+fn validate_children(
+ s: &SlideModel,
+ self_id: &str,
+ value: &Value,
+) -> Result<Vec<String>, CoreError> {
if value.is_null() {
return Ok(Vec::new());
}
@@ -676,7 +679,11 @@ fn remove_from_columns_children(txn: &mut TransactionMut, blocks: &ArrayRef, rem
.into_iter()
.filter(|c| c.as_str() != Some(removed_id))
.collect();
- pm.insert(txn, "children", schema::json_to_any(&Value::Array(filtered)));
+ pm.insert(
+ txn,
+ "children",
+ schema::json_to_any(&Value::Array(filtered)),
+ );
}
}
@@ -910,7 +917,9 @@ fn apply_one(txn: &mut TransactionMut, roots: &Roots, op: &Op) -> Result<(), Cor
} else {
None
};
- let region = region.as_deref().unwrap_or_else(|| layout::default_region(kind));
+ let region = region
+ .as_deref()
+ .unwrap_or_else(|| layout::default_region(kind));
let (region_key, box_value, z_value) = if canvas {
let bx = bx.map(|b| b.json()).unwrap_or_else(|| {
let (x, y, w, h) = layout::region_rect(region, false);
diff --git a/core/tests/ops.rs b/core/tests/ops.rs
index 2d2e03e..dd68fef 100644
--- a/core/tests/ops.rs
+++ b/core/tests/ops.rs
@@ -447,8 +447,7 @@ fn remove_block_drops_it_from_columns_children() {
)
.expect("setup applies");
- apply_ops(&d, r#"[{"op":"remove_block","slide":"s1","id":"b1"}]"#)
- .expect("remove applies");
+ apply_ops(&d, r#"[{"op":"remove_block","slide":"s1","id":"b1"}]"#).expect("remove applies");
let v = view(&d);
let columns = v["slides"][0]["blocks"]
.as_array()
diff --git a/internal/slides/core/testdata/slides_core.wasm b/internal/slides/core/testdata/slides_core.wasm
index 75432c3..76a31bd 100755
Binary files a/internal/slides/core/testdata/slides_core.wasm and b/internal/slides/core/testdata/slides_core.wasm differ