# How to build a Whyseum exhibit

An exhibit is one interactive page that makes one idea click for a school-age kid (roughly ages 6 to 14). It lives at `exhibits/<slug>/index.html` and gets an entry in `data/exhibits.json`.

## The rules

1. **Self-contained.** One `index.html`. Inline your CSS and JS. External scripts may come only from `cdn.jsdelivr.net/npm/`, `unpkg.com`, or `cdnjs.cloudflare.com`, pinned to an exact version. Google Fonts are fine. No build step, no API keys, no tracking.
2. **Real physics, honest exaggeration.** Numbers on screen come from real equations. When you exaggerate for visibility (scale, speed, size), say so on the page in one line.
3. **Touch it in the first 3 seconds.** The page opens on something moving, with one obvious thing to drag, click, or slide. Reading comes second.
4. **Two reading levels.** Every exhibit has a toggle between **Explorer** (about age 7: short sentences, everyday words, one idea at a time) and **Scientist** (about age 12: real terms, numbers, and the equation when it helps). The toggle changes the text, not the simulation.
5. **"Try this" challenges.** 3 to 5 small missions that point kids at the aha moment ("Make the wing stall", "Reach orbit with the least fuel").
6. **Works everywhere.** Phones, tablets (touch), and laptops. Keeps 60fps on a school Chromebook. Pauses animation when the tab is hidden. Respects `prefers-reduced-motion`.
7. **Shared chrome.** Top-left link back to the museum: `<a href="/" class="wm-home">← Whyseum</a>`. Title and one-line question up top (the question is the hook: "Why do rockets need so much fuel?").

## Look and feel

Dark "night lab" stage so glowing visuals pop, with warm, friendly type.

- Background `#0b1020`, panels `rgba(255,255,255,0.06)` with `1px solid rgba(255,255,255,0.10)` borders, 14px radius.
- Text `#eef2ff`, muted `#9aa4c7`.
- Accents: hot `#ff7a45` (fire, warm air, thrust), cool `#4cc9f0` (cold air, lift, water), yellow `#ffd166` (energy, highlights), green `#7bd88f` (success).
- Fonts: `Fredoka` (display, 500 to 600) and `Inter` (body), from Google Fonts.
- Controls are big (44px touch targets). Sliders show their live value with units.

## Catalog entry

```json
{
  "slug": "rocket",
  "title": "Rocket Lab",
  "question": "Why do rockets need so much fuel?",
  "url": "/exhibits/rocket/",
  "kind": "original",
  "author": "Whyseum",
  "topics": ["space", "forces"],
  "ages": "7-14",
  "blurb": "One sentence a kid would want to click.",
  "emoji": "🚀"
}
```

`kind` is `original` (hosted here) or `web` (links to a great visual somewhere else, with credit).

## The 3D look: museum dioramas

The hero of every exhibit is a rich, real-time 3D scene, not a flat diagram. Think of a lit museum diorama you can walk around.

**Staging**
- The subject sits on a museum plinth or lab bench in a dim gallery: dark walls, a warm key spotlight from above, a cool rim light behind, soft contact shadows. A small brass nameplate on the plinth carries the exhibit name.
- In-world instruments: glowing screens, dials, and gauges built into the plinth or hanging on the back wall, showing live values and charts from the simulation (draw them to a canvas and use it as a texture).
- Labels are HTML callouts pinned to 3D points with a thin leader line ("Flap: drag me", "Throat Mach 1"). They fade when their part faces away.
- Views, switchable with buttons and number keys, with a smooth camera tween between them: **Gallery** (default, three-quarter hero angle), **Exploded or Cutaway** (parts slide apart or the skin turns to glass), **Studio** (clean backdrop, subject only). A slow idle camera drift when nobody is touching it. OrbitControls with sensible limits.

**Rendering (Three.js, pinned version, importmap from jsdelivr)**
- `renderer.toneMapping = ACESFilmicToneMapping`, sRGB output, `PMREMGenerator` + `RoomEnvironment` for reflections.
- `MeshPhysicalMaterial` for everything that matters: brushed aluminum and painted metal (metalness, roughness, clearcoat), glass (transmission, thickness, ior) for cutaways, emissive for flames, screens, and glowing flow.
- Post-processing via `EffectComposer`: `RenderPass`, a subtle `UnrealBloomPass` (glow on hot and emissive things only), optional `BokehPass` or a cheap tilt-shift blur for background depth in Gallery view, then `OutputPass`.
- Innovative WebGL is welcome where it teaches: GPU particle systems in shaders for air, smoke, exhaust, snow; instanced meshes; custom shader materials that color a surface by pressure or temperature; volumetric-looking clouds from layered sprites.

**Speed**
- Build geometry procedurally (lathe, extrude, tube, instancing). No model files over ~300 KB, no big textures. Target under ~1.5 MB total transfer including Three.js.
- Show a styled poster (title, question, spinner) instantly while Three.js loads.
- Quality tiers: measure frame time for the first ~2 seconds and step down automatically (bloom off, depth blur off, fewer particles, pixel ratio 1) on slow devices like school Chromebooks. Cap pixel ratio at 1.75.
- Pause rendering when the tab is hidden or the canvas is offscreen. Respect `prefers-reduced-motion` (no idle drift, fewer particles).

**UI chrome**
- Title block top-left: small caps exhibit name, big display title, two lines of intro, a row of live readout chips, and a "right now" explainer card that updates with what's happening.
- Controls top-right in a frosted-glass panel (`backdrop-filter: blur`), with the keyboard shortcut shown next to each control.
- On phones: the scene fills the top ~60% of the screen, and the controls live in a bottom sheet.

## Credit where it's due

If an exhibit is inspired by someone's work, or uses their data or library, say so at the bottom of the page with a link: "Inspired by Bartosz Ciechanowski's Airfoil", "Map data: Natural Earth via us-atlas". Never copy someone's branding, name, or signature look wholesale.

## Take the tour (and how we record videos)

Every exhibit has a **Take the tour ▶** button: a scripted 30 to 45 second sequence that shows the aha moment without any clicking. Camera moves between views, controls change on their own (the sliders visibly move), and short captions appear in the current reading level. Any touch, click, or key stops the tour and hands control back.

The same tour doubles as our video source:
- `?tour=1` starts the tour on load. `?tour=1&record=1` also hides all UI chrome except the captions and the exhibit title, forces high quality, and disables the idle drift randomness so every run is identical.
- Timing uses the tour's own clock, not wall time, and waits for the scene to be ready before it starts.
- The page sets `window.__tour = { state: "idle" | "running" | "done", duration }` so a recorder knows when to stop.
- Captions are big (at least 28px at 1280×720), high contrast, and at most one short sentence at a time, so the clip works with the sound off.
- The first 6 to 10 seconds should loop well on their own (they become the silent preview on the museum's home page).

## Sound: narration, read to me, and sound design

Exhibits use the shared module `/assets/whyseum-audio.js` (the one exception to "self-contained"; it keeps one sound preference across the whole museum). Nothing makes a sound until the visitor clicks, taps, or presses a key, and nothing ever talks on page load.

```js
import { createAudio } from "/assets/whyseum-audio.js";
const audio = createAudio({ slug: "rocket" });
audio.mountToggle(document.querySelector("#soundSlot"));   // 🔊/🔇 button, key M, remembered across exhibits
```

**1. Narrated tours.** Every tour caption is a recorded clip in both reading levels. Write the lines in `tools/narration/<slug>.json` with ids like `tour-01` and levels `explorer` / `scientist`, then run `python3 tools/narration/generate.py <slug>` (Gemini TTS, the museum's docent voice, each take checked by Whisper; re-runs only record changed lines). The tour plays `await audio.narrate(id, level)` for each beat, and the tour timeline is built from `audio.clipDuration(id, level)` so the pictures and the voice line up. Record mode (`?record=1`) must stay deterministic: it reads durations from the manifest, never from playback. Call `audio.preload([...ids], level)` when the tour starts. Any input that stops the tour also calls `audio.stopNarration()`.

**2. Read to me.** A 🔊 "Read to me" button next to explainer text and the "Right now" card. Fixed text gets a recorded clip (`read-<section>`); text with live numbers uses `audio.readAloud(id, level, { fallbackText })`, which falls back to the browser voice. Tapping it while sound is off turns sound on (the visitor asked). Tapping again stops it.

**3. Sound design.** Procedural, so it costs no download: `audio.wind()`, `audio.hum()`, `audio.rumble()`, `audio.engine({ kind: "prop" | "turbine" })`, each with `.set({...})` (0..1 params, call every frame, it ramps smoothly) and `.stop()`, plus one-shots `audio.sfx("tick" | "pop" | "whoosh" | "thud" | "splash" | "success" | "alert")`. Keep it quiet and physical: sound follows the simulation (wind gets louder as the storm deepens, the roar follows throttle), never a looping soundtrack. Narration automatically ducks the ambience.

## Analytics

Every public page (not `/admin`) loads Vercel Web Analytics, which is cookieless and collects no personal data. Put these two lines just before `</head>`:

```html
<script>window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };</script>
<script defer src="/_vercel/insights/script.js"></script>
```

## Performance budget (phones first)

Measured with Lighthouse's mobile profile (4x CPU slowdown, slow 4G) against the deployed page. Every exhibit must hit:

| Metric | Budget |
|---|---|
| First paint (FCP) | ≤ 1.8 s: the poster is plain HTML/CSS in the page, visible before any script runs |
| Largest paint (LCP) | ≤ 2.5 s: the poster (title, question, a static illustration) is the LCP element |
| Total blocking time (TBT) | ≤ 400 ms |
| Longest single task after the poster | ≤ 150 ms (build the scene in slices and yield between them) |
| Frame rate on the phone tier | ≥ 30 fps with 4x CPU throttle at 390×844 |

How:
- **Local libraries only.** Import map: `"three": "/assets/vendor/three@0.170.0/build/three.module.min.js"`, `"three/addons/": "/assets/vendor/three@0.170.0/examples/jsm/"`. Map libs: `/assets/vendor/d3-geo@3.1.1.min.js`, `/assets/vendor/topojson-client@3.1.0.min.js`. Fonts: `<link rel="stylesheet" href="/assets/site.css">` is NOT for exhibits; copy the two `@font-face` rules and fallback faces from the top of `/assets/site.css` into the page and preload `/assets/fonts/fredoka-latin-var.woff2` and `/assets/fonts/inter-latin-var.woff2`. No third-party requests except analytics.
- **Pick the tier before building.** Phones and small screens (`matchMedia("(pointer: coarse)")`, width < 820, `navigator.deviceMemory <= 4`, `hardwareConcurrency <= 4`) start on the low tier: pixel ratio ≤ 1.25, no bloom or depth blur, a fraction of the particles, smaller canvas textures, simpler geometry. Frame-time sampling can still step up or down afterwards.
- **Build in slices.** Split scene setup into steps and `await` a yield between them (`scheduler.yield?.()` or `setTimeout(0)`), building what the first view needs first. Build other views, other aircraft, cutaways, and read-to-me content lazily on first use.
- **Heavy math off the critical path.** Precompute once at build time where possible (ship small JSON), chunk it, or move it to a Web Worker.
- **Small data.** Ship only what the scene draws (clip maps to the region, simplify, quantize).
- **Keep it cheap every frame.** No allocations in the render loop, no DOM writes unless a value changed, pause when hidden or offscreen.
