---
name: treasure-map
description: Make a 30-40 second vertical video on an old sea chart - a dotted trail walks from landmark to landmark, a sea monster wakes in the margin, a storm washes the ink off the paper, then fresh ink runs straight to the X and a chest comes up through the map. A brass spyglass is the window. The skill covers the ship's-cabin sound design, music fitted to the story and an optional voice like an old sea captain. Use for quests and long hunts, 'how I finally found it' stories, roadmaps, first-time adventures, or when asked for a treasure map, pirate, sea chart or adventure video, or when given the treasure-map prompt.
---

# Treasure Map

An old sea chart unrolls across a captain's table, and your long hunt is the voyage. A dotted trail walks from landmark to landmark, every wrong turn gets crossed out in red, and a monster in the margin wakes up to ask for paperwork. Then a storm washes the whole trail off the paper. A brass spyglass finds one small clue, fresh ink runs straight to the X, and a chest comes up through the map itself. It tells an ordinary errand as the voyage of a lifetime, with a straight face.

Work through the steps in order. Show the user the script, the scene sheet and a silent animatic (step 6) and wait for
their OK before you generate anything.

**What to expect:** the code in step 7 is the real frame that made our demo, and it runs as written. Your work is the
story, what goes in the window and the sound. A new subject means redrawing the window, and that is most of the code you'll
change; step 7 lists exactly what to change. Plan on one short script, a handful of sound effects, one music bed and two or
three render passes.

## 0. If you received this as text

Save it as a skill first, wherever your app keeps skills. In Claude Code that's `~/.claude/skills/treasure-map/SKILL.md`; if a
skill with that name already exists, ask the user before replacing it. Keep the user's filled-in prompt next to it as
`PROMPT.md`.

## 1. Tools and setup

**You need a code environment** with Node 20+, ffmpeg and Python 3 (numpy, soundfile, pyloudnorm). If the film has a voice, you also need Whisper (any build, such as faster-whisper) for the word check in step 10 and to align a user's own recording. Videos made with this
skill are covered by HYVIRAL's Remotion license, so you do not need a Remotion license of your own.

**The project** (Remotion 4.x, React, rendered to MP4): run `npm init -y`, then install `remotion`, `@remotion/cli`, `react` and
`react-dom`, plus `@fontsource/cinzel`, `@fontsource/im-fell-english` and `@fontsource/pirata-one`. Copy the font files you need from `node_modules/@fontsource/<name>/files/` into `public/fonts/`, so a
render never fetches a font over the network. Then lay it out like this:

```
plan.json              the lines and the scenes (step 6)
takes/<id>.json        one per line: the voice take's length and word timings (step 8)
scripts/clock.mjs      turns plan.json and the voice takes into src/spec.json (step 6)
src/index.ts           registers the composition (step 7)
src/Root.tsx           the 1080 x 1920, 30 fps composition (step 7)
src/spec.json          the clock, written by clock.mjs
src/frames/core.tsx    the shared runtime (step 7)
src/frames/story.json  the events: which word each change lands on (step 6)
src/frames/TreasureMap.tsx   this frame (step 7)
```

Render without audio, then mux your own mix (step 10):
`npx remotion render src/index.ts Film-9x16 out/silent.mp4 --muted`. Set TEMP and TMP to a folder on a roomy drive first;
Remotion writes a lot of temporary files.

**Prove the setup before you write anything.** Copy the code and our `plan.json` from step 7, run the clock from step 6, and render
the first few seconds (add `--frames=0-90`). Only then start on your own story, at step 5. The font files you need are named in
`FACES` at the top of the frame.

**For the voice, images, music and sound effects, use whatever the user prefers.** Every step works the same with any
provider. If the user hasn't said, ask which tools they want to use, or use what is already set up.

- **Your own providers.** A text-to-speech that returns word timestamps (for example ElevenLabs with timestamps), a music
  model or a licensed track, and a sound-effects generator (for example ElevenLabs sound effects) or a licensed sound
  library. Keep API keys in environment variables, never in the project. No AI media at all is fine too: the picture is
  code, the narration can be the user's own recording (align its words with Whisper), and the film works with no voice.
- **HYVIRAL (optional).** If the user has it connected (hyviral.com), one connection covers the media:

| You need | HYVIRAL tool |
|----------|--------------|
| Narration with word timestamps (stock voices or your own cloned voice) | `generate_voiceover` (`content_type: "shorts"`), `manage_voice_clone` |
| Still images | `generate_image` |
| Music bed | `generate_music` |
| Sound library, placing sounds, final render | `video_audio`, `video_export` |
| Long jobs | `get_job_status` (poll until delivered) |

  To connect it: in Claude.ai or Claude Desktop, Customize → Connectors → add a custom connector with the URL
  `https://api.hyviral.com/mcp`; in Claude Code, `claude mcp add --transport http hyviral https://api.hyviral.com/mcp` then
  `/mcp` to sign in. Start a session with `get_connection_info`. HYVIRAL never runs your code: you render the animation
  yourself.

## 2. How a frame film works

- **One frame, the whole film.** A frame is a world with fixed parts on screen, and it never cuts away. The story happens
  to the world: the trail walks, landmarks ink themselves in, the spyglass swings, the storm makes the ink run, and the chart itself gets dug up. That's why it holds attention: the viewer learns the world in the first second, then
  watches it change.
- **One clock: the spoken words.** Each line is voiced as its own take with word timings, scenes are laid end to end on
  those takes, and every change is keyed to a word in `story.json`. Re-voice a line and everything moves with it.
- **A function of time.** Every part of the frame computes what it shows from the current time, with no stored state,
  `Math.random` or `Date`. Use `hash()` for anything that looks random, so two renders match.
- **Sound off first.** Most people start muted, so the frame and the captions have to tell the whole story on their own.
  Sound makes it better, never necessary.
- **Safe area.** 1080 x 1920. Platform buttons and captions cover the bottom 440 px, and the right 130 px from about
  y = 900 down. Keep captions and anything that must be read inside x 60-950, y 250-1480. Above y = 900 the frame can run to
  the right edge. Below y = 1480, draw only texture. The top 250 px can hold the world's title and small jokes, as long as nothing
  there has to be read.
- **Pacing rules.** No voice gap over 1.0 s, except one voiceless moment of 2 to 5 s with the music lifted. No still stretch
  over 1.2 s. Never cut to black. Something moves on the very first frame.

## 3. The world

A captain's table in a ship's cabin, seen as one old sea chart that fills the screen.

- **Palette:** parchment `#d9c08c`, land `#e8d6a6`, sea teal `#3f8f8a`, ink brown `#4a2c17` with a darker `#2a180b`, X red `#c0392b`, brass `#b88a2e`.
- **Type:** Cinzel for the cartouche and the plaques, IM Fell English for place names and the captions, Pirata One only for a monster's name and the compass N. Blackletter closes up at phone size, so never set ordinary place names in it.
- **Fixed parts:** a title cartouche at the top left, a brass spyglass at the top right, one island in the middle, scroll captions in one place (y 1294-1478), then a compass rose and a scale bar as texture below the safe area. The top of the cartouche and of the spyglass sit above the safe area, so nothing there has to be read.
- **What makes it a chart and not a template:** a graduated neatline around the edge, rhumb lines fanning across the sea, a coast cut by `fractal()` so it looks surveyed, water lining repeating the coast outward, fold lines and small soundings in the water. Keep all of these when you change the story.
- **Nothing is ever still:** the sea marks shift, the lantern's light sways, the whole table rocks a little, and everything takes the same knock when something lands.
- **The camera stays on the paper:** it drifts and pushes in. Zoom with `zoom()`, around a point inside the frame, so the table doesn't show.
- **Small jokes live on the chart:** Sea of Open Tabs, Shoals of Still Available?, The Walk-Ups (the hills), Leagues to the Train, Natural Light (rumored), and "Surveyed in haste. Damp in places." on the cartouche, which the storm then proves.

**Real people and real marks, in every version of this film.** The user's own name, brand and product are fine. Never use anyone else's company, agency, team or product name, logo, livery, seal or badge. Never use a real person's name, face or voice without their OK, and never make a real person or a named company the villain, the target of the joke or the one to blame.

## 4. The window

The window is the part of the frame that shows the subject. It has two modes, and the frame must work with either.

- **With no person (the default).** A scene that belongs to the world, drawn in code or from still images. It has to follow
  the story as well, not just sit there. "No person" means no filmed presenter: small drawn figures are fine.
- **With the user's own clip.** Replace the scene with their footage (`<OffthreadVideo>` from `remotion`, cropped to the window,
  muted if the narration is separate). Keep their face in the middle third, and take
  the word timings from their clip with Whisper.

For this world the window is the spyglass, and each new sighting moves the voyage forward. It swings quickly from one to the next. Ours are drawn in code:

| Story moment | In the glass |
|---|---|
| Opening | Blurred empty water, then the glass finds a ship with a sofa lashed to the deck |
| Setting out | A small house on a hill, two tiny figures waving, then a close look at the sofa: "1 sofa (good)" |
| The crowd | A hallway in perspective. Twelve crews in hats fill it, seen from behind, and start to jostle |
| The wrong turn | A listing with a drawing of a mansion, and COZY stamped on it. Then a door under a brass MASTER SUITE plate swings open on a closet with a stove |
| The monster | One huge yellow eye opens, behind a monocle. A clipboard rises: REFERENCES, 1, 2, 3 |
| The storm | A dark sky, big seas, the ship pitching. A faster boat flies past under a pennant reading SIGNED, then the sea comes over the glass |
| The turn | Blurred, then sharp: one lit lamppost on a quay, and a paper sign tied to it |
| The clue | The sign up close: ROOM, a 555 number, tear-off tabs. One tab rips away |
| The key | A brass lock plate. The key slides in and turns |
| The treasure | A small room at night: the moon in the window, a singing radiator, and the good sofa with the captain's hat on it. When the neighbor stomps, dust falls and the lamp swings |

The glass is a 600 x 600 stage, so a new scene is one more component in `VIEWS`. It also works with the user's own clip: play it inside `Glass`, cropped to the circle, and keep the brass ring, the glare and the rim shadow on top. For a real find, use a real photo for the clue, and keep the blur that snaps sharp.

## 5. The story

This is the part that makes the film, so write it before you touch anything else. Read every line aloud.

- **Use the world's own events.** Every turn in the topic has to become something that really happens in this world. If a
  line could sit in any video, rewrite it.
- **Use the voice's deadpan.** The funniest and most moving versions take the world completely seriously.
- **Short lines.** Mostly 5 to 11 words, one idea each, with an occasional two-word punch.
- **Real claims only.** Use a number or a fact only if the user gave it to you or you can source it. Invented details are
  fine only when the film is plainly a joke or a metaphor.
- **Build to a low point, then a turn.** The low point is a picture event, never silence: the world goes dim or dead while
  room tone and a designed sound keep running.
- **No em dashes on screen.** Commas, full stops and line breaks do that job.
- **Ending, organic or sponsored.** Ask the user which one, and for an organic ending, what they'll send people. Organic:
  "Comment KEYWORD and I'll send you THE THING", said in the world's voice, and no link. The keyword stays on screen in the world's own way until the end, because the caption moves on.
  Sponsored: a direct line and a URL.
- **Length.** Run the clock from step 6 as soon as the script exists, before you build anything. Each extra line adds about 3 s,
  and even a one-word line takes about 0.5 s. Time follows the words, so a cut only helps if words go: folding two moves into one shorter line works, and joining
  them word for word saves nothing. Then re-anchor every event in `story.json`.

**The moves.** Map any topic onto these. The film tells an ordinary errand as the great voyage of a captain's life. Ours is the hunt for a first apartment.

| # | Move | What it does | Our line | On the chart |
|---|------|--------------|----------|--------------|
| 1 | The map | The hook: a small quest, told like a legend | "Gather round. I'll tell ye of the hunt for a first apartment." | The chart unrolls, the title inks itself in, and the spyglass swings in and finds a ship. A monster is already asleep in the margin |
| 2 | Setting sail | Where it starts, and one funny detail | "We set out from your parents' house, with one good sofa." | A cottage inks in, PARENTS' COVE, and the dotted trail sets off |
| 3 | First stop | Everyone else wants this too | "Viewing Point. Twelve crews in one hallway." | A landmark inks in, and twelve tiny ships jam the water around it |
| 4 | Wrong turn | The promise that was a lie | "The listing said cozy. It was a closet with a stove." | The trail reaches CAPE COZY and doubles back. The name is crossed out in red: (a closet) |
| 5 | The monster | The gatekeeper | "Then the beast woke. It asked for three references." | The sleeping monster opens one eye and rears up, keys at its neck, and gets its name: THE LANDLORD |
| 6 | Low point | Everything is lost | "Storm took the last one. Someone faster signed." | The chart darkens, rain falls on the paper, every name runs in long drips, and the trail washes away dash by dash. Music out; waves and a creaking hull keep running |
| 7 | The turn | A small clue, found by luck | "Then, through the glass, a sign on a lamppost." | The spyglass swings forward over the dark chart, blurred, and snaps sharp on "sign". A tiny lamp lights on the map. Music returns on that word |
| 8 | The clue | The way was simple | "No listing. No agent. One phone number." | The storm clears, and fresh red ink runs ruler-straight across the island. A big red X lands on "number" |
| 9 | The dig | The voiceless moment, about 3 s, music at its fullest | (captions read X MARKS IT) | The camera dives in, a spade chops the paper three times, the chart tears open, a chest thuds up through it, and the lid opens on one brass key |
| 10 | The treasure | The real, modest prize | "Inside, one key, and a radiator that sings all night." | The key turns in the glass. A tiny house stands on the X, its window lights, and night falls on the chart |
| 11 | The button | The last laugh | "Best treasure on this coast. Mind the upstairs neighbor." | FIRST APARTMENT inks in. A small new monster inks itself in on the roof and stomps |
| 12 | Call to action | One action | "Comment map and I'll send you the skill that made this." | The lantern comes over the cartouche, and COMMENT MAP gets inked into it and stays |

**For other topics:** landing a first job, finding a first customer, the one good mechanic in town, the search for a lost file, how a band got its first gig. The gatekeeper is whoever asks for the paperwork, and the treasure should be small, real and a little flawed.

The trick that makes it land is the straight line. The old trail wanders for half the film, then the storm wipes out everything the viewer watched get drawn. So when the fresh ink goes ruler-straight from the same starting point, and the X turns out to sit a few dashes off the old trail, the viewer feels how close it was all along. Find the small, unglamorous clue first, because it decides the rest. Use real facts if the hunt is real, and keep it plainly a joke if it isn't. Never name a real city or a real landlord.

Our film runs 40.9 s on the clock's estimate, the long end of the range. For about 35 s, fold moves 7 and 8 into one shorter line and drop a sentence from move 3 or 11. Joining lines word for word saves nothing.

## 6. Plan and animatic (before generating anything)

**1. Write `plan.json`.** `text` is what the captions show. Add `say` when the spoken version differs, with the same number of
words. Scenes hold the lines. `lead` and `tail` are seconds of room around a scene's words, and a voiceless scene has `sec` instead of
`lines`.

```json
{
  "lines": [
    { "id": "l01", "text": "First line." },
    { "id": "l02", "text": "Comment TOUR and I'll send it to you.", "say": "Comment, tour, and I'll send it to you." }
  ],
  "scenes": [
    { "id": "s01", "lines": ["l01"], "lead": 0.7 },
    { "id": "s02", "sec": 3.4 },
    { "id": "s03", "lines": ["l02"] }
  ]
}
```

**2. Run the clock.** Write `scripts/clock.mjs` as below, and run it from the project folder. A line with no take yet gets an
estimate, so you can see the whole film before generating anything. A real take goes in `takes/<id>.json` as
`{ "dur": seconds, "words": [{ "w": "First", "t0": 0, "t1": 0.3 }] }` and replaces the estimate.

```js
// node scripts/clock.mjs   plan.json + takes/<id>.json -> src/spec.json (what the frame renders) and clock.json (where each line plays)
import fs from 'node:fs';

const FPS = 30;
const plan = JSON.parse(fs.readFileSync('plan.json', 'utf8'));
const PAUSE = 0.35, LEAD = 0.05, TAIL = 0.25, TAIL_OUT = 1.2;
// No take yet: an estimate for a quick, clear voice, fitted to 53 real takes. A real take in takes/<id>.json replaces it.
function estimate(text) {
  const letters = (s) => (s.match(/[\p{L}\p{N}]/gu) ?? []).length;
  const stops = (text.match(/[.?!]+(?=\s)/g) ?? []).length, commas = (text.match(/,/g) ?? []).length;
  const dur = 0.29 + 0.059 * letters(text) + 0.53 * stops + 0.07 * commas;
  const parts = text.split(/\s+/), total = parts.reduce((n, p) => n + letters(p) + 1, 0);
  let at = 0;
  const words = parts.map((w) => { const d = (dur * (letters(w) + 1)) / total, x = { w, t0: at, t1: at + d }; at += d; return x; });
  return { dur, words };
}
let t = 0.1;
const beats = [], placed = [];
for (const s of plan.scenes) {
  const from = Math.round(t * FPS);
  let end = t + (s.sec ?? 0);
  const words = [];
  if (s.lines?.length) {
    let c = t + (s.lead ?? LEAD);
    s.lines.forEach((id, i) => {
      const line = plan.lines.find((l) => l.id === id);
      const take = fs.existsSync(`takes/${id}.json`) ? JSON.parse(fs.readFileSync(`takes/${id}.json`, 'utf8')) : estimate(line.say ?? line.text);
      // Captions show `text`, even when `say` differs ("Comment, gate"), as long as the word counts match.
      const shown = line.text.split(/\s+/);
      const same = shown.length === take.words.length;
      placed.push({ line: id, t0: c, dur: take.dur, scene: s.id });
      take.words.forEach((w, k) => words.push({ w: same ? shown[k] : w.w, f0: Math.round((c + w.t0) * FPS) - from, f1: Math.round((c + w.t1) * FPS) - from }));
      c += take.dur + (i < s.lines.length - 1 ? PAUSE : 0);
    });
    end = c + (s.tail ?? TAIL);
  }
  const dur = Math.max(1, Math.round(end * FPS) - from);
  beats.push({ id: s.id, from, dur, words, p: s.p ?? {} });
  t = (from + dur) / FPS;
}
const total = Math.round((t + TAIL_OUT) * FPS);
beats[beats.length - 1].dur = total - beats[beats.length - 1].from;
beats[0].dur += beats[0].from;
beats[0].words.forEach((w) => { w.f0 += beats[0].from; w.f1 += beats[0].from; });
beats[0].from = 0;
for (let i = 1; i < placed.length; i++) {
  const gap = placed[i].t0 - (placed[i - 1].t0 + placed[i - 1].dur);
  const quiet = beats.some((b) => !b.words.length && b.from / FPS < placed[i].t0 && (b.from + b.dur) / FPS > placed[i - 1].t0);
  if (gap > 1.0 && !quiet) console.warn(`voice gap ${gap.toFixed(2)} s before ${placed[i].line}: shorten a lead or tail`);
}
fs.mkdirSync('src', { recursive: true });
fs.writeFileSync('src/spec.json', JSON.stringify({ fps: FPS, durationInFrames: total, beats }, null, 1));
fs.writeFileSync('clock.json', JSON.stringify({ fps: FPS, total: total / FPS, lines: placed }, null, 1));
console.log(`${(total / FPS).toFixed(2)} s, ${beats.length} scenes, ${placed.length} lines`);
```

**3. Write `src/frames/story.json`.** Name every change in the story and pin it to a scene, and to a word in it where you
can. The frame reads events by name, and a typo fails the render instead of drifting. Every event is checked when the film loads,
even one the frame never reads. Our list keeps some events only so a sound can land on them; you can delete those.
A `word` matches the first word in its scene that starts with those letters, so "the" also hits "there", and a repeated word can only be
reached with `off` from an earlier one.

**4. Build the frame** with the code in step 7, changing the words and schedules to your story. Keep each schedule in time order.

**5. Render a silent animatic.** Make contact sheets at one frame per second with the safe box drawn, and look at every tile:
`ffmpeg -i out/silent.mp4 -vf "select='not(mod(n\,30))',drawbox=x=60:y=250:w=890:h=1230:color=red@0.9:t=6,scale=216:-2,tile=8x3" -fps_mode passthrough sheet_%02d.jpg`.
Empty tiles at the end of the last sheet are padding, not black frames. A tile that small hides small errors, so also look at about
6 frames at full size, with the window cropped large (`npx remotion still src/index.ts Film-9x16 out/f.png --frame=N`).

**6. Show the user** the script, the scene sheet (one row per scene: its line, what changes on screen, and its seconds) and the
animatic. Wait for their OK.

## 7. Build the frame

These files are the real frame that made our demo, with our demo's story in it. You rendered them in step 1 to prove your
setup. Now change the story, using the list below. Leave the machinery alone until the film works, and read the code before you
change it. Paper, panels and labels are sized to our demo's words, so check anything longer on the contact sheet.

### What to change for your story

Most of this file is the chart's marks and the scenes in the glass, and almost all of them are about apartments. Each is its own component, so replace them one at a time. `core.tsx` and `clock.mjs` stay as they are.

| To change | Where |
|---|---|
| Every word on the chart and in the glass | `TXT` at the top of the file |
| The comment keyword | `KEYWORD` at the top and your `plan.json` line. The cartouche draws it, and `Captions` lights every use of the word once `keyword` fires |
| The scenes in the glass | `VIEWS` lists them in story order, with their events. Then `SeaSearch`, `HomeShore`, `Hallway` (with `CROWD`), `ListingPaper`, `Closet`, `Eye`, `StormSea`, `Lamp`, `LampWords`, `SignClose`, `Lock` and `Room`. Redrawing these is the biggest job |
| Where things sit on the map | `AT` holds the landmarks, and `LEG1`, `LEG2` and `LEG3` are the old trail's three walks. The fresh trail is simply `AT.cove` to `AT.x` |
| The trail's timing | `trailLen` and `newLen` |
| The island | `COAST_PTS`, `ISLETS`, `TREES` and `PEAKS` |
| The landmarks | `Cottage`, `ViewingHouse`, `CapeHouse`, `MapLamp` and `Apartment`, placed in `Stage` |
| The place names | The `Label` rows in `Stage` set their own x and y. Anything that must read after dark is in the second `CamLayer`, above `Light` |
| What the storm does | `DRIP_SOURCES` lists a box under each name for the drips and the wash, so move it when a name moves. `stormK`, `washK` and `FLASHES` set the weather |
| The red strike through the wrong turn | The two `Stroke` rows at the end of the chart's `svg` in `Stage`, placed by hand over our name |
| The monsters | `Beast` draws the head, `Monster` and `HUMPS` the sea monster, `Neighbor` the small one, and `stomp` its rhythm. `Snores` runs until `beast` |
| The crowd on the map | `FLEET`, twelve ships. `Ship` draws them, and the sofa is a prop on it |
| The dig | `Dig`, `KeyArt` and `DIRT`, on the events `chop1`, `chop2`, `chop3`, `rise`, `thud`, `lid` and `gleam` |
| Night | `nightK`, its overlay in `Light`, and the window in `Room` |
| The spyglass's moves | `HOME`, `OFF`, `FOCUS` and `LENS`. `glassBlur` sets the two moments it's out of focus |
| The camera and the knocks | `KEYS` for the camera, `HITS` for which events shake the table |

- **Scene ids in the code:** none. The frame reads events, including the voiceless caption, which runs from `dig` to `inside`. Only `story.json` names scenes.
- **Panels are sized to our words.** A longer keyword overflows `Cartouche` at 100 px, and the title at 66 px fills its box. `TXT.listingWord` is fitted to `ListingPaper` at 112 px, `TXT.sign` to `SignClose` at 112 px, `TXT.plaque` to its plate in `Closet`, `TXT.clipboard` to the board in `Eye`, and `TXT.voiceless` to the scroll at 76 px. Check each one on a full-size still.
- **Captions:** `PER_ROW` is 29 at 60 px, which fills the scroll between its rollers on our longest rows. Lower both together if yours run wider.
- **Delete a landmark's events along with its component.** `story.json` events that no longer match a word stop the render, which is what you want.

### The code

`plan.json`, our demo's script:

```json
{
 "lines": [
  {
   "id": "l01",
   "text": "Gather round. I'll tell ye of the hunt for a first apartment."
  },
  {
   "id": "l02",
   "text": "We set out from your parents' house, with one good sofa."
  },
  {
   "id": "l03",
   "text": "Viewing Point. Twelve crews in one hallway."
  },
  {
   "id": "l04",
   "text": "The listing said cozy. It was a closet with a stove."
  },
  {
   "id": "l05",
   "text": "Then the beast woke. It asked for three references."
  },
  {
   "id": "l06",
   "text": "Storm took the last one. Someone faster signed."
  },
  {
   "id": "l07",
   "text": "Then, through the glass, a sign on a lamppost."
  },
  {
   "id": "l08",
   "text": "No listing. No agent. One phone number."
  },
  {
   "id": "l09",
   "text": "Inside, one key, and a radiator that sings all night."
  },
  {
   "id": "l10",
   "text": "Best treasure on this coast. Mind the upstairs neighbor."
  },
  {
   "id": "l11",
   "text": "Comment map and I'll send you the skill that made this.",
   "say": "Comment, map, and I'll send you the skill that made this."
  }
 ],
 "scenes": [
  {
   "id": "s01",
   "lines": [
    "l01"
   ],
   "lead": 0.3,
   "tail": 0.05
  },
  {
   "id": "s02",
   "lines": [
    "l02"
   ],
   "lead": 0.1,
   "tail": 0.05
  },
  {
   "id": "s03",
   "lines": [
    "l03"
   ],
   "lead": 0.1,
   "tail": 0.05
  },
  {
   "id": "s04",
   "lines": [
    "l04"
   ],
   "lead": 0.1,
   "tail": 0.05
  },
  {
   "id": "s05",
   "lines": [
    "l05"
   ],
   "lead": 0.15,
   "tail": 0.05
  },
  {
   "id": "s06",
   "lines": [
    "l06"
   ],
   "lead": 0.55,
   "tail": 0.05
  },
  {
   "id": "s07",
   "lines": [
    "l07"
   ],
   "lead": 0.45,
   "tail": 0.05
  },
  {
   "id": "s08",
   "lines": [
    "l08"
   ],
   "lead": 0.05,
   "tail": 0.05
  },
  {
   "id": "s09",
   "sec": 3.2
  },
  {
   "id": "s10",
   "lines": [
    "l09"
   ],
   "lead": 0.05,
   "tail": 0.05
  },
  {
   "id": "s11",
   "lines": [
    "l10"
   ],
   "lead": 0.05,
   "tail": 0.05
  },
  {
   "id": "s12",
   "lines": [
    "l11"
   ],
   "lead": 0.1,
   "tail": 0.05
  }
 ]
}
```

`src/index.ts`:

```ts
import { registerRoot } from 'remotion';
import { Root } from './Root';
registerRoot(Root);
```

`src/Root.tsx`:

```tsx
import React from 'react';
import { Composition } from 'remotion';
import { TreasureMap } from './frames/TreasureMap';
import spec from './spec.json';

export const Root: React.FC = () => (
  <Composition id="Film-9x16" component={TreasureMap} durationInFrames={Math.max(1, (spec as { durationInFrames: number }).durationInFrames)} fps={30} width={1080} height={1920} />
);
```

`src/frames/core.tsx`, the shared runtime:

```tsx
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { continueRender, delayRender, staticFile, useCurrentFrame } from 'remotion';
import spec from '../spec.json';

/* The shared frame runtime. One frame runs through the whole film; the clock comes from the spoken words. */
export const clamp = (x: number, a = 0, b = 1) => Math.min(b, Math.max(a, x));
export const ramp = (t: number, a: number, b: number) => clamp((t - a) / (b - a));
export const easeOut = (x: number) => 1 - Math.pow(1 - x, 3);
export const easeInOut = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
export const easeIn = (x: number) => x * x * x;
export const back = (x: number) => { const c = 1.70158; return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); };
export const hash = (n: number) => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };

export type Word = { w: string; t0: number; t1: number; scene: string };
export type SceneT = { id: string; t0: number; t1: number; words: Word[]; p: Record<string, unknown> };
export type StoryEvent = { id: string; scene: string; word?: string; off?: number };
export type StoryJson = { events: StoryEvent[] };

const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9']/g, '');
type SpecBeat = { id: string; from: number; dur: number; words: { w: string; f0: number; f1: number }[]; p: Record<string, unknown> };
const FPS = (spec as { fps: number }).fps;
export const SCENES: SceneT[] = (spec as unknown as { beats: SpecBeat[] }).beats.map((b) => ({
  id: b.id,
  t0: b.from / FPS,
  t1: (b.from + b.dur) / FPS,
  p: b.p ?? {},
  words: b.words.map((w) => ({ w: w.w, t0: (b.from + w.f0) / FPS, t1: (b.from + w.f1) / FPS, scene: b.id })),
}));
export const WORDS: Word[] = SCENES.flatMap((s) => s.words);
export const DURATION = (spec as { durationInFrames: number }).durationInFrames / FPS;

/** The film second of an event: a scene's start, or a word in it, plus an offset. Throws on a typo, so a wrong anchor fails the render. */
export function eventTime(e: StoryEvent): number {
  const s = SCENES.find((x) => x.id === e.scene);
  if (!s) throw new Error(`event ${e.id}: no scene ${e.scene}`);
  let t = s.t0;
  if (e.word) {
    const w = s.words.find((x) => norm(x.w).startsWith(norm(e.word as string)));
    if (!w) throw new Error(`event ${e.id}: no word "${e.word}" in ${e.scene} (${s.words.map((x) => x.w).join(' ')})`);
    t = w.t0;
  }
  return t + (e.off ?? 0);
}
export const makeE = (story: StoryJson) => {
  const map = new Map(story.events.map((e) => [e.id, eventTime(e)]));
  return (id: string) => {
    const v = map.get(id);
    if (v === undefined) throw new Error(`unknown event ${id}`);
    return v;
  };
};

const TimeCtx = createContext(0);
export const useT = () => useContext(TimeCtx);
export const Clock: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const f = useCurrentFrame();
  return <TimeCtx.Provider value={f / FPS}>{children}</TimeCtx.Provider>;
};

/** One caption chunk at a time, cut to fit rows of `perRow` cells; `wi` is the word being spoken, or -1. */
export type Chunk = { t0: number; rows: { s: string; map: number[] }[]; words: Word[] };
const clean = (w: string) => w.replace(/[.,;:!?"]/g, '');
export function buildChunks(perRow: number, maxRows: number): Chunk[] {
  const chunks: Chunk[] = [];
  for (const scene of SCENES) {
    let cur: Word[] = [];
    const flush = () => {
      if (!cur.length) return;
      const rows: { s: string; map: number[] }[] = [{ s: '', map: [] }];
      cur.forEach((w, i) => {
        const up = clean(w.w).toUpperCase();
        let r = rows[rows.length - 1];
        if (r.s.length && r.s.length + 1 + up.length > perRow) { rows.push({ s: '', map: [] }); r = rows[rows.length - 1]; }
        if (r.s.length) { r.s += ' '; r.map.push(-1); }
        for (let k = 0; k < up.length; k++) r.map.push(i);
        r.s += up;
      });
      chunks.push({ t0: cur[0].t0, rows, words: cur });
      cur = [];
    };
    for (const w of scene.words) {
      const trial = [...cur, w];
      let rowsUsed = 1, len = 0;
      for (const x of trial) {
        const L = clean(x.w).length;
        if (len && len + 1 + L > perRow) { rowsUsed += 1; len = L; } else len += (len ? 1 : 0) + L;
      }
      if (rowsUsed > maxRows) flush();
      cur.push(w);
      if (/[.!?]$/.test(w.w)) flush();
    }
    flush();
  }
  return chunks;
}

/** Loads the frame's fonts before the first frame. The handle is taken inside React: a module-level one can outlive its render. */
export const Fonts: React.FC<{ faces: { family: string; file: string; weight?: string; style?: string }[]; children: React.ReactNode }> = ({ faces, children }) => {
  const [handle] = useState(() => delayRender('fonts', { timeoutInMilliseconds: 120000 }));
  const [ready, setReady] = useState(false);
  const list = useMemo(() => faces, [faces]);
  useEffect(() => {
    Promise.all(list.map((x) => {
      const ff = new FontFace(x.family, `url(${staticFile(`fonts/${x.file}`)})`, { weight: x.weight ?? '400', style: x.style ?? 'normal' });
      return ff.load().then((l) => { (document.fonts as unknown as { add: (f: FontFace) => void }).add(l); });
    }))
      .then(() => document.fonts.ready)
      .then(() => { setReady(true); continueRender(handle); })
      .catch((e) => { console.error('font load failed', e); setReady(true); continueRender(handle); });
  }, [handle, list]);
  return ready ? <>{children}</> : null;
};
```

`src/frames/story.json`, our demo's events:

```json
{
 "events": [
  {
   "id": "on",
   "scene": "s01"
  },
  {
   "id": "gather",
   "scene": "s01",
   "word": "Gather"
  },
  {
   "id": "glass",
   "scene": "s01",
   "word": "tell",
   "off": -0.1
  },
  {
   "id": "hunt",
   "scene": "s01",
   "word": "hunt"
  },
  {
   "id": "first",
   "scene": "s01",
   "word": "first"
  },
  {
   "id": "cove",
   "scene": "s02",
   "word": "We"
  },
  {
   "id": "parents",
   "scene": "s02",
   "word": "parents"
  },
  {
   "id": "house",
   "scene": "s02",
   "word": "house"
  },
  {
   "id": "sofa",
   "scene": "s02",
   "word": "sofa"
  },
  {
   "id": "viewing",
   "scene": "s03",
   "word": "Viewing"
  },
  {
   "id": "twelve",
   "scene": "s03",
   "word": "Twelve"
  },
  {
   "id": "hallway",
   "scene": "s03",
   "word": "hallway"
  },
  {
   "id": "listing",
   "scene": "s04",
   "word": "listing"
  },
  {
   "id": "cozy",
   "scene": "s04",
   "word": "cozy"
  },
  {
   "id": "closet",
   "scene": "s04",
   "word": "closet"
  },
  {
   "id": "stove",
   "scene": "s04",
   "word": "stove"
  },
  {
   "id": "beast",
   "scene": "s05",
   "word": "beast"
  },
  {
   "id": "woke",
   "scene": "s05",
   "word": "woke"
  },
  {
   "id": "three",
   "scene": "s05",
   "word": "three"
  },
  {
   "id": "refs",
   "scene": "s05",
   "word": "references"
  },
  {
   "id": "low",
   "scene": "s06"
  },
  {
   "id": "storm",
   "scene": "s06",
   "word": "Storm"
  },
  {
   "id": "last",
   "scene": "s06",
   "word": "last"
  },
  {
   "id": "faster",
   "scene": "s06",
   "word": "faster"
  },
  {
   "id": "signed",
   "scene": "s06",
   "word": "signed"
  },
  {
   "id": "turn",
   "scene": "s07",
   "word": "through"
  },
  {
   "id": "swing",
   "scene": "s07",
   "word": "glass"
  },
  {
   "id": "sign",
   "scene": "s07",
   "word": "sign"
  },
  {
   "id": "lamppost",
   "scene": "s07",
   "word": "lamppost"
  },
  {
   "id": "nolisting",
   "scene": "s08",
   "word": "listing",
   "off": -0.15
  },
  {
   "id": "noagent",
   "scene": "s08",
   "word": "agent",
   "off": -0.1
  },
  {
   "id": "phone",
   "scene": "s08",
   "word": "phone"
  },
  {
   "id": "number",
   "scene": "s08",
   "word": "number"
  },
  {
   "id": "dig",
   "scene": "s09"
  },
  {
   "id": "chop1",
   "scene": "s09",
   "off": 0.3
  },
  {
   "id": "chop2",
   "scene": "s09",
   "off": 0.72
  },
  {
   "id": "chop3",
   "scene": "s09",
   "off": 1.14
  },
  {
   "id": "rise",
   "scene": "s09",
   "off": 1.4
  },
  {
   "id": "thud",
   "scene": "s09",
   "off": 1.8
  },
  {
   "id": "lid",
   "scene": "s09",
   "off": 2.2
  },
  {
   "id": "gleam",
   "scene": "s09",
   "off": 2.6
  },
  {
   "id": "inside",
   "scene": "s10",
   "word": "Inside"
  },
  {
   "id": "key",
   "scene": "s10",
   "word": "key"
  },
  {
   "id": "radiator",
   "scene": "s10",
   "word": "radiator"
  },
  {
   "id": "sings",
   "scene": "s10",
   "word": "sings"
  },
  {
   "id": "night",
   "scene": "s10",
   "word": "night"
  },
  {
   "id": "best",
   "scene": "s11",
   "word": "Best"
  },
  {
   "id": "coast",
   "scene": "s11",
   "word": "coast"
  },
  {
   "id": "mind",
   "scene": "s11",
   "word": "Mind"
  },
  {
   "id": "neighbor",
   "scene": "s11",
   "word": "neighbor",
   "off": -0.15
  },
  {
   "id": "cta",
   "scene": "s12"
  },
  {
   "id": "keyword",
   "scene": "s12",
   "word": "map",
   "off": -0.1
  },
  {
   "id": "skill",
   "scene": "s12",
   "word": "skill"
  }
 ]
}
```

`src/frames/TreasureMap.tsx`:

```tsx
import React from 'react';
import { AbsoluteFill } from 'remotion';
import story from './story.json';
import { back, buildChunks, clamp, Clock, easeIn, easeInOut, easeOut, Fonts, hash, makeE, ramp, useT, type StoryJson } from './core';

/* Treasure Map: an old sea chart on a captain's table. A dotted trail walks from landmark to landmark, a brass spyglass is the window,
   the storm washes the ink off the paper, and fresh ink runs straight to the X. 1080 x 1920. Time comes from the spoken words through
   story.json, so a re-voice moves every mark with its word. */
const E = makeE(story as StoryJson);

/* ---------- the words on the chart: change these first ---------- */
const KEYWORD = 'MAP';
const TXT = {
  titleTop: 'A True Chart of',
  title: 'The Hunt',
  titleSub: 'for a First Apartment',
  note: ['Surveyed in haste.', 'Damp in places.'],
  comment: 'COMMENT',
  start: "Parents' Cove",
  stop: 'Viewing Point',
  crews: 'XII crews',
  wrong: 'Cape Cozy',
  wrongNote: '(a closet)',
  beast: 'The Landlord',
  end: 'First Apartment',
  pest: 'The Upstairs Neighbor',
  voiceless: 'X MARKS IT',
  cargo: '1 sofa (good)',
  listing: 'A Listing',
  listingWord: 'COZY',
  plaque: 'MASTER SUITE',
  clipboard: 'REFERENCES',
  pennant: 'SIGNED',
  sign: 'ROOM',
  call: 'call',
  phone: '555-0142',
  seaA: 'Sea of Open Tabs',
  seaB: 'Shoals of Still Available?',
  hills: 'The Walk-Ups',
  scale: 'Leagues to the Train',
  rumor: 'Natural Light (rumored)',
};

const INK = '#4a2c17', INK_D = '#2a180b', PARCH = '#d9c08c', LAND = '#e8d6a6', SEA = '#3f8f8a', SEA_D = '#2e6d69', XRED = '#c0392b', BRASS = '#b88a2e', CREAM = '#efe0b8', GOLD = '#ffd34d';
const DISPLAY = 'Cinzel, serif', MARK = '"Pirata One", serif', FELL = '"IM Fell English", serif';
const FACES = [
  { family: 'Cinzel', file: 'cinzel-latin-700-normal.woff2', weight: '700' },
  { family: 'Cinzel', file: 'cinzel-latin-900-normal.woff2', weight: '900' },
  { family: 'Pirata One', file: 'pirata-one-latin-400-normal.woff2' },
  { family: 'IM Fell English', file: 'im-fell-english-latin-400-normal.woff2' },
  { family: 'IM Fell English', file: 'im-fell-english-latin-400-italic.woff2', style: 'italic' },
];

type P = [number, number];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;

/* Where everything sits on the chart. Plan this first: the map has to hold every mark by the end. */
const AT = {
  cove: [214, 1136] as P,
  view: [186, 726] as P,
  viewHouse: [272, 700] as P,
  cape: [716, 1112] as P,
  x: [600, 796] as P,
  lamp: [356, 1012] as P,
  fork: [470, 1056] as P,
};
const LEG1: P[] = [AT.cove, [136, 1066], [158, 960], [118, 880], [136, 790], AT.view];
const LEG2: P[] = [AT.view, [300, 748], [420, 738], [516, 770], [508, 860], [440, 950], [506, 1034], [600, 1040], [660, 1072], AT.cape];
const LEG3: P[] = [AT.cape, [664, 1112], [600, 1090], [532, 1088], AT.fork];

/* ---------- path tools ---------- */
const smooth = (pts: P[], n = 12): P[] => {
  const out: P[] = [];
  for (let i = 0; i < pts.length - 1; i++) {
    const a = pts[Math.max(0, i - 1)], b = pts[i], c = pts[i + 1], d = pts[Math.min(pts.length - 1, i + 2)];
    for (let k = 0; k < n; k++) {
      const u = k / n, u2 = u * u, u3 = u2 * u;
      out.push([
        0.5 * (2 * b[0] + (c[0] - a[0]) * u + (2 * a[0] - 5 * b[0] + 4 * c[0] - d[0]) * u2 + (3 * b[0] - a[0] - 3 * c[0] + d[0]) * u3),
        0.5 * (2 * b[1] + (c[1] - a[1]) * u + (2 * a[1] - 5 * b[1] + 4 * c[1] - d[1]) * u2 + (3 * b[1] - a[1] - 3 * c[1] + d[1]) * u3),
      ]);
    }
  }
  out.push(pts[pts.length - 1]);
  return out;
};
type Mark = { x: number; y: number; a: number; s: number };
const walk = (pts: P[], step: number): Mark[] => {
  const out: Mark[] = [];
  let acc = 0, next = 0;
  for (let i = 1; i < pts.length; i++) {
    const [ax, ay] = pts[i - 1], [bx, by] = pts[i];
    const d = Math.hypot(bx - ax, by - ay);
    if (d === 0) continue;
    while (next <= acc + d) {
      const k = (next - acc) / d;
      out.push({ x: lerp(ax, bx, k), y: lerp(ay, by, k), a: (Math.atan2(by - ay, bx - ax) * 180) / Math.PI, s: next });
      next += step;
    }
    acc += d;
  }
  return out;
};
const OLD_LINE = smooth([...LEG1, ...LEG2.slice(1), ...LEG3.slice(1)]);
const OLD = walk(OLD_LINE, 30);
const OLD_FINE = walk(OLD_LINE, 3);
const nearest = (p: P, from = 0) => { let best = from; let bd = 1e9; for (let i = from; i < OLD_FINE.length; i++) { const d = Math.hypot(OLD_FINE[i].x - p[0], OLD_FINE[i].y - p[1]); if (d < bd) { bd = d; best = i; } } return best; };
const I_VIEW = nearest(AT.view);
const I_CAPE = nearest(AT.cape, I_VIEW + 10);
const S_VIEW = OLD_FINE[I_VIEW].s, S_CAPE = OLD_FINE[I_CAPE].s, S_END = OLD_FINE[OLD_FINE.length - 1].s;
/** How far the old trail has walked. Each leg runs on its own words. */
const trailLen = (t: number) => {
  if (t < E('house')) return 0;
  if (t < E('viewing') + 0.25) return S_VIEW * easeInOut(ramp(t, E('house'), E('viewing') + 0.25));
  if (t < E('listing')) return S_VIEW;
  if (t < E('cozy') + 0.2) return lerp(S_VIEW, S_CAPE, easeInOut(ramp(t, E('listing'), E('cozy') + 0.2)));
  if (t < E('closet') + 0.15) return S_CAPE;
  return lerp(S_CAPE, S_END, easeInOut(ramp(t, E('closet') + 0.15, E('stove') + 0.4)));
};
const NEW_LINE: P[] = [AT.cove, AT.x];
const NEW = walk(NEW_LINE, 36);
const NEW_FINE = walk(NEW_LINE, 3);
const S_NEW = NEW_FINE[NEW_FINE.length - 1].s;
const newLen = (t: number) => S_NEW * easeInOut(ramp(t, E('nolisting'), E('number') + 0.02));

/* The weather: 0 to 1. The storm comes in at the low point and clears on the clue. Night falls on the last word of the treasure. */
const stormK = (t: number) => ramp(t, E('low') - 0.1, E('low') + 0.7) * (1 - ramp(t, E('nolisting') + 0.1, E('phone') + 0.2));
const washK = (t: number) => ramp(t, E('last'), E('signed') + 0.6);
const nightK = (t: number) => ramp(t, E('radiator'), E('night') + 0.3) * (1 - 0.45 * ramp(t, E('cta'), E('keyword') + 0.5));
const FLASHES = [E('storm') - 0.05, E('faster') + 0.3];
const flash = (t: number) => FLASHES.reduce((a, e) => (t >= e ? a + Math.exp(-(t - e) * 7) * (0.6 + 0.4 * Math.sin((t - e) * 70)) : a), 0);

/* Everything on the table takes the same knock: a landmark a little, the chest a lot. */
const HITS: [number, number][] = ([['viewing', 4], ['cozy', 3], ['beast', 6], ['storm', 9], ['signed', 8], ['number', 13], ['chop1', 7], ['chop2', 7], ['chop3', 10], ['thud', 28], ['key', 5], ['neighbor', 5], ['keyword', 5]] as [string, number][]).map(([e, a]) => [E(e), a]);
const stomp = (t: number) => (t >= E('neighbor') + 0.4 ? Math.max(0, 1 - ((t - E('neighbor') - 0.4) % 1.05) * 6) : 0);
const shake = (t: number) => HITS.reduce((acc, [e, a]) => (t >= e ? acc + a * Math.sin((t - e) * 58) * Math.exp(-(t - e) * 11) : acc), 0) + stomp(t) * 3 * Math.sin(t * 60);

/* The camera drifts and pushes. A zoom around a point inside the frame keeps the chart's edges off screen. */
type View = { s: number; tx: number; ty: number };
const zoom = (s: number, fx: number, fy: number): View => ({ s, tx: fx * (1 - s), ty: fy * (1 - s) });
const KEYS: { at: number; d: number; v: View }[] = [
  { at: 0, d: 0.01, v: zoom(1.03, 300, 700) },
  { at: E('cove') - 0.1, d: 1.0, v: zoom(1.13, 110, 1240) },
  { at: E('viewing') - 0.2, d: 0.8, v: zoom(1.14, 120, 620) },
  { at: E('listing') - 0.15, d: 0.8, v: zoom(1.1, 830, 1250) },
  { at: E('beast') - 0.3, d: 0.6, v: zoom(1.14, 1000, 1190) },
  { at: E('low') - 0.05, d: 1.0, v: zoom(1.05, 540, 900) },
  { at: E('number') - 0.35, d: 0.45, v: zoom(1.08, 590, 800) },
  { at: E('dig') - 0.05, d: 0.55, v: zoom(2.0, 590, 800) },
  { at: E('key') - 0.05, d: 0.9, v: zoom(1.08, 590, 800) },
  { at: E('best') - 0.05, d: 0.9, v: zoom(1.16, 620, 800) },
  { at: E('cta') - 0.05, d: 0.9, v: zoom(1.02, 300, 700) },
];
const camAt = (t: number): View & { a: number } => {
  let v = KEYS[0].v;
  for (const k of KEYS.slice(1)) {
    if (t < k.at) break;
    const q = easeInOut(ramp(t, k.at, k.at + k.d));
    v = { s: lerp(v.s, k.v.s, q), tx: lerp(v.tx, k.v.tx, q), ty: lerp(v.ty, k.v.ty, q) };
  }
  const s = Math.max(1.012, v.s + 0.006 * Math.sin(t * 0.37));
  const tx = clamp(v.tx + 8 * Math.sin(t * 0.43 + 1), 1080 - 1080 * s, 0);
  const ty = clamp(v.ty + 7 * Math.sin(t * 0.31 + 2), 1920 - 1920 * s, 0);
  const j = shake(t);
  const storm = stormK(t);
  return { s, tx: tx + j, ty: ty + j * 0.5, a: 0.22 * Math.sin(t * 0.8) + 1.25 * storm * Math.sin(t * 1.7) };
};

/* ---------- ink ---------- */

/** Ink wipes on from the left. Once it ends the clip opens past the box, or a letter's overhang stays cut off. */
const Wipe: React.FC<{ at: number; d?: number; style: React.CSSProperties; children: React.ReactNode }> = ({ at, d = 0.35, style, children }) => {
  const t = useT();
  if (t < at) return null;
  const k = ramp(t, at, at + d);
  return <div style={{ position: 'absolute', whiteSpace: 'nowrap', clipPath: k >= 1 ? undefined : `inset(-30% ${(1 - k) * 100}% -30% -6%)`, ...style }}>{children}</div>;
};
const HALO = '0 0 5px rgba(240,226,184,.95),0 0 12px rgba(240,226,184,.8),0 0 2px rgba(240,226,184,1)';

/** A place name on the chart. The storm blurs and fades it. Nothing about it moves once it's inked. */
const Label: React.FC<{ x: number; y: number; at: number; text: string; size?: number; font?: string; color?: string; run?: boolean; rot?: number; style?: React.CSSProperties }> = ({ x, y, at, text, size = 58, font = FELL, color = INK_D, run = true, rot = 0, style }) => {
  const t = useT();
  const w = run ? washK(t) : 0;
  return (
    <Wipe at={at} style={{ left: x, top: y, font: `${size}px/1.05 ${font}`, color, opacity: 1 - 0.34 * w, filter: w > 0.02 ? `blur(${(w * 0.9).toFixed(2)}px)` : undefined, transform: rot ? `rotate(${rot}deg)` : undefined, textShadow: HALO, WebkitTextStroke: font === FELL ? `1.1px ${color}` : undefined, ...style }}>
      {text}
    </Wipe>
  );
};

const Stroke: React.FC<{ d: string; at: number; dur: number; color?: string; width: number; opacity?: number; cap?: 'round' | 'butt' }> = ({ d, at, dur, color = INK, width, opacity = 1, cap = 'round' }) => {
  const t = useT();
  if (t < at) return null;
  return <path d={d} fill="none" stroke={color} strokeWidth={width} strokeLinecap={cap} strokeLinejoin="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - ramp(t, at, at + dur)} opacity={opacity} />;
};

/* ---------- the chart itself ---------- */

const flecks = (n: number, seed: number) => Array.from({ length: n }, (_, i) => `M${(hash(i * 3 + seed) * 1080).toFixed(1)} ${(hash(i * 7 + seed + 1) * 1920).toFixed(1)}l${(hash(i + seed + 2) * 5).toFixed(1)} ${(hash(i + seed + 3) * 3).toFixed(1)}`).join('');
const FLECKS: [string, string, number, number][] = [[flecks(1500, 21), '#6e4a20', 2.6, 0.22], [flecks(900, 5021), '#f4e4b8', 2.4, 0.3], [flecks(260, 9021), '#5a3a18', 5, 0.16]];

/* The coast: a few hand-placed points, then split and nudged a few times, so it looks surveyed and not drawn with a template. */
const COAST_PTS: P[] = [[92, 836], [84, 772], [118, 722], [168, 686], [198, 650], [232, 612], [270, 604], [298, 634], [322, 672], [372, 694], [420, 674], [456, 702], [494, 692], [532, 654], [590, 640], [650, 650], [700, 686], [724, 748], [702, 800], [710, 850], [678, 892], [670, 944], [702, 990], [734, 1040], [772, 1086], [786, 1140], [746, 1178], [692, 1192], [640, 1180], [590, 1162], [540, 1180], [500, 1224], [450, 1262], [382, 1274], [320, 1264], [288, 1238], [264, 1198], [228, 1178], [198, 1190], [182, 1228], [140, 1246], [96, 1226], [70, 1162], [76, 1090], [96, 1020], [90, 950], [106, 890]];
const fractal = (pts: P[], depth: number, seed: number): P[] => {
  let cur = pts;
  for (let d = 0; d < depth; d++) {
    const out: P[] = [];
    cur.forEach((a, i) => {
      const b = cur[(i + 1) % cur.length];
      const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
      const k = (hash(i * 13 + d * 101 + seed) - 0.5) * 0.34 * len;
      out.push(a, [(a[0] + b[0]) / 2 + ((b[1] - a[1]) / (len || 1)) * k, (a[1] + b[1]) / 2 - ((b[0] - a[0]) / (len || 1)) * k]);
    });
    cur = out;
  }
  return cur;
};
const toPath = (pts: P[]) => `M${pts.map((p) => `${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' L')} Z`;
const COAST = toPath(fractal(COAST_PTS, 3, 7));
const ISLETS = [toPath(fractal([[796, 1218], [822, 1206], [840, 1228], [812, 1242]], 2, 31)), toPath(fractal([[92, 640], [120, 628], [132, 650], [104, 664]], 2, 47)), toPath(fractal([[962, 690], [988, 680], [1004, 702], [976, 716]], 2, 53))];

const rhumbs = (cx: number, cy: number, len: number) => Array.from({ length: 32 }, (_, i) => {
  const a = (i / 32) * Math.PI * 2;
  return { d: `M${cx} ${cy} L${(cx + Math.cos(a) * len).toFixed(1)} ${(cy + Math.sin(a) * len).toFixed(1)}`, i };
});
const RHUMB = rhumbs(560, 1010, 1500);

const Rose: React.FC<{ x: number; y: number; r: number }> = ({ x, y, r }) => {
  const pt = (a: number, len: number, w: number, fillA: string, fillB: string, key: string) => (
    <g key={key} transform={`rotate(${a})`}>
      <path d={`M0 ${-len} L${w} 0 L0 0 Z`} fill={fillA} stroke={INK} strokeWidth={1.6} />
      <path d={`M0 ${-len} L${-w} 0 L0 0 Z`} fill={fillB} stroke={INK} strokeWidth={1.6} />
    </g>
  );
  return (
    <g transform={`translate(${x} ${y})`}>
      <circle r={r} fill="none" stroke={INK} strokeWidth={3} /><circle r={r - 10} fill="none" stroke={INK} strokeWidth={1.4} />
      {Array.from({ length: 64 }, (_, i) => <rect key={i} x={-1} y={-r} width={2} height={i % 4 ? 6 : 10} fill={INK} transform={`rotate(${(i / 64) * 360})`} />)}
      {[22.5, 67.5, 112.5, 157.5, 202.5, 247.5, 292.5, 337.5].map((a) => pt(a, r * 0.62, r * 0.06, '#a8895a', LAND, `s${a}`))}
      {[45, 135, 225, 315].map((a) => pt(a, r * 0.8, r * 0.1, XRED, LAND, `m${a}`))}
      {[0, 90, 180, 270].map((a) => pt(a, r * 1.02, r * 0.14, INK_D, LAND, `l${a}`))}
      <circle r={r * 0.08} fill={BRASS} stroke={INK} strokeWidth={2} />
    </g>
  );
};

/* Mountains: a row of little peaks with their hatching. */
const PEAKS: [number, number, number][] = [[578, 966, 44], [628, 958, 56], [682, 966, 40], [538, 974, 32], [606, 988, 28]];
const Peak: React.FC<{ x: number; y: number; h: number }> = ({ x, y, h }) => (
  <g transform={`translate(${x} ${y})`}>
    <path d={`M${-h * 0.8} 0 L0 ${-h} L${h * 0.8} 0 Z`} fill="#cdb580" stroke={INK} strokeWidth={3.4} strokeLinejoin="round" />
    <path d={`M0 ${-h} L${h * 0.8} 0 L${h * 0.22} 0 Z`} fill="#8d6c3c" opacity={0.75} />
    {[0.3, 0.5, 0.7].map((k) => <path key={k} d={`M${-h * 0.8 * k + 3} ${-h + h * k + 2} l${6 + k * 8} ${-3}`} stroke={INK} strokeWidth={2} />)}
  </g>
);
const TREES: P[] = [[238, 900], [272, 928], [214, 948], [306, 896], [344, 934], [246, 978], [302, 968], [372, 886], [650, 712], [684, 748], [560, 690], [150, 1100], [116, 960], [600, 1130], [660, 1150]];

const WAVES: [number, number, number][] = Array.from({ length: 36 }, (_, i) => [50 + hash(i + 61) * 980, 440 + hash(i + 133) * 900, hash(i + 201) * 6.28]);
const SOUNDINGS: [number, number, string][] = [[106, 470, '12'], [352, 520, '7'], [74, 590, '30'], [430, 608, '5'], [770, 690, '41'], [820, 1270, '18'], [980, 860, '60'], [60, 1270, '9'], [460, 1262, '3'], [988, 1100, '52'], [720, 1250, '11'], [1000, 1250, '26']];

const ChartBase: React.FC = () => (
  <>
    <div style={{ position: 'absolute', inset: 0, background: PARCH, backgroundImage: 'radial-gradient(ellipse 75% 60% at 50% 50%,rgba(255,240,200,.35),rgba(120,70,20,.45))' }} />
    <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
      <defs>
        <clipPath id="chartIn"><rect x="52" y="52" width="976" height="1816" /></clipPath>
        <clipPath id="landIn"><path d={COAST} /></clipPath>
        <radialGradient id="seaG" cx="0.5" cy="0.5" r="0.75"><stop offset="0" stopColor={SEA} stopOpacity="0.5" /><stop offset="1" stopColor={SEA_D} stopOpacity="0.8" /></radialGradient>
      </defs>
      <g clipPath="url(#chartIn)">
        <rect x="52" y="52" width="976" height="1816" fill="url(#seaG)" />
        {RHUMB.map(({ d, i }) => <path key={i} d={d} stroke={i % 4 === 0 ? INK_D : i % 2 === 0 ? XRED : '#1f5450'} strokeWidth={i % 4 === 0 ? 1.6 : 1} opacity={0.34} />)}
        {/* water lining: the coast repeats itself outward, the way the old engravers cut it */}
        <path d={COAST} fill="none" stroke="#a9cdbf" strokeWidth={76} opacity={0.3} strokeLinejoin="round" />
        <path d={COAST} fill="none" stroke="#c6dcc4" strokeWidth={50} opacity={0.42} strokeLinejoin="round" />
        <path d={COAST} fill="none" stroke="#e0e6cc" strokeWidth={24} opacity={0.6} strokeLinejoin="round" />
        {ISLETS.map((d, i) => <g key={i}><path d={d} fill="none" stroke="#c6dcc4" strokeWidth={26} opacity={0.5} strokeLinejoin="round" /><path d={d} fill={LAND} stroke={INK} strokeWidth={4} strokeLinejoin="round" /></g>)}
        <path d={COAST} fill={LAND} stroke={INK} strokeWidth={5} strokeLinejoin="round" />
        <g clipPath="url(#landIn)">
          <path d={flecks(2200, 700)} stroke="#8a6a38" strokeWidth={2.4} opacity={0.3} strokeLinecap="round" />
          <path d={COAST} fill="none" stroke={INK} strokeWidth={30} strokeDasharray="2.6 7" opacity={0.42} />
          <path d={COAST} fill="none" stroke="#b08a50" strokeWidth={56} opacity={0.18} />
        </g>
        {TREES.map(([x, y], i) => (
          <g key={i} transform={`translate(${x} ${y})`}>
            <rect x={-2} y={0} width={4} height={13} fill={INK} /><circle cy={-7} r={12} fill="#7f8a4c" stroke={INK} strokeWidth={2.6} /><path d="M-5 -9 q4 -6 9 -2" stroke={INK} strokeWidth={1.6} fill="none" />
          </g>
        ))}
        {PEAKS.map(([x, y, h], i) => <Peak key={i} x={x} y={y} h={h} />)}
        {/* the part the platform's buttons cover: only texture */}
        <Rose x={800} y={1712} r={150} />
        <g transform="translate(110 1730)">
          {Array.from({ length: 8 }, (_, i) => <rect key={i} x={i * 56} y={0} width={56} height={16} fill={i % 2 ? LAND : INK} stroke={INK} strokeWidth={2} />)}
          {Array.from({ length: 9 }, (_, i) => <rect key={i} x={i * 56 - 1} y={-10} width={2} height={10} fill={INK} />)}
        </g>
        <circle cx="240" cy="1560" r="86" fill="none" stroke="#6e4a20" strokeWidth={12} opacity={0.16} />
      </g>
      {FLECKS.map(([d, c, w, o], i) => <path key={i} d={d} fill="none" stroke={c} strokeWidth={w} strokeLinecap="round" opacity={o} />)}
      {/* old fold lines */}
      {[360, 720].map((x) => <g key={x}><rect x={x} y={0} width={2.5} height={1920} fill="#5a3a18" opacity={0.2} /><rect x={x + 2.5} y={0} width={3} height={1920} fill="#fff4d0" opacity={0.2} /></g>)}
      {[480, 960, 1440].map((y) => <g key={y}><rect x={0} y={y} width={1080} height={2.5} fill="#5a3a18" opacity={0.2} /><rect x={0} y={y + 2.5} width={1080} height={3} fill="#fff4d0" opacity={0.2} /></g>)}
      {/* the neatline: a graduated border, as on a real chart */}
      <rect x="26" y="26" width="1028" height="1868" fill="none" stroke={INK_D} strokeWidth={4} />
      <rect x="52" y="52" width="976" height="1816" fill="none" stroke={INK_D} strokeWidth={3} />
      {Array.from({ length: 16 }, (_, i) => i % 2 ? null : <g key={i}><rect x={52 + i * 61} y={32} width={61} height={14} fill={INK_D} /><rect x={52 + i * 61} y={1874} width={61} height={14} fill={INK_D} /></g>)}
      {Array.from({ length: 30 }, (_, i) => i % 2 ? null : <g key={i}><rect x={32} y={52 + i * 60.6} width={14} height={60.6} fill={INK_D} /><rect x={1034} y={52 + i * 60.6} width={14} height={60.6} fill={INK_D} /></g>)}
    </svg>
    {SOUNDINGS.map(([x, y, s], i) => <div key={i} style={{ position: 'absolute', left: x, top: y, font: `italic 26px/1 ${FELL}`, color: '#123f3c', opacity: 0.55 }}>{s}</div>)}
    <div style={{ position: 'absolute', left: 715, top: 1496, width: 170, textAlign: 'center', font: `52px/1 ${MARK}`, color: INK_D }}>N</div>
    <div style={{ position: 'absolute', left: 110, top: 1762, font: `italic 36px/1 ${FELL}`, color: INK_D, opacity: 0.85 }}>{TXT.scale}</div>
    <div style={{ position: 'absolute', left: 92, top: 1636, font: `italic 38px/1 ${FELL}`, color: '#123f3c', opacity: 0.75 }}>{TXT.rumor}</div>
    <div style={{ position: 'absolute', left: 92, top: 1530, font: `italic 38px/1 ${FELL}`, color: '#123f3c', opacity: 0.75 }}>{TXT.seaB}</div>
  </>
);

/** Little waves on the sea, always moving, so the chart is never still. They lean harder in the storm. */
const SeaMarks: React.FC = () => {
  const t = useT();
  const s = stormK(t);
  return (
    <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
      {WAVES.map(([x, y, ph], i) => {
        const inLand = x > 90 && x < 700 && y > 650 && y < 1240;
        if (inLand) return null;
        const dx = Math.sin(t * 0.9 + ph) * (5 + s * 12);
        return <path key={i} d={`M${x + dx} ${y} q 11 -11 22 0 t 22 0 t 22 0`} fill="none" stroke="#123f3c" strokeWidth={3.2} strokeLinecap="round" opacity={0.5} />;
      })}
    </svg>
  );
};

const Unroll = (t: number) => lerp(470, 2030, easeOut(ramp(t, 0, 1.0)));

const Table: React.FC = () => (
  <div style={{ position: 'absolute', inset: 0, background: '#2b1a0e', backgroundImage: 'repeating-linear-gradient(90deg,rgba(0,0,0,.34) 0 4px,transparent 4px 214px),linear-gradient(90deg,#3a2413,#2b1a0e 40%,#3a2413)' }} />
);

/* ---------- the title cartouche, where the keyword gets inked at the end ---------- */
const CX0 = 62, CY0 = 92, CX1 = 478, CY1 = 436, CC = 30;
const Cartouche: React.FC = () => {
  const t = useT();
  const cta = E('keyword');
  const k = ramp(t, cta, cta + 0.45);
  const curl = (x: number, y: number, sx: number, sy: number, key: string) => (
    <g key={key} transform={`translate(${x} ${y}) scale(${sx} ${sy})`} fill="none" stroke={INK} strokeWidth={4} strokeLinecap="round">
      <path d="M0 0 C-20 -6 -34 -24 -24 -36 C-16 -46 0 -40 -4 -28 C-7 -20 -16 -22 -15 -29" />
      <path d="M0 0 C-6 -20 -24 -34 -36 -24 C-46 -16 -40 0 -28 -4 C-20 -7 -22 -16 -29 -15" />
    </g>
  );
  return (
    <>
      <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}>
        <path transform="translate(7 11)" d={`M${CX0 + CC} ${CY0} L${CX1 - CC} ${CY0} A${CC} ${CC} 0 0 0 ${CX1} ${CY0 + CC} L${CX1} ${CY1 - CC} A${CC} ${CC} 0 0 0 ${CX1 - CC} ${CY1} L${CX0 + CC} ${CY1} A${CC} ${CC} 0 0 0 ${CX0} ${CY1 - CC} L${CX0} ${CY0 + CC} A${CC} ${CC} 0 0 0 ${CX0 + CC} ${CY0} Z`} fill="rgba(40,20,0,.32)" />
        <path d={`M${CX0 + CC} ${CY0} L${CX1 - CC} ${CY0} A${CC} ${CC} 0 0 0 ${CX1} ${CY0 + CC} L${CX1} ${CY1 - CC} A${CC} ${CC} 0 0 0 ${CX1 - CC} ${CY1} L${CX0 + CC} ${CY1} A${CC} ${CC} 0 0 0 ${CX0} ${CY1 - CC} L${CX0} ${CY0 + CC} A${CC} ${CC} 0 0 0 ${CX0 + CC} ${CY0} Z`} fill="#f2e6c2" stroke={INK_D} strokeWidth={5} />
        <path d={`M${CX0 + 34} ${CY0 + 12} L${CX1 - 34} ${CY0 + 12} A24 24 0 0 0 ${CX1 - 12} ${CY0 + 34} L${CX1 - 12} ${CY1 - 34} A24 24 0 0 0 ${CX1 - 34} ${CY1 - 12} L${CX0 + 34} ${CY1 - 12} A24 24 0 0 0 ${CX0 + 12} ${CY1 - 34} L${CX0 + 12} ${CY0 + 34} A24 24 0 0 0 ${CX0 + 34} ${CY0 + 12} Z`} fill="none" stroke={INK} strokeWidth={2} />
        {curl(CX0 + 6, CY0 + 6, -1, -1, 'a')}{curl(CX1 - 6, CY0 + 6, 1, -1, 'b')}{curl(CX0 + 6, CY1 - 6, -1, 1, 'c')}{curl(CX1 - 6, CY1 - 6, 1, 1, 'd')}
        <path d={`M${CX0 + 90} 278 C${CX0 + 150} 266 ${CX1 - 150} 290 ${CX1 - 90} 278`} fill="none" stroke={INK} strokeWidth={2.6} />
      </svg>
      <div style={{ position: 'absolute', left: CX0, width: CX1 - CX0, top: 118, textAlign: 'center', font: `italic 34px/1 ${FELL}`, color: INK }}>{TXT.titleTop}</div>
      <Wipe at={E('hunt') - 0.1} d={0.3} style={{ left: CX0, width: CX1 - CX0, top: 150, textAlign: 'center', font: `900 66px/1 ${DISPLAY}`, color: INK_D }}>{TXT.title}</Wipe>
      <Wipe at={E('first') - 0.05} d={0.45} style={{ left: CX0, width: CX1 - CX0, top: 220, textAlign: 'center', font: `italic 42px/1 ${FELL}`, color: INK_D }}>{TXT.titleSub}</Wipe>
      {/* before the keyword, the small print: it fades as the fresh ink goes on */}
      <div style={{ position: 'absolute', left: CX0, width: CX1 - CX0, top: 306, textAlign: 'center', font: `italic 38px/46px ${FELL}`, color: INK, opacity: 0.88 * (1 - k) }}>{TXT.note.map((l) => <div key={l}>{l}</div>)}</div>
      <Wipe at={cta} d={0.3} style={{ left: CX0, width: CX1 - CX0, top: 286, textAlign: 'center', font: `900 40px/1 ${DISPLAY}`, color: INK_D }}>{TXT.comment}</Wipe>
      <Wipe at={cta + 0.2} d={0.3} style={{ left: CX0, width: CX1 - CX0, top: 322, textAlign: 'center', font: `900 100px/1 ${DISPLAY}`, color: XRED }}>{KEYWORD}</Wipe>
    </>
  );
};

/* ---------- a ship, used on the chart and in the glass ---------- */
const Ship: React.FC<{ x?: number; y?: number; s?: number; rot?: number; hull?: string; sail?: string; sofa?: boolean; flip?: boolean; line?: string }> = ({ x = 0, y = 0, s = 1, rot = 0, hull = '#7d4a22', sail = '#f2e7c9', sofa = true, flip = false, line = INK_D }) => (
  <g transform={`translate(${x} ${y}) rotate(${rot}) scale(${flip ? -s : s} ${s})`}>
    <rect x={-36} y={-178} width={8} height={176} fill={line} />
    <path d="M-30 -172 C52 -150 52 -62 -30 -36 Z" fill={sail} stroke={line} strokeWidth={4.5} strokeLinejoin="round" />
    <path d="M-30 -130 C22 -120 24 -92 -30 -82" fill="none" stroke={XRED} strokeWidth={9} />
    <path d="M-36 -176 L-74 -166 L-36 -154 Z" fill={XRED} stroke={line} strokeWidth={3} />
    <path d="M-128 -50 L-96 -48 L-84 -8 L92 -8 C110 -10 126 -24 142 -38 L122 28 C116 40 106 44 90 44 L-98 44 C-112 44 -122 22 -128 -50 Z" fill={hull} stroke={line} strokeWidth={5.5} strokeLinejoin="round" />
    <path d="M-110 12 L120 12 M-114 28 L116 28" stroke={line} strokeWidth={2.6} opacity={0.7} />
    {[-72, -30, 12, 54].map((p) => <circle key={p} cx={p} cy={-1} r={5} fill={line} />)}
    {sofa ? (
      <g>
        <rect x={32} y={-54} width={76} height={34} rx={9} fill="#b5482c" stroke={line} strokeWidth={4} />
        <rect x={26} y={-30} width={88} height={22} rx={7} fill="#cf6541" stroke={line} strokeWidth={4} />
        <circle cx={28} cy={-34} r={10} fill="#b5482c" stroke={line} strokeWidth={4} /><circle cx={112} cy={-34} r={10} fill="#b5482c" stroke={line} strokeWidth={4} />
        <path d="M54 -56 L46 -8 M88 -56 L96 -8" stroke="#e8d6a6" strokeWidth={3.4} />
      </g>
    ) : null}
  </g>
);

/* ---------- the map's marks ---------- */

const Cottage: React.FC<{ x: number; y: number; at: number }> = ({ x, y, at }) => {
  const t = useT();
  const fill = ramp(t, at + 0.2, at + 0.45);
  if (t < at) return null;
  const smoke = (i: number) => { const u = (t * 0.5 + i * 0.33) % 1; return <circle key={i} cx={x + 24 + u * 16} cy={y - 62 - u * 40} r={4 + u * 7} fill={INK} opacity={0.32 * (1 - u)} />; };
  return (
    <g>
      {[0, 1, 2].map(smoke)}
      <path d={`M${x - 34} ${y} L${x - 34} ${y - 34} L${x} ${y - 62} L${x + 34} ${y - 34} L${x + 34} ${y} Z`} fill="#c97a4a" opacity={fill} />
      <Stroke d={`M${x - 34} ${y} L${x - 34} ${y - 34} L${x} ${y - 62} L${x + 34} ${y - 34} L${x + 34} ${y} Z`} at={at} dur={0.3} width={4.5} color={INK_D} />
      <Stroke d={`M${x - 8} ${y} L${x - 8} ${y - 22} L${x + 8} ${y - 22} L${x + 8} ${y}`} at={at + 0.15} dur={0.15} width={3.5} color={INK_D} />
    </g>
  );
};

const ViewingHouse: React.FC<{ x: number; y: number; at: number }> = ({ x, y, at }) => {
  const t = useT();
  if (t < at) return null;
  const fill = ramp(t, at + 0.2, at + 0.45);
  return (
    <g>
      <path d={`M${x - 30} ${y} L${x - 30} ${y - 72} L${x + 30} ${y - 72} L${x + 30} ${y} Z`} fill="#b8a06a" opacity={fill} />
      <Stroke d={`M${x - 30} ${y} L${x - 30} ${y - 72} L${x + 30} ${y - 72} L${x + 30} ${y} Z`} at={at} dur={0.3} width={4.5} color={INK_D} />
      <Stroke d={`M${x} ${y - 72} L${x} ${y - 112}`} at={at + 0.2} dur={0.1} width={4} color={INK_D} />
      <Stroke d={`M${x} ${y - 112} L${x + 28} ${y - 102} L${x} ${y - 94}`} at={at + 0.3} dur={0.1} width={4} color={XRED} />
      <Stroke d={`M${x - 10} ${y} L${x - 10} ${y - 26} L${x + 10} ${y - 26} L${x + 10} ${y}`} at={at + 0.2} dur={0.15} width={3.5} color={INK_D} />
      {[-14, 2].map((dx) => <rect key={dx} x={x + dx} y={y - 60} width={12} height={16} fill={INK_D} opacity={fill} />)}
    </g>
  );
};

const CapeHouse: React.FC<{ x: number; y: number; at: number }> = ({ x, y, at }) => {
  const t = useT();
  if (t < at) return null;
  const fill = ramp(t, at + 0.15, at + 0.4);
  return (
    <g>
      <path d={`M${x - 16} ${y} L${x - 16} ${y - 54} L${x + 16} ${y - 54} L${x + 16} ${y} Z`} fill="#c9a46a" opacity={fill} />
      <Stroke d={`M${x - 16} ${y} L${x - 16} ${y - 54} L${x + 16} ${y - 54} L${x + 16} ${y} Z`} at={at} dur={0.25} width={4.5} color={INK_D} />
      <Stroke d={`M${x + 4} ${y - 54} L${x + 4} ${y - 78} L${x + 14} ${y - 78}`} at={at + 0.2} dur={0.12} width={4} color={INK_D} />
    </g>
  );
};

const MapLamp: React.FC = () => {
  const t = useT();
  const at = E('sign') - 0.05;
  if (t < at) return null;
  const [x, y] = AT.lamp;
  const pulse = 0.55 + 0.2 * Math.sin(t * 5);
  return (
    <g>
      <circle cx={x} cy={y - 60} r={70} fill="url(#warm)" opacity={pulse} />
      <Stroke d={`M${x} ${y} L${x} ${y - 52}`} at={at} dur={0.15} width={5.5} color={INK_D} />
      <Stroke d={`M${x - 9} ${y - 52} L${x + 9} ${y - 52} L${x + 6} ${y - 70} L${x - 6} ${y - 70} Z`} at={at + 0.1} dur={0.15} width={4} color={INK_D} />
      <rect x={x - 6} y={y - 68} width={12} height={15} fill={GOLD} opacity={ramp(t, at + 0.15, at + 0.3)} />
    </g>
  );
};

/* ---------- the old trail, and what the storm does to it ---------- */
const OldTrail: React.FC = () => {
  const t = useT();
  const len = trailLen(t);
  return (
    <>
      {OLD.map((d, i) => {
        if (d.s > len) return null;
        const st = E('last') + hash(i + 5) * 1.7;
        const k = ramp(t, st, st + 0.85);
        if (k >= 1) return null;
        const y = d.y + easeIn(k) * (50 + hash(i + 9) * 170);
        const w = 19 + k * 30;
        const fresh = 1 - ramp(len - d.s, 0, 60);
        return <rect key={i} x={-w / 2} y={-4.5 * (1 - 0.5 * k)} width={w} height={9 * (1 - 0.5 * k)} rx={4.5 * (1 - 0.5 * k)} fill={fresh > 0.5 ? INK_D : INK} opacity={1 - k * k} transform={`translate(${d.x.toFixed(1)} ${y.toFixed(1)}) rotate(${lerp(d.a, 90, ramp(k, 0, 0.3)).toFixed(1)}) scale(${1 + fresh * 0.25})`} />;
      })}
    </>
  );
};

/* Every label the storm reaches sheds drips of ink. The stains stay, faintly, for the rest of the film. */
const DRIP_SOURCES: { x0: number; x1: number; y: number; n: number }[] = [
  { x0: 262, x1: 540, y: 1142, n: 17 },
  { x0: 150, x1: 480, y: 830, n: 20 },
  { x0: 522, x1: 760, y: 1210, n: 13 },
  { x0: 725, x1: 945, y: 1254, n: 8 },
  { x0: 232, x1: 312, y: 700, n: 8 },
  { x0: 186, x1: 244, y: 1136, n: 7 },
];
const wobble = (x: number, h: number, seed: number) => `M${x} 0 C${x + (hash(seed) - 0.5) * 14} ${h * 0.3} ${x + (hash(seed + 1) - 0.5) * 18} ${h * 0.66} ${x + (hash(seed + 2) - 0.5) * 8} ${h}`;
const DRIPS = DRIP_SOURCES.flatMap((s, si) => Array.from({ length: s.n }, (_, i) => ({ x: s.x0 + hash(si * 11 + i * 3 + 1) * (s.x1 - s.x0), y: s.y - 8 + hash(i + si + 44) * 10, delay: hash(si * 5 + i * 7 + 2) * 1.5, len: 30 + Math.pow(hash(si + i * 13 + 3), 2) * 210, w: 1.6 + hash(si * 2 + i + 4) * 3.2, seed: si * 37 + i * 5 })));
const VEILS = DRIP_SOURCES.map((s) => ({ x: s.x0 - 6, w: s.x1 - s.x0 + 12, y: s.y - 44 }));
const WET = Array.from({ length: 26 }, (_, i) => ({ x: 70 + hash(i + 301) * 940, y: 480 + hash(i + 401) * 800, r: 50 + hash(i + 501) * 110, delay: hash(i + 601) * 2.2 }));

const InkRun: React.FC = () => {
  const t = useT();
  if (t < E('low')) return null;
  const clearing = ramp(t, E('nolisting'), E('phone') + 0.4);
  const veil = easeOut(ramp(t, E('last'), E('signed') + 0.5));
  return (
    <>
      <defs>
        <linearGradient id="veilG" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor={INK_D} stopOpacity="0.5" /><stop offset="0.35" stopColor={INK} stopOpacity="0.28" /><stop offset="1" stopColor={INK} stopOpacity="0" /></linearGradient>
        <filter id="softInk" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="9" /></filter>
      </defs>
      {WET.map((w, i) => {
        const k = easeOut(ramp(t, E('low') + 0.3 + w.delay, E('low') + 1.1 + w.delay));
        return k > 0 ? <circle key={i} cx={w.x} cy={w.y} r={w.r * k} fill="url(#wet)" opacity={0.95 - 0.68 * clearing} /> : null;
      })}
      {/* the ink lifts off the paper in a soft wash under each name */}
      {veil > 0 ? VEILS.map((v, i) => <rect key={i} x={v.x} y={v.y} width={v.w} height={40 + 190 * veil} fill="url(#veilG)" filter="url(#softInk)" opacity={veil * (1 - 0.72 * clearing)} />) : null}
      {DRIPS.map((d, i) => {
        const k = easeOut(ramp(t, E('last') + d.delay, E('last') + d.delay + 1.2));
        if (k <= 0) return null;
        const h = Math.max(1, d.len * k);
        const end = (hash(d.seed + 2) - 0.5) * 8;
        return (
          <g key={i} transform={`translate(0 ${d.y})`} opacity={0.8 - 0.62 * clearing}>
            <path d={wobble(d.x, h, d.seed)} fill="none" stroke={INK_D} strokeWidth={d.w} strokeLinecap="round" />
            <ellipse cx={d.x + end} cy={h} rx={d.w * 0.75} ry={d.w * 1.2} fill={INK_D} />
          </g>
        );
      })}
    </>
  );
};

const FreshTrail: React.FC = () => {
  const t = useT();
  if (t < E('nolisting')) return null;
  const len = newLen(t);
  return (
    <>
      {NEW.map((d, i) => {
        if (d.s > len) return null;
        const age = (len - d.s) / 90;
        const s = lerp(1.5, 1, clamp(age));
        return <g key={i} transform={`translate(${d.x.toFixed(1)} ${d.y.toFixed(1)}) rotate(${d.a.toFixed(1)}) scale(${s})`}><rect x={-13} y={-6} width={26} height={12} rx={6} fill={XRED} /><rect x={-9} y={-4} width={12} height={2.6} rx={1.3} fill="#ffb9a8" opacity={0.75} /></g>;
      })}
    </>
  );
};

const XMark: React.FC = () => {
  const t = useT();
  const at = E('number') - 0.08;
  if (t < at) return null;
  const [x, y] = AT.x;
  const k = back(ramp(t, at, at + 0.14));
  const open = ramp(t, E('chop3'), E('chop3') + 0.1);
  const r = 44;
  const under = ramp(t, E('key') + 0.18, E('key') + 0.3);
  return open >= 1 && t < E('key') + 0.2 ? null : (
    <g transform={`translate(${x} ${y + under * 40}) scale(${lerp(1.7, 1, k) * lerp(1, 0.5, under)})`} opacity={1 - open * (t < E('key') ? 1 : 0)}>
      <path d={`M${-r} ${-r} L${r} ${r} M${r} ${-r} L${-r} ${r}`} stroke="rgba(60,15,5,.35)" strokeWidth={20} strokeLinecap="round" transform="translate(3 5)" />
      <path d={`M${-r} ${-r} L${r} ${r} M${r} ${-r} L${-r} ${r}`} stroke={XRED} strokeWidth={20} strokeLinecap="round" />
    </g>
  );
};

/* ---------- the monster in the margin ---------- */
const Beast: React.FC<{ eye: number; lift: number; keys?: boolean; small?: boolean }> = ({ eye, lift, keys = true, small = false }) => {
  const t = useT();
  const BODY = small ? '#7f6aa0' : '#4f8a78', DARK = small ? '#5b4a78' : '#2f6657';
  const sway = Math.sin(t * 1.8) * 4;
  return (
    <g transform={`translate(0 ${(1 - lift) * 120}) rotate(${(1 - lift) * 14 + sway * lift * 0.5})`}>
      {/* crest */}
      {[0, 1, 2, 3].map((i) => <path key={i} d={`M${16 + i * 16} ${-44 + i * 34} l34 -16 l-12 34 Z`} fill={DARK} stroke={INK_D} strokeWidth={3.5} strokeLinejoin="round" />)}
      {/* horn and ear */}
      <path d="M6 -52 C22 -92 56 -106 82 -104 C62 -88 48 -62 40 -28 Z" fill="#e8d6a6" stroke={INK_D} strokeWidth={4.5} strokeLinejoin="round" />
      {/* lower jaw */}
      <path d="M-122 12 C-92 34 -42 44 8 46 L26 26 C-18 18 -80 6 -122 12 Z" fill={DARK} stroke={INK_D} strokeWidth={4.5} strokeLinejoin="round" />
      {[-96, -70, -44].map((x) => <path key={x} d={`M${x} 8 l10 -18 l10 18 Z`} fill="#f4ecd2" stroke={INK_D} strokeWidth={2} />)}
      {/* skull */}
      <path d="M44 22 C66 -30 22 -74 -30 -70 C-72 -66 -122 -52 -152 -34 C-168 -22 -162 -6 -140 -4 L-70 0 C-38 2 -8 12 14 36 Z" fill={BODY} stroke={INK_D} strokeWidth={5} strokeLinejoin="round" />
      <circle cx={-128} cy={-24} r={5} fill={INK_D} />
      {[0, 1, 2].map((i) => <path key={i} d={`M${-80 + i * 22} -6 q6 -8 12 0`} fill="none" stroke={INK_D} strokeWidth={2.4} opacity={0.6} />)}
      {/* the eye */}
      {eye > 0.04 ? (
        <g>
          <ellipse cx={-38} cy={-36} rx={18} ry={16 * eye} fill={GOLD} stroke={INK_D} strokeWidth={3.5} />
          <rect x={-41} y={-36 - 13 * eye} width={6} height={26 * eye} rx={3} fill={INK_D} />
        </g>
      ) : <path d="M-56 -36 q18 12 36 0" fill="none" stroke={INK_D} strokeWidth={5} strokeLinecap="round" />}
      {keys ? (
        <g>
          <circle cx={-38} cy={-36} r={27} fill="none" stroke={BRASS} strokeWidth={5} opacity={eye} />
          <g transform={`translate(26 46) rotate(${Math.sin(t * 2.4) * 10})`}>
            <circle cy={18} r={18} fill="none" stroke={BRASS} strokeWidth={5.5} />
            {[-18, 0, 18].map((a, i) => <g key={i} transform={`translate(0 36) rotate(${a + Math.sin(t * 3.1 + i) * 6})`}><rect x={-2.5} y={0} width={5} height={30} fill={BRASS} stroke={INK_D} strokeWidth={1.2} /><rect x={-2.5} y={22} width={11} height={5} fill={BRASS} /></g>)}
          </g>
        </g>
      ) : null}
    </g>
  );
};

const HUMPS: [number, number, number, number][] = [[934, 1004, 78, 74], [838, 1084, 62, 58], [906, 1168, 46, 38]];
const Monster: React.FC = () => {
  const t = useT();
  const wake = easeOut(ramp(t, E('beast') - 0.02, E('beast') + 0.5));
  const sink = ramp(t, E('low'), E('storm') + 0.2);
  const rise = (0.42 + 0.58 * wake) * (1 - 0.5 * sink);
  const eye = wake * (1 - sink) * (Math.sin(t * 0.9) > 0.985 ? 0.2 : 1);
  const w = washK(t);
  const topY = 806 + (1 - rise) * 130;
  const neck = `M950 958 C966 930 900 920 900 880 C900 ${lerp(860, 840, rise)} 900 ${topY + 30} 896 ${topY}`;
  return (
    <g opacity={1 - 0.3 * w}>
      {/* the neck rises from behind the first hump, and the head sits on it */}
      <path d={neck} fill="none" stroke={INK_D} strokeWidth={74} strokeLinecap="round" />
      <path d={neck} fill="none" stroke="#4f8a78" strokeWidth={64} strokeLinecap="round" />
      {HUMPS.map(([x, y, rx, h], i) => {
        const k = rise * (0.92 + 0.08 * Math.sin(t * 1.9 + i * 1.7));
        const ht = h * k + 18;
        return (
          <g key={i}>
            <path d={`M${x - rx} ${y} C${x - rx * 0.8} ${y - ht * 1.3} ${x + rx * 0.8} ${y - ht * 1.3} ${x + rx} ${y} Z`} fill="#4f8a78" stroke={INK_D} strokeWidth={5} strokeLinejoin="round" />
            {[-0.45, 0, 0.45].map((f) => <path key={f} d={`M${x + f * rx - 8} ${y - ht * 0.45} q8 10 16 0`} fill="none" stroke={INK_D} strokeWidth={2.4} opacity={0.55} />)}
            <path d={`M${x - rx - 24} ${y + 4} q18 -10 36 0 t36 0 t36 0 t36 0`} fill="none" stroke="#123f3c" strokeWidth={3.4} strokeLinecap="round" />
          </g>
        );
      })}
      <g transform={`translate(886 ${topY - 30})`}><Beast eye={eye} lift={1} /></g>
    </g>
  );
};
const Snores: React.FC = () => {
  const t = useT();
  if (t > E('beast')) return null;
  return <>{[0, 1, 2].map((i) => { const u = (t * 0.45 + i * 0.33) % 1; return <div key={i} style={{ position: 'absolute', left: 826 + u * 44 + Math.sin(u * 9 + i) * 6, top: 790 - u * 120, font: `${34 + u * 34}px/1 ${FELL}`, fontStyle: 'italic', color: INK_D, opacity: Math.min(1, u * 5) * (1 - u) }}>z</div>; })}</>;
};

/* ---------- twelve crews jammed around one landmark ---------- */
const FLEET: [number, number, number, boolean][] = [[118, 640, -4, false], [186, 566, 5, true], [268, 556, -3, false], [352, 626, 4, true], [440, 640, -5, true], [100, 558, 3, false], [356, 556, -4, true], [442, 566, 4, true], [522, 612, -3, true], [150, 624, 3, false], [404, 604, 2, true], [300, 606, -2, false]];
const Fleet: React.FC = () => {
  const t = useT();
  if (t < E('twelve')) return null;
  const gone = E('storm');
  const hulls = ['#5a3a1c', '#6c4422', '#3f2b16', '#7d4a22'];
  return (
    <>
      {FLEET.map(([x, y, r, flip], i) => {
        const at = E('twelve') + i * 0.075;
        if (t < at) return null;
        const k = back(ramp(t, at, at + 0.16));
        const leave = ramp(t, gone + i * 0.08, gone + i * 0.08 + 0.9);
        if (leave >= 1) return null;
        const jam = Math.sin(t * 2.3 + i * 1.9) * 3;
        return <g key={i} opacity={1 - leave}><Ship x={x + jam + leave * (flip ? 150 : -150)} y={y + Math.sin(t * 2 + i) * 3 + leave * 60} s={0.3 * k} rot={r + jam + leave * 40} hull={hulls[i % 4]} sofa={false} flip={flip} /></g>;
      })}
    </>
  );
};

const Token: React.FC = () => {
  const t = useT();
  let x = AT.cove[0] + 6, y = AT.cove[1] + 22, flip = false;
  if (t < E('faster')) {
    const len = trailLen(t);
    const i = clamp(Math.round(len / 3), 0, OLD_FINE.length - 2);
    const d = OLD_FINE[i], moving = len > 0;
    if (moving) { x = d.x; y = d.y; flip = OLD_FINE[i + 1].x < d.x; }
  }
  const sink = ramp(t, E('faster') - 0.4, E('faster') + 0.3);
  const fresh = t >= E('nolisting');
  if (fresh) {
    const len = newLen(t);
    const i = clamp(Math.round(len / 3), 0, NEW_FINE.length - 1);
    x = NEW_FINE[i].x; y = NEW_FINE[i].y; flip = false;
    if (t > E('dig')) return null;
  } else if (sink >= 1) return null;
  const pop = fresh ? 1 : 1 - sink;
  const rock = Math.sin(t * 3) * 4 + stormK(t) * 18 * Math.sin(t * 2.3);
  return <g opacity={pop}><Ship x={x} y={y - 26 + Math.sin(t * 5) * 3} s={0.36} rot={rock} hull={XRED} flip={flip} /></g>;
};

/* ---------- the dig: the chart itself gets cut open ---------- */
const DIRT = Array.from({ length: 16 }, (_, i) => ({ a: hash(i + 701) * Math.PI - Math.PI, v: 180 + hash(i + 801) * 320, r: 3 + hash(i + 901) * 6 }));
const Burst: React.FC<{ at: number; x: number; y: number }> = ({ at, x, y }) => {
  const t = useT();
  const u = t - at;
  if (u < 0 || u > 0.55) return null;
  return <>{DIRT.map((d, i) => <circle key={i} cx={x + Math.cos(d.a) * d.v * u} cy={y + Math.sin(d.a) * d.v * u + 620 * u * u} r={d.r} fill={i % 3 ? '#2a180b' : PARCH} opacity={1 - u / 0.55} />)}</>;
};

const KeyArt: React.FC<{ s?: number }> = ({ s = 1 }) => (
  <g transform={`scale(${s})`}>
    <circle cx={0} cy={-62} r={30} fill="none" stroke="#6e4f17" strokeWidth={20} /><circle cx={0} cy={-62} r={30} fill="none" stroke="#dcb04c" strokeWidth={13} />
    <circle cx={-10} cy={-74} r={3.4} fill="#fff1c0" />
    <rect x={-8} y={-34} width={16} height={128} rx={4} fill="#dcb04c" stroke="#6e4f17" strokeWidth={4} />
    <rect x={-13} y={-26} width={26} height={8} rx={3} fill="#b88a2e" stroke="#6e4f17" strokeWidth={2.4} />
    <path d="M8 56 L38 56 L38 68 L26 68 L26 76 L38 76 L38 92 L8 92 Z" fill="#dcb04c" stroke="#6e4f17" strokeWidth={4} strokeLinejoin="round" />
  </g>
);

const Dig: React.FC = () => {
  const t = useT();
  const D = E('dig');
  const [x, y] = AT.x;
  if (t < D - 0.05 || t > E('key') + 0.42) return null;
  const c1 = E('chop1'), c2 = E('chop2'), c3 = E('chop3');
  /* the spade: raised, then down on each chop */
  const lift = (c: number) => { const u = t - c; return u < -0.16 ? 1 : u < 0 ? 1 - easeIn(ramp(u, -0.16, 0)) : ramp(u, 0.06, 0.26); };
  const next = t < c1 + 0.2 ? c1 : t < c2 + 0.2 ? c2 : c3;
  const h = lift(next);
  const away = easeIn(ramp(t, c3 + 0.14, c3 + 0.5));
  const hole = t < c3 ? 0 : easeOut(ramp(t, c3, c3 + 0.16));
  const riseK = easeOut(ramp(t, E('rise'), E('thud')));
  const lidK = back(ramp(t, E('lid'), E('lid') + 0.42));
  const lid = lerp(1, -0.62, lidK);
  const gleam = ramp(t, E('lid') + 0.1, E('gleam') + 0.2);
  const keyUp = easeOut(ramp(t, E('lid') + 0.1, E('gleam') + 0.25));
  const out = easeIn(ramp(t, E('key') + 0.08, E('key') + 0.4));
  const jump = t >= E('thud') ? Math.exp(-(t - E('thud')) * 9) * Math.sin((t - E('thud')) * 40) * 8 : 0;
  const flaps = Array.from({ length: 12 }, (_, i) => (i / 12) * Math.PI * 2);
  return (
    <g transform={`translate(${x} ${y}) scale(${1 - out}) translate(${-x} ${-y})`}>
      {/* the cuts, one per chop, before the paper opens */}
      <Stroke d={`M${x - 76} ${y - 10} L${x + 70} ${y + 12}`} at={c1} dur={0.05} width={5} color={INK_D} />
      <Stroke d={`M${x - 66} ${y + 18} L${x + 78} ${y - 16}`} at={c2} dur={0.05} width={5} color={INK_D} />
      {hole > 0 ? (
        <g>
          {/* torn paper folding back round the hole */}
          {flaps.map((a, i) => {
            const rx = 96 * hole, ry = 42 * hole;
            const px = x + Math.cos(a) * rx, py = y + Math.sin(a) * ry + 8;
            const out2 = 22 + hash(i + 11) * 24;
            return <path key={i} d={`M${px - 22 * Math.sin(a)} ${py + 10 * Math.cos(a)} L${px + Math.cos(a) * out2} ${py + Math.sin(a) * out2 * 0.6} L${px + 22 * Math.sin(a)} ${py - 10 * Math.cos(a)} Z`} fill="#f0e2bb" stroke={INK} strokeWidth={2.4} />;
          })}
          <ellipse cx={x} cy={y + 8} rx={96 * hole} ry={42 * hole} fill="#1c0f06" stroke={INK_D} strokeWidth={5} />
          <ellipse cx={x} cy={y + 22} rx={70 * hole} ry={22 * hole} fill="#3a2110" />
        </g>
      ) : null}
      {/* the chest comes up out of the paper */}
      {t >= E('rise') ? (
        <g clipPath="url(#holeFront)">
          <g transform={`translate(${x} ${y + 26 + (1 - riseK) * 150 + jump})`}>
            {/* rays and the key sit behind the chest's front */}
            {gleam > 0 ? Array.from({ length: 12 }, (_, i) => <path key={i} d="M-9 0 L0 -190 L9 0 Z" fill={GOLD} opacity={0.5 * gleam} transform={`translate(0 -90) rotate(${i * 30 + t * 22})`} />) : null}
            {keyUp > 0 ? <g transform={`translate(0 ${lerp(-40, -176, keyUp)}) rotate(${Math.sin(t * 2.6) * 6})`}><KeyArt s={0.82} /></g> : null}
            <rect x={-84} y={-94} width={168} height={94} rx={8} fill="#8a5a2b" stroke={INK_D} strokeWidth={5.5} />
            {[-66, -36, -6].map((p) => <path key={p} d={`M-84 ${p} L84 ${p}`} stroke={INK_D} strokeWidth={2.4} opacity={0.5} />)}
            {[-62, 44].map((p) => <rect key={p} x={p} y={-94} width={18} height={94} fill="#4a3a28" stroke={INK_D} strokeWidth={3} />)}
            <rect x={-15} y={-60} width={30} height={34} rx={4} fill={BRASS} stroke={INK_D} strokeWidth={3.5} /><rect x={-3} y={-50} width={6} height={13} fill={INK_D} />
            <g transform={`translate(0 -94) scale(1 ${lid}) translate(0 94)`}>
              <path d="M-84 -94 C-84 -150 84 -150 84 -94 Z" fill={lid < 0 ? '#6e2020' : '#a8703a'} stroke={INK_D} strokeWidth={5.5} strokeLinejoin="round" />
              {lid >= 0 ? [-62, 44].map((p) => <rect key={p} x={p} y={-134} width={18} height={40} fill="#4a3a28" stroke={INK_D} strokeWidth={3} />) : null}
            </g>
          </g>
        </g>
      ) : null}
      {t >= E('thud') && t < E('thud') + 0.5 ? <ellipse cx={x} cy={y + 24} rx={110 + (t - E('thud')) * 360} ry={18 + (t - E('thud')) * 70} fill="none" stroke={CREAM} strokeWidth={8 * (1 - (t - E('thud')) * 2)} opacity={1 - (t - E('thud')) * 2} /> : null}
      <Burst at={c1} x={x} y={y} /><Burst at={c2} x={x} y={y} /><Burst at={c3} x={x} y={y} />
      {/* the spade */}
      {away < 1 ? (
        <g transform={`translate(${x + 8 + h * 40 + away * 420} ${y + 2 - h * 118 - away * 360}) rotate(${22 + h * 12 + away * 30})`} opacity={1 - away}>
          <rect x={-8} y={-430} width={16} height={350} rx={6} fill="#9a6c34" stroke={INK_D} strokeWidth={5} />
          <path d="M-34 -470 L34 -470 L24 -430 L-24 -430 Z" fill="#9a6c34" stroke={INK_D} strokeWidth={5} strokeLinejoin="round" />
          <path d="M0 6 C-44 -8 -54 -60 -48 -96 L48 -96 C54 -60 44 -8 0 6 Z" fill="#a9b2b6" stroke={INK_D} strokeWidth={5.5} strokeLinejoin="round" />
          <path d="M-30 -84 C-34 -56 -26 -30 -10 -14" fill="none" stroke="#e7eded" strokeWidth={6} strokeLinecap="round" />
          <rect x={-13} y={-112} width={26} height={30} rx={4} fill="#6d7478" stroke={INK_D} strokeWidth={4} />
        </g>
      ) : null}
    </g>
  );
};

/* ---------- what comes after: a tiny lit window, and who lives upstairs ---------- */
const lit = (t: number) => ramp(t, E('key') + 0.28, E('key') + 0.42);
const Note: React.FC<{ x: number; y: number; s: number; o: number; c?: string }> = ({ x, y, s, o, c = INK_D }) => (
  <g transform={`translate(${x} ${y}) scale(${s})`} opacity={o}>
    <ellipse cx={-5} cy={13} rx={9} ry={7} fill={c} transform="rotate(-18 -5 13)" />
    <rect x={2} y={-22} width={3.6} height={36} fill={c} />
    <path d="M4 -22 C14 -18 18 -10 15 -2 C13 -8 9 -11 4 -12 Z" fill={c} />
  </g>
);

const Apartment: React.FC = () => {
  const t = useT();
  const at = E('key') + 0.18;
  if (t < at) return null;
  const [x, y] = AT.x;
  const pop = back(ramp(t, at, at + 0.2));
  const L = lit(t);
  const hop = stomp(t) * 4;
  const notesOn = ramp(t, E('sings'), E('sings') + 0.2);
  return (
    <g>
      <circle cx={x} cy={y - 40} r={150} fill="url(#warm)" opacity={L * (0.75 + 0.1 * Math.sin(t * 6))} />
      <g transform={`translate(${x} ${y + 14 + hop}) scale(${pop})`}>
        <path d="M-44 0 L-44 -66 L0 -102 L44 -66 L44 0 Z" fill="#d9c28f" stroke={INK_D} strokeWidth={5.5} strokeLinejoin="round" />
        <path d="M-52 -60 L0 -108 L52 -60" fill="none" stroke={INK_D} strokeWidth={8} strokeLinecap="round" strokeLinejoin="round" />
        <rect x={-30} y={-34} width={18} height={34} fill={INK} />
        <rect x={0} y={-48} width={30} height={30} fill={L > 0 ? GOLD : '#3a2a18'} stroke={INK_D} strokeWidth={4} />
        <path d="M15 -48 L15 -18 M0 -33 L30 -33" stroke={INK_D} strokeWidth={2.6} />
        <rect x={-10} y={-82} width={20} height={18} fill="#3a2a18" stroke={INK_D} strokeWidth={3} />
      </g>
      {notesOn > 0 ? [0, 1, 2, 3].map((i) => { const u = (t * 0.55 + i * 0.25) % 1; return <Note key={i} x={x + 44 + u * 56 + Math.sin(u * 8 + i) * 8} y={y - 30 - u * 86} s={1.1 + u * 0.4} o={notesOn * Math.sin(u * Math.PI)} />; }) : null}
    </g>
  );
};

const Neighbor: React.FC = () => {
  const t = useT();
  const at = E('neighbor');
  if (t < at) return null;
  const [x, y] = AT.x;
  const k = ramp(t, at, at + 0.4);
  const s = stomp(t);
  return (
    <div style={{ position: 'absolute', left: x - 110, top: y - 268, width: 220, height: 190, clipPath: k >= 1 ? undefined : `inset(0 0 ${(1 - k) * 100}% 0)` }}>
      <svg viewBox="-110 -130 220 190" width={220} height={190} style={{ overflow: 'visible' }}>
        <g transform={`translate(6 ${6 - s * 12}) scale(0.62)`}><Beast eye={1} lift={1} keys={false} small /></g>
      </svg>
    </div>
  );
};

/* ---------- the window: a brass spyglass, and what it finds ---------- */
const Art: React.FC<{ children: React.ReactNode }> = ({ children }) => <svg viewBox="0 0 600 600" width={600} height={600} style={{ position: 'absolute', left: 0, top: 0, display: 'block', overflow: 'hidden' }}>{children}</svg>;
const waveD = (y: number, amp: number, len: number, ph: number) => {
  const x0 = -len * 2 + (((ph % len) + len) % len);
  let d = `M${x0.toFixed(1)} 700 L${x0.toFixed(1)} ${y.toFixed(1)}`;
  for (let i = 0; i < Math.ceil(800 / len) + 3; i++) d += ` q${len / 4} ${-amp} ${len / 2} 0 q${len / 4} ${amp * 0.5} ${len / 2} 0`;
  return `${d} L1200 700 Z`;
};
const Backdrop: React.FC<{ a: string; b: string; rows: [string, number, number, number, number][] }> = ({ a, b, rows }) => {
  const t = useT();
  const id = `bk${a.slice(1)}${b.slice(1)}`;
  return (
    <>
      <defs><linearGradient id={id} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor={a} /><stop offset="0.72" stopColor={b} /></linearGradient></defs>
      <rect x="0" y="0" width="600" height="600" fill={`url(#${id})`} />
      {rows.map(([c, y, amp, len, sp], i) => <path key={i} d={waveD(y, amp, len, t * sp)} fill={c} />)}
    </>
  );
};
const CALM: [string, number, number, number, number][] = [['#2f7f7c', 360, 10, 120, 18], ['#287472', 410, 14, 150, -26], ['#1f6062', 470, 18, 190, 34], ['#174e52', 530, 22, 230, -44]];

/** First sight: the glass hunts across empty water, then finds the ship. */
const SeaSearch: React.FC = () => {
  const t = useT();
  const k = easeInOut(ramp(t, E('glass') + 0.05, E('glass') + 0.85));
  const x = lerp(860, 310, k);
  return (
    <Art>
      <Backdrop a="#e9c687" b="#f4e2b4" rows={[]} />
      <circle cx={140} cy={210} r={52} fill="#fff1c7" opacity={0.9} />
      {[0, 1, 2].map((i) => <path key={i} d={`M${130 + i * 140} ${130 - i * 16} q18 -14 36 0 q18 -14 36 0`} fill="none" stroke="#7a5a3a" strokeWidth={3.6} opacity={0.6} />)}
      <path d={waveD(360, 10, 120, t * 18)} fill="#2f7f7c" />
      <Ship x={x} y={352 + Math.sin(t * 2.1) * 6} s={0.95} rot={Math.sin(t * 1.5) * 3} />
      <path d={waveD(410, 14, 150, -t * 26)} fill="#287472" />
      <path d={waveD(470, 18, 190, t * 34)} fill="#1f6062" />
      <path d={waveD(530, 22, 230, -t * 44)} fill="#174e52" />
    </Art>
  );
};

const HomeShore: React.FC = () => {
  const t = useT();
  const z = lerp(1, 1.95, easeInOut(ramp(t, E('sofa') - 0.3, E('sofa') + 0.12)));
  const shipX = lerp(330, 440, ramp(t, E('cove'), E('sofa')));
  const shipY = 352 + Math.sin(t * 2.1) * 5;
  const zk = ramp(z, 1, 1.95);
  const fx = shipX + 70 * zk, fy = shipY - 34 * zk;
  const sx = lerp(fx, 300, zk), sy = lerp(fy, 350, zk);
  const wave = Math.sin(t * 7) * 18;
  const tag = back(ramp(t, E('sofa') - 0.02, E('sofa') + 0.2));
  return (
    <>
      <Art>
        <g transform={`translate(${sx - fx * z} ${sy - fy * z}) scale(${z})`}>
          <rect x="-300" y="-300" width="1300" height="1100" fill="#d9dcc0" />
          <Backdrop a="#a9d4cf" b="#f7e3b3" rows={[]} />
          <circle cx={470} cy={140} r={46} fill="#fff4cc" />
          <path d={waveD(362, 8, 120, t * 14)} fill="#3b908c" />
          {/* the house you grew up in */}
          <path d="M-40 380 C40 280 150 250 250 330 C270 350 270 380 280 400 L-40 420 Z" fill="#7f8a4c" stroke={INK_D} strokeWidth={4} />
          <g transform="translate(120 290)">
            <rect x={-46} y={-50} width={92} height={62} fill="#efe0b8" stroke={INK_D} strokeWidth={4} />
            <path d="M-58 -46 L0 -92 L58 -46 Z" fill="#b5482c" stroke={INK_D} strokeWidth={4} strokeLinejoin="round" />
            <rect x={-12} y={-22} width={24} height={34} fill="#5a3a1c" />
            <rect x={20} y={-34} width={18} height={18} fill={GOLD} stroke={INK_D} strokeWidth={2.4} />
            <rect x={-38} y={-34} width={18} height={18} fill={GOLD} stroke={INK_D} strokeWidth={2.4} />
          </g>
          {/* two small figures waving goodbye */}
          {[[178, 322], [206, 328]].map(([x, y], i) => (
            <g key={i} transform={`translate(${x} ${y})`}>
              <circle cy={-22} r={7} fill="#e8c9a0" stroke={INK_D} strokeWidth={2.4} />
              <path d="M0 -14 L0 8 M0 8 L-6 22 M0 8 L6 22" stroke={INK_D} strokeWidth={4} strokeLinecap="round" fill="none" />
              <path d={`M0 -10 L${10 + (i ? wave : -wave) * 0.3} -24`} stroke={INK_D} strokeWidth={4} strokeLinecap="round" />
            </g>
          ))}
          <Ship x={shipX} y={shipY} s={0.86} rot={Math.sin(t * 1.5) * 2.4} />
          <path d={waveD(408, 12, 150, -t * 22)} fill="#2f7f7c" />
          <path d={waveD(470, 16, 190, t * 30)} fill="#247073" />
          <path d={waveD(530, 20, 230, -t * 38)} fill="#1b5a5e" />
        </g>
      </Art>
      {tag > 0 ? <div style={{ position: 'absolute', left: 150, top: 96, padding: '6px 22px', background: CREAM, border: `4px solid ${INK_D}`, borderRadius: 6, font: `italic 46px/1.1 ${FELL}`, color: INK_D, whiteSpace: 'nowrap', transform: `rotate(-4deg) scale(${tag})`, boxShadow: '0 6px 10px rgba(0,0,0,.35)' }}>{TXT.cargo}</div> : null}
    </>
  );
};

/* Twelve crews, back to front. Hats from behind. */
const CROWD: [number, number, number][] = [[250, 348, 0.34], [300, 352, 0.34], [352, 348, 0.34], [214, 382, 0.52], [280, 392, 0.52], [340, 390, 0.52], [400, 380, 0.52], [158, 442, 0.82], [260, 456, 0.82], [362, 452, 0.82], [456, 438, 0.82], [300, 540, 1.28]];
const Hallway: React.FC = () => {
  const t = useT();
  return (
    <Art>
      <rect x="0" y="0" width="600" height="600" fill="#6f7f63" />
      <path d="M0 0 L245 190 L245 330 L0 600 Z" fill="#7b8a68" stroke={INK_D} strokeWidth={3} />
      <path d="M600 0 L355 190 L355 330 L600 600 Z" fill="#6a7a59" stroke={INK_D} strokeWidth={3} />
      <path d="M0 0 L600 0 L355 190 L245 190 Z" fill="#c7b78f" stroke={INK_D} strokeWidth={3} />
      <path d="M0 600 L600 600 L355 330 L245 330 Z" fill="#54391f" stroke={INK_D} strokeWidth={3} />
      {[0.15, 0.35, 0.6, 0.85].map((k) => <path key={k} d={`M${lerp(245, 0, k)} ${lerp(330, 600, k)} L${lerp(355, 600, k)} ${lerp(330, 600, k)}`} stroke={INK_D} strokeWidth={2} opacity={0.5} />)}
      {[0.3, 0.6].map((k) => <g key={k}><path d={`M${lerp(245, 0, k)} ${lerp(190, 0, k) + 30 * (1 + k * 2)} L${lerp(245, 0, k)} ${lerp(330, 600, k) - 8}`} stroke={INK_D} strokeWidth={3 + k * 4} opacity={0.6} /><path d={`M${lerp(355, 600, k)} ${lerp(190, 0, k) + 30 * (1 + k * 2)} L${lerp(355, 600, k)} ${lerp(330, 600, k) - 8}`} stroke={INK_D} strokeWidth={3 + k * 4} opacity={0.6} /></g>)}
      <rect x={245} y={190} width={110} height={140} fill="#3b4432" stroke={INK_D} strokeWidth={3} />
      <rect x={268} y={214} width={64} height={116} fill="#7d4a22" stroke={INK_D} strokeWidth={3} /><circle cx={320} cy={276} r={4} fill={GOLD} />
      <circle cx={300} cy={120} r={20} fill={GOLD} opacity={0.85 + 0.1 * Math.sin(t * 9)} stroke={INK_D} strokeWidth={3} />
      <rect x={298} y={60} width={4} height={42} fill={INK_D} />
      {CROWD.map(([x, y, s], i) => {
        const at = E('twelve') + i * 0.075;
        if (t < at) return null;
        const k = back(ramp(t, at, at + 0.16));
        const squeeze = ramp(t, E('hallway') - 0.1, E('hallway') + 0.1);
        const jx = Math.sin(t * 4.2 + i * 2.3) * (2 + 5 * squeeze) * s;
        const jy = Math.abs(Math.sin(t * 5.5 + i * 1.3)) * 5 * s * (0.3 + squeeze);
        const hat = ['#2a180b', '#3a2413', '#1f2a33', '#4a2c17'][i % 4];
        const coat = ['#27384a', '#5a2a22', '#35452d', '#3f3320'][(i * 3 + 1) % 4];
        return (
          <g key={i} transform={`translate(${x + jx} ${y - jy}) scale(${s * k})`}>
            <path d="M-54 150 C-54 40 -34 8 0 8 C34 8 54 40 54 150 Z" fill={coat} stroke={INK_D} strokeWidth={4} />
            <circle cy={-14} r={28} fill={['#3a2413', '#6b5a48', '#1d1712', '#8a5a2b'][(i * 5 + 2) % 4]} stroke={INK_D} strokeWidth={4} /><circle cx={-27} cy={-10} r={6} fill="#caa377" stroke={INK_D} strokeWidth={2} /><circle cx={27} cy={-10} r={6} fill="#caa377" stroke={INK_D} strokeWidth={2} />
            <path d="M-62 -18 C-34 -30 -26 -56 0 -56 C26 -56 34 -30 62 -18 C34 -28 -34 -28 -62 -18 Z" fill={hat} stroke={INK_D} strokeWidth={4} strokeLinejoin="round" />
          </g>
        );
      })}
    </Art>
  );
};

const ListingPaper: React.FC = () => {
  const t = useT();
  const land = back(ramp(t, E('listing') - 0.05, E('listing') + 0.14));
  const stamp = easeIn(ramp(t, E('cozy') - 0.1, E('cozy')));
  return (
    <>
      <Art>
        <Backdrop a="#c9b58a" b="#a9c8bd" rows={CALM} />
      </Art>
      <div style={{ position: 'absolute', left: 88, top: 62, width: 424, height: 470, background: '#f3e8c8', border: `5px solid ${INK_D}`, transform: `rotate(-3deg) scale(${lerp(1.35, 1, land)})`, boxShadow: '0 16px 26px rgba(0,0,0,.45)' }}>
        <div style={{ textAlign: 'center', font: `900 38px/1 ${DISPLAY}`, color: INK_D, marginTop: 20, letterSpacing: '.04em' }}>{TXT.listing}</div>
        <svg viewBox="0 0 424 250" width={414} height={244} style={{ position: 'absolute', left: 0, top: 66 }}>
          {/* what the listing promised: a mansion */}
          <rect x="60" y="100" width="300" height="120" fill="#e3cf9d" stroke={INK_D} strokeWidth={4} />
          <path d="M40 104 L212 22 L384 104 Z" fill="#b5482c" stroke={INK_D} strokeWidth={4} strokeLinejoin="round" />
          {[86, 146, 246, 306].map((x) => <rect key={x} x={x} y={126} width={32} height={46} fill={GOLD} stroke={INK_D} strokeWidth={3} />)}
          <rect x={192} y={150} width={40} height={70} fill="#5a3a1c" stroke={INK_D} strokeWidth={3} />
          {[70, 350].map((x) => <rect key={x} x={x - 7} y={100} width={14} height={120} fill="#f3e8c8" stroke={INK_D} strokeWidth={3} />)}
          <circle cx={392} cy={42} r={22} fill={GOLD} stroke={INK_D} strokeWidth={3} />
        </svg>
        {t >= E('cozy') - 0.1 ? <div style={{ position: 'absolute', left: 0, width: 414, top: 318, textAlign: 'center', font: `900 112px/1 ${DISPLAY}`, color: XRED, transform: `rotate(-6deg) scale(${lerp(2.1, 1, stamp)})` }}>{TXT.listingWord}</div> : null}
      </div>
    </>
  );
};

const Closet: React.FC = () => {
  const t = useT();
  const open = easeInOut(ramp(t, E('closet') - 0.02, E('closet') + 0.3));
  const puff = ramp(t, E('stove') - 0.05, E('stove') + 0.7);
  return (
    <>
      <Art>
        <rect x="0" y="0" width="600" height="600" fill="#8a6a4a" />
        {Array.from({ length: 13 }, (_, i) => <rect key={i} x={i * 50} y={0} width={24} height={600} fill="#7d5e40" opacity={0.6} />)}
        {/* the room behind the door, all of it */}
        <rect x={168} y={156} width={264} height={444} fill="#2c2015" />
        {[214, 262, 310, 358, 406].map((x) => <rect key={x} x={x} y={156} width={2.5} height={444} fill="#150e07" />)}
        <rect x={168} y={156} width={264} height={12} fill="#150e07" />
        {/* the coat, on the only hook */}
        <circle cx={206} cy={200} r={6} fill={BRASS} />
        <path d="M206 204 L184 236 L180 340 L232 340 L228 236 Z" fill="#27384a" stroke={INK_D} strokeWidth={3} />
        {/* the stove, which is also the kitchen */}
        <rect x={316} y={156} width={22} height={278} fill="#14100c" stroke={INK_D} strokeWidth={3} />
        <rect x={258} y={430} width={136} height={132} rx={7} fill="#3a3530" stroke={INK_D} strokeWidth={4} />
        <rect x={250} y={420} width={152} height={16} rx={4} fill="#26211d" stroke={INK_D} strokeWidth={3} />
        <rect x={290} y={460} width={72} height={58} rx={5} fill="#120d09" stroke="#5a544d" strokeWidth={3} />
        <path d={`M304 516 C298 ${490 + Math.sin(t * 14) * 6} 322 ${476 + Math.sin(t * 11) * 6} 326 ${462 + Math.sin(t * 17) * 5} C334 ${484 + Math.sin(t * 13) * 5} 352 ${492 + Math.sin(t * 19) * 5} 346 516 Z`} fill="#f09a2a" />
        {[272, 380].map((x) => <rect key={x} x={x - 6} y={558} width={12} height={22} fill="#26211d" />)}
        {puff > 0 ? [0, 1, 2, 3].map((i) => { const u = clamp(puff * 1.3 - i * 0.12); return <circle key={i} cx={270 + i * 34 + u * 10} cy={410 - u * 120} r={16 + u * 34} fill="#c7c0b3" opacity={0.6 * (1 - u)} />; }) : null}
        <rect x={168 - 22} y={140} width={22} height={460} fill="#d7c9a6" stroke={INK_D} strokeWidth={4} />
        <rect x={432} y={140} width={22} height={460} fill="#d7c9a6" stroke={INK_D} strokeWidth={4} />
        <rect x={146} y={126} width={308} height={32} fill="#d7c9a6" stroke={INK_D} strokeWidth={4} />
        {/* the door swings open on its left hinge */}
        <g transform={`translate(168 0) scale(${lerp(1, 0.1, open)} 1) translate(-168 0)`}>
          <rect x={168} y={158} width={264} height={442} fill="#b9c2a4" stroke={INK_D} strokeWidth={4} />
          <rect x={198} y={190} width={204} height={150} fill="none" stroke={INK_D} strokeWidth={3} /><rect x={198} y={368} width={204} height={200} fill="none" stroke={INK_D} strokeWidth={3} />
          <circle cx={404} cy={360} r={11} fill={GOLD} stroke={INK_D} strokeWidth={3} />
        </g>
      </Art>
      <div style={{ position: 'absolute', left: 102, top: 66, width: 396, textAlign: 'center', padding: '8px 0 6px', background: BRASS, border: `4px solid ${INK_D}`, borderRadius: 5, font: `900 37px/1.1 ${DISPLAY}`, color: INK_D, boxShadow: '0 6px 8px rgba(0,0,0,.4)' }}>{TXT.plaque}</div>
    </>
  );
};

const SCALES = Array.from({ length: 110 }, (_, i) => ({ x: (i % 10) * 66 + (Math.floor(i / 10) % 2 ? 33 : 0) - 10, y: Math.floor(i / 10) * 62 - 10 }));
const Eye: React.FC = () => {
  const t = useT();
  const open = easeOut(ramp(t, E('beast') - 0.04, E('beast') + 0.32));
  const blink = t > E('woke') + 0.1 && t < E('woke') + 0.24 ? 0.1 : 1;
  const board = back(ramp(t, E('three') - 0.28, E('three') + 0.05));
  const down = ramp(t, E('three') - 0.15, E('three') + 0.1) * (1 - ramp(t, E('refs') + 0.3, E('refs') + 0.5));
  const lookX = Math.sin(t * 1.3) * 16;
  const ey = lerp(250, 158, board > 0 ? ramp(board, 0, 1) : 0);
  const ry = 98 * open * blink;
  return (
    <>
      <Art>
        <rect x="0" y="0" width="600" height="600" fill="#2f6657" />
        {SCALES.map((s, i) => <path key={i} d={`M${s.x} ${s.y} a33 33 0 0 0 66 0`} fill={i % 3 ? '#3a7564' : '#336c5c'} stroke="#1c4539" strokeWidth={3.4} />)}
        <g transform={`translate(0 ${ey - 250})`}>
          <ellipse cx={300} cy={250} rx={168} ry={Math.max(10, ry + 16)} fill="#163a30" />
          {ry > 3 ? <ellipse cx={300} cy={250} rx={148} ry={ry} fill={GOLD} stroke={INK_D} strokeWidth={7} /> : <path d="M152 250 Q300 320 448 250" fill="none" stroke={INK_D} strokeWidth={12} strokeLinecap="round" />}
          {ry > 12 ? <g><rect x={290 + lookX - 16} y={250 + down * 34 - Math.max(0, ry - 8)} width={32} height={Math.max(0, 2 * (ry - 8))} rx={16} fill={INK_D} /><circle cx={302 + lookX} cy={224 + down * 30} r={8} fill="#fff6d0" /></g> : null}
          {/* the monocle: this monster reads the small print */}
          <circle cx={300} cy={250} r={172} fill="none" stroke={BRASS} strokeWidth={16} />
          <circle cx={300} cy={250} r={172} fill="none" stroke={INK_D} strokeWidth={3} />
          <path d="M468 298 C520 340 500 460 560 560" fill="none" stroke={BRASS} strokeWidth={6} strokeDasharray="10 7" />
        </g>
      </Art>
      {board > 0 ? (
        <div style={{ position: 'absolute', left: 118, top: lerp(620, 296, board), width: 364, height: 256, background: '#f3e8c8', border: `5px solid ${INK_D}`, borderRadius: 6, transform: 'rotate(-2deg)', boxShadow: '0 12px 20px rgba(0,0,0,.5)' }}>
          <div style={{ position: 'absolute', left: 130, top: -18, width: 104, height: 30, background: BRASS, border: `4px solid ${INK_D}`, borderRadius: 5 }} />
          <div style={{ textAlign: 'center', font: `900 37px/1 ${DISPLAY}`, color: INK_D, marginTop: 26 }}>{TXT.clipboard}</div>
          {[0, 1, 2].map((i) => {
            const on = t >= E('three') + 0.1 + i * 0.18;
            return <div key={i} style={{ position: 'absolute', left: 30, top: 76 + i * 56, width: 304, height: 50, borderBottom: `3px solid ${INK}`, font: `46px/50px ${FELL}`, color: XRED, opacity: on ? 1 : 0.15 }}>{i + 1}.</div>;
          })}
        </div>
      ) : null}
    </>
  );
};

const DROPS = Array.from({ length: 24 }, (_, i) => ({ x: hash(i + 1100) * 600, ph: hash(i + 1200), sp: 0.5 + hash(i + 1300) * 0.7, r: 5 + hash(i + 1400) * 9 }));
const LANCE = Array.from({ length: 44 }, (_, i) => ({ x: hash(i + 1500) * 760, ph: hash(i + 1600) * 700, sp: 800 + hash(i + 1700) * 500 }));
const StormSea: React.FC = () => {
  const t = useT();
  const pitch = Math.sin(t * 1.9) * 18;
  const fl = flash(t);
  const f0 = E('faster') - 0.15;
  const cutter = ramp(t, f0, E('signed') + 0.55);
  const cx = lerp(-240, 860, cutter);
  const pen = cutter > 0 && cutter < 1;
  const swamp = easeIn(ramp(t, E('signed') + 0.15, E('signed') + 0.6));
  return (
    <>
      <Art>
        <Backdrop a="#2c3b52" b="#6d8194" rows={[]} />
        <path d="M-20 90 C60 40 170 60 210 100 C270 40 420 50 470 110 C540 90 620 120 640 160 L640 0 L-20 0 Z" fill="#1a2436" opacity={0.9} />
        {fl > 0.02 ? <rect x="0" y="0" width="600" height="600" fill="#cfd8ea" opacity={Math.min(0.55, fl * 0.6)} /> : null}
        {fl > 0.15 ? <path d="M420 0 L382 120 L420 122 L352 300" fill="none" stroke="#f4f6ff" strokeWidth={7} strokeLinejoin="round" opacity={Math.min(1, fl)} /> : null}
        <path d={waveD(300 + Math.sin(t * 1.3) * 22, 50, 260, t * 46)} fill="#2f6a70" />
        <Ship x={260} y={318 + Math.sin(t * 1.9 + 0.4) * 22} s={0.82} rot={pitch} hull="#6a4020" sail="#d8d2bd" line="#120c08" />
        <path d={waveD(350 + Math.sin(t * 1.6 + 1) * 24, 62, 300, -t * 70)} fill="#245860" />
        {pen ? (
          <g transform={`translate(${cx} ${300 + Math.sin(t * 2.2) * 12}) rotate(-5)`}>
            <path d="M-120 -8 L120 -8 L92 26 L-92 26 Z" fill="#0b0d12" stroke="#000" strokeWidth={3} />
            <path d="M-6 -166 C70 -120 90 -56 96 -14 L-6 -14 Z" fill="#e9e4d3" stroke="#000" strokeWidth={4} />
            <path d="M-14 -176 L-14 -8" stroke="#000" strokeWidth={8} />
          </g>
        ) : null}
        <path d={waveD(420 + Math.sin(t * 1.1 + 2) * 26, 70, 340, t * 90)} fill="#1a4650" />
        <path d={waveD(500 + Math.sin(t * 1.4 + 3) * 22, 54, 280, -t * 110)} fill="#123640" />
        {LANCE.map((l, i) => { const y = ((l.ph + t * l.sp) % 760) - 80; return <line key={i} x1={l.x} y1={y} x2={l.x - 18} y2={y + 56} stroke="#b9cdea" strokeWidth={2.6} opacity={0.36} />; })}
        {/* the sea comes over the glass */}
        {swamp > 0 ? <path d={waveD(lerp(640, -60, swamp), 34, 240, t * 130)} fill="#1a4650" /> : null}
      </Art>
      {pen ? <div style={{ position: 'absolute', left: cx - 214, top: 166 + Math.sin(t * 2.2) * 12, padding: '8px 18px 4px', background: XRED, color: '#fff3d8', font: `900 50px/1 ${DISPLAY}`, whiteSpace: 'nowrap', transform: 'rotate(-5deg)', clipPath: 'polygon(0 0,100% 0,92% 50%,100% 100%,0 100%)', paddingRight: 44 }}>{TXT.pennant}</div> : null}
    </>
  );
};

const Lamp: React.FC = () => {
  const t = useT();
  const glow = 0.82 + 0.1 * Math.sin(t * 11) * Math.sin(t * 3.7);
  const rain = 1 - ramp(t, E('turn'), E('lamppost'));
  const mothA = t * 4.2;
  return (
    <Art>
      <defs>
        <radialGradient id="lampG" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="#ffe9a6" stopOpacity="0.95" /><stop offset="0.45" stopColor="#ffc35a" stopOpacity="0.4" /><stop offset="1" stopColor="#ffb040" stopOpacity="0" /></radialGradient>
      </defs>
      <Backdrop a="#1c2747" b="#6f5468" rows={[]} />
      <path d={waveD(392, 5, 110, t * 10)} fill="#1b3a4c" />
      <path d={waveD(420, 6, 140, -t * 14)} fill="#152f3e" />
      <rect x="0" y="470" width="600" height="130" fill="#3d3a3a" />
      {Array.from({ length: 9 }, (_, i) => <path key={i} d={`M${i * 76 - 20} 470 l-14 130`} stroke="#22201f" strokeWidth={3} />)}
      <path d="M0 470 L600 470" stroke="#22201f" strokeWidth={5} /><path d="M0 524 L600 524" stroke="#22201f" strokeWidth={3} />
      <circle cx={300} cy={118} r={230 * glow} fill="url(#lampG)" />
      <ellipse cx={300} cy={486} rx={190} ry={22} fill="#ffcf7a" opacity={0.3 * glow} />
      <rect x={290} y={160} width={20} height={320} fill="#1c1a1a" />
      <rect x={278} y={470} width={44} height={18} rx={3} fill="#1c1a1a" />
      <path d="M272 158 L328 158 L318 70 L282 70 Z" fill={`rgba(255,226,140,${glow})`} stroke="#1c1a1a" strokeWidth={7} strokeLinejoin="round" />
      <path d="M266 74 L334 74 L300 40 Z" fill="#1c1a1a" stroke="#1c1a1a" strokeWidth={4} strokeLinejoin="round" />
      <circle cx={300 + Math.cos(mothA) * 46} cy={112 + Math.sin(mothA * 1.3) * 32} r={4} fill="#1c1a1a" />
      {/* the sign, tied to the post */}
      <g transform={`rotate(${Math.sin(t * 1.6) * 1.2} 300 240)`}>
        <path d="M212 228 C260 216 340 216 388 228" fill="none" stroke="#c9b48a" strokeWidth={4} />
        <rect x={190} y={222} width={220} height={160} fill="#f6efd8" stroke="#3a2a18" strokeWidth={4} />
      </g>
      {rain > 0 ? LANCE.slice(0, 22).map((l, i) => { const y = ((l.ph + t * l.sp * 0.7) % 760) - 80; return <line key={i} x1={l.x} y1={y} x2={l.x - 10} y2={y + 40} stroke="#d4dfee" strokeWidth={2} opacity={0.3 * rain} />; }) : null}
    </Art>
  );
};

const LampWords: React.FC = () => {
  const t = useT();
  return (
    <div style={{ position: 'absolute', left: 190, top: 226, width: 220, textAlign: 'center', transform: `rotate(${Math.sin(t * 1.6) * 1.2}deg)` }}>
      <div style={{ font: `900 56px/1 ${DISPLAY}`, color: INK_D, marginTop: 14 }}>{TXT.sign}</div>
      <div style={{ font: `36px/1 ${FELL}`, color: INK_D, marginTop: 8 }}>{TXT.phone}</div>
    </div>
  );
};

const TABS = Array.from({ length: 7 }, (_, i) => i);
const SignClose: React.FC = () => {
  const t = useT();
  const z = easeOut(ramp(t, E('nolisting'), E('nolisting') + 0.35));
  const under = ramp(t, E('number'), E('number') + 0.3);
  const tear = t - E('phone');
  return (
    <>
      <Art>
        <rect x="0" y="0" width="600" height="600" fill="#2b2a33" />
        <circle cx={300} cy={40} r={380} fill="url(#signG)" />
        <defs><radialGradient id="signG" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="#ffc87a" stopOpacity="0.7" /><stop offset="1" stopColor="#ffb040" stopOpacity="0" /></radialGradient></defs>
        <rect x="0" y="0" width="600" height="600" fill="#f0d49a" opacity={0.85 * ramp(t, E('nolisting'), E('phone'))} />
        <rect x={262} y={0} width={76} height={600} fill="#191820" />
        <rect x={272} y={0} width={10} height={600} fill="#3a3844" />
      </Art>
      <div style={{ position: 'absolute', left: 60, top: 96, width: 480, height: 400, background: '#f6efd8', border: `5px solid #3a2a18`, transform: `rotate(${-1.6 + Math.sin(t * 1.6) * 0.7}deg) scale(${lerp(0.82, 1, z)})`, boxShadow: '0 14px 24px rgba(0,0,0,.55)', transformOrigin: '50% 40%' }}>
        <div style={{ textAlign: 'center', font: `900 112px/1 ${DISPLAY}`, color: INK_D, marginTop: 18 }}>{TXT.sign}</div>
        <div style={{ textAlign: 'center', font: `italic 40px/1 ${FELL}`, color: INK, marginTop: 2 }}>{TXT.call}</div>
        <div style={{ position: 'relative', textAlign: 'center', font: `66px/1.05 ${FELL}`, color: under > 0 ? XRED : INK_D, marginTop: 2 }}>
          {TXT.phone}
          {under > 0 ? <div style={{ position: 'absolute', left: 80, top: 66, height: 6, width: 310 * under, background: XRED, borderRadius: 3 }} /> : null}
        </div>
        {/* the tear-off tabs along the bottom edge */}
        <div style={{ position: 'absolute', left: 8, right: 8, bottom: 0, height: 98, display: 'flex', gap: 6 }}>
          {TABS.map((i) => {
            const off = i === 3 && tear > 0;
            const fall = off ? easeIn(ramp(tear, 0, 0.75)) : 0;
            return <div key={i} style={{ flex: 1, background: '#f6efd8', borderLeft: '2px solid #8a7a5a', borderRight: '2px solid #8a7a5a', borderTop: i === 3 && off ? '2px solid #8a7a5a' : undefined, transform: off ? `translate(${fall * 40}px,${fall * 300}px) rotate(${fall * 70}deg)` : `rotate(${Math.sin(t * 2.2 + i) * 1.2}deg)`, transformOrigin: '50% 0', opacity: 1 - fall, position: 'relative' }}><div style={{ position: 'absolute', left: 0, right: 0, top: 44, textAlign: 'center', transform: 'rotate(-90deg)', font: `19px/1 ${FELL}`, color: INK_D, whiteSpace: 'nowrap' }}>{TXT.phone}</div></div>;
          })}
        </div>
      </div>
    </>
  );
};

const Lock: React.FC = () => {
  const t = useT();
  const insert = easeOut(ramp(t, E('inside'), E('inside') + 0.34));
  const turn = easeInOut(ramp(t, E('key') - 0.02, E('key') + 0.26));
  const seam = ramp(t, E('key') + 0.2, E('key') + 0.5);
  return (
    <Art>
      <rect x="0" y="0" width="600" height="600" fill="#3e5a4e" />
      {[0, 150, 300, 450].map((x) => <rect key={x} x={x} y={0} width={4} height={600} fill="#2b4238" />)}
      <rect x={70} y={40} width={200} height={230} fill="none" stroke="#2b4238" strokeWidth={6} /><rect x={70} y={320} width={200} height={240} fill="none" stroke="#2b4238" strokeWidth={6} />
      <rect x={560} y={0} width={40} height={600} fill={GOLD} opacity={seam} />
      <rect x={574 - 90 * seam} y={0} width={Math.max(0, 90 * seam)} height={600} fill={GOLD} opacity={0.45 * seam} />
      <rect x={210} y={150} width={180} height={320} rx={18} fill={BRASS} stroke={INK_D} strokeWidth={6} />
      {[[232, 172], [368, 172], [232, 448], [368, 448]].map(([x, y]) => <circle key={`${x}${y}`} cx={x} cy={y} r={8} fill="#6e4f17" stroke={INK_D} strokeWidth={2.4} />)}
      <circle cx={300} cy={300} r={36} fill="#1a1007" /><path d="M286 312 L314 312 L322 380 L278 380 Z" fill="#1a1007" />
      {/* the key comes straight at you, then turns */}
      <g transform={`translate(300 ${lerp(116, 318, insert)}) rotate(${turn * 90})`}><g transform="rotate(180) translate(0 -98) scale(1.45)"><KeyArt /></g></g>
    </Art>
  );
};

const SOFA_ROOM: React.FC<{ x: number; y: number }> = ({ x, y }) => (
  <g transform={`translate(${x} ${y})`}>
    <rect x={-104} y={-96} width={208} height={80} rx={18} fill="#b5482c" stroke={INK_D} strokeWidth={5} />
    <rect x={-116} y={-44} width={232} height={54} rx={14} fill="#cf6541" stroke={INK_D} strokeWidth={5} />
    <circle cx={-116} cy={-44} r={26} fill="#b5482c" stroke={INK_D} strokeWidth={5} /><circle cx={116} cy={-44} r={26} fill="#b5482c" stroke={INK_D} strokeWidth={5} />
    {[-92, 92].map((p) => <rect key={p} x={p - 7} y={10} width={14} height={18} fill={INK_D} />)}
    <path d="M-40 -96 L-52 -2 M44 -96 L58 -2" stroke="#e8d6a6" strokeWidth={5} opacity={0.85} />
    {/* the captain's hat, on the good sofa */}
    <g transform="translate(58 -44) scale(1.2)">
      <path d="M-62 -2 C-34 -14 -26 -44 0 -44 C26 -44 34 -14 62 -2 C34 -12 -34 -12 -62 -2 Z" fill={INK_D} stroke={INK_D} strokeWidth={4} strokeLinejoin="round" />
      <path d="M-56 -3 C-34 -10 34 -10 56 -3" fill="none" stroke={GOLD} strokeWidth={3} />
    </g>
  </g>
);
const DUST = Array.from({ length: 22 }, (_, i) => ({ x: 40 + hash(i + 2100) * 520, sp: 160 + hash(i + 2200) * 240, r: 2 + hash(i + 2300) * 4.5 }));
const Room: React.FC = () => {
  const t = useT();
  const sing = ramp(t, E('sings') - 0.1, E('sings') + 0.2);
  const dusk = ramp(t, E('radiator'), E('night') + 0.2);
  const s = stomp(t);
  const swing = Math.sin(t * 2.2) * (3 + (t > E('neighbor') ? 9 : 0));
  const buzz = sing * Math.sin(t * 48) * 1.4;
  const sinceStomp = t >= E('neighbor') + 0.4 ? (t - E('neighbor') - 0.4) % 1.05 : 9;
  return (
    <Art>
      <g transform={`translate(0 ${s * 6})`}>
        <rect x="-20" y="-20" width="640" height="640" fill="#5a3324" />
        {Array.from({ length: 14 }, (_, i) => <rect key={i} x={i * 46} y={0} width={22} height={600} fill="#65392a" opacity={0.7} />)}
        {/* the window, and the night through it */}
        <rect x={60} y={70} width={180} height={190} fill={dusk > 0.5 ? '#16213f' : '#6d7fb0'} stroke={INK_D} strokeWidth={8} />
        <rect x={60} y={70} width={180} height={190} fill="#16213f" opacity={dusk} />
        <circle cx={186} cy={122} r={26} fill="#f4e9c0" />
        {[[92, 110], [130, 96], [108, 158], [200, 200], [152, 176]].map(([x, y], i) => <circle key={i} cx={x} cy={y} r={2.8} fill="#f4e9c0" opacity={0.5 + 0.5 * Math.sin(t * 2.4 + i * 1.3)} />)}
        <path d="M150 70 L150 260 M60 165 L240 165" stroke={INK_D} strokeWidth={6} />
        <rect x="-20" y="470" width="640" height="150" fill="#3a2112" />
        {[-10, 110, 240, 380, 510].map((x) => <path key={x} d={`M${x} 470 l-30 150`} stroke="#22130a" strokeWidth={3} />)}
        <SOFA_ROOM x={186} y={492} />
        {/* the radiator, singing */}
        <g transform={`translate(${436 + buzz} 356) scale(1.18)`}>
          {Array.from({ length: 7 }, (_, i) => <rect key={i} x={-72 + i * 22} y={-6 - (i % 2) * 2} width={19} height={132} rx={8} fill="#a9a095" stroke={INK_D} strokeWidth={4} />)}
          <rect x={-78} y={102} width={160} height={12} rx={4} fill="#857c70" stroke={INK_D} strokeWidth={3} />
          <rect x={-90} y={94} width={14} height={40} fill="#6d655a" stroke={INK_D} strokeWidth={3} />
        </g>
        {sing > 0 ? [0, 1, 2, 3, 4].map((i) => { const u = (t * 0.6 + i * 0.2) % 1; return <Note key={i} x={380 + i * 22 + Math.sin(u * 9 + i) * 16 + u * 30} y={350 - u * 260} s={1.6 + u * 1.3} o={sing * Math.sin(u * Math.PI)} c="#ffe39a" />; }) : null}
        {/* the lamp on its wire */}
        <g transform={`translate(330 -6) rotate(${swing})`}>
          <rect x={-2} y={0} width={4} height={104} fill={INK_D} />
          <circle cx={0} cy={118} r={70} fill="#ffdc8a" opacity={0.18} />
          <path d="M-30 104 L30 104 L18 84 L-18 84 Z" fill={INK_D} /><circle cx={0} cy={112} r={13} fill={GOLD} />
        </g>
      </g>
      {/* what the neighbor does to the ceiling */}
      {sinceStomp < 0.9 ? DUST.map((d, i) => { const y = -10 + d.sp * sinceStomp + 220 * sinceStomp * sinceStomp; return <circle key={i} cx={d.x} cy={y} r={d.r} fill="#e8dcc2" opacity={0.85 * (1 - sinceStomp / 0.9)} />; }) : null}
      {t >= E('neighbor') ? <path d={`M210 ${0 + s * 6} l14 22 l-12 18 l18 26 M400 ${0 + s * 6} l-10 16 l14 18 l-8 22`} fill="none" stroke={INK_D} strokeWidth={4.5} strokeLinejoin="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - ramp(t, E('neighbor') + 0.4, E('neighbor') + 0.5)} /> : null}
    </Art>
  );
};

/** What the glass shows, in story order. Each change is a quick swing of the spyglass. */
const VIEWS: [() => number, React.FC][] = [
  [() => -1, SeaSearch],
  [() => E('cove') - 0.05, HomeShore],
  [() => E('viewing') - 0.05, Hallway],
  [() => E('listing') - 0.1, ListingPaper],
  [() => E('closet') - 0.1, Closet],
  [() => E('beast') - 0.3, Eye],
  [() => E('low'), StormSea],
  [() => E('turn') - 0.05, () => <><Lamp /><LampWords /></>],
  [() => E('nolisting') - 0.05, SignClose],
  [() => E('inside') - 0.15, Lock],
  [() => E('radiator') - 0.12, Room],
];
const WHIP = 0.2;

const glassBlur = (t: number) => {
  const first = 14 * (1 - ramp(t, E('glass') + 0.05, E('glass') + 0.8));
  const turn = t >= E('turn') - 0.05 && t < E('sign') + 0.2 ? 20 * (1 - easeInOut(ramp(t, E('swing'), E('sign') + 0.02))) : 0;
  return Math.max(t < E('glass') + 0.9 ? first : 0, turn);
};

const Glass: React.FC = () => {
  const t = useT();
  let idx = 0;
  VIEWS.forEach(([at], i) => { if (t >= at()) idx = i; });
  const local = t - VIEWS[idx][0]();
  const k = easeOut(ramp(local, 0, WHIP));
  const Now = VIEWS[idx][1], Was = idx > 0 ? VIEWS[idx - 1][1] : null;
  const moving = k < 1;
  const blur = glassBlur(t);
  const drops = stormK(t) * (1 - ramp(t, E('swing'), E('sign')));
  return (
    <div style={{ position: 'absolute', left: 0, top: 0, width: 600, height: 600, background: '#101a20', overflow: 'hidden' }}>
      <div style={{ position: 'absolute', inset: 0, filter: blur > 0.1 ? `blur(${blur.toFixed(2)}px)` : undefined }}>
        {moving && Was ? <div style={{ position: 'absolute', left: 0, top: 0, width: 600, height: 600, transform: `translateX(${-k * 620}px)`, filter: 'blur(6px)' }}><Was /></div> : null}
        <div style={{ position: 'absolute', left: 0, top: 0, width: 600, height: 600, transform: moving && Was ? `translateX(${(1 - k) * 620}px)` : undefined, filter: moving && Was ? 'blur(6px)' : undefined }}><Now /></div>
      </div>
      {drops > 0.02 ? (
        <svg viewBox="0 0 600 600" width={600} height={600} style={{ position: 'absolute', left: 0, top: 0 }}>
          {DROPS.map((d, i) => { const y = ((d.ph + t * d.sp) % 1.2) * 600 - 40; return <g key={i} opacity={drops}><rect x={d.x - d.r * 0.3} y={y - 70} width={d.r * 0.6} height={70} fill="#dfe9f0" opacity={0.22} /><circle cx={d.x} cy={y} r={d.r} fill="#dfe9f0" opacity={0.5} /></g>; })}
        </svg>
      ) : null}
      {/* the lens itself: dark at the rim, a glare across the top */}
      <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(circle at 50% 50%, transparent 58%, rgba(8,8,4,.55) 96%)' }} />
      <svg viewBox="0 0 600 600" width={600} height={600} style={{ position: 'absolute', left: 0, top: 0 }}>
        <path d="M84 170 C126 86 210 40 306 34" fill="none" stroke="#fff" strokeOpacity="0.32" strokeWidth={14} strokeLinecap="round" />
        <path d="M60 234 C64 216 70 204 78 190" fill="none" stroke="#fff" strokeOpacity="0.22" strokeWidth={10} strokeLinecap="round" />
      </svg>
    </div>
  );
};

/* Where the spyglass sits. It swings in, swings forward for the sign, swings away for the dig, and comes back. */
type LensPos = { x: number; y: number; r: number };
const HOME: LensPos = { x: 770, y: 372, r: 248 }, OFF: LensPos = { x: 1360, y: -230, r: 248 }, FOCUS: LensPos = { x: 690, y: 500, r: 306 };
const LENS: { at: number; d: number; to: LensPos; over?: boolean }[] = [
  { at: E('glass'), d: 0.5, to: HOME, over: true },
  { at: E('swing'), d: 0.62, to: FOCUS },
  { at: E('phone') + 0.3, d: 0.42, to: HOME },
  { at: E('dig') - 0.02, d: 0.34, to: OFF },
  { at: E('inside') - 0.1, d: 0.42, to: HOME, over: true },
];
const lensAt = (t: number): LensPos => {
  let v = OFF;
  for (const k of LENS) {
    if (t < k.at) break;
    const u = ramp(t, k.at, k.at + k.d);
    const q = k.over ? back(u) : easeInOut(u);
    v = { x: lerp(v.x, k.to.x, q), y: lerp(v.y, k.to.y, q), r: lerp(v.r, k.to.r, q) };
  }
  const st = stormK(t);
  return { x: v.x + Math.sin(t * 1.7) * st * 12, y: v.y + Math.sin(t * 2.3) * st * 9 + Math.sin(t * 0.9) * 3, r: v.r };
};

const Spyglass: React.FC = () => {
  const t = useT();
  const { x, y, r } = lensAt(t);
  if (x > 1320) return null;
  const R = r + 34;
  const dim = 1 - 0.5 * stormK(t) * (1 - ramp(t, E('swing'), E('sign')));
  const twist = (t - E('glass')) * 8 + ramp(t, E('swing'), E('sign')) * 180;
  const tube = -38;
  return (
    <div style={{ position: 'absolute', inset: 0, filter: dim < 0.99 ? `brightness(${dim.toFixed(3)})` : undefined }}>
      {/* the tube runs up and off the chart */}
      <div style={{ position: 'absolute', left: x, top: y - R * 0.78, width: 1100, height: R * 1.56, transformOrigin: `0 ${R * 0.78}px`, transform: `rotate(${tube}deg)`, clipPath: 'polygon(0 0,100% 18%,100% 82%,0 100%)', background: 'linear-gradient(#6e4f17,#e8c870 22%,#b88a2e 50%,#6e4f17 86%,#3e2a0a)', boxShadow: '0 30px 40px rgba(30,12,0,.5)' }}>
        <div style={{ position: 'absolute', left: R + 26, top: 0, bottom: 0, width: 150, background: 'linear-gradient(#2e1b0d,#6b4526 26%,#4a2c17 55%,#1a0e05)' }} />
        <div style={{ position: 'absolute', left: R + 170, top: 0, bottom: 0, width: 30, background: 'linear-gradient(#4a3510,#f0d48a 26%,#b88a2e 55%,#2e1f06)' }} />
      </div>
      <div style={{ position: 'absolute', left: x - R, top: y - R, width: R * 2, height: R * 2, borderRadius: '50%', boxShadow: '0 22px 34px rgba(20,8,0,.6)', background: 'conic-gradient(#f4dc98,#8a6420 12%,#e8c870 26%,#6e4f17 40%,#f0d48a 54%,#8a6420 68%,#e8c870 82%,#6e4f17 92%,#f4dc98)' }} />
      <svg viewBox={`${-R} ${-R} ${R * 2} ${R * 2}`} width={R * 2} height={R * 2} style={{ position: 'absolute', left: x - R, top: y - R }}>
        <circle r={R - 2} fill="none" stroke={INK_D} strokeWidth={3} />
        <circle r={r + 6} fill="none" stroke={INK_D} strokeWidth={6} />
        <g transform={`rotate(${twist})`}>
          {Array.from({ length: 72 }, (_, i) => <rect key={i} x={-1.2} y={-(R - 8)} width={2.4} height={i % 6 ? 8 : 15} fill={INK_D} opacity={0.82} transform={`rotate(${i * 5})`} />)}
        </g>
      </svg>
      <div style={{ position: 'absolute', left: x - r, top: y - r, width: r * 2, height: r * 2, borderRadius: '50%', overflow: 'hidden', background: '#0e1a1e' }}>
        <div style={{ position: 'absolute', left: 0, top: 0, width: 600, height: 600, transformOrigin: '0 0', transform: `scale(${(r * 2) / 600})` }}><Glass /></div>
      </div>
    </div>
  );
};

/* ---------- light on the table ---------- */
const RAIN = Array.from({ length: 80 }, (_, i) => ({ x: hash(i + 15) * 1300 - 100, sp: 1100 + hash(i + 87) * 600, ph: hash(i + 160) * 2300, l: 60 + hash(i + 230) * 120, o: 0.25 + hash(i + 310) * 0.55 }));
const SPLASH = Array.from({ length: 34 }, (_, i) => ({ x: 60 + hash(i + 2500) * 960, y: 450 + hash(i + 2600) * 840, ph: hash(i + 2700), sp: 1.4 + hash(i + 2800) * 1.1 }));
const Light: React.FC = () => {
  const t = useT();
  const cam = camAt(t);
  const st = stormK(t), fl = flash(t), ng = nightK(t);
  const lx = 50 + Math.sin(t * 0.9) * 9 + st * 7 * Math.sin(t * 2.1), ly = 42 + Math.cos(t * 0.6) * 4;
  const dark = 0.6 * st * (1 - Math.min(0.75, fl * 0.8));
  const sun = ramp(t, E('nolisting'), E('nolisting') + 0.5) * (1 - ramp(t, E('number') + 0.3, E('number') + 1.0));
  const homeX = cam.s * AT.x[0] + cam.tx, homeY = cam.s * (AT.x[1] - 30) + cam.ty;
  return (
    <>
      <AbsoluteFill style={{ background: `radial-gradient(ellipse 78% 58% at ${lx}% ${ly}%, rgba(255,232,180,.2), rgba(255,232,180,0) 58%, rgba(40,18,0,.3))` }} />
      {st > 0.01 ? <AbsoluteFill style={{ background: `rgba(14,22,40,${dark.toFixed(3)})` }} /> : null}
      {sun > 0.01 ? <AbsoluteFill style={{ background: `linear-gradient(115deg, rgba(255,226,150,0) ${lerp(-20, 62, sun)}%, rgba(255,226,150,${0.26 * sun}) ${lerp(0, 82, sun)}%, rgba(255,226,150,0) ${lerp(20, 104, sun)}%)` }} /> : null}
      {ng > 0.01 ? <AbsoluteFill style={{ background: `radial-gradient(ellipse 420px 360px at ${homeX}px ${homeY}px, rgba(16,24,58,${(0.16 * ng).toFixed(3)}) 18%, rgba(16,24,58,${(0.5 * ng).toFixed(3)}) 100%)` }} /> : null}
      {st > 0.01 ? (
        <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, opacity: st }}>
          {RAIN.map((r, i) => { const y = ((r.ph + t * r.sp) % 2300) - 180; return <line key={i} x1={r.x - (y / 1920) * 180} y1={y} x2={r.x - (y / 1920) * 180 - 16} y2={y + r.l} stroke="#c5d6ee" strokeWidth={2.4} opacity={r.o * 0.6} strokeLinecap="round" />; })}
          {SPLASH.map((s, i) => { const u = (t * s.sp + s.ph) % 1; return <circle key={i} cx={s.x} cy={s.y} r={3 + u * 22} fill="none" stroke="#d6e2f2" strokeWidth={2.4 * (1 - u)} opacity={0.65 * (1 - u)} />; })}
        </svg>
      ) : null}
    </>
  );
};

/* ---------- captions: the scroll ---------- */
const PER_ROW = 29;
const CHUNKS = buildChunks(PER_ROW, 2);
const SX0 = 60, SX1 = 950, SY0 = 1294, SY1 = 1478;
const Captions: React.FC = () => {
  const t = useT();
  const open = easeOut(ramp(t, 0.05, 0.5));
  const cx = (SX0 + SX1) / 2, half = ((SX1 - SX0) / 2 - 22) * lerp(0.1, 1, open);
  const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.06 ? i : a), -1);
  const c = idx >= 0 ? CHUNKS[idx] : null;
  const quiet = t >= E('dig') + 0.05 && t < E('inside') - 0.04;
  const hot = (w: { w: string; t0: number; t1: number }) => (t >= w.t0 - 0.03 && t < w.t1 + 0.08) || (t >= E('keyword') && w.w.toUpperCase().replace(/[^A-Z]/g, '') === KEYWORD);
  return (
    <>
      {/* the paper, between its rollers */}
      <div style={{ position: 'absolute', left: cx - half, top: SY0 + 8, width: half * 2, height: SY1 - SY0 - 16, background: `linear-gradient(90deg,#d8c490,${CREAM} 6%,#f5eacb 50%,${CREAM} 94%,#d8c490)`, boxShadow: '0 12px 22px rgba(25,10,0,.55), inset 0 0 26px rgba(120,70,20,.3)', overflow: 'hidden' }}>
        {open >= 1 ? (
          <div style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
            {quiet ? (
              <div style={{ font: `900 76px/1 ${DISPLAY}`, color: XRED, letterSpacing: '.03em', opacity: ramp(t, E('dig') + 0.05, E('dig') + 0.2) }}>{TXT.voiceless}</div>
            ) : c ? (
              c.rows.map((row, ri) => {
                const ids = Array.from(new Set(row.map.filter((m) => m >= 0)));
                return (
                  <div key={`${idx}-${ri}`} style={{ display: 'flex', gap: 13, whiteSpace: 'nowrap', font: `60px/70px ${FELL}` }}>
                    {ids.map((wi) => {
                      const w = c.words[wi];
                      const k = ramp(t, w.t0 - 0.05, w.t0 + 0.05);
                      return <span key={wi} style={{ color: hot(w) ? XRED : INK_D, opacity: k, visibility: k > 0 ? 'visible' : 'hidden' }}>{w.w}</span>;
                    })}
                  </div>
                );
              })
            ) : null}
          </div>
        ) : null}
      </div>
      {[cx - half - 22, cx + half - 22].map((x, i) => (
        <div key={i} style={{ position: 'absolute', left: x, top: SY0 - 6, width: 44, height: SY1 - SY0 + 6, borderRadius: 22, background: 'linear-gradient(90deg,#8a6a38,#efe0b8 38%,#d0b67e 60%,#7a5a2e)', boxShadow: '0 10px 14px rgba(25,10,0,.5)', border: `3px solid ${INK_D}`, boxSizing: 'border-box' }} />
      ))}
    </>
  );
};

/* ---------- the film ---------- */

export const TreasureMap: React.FC = () => (
  <Clock>
    <Fonts faces={FACES}>
      <Stage />
    </Fonts>
  </Clock>
);

/** Moves with the chart. Used twice: once for the chart, once above the light for the few things that stay lit at night. */
const CamLayer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const t = useT();
  const cam = camAt(t);
  return (
    <div style={{ position: 'absolute', inset: 0, transform: `rotate(${cam.a.toFixed(3)}deg)`, transformOrigin: '50% 50%' }}>
      <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, transformOrigin: '0 0', transform: `translate(${cam.tx}px, ${cam.ty}px) scale(${cam.s})` }}>{children}</div>
    </div>
  );
};

const Stage: React.FC = () => {
  const t = useT();
  const roll = Unroll(t);
  const [xx, xy] = AT.x;
  const strike = easeOut(ramp(t, E('closet') + 0.12, E('closet') + 0.34));
  return (
    <AbsoluteFill style={{ background: '#2b1a0e', overflow: 'hidden' }}>
      <Table />
      <div style={{ position: 'absolute', inset: 0, clipPath: roll < 1920 ? `inset(0 0 ${Math.max(0, 1920 - roll).toFixed(1)}px 0)` : undefined }}>
        <CamLayer>
            <ChartBase />
            <SeaMarks />
            <div style={{ position: 'absolute', left: 96, top: 448, font: `italic 42px/1 ${FELL}`, color: '#0f3a37', opacity: 0.85, letterSpacing: '.05em', transform: 'rotate(-2deg)' }}>{TXT.seaA}</div>
            <div style={{ position: 'absolute', left: 520, top: 992, font: `italic 34px/1 ${FELL}`, color: INK_D, opacity: 0.85, textShadow: HALO }}>{TXT.hills}</div>
            <Cartouche />
            <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}>
              <defs>
                <radialGradient id="wet" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="#2a2118" stopOpacity="0.3" /><stop offset="0.8" stopColor="#2a2118" stopOpacity="0.22" /><stop offset="1" stopColor="#2a2118" stopOpacity="0" /></radialGradient>
                <radialGradient id="warm" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="#ffdc7a" stopOpacity="0.9" /><stop offset="0.5" stopColor="#ffc35a" stopOpacity="0.32" /><stop offset="1" stopColor="#ffb040" stopOpacity="0" /></radialGradient>
                <clipPath id="holeFront"><path d={`M${xx - 170} ${xy - 620} L${xx + 170} ${xy - 620} L${xx + 170} ${xy + 8} L${xx + 96} ${xy + 8} A96 42 0 0 1 ${xx - 96} ${xy + 8} L${xx - 170} ${xy + 8} Z`} /></clipPath>
              </defs>
              <Monster />
              <OldTrail />
              <Cottage x={AT.cove[0]} y={AT.cove[1]} at={E('cove')} />
              <ViewingHouse x={AT.viewHouse[0]} y={AT.viewHouse[1]} at={E('viewing') - 0.05} />
              <CapeHouse x={AT.cape[0]} y={AT.cape[1]} at={E('cozy') - 0.1} />
              <Fleet />
              <InkRun />
              <FreshTrail />
              <MapLamp />
              <XMark />
              <Dig />
              <Token />
              {/* the mistake, crossed out in red */}
              <Stroke d="M514 1174 C582 1164 672 1192 760 1166" at={E('closet') + 0.12} dur={0.2} width={9} color={XRED} />
              <Stroke d="M514 1196 C582 1182 672 1210 760 1186" at={E('closet') + 0.26} dur={0.2} width={7} color={XRED} />
            </svg>
            <Snores />
            <Label x={262} y={1086} at={E('parents') - 0.1} text={TXT.start} />
            <Label x={150} y={768} at={E('viewing')} text={TXT.stop} />
            <Label x={160} y={838} at={E('twelve') + 0.9} text={TXT.crews} size={44} font={FELL} color={XRED} style={{ fontStyle: 'italic' }} />
            <Label x={522} y={1150} at={E('cozy') - 0.05} text={TXT.wrong} />
            {strike > 0 ? <Label x={566} y={1208} at={E('closet') + 0.34} text={TXT.wrongNote} size={40} font={FELL} color={XRED} style={{ fontStyle: 'italic' }} /> : null}
            <Label x={724} y={1212} at={E('woke') - 0.1} text={TXT.beast} size={50} font={MARK} />
        </CamLayer>
      </div>
      {roll < 1920 ? <div style={{ position: 'absolute', left: -10, right: -10, top: roll - 50, height: 100, borderRadius: 50, background: 'linear-gradient(#7a5a2e,#efe0b8 36%,#d9c08c 60%,#6e4f22)', boxShadow: '0 18px 30px rgba(10,5,0,.6)' }} /> : null}
      <Light />
      {/* what stays lit after dark: the window, the names around it, and at the end the cartouche, as if the lantern came over */}
      {t >= E('key') ? (
        <CamLayer>
          {t >= E('keyword') - 0.05 ? <div style={{ position: 'absolute', inset: 0, opacity: ramp(t, E('keyword') - 0.05, E('keyword') + 0.15) }}><Cartouche /></div> : null}
          <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}><Apartment /></svg>
          <Label x={204} y={698} at={E('best') + 0.05} text={TXT.end} size={52} color={XRED} run={false} />
          <Neighbor />
          <Wipe at={E('neighbor') + 0.15} style={{ left: 296, top: 530, width: 250, textAlign: 'center', whiteSpace: 'normal', font: `50px/46px ${MARK}`, color: CREAM, textShadow: '0 2px 6px rgba(10,20,40,.8)' }}>{TXT.pest}</Wipe>
        </CamLayer>
      ) : null}
      <Spyglass />
      <Captions />
      <AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%, transparent 62%, rgba(20,8,0,.42) 100%)', pointerEvents: 'none' }} />
    </AbsoluteFill>
  );
};
```

## 8. Voice (optional)

A voice is worth adding when the world has a speaker of its own, because the deadpan delivery is half the joke. Skip it for a
sound-off post, or when the user would rather not have one: the film still works.

**The persona:** an old sea captain at his own table, low and gravelly and warm, telling a hunt for a first apartment as the great voyage of his life. He's completely serious. He's a storyteller, never a cartoon pirate: no growls and no "arr", and "ye" appears once in the whole film, in the first line.

**When to use it:** on every line except the dig, which is voiceless. Slow, heavy voices run long, so speed the takes up until a short line lands in 2 to 3 s. Give room to "Someone faster signed", the coldest line, and to "One phone number", where each short sentence should fall like a footstep. Add a small wooden cabin to it with a very short echo and a little warmth around 160 Hz, and compress before the room.

- **Never clone or imitate a real person.** Audition 3 or 4 voices on two lines of the script and let the user pick, or use
  the user's own voice.
- **One take per line,** with word timestamps. Pass the neighboring lines as context if your provider allows it, so the
  delivery flows. Trim each take to its speech, and tighten any pause inside it longer than 0.5 s.
- **Listen for your call to action.** Keywords blur easily. Commas around one in the spoken text ("Comment, gate, and...")
  usually fix it. Check the final mix with Whisper, line by line.
- **With no voice.** Keep the estimated timings as the clock. If there's music, put the captions on its grid. Raise the effects
  and the music: they carry the film now. Make sure the words stay on screen long enough to read, about 0.3 s a word. The estimate runs close to
  that, so add `tail` after a line that feels rushed.

## 9. Sound

The picture tells the story without sound, so a film can ship silent. Sound is what makes each change land, though, so add it
unless the user says not to. This section says what to use and when it plays. Add it after the user approves the animatic.

**Effects: make a set for this world.** Generate them with the sound-effects tool the user chose, or pick from a licensed
library. Prompt the thing itself, and keep it warm, never bright or harsh.

| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| chart unroll | 1 s | a stiff old parchment map unrolled across a wooden table, a dry papery rush ending in a soft slap | `on`, the first frame |
| brass spyglass | 0.8 s | a brass telescope pulled open, two small smooth metal clicks, close and dry | `glass`, and bigger on `swing` |
| quill | 1 s | a quill pen scratching quickly on thick paper, dry and close | every name and landmark that inks itself in, and quietly under the trail as it walks |
| crowd in a hallway | 3 s | many people in heavy boots shuffling and murmuring in a narrow wooden hallway, no clear words | `twelve` to `hallway` |
| rubber stamp | 0.6 s | a wooden stamp pressed hard onto paper on a table, a short dull thud | `cozy` |
| door and stove | 1.5 s | a small wooden door creaks open on a cramped room, a tiny iron stove door clinks, close | `closet`, with a soft puff of steam on `stove` |
| monster wakes | 2.5 s | a huge sea creature stirring under water, a long low wet groan and a slow surge, deep and dark, not a roar | `beast` |
| pen ticks | 0.6 s | three short pen ticks on a clipboard, dry and small | `three`, one per number |
| thunder | 3 s | distant heavy thunder over open sea, low and rolling, no crack | `storm` |
| fast boat | 1 s | a small sailing boat rushing past close by, a quick hiss of water and a snap of canvas | `faster` |
| wet pen | 0.7 s | a quick signature scratched with a wet pen, then a small blot | `signed` |
| lamp and quiet | 1.5 s | a gas street lamp hissing softly, one drop of water falling, very quiet | `sign`, when the glass snaps sharp |
| paper tear | 0.5 s | one small strip of paper torn off a notice, short and crisp | `phone` |
| fresh ink run | 2.4 s | a dip pen tapping a fast run of dots across paper, quick even taps, close | `nolisting` to `number` |
| X slam | 0.7 s | a heavy wooden mallet hit once on a table, deep and solid, warm and dark | `number`, and the table jumps |
| spade | 0.5 s | a steel spade cutting into packed earth, a short dry chop with a small scatter of dirt | `chop1`, `chop2`, `chop3` |
| chest thud | 1.5 s | a heavy wooden chest dropped on hard ground, a deep solid thud, small objects rattle, warm and dark | `thud`, the loudest thing in the film |
| chest lid | 1 s | old iron hinges on a wooden chest opening slowly, a low creak | `lid` |
| gleam | 1.5 s | one soft warm metallic shimmer, low and quiet, not sparkly | `gleam`, very low |
| key | 0.6 s | a heavy old brass key turning in a door lock, two solid clicks | `key` |
| singing radiator | 4 s | an old iron radiator ticking and whistling a wavering low note, almost a tune, warm | `sings`, through to the end |
| footsteps above | 0.6 s | one heavy footstep on floorboards heard from the room below, dull thud, a little dust falling | `neighbor`, then about once a second, in time with `stomp` |
| waves | 10 s | sea waves against a wooden hull heard from inside a cabin, slow and low, no gulls | a bed throughout, rising in the storm |
| creaking hull | 10 s | an old wooden ship creaking slowly at sea from inside a cabin, ropes and timber, no voices, no music | a low bed throughout, and what's left at the low point |
| rain | 10 s | heavy rain on a wooden deck above a cabin, steady and low, no thunder | `low` to `phone`, peaking at `signed` |

- **Place a sound on every visible change,** at the same event the picture uses, so a re-voice moves both.
- **Beds run under everything:** a world without room tone feels empty, and room tone is what keeps the low point alive.
- **The same sound twice within 80 ms:** keep the louder one.
- **Warm, never bright:** low-pass effects to about 1.5-3.5 kHz if they bite.

**Music: one instrumental bed.** Describe the sound, never an artist. Generators ignore requested timestamps, so fit the
track instead: find its real downbeats from bar-to-bar energy jumps, pick the bar where it lifts, place that bar on the
story's turn, and stretch (keeping the pitch, within about 4 %) so a second downbeat lands on the voiceless moment.

**The brief:** "A warm sea-shanty instrumental: a slow squeezebox over a bowed double bass, a low ship's drum on the beat, a plucked mandolin that comes in later, sparse at first, then a patient build that opens up into a full hearty swell, ending resolved, no vocals."

**When it plays:** low and steady under the first lines, as if someone in the next cabin is playing. It drains to nothing over 0.7 s between `low` and `storm`, stays out through the low point so only waves, rain and the creaking hull remain, and comes back very low on the word "sign". It climbs through the fresh ink, is at its fullest on the voiceless dig, on a bar where the track opens up, and ducks right after the chest thud. It thins out to the squeezebox alone for the night, so the radiator can be heard, and rests under the last line.

**Mix, voice first.**

- **Voice:** compress lightly so consonants stay off the limiter, about -14 LUFS.
- **Music:** about 16 to 20 LU under the voice while it speaks, lifted in gaps and on the voiceless moment.
- **Effects:** 7 to 10 LU under the voice overall, ducked about 6 dB under speech, so nothing masks a word.
- **Master:** -14 LUFS integrated, sample-peak ceiling about -2.4 dBFS. AAC encoding adds about 0.8 dB, so check the true peak
  of the final file. Give the master a 10 ms fade-in, or every view opens on a click.

## 10. Render and QA

1. **Render** without audio. Mux your mix onto it, trimmed to the exact length (`-t`, never `-shortest`), as H.264 at high
   quality with AAC 320k and `+faststart`.
2. **Sync and length:** the frame count matches the clock, and a cross-correlation of the muxed audio against your mix shows
   an offset of 0 ms.
3. **Contact sheets:** one frame per second with the safe box drawn. Look at every sheet: nothing cut off, nothing important
   outside the box, no empty or black frames, and the time of day and the story still agree.
4. **Determinism:** render about 10 spread frames twice with `npx remotion still src/index.ts Film-9x16 out/a.png --frame=N`, and
   compare each pair with `cmp`; they should match.
5. **Loudness:** -14 LUFS integrated, true peak at or below -1 dBTP.
6. **Words:** transcribe the mix line by line with Whisper. A difference on a plain word is a masked or swallowed word.
7. **Gaps and stills:** no voice gap over 1.0 s outside the voiceless moment (`clock.mjs` warns about these), and no still stretch
   over 1.2 s or black stretch (`ffmpeg -i out/silent.mp4 -vf "freezedetect=n=0.001:d=1.2,blackdetect=d=0.1" -an -f null -` prints any).
   A small moving corner can hide a freeze from that check, so trust the contact sheets over it.

For a silent film, skip the checks that need audio (the offset in 2, and 5 and 6) and use `out/silent.mp4` as the final file.

## 11. Traps

- **A full chart too early:** plan `AT` first. Ours needs room late in the film for the house, its name, the small monster and its name, on top of everything already there.
- **Names colliding:** every name is placed by hand. A longer one runs into its neighbor, the old trail or the monster's neck, so check the last frame at full size.
- **Names that lift off the paper:** `DRIP_SOURCES` and the red strike strokes don't follow the labels on their own. Move them together, or the ink runs from empty paper.
- **A black low point:** the chart goes dark, but the rain, the drips and the wet patches keep it moving, and the glass stays teal when the sea comes over it. It's never black.
- **Names lost at night:** the night overlay dims all the ink. Anything that must read after dark goes in the second `CamLayer`, above `Light`, which is also why the cartouche is drawn a second time for the ending.
- **The glass running past the safe area:** at `HOME` its top sits above y = 250, so put must-read text in the lower two-thirds of a scene. Small jokes can sit higher.
- **A push that hides a name:** a camera push can slide a low name under the scroll. Check the frames where each name first appears.
- **Words look cut off on a contact sheet:** that's words arriving. Watch the video to check that each chunk finishes well before the next starts.
- **A map that wants a real place:** invent every name. A real city or a real landlord turns the joke into a claim.

| Symptom | Fix |
|---------|-----|
| A few random frames show text at the wrong size | moving text as HTML, not SVG `<text>`, under per-frame transforms |
| A shape keeps a style from an earlier frame | a negative CSS length was rejected: clamp at 0 |
| A render dies on a font timeout | take `delayRender` inside React, as `core.tsx` does, never at module level |
| Sound drifts against the picture | mux your own mix with `-t` and check the offset by cross-correlation |
| The music's lift lands late | fit by real downbeats; do not trust requested timestamps |
| Two renders of a frame differ slightly along a panel's edge | a large blurred `box-shadow` isn't exactly repeatable: use a shadow with no blur on big panels |
| A caption breaks after "a.m." | The caption chunker (`buildChunks`, or the frame's own if it has one) ends a chunk at any word ending in a full stop: write "am" in `text`, or break the chunks yourself |

## 12. Deliver

The MP4, a cover frame and a ready-to-paste caption. For organic posts the caption repeats the comment keyword and carries
no link.
