oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1# AGENTS.md
2
3Guidance for AI coding agents working in this repository. This is the
4canonical agent-instructions file; `CLAUDE.md` points here and duplicates
5nothing, because two copies drift and the drift is silent.
6
7Slides is a presentation editor for the Oficina suite: a deck that looks
8like a Keynote deck on a desktop, reads like a document on a phone,
9presents from a laptop and is driven from a phone. One instance per team
10on the CARLOS platform, at `slides.oficina.works` for the first team.
11It 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
14made over there, this repo consumes it rather than restating it.
15
16Design: `docs/superpowers/specs/2026-09-07-slides-design.md` (revision 1,
17still unreviewed — §12 puts the review pass after slice 1 ships, so the
18reviewer gets a running instance instead of a document; ruling 12 was
19added 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
22touch. Read rastrillo's own `SKILL.md` (repo root, or in the module
23cache) before writing handlers or models — it is the whole app story in
24about 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
29the only truth. The viewer renders it, the phone reflows it, the presenter
30shows it, the drive API patches it, and the editor edits that same tree
31through the same operations — its stage is the server's own HTML for the
32slide, not a second rendering of it. There is never a desktop version and
33a mobile version of a deck, only one deck and a viewport.
34
35Everything else follows. `flow` and `canvas` are two ways of placing the
36same blocks, not two kinds of slide (§2.3). The phone overrides are entries
37in the tree, not a second file (§2.5). The drive API is the inspector's
38operations with a JSON body (§7). If a change you are making needs a second
39representation of a deck, you have almost certainly taken a wrong turn.
40
41## What is actually built
42
43Slices 1, 2 and 3. A deck is edited in the browser at `/d/{sqid}/edit`,
44driven through the API (`docs/api.md`), or arrives as the sample deck
45seeded 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
123Not built, and don't write around their absence: charts from Sheets,
124video and embeds, an MCP adapter, aspect ratios other than 16:9,
125comments, 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
127agent both.
128
129**The deployment has no object store bound today** (`carlos store create`
130is 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
132than 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`
138plus `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
143package 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
146embedded faces and their metrics), `pdfcore` (the PDF module's wazero
147host), `present` (the presentation SSE hub), `live` (the deck's own
148hub: seqs and presence). `internal/slides/static/` holds the vendored
149stylesheets and the committed editor bundle. `core/` at the repo root is
150the Rust crate. `manifest/` and `gen/` are rastrillo's declarative path,
151unused here.
152
153`core/pdf/` is a second Rust crate beside the CRDT core: `slides_pdf.wasm`
154wraps `krilla` and `krilla-svg` and takes one SVG per page plus the
155Liberation TTFs, and writes one PDF with the text as subset, embedded
156glyphs. It wrapped `svg2pdf` until 2026-09-09; that repository was
157archived on 2026-07-10 naming krilla as its successor, and the swap kept
158the ABI byte-identical (spec §1 ruling 13). `internal/slides/pdfcore`
159hosts it under wazero with the same containment as `core` but a simpler
160shape — a fresh module instance per `Render`, discarded afterwards, under
161a sixty-second job deadline rather than the CRDT host's one-second call
162deadline. `internal/slides/export.go` is the pipeline over it
163(`layout.Layout` → `svg.Encode` with every image inlined → `Render`),
164including the notes pages, which are paint lists typeset here with the
165fonts package. `scripts/build-pdf-wasm.sh` builds the module; the
166artifact is committed at `internal/slides/pdfcore/testdata/slides_pdf.wasm`
167and 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
169screens, the job, and the bytes.
170
171Both wasm hosts cap the memory one instance may take — 512 MiB for
172`pdfcore`, 256 MiB for `core` (`defaultMemoryPages` in each) — because
173both run a parser over bytes a deck author controls on an instance
174shared with everything else. Past the cap the guest's allocator aborts
175and 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
179keeps 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
186A handful of functions carry most of the app's invariants. Go through them
187rather than around them; a new handler that reaches past one is the usual
188way a rule stops holding.
189
190`currentUser(w, r)` (`auth.go`) maps `sessions.Current(r).Subject` — an
191email address under rastrillo/auth, never a user id — to the `users` row,
192making it on first sign-in. It is also **the admission recheck**: an
193address no longer on `SLIDES_ALLOWED_ADDRESSES` is signed out here
194(`offboard` — every session row for it deleted, live talks ended, share
195links left alone) and told so on the sign-in page, which is why it takes
196a ResponseWriter and answers `sessionOffboarded` rather than a bare
197false. The bearer middleware does the same for an agent's operator.
198Without both, taking an address off the list removed nothing that address
199already 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
201middleware produces `agent:<id>` the same way, and a presentation remote
202produces `remote:<session>`, the one deliberately anonymous actor (§6.2).
203
204`visibleDecks(ctx, viewer)` is the read scope: owner, team rung, or a
205grant. Its `OR` chain is parenthesised inside the string on purpose —
206every caller chains another `Where` on, and an unparenthesised `OR` widens
207it to "or this deck is on the team rung", defeating the whole seam by
208operator precedence. `deckFor` is a deck the person may read; `ownedDeckFor`
209is one they own. Both answer **404, never 403**, so a stranger holding a
210sqid 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,
214and the JSON view that produces. The handle is the caller's to close. A
215snapshot is derived data and never the truth (`snapshot.go`) — the log
216alone still is the deck, so a missing or unreadable snapshot means a slower
217open and nothing else.
218
219`applyOps` and `appendUpdate` are the two write paths into a document, and
220the only two. Each puts the update row, the `seq` bump and any deck-row
221columns in one transaction with the seq as an optimistic lock, so the row
222and the log commit or roll back together, then publishes the new seq and
223takes a snapshot if it crossed the boundary. They differ in one line and in
224what a failure of it means: `applyOps` (`editDeck` wraps it with the
225handle's life and one retry on `errDeckBusy`) hands the core an op batch
226this app wrote, so a refusal is a bug; `appendUpdate` (`live.go`) hands it
227bytes 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
230transaction 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
244The projection alone is not enough and that is the whole point: the view
245is deliberately tolerant (`core/src/view.rs`, mirrored in
246`fixtures/view.mjs`) and reads straight past an unknown mark. Bytes off
247the wire can put any shape at any key, and what these two keep out of
248the log is what would otherwise brick every `openDeck` after it, or
249quietly 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
254because there is one idea. **Both ordered arrays in the document — the
255deck'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
257index, array index breaking ties (spec §2.1–2.3, rulings 18 and 21,
258codex 4). A reorder writes that key and never moves an array entry,
259because moving one means deleting the map and inserting a copy, and
260every edit a peer had in flight against the original — a sentence being
261typed into a block, or into any block of the slide — is then addressing
262a 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
265expose it**, so `deck.go`, `render/`, `layout/`, the viewer, the
266presenter and the exporter all read one sequence and none of them knows
267the key is there — which is also why adding a reader is not a reason to
268teach it about `order`.
269
270`render.Slide(d, index, viewport)` is the only place slide HTML is
271produced, for every surface — the viewer, the presenter, the API's
272`.html` twin, the editor's stage and its thumbnails. It builds markup by
273hand rather than templating it, because `html/template` cannot take a
274dynamic tag or attribute name; read the package doc comment for the four
275invariants that make that safe before adding a block type.
276
277`render.SlideWith(..., render.Options{AssetBase})` is that function when
278the slide has pictures on it, and **every real surface has to pass its
279own base**: `/d/{sqid}/assets` signed in, `/s/{token}/assets` over a share
280link, `/p/{session}/assets` while presenting. One slide is rendered on
281surfaces authenticated four different ways, and the `<img>` has to be
282fetchable by whoever is looking at it. `Slide` stays for callers that have
283no pictures to worry about (a test, a fixture) and falls back to
284`render.DefaultAssetBase`, which is deliberately a path nothing is mounted
285at: forget to say which surface you are and you get visibly broken images
286rather than a URL that quietly works from the wrong place. Note also that
287the `<img src>` carries the asset's **ref**, never its id — the id is the
288deck's own name for it and stays out of URLs.
289
290`serveAsset` (`assets.go`) is the one body behind all three of those
291routes, and the check inside it is the security property, not a
292formality: a ref is a content address, so knowing one is knowing the
293SHA-256 of somebody's file. It resolves the deck and confirms *this*
294deck's `assets` map refers to *this* ref before it serves a byte;
295without that, any capability for any deck would fetch any object in the
296bucket. It costs an `openDeck` per image request, which is known and
297noted rather than forgotten. It also requires a `deck_assets` row for
298the pair, written only by `registerAsset` after it read, sniffed and
299sanitised the bytes: `register_asset` is an op any member can apply with
300any ref, and a presigned PUT lands whatever the browser sent, so the map
301alone would let an unverified object be served.
302
303`layout.Layout(d, index)` is the other renderer, and the rule about it
304is the one in the plan: **the paint list is the only input to SVG and to
305PDF.** Nothing under `svg/`, `pdfcore/` or `export.go` reads a deck.
306Layout measures with the same embedded Liberation faces the browser sets
307the slide in (`fonts/`, served as `@font-face` from `static/fonts/` on
308every slide surface), so the two agree on everything but where a line
309breaks. Every warning it makes carries the slide it is about
310(`layout.Warning.Slide`), because the export's status page lists the
311warnings of a whole deck and a line that cannot name its slide is a line
312nobody 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
317are worth knowing before touching it. The PDF module is compiled once
318per app, lazily, and closed with the app (`pdfRuntime`), because
319compiling it costs the best part of a second and most processes never
320export. And **with no object store bound the finished PDF is held in
321memory for an hour**, capped at 50 MiB per deck and 200 MiB per instance:
322over either cap the job fails with a sentence the person can act on
323rather than the instance failing with an OOM nobody can read. When a
324store IS bound the same job uploads it and drops the held bytes, and the
325download 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
329are whole state, never deltas, so a subscriber that falls behind is dropped
330and reconnects rather than having events skipped — a skipped event leaves a
331projector 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
334advisory ("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.
336Presence lives there and nowhere else — it is not document state, so it
337never reaches the log, is never snapshotted, and is gone with the process.
338
339## The wire the editor speaks
340
341Docs' live-sync design, adopted as written (spec §3.3). Four routes, all
342in 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
345update after seq N, each framed as a 4-byte big-endian length then the
346bytes, in seq order. `Slides-Seq` is the highest seq in the body, or N
347when the body is empty — derived from the bytes in hand, never from a
348second `MAX(seq)` query, which could see an update this response does not
349carry and leave the client holding a cursor for bytes it never received.
350At `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
354through `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
356event: 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:
360presence` with the whole set as JSON, `: ping` every 15 s, one of each on
361connect, 5 minute TTL. Whole state rather than deltas, so a subscriber
362that falls behind is dropped and reconnects.
363
364`POST /d/{sqid}/presence` takes `{client, slide, block}` every 20 s and on
365change. **The server stamps the name and the colour** from the `users`
366row; a client that sends its own is ignored, or presence becomes a place
367to write somebody else's name. Two missed heartbeats and the sweeper
368drops it.
369
370## The fixtures rule
371
372`fixtures/ops.mjs` and `core/src/ops.rs` are two implementations of one op
373vocabulary (spec §7.2), because the browser writes the document the core
374reads. `fixtures/data/*.json` are op batches; `node fixtures/generate.mjs`
375runs each through the JS side and writes `vectors/<name>.yjs` (the Y
376update) and `vectors/<name>.view.json` (the JS view of it).
377`internal/slides/core/vectors_test.go` loads the same bytes through the
378core and requires the same view, and also runs the same op batch through
379the core's own `Ops` and compares again. `generate.mjs` also runs
380`checkDoc` over every vector before it writes one, so a vector that
381would not pass the write gate never gets committed in the first place.
382
383So: **an op changes on both sides or on neither.** Regenerate in the same
384commit as the change. Never edit a vector to make a test pass — a vector
385that was quietly adjusted leaves the Go test comparing the core against a
386JS implementation that no longer exists, and it passes while the two sides
387drift. The gate regenerates and diffs.
388
389It 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
392mark 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
395attribute's name, and a plain-string `href` throws inside `schema.mark` —
396and y-prosemirror deletes the `XmlText` when a read throws, which loses
397the text and not just the link. Naming the marks after the attributes
398makes the mapping the identity and keeps mapping code out of the editor.
399The writer is exact; both readers are tolerant, taking any truthy value
400for `b`/`i`/`c`/`h` (y-prosemirror writes `{}`) and either the map or a
401plain string for `href` (documents written before the ruling).
402
403## How the editor is put together
404
405Read this before changing anything under `fixtures/`.
406
407The browser is not a second renderer. The stage shows the server's own
408HTML for the current slide, fetched from
409`GET /d/{sqid}/slides/{id}.html?vp=desktop|phone|thumb`, and the client
410binds a ProseMirror `EditorView` into each text block already in it. After
411any structural change lands, the slide is re-fetched. Thumbnails come from
412the server too. If you find yourself building slide markup in JS, stop.
413
414Structural edits go through `fixtures/ops.mjs`'s `applyOps` — the same
415vocabulary the API takes — inside one Y transaction, and reach the server
416as ordinary Yjs updates through the save queue. Undo is a single
417`Y.UndoManager` over the three roots with this client's origins tracked,
418driven directly rather than through `yUndoPlugin` (whose view hook
419destroys a manager shared by many views the moment the first view goes).
420`commit()` calls `stopCapturing()` first, so each batch is its own undo
421step.
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
425does not: `canvas.mjs` (drag, resize and snapping are one concern and
426run to about 320), which is noted rather than split. `live-updates.mjs`,
427`save-queue.mjs` and `resync-controller.mjs` are Docs' modules copied
428verbatim with a header saying so — this is the second implementation, and
429extracting them to something shared is slice 4's problem, not yours.
430
431The bundle is built by hand and committed:
432
433```sh
434cd 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
439build that stopped emitting one, or a merge that left two, refuses to boot
440rather 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`
529that starts empty every run. Sign in with any address; the magic link is
530logged to the terminal instead of being mailed, and it is a real link
531through the real flow.
532
533Development mode is the only place four settings default — a fixed public
534instance key, mail to the terminal, an allowlist that admits everyone, and
535a directory-backed object store — and it can only be selected by the `-env
536development` flag. `$SLIDES_ENV` cannot reach it, deliberately: platform
537config *is* environment variables, so a stray `SLIDES_ENV=development`
538beside the real ones would otherwise be a production-reachable path to a
539public instance key and to every magic link landing in the instance log.
540Anywhere but development, a missing `SLIDES_ORIGIN`,
541`SLIDES_INSTANCE_KEY`, `SLIDES_SMTP_*` or `SLIDES_ALLOWED_ADDRESSES` is a
542startup 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
547to the amadan remote, and it is the same script you run locally:
548
549```sh
550./.amadan/ci.d/10-gate
551```
552
553It does rustfmt, clippy with the disallowed-methods lint, the wasm build,
554the `cmp` against the committed artifact, the Rust tests; then the JS half
555— `npm ci`, `node --test`, the vectors regenerated and the bundle rebuilt
556and both diffed against what is committed; then `make ci` (vet, gofmt, Go
557tests, migration check). `make ci` alone is the fast loop between edits;
558the full gate is what you run before pushing.
559
560Since slice 2 it needs Node 20+ and `npm`, and `npm ci` wants the registry
561reachable while it runs — the one part of this gate that is not offline
562once its caches are warm. `.amadan/README.md` says why that trade is worth
563making, what each step catches, and the runner's own two traps. In a
564sandbox, point `NPM_CACHE` somewhere writable or npm fails on its cache
565rather than on the install.
566
567## Deploying
568
569One static binary, stamped: `make release` (refuses an empty version or a
570dirty tree — a `-dirty` stamp names a commit plus changes nobody can name,
571so two different binaries can carry the same string). Then `carlos deploy
572--app slides --account oficina`, with `carlos store create` for the object
573store and `carlos domains attach slides.oficina.works`.
574`SLIDES_INSTANCE_KEY` and `SLIDES_SMTP_PASS` go in via `carlos secrets`,
575the rest via `carlos env`, mirroring Docs' set (spec §6.3).
576
577Verify against `x-carlos-version` on the real host or let `carlos deploy`
578watch the URL. `carlos channels` reports which release a channel *points
579at*, which is not the question of what the box is *serving* — adoption runs
580on 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
585This repo is hosted on an amadan hub, not GitHub: no `gh`, no pull request,
586no GitHub Actions. Post as the agent account (`<name>~<operator keymail>`),
587never as Paul. Work happens on a worktree branch and reaches `main` only by
588squash 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
590nothing ran, and neither is green.
591
592**Every branch gets a discussion, opened before the merge, and it is the
593review record.** A squash merge flattens the history that carried the
594branch's reasoning; the discussion is where that survives. If it does not
595exist before the merge, the reasoning is not scattered, it is gone.
596
597## UX conventions (seeded from profile: considered)
598
599Accessible by construction, progressively enhanced, and never dependent on
600JavaScript 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
612equivalent. `[ ]` is guidance to apply by hand. A convention absent from
613this list is one rastrillo has no opinion about.
614
615Edit any line. **This file is the source of truth, not the profile name.**
616Nothing re-reads the profile, so changing a line here changes what the app
617does, and a convention deleted from this file is not one to reinstate.
618
619## Work in a worktree, and catch main up first
620
621Take 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
626Both halves matter, for different reasons.
627
628**The worktree**, because several sessions share this machine and some
629share this repository, and a checkout has one HEAD, one index and one
630working tree between all of them. A branch switch under somebody else's
631uncommitted work destroys it with nothing on screen to say so, and two
632sessions editing the same paths disagree quietly instead of conflicting
633loudly. A worktree gives the work its own HEAD and its own files, and
634costs a directory.
635
636**From `origin/main`, after a fetch** — not from whatever the checkout
637happens to be sitting on. A branch cut from a stale main is a merge
638conflict scheduled for later, against files somebody else has renamed in
639the meantime. A branch cut from another task's branch is worse: it
640carries that task's commits into yours, where they are reviewed as
641though you wrote them, and the two can only be separated by hand.
642
643Remove it when the work has landed — `git worktree remove` — so that
644`git worktree list` keeps saying something true.
645
646Written down because of 2026-09-17, in `meet`. The shared checkout was
647sitting on one task's branch while holding a *different* task's feature
648uncommitted on disk — some four hundred lines, a new migration among
649them — put there by a session nobody else knew was working in that tree.
650Two other sessions were meanwhile coordinating over the same checkout by
651asking each other not to touch it, which is the arrangement a worktree
652makes unnecessary. Nothing was lost. Nothing about the arrangement would
653have said so if it had been, and by then the same feature existed twice,
654once on each side of a rename, because the two sessions had branched
655from different starting points.
656