Let It Disconnect: A Local-First Future #
Closing keynote · CodeBEAM Europe 2026 · Track 1, Kleine Zaal · Thu 16:45–17:30
“Let it crash” was never really about crashing — it was about designing systems that assume failure and keep working anyway. Local-first software takes the same view about the network, service providers, and open source: assume disconnection, variable latency, service EOLs, and forks to design for convergence and user agency rather than coordination and lock-in.
Thirty-nine slides, ~45 minutes. Every slide has speaker notes (press S).
Run it #
npm install # reveal.js + anime.js
npm run vendor # copy them into vendor/
npm start # http://localhost:8000
Or with Nix:
nix develop # node, wrangler, language servers
nix run # build the deck in the store and serve it
nix build # just the static site, in ./result
Note
Slides are fetched over HTTP, so opening index.html from the filesystem shows a blank deck. Use the server.
Note
Nix only sees git-tracked files. Until new files are committed, use nix build path:. / nix run path:.
Deploy #
npm run deploy # vendor + assemble _site/, then wrangler deploy
Cloudflare Pages via GitHub: set the build command to npm run build and the output directory to _site.
Tangled sites #
Tangled serves a branch as-is, with no build step, so the built deck lives on its own pages branch:
npm ci # once
npm run publish:pages # build, commit _site/ to `pages` (one signed commit)
git push tangled pages # or: npm run publish:pages -- --push
npm run publish:pages -- --dry-run # build and stage only, no commit
The script uses a throwaway index, so your working tree and staging area are untouched, and it skips the commit if nothing changed. In the repo's Settings → Sites, deploy from branch pages with directory /. All paths are relative, so the deck works as a sub-path site (<you>.tngl.sh/<repo>).
Keys #
| Key | Does |
|---|---|
→ / space |
Next build step / slide |
s |
Speaker view |
f |
Fullscreen |
o |
Overview |
ctrl/cmd + shift + f |
Search |
Structure #
index.html reveal config + slide manifest (order lives here)
css/deck.css hand-written theme, no SCSS build
css/widgets.css interactive widget styles
js/deck.js ambient motion: peer mesh, entrances, logo bounce
js/widgets.js interactive slides (data-widget="…")
slides/NN-part/*.md one file per slide
build.mjs assemble _site/
serve.mjs zero-dependency static server (with range requests)
vendor.mjs copy reveal/anime out of node_modules into vendor/
publish-pages.mjs commit the built _site/ to the `pages` branch (Tangled)
| Part | Slides | Beat |
|---|---|---|
| Open | 3 | Title, about, Lamport epigraph |
| I. Let it crash | 5 | Telecom origins, four ideas, Joe's thesis |
| II. Lost in the cloud(s) | 10 | Cloud promise, AWS map, the long way to the next seat, carrier vs bike, eras |
| III. Let it disconnect | 9 | The four "assume"s, CRDT switch, sneakernet-complete, kill the server, same loop |
| IV. Let it delegate | 6 | Holding a reference is permission, authority you can see, auth in the wrong layer, interop n² vs n, b5 |
| V. Software in 2030 | 6 | Automerge/Keyhive, BEAM → LF map (summary), closing, Lamport callback |
Interactive slides #
Three slides have live widgets rather than builds. Each mounts when its slide appears and resets when you leave, so every run-through starts clean. Buttons drop focus after a click, so the clicker still advances the deck.
| Slide | Widget | On stage |
|---|---|---|
| Never about crashing | supervisor |
Runs itself (data-auto): workers crash at random, supervisors restart them, calls keep climbing |
| Who's in charge here? | topology |
Cloud vs local-first; kill the server; quilts show who is in sync |
| Choose which versions…? | crdt |
Flip the network off, edit both replicas, flip it back on |
Adding one: Widgets.register("name", (root, scope) => { … }) in js/widgets.js, then <div class="widget" data-widget="name"></div> in a slide. Use scope.timeout / scope.interval / scope.loop so timers die with the slide.
Builds and animation #
- Presenter-paced steps are reveal
fragments. HTML fragments rise; SVG fragments only fade (a CSS transform would clobber theirtransformattribute). class="draw"on an SVG stroke draws it in when its fragment appears (--lensets the dash length)..swapstacks children in one cell, so a later fragment cross-fades over an earlier image.- The peer mesh runs on the title and section slides, or anywhere with a
data-meshattribute. <span class="fragment zoom" data-zoom="selector">grows that element to fill the slide below the heading on its step, and shrinks it back on the way out.
Theme #
Ink on warm paper, with an Elixir-purple → Gleam-pink gradient for headings. Erlang red is kept for one meaning: the central server. Teal means "converges"; amber means "breaks". Fonts are vendored, so nothing loads from a CDN and the deck works with no network (which would be embarrassing to get wrong in this talk).
Gotchas #
- Keep inline
<svg>free of blank lines. Markdown treats a blank line as a block break and strands child nodes outside the SVG. - World map: do not edit dot positions or ellipse geometry. They were placed by hand.
- CSS 54%
- JavaScript 40%
- HTML 4%
- Nix 2%