# AGENTS.md

Guidance for AI coding agents working in this repository. This is the
canonical agent-instructions file; `CLAUDE.md` points here and duplicates
nothing, because two copies drift and the drift is silent.

Slides is a presentation editor for the Oficina suite: a deck that looks
like a Keynote deck on a desktop, reads like a document on a phone,
presents from a laptop and is driven from a phone. One instance per team
on the CARLOS platform, at `slides.oficina.works` for the first team.
It is built beside [Docs](https://amadan.net/oficina/docs),
[Sheets](https://amadan.net/oficina/sheets) and the suite
[doctrine](https://amadan.net/oficina/suite); where a decision is already
made over there, this repo consumes it rather than restating it.

Design: `docs/superpowers/specs/2026-09-07-slides-design.md` (revision 1,
still unreviewed — §12 puts the review pass after slice 1 ships, so the
reviewer gets a running instance instead of a document; ruling 12 was
added during slice 2 and §2.6 rewritten with it). Plans:
`docs/superpowers/plans/2026-09-07-slice-1-live.md` and
`2026-09-07-slice-2-editor.md`. Read the spec section behind whatever you
touch. Read rastrillo's own `SKILL.md` (repo root, or in the module
cache) before writing handlers or models — it is the whole app story in
about 15KB, and you should assume none of it is in your training data.

## The one rule

**A slide is one block tree, rendered many ways** (spec §0.1). The tree is
the only truth. The viewer renders it, the phone reflows it, the presenter
shows it, the drive API patches it, and the editor edits that same tree
through the same operations — its stage is the server's own HTML for the
slide, not a second rendering of it. There is never a desktop version and
a mobile version of a deck, only one deck and a viewport.

Everything else follows. `flow` and `canvas` are two ways of placing the
same blocks, not two kinds of slide (§2.3). The phone overrides are entries
in the tree, not a second file (§2.5). The drive API is the inspector's
operations with a JSON body (§7). If a change you are making needs a second
representation of a deck, you have almost certainly taken a wrong turn.

## What is actually built

Slices 1, 2 and 3. A deck is edited in the browser at `/d/{sqid}/edit`,
driven through the API (`docs/api.md`), or arrives as the sample deck
seeded on first sign-in — and comes back out as a PDF.

- **Sign-in** (`internal/slides/auth.go`) is Keymail for an address
  whose domain runs Keymail and an emailed magic link for every other
  address — decided by the domain alone, never by
  `SLIDES_ALLOWED_ADDRESSES`, or the endpoint becomes a membership
  oracle. A Keymail address, permitted or not, gets the continuation
  page to Keymail (never a 303 out of the form POST: Safari applies
  `form-action` to it); any other gets `303 ?sent=1`, and only a
  permitted one is mailed, by a worker after the response (`mailQueue`), so not even the response time differs. Admission is
  rastrillo/auth's `Authorize`, after verification, and its refusal is
  the door, rendered in the refusal's own 403 by auth's `Refused` hook
  (`refused`; never a query anyone could type), not rastrillo's bare
  text. An emailed link opens on a confirm step (`renderConfirm`, auth's
  `RenderConfirm`): the door with one Sign in button, which POSTs the
  token back to `/auth/verify` — the GET spends nothing, so a mail
  scanner fetching the link cannot burn it. Both methods of
  `/auth/verify` are mounted in both modes. `?force=1&err=keymail` (a failed Keymail exchange) makes the
  form ask for a link instead. The list is also **rechecked on
  every request** that resolves a principal, so taking an address off it
  signs that person out of every device and refuses their agents' tokens
  rather than leaving them everything they already held (spec §6.1,
  ruling 16, codex 6).
- **Decks**: list, create, settings, delete, three rungs (private,
  granted, team) per suite ruling 15.
- **The viewer** (`internal/slides/viewer.go`): one slide per page,
  previous and next as ordinary links, notes in a `<details>`. The owner,
  a team member and a stranger holding a share link all get the same page
  from the same template with `mode` as a field, because the day those
  three drift is the day one of them leaks the speaker notes.
- **Share links** (`share.go`): a read-only capability, SHA-256 stored,
  URL shown once, revocable. A missing token and a revoked one 404 alike.
- **Presenting** (`presentation.go`, `present/hub.go`): a server-side
  session with a pairing code, the fullscreen surface, the presenter view
  with notes and a clock, the phone remote, and audience follow from a
  share link — which **stays on** `/s/{token}` routes, stream included,
  so revoking the link ends the follow. The session sqid is a projector
  address and is never rendered onto a follow page (spec §3.4, codex 5).
- **The editor** (`editor.go`, `templates/editor.html`,
  `static/editor.css`, and the committed bundle built from `fixtures/`):
  the strip of server-rendered thumbnails, the stage, the inspector for
  the deck, the slide and the selected block, text edited in place through
  ProseMirror bound to the server's own HTML, canvas drag and resize with
  snapping, the desktop/phone switcher whose phone-view edits become
  `set_phone` overrides, undo scoped to the person who made the change,
  presence, and image upload when a store is bound.
- **Live sync** (`live.go`, `live/hub.go`, `snapshot.go`): the update log
  served by cursor, an SSE stream of advisory seqs and whole presence
  sets, and a snapshot every 200 updates so opening a long-lived deck is
  not a full replay.
- **Assets** (`assets.go`, `svg.go`): presigned PUT browser-to-bucket
  into a **staging key this app mints** (never an address the client
  named), registration that resolves the upload receipt for it, re-reads
  and sniffs what actually landed, sanitises an SVG, and **promotes the
  bytes to a final object keyed by their own hash** — then three
  capability-checked routes serving them back. The receipt is the
  `asset_uploads` row (`models.go`, migration
  `0008_create_asset_uploads.sql`): one deck, one actor, single use, two
  hours. A ref alone registers nothing, and no PUT is ever signed for an
  address something is already served from (spec §2.7, codex 1 and 2).
- **The layout engine and thumbnails** (`layout/`, `svg/`, `fonts/`,
  `thumbs.go`): every slide measured into a paint list with the same
  embedded Liberation faces the browser sets it in, written as an SVG,
  and served as `{surface}/slides/{id}.svg` from all four capabilities.
  The editor strip, the presenter's next-slide and the remote are that
  picture.
- **PDF export** (`export.go`, `exports.go`, `pdfcore/`, `core/pdf/`):
  `POST /d/{sqid}/export` starts a rastrillo job, the status page polls
  itself (and works with scripts off), and the file comes back from
  `GET /d/{sqid}/export/{job}.pdf`. One page per slide, optionally the
  speaker notes as pages of their own, text as embedded glyphs, images
  inlined. The API twins are `POST /api/v1/decks/{sqid}/export` and its
  two GETs. The file is answerable for an hour and **the status page
  says so for the whole hour**: rastrillo/jobs forgets a finished job
  after ten minutes, so the page, its fragment and the API's status all
  fall back to this app's own held record (`exportFallback`) rather than
  404ing while `.pdf` at the same address still works.

Not built, and don't write around their absence: charts from Sheets,
video and embeds, an MCP adapter, aspect ratios other than 16:9,
comments, and version history. Those are the triggers in spec §9.
`docs/api.md` is the reference for the API, written for a person and an
agent both.

**The deployment has no object store bound today** (`carlos store create`
is the fix, and nobody has run it). The upload endpoints answer
`503` there and the editor's image field says so, server-rendered, rather
than letting somebody pick a file and find out afterwards.

## Layout

`internal/slides/` is the app in SKILL.md's five-file shape — `models.go`,
`migrations.go` plus `migrations/`, `app.go`, `handlers.go`, `render.go`
plus `templates/` — with one file per screen area beside them (`auth.go`,
`decks.go`, `viewer.go`, `share.go`, `presentation.go`, `sample.go`,
`live.go`, `editor.go`, `snapshot.go`, `assets.go`, `svg.go`,
`thumbs.go`, `export.go`, `exports.go`, `api.go`, `agents.go`,
`assets_embed.go`) and one
package per concern nested underneath: `core` (the wazero host), `deck`
(Go types for the core's JSON view), `render` (slide HTML), `layout`
(the paint list), `svg` (the paint list as a picture), `fonts` (the
embedded faces and their metrics), `pdfcore` (the PDF module's wazero
host), `present` (the presentation SSE hub), `live` (the deck's own
hub: seqs and presence). `internal/slides/static/` holds the vendored
stylesheets and the committed editor bundle. `core/` at the repo root is
the Rust crate. `manifest/` and `gen/` are rastrillo's declarative path,
unused here.

`core/pdf/` is a second Rust crate beside the CRDT core: `slides_pdf.wasm`
wraps `krilla` and `krilla-svg` and takes one SVG per page plus the
Liberation TTFs, and writes one PDF with the text as subset, embedded
glyphs. It wrapped `svg2pdf` until 2026-09-09; that repository was
archived on 2026-07-10 naming krilla as its successor, and the swap kept
the ABI byte-identical (spec §1 ruling 13). `internal/slides/pdfcore`
hosts it under wazero with the same containment as `core` but a simpler
shape — a fresh module instance per `Render`, discarded afterwards, under
a sixty-second job deadline rather than the CRDT host's one-second call
deadline. `internal/slides/export.go` is the pipeline over it
(`layout.Layout` → `svg.Encode` with every image inlined → `Render`),
including the notes pages, which are paint lists typeset here with the
fonts package. `scripts/build-pdf-wasm.sh` builds the module; the
artifact is committed at `internal/slides/pdfcore/testdata/slides_pdf.wasm`
and the gate rebuilds and `cmp`s it exactly as it does the CRDT core's.
`internal/slides/exports.go` is everything around that pipeline: the
screens, the job, and the bytes.

Both wasm hosts cap the memory one instance may take — 512 MiB for
`pdfcore`, 256 MiB for `core` (`defaultMemoryPages` in each) — because
both run a parser over bytes a deck author controls on an instance
shared with everything else. Past the cap the guest's allocator aborts
and the host reports an ordinary error, which is what the two
`…PastTheMemoryLimitIsAnErrorNotACrash` tests hold.

`fixtures/` at the repo root is the browser half and the contract that
keeps it honest: the editor's ES modules, their `node --test` tests,
`build-bundles.mjs` (esbuild, output committed), and `generate.mjs` with
`data/` and `vectors/`. It is a Node package with a committed lockfile;
`node_modules/` is not.

## The seams

A handful of functions carry most of the app's invariants. Go through them
rather than around them; a new handler that reaches past one is the usual
way a rule stops holding.

`currentUser(w, r)` (`auth.go`) maps `sessions.Current(r).Subject` — an
email address under rastrillo/auth, never a user id — to the `users` row,
making it on first sign-in. It is also **the admission recheck**: an
address no longer on `SLIDES_ALLOWED_ADDRESSES` is signed out here
(`offboard` — every session row for it deleted, live talks ended, share
links left alone) and told so on the sign-in page, which is why it takes
a ResponseWriter and answers `sessionOffboarded` rather than a bare
false. The bearer middleware does the same for an agent's operator.
Without both, taking an address off the list removed nothing that address
already held (spec §6.1, codex 6). `actorFor(u)` turns the row into the
`person:<id>` string every write records (§6.2); the API's bearer
middleware produces `agent:<id>` the same way, and a presentation remote
produces `remote:<session>`, the one deliberately anonymous actor (§6.2).

`visibleDecks(ctx, viewer)` is the read scope: owner, team rung, or a
grant. Its `OR` chain is parenthesised inside the string on purpose —
every caller chains another `Where` on, and an unparenthesised `OR` widens
it to "or this deck is on the team rung", defeating the whole seam by
operator precedence. `deckFor` is a deck the person may read; `ownedDeckFor`
is one they own. Both answer **404, never 403**, so a stranger holding a
sqid cannot learn whether it is real.

`openDeck` instantiates the Y.Doc: a fresh wasm module, the newest
`deck_snapshots` row applied, every update after it replayed in seq order,
and the JSON view that produces. The handle is the caller's to close. A
snapshot is derived data and never the truth (`snapshot.go`) — the log
alone still is the deck, so a missing or unreadable snapshot means a slower
open and nothing else.

`applyOps` and `appendUpdate` are the two write paths into a document, and
the only two. Each puts the update row, the `seq` bump and any deck-row
columns in one transaction with the seq as an optimistic lock, so the row
and the log commit or roll back together, then publishes the new seq and
takes a snapshot if it crossed the boundary. They differ in one line and in
what a failure of it means: `applyOps` (`editDeck` wraps it with the
handle's life and one retry on `errDeckBusy`) hands the core an op batch
this app wrote, so a refusal is a bug; `appendUpdate` (`live.go`) hands it
bytes off the wire, so a refusal is ordinary and comes back as a 422.

"Refusal" there is **two** checks on the fresh handle, both before the
transaction opens, and they look at different things:

- `Handle.Check(ctx)` — the core's `slides_core_check` (ABI 2,
  `core/src/check.rs`, mirrored in `fixtures/check.mjs`) — walks the
  **document**: every notes fragment, every block `text`, every table
  cell, and refuses 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). This is the gate that has to exist,
  because y-prosemirror 1.3.7 *deletes* an `XmlText` whose attributes it
  cannot map to a mark: the words are lost in the browser while the
  server sees nothing wrong (spec ruling 19, codex 3).
- `deck.Parse` on the JSON view — the **projection** — which is what
  keeps a shape the host's typed structs cannot read out of the log.

The projection alone is not enough and that is the whole point: the view
is deliberately tolerant (`core/src/view.rs`, mirrored in
`fixtures/view.mjs`) and reads straight past an unknown mark. Bytes off
the wire can put any shape at any key, and what these two keep out of
the log is what would otherwise brick every `openDeck` after it, or
quietly eat somebody's sentence. Nothing else may append to
`deck_updates`.

`schema::ordered_maps` (`core/src/schema.rs`) and `orderedMaps`
(`fixtures/ydoc.mjs`) are the reading order, and there is one of them
because there is one idea. **Both ordered arrays in the document — the
deck's `slides` and a slide's `blocks` — are SETS**, and the order is an
`order` key on each map in them: a number, absent ranking at the array
index, array index breaking ties (spec §2.1–2.3, rulings 18 and 21,
codex 4). A reorder writes that key and never moves an array entry,
because moving one means deleting the map and inserting a copy, and
every edit a peer had in flight against the original — a sentence being
typed into a block, or into any block of the slide — is then addressing
a deleted type and is lost on merge. `move_slide`, `move_block`,
`add_*`, `duplicate_slide` and canvas-to-flow all go through
`place_entry`/`placeEntry`. **The view sorts by the key and does not
expose it**, so `deck.go`, `render/`, `layout/`, the viewer, the
presenter and the exporter all read one sequence and none of them knows
the key is there — which is also why adding a reader is not a reason to
teach it about `order`.

`render.Slide(d, index, viewport)` is the only place slide HTML is
produced, for every surface — the viewer, the presenter, the API's
`.html` twin, the editor's stage and its thumbnails. It builds markup by
hand rather than templating it, because `html/template` cannot take a
dynamic tag or attribute name; read the package doc comment for the four
invariants that make that safe before adding a block type.

`render.SlideWith(..., render.Options{AssetBase})` is that function when
the slide has pictures on it, and **every real surface has to pass its
own base**: `/d/{sqid}/assets` signed in, `/s/{token}/assets` over a share
link, `/p/{session}/assets` while presenting. One slide is rendered on
surfaces authenticated four different ways, and the `<img>` has to be
fetchable by whoever is looking at it. `Slide` stays for callers that have
no pictures to worry about (a test, a fixture) and falls back to
`render.DefaultAssetBase`, which is deliberately a path nothing is mounted
at: forget to say which surface you are and you get visibly broken images
rather than a URL that quietly works from the wrong place. Note also that
the `<img src>` carries the asset's **ref**, never its id — the id is the
deck's own name for it and stays out of URLs.

`serveAsset` (`assets.go`) is the one body behind all three of those
routes, and the check inside it is the security property, not a
formality: a ref is a content address, so knowing one is knowing the
SHA-256 of somebody's file. It resolves the deck and confirms *this*
deck's `assets` map refers to *this* ref before it serves a byte;
without that, any capability for any deck would fetch any object in the
bucket. It costs an `openDeck` per image request, which is known and
noted rather than forgotten. It also requires a `deck_assets` row for
the pair, written only by `registerAsset` after it read, sniffed and
sanitised the bytes: `register_asset` is an op any member can apply with
any ref, and a presigned PUT lands whatever the browser sent, so the map
alone would let an unverified object be served.

`layout.Layout(d, index)` is the other renderer, and the rule about it
is the one in the plan: **the paint list is the only input to SVG and to
PDF.** Nothing under `svg/`, `pdfcore/` or `export.go` reads a deck.
Layout measures with the same embedded Liberation faces the browser sets
the slide in (`fonts/`, served as `@font-face` from `static/fonts/` on
every slide surface), so the two agree on everything but where a line
breaks. Every warning it makes carries the slide it is about
(`layout.Warning.Slide`), because the export's status page lists the
warnings of a whole deck and a line that cannot name its slide is a line
nobody can act on.

`Export(ctx, view, ExportOptions)` (`export.go`) is that pipeline —
`layout.Layout` → `svg.Encode` with every image inlined → the module's
`Render` — and `exports.go` is everything around it. Two things there
are worth knowing before touching it. The PDF module is compiled once
per app, lazily, and closed with the app (`pdfRuntime`), because
compiling it costs the best part of a second and most processes never
export. And **with no object store bound the finished PDF is held in
memory for an hour**, capped at 50 MiB per deck and 200 MiB per instance:
over either cap the job fails with a sentence the person can act on
rather than the instance failing with an OOM nobody can read. When a
store IS bound the same job uploads it and drops the held bytes, and the
download route 302s to a presigned GET. Both paths are in one handler
(`serveExport`) so neither can rot.

`present.Hub` is the in-memory fan-out for a live presentation. Its events
are whole state, never deltas, so a subscriber that falls behind is dropped
and reconnects rather than having events skipped — a skipped event leaves a
projector showing slide 4 for the rest of a talk with nothing to notice it.

`live.Hub` is the same shape for a deck being edited: a seq, which is
advisory ("there is something at least this new" — the bytes come from
`GET /d/{sqid}/updates?since=`), and the whole presence set, which is not.
Presence lives there and nowhere else — it is not document state, so it
never reaches the log, is never snapshotted, and is gone with the process.

## The wire the editor speaks

Docs' live-sync design, adopted as written (spec §3.3). Four routes, all
in the signed-in group, all behind `deckFor`, all 404 for a stranger.

`GET /d/{sqid}/updates?since=N` answers `application/octet-stream`: every
update after seq N, each framed as a 4-byte big-endian length then the
bytes, in seq order. `Slides-Seq` is the highest seq in the body, or N
when the body is empty — derived from the bytes in hand, never from a
second `MAX(seq)` query, which could see an update this response does not
carry and leave the client holding a cursor for bytes it never received.
At `since=0` the answer may start from the newest snapshot, and
`Slides-Snapshot: <seq>` says when it did.

`POST /d/{sqid}/updates` takes one Yjs v1 update, 1 MiB cap. It goes
through `appendUpdate`, is stored with the poster's actor, and answers
`204` with `Slides-Seq`. A refusal from the core is a 422 and an ordinary
event: these are bytes off the wire, not a batch this app wrote.

`GET /d/{sqid}/stream` is SSE and carries no document bytes at all:
`event: update` with `data: <seq>` (advisory — go and fetch), `event:
presence` with the whole set as JSON, `: ping` every 15 s, one of each on
connect, 5 minute TTL. Whole state rather than deltas, so a subscriber
that falls behind is dropped and reconnects.

`POST /d/{sqid}/presence` takes `{client, slide, block}` every 20 s and on
change. **The server stamps the name and the colour** from the `users`
row; a client that sends its own is ignored, or presence becomes a place
to write somebody else's name. Two missed heartbeats and the sweeper
drops it.

## The fixtures rule

`fixtures/ops.mjs` and `core/src/ops.rs` are two implementations of one op
vocabulary (spec §7.2), because the browser writes the document the core
reads. `fixtures/data/*.json` are op batches; `node fixtures/generate.mjs`
runs each through the JS side and writes `vectors/<name>.yjs` (the Y
update) and `vectors/<name>.view.json` (the JS view of it).
`internal/slides/core/vectors_test.go` loads the same bytes through the
core and requires the same view, and also runs the same op batch through
the core's own `Ops` and compares again. `generate.mjs` also runs
`checkDoc` over every vector before it writes one, so a vector that
would not pass the write gate never gets committed in the first place.

So: **an op changes on both sides or on neither.** Regenerate in the same
commit as the change. Never edit a vector to make a test pass — a vector
that was quietly adjusted leaves the Go test comparing the core against a
JS implementation that no longer exists, and it passes while the two sides
drift. The gate regenerates and diffs.

It has already caught something real. **`href` is stored as the one-key map
`{"href": url}`, and the ProseMirror marks are named `b`, `i`, `c`, `h`,
`href`** — spec §1 ruling 12, §2.6. y-prosemirror 1.3.7 has no hook to map
mark names to Y attribute names: it writes
`attributes[mark.type.name] = mark.attrs` and reads
`schema.mark(attrName, attrValue)`. So the mark's name has to be the
attribute's name, and a plain-string `href` throws inside `schema.mark` —
and y-prosemirror deletes the `XmlText` when a read throws, which loses
the text and not just the link. Naming the marks after the attributes
makes the mapping the identity and keeps mapping code out of the editor.
The writer is exact; both readers are tolerant, taking any truthy value
for `b`/`i`/`c`/`h` (y-prosemirror writes `{}`) and either the map or a
plain string for `href` (documents written before the ruling).

## How the editor is put together

Read this before changing anything under `fixtures/`.

The browser is not a second renderer. The stage shows the server's own
HTML for the current slide, fetched from
`GET /d/{sqid}/slides/{id}.html?vp=desktop|phone|thumb`, and the client
binds a ProseMirror `EditorView` into each text block already in it. After
any structural change lands, the slide is re-fetched. Thumbnails come from
the server too. If you find yourself building slide markup in JS, stop.

Structural edits go through `fixtures/ops.mjs`'s `applyOps` — the same
vocabulary the API takes — inside one Y transaction, and reach the server
as ordinary Yjs updates through the save queue. Undo is a single
`Y.UndoManager` over the three roots with this client's origins tracked,
driven directly rather than through `yUndoPlugin` (whose view hook
destroys a manager shared by many views the moment the first view goes).
`commit()` calls `stopCapturing()` first, so each batch is its own undo
step.

`editor-entry.js` is wiring and nothing else; every other module under
`fixtures/` is one concern and is meant to stay under 300 lines — one
does not: `canvas.mjs` (drag, resize and snapping are one concern and
run to about 320), which is noted rather than split. `live-updates.mjs`,
`save-queue.mjs` and `resync-controller.mjs` are Docs' modules copied
verbatim with a header saying so — this is the second implementation, and
extracting them to something shared is slice 4's problem, not yours.

The bundle is built by hand and committed:

```sh
cd fixtures && npm ci && node build-bundles.mjs
```

`assets_embed.go` resolves `editor.<16 hex>.js` out of the embedded
`static/` at package init and **panics unless there is exactly one**, so a
build that stopped emitting one, or a merge that left two, refuses to boot
rather than serving an editor that disagrees with the server.

## Traps

- **Never call `sessions.UserID`.** Under rastrillo/auth the Subject is
  the email address, so it answers `(0, false)` and would scope every
  owned query to `user_id = 0`. Map the address to the row first.
- **Neither committed wasm is rebuilt for you.** `go:embed` will happily
  embed a stale `internal/slides/core/testdata/slides_core.wasm` and the
  Go tests will pass against it; the same is true of
  `internal/slides/pdfcore/testdata/slides_pdf.wasm`. **The gate
  rebuilds and `cmp`s both** (`scripts/build-wasm.sh` and
  `scripts/build-pdf-wasm.sh`); when it fails, rebuild and commit the
  artifact rather than arguing with it.
- **The core ABI is version 2 and the host's check is exact-match.** Add
  or change an export in `core/src/abi.rs` and you bump both
  `slides_core_abi_version()` in `core/src/lib.rs` and `abiVersion` in
  `internal/slides/core/core.go`, in the same commit, or the host
  refuses to instantiate the module — which is the point: it turns
  "unknown export" deep inside a request into a refusal at load time.
  Version 2 is where `slides_core_check` arrived (codex 3).
- **`Doc::new()` and `Doc::with_client_id()` are banned in the Rust core**
  (`clippy.toml`) — both draw entropy this wasm build has no safe source
  for. Use `Options::with_guid_and_client_id`. A doc comment alone was got
  around more than once, which is why it is a lint.
- **The committed editor bundle is not rebuilt for you either.** Change a
  `.mjs` under `fixtures/` and `internal/slides/static/editor.<hash>.js`
  is stale until you run `node fixtures/build-bundles.mjs` and commit what
  it writes. Nothing builds it at run time and CI does not build it for
  you — the gate rebuilds and diffs, which is a check, not a fix.
- **A stale vector passes.** `vectors_test.go` compares the core against
  whatever is committed under `fixtures/vectors`, so a vector nobody
  regenerated makes it compare the core against a JS implementation that
  no longer exists. Run `node fixtures/generate.mjs` in the same commit as
  any op change.
- **Exactly one directory under `internal/`.** `rastrillo migration check`
  requires it, which is why every package is nested under
  `internal/slides/` (`core`, `deck`, `render`, `layout`, `svg`, `fonts`,
  `pdfcore`, `present`, `live`) rather than sitting beside it.
- **A job outlives its request, so it cannot hold one.** The export's
  sentences — its name, its progress line, every failure it can report —
  are resolved from the catalog while the request is still here
  (`exportWords`) and carried into the goroutine. `rastrillo.T` reads the
  locale off a request; a job that called it would either be holding a
  request it must not hold or answering in the wrong language. The same
  goes for the context: use the job's, never the request's.
- **A hardcoded English string is a bug.** Every user-visible string is a
  key in `internal/slides/locales/en.toml`, resolved through `rastrillo.T`
  at render time. Twelve locales are the suite target; this slice ships
  `en` and leaves the keys ready. Deck titles and timestamps are data, not
  copy, and are correctly not keys.
- **Every screen but the editor works with scripts off** (spec §8) —
  including the remote, where each button is a form that POSTs and
  redirects back. The editor is the one stated exception, and it says so
  in a `<noscript>` that links the viewer. Everywhere else JavaScript is
  an enhancement and nothing depends on it.
- **Every user-facing string the bundle shows is still a catalog key.**
  It reaches the browser as a `data-text-*` attribute the template
  renders from `en.toml`; an English literal in a `.mjs` file is the same
  bug it would be in Go.
- **`cargo fmt --check` covers `core/`.** It was not in the gate until
  2026-09-07 and three files had drifted by then; they were reformatted in
  the same commit that added the check.

## Rules the family holds hard

- Every query touching user-owned rows goes through the seams above —
  reads and writes, transactions included.
- Never bind a request body onto a GORM model: explicit `map[string]any`
  plus a `.Select` allowlist.
- Migrations apply at boot, once each, and are never re-run or reversed.
  Change `models.go`, then run `rastrillo migration generate` and *read
  the SQL* — it may be a full table rebuild rather than an `ADD COLUMN`.
  Never edit a migration that has shipped. Renames are hand-written
  (`rastrillo migration new rename_x`), because a rename is
  indistinguishable from a drop plus an add.
- Every state-changing route is a POST behind `csrf.Protect`. Destructive
  actions get their own confirm-page URL, never a modal.
- Markup is the bare `rst-*` attribute grammar (`<a rst-btn="primary">`),
  never `class="rst-*"`. This app's own attributes are `slides-*`.
- WCAG 2.2 AA with no exemptions: 24×24 targets, 320px reflow, colour
  never the only carrier of state, `alt` on every image.
- `CGO_ENABLED=0` throughout. The stack is cgo-free by design and the
  binary that ships is `linux/arm64`.

## Running it locally

`./scripts/run-dev.sh` builds and runs the app on `localhost:8091`
(`$SLIDES_DEV_PORT` to move it) against a scratch database under `$TMPDIR`
that starts empty every run. Sign in with any address; the magic link is
logged to the terminal instead of being mailed, and it is a real link
through the real flow.

Development mode is the only place four settings default — a fixed public
instance key, mail to the terminal, an allowlist that admits everyone, and
a directory-backed object store — and it can only be selected by the `-env
development` flag. `$SLIDES_ENV` cannot reach it, deliberately: platform
config *is* environment variables, so a stray `SLIDES_ENV=development`
beside the real ones would otherwise be a production-reachable path to a
public instance key and to every magic link landing in the instance log.
Anywhere but development, a missing `SLIDES_ORIGIN`,
`SLIDES_INSTANCE_KEY`, `SLIDES_SMTP_*` or `SLIDES_ALLOWED_ADDRESSES` is a
startup refusal rather than a default.

## The gate

`.amadan/ci.d/10-gate` is the only CI this repo has, it runs on every push
to the amadan remote, and it is the same script you run locally:

```sh
./.amadan/ci.d/10-gate
```

It does rustfmt, clippy with the disallowed-methods lint, the wasm build,
the `cmp` against the committed artifact, the Rust tests; then the JS half
— `npm ci`, `node --test`, the vectors regenerated and the bundle rebuilt
and both diffed against what is committed; then `make ci` (vet, gofmt, Go
tests, migration check). `make ci` alone is the fast loop between edits;
the full gate is what you run before pushing.

Since slice 2 it needs Node 20+ and `npm`, and `npm ci` wants the registry
reachable while it runs — the one part of this gate that is not offline
once its caches are warm. `.amadan/README.md` says why that trade is worth
making, what each step catches, and the runner's own two traps. In a
sandbox, point `NPM_CACHE` somewhere writable or npm fails on its cache
rather than on the install.

## Deploying

One static binary, stamped: `make release` (refuses an empty version or a
dirty tree — a `-dirty` stamp names a commit plus changes nobody can name,
so two different binaries can carry the same string). Then `carlos deploy
--app slides --account oficina`, with `carlos store create` for the object
store and `carlos domains attach slides.oficina.works`.
`SLIDES_INSTANCE_KEY` and `SLIDES_SMTP_PASS` go in via `carlos secrets`,
the rest via `carlos env`, mirroring Docs' set (spec §6.3).

Verify against `x-carlos-version` on the real host or let `carlos deploy`
watch the URL. `carlos channels` reports which release a channel *points
at*, which is not the question of what the box is *serving* — adoption runs
on a timer and the two can disagree for up to an hour, with nothing saying
"not live yet".

## Working through amadan

This repo is hosted on an amadan hub, not GitHub: no `gh`, no pull request,
no GitHub Actions. Post as the agent account (`<name>~<operator keymail>`),
never as Paul. Work happens on a worktree branch and reaches `main` only by
squash merge, after review and with the gate green — `amadan ci status
-branch <n>` exit 0 is the merge gate; exit 2 is pending and exit 3 means
nothing ran, and neither is green.

**Every branch gets a discussion, opened before the merge, and it is the
review record.** A squash merge flattens the history that carried the
branch's reasoning; the discussion is where that survives. If it does not
exist before the merge, the reasoning is not scattered, it is gone.

## UX conventions (seeded from profile: considered)

Accessible by construction, progressively enhanced, and never dependent on
JavaScript to be usable.

- [x] Icons — lucide, inline — `internal/slides/icons`
- [ ] 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
- [x] Fields — label, hint, help and error in one envelope, wired with aria-describedby — `ui/field, ui/field-select, ui/field-text`
- [x] Destructive actions — their own confirm-page URL, never a modal — `ui/confirm-form`
- [x] State — never colour alone — always a text label too — `ui/status-pill, ui/badge`
- [ ] Dates — relative, with the absolute date in a title attribute
- [ ] Errors — server-rendered beside the field, in plain language
- [x] Empty states — say what the screen is for, not just that it is empty — `ui/empty-state`

`[x]` is enforced by a vendored component: use it, do not hand-roll an
equivalent. `[ ]` is guidance to apply by hand. A convention absent from
this list is one rastrillo has no opinion about.

Edit any line. **This file is the source of truth, not the profile name.**
Nothing re-reads the profile, so changing a line here changes what the app
does, and a convention deleted from this file is not one to reinstate.

## Work in a worktree, and catch main up first

Take a worktree for the work. Do not work in the shared checkout.

    git -C <repo> fetch origin
    git -C <repo> worktree add ../<repo>-<task> -b <branch> origin/main

Both halves matter, for different reasons.

**The worktree**, because several sessions share this machine and some
share this repository, and a checkout has one HEAD, one index and one
working tree between all of them. A branch switch under somebody else's
uncommitted work destroys it with nothing on screen to say so, and two
sessions editing the same paths disagree quietly instead of conflicting
loudly. A worktree gives the work its own HEAD and its own files, and
costs a directory.

**From `origin/main`, after a fetch** — not from whatever the checkout
happens to be sitting on. A branch cut from a stale main is a merge
conflict scheduled for later, against files somebody else has renamed in
the meantime. A branch cut from another task's branch is worse: it
carries that task's commits into yours, where they are reviewed as
though you wrote them, and the two can only be separated by hand.

Remove it when the work has landed — `git worktree remove` — so that
`git worktree list` keeps saying something true.

Written down because of 2026-09-17, in `meet`. The shared checkout was
sitting on one task's branch while holding a *different* task's feature
uncommitted on disk — some four hundred lines, a new migration among
them — put there by a session nobody else knew was working in that tree.
Two other sessions were meanwhile coordinating over the same checkout by
asking each other not to touch it, which is the arrangement a worktree
makes unnecessary. Nothing was lost. Nothing about the arrangement would
have said so if it had been, and by then the same feature existed twice,
once on each side of a rename, because the two sessions had branched
from different starting points.
