# Juli-Fone 850 — Answering Machine Prototype

A retro-styled interactive answering machine demo: a Nuxt 4 single-page app
(`ssr: false`) that draws a 402×874 machine and behaves like the object rather
than like a web page. It began as one 4,100-line `index.html` of React
transpiled in the browser; that build was ported to this one and deleted, and git
history is where it lives now. See "How the app is put together" below.

---

## Running the app

```bash
pnpm install
pnpm dev
```

`install` matters the first time in a fresh worktree, and often not even then —
`.worktreeinclude` copies `node_modules` into a new one.

`pnpm dev` takes no port. It serves on **this worktree's slot** (3456 for the
main checkout — see the table below), refuses rather than sliding if the slot is
taken, and prints the directory it's serving with the URL it chose. Pass
`--port 3461` to override it, or `--host` as well to reach it from the iPad on
the same WiFi — layout only, since the mic needs a secure context and a
plain-http LAN address isn't one.

---

## Worktrees and ports

Work happens in **git worktrees**, one per task, so several versions of the
machine can be served at once and compared side by side in a browser. Each
worktree gets its own port — a *slot* — and the slot is written into the
directory name so nothing has to be looked up:

| worktree directory      | slot | port | launch.json config |
| ----------------------- | ---- | ---- | ------------------ |
| `answeringmachine`      | 1    | 3456 | `slot-1-main`      |
| `answeringmachine-2`    | 2    | 3457 | `slot-2`           |
| `answeringmachine-3`    | 3    | 3458 | `slot-3`           |
| `answeringmachine-4`    | 4    | 3459 | `slot-4`           |

Create one next to the main checkout, taking the lowest free slot:

```bash
git worktree add ../answeringmachine-2 -b some-task
```

Then in that directory, `pnpm dev` needs no argument — `scripts/dev.mjs` reads
the slot off the directory name (`-2` → 3457) and prints the URL it picked, along
with the directory it's serving, so two terminals are never confused for each
other. The main checkout has no suffix and gets 3456. An explicit
`pnpm dev --port 3461` or `AM_PORT=3461 pnpm dev` still wins over both.

The port is deliberately **not** auto-negotiated. If a slot is taken the server
refuses to start and says so, rather than quietly sliding to another port —
sliding would leave the browser pointed at a *different* worktree's app, which
looks like your change didn't work. Four slots is the roster; a fifth
simultaneous worktree needs a row added to the table and to
`.claude/launch.json`.

That refusal is `scripts/dev.mjs`, not Nuxt. `nuxt dev --port 3456` will happily
bind `127.0.0.1:3456` next to another process holding `*:3456` — both servers
"up", and the browser gets whichever one it resolves first — and when it can't
bind at all it takes a *random* port instead. The script connects to the port
first, which is the only check that sees the first case, and exits with the
collision message. It's the one piece of the old `server.py` that was worth
keeping.

Each worktree is a full checkout of the same repo, so all of it works unchanged:
audio, fonts, the stickers. Per-directory are `node_modules` (copied in by
`.worktreeinclude`), the ignored `.nuxt/` and `.output/` build directories,
`.claude/settings.local.json`, and the ignored `firmware/` paths.

A worktree created by Claude Code sits under `.claude/worktrees/` inside the
main checkout rather than beside it, so that path is gitignored — otherwise
every other session's copy of the app shows up as untracked files in this one.

When a task in a worktree is done, it gets merged back into the main worktree
and the worktree is removed:

```bash
git worktree remove ../answeringmachine-2
git branch -d some-task
```

---

## File structure

```
nuxt.config.ts      — ssr:false, the two global stylesheets, and the <head>:
                      title, viewport, and the one Google Fonts link
package.json        — nuxt / vue / vue-router, and nothing else
scripts/dev.mjs     — what `pnpm dev` runs: picks this worktree's port, refuses a
                      taken one, prints what it's serving, then starts Nuxt
.claude/launch.json — one dev-server config per slot, so a session in a worktree
                      starts on that worktree's port
.worktreeinclude    — untracked paths a new worktree gets its own copy of, as
                      gitignore-style globs. Just `node_modules` — 250M of pnpm
                      tree that a new worktree would otherwise have to install
README.md           — this file

app/
  app.vue           — one route, mounted through <NuxtPage>
  pages/index.vue   — the frame: chassis, screen body, nav, and every modal and
                      sheet that sits over them
  components/       — 28 SFCs. The cassette (Cassette + ShellArt, HubAssembly,
                      AnimatedName, ShellSwatch, TapeSlot, TapeSpine, GhostTape),
                      the three screens, TapeCustomiser and the page that hosts
                      it, the modals and sheets, and the onboarding flow's five
  composables/
    useMachine.js   — the machine's whole state, its refs and every handler
  utils/
    tape-parts.js   — Kat's cassette, data half: the four part maps, the sticker
                      list and the geometry. The artwork is the components above
                      and the stylesheet below
    data.js         — FRIENDS, MESSAGES, RADIO_SESSIONS, ME, TAPE_W, REC_PROMPTS
    helpers.js      — the pure functions (fmtSecs, the two date helpers withMeta
                      derives a row from, copyText, inviteFromLink, obMakeInvite …)
    onboarding.js   — the flow's two constants
  assets/css/
    tape-parts.css  — the cassette's own styles, and the @font-face rules for the
                      four label faces in public/fonts
    main.css        — everything else: the machine, all three screens, the
                      customiser page and the onboarding flow

public/             — everything fetched by URL at runtime, which is why these
                      paths are plain strings in the code rather than imports
  fonts/            — pencil / blocky / script / brush .otf, the tape faces that
                      aren't on Google Fonts
  assets/stickers/  — the twelve glitter stickers, drawn onto a tape when it
                      carries them, plus `unreadmessages-sticker.png`, the word
                      sticker the machine's own tape wears instead of a name
  assets/labels/    — the six printed label stocks, Kat's files under Kat's
                      names (label1…label6), each a seamless tile. The name
                      carries the ink the artwork was drawn to take —
                      `label3_whitetext`, `label5_whitetextblackstroke` — and
                      LABEL_DESIGNS is the only place the files are named.
                      `glitterswatch.png` sits beside them as a seventh stock,
                      the machine tape's only, and is the one file here that
                      ISN'T seamless — hence its 200% scale, which keeps the
                      single seam under the sticker
  assets/machine.png — the photograph on the onboarding intro
  Leeheng.m4a       — home voicemail from Leeheng
  Tony.m4a          — home voicemail from Tony W
  celine.m4a        — home voicemail from Celine
  alex.m4a          — unused: Alex isn't a friend, so he has no tape. Kept on disk
  Patrik habit.m4a  — unused: answered the old "worst habit" prompt, which the
  Jenny habit.m4a     sentimental prompts replaced. Kept on disk like alex.m4a;
  Tony habit.m4a      RADIO_SESSIONS no longer names any response audio
  BEEP.mp3          — plays when user selects a contact to leave a message for,
                      and when user taps Record on the radio tab
  REWIND.mp3        — plays as intro before the radio show begins
```

Two files that used to be here are gone, deleted with the React build they ran
on: `prototype-hybrid-tape.html`, the cassette sandbox that drew every part at
every size on one screen, and `prototype-onboarding.html`, where the sign-up flow
was designed. The flow has a real home now — `Onboarding.vue` and its four steps
— but **the cassette sandbox has no successor**, and it was where a change to the
part maps got looked at. It would come back as a route (`/tape-sandbox`) in about
forty lines if that's wanted.

---

## How the app is put together

Nuxt 4, Vue 3, `ssr: false` — a client-side SPA, because the machine opens the
mic, holds object URLs, measures its own boxes and keeps no storage, none of
which has a server half.

It's a port of a single 4,100-line `index.html` (React, transpiled in the browser
by Babel), and the port's whole rule was that nothing about the design, the layout
or the flow changed on the way across: the two were compared screen by screen in
a browser before the old one was deleted. That's why the sections below explain
Vue in terms of the React they replaced — those notes are the map for anyone
reading a commit from before the port, and `git log -- index.html` is where it is.

**The CSS was moved, not rewritten.** `app/assets/css/main.css` was
`index.html`'s `<style>` block, byte for byte — `diff` confirmed it at the time,
which is worth knowing when a rule in there looks odd: it's the original,
comments and all, minus a `#__nuxt` rule added at the top and three comments
repointed from `<Cassette>` to `Cassette.vue`. `tape-parts.css` came across the
same way, and it's still Kat's. Both are loaded globally and **unscoped**,
cassette first: the class names *are* the
design, and scoping them would rewrite the selectors the materials, the height
budget and the touch targets all depend on. No SFC has a `<style>` block. The one
rule that isn't the original's is `#__nuxt { display: contents }`, which takes
Nuxt's wrapper div out of the layout so `.device` is still a direct flex item of
the centring body and its `--fit` arithmetic is untouched.

**One route, three screens.** The tabs are tabs on a physical object, not pages:
a PLAY chain keeps running while you're on TAPES, the deck holds its cassette and
the carousel keeps its scroll position. Routing them would throw all of that away
on every tap, so `currentScreen` still picks a screen and `pages/index.vue` is
the only route.

**State lives in `useMachine()`, not in props.** Everything that was the old
`AnsweringMachine` component's state, refs and handlers is one composable — a
single instance in a detached effect scope — so a screen or a row reads the
machine directly instead of taking thirty props. The functions kept their names,
their order and their comments, which is what made them diffable against the
React while both existed.

How the React came across, and it's mechanical everywhere else:

- `useState` → `ref` (`shallowRef` for the objects and arrays that are always
  replaced whole, which is all of them — that also keeps a recorded `Blob` out of
  a reactive proxy).
- `useRef` → a plain `{ current }` box, so the ported lines still read
  `chainSessionIdRef.current`. Leave them that way — they're not reactive and
  they must not become reactive; every one of them is a cancellation flag, a
  timer handle or a DOM node.
- The three refs that existed only to give a callback a *fresh* read —
  `messagesRef`, `homeCardsRef`, `loadedCardIdRef` — are gone: a ref's `.value`
  and a computed are always current, which is all they were for.
- A callback ref stays a callback ref. Vue calls a `:ref` function with the node
  on mount and `null` on unmount, so `attachList` — the ResizeObserver that holds
  the message list still while the LCD resizes — is unchanged. DOM refs are set
  by stable named functions, never an inline arrow: a new function identity each
  render makes Vue tear the ref down and rebuild it on every patch.
- `useEffect` → `watch(..., { flush: 'post' })`. The two that React re-ran via a
  `currentScreen` dependency instead run from `HomeScreen`'s own `onMounted`,
  which is the same moment.
- `autoFocus` → an explicit `.focus()` when the field appears. The HTML attribute
  only fires on a page's first load, and the label's name field and the
  paste-a-link card both open mid-session.
- Numbers in inline styles carry their unit. React wrote `style={{height:3}}` as
  `3px`; Vue assigns the string, so it's `'3px'` here.

**One place needed care, and it's the nav.** React read `playState` from the
render that drew the key, so tapping HOME *while recording* stopped the take and
then skipped the "reset the deck to the machine's own tape" branch, because the
state it tested was still `'recording'`. Vue's refs update synchronously, so the
branch would have fired and the deck would have jumped out from under you.
`navTo` reads `wasIdle` before it calls `stopAll` and keeps the original
behaviour on purpose.

**`public/` for anything fetched by URL, `app/` for anything imported.** The four
label faces, the twelve stickers, the intro's photograph and the audio fixtures
are all named as *data* — `'BEEP.mp3'` in a message row, `/assets/stickers/…` in
the sticker map, `url('/fonts/pencil.otf')` in the cassette's stylesheet — so
they can't be build-time imports, which would hash their filenames. They sit
under `public/` and the paths stay root-absolute. Nothing in `app/` imports an
asset, and the config needs no `nitro.publicAssets` or Vite alias to make that
work.

**Verified in the browser**, screen by screen against the React build before it
was deleted: home / TAPES / RADIO / customiser / friend sheet / REC picker /
onboarding intro rendered identically; the PLAY chain walks a tape oldest-first
with the LCD counter, the transcript typing out and rows going from unread to
played; STOP halts it clean; NEXT wraps; SPEED lights and toggles; ✓ files a row
off the queue and ✕ shakes it away; the customiser's font chips re-write the name
(type and write modes both), a sticker places, drags and peels off; the back key
asks before it throws a draft away; saving spins the tape into place; the
onboarding flow runs end to end and its tape and invite code land on the machine;
a pasted invite link files a new tape; and REC with the mic refused puts the amber
`MIC ERROR` on the LCD. `pnpm build` is clean, and the built app serves its fonts,
stickers and audio. **Not verified:** a real take through review → SEND, which
needs mic access the review browser doesn't grant — that path is a line-for-line
port, including the Louie POST.

---

## App structure

The app has three tabs — HOME / TAPES / RADIO — on a nav bar at the **foot** of the device, below the transport controls. Top to bottom the machine is: wood panel, screen body, nav, foot strip.

The middle tab reads TAPES on the device, but its screen id, its section below and the people behind it are all still `friends` in the code. That's deliberate: `FRIENDS` is the source of truth for people and renaming it would touch every message row.

**A cassette is a person's avatar.** You customise exactly one tape — your own — and everyone else's arrives on your shelf looking however *they* made it. That's the whole model on this tab: `ME` is you, `myTape` is the only tape the customiser opens, and a friend's tape is read-only here.

**The list's header bar is the same walnut as the wood panel.** `.messages-header` — `INBOX` on a friend's tape, `NEW MESSAGES` on the queue, `MY TAPE` / `FRIENDS' TAPES` on the tapes tab, and `PREVIOUS SHOWS` on RADIO through its own twin rule — paints `var(--walnut)`, the three grain layers lifted out of `.wood-panel` into one variable so the two can't drift, with the top-lit sheen restated over it and the seam shadowed in below. It doesn't carry the panel's fine ribbing: that belongs to the panel and the nav slot, and at 31px tall it reads as noise. The grain SVGs are 402px wide and paint at intrinsic size, so a 366px bar is covered without a seam.

**A header bar names its list and nothing else.** No count, no hint, nothing right-aligned — the bar is furniture and the numbers belong on the LCD, which is one glance up. That took `3 WAITING` / `ALL CLEAR` off the queue, `NOT SENT` off review, `TAP TO SHARE` and the friend count off the tapes tab, and `4 EPS` off RADIO. A right-aligned scrap of text in a walnut bar reads as a second column that never fills.

**Three typefaces, split by material.** Titles are Newsreader at regular weight and its own tracking (`--head-font`) — a modal's or a sheet's title, so `Celine` over her tape is set rather than stamped. It stops at titles: **the bar above a list is a stamped label on the chassis and stays tracked caps in the UI face** (`INBOX`, bold, 16px, 2px), as do its month bands (`APR 2026`, bold, 12px, 2.5px) — Newsreader there read as a magazine standfirst over the messages underneath. A running time on a row (`.msg-duration`) is a readout rather than copy and keeps VT323, the LCD's own digits. Everything else the machine says *in ink* is Figtree (`--ui-font`) — message rows, the body of every modal and sheet, the customiser's labels and fields, the radio list, and the silkscreen on the transport keys and the MSG / REC lamps. Everything *lit* stays VT323: the LCD and its sub-lines, the invite readout, the ON AIR panel. Two things sit outside both because they're artwork, not interface — the cassette labels (whatever `TAPE_FONTS` says, plus the Courier `60 MIN · TYPE I` foot and phone line) and the `Julifone` Newsreader wordmark. Figtree is loaded as a 400–700 variable range, so a bold row is a real weight and not a synthesised one.

**Tracked caps is for things stamped on the machine.** The transport keys, the nav, the segment tabs, the swatch chips, the keys in modals and sheets, the 7px `MSG` / `REC` silkscreen, and the header bars and month bands cut into the chassis. Everything set *in* a screen is sentence case at ~0%: titles (`My tape`, `Add a friend`, `Invite Celine`), labels and hints (`Name on tape`, `Label design`, `Share via`), and the LCD's own lines.

The wood panel is walnut veneer drawn in CSS — stretched fractal noise, no image asset — and carries two things: the `Julifone` serif wordmark on the left and the MSG / REC lamps on the right. No model number up there; the model lives on the machine's own cassette label.

**Two lamps, and both of them mean something.** There's no power light: the machine being on is obvious from everything else on screen, and a decorative lamp was holding the green that belongs to "you have messages".

- **MSG is green** — lit while there are messages waiting, dim at zero.
- **REC is red** — lit only while the mic is actually open (`playState === 'recording'`), dim otherwise. That includes the review state: reviewing a take isn't recording, and a red light while you're listening back would be a lie about what the machine is doing.

**No lamp anywhere on the machine blinks, and a lamp only exists where it has something to report.** Lit or dim is the whole vocabulary, because anything flashing reads as the machine doing something right now — a blinking light with no activity behind it is the same lie in a different colour. That rule started on the wood panel and now covers every dot: the radio banner's went from a pulsing amber to dark amber at rest and lit red on air, and the previous-shows list lost its dots outright, the newest row's having pulsed as though a finished show were still going out. What still flashes is a cursor and a lit readout — the transcript's caret and the LCD's `● RECORDING FOR …` line — which are text doing text things, not lamps. `ledBlinkAmber`, `ledBlinkRec` and `onAirPulse` went with the dots they drove; `ledBlink` is the one left.

Each lamp is a 10px domed lens rather than a flat dot: a radial gradient curves the glass (bright off-centre, mid-tone, dark at the rim), an inset shadow seats it in the wood, and a `::after` pin-light is the specular. Lit adds a bloom onto the panel around it; dim keeps the dome and a fainter highlight, so a dead bulb still reads as glass and not a hole in the panel. The `MSG` / `REC` labels are the transport keys' type (`.btn-label` — bold Figtree caps, tracked) stepped down to 7px and dimmed: they name two lamps, they don't label a control you press.

That puts two rows of controls at the bottom, so they're deliberately built from different materials: the transport panel is raised light plastic inset 16px from the sides, and the nav is a dark slot cut full-bleed into the chassis — seam facing up, well shadowed inwards, carrying the same fine ribbing as the wood panel. The 16px of chassis showing between them is what stops them reading as one control strip. The active tab's lit seam sits along its **top** edge, pointing up at the screen it opens.

Inside that slot are three dark metallic-brown keys, 3px of slot floor showing around each, labelled in Figtree medium at 10% tracking. Unpressed they're raised — lit top edge, dark bottom edge, a hard specular break across the middle for the metal, and a shadow cast down onto the floor. The current tab is pressed: cast shadow gone, shadowed in from every edge, light only on the bottom lip, label a lighter cream nudged 1px down. **The pressed key has to end up darker overall than its neighbours** — brighten the face instead and it reads as merely highlighted, not pressed.

### HOME
Organised by person, not one flat list.
- **Cassette carousel** in place of the old single deck cassette: the machine's own tape first, then one deck-size tape per friend, drawn by the shared `Cassette` so each shows that friend's customised shell, label and font. Swipe on a touchscreen (native scroll-snap) or click-drag with a mouse/trackpad; **whatever the carousel settles on is loaded into the deck**, and tapping a card works too. The loaded tape gets the amber glow and its hubs are the ones that spin. The glow is stacked `drop-shadow()`s rather than a `box-shadow`, so it hugs the cassette's own rounded silhouette instead of drawing a rectangle around the card
- **A deck card is 250×156**, which is the shared cassette's 1.604 ratio worked backwards from the height: the carousel gets 176px of the frame, less 12 above and 8 below. Width is the only thing the app chooses about a tape's size, so that number *is* the deck's design — see `TAPE_W`
- **The first card is the machine's own NEW MESSAGES tape — everything still waiting, across everyone.** It's what's loaded at boot, and home returns to it whenever you come back to the tab. Its label carries the total (`3 NEW MESSAGES`), the LCD reads `ALL MESSAGES · 3 WAITING` (or `NO NEW MESSAGES`), and the list below is the play queue: not a history but what's outstanding, oldest first, with no month bands. **A message leaves the queue when you've heard it** — it lives on the friend's tape from then on, so at zero the whole screen goes quiet: `no new messages` in the list and the LCD's own `NO NEW MESSAGES`, with no instruction to go anywhere — the carousel is right there
- **A friend's cassette holds their whole history**, not just what's new — newest at the top, older scrolling below, split into sticky month bands (`JUL 2026`, `JUN 2026`, …). There's no separate archive: the tape *is* the archive
- Only friends can leave messages, so every row belongs on one of the carousel's tapes — there's no unknown-caller tape
- **A friend's tape says their name once.** The cassette in the deck carries it, so the LCD header is just `CELINE` and the line under it is only the counts — `1 message waiting · 7 total messages` — the bar above the list reads `INBOX` with nothing on its right, and a row is the date, the time and the length (`Wed 1 — 7:22 PM`, `2:05`) with no name repeated down the tape. The queue is the exception and keeps the name on every row: that card is everyone's messages, so a row there has to say whose it is
- **The LCD is only full height when there's a take to draw.** Idle it collapses to 73px — whose tape is in the deck, the time, and the "N messages waiting · M total messages" line, with no waveform and no empty transcript window — and expands to 154px whenever something is playing, recording or in review. The 81px it gives up goes to the message list, which roughly doubles from 75px of scroll to 156px. Both halves animate over 180ms so it reads as the machine waking up. The amber prompts stay in the collapsed display rather than forcing it open: it goes to 88px, enough for the error line and its hint
- **PLAY runs everything that's loaded, oldest first, with `BEEP.mp3` between each message** — the way you'd walk in and hit PLAY on the real thing. On the machine's own tape that's every outstanding message across all friends; on a friend's tape it's their whole tape. Same function either way (`playChain`), a different list:
  - the LCD header is whose message it is and where it sits in the run — `LEEHENG 3/3`
  - the carousel travels to that person's cassette as their message plays, so you watch it come off the shelf; the deck itself stays on the tape you pressed PLAY on, so the queue stays under your eye and you see rows drop off it one at a time. Unread counts and the MSG LED tick down with them
  - while a chain is running the shelf is the machine's — the carousel is locked to swipe and drag. STOP hands it back
  - my own sent replies are stepped over, and the run ends on `END OF MESSAGES` for three seconds before the display drops back to idle
  - STOP halts mid-chain without triggering the next message, and a message you cut short stays waiting for you — a message counts as heard when it reaches the end
- Controls: PLAY, STOP, REC, **NEXT**, **SPEED** — NEXT skips to the next message the machine would play and wraps within the loaded tape; if something's playing it carries on from there. SPEED toggles `×1` / `×1.5` (lights up at 1.5×) and applies to a take already playing as well as the next one, re-timing the scrolling transcript to match
- Each message row has its own small ✕ — shakes, then takes that note off the tape for real. Deleting the note that's playing stops playback first
- **On the machine's own tape each row also gets a ✓ — mark it heard without listening.** The row slides off the queue and goes on living on its sender's cassette; nothing is deleted, which is the whole difference between it and the ✕ beside it. It's green like the LED it puts out, and it only appears on the queue — on a friend's tape there'd be nothing for it to do. ✓ on the row that's playing stops playback first (a row can't leave the queue while you're hearing it); ✓ on any other row leaves the run going and just takes that one out of it
- **A reply you sent sits on the same tape, but below the incoming messages in priority** — inset from the left, narrower, dimmer, labelled `YOU`, no unread LED. It doesn't count toward the tape's waiting count, the machine's own tape, the MSG LED or the "N messages waiting" line, and it's outside the PLAY/NEXT chain: tapping the row plays it (tap again to stop), but PLAY steps over it to the next real message. A tape holding nothing but replies says so on the LCD (`nothing waiting · N replies sent`) rather than on the `INBOX` bar, which stays bare
- Transcript scrolls word-by-word in sync with audio duration
- **REC records for whoever's cassette is in the deck** — no picker, no "who for?" step. It plays BEEP, then requests mic access and records for real via `MediaRecorder`. To leave someone a message you load their tape and press REC
  - **On the machine's own tape it asks instead.** That card is everyone's messages, so there's no one recipient to infer — REC raises the same bottom sheet a friend's tape uses, titled `Send a new voice note` over the shelf of **spines**: every friend's cassette seen edge-on, in their own shell, label and font. No instruction line under the title — the tapes are the whole content, and what a tap does is obvious once one is under your thumb. Tap one and it goes in the deck *and* the take starts on that tap, so the queue view is one press from recording for anyone. The carousel travels to them, the LCD reads `REC > NAME`, and tapping off the sheet records nothing
  - REC still refuses on an empty collection — nothing to offer — with `NO CASSETTE IN THE DECK` and a hint to add someone, amber like the mic error. Both live in `REC_PROMPTS`, which carries each one's status line, message and hint together. `NO ONE TO RECORD FOR` is gone with the refusal it belonged to
  - STOP ends the take and drops into the review state: the LCD reads `REVIEW > NAME`, the take is held as a Blob + object URL in state (`recordedBlob` / `recordedUrl`), and the message list is replaced by the take with RE-RECORD / DELETE / SEND
  - PLAY / STOP listen back as many times as you like — same reels, waveform and playing/idle states as an incoming message, with a `0:00 / 0:07` position timer on the LCD
  - RE-RECORD discards the take and starts over with the same contact (BEEP and all); DELETE discards it and leaves the flow; SEND shows the confirmation **and puts the take on that person's tape as an outgoing message**. Hardware DELETE also discards, SKIP is inert in review
  - If mic permission is denied the LCD shows `NO MIC — CHECK PERMISSIONS` and the session is torn down

### TAPES
Your cassette at the top, everyone else's below it. For inviting people and adding them as friends. There's no backend, so **the invite link is the mechanic** — you add someone by sharing a link with them.
- **No LCD on this tab.** The shelf starts at the top of the screen body and takes the height the display used to hold — the only green display in the machine is the one on home. There's nothing to read out here that the panel headers and the tapes don't already say
- **`MY TAPE` — a full-size cassette at the top, labelled with your name.** It's your avatar: the tape your friends see when your link reaches them, and the only tape in the app you can change. It takes the panel's whole inner width — 344px, so 214px tall — and the panel sizes itself to that (323px) rather than the other way round, with the shelf below flexing into the 403px left over
  - **two keys are cut into the foot of the panel: `SHARE LINK` and `EDIT TAPE`.** The cassette itself is artwork here — not a target, and with no pencil on it. The keys are the nav's construction in pink: a slot with the seam facing up, the well shadowed inwards, the same fine ribbing, and two raised keys with 3px of floor showing round them. Pink metal rather than the nav's brown, and a **cream well rather than its near-black one** — the nav is cut into the dark chassis so its floor is that wood in shadow, but this slot is cut into a pale tray, and the dark floor drew a hard black frame round the keys that read as a stroke rather than a recess. The cream floor is a shade darker than the key faces and shadowed in from the top seam, so the depth survives without the outline. Every shadow on the keys is a warm rose-brown for the same reason: pure black on pink goes grey. They're 44px tall rather than the nav's 36 + expander — `.my-tape-panel` clips its overflow, so a pseudo-element reaching past the slot would be thrown away against that edge
  - **`EDIT TAPE` opens the customiser** — a page of its own, one part at a time over a live large preview, and the same screen the onboarding's customise step is (see below). Saving spins the tape into place where it sits
  - **`SHARE LINK` hands out your invite link.** Your link is fixed — it's who you are, not something you mint per person — so the popup has no name step: the link in an LCD field, `COPY LINK`, the decorative share tray, and the line *"Add more friends to unlock extra customisation options for your tape!"*. That copy is a promise, not a mechanic — nothing is actually gated on the friend count yet
  - the line along the foot of the label is your own invite code (`INVITE 2461`), which is what a friend's tape shows too. The shared label has **one** such line, in the slot it otherwise fills with a track count — so a tape says its number *or* its length, never both
- **`FRIENDS' TAPES` — one tape per friend, read-only**, the same `Cassette` in a 2-up grid at whatever width the cell gives it (165px, so 103 tall), each showing the shell, label and font its owner chose. No edit key: you don't dress up someone else's cassette
  - **tapping one opens a bottom sheet** — it rises out of the foot of the machine over 220ms, full width, top corners rounded to the chassis radius and the bottom two clipped by the device's own overflow. Same plastic as a modal card with a moulded grab rail on top; the rail is decoration, the sheet doesn't drag. It's **dismissed only by tapping off it** — no close key and no CANCEL row, because nothing in here is a step you're part-way through
  - the sheet holds their tape, their contact line and one key: `▶ GO TO VOICE NOTES`, which loads their cassette into the home deck and switches tabs, stopping anything playing first. It's labelled in the **nav's voice** — Figtree medium, 10% tracking, caps
  - **deleting is a bare brown bin in the top right**, drawn as an inline SVG rather than 🗑 (the emoji is a different visual language and illegible at 20px). No plastic key around it, so the only thing on the sheet that reads as a *button* is the green one; it takes 20px of glyph and a 44px target, and sits 148px clear of that key — this one deletes for real, so the 8px separation rule is a floor, not a target
  - **the bin asks first**: *"Are you sure you want to delete this friend?"* on a centred card over the sheet, YES (red) or CANCEL. CANCEL and a tap on that card's own backdrop both drop back to the sheet with the friend intact; YES takes them off the shelf and closes both. It's the one confirm step in the app — a tape is a whole history and there's no undo
- An `+ ADD A FRIEND` ghost-outline cassette always opens the shelf — first cell, top left, ahead of every real tape; with no friends it's the only thing under your tape, centred (no panel either). It's not a `Cassette` — it's the absence of one — but it borrows the module's ratio so it sits level with the real tapes beside it in the grid
- Tapping it opens the add flow:
  1. **Paste their link** — one field, `Paste your invite link`, and one key. No title and no CANCEL: tapping off the card is the way out, the same dismissal a friend's tape sheet has. The key is `No link? Invite a friend` while the field is empty and becomes `◈ Add their tape` the moment there's anything in it, so a pasted link always has a way forward (Enter does the same). The link is read back into a name and a code — `answeringmachine.app/i/jun-mei-7412` files *Jun Mei* with `INVITE 7412` — forgivingly, so a link with or without a scheme, or with the code missing, still lands
  2. **Add a friend** — where that key goes, in one tap. There's no name step in front of it: **the link you hand out is your own**, `ME.invite.url`, because it's your identity rather than something minted per person. One title line and no subtitle, and the readout carries no `MY INVITE LINK` label — the key under it says what the link is for, and a lit label on a card this short reads as a second title. Shown in an LCD-styled field; `COPY LINK` really copies (Clipboard API, with a textarea fallback so it still works over plain-http LAN) and flips to `✓ COPIED` for 1.6s
  3. **Share tray** — SMS / WHATSAPP / MAIL / MORE as machine buttons. Purely decorative. Deliberately *not* `navigator.share`: a native sheet breaks the aesthetic mid-demo
  4. **DONE** just closes, and so does tapping off the card. **This card files nobody**: their tape arrives on your shelf when they use the link and make one, so the only route onto the shelf in the prototype is a link pasted into step 1
- **The customiser is a page, not a dialog.** `EDIT TAPE` raises `.tape-page` over the whole 402×874, because a 288px modal card can't hold a cassette big enough to type on or drag a sticker around. Paper surface, Newsreader display heading (*Edit your tape*, and no subtitle under it — the tape below is the instruction), the tape on a stage at 340px, and `Save my tape` across the bar at the foot, writing to `myTape`. It only ever opens on your tape
  - **it's the same screen as the onboarding's customise step**, not a relative of it: everything under the heading is `TapeCustomiser`, rendered by both, so making your tape and editing it later can't drift apart. This page supplies the draft, the heading, the bar and `tapeSub(me)` for the track slot; the component owns which tab you're on, whether the label is a field and which sticker is lifted — which is also why nothing needs resetting when the page opens, the mount is the reset
  - **it rises out of the foot of the device.** `tapePageIn` travels the page's own full height in 0.34s and doesn't cross-fade: it's a drawer coming up over the tapes screen, and a sheet you can see through on the way up reads as a dialog
  - **the bar carries one key and still no `✕`.** Saving is the only thing at the foot, so a near-miss down there can't throw the draft away — the key has nothing beside it
  - **the way out without saving is a back key beside the heading** (`.tp-head` / `.tp-back`, a chevron in a 44×44 box, the row pulled 11px left so the arrow sits on the page's own margin while its target hangs over it — `.tp-scroll` is a scroller and a pseudo-element expander at its top edge would be clipped). It never leaves on its own tap: it raises `.tp-confirm`, *Leave without saving changes?* with `Leave` and `Keep editing` side by side, on the page's own paper rather than the machine's plastic modal. Tapping off the card keeps editing, `Leave` drops `draftTape` and closes — reopening rebuilds the draft from `myTape`, so nothing half-committed survives
  - **still one part at a time, still the same segmented tabs**: `DESIGN` (ten shells, then eight label designs), `FONT` (a text-size slider, then six faces), `STICKERS` (the module's twelve). The tabs keep the machine's plastic — the page is paper but the control is the one this customiser always had, and the tab you're on has to read as *pressed*
  - **the name is edited on the cassette itself.** There's no NAME ON TAPE field any more: tapping the writing band turns the label into the input (`onNameTap` / `onNameDone` on `Cassette`, measured 283×48, so the target clears 44 with no expander), and Enter or a tap anywhere else ends the edit. The band sits under the sticker layer, so a sticker dropped over the name still takes its own taps
  - **a group label appears only where the tab alone can't name the group.** `SHELL` and `LABEL` separate `DESIGN`'s two groups, and `TEXT SIZE` names the `FONT` tab's slider — which isn't the faces the tab names. The font grid and `STICKERS` still carry no label and no tip under them — what a face does to your name is written on the tape above it, and a sticker teaches its own gestures the moment one is on
  - **`FONT` opens with a text-size slider** above the faces: `nameScale` on the tape, moved through `NAME_SCALE`'s 0.6–1.2 in steps of 0.05, multiplying the face's own optical `scale` inside `Cassette`'s one `type` object — so the input and the animated name can't disagree, and every place the tape is drawn follows the saved size. A tape that never touched it carries no `nameScale` and reads exactly as before. The control is a native range in the page's own materials (`.tp-size`: a groove in the paper, a light-plastic knob), and the input is 44px tall (measured 44.1 at `--fit` 0.884) so the target is the whole row, not the 26px knob
  - **the options are grids stretched to the page's width, hung as one block under the tabs.** Not chips at a fixed size in a wrapping row, which left a ragged strip of paper down the right. Column counts divide each set evenly — 5 shells × 2 rows, 2 labels × 2 rows, 2 faces × 3 rows, 4 stickers × 3 rows — so no row is short. Every tab still fits without scrolling (measured: no tab scrolls its `.tp-scroll`)
  - **a swatch is the shape of the part it selects.** Both grids on `DESIGN` are landscape rectangles at their artwork's own ratio rather than tiles at a size of their own: a shell is the cassette's 1.604 (64×40) and a label is the label's 3.42 (176×51.5, from `G.label`'s 896 × 262). That's what took the labels from four across to two — a 3.42 rectangle one per row is 106px tall, and eight of those don't fit the page. **The label chip says nothing**: it wears the design's own band and that artwork is the preview — a word printed over it was a caption on one. `name` survives as data, the chip's `title` and accessible label, the same treatment the shells have always had. The shell tile is the one that pays for it: 40px is under the minimum, so `.tp-sw-shell` releases the 44 floor, takes a `::after` at `inset: -3px 0`, and the shells' `row-gap` goes to 16 so two rows of expanders still keep 10px of dead paper between them
  - **the spacing is one rhythm and the same on all three tabs**: 46px from the cassette down to the tabs, 18px from the tabs to the first group, 20px between groups, 10px between cells, 9px from a group's label to its grid. `.tp-options` is `justify-content: flex-start`, so the slack pools once above the bar (109 / ~21 / 146px on DESIGN / FONT / STICKERS — the slider now spends most of FONT's) instead of being spent *between* the groups — `space-evenly` used to push SHELL and LABEL ~130px apart, which read as two unrelated screens rather than one page of parts, and it moved the first row every time you changed tab. The two `DESIGN` grids no longer end level with FONT's block — the swatches are the artwork's own proportions now, so their heights are whatever those proportions come to and the slack pools above the bar as usual
    - **that rhythm is the flow's too**, since it's the same component. The customise step carries a stepper and a subtitle the page doesn't, so it has ~90px less slack to pool above its own key. `DESIGN` and `STICKERS` still fit it; `FONT` no longer can by arithmetic (the page keeps ~21px of slack and the flow has ~90 less), so the step's own scroller absorbs it — that overflow hasn't been re-measured in the flow itself
  - **a shell chip is a miniature cassette**, `ShellSwatch` from the same file, so the choice is the plastic itself and the chip can't drift from the tape it produces. Its `size` prop is only a fallback here — `.tp-sw-shell > svg` fills the tile's *height* and lets its own ratio decide the width, which is why `ShellSwatch`'s viewBox is `100/RATIO` unrounded: rounded to 62 the artwork came out a fraction wider than the 1.604 tile holding it. **A font chip says that face's own name, set in the face** — `title` from `TAPE_FONTS`, so the chip is the specimen and the label at once: *Pencil*, *Simple*, *Script*, *Brush*, *Type*, *Funky*, left to right. It describes the hand rather than naming the file, which is why `BLOCKY` reads as *Simple* and `SWANKY` as *Funky*; `name` stays the caps key underneath. The word is set at 32px × the face's own optical `scale` (an `Aa` could take 38, but a word can't) — a script at the same px as a mono reads much smaller, so the scale is what makes six different words look like one size. Sentence case, not the tracked caps the rest of the swatches use: a chip set in the tape's own face is artwork, not silkscreen
  - stickers: tap the tray to stick one on (landing spots avoid the middle of the label, where the name is), drag to reposition and it peels while it moves, tap to select and `×` to remove. `myTape` carries them, so they survive SAVE and show up on the shelf and in the deck
  - **a just-placed sticker drops onto the shell** — it starts big, high and unshadowed (the peel's own vocabulary), lands past flat and settles, 0.38s (`stickerDrop`). The customiser flags the new index for the animation's length only (`droppingSticker` on `Cassette`), so a re-render can't replay the fall, and grabbing it mid-drop ends the animation rather than fighting it
  - **your own glitter catches the light**: the stickers on MY TAPE and on the customiser's stage run a faint shine once every 8 seconds (`.sticker-shimmer`, a gradient sweep masked by each sticker's own PNG so it stays inside the silhouette; `shimmer-stickers` on `Cassette`). Only your tape shimmers — a friend's tapes, the deck and the sheets stay still. The onboarding's customise step inherits it, being the same component
  - **the bottom row of the tray is locked** — the last four of `STICKER_ORDER`, which in a 4-up tray is exactly one row, sliced off as `STICKER_LOCKED`. They stay visible and greyed with a padlock rather than being absent, because *"Add more friends to unlock more stickers!"* under the tray only means something if you can see what you haven't got. `disabled` takes the tap and the pressed state with it, so a locked one can't be placed by a near-miss (verified: tapping two of them adds nothing, and an unlocked one still places). The dimming is on the artwork, not the tile — nested opacity multiplies, and greying the whole button took the padlock down with it. The padlock is drawn as an inline SVG for the same reason nothing else here is an emoji: a system glyph is borrowed chrome. Like the SHARE LINK copy, **nothing is actually gated on the friend count** — the list is a constant
  - **a sticker is clipped to the shell's silhouette.** `.sticker-layer` wraps the artwork with `overflow: hidden` and the body's own corner radius, so one pushed over an edge is cut by the plastic instead of hanging off the tape — a real one wraps round the back and disappears. The radius is `3% / 4.816%`, which is a *pair* of percentages on purpose: both resolve to 0.03 × the tape's width, so it's the same circle `ShellArt` draws as `rx:30` at every tape size, where one percentage would have given an ellipse. It has to be kept in step with the body rect by hand — no CSS can read a viewBox
  - **the selection lets go of itself after three seconds**, and the `×` goes with it. Everything else on the tape is artwork, so a delete key parked on a finished cassette is furniture: being selected is a moment rather than a mode, and tapping the sticker brings it back. The clock is *dropped while a sticker is lifted and set going again when it lands* rather than merely started once — a drag is a live gesture and a slow one must not have the thing it's holding deselected out from under it. `watch([sel, peel])` is what makes every route in (placing, tapping, dragging, the host's settle) arrive at the same timer instead of each arming its own, and it's cleared on unmount. Verified end to end: ✕ up on place, still up at 1.7s, gone at 3.6s, back on a tap, hidden mid-drag while the selection survives a 3.6s hold, back on release, gone 3s later — and the sticker itself untouched throughout
  - **the `×` rides above that clip on a layer of its own.** It hangs off the sticker's top corner, so on a sticker held against an edge it would be cut away with the artwork and the sticker would have no way off the tape. Both boxes come from one `stickerBox()` in `Cassette`, so the key can't drift off the corner it belongs to (measured: 0px on all four sides), and it's hidden while that same sticker is being dragged — it's lifted and scaled then, and the key isn't what the finger is aiming for
  - the tape re-writes its name whenever you change the face — ink sweep for the drawn faces, glyph-by-glyph for the mechanical ones — which is why the FONT tab needs no sample bigger than a chip. **That animation now happens here and nowhere else**: see the note under the cassette below
- New friends get `DEFAULT_TAPE`. Existing friends print their phone number along the label's foot (`tapeSub`); a friend added by invite has no number, and that corner of their label now stays **empty** — the invite code used to be stamped there and it read as clutter on the artwork. The code still exists everywhere it's *used*: friends added by invite have no email, so the popup on their tape shows their invite link on the contact line
- The shelf is a ref in `useMachine()` (`friends`, seeded from `FRIENDS`) and resets on reload, as does `myTape` (seeded from `ME.tape`). A newly invited friend can be recorded to straight away — REC reads the loaded carousel card, which comes from that same state
- **Your tape is not in the home carousel.** It's an avatar, not a mailbox: nothing is addressed to you *on* your own shelf, and the carousel is a row of tapes you can play
- Shell colours, label designs and faces all live in `app/utils/tape-parts.js` — see the implementation notes below

### RADIO
- Live prompt banner — `NEW SHOW RECORDING` over `CLOSES MIDNIGHT SUNDAY AUGUST
  2ND 2026`, then "How did your parents meet?" — with a record button (4 states:
  idle → recording → review → finished)
- **The record key opens the mic for real.** Same session as the home deck —
  BEEP first, then `MediaRecorder`, on the shared recorder plumbing and the
  `recSessionIdRef` cancellation flag, so `stopAll` and `cleanup` tear a radio
  take down like any other and the wood panel's REC lamp lights for it. The
  take lands in its own slot (`radioTakeUrlRef`, length measured with
  `performance.now()`), not the deck's. Mic refusal puts
  `REC_PROMPTS.radioNoMic` up in the banner for six seconds, the LCD's amber
  treatment behind the banner's own glass — one line, `ERROR: PLEASE ALLOW MIC
  ACCESS TO RECORD`, because what went wrong and what to do about it are short
  enough together that the hint under it was repeating itself. It's the one
  prompt in the map with no `sub`, and the banner renders no second line when a
  prompt hasn't got one, so a future one-liner needs nothing but the copy
- **While the tape rolls the banner goes on air.** `NEW SHOW RECORDING` becomes
  `ON AIR` in red and the lamp beside it lights red with it — dark amber at rest,
  lit on air, never blinking either way, which is the wood panel's lamp rule
  applied here. A VT323 counter ticks up on the right of the header (started when
  the recorder actually starts, not on the beep), and the LCD's thirty
  `WaveformBars` run under the topic in the banner's own amber. All of it leaves
  with the recording state
- **Tap-to-finish lands in review, on the banner's own glass.** A VT323
  readout (`■ YOUR ANSWER · 0:00 / 0:23`, ticking while it plays), a PLAY IT
  BACK / STOP key, then KEEP IT beside RE-RECORD — both 44px and 8px
  apart, sized *and* separated because re-record throws the take away. Review
  only opens when a blob was actually kept; an empty capture drops back to
  idle. Doing anything else abandons an unkept answer, same as the home deck:
  playing a show or a responder row, switching tabs
- **The kept answer is a playable object, not a label on a key.** Keeping it
  puts `▶ YOUR ANSWER · 0:23` on the glass as a VT323 readout — the whole row
  is the play key, and while it plays the mark flips to `■`, the position ticks
  in front of the length (`■ YOUR ANSWER · 0:07 / 0:23`) and the row lights to
  the banner's brighter amber. It runs on the same `toggleRadioTakePlayback` as
  the review's PLAY IT BACK. Under it the record key steps back to the review's
  dark plastic as a plain `● RE-RECORD` — the answer is recorded, so going
  again is the subordinate act, the order an outgoing reply keeps on a friend's
  tape. The prompt above stays the loudest thing in the banner. Anything that
  takes the machine over stops the playback without touching the answer: a tab
  switch, a responder row, a show
- **A finished answer lives for the session.** `navTo` used to discard the take
  and reset the state on every entry to the radio tab, so a glance at HOME
  destroyed a recording you'd already kept. Nothing resets the tab on the way
  in now — `cancelRadioTake` still drops a take in flight or an unkept answer in
  review, which is the only thing that reset was doing that was wanted. A reload
  clears it, like every other bit of state here
- **What stops the answer is `radioReviewPlaying`, not the state.** Now that a
  finished answer follows you onto the other tabs, `audioRef` on a tab switch is
  as likely to be a home chain mid-message as it is your own voice — so the tab
  guard only pauses playback when the flag says the answer is what's playing.
  Pausing a chain's audio without ending the chain leaves it waiting on an
  `ended` that never comes, which looks exactly like the audio failing to load
- **A responder row goes through `stopAll` like the show key.** It used to pause
  `audioRef` by hand and leave `chainSessionIdRef` alone, so tapping a row with
  a home chain still running killed the sound and stalled the chain on the
  message it was on. Tapping a row is a new thing to play, so the machine stops
  first — a take in flight, an unkept answer and a live chain all go
- Tapping to finish during the BEEP stops the beep as well as the take. It's the
  one thing on the radio flow that isn't torn down by `stopAll`, because the take
  is cancelled rather than stopped: the mic hadn't opened yet
- Previous shows list — expandable rows, one show a week walking back from the
  banner's Sunday: "Something your mum always said?" (released Jul 26), then
  weeks of Jul 19, Jul 12, Jul 5. **No lamp on any of them.** They're finished
  shows, so a dot had nothing to say; the pulsing red one on the newest row read
  as a broadcast still going out. The row's own date is what dates it. That
  leaves `isLive` in `RADIO_SESSIONS` with no reader — it's still true of the
  data, but nothing draws it now
- **An opened show fills with the chassis's dusty rose**, cream copy on it, so
  the row you tapped reads as the heading of the responders underneath rather
  than a tint of the row it used to be. It was tried in `var(--walnut)` first and
  that put a second wood band directly under the `PREVIOUS SHOWS` bar, far
  heavier than a selected row wants to be — the rose is the deep end of the pink
  the machine is already moulded from rather than a third material, and it leaves
  the bar above it the only wood on the screen. The red left edge stays, deepened
  so it still separates against pink. The fill covers the tint the hover and the
  press used, so the opened state restates both as a brightness lift: it's still
  the key that closes the show, and a surface that answers a mouse and not a
  thumb is unfinished
- **The screen's copy runs two points over the rest of the machine**, top to
  bottom — the banner, the bar, the rows and the readouts — because it's read at
  arm's length rather than glanced at. The one thing that didn't move with it is
  the show key (see below); the one thing the extra two points broke was
  `RECORD YOUR ANSWER`, which wrapped to two lines at 22px until its tracking
  came back half a point
- Every responder is a friend from FRIENDS — same rule as message rows, no
  made-up names. No response carries audio right now: a responder row without a
  clip ignores taps, and "▶ PLAY SHOW" plays the REWIND intro then steps
  through the rows at 4.5s each, lighting each one as it goes
- Stopping mid-show cancels the chain cleanly

### ONBOARDING
The sign-up flow lives in `Onboarding.vue` and its four steps. It isn't a fourth
tab — **it replaces the machine in the frame**, at the same 402×874 on the same
`--fit` scale, so nothing moves or resizes as you go in and out. The intro's
photograph of the machine is the reason: you don't have one yet.

- **Tapping the `Julifone` wordmark starts it.** That's a demo shortcut, not a
  product route — there's no storage in this prototype on purpose, so a flow
  that ran at boot would run every time the page reloads, and it reloads all
  day. The mark is drawn exactly as it was; it presses like a key and its
  target is a 46px pseudo-element inside the 58px wood panel
- Starting it tears down anything the machine had open — playback, a take, the
  REC picker, the share popup, a friend's sheet, the customiser, the add-friend
  flow — the same way switching tabs mid-take does
- Four screens, and **the stepper counts three of them**: **intro** (a poster,
  and no stepper on it — it's what's in front of the flow rather than a step
  you're partway through, so it doesn't count itself), then **name your tape**
  (the label *is* the field, character count in the track slot), **customize
  your tape** (the customiser, below), **your tape is ready** (invite link on an
  LCD, decorative share tray). The stepper along the top is the navigation as
  well as the progress — there's only one of them
- **The customise step is the customiser page.** Not a copy of it and not a
  smaller version: `TapeCustomiser` is one component with two hosts, so the tape
  on its stage, the `DESIGN` / `FONT` / `STICKERS` tabs, the swatch grids
  stretched to the page's width and every sticker gesture are literally the same
  screen you get from `EDIT TAPE` later — a part added to either appears on both.
  Each host owns only its heading, its key at the foot, and the line in the
  tape's track slot (`NEW TAPE` here, empty in the app — `tapeSub` prints a
  number or nothing, since the invite code came off the label).
  The flow had grown its own chips, its own six-up tray and its own tip copy, and
  the two had started to drift
  - **the name is editable here too**, on the label, because the label is the
    field on both hosts. It writes back to the flow's own name rather than onto
    the draft tape — so the step before it agrees, and your invite link re-mints
    from the new name (`katherine-5550`)
- **The share step spreads, it doesn't centre.** The tape, the LCD and the tray
  share the leftover height between them — measured 62 / 82 / 82 / 62. The stage
  used to take all the slack, which centred the cassette in one 290px void, left
  another above the bar, and stranded the copy at the top of the frame
- **Finishing writes your identity.** `ME` is only a seed now: `me.name`,
  `me.invite` and `myTape` all come out of the flow, so MY TAPE, the SHARE LINK
  popup and the customiser's placeholder all
  read what you just made. It lands on home holding the machine's own tape,
  which is what "Enter Julifone" says it does
- **The back arrow on the intro is the way out.** It leaves everything exactly
  as it was — the flow only writes on "Enter Julifone"
- The paper frame carries the machine's own corner radius and drop-shadow
  stack. The prototype is paper on paper and needs no edge; here it sits on the
  dark mat, and the shared silhouette is what makes it read as the same object
  arriving rather than a different page
- Its CSS is a verbatim port with every class `ob-` prefixed — the flow's own
  names (`.lcd`, `.screen`, `.bar`, `.chip`) collide with the machine's and the
  two share one document. The exception is the customiser's `.tp-` block, which
  the customise step wears as-is; the paper palette is declared for both hosts
  and `--ob-paper` / `--ob-ink` and the rest are now aliases of it, so a colour
  changes in one place. The flow it was ported from
  (`prototype-onboarding.html`) is gone — this is where it's designed now

---

## Native interaction layer

Cross-cutting, and it applies to all three tabs. The machine *looks*
skeuomorphic and *behaves* native — those are separate decisions, and this
section is only the second one. None of it changes how anything is drawn.

The rules themselves, and the reasoning to follow when adding something new, are
in [CLAUDE.md](CLAUDE.md) under **Native interaction rules**. What's actually in
the file:

### Touch targets

**Every interactive control clears 44×44 on both axes.** Measured by hit-testing
the served page, not calculated — every state was walked, including the modals,
the customiser and the review panel:

| Control | Visible | Target | How |
|---|---|---|---|
| ✓ / ✕ on a message row | 24×24 | **44×44** | key grew 19→24, gap 10→28, `::after` |
| ✕ on an outgoing row | 16×16 | **44×44** | row `min-height: 44px`, `::after` |
| Message row | 344×47 | **344×47** | already passed |
| SHARE LINK / EDIT TAPE | 179×44 | **179×44** | `min-height: 44px` |
| A friend's tape | 165×103 | **165×103** | already passed |
| GO TO VOICE NOTES (sheet) | — | **336×44** | `min-height: 44px` |
| Bin on the sheet | 20×20 | **44×44** | `::after`, `inset: -12px` |
| Spine in the REC picker | 332×46 | **332×46** | already passed, 8px apart |
| YES / CANCEL (confirm) | — | **244×44** | `min-height: 44px` |
| CLOSE (share popup) | — | **272×44** | `min-height: 44px` |
| Nav key | 123×36 | **124×44** | `::before` fills the 44px slot |
| Transport keys | 60×48 | **60×48** | already passed |
| Segmented control tab | 120×26 | **120×46** | `::after`; container no longer clips |
| Name on the label (customiser) | 283×48 | **283×48** | the writing band itself, no expander |
| Shell chip (customiser) | 64×40 | **64×46** | `::after`, `inset: -3px 0`; the row gap is 16 |
| Label chip (customiser) | 62×44 | **176×51.5** | a 2-up grid column at the label's 3.42, 10px apart |
| Font chip (customiser) | 48–59×44 | **176×96** | a 2-up grid column, 10px apart |
| Sticker in the tray | 55×55 | **83×83** | `aspect-ratio: 1` in a 4-up grid |
| Save my tape (customiser bar) | 298×52 | **362×52** | the whole bar; nothing beside it |
| Back (customiser heading) | — | **44×44** | the key's own box; the row is pulled 11px left |
| Leave / Keep editing | — | **123.5×46 each** | 10px apart, over the 8px minimum |
| Invite name field | — | **272×45** | `min-height: 44px` |
| MAKE INVITE / DELETE / DONE | — | **272–296×44** | `min-height: 44px` |
| COPY LINK | — | **272×45** | `min-height: 44px` |
| Share tray buttons | 64×44 | **64×45** | height 42→44 |
| CANCEL | — | **272–296×44** | `min-height: 44px` |
| RE-RECORD / DELETE / SEND (review) | 103×33 | **103×44** | `::after` (see below) |
| Radio responder row | 344×44 | **366×44** | `min-height: 44px` |
| `▶ YOUR ANSWER` (radio banner) | — | **338×44** | `min-height: 44px`; the whole readout |
| `● RE-RECORD` (kept answer) | — | **338×44** | 8px under the readout, which it destroys |
| ▶ PLAY SHOW | 182×43 | **154×44** | `min-height: 44px`; held at 16px when the screen went up two |
| Session row | 344×45 | **366×53–71** | already passed; 71 is a two-line topic |
| `Julifone` wordmark | 99×27 | **99×46** | `::after`, `inset: -10px` |
| Onboarding stepper segment | 117×4 | **117×46** | `::after`, `inset: -18px 0 -24px` |
| Onboarding back / CTA | 52×52, 298×52 | **52×52**, **298×52** | already passed |
| Name on the tape label | 283×35 | **283×44** | `min-height: 44px` on `.tape-input`, both wraps |
| Onboarding COPY | 53×29 | **53×44** | `min-height: 44px` |
| Onboarding share target | 85×59 | **85×59** | already passed |

The customiser rows are measured once and true twice: the onboarding's customise
step renders the same component, so its chips, tray, tabs and name band are those
numbers rather than a second set below.

Most of those cost nothing visible — an invisible pseudo-element opens the hit
area out and the artwork is untouched. Six cases needed more:

- **The ✓ and ✕ are the one place the minimum cost visible artwork.** Two 44px
  targets need 88px of row plus a gap; 19px keys 10px apart spanned 48, so no
  expander could fit. The keys grew to 24px and their gap to 28px, which puts
  their centres 52px apart: **44px of target each with 8px of dead row between
  them**. The separation is deliberate — one of these deletes a message for real,
  and a mis-aimed tap lands on the row (which plays the message) rather than on
  the wrong key. This is the only visible change to the machine in this pass
- **A nav key's target fills its slot.** The key is 36px because the slot is held
  at 44px and spends 3px on floor and 2px on its seam. The target takes the whole
  44 — 5px up, 3px down — which is how a real tab bar behaves anyway: you hit the
  bar, not the glyph. It stops at the slot's edges, so the transport panel above
  keeps every pixel of its own row. It's a `::before`, because
  `.nav-tab.active::after` is already the lit seam
- **`.seg-tabs` lost its `overflow: hidden`.** It clipped the tabs' square corners
  to the container radius, but it also clipped their expanders. The end tabs round
  their own outer corners instead, so the control looks identical and the 26px-tall
  tabs get a 46px target. On the customiser page the 10px goes up into the 22px gap
  under the tape's stage and down into the control's own 22px bottom margin, both
  inert — and `.tp-scroll` scrolls but never clips them, which was measured rather
  than assumed
- **`SHARE LINK` and `EDIT TAPE` sit 3px apart, the nav's own gap.** Two keys in
  one slot are the nav's construction, and its keys are 3px apart too — the floor
  showing between them is what makes them separate objects, and widening it to 8
  would read as two controls that happened to land side by side. It's allowed
  because neither outcome destroys anything: both are a modal with CANCEL on it.
  The 8px rule stands wherever one of the two targets is a deletion
- **The three review buttons keep a 33px body and expand instead.** During review
  the LCD is at its full 154px, leaving this panel 75px for the take row *and*
  these buttons — at 44px each they'd take 60 of it and crush the row. They
  expand 8px up under the review row, which is inert by design, and 8px down into
  the panel's padding
- **The onboarding stepper's expander is asymmetric — 18px up, 24px down.**
  `.ob-phone` clips its overflow and the stepper sits 18px below the frame's top
  edge, so a symmetric `-20px` threw 2px away against that edge and measured 42.
  Everything it grows down into is the stepper's own 24px margin, which holds
  nothing tappable. Its segments stay 6px apart, the nav's reasoning: a mis-tap
  moves you a step and a step is free
- **The name on the tape carries its own 44.** An `<input>` can't hold a
  pseudo-element, and the label band it sits in is about 35px tall — so
  `.ob-tapewrap .tape-input` takes `min-height: 44px` instead. `.tape-title`
  centres its child, so the 9px opens evenly above and below and the lettering
  doesn't move. It's scoped to the flow: nothing else in the app draws an
  editable cassette

Two gotchas worth knowing before adding a control: `overflow: hidden` on any
ancestor silently eats a pseudo-element expander, and `inset` resolves against
the **padding** box, so a 1px border costs 2px on each axis (the row keys need
`inset: -11px`, not `-10px`, to land on 44).

The customiser is no longer a card to measure — it's a page at the full 874, and
what has to fit inside it is its own content: 18px of top padding, the heading and
its hint, the tape's 340×212 stage, the tabs, then 74px for the bar and 20px of
bottom padding. That leaves the options about **380px**, against the tallest tab's
~210px, so nothing scrolls in practice. The two popups on the TAPES tab still
measure comfortably inside the frame.

### Pointer, press and scroll

- **An outgoing row's keys sit at 0.85 opacity, not 0.65.** They're inside a row
  that's already at 0.7 and the two multiply — the old value left them at 0.46,
  legible only once a mouse found them. The row stays subordinate on its inset
  and its dimmer type instead
- **Every `:hover` rule is behind `@media (hover: hover)`, and every one has an
  `:active` twin outside it.** A touch browser fires hover on tap and leaves it
  on until you touch something else, so unguarded hover states stick. Rules were
  guarded at each site rather than overridden in one late block, so nothing
  fights the `.selected` / `.playing` / `.expanded` state classes
- **A tappable cassette gets `.tappable`** — a mouse lift on hover, a press
  travel outside the query, and that's both tapes on the TAPES tab covered by one
  class. Only the home carousel's cassettes stay unresponsive to a press, because
  they're dragged as often as tapped
- **New pressed states**, where hover was previously the only feedback: message
  rows and outgoing rows, radio session rows, the segmented control, the ghost
  cassette (which now changes background and border on press, not just on
  hover), and the nav keys — a held key travels the same way the active one
  does, cast shadow gone and label nudged into the well, staying darker overall
  than its neighbours per the pressed-key rule
- **The review row opts out of both state layers.** It's a `.message-row` but
  isn't tappable — PLAY and the buttons below it drive it — so pressing it
  acknowledges nothing
- **The carousel deliberately has no pressed state.** It's dragged as often as
  tapped and a press flash mid-swipe reads as a bug; the tape loading is the
  feedback
- **Scrollers are contained** — `overscroll-behavior-y: contain` on the message
  list and the radio list, `overscroll-behavior-x: contain` on the carousel and
  the invite field, `overscroll-behavior: none` on `html, body`. Reaching the end
  of a tape can't drag the device, and swiping past the last cassette can't
  trigger a page-back
- **No tap flash, no tap delay, pinch zoom kept.**
  `-webkit-tap-highlight-color: transparent` globally (the buttons have their own
  pressed states; the blue flash landed on top of them) and `touch-action:
  manipulation` on `body`, which drops the 300ms double-tap delay. Deliberately
  *not* `user-scalable=no` — a fixed 402px frame is where someone is most likely
  to want to zoom, so only the misfiring gesture goes
- **Safe-area insets are on `body`, never inside `.device`.** The viewport tag
  carries `viewport-fit=cover` and `body` pads by `env(safe-area-inset-*)`, so
  the machine sits clear of a notch or home indicator without losing any of its
  874px. `.device` measures 402×874 exactly as before
- **The frame scales to fit rather than reflowing.** `.device` stays 402×874 in
  layout terms and takes `transform: scale(var(--fit))`, where `--fit` is the
  largest scale that still fits the window, capped at 1. It's pure CSS —
  dividing a length by a length gives a unitless number, so there's no JS and no
  resize listener — and it's built from `svh`, so a mobile URL bar appearing
  doesn't rescale the machine mid-demo. A negative `margin-bottom` reclaims the
  layout space the un-scaled box would still reserve, which is what keeps the
  page from scrolling under a shrunk machine. The aspect ratio is exact at every
  size, because a uniform scale can't change it:

  | Viewport height | `--fit` | Rendered | Ratio error |
  |---|---|---|---|
  | 1000px | 1 | 402×874 | 0 |
  | 874px | 1 | 402×874 | 0 |
  | 720px | 0.8238 | 331.2×720 | 8e-6 % |
  | 640px | 0.7277 | 292.5×636 | 3e-6 % |

  The `--surround` variable is the dark mat around the machine — spare height up
  to 32px, and **exactly 0 at 874px**, so the surround never costs the machine
  any scale on a viewport that could show it at full size. `body`'s padding and
  `--fit-h` both divide by it so they can't drift.

  The cost: scaling shrinks the touch targets with everything else, so a 44px
  target is 36px at a 720px viewport. Targets are still designed against the
  frame's own 402×874 coordinates — the scale is a property of this demo surface,
  not of the native port

---

## Key implementation notes

- All audio is fetched as a blob and re-wrapped as `audio/mp4` before playback. It was written against Python's mimetypes table, which served `.m4a` as `audio/mp4a-latm` and made Chrome refuse the file; Nitro types it correctly, so the re-wrap is belt and braces now — and it's what lets a fixture and a just-recorded take play through one path
- Radio show chaining uses a `radioSessionIdRef` cancellation flag so stopping mid-show doesn't trigger the next clip
- **Home playback is one chain function, `playChain(list, waitingOnly)`.** Playing a friend's tape and playing everything waiting are the same code path with a different list — the machine's own card supplies the queue, a friend's card supplies their tape reversed into oldest-first order. `playOne` plays a single message and hands back to the chain when it ends, and `chainSessionIdRef` is the cancellation flag (same pattern as `radioSessionIdRef`): STOP nulls it and every step, callback and timer falls out instead of starting the next message. `playOne` reads rows off the `messages` ref rather than a closure, so a chain already in flight sees the current list
- The list is a snapshot, so each step reads its row back before playing it: gone, or (with `waitingOnly`, which the queue passes) no longer waiting, and the chain walks straight past. That's what makes ✓ and ✕ during a run mean something — clear the rest of the queue and the machine doesn't play what you just dismissed. **The beep belongs to the message about to play, not the one that just ended**: decide there's something to play, then `playBeep`, then play it. The other way round leaves a beep hanging with nothing after it when the rest of the queue has been cleared
- **A message is marked heard when it finishes, not when it starts** — cut one short and it's still waiting for you, and on the machine's own tape you watch the row drop off as it ends. `chainInfo` carries `{pos, total, name, cardId}` for the LCD header
- `spotlightId` is which card the carousel centres, lights and spins: normally the loaded tape, mid-chain the tape of whoever's message is playing. That's how the shelf travels while the deck keeps the queue on screen
- Home recording uses the same pattern (`recSessionIdRef`) so a session cancelled during the BEEP or while the permission prompt is up never opens the mic, and a superseded recorder's `onstop` can't publish over or review the current take. `stopAll()` tears down the recorder, releases the mic and revokes the object URL; `stopAll(true)` keeps the finished take (the SEND path). Switching tabs while recording *or* reviewing calls `stopAll()`
- The review state is just `inReview` + the existing `playState`, so the reels, waveform and PLAY button reuse the incoming-message visuals unchanged. Take length is measured with `performance.now()` while recording — Chrome reports `duration: Infinity` for its own WebM until it's been played through
- **`ME` is you, and it has the same shape as a friend** — name, `invite`, `tape` — so `tapeParts()`, `tapeSub()` and `Cassette` draw your cassette with no special case. It lives below `makeInvite` rather than beside `FRIENDS` because your invite URL is built from `INVITE_HOST`, and a module-scope const can't read one declared after it. Your link is fixed once you have one, not minted per person: it's your identity
- **`ME` is only the seed, though — `me` is the state.** Onboarding writes your name and your invite code, so every read is `me.name` / `me.invite` and never the const: MY TAPE, the SHARE LINK popup and the customiser's placeholder all follow what you made in the flow. `myTape` stays the separate state it already was, and the two places that fall back to your name (`openMyTapeEditor`, `saveTapeEdit`) read `me.name` when they run. Without running onboarding you're still Alice, straight off `ME`
- **`myTape` is the only tape the customiser writes.** It's separate state from `friends`, so the customiser has no id to carry around: `editingTape` is a boolean, `draftTape` is the same draft-then-SAVE pattern as before, and the settle animation keys off `settlingId === 'me'`. A friend's tape is drawn from their own record and nothing on this tab can write to it
- `FRIENDS` is the single source of truth for people, and every `MESSAGES` row gets a `friendId` stamped on by name via `withMeta()`. Only friends leave messages, and the radio follows the same rule now — every `RADIO_SESSIONS` responder is a friend's name (they're session data rather than messages, so they don't carry a `friendId`, but no name appears that doesn't have a tape)
- Each message row carries one `at` timestamp, and `withMeta()` derives its `time` label and its `month` band from it, then sorts the whole set newest-first. Nothing is hand-written twice, so a tape's order and the headings down it can't disagree. `monthBands` walks the already-sorted list and opens a new band whenever `month` changes; a sent reply is stamped with the same three fields so it files itself into the thread
- A home message row is either **incoming** (the default — has `unread` and a `transcript`, plays through `playOne`) or **outgoing** (`outgoing:true` — a sent reply, holds `blob` + object-URL `url` instead of an `audio` path, plays through `playOutgoing`). On an outgoing row `name` is *whose tape it's on*, not who's speaking, so the same `friendId` grouping puts both halves of a conversation on one tape. `incomingMsgs` is what every count and transport control reads; `visibleMsgs` is only for rendering
- SEND hands the object URL over to the message row and nulls `recordedUrlRef` **without revoking** — otherwise the next recording's `discardRecording()` would revoke a URL a sent reply is still playing from. Deleting an outgoing row revokes it at that point instead
- **A printed label is still just a `band`.** Six of the eight label designs are PNG stock from `public/assets/labels`, and they needed no image plumbing at all: `band` is spent straight into `background:`, which takes layers, so a printed design is a string in `LABEL_DESIGNS` like every other one and the customiser's chip wears it by the same line of code that wears a gradient. The `printed()` helper writes `url(…) 0 0/auto <scale>% repeat-x` — sized off the *paper* rather than the screen, so the tape and the chip show the pattern at the same scale (the chip is the same 3.42 rectangle). `scale` is per stock: the files were drawn dense, and at 100% a whole tile compressed into the band left gingham's checks and chalk's grid reading as noise, so the five patterns run 135–160% (the band shows the top slice of a taller tile, safe because every file is seamless) while kraft stays at 100 — it's a texture, and blowing a texture up just blurs it. Under the image is a flat gradient in the design's own tone, which is never seen while the file is there: it's what a not-yet-landed asset falls back to, so a missing PNG leaves plain coloured paper instead of a hole. **The ink comes from the filename, not from eyeballing the artwork**: Kat named each file with the writing it was drawn to take (`label1_blacktext`, `label3_whitetext`, `label5_whitetextblackstroke`), so the designs share two shorthands — `BLACK_TEXT` / `WHITE_TEXT`, pure `#000` / `#fff` with a weakened `sub` — instead of eight hand-tuned colours, and they shouldn't be retuned by eye. The floral stock is the one a colour can't solve: white sinks into its orange-and-cream pattern, so it carries `inkStroke:'#000000'` and `Cassette` sets `-webkit-text-stroke` with `paint-order: stroke fill` — a true outline under the fill rather than a stroke eating the glyph from both sides, its width riding the font size so the shelf and the deck get the same weight of line. Only `kraft` keeps `LB`'s ruled lines; it's a texture, and the five patterns would fight a drawn line. `INDEX` and `STRIPE` are gone; they were CSS approximations of printed stock
- **A cassette's looks are defined in `app/utils/tape-parts.js` and nowhere else.** `TAPE_SHELLS` (`stops` / `edge` / `translucent` / `opacity` / `internals`), `LABEL_DESIGNS` (`band` / `ink` / `sub` / `rule`), `TAPE_FONTS` (a style object plus each face's optical `scale`, `offsetY` and re-write animation) and `STICKERS`. The customiser builds its swatches from these maps plus the `*_ORDER` arrays, and every tape is drawn through `tapeParts()` — so swapping values or whole entries needs no component change. Keeping it to one definition is what stops the deck, the shelf, MY TAPE, the customiser's stage, a friend's sheet and the REC picker's spines drifting apart, which they did once when the app carried its own copy of the maps
- **The REC picker opens on mousedown, and that's why its backdrop needs no guard.** The transport keys fire on `onMouseDown`, so the sheet is already up when the press ends and the mouseup lands on the backdrop — but the `click` that follows is dispatched on the nearest common ancestor of the two targets, `.device`, which is an *ancestor* of the backdrop rather than a descendant. The backdrop is never on that event's path. Swallowing "the first click" to compensate is wrong twice over: it isn't needed, and it eats the user's real tap-off, so the sheet takes two taps to close
- **A take started from the picker centres the shelf instantly, not smoothly.** The centring effect now passes `behavior: deckBusy ? 'auto' : 'smooth'`, because recording locks the carousel and `.locked` is `overflow-x: hidden` — Chrome won't animate a smooth programmatic scroll on one, so the shelf would stay on the tape you pressed REC from while the deck recorded for someone else. Same trap as locking mid-chain, from the other direction
- **The name re-writes itself only where a font is being chosen.** `AnimatedName` takes an `animate` flag (`animateName` on `Cassette`) and it is **off by default**: the customiser page and the onboarding's customise step are the only two callers that turn it on, because the re-write is how you see what a face does to *your* name. Everywhere else a tape simply paints its name — a shelf of cassettes writing themselves in as the screen opens reads as a loading state, and the deck used to do it again on every swipe. Even where it's on it fires on **font change only**: `prevFont` is seeded with the face the label mounted with, so a first paint isn't a change, and a text edit never restarts the sweep (it would fire on every keystroke). The stamp punch on the mechanical faces is gated the same way — its class only lands once a swap has happened, since a CSS animation runs the moment the class appears
- **One tape renderer: `app/components/Cassette.vue`.** The deck, the shelf, your own tape, the customiser page's stage and a friend's bottom sheet are all the same component at a different `width` — the 1.604 ratio turns each width into a height, so `TAPE_W` is the whole of the app's tape sizing. There is no second silhouette and no `size` prop; a bigger tape is a bigger number
- **One customiser: `TapeCustomiser`.** Making a tape is a component, not a screen — the stage, the segmented tabs, the swatch grids and the sticker gestures — and the two places you make one (`EDIT TAPE` and the onboarding's third step) are hosts that supply a draft, a heading and a key. So a new part, tab or sticker gesture lands on both by construction. The host passes `set` as an *updater* (a sticker drag has to read the list it's moving) and `onNameChange` separately from it, because the app writes the name onto the draft tape while the flow keeps it in the step that typed it. Everything transient — the current tab, whether the label is a field, which sticker is selected or lifted — is the component's own state, which is why neither host resets anything on open: the mount is the reset. The one thing a host can reach in is **`active`**: the page passes `:active="!leaveTapeAsk"`, and the customiser settles when the back key's question goes up, so it isn't asked over a live name field or a sticker still lifted off the shell. The tab stays put — coming back to a different part of the tape than you left would be the surprise
- Two things wrap a cassette rather than redraw it. **`TapeSlot`** carries what the artwork has no opinion about — whether the tape can be tapped, the pressed state and the settle animation — so the module stays a pure renderer. **`TapeSpine`** is the exception: a spine is the same object turned ninety degrees and no width produces it, so its shape is local, but its plastic, label band, ink and face all come out of `tapeParts()`, down to each font's own `scale` and `offsetY`. A spine is the same tape as the one on the shelf
- **The machine's own tape is an ordinary tape object** (`MACHINE_TAPE`) — not a `tape={null}` special case, and now the most decorated tape on the shelf rather than the plain white blank it started as. Smoked-brown see-through plastic (`clear-brown`) over holographic glitter stock (`glitter`), five stickers on it, and **no typed label at all**: its title is the `unread` word sticker across the middle, so a name under it would only fight the glitter. The deck therefore passes it exactly the props every other card gets — the old `titleSize: 26` override is gone with the text it was shrinking. Its foot line is still `card.sub`, the waiting count, where a friend's card writes its message count
- **Three parts belong to that tape and to nothing else**, and all three are deliberately absent from the customiser: `clear-brown` and `glitter` are in `TAPE_SHELLS` / `LABEL_DESIGNS` but not in `SHELL_ORDER` / `LABEL_ORDER`, and the `unread` sticker is listed in its own `MACHINE_STICKER_FILES` rather than in `STICKER_FILES`, so it stays out of `STICKER_ORDER` (the tray) and leaves `STICKER_LOCKED`'s slice of twelve alone. The machine's tape isn't a tape anybody customised, so its materials aren't stock anybody is offered
- Home carousel: native `overflow-x` + `scroll-snap-type: x mandatory` gives iPad swipe, momentum and snapping for free. Pointer-drag is wired up **only** for non-touch pointers (`e.pointerType !== 'touch'`) so it adds mouse/trackpad dragging without fighting native touch scrolling; while dragging, snap is switched off and re-armed on release so it settles on the nearest card
- Selection follows the scroll: a debounced `onScroll` (140ms after the last scroll event, so momentum and snap have settled) picks the card nearest the centre. Since swipe, trackpad scroll and drag all end in a scroll, one handler covers every input. Two things stop that handler mistaking the machine's own scrolling for a swipe: **while a chain is running it doesn't pick at all** (a state check on `chainSessionIdRef` — the machine is driving the shelf, and STOP is how you take it back), and outside a chain our centring `scrollIntoView` opens a 900ms window (`centringUntilRef`) it ignores picks inside. The chain guard has to be state, not timing: a smooth scroll that runs slow — a heavy frame, a throttled tab — outlives any window, and then the scroll caused by playing a message stops the very chain that caused it
- **Don't lock the carousel during a chain.** `.locked` is `overflow-x: hidden`, and Chrome won't animate a *smooth* programmatic scroll on a hidden-overflow box (an instant one still works, which makes this easy to test wrong) — so locking mid-chain stops the shelf travelling at all. The lock is for recording and review only, where nothing needs to scroll
- While a take is recording or in review the carousel is locked and scroll-derived loads are refused, so the deck can't disagree with the flow. Tapping a card is still a deliberate act and still loads it, stopping whatever was playing
- **The row that's playing is scrolled into view** 240ms after it changes — after the LCD's 180ms resize, so the `ResizeObserver` below isn't fighting it. Without this, PLAY on a friend's tape starts at the oldest message, which is off the bottom of the list
- A recording never has to load anything: it's always for the tape already in the deck, and the carousel is locked for the whole take, so `beginRecordingSession` has no deck state to keep in step
- The collapse is two CSS heights, not a wrapper: `.waveform-container` goes 52px → 0 and `.transcript-area` 64px → 35px (50px when `recError` is up), both transitioning over 180ms, so the LCD keeps its own glass, scanlines and glow and is the same element at a different height. The classes come off one flag, `lcdOpen`
- **The message list holds its position while the LCD resizes.** Its bottom edge is pinned, so a height change of Δ moves its top edge by −Δ and every row would slide under the finger of whoever is mid-scroll. A `ResizeObserver` on `.messages-rows-scroll` takes Δ off `scrollTop` on each frame of the transition, which keeps the rows still and opens the reclaimed space at the bottom instead. It tracks the intended position in a closure rather than reading `scrollTop` back: at the bottom of a tape, growing the list shrinks the maximum and the browser has already pulled `scrollTop` up by part of the delta, so re-reading it double-counts and the tape creeps. A scroll listener adopts the live position again between transitions, which is how the user's own scrolling gets picked up
- Message selection and playback are keyed by **message id**, not list position — messages can be deleted, so an index into `MESSAGES` would drift and play the wrong audio. Which tape is loaded starts as the machine's own and only changes on a tap, a swipe or returning to the home tab, so it doesn't wander as unread counts change underneath it
- **The label faces are real fonts, and they come with the cassette.** Four are local `.otf` in `public/fonts/`, declared by `@font-face` in `app/assets/css/tape-parts.css`; Fontdiner Swanky is Google; and `typer` is ITC American Typewriter — which ships with macOS, so it falls back to Special Elite then a generic mono elsewhere. Swapping a face means editing `TAPE_FONTS` and, if it's a webfont, the one Google Fonts link in `nuxt.config.ts`. Re-derive that face's `scale` and `offsetY` while you're there: both are measured off the file, not guessed
- **The app is Nuxt 4 / Vue 3 with `ssr: false`** — `pnpm install`, then `pnpm dev`. The cassette's maps are ordinary ES exports from `app/utils/tape-parts.js`, and the two stylesheets are the `css` array in `nuxt.config.ts`, cassette first
- Vue is bundled, so the only thing still loaded from a CDN at runtime is Google Fonts: VT323, Figtree (400–700), Newsreader and Fontdiner Swanky

---

## The Louie hardware path

One friend is real: **Louie's tape is backed by the physical answering machine**
(the SA-310 retrofit — see `firmware/`). His `FRIENDS` entry carries
`hasHardware: true`, and `handleSend` treats that flag as "also hand the take to
the demo server": a fire-and-forget `POST /send` with the recorded blob, its
mime type, `X-Sender` and `X-Duration`, authed by `LOUIE_TOKEN`. The server
(runs on Hamish's NUC, public at `am.mynameishamish.website`, and NOT part of
this repo) transcodes to 16 kHz mono WAV and drops it in the inbox the ESP32
polls — machine blinks, PLAY plays it.

Everything is fail-soft: if the POST fails for any reason the take still lands
on Louie's local tape exactly as before, and the only difference is a
`SEND ERROR` prompt on the LCD (`REC_PROMPTS.sendFail`). Kill the flag or the
server and the app behaves as pure prototype again. `SERVER_BASE` is same-origin
when the page is served by the demo server itself and absolute only from the
`:3456` dev server; the committed token is demo-grade by decision (it gates one
demo inbox — see STATUS.md).

**So the demo runs on port 3456 and nowhere else.** `SERVER_BASE` is
`location.port === '3456' ? 'https://am.mynameishamish.website' : ''`
(`app/utils/data.js`), so on any other port the POST goes same-origin, 404s, and
the LCD shows `SEND ERROR` while the tape looks fine — the failure is quiet and
looks like the machine working. Slot 1 is 3456; keep the demo there. `pnpm
preview`, if you ever serve the built app, defaults to 3000 and needs
`--port 3456` too. `firmware/test_server.py` also defaults to 3456, so give it an
explicit port if it's running beside the app.

## Things not yet implemented

- Only the three newest messages have real audio (`public/Leeheng.m4a`, `Tony.m4a`, `celine.m4a`). Everything further back plays its transcript on a timer with no sound
- The message list is a ~350px window in the fixed 402×874 frame — about seven rows and their month bands, a two-line queue row being 47.5px and a one-line row on a friend's tape 44px (`min-height` on the row, not padding arithmetic) — and drops to 273px while the LCD is open, so a long tape still needs scrolling to read
- **The friends' shelf no longer scrolls at five friends.** Your tape takes 268px off the top of the tab, which leaves the grid a 457px scroller holding 345px of cassettes — three rows with room to spare, where the 676 frame clipped the third row and used the clip as its scroll cue. Past six friends it scrolls again, but there's now no cue until it does
- Fixture dates are anchored to late July 2026 — the newest rows land on Jul 29–30 and the history walks back from there, except the transcripts that name a date (new year, Christmas, April Fools), which keep their calendar. Recording a reply stamps it with the *real* clock, so it files in beside the newest fixtures
- The Radio answer records for real and can be listened back to in review, but goes nowhere once kept: no submission, no row on any show, and no way to replay it after KEEP IT
- SEND reaches no one **except Louie** (see "The Louie hardware path" above) — there's no backend for people. It closes the loop locally: the take becomes an outgoing message on that person's tape, held in the `messages` ref
- Deleting a message only removes it from the `messages` ref — it comes back on reload. Sent replies work the other way round: they're only in state, so they're gone on reload
- The live prompt ("Your hottest take right now") record button is separate from the list of previous shows
- `prefers-reduced-motion` isn't honoured anywhere. The reels, the shake, the tape spin and the 180ms LCD resize all run regardless
