| 1 | # AGENTS.md |
| 2 | |
| 3 | Guidance for AI coding agents working in this repository. This is the |
| 4 | canonical agent-instructions file; `CLAUDE.md` points here and duplicates |
| 5 | nothing, because two copies drift and the drift is silent. |
| 6 | |
| 7 | Slides is a presentation editor for the Oficina suite: a deck that looks |
| 8 | like a Keynote deck on a desktop, reads like a document on a phone, |
| 9 | presents from a laptop and is driven from a phone. One instance per team |
| 10 | on the CARLOS platform, at `slides.oficina.works` for the first team. |
| 11 | It is built beside [Docs](https://amadan.net/oficina/docs), |
| 12 | [Sheets](https://amadan.net/oficina/sheets) and the suite |
| 13 | [doctrine](https://amadan.net/oficina/suite); where a decision is already |
| 14 | made over there, this repo consumes it rather than restating it. |
| 15 | |
| 16 | Design: `docs/superpowers/specs/2026-09-07-slides-design.md` (revision 1, |
| 17 | still unreviewed — §12 puts the review pass after slice 1 ships, so the |
| 18 | reviewer gets a running instance instead of a document; ruling 12 was |
| 19 | added during slice 2 and §2.6 rewritten with it). Plans: |
| 20 | `docs/superpowers/plans/2026-09-07-slice-1-live.md` and |
| 21 | `2026-09-07-slice-2-editor.md`. Read the spec section behind whatever you |
| 22 | touch. Read rastrillo's own `SKILL.md` (repo root, or in the module |
| 23 | cache) before writing handlers or models — it is the whole app story in |
| 24 | about 15KB, and you should assume none of it is in your training data. |
| 25 | |
| 26 | ## The one rule |
| 27 | |
| 28 | **A slide is one block tree, rendered many ways** (spec §0.1). The tree is |
| 29 | the only truth. The viewer renders it, the phone reflows it, the presenter |
| 30 | shows it, the drive API patches it, and the editor edits that same tree |
| 31 | through the same operations — its stage is the server's own HTML for the |
| 32 | slide, not a second rendering of it. There is never a desktop version and |
| 33 | a mobile version of a deck, only one deck and a viewport. |
| 34 | |
| 35 | Everything else follows. `flow` and `canvas` are two ways of placing the |
| 36 | same blocks, not two kinds of slide (§2.3). The phone overrides are entries |
| 37 | in the tree, not a second file (§2.5). The drive API is the inspector's |
| 38 | operations with a JSON body (§7). If a change you are making needs a second |
| 39 | representation of a deck, you have almost certainly taken a wrong turn. |
| 40 | |
| 41 | ## What is actually built |
| 42 | |
| 43 | Slices 1, 2 and 3. A deck is edited in the browser at `/d/{sqid}/edit`, |
| 44 | driven through the API (`docs/api.md`), or arrives as the sample deck |
| 45 | seeded on first sign-in — and comes back out as a PDF. |
| 46 | |
| 47 | - **Sign-in** (`internal/slides/auth.go`) is Keymail for an address |
| 48 | whose domain runs Keymail and an emailed magic link for every other |
| 49 | address — decided by the domain alone, never by |
| 50 | `SLIDES_ALLOWED_ADDRESSES`, or the endpoint becomes a membership |
| 51 | oracle. A Keymail address, permitted or not, gets the continuation |
| 52 | page to Keymail (never a 303 out of the form POST: Safari applies |
| 53 | `form-action` to it); any other gets `303 ?sent=1`, and only a |
| 54 | permitted one is mailed, by a worker after the response (`mailQueue`), so not even the response time differs. Admission is |
| 55 | rastrillo/auth's `Authorize`, after verification, and its refusal is |
| 56 | the door, rendered in the refusal's own 403 by auth's `Refused` hook |
| 57 | (`refused`; never a query anyone could type), not rastrillo's bare |
| 58 | text. An emailed link opens on a confirm step (`renderConfirm`, auth's |
| 59 | `RenderConfirm`): the door with one Sign in button, which POSTs the |
| 60 | token back to `/auth/verify` — the GET spends nothing, so a mail |
| 61 | scanner fetching the link cannot burn it. Both methods of |
| 62 | `/auth/verify` are mounted in both modes. `?force=1&err=keymail` (a failed Keymail exchange) makes the |
| 63 | form ask for a link instead. The list is also **rechecked on |
| 64 | every request** that resolves a principal, so taking an address off it |
| 65 | signs that person out of every device and refuses their agents' tokens |
| 66 | rather than leaving them everything they already held (spec §6.1, |
| 67 | ruling 16, codex 6). |
| 68 | - **Decks**: list, create, settings, delete, three rungs (private, |
| 69 | granted, team) per suite ruling 15. |
| 70 | - **The viewer** (`internal/slides/viewer.go`): one slide per page, |
| 71 | previous and next as ordinary links, notes in a `<details>`. The owner, |
| 72 | a team member and a stranger holding a share link all get the same page |
| 73 | from the same template with `mode` as a field, because the day those |
| 74 | three drift is the day one of them leaks the speaker notes. |
| 75 | - **Share links** (`share.go`): a read-only capability, SHA-256 stored, |
| 76 | URL shown once, revocable. A missing token and a revoked one 404 alike. |
| 77 | - **Presenting** (`presentation.go`, `present/hub.go`): a server-side |
| 78 | session with a pairing code, the fullscreen surface, the presenter view |
| 79 | with notes and a clock, the phone remote, and audience follow from a |
| 80 | share link — which **stays on** `/s/{token}` routes, stream included, |
| 81 | so revoking the link ends the follow. The session sqid is a projector |
| 82 | address and is never rendered onto a follow page (spec §3.4, codex 5). |
| 83 | - **The editor** (`editor.go`, `templates/editor.html`, |
| 84 | `static/editor.css`, and the committed bundle built from `fixtures/`): |
| 85 | the strip of server-rendered thumbnails, the stage, the inspector for |
| 86 | the deck, the slide and the selected block, text edited in place through |
| 87 | ProseMirror bound to the server's own HTML, canvas drag and resize with |
| 88 | snapping, the desktop/phone switcher whose phone-view edits become |
| 89 | `set_phone` overrides, undo scoped to the person who made the change, |
| 90 | presence, and image upload when a store is bound. |
| 91 | - **Live sync** (`live.go`, `live/hub.go`, `snapshot.go`): the update log |
| 92 | served by cursor, an SSE stream of advisory seqs and whole presence |
| 93 | sets, and a snapshot every 200 updates so opening a long-lived deck is |
| 94 | not a full replay. |
| 95 | - **Assets** (`assets.go`, `svg.go`): presigned PUT browser-to-bucket |
| 96 | into a **staging key this app mints** (never an address the client |
| 97 | named), registration that resolves the upload receipt for it, re-reads |
| 98 | and sniffs what actually landed, sanitises an SVG, and **promotes the |
| 99 | bytes to a final object keyed by their own hash** — then three |
| 100 | capability-checked routes serving them back. The receipt is the |
| 101 | `asset_uploads` row (`models.go`, migration |
| 102 | `0008_create_asset_uploads.sql`): one deck, one actor, single use, two |
| 103 | hours. A ref alone registers nothing, and no PUT is ever signed for an |
| 104 | address something is already served from (spec §2.7, codex 1 and 2). |
| 105 | - **The layout engine and thumbnails** (`layout/`, `svg/`, `fonts/`, |
| 106 | `thumbs.go`): every slide measured into a paint list with the same |
| 107 | embedded Liberation faces the browser sets it in, written as an SVG, |
| 108 | and served as `{surface}/slides/{id}.svg` from all four capabilities. |
| 109 | The editor strip, the presenter's next-slide and the remote are that |
| 110 | picture. |
| 111 | - **PDF export** (`export.go`, `exports.go`, `pdfcore/`, `core/pdf/`): |
| 112 | `POST /d/{sqid}/export` starts a rastrillo job, the status page polls |
| 113 | itself (and works with scripts off), and the file comes back from |
| 114 | `GET /d/{sqid}/export/{job}.pdf`. One page per slide, optionally the |
| 115 | speaker notes as pages of their own, text as embedded glyphs, images |
| 116 | inlined. The API twins are `POST /api/v1/decks/{sqid}/export` and its |
| 117 | two GETs. The file is answerable for an hour and **the status page |
| 118 | says so for the whole hour**: rastrillo/jobs forgets a finished job |
| 119 | after ten minutes, so the page, its fragment and the API's status all |
| 120 | fall back to this app's own held record (`exportFallback`) rather than |
| 121 | 404ing while `.pdf` at the same address still works. |
| 122 | |
| 123 | Not built, and don't write around their absence: charts from Sheets, |
| 124 | video and embeds, an MCP adapter, aspect ratios other than 16:9, |
| 125 | comments, and version history. Those are the triggers in spec §9. |
| 126 | `docs/api.md` is the reference for the API, written for a person and an |
| 127 | agent both. |
| 128 | |
| 129 | **The deployment has no object store bound today** (`carlos store create` |
| 130 | is the fix, and nobody has run it). The upload endpoints answer |
| 131 | `503` there and the editor's image field says so, server-rendered, rather |
| 132 | than letting somebody pick a file and find out afterwards. |
| 133 | |
| 134 | ## Layout |
| 135 | |
| 136 | `internal/slides/` is the app in SKILL.md's five-file shape — `models.go`, |
| 137 | `migrations.go` plus `migrations/`, `app.go`, `handlers.go`, `render.go` |
| 138 | plus `templates/` — with one file per screen area beside them (`auth.go`, |
| 139 | `decks.go`, `viewer.go`, `share.go`, `presentation.go`, `sample.go`, |
| 140 | `live.go`, `editor.go`, `snapshot.go`, `assets.go`, `svg.go`, |
| 141 | `thumbs.go`, `export.go`, `exports.go`, `api.go`, `agents.go`, |
| 142 | `assets_embed.go`) and one |
| 143 | package per concern nested underneath: `core` (the wazero host), `deck` |
| 144 | (Go types for the core's JSON view), `render` (slide HTML), `layout` |
| 145 | (the paint list), `svg` (the paint list as a picture), `fonts` (the |
| 146 | embedded faces and their metrics), `pdfcore` (the PDF module's wazero |
| 147 | host), `present` (the presentation SSE hub), `live` (the deck's own |
| 148 | hub: seqs and presence). `internal/slides/static/` holds the vendored |
| 149 | stylesheets and the committed editor bundle. `core/` at the repo root is |
| 150 | the Rust crate. `manifest/` and `gen/` are rastrillo's declarative path, |
| 151 | unused here. |
| 152 | |
| 153 | `core/pdf/` is a second Rust crate beside the CRDT core: `slides_pdf.wasm` |
| 154 | wraps `krilla` and `krilla-svg` and takes one SVG per page plus the |
| 155 | Liberation TTFs, and writes one PDF with the text as subset, embedded |
| 156 | glyphs. It wrapped `svg2pdf` until 2026-09-09; that repository was |
| 157 | archived on 2026-07-10 naming krilla as its successor, and the swap kept |
| 158 | the ABI byte-identical (spec §1 ruling 13). `internal/slides/pdfcore` |
| 159 | hosts it under wazero with the same containment as `core` but a simpler |
| 160 | shape — a fresh module instance per `Render`, discarded afterwards, under |
| 161 | a sixty-second job deadline rather than the CRDT host's one-second call |
| 162 | deadline. `internal/slides/export.go` is the pipeline over it |
| 163 | (`layout.Layout` → `svg.Encode` with every image inlined → `Render`), |
| 164 | including the notes pages, which are paint lists typeset here with the |
| 165 | fonts package. `scripts/build-pdf-wasm.sh` builds the module; the |
| 166 | artifact is committed at `internal/slides/pdfcore/testdata/slides_pdf.wasm` |
| 167 | and the gate rebuilds and `cmp`s it exactly as it does the CRDT core's. |
| 168 | `internal/slides/exports.go` is everything around that pipeline: the |
| 169 | screens, the job, and the bytes. |
| 170 | |
| 171 | Both wasm hosts cap the memory one instance may take — 512 MiB for |
| 172 | `pdfcore`, 256 MiB for `core` (`defaultMemoryPages` in each) — because |
| 173 | both run a parser over bytes a deck author controls on an instance |
| 174 | shared with everything else. Past the cap the guest's allocator aborts |
| 175 | and the host reports an ordinary error, which is what the two |
| 176 | `…PastTheMemoryLimitIsAnErrorNotACrash` tests hold. |
| 177 | |
| 178 | `fixtures/` at the repo root is the browser half and the contract that |
| 179 | keeps it honest: the editor's ES modules, their `node --test` tests, |
| 180 | `build-bundles.mjs` (esbuild, output committed), and `generate.mjs` with |
| 181 | `data/` and `vectors/`. It is a Node package with a committed lockfile; |
| 182 | `node_modules/` is not. |
| 183 | |
| 184 | ## The seams |
| 185 | |
| 186 | A handful of functions carry most of the app's invariants. Go through them |
| 187 | rather than around them; a new handler that reaches past one is the usual |
| 188 | way a rule stops holding. |
| 189 | |
| 190 | `currentUser(w, r)` (`auth.go`) maps `sessions.Current(r).Subject` — an |
| 191 | email address under rastrillo/auth, never a user id — to the `users` row, |
| 192 | making it on first sign-in. It is also **the admission recheck**: an |
| 193 | address no longer on `SLIDES_ALLOWED_ADDRESSES` is signed out here |
| 194 | (`offboard` — every session row for it deleted, live talks ended, share |
| 195 | links left alone) and told so on the sign-in page, which is why it takes |
| 196 | a ResponseWriter and answers `sessionOffboarded` rather than a bare |
| 197 | false. The bearer middleware does the same for an agent's operator. |
| 198 | Without both, taking an address off the list removed nothing that address |
| 199 | already held (spec §6.1, codex 6). `actorFor(u)` turns the row into the |
| 200 | `person:<id>` string every write records (§6.2); the API's bearer |
| 201 | middleware produces `agent:<id>` the same way, and a presentation remote |
| 202 | produces `remote:<session>`, the one deliberately anonymous actor (§6.2). |
| 203 | |
| 204 | `visibleDecks(ctx, viewer)` is the read scope: owner, team rung, or a |
| 205 | grant. Its `OR` chain is parenthesised inside the string on purpose — |
| 206 | every caller chains another `Where` on, and an unparenthesised `OR` widens |
| 207 | it to "or this deck is on the team rung", defeating the whole seam by |
| 208 | operator precedence. `deckFor` is a deck the person may read; `ownedDeckFor` |
| 209 | is one they own. Both answer **404, never 403**, so a stranger holding a |
| 210 | sqid cannot learn whether it is real. |
| 211 | |
| 212 | `openDeck` instantiates the Y.Doc: a fresh wasm module, the newest |
| 213 | `deck_snapshots` row applied, every update after it replayed in seq order, |
| 214 | and the JSON view that produces. The handle is the caller's to close. A |
| 215 | snapshot is derived data and never the truth (`snapshot.go`) — the log |
| 216 | alone still is the deck, so a missing or unreadable snapshot means a slower |
| 217 | open and nothing else. |
| 218 | |
| 219 | `applyOps` and `appendUpdate` are the two write paths into a document, and |
| 220 | the only two. Each puts the update row, the `seq` bump and any deck-row |
| 221 | columns in one transaction with the seq as an optimistic lock, so the row |
| 222 | and the log commit or roll back together, then publishes the new seq and |
| 223 | takes a snapshot if it crossed the boundary. They differ in one line and in |
| 224 | what a failure of it means: `applyOps` (`editDeck` wraps it with the |
| 225 | handle's life and one retry on `errDeckBusy`) hands the core an op batch |
| 226 | this app wrote, so a refusal is a bug; `appendUpdate` (`live.go`) hands it |
| 227 | bytes off the wire, so a refusal is ordinary and comes back as a 422. |
| 228 | |
| 229 | "Refusal" there is **two** checks on the fresh handle, both before the |
| 230 | transaction opens, and they look at different things: |
| 231 | |
| 232 | - `Handle.Check(ctx)` — the core's `slides_core_check` (ABI 2, |
| 233 | `core/src/check.rs`, mirrored in `fixtures/check.mjs`) — walks the |
| 234 | **document**: every notes fragment, every block `text`, every table |
| 235 | cell, and refuses anything outside §2.6 (a formatting attribute other |
| 236 | than `b i c h href`, a non-`paragraph` child, an element or fragment |
| 237 | inside a paragraph, an embed). This is the gate that has to exist, |
| 238 | because y-prosemirror 1.3.7 *deletes* an `XmlText` whose attributes it |
| 239 | cannot map to a mark: the words are lost in the browser while the |
| 240 | server sees nothing wrong (spec ruling 19, codex 3). |
| 241 | - `deck.Parse` on the JSON view — the **projection** — which is what |
| 242 | keeps a shape the host's typed structs cannot read out of the log. |
| 243 | |
| 244 | The projection alone is not enough and that is the whole point: the view |
| 245 | is deliberately tolerant (`core/src/view.rs`, mirrored in |
| 246 | `fixtures/view.mjs`) and reads straight past an unknown mark. Bytes off |
| 247 | the wire can put any shape at any key, and what these two keep out of |
| 248 | the log is what would otherwise brick every `openDeck` after it, or |
| 249 | quietly eat somebody's sentence. Nothing else may append to |
| 250 | `deck_updates`. |
| 251 | |
| 252 | `schema::ordered_maps` (`core/src/schema.rs`) and `orderedMaps` |
| 253 | (`fixtures/ydoc.mjs`) are the reading order, and there is one of them |
| 254 | because there is one idea. **Both ordered arrays in the document — the |
| 255 | deck's `slides` and a slide's `blocks` — are SETS**, and the order is an |
| 256 | `order` key on each map in them: a number, absent ranking at the array |
| 257 | index, array index breaking ties (spec §2.1–2.3, rulings 18 and 21, |
| 258 | codex 4). A reorder writes that key and never moves an array entry, |
| 259 | because moving one means deleting the map and inserting a copy, and |
| 260 | every edit a peer had in flight against the original — a sentence being |
| 261 | typed into a block, or into any block of the slide — is then addressing |
| 262 | a deleted type and is lost on merge. `move_slide`, `move_block`, |
| 263 | `add_*`, `duplicate_slide` and canvas-to-flow all go through |
| 264 | `place_entry`/`placeEntry`. **The view sorts by the key and does not |
| 265 | expose it**, so `deck.go`, `render/`, `layout/`, the viewer, the |
| 266 | presenter and the exporter all read one sequence and none of them knows |
| 267 | the key is there — which is also why adding a reader is not a reason to |
| 268 | teach it about `order`. |
| 269 | |
| 270 | `render.Slide(d, index, viewport)` is the only place slide HTML is |
| 271 | produced, for every surface — the viewer, the presenter, the API's |
| 272 | `.html` twin, the editor's stage and its thumbnails. It builds markup by |
| 273 | hand rather than templating it, because `html/template` cannot take a |
| 274 | dynamic tag or attribute name; read the package doc comment for the four |
| 275 | invariants that make that safe before adding a block type. |
| 276 | |
| 277 | `render.SlideWith(..., render.Options{AssetBase})` is that function when |
| 278 | the slide has pictures on it, and **every real surface has to pass its |
| 279 | own base**: `/d/{sqid}/assets` signed in, `/s/{token}/assets` over a share |
| 280 | link, `/p/{session}/assets` while presenting. One slide is rendered on |
| 281 | surfaces authenticated four different ways, and the `<img>` has to be |
| 282 | fetchable by whoever is looking at it. `Slide` stays for callers that have |
| 283 | no pictures to worry about (a test, a fixture) and falls back to |
| 284 | `render.DefaultAssetBase`, which is deliberately a path nothing is mounted |
| 285 | at: forget to say which surface you are and you get visibly broken images |
| 286 | rather than a URL that quietly works from the wrong place. Note also that |
| 287 | the `<img src>` carries the asset's **ref**, never its id — the id is the |
| 288 | deck's own name for it and stays out of URLs. |
| 289 | |
| 290 | `serveAsset` (`assets.go`) is the one body behind all three of those |
| 291 | routes, and the check inside it is the security property, not a |
| 292 | formality: a ref is a content address, so knowing one is knowing the |
| 293 | SHA-256 of somebody's file. It resolves the deck and confirms *this* |
| 294 | deck's `assets` map refers to *this* ref before it serves a byte; |
| 295 | without that, any capability for any deck would fetch any object in the |
| 296 | bucket. It costs an `openDeck` per image request, which is known and |
| 297 | noted rather than forgotten. It also requires a `deck_assets` row for |
| 298 | the pair, written only by `registerAsset` after it read, sniffed and |
| 299 | sanitised the bytes: `register_asset` is an op any member can apply with |
| 300 | any ref, and a presigned PUT lands whatever the browser sent, so the map |
| 301 | alone would let an unverified object be served. |
| 302 | |
| 303 | `layout.Layout(d, index)` is the other renderer, and the rule about it |
| 304 | is the one in the plan: **the paint list is the only input to SVG and to |
| 305 | PDF.** Nothing under `svg/`, `pdfcore/` or `export.go` reads a deck. |
| 306 | Layout measures with the same embedded Liberation faces the browser sets |
| 307 | the slide in (`fonts/`, served as `@font-face` from `static/fonts/` on |
| 308 | every slide surface), so the two agree on everything but where a line |
| 309 | breaks. Every warning it makes carries the slide it is about |
| 310 | (`layout.Warning.Slide`), because the export's status page lists the |
| 311 | warnings of a whole deck and a line that cannot name its slide is a line |
| 312 | nobody can act on. |
| 313 | |
| 314 | `Export(ctx, view, ExportOptions)` (`export.go`) is that pipeline — |
| 315 | `layout.Layout` → `svg.Encode` with every image inlined → the module's |
| 316 | `Render` — and `exports.go` is everything around it. Two things there |
| 317 | are worth knowing before touching it. The PDF module is compiled once |
| 318 | per app, lazily, and closed with the app (`pdfRuntime`), because |
| 319 | compiling it costs the best part of a second and most processes never |
| 320 | export. And **with no object store bound the finished PDF is held in |
| 321 | memory for an hour**, capped at 50 MiB per deck and 200 MiB per instance: |
| 322 | over either cap the job fails with a sentence the person can act on |
| 323 | rather than the instance failing with an OOM nobody can read. When a |
| 324 | store IS bound the same job uploads it and drops the held bytes, and the |
| 325 | download route 302s to a presigned GET. Both paths are in one handler |
| 326 | (`serveExport`) so neither can rot. |
| 327 | |
| 328 | `present.Hub` is the in-memory fan-out for a live presentation. Its events |
| 329 | are whole state, never deltas, so a subscriber that falls behind is dropped |
| 330 | and reconnects rather than having events skipped — a skipped event leaves a |
| 331 | projector showing slide 4 for the rest of a talk with nothing to notice it. |
| 332 | |
| 333 | `live.Hub` is the same shape for a deck being edited: a seq, which is |
| 334 | advisory ("there is something at least this new" — the bytes come from |
| 335 | `GET /d/{sqid}/updates?since=`), and the whole presence set, which is not. |
| 336 | Presence lives there and nowhere else — it is not document state, so it |
| 337 | never reaches the log, is never snapshotted, and is gone with the process. |
| 338 | |
| 339 | ## The wire the editor speaks |
| 340 | |
| 341 | Docs' live-sync design, adopted as written (spec §3.3). Four routes, all |
| 342 | in the signed-in group, all behind `deckFor`, all 404 for a stranger. |
| 343 | |
| 344 | `GET /d/{sqid}/updates?since=N` answers `application/octet-stream`: every |
| 345 | update after seq N, each framed as a 4-byte big-endian length then the |
| 346 | bytes, in seq order. `Slides-Seq` is the highest seq in the body, or N |
| 347 | when the body is empty — derived from the bytes in hand, never from a |
| 348 | second `MAX(seq)` query, which could see an update this response does not |
| 349 | carry and leave the client holding a cursor for bytes it never received. |
| 350 | At `since=0` the answer may start from the newest snapshot, and |
| 351 | `Slides-Snapshot: <seq>` says when it did. |
| 352 | |
| 353 | `POST /d/{sqid}/updates` takes one Yjs v1 update, 1 MiB cap. It goes |
| 354 | through `appendUpdate`, is stored with the poster's actor, and answers |
| 355 | `204` with `Slides-Seq`. A refusal from the core is a 422 and an ordinary |
| 356 | event: these are bytes off the wire, not a batch this app wrote. |
| 357 | |
| 358 | `GET /d/{sqid}/stream` is SSE and carries no document bytes at all: |
| 359 | `event: update` with `data: <seq>` (advisory — go and fetch), `event: |
| 360 | presence` with the whole set as JSON, `: ping` every 15 s, one of each on |
| 361 | connect, 5 minute TTL. Whole state rather than deltas, so a subscriber |
| 362 | that falls behind is dropped and reconnects. |
| 363 | |
| 364 | `POST /d/{sqid}/presence` takes `{client, slide, block}` every 20 s and on |
| 365 | change. **The server stamps the name and the colour** from the `users` |
| 366 | row; a client that sends its own is ignored, or presence becomes a place |
| 367 | to write somebody else's name. Two missed heartbeats and the sweeper |
| 368 | drops it. |
| 369 | |
| 370 | ## The fixtures rule |
| 371 | |
| 372 | `fixtures/ops.mjs` and `core/src/ops.rs` are two implementations of one op |
| 373 | vocabulary (spec §7.2), because the browser writes the document the core |
| 374 | reads. `fixtures/data/*.json` are op batches; `node fixtures/generate.mjs` |
| 375 | runs each through the JS side and writes `vectors/<name>.yjs` (the Y |
| 376 | update) and `vectors/<name>.view.json` (the JS view of it). |
| 377 | `internal/slides/core/vectors_test.go` loads the same bytes through the |
| 378 | core and requires the same view, and also runs the same op batch through |
| 379 | the core's own `Ops` and compares again. `generate.mjs` also runs |
| 380 | `checkDoc` over every vector before it writes one, so a vector that |
| 381 | would not pass the write gate never gets committed in the first place. |
| 382 | |
| 383 | So: **an op changes on both sides or on neither.** Regenerate in the same |
| 384 | commit as the change. Never edit a vector to make a test pass — a vector |
| 385 | that was quietly adjusted leaves the Go test comparing the core against a |
| 386 | JS implementation that no longer exists, and it passes while the two sides |
| 387 | drift. The gate regenerates and diffs. |
| 388 | |
| 389 | It has already caught something real. **`href` is stored as the one-key map |
| 390 | `{"href": url}`, and the ProseMirror marks are named `b`, `i`, `c`, `h`, |
| 391 | `href`** — spec §1 ruling 12, §2.6. y-prosemirror 1.3.7 has no hook to map |
| 392 | mark names to Y attribute names: it writes |
| 393 | `attributes[mark.type.name] = mark.attrs` and reads |
| 394 | `schema.mark(attrName, attrValue)`. So the mark's name has to be the |
| 395 | attribute's name, and a plain-string `href` throws inside `schema.mark` — |
| 396 | and y-prosemirror deletes the `XmlText` when a read throws, which loses |
| 397 | the text and not just the link. Naming the marks after the attributes |
| 398 | makes the mapping the identity and keeps mapping code out of the editor. |
| 399 | The writer is exact; both readers are tolerant, taking any truthy value |
| 400 | for `b`/`i`/`c`/`h` (y-prosemirror writes `{}`) and either the map or a |
| 401 | plain string for `href` (documents written before the ruling). |
| 402 | |
| 403 | ## How the editor is put together |
| 404 | |
| 405 | Read this before changing anything under `fixtures/`. |
| 406 | |
| 407 | The browser is not a second renderer. The stage shows the server's own |
| 408 | HTML for the current slide, fetched from |
| 409 | `GET /d/{sqid}/slides/{id}.html?vp=desktop|phone|thumb`, and the client |
| 410 | binds a ProseMirror `EditorView` into each text block already in it. After |
| 411 | any structural change lands, the slide is re-fetched. Thumbnails come from |
| 412 | the server too. If you find yourself building slide markup in JS, stop. |
| 413 | |
| 414 | Structural edits go through `fixtures/ops.mjs`'s `applyOps` — the same |
| 415 | vocabulary the API takes — inside one Y transaction, and reach the server |
| 416 | as ordinary Yjs updates through the save queue. Undo is a single |
| 417 | `Y.UndoManager` over the three roots with this client's origins tracked, |
| 418 | driven directly rather than through `yUndoPlugin` (whose view hook |
| 419 | destroys a manager shared by many views the moment the first view goes). |
| 420 | `commit()` calls `stopCapturing()` first, so each batch is its own undo |
| 421 | step. |
| 422 | |
| 423 | `editor-entry.js` is wiring and nothing else; every other module under |
| 424 | `fixtures/` is one concern and is meant to stay under 300 lines — one |
| 425 | does not: `canvas.mjs` (drag, resize and snapping are one concern and |
| 426 | run to about 320), which is noted rather than split. `live-updates.mjs`, |
| 427 | `save-queue.mjs` and `resync-controller.mjs` are Docs' modules copied |
| 428 | verbatim with a header saying so — this is the second implementation, and |
| 429 | extracting them to something shared is slice 4's problem, not yours. |
| 430 | |
| 431 | The bundle is built by hand and committed: |
| 432 | |
| 433 | ```sh |
| 434 | cd fixtures && npm ci && node build-bundles.mjs |
| 435 | ``` |
| 436 | |
| 437 | `assets_embed.go` resolves `editor.<16 hex>.js` out of the embedded |
| 438 | `static/` at package init and **panics unless there is exactly one**, so a |
| 439 | build that stopped emitting one, or a merge that left two, refuses to boot |
| 440 | rather than serving an editor that disagrees with the server. |
| 441 | |
| 442 | ## Traps |
| 443 | |
| 444 | - **Never call `sessions.UserID`.** Under rastrillo/auth the Subject is |
| 445 | the email address, so it answers `(0, false)` and would scope every |
| 446 | owned query to `user_id = 0`. Map the address to the row first. |
| 447 | - **Neither committed wasm is rebuilt for you.** `go:embed` will happily |
| 448 | embed a stale `internal/slides/core/testdata/slides_core.wasm` and the |
| 449 | Go tests will pass against it; the same is true of |
| 450 | `internal/slides/pdfcore/testdata/slides_pdf.wasm`. **The gate |
| 451 | rebuilds and `cmp`s both** (`scripts/build-wasm.sh` and |
| 452 | `scripts/build-pdf-wasm.sh`); when it fails, rebuild and commit the |
| 453 | artifact rather than arguing with it. |
| 454 | - **The core ABI is version 2 and the host's check is exact-match.** Add |
| 455 | or change an export in `core/src/abi.rs` and you bump both |
| 456 | `slides_core_abi_version()` in `core/src/lib.rs` and `abiVersion` in |
| 457 | `internal/slides/core/core.go`, in the same commit, or the host |
| 458 | refuses to instantiate the module — which is the point: it turns |
| 459 | "unknown export" deep inside a request into a refusal at load time. |
| 460 | Version 2 is where `slides_core_check` arrived (codex 3). |
| 461 | - **`Doc::new()` and `Doc::with_client_id()` are banned in the Rust core** |
| 462 | (`clippy.toml`) — both draw entropy this wasm build has no safe source |
| 463 | for. Use `Options::with_guid_and_client_id`. A doc comment alone was got |
| 464 | around more than once, which is why it is a lint. |
| 465 | - **The committed editor bundle is not rebuilt for you either.** Change a |
| 466 | `.mjs` under `fixtures/` and `internal/slides/static/editor.<hash>.js` |
| 467 | is stale until you run `node fixtures/build-bundles.mjs` and commit what |
| 468 | it writes. Nothing builds it at run time and CI does not build it for |
| 469 | you — the gate rebuilds and diffs, which is a check, not a fix. |
| 470 | - **A stale vector passes.** `vectors_test.go` compares the core against |
| 471 | whatever is committed under `fixtures/vectors`, so a vector nobody |
| 472 | regenerated makes it compare the core against a JS implementation that |
| 473 | no longer exists. Run `node fixtures/generate.mjs` in the same commit as |
| 474 | any op change. |
| 475 | - **Exactly one directory under `internal/`.** `rastrillo migration check` |
| 476 | requires it, which is why every package is nested under |
| 477 | `internal/slides/` (`core`, `deck`, `render`, `layout`, `svg`, `fonts`, |
| 478 | `pdfcore`, `present`, `live`) rather than sitting beside it. |
| 479 | - **A job outlives its request, so it cannot hold one.** The export's |
| 480 | sentences — its name, its progress line, every failure it can report — |
| 481 | are resolved from the catalog while the request is still here |
| 482 | (`exportWords`) and carried into the goroutine. `rastrillo.T` reads the |
| 483 | locale off a request; a job that called it would either be holding a |
| 484 | request it must not hold or answering in the wrong language. The same |
| 485 | goes for the context: use the job's, never the request's. |
| 486 | - **A hardcoded English string is a bug.** Every user-visible string is a |
| 487 | key in `internal/slides/locales/en.toml`, resolved through `rastrillo.T` |
| 488 | at render time. Twelve locales are the suite target; this slice ships |
| 489 | `en` and leaves the keys ready. Deck titles and timestamps are data, not |
| 490 | copy, and are correctly not keys. |
| 491 | - **Every screen but the editor works with scripts off** (spec §8) — |
| 492 | including the remote, where each button is a form that POSTs and |
| 493 | redirects back. The editor is the one stated exception, and it says so |
| 494 | in a `<noscript>` that links the viewer. Everywhere else JavaScript is |
| 495 | an enhancement and nothing depends on it. |
| 496 | - **Every user-facing string the bundle shows is still a catalog key.** |
| 497 | It reaches the browser as a `data-text-*` attribute the template |
| 498 | renders from `en.toml`; an English literal in a `.mjs` file is the same |
| 499 | bug it would be in Go. |
| 500 | - **`cargo fmt --check` covers `core/`.** It was not in the gate until |
| 501 | 2026-09-07 and three files had drifted by then; they were reformatted in |
| 502 | the same commit that added the check. |
| 503 | |
| 504 | ## Rules the family holds hard |
| 505 | |
| 506 | - Every query touching user-owned rows goes through the seams above — |
| 507 | reads and writes, transactions included. |
| 508 | - Never bind a request body onto a GORM model: explicit `map[string]any` |
| 509 | plus a `.Select` allowlist. |
| 510 | - Migrations apply at boot, once each, and are never re-run or reversed. |
| 511 | Change `models.go`, then run `rastrillo migration generate` and *read |
| 512 | the SQL* — it may be a full table rebuild rather than an `ADD COLUMN`. |
| 513 | Never edit a migration that has shipped. Renames are hand-written |
| 514 | (`rastrillo migration new rename_x`), because a rename is |
| 515 | indistinguishable from a drop plus an add. |
| 516 | - Every state-changing route is a POST behind `csrf.Protect`. Destructive |
| 517 | actions get their own confirm-page URL, never a modal. |
| 518 | - Markup is the bare `rst-*` attribute grammar (`<a rst-btn="primary">`), |
| 519 | never `class="rst-*"`. This app's own attributes are `slides-*`. |
| 520 | - WCAG 2.2 AA with no exemptions: 24×24 targets, 320px reflow, colour |
| 521 | never the only carrier of state, `alt` on every image. |
| 522 | - `CGO_ENABLED=0` throughout. The stack is cgo-free by design and the |
| 523 | binary that ships is `linux/arm64`. |
| 524 | |
| 525 | ## Running it locally |
| 526 | |
| 527 | `./scripts/run-dev.sh` builds and runs the app on `localhost:8091` |
| 528 | (`$SLIDES_DEV_PORT` to move it) against a scratch database under `$TMPDIR` |
| 529 | that starts empty every run. Sign in with any address; the magic link is |
| 530 | logged to the terminal instead of being mailed, and it is a real link |
| 531 | through the real flow. |
| 532 | |
| 533 | Development mode is the only place four settings default — a fixed public |
| 534 | instance key, mail to the terminal, an allowlist that admits everyone, and |
| 535 | a directory-backed object store — and it can only be selected by the `-env |
| 536 | development` flag. `$SLIDES_ENV` cannot reach it, deliberately: platform |
| 537 | config *is* environment variables, so a stray `SLIDES_ENV=development` |
| 538 | beside the real ones would otherwise be a production-reachable path to a |
| 539 | public instance key and to every magic link landing in the instance log. |
| 540 | Anywhere but development, a missing `SLIDES_ORIGIN`, |
| 541 | `SLIDES_INSTANCE_KEY`, `SLIDES_SMTP_*` or `SLIDES_ALLOWED_ADDRESSES` is a |
| 542 | startup refusal rather than a default. |
| 543 | |
| 544 | ## The gate |
| 545 | |
| 546 | `.amadan/ci.d/10-gate` is the only CI this repo has, it runs on every push |
| 547 | to the amadan remote, and it is the same script you run locally: |
| 548 | |
| 549 | ```sh |
| 550 | ./.amadan/ci.d/10-gate |
| 551 | ``` |
| 552 | |
| 553 | It does rustfmt, clippy with the disallowed-methods lint, the wasm build, |
| 554 | the `cmp` against the committed artifact, the Rust tests; then the JS half |
| 555 | — `npm ci`, `node --test`, the vectors regenerated and the bundle rebuilt |
| 556 | and both diffed against what is committed; then `make ci` (vet, gofmt, Go |
| 557 | tests, migration check). `make ci` alone is the fast loop between edits; |
| 558 | the full gate is what you run before pushing. |
| 559 | |
| 560 | Since slice 2 it needs Node 20+ and `npm`, and `npm ci` wants the registry |
| 561 | reachable while it runs — the one part of this gate that is not offline |
| 562 | once its caches are warm. `.amadan/README.md` says why that trade is worth |
| 563 | making, what each step catches, and the runner's own two traps. In a |
| 564 | sandbox, point `NPM_CACHE` somewhere writable or npm fails on its cache |
| 565 | rather than on the install. |
| 566 | |
| 567 | ## Deploying |
| 568 | |
| 569 | One static binary, stamped: `make release` (refuses an empty version or a |
| 570 | dirty tree — a `-dirty` stamp names a commit plus changes nobody can name, |
| 571 | so two different binaries can carry the same string). Then `carlos deploy |
| 572 | --app slides --account oficina`, with `carlos store create` for the object |
| 573 | store and `carlos domains attach slides.oficina.works`. |
| 574 | `SLIDES_INSTANCE_KEY` and `SLIDES_SMTP_PASS` go in via `carlos secrets`, |
| 575 | the rest via `carlos env`, mirroring Docs' set (spec §6.3). |
| 576 | |
| 577 | Verify against `x-carlos-version` on the real host or let `carlos deploy` |
| 578 | watch the URL. `carlos channels` reports which release a channel *points |
| 579 | at*, which is not the question of what the box is *serving* — adoption runs |
| 580 | on a timer and the two can disagree for up to an hour, with nothing saying |
| 581 | "not live yet". |
| 582 | |
| 583 | ## Working through amadan |
| 584 | |
| 585 | This repo is hosted on an amadan hub, not GitHub: no `gh`, no pull request, |
| 586 | no GitHub Actions. Post as the agent account (`<name>~<operator keymail>`), |
| 587 | never as Paul. Work happens on a worktree branch and reaches `main` only by |
| 588 | squash merge, after review and with the gate green — `amadan ci status |
| 589 | -branch <n>` exit 0 is the merge gate; exit 2 is pending and exit 3 means |
| 590 | nothing ran, and neither is green. |
| 591 | |
| 592 | **Every branch gets a discussion, opened before the merge, and it is the |
| 593 | review record.** A squash merge flattens the history that carried the |
| 594 | branch's reasoning; the discussion is where that survives. If it does not |
| 595 | exist before the merge, the reasoning is not scattered, it is gone. |
| 596 | |
| 597 | ## UX conventions (seeded from profile: considered) |
| 598 | |
| 599 | Accessible by construction, progressively enhanced, and never dependent on |
| 600 | JavaScript to be usable. |
| 601 | |
| 602 | - [x] Icons — lucide, inline — `internal/slides/icons` |
| 603 | - [ ] Screen flow — one job per screen — a link, then a page for that action alone, then a confirmation; never two ways to start work side by side |
| 604 | - [x] Fields — label, hint, help and error in one envelope, wired with aria-describedby — `ui/field, ui/field-select, ui/field-text` |
| 605 | - [x] Destructive actions — their own confirm-page URL, never a modal — `ui/confirm-form` |
| 606 | - [x] State — never colour alone — always a text label too — `ui/status-pill, ui/badge` |
| 607 | - [ ] Dates — relative, with the absolute date in a title attribute |
| 608 | - [ ] Errors — server-rendered beside the field, in plain language |
| 609 | - [x] Empty states — say what the screen is for, not just that it is empty — `ui/empty-state` |
| 610 | |
| 611 | `[x]` is enforced by a vendored component: use it, do not hand-roll an |
| 612 | equivalent. `[ ]` is guidance to apply by hand. A convention absent from |
| 613 | this list is one rastrillo has no opinion about. |
| 614 | |
| 615 | Edit any line. **This file is the source of truth, not the profile name.** |
| 616 | Nothing re-reads the profile, so changing a line here changes what the app |
| 617 | does, and a convention deleted from this file is not one to reinstate. |
| 618 | |
| 619 | ## Work in a worktree, and catch main up first |
| 620 | |
| 621 | Take a worktree for the work. Do not work in the shared checkout. |
| 622 | |
| 623 | git -C <repo> fetch origin |
| 624 | git -C <repo> worktree add ../<repo>-<task> -b <branch> origin/main |
| 625 | |
| 626 | Both halves matter, for different reasons. |
| 627 | |
| 628 | **The worktree**, because several sessions share this machine and some |
| 629 | share this repository, and a checkout has one HEAD, one index and one |
| 630 | working tree between all of them. A branch switch under somebody else's |
| 631 | uncommitted work destroys it with nothing on screen to say so, and two |
| 632 | sessions editing the same paths disagree quietly instead of conflicting |
| 633 | loudly. A worktree gives the work its own HEAD and its own files, and |
| 634 | costs a directory. |
| 635 | |
| 636 | **From `origin/main`, after a fetch** — not from whatever the checkout |
| 637 | happens to be sitting on. A branch cut from a stale main is a merge |
| 638 | conflict scheduled for later, against files somebody else has renamed in |
| 639 | the meantime. A branch cut from another task's branch is worse: it |
| 640 | carries that task's commits into yours, where they are reviewed as |
| 641 | though you wrote them, and the two can only be separated by hand. |
| 642 | |
| 643 | Remove it when the work has landed — `git worktree remove` — so that |
| 644 | `git worktree list` keeps saying something true. |
| 645 | |
| 646 | Written down because of 2026-09-17, in `meet`. The shared checkout was |
| 647 | sitting on one task's branch while holding a *different* task's feature |
| 648 | uncommitted on disk — some four hundred lines, a new migration among |
| 649 | them — put there by a session nobody else knew was working in that tree. |
| 650 | Two other sessions were meanwhile coordinating over the same checkout by |
| 651 | asking each other not to touch it, which is the arrangement a worktree |
| 652 | makes unnecessary. Nothing was lost. Nothing about the arrangement would |
| 653 | have said so if it had been, and by then the same feature existed twice, |
| 654 | once on each side of a rename, because the two sessions had branched |
| 655 | from different starting points. |
| 656 | |