| 1 | # π€ Slides β design |
| 2 | |
| 3 | Design, 2026-09-07. Direction approved by Paul in conversation; this is the |
| 4 | written form. **Revision 1.** Drafted by the Slides session (Fable 5.1) |
| 5 | from a brainstorm with Paul the same day. Every ruling in Β§1 attributed to |
| 6 | Paul was given in that conversation. |
| 7 | |
| 8 | Slides is a presentation editor for the Oficina suite: a deck of slides |
| 9 | that looks like a Keynote or PowerPoint deck on a desktop and reads like |
| 10 | a document on a phone, edited by several people at once, presented from a |
| 11 | laptop and driven from a phone, exported to PDF, and driveable by an agent |
| 12 | through the same operations a person has. One instance per team, on the |
| 13 | CARLOS platform, at `slides.oficina.works` for the first team. |
| 14 | |
| 15 | Built beside [Docs](https://amadan.net/oficina/docs), |
| 16 | [Sheets](https://amadan.net/oficina/sheets) and the suite |
| 17 | [doctrine](https://amadan.net/oficina/suite). Where a decision is already |
| 18 | made over there, this spec consumes it rather than restating it (Β§0.2). |
| 19 | |
| 20 | --- |
| 21 | |
| 22 | ## 0. The rules this app lives under |
| 23 | |
| 24 | ### 0.1 The one rule |
| 25 | |
| 26 | **A slide is one block tree, rendered many ways.** The tree is the only |
| 27 | truth: the editor edits it, the viewer renders it, the phone reflows it, |
| 28 | the PDF prints it, the agent patches it. Nothing that matters to what a |
| 29 | reader sees lives outside the tree, so there is never a "desktop version" |
| 30 | and a "mobile version" of a deck, only one deck and a viewport. |
| 31 | |
| 32 | Everything below derives from it. The hybrid layout modes (Β§2.3) are two |
| 33 | ways of placing the same blocks, not two kinds of slide. The phone |
| 34 | overrides (Β§2.5) are entries in the tree, not a second file. The drive |
| 35 | API (Β§7) is the inspector's operations with a JSON body. |
| 36 | |
| 37 | ### 0.2 Consumed suite rulings |
| 38 | |
| 39 | This app cites the suite sharing doctrine |
| 40 | (`oficina/suite`, `docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md`, |
| 41 | revision 8) by ruling number and never restates it. |
| 42 | |
| 43 | | Ruling | Used for | |
| 44 | |---|---| |
| 45 | | 1, 5 | One instance per team; the first deploy is the **lone shape** (Β§6.1). | |
| 46 | | 4, 12 | Identity and roster belong to the home; until the home exists (it is a skeleton, `oficina/home` `internal/home/app.go`), the lone instance keeps its own allowlist exactly as Docs does. | |
| 47 | | 6 | No product-wide egress. A PDF leaves as a download the person asked for; nothing is sent anywhere. | |
| 48 | | 9 | Every write names its real actor. An agent's write names the agent (Β§7). | |
| 49 | | 15 | Three rungs (private, granted, team) plus a **read-only link capability**. A share link never edits (Β§5). | |
| 50 | |
| 51 | One ruling made by Paul during this brainstorm is suite-wide and is |
| 52 | filed as an `oficina/suite` discussion rather than restated here as if it |
| 53 | were this app's: **agents drive every Oficina app** as their own |
| 54 | identified principal, `claude~paul` style, on behalf of a person, never |
| 55 | as the person (2026-09-07). Slides is the first app to build the surface |
| 56 | (Β§7). |
| 57 | |
| 58 | ### 0.3 What this spec does not claim |
| 59 | |
| 60 | It does not claim pixel identity between the browser stage and the PDF. |
| 61 | Β§4 says where they may differ and by how much. It does not claim the |
| 62 | suite home exists. It does not claim a native remote exists; Β§3.4 |
| 63 | designs the phone web page so a native app later speaks the same three |
| 64 | calls. |
| 65 | |
| 66 | --- |
| 67 | |
| 68 | ## 1. Settled decisions (don't relitigate casually) |
| 69 | |
| 70 | | # | Ruling | Rejected alternative | Who, when | |
| 71 | |---|---|---|---| |
| 72 | | 1 | A slide is a tree of typed blocks; the slide's container has a **layout mode**, `flow` or `canvas`. Same blocks, same editor, two placements. | Blocks only (no free placement; feels like Gamma, not Keynote). Canvas only (unreadable on phones). Two separate slide kinds (two models to maintain). | Paul, 2026-09-07 | |
| 73 | | 2 | Phones get **reflow** for flow slides and **scale-to-fit** for canvas slides, with a per-slide switch to reflow a canvas slide in reading order. | Hand-authored second layout per slide. | Paul, 2026-09-07 | |
| 74 | | 3 | Collab core is **Yjs in the browser, yrs under wazero on the server**, Docs' pattern and Docs' live-sync transport (SSE advisory, bytes over POST). Slides is the second implementation of live-sync. | Server-authoritative JSON with last-write-wins (two people in one text block clobber each other). | Paul, 2026-09-07 | |
| 75 | | 4 | **One Go renderer produces HTML** for the viewer, presenter, remote and the editor's stage. **Export** uses a Go layout engine that emits SVG per slide, and PDF is SVG to PDF through a Rust module under wazero. No Chromium. | Extend Docs' hand-rolled PDF with boxes and images (a second layout engine to keep in step). Headless Chromium (no such platform capability; one static binary). | Paul approved the SVG-and-Rust export path 2026-09-07; the HTML-first split for the live surfaces is the session's refinement, recorded in Β§4.1. | |
| 76 | | 5 | Presenting is a **server-side presentation session**: current slide, presenter, pairing code. The phone remote, the presenter view and audience follow all read and write that one record. | Present as a pure viewer with no session (no remote possible). | Paul, 2026-09-07 ("include plans for speaker notes, presentation mode, and a mobile remote control") | |
| 77 | | 6 | The remote is a **phone web page** first; a native app later speaks the same calls. | Native first. | Paul, 2026-09-07 | |
| 78 | | 7 | **Every write names its actor**, and an agent is its own principal with an operator, never the person (suite ruling 9 plus Paul's 2026-09-07 ruling). Agent tokens are minted in the app by a signed-in person, named `<agent>~<person>`, stored hashed. | Agents borrowing a person's session. | Paul, 2026-09-07 | |
| 79 | | 8 | First deploy is the **lone shape**: magic-link sign-in with an address allowlist, exactly Docs' `internal/docs/auth.go` shape. | Waiting for the home. | Session, 2026-09-07, from the survey: no app signs in through the home; the home is a skeleton. | |
| 80 | | 9 | Themes are a **built-in set of token bundles** over estilo's variables. A deck may override accent colour and the two typefaces. No per-block fonts. | Free font picker per block (the fastest route to an ugly deck). | Paul approved the model 2026-09-07 | |
| 81 | | 10 | Bytes (images) live in the **platform object store**, referenced by `blobs.Ref`; never on the instance disk, never in a column beyond the 4 KiB inline rule. | SQLite blobs (the instance hibernates and its disk is ephemeral; rastrillo Β§2). | rastrillo rule, adopted | |
| 82 | | 11 | Repo `oficina/slides`, module `amadan.net/oficina/slides`, scaffolded by `rastrillo new` v0.26.0 (day theme, topbar shell). | Spanish name (`presentaciones`); the suite names generic apps in English. | Session, by the naming precedent | |
| 83 | | 12 | `href` stored as `{href}`; marks named after Y attributes | plain-string href (does not survive y-prosemirror) | Session, 2026-09-07, from the Task 2 finding | |
| 84 | | 13 | PDF via Rust svg2pdf under wazero: **viable** β `svg2pdf` 0.13 with `usvg` 0.45 (text) and `fontdb` fed the Liberation TTFs from linear memory builds for wasm32-unknown-unknown with zero host imports, a 3.5 MiB reproducible module, and renders text as subset embedded TrueType (Type0/CIDFontType2, FontFile2, ToUnicode), 200 pages in 140 ms; slice 3 proceeds with svg2pdf (`core/pdf`, `internal/slides/pdfcore`). | The Go paint-list writer (`pdfgo`), Β§4.3's recorded fallback: stays recorded, not built. | Session, 2026-09-08, Task 1 spike | |
| 85 | | 13a | **`svg2pdf` replaced by `krilla` 0.8.2 + `krilla-svg` 0.8.1 behind the same ABI.** typst/svg2pdf was archived 2026-07-10 naming krilla β same author, same usvg tree β as its successor, so the module was wrapping an unmaintained dependency the spike had not checked. The swap builds for wasm32-unknown-unknown with **no feature flags turned off at all** (krilla-svg has none, and its resvg/tiny-skia/usvg/fontdb defaults compile clean for wasm32), **0 host imports**, and keeps every property ruling 13 asked for: one page per SVG, 1920Γ1080 pt, Type0/CIDFontType2, FontFile2, ToUnicode, subset tags, embedded raster images. Module 3,672,407 β **5,301,002 bytes** (3.50 β 5.06 MiB). 200 pages through the Go host: 642 ms β **220 ms**, and the PDF 1,719,771 β **140,444 bytes**, because krilla writes one subset per document where svg2pdf wrote one per page. `slides_pdf_abi_version` is still 1 and `internal/slides/pdfcore` was not touched. | Staying on an archived dependency; or waiting for a forced upgrade with a CVE attached to it. Also rejected: keeping krilla's default XMP packet, whose RDF namespace URLs would put `http` in a PDF that names nothing outside itself. | Session, 2026-09-09, krilla spike | |
| 86 | | 14 | Uploads are **staged then promoted**: `POST /uploads` mints a staging key of the app's own and signs a PUT for that alone; registration resolves an upload receipt (this deck, this actor, unspent, under two hours), reads the bytes, and puts them back at their own SHA-256. No PUT is ever signed for an address anything is served from, and a ref alone registers nothing. | Signing a PUT for the client's declared hash and trusting the object afterwards β which made a verified picture overwritable and made knowing a ref permission to read it. Also rejected: an authorised cross-deck copy, which nothing asks for yet. | Session, 2026-09-08, from codex 1 and codex 2 | |
| 87 | | 15 | **Presenting is every member's; minting a share link is the owner's**, and an audience follows through the **share link**, never the session sqid. The sqid is a projector address: unrevocable, so it goes only to whoever is presenting. | Making presenting owner-only (reading a deck to a room is reading a deck), or leaving followers on `/p/{session}` where the link's revocation could not reach them. | Session, 2026-09-08, from codex 5 | |
| 88 | | 16 | **Admission is rechecked wherever a principal is resolved** β the session's subject in `currentUser`, the operator's address in the bearer gate β and fails closed. Removing an address revokes its sessions on every device and ends its live talks; its share links keep working, because a share link is the deck's and the deck is the team's. | The allowlist gating only the minting of a session, which left a removed person everything they already held, agents included. | Session, 2026-09-08, from codex 6 | |
| 89 | | 17 | A presentation command is recorded as **`remote:<session sqid>`**, explicitly anonymous, with the session's issuer logged beside it. The remote token is transferable by design, so no name can be put on those writes honestly. | Recording the session creator, which made ruling 7 false for exactly the writes nobody could vouch for. | Session, 2026-09-08, from codex 15 | |
| 90 | | 18 | **A slide's `blocks` array is the set of its blocks; reading order is an `order` key on each block map** (a number; array index when absent, array index as the tie-break), and every reorder β `move_block`, canvas-to-flow β writes the key rather than moving array entries. The view sorts by it and does not expose it, so every reader sees one sequence. | Delete-and-reinsert in the array (loses a peer's concurrent edit to the moved block, reproduced by the review). A separate `blockOrder` id list on the slide (a second place the block set lives, which can drift from the array). | Session, 2026-09-08 (codex 4) | |
| 91 | | 19 | **The write gate checks the document, not its projection.** A posted update is refused (422) if any text fragment holds anything outside Β§2.6 β a formatting attribute other than `b`, `i`, `c`, `h`, `href`, a non-`paragraph` child, an element or fragment inside a paragraph, an embed β because y-prosemirror 1.3.7 deletes an `XmlText` whose attributes it cannot map to a mark, and the tolerant view reads right through exactly that. The check is a core ABI call (`slides_core_check`, ABI 2) mirrored in `fixtures/check.mjs`; the browser also strips unknown attributes from a fragment before binding, for documents that already carry one. | Parsing the JSON view alone (what slice 2 did; passes the update that loses the words). | Session, 2026-09-08 (codex 3) | |
| 92 | | 20 | **The children of a slide's `columns` blocks form a forest**: a block appears in at most one `children` list on its slide, at most once, and a columns block is never placed under its own descendant. Both implementations refuse the op with the same message. A document that nonetheless holds a cycle (two peers' concurrent assignments merged) renders and exports with the unreached blocks appended at the end, marked, never dropped. | Rejecting only self-reference (what slice 2 did; a two-block cycle vanished from the page in silence). | Session, 2026-09-08 (codex 7) | |
| 93 | | 21 | **Ruling 18 again, one level up: the deck's `slides` array is the set of its slides and their order is an `order` key on each slide map**, on the same terms (absent ranks at the array index; array index breaks ties). `move_slide`, `add_slide`, `duplicate_slide` and `remove_slide` never rebuild the array, so a slide reordered while somebody types on it keeps the typing. One idea at both levels, one helper each side (`ordered_maps` / `orderedMaps`). | Leaving `move_slide` cloning and re-inserting, which loses a concurrent edit exactly as `move_block` did. Inventing a second ordering mechanism for slides, which would be two ideas where the document has one. | Session, 2026-09-08 (codex 4, at slide level) | |
| 94 | |
| 95 | --- |
| 96 | |
| 97 | ## 2. The deck model |
| 98 | |
| 99 | ### 2.1 The Y.Doc |
| 100 | |
| 101 | A deck is one Y.Doc. Top-level shared types: |
| 102 | |
| 103 | - `meta` β Y.Map: `title`, `theme` (theme id), `accent` (optional hex), |
| 104 | `fonts` (optional `{heading, body}`), `aspect` (`16:9` only in v1; the |
| 105 | key exists so `4:3` can arrive without a migration). |
| 106 | - `slides` β Y.Array of Y.Map, one per slide: the SET of the deck's |
| 107 | slides. Deck order is the `order` key on each slide map, exactly as a |
| 108 | slide's `blocks` array carries its reading order (ruling 21, codex 4 |
| 109 | at slide level). |
| 110 | - `assets` β Y.Map from asset id to `{ref, name, width, height, alt}`, |
| 111 | where `ref` is the object store address (Β§2.7). |
| 112 | |
| 113 | ### 2.2 A slide |
| 114 | |
| 115 | A slide Y.Map holds: |
| 116 | |
| 117 | - `id` β stable, random, 12 lowercase base32 characters, assigned at |
| 118 | creation, never reused. The drive API addresses slides by it. |
| 119 | - `order` β the slide's place in the deck, a number, on exactly the |
| 120 | terms the block `order` below has: absent ranks at the array index, |
| 121 | array index breaks ties, and a deck nobody has reordered carries none |
| 122 | (ruling 21, codex 4). `move_slide` writes a dense `0..n-1` onto every |
| 123 | slide; `add_slide` and `duplicate_slide` on a deck that carries |
| 124 | orders renumber so the new slide lands where `after` asked. The JSON |
| 125 | view sorts by it and does not expose it. |
| 126 | - `layout` β `flow` or `canvas`. |
| 127 | - `template` β for flow slides: `title`, `title-body`, `two-column`, |
| 128 | `section`, `image-full`, `quote`, `blank`. Ignored on canvas slides. |
| 129 | - `blocks` β Y.Array of block Y.Maps: the SET of the slide's blocks. |
| 130 | Reading order is the `order` key on each block map, a number; a block |
| 131 | without one ranks at its array index, and array index breaks ties |
| 132 | (ruling 18, codex 4). A slide nobody has reordered carries no `order` |
| 133 | and reads in array order, as before. `move_block` and canvas-to-flow |
| 134 | write a dense `0..n-1` onto every block; `add_block` on a slide that |
| 135 | carries orders renumbers so the new block lands where `after` asked. |
| 136 | The JSON view sorts by it and does not expose it. On a canvas slide |
| 137 | `z` is stacking, `order` is reading order, and the two are different |
| 138 | things. |
| 139 | - `notes` β Y.XmlFragment, speaker notes, the same text schema as Β§2.6. |
| 140 | - `background` β `{kind: theme | color | image, value}`. |
| 141 | - `phone` β `inherit` (default) or `reflow`. Only meaningful on canvas |
| 142 | slides. |
| 143 | - `transition` β `none` or `fade`. |
| 144 | |
| 145 | ### 2.3 Layout modes |
| 146 | |
| 147 | **Flow.** The template names regions (`heading`, `body`, `aside`, |
| 148 | `media`), each block has a `region` prop, and blocks stack inside their |
| 149 | region in array order. The renderer places regions with CSS grid, and |
| 150 | the template's grid collapses to one column at the phone breakpoint, so |
| 151 | reflow is CSS, not a second layout. |
| 152 | |
| 153 | **Canvas.** Each block has `box: {x, y, w, h}` as fractions of the 16:9 |
| 154 | frame (0 to 1, four decimals) and `z`. The renderer positions blocks |
| 155 | absolutely inside a frame that scales to the viewport width. On a phone |
| 156 | the frame scales to fit, text and all, unless `phone` is `reflow`, in |
| 157 | which case the renderer emits the blocks as a flow slide with the |
| 158 | `blank` template in `z` then top-left reading order. |
| 159 | |
| 160 | Switching a slide's mode is an operation, not a conversion the person |
| 161 | does by hand: flow to canvas assigns each block the box its region gave |
| 162 | it (`z` is the reading-order index); canvas to flow drops boxes, |
| 163 | assigns regions by position, and sets the reading order top to bottom |
| 164 | then left to right. Both are lossy in one direction and say so in the |
| 165 | inspector. |
| 166 | |
| 167 | The reorder in canvas-to-flow is written as `order` on each block map |
| 168 | (Β§2.2) and never by rebuilding the `blocks` array (codex 4). Rebuilding |
| 169 | it β clone every block, clear the array, insert the copies β is what |
| 170 | slice 2 did, and a peer typing into a block at that moment lost the |
| 171 | sentence: their edit addressed the deleted original and the merge kept |
| 172 | the copy. Placement is allowed to be lossy; content is not. The same |
| 173 | rule holds for `move_block`. |
| 174 | |
| 175 | It holds for `move_slide` too, and by the same mechanism rather than a |
| 176 | second one (ruling 21, codex 4 at slide level). A slide reordered while |
| 177 | somebody is typing on it used to lose the typing for exactly the reason |
| 178 | a moved block did: the slide map the other client's text, notes and |
| 179 | blocks all hang off was deleted and replaced by a copy. So the deck's |
| 180 | `slides` array is the set of its slides and their order is an `order` |
| 181 | key on each slide map (Β§2.1, Β§2.2) β one idea at both levels, one |
| 182 | helper in each implementation, and `move_slide`, `add_slide`, |
| 183 | `duplicate_slide` and `remove_slide` never rebuild the array. |
| 184 | |
| 185 | ### 2.4 Blocks |
| 186 | |
| 187 | A block Y.Map holds `id` (same scheme as slides), `type`, `props` |
| 188 | (Y.Map), and for text-bearing types `text` (Y.XmlFragment). The v1 |
| 189 | vocabulary, the "maximally viable" set: |
| 190 | |
| 191 | | Type | Props | Text | |
| 192 | |---|---|---| |
| 193 | | `heading` | `level` 1 to 3 | yes | |
| 194 | | `text` | `size` (`s`, `m`, `l`), `align` | yes | |
| 195 | | `list` | `style` (`bullet`, `number`), `size` | yes, one paragraph per item | |
| 196 | | `quote` | `cite` | yes | |
| 197 | | `image` | `asset`, `fit` (`cover`, `contain`), `alt` (required, may be empty only if `decorative` is true), `radius` | no | |
| 198 | | `columns` | `count` 2 or 3, `children` (Y.Array of block ids laid out left to right, each a block in this slide's `blocks`) | no | |
| 199 | | `code` | `language`, `size` | yes, plain, one paragraph per line | |
| 200 | | `shape` | `kind` (`rect`, `ellipse`, `line`, `arrow`), `fill`, `stroke`, `strokeWidth`, `radius` | optional single-paragraph label | |
| 201 | | `table` | `rows`, `cols`, `header` (bool), `cells` (Y.Array of Y.XmlFragment, row-major) | per cell | |
| 202 | | `divider` | none | no | |
| 203 | | `spacer` | `size` | no | |
| 204 | |
| 205 | Every block additionally carries `region` (flow) or `box` and `z` |
| 206 | (canvas), an optional `order` (Β§2.2), and an optional `phone` override |
| 207 | map (Β§2.5). |
| 208 | |
| 209 | The `children` of a slide's `columns` blocks form a forest (ruling 16, |
| 210 | codex 7): a block id appears in at most one `children` list on its |
| 211 | slide, at most once in that list, and a columns block may not name a |
| 212 | block that it already sits under. `add_block` and `set_block` refuse |
| 213 | any of those, in both implementations with one message per case |
| 214 | (`names "x" twice`, `names "x", already a child of columns "y"`, `names |
| 215 | "x", which would put columns "self" inside itself`), and the batch |
| 216 | applies nothing. A concurrent pair of assignments can still merge into a |
| 217 | shared child or a cycle, so the renderer and the layout engine apply |
| 218 | one deterministic policy: the first columns block in reading order that |
| 219 | names a child owns it, and whatever no root reaches is rendered after |
| 220 | everything else β in its own trailing region on a flow slide, at its box |
| 221 | on a canvas one β marked `data-unreached` in HTML and reported as a |
| 222 | `unreached` warning by the layout engine. Content is never dropped for |
| 223 | being badly parented. |
| 224 | |
| 225 | Charts from Sheets, embeds and video are **not in v1** (Β§9). |
| 226 | |
| 227 | ### 2.5 Phone overrides |
| 228 | |
| 229 | A block's `phone` Y.Map may hold: `hidden` (bool), `order` (integer, |
| 230 | sorts before array order among siblings), `size` (a size step), and on |
| 231 | canvas slides `box`. Nothing else. The desktop layout is always the |
| 232 | base; the phone layer only adds. |
| 233 | |
| 234 | The editor's desktop/phone switcher (Β§3.2) writes to this map when the |
| 235 | stage is in phone view. The renderer reads it when the viewport is a |
| 236 | phone, and the print engine never reads it. |
| 237 | |
| 238 | `order` is reading order, not a visual effect (codex 10). Rendering for |
| 239 | the phone viewport EMITS the blocks in the effective phone sequence β |
| 240 | regions in region order; within a region and within a columns block, a |
| 241 | stable sort by `order` with an unset order counting as 0 β so the |
| 242 | sequence a screen reader hears and the Tab key follows is the one a |
| 243 | sighted reader sees (Β§8, WCAG 2.2 AA, no exemptions). The stylesheet's |
| 244 | `order: var(--slides-phone-order, 0)` is kept with the same key for the |
| 245 | one case it still serves β a desktop rendering opened on a narrow |
| 246 | screen β and is no longer what makes the order true. A reflowed canvas |
| 247 | slide takes Β§2.3's z-then-top-left sequence first and the phone `order` |
| 248 | on top of it. Rendering for the desktop viewport keeps the desktop |
| 249 | document order. |
| 250 | |
| 251 | ### 2.6 Text |
| 252 | |
| 253 | Text is a ProseMirror document under a constrained schema: `paragraph` |
| 254 | only at the block level; bold, italic, code, link and highlight as marks. |
| 255 | No nested blocks, no headings inside text (a heading is a block). Lists |
| 256 | are `list` blocks whose paragraphs are items; a nested list is a second |
| 257 | `list` block with `indent` 1. The same schema drives speaker notes and |
| 258 | table cells. |
| 259 | |
| 260 | Over Yjs, that document is an XmlFragment of `<paragraph>` elements, each |
| 261 | holding one XmlText whose formatting attributes are the marks. **The |
| 262 | ProseMirror marks are named `b`, `i`, `c`, `h`, `href` β the Y attribute |
| 263 | names β and `href` is stored as the one-key map `{"href": url}`, not as a |
| 264 | plain string** (ruling 12). Both follow from y-prosemirror's attribute |
| 265 | mapping, which has no hook to override: it writes |
| 266 | `attributes[mark.type.name] = mark.attrs` and reads |
| 267 | `schema.mark(attrName, attrValue)`. So a mark's name must be the |
| 268 | attribute name, and the mark `href` with attrs `{href}` is the attribute |
| 269 | `href` carrying that object. A plain string there throws inside |
| 270 | `schema.mark`, and y-prosemirror deletes the XmlText on a throw. Naming |
| 271 | the marks this way makes the mapping the identity and keeps mapping code |
| 272 | out of the editor. |
| 273 | |
| 274 | The writer is exact; the reader is tolerant, because updates reach the |
| 275 | server from browsers as well. `b`, `i`, `c`, `h` are written `true` and |
| 276 | read as set on any truthy value (y-prosemirror writes `{}` for an |
| 277 | attr-less mark); `href` is written as the map and read as either the map |
| 278 | or a plain string (documents written before this ruling). |
| 279 | |
| 280 | **The accepted shape is narrower than what the reader tolerates** |
| 281 | (ruling 19, codex 3). The reader's tolerance is for documents that |
| 282 | already exist; it is not the gate on a write, because the editor does |
| 283 | not consume the reader's projection β it binds y-prosemirror to the |
| 284 | shared text, and y-prosemirror 1.3.7 deletes an `XmlText` whose |
| 285 | attributes name a mark the schema lacks (`schema.mark` throws a |
| 286 | `RangeError`; `createTextNodesFromYText`'s catch deletes the item). A |
| 287 | posted update is therefore accepted only if every text fragment in the |
| 288 | document it produces β each slide's `notes`, each block's `text`, each |
| 289 | table's `cells` β is `<paragraph>` elements holding only text whose |
| 290 | formatting attributes are among `b`, `i`, `c`, `h`, `href`, with no |
| 291 | embeds. Anything else is refused before it is stored (Β§3.3). The |
| 292 | browser, for its part, strips an unknown attribute from a fragment |
| 293 | before binding, so a document that already carries one loses the mark |
| 294 | and not the words β that is the smallest change the library leaves |
| 295 | room for, since it offers no hook between reading the delta and |
| 296 | constructing the mark. |
| 297 | |
| 298 | ### 2.7 Assets |
| 299 | |
| 300 | An image is uploaded browser-to-bucket by presigned PUT (rastrillo |
| 301 | `blobs`), then registered in `assets` with its `blobs.Ref`, dimensions |
| 302 | and alt text. The server verifies the object exists and is an image |
| 303 | before registering. Limits: 20 MiB per image, PNG, JPEG, WebP, SVG |
| 304 | (SVG is sanitised on registration). Deleting the last block that uses an |
| 305 | asset leaves the asset; a deck-level "unused assets" list lets a person |
| 306 | remove them. There is no cross-deck asset library in v1. |
| 307 | |
| 308 | **Stage, then promote (codex 1, codex 2).** The browser never names the |
| 309 | address its bytes will live at, and a ref is never on its own enough to |
| 310 | get anything registered. Ruling 14; the shape: |
| 311 | |
| 312 | - `POST /d/{sqid}/uploads` mints a **staging key** β a hash of the deck, |
| 313 | the actor and fresh randomness β writes an upload **receipt** |
| 314 | (`asset_uploads`: deck, actor, staging key, object key, declared |
| 315 | hash, consumed_at) and signs a PUT for that key and nothing else. It |
| 316 | refuses to sign for an address any `deck_assets` row verifies, which |
| 317 | a fresh random key can never be: the check is there so the mistake |
| 318 | cannot come back. |
| 319 | - `POST /d/{sqid}/assets` resolves the receipt β same deck, same actor, |
| 320 | unconsumed, under two hours β reads the staged bytes, sniffs them, |
| 321 | sanitises an SVG, and **puts what it read back** as a final object |
| 322 | addressed by its own SHA-256. That address is what the deck refers |
| 323 | to. The receipt is then spent, single-use, and the staged copy is |
| 324 | deleted unless it is already the promoted object. |
| 325 | - Nothing anywhere signs a PUT for a final address. A verified object |
| 326 | is immutable by construction, not by nobody having tried. |
| 327 | |
| 328 | Why, in one line each. A presigned PUT is unconditional |
| 329 | (`rastrillo/blobs`' own note says the server must verify what landed), |
| 330 | so an address a browser can name is an address a browser can overwrite β |
| 331 | naming a ref another deck was already serving from changed that |
| 332 | picture, in that deck, with no document update anywhere. And |
| 333 | registration wrote the destination deck's verification row for any ref |
| 334 | it was handed, with no receipt and no access to the source deck β so |
| 335 | knowing a content address, which is knowing the SHA-256 of somebody's |
| 336 | file, was permission to read it. Both were reachable by any member. |
| 337 | |
| 338 | The cost, accepted: registration now reads **and** re-writes up to |
| 339 | 20 MiB per picture instead of only reading it. The download was already |
| 340 | happening; the upload is the price of a final address derived from |
| 341 | bytes this app has actually seen. |
| 342 | |
| 343 | **An agent cannot add a picture.** It can name a ref in a |
| 344 | `register_asset` op and the document will hold it, but no surface will |
| 345 | serve those bytes, because serving requires a verification row and a |
| 346 | verification row is only written for an upload this app signed. A |
| 347 | bearer-authenticated upload is the obvious next thing and is not built |
| 348 | (Β§9). |
| 349 | |
| 350 | The **copy** path codex 2 offers as an alternative β registering a ref |
| 351 | from a deck the person may read, scoped through `visibleDecks` β is |
| 352 | deliberately not built. Nothing asks for it, and a cross-deck asset |
| 353 | library is already out of v1 by the paragraph above; when it lands it |
| 354 | goes through `visibleDecks` and takes a receipt of its own. |
| 355 | |
| 356 | ### 2.8 Fixtures are the contract |
| 357 | |
| 358 | The JS side generates Y.Doc updates for a fixed set of decks |
| 359 | (`fixtures/data/*.json` describing the tree, `fixtures/generate.mjs` |
| 360 | producing `*.yjs` updates). The Rust core must load each and produce the |
| 361 | same JSON view (`slides_core_json_read`). Docs' rule, adopted unchanged. |
| 362 | |
| 363 | --- |
| 364 | |
| 365 | ## 3. The editor, presenting, and the remote |
| 366 | |
| 367 | ### 3.1 Editor |
| 368 | |
| 369 | A JS bundle built like Docs' (`fixtures/build-bundles.mjs`, esbuild, |
| 370 | committed hashed output, embedded by `assets.go`), ProseMirror plus yjs, |
| 371 | no framework. Three panes on a desktop: the **strip** (slide thumbnails, |
| 372 | drag to reorder, keyboard reorder with a menu equivalent), the **stage**, |
| 373 | and the **inspector**. On a phone the strip is a horizontal filmstrip |
| 374 | under the stage and the inspector is a bottom sheet. |
| 375 | |
| 376 | The stage shows the server's HTML for the current slide (Β§4.1) with the |
| 377 | editor bound to it: each text-bearing block is a ProseMirror view over |
| 378 | its XmlFragment; canvas blocks get drag, resize handles, snap guides at |
| 379 | the frame's thirds and other blocks' edges, and z-order from the |
| 380 | inspector. Flow blocks reorder by drag or by the inspector's move |
| 381 | buttons. |
| 382 | |
| 383 | The inspector is per selection: block props, slide props (layout, |
| 384 | template, background, transition, phone behaviour), deck props (title, |
| 385 | theme, accent, fonts). **Every inspector control is one drive operation |
| 386 | (Β§7.2)** and the editor calls the same function the API handler calls, |
| 387 | so a person and an agent can never do different things. |
| 388 | |
| 389 | Undo is per person (yjs `UndoManager`, scoped to that client's origin). |
| 390 | Keyboard: the ordinary text shortcuts, plus `Cmd/Ctrl+Enter` for a new |
| 391 | slide, `Cmd/Ctrl+D` duplicate slide, `Cmd/Ctrl+Shift+F` present. |
| 392 | |
| 393 | ### 3.2 The desktop/phone switcher |
| 394 | |
| 395 | A three-state control on the stage: **desktop**, **phone**, **both**. |
| 396 | Phone view renders the stage at 390 CSS pixels wide with the phone |
| 397 | layer applied. Edits to layout while in phone view go to the `phone` |
| 398 | override map (Β§2.5); edits to text go to the text (there is one text). |
| 399 | A block with an override shows a small badge in both views with "reset |
| 400 | to desktop" in its inspector. "Both" shows desktop and phone side by |
| 401 | side, the phone read-only. |
| 402 | |
| 403 | *Slice 2 deviation:* the override badge lives in the inspector, beside |
| 404 | "reset to desktop", not on the block on the stage β the stage is the |
| 405 | server's own slide HTML and carries no editor chrome yet. Putting it on |
| 406 | the block is a slice 3 candidate. |
| 407 | |
| 408 | ### 3.3 Collab |
| 409 | |
| 410 | Docs' live-sync spec is adopted as written |
| 411 | (`oficina/docs`, `docs/superpowers/specs/2026-09-01-live-sync-design.md` |
| 412 | Β§3 to Β§7): `GET /d/{sqid}/stream` SSE emitting `event: update` with a |
| 413 | seq; bytes by `GET|POST /d/{sqid}/updates`; the cursor+1 rule; broadcast |
| 414 | on success only; the 15 s ping, 30 s write deadline, 5 minute TTL; |
| 415 | hibernation-safe because the stream carries no bytes. Slides is the |
| 416 | second implementation, which is the estilo rule's trigger: whatever |
| 417 | Docs' hub and stream code turns out to need in both places is extracted |
| 418 | that week, and the Slides spec records the extraction when it happens. |
| 419 | |
| 420 | The gate on `POST /d/{sqid}/updates` applies the bytes to a fresh |
| 421 | handle and, before the transaction opens, checks the document they |
| 422 | leave (codex 3): first the core's accepted-shape check over every text |
| 423 | fragment (Β§2.6; `slides_core_check`, ABI 2), then the typed parse of |
| 424 | the JSON view for shapes the reader cannot hold at all. Either finding |
| 425 | is a 422 and nothing is stored. The check is on the document rather |
| 426 | than on the view because the view is a tolerant projection, and what it |
| 427 | tolerates is precisely what the editor cannot. |
| 428 | |
| 429 | Presence rides the same stream as a second event type, `presence`, |
| 430 | carrying `{client, name, colour, slide, block}`; the server holds |
| 431 | presence in memory only (it is not document state) and drops a client |
| 432 | after two missed pings. The editor shows avatars on the strip |
| 433 | thumbnails and a coloured outline on the block another person has |
| 434 | selected. Docs owns the first build of the presence stack per estilo's |
| 435 | ownership table; if Docs has not built it when Slides needs it, Slides |
| 436 | builds it and Docs adopts. |
| 437 | |
| 438 | ### 3.4 Presenting, speaker notes, the remote |
| 439 | |
| 440 | **Presentation session.** A row: `deck`, `presenter` (actor), `slide` |
| 441 | (index), `blackout` (bool), `started_at`, `ended_at`, `code` (six |
| 442 | characters, unambiguous alphabet), `remote_token_hash`. Created by |
| 443 | `POST /d/{sqid}/present`, which returns the session sqid and, once, a |
| 444 | remote capability URL. Ended explicitly or after 12 hours. One live |
| 445 | session per deck per presenter. |
| 446 | |
| 447 | **Presentation mode.** `GET /p/{session}` is the fullscreen surface: |
| 448 | the current slide filling the viewport, keyboard (arrows, space, `b` |
| 449 | for blackout, `n` for notes, `Esc`), swipe, a thin progress bar, and |
| 450 | the slide's transition. The page subscribes to the session's SSE stream |
| 451 | (`GET /p/{session}/stream`, `event: slide` with the index and blackout |
| 452 | state) and applies what it hears, so it is the same page whether the |
| 453 | presenter's keyboard or the phone moved the deck. It works with scripts |
| 454 | off as a page per slide with next and previous links (Β§8). |
| 455 | |
| 456 | **Presenter view.** `GET /p/{session}/notes` shows the current slide, |
| 457 | the next slide, the notes, a clock and elapsed time, and the pairing |
| 458 | code and its QR. Meant for a second window on a desktop or the |
| 459 | presenter's phone. Its controls post the same commands as the remote. |
| 460 | |
| 461 | **Remote.** `GET /r/{code}` on a phone: enter the code or scan the QR, |
| 462 | which carries the remote capability URL. The page shows notes, current |
| 463 | and next slide thumbnails, and **next**, **previous**, **blackout**, |
| 464 | plus **end**. Commands are `POST /p/{session}/command` with |
| 465 | `{op: next | prev | goto | blackout | end, slide?}` and the remote |
| 466 | capability as a bearer (or, scriptless, as a form field). The presenting |
| 467 | browser applies the resulting `slide` event. Latency target under 300 ms |
| 468 | on the same continent. The capability lives as long as the session. A |
| 469 | native remote later speaks exactly `present`, `command` and the |
| 470 | `stream`; nothing else is needed. |
| 471 | |
| 472 | **Audience follow.** A share link (Β§5) opened while a session is live |
| 473 | for that deck shows "follow the presenter". Following subscribes the |
| 474 | viewer to the session stream read-only; the viewer can detach and |
| 475 | browse, and reattach. |
| 476 | |
| 477 | **An audience stays behind the capability it arrived with (codex 5).** |
| 478 | Following is `/s/{token}?follow=1`, and every read that page then makes |
| 479 | is a `/s/{token}` route: the slide fragments |
| 480 | (`?follow=1&slide=N&fragment=1`), the stream (`/s/{token}/stream`), the |
| 481 | thumbnails and the pictures. Each resolves the token again, and the |
| 482 | stream re-resolves it before every event and every heartbeat, so |
| 483 | revoking the link ends the follow immediately rather than at the end of |
| 484 | the talk. **The session sqid is never rendered onto a follow page**, and |
| 485 | the two URLs the script uses come down as `data-stream` and |
| 486 | `data-fragment` attributes rather than being assembled in the browser |
| 487 | out of a sqid it does not have. Before this, following handed over the |
| 488 | sqid and every later read went to `/p/β¦`, which asks only for a live |
| 489 | presentation β a capability the person who granted the link could not |
| 490 | take back. |
| 491 | |
| 492 | **Who may publish which capability**, settled (codex 5 asked; ruling |
| 493 | 15). Presenting stays open to **every member who can read the deck**, |
| 494 | not the owner alone: reading a deck to a room is reading a deck, and |
| 495 | `POST /d/{sqid}/present` is `deckFor`'s audience like the editor and |
| 496 | the export. Minting a **share link** stays the owner's, because a share |
| 497 | link grants read to people with no account here and outlives any talk. |
| 498 | The two are different powers and the mismatch was only apparent: the |
| 499 | session sqid is a **projector address** β it lives as long as the talk, |
| 500 | carries no notes and cannot be revoked β and it is now given only to |
| 501 | whoever is presenting, never to an audience. An audience gets a share |
| 502 | link, which is revocable, which is why audience-follow derives from the |
| 503 | link and not from the session. |
| 504 | |
| 505 | **A projector page is not a nothing-happens page.** `/p/{session}` |
| 506 | still answers to anybody holding the sqid, deliberately: that is what |
| 507 | makes "put this on the screen" a URL. What changed is that nobody is |
| 508 | handed one who was not meant to have it. |
| 509 | |
| 510 | **Out-of-order fragments (codex 13).** The script tracks *requested* |
| 511 | and *displayed* separately and stamps every fetch with a monotonic id: |
| 512 | a response whose id is not the newest is discarded, displayed only |
| 513 | moves after an install succeeds, and a failed fetch rolls requested |
| 514 | back to displayed. One state β "the slide the page has announced" β |
| 515 | left a projector permanently behind: with the record already at 3, a |
| 516 | late response for 2 overwrote the stage and the event that would have |
| 517 | corrected it was suppressed as a slide the page was already on. |
| 518 | |
| 519 | --- |
| 520 | |
| 521 | ## 4. Rendering and export |
| 522 | |
| 523 | ### 4.1 The HTML renderer |
| 524 | |
| 525 | One Go package, `internal/render`, turns a slide (from the Rust core's |
| 526 | JSON view) into HTML: a `<section slides-slide>` with the template's |
| 527 | grid or the canvas frame, blocks as semantic elements (`h1` to `h3`, |
| 528 | `p`, `ul`, `ol`, `blockquote`, `figure`, `pre`, `table`), theme tokens |
| 529 | as CSS custom properties on the section, and the phone layer as |
| 530 | `data-phone-*` attributes that the stylesheet honours under the phone |
| 531 | media query. It serves the viewer, the presenter, the remote thumbnails |
| 532 | and the editor stage. |
| 533 | |
| 534 | The one rule's refinement recorded here: the session had said SVG would |
| 535 | serve the scriptless viewer. HTML is the better choice for every live |
| 536 | surface because it reflows, it is accessible without extra work, and it |
| 537 | is what the editor must bind to. SVG remains for export and thumbnails, |
| 538 | where a fixed geometry is the point. |
| 539 | |
| 540 | ### 4.2 The layout engine and SVG |
| 541 | |
| 542 | `internal/layout` measures text with the theme's fonts (self-hosted |
| 543 | TTFs embedded in the binary, metrics via `golang.org/x/image/font/sfnt`) |
| 544 | and places blocks in a 1920 by 1080 frame, producing a paint list: |
| 545 | rectangles, images, text runs with font, size, position. `internal/svg` |
| 546 | serialises a paint list as SVG with text as `<text>` runs and images by |
| 547 | reference. Thumbnails are the SVG scaled down, served as SVG. |
| 548 | |
| 549 | Divergence from the browser is confined to line breaking inside a text |
| 550 | block and is small when fonts match, the Docs finding. It is not zero |
| 551 | and this spec does not claim it is. |
| 552 | |
| 553 | **Amended 2026-09-08, from what slice 3 shipped.** The paint list feeds |
| 554 | exactly two things, the thumbnails and the PDF. Every live surface β the |
| 555 | viewer, the presenter's stage, the editor's stage, the API's `.html` |
| 556 | twin β is still `render.Slide`'s HTML, so Β§0.1's one tree is rendered |
| 557 | by two renderers and never a third. Images are *inlined* rather than |
| 558 | referenced wherever the bytes can be read, because an SVG inside an |
| 559 | `<img src>` fetches nothing at all β a referenced picture is simply |
| 560 | absent from a thumbnail. The image an inline cannot carry falls back to |
| 561 | a URL, which draws only when the `.svg` is opened as a document. See |
| 562 | Β§4.3's amendment for the fonts, which is the other half of the same |
| 563 | restriction. |
| 564 | |
| 565 | ### 4.3 PDF |
| 566 | |
| 567 | `core/` (Rust) gains a second module beside the CRDT: `slides_pdf`, |
| 568 | wrapping `krilla` and `krilla-svg` with `usvg` and `fontdb` fed the same |
| 569 | embedded fonts, compiled to wasm32 and run under wazero like the CRDT |
| 570 | core. (It wrapped `svg2pdf` from slice 3's Task 1 spike until |
| 571 | 2026-09-09; see the amendment at the end of this section.) Input: one |
| 572 | SVG per slide plus the fonts; output: one PDF, 1920 by 1080 points per |
| 573 | page, with the speaker notes as an optional second page per slide |
| 574 | ("with notes" export). Images are embedded at their stored resolution |
| 575 | capped at 2000 px on the long edge. |
| 576 | |
| 577 | If SVG-to-PDF in Rust under wasm32 proves unworkable in the plan's spike, the |
| 578 | fallback recorded now is a Go PDF writer over the paint list directly, |
| 579 | reusing Docs' font subsetting (`oficina/docs` `internal/pdf`) by |
| 580 | extraction to a shared module, not by copy. |
| 581 | |
| 582 | Export runs as a rastrillo job: `POST /d/{sqid}/export` starts it, the |
| 583 | status page polls, the result is a presigned GET from the object store |
| 584 | that expires in one hour. A deck over 200 slides is refused with a |
| 585 | message. |
| 586 | |
| 587 | **Amended 2026-09-08, from what slice 3 shipped.** Four things this |
| 588 | section said differently: |
| 589 | |
| 590 | - The fonts are the twelve Liberation faces (Sans, Serif, Mono in four |
| 591 | styles), embedded in the binary for the layout engine and the PDF and |
| 592 | served as `@font-face` to every slide surface, rather than "the |
| 593 | theme's fonts" per theme. Metrics come from this repo's own TrueType |
| 594 | reader (`internal/slides/fonts`), not `x/image/font/sfnt`. |
| 595 | - The packages are `internal/slides/layout` and `internal/slides/svg` |
| 596 | (Β§4.2's `internal/layout` and `internal/svg` predate the |
| 597 | one-directory-under-internal rule). |
| 598 | - **A thumbnail carries no embedded fonts.** An SVG in an `<img src>` |
| 599 | fetches nothing, so a thumbnail is set in whatever face the reader's |
| 600 | machine has under the same stack `slides.css` names; the text wraps |
| 601 | where the paint list says either way. Embedding the faces would be |
| 602 | about 800 KB per thumbnail. The PDF is unaffected β the module is |
| 603 | handed the faces directly. |
| 604 | - **With no object store bound the finished PDF is held in memory for an |
| 605 | hour** and served from the instance, capped at 50 MiB per deck and |
| 606 | 200 MiB per instance; with a bucket the job uploads it and the |
| 607 | download route 302s to the presigned GET. Both paths are one handler. |
| 608 | The spike ruled SVG-to-PDF in Rust under wasm32 viable, so the |
| 609 | fallback Go PDF writer above was not built. |
| 610 | - **The status page and the file agree about the hour.** rastrillo/jobs |
| 611 | forgets a finished job ten minutes after it ends; the bytes are |
| 612 | answerable for the hour the page promised. So the status page, its |
| 613 | polled fragment and the API's status document all fall back to this |
| 614 | app's own held record once the registry has forgotten β done, with |
| 615 | the download still offered β rather than 404ing for fifty minutes |
| 616 | while `.pdf` at the same address still hands over the file. |
| 617 | Shortening the hold to the job's ten minutes was the other way to |
| 618 | make the two agree and takes away a download that was promised. |
| 619 | |
| 620 | A WebP image is re-encoded as a PNG on its way into a page |
| 621 | (`golang.org/x/image/webp`), because a browser rendering an `<img src>` |
| 622 | SVG fetches nothing and WebP is one of the four types Β§2.7 admits. |
| 623 | `krilla-svg` does decode WebP directly, so since the swap below this is |
| 624 | belt and braces on the PDF side; it is kept because dropping it changes |
| 625 | what the SVG encoder emits. |
| 626 | |
| 627 | **Amended 2026-09-09: the converter is krilla, not svg2pdf.** |
| 628 | `typst/svg2pdf` was archived on 2026-07-10 naming krilla as its |
| 629 | successor, which the original spike had not checked, so the module was |
| 630 | built on a dependency nobody was maintaining. A second timeboxed spike |
| 631 | swapped it: `krilla` 0.8.2 with `krilla-svg` 0.8.1 over `usvg` 0.47 and |
| 632 | the same `fontdb` fed from linear memory. It builds for |
| 633 | wasm32-unknown-unknown with no features disabled β krilla-svg has none, |
| 634 | and the resvg and tiny-skia defaults it drags in compile clean β with |
| 635 | zero host imports, and every property ruling 13 asked for holds: one |
| 636 | page per SVG at 1920Γ1080 pt, text as subset embedded TrueType |
| 637 | (Type0/CIDFontType2, FontFile2, ToUnicode), raster images embedded, and |
| 638 | a frame error still a refusal rather than a trap. The module grew from |
| 639 | 3,672,407 to 5,301,002 bytes. Against that, a 200-page export through |
| 640 | the Go host went from 642 ms to 220 ms and from 1,719,771 to 140,444 |
| 641 | bytes, because krilla writes one font subset per document where svg2pdf |
| 642 | wrote one per page β a handout that used to be 1.7 MB of repeated font |
| 643 | programs is now 140 KB. krilla's XMP metadata packet is turned off: |
| 644 | this module has no title, author or clock to put in one, and what it |
| 645 | would carry instead is RDF namespace URLs, which would break the plainer |
| 646 | rule that a Slides PDF names nothing outside itself. The C ABI |
| 647 | (`slides_pdf_abi_version` is still 1) and `internal/slides/pdfcore` did |
| 648 | not change; what did change above the module is the tests, which grep |
| 649 | PDF bytes and now take a dictionary entry in either spelling, because |
| 650 | pdf-writer 0.12 wrote `/Type /Page` and 0.15 writes `/Type/Page`. |
| 651 | |
| 652 | --- |
| 653 | |
| 654 | ## 5. Sharing and links |
| 655 | |
| 656 | Rungs are suite ruling 15's: **private** (the creator), **granted** |
| 657 | (named people on this instance), **team** (everyone on the instance can |
| 658 | open it). Every deck starts private. Editing needs sign-in and a rung of |
| 659 | `granted` or `team`; a `team` deck is editable by every member, which is |
| 660 | the Docs reading and the one that suits a small team. |
| 661 | |
| 662 | A **share link** is a read-only capability, calendar's pattern |
| 663 | (`oficina/calendar` calendar design Β§6): `GET /s/{token}` mounted |
| 664 | outside the session group, only the token's SHA-256 stored, the URL |
| 665 | shown once at creation, revocable from the deck's share page, with a |
| 666 | view count. The link opens the viewer (Β§8) with the deck as it is now, |
| 667 | not a snapshot. If a presentation session is live, the link offers |
| 668 | follow (Β§3.4) β and following stays **on** `/s/{token}` routes, so |
| 669 | revocation reaches it (codex 5). |
| 670 | |
| 671 | **Revocation is the property, so nothing may outlive it.** Every read a |
| 672 | link holder makes β page, slide fragment, thumbnail, picture, |
| 673 | presentation stream β resolves the token on that request. A capability |
| 674 | handed out *through* a link that the link's own revocation cannot |
| 675 | reach is a bug, not a feature; codex 5 found the one there was. |
| 676 | |
| 677 | A share link is the **deck's** capability and not the person's who |
| 678 | minted it. It survives that person being offboarded (Β§6.1), and it is |
| 679 | turned off from the deck's share page by somebody who still has access. |
| 680 | |
| 681 | The **present link** is the same capability with `?present=1`, opening |
| 682 | the fullscreen viewer directly. |
| 683 | |
| 684 | An "edit link" does not exist; ruling 15 says an edit capability is a |
| 685 | sign-in that mints a guest grant, which waits on the home. |
| 686 | |
| 687 | --- |
| 688 | |
| 689 | ## 6. Identity and the instance |
| 690 | |
| 691 | ### 6.1 Lone shape |
| 692 | |
| 693 | Sign-in is Docs' `internal/docs/auth.go` adopted whole: rastrillo/auth |
| 694 | magic links, `SLIDES_ALLOWED_ADDRESSES` gating `POST /signin` in front |
| 695 | of `Begin` and in `Authorize`, `SLIDES_ORIGIN`, `SLIDES_INSTANCE_KEY`, |
| 696 | `SLIDES_SMTP_*`, each a startup refusal outside `-env development`. The |
| 697 | same 303 for permitted and non-permitted addresses. This is the second |
| 698 | copy of that file, which is the extraction trigger: the plan includes a |
| 699 | task to propose `auth.go` to rastrillo or estilo as a package. |
| 700 | |
| 701 | The person row is `users(id, email, name, colour)` created on first |
| 702 | sign-in. `sessions.Current(r).Subject` is the email, mapped to the row |
| 703 | before any scoping, per the rastrillo Β§5 warning. |
| 704 | |
| 705 | **Offboarding (codex 6, ruling 16).** The allowlist is the *only* |
| 706 | membership control a lone instance has, so it has to work in both |
| 707 | directions: taking an address off `SLIDES_ALLOWED_ADDRESSES` must |
| 708 | remove the access that address already had, not merely stop it getting |
| 709 | more. It gated the minting of a session and nothing else β an existing |
| 710 | session resolved its user row without asking, rastrillo's |
| 711 | `RequireSession` asks nothing, and an agent needed only an unrevoked |
| 712 | token and an operator row β so a removed person kept everything until |
| 713 | their session expired, agents included. |
| 714 | |
| 715 | So admission is **rechecked wherever a principal is resolved**, on |
| 716 | every request, and fails closed: |
| 717 | |
| 718 | - `currentUser` (auth.go) checks the session's subject. Not admitted |
| 719 | means: every session row for that address is deleted (real |
| 720 | revocation, every device, not just this browser's cookie), the cookie |
| 721 | is cleared, and the person is sent to `/signin?err=removed` with a |
| 722 | sentence saying their access was removed rather than that sign-in |
| 723 | failed. A non-page request gets a 403 instead, which is rauth's own |
| 724 | split. |
| 725 | - The bearer middleware (agents.go) checks the **operator's** address. |
| 726 | Not admitted means 401 β the same 401 a revoked token gets. An agent |
| 727 | sees exactly what its operator sees, and this is the one path out of |
| 728 | the instance that survives a browser being signed out. |
| 729 | |
| 730 | The outstanding capabilities, decided: |
| 731 | |
| 732 | | Capability | On offboarding | Why | |
| 733 | |---|---|---| |
| 734 | | Sessions (every device) | **revoked** | It is the person's own credential and nothing else depends on it. | |
| 735 | | Agent tokens | **refused, not revoked** | The row stays, so re-admitting the address restores the agent rather than making somebody mint a new token; the recheck is what makes the refusal current. | |
| 736 | | Live presentations they started | **ended** | A live session is a public URL that reads the deck out. Every projector, phone and follower gets the `ended` event at once. | |
| 737 | | Share links on their decks | **kept working** | A share link is the *deck's* capability (Β§5), the deck is the team's, and revoking the team's links because one member left is an outage the departure did not call for. Somebody with access turns them off from the share page. | |
| 738 | | Decks they own | **kept** | Ownership is not access. A deck with an owner who cannot sign in is still readable by the team rung and by grants; transferring ownership needs a screen and has none (Β§9). | |
| 739 | |
| 740 | Re-adding the address restores everything except the sessions and the |
| 741 | talks, which are gone: signing in again is one email. |
| 742 | |
| 743 | ### 6.2 Actors |
| 744 | |
| 745 | Every write records an **actor**: `person:<user id>` or |
| 746 | `agent:<agent id>`. Presentation sessions, share links, exports, agent |
| 747 | tokens and the deck's activity list all carry it. The Y.Doc update log |
| 748 | carries it too (a column on the updates table, not in the CRDT). |
| 749 | |
| 750 | **One explicitly anonymous actor: `remote:<session sqid>`** (codex 15, |
| 751 | ruling 17). A presentation command (`POST /p/{session}/command`) is |
| 752 | authenticated by the remote token, which is a **transferable |
| 753 | capability** β that is the feature: it goes on a phone, the phone is |
| 754 | shown as a QR to a room, the phone may be somebody else's. The server |
| 755 | therefore cannot establish who is holding it, and recording the |
| 756 | session's creator on those writes was a claim about identity the app |
| 757 | could not support. So a remote command is recorded as |
| 758 | `remote:<session sqid>`, which is honest, with the **issuer** β the |
| 759 | session's own actor, the person or agent who started the talk β logged |
| 760 | beside it, so the trail still reaches somebody answerable. |
| 761 | |
| 762 | This is the exception and it is named as one: ruling 7's "every write |
| 763 | names its real actor" holds, and this is what the real actor of a |
| 764 | transferable capability is. Nothing else in the app writes anonymously, |
| 765 | and a remote command touches only the presentation row β never the |
| 766 | document, whose log has no anonymous entries at all. |
| 767 | |
| 768 | ### 6.3 Deploy |
| 769 | |
| 770 | One static binary, `CGO_ENABLED=0`, `linux-arm64`, `make release` with |
| 771 | the version stamped, `carlos deploy --app slides --account oficina` |
| 772 | with the build probe on `/api/version`. `carlos store create` for the |
| 773 | object store; `carlos domains attach slides.oficina.works`. Secrets |
| 774 | `SLIDES_INSTANCE_KEY` and `SLIDES_SMTP_PASS` via `carlos secrets`, the |
| 775 | rest via `carlos env`, mirroring Docs' set. |
| 776 | |
| 777 | --- |
| 778 | |
| 779 | ## 7. The drive surface |
| 780 | |
| 781 | ### 7.1 Principals |
| 782 | |
| 783 | A signed-in person mints an **agent token** from Settings, naming the |
| 784 | agent (`claude`, `codex`); the app stores `agents(id, name, operator, |
| 785 | token_hash, created_at, revoked_at)` and shows the token once. The |
| 786 | agent's principal is `<name>~<operator email>`; every write it makes is |
| 787 | badged with a robot mark and the operator's name in the activity list, |
| 788 | following amadan's own convention. A revoked token fails closed. Tokens |
| 789 | are `Authorization: Bearer` on the API only; the API never reads a |
| 790 | session cookie and the HTML routes never read a bearer. |
| 791 | |
| 792 | A token also fails closed on its **operator's** admission, rechecked on |
| 793 | every request (Β§6.1, codex 6): an operator taken off the allowlist |
| 794 | means 401, indistinguishable from a revoked token. An agent sees |
| 795 | exactly what its operator sees, and that includes seeing nothing. |
| 796 | |
| 797 | ### 7.2 Operations |
| 798 | |
| 799 | `POST /api/v1/decks/{sqid}/ops` takes a JSON array of operations and |
| 800 | applies them in order inside one transaction against the Y.Doc through |
| 801 | the Rust core, producing one update, which is then broadcast exactly as |
| 802 | a browser's POST would be. Operations, each with the ids of Β§2: |
| 803 | |
| 804 | - deck: `set_meta` |
| 805 | - slide: `add_slide` (after, layout, template), `remove_slide`, |
| 806 | `move_slide`, `set_slide` (props), `set_notes` (text as Markdown |
| 807 | subset, converted server-side), `duplicate_slide` |
| 808 | - block: `add_block` (slide, type, props, text, after or box), |
| 809 | `remove_block`, `set_block` (props, box, z, region), `set_text` |
| 810 | (Markdown subset), `move_block`, `set_phone` (the Β§2.5 map) |
| 811 | - asset: `register_asset` after a presigned upload obtained from |
| 812 | `POST /api/v1/decks/{sqid}/uploads` |
| 813 | |
| 814 | Reads: `GET /api/v1/decks` (list), `GET /api/v1/decks/{sqid}` (the JSON |
| 815 | view, with an `ETag` of the seq), `GET /api/v1/decks/{sqid}/slides/{id}.svg` |
| 816 | (a thumbnail, so an agent can look at what it made), |
| 817 | `GET /api/v1/decks/{sqid}/export.pdf`. `POST /api/v1/decks` creates. |
| 818 | |
| 819 | The editor's inspector calls the same `ops` function in-process through |
| 820 | the JS side of yjs; the server's `ops` handler calls the Rust core. The |
| 821 | fixtures (Β§2.8) include one file per operation so both sides are held |
| 822 | to the same result. |
| 823 | |
| 824 | ### 7.3 MCP |
| 825 | |
| 826 | An MCP server is a thin adapter over Β§7.2, deferred until a consumer |
| 827 | asks for it by name, memoria's rule. The ops are designed so each maps |
| 828 | to one tool with the same name. |
| 829 | |
| 830 | --- |
| 831 | |
| 832 | ## 8. The scriptless floor |
| 833 | |
| 834 | With scripts off, a person can: sign in, list decks, create a deck |
| 835 | (title and theme, from a form), open a deck as a paged viewer (one slide |
| 836 | per page, next and previous links, notes below when the deck allows), |
| 837 | present from a share link as the same paged viewer, read the presenter |
| 838 | notes page, download a PDF, manage sharing, mint and revoke agent |
| 839 | tokens, and use the remote (each button is a form that POSTs and |
| 840 | redirects back). They cannot edit slide content; the editor is the one |
| 841 | surface that needs the bundle, Docs' ruling. |
| 842 | |
| 843 | WCAG 2.2 AA with no exemptions, 24 by 24 targets, 320 px reflow (the |
| 844 | canvas stage may scroll inside its own region in the editor, the estilo |
| 845 | allowance), colour never alone, every visible string a `rastrillo.T` |
| 846 | key. |
| 847 | |
| 848 | --- |
| 849 | |
| 850 | ## 9. Deliberately not built yet |
| 851 | |
| 852 | | Not built | Trigger | |
| 853 | |---|---| |
| 854 | | Sign-in through the home; guest grants; edit links | The home's identity layer (home spec Β§5.3, Β§9). | |
| 855 | | Charts from Sheets by S6 | Sheets' S6 server exists and a deck wants a live number. | |
| 856 | | Video, embeds, audio | A person asks and the platform has a media store policy. | |
| 857 | | Follow-the-presenter inside Meet | Meet v1 ships. | |
| 858 | | Native remote | The web remote has been used in anger for a month. | |
| 859 | | MCP | A consumer names it. | |
| 860 | | 4:3 and custom aspect | A person asks; the key already exists. | |
| 861 | | Cross-deck asset library, templates gallery beyond built-ins | Ten decks on one instance. | |
| 862 | | Comments on slides | Docs' comment thread lands in estilo. | |
| 863 | | Version history and named snapshots | Docs' snapshots design is adopted rather than reinvented. | |
| 864 | |
| 865 | --- |
| 866 | |
| 867 | ## 10. Slices |
| 868 | |
| 869 | 1. **Live at `slides.oficina.works`:** scaffold, sign-in, deck list, |
| 870 | Rust core with the schema and JSON view, HTML renderer for both |
| 871 | modes with themes, viewer, share links, presentation sessions with |
| 872 | presenter view and phone remote, scriptless floor throughout, the |
| 873 | drive API for create and ops, first deploy. |
| 874 | 2. **Editor and collab:** the bundle, live-sync adoption, presence, the |
| 875 | switcher, undo, asset upload. |
| 876 | 3. **Export:** layout engine, SVG thumbnails, the PDF module, the export |
| 877 | job. |
| 878 | 4. **Extractions:** `auth.go` and the live-sync hub to shared modules; |
| 879 | suite discussion outcomes adopted. |
| 880 | |
| 881 | Slice 1 is what Paul asked to try first. It is a deck a person or an |
| 882 | agent can build through the API and present from a phone before the |
| 883 | editor exists, which is also the order that proves the model before the |
| 884 | most expensive part is built on it. |
| 885 | |
| 886 | --- |
| 887 | |
| 888 | ## 11. Testing |
| 889 | |
| 890 | - Rust core: unit tests per operation; fixture replay against the JS |
| 891 | generated updates (`fixtures/verify-rust.mjs` pattern from Docs). |
| 892 | - Renderer: golden HTML per template and per block type, both modes, |
| 893 | both viewports; an axe pass in the browser drive. |
| 894 | - Presentation sessions: a Go test driving two subscribers and a remote |
| 895 | command, asserting both see the slide event within the deadline. |
| 896 | - Share links: token stored hashed, revoked link 404s, live-session |
| 897 | follow offered only while live. |
| 898 | - Drive API: every op has a fixture; a bearer from a revoked agent is |
| 899 | refused; the actor column names the agent. |
| 900 | - Scriptless: the browser drive with JavaScript disabled walks sign-in, |
| 901 | create, view, present, remote. |
| 902 | - Deploy: `/api/version` reports the stamped version on the live host |
| 903 | before the deploy is called done. |
| 904 | |
| 905 | --- |
| 906 | |
| 907 | ## 12. Review record |
| 908 | |
| 909 | Revision 1, with Β§2.6 and ruling 12 rewritten during slice 2, Β§4.2 |
| 910 | and Β§4.3 amended on 2026-09-08 from what slice 3 shipped, and Β§2.2, |
| 911 | Β§2.3, Β§2.4, Β§2.5, Β§2.6 and Β§3.3 amended with rulings 18 to 20 the same |
| 912 | day from the Codex review's findings 3, 4, 7 and 10 (each paragraph |
| 913 | marked `codex N`): unreviewed. |
| 914 | The review pass is the first item after slice 1 |
| 915 | ships, so that the reviewer has a running instance and not a document. |
| 916 | |