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
.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 −2AGENTS.md+237 −60PRODUCT.md+263 −0README.md+58 −15core/src/layout.rs+51 −9core/src/ops.rs+14 −5core/tests/ops.rs+1 −2internal/slides/core/testdata/slides_core.wasmbinary
Intent
<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>
<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-`<progress>` pattern from spec §3.4 (counter text carries the semantics via a screen-reader-visible span, `<progress>` 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) (<-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=<token>`: `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/>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 `<meta refresh 5>`, 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 := <-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 |