# Joocy: the guide for agents

Joocy runs interactive presentations. A presenter shows slides on a big screen,
the audience joins on their phones, and every slide decides what the phones show:
a question, a picture, buttons, or simply "look up". Answers appear on the screen live.

You can make new decks and change the presenter's own, add questions, restyle
everything, write slides and phone screens in HTML with three.js, GSAP and
more, design transitions, start a live session, drive it, and read the results.

- Base URL: https://joo.cy/api/v1
- Auth: send `Authorization: Bearer $JOOCY_TOKEN` on every call. The presenter
  makes a key in the app under **Your AI**. This guide and the catalog need none.
- A key works on decks, files and live sessions. It can never delete for good
  (a deck, an upload, a session's answers), make or remove keys, change the
  presenter's account, pair a phone as their clicker or use the owner's powers:
  those are the presenter's own, in the app, and a key gets 403.
- MCP: the same abilities as tools at `https://joo.cy/mcp` (same header), or at
  `https://joo.cy/mcp/t/<key>` for clients that cannot send headers.
- Everything is JSON. Errors come back as `{ "error": "...", ... }` with a real
  HTTP status.

## Contents

- How to work
- The deck
- Patch ops
- Everything you can use
- Styling: questions, answers, everything
- Coded decks (HTML)
- Full HTML, WebGL and animation (custom slides and phone screens)
- Transitions: built in, and your own
- Live signals, during a talk
- Video slides
- Questions that depend on who answers
- Finding and organising decks
- Live sessions
- Writing for a room

This guide is long. Each part can be read on its own: `get_guide` with
`section` (its name above, like "Patch ops"), or
`GET https://joo.cy/api/v1/guide?section=Patch%20ops`.

## How to work

The presenter's request says what to do: a new deck, a change to one they
have, a deck written in HTML, a live session, or what a room answered. Do
that, and leave every other deck as it is.

**A new deck** (whenever the request does not name one they have): build the
whole presentation as one spec (below) and send it in one call,
`POST /decks` with `{ "spec": ... }`. It is checked before it is saved; you
get back `{ id, title, version, spec }` with 201. `{ "title": "..." }` alone
makes a blank deck. `POST /decks/{id}/duplicate` copies a deck they have,
with its uploaded files, to make something new from it.

**A deck they have:**

1. Find it: `GET /decks?q=words` searches every word in every deck, and
   `GET /decks` lists them all. `GET /decks/{id}` reads one: you get
   `{ id, title, version, spec }`. `spec` is the whole presentation.
2. Change it with `PATCH /decks/{id}` and a list of ops (below). This is the
   safe way: it applies to whatever is current, so the presenter can keep
   editing in the app while you work. Change only what they asked for.
3. Or replace it whole with `PUT /decks/{id}` and `{ "spec": ..., "version": n }`,
   where `n` is the version you read. A stale version gets 409: read again.

**Either way:**

- If the deck is not right, nothing is saved and you get 422 with
  `problems: [{ path, message }]`. Each path points at the exact field.
  Fix those and send again. `POST /decks/{id}/validate` checks a spec without saving.
- Every save is kept. `GET /decks/{id}/versions` lists them and
  `POST /decks/{id}/versions/{v}/restore` goes back, so any change to a deck
  can be undone. A file uploaded again replaces the one at that path.
- When you finish, tell the presenter what you made or changed, with the link
  that opens it in the app: `https://joo.cy/app/#/d/{id}`.

## The deck

```json
{
  "format": "stoa.deck/1",
  "title": "AI for Creative People",
  "aspect": "16:9",
  "theme": { "preset": "aurora" },
  "settings": { "reactions": ["heart", "laugh", "wow", "mindblown", "fire"] },
  "interactions": {
    "mood": { "type": "choice", "prompt": "How are you feeling?",
              "options": [{ "id": "good", "label": "Good" }, { "id": "meh", "label": "Meh" }] }
  },
  "slides": [
    { "id": "s_hello", "stage": { "type": "title", "title": "Hello" } },
    { "id": "s_mood",
      "stage": { "type": "results", "interaction": "mood", "view": "bars" },
      "phone": { "type": "ask", "interaction": "mood" },
      "notes": "Read the top answer out loud." }
  ]
}
```

- A slide has a **stage** (the big screen) and a **phone** (what the audience sees).
  No phone means "look up at the screen".
- Questions live in `interactions`, keyed by an id you choose (letters first:
  `mood`, `q_rate`). A slide asks one with `phone: { type: "ask", interaction }`
  and shows its answers with a `results` stage, or with an `overlay` on any slide:
  `"overlay": { "interaction": "mood", "place": "bottom" }`. Asking on one slide
  and showing the results three slides later is fine.
- The most common pair is one slide that does both: results on the stage, the
  question on the phones. That is what the editor's "Question" button makes.
- Imported pages are `{ "type": "image", "src": "/a/..." }`. Keep their `src`,
  `thumb`, `mid`, `source` and `page` as they are.
- `notes` are speaker notes. Only the presenter sees them.
- `skip: true` keeps a slide in the deck but never shows it.
- `phoneByStep: { "1": {...} }` changes what phones show from a later beat of the
  same slide, for slides that build.
- Only store what you mean. Defaults fill everything else in.
- Choice options can be sent as plain strings (`"options": ["Yes", "No"]`): ids are
  added for you. Slide ids are added for you too.
- Pictures: never invent a URL. `POST /files/from-url` with `{ "url": "https://..." }`
  copies a picture in and returns `{ "url": "/a/..." }` to use. Or upload bytes with
  `POST /files?name=x.png` and the right content-type.

## Patch ops

JSON Patch (RFC 6902) with two additions:

- Address list items by id: `/slides/@s_mood/notes`. Adding at `/slides/@s_mood`
  inserts before that slide. `/slides/-` appends.
- `merge` sets several fields at once; a field set to `null` is removed.

```json
{ "ops": [
  { "op": "merge", "path": "/slides/@s_hello/stage", "value": { "title": "Welcome", "subtitle": "Phones out" } },
  { "op": "add", "path": "/interactions/rate", "value": { "type": "scale", "prompt": "Rate today", "max": 5 } },
  { "op": "add", "path": "/slides/-", "value": { "stage": { "type": "results", "interaction": "rate" },
      "phone": { "type": "ask", "interaction": "rate" } } },
  { "op": "move", "from": "/slides/@s_mood", "path": "/slides/0" },
  { "op": "remove", "path": "/slides/@s_old" }
] }
```

## Everything you can use

The full machine-readable list is `GET /catalog`. In short:

### Layouts: `slide.stage.type`
- **image**: An imported page, or any picture. `src` (asset, required), `fit` (select, contain|cover, default "contain"), `caption` (text, max 140), `alt` (longtext, max 4000)
- **html**: One slide of an HTML deck you uploaded. `source` (ref, required), `index` (number, required, 0..5000), `steps` (number, default 0, 0..50), `mouse` (bool)
- **video**: A video on the big screen. Pause points make it a slide that builds: each press plays on to the next pause. A PowerPoint or Keynote deck exported as a video keeps every animation this way, and each part between pauses can be a slide of its own (from and to), so the deck has thumbnails, notes and questions in between. `src` (asset, required), `stops` (times), `muted` (bool, default false), `loop` (bool, default false), `from` (number, 0..86400), `to` (number, 0..86400), `fit` (select, contain|cover, default "contain"), `poster` (asset)
- **title**: Title. `eyebrow` (text, max 60), `title` (text, required, max 140), `subtitle` (text, max 200)
- **statement**: One idea per line. Each line arrives on its own press. `lines` (lines, required, max 160), `small` (text, max 220), `build` (bool, default true)
- **takeaway**: The thing they leave with. Loud on purpose. `tag` (text, default "Remember", max 24), `n` (number, 0..99), `lines` (lines, required, max 60), `note` (longtext, max 400)
- **bullets**: Short items. Each arrives on its own press. `title` (text, max 140), `items` (lines, required, max 140), `build` (bool, default true)
- **quote**: Quote. `text` (longtext, required, max 400), `by` (text, max 80)
- **number**: One number, huge. It can follow a question live. `eyebrow` (text, max 60), `value` (text, max 24), `live` (live), `label` (text, max 160)
- **split**: Words and picture. `title` (text, max 140), `body` (longtext, max 600), `src` (asset), `side` (select, left|right, default "right"), `fit` (select, cover|contain, default "cover")
- **qr**: Any link, as a code the room can scan. `url` (url, required), `title` (text, max 140), `caption` (text, max 140)
- **join**: The code to join, with the live headcount. `title` (text, default "Join in", max 140), `sub` (text, max 160)
- **results**: Live answers to a question, drawn big. `interaction` (ref, required), `view` (view), `by` (ref), `title` (text, max 280), `count` (bool, default true)
- **leaderboard**: Quiz scores so far. `title` (text, default "Leaderboard", max 140), `top` (number, default 8, 3..20)
- **embed**: YouTube, Vimeo, or any page that allows it. `url` (url, required), `title` (text, max 140)
- **countdown**: Breaks and exercises. Starts when the slide does. `title` (text, default "Back in", max 140), `minutes` (number, default 5, 0.25..180)
- **custom**: Your own HTML and script, safely boxed in. Gets live answers too. `html` (code, required, max 200000), `steps` (number, 0..50)
- **blank**: Just the background. For when you want them looking at you. `text` (text, max 120)

### Phone views: `slide.phone.type`
- **follow**: A calm card that sends their eyes back to you. `text` (text, max 140)
- **ask**: They answer. The screen counts it live. `interaction` (ref, required)
- **card**: Words, a picture, buttons and links. `blocks` (blocks, required)
- **mirror**: Their phone shows what the screen shows. Good for the back row. no fields
- **results**: The answers to a question, on every phone. `interaction` (ref, required), `view` (view)
- **custom**: Your own phone screen, as HTML, on every phone: three.js, GSAP, Motion, React and more from the library kit. It runs sealed: it can reach nothing but the room. Room.send(value) saves an answer (any JSON, up to 4 KB) into its question, which is a custom one; Room.emit(name, data) sends a live signal to the big screen; Room.on("signal", fn) hears the big screen; Room.on("data", fn) hears the room's answers when phones may see them. Or use a page from an uploaded folder: source and src. `html` (code, max 100000), `interaction` (ref), `fill` (bool, default false), `source` (text, max 48), `src` (text, max 300)

### Card blocks: `slide.phone.blocks[].type`
- **text**: `text` (longtext, required, max 600), `size` (select, big|normal|small, default "normal")
- **image**: `src` (asset, required), `alt` (text, max 200)
- **button**: `label` (text, required, max 40), `url` (url, required)
- **social**: `platform` (select, instagram|linkedin|x|youtube|tiktok|whatsapp|website, default "instagram"), `handle` (text, required, max 60), `url` (url), `line` (text, max 140), `photo` (asset)
- **file**: `label` (text, required, max 40), `src` (asset, required)

### Question types: `interactions.<id>.type`
- **choice** (Poll). Results as: bars, columns, donut, grid, stickers. Fields: `prompt` (text, required, max 280), `hint` (text, max 160), `view` (view), `reveal` (select, live|click, default "live"), `phones` (select, never|after|reveal, default "never"), `open` (select, slide|always, default "slide"), `options` (options, required), `multi` (bool, default false), `max` (number, 2..24), `exclusive` (ref), `style` (select, auto|list|grid|stickers, default "auto"), `correct` (ids), `points` (number, default 1000, 0..10000), `timer` (number, default 0, 0..600), `shuffle` (bool, default false), `change` (bool)
- **scale** (Rating). Results as: average, bars. Fields: `prompt` (text, required, max 280), `hint` (text, max 160), `view` (view), `reveal` (select, live|click, default "live"), `phones` (select, never|after|reveal, default "never"), `open` (select, slide|always, default "slide"), `min` (number, default 1, 0..1), `max` (number, default 5, 2..100), `minLabel` (text, max 40), `maxLabel` (text, max 40), `style` (select, auto|stars|numbers|slider, default "auto"), `change` (bool, default true)
- **text** (Open question). Results as: wall, cloud. Fields: `prompt` (text, required, max 280), `hint` (text, max 160), `view` (view), `reveal` (select, live|click, default "live"), `phones` (select, never|after|reveal, default "never"), `open` (select, slide|always, default "slide"), `asName` (bool, default false), `max` (number, default 120, 5..500), `entries` (number, default 1, 1..10), `placeholder` (text, max 80), `filter` (bool, default true), `moderate` (bool)
- **number** (Number). Results as: average, histogram. Fields: `prompt` (text, required, max 280), `hint` (text, max 160), `view` (view), `reveal` (select, live|click, default "live"), `phones` (select, never|after|reveal, default "never"), `open` (select, slide|always, default "slide"), `min` (number), `max` (number), `unit` (text, max 12), `unitFirst` (bool, default false), `decimals` (bool, default false), `correct` (number), `change` (bool, default true)
- **photo** (Photo). Results as: wall. Fields: `prompt` (text, required, max 280), `hint` (text, max 160), `view` (view), `reveal` (select, live|click, default "live"), `phones` (select, never|after|reveal, default "never"), `open` (select, slide|always, default "slide"), `entries` (number, default 1, 1..5), `moderate` (bool, default true)
- **audio** (Voice note). Results as: list. Fields: `prompt` (text, required, max 280), `hint` (text, max 160), `view` (view), `reveal` (select, live|click, default "live"), `phones` (select, never|after|reveal, default "never"), `open` (select, slide|always, default "slide"), `seconds` (number, default 30, 5..120), `entries` (number, default 1, 1..3), `moderate` (bool, default true)
- **qa** (Q&A). Results as: top, wall. Fields: `prompt` (text, required, max 280), `hint` (text, max 160), `view` (view), `reveal` (select, live|click, default "live"), `phones` (select, never|after|reveal, default "never"), `open` (select, slide|always, default "slide"), `votes` (bool, default true), `names` (bool, default false), `filter` (bool, default true), `moderate` (bool, default false)
- **form** (Sign-up). Results as: count. Fields: `prompt` (text, required, max 280), `hint` (text, max 160), `view` (view), `reveal` (select, live|click, default "live"), `phones` (select, never|after|reveal, default "never"), `open` (select, slide|always, default "slide"), `fields` (formfields, required), `consent` (text, default "I am happy to be contacted about this.", max 200), `button` (text, default "Send", max 30)
- **custom** (Custom). Results as: raw. Fields: `prompt` (text, max 280), `hint` (text, max 160), `view` (view), `reveal` (select, live|click, default "live"), `phones` (select, never|after|reveal, default "never"), `open` (select, slide|always, default "slide"), `change` (bool, default true), `filter` (bool, default true)

Choice options: `id` (text, required, max 48), `label` (text, max 80), `glyph` (glyph), `image` (asset). Sign-up fields: `key` (text, required, max 32), `label` (text, required, max 60), `kind` (select, text|email|phone|long, default "text"), `required` (bool, default false).

### Overlay: `slide.overlay`
`interaction` (ref, required), `view` (view), `place` (select, bottom|right|center|full, default "bottom")

### Theme: `theme`
Presets: aurora, marble, ink, paper. Fonts: didot, serif, sans, heavy, rounded, mono. Fields: `preset` (select, aurora|marble|ink|paper, default "aurora"), `ground` (color), `surface` (color), `ink` (color), `dim` (color), `faint` (color), `accent` (color), `accent2` (color), `accent3` (color), `gold` (color), `line` (color), `display` (select, didot|serif|sans|heavy|rounded|mono), `body` (select, didot|serif|sans|heavy|rounded|mono), `aurora` (number, 0..1), `radius` (number, 0..32), `scheme` (select, dark|light), `css` (code, max 100000), `stickers` (select, animated|drawn, default "animated"), `glyphs` (glyphmap), `logo` (asset)

### Settings: `settings`
`reactions` (lines, default ["heart","laugh","wow","mindblown","fire"], max 32), `joinBadge` (select, auto|always|off, default "auto"), `names` (select, never|quiz|always, default "quiz"), `filter` (bool, default true), `blocked` (lines, default [], max 40), `sound` (bool, default true), `transition` (transition, default {"type":"fade"}), `transitions` (motions), `qa` (ref), `mirror` (bool, default false), `mouse` (bool, default false)

### Transitions: `settings.transition` and `slide.transition` (`{ type, duration }`), and your own in `settings.transitions`
- **fade**: A soft dissolve: one fades as the next arrives. The house default.
- **none**: No animation: an instant change.
- **slide**: The next slide pushes the last one off, the way you are moving.
- **rise**: The next slide rises up as the last lifts away.
- **zoom**: The last slide grows past you; the next one comes in from the distance.
- **blur**: A dreamy blur out and back into focus.
- **iris**: A circle opens from the middle onto the next slide.
- **wipe**: The next slide wipes across the last, right to left.
- **curtain**: The next slide rises like a curtain, bottom to top.
- **flip**: The slide turns over like a card, the way you are moving.
- **spin**: A small turn and scale as one hands over to the next.
- **drop**: The next slide drops in from above, with a little bounce.
- **glide**: Slow and cinematic: a long dissolve with a gentle drift.
- **morph**: Pieces both slides share glide into their new places and sizes; the rest dissolves. Titles, numbers, pictures and lines pair by role, or by name in your CSS (view-transition-name) and data-morph.

### Library kit: import these in a coded deck, custom slide or phone screen
- `three` (0.186.1): 3D and WebGL: scenes, meshes, materials, cameras, lights.
- `three/webgpu` (0.186.1): three.js on WebGPU (WebGPURenderer), with node materials.
- `three/tsl` (0.186.1): three.js shading language: write shaders in JavaScript.
- `three/addons/...` (0.186.1): Every three.js add-on: OrbitControls, GLTFLoader, DRACOLoader, EffectComposer, UnrealBloomPass, Water, Sky and the rest. Import by path, as in three’s own examples.
- `gsap` (3.15.0): The GSAP animation engine: tweens and timelines.
- `gsap/all` (3.15.0): GSAP and every plugin at once.
- `gsap/ScrollTrigger` (3.15.0): GSAP ScrollTrigger. Register it: gsap.registerPlugin(ScrollTrigger).
- `gsap/ScrollSmoother` (3.15.0): GSAP ScrollSmoother. Register it: gsap.registerPlugin(ScrollSmoother).
- `gsap/Flip` (3.15.0): GSAP Flip. Register it: gsap.registerPlugin(Flip).
- `gsap/SplitText` (3.15.0): GSAP SplitText. Register it: gsap.registerPlugin(SplitText).
- `gsap/MotionPathPlugin` (3.15.0): GSAP MotionPathPlugin. Register it: gsap.registerPlugin(MotionPathPlugin).
- `gsap/MorphSVGPlugin` (3.15.0): GSAP MorphSVGPlugin. Register it: gsap.registerPlugin(MorphSVGPlugin).
- `gsap/DrawSVGPlugin` (3.15.0): GSAP DrawSVGPlugin. Register it: gsap.registerPlugin(DrawSVGPlugin).
- `gsap/Draggable` (3.15.0): GSAP Draggable. Register it: gsap.registerPlugin(Draggable).
- `gsap/InertiaPlugin` (3.15.0): GSAP InertiaPlugin. Register it: gsap.registerPlugin(InertiaPlugin).
- `gsap/Observer` (3.15.0): GSAP Observer. Register it: gsap.registerPlugin(Observer).
- `gsap/CustomEase` (3.15.0): GSAP CustomEase. Register it: gsap.registerPlugin(CustomEase).
- `gsap/CustomBounce` (3.15.0): GSAP CustomBounce. Register it: gsap.registerPlugin(CustomBounce).
- `gsap/CustomWiggle` (3.15.0): GSAP CustomWiggle. Register it: gsap.registerPlugin(CustomWiggle).
- `gsap/EasePack` (3.15.0): GSAP EasePack. Register it: gsap.registerPlugin(EasePack).
- `gsap/ScrollToPlugin` (3.15.0): GSAP ScrollToPlugin. Register it: gsap.registerPlugin(ScrollToPlugin).
- `gsap/TextPlugin` (3.15.0): GSAP TextPlugin. Register it: gsap.registerPlugin(TextPlugin).
- `gsap/ScrambleTextPlugin` (3.15.0): GSAP ScrambleTextPlugin. Register it: gsap.registerPlugin(ScrambleTextPlugin).
- `gsap/Physics2DPlugin` (3.15.0): GSAP Physics2DPlugin. Register it: gsap.registerPlugin(Physics2DPlugin).
- `gsap/PhysicsPropsPlugin` (3.15.0): GSAP PhysicsPropsPlugin. Register it: gsap.registerPlugin(PhysicsPropsPlugin).
- `gsap/PixiPlugin` (3.15.0): GSAP PixiPlugin. Register it: gsap.registerPlugin(PixiPlugin).
- `motion` (14.2.0): Motion (the engine behind Framer Motion): animate, spring, scroll, inView.
- `motion/react` (14.2.0): Framer Motion for React: <motion.div>, AnimatePresence, layout animations.
- `animejs` (4.5.0): Anime.js: a small, expressive animation library.
- `react` (19.3.1): React.
- `react/jsx-runtime` (19.3.1): React JSX runtime (jsx, jsxs).
- `react-dom` (19.3.1): React DOM.
- `react-dom/client` (19.3.1): React DOM createRoot.
- `htm` (3.1.1): JSX-like tagged templates, no build step.
- `htm/react` (3.1.1): htm bound to React: html`<div />`.
- `d3` (7.9.0): D3: data-driven documents, scales, shapes, selections.
- `pixi.js` (8.22.0): PixiJS: fast 2D WebGL rendering.
- `p5` (2.3.4): p5.js: creative coding sketches.
- `lottie-web` (5.13.0): Play Lottie / Bodymovin animations.
- `matter-js` (0.20.0): Matter.js: 2D rigid-body physics.
- `canvas-confetti` (1.9.4): Confetti bursts on a canvas.
- `chart.js` (4.5.1): Chart.js: bar, line, pie and more (auto-registered).
- `simplex-noise` (4.0.3): Simplex noise: createNoise2D, 3D, 4D.
- `tone` (15.1.22): Tone.js: synths, effects and timing for Web Audio.

### Stickers
hungover, sleepy, fine, buzzing, party, heart, laugh, wow, mindblown, fire, stressed, coin, poop, pray, catlaugh, thumbsup, thumbsdown, star, check, cross, sparkles, rocket, bulb, question, hundred, clap, popper, hearteyes, raisedhands, thinking, crying, cool, eyes, muscle, handshake, target, wave, trophy, gem, crown, rainbow, zap, boom, brain, moneyface, grin, wink, hug, shush, yawn

## Styling: questions, answers, everything

Presenters can ask you to restyle anything: question and answer sizes, spacing,
fonts, colours, the results, the phones. Write CSS.

Put CSS in `theme.css` (merge `/theme` with `{ "css": "..." }`). Any CSS, up to 100,000 characters. Every property works.

- Big screen: the whole stage page. Phones: the whole phone page. Editor and console: only the slide previews, never their controls.
- :root, html and body mean the deck itself. Variables set there override the theme.
- The deck itself is .deck-scope. On the big screen and on phones that is the page's body: body.stage-page on the big screen, body.phone on phones. So body.stage-page .x styles only the big screen, and body.phone .x only phones. In the editor and the console it is each slide preview.
- @media, @supports, @container, @layer, @keyframes and @font-face all work.
- @import works only for a font service's style sheet, over https: Google Fonts, Bunny Fonts, Fontshare or Adobe Fonts. Put it at the top, write the address plainly in url("..."), and end it with ";". A Google Fonts address with ";" in it is fine. Any other import is ignored.

**Variables**, set on `:root`:

- `--ground`: the background
- `--surface`: cards and panels
- `--ink`: main text
- `--dim`: quieter text
- `--faint`: quietest text
- `--accent`: the main highlight
- `--accent2`: second highlight
- `--accent3`: third highlight
- `--gold`: the gilt lines and marks
- `--line`: thin lines
- `--display`: the display font
- `--body`: the body font
- `--heavy`: the heavy font
- `--radius`: corner rounding

**Big screen and previews**:

- `[data-slide="s_id"]`: one slide (on the big screen and in previews)
- `[data-layout="results"]`: every slide of one layout
- `[data-mode="stage"]`: only the big screen (also: preview, thumb, phone)
- `[data-iid="mood"]`: slides that show this question
- `.L-eyebrow, .L-title, .L-sub`: small line above, title, line under
- `.L-line, .L-bullets li, .L-quote, .L-tk, .L-big, .L-caption`: statement lines, bullets, quotes, takeaway lines, big numbers, captions
- `.L-rtitle`: the question on a results slide
- `.view.v-bars (v-columns, v-donut, v-grid, v-stickers, v-average, v-histogram, v-cloud, v-wall, v-top, v-count)`: one results view
- `.vrow, .vcol`: one answer in bars or columns
- `.vlabel, .vfill, .vnum`: its label, its bar, its count and percent
- `.lead, .right, .wrong`: the leading answer; after a quiz reveal, right and wrong answers
- `.L-join-url, .L-join-n, .L-plaque`: join address, headcount, QR code

**Phones**:

- `#main[data-kind="ask"]`: what phones show (also: follow, results, card, mirror, custom, ended)
- `#main[data-type="choice"]`: one kind of question (choice, scale, text, number, photo, audio, qa, form)
- `#main[data-iid="mood"]`: one question
- `.q, .sub`: the question, and the hint under it
- `.opts (.grid, .list, .stickers)`: the options, and how they are laid out
- `.opt, .opt.on, .opt span`: one option, a picked option, its label
- `.sticker, .sticker b`: one sticker option, its label
- `.big, .field, textarea`: main buttons, text boxes
- `.note`: messages under a question
- `#top, #bar, .p-follow, .blocks`: header, reaction bar, the look-up screen, info cards

**Example**:

```css
@import url("https://fonts.googleapis.com/css2?family=Playfair+Display&display=swap");
:root { --accent: #FFB703; --display: "Playfair Display", serif; }
[data-kind="ask"] .q { font-size: 2.4rem; line-height: 1.1; }
[data-kind="ask"] .opts { gap: 1rem; }
[data-kind="ask"] .opt { border-radius: 999px; padding: 1.2rem 1.4rem; font-size: 1.2rem; }
.v-bars .vfill { background: linear-gradient(90deg, var(--accent), #FB8500); }
[data-slide="s_intro"] .L-title { text-transform: uppercase; letter-spacing: .05em; }
```

CSS cannot run code. Addresses in it (fonts, pictures) load from the web, so use https. Every save is versioned: if a change looks wrong, restore the version before it.

## Coded decks (HTML)

A presenter may already have a deck written in HTML, or you can write one. Rules:

- One HTML file. One element per slide with a `data-slide` attribute, in order.
- Optional per slide: `data-title`, `data-notes`, `data-id` (keeps settings
  stable when the file is uploaded again), `data-steps` or elements with
  `data-step="1"`, `data-step="2"` that light up one per arrow press.
- A slide can declare its own question: `data-ask='{"id":"tea","type":"choice","prompt":"Tea or coffee?","options":["Tea","Coffee"]}'`.
  Phones are asked it on that slide automatically. The `id` is how other slides refer to it.
- Show live answers inside your own design: `<b data-live="q1.n"></b>` fills in a
  number (`n`, `avg`, `total`, `people`, `top`), and
  `<div data-results="q1"></div>` draws simple bars you can style. Or listen in script:
  `Stage.on('data', (d) => ...)`.
- The bridge script is added for you when the file is served. Slides are shown one
  at a time and the presenter's arrow keys drive them.
- Upload: `PUT /decks/{id}/sources/{sourceId}/files/index.html` with the HTML as the
  body (and any other files at their paths), then
  `POST /decks/{id}/sources/{sourceId}/apply` with `{ "entry": "index.html", "scan": true }`.
- Upload again later and apply again: each slide keeps what is already set on it
  in the deck. That is its `phone`, `phoneByStep`, `notes`, `title` and
  `transition`. A page's question is made once, and changes only when the
  page's own question changes.
- So a new `data-phone`, notes or question id in the file does not reach a
  slide that already has one. To change those from code, write them into the
  deck after you apply: `PATCH` each slide's `phone`, `phoneByStep` and
  `notes`, or `PUT` the whole spec with the version you read. A deploy
  script does this every time.
- Keynote's HTML export (File, Export To, HTML) works as it is: upload every file
  with its path and apply with `"scan": true`. Its builds and transitions play
  exactly as designed, one step per click.
- **A deck that is one world** (a three.js scene the camera flies through, one
  long canvas): list its slides in
  `<script type="application/json" id="joocy-slides">[{ "id": "earth", "title": "Earth", "steps": 2, "notes": "...", "ask": {...}, "phone": {...} }]</script>`
  instead of sections, and draw each one in your own code with
  `Stage.on('goto')`, `Stage.on('step')` and `Stage.on('progress')` (0 to 1
  through the whole deck, moving smoothly between beats: bind a camera path to it).
- Pages move with transitions: `data-transition="zoom"` on a page (or JSON
  `'{"base":"slide","duration":600}'`), `<html data-transition="...">` for the deck.
  Pieces with the same `data-morph="name"` on two pages glide from one to the
  other (magic move). `Stage.transition(fn)` takes over entirely (below).
- Examples to start from, each a file you can upload as it is:
  https://joo.cy/kit/example-deck (every convention),
  https://joo.cy/kit/examples/world (a 3D world: camera path, phones light stars, answers as pillars),
  https://joo.cy/kit/examples/magic (magic move with data-morph),
  https://joo.cy/kit/examples/showcase (three.js, GSAP, a live poll, a phone screen).
- To try a deck on your own computer, add `<script src="https://joo.cy/kit/map.js"></script>`,
  `<script src="https://joo.cy/kit/motion.js"></script>` and `<script src="https://joo.cy/kit/bridge.js"></script>`
  to its head: the kit, the motion engine and the arrow keys work in any browser.
  More in "Trying it", below.
- Full detail: https://joo.cy/docs/coded-decks/

## Full HTML, WebGL and animation (custom slides and phone screens)

Beyond a whole coded deck, a single slide, or a single phone screen, can be
your own HTML with real libraries. Two places:

- **A custom stage slide** (the big screen): `{ "type": "custom", "html": "..." }`
  as a slide's `stage`. Add `"steps": n` for beats, or put `data-step="1"`,
  `data-step="2"` on elements that light up one per arrow press.
- **A custom phone screen** (every phone): `{ "type": "custom", "html": "..." }`
  as a slide's `phone`, with an optional `"interaction"` to save answers into
  (a question of type custom: `"interactions": { "pick": { "type": "custom" } }`),
  `"fill": true` for edge to edge, or `"source"` + `"src"` to use a page from an
  uploaded folder (so it can load models, textures and sound by relative path).

Both run sandboxed with no origin of their own, open no new windows and save
no files, and an import map is added so you can use the library kit with bare
imports:

```html
<script type="module">
  import * as THREE from 'three';
  import { gsap } from 'gsap';
  import confetti from 'canvas-confetti';
</script>
```

The kit, self-hosted so a venue that blocks CDNs still runs it:

- `three@0.186.1`
- `three/webgpu@0.186.1`
- `three/tsl@0.186.1`
- `three/addons/...@0.186.1`
- `gsap@3.15.0`
- `gsap/all@3.15.0`
- `gsap/ScrollTrigger@3.15.0`
- `gsap/ScrollSmoother@3.15.0`
- `gsap/Flip@3.15.0`
- `gsap/SplitText@3.15.0`
- `gsap/MotionPathPlugin@3.15.0`
- `gsap/MorphSVGPlugin@3.15.0`
- `gsap/DrawSVGPlugin@3.15.0`
- `gsap/Draggable@3.15.0`
- `gsap/InertiaPlugin@3.15.0`
- `gsap/Observer@3.15.0`
- `gsap/CustomEase@3.15.0`
- `gsap/CustomBounce@3.15.0`
- `gsap/CustomWiggle@3.15.0`
- `gsap/EasePack@3.15.0`
- `gsap/ScrollToPlugin@3.15.0`
- `gsap/TextPlugin@3.15.0`
- `gsap/ScrambleTextPlugin@3.15.0`
- `gsap/Physics2DPlugin@3.15.0`
- `gsap/PhysicsPropsPlugin@3.15.0`
- `gsap/PixiPlugin@3.15.0`
- `motion@14.2.0`
- `motion/react@14.2.0`
- `animejs@4.5.0`
- `react@19.3.1`
- `react/jsx-runtime@19.3.1`
- `react-dom@19.3.1`
- `react-dom/client@19.3.1`
- `htm@3.1.1`
- `htm/react@3.1.1`
- `d3@7.9.0`
- `pixi.js@8.22.0`
- `p5@2.3.4`
- `lottie-web@5.13.0`
- `matter-js@0.20.0`
- `canvas-confetti@1.9.4`
- `chart.js@4.5.1`
- `simplex-noise@4.0.3`
- `tone@15.1.22`

Other libraries can be loaded from a CDN **on the big screen** (the stage tier
is open). **Phone screens are sealed**: they may load only the kit, the deck's
own files and a font service, and can send nothing to any other site. So on a
phone, import from the kit or add the file to the deck.

### The Stage bridge (custom slides and coded decks)

A global `Stage` is there (opened on its own, outside the app, it simply is not,
so a deck still works in a plain browser):

- `Stage.on('data', d => ...)` the room's answers: `d.aggs[id]`, `d.interactions`,
  `d.people`. Draw them any way you like, or use `data-live` / `data-results`.
  Each question's shape is below.
- `Stage.on('step', ({ step, dir, instant }) => ...)` a beat changed (dir is 1 or
  -1; instant is a jump). `Stage.on('goto', ...)` the same with the slide index.
- `Stage.on('in'|'out', ...)` the slide came on or left the screen.
- `Stage.on('signal', ({ name, data, from }) => ...)` a live cue from a phone
  (see signals, below). `Stage.on('reaction', counts => ...)` the reaction taps.
- `Stage.broadcast(name, data)` sends a live cue to every phone.
- `Stage.timeline(tl, which)` drives a GSAP timeline by the beats: one press
  plays on to the next stop, back plays back, a jump goes straight there.
  `which`: `'deck'` (every beat of every slide is a stop: one timeline for a
  whole world), a slide (its index, data-id or an element in it), or nothing
  (the only slide, or the deck). Stops are labels (`"2"` for a slide's beat 2,
  `"3.1"` or `"<data-id>.1"` across a deck), or spread evenly.
- `Stage.on('progress', ({ value, beat, total }) => ...)`: where the talk is
  through the deck, 0 to 1, moving smoothly between beats.
- `Stage.transition(async ({ fromEl, toEl, dir, instant, duration }) => { ... })`:
  move between this deck's pages yourself (GSAP, three.js, anything). Both pages
  are on screen while it runs, for up to 8 seconds; return false to use the
  built-in one. `Stage.motion(name, fromEl, toEl)` plays any built-in or deck
  transition between two elements of yours.
- Presets: an element with `data-anim="rise|fall|left|right|zoom|pop|fade|blur"`
  and a `data-step` animates in on its beat; on a page's first beat it arrives
  with the page.

### Live answers: what Stage.on('data') gives

`d.aggs` holds one roll-up per question id, the same the big screen draws.
`d.interactions[id]` is the question: its `type`, `prompt`, `options` and
`view`. `d.people` is how many have joined. A coded deck hears it on the
big screen. A custom slide hears it on the big screen and in the console,
the same roll-up in both. The editor's preview has no live answers.

- `choice`: `{ n, counts, total }`. `counts` is `{ optionId: number }`.
  `n` is people who answered. `total` is every pick (several per person
  on a multiple-answer question).
- `scale`: `{ n, avg, dist, min, max, width }`. `dist` is
  `{ value: number }`. A wide scale counts in buckets `width` wide, each
  keyed by where it starts.
- `text`: `{ n, words, items }`. `words` is `[[word, count], ...]`, most
  said first. `items` is the latest answers: `{ id, text, at }`.
- `qa`: `{ n, items }`, unanswered first, most votes first:
  `{ id, text, votes, answered, at }`.
- `number`: `{ n, avg, median, min, max, hist }`. `hist` is
  `[{ from, to, n }]`. With `correct` set, also `correct` and `closest`:
  the nearest guesses, `[{ name, value, off }]`.
- `photo`, `audio`: `{ n, items }`, each `{ id, url, at }`.
- `form`: `{ n }` only. What people filled in stays with the presenter.
- `custom`: `{ n, latest }`, the latest 50 values sent with `Room.send`.
- `d.aggs['@leaderboard']`: `{ n, rows }`, the top scores of every quiz,
  each `{ name, score }`.

Answers the presenter has not approved never arrive, and neither does a
filtered answer (a custom one too) until the presenter shows it. Names
arrive only where the room shows them: on the leaderboard, in the closest guesses, and on a Q&A
with "show who asked" on (`name` on each item). A name the room keeps hidden
reads "Guest 12". `n` can be 0 and a list empty: draw that too.

### The big screen around your deck

- **Leave room for the join badge.** On slides that ask something it sits in
  the bottom left corner, about a fifth of the width and a sixth of the
  height (`settings.joinBadge`: `"auto"`, `"always"` or `"off"`).
- **Reactions rise up the right edge**, across 22% of the width
  (`22vw`). Keep what people must read out of that strip.
- **Short notices** ("Answers locked", "Reconnecting") show at the top centre
  for a moment.
- **The mouse is the show's, unless you ask for it.** By default nothing
  of the mouse reaches a coded slide: a click on the left or right edge of
  the big screen moves the show, as on any slide.
  `"settings": { "mouse": true }` gives every coded slide the mouse,
  trackpad and pen (click, hover, drag, the edges too), and a coded slide's
  `stage.mouse` says `true` or `false` for that slide alone. Turn it on
  only for a deck that needs it.
- **A link replaces your deck.** With the mouse on, a plain link opens inside
  your deck's frame, and that page stays on the big screen until it reloads.
  It hears no answers and gets no pointer or keys. A new window
  (`target="_blank"`, `window.open`) or a download does nothing. Put links
  people should open on their phones instead: `Room.open`, or a card's
  button.
- **Keys reach your deck only once someone clicks into it**, so only with
  the mouse on. Even then, the bridge hands these to the show: arrows,
  Space, Enter, Page Up, Page Down, Backspace, Home, End, Escape, and `B`
  `.` `J` `R` `L` `N` `O` `F` `M` `C` `?`. Every other key is yours: digits, the other letters, and
  keys with Ctrl, Cmd or Alt. Skip `G` and `H` too: on the big screen
  itself they open all slides and the keys help.
- **What the presenter's keys do**, so a deck can plan for them: `R` reveals
  a question's results, `J` shows the big join code, `L` locks answers,
  `N` shows speaker notes, `B` (or `.`) goes black, `O` shows all slides,
  `F` full screen, `M` sound on or off, `C` opens the console.
- **Stickers** are this site's own animated WebP files, so a deck can use
  them as pictures: `/stickers/<name>.big.webp` (320 px, for the big screen),
  `/stickers/<name>.webp` (128 px) and `/stickers/<name>.still.webp` (still).
  The names are in the catalog under `glyphs`. Use them in `<img>` or a CSS
  background.
- **Theme CSS reaches the big screen page** (see Styling). `body.stage-page`
  there, `body.phone` on phones.

### The Room bridge (phone screens)

A global `Room` is there on a custom phone screen:

- `Room.send(value)` saves an answer (any JSON up to 4 KB) into the slide's
  `interaction`, a question of type custom. Returns `{ ok, error }`. An
  answer with a bad word anywhere in it (a key too) is filtered: it waits
  for the presenter's OK, like a filtered word on a wall. The phone is told
  it was sent, and no screen gets it until the presenter shows it. Leave
  "Filter bad words" on (the deck's `settings.filter` and each question's
  own `filter`) unless the presenter asks.
- `Room.emit(name, data)` sends a live cue to the big screen (a tap, a drag, a
  colour), kept nowhere. The screen hears `Stage.on('signal')`.
- `Room.on('signal', ({ name, data }) => ...)` a cue from the big screen
  (`Stage.broadcast`). `Room.on('state', ...)` the beat moved, or `Room.me`
  changed. `Room.on('motion', ...)` tilt, once the person allows it
  (`Room.motion()`).
- `Room.on('data', agg => ...)` the slide's question, in the same shapes as
  `Stage.on('data')` (above) with fewer items. Only when phones may see
  results: `phones: "after"` or `"reveal"`, and always for Q&A.
- `Room.me.tag` a short stable id for this person (also on their signals, so the
  screen can give each phone its own colour).
- `Room.me.name` their own name, as the room shows it: "Hi, Asha". It is
  empty until they give one, and while the room keeps it hidden. A screen
  never hears anybody else's name. Write for both: `Room.me.name || 'friend'`.
- `Room.theme` the deck's colours and fonts (also CSS variables:
  `var(--accent)`, `var(--ink)`, `var(--display)`).
- `Room.vibrate(ms)`, `Room.open(httpsUrl)` (the phone confirms the link first).
- Fast taps never zoom the phone, so a screen can be one big button people hit
  as fast as they like. A drawing pad or a drag of its own sets
  `touch-action: none` on itself.

### What phone screens may never do

The room refuses, and never shows, a phone screen that asks for a password, a
card number or a one-time code, and refuses any answer that is a card number or
is sent under a key like `password`, `otp` or `cvv`. Do not build screens that
ask a room for secrets; they will not work and the save fails.

A phone screen is sealed, so plan around these:

- It reaches only the kit, the deck's own files and a font service.
- No downloads, no popups, no dialogs (`alert`, `confirm`), no leaving the
  page, no `localStorage`. `Room.open(url)` offers an https link; the phone
  asks the person first.
- To let people keep a picture, show it as an `<img>`. They long-press it to
  save it.
- Everything it sends goes to the room, through `Room.send` and `Room.emit`.

### Trying it

- Open a coded deck on its own (with the three scripts above): the arrow keys,
  the kit and the transitions work, but no answers arrive. Feed your drawing
  code sample answers in the shapes above. Without the bridge script there
  is no `Stage` at all, so guard it: `window.Stage && Stage.on(...)`.
- The big screen's links (`links.stage` and `links.screen` from
  `start_session`) wait on a **Start** button, because browsers play sound
  only after a click. A browser test must click Start before the slides move.
- Start a session and join from a phone to see real answers and
  `Room.me` arrive.

### Working with files, as an agent

- `import_html_deck` uploads a whole single-file deck (or re-uploads to update).
- `upload_deck_file` puts one text file (an extra page, a stylesheet, a phone
  screen, a JSON data file) into a source folder. `list_deck_files` and
  `read_deck_file` read what is there. Pictures and models: `save_image_from_url`.
- A phone screen from a folder: upload `screens/vote.html` into `src_show`, then
  set the slide's phone to `{ "type": "custom", "source": "src_show", "src": "screens/vote.html" }`
  (`upload_deck_file` returns it as `phone_screen`). Add `"interaction"` with a
  custom question's id to keep what the screen sends.

## Transitions: built in, and your own

`settings.transition` sets the deck's default move between slides; any slide's
`transition` overrides it: `{ "type": "slide" }`, `{ "type": "zoom", "duration": 600 }`.
Built in: fade, none, slide, rise, zoom, blur, iris, wipe, curtain, flip, spin, drop, glide, morph (push, dissolve, cut and magic-move work as names too). `morph` is magic move:
the pieces two slides share (the title, the big number, the picture, line by
line) glide from one place and size to the other while the rest dissolves.
Pair other pieces by name with `view-transition-name` in the deck's CSS.

**Design your own.** A transition is data, so you can write any you can
imagine: in `settings.transitions`, by name, then use its name like a
built-in. It is checked when saved: only visual properties animate, nothing
can load anything, every time is clamped. `get_transitions` returns every
built-in in full (start from one with `"base"`) and checks yours.

```json
"settings": {
  "transition": { "type": "hero-zoom" },
  "transitions": {
    "hero-zoom": {
      "label": "Hero zoom", "duration": 1300, "easing": "cubic-bezier(.7,0,.2,1)",
      "out": { "keyframes": [{ "opacity": 1, "transform": "scale(1)" }, { "opacity": 0, "transform": "scale(1.4)", "filter": "blur(14px)" }], "at": [0, 0.55] },
      "in":  { "keyframes": [{ "opacity": 0, "transform": "scale(.7) rotate(-3deg)" }, { "opacity": 1, "transform": "none" }], "at": [0.35, 1] },
      "parts": [
        { "select": "title", "from": { "opacity": 0, "transform": "translateY(8cqh)", "letterSpacing": ".3em" }, "delay": 500, "duration": 800 },
        { "select": "items", "from": { "opacity": 0, "transform": "translateX(-4cqw)" }, "delay": 700, "stagger": 90 }
      ],
      "phone": { "from": { "opacity": 0, "transform": "scale(.9)" } }
    }
  }
}
```

- `out` and `in`: the leaving and arriving slide, as Web Animations keyframes
  (camelCase: opacity, transform, filter, clipPath, colours, shadows ...), with
  `at`: when, as parts of the duration. A layer's `style` holds for the move
  (a mask, a 3D origin), and can use the animatable --t-r, --t-x, --t-y, --t-a, --t-n.
- `parts`: pieces of the arriving slide by role (title, eyebrow, sub, lines, items, number, quote, image, results, qr, tag, join, all) or a CSS
  selector inside it, each with `from`, `delay`, `duration`, `stagger`, `easing`.
- `morph`: true, or `{ "select": ["title", "image"], "duration": 900 }`.
- `back`: its own design for going back. Without it, going back plays the
  same transition backwards in time (a push goes the other way, an iris closes).
- `phone`: how a phone's screen arrives on that slide.
- Slides are see-through over one shared background, so design the two layers
  not to sit in the same place at full strength: stagger them with `at`, or move
  them apart. People whose device asks for less motion get a short dissolve.
- A transition can also be written in place on one slide:
  `"transition": { "base": "slide", "duration": 500 }`.
- A coded deck's pages play the same transitions (data-transition, or the one
  you set on its slide in the deck), and `Stage.transition(fn)` lets its own
  code do anything at all.

## Live signals, during a talk

- A phone screen's `Room.emit(name, data)` reaches a custom slide or coded deck
  on the big screen as `Stage.on('signal')`: taps, drags, colours, a game move.
- The big screen's `Stage.broadcast(name, data)`, or your `control_session` with
  `command: "broadcast"`, reaches every phone screen as `Room.on('signal')`.
- Both are ephemeral (kept nowhere), rate- and size-limited; a phone's signals
  are word-filtered, so the audience cannot push a slur onto the screen.

## Video slides

`{ "type": "video", "src": "/a/...", "stops": [4.8, 12.1] }` plays a video on the
big screen and pauses at each stop: one step per stop, so the next press plays
on to the next one. A deck exported as a video keeps its animations this way.
Upload the file with `POST /files` first. The presenter's app can find the stops
for them; you can also set them.

A slide can also be one part of a video: `"from"` and `"to"` (seconds) limit it
to that stretch of the file. A deck exported as a video works best as one slide
per part, all with the same `src`: the big screen keeps one player for the file,
so one press forward plays on from where the last part stopped, with nothing
reloading. Give each part `"thumb"` (a picture of how it ends, for thumbnails)
and `"poster"` (how it starts, which is how the part before it ends). Arriving
any other way than one press forward shows how the part ends. Questions and
other slides can sit between parts.

## Questions that depend on who answers

- **Only some people.** Any phone view can carry a condition and what everybody
  else sees instead. Each phone picks for itself from its own earlier answers,
  and the room checks the same way before it keeps an answer.

  ```json
  { "type": "ask", "interaction": "rate_shoot",
    "when": { "interaction": "craft", "in": ["photo", "video"] },
    "otherwise": { "type": "ask", "interaction": "rate_event" } }
  ```

  `when` needs a choice question and some of its option ids. `otherwise` can
  carry its own `when`, up to four deep; without one, everybody else looks up.
- **In their own words.** `{craft}` in a question's prompt, a card's text or a
  "look up" line puts in what that person answered to "craft":
  "{craft|Friends}, how was your year?" reads "Photographer, how was your
  year?" on a photographer's phone, and "Friends, how was your year?" on the
  big screen, where there is no one person, and for anybody who has not
  answered. Write it so any answer reads well in it.
- **Their name.** `{name|friend}` is always the person's own name: "Hi
  {name|friend}, ready?" People give a name when the deck asks
  (`"settings": { "names": "always" }` asks when they join), or through a
  text question with `"asName": true` ("What should we call you?"), whose
  answer becomes their name on the leaderboard, in Q&A and in `{name}`.
- **Split by an answer.** A results slide with `"by": "craft"` shows the answers
  split by profession: a ball pit per profession for a sticker poll (moods by
  profession), a split bar per profession for other polls, and an average per
  profession for ratings and numbers. Only people who answered both count.
  `get_results` returns these in `splits`, group by group with each group's
  name, and `GET /sessions/{sid}/results` in `aggs` under `"mood@craft"`.

## Finding and organising decks

- `GET /decks?q=words` searches every word in every deck: titles, slides, notes,
  questions and options. Every word has to appear somewhere in a deck.
- `?folder={id}`, `?folder=none` or `?folder=starred` narrows the list. Each
  deck in it has `folder_id` and `starred`.
- `GET /folders` lists folders with how many decks each holds.
  `POST /folders` with `{ "name": "Workshops" }` makes one, `PATCH /folders/{id}`
  renames it, `DELETE /folders/{id}` removes it. Its decks stay, without a folder.
- `PUT /decks/{id}/meta` with `{ "folder": "{id}" or null, "starred": true }` moves
  or stars a deck. It never changes the deck itself or its history.
- `POST /decks` takes `"folder": "{id}"` to start a new deck inside a folder.
- `POST /decks/{id}/duplicate` copies a deck, with its uploaded files, into a
  new one titled "... (copy)".

## Live sessions

- `POST /decks/{id}/present` starts one (or resumes the running one) and returns the
  join code and links. `{ "fresh": true }` starts over for a new audience.
- `POST /sessions/{sid}/control` drives it:
  `{ "t": "nav", "dir": "next" }`, `{ "t": "goto", "slide": "s_mood", "step": 0 }`,
  `{ "t": "control", "action": "reveal", "iid": "quiz1" }`.
  Actions: reveal, lock, clear, black, overlay (`"name": "join"` or null), spotlight,
  item (`"item"` and `"state": "ok"` or `"hidden"`), approve-all, room-pause and
  room-lock (`"on": true` or false), name (`"pid"`, `"show": true` or false),
  remove (`"pid"`: everything they sent leaves, their phone is let go), end.
- `GET /sessions?deck={id}` lists sessions. `GET /sessions/{sid}/results` returns every
  answer, rolled up per question in `aggs`.

## Writing for a room

- One idea per slide. Short lines. Big words read from the back row.
- Questions under ten words. Options under five words each. Two to six options.
- Ask early, and often enough that phones stay in hands.
- Keep the presenter's own wording and voice when you edit their slides.
