The problem

I make my slides in reveal.js. It’s open, it’s just HTML, CSS and JavaScript, it lives in git. Claude Code and I iterate on the deck together and that works well.

I write slides in HTML for one reason: a slide can be a live application. When I’m demoing, I don’t want to alt-tab to a browser, find the window, remember which tab, and watch the room’s attention drain. I want the demo in the slide, using an <iframe>, running the real thing. Interact then switch to the next slide.

Sometimes I don’t want to describe a change, I want to grab the element and move it. Nudge the box. Try the heading two sizes bigger. Saying that to an LLM instead (left a bit, no, less, now the gap above it) costs more than the change is worth, and you don’t see the result until it’s already done.

So I went to slides.com, which is the obvious answer. It’s polished, it has AI features and collaboration and cloud storage, and it’s fair value.

It’s also built on reveal.js. It’s the SaaS from reveal.js’s own author.

The features page is a spec

So rather than subscribe, I opened the features page and pointed Claude Code at it: read this, tell me what’s on offer, and sort it by whether I already have it.

That page exists to make me subscribe. Read the other way round, it’s a well-organised, vendor-authored description of the product.

The answer came back in three piles. Most of what I’d pay for was already in reveal.js and I just wasn’t using it: fragments, data-auto-animate, speaker view, syntax highlighting, math, PDF export, themes. Custom CSS, which they sell as “Developer Mode” and which I have because they’re my files. Revision history, which is git.

A large slice was irrelevant to one person working alone: cloud storage, sharing links, live cursors, comments, team billing, SSO. Real features. Not for me.

And one pile had something in it:

What slides.com hasWhy it helpsCan I get it locally?
Layout grid + snappingKills the alignment fiddleOnly if I build it
Drag positioningNudging beats editing pixel values blindOnly if I build it
Live preview while editingSee the change instantlyYes, via live-reload
Grouped content, bulk editMove many blocks at oncePartly; CSS classes are groups
Lines & arrowsFiddly in raw HTMLSVG overlay

One column. The canvas. Everything else was engine, and I already had the engine.

What I actually did

I talked it through with Claude until we agreed on what this was: the document model, the layout vocabulary, where the invariants sat. I didn’t write the spec files. We aligned, then it wrote them.

Then I pointed a Ralph loop at the specs and let it run. Phases 0 through 8, foundations to Claude Code integration, landed within an afternoon.

Then the part that took the actual time: I kept trying to make my real talk in it. Not a test deck. The Autonomous Software Factory slides I had to stand up and present. Every time something annoyed me, that annoyance was the bug report. A drag that snapped to the wrong guide. A paste that ate my formatting. A thumbnail storm that hammered the server. I’d say what happened, it’d get fixed, back to the slides.

That loop is why the feature set is what it is. Nothing in it exists because slides.com had it. Everything in it exists because it stood between me and finishing a talk.

What I built

decks: a local, offline WYSIWYG editor for reveal.js. One Go binary, Svelte canvas embedded, reveal rendering in a sandboxed iframe. No account, no network, and no node_modules on your machine: the frontend is built once and baked into the binary.

The decks editor, dragging an element on the canvas while the deck.html source updates alongside it

Canvas on the left, deck.html on the right, both live.

The decision everything hangs off: alignment is stored as intent, not coordinates.

<div data-lay="row" data-gap="64" data-align="center">
  <img src="scheduler.svg">
  <p>The factory picks the next task itself.</p>
</div>

I dragged that on the canvas. That’s what landed on disk: plain HTML, and plain CSS. data-lay isn’t a format the editor interprets; it’s an attribute selector in a stylesheet vendored into the deck, where [data-lay="row"] is display: flex and [data-align="center"] is align-items: center. The editor writes attributes. The browser does the layout. There’s no proprietary document, no JSON sidecar, no runtime of mine between the file and the screen.

Which is why Claude Code can edit a deck, and why nothing here is a pixel coordinate: the layout survives a resolution change, an aspect-ratio change, and an edit that makes the text longer.

Two invariants sit under it. Load then save is byte-stable, so my formatting survives a visit to the canvas. And HTML the canvas can’t represent is never dropped. It’s preserved verbatim and badged “source only”, with a jump-to-source rather than a quiet normalisation. That second one is what gives you an escape hatch: every deck ships a custom.css, and if CSS won’t do it you drop a <script> in the deck and write the behaviour yourself. The canvas doesn’t have to understand your code to keep it.

Because Claude Code can edit a deck directly, I wanted something deterministic sitting between the agent and the file. decks validate is that check. It reads a deck.html and enforces five things: the layout vocabulary is a closed set, every data-eid is unique, every relative src and href resolves to a file that exists inside the deck folder, the HTML parses with its tags balanced, and nothing is fetched over the network.

<iframe src="https://vault.lochie.dev"> is an external URL;
decks must be offline-first (vendor it locally)

That’s backpressure. The agent can write whatever it likes; the validator decides what’s allowed to ship, and it decides the same way every time.

The part worth stealing is that the validator shares no code with the editor. The byte-stable round-trip lives in the TypeScript model; the validator is a second, independent implementation of the same rules in Go, written from the spec rather than from the other implementation. Two implementations of one contract catch drift in either. If the canvas starts emitting something the spec doesn’t allow, the checker that has never seen the canvas is the one that says so.

One more decision that looks like a bug until you see it. A workspace has a shared/ folder for the logo and headshot you reuse, and inserting from it copies the file into the deck rather than referencing it. That’s wrong if you’re optimising for DRY. It’s right if you want a deck that still presents from a fresh clone in five years. A shared reference is a thing that can rot: delete an asset and it silently vanishes from six talks you gave two years ago.

I’d never written Svelte

Worth saying, because it changed how I picked the stack.

The backend is Go: one static binary, go:embed, fsnotify, server-sent events. I chose that because I know what it buys: a thing you download and run, with nothing to install and nothing to break in five years.

The frontend is Svelte 5, with TypeScript, Tailwind, Vite, and CodeMirror for the source pane. I had never used Svelte. Not once.

You used to pick what you can already write, because a new framework costs weeks before you’re productive, and your tools slowly become a record of what you happened to learn first. I’m not the one typing it, so the question stopped being what can I write and became what suits a canvas with a lot of fine-grained reactive state. That looked like Svelte. It also seemed cool, and that was allowed to be a reason.

Which is the thing I wrote about in March, in Model-First Companies: when a tool doesn’t fit your workflow, you don’t compromise anymore, you build one that does. I made the prediction and then spent a weekend being an example of it. Hyper-personalised software isn’t a product category. It’s just what you end up with when building the exact thing gets cheaper than tolerating the near-thing.

It worked on stage

I had the Autonomous Software Factory talk about 80% built in raw reveal.js. I told Claude Code to port it to decks. It ported, and I presented off it at Sec Talks Gold Coast.

Presenting the Autonomous Software Factory deck at Sec Talks Gold Coast.

That only works because of the invariant. The deck was never in a decks format. It was reveal.js HTML before and it’s reveal.js HTML now. Porting in was reading a file. Porting out would be closing the editor.

Two things made it safe to present. reveal.js is vendored into every deck, so nothing is fetched while I’m on stage, and the demo iframe points at localhost. And there’s PDF export via reveal’s ?print-pdf, driven by headless Chrome from the Go backend, which is both the fallback if everything breaks and the answer to “can you send me your slides?”, a question nobody has ever meant as “link me your git repo”. A PDF has a rectangle where the live demo was. That’s the trade, and knowing what it costs is the point.

There’s no decks export, because there’s nothing to export. No external URLs, reveal vendored in, every asset resolving to a file inside the folder: the validator enforces all three, so the deck folder already is the bundle. Copy the directory onto any static host and it presents. The constraint I imposed so nothing would break on stage turned out to be the distribution story too.

If you want it

decks is on GitHub. One binary, works offline, and the deck it produces is a plain HTML file you can open without it. The binary also embeds the Claude Code skill that teaches an agent to drive it, so the tool ships the document that explains the tool and they can’t drift.

slides.com is a good product at a fair price and it wasn’t for me. The honest reason I built this isn’t ROI. I wanted the thing to exist and it was a good weekend.

But it’s worth reading a features page the other way round before you reach for your card. Sometimes you’re paying for the engine. Increasingly, you’re paying for the wrapper, and the wrapper is the part that got cheap.