oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Slides β€” design
2
3Design, 2026-09-07. Direction approved by Paul in conversation; this is the
4written form. **Revision 1.** Drafted by the Slides session (Fable 5.1)
5from a brainstorm with Paul the same day. Every ruling in Β§1 attributed to
6Paul was given in that conversation.
7
8Slides is a presentation editor for the Oficina suite: a deck of slides
9that looks like a Keynote or PowerPoint deck on a desktop and reads like
10a document on a phone, edited by several people at once, presented from a
11laptop and driven from a phone, exported to PDF, and driveable by an agent
12through the same operations a person has. One instance per team, on the
13CARLOS platform, at `slides.oficina.works` for the first team.
14
15Built 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
18made 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
27truth: the editor edits it, the viewer renders it, the phone reflows it,
28the PDF prints it, the agent patches it. Nothing that matters to what a
29reader sees lives outside the tree, so there is never a "desktop version"
30and a "mobile version" of a deck, only one deck and a viewport.
31
32Everything below derives from it. The hybrid layout modes (Β§2.3) are two
33ways of placing the same blocks, not two kinds of slide. The phone
34overrides (Β§2.5) are entries in the tree, not a second file. The drive
35API (Β§7) is the inspector's operations with a JSON body.
36
37### 0.2 Consumed suite rulings
38
39This app cites the suite sharing doctrine
40(`oficina/suite`, `docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md`,
41revision 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
51One ruling made by Paul during this brainstorm is suite-wide and is
52filed as an `oficina/suite` discussion rather than restated here as if it
53were this app's: **agents drive every Oficina app** as their own
54identified principal, `claude~paul` style, on behalf of a person, never
55as 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
60It 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
62suite home exists. It does not claim a native remote exists; Β§3.4
63designs the phone web page so a native app later speaks the same three
64calls.
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
101A 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
115A 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
149region in array order. The renderer places regions with CSS grid, and
150the template's grid collapses to one column at the phone breakpoint, so
151reflow is CSS, not a second layout.
152
153**Canvas.** Each block has `box: {x, y, w, h}` as fractions of the 16:9
154frame (0 to 1, four decimals) and `z`. The renderer positions blocks
155absolutely inside a frame that scales to the viewport width. On a phone
156the frame scales to fit, text and all, unless `phone` is `reflow`, in
157which case the renderer emits the blocks as a flow slide with the
158`blank` template in `z` then top-left reading order.
159
160Switching a slide's mode is an operation, not a conversion the person
161does by hand: flow to canvas assigns each block the box its region gave
162it (`z` is the reading-order index); canvas to flow drops boxes,
163assigns regions by position, and sets the reading order top to bottom
164then left to right. Both are lossy in one direction and say so in the
165inspector.
166
167The 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
169it β€” clone every block, clear the array, insert the copies β€” is what
170slice 2 did, and a peer typing into a block at that moment lost the
171sentence: their edit addressed the deleted original and the merge kept
172the copy. Placement is allowed to be lossy; content is not. The same
173rule holds for `move_block`.
174
175It holds for `move_slide` too, and by the same mechanism rather than a
176second one (ruling 21, codex 4 at slide level). A slide reordered while
177somebody is typing on it used to lose the typing for exactly the reason
178a moved block did: the slide map the other client's text, notes and
179blocks 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`
181key on each slide map (Β§2.1, Β§2.2) β€” one idea at both levels, one
182helper in each implementation, and `move_slide`, `add_slide`,
183`duplicate_slide` and `remove_slide` never rebuild the array.
184
185### 2.4 Blocks
186
187A block Y.Map holds `id` (same scheme as slides), `type`, `props`
188(Y.Map), and for text-bearing types `text` (Y.XmlFragment). The v1
189vocabulary, 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
205Every block additionally carries `region` (flow) or `box` and `z`
206(canvas), an optional `order` (Β§2.2), and an optional `phone` override
207map (Β§2.5).
208
209The `children` of a slide's `columns` blocks form a forest (ruling 16,
210codex 7): a block id appears in at most one `children` list on its
211slide, at most once in that list, and a columns block may not name a
212block that it already sits under. `add_block` and `set_block` refuse
213any 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
216applies nothing. A concurrent pair of assignments can still merge into a
217shared child or a cycle, so the renderer and the layout engine apply
218one deterministic policy: the first columns block in reading order that
219names a child owns it, and whatever no root reaches is rendered after
220everything else β€” in its own trailing region on a flow slide, at its box
221on a canvas one β€” marked `data-unreached` in HTML and reported as a
222`unreached` warning by the layout engine. Content is never dropped for
223being badly parented.
224
225Charts from Sheets, embeds and video are **not in v1** (Β§9).
226
227### 2.5 Phone overrides
228
229A block's `phone` Y.Map may hold: `hidden` (bool), `order` (integer,
230sorts before array order among siblings), `size` (a size step), and on
231canvas slides `box`. Nothing else. The desktop layout is always the
232base; the phone layer only adds.
233
234The editor's desktop/phone switcher (Β§3.2) writes to this map when the
235stage is in phone view. The renderer reads it when the viewport is a
236phone, and the print engine never reads it.
237
238`order` is reading order, not a visual effect (codex 10). Rendering for
239the phone viewport EMITS the blocks in the effective phone sequence β€”
240regions in region order; within a region and within a columns block, a
241stable sort by `order` with an unset order counting as 0 β€” so the
242sequence a screen reader hears and the Tab key follows is the one a
243sighted 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
245one case it still serves β€” a desktop rendering opened on a narrow
246screen β€” and is no longer what makes the order true. A reflowed canvas
247slide takes Β§2.3's z-then-top-left sequence first and the phone `order`
248on top of it. Rendering for the desktop viewport keeps the desktop
249document order.
250
251### 2.6 Text
252
253Text is a ProseMirror document under a constrained schema: `paragraph`
254only at the block level; bold, italic, code, link and highlight as marks.
255No nested blocks, no headings inside text (a heading is a block). Lists
256are `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
258table cells.
259
260Over Yjs, that document is an XmlFragment of `<paragraph>` elements, each
261holding one XmlText whose formatting attributes are the marks. **The
262ProseMirror marks are named `b`, `i`, `c`, `h`, `href` β€” the Y attribute
263names β€” and `href` is stored as the one-key map `{"href": url}`, not as a
264plain string** (ruling 12). Both follow from y-prosemirror's attribute
265mapping, 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
268attribute 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
271the marks this way makes the mapping the identity and keeps mapping code
272out of the editor.
273
274The writer is exact; the reader is tolerant, because updates reach the
275server from browsers as well. `b`, `i`, `c`, `h` are written `true` and
276read as set on any truthy value (y-prosemirror writes `{}` for an
277attr-less mark); `href` is written as the map and read as either the map
278or 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
282already exist; it is not the gate on a write, because the editor does
283not consume the reader's projection β€” it binds y-prosemirror to the
284shared text, and y-prosemirror 1.3.7 deletes an `XmlText` whose
285attributes name a mark the schema lacks (`schema.mark` throws a
286`RangeError`; `createTextNodesFromYText`'s catch deletes the item). A
287posted update is therefore accepted only if every text fragment in the
288document it produces β€” each slide's `notes`, each block's `text`, each
289table's `cells` β€” is `<paragraph>` elements holding only text whose
290formatting attributes are among `b`, `i`, `c`, `h`, `href`, with no
291embeds. Anything else is refused before it is stored (Β§3.3). The
292browser, for its part, strips an unknown attribute from a fragment
293before binding, so a document that already carries one loses the mark
294and not the words β€” that is the smallest change the library leaves
295room for, since it offers no hook between reading the delta and
296constructing the mark.
297
298### 2.7 Assets
299
300An image is uploaded browser-to-bucket by presigned PUT (rastrillo
301`blobs`), then registered in `assets` with its `blobs.Ref`, dimensions
302and alt text. The server verifies the object exists and is an image
303before registering. Limits: 20 MiB per image, PNG, JPEG, WebP, SVG
304(SVG is sanitised on registration). Deleting the last block that uses an
305asset leaves the asset; a deck-level "unused assets" list lets a person
306remove them. There is no cross-deck asset library in v1.
307
308**Stage, then promote (codex 1, codex 2).** The browser never names the
309address its bytes will live at, and a ref is never on its own enough to
310get 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
328Why, in one line each. A presigned PUT is unconditional
329(`rastrillo/blobs`' own note says the server must verify what landed),
330so an address a browser can name is an address a browser can overwrite β€”
331naming a ref another deck was already serving from changed that
332picture, in that deck, with no document update anywhere. And
333registration wrote the destination deck's verification row for any ref
334it was handed, with no receipt and no access to the source deck β€” so
335knowing a content address, which is knowing the SHA-256 of somebody's
336file, was permission to read it. Both were reachable by any member.
337
338The cost, accepted: registration now reads **and** re-writes up to
33920 MiB per picture instead of only reading it. The download was already
340happening; the upload is the price of a final address derived from
341bytes 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
345serve those bytes, because serving requires a verification row and a
346verification row is only written for an upload this app signed. A
347bearer-authenticated upload is the obvious next thing and is not built
348(Β§9).
349
350The **copy** path codex 2 offers as an alternative β€” registering a ref
351from a deck the person may read, scoped through `visibleDecks` β€” is
352deliberately not built. Nothing asks for it, and a cross-deck asset
353library is already out of v1 by the paragraph above; when it lands it
354goes through `visibleDecks` and takes a receipt of its own.
355
356### 2.8 Fixtures are the contract
357
358The JS side generates Y.Doc updates for a fixed set of decks
359(`fixtures/data/*.json` describing the tree, `fixtures/generate.mjs`
360producing `*.yjs` updates). The Rust core must load each and produce the
361same 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
369A JS bundle built like Docs' (`fixtures/build-bundles.mjs`, esbuild,
370committed hashed output, embedded by `assets.go`), ProseMirror plus yjs,
371no framework. Three panes on a desktop: the **strip** (slide thumbnails,
372drag to reorder, keyboard reorder with a menu equivalent), the **stage**,
373and the **inspector**. On a phone the strip is a horizontal filmstrip
374under the stage and the inspector is a bottom sheet.
375
376The stage shows the server's HTML for the current slide (Β§4.1) with the
377editor bound to it: each text-bearing block is a ProseMirror view over
378its XmlFragment; canvas blocks get drag, resize handles, snap guides at
379the frame's thirds and other blocks' edges, and z-order from the
380inspector. Flow blocks reorder by drag or by the inspector's move
381buttons.
382
383The inspector is per selection: block props, slide props (layout,
384template, background, transition, phone behaviour), deck props (title,
385theme, accent, fonts). **Every inspector control is one drive operation
386(Β§7.2)** and the editor calls the same function the API handler calls,
387so a person and an agent can never do different things.
388
389Undo is per person (yjs `UndoManager`, scoped to that client's origin).
390Keyboard: the ordinary text shortcuts, plus `Cmd/Ctrl+Enter` for a new
391slide, `Cmd/Ctrl+D` duplicate slide, `Cmd/Ctrl+Shift+F` present.
392
393### 3.2 The desktop/phone switcher
394
395A three-state control on the stage: **desktop**, **phone**, **both**.
396Phone view renders the stage at 390 CSS pixels wide with the phone
397layer applied. Edits to layout while in phone view go to the `phone`
398override map (Β§2.5); edits to text go to the text (there is one text).
399A block with an override shows a small badge in both views with "reset
400to desktop" in its inspector. "Both" shows desktop and phone side by
401side, 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
405server's own slide HTML and carries no editor chrome yet. Putting it on
406the block is a slice 3 candidate.
407
408### 3.3 Collab
409
410Docs' 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
413seq; bytes by `GET|POST /d/{sqid}/updates`; the cursor+1 rule; broadcast
414on success only; the 15 s ping, 30 s write deadline, 5 minute TTL;
415hibernation-safe because the stream carries no bytes. Slides is the
416second implementation, which is the estilo rule's trigger: whatever
417Docs' hub and stream code turns out to need in both places is extracted
418that week, and the Slides spec records the extraction when it happens.
419
420The gate on `POST /d/{sqid}/updates` applies the bytes to a fresh
421handle and, before the transaction opens, checks the document they
422leave (codex 3): first the core's accepted-shape check over every text
423fragment (Β§2.6; `slides_core_check`, ABI 2), then the typed parse of
424the JSON view for shapes the reader cannot hold at all. Either finding
425is a 422 and nothing is stored. The check is on the document rather
426than on the view because the view is a tolerant projection, and what it
427tolerates is precisely what the editor cannot.
428
429Presence rides the same stream as a second event type, `presence`,
430carrying `{client, name, colour, slide, block}`; the server holds
431presence in memory only (it is not document state) and drops a client
432after two missed pings. The editor shows avatars on the strip
433thumbnails and a coloured outline on the block another person has
434selected. Docs owns the first build of the presence stack per estilo's
435ownership table; if Docs has not built it when Slides needs it, Slides
436builds 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
442characters, unambiguous alphabet), `remote_token_hash`. Created by
443`POST /d/{sqid}/present`, which returns the session sqid and, once, a
444remote capability URL. Ended explicitly or after 12 hours. One live
445session per deck per presenter.
446
447**Presentation mode.** `GET /p/{session}` is the fullscreen surface:
448the current slide filling the viewport, keyboard (arrows, space, `b`
449for blackout, `n` for notes, `Esc`), swipe, a thin progress bar, and
450the slide's transition. The page subscribes to the session's SSE stream
451(`GET /p/{session}/stream`, `event: slide` with the index and blackout
452state) and applies what it hears, so it is the same page whether the
453presenter's keyboard or the phone moved the deck. It works with scripts
454off as a page per slide with next and previous links (Β§8).
455
456**Presenter view.** `GET /p/{session}/notes` shows the current slide,
457the next slide, the notes, a clock and elapsed time, and the pairing
458code and its QR. Meant for a second window on a desktop or the
459presenter'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,
462which carries the remote capability URL. The page shows notes, current
463and next slide thumbnails, and **next**, **previous**, **blackout**,
464plus **end**. Commands are `POST /p/{session}/command` with
465`{op: next | prev | goto | blackout | end, slide?}` and the remote
466capability as a bearer (or, scriptless, as a form field). The presenting
467browser applies the resulting `slide` event. Latency target under 300 ms
468on the same continent. The capability lives as long as the session. A
469native 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
473for that deck shows "follow the presenter". Following subscribes the
474viewer to the session stream read-only; the viewer can detach and
475browse, and reattach.
476
477**An audience stays behind the capability it arrived with (codex 5).**
478Following is `/s/{token}?follow=1`, and every read that page then makes
479is a `/s/{token}` route: the slide fragments
480(`?follow=1&slide=N&fragment=1`), the stream (`/s/{token}/stream`), the
481thumbnails and the pictures. Each resolves the token again, and the
482stream re-resolves it before every event and every heartbeat, so
483revoking the link ends the follow immediately rather than at the end of
484the talk. **The session sqid is never rendered onto a follow page**, and
485the two URLs the script uses come down as `data-stream` and
486`data-fragment` attributes rather than being assembled in the browser
487out of a sqid it does not have. Before this, following handed over the
488sqid and every later read went to `/p/…`, which asks only for a live
489presentation β€” a capability the person who granted the link could not
490take back.
491
492**Who may publish which capability**, settled (codex 5 asked; ruling
49315). Presenting stays open to **every member who can read the deck**,
494not 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
496the export. Minting a **share link** stays the owner's, because a share
497link grants read to people with no account here and outlives any talk.
498The two are different powers and the mismatch was only apparent: the
499session sqid is a **projector address** β€” it lives as long as the talk,
500carries no notes and cannot be revoked β€” and it is now given only to
501whoever is presenting, never to an audience. An audience gets a share
502link, which is revocable, which is why audience-follow derives from the
503link and not from the session.
504
505**A projector page is not a nothing-happens page.** `/p/{session}`
506still answers to anybody holding the sqid, deliberately: that is what
507makes "put this on the screen" a URL. What changed is that nobody is
508handed one who was not meant to have it.
509
510**Out-of-order fragments (codex 13).** The script tracks *requested*
511and *displayed* separately and stamps every fetch with a monotonic id:
512a response whose id is not the newest is discarded, displayed only
513moves after an install succeeds, and a failed fetch rolls requested
514back to displayed. One state β€” "the slide the page has announced" β€”
515left a projector permanently behind: with the record already at 3, a
516late response for 2 overwrote the stage and the event that would have
517corrected 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
525One Go package, `internal/render`, turns a slide (from the Rust core's
526JSON view) into HTML: a `<section slides-slide>` with the template's
527grid or the canvas frame, blocks as semantic elements (`h1` to `h3`,
528`p`, `ul`, `ol`, `blockquote`, `figure`, `pre`, `table`), theme tokens
529as CSS custom properties on the section, and the phone layer as
530`data-phone-*` attributes that the stylesheet honours under the phone
531media query. It serves the viewer, the presenter, the remote thumbnails
532and the editor stage.
533
534The one rule's refinement recorded here: the session had said SVG would
535serve the scriptless viewer. HTML is the better choice for every live
536surface because it reflows, it is accessible without extra work, and it
537is what the editor must bind to. SVG remains for export and thumbnails,
538where 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
543TTFs embedded in the binary, metrics via `golang.org/x/image/font/sfnt`)
544and places blocks in a 1920 by 1080 frame, producing a paint list:
545rectangles, images, text runs with font, size, position. `internal/svg`
546serialises a paint list as SVG with text as `<text>` runs and images by
547reference. Thumbnails are the SVG scaled down, served as SVG.
548
549Divergence from the browser is confined to line breaking inside a text
550block and is small when fonts match, the Docs finding. It is not zero
551and this spec does not claim it is.
552
553**Amended 2026-09-08, from what slice 3 shipped.** The paint list feeds
554exactly two things, the thumbnails and the PDF. Every live surface β€” the
555viewer, the presenter's stage, the editor's stage, the API's `.html`
556twin β€” is still `render.Slide`'s HTML, so Β§0.1's one tree is rendered
557by two renderers and never a third. Images are *inlined* rather than
558referenced wherever the bytes can be read, because an SVG inside an
559`<img src>` fetches nothing at all β€” a referenced picture is simply
560absent from a thumbnail. The image an inline cannot carry falls back to
561a 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
563restriction.
564
565### 4.3 PDF
566
567`core/` (Rust) gains a second module beside the CRDT: `slides_pdf`,
568wrapping `krilla` and `krilla-svg` with `usvg` and `fontdb` fed the same
569embedded fonts, compiled to wasm32 and run under wazero like the CRDT
570core. (It wrapped `svg2pdf` from slice 3's Task 1 spike until
5712026-09-09; see the amendment at the end of this section.) Input: one
572SVG per slide plus the fonts; output: one PDF, 1920 by 1080 points per
573page, with the speaker notes as an optional second page per slide
574("with notes" export). Images are embedded at their stored resolution
575capped at 2000 px on the long edge.
576
577If SVG-to-PDF in Rust under wasm32 proves unworkable in the plan's spike, the
578fallback recorded now is a Go PDF writer over the paint list directly,
579reusing Docs' font subsetting (`oficina/docs` `internal/pdf`) by
580extraction to a shared module, not by copy.
581
582Export runs as a rastrillo job: `POST /d/{sqid}/export` starts it, the
583status page polls, the result is a presigned GET from the object store
584that expires in one hour. A deck over 200 slides is refused with a
585message.
586
587**Amended 2026-09-08, from what slice 3 shipped.** Four things this
588section 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
620A 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>`
622SVG 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
624belt and braces on the PDF side; it is kept because dropping it changes
625what 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
629successor, which the original spike had not checked, so the module was
630built on a dependency nobody was maintaining. A second timeboxed spike
631swapped it: `krilla` 0.8.2 with `krilla-svg` 0.8.1 over `usvg` 0.47 and
632the same `fontdb` fed from linear memory. It builds for
633wasm32-unknown-unknown with no features disabled β€” krilla-svg has none,
634and the resvg and tiny-skia defaults it drags in compile clean β€” with
635zero host imports, and every property ruling 13 asked for holds: one
636page per SVG at 1920Γ—1080 pt, text as subset embedded TrueType
637(Type0/CIDFontType2, FontFile2, ToUnicode), raster images embedded, and
638a frame error still a refusal rather than a trap. The module grew from
6393,672,407 to 5,301,002 bytes. Against that, a 200-page export through
640the Go host went from 642 ms to 220 ms and from 1,719,771 to 140,444
641bytes, because krilla writes one font subset per document where svg2pdf
642wrote one per page β€” a handout that used to be 1.7 MB of repeated font
643programs is now 140 KB. krilla's XMP metadata packet is turned off:
644this module has no title, author or clock to put in one, and what it
645would carry instead is RDF namespace URLs, which would break the plainer
646rule that a Slides PDF names nothing outside itself. The C ABI
647(`slides_pdf_abi_version` is still 1) and `internal/slides/pdfcore` did
648not change; what did change above the module is the tests, which grep
649PDF bytes and now take a dictionary entry in either spelling, because
650pdf-writer 0.12 wrote `/Type /Page` and 0.15 writes `/Type/Page`.
651
652---
653
654## 5. Sharing and links
655
656Rungs are suite ruling 15's: **private** (the creator), **granted**
657(named people on this instance), **team** (everyone on the instance can
658open 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
660the Docs reading and the one that suits a small team.
661
662A **share link** is a read-only capability, calendar's pattern
663(`oficina/calendar` calendar design Β§6): `GET /s/{token}` mounted
664outside the session group, only the token's SHA-256 stored, the URL
665shown once at creation, revocable from the deck's share page, with a
666view count. The link opens the viewer (Β§8) with the deck as it is now,
667not a snapshot. If a presentation session is live, the link offers
668follow (Β§3.4) β€” and following stays **on** `/s/{token}` routes, so
669revocation reaches it (codex 5).
670
671**Revocation is the property, so nothing may outlive it.** Every read a
672link holder makes β€” page, slide fragment, thumbnail, picture,
673presentation stream β€” resolves the token on that request. A capability
674handed out *through* a link that the link's own revocation cannot
675reach is a bug, not a feature; codex 5 found the one there was.
676
677A share link is the **deck's** capability and not the person's who
678minted it. It survives that person being offboarded (Β§6.1), and it is
679turned off from the deck's share page by somebody who still has access.
680
681The **present link** is the same capability with `?present=1`, opening
682the fullscreen viewer directly.
683
684An "edit link" does not exist; ruling 15 says an edit capability is a
685sign-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
693Sign-in is Docs' `internal/docs/auth.go` adopted whole: rastrillo/auth
694magic links, `SLIDES_ALLOWED_ADDRESSES` gating `POST /signin` in front
695of `Begin` and in `Authorize`, `SLIDES_ORIGIN`, `SLIDES_INSTANCE_KEY`,
696`SLIDES_SMTP_*`, each a startup refusal outside `-env development`. The
697same 303 for permitted and non-permitted addresses. This is the second
698copy of that file, which is the extraction trigger: the plan includes a
699task to propose `auth.go` to rastrillo or estilo as a package.
700
701The person row is `users(id, email, name, colour)` created on first
702sign-in. `sessions.Current(r).Subject` is the email, mapped to the row
703before any scoping, per the rastrillo Β§5 warning.
704
705**Offboarding (codex 6, ruling 16).** The allowlist is the *only*
706membership control a lone instance has, so it has to work in both
707directions: taking an address off `SLIDES_ALLOWED_ADDRESSES` must
708remove the access that address already had, not merely stop it getting
709more. It gated the minting of a session and nothing else β€” an existing
710session resolved its user row without asking, rastrillo's
711`RequireSession` asks nothing, and an agent needed only an unrevoked
712token and an operator row β€” so a removed person kept everything until
713their session expired, agents included.
714
715So admission is **rechecked wherever a principal is resolved**, on
716every 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
730The 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
740Re-adding the address restores everything except the sessions and the
741talks, which are gone: signing in again is one email.
742
743### 6.2 Actors
744
745Every write records an **actor**: `person:<user id>` or
746`agent:<agent id>`. Presentation sessions, share links, exports, agent
747tokens and the deck's activity list all carry it. The Y.Doc update log
748carries it too (a column on the updates table, not in the CRDT).
749
750**One explicitly anonymous actor: `remote:<session sqid>`** (codex 15,
751ruling 17). A presentation command (`POST /p/{session}/command`) is
752authenticated by the remote token, which is a **transferable
753capability** β€” that is the feature: it goes on a phone, the phone is
754shown as a QR to a room, the phone may be somebody else's. The server
755therefore cannot establish who is holding it, and recording the
756session's creator on those writes was a claim about identity the app
757could not support. So a remote command is recorded as
758`remote:<session sqid>`, which is honest, with the **issuer** β€” the
759session's own actor, the person or agent who started the talk β€” logged
760beside it, so the trail still reaches somebody answerable.
761
762This is the exception and it is named as one: ruling 7's "every write
763names its real actor" holds, and this is what the real actor of a
764transferable capability is. Nothing else in the app writes anonymously,
765and a remote command touches only the presentation row β€” never the
766document, whose log has no anonymous entries at all.
767
768### 6.3 Deploy
769
770One static binary, `CGO_ENABLED=0`, `linux-arm64`, `make release` with
771the version stamped, `carlos deploy --app slides --account oficina`
772with the build probe on `/api/version`. `carlos store create` for the
773object store; `carlos domains attach slides.oficina.works`. Secrets
774`SLIDES_INSTANCE_KEY` and `SLIDES_SMTP_PASS` via `carlos secrets`, the
775rest via `carlos env`, mirroring Docs' set.
776
777---
778
779## 7. The drive surface
780
781### 7.1 Principals
782
783A signed-in person mints an **agent token** from Settings, naming the
784agent (`claude`, `codex`); the app stores `agents(id, name, operator,
785token_hash, created_at, revoked_at)` and shows the token once. The
786agent's principal is `<name>~<operator email>`; every write it makes is
787badged with a robot mark and the operator's name in the activity list,
788following amadan's own convention. A revoked token fails closed. Tokens
789are `Authorization: Bearer` on the API only; the API never reads a
790session cookie and the HTML routes never read a bearer.
791
792A token also fails closed on its **operator's** admission, rechecked on
793every request (Β§6.1, codex 6): an operator taken off the allowlist
794means 401, indistinguishable from a revoked token. An agent sees
795exactly 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
800applies them in order inside one transaction against the Y.Doc through
801the Rust core, producing one update, which is then broadcast exactly as
802a 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
814Reads: `GET /api/v1/decks` (list), `GET /api/v1/decks/{sqid}` (the JSON
815view, 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
819The editor's inspector calls the same `ops` function in-process through
820the JS side of yjs; the server's `ops` handler calls the Rust core. The
821fixtures (Β§2.8) include one file per operation so both sides are held
822to the same result.
823
824### 7.3 MCP
825
826An MCP server is a thin adapter over Β§7.2, deferred until a consumer
827asks for it by name, memoria's rule. The ops are designed so each maps
828to one tool with the same name.
829
830---
831
832## 8. The scriptless floor
833
834With 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
836per page, next and previous links, notes below when the deck allows),
837present from a share link as the same paged viewer, read the presenter
838notes page, download a PDF, manage sharing, mint and revoke agent
839tokens, and use the remote (each button is a form that POSTs and
840redirects back). They cannot edit slide content; the editor is the one
841surface that needs the bundle, Docs' ruling.
842
843WCAG 2.2 AA with no exemptions, 24 by 24 targets, 320 px reflow (the
844canvas stage may scroll inside its own region in the editor, the estilo
845allowance), colour never alone, every visible string a `rastrillo.T`
846key.
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
8691. **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.
8742. **Editor and collab:** the bundle, live-sync adoption, presence, the
875 switcher, undo, asset upload.
8763. **Export:** layout engine, SVG thumbnails, the PDF module, the export
877 job.
8784. **Extractions:** `auth.go` and the live-sync hub to shared modules;
879 suite discussion outcomes adopted.
880
881Slice 1 is what Paul asked to try first. It is a deck a person or an
882agent can build through the API and present from a phone before the
883editor exists, which is also the order that proves the model before the
884most 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
909Revision 1, with Β§2.6 and ruling 12 rewritten during slice 2, Β§4.2
910and Β§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
912day from the Codex review's findings 3, 4, 7 and 10 (each paragraph
913marked `codex N`): unreviewed.
914The review pass is the first item after slice 1
915ships, so that the reviewer has a running instance and not a document.
916