# How it works

For whoever extends it next: you, a developer, or your AI.

## One Worker, four kinds of storage

```
            phones                       presenter
   /K7M4P   /@handle   /             /app/   /stage/   /console/
      |         |      |                |        |          |
      +---------+------+-------+--------+--------+----------+
                               |
                     Cloudflare Worker (src/index.ts)
        static files straight from the edge, no Worker involved
                               |
     +-----------+-------------+---------------+---------------+
     |           |                             |               |
     D1          R2                    Durable Object: Room    MCP + API
  users, decks,  slides, pictures,     one per live session:   for agents
  versions,      coded decks,          every connection,
  sessions       photos, voice notes   every answer (SQLite)
```

- **D1** (SQLite) holds presenters, decks (one JSON document each), every
  saved version, and the list of sessions. `remotes` holds phones paired as
  remotes: a pairing code works once, for three minutes, and each phone that
  claims one gets a key of its own the presenter can remove (only hashes are
  kept).
- **R2** holds files: `a/` pictures and downloads, `u/` coded decks, `m/` the
  room's photos and voice notes. Everything is served with a sandboxing
  Content-Security-Policy.
- **A Durable Object per session** is the live room. Every phone, the stage,
  the console and any remote hold a WebSocket to it. It decides what phones
  show on each beat, checks and stores answers, and rolls them up. It uses
  the hibernation API, so a quiet room costs almost nothing.
- **Static files** (the app, the stage, the phone app, stickers, fonts) come
  from Cloudflare's edge without waking the Worker at all.

## Brand and sites

- `brand.json` holds the name, tagline, domain and pictures. `npm run
  rebrand` writes it, then `public/lib/brand.js` (which the app and the
  Worker import) and every place a page needs it before any script runs,
  marked with `data-brand` attributes. Only marked places are touched.
- `wordmark` says whether the logo spells the name. Pages write the name
  beside a symbol only (`data-brand-if="symbol"` in pages,
  `public/lib/ui/logo.js` in the app), so a wordmark is never doubled.
  `logo_width` and `logo_height` keep its proportions while it loads.
- `scripts/brand-pictures.mjs` (sharp) makes the web copies: `logo.webp`,
  the icon set in `public/brand/` (512, 192, 180 for iPhones, maskable for
  Android), `favicon.ico`, `share.jpg` for link previews, and
  `site.webmanifest`. Every page carries the same icon links. Join links get
  the preview picture at their own address (an HTMLRewriter in
  `src/index.ts`), so previews work on any domain.
- `sites.json` lists the sites this code runs on: account, worker, database,
  bucket, address. `wrangler.toml` is the plan for all of them and runs the
  laptop copy; `scripts/sites.mjs` writes each site's copy (`.site-<id>.toml`)
  before running wrangler for it.

## The public landing page

`public/index.html` and `public/css/landing.css` form the public home page.
`public/lib/landing/landing.js` coordinates it, lazily loading the local demos
in `demos.js`; `sound.js` supplies synthesized interaction sounds. These
previews use sample data and pause their ongoing work when inactive. The
code box and account/early-access flows still connect to the app.

[The landing-page guide](LANDING.md) records the hero tour, AI preview, video
behavior, volume and timing controls, responsive rules and validation limits.

## One source of truth for the deck format

`public/lib/core/` is plain JavaScript that runs in the browser **and** in the
Worker:

| File | Holds |
| --- | --- |
| `spec.js` | The deck format: themes, settings, validation, repair, beats. |
| `layouts.js` | Every stage layout and phone view, as field lists. The parts of a video slide. |
| `interactions.js` | Every question type: fields, answer checks, roll-ups (and split roll-ups), presets. |
| `personal.js` | Questions that depend on who answers: who sees a screen, `{name}` and `{question}` tokens, splits. |
| `fields.js` | The field mini-language: validation, defaults, descriptions. |
| `patch.js` | JSON Patch with id paths, merge, and exact inverses for undo. |
| `sources.js` | Folding re-imported files into a deck without losing work. |
| `css.js` | A deck's own CSS, scoped so it can never touch the app around it. An `@import` stays only for a font service's sheet, its address read the way a browser reads it. |
| `sandbox.js` | The two tiers a presenter's own HTML runs in: the open stage tier and the sealed audience tier (phones). Builds each one's Content-Security-Policy. |
| `guard.js` | What a room is never asked for: a card number (Luhn), a password or one-time-code field. |
| `words.js` | Words that wait for the presenter: a name, a word or a question from the room with a swear word or a slur in it. |
| `kit.js` | The library kit's import map and catalog (three.js, GSAP, and more), written by `scripts/kit.mjs`; merging a page's own map into it. |
| `motion.js` | Transitions as data: the built-ins, checking a design, resolving what plays. |
| `ids.js`, `templates.js` | Ids and join codes; the tour deck. |
| `starters.js` | Starter templates for custom slides and phone screens. |
| `catalog.js` | The catalog agents read, generated from the above. |
| `brief.js` | The note a presenter pastes into their own AI with a request, and the link that opens a deck. |

The editor draws its panels from the same field lists the server validates
with, and the agent catalog is generated from them too. They cannot disagree.

Drawing lives in `public/lib/render/`, shared by the stage, the console, the
editor and phones:

| File | Draws |
| --- | --- |
| `slide.js` | One slide at one beat, for any screen and any size. |
| `views.js` | Live results: bars, pillars, the ball pit, clouds, walls, splits. |
| `pit.js` | The ball pit's physics: a small solver that sleeps once settled. |
| `inputs.js` | What phones answer with. |
| `coded.js` | Coded decks and Keynote exports, in sandboxed frames (with `/kit/bridge.js`). |
| `frames.js` | The documents a custom slide and a phone screen are written into: the bridge, the kit's import map and the deck's theme, and the relay that hands the frame the kit files it imports. |
| `motion.js` | The motion engine: plays a transition with the browser's own animations (layers, pieces one by one, magic move, backwards in time for going back). Built for frames as `/kit/motion.js` by `scripts/motion.mjs`. |
| `videos.js` | One video player per file under the slides, so the parts of a video deck play on without reloading. |

`public/lib/live/client.js` is the one connection every screen and phone
holds to the room: it falls back to following the room's feed over HTTP,
and backs off when the room does not answer.

## Adding a question type

Say, **ranking**: drag options into order.

1. **`public/lib/core/interactions.js`**: add `TYPES.ranking` with `fields`,
   `check(ix, value)` (is this a valid order?), `aggregate(ix, rows)` (average
   position per option), `views` (`['bars']` to start) and `sample(ix)`.
   Add a preset to `PRESETS` if it deserves a tile.
2. **`public/lib/render/inputs.js`**: add `INPUTS.ranking(host, ctx)`, the
   phone screen. Call `ctx.send(value)` when they are done.
3. **`public/lib/render/views.js`**: a new view only if none fits.

That is all. The Worker, the live room, the editor, validation, the agent
catalog and the MCP tools pick it up from those files.

## Adding a layout

1. **`public/lib/core/layouts.js`**: add it to `LAYOUTS` with `fields`,
   `steps(stage)` and `make()`.
2. **`public/lib/render/slide.js`**: add its HTML to `HTML`, sized in `cqw`
   and `cqh`, with `data-s="n"` on whatever lights up on beat `n`.
3. **`public/css/slide.css`**: its styles, using only theme variables.

The editor's insert sheet and Screen tab show it automatically.

## The live protocol

Messages are small JSON objects on one WebSocket.

| From | Message |
| --- | --- |
| phone | `answer {iid, value, rid}`, `vote {item, on}`, `react {k, n}`, `name {name}`, `unsend {item}`, `signal {k, d}`, `sync` |
| presenter | `goto {slide, step, nonce}`, `nav {dir}`, `control {action, ...}`, `broadcast {k, d}`, `names {q}`, `sync` |
| room to phones | `welcome`, `view {seq, view, spread}`, `ack {rid, ...}`, `agg`, `clear`, `epoch`, `deck`, `crowd`, `signal {k, d}` |
| room to screens | `hello`, `state`, `agg`, `people`, `cheer`, `deck`, `play`, `signal {k, d, from}`, `names`, `remotes` |

Live signals are cues kept nowhere. A phone screen's `Room.emit` is a
`signal` to the presenter's screens, with `from`, a short stable tag for that
phone; the big screen's `Stage.broadcast` (or an agent's `broadcast`
command) is a `signal` to every phone. Names are letters first, up to 32,
and the data is JSON up to 512 bytes. A phone sends at most 15 a second and
the big screen 20; a phone's signal is dropped while the room is paused, or
if a string in it is a word the room would filter.

A `view` meant for only some people carries `when` and `otherwise`; every
phone gets the same view and picks its own branch, and the room picks the
same way before it keeps an answer. Results split by an earlier answer
travel as `agg` under `question@by`.

`src/room.ts` handles everything in `handle()`. Phones that cannot hold a
WebSocket (some venue networks break them) send with `/live/CODE/act`,
through the same `handle()`, and follow `/live/CODE/events`: the room's
numbered feed of every message to phones, read with long polls. A read
answers at once with what came after the last one, or waits for the next
message, or 25 seconds; a phone that is new, behind, or from before the
room restarted (another `era`) gets what phones see now. They hear what
phones with a socket hear, as fast, and a quiet room costs one request a
phone every 25 seconds. (`/live/CODE/view` stays for pages from before.)

From 250 people (`CROWD`), the room tells phones to show still stickers:
15 KB a phone instead of 228 KB, on a Wi-Fi a whole room shares.

The stage moves the moment a key is pressed and tells the room after, with a
nonce so its own echoes are ignored. If the network drops, the talk carries on
and the room catches up on reconnect.

## Security, briefly

- Passwords: PBKDF2-SHA256. Sessions and agent keys are random tokens, stored
  only as hashes.
- Browser requests that change anything must carry the app's own header, so
  other sites cannot forge them.
- Every user file and every coded deck is served sandboxed, with no origin, so
  even hostile HTML or SVG cannot reach anybody's session.
- Custom HTML runs in sandboxed frames and talks only through `postMessage`
  bridges (`/kit/bridge.js`, `/kit/phone.js`). There are two tiers
  (`core/sandbox.js`). The **stage tier**, on the presenter's own machine (the
  big screen, the console, the editor), is open inside the frame: any library
  from any CDN, but no popups and no downloads, in coded decks and custom
  slides alike. The **audience tier**, on phones, is sealed: served at `/p/`
  with a strict per-request Content-Security-Policy that lets a page reach only
  the self-hosted library kit (`/vendor/kit/`, built by `scripts/kit.mjs`), the
  deck's own files and a font service. No fetch, picture, socket, form or
  navigation reaches another site, so whatever a presenter's screen asks for,
  anything typed into it can go only to the room. The phone page may frame only
  `/p/`, and nothing under `/p/` redirects, so a screen cannot slip its frame
  to a looser policy.
- The room is the last gate on what the audience sends (`core/guard.js`): a
  payment-card number, or a field named like a password or a one-time code, is
  refused, and a phone screen asking for one is never served. Every custom
  phone screen also carries a line, which it cannot hide, saying never to type
  a secret into it.
- The big screen sees public roll-ups only: a filtered word and a
  hidden name stay on the moderation console and never reach the projector, or
  a coded or custom deck drawing the answers there. The console is sent the
  public roll-ups too, worked out by the room beside its own, and a custom
  slide it shows gets only those.
- A presenter's phone screen is told the person's own name (`Room.me.name`)
  only while the room shows it. A filtered, hidden or removed name reaches
  no screen, and the room never sends a phone anybody else's.
- A deck's CSS is scoped to the deck (`core/css.js`). The one way out of the
  scope, `@import`, is kept only for a font service's sheet over https, and
  only when its address is written plainly: no escapes, so what is checked is
  what the browser fetches.
- What a phone says in its own voice (the line under a presenter's screen,
  where a link really goes, asking to use the tilt, a refused card number)
  lives in `ui/trusted.js`: a closed shadow root in the browser's top layer,
  its host's styles pinned with `!important` from inside, outside the part of
  the page a deck's CSS can reach. A deck can restyle every question, never
  those words.
- Transitions a deck designs are data (`core/motion.js`): checked on save
  against a list of visual properties, with `url()`, braces and semicolons
  refused and every time clamped, and played with `Element.animate`, which
  takes values, never code. Code that moves things runs only inside a sandboxed
  frame: a coded deck, a custom slide, a phone screen.
- Phones never see speaker notes or a quiz's answer before it is revealed.
- Photos are shrunk on the phone before upload, which also strips location data.
  The room's photos and voice notes are served `private`: never kept by a
  shared cache, so deleting a session deletes them everywhere.
- MCP takes a key, never a cookie. A cookie opens the presenter's socket only
  from this site's own pages.
- Sign-in limits count an IPv6 address by its /64. A browser that has signed
  in to an account before is counted on its own, so strangers' guesses never
  lock the presenter's own laptop out (`known_browsers`). The audience path has no
  per-address limits (a venue is hundreds of phones behind one address); only
  different wrong codes count, 300 a minute per place, so a script cannot hunt
  for live rooms, while a whole room typing the same stale code counts once.
- Room safety. `public/lib/core/words.js` filters words, questions and
  names with a swear word or slur (English, Hinglish, some Devanagari, seen
  through l33t, spacing and stretching): each waits for the presenter's OK;
  the room stores why in the item's meta. A custom screen's answer is read
  the same way, every string and key in it, and a filtered one is kept for
  the console and left out of every public roll-up until the presenter shows
  it. The deck's switch and each question's own ("Filter bad words") must
  both be on for a question's answers to be filtered; the deck's own words
  wait regardless. A filtered or hidden name shows as "Guest 12" wherever
  names are public (`people.hidden`: 1 filtered, 2 hidden by the
  presenter). The presenter can remove a person (`people.removed`: their
  answers, items, votes and files go, their sockets close with 4403 and the
  reason), pause answers (`state.paused`, also on phones' view) and close the
  room to new people (`state.locked`).

## Offline mode

The same Worker, run on the presenter's computer for a venue with bad
internet or none: built into one minified file (the bundle `wrangler
deploy` uploads) and run by Miniflare on workerd, Cloudflare's own runtime.
It starts in a fraction of a second, and the desktop app ships the same
file, never the source. The full
design is `design/OFFLINE-MODE.md`; the presenter's guide is
[Offline mode](OFFLINE.md).

```
 phones ──venue Wi-Fi──> audience door ──> the copy (the same Worker)
                         offline/door.mjs   listens on localhost only,
                         an allow list:     its own D1, R2 and rooms
                         join, phone page,  in .offline/
                         the room's socket        ^
                                                  | localhost
                                       the presenter's browser
```

- **Two doors.** The copy listens on this computer only: the presenter's
  door. Phones come in through the audience door (`offline/door.mjs`), which
  passes on only what a phone needs and answers 404 to everything else: no
  app, no API, nothing a cookie opens. It sends each request on with the
  phone's own address, and without any cookie, Authorization or header a
  phone could pass itself off with. The presenter's phone as the clicker
  and a second screen come in the same way as online, with their keys.
- **Two addresses.** The copy calls itself by the venue address
  (Miniflare's `upstream`), so join links and the QR name what phones reach,
  while the presenter's own screens stay on localhost (`originsOf` in
  `src/live.ts`, `meta.origin` in the room).
- **Linking a computer.** It shows a code; the presenter allows it in the
  app; it collects a key of its own with scope `offline`, which reads that
  presenter's decks and files and brings rooms back, and is no key at all
  anywhere else (`SCOPES` in `src/auth.ts`).
- **Copying.** `offline/prepare.mjs` copies the account (the same ids), the
  decks and every file they use into the copy, with its own secret
  (`OFFLINE_SECRET`, `src/offline.ts`). Again, only what changed.
- **Bringing rooms back.** The room packs every row it keeps
  (`src/room-dump.js`); the cloud checks every field before it keeps any,
  unpacks it into the session's own room, ended, and asks for the photos and
  voice notes it does not have. An ended room then leaves the computer.
- **Off on a copy:** sign ups, keys, emails, the AI tools and fetching from
  the web (`CLOUD_ONLY` in `src/index.ts`).
- **The desktop app** (`desktop/`, Electron, one code base for Mac and
  Windows). `desktop/src/engine.mjs` does everything (link, decks, copy,
  start, stop, sync) on top of `offline/`, and runs in plain Node for
  tests; `main.mjs` is the glue: the windows, the keychain (the key is kept
  encrypted by the system, never in a file as text), the stage on the
  second display, sleep kept off while the room runs. Every window is
  sandboxed with context isolation; only our own pages load in them, and
  the control window reaches the app through one channel and a short list
  of calls. `desktop/build.mjs` stages only what the app runs: the minified
  Worker (no source map), `public/`, the migrations, Miniflare and workerd.
  The runtime and what it reads sit outside the app's archive, where a
  program can run; `MINIFLARE_WORKERD_PATH` points Miniflare there.

## The library kit, and why the page hands it over

`scripts/kit.mjs` builds the kit in one pass, with shared chunks, so every
library that needs React (or three's core) gets the same copy; CommonJS
packages are given their names one by one; three's whole `examples/jsm` tree
is there as `three/addons/...`. Inside the kit, files name each other
`@kit/<path>`, never by a relative path, and `graph.json` says which file
needs which.

A frame with no origin of its own never reuses the browser's cache (measured:
every new frame downloads a module again). So for a custom slide and a phone
screen, the page around the frame, whose cache works, fetches the kit files
the author's code imports (`render/frames.js`) and sends their text with the
document; `kit/frame.js` makes each a blob: module and points the import map
at it. A phone downloads three.js once a talk, not once a slide. Coded decks
load once and stay loaded, so they fetch by address.

## Moving between slides

`render/motion.js` plays a transition (`core/motion.js`) on layers: the
leaving and arriving slide, and the coded deck's frame and the video under
them when those change too. The layers over a coded deck's frame catch the
pointer, so a click on the edge moves the show. Only while the deck or the
slide allows the mouse (`settings.mouse`, `stage.mouse`, off by default,
`core/spec.js codedMouse`) and its bridge is listening do they let it
through (`css/stage.css`), so the deck takes the mouse, trackpad and pen;
the app's own controls stay above. A frame is listening once its bridge
says ready, until it loads again (`render/coded.js`). A page the deck
linked to is not: it hears no answers, takes no pointer, and the keys come
back to the stage. A move going back plays backwards in time unless it
has its own `back`. A press while a move plays ends that move at once and
starts the next, so slides never stack. A coded deck's own pages play the
same definitions through `/kit/motion.js` in its frame, with magic move
between `data-morph` pieces, and `Stage.transition(fn)` for code of its own.

## Limits worth knowing

- A deck: 500 slides, 300 questions, about 1.5 MB of JSON.
- A session: one Durable Object. Comfortable into the thousands of phones;
  results reach phones at most every 1.5 seconds (a little slower above a
  thousand phones, 3 seconds at 2000) so a big room stays light. Phones are
  sent at most 40 items of a list. After a drop, phones come back spread over
  2.5ms per phone in the room (`spread`, at most 8 seconds), about 400 a
  second, under what one Durable Object takes. A session makes room for
  10,000 people and keeps 4 GB of photos and voice notes.
- A phone keeps anything the room has not acknowledged and sends it again on
  the next line, for five minutes; the room takes a repeat as the same answer.
- Uploads: pictures 25 MB, videos 95 MB, PDFs and zips 60 MB, coded deck
  files 25 MB each, phone photos 12 MB, voice notes 6 MB. Each presenter
  has 2 GB unless the owner says otherwise.

## Tests

```bash
npm test          # the deck format, patches, question types, transitions, the sandbox tiers, the live room, room safety, the brand
npm run test:setup  # setup and deploy, against a pretend Cloudflare
npm run dev       # in one terminal
npm run test:live # in another: accounts, decks, a live room with six phones, MCP
npm run test:offline  # also with npm run dev: offline mode end to end, PHONES=1500 for a crowd
npm run test:desktop  # also with npm run dev: the desktop app's engine, as its buttons drive it
node test/desktop-smoke.mjs  # the app itself, windows hidden, a picture of every screen
PHONES=300 node test/load.mjs   # a crowd: 300 phones join and answer at once
npx playwright-core install chromium webkit firefox   # once, for the browser tests
npm run test:browser  # also with npm run dev: the sealed phone tier, the library kit, the Stage bridge, phone screens, the stage, in a real browser
BROWSER=webkit npm run test:browser   # the same in Safari's engine; BROWSER=firefox for Firefox
npm run check     # writing style and file weights
```
