oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

πŸ€– Slides β€” design

Design, 2026-09-07. Direction approved by Paul in conversation; this is the written form. Revision 1. Drafted by the Slides session (Fable 5.1) from a brainstorm with Paul the same day. Every ruling in Β§1 attributed to Paul was given in that conversation.

Slides is a presentation editor for the Oficina suite: a deck of slides that looks like a Keynote or PowerPoint deck on a desktop and reads like a document on a phone, edited by several people at once, presented from a laptop and driven from a phone, exported to PDF, and driveable by an agent through the same operations a person has. One instance per team, on the CARLOS platform, at slides.oficina.works for the first team.

Built beside Docs, Sheets and the suite doctrine. Where a decision is already made over there, this spec consumes it rather than restating it (Β§0.2).

---

0. The rules this app lives under

0.1 The one rule

A slide is one block tree, rendered many ways. The tree is the only truth: the editor edits it, the viewer renders it, the phone reflows it, the PDF prints it, the agent patches it. Nothing that matters to what a reader sees lives outside the tree, so there is never a "desktop version" and a "mobile version" of a deck, only one deck and a viewport.

Everything below derives from it. The hybrid layout modes (Β§2.3) are two ways of placing the same blocks, not two kinds of slide. The phone overrides (Β§2.5) are entries in the tree, not a second file. The drive API (Β§7) is the inspector's operations with a JSON body.

0.2 Consumed suite rulings

This app cites the suite sharing doctrine (oficina/suite, docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md, revision 8) by ruling number and never restates it.

| Ruling | Used for | |---|---| | 1, 5 | One instance per team; the first deploy is the lone shape (Β§6.1). | | 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. | | 6 | No product-wide egress. A PDF leaves as a download the person asked for; nothing is sent anywhere. | | 9 | Every write names its real actor. An agent's write names the agent (Β§7). | | 15 | Three rungs (private, granted, team) plus a read-only link capability. A share link never edits (Β§5). |

One ruling made by Paul during this brainstorm is suite-wide and is filed as an oficina/suite discussion rather than restated here as if it were this app's: agents drive every Oficina app as their own identified principal, claude~paul style, on behalf of a person, never as the person (2026-09-07). Slides is the first app to build the surface (Β§7).

0.3 What this spec does not claim

It does not claim pixel identity between the browser stage and the PDF. Β§4 says where they may differ and by how much. It does not claim the suite home exists. It does not claim a native remote exists; Β§3.4 designs the phone web page so a native app later speaks the same three calls.

---

1. Settled decisions (don't relitigate casually)

| # | Ruling | Rejected alternative | Who, when | |---|---|---|---| | 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 | | 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 | | 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 | | 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. | | 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") | | 6 | The remote is a phone web page first; a native app later speaks the same calls. | Native first. | Paul, 2026-09-07 | | 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 | | 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. | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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) | | 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) | | 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) | | 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) |

---

2. The deck model

2.1 The Y.Doc

A deck is one Y.Doc. Top-level shared types:

  • meta β€” Y.Map: title, theme (theme id), accent (optional hex), fonts (optional {heading, body}), aspect (16:9 only in v1; the key exists so 4:3 can arrive without a migration).
  • slides β€” Y.Array of Y.Map, one per slide: the SET of the deck's slides. Deck order is the order key on each slide map, exactly as a slide's blocks array carries its reading order (ruling 21, codex 4 at slide level).
  • assets β€” Y.Map from asset id to {ref, name, width, height, alt}, where ref is the object store address (Β§2.7).

2.2 A slide

A slide Y.Map holds:

  • id β€” stable, random, 12 lowercase base32 characters, assigned at creation, never reused. The drive API addresses slides by it.
  • order β€” the slide's place in the deck, a number, on exactly the terms the block order below has: absent ranks at the array index, array index breaks ties, and a deck nobody has reordered carries none (ruling 21, codex 4). move_slide writes a dense 0..n-1 onto every slide; add_slide and duplicate_slide on a deck that carries orders renumber so the new slide lands where after asked. The JSON view sorts by it and does not expose it.
  • layout β€” flow or canvas.
  • template β€” for flow slides: title, title-body, two-column, section, image-full, quote, blank. Ignored on canvas slides.
  • blocks β€” Y.Array of block Y.Maps: the SET of the slide's blocks. Reading order is the order key on each block map, a number; a block without one ranks at its array index, and array index breaks ties (ruling 18, codex 4). A slide nobody has reordered carries no order and reads in array order, as before. move_block and canvas-to-flow write a dense 0..n-1 onto every block; add_block on a slide that carries orders renumbers so the new block lands where after asked. The JSON view sorts by it and does not expose it. On a canvas slide z is stacking, order is reading order, and the two are different things.
  • notes β€” Y.XmlFragment, speaker notes, the same text schema as Β§2.6.
  • background β€” {kind: theme | color | image, value}.
  • phone β€” inherit (default) or reflow. Only meaningful on canvas slides.
  • transition β€” none or fade.

2.3 Layout modes

Flow. The template names regions (heading, body, aside, media), each block has a region prop, and blocks stack inside their region in array order. The renderer places regions with CSS grid, and the template's grid collapses to one column at the phone breakpoint, so reflow is CSS, not a second layout.

Canvas. Each block has box: {x, y, w, h} as fractions of the 16:9 frame (0 to 1, four decimals) and z. The renderer positions blocks absolutely inside a frame that scales to the viewport width. On a phone the frame scales to fit, text and all, unless phone is reflow, in which case the renderer emits the blocks as a flow slide with the blank template in z then top-left reading order.

Switching a slide's mode is an operation, not a conversion the person does by hand: flow to canvas assigns each block the box its region gave it (z is the reading-order index); canvas to flow drops boxes, assigns regions by position, and sets the reading order top to bottom then left to right. Both are lossy in one direction and say so in the inspector.

The reorder in canvas-to-flow is written as order on each block map (Β§2.2) and never by rebuilding the blocks array (codex 4). Rebuilding it β€” clone every block, clear the array, insert the copies β€” is what slice 2 did, and a peer typing into a block at that moment lost the sentence: their edit addressed the deleted original and the merge kept the copy. Placement is allowed to be lossy; content is not. The same rule holds for move_block.

It holds for move_slide too, and by the same mechanism rather than a second one (ruling 21, codex 4 at slide level). A slide reordered while somebody is typing on it used to lose the typing for exactly the reason a moved block did: the slide map the other client's text, notes and blocks all hang off was deleted and replaced by a copy. So the deck's slides array is the set of its slides and their order is an order key on each slide map (Β§2.1, Β§2.2) β€” one idea at both levels, one helper in each implementation, and move_slide, add_slide, duplicate_slide and remove_slide never rebuild the array.

2.4 Blocks

A block Y.Map holds id (same scheme as slides), type, props (Y.Map), and for text-bearing types text (Y.XmlFragment). The v1 vocabulary, the "maximally viable" set:

| Type | Props | Text | |---|---|---| | heading | level 1 to 3 | yes | | text | size (s, m, l), align | yes | | list | style (bullet, number), size | yes, one paragraph per item | | quote | cite | yes | | image | asset, fit (cover, contain), alt (required, may be empty only if decorative is true), radius | no | | 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 | | code | language, size | yes, plain, one paragraph per line | | shape | kind (rect, ellipse, line, arrow), fill, stroke, strokeWidth, radius | optional single-paragraph label | | table | rows, cols, header (bool), cells (Y.Array of Y.XmlFragment, row-major) | per cell | | divider | none | no | | spacer | size | no |

Every block additionally carries region (flow) or box and z (canvas), an optional order (Β§2.2), and an optional phone override map (Β§2.5).

The children of a slide's columns blocks form a forest (ruling 16, codex 7): a block id appears in at most one children list on its slide, at most once in that list, and a columns block may not name a block that it already sits under. add_block and set_block refuse any of those, in both implementations with one message per case (names "x" twice, names "x", already a child of columns "y", names "x", which would put columns "self" inside itself), and the batch applies nothing. A concurrent pair of assignments can still merge into a shared child or a cycle, so the renderer and the layout engine apply one deterministic policy: the first columns block in reading order that names a child owns it, and whatever no root reaches is rendered after everything else β€” in its own trailing region on a flow slide, at its box on a canvas one β€” marked data-unreached in HTML and reported as a unreached warning by the layout engine. Content is never dropped for being badly parented.

Charts from Sheets, embeds and video are not in v1 (Β§9).

2.5 Phone overrides

A block's phone Y.Map may hold: hidden (bool), order (integer, sorts before array order among siblings), size (a size step), and on canvas slides box. Nothing else. The desktop layout is always the base; the phone layer only adds.

The editor's desktop/phone switcher (Β§3.2) writes to this map when the stage is in phone view. The renderer reads it when the viewport is a phone, and the print engine never reads it.

order is reading order, not a visual effect (codex 10). Rendering for the phone viewport EMITS the blocks in the effective phone sequence β€” regions in region order; within a region and within a columns block, a stable sort by order with an unset order counting as 0 β€” so the sequence a screen reader hears and the Tab key follows is the one a sighted reader sees (Β§8, WCAG 2.2 AA, no exemptions). The stylesheet's order: var(--slides-phone-order, 0) is kept with the same key for the one case it still serves β€” a desktop rendering opened on a narrow screen β€” and is no longer what makes the order true. A reflowed canvas slide takes Β§2.3's z-then-top-left sequence first and the phone order on top of it. Rendering for the desktop viewport keeps the desktop document order.

2.6 Text

Text is a ProseMirror document under a constrained schema: paragraph only at the block level; bold, italic, code, link and highlight as marks. No nested blocks, no headings inside text (a heading is a block). Lists are list blocks whose paragraphs are items; a nested list is a second list block with indent 1. The same schema drives speaker notes and table cells.

Over Yjs, that document is an XmlFragment of <paragraph> elements, each holding one XmlText whose formatting attributes are the marks. The ProseMirror marks are named b, i, c, h, href β€” the Y attribute names β€” and href is stored as the one-key map {"href": url}, not as a plain string (ruling 12). Both follow from y-prosemirror's attribute mapping, which has no hook to override: it writes attributes[mark.type.name] = mark.attrs and reads schema.mark(attrName, attrValue). So a mark's name must be the attribute name, and the mark href with attrs {href} is the attribute href carrying that object. A plain string there throws inside schema.mark, and y-prosemirror deletes the XmlText on a throw. Naming the marks this way makes the mapping the identity and keeps mapping code out of the editor.

The writer is exact; the reader is tolerant, because updates reach the server from browsers as well. b, i, c, h are written true and read as set on any truthy value (y-prosemirror writes {} for an attr-less mark); href is written as the map and read as either the map or a plain string (documents written before this ruling).

The accepted shape is narrower than what the reader tolerates (ruling 19, codex 3). The reader's tolerance is for documents that already exist; it is not the gate on a write, because the editor does not consume the reader's projection β€” it binds y-prosemirror to the shared text, and y-prosemirror 1.3.7 deletes an XmlText whose attributes name a mark the schema lacks (schema.mark throws a RangeError; createTextNodesFromYText's catch deletes the item). A posted update is therefore accepted only if every text fragment in the document it produces β€” each slide's notes, each block's text, each table's cells β€” is <paragraph> elements holding only text whose formatting attributes are among b, i, c, h, href, with no embeds. Anything else is refused before it is stored (Β§3.3). The browser, for its part, strips an unknown attribute from a fragment before binding, so a document that already carries one loses the mark and not the words β€” that is the smallest change the library leaves room for, since it offers no hook between reading the delta and constructing the mark.

2.7 Assets

An image is uploaded browser-to-bucket by presigned PUT (rastrillo blobs), then registered in assets with its blobs.Ref, dimensions and alt text. The server verifies the object exists and is an image before registering. Limits: 20 MiB per image, PNG, JPEG, WebP, SVG (SVG is sanitised on registration). Deleting the last block that uses an asset leaves the asset; a deck-level "unused assets" list lets a person remove them. There is no cross-deck asset library in v1.

Stage, then promote (codex 1, codex 2). The browser never names the address its bytes will live at, and a ref is never on its own enough to get anything registered. Ruling 14; the shape:

  • POST /d/{sqid}/uploads mints a staging key β€” a hash of the deck, the actor and fresh randomness β€” writes an upload receipt (asset_uploads: deck, actor, staging key, object key, declared hash, consumedat) and signs a PUT for that key and nothing else. It refuses to sign for an address any `deckassets` row verifies, which a fresh random key can never be: the check is there so the mistake cannot come back.
  • POST /d/{sqid}/assets resolves the receipt β€” same deck, same actor, unconsumed, under two hours β€” reads the staged bytes, sniffs them, sanitises an SVG, and puts what it read back as a final object addressed by its own SHA-256. That address is what the deck refers to. The receipt is then spent, single-use, and the staged copy is deleted unless it is already the promoted object.
  • Nothing anywhere signs a PUT for a final address. A verified object is immutable by construction, not by nobody having tried.

Why, in one line each. A presigned PUT is unconditional (rastrillo/blobs' own note says the server must verify what landed), so an address a browser can name is an address a browser can overwrite β€” naming a ref another deck was already serving from changed that picture, in that deck, with no document update anywhere. And registration wrote the destination deck's verification row for any ref it was handed, with no receipt and no access to the source deck β€” so knowing a content address, which is knowing the SHA-256 of somebody's file, was permission to read it. Both were reachable by any member.

The cost, accepted: registration now reads and re-writes up to 20 MiB per picture instead of only reading it. The download was already happening; the upload is the price of a final address derived from bytes this app has actually seen.

An agent cannot add a picture. It can name a ref in a register_asset op and the document will hold it, but no surface will serve those bytes, because serving requires a verification row and a verification row is only written for an upload this app signed. A bearer-authenticated upload is the obvious next thing and is not built (Β§9).

The copy path codex 2 offers as an alternative β€” registering a ref from a deck the person may read, scoped through visibleDecks β€” is deliberately not built. Nothing asks for it, and a cross-deck asset library is already out of v1 by the paragraph above; when it lands it goes through visibleDecks and takes a receipt of its own.

2.8 Fixtures are the contract

The JS side generates Y.Doc updates for a fixed set of decks (fixtures/data/*.json describing the tree, fixtures/generate.mjs producing *.yjs updates). The Rust core must load each and produce the same JSON view (slides_core_json_read). Docs' rule, adopted unchanged.

---

3. The editor, presenting, and the remote

3.1 Editor

A JS bundle built like Docs' (fixtures/build-bundles.mjs, esbuild, committed hashed output, embedded by assets.go), ProseMirror plus yjs, no framework. Three panes on a desktop: the strip (slide thumbnails, drag to reorder, keyboard reorder with a menu equivalent), the stage, and the inspector. On a phone the strip is a horizontal filmstrip under the stage and the inspector is a bottom sheet.

The stage shows the server's HTML for the current slide (Β§4.1) with the editor bound to it: each text-bearing block is a ProseMirror view over its XmlFragment; canvas blocks get drag, resize handles, snap guides at the frame's thirds and other blocks' edges, and z-order from the inspector. Flow blocks reorder by drag or by the inspector's move buttons.

The inspector is per selection: block props, slide props (layout, template, background, transition, phone behaviour), deck props (title, theme, accent, fonts). Every inspector control is one drive operation (Β§7.2) and the editor calls the same function the API handler calls, so a person and an agent can never do different things.

Undo is per person (yjs UndoManager, scoped to that client's origin). Keyboard: the ordinary text shortcuts, plus Cmd/Ctrl+Enter for a new slide, Cmd/Ctrl+D duplicate slide, Cmd/Ctrl+Shift+F present.

3.2 The desktop/phone switcher

A three-state control on the stage: desktop, phone, both. Phone view renders the stage at 390 CSS pixels wide with the phone layer applied. Edits to layout while in phone view go to the phone override map (Β§2.5); edits to text go to the text (there is one text). A block with an override shows a small badge in both views with "reset to desktop" in its inspector. "Both" shows desktop and phone side by side, the phone read-only.

Slice 2 deviation: the override badge lives in the inspector, beside "reset to desktop", not on the block on the stage β€” the stage is the server's own slide HTML and carries no editor chrome yet. Putting it on the block is a slice 3 candidate.

3.3 Collab

Docs' live-sync spec is adopted as written (oficina/docs, docs/superpowers/specs/2026-09-01-live-sync-design.md Β§3 to Β§7): GET /d/{sqid}/stream SSE emitting event: update with a seq; bytes by GET|POST /d/{sqid}/updates; the cursor+1 rule; broadcast on success only; the 15 s ping, 30 s write deadline, 5 minute TTL; hibernation-safe because the stream carries no bytes. Slides is the second implementation, which is the estilo rule's trigger: whatever Docs' hub and stream code turns out to need in both places is extracted that week, and the Slides spec records the extraction when it happens.

The gate on POST /d/{sqid}/updates applies the bytes to a fresh handle and, before the transaction opens, checks the document they leave (codex 3): first the core's accepted-shape check over every text fragment (Β§2.6; slides_core_check, ABI 2), then the typed parse of the JSON view for shapes the reader cannot hold at all. Either finding is a 422 and nothing is stored. The check is on the document rather than on the view because the view is a tolerant projection, and what it tolerates is precisely what the editor cannot.

Presence rides the same stream as a second event type, presence, carrying {client, name, colour, slide, block}; the server holds presence in memory only (it is not document state) and drops a client after two missed pings. The editor shows avatars on the strip thumbnails and a coloured outline on the block another person has selected. Docs owns the first build of the presence stack per estilo's ownership table; if Docs has not built it when Slides needs it, Slides builds it and Docs adopts.

3.4 Presenting, speaker notes, the remote

Presentation session. A row: deck, presenter (actor), slide (index), blackout (bool), started_at, ended_at, code (six characters, unambiguous alphabet), remote_token_hash. Created by POST /d/{sqid}/present, which returns the session sqid and, once, a remote capability URL. Ended explicitly or after 12 hours. One live session per deck per presenter.

Presentation mode. GET /p/{session} is the fullscreen surface: the current slide filling the viewport, keyboard (arrows, space, b for blackout, n for notes, Esc), swipe, a thin progress bar, and the slide's transition. The page subscribes to the session's SSE stream (GET /p/{session}/stream, event: slide with the index and blackout state) and applies what it hears, so it is the same page whether the presenter's keyboard or the phone moved the deck. It works with scripts off as a page per slide with next and previous links (Β§8).

Presenter view. GET /p/{session}/notes shows the current slide, the next slide, the notes, a clock and elapsed time, and the pairing code and its QR. Meant for a second window on a desktop or the presenter's phone. Its controls post the same commands as the remote.

Remote. GET /r/{code} on a phone: enter the code or scan the QR, which carries the remote capability URL. The page shows notes, current and next slide thumbnails, and next, previous, blackout, plus end. Commands are POST /p/{session}/command with {op: next | prev | goto | blackout | end, slide?} and the remote capability as a bearer (or, scriptless, as a form field). The presenting browser applies the resulting slide event. Latency target under 300 ms on the same continent. The capability lives as long as the session. A native remote later speaks exactly present, command and the stream; nothing else is needed.

Audience follow. A share link (Β§5) opened while a session is live for that deck shows "follow the presenter". Following subscribes the viewer to the session stream read-only; the viewer can detach and browse, and reattach.

An audience stays behind the capability it arrived with (codex 5). Following is /s/{token}?follow=1, and every read that page then makes is a /s/{token} route: the slide fragments (?follow=1&slide=N&fragment=1), the stream (/s/{token}/stream), the thumbnails and the pictures. Each resolves the token again, and the stream re-resolves it before every event and every heartbeat, so revoking the link ends the follow immediately rather than at the end of the talk. The session sqid is never rendered onto a follow page, and the two URLs the script uses come down as data-stream and data-fragment attributes rather than being assembled in the browser out of a sqid it does not have. Before this, following handed over the sqid and every later read went to /p/…, which asks only for a live presentation β€” a capability the person who granted the link could not take back.

Who may publish which capability, settled (codex 5 asked; ruling 15). Presenting stays open to every member who can read the deck, not the owner alone: reading a deck to a room is reading a deck, and POST /d/{sqid}/present is deckFor's audience like the editor and the export. Minting a share link stays the owner's, because a share link grants read to people with no account here and outlives any talk. The two are different powers and the mismatch was only apparent: the session sqid is a projector address β€” it lives as long as the talk, carries no notes and cannot be revoked β€” and it is now given only to whoever is presenting, never to an audience. An audience gets a share link, which is revocable, which is why audience-follow derives from the link and not from the session.

A projector page is not a nothing-happens page. /p/{session} still answers to anybody holding the sqid, deliberately: that is what makes "put this on the screen" a URL. What changed is that nobody is handed one who was not meant to have it.

Out-of-order fragments (codex 13). The script tracks requested and displayed separately and stamps every fetch with a monotonic id: a response whose id is not the newest is discarded, displayed only moves after an install succeeds, and a failed fetch rolls requested back to displayed. One state β€” "the slide the page has announced" β€” left a projector permanently behind: with the record already at 3, a late response for 2 overwrote the stage and the event that would have corrected it was suppressed as a slide the page was already on.

---

4. Rendering and export

4.1 The HTML renderer

One Go package, internal/render, turns a slide (from the Rust core's JSON view) into HTML: a <section slides-slide> with the template's grid or the canvas frame, blocks as semantic elements (h1 to h3, p, ul, ol, blockquote, figure, pre, table), theme tokens as CSS custom properties on the section, and the phone layer as data-phone-* attributes that the stylesheet honours under the phone media query. It serves the viewer, the presenter, the remote thumbnails and the editor stage.

The one rule's refinement recorded here: the session had said SVG would serve the scriptless viewer. HTML is the better choice for every live surface because it reflows, it is accessible without extra work, and it is what the editor must bind to. SVG remains for export and thumbnails, where a fixed geometry is the point.

4.2 The layout engine and SVG

internal/layout measures text with the theme's fonts (self-hosted TTFs embedded in the binary, metrics via golang.org/x/image/font/sfnt) and places blocks in a 1920 by 1080 frame, producing a paint list: rectangles, images, text runs with font, size, position. internal/svg serialises a paint list as SVG with text as <text> runs and images by reference. Thumbnails are the SVG scaled down, served as SVG.

Divergence from the browser is confined to line breaking inside a text block and is small when fonts match, the Docs finding. It is not zero and this spec does not claim it is.

Amended 2026-09-08, from what slice 3 shipped. The paint list feeds exactly two things, the thumbnails and the PDF. Every live surface β€” the viewer, the presenter's stage, the editor's stage, the API's .html twin β€” is still render.Slide's HTML, so Β§0.1's one tree is rendered by two renderers and never a third. Images are inlined rather than referenced wherever the bytes can be read, because an SVG inside an <img src> fetches nothing at all β€” a referenced picture is simply absent from a thumbnail. The image an inline cannot carry falls back to a URL, which draws only when the .svg is opened as a document. See Β§4.3's amendment for the fonts, which is the other half of the same restriction.

4.3 PDF

core/ (Rust) gains a second module beside the CRDT: slides_pdf, wrapping krilla and krilla-svg with usvg and fontdb fed the same embedded fonts, compiled to wasm32 and run under wazero like the CRDT core. (It wrapped svg2pdf from slice 3's Task 1 spike until 2026-09-09; see the amendment at the end of this section.) Input: one SVG per slide plus the fonts; output: one PDF, 1920 by 1080 points per page, with the speaker notes as an optional second page per slide ("with notes" export). Images are embedded at their stored resolution capped at 2000 px on the long edge.

If SVG-to-PDF in Rust under wasm32 proves unworkable in the plan's spike, the fallback recorded now is a Go PDF writer over the paint list directly, reusing Docs' font subsetting (oficina/docs internal/pdf) by extraction to a shared module, not by copy.

Export runs as a rastrillo job: POST /d/{sqid}/export starts it, the status page polls, the result is a presigned GET from the object store that expires in one hour. A deck over 200 slides is refused with a message.

Amended 2026-09-08, from what slice 3 shipped. Four things this section said differently:

  • The fonts are the twelve Liberation faces (Sans, Serif, Mono in four styles), embedded in the binary for the layout engine and the PDF and served as @font-face to every slide surface, rather than "the theme's fonts" per theme. Metrics come from this repo's own TrueType reader (internal/slides/fonts), not x/image/font/sfnt.
  • The packages are internal/slides/layout and internal/slides/svg (Β§4.2's internal/layout and internal/svg predate the one-directory-under-internal rule).
  • A thumbnail carries no embedded fonts. An SVG in an <img src> fetches nothing, so a thumbnail is set in whatever face the reader's machine has under the same stack slides.css names; the text wraps where the paint list says either way. Embedding the faces would be about 800 KB per thumbnail. The PDF is unaffected β€” the module is handed the faces directly.
  • With no object store bound the finished PDF is held in memory for an hour and served from the instance, capped at 50 MiB per deck and 200 MiB per instance; with a bucket the job uploads it and the download route 302s to the presigned GET. Both paths are one handler. The spike ruled SVG-to-PDF in Rust under wasm32 viable, so the fallback Go PDF writer above was not built.
  • The status page and the file agree about the hour. rastrillo/jobs forgets a finished job ten minutes after it ends; the bytes are answerable for the hour the page promised. So the status page, its polled fragment and the API's status document all fall back to this app's own held record once the registry has forgotten β€” done, with the download still offered β€” rather than 404ing for fifty minutes while .pdf at the same address still hands over the file. Shortening the hold to the job's ten minutes was the other way to make the two agree and takes away a download that was promised.

A WebP image is re-encoded as a PNG on its way into a page (golang.org/x/image/webp), because a browser rendering an <img src> SVG fetches nothing and WebP is one of the four types Β§2.7 admits. krilla-svg does decode WebP directly, so since the swap below this is belt and braces on the PDF side; it is kept because dropping it changes what the SVG encoder emits.

Amended 2026-09-09: the converter is krilla, not svg2pdf. typst/svg2pdf was archived on 2026-07-10 naming krilla as its successor, which the original spike had not checked, so the module was built on a dependency nobody was maintaining. A second timeboxed spike swapped it: krilla 0.8.2 with krilla-svg 0.8.1 over usvg 0.47 and the same fontdb fed from linear memory. It builds for wasm32-unknown-unknown with no features disabled β€” krilla-svg has none, and the resvg and tiny-skia defaults it drags in compile clean β€” with zero host imports, and every property ruling 13 asked for holds: one page per SVG at 1920Γ—1080 pt, text as subset embedded TrueType (Type0/CIDFontType2, FontFile2, ToUnicode), raster images embedded, and a frame error still a refusal rather than a trap. The module grew from 3,672,407 to 5,301,002 bytes. Against that, a 200-page export through the Go host went from 642 ms to 220 ms and from 1,719,771 to 140,444 bytes, because krilla writes one font subset per document where svg2pdf wrote one per page β€” a handout that used to be 1.7 MB of repeated font programs is now 140 KB. krilla's XMP metadata packet is turned off: this module has no title, author or clock to put in one, and what it would carry instead is RDF namespace URLs, which would break the plainer rule that a Slides PDF names nothing outside itself. The C ABI (slides_pdf_abi_version is still 1) and internal/slides/pdfcore did not change; what did change above the module is the tests, which grep PDF bytes and now take a dictionary entry in either spelling, because pdf-writer 0.12 wrote /Type /Page and 0.15 writes /Type/Page.

---

5. Sharing and links

Rungs are suite ruling 15's: private (the creator), granted (named people on this instance), team (everyone on the instance can open it). Every deck starts private. Editing needs sign-in and a rung of granted or team; a team deck is editable by every member, which is the Docs reading and the one that suits a small team.

A share link is a read-only capability, calendar's pattern (oficina/calendar calendar design Β§6): GET /s/{token} mounted outside the session group, only the token's SHA-256 stored, the URL shown once at creation, revocable from the deck's share page, with a view count. The link opens the viewer (Β§8) with the deck as it is now, not a snapshot. If a presentation session is live, the link offers follow (Β§3.4) β€” and following stays on /s/{token} routes, so revocation reaches it (codex 5).

Revocation is the property, so nothing may outlive it. Every read a link holder makes β€” page, slide fragment, thumbnail, picture, presentation stream β€” resolves the token on that request. A capability handed out through a link that the link's own revocation cannot reach is a bug, not a feature; codex 5 found the one there was.

A share link is the deck's capability and not the person's who minted it. It survives that person being offboarded (Β§6.1), and it is turned off from the deck's share page by somebody who still has access.

The present link is the same capability with ?present=1, opening the fullscreen viewer directly.

An "edit link" does not exist; ruling 15 says an edit capability is a sign-in that mints a guest grant, which waits on the home.

---

6. Identity and the instance

6.1 Lone shape

Sign-in is Docs' internal/docs/auth.go adopted whole: rastrillo/auth magic links, SLIDES_ALLOWED_ADDRESSES gating POST /signin in front of Begin and in Authorize, SLIDES_ORIGIN, SLIDES_INSTANCE_KEY, SLIDES_SMTP_*, each a startup refusal outside -env development. The same 303 for permitted and non-permitted addresses. This is the second copy of that file, which is the extraction trigger: the plan includes a task to propose auth.go to rastrillo or estilo as a package.

The person row is users(id, email, name, colour) created on first sign-in. sessions.Current(r).Subject is the email, mapped to the row before any scoping, per the rastrillo Β§5 warning.

Offboarding (codex 6, ruling 16). The allowlist is the only membership control a lone instance has, so it has to work in both directions: taking an address off SLIDES_ALLOWED_ADDRESSES must remove the access that address already had, not merely stop it getting more. It gated the minting of a session and nothing else β€” an existing session resolved its user row without asking, rastrillo's RequireSession asks nothing, and an agent needed only an unrevoked token and an operator row β€” so a removed person kept everything until their session expired, agents included.

So admission is rechecked wherever a principal is resolved, on every request, and fails closed:

  • currentUser (auth.go) checks the session's subject. Not admitted means: every session row for that address is deleted (real revocation, every device, not just this browser's cookie), the cookie is cleared, and the person is sent to /signin?err=removed with a sentence saying their access was removed rather than that sign-in failed. A non-page request gets a 403 instead, which is rauth's own split.
  • The bearer middleware (agents.go) checks the operator's address. Not admitted means 401 β€” the same 401 a revoked token gets. An agent sees exactly what its operator sees, and this is the one path out of the instance that survives a browser being signed out.

The outstanding capabilities, decided:

| Capability | On offboarding | Why | |---|---|---| | Sessions (every device) | revoked | It is the person's own credential and nothing else depends on it. | | 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. | | 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. | | 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. | | 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). |

Re-adding the address restores everything except the sessions and the talks, which are gone: signing in again is one email.

6.2 Actors

Every write records an actor: person:<user id> or agent:<agent id>. Presentation sessions, share links, exports, agent tokens and the deck's activity list all carry it. The Y.Doc update log carries it too (a column on the updates table, not in the CRDT).

One explicitly anonymous actor: remote:<session sqid> (codex 15, ruling 17). A presentation command (POST /p/{session}/command) is authenticated by the remote token, which is a transferable capability β€” that is the feature: it goes on a phone, the phone is shown as a QR to a room, the phone may be somebody else's. The server therefore cannot establish who is holding it, and recording the session's creator on those writes was a claim about identity the app could not support. So a remote command is recorded as remote:<session sqid>, which is honest, with the issuer β€” the session's own actor, the person or agent who started the talk β€” logged beside it, so the trail still reaches somebody answerable.

This is the exception and it is named as one: ruling 7's "every write names its real actor" holds, and this is what the real actor of a transferable capability is. Nothing else in the app writes anonymously, and a remote command touches only the presentation row β€” never the document, whose log has no anonymous entries at all.

6.3 Deploy

One static binary, CGO_ENABLED=0, linux-arm64, make release with the version stamped, carlos deploy --app slides --account oficina with the build probe on /api/version. carlos store create for the object store; carlos domains attach slides.oficina.works. Secrets SLIDES_INSTANCE_KEY and SLIDES_SMTP_PASS via carlos secrets, the rest via carlos env, mirroring Docs' set.

---

7. The drive surface

7.1 Principals

A signed-in person mints an agent token from Settings, naming the agent (claude, codex); the app stores agents(id, name, operator, token_hash, created_at, revoked_at) and shows the token once. The agent's principal is <name>~<operator email>; every write it makes is badged with a robot mark and the operator's name in the activity list, following amadan's own convention. A revoked token fails closed. Tokens are Authorization: Bearer on the API only; the API never reads a session cookie and the HTML routes never read a bearer.

A token also fails closed on its operator's admission, rechecked on every request (Β§6.1, codex 6): an operator taken off the allowlist means 401, indistinguishable from a revoked token. An agent sees exactly what its operator sees, and that includes seeing nothing.

7.2 Operations

POST /api/v1/decks/{sqid}/ops takes a JSON array of operations and applies them in order inside one transaction against the Y.Doc through the Rust core, producing one update, which is then broadcast exactly as a browser's POST would be. Operations, each with the ids of Β§2:

  • deck: set_meta
  • slide: add_slide (after, layout, template), remove_slide, move_slide, set_slide (props), set_notes (text as Markdown subset, converted server-side), duplicate_slide
  • block: add_block (slide, type, props, text, after or box), remove_block, set_block (props, box, z, region), set_text (Markdown subset), move_block, set_phone (the Β§2.5 map)
  • asset: register_asset after a presigned upload obtained from POST /api/v1/decks/{sqid}/uploads

Reads: GET /api/v1/decks (list), GET /api/v1/decks/{sqid} (the JSON view, with an ETag of the seq), GET /api/v1/decks/{sqid}/slides/{id}.svg (a thumbnail, so an agent can look at what it made), GET /api/v1/decks/{sqid}/export.pdf. POST /api/v1/decks creates.

The editor's inspector calls the same ops function in-process through the JS side of yjs; the server's ops handler calls the Rust core. The fixtures (Β§2.8) include one file per operation so both sides are held to the same result.

7.3 MCP

An MCP server is a thin adapter over Β§7.2, deferred until a consumer asks for it by name, memoria's rule. The ops are designed so each maps to one tool with the same name.

---

8. The scriptless floor

With scripts off, a person can: sign in, list decks, create a deck (title and theme, from a form), open a deck as a paged viewer (one slide per page, next and previous links, notes below when the deck allows), present from a share link as the same paged viewer, read the presenter notes page, download a PDF, manage sharing, mint and revoke agent tokens, and use the remote (each button is a form that POSTs and redirects back). They cannot edit slide content; the editor is the one surface that needs the bundle, Docs' ruling.

WCAG 2.2 AA with no exemptions, 24 by 24 targets, 320 px reflow (the canvas stage may scroll inside its own region in the editor, the estilo allowance), colour never alone, every visible string a rastrillo.T key.

---

9. Deliberately not built yet

| Not built | Trigger | |---|---| | Sign-in through the home; guest grants; edit links | The home's identity layer (home spec Β§5.3, Β§9). | | Charts from Sheets by S6 | Sheets' S6 server exists and a deck wants a live number. | | Video, embeds, audio | A person asks and the platform has a media store policy. | | Follow-the-presenter inside Meet | Meet v1 ships. | | Native remote | The web remote has been used in anger for a month. | | MCP | A consumer names it. | | 4:3 and custom aspect | A person asks; the key already exists. | | Cross-deck asset library, templates gallery beyond built-ins | Ten decks on one instance. | | Comments on slides | Docs' comment thread lands in estilo. | | Version history and named snapshots | Docs' snapshots design is adopted rather than reinvented. |

---

10. Slices

  1. Live at slides.oficina.works: scaffold, sign-in, deck list, Rust core with the schema and JSON view, HTML renderer for both modes with themes, viewer, share links, presentation sessions with presenter view and phone remote, scriptless floor throughout, the drive API for create and ops, first deploy.
  2. Editor and collab: the bundle, live-sync adoption, presence, the switcher, undo, asset upload.
  3. Export: layout engine, SVG thumbnails, the PDF module, the export job.
  4. Extractions: auth.go and the live-sync hub to shared modules; suite discussion outcomes adopted.

Slice 1 is what Paul asked to try first. It is a deck a person or an agent can build through the API and present from a phone before the editor exists, which is also the order that proves the model before the most expensive part is built on it.

---

11. Testing

  • Rust core: unit tests per operation; fixture replay against the JS generated updates (fixtures/verify-rust.mjs pattern from Docs).
  • Renderer: golden HTML per template and per block type, both modes, both viewports; an axe pass in the browser drive.
  • Presentation sessions: a Go test driving two subscribers and a remote command, asserting both see the slide event within the deadline.
  • Share links: token stored hashed, revoked link 404s, live-session follow offered only while live.
  • Drive API: every op has a fixture; a bearer from a revoked agent is refused; the actor column names the agent.
  • Scriptless: the browser drive with JavaScript disabled walks sign-in, create, view, present, remote.
  • Deploy: /api/version reports the stamped version on the live host before the deploy is called done.

---

12. Review record

Revision 1, with Β§2.6 and ruling 12 rewritten during slice 2, Β§4.2 and Β§4.3 amended on 2026-09-08 from what slice 3 shipped, and Β§2.2, Β§2.3, Β§2.4, Β§2.5, Β§2.6 and Β§3.3 amended with rulings 18 to 20 the same day from the Codex review's findings 3, 4, 7 and 10 (each paragraph marked codex N): unreviewed. The review pass is the first item after slice 1 ships, so that the reviewer has a running instance and not a document.