oficina / slides Public

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

Plain git — no account needed to clone.

Download

Slides

Slides is the presentation app for the Oficina suite. A deck looks like a Keynote deck on a desktop and reads like a document on a phone; you share it as a read-only link, present it fullscreen with speaker notes, and drive the talk from your phone while the room follows along. One instance per team, on the CARLOS platform.

You edit a deck in the browser. Press Edit on a deck and /d/{sqid}/edit gives you a slide strip down the side, the stage in the middle, and an inspector for the deck's theme, the slide's layout and whichever block you have selected. You type into the slide itself. Canvas blocks drag and resize, snapping to the thirds and to each other. A switcher flips the stage between desktop and phone widths, and what you change in phone view becomes an override for phones rather than a change everyone sees. Several people can be in a deck at once: you see where they are, and undo only ever takes back your own work. Pictures upload straight to the object store when one is bound.

The stage is not a second renderer. It shows the HTML the viewer would serve for that slide, fetched from the server, with the text blocks bound for editing in place — which is what keeps a deck the same deck wherever it is seen.

A deck comes back out as a PDF. Press Export PDF on a deck, say whether the speaker notes should come too, and the export runs as a job you watch on a page of its own — one 16:9 page per slide, the text as real text you can search and copy, the pictures embedded. It is the same paint list the thumbnails are drawn from, set in the same faces the browser sets a slide in, so the file is the deck rather than a photograph of it. With no object store bound the file is held for an hour and served from the instance; with a bucket it goes there and the download is a signed URL.

The editor cannot hold a comment or show you an older version. And the first deployment has no object store bound, so uploads there answer 503 and the image field says so on the page.

A deck can also be built entirely by a program. The drive API in docs/api.md has every endpoint and every operation, and an agent is a principal of its own with its own token — its writes are badged as the agent's, never laundered into a person's name. Every screen but the editor works with JavaScript switched off, and every string a person can read is a catalog key.

Sign-in is a magic link, and the instance only mails one to an address in SLIDES_ALLOWED_ADDRESSES.

Running it

./scripts/run-dev.sh          # http://localhost:8091

That builds the binary and runs it against a scratch database that starts empty every time. Sign in with any address you like: the magic link is logged to the terminal rather than mailed, but it is the real flow, not a stand-in.

Images go in an object store, never in the database and never on the instance's own disk. Development uses a directory beside the database, so uploading works out of the box; a real deployment gets its bucket from CARLOS_STORE_*, and with no bucket bound the upload endpoints answer 503 and the editor says so rather than half-working.

Deploying with a bucket needs one thing this app cannot do for you: uploads are presigned PUTs made by the browser, straight to the store, so the bucket's CORS policy has to allow PUT from the instance's origin with the Content-Type header. Without it uploads fail in the browser with nothing in this app's log — the request never reaches it. docs/api.md has the details.

The editor's bundle

The editor is the one screen that needs JavaScript. Its bundle is built by hand and committed:

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

That writes internal/slides/static/editor.<hash>.js, deletes the previous hashed file, and the Go binary embeds whatever it finds there. Nothing builds it at run time and CI does not commit it for you, so the moment you change a .mjs under fixtures/ the committed bundle is out of date until you rebuild and commit it. The gate rebuilds and diffs, so you find out before the runner does.

Uploads need https (or localhost): the browser hashes the file with crypto.subtle before asking for a signed PUT, and that API only exists in a secure context. On a plain http host that is not localhost the image field cannot upload, and nothing in this app's log will say why.

The fixtures rule

fixtures/ also holds a second implementation of the deck's operations, in JavaScript, because the browser has to write the same document the Rust core reads. fixtures/data/*.json are op batches; node generate.mjs runs each one through the JS ops and writes the resulting Yjs update and the JS view of it into fixtures/vectors/. internal/slides/core/vectors_test.go loads those same bytes through the core and requires the same view.

So change an op and you change both sides, or neither:

node fixtures/generate.mjs     # then commit what moves

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 — it passes, and the two sides have already drifted.

The gate

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

One script, run by CI and by you: rustfmt, clippy, the wasm build and a cmp against the committed artifact, the Rust tests, then the JS half (npm ci, node --test, the vectors regenerated and the bundle rebuilt, both diffed against what is committed), then make ci (vet, gofmt, Go tests, migration check). make ci on its own is the fast loop while you work; run the whole gate before you push.

It needs Node 20 or newer with npm, and npm ci wants the registry reachable while it runs. .amadan/README.md says why that trade is worth making and what each step catches.

The browser drive

internal/slidestest/browser_test.go walks the app in a real Chromium and is loud about any console error, failed request, or JS value that leaked to the screen:

go test -tags browser ./...

It needs a Chromium on PATH, at $RASTRILLO_CHROME, or in a Playwright cache, and a skip is not a pass: with no browser it fails, unless RASTRILLO_BROWSER_OPTIONAL=1 makes the skip a deliberate, visible choice. It is not part of the plain suite or of the gate.

Where the reasoning lives

The design is docs/superpowers/specs/2026-09-07-slides-design.md. The plans are docs/superpowers/plans/2026-09-07-slice-1-live.md (sign-in, viewer, sharing, presenting), 2026-09-07-slice-2-editor.md (the editor and collaboration) and 2026-09-08-slice-3-export.md (the layout engine, thumbnails and PDF export). AGENTS.md is the working file: the seams, the traps, how to deploy. Read it before you change anything here.