oficina / slides Public
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, Sheets and the suite doctrine; 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 bySLIDES_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 appliesform-actionto it); any other gets303 ?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'sAuthorize, after verification, and its refusal is the door, rendered in the refusal's own 403 by auth'sRefusedhook (refused; never a query anyone could type), not rastrillo's bare text. An emailed link opens on a confirm step (renderConfirm, auth'sRenderConfirm): 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/verifyare 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 withmodeas 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 fromfixtures/): 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 becomeset_phoneoverrides, 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 theasset_uploadsrow (models.go, migration0008_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}.svgfrom 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}/exportstarts a rastrillo job, the status page polls itself (and works with scripts off), and the file comes back fromGET /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 arePOST /api/v1/decks/{sqid}/exportand 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.pdfat 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 cmps 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'sslides_core_check(ABI 2,core/src/check.rs, mirrored infixtures/check.mjs) — walks the document: every notes fragment, every blocktext, every table cell, and refuses anything outside §2.6 (a formatting attribute other thanb i c h href, a non-paragraphchild, an element or fragment inside a paragraph, an embed). This is the gate that has to exist, because y-prosemirror 1.3.7 deletes anXmlTextwhose 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.Parseon 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:
cd fixtures && npm ci && node build-bundles.mjsassets_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 touser_id = 0. Map the address to the row first. - Neither committed wasm is rebuilt for you.
go:embedwill happily embed a staleinternal/slides/core/testdata/slides_core.wasmand the Go tests will pass against it; the same is true ofinternal/slides/pdfcore/testdata/slides_pdf.wasm. The gate rebuilds andcmps both (scripts/build-wasm.shandscripts/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.rsand you bump bothslides_core_abi_version()incore/src/lib.rsandabiVersionininternal/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 whereslides_core_checkarrived (codex 3). Doc::new()andDoc::with_client_id()are banned in the Rust core (clippy.toml) — both draw entropy this wasm build has no safe source for. UseOptions::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
.mjsunderfixtures/andinternal/slides/static/editor.<hash>.jsis stale until you runnode fixtures/build-bundles.mjsand 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.gocompares the core against whatever is committed underfixtures/vectors, so a vector nobody regenerated makes it compare the core against a JS implementation that no longer exists. Runnode fixtures/generate.mjsin the same commit as any op change. - Exactly one directory under
internal/.rastrillo migration checkrequires it, which is why every package is nested underinternal/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.Treads 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 throughrastrillo.Tat render time. Twelve locales are the suite target; this slice shipsenand 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 fromen.toml; an English literal in a.mjsfile is the same bug it would be in Go. cargo fmt --checkcoverscore/. 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]anyplus a.Selectallowlist. - Migrations apply at boot, once each, and are never re-run or reversed. Change
models.go, then runrastrillo migration generateand read the SQL — it may be a full table rebuild rather than anADD 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">), neverclass="rst-*". This app's own attributes areslides-*. - WCAG 2.2 AA with no exemptions: 24×24 targets, 320px reflow, colour never the only carrier of state,
alton every image. CGO_ENABLED=0throughout. The stack is cgo-free by design and the binary that ships islinux/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:
./.amadan/ci.d/10-gateIt 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.