# 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 (it lives in the museum bar, below): 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.

## One voice everywhere

Every 🔊 button plays a recorded clip in the museum's narrator voice. The browser's built-in voice is never used. For cards whose text changes with the simulation ("Right now", "What's happening", live-number sections), record a small set of **state clips** instead of reading live numbers: ids `state-<name>`, both levels, one or two sentences that describe what is happening and why (the numbers stay on screen). Scientist state lines can name the physics, but still no live values. For sections that show a formula with live values, record a fixed clip that explains the formula in words. When tapped, the button picks the clip for the current state; it never calls `readAloud` with `fallbackText`.

## The museum bar and the first-visit guide

Every exhibit uses the same bar for the controls the whole museum shares, in the same place and order: **← Whyseum · ▶ Take the tour · Explorer | Scientist · 🔊 · ↗ Share · ?**. Desktop: top-left (`left:16px; top:16px`, 40px tall). Phones (≤ 820px): one row across the top (`left:8px; right:8px; top:8px`, 36px tall). Reserve that space in the page's CSS so nothing shifts when it mounts, and move the title block below it. The exhibit does not build its own home link, tour button, level toggle, sound toggle, or share button.

```js
import { mountMuseumBar } from "/assets/museum-bar.js";
const bar = mountMuseumBar(document.getElementById("museumBar"), {
  slug: "rocket", audio,
  onTour: () => toggleTour(),
  level: { get: () => st.level, set: (lv) => setLevel(lv) },
  share: { title: "Rocket Lab", text: "How does a rocket get to space, and why does it need so much fuel?" },
  steps: [ /* this exhibit's guide steps, below */ ],
});
// bar.setTourRunning(on) whenever the tour starts or stops; bar.setLevel(lv) if the level changes another way (a key);
// bar.ready() once the scene is visible (the guide appears after this on a first visit).
```

The bar is hidden in record mode (`body.rec`), and taps on it (other than the tour button itself) must not stop a running tour. The bar only stops click propagation, so if the exhibit stops its tour on `pointerdown` or `keydown`, exclude `#museumBar` in that handler.

**The guide** shows once per exhibit (`localStorage` key `whyseum.guide.v1`), never with `?tour=1`, `?record=1`, or `?noguide`. The **?** button replays the whole guide, shared steps included. The shared steps (tour, reading level, sound, replay) only appear on the first exhibit a visitor opens; after that, only the exhibit's own steps run.

Writing the exhibit's steps (2 to 4):
- Each step points at one real control or gesture that is special to this exhibit, in the order a kid would use them. Point at something visible at that moment on desktop and on phones (hidden targets are skipped).
- **Title:** 1 to 3 words, the control's name or the action ("Grab the wing", "Launch", "Scrub through time").
- **Text:** at most 2 sentences and about 25 words, in Explorer words. Say what to do and what happens. Nothing else.
- Never: "Welcome", "Let's", "explore", "dive in", "amazing", "interactive experience", exclamation marks, emoji, or explaining what is obvious from the screen.
- Each step gets a narration clip so the card's 🔊 works: id `guide-<name>`, same text in both levels, and pass it as the step's `clip` field (`{ target, title, text, clip: "guide-<name>" }`). The generator records identical text once and reuses it for the other level. Also add the four shared clips to the exhibit's narration file with exactly this text: `guide-tour` "Press Take the tour and the exhibit shows you around in under a minute. Tap anywhere to stop it." · `guide-level` "Explorer uses simple words. Scientist adds the real names and numbers." · `guide-sound` "Turn sound on to hear the narrator. Any speaker button reads that card out loud." · `guide-help` "Press the question mark any time to replay this guide."

## iPad and touch

Kids use this on iPads, held at arm's length, with fingers. Test on WebKit (Playwright `webkit` with the `iPad Mini`, `iPad (gen 11)` and `iPad Pro 11` profiles, portrait and landscape), not just Chrome.

- **Layout by input, not just width.** Use `@media (pointer: coarse)` (and `hover: none`) to switch to touch sizing on any width. iPad landscape is 1024 to 1194px wide but must not get the mouse-sized desktop controls: on coarse pointers with width ≥ 600px, every tappable control is at least 44×44px (sliders: a 44px-tall track hit area and a thumb ≥ 28px), body text ≥ 15px, labels ≥ 12px.
- **Gestures.** Drags on the 3D scene, the wing, the yoke, the throttle, sliders and timelines must work with one finger and never scroll or bounce the page: `touch-action: none` on those elements (and `manipulation` on buttons, so double-tap never zooms). OrbitControls: one finger orbits, two fingers pinch-zoom and pan. Long-press must not open the text-selection or callout menu on controls (`-webkit-user-select: none; -webkit-touch-callout: none`).
- **No hover-only information.** Anything shown on hover (labels, tooltips, part names) must also appear on tap.
- **Height.** Use `dvh` (with a `vh` fallback) for full-height layouts so Safari's address bar showing or hiding doesn't cover controls. Respect safe areas when in "Add to Home Screen" mode (`env(safe-area-inset-*)`).
- **Sound.** Audio unlocks on the first tap; verify the 🔊 toggle, tour narration and read-aloud play on WebKit after one tap.
- **Head tags.** Add the same Apple icon tags the hub pages use (`/assets/icons/apple-touch-icon.png`, `apple-mobile-web-app-*`) as static tags. The `<link rel="manifest" href="/manifest.webmanifest">` is injected by a few lines of script at the end of body about 1 s after first paint, because a manifest link in `<head>` makes Chrome fetch the 192px icon at high priority and costs about 0.3 s of LCP.
- **Museum bar height** is 44px on touch screens ≥ 600px wide; reserve that much space in those cases.
