| 1 | # Product |
| 2 | |
| 3 | <!-- impeccable:product-schema 1 --> |
| 4 | |
| 5 | > **How this file was written.** Impeccable's `init` normally interviews |
| 6 | > the user before writing anything here. This one was written from |
| 7 | > repository evidence instead, during slice 1 and brought up to date at |
| 8 | > the end of each slice since — most recently slice 3: the design |
| 9 | > (`docs/superpowers/specs/2026-09-07-slides-design.md`, revision 1), the |
| 10 | > plans (`docs/superpowers/plans/2026-09-07-slice-1-live.md`, |
| 11 | > `2026-09-07-slice-2-editor.md` and `2026-09-08-slice-3-export.md`), |
| 12 | > `AGENTS.md`, and the shipped code in `internal/slides/` and |
| 13 | > `fixtures/`. Facts traceable to a spec |
| 14 | > ruling or to code are cited. Anything that is a reading rather than a |
| 15 | > citation is marked **[inferred — confirm]** and should be corrected |
| 16 | > rather than trusted. |
| 17 | |
| 18 | ## Platform |
| 19 | |
| 20 | web |
| 21 | |
| 22 | ## Users |
| 23 | |
| 24 | **Primary: a member of one team, making and giving a talk.** The instance |
| 25 | boundary *is* the team — one deployed instance per team, not one |
| 26 | multi-tenant app with teams inside it (spec §0.2, suite rulings 1 and 5). |
| 27 | Sharing is therefore membership plus a link capability, not a permission |
| 28 | matrix. |
| 29 | |
| 30 | **Secondary: an agent, driving a deck on a person's behalf.** Not a |
| 31 | metaphor and not a later feature — it was the only way to build a deck |
| 32 | before the editor existed, and it is still a peer of it: an agent's ops |
| 33 | and a person's keystrokes land in one document, in one order. An agent is |
| 34 | its own principal, |
| 35 | `<name>~<operator email>`, minted by a signed-in person and named in every |
| 36 | write it makes (spec §1 ruling 7, §7.1). It never borrows a person's |
| 37 | session, and the API never reads a session cookie. |
| 38 | |
| 39 | **Third: someone holding a share link**, who has no account and gets a |
| 40 | read-only deck (spec §5). If a talk is live, that same link offers "follow |
| 41 | the presenter" (§3.4). |
| 42 | |
| 43 | Access today is a magic link gated by `SLIDES_ALLOWED_ADDRESSES` |
| 44 | (`internal/slides/auth.go`) — a per-instance allowlist, exactly Docs' |
| 45 | shape, chosen because the suite home that will own identity is a skeleton |
| 46 | (spec §1 ruling 8). Guest grants and edit links wait on that home (§5, §9). |
| 47 | |
| 48 | **[inferred — confirm]** The team is assumed small enough that an address |
| 49 | allowlist is the right admission mechanism. Nothing in the repo states a |
| 50 | target team size. |
| 51 | |
| 52 | ## Product Purpose |
| 53 | |
| 54 | Slides is a **presentation editor** for the Oficina suite: a deck that |
| 55 | looks like a Keynote deck on a desktop, reads like a document on a phone, |
| 56 | is presented from a laptop and driven from a phone, exports to PDF, and is |
| 57 | driveable by an agent through the same operations a person has (spec |
| 58 | preamble). |
| 59 | |
| 60 | Success is a deck that is genuinely the same deck everywhere it is seen — |
| 61 | one block tree, rendered for a viewport rather than authored twice (§0.1). |
| 62 | A phone does not get a cut-down deck; it gets the same deck reflowed. |
| 63 | |
| 64 | ## Positioning |
| 65 | |
| 66 | Three things a neighbouring presentation tool could not copy without |
| 67 | rebuilding its foundations: |
| 68 | |
| 69 | 1. **One tree, one renderer, no second layout.** The layout modes are two |
| 70 | placements of the same blocks, and the phone layer is entries in the |
| 71 | same tree (§2.3, §2.5). `internal/slides/render` is the only code that |
| 72 | turns a slide into HTML, and it serves the viewer, the presenter view, |
| 73 | the remote, and the editor's stage and thumbnails (§4.1) — the browser |
| 74 | fetches that HTML rather than drawing its own. A product with a mobile |
| 75 | view bolted on cannot make that claim afterwards, and neither can one |
| 76 | whose editor renders slides a second time in JavaScript. |
| 77 | 2. **An agent is a first-class author, badged as itself.** Every write |
| 78 | records `person:<id>` or `agent:<id>` (`DeckUpdate.Actor`, |
| 79 | `internal/slides/models.go`; spec §6.2), and the agent's writes are |
| 80 | attributed to the agent with its operator named, never laundered into |
| 81 | the person's name. Slides is the first Oficina app to build that |
| 82 | surface (§0.2). |
| 83 | 3. **Presenting is a server-side session, so the room is one system.** The |
| 84 | presenter view, the projector, the phone remote and every audience |
| 85 | follower read and write one record (§3.4, `internal/slides/ |
| 86 | presentation.go`). A phone remote is not possible at all in a design |
| 87 | where presenting is just a viewer with no session, which is the |
| 88 | rejected alternative recorded in §1 ruling 5. |
| 89 | |
| 90 | **What is not shipped and must not be described as though it were:** |
| 91 | comments, version history, charts from Sheets, video and embeds, an MCP |
| 92 | adapter, aspect ratios other than 16:9. §9 gives each a trigger rather |
| 93 | than a date. And **the first deployment has no object store bound**, so |
| 94 | image upload — which is built, and works against a bucket or a |
| 95 | directory — answers `503` there and says so on the page, and an exported |
| 96 | PDF is held in the instance's own memory for an hour instead of going to |
| 97 | a bucket. |
| 98 | |
| 99 | ## Operating Context |
| 100 | |
| 101 | - **One Go binary per instance**, `CGO_ENABLED=0`, cross-compiled to |
| 102 | `linux/arm64`, on the CARLOS platform, at `slides.oficina.works` for the |
| 103 | first team (spec §6.3, `Makefile`). |
| 104 | - **The deck is a Yjs document held by a Rust `yrs` core compiled to wasm |
| 105 | and run under wazero** (spec §1 ruling 3; `core/`, |
| 106 | `internal/slides/core`). The Go side persists updates in SQLite, asks |
| 107 | the core for a JSON view, and renders that view. This is Docs' pattern, |
| 108 | adopted rather than reinvented. |
| 109 | - **Routes shipped today** (`internal/slides/app.go`): sign-in and the |
| 110 | deck list, `/decks/new`, `/d/{sqid}` and its notes, settings, share and |
| 111 | delete pages; the editor at `/d/{sqid}/edit` with its slide HTML twin, |
| 112 | its `.svg` thumbnail twin, its update log, stream and presence, and its |
| 113 | upload, register and asset routes; `/d/{sqid}/export`, the job's status |
| 114 | page under it and the `.pdf` beside that; `/s/{token}` for a share link; `/p/{session}` and its notes, |
| 115 | stream, remote and command; `/r/{code}` to pair a phone. The share, |
| 116 | present and remote routes sit outside the session group on purpose — |
| 117 | the projector, the phone and the audience each carry their own |
| 118 | capability and have no session at all. |
| 119 | - **The browser holds a Yjs document too.** The editor's bundle is |
| 120 | committed, hashed, and built by hand from `fixtures/` |
| 121 | (`node build-bundles.mjs`); the app builds nothing at run time and CI |
| 122 | builds nothing for it. `fixtures/` is also a second implementation of |
| 123 | the operation vocabulary, held to the Rust one by generated vectors |
| 124 | (spec §2.8) — an operation changes on both sides or on neither. |
| 125 | - **Local development** is `./scripts/run-dev.sh`: `localhost:8091`, a |
| 126 | scratch SQLite database that starts empty every run, magic links logged |
| 127 | to the terminal rather than mailed. |
| 128 | - **The scriptless floor is a product requirement, not a fallback** (spec |
| 129 | §8). Signing in, listing, creating, viewing, presenting, reading notes, |
| 130 | managing sharing and using the remote all work with JavaScript off; each |
| 131 | remote button is a form that POSTs and redirects. The editor is the one |
| 132 | stated exception, and it says so in a `<noscript>` that links the |
| 133 | viewer — a boundary declared in the spec before it was built, not |
| 134 | discovered afterwards. |
| 135 | - **Work happens on a worktree branch and reaches `main` only by squash |
| 136 | merge**, after review and with the gate green. The review record is an |
| 137 | amadan branch discussion, not a GitHub PR — this repo is hosted on an |
| 138 | amadan hub (`AGENTS.md`). |
| 139 | |
| 140 | ## Capabilities and Constraints |
| 141 | |
| 142 | **Shipped in slice 1:** magic-link sign-in with an admission allowlist and |
| 143 | a `users` row per person; deck list, create, settings, delete, three |
| 144 | sharing rungs; the Rust core with the deck schema, the operation set and |
| 145 | the JSON view; the HTML renderer with eleven block types, seven flow |
| 146 | templates, the canvas frame, the phone layer and six themes; the paged |
| 147 | viewer and the notes page; read-only share links, hashed and revocable; |
| 148 | presentation sessions with the fullscreen surface, presenter view, phone |
| 149 | remote and audience follow; the drive API for creating and driving a deck. |
| 150 | |
| 151 | **Shipped in slice 2:** the browser editor at `/d/{sqid}/edit` — slide |
| 152 | strip, stage, inspector for the deck, the slide and the selected block, |
| 153 | text edited in place, canvas drag and resize with snapping, the |
| 154 | desktop/phone switcher whose phone-view edits become overrides rather than |
| 155 | changes everyone sees, undo scoped to the person who made the change, and |
| 156 | presence. Under it: the live-sync wire (an update log served by cursor, an |
| 157 | SSE stream of advisory seqs and whole presence sets, periodic snapshots so |
| 158 | opening a long-lived deck is not a full replay), image upload |
| 159 | browser-to-bucket with SVG sanitising and a deck-scoped check on every |
| 160 | byte served, and the JS half of the operation vocabulary with the vectors |
| 161 | that hold it to the Rust one. |
| 162 | |
| 163 | **Shipped in slice 3:** the layout engine (`internal/slides/layout`), |
| 164 | which measures a slide into a paint list with the twelve Liberation faces |
| 165 | embedded in the binary and served to every slide surface; the SVG |
| 166 | serialiser over that paint list, and a thumbnail route on all four |
| 167 | capabilities; and PDF export — `krilla` compiled to wasm32 and hosted |
| 168 | under wazero beside the CRDT core (`core/pdf`, |
| 169 | `internal/slides/pdfcore`), driven by a rastrillo job whose status page |
| 170 | polls itself and works with scripts off: one 16:9 page per slide, the |
| 171 | speaker notes as pages of their own when they are asked for, the text as |
| 172 | embedded subset glyphs, the images inlined. The API twins are |
| 173 | `POST /api/v1/decks/{sqid}/export` and its two GETs. |
| 174 | |
| 175 | **Deliberately not built yet** (spec §9): charts from Sheets, video and |
| 176 | embeds, an MCP adapter, 4:3 and custom aspect ratios, comments, and |
| 177 | version history. Each has a named trigger in §9 rather than a date. |
| 178 | |
| 179 | **Binding technical constraints:** |
| 180 | |
| 181 | - **A slide is one block tree, rendered many ways** (§0.1). Nothing that |
| 182 | matters to what a reader sees may live outside the tree. |
| 183 | - **Every write names its real actor**, `person:<id>` or `agent:<id>` |
| 184 | (§6.2). An agent's write names the agent and its operator, never the |
| 185 | person (suite ruling 9, plus Paul's 2026-09-07 ruling that agents drive |
| 186 | every Oficina app as their own identified principal). |
| 187 | - **Not-found and not-yours both answer 404, never 403** |
| 188 | (`internal/slides/decks.go`), so a stranger holding a sqid cannot learn |
| 189 | whether it is real. |
| 190 | - **Only the SHA-256 of a share link is stored**, so the URL is shown |
| 191 | exactly once and the page says so at that moment (`share.go`, §5). |
| 192 | - **Every user-visible string is a catalog key** resolved through |
| 193 | `rastrillo.T` (`internal/slides/locales/en.toml`, 339 keys today). |
| 194 | Twelve base locales are the suite target; one, `en`, exists. A |
| 195 | hardcoded English string is a bug — including in the editor's |
| 196 | JavaScript, which reads its strings off `data-text-*` attributes the |
| 197 | template renders from the catalog. Deck titles and timestamps are data, |
| 198 | not authored copy, and are correctly not keys. |
| 199 | - **The document is the log, and presence is not in it.** Every write is |
| 200 | a row with an actor; a snapshot is a shortcut and never the truth. |
| 201 | Presence lives in memory, reaches no log, is in no snapshot, and is |
| 202 | gone when the process restarts (spec §3.3). |
| 203 | - **The browser is not a second renderer.** The editor's stage shows the |
| 204 | server's HTML for the current slide and rebinds after a structural |
| 205 | change; building slide markup in JavaScript would recreate exactly the |
| 206 | divergence §0.1 exists to prevent. |
| 207 | - **`href` is stored as the one-key map `{"href": url}` and the marks are |
| 208 | named `b`, `i`, `c`, `h`, `href`** (§1 ruling 12, §2.6). Forced by |
| 209 | y-prosemirror, which has no hook to map mark names to attribute names |
| 210 | and deletes a text node when a read throws — so the wrong shape loses |
| 211 | the words, not just the link. |
| 212 | - **Markup is the bare `rst-*` attribute grammar**, never `class="rst-*"`; |
| 213 | this app's own attributes are `slides-*`. |
| 214 | - **Themes are a built-in set of token bundles**, six of them (`paper`, |
| 215 | `ink`, `sunset`, `forest`, `slate`, `candy` — |
| 216 | `internal/slides/render/themes.go`). A deck may override accent and the |
| 217 | two typefaces; there are no per-block fonts, which §1 ruling 9 calls the |
| 218 | fastest route to an ugly deck. |
| 219 | - **`CGO_ENABLED=0` everywhere**, and the committed wasm artifact must |
| 220 | match a fresh build or the gate fails. |
| 221 | |
| 222 | ## Brand Commitments |
| 223 | |
| 224 | - **Name:** Slides, in the `oficina` namespace at |
| 225 | `amadan.net/oficina/slides`, hosted at `slides.oficina.works` for the |
| 226 | first team (§1 ruling 11). English, not Spanish — the suite names |
| 227 | generic apps in English. |
| 228 | - **The design system is rastrillo's**, with estilo |
| 229 | (`amadan.net/oficina/estilo`) as the suite variant. Vendor tokens rather |
| 230 | than copying files by hand. |
| 231 | - **A slide is not themed chrome.** The deck carries its own theme tokens |
| 232 | as CSS custom properties on each `<section slides-slide>` |
| 233 | (`render/render.go`); the app's chrome follows the design system and the |
| 234 | slide does not follow the reader's preferences. **[inferred — confirm]** |
| 235 | The spec does not spell this out for Slides the way Docs' §10.2 does for |
| 236 | the page canvas; it is the reading the renderer implements. |
| 237 | - **The empty state is a designed screen, not a blank page**, and says |
| 238 | what the screen is for: "A deck is a set of slides you write, present |
| 239 | and share. Start one and it will show up here." |
| 240 | (`slides.decks.empty.body`). |
| 241 | - **Voice, as evidenced by the shipped catalog:** plain, unhedged, second |
| 242 | person, no exclamation marks, no product cheer. Headings carry no full |
| 243 | stop ("No decks yet"); errors state what happened and what helps ("That |
| 244 | link has expired or was already used. Enter your email below for a new |
| 245 | one."); a warning goes in front of the button rather than in a help page |
| 246 | ("Copy this now — it is not shown again"). |
| 247 | - **What does not exist and must not be invented:** no customers, no |
| 248 | testimonials, no benchmarks, no pricing, no usage numbers, no launch |
| 249 | date, no press. There is one instance and it is not live yet. |
| 250 | |
| 251 | ## Evidence on Hand |
| 252 | |
| 253 | - **Spec:** `docs/superpowers/specs/2026-09-07-slides-design.md`, revision |
| 254 | 1, drafted 2026-09-07 from a brainstorm with Paul the same day. It is |
| 255 | **unreviewed**: §12 records that the review pass is the first item after |
| 256 | slice 1 ships, so the reviewer has a running instance rather than a |
| 257 | document. Treat its rulings as settled and its prose as unaudited. One |
| 258 | amendment has landed since: ruling 12 and the rewritten §2.6, added |
| 259 | during slice 2 when the fixtures proved the old `href` shape did not |
| 260 | survive y-prosemirror. |
| 261 | - **Plans:** `docs/superpowers/plans/2026-09-07-slice-1-live.md` (eight |
| 262 | tasks) and `2026-09-07-slice-2-editor.md` (seven), one slice each. |
| 263 | - **Consumed rulings:** the suite sharing doctrine (`oficina/suite`, |
| 264 | revision 8) by number — 1, 4, 5, 6, 9, 12, 15 — never restated here |
| 265 | (§0.2). Docs' live-sync design is adopted as written for slice 2 (§3.3). |
| 266 | - **API reference:** `docs/api.md`, every endpoint and every operation |
| 267 | with an example, written for a person and an agent both. |
| 268 | - **The export, end to end:** `internal/slidestest/export_test.go` walks |
| 269 | the button, the job, the polled fragment, the SSE stream and the file, |
| 270 | and asserts the first heading is extractable with `pdftotext` when the |
| 271 | machine has one (visibly skipped when it does not). |
| 272 | `internal/slides/export_test.go` does the same at the pipeline's own |
| 273 | level, recovering every heading from the PDF's glyph runs through each |
| 274 | font's ToUnicode CMap. |
| 275 | - **Sample content:** `internal/slides/sample.go` seeds one deck on first |
| 276 | sign-in, covering each flow template, a canvas slide and a phone-reflow |
| 277 | slide — which is also the fastest way to see what the renderer does. |
| 278 | - **The contract between the two implementations:** `fixtures/data/*.json` |
| 279 | are operation batches, `fixtures/vectors/` is what the JS side makes of |
| 280 | them, and `internal/slides/core/vectors_test.go` requires the Rust core |
| 281 | to agree. Five vectors between them exercise every operation in §7.2. |
| 282 | - **The gate:** `.amadan/ci.d/10-gate`, run by CI and by hand, and |
| 283 | `.amadan/README.md` for what it needs. Since slice 2 it also runs the JS |
| 284 | toolchain — `npm ci`, `node --test`, the vectors regenerated and the |
| 285 | bundle rebuilt, both diffed against what is committed — which is what |
| 286 | makes the two claims above checkable rather than remembered. |
| 287 | - **The browser drive:** `internal/slidestest/browser_test.go` walks the |
| 288 | editor in a real Chromium and fails on any console error, failed request |
| 289 | or JS value that reached the screen. It is deliberately outside the |
| 290 | gate: this project's sandbox will not start a Chromium, and a gate that |
| 291 | ran it would report "could not see" as green. |
| 292 | |
| 293 | ## Product Principles |
| 294 | |
| 295 | 1. **State the degradation rather than let it be discovered.** The spec |
| 296 | refuses to claim pixel identity between the browser and the PDF and |
| 297 | says where they may differ (§0.3, §4.2); the share page says the URL |
| 298 | will not be shown again at the moment it is shown; the editor's image |
| 299 | field, on an instance with no object store, says so on the page instead |
| 300 | of waiting for somebody to choose a file and hit a 503. |
| 301 | 2. **One tree, one renderer.** Any second representation of a deck — a |
| 302 | mobile layout, a print document, an editor's own idea of a slide — is |
| 303 | a design mistake before it is a maintenance cost, because the two will |
| 304 | diverge and the divergence will reach a reader. |
| 305 | 3. **A capability is a capability, and nothing more.** A share link never |
| 306 | edits (suite ruling 15). An edit link does not exist, because an edit |
| 307 | capability is a sign-in that mints a guest grant, and that waits on the |
| 308 | home (§5). |
| 309 | 4. **An agent is a principal, never a costume.** It has its own token, its |
| 310 | own name, its own revocation, and its own badge in the activity list |
| 311 | (§7.1). The alternative — an agent borrowing a person's session — is |
| 312 | recorded as rejected, not overlooked. |
| 313 | 5. **The scriptless floor is the floor, not a fallback.** A screen that |
| 314 | only works with JavaScript is a screen that has not been finished |
| 315 | (§8). |
| 316 | 6. **A string a person can read is a catalog key.** Localisation is a |
| 317 | structural property of the app, not a phase. |
| 318 | |
| 319 | ## Accessibility & Inclusion |
| 320 | |
| 321 | **WCAG 2.2 AA, no exemptions** (spec §8, restated in `AGENTS.md`). Four |
| 322 | requirements bind concrete decisions here: |
| 323 | |
| 324 | - **24×24 minimum target size**, which the phone remote's buttons are |
| 325 | sized for — it is a control someone uses in the dark, one-handed, while |
| 326 | talking. |
| 327 | - **320px reflow.** The one allowance is estilo's: the canvas stage may |
| 328 | scroll inside its own region in the editor (§8). Nothing in the viewer |
| 329 | uses it. Below 1100px the editor's three panes stack — the strip |
| 330 | becomes a horizontal filmstrip and the inspector a `<details>` at the |
| 331 | foot — rather than scrolling the page sideways. |
| 332 | - **Canvas resize handles are pointer-only, and the keyboard has a real |
| 333 | equivalent** rather than a smaller version of the same thing: the |
| 334 | selected block's overlay is focusable and named, arrows nudge, Shift |
| 335 | nudges further, Alt resizes, and the inspector's Left/Top/Width/Height |
| 336 | fields do it by number. |
| 337 | - **Colour never carries meaning alone**, so a deck's sharing rung shows |
| 338 | a text label beside its tone and never a bare colour. |
| 339 | - **`alt` on every image.** The model makes it structural rather than |
| 340 | advisory: `alt` is a required prop on an `image` block and may be empty |
| 341 | only when `decorative` is true (§2.4). |
| 342 | |
| 343 | The phone is not a degraded case. Reflow for flow slides and scale-to-fit |
| 344 | for canvas slides, with a per-slide switch to reflow a canvas slide in |
| 345 | reading order (§1 ruling 2), exists so that reading a deck on a phone is a |
| 346 | supported way to read it. |
| 347 | |
| 348 | Twelve base locales are the target set; today exactly one exists (`en`). |
| 349 | That is a commitment, not a shipped state, and this file should not be read |
| 350 | as though eleven more catalogs are sitting in the repo. |
| 351 | |