oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Product
2
3<!-- impeccable:product-schema 1 -->
4
5> **How this file was written.** Impeccable's `init` normally interviews
6> the user before writing anything here. This one was written from
7> repository evidence instead, during slice 1 and brought up to date at
8> the end of each slice since — most recently slice 3: the design
9> (`docs/superpowers/specs/2026-09-07-slides-design.md`, revision 1), the
10> plans (`docs/superpowers/plans/2026-09-07-slice-1-live.md`,
11> `2026-09-07-slice-2-editor.md` and `2026-09-08-slice-3-export.md`),
12> `AGENTS.md`, and the shipped code in `internal/slides/` and
13> `fixtures/`. Facts traceable to a spec
14> ruling or to code are cited. Anything that is a reading rather than a
15> citation is marked **[inferred — confirm]** and should be corrected
16> rather than trusted.
17
18## Platform
19
20web
21
22## Users
23
24**Primary: a member of one team, making and giving a talk.** The instance
25boundary *is* the team — one deployed instance per team, not one
26multi-tenant app with teams inside it (spec §0.2, suite rulings 1 and 5).
27Sharing is therefore membership plus a link capability, not a permission
28matrix.
29
30**Secondary: an agent, driving a deck on a person's behalf.** Not a
31metaphor and not a later feature — it was the only way to build a deck
32before the editor existed, and it is still a peer of it: an agent's ops
33and a person's keystrokes land in one document, in one order. An agent is
34its own principal,
35`<name>~<operator email>`, minted by a signed-in person and named in every
36write it makes (spec §1 ruling 7, §7.1). It never borrows a person's
37session, and the API never reads a session cookie.
38
39**Third: someone holding a share link**, who has no account and gets a
40read-only deck (spec §5). If a talk is live, that same link offers "follow
41the presenter" (§3.4).
42
43Access today is a magic link gated by `SLIDES_ALLOWED_ADDRESSES`
44(`internal/slides/auth.go`) — a per-instance allowlist, exactly Docs'
45shape, chosen because the suite home that will own identity is a skeleton
46(spec §1 ruling 8). Guest grants and edit links wait on that home (§5, §9).
47
48**[inferred — confirm]** The team is assumed small enough that an address
49allowlist is the right admission mechanism. Nothing in the repo states a
50target team size.
51
52## Product Purpose
53
54Slides is a **presentation editor** for the Oficina suite: a deck that
55looks like a Keynote deck on a desktop, reads like a document on a phone,
56is presented from a laptop and driven from a phone, exports to PDF, and is
57driveable by an agent through the same operations a person has (spec
58preamble).
59
60Success is a deck that is genuinely the same deck everywhere it is seen —
61one block tree, rendered for a viewport rather than authored twice (§0.1).
62A phone does not get a cut-down deck; it gets the same deck reflowed.
63
64## Positioning
65
66Three things a neighbouring presentation tool could not copy without
67rebuilding its foundations:
68
691. **One tree, one renderer, no second layout.** The layout modes are two
70 placements of the same blocks, and the phone layer is entries in the
71 same tree (§2.3, §2.5). `internal/slides/render` is the only code that
72 turns a slide into HTML, and it serves the viewer, the presenter view,
73 the remote, and the editor's stage and thumbnails (§4.1) — the browser
74 fetches that HTML rather than drawing its own. A product with a mobile
75 view bolted on cannot make that claim afterwards, and neither can one
76 whose editor renders slides a second time in JavaScript.
772. **An agent is a first-class author, badged as itself.** Every write
78 records `person:<id>` or `agent:<id>` (`DeckUpdate.Actor`,
79 `internal/slides/models.go`; spec §6.2), and the agent's writes are
80 attributed to the agent with its operator named, never laundered into
81 the person's name. Slides is the first Oficina app to build that
82 surface (§0.2).
833. **Presenting is a server-side session, so the room is one system.** The
84 presenter view, the projector, the phone remote and every audience
85 follower read and write one record (§3.4, `internal/slides/
86 presentation.go`). A phone remote is not possible at all in a design
87 where presenting is just a viewer with no session, which is the
88 rejected alternative recorded in §1 ruling 5.
89
90**What is not shipped and must not be described as though it were:**
91comments, version history, charts from Sheets, video and embeds, an MCP
92adapter, aspect ratios other than 16:9. §9 gives each a trigger rather
93than a date. And **the first deployment has no object store bound**, so
94image upload — which is built, and works against a bucket or a
95directory — answers `503` there and says so on the page, and an exported
96PDF is held in the instance's own memory for an hour instead of going to
97a bucket.
98
99## Operating Context
100
101- **One Go binary per instance**, `CGO_ENABLED=0`, cross-compiled to
102 `linux/arm64`, on the CARLOS platform, at `slides.oficina.works` for the
103 first team (spec §6.3, `Makefile`).
104- **The deck is a Yjs document held by a Rust `yrs` core compiled to wasm
105 and run under wazero** (spec §1 ruling 3; `core/`,
106 `internal/slides/core`). The Go side persists updates in SQLite, asks
107 the core for a JSON view, and renders that view. This is Docs' pattern,
108 adopted rather than reinvented.
109- **Routes shipped today** (`internal/slides/app.go`): sign-in and the
110 deck list, `/decks/new`, `/d/{sqid}` and its notes, settings, share and
111 delete pages; the editor at `/d/{sqid}/edit` with its slide HTML twin,
112 its `.svg` thumbnail twin, its update log, stream and presence, and its
113 upload, register and asset routes; `/d/{sqid}/export`, the job's status
114 page under it and the `.pdf` beside that; `/s/{token}` for a share link; `/p/{session}` and its notes,
115 stream, remote and command; `/r/{code}` to pair a phone. The share,
116 present and remote routes sit outside the session group on purpose —
117 the projector, the phone and the audience each carry their own
118 capability and have no session at all.
119- **The browser holds a Yjs document too.** The editor's bundle is
120 committed, hashed, and built by hand from `fixtures/`
121 (`node build-bundles.mjs`); the app builds nothing at run time and CI
122 builds nothing for it. `fixtures/` is also a second implementation of
123 the operation vocabulary, held to the Rust one by generated vectors
124 (spec §2.8) — an operation changes on both sides or on neither.
125- **Local development** is `./scripts/run-dev.sh`: `localhost:8091`, a
126 scratch SQLite database that starts empty every run, magic links logged
127 to the terminal rather than mailed.
128- **The scriptless floor is a product requirement, not a fallback** (spec
129 §8). Signing in, listing, creating, viewing, presenting, reading notes,
130 managing sharing and using the remote all work with JavaScript off; each
131 remote button is a form that POSTs and redirects. The editor is the one
132 stated exception, and it says so in a `<noscript>` that links the
133 viewer — a boundary declared in the spec before it was built, not
134 discovered afterwards.
135- **Work happens on a worktree branch and reaches `main` only by squash
136 merge**, after review and with the gate green. The review record is an
137 amadan branch discussion, not a GitHub PR — this repo is hosted on an
138 amadan hub (`AGENTS.md`).
139
140## Capabilities and Constraints
141
142**Shipped in slice 1:** magic-link sign-in with an admission allowlist and
143a `users` row per person; deck list, create, settings, delete, three
144sharing rungs; the Rust core with the deck schema, the operation set and
145the JSON view; the HTML renderer with eleven block types, seven flow
146templates, the canvas frame, the phone layer and six themes; the paged
147viewer and the notes page; read-only share links, hashed and revocable;
148presentation sessions with the fullscreen surface, presenter view, phone
149remote and audience follow; the drive API for creating and driving a deck.
150
151**Shipped in slice 2:** the browser editor at `/d/{sqid}/edit` — slide
152strip, stage, inspector for the deck, the slide and the selected block,
153text edited in place, canvas drag and resize with snapping, the
154desktop/phone switcher whose phone-view edits become overrides rather than
155changes everyone sees, undo scoped to the person who made the change, and
156presence. Under it: the live-sync wire (an update log served by cursor, an
157SSE stream of advisory seqs and whole presence sets, periodic snapshots so
158opening a long-lived deck is not a full replay), image upload
159browser-to-bucket with SVG sanitising and a deck-scoped check on every
160byte served, and the JS half of the operation vocabulary with the vectors
161that hold it to the Rust one.
162
163**Shipped in slice 3:** the layout engine (`internal/slides/layout`),
164which measures a slide into a paint list with the twelve Liberation faces
165embedded in the binary and served to every slide surface; the SVG
166serialiser over that paint list, and a thumbnail route on all four
167capabilities; and PDF export — `krilla` compiled to wasm32 and hosted
168under wazero beside the CRDT core (`core/pdf`,
169`internal/slides/pdfcore`), driven by a rastrillo job whose status page
170polls itself and works with scripts off: one 16:9 page per slide, the
171speaker notes as pages of their own when they are asked for, the text as
172embedded subset glyphs, the images inlined. The API twins are
173`POST /api/v1/decks/{sqid}/export` and its two GETs.
174
175**Deliberately not built yet** (spec §9): charts from Sheets, video and
176embeds, an MCP adapter, 4:3 and custom aspect ratios, comments, and
177version history. Each has a named trigger in §9 rather than a date.
178
179**Binding technical constraints:**
180
181- **A slide is one block tree, rendered many ways** (§0.1). Nothing that
182 matters to what a reader sees may live outside the tree.
183- **Every write names its real actor**, `person:<id>` or `agent:<id>`
184 (§6.2). An agent's write names the agent and its operator, never the
185 person (suite ruling 9, plus Paul's 2026-09-07 ruling that agents drive
186 every Oficina app as their own identified principal).
187- **Not-found and not-yours both answer 404, never 403**
188 (`internal/slides/decks.go`), so a stranger holding a sqid cannot learn
189 whether it is real.
190- **Only the SHA-256 of a share link is stored**, so the URL is shown
191 exactly once and the page says so at that moment (`share.go`, §5).
192- **Every user-visible string is a catalog key** resolved through
193 `rastrillo.T` (`internal/slides/locales/en.toml`, 339 keys today).
194 Twelve base locales are the suite target; one, `en`, exists. A
195 hardcoded English string is a bug — including in the editor's
196 JavaScript, which reads its strings off `data-text-*` attributes the
197 template renders from the catalog. Deck titles and timestamps are data,
198 not authored copy, and are correctly not keys.
199- **The document is the log, and presence is not in it.** Every write is
200 a row with an actor; a snapshot is a shortcut and never the truth.
201 Presence lives in memory, reaches no log, is in no snapshot, and is
202 gone when the process restarts (spec §3.3).
203- **The browser is not a second renderer.** The editor's stage shows the
204 server's HTML for the current slide and rebinds after a structural
205 change; building slide markup in JavaScript would recreate exactly the
206 divergence §0.1 exists to prevent.
207- **`href` is stored as the one-key map `{"href": url}` and the marks are
208 named `b`, `i`, `c`, `h`, `href`** (§1 ruling 12, §2.6). Forced by
209 y-prosemirror, which has no hook to map mark names to attribute names
210 and deletes a text node when a read throws — so the wrong shape loses
211 the words, not just the link.
212- **Markup is the bare `rst-*` attribute grammar**, never `class="rst-*"`;
213 this app's own attributes are `slides-*`.
214- **Themes are a built-in set of token bundles**, six of them (`paper`,
215 `ink`, `sunset`, `forest`, `slate`, `candy` —
216 `internal/slides/render/themes.go`). A deck may override accent and the
217 two typefaces; there are no per-block fonts, which §1 ruling 9 calls the
218 fastest route to an ugly deck.
219- **`CGO_ENABLED=0` everywhere**, and the committed wasm artifact must
220 match a fresh build or the gate fails.
221
222## Brand Commitments
223
224- **Name:** Slides, in the `oficina` namespace at
225 `amadan.net/oficina/slides`, hosted at `slides.oficina.works` for the
226 first team (§1 ruling 11). English, not Spanish — the suite names
227 generic apps in English.
228- **The design system is rastrillo's**, with estilo
229 (`amadan.net/oficina/estilo`) as the suite variant. Vendor tokens rather
230 than copying files by hand.
231- **A slide is not themed chrome.** The deck carries its own theme tokens
232 as CSS custom properties on each `<section slides-slide>`
233 (`render/render.go`); the app's chrome follows the design system and the
234 slide does not follow the reader's preferences. **[inferred — confirm]**
235 The spec does not spell this out for Slides the way Docs' §10.2 does for
236 the page canvas; it is the reading the renderer implements.
237- **The empty state is a designed screen, not a blank page**, and says
238 what the screen is for: "A deck is a set of slides you write, present
239 and share. Start one and it will show up here."
240 (`slides.decks.empty.body`).
241- **Voice, as evidenced by the shipped catalog:** plain, unhedged, second
242 person, no exclamation marks, no product cheer. Headings carry no full
243 stop ("No decks yet"); errors state what happened and what helps ("That
244 link has expired or was already used. Enter your email below for a new
245 one."); a warning goes in front of the button rather than in a help page
246 ("Copy this now — it is not shown again").
247- **What does not exist and must not be invented:** no customers, no
248 testimonials, no benchmarks, no pricing, no usage numbers, no launch
249 date, no press. There is one instance and it is not live yet.
250
251## Evidence on Hand
252
253- **Spec:** `docs/superpowers/specs/2026-09-07-slides-design.md`, revision
254 1, drafted 2026-09-07 from a brainstorm with Paul the same day. It is
255 **unreviewed**: §12 records that the review pass is the first item after
256 slice 1 ships, so the reviewer has a running instance rather than a
257 document. Treat its rulings as settled and its prose as unaudited. One
258 amendment has landed since: ruling 12 and the rewritten §2.6, added
259 during slice 2 when the fixtures proved the old `href` shape did not
260 survive y-prosemirror.
261- **Plans:** `docs/superpowers/plans/2026-09-07-slice-1-live.md` (eight
262 tasks) and `2026-09-07-slice-2-editor.md` (seven), one slice each.
263- **Consumed rulings:** the suite sharing doctrine (`oficina/suite`,
264 revision 8) by number — 1, 4, 5, 6, 9, 12, 15 — never restated here
265 (§0.2). Docs' live-sync design is adopted as written for slice 2 (§3.3).
266- **API reference:** `docs/api.md`, every endpoint and every operation
267 with an example, written for a person and an agent both.
268- **The export, end to end:** `internal/slidestest/export_test.go` walks
269 the button, the job, the polled fragment, the SSE stream and the file,
270 and asserts the first heading is extractable with `pdftotext` when the
271 machine has one (visibly skipped when it does not).
272 `internal/slides/export_test.go` does the same at the pipeline's own
273 level, recovering every heading from the PDF's glyph runs through each
274 font's ToUnicode CMap.
275- **Sample content:** `internal/slides/sample.go` seeds one deck on first
276 sign-in, covering each flow template, a canvas slide and a phone-reflow
277 slide — which is also the fastest way to see what the renderer does.
278- **The contract between the two implementations:** `fixtures/data/*.json`
279 are operation batches, `fixtures/vectors/` is what the JS side makes of
280 them, and `internal/slides/core/vectors_test.go` requires the Rust core
281 to agree. Five vectors between them exercise every operation in §7.2.
282- **The gate:** `.amadan/ci.d/10-gate`, run by CI and by hand, and
283 `.amadan/README.md` for what it needs. Since slice 2 it also runs the JS
284 toolchain — `npm ci`, `node --test`, the vectors regenerated and the
285 bundle rebuilt, both diffed against what is committed — which is what
286 makes the two claims above checkable rather than remembered.
287- **The browser drive:** `internal/slidestest/browser_test.go` walks the
288 editor in a real Chromium and fails on any console error, failed request
289 or JS value that reached the screen. It is deliberately outside the
290 gate: this project's sandbox will not start a Chromium, and a gate that
291 ran it would report "could not see" as green.
292
293## Product Principles
294
2951. **State the degradation rather than let it be discovered.** The spec
296 refuses to claim pixel identity between the browser and the PDF and
297 says where they may differ (§0.3, §4.2); the share page says the URL
298 will not be shown again at the moment it is shown; the editor's image
299 field, on an instance with no object store, says so on the page instead
300 of waiting for somebody to choose a file and hit a 503.
3012. **One tree, one renderer.** Any second representation of a deck — a
302 mobile layout, a print document, an editor's own idea of a slide — is
303 a design mistake before it is a maintenance cost, because the two will
304 diverge and the divergence will reach a reader.
3053. **A capability is a capability, and nothing more.** A share link never
306 edits (suite ruling 15). An edit link does not exist, because an edit
307 capability is a sign-in that mints a guest grant, and that waits on the
308 home (§5).
3094. **An agent is a principal, never a costume.** It has its own token, its
310 own name, its own revocation, and its own badge in the activity list
311 (§7.1). The alternative — an agent borrowing a person's session — is
312 recorded as rejected, not overlooked.
3135. **The scriptless floor is the floor, not a fallback.** A screen that
314 only works with JavaScript is a screen that has not been finished
315 (§8).
3166. **A string a person can read is a catalog key.** Localisation is a
317 structural property of the app, not a phase.
318
319## Accessibility & Inclusion
320
321**WCAG 2.2 AA, no exemptions** (spec §8, restated in `AGENTS.md`). Four
322requirements bind concrete decisions here:
323
324- **24×24 minimum target size**, which the phone remote's buttons are
325 sized for — it is a control someone uses in the dark, one-handed, while
326 talking.
327- **320px reflow.** The one allowance is estilo's: the canvas stage may
328 scroll inside its own region in the editor (§8). Nothing in the viewer
329 uses it. Below 1100px the editor's three panes stack — the strip
330 becomes a horizontal filmstrip and the inspector a `<details>` at the
331 foot — rather than scrolling the page sideways.
332- **Canvas resize handles are pointer-only, and the keyboard has a real
333 equivalent** rather than a smaller version of the same thing: the
334 selected block's overlay is focusable and named, arrows nudge, Shift
335 nudges further, Alt resizes, and the inspector's Left/Top/Width/Height
336 fields do it by number.
337- **Colour never carries meaning alone**, so a deck's sharing rung shows
338 a text label beside its tone and never a bare colour.
339- **`alt` on every image.** The model makes it structural rather than
340 advisory: `alt` is a required prop on an `image` block and may be empty
341 only when `decorative` is true (§2.4).
342
343The phone is not a degraded case. Reflow for flow slides and scale-to-fit
344for canvas slides, with a per-slide switch to reflow a canvas slide in
345reading order (§1 ruling 2), exists so that reading a deck on a phone is a
346supported way to read it.
347
348Twelve base locales are the target set; today exactly one exists (`en`).
349That is a commitment, not a shipped state, and this file should not be read
350as though eleven more catalogs are sitting in the repo.
351