# Working in this repo

Retro answering-machine prototype for a two-day hackathon. Demo is Thursday at
noon. Optimise for a convincing demo, not for production.

**Read [README.md](README.md) before changing behaviour** — it's the source of
truth for how the app actually works, and it's kept current. Update it as part
of the same commit when you change something it describes.

- [PRODUCT-BRIEF.md](PRODUCT-BRIEF.md) — what we're building and why, demo
  script, what's deliberately out of scope
- [STATUS.md](STATUS.md) — what's built, what's decided but not built, what's next

---

## Worktrees are the default

**Work in a git worktree, one per task** — not in the main checkout. Several
sessions run at once and each needs its own copy of the machine to serve, so
two versions can be compared side by side in a browser.

Take the lowest free slot (see the table in [README.md](README.md#worktrees-and-ports)) and name the
directory after it:

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

The suffix is the slot, the slot is the port: `-2` → 3457, `-3` → 3458, `-4` →
3459, and the unsuffixed main checkout → 3456. What else is per-worktree is
`node_modules` (copied in by `.worktreeinclude`, so `pnpm install` is usually a
no-op) and the ignored `.nuxt/` and `.output/` build directories.

The branch exists because a worktree can't check out `main` while the main
checkout has it — it isn't a feature branch and it doesn't get a pull request.
Commit to it as you'd commit to `main`: small, scoped, one feature per commit.

**When the task is finished, stop and say so** — report that it's done and ask
to be told to merge back to the main worktree. Don't merge unprompted; Kat
decides when a worktree lands, and merging while she's mid-review costs her the
side-by-side comparison the worktree existed for. On the word:

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

## Running the app

```bash
pnpm install
pnpm dev
```

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

`pnpm dev` serves the machine on **the current worktree's port** — no argument
needed, `scripts/dev.mjs` reads the slot off the directory name and prints the
URL it chose. `getUserMedia` needs a secure context and `localhost` counts as
one, so recording works here.

If the port is already taken it refuses to start rather than sliding to another
one. That's deliberate, and the check is **ours** rather than Nuxt's: `nuxt dev`
will quietly bind `127.0.0.1:3456` beside another process holding `*:3456` and
then the browser gets whichever of the two it resolves first — another
worktree's machine, looking exactly like your change didn't work. It also falls
back to a *random* port when it can't bind at all. Fix the collision; don't pass
a different port to route around it.

One server per worktree. Stop it when you're done rather than leaving slots
occupied, and don't stop another session's — check the directory in the server's
startup line before killing anything.

Changes appear **live**, over Vite's HMR. Nothing here needs a hard refresh, so
if an edit hasn't shown up, read the dev server's output for an error rather
than reloading at it.

---

## How to work here

**One feature, one commit.** Small scoped changes that Patrick can read and port
individually. He's diffing these commits to bring design changes into the
functional codebase, so a commit that does three things costs him time.

**Scope every change explicitly.** Say which screen you're touching and which
ones to leave alone. Unscoped work drifts into adjacent tabs and creates
conflicts with Kat.

**Don't test unless the change genuinely needs it.** Alice verifies in her own
session — she has promised to, and she'd rather have the change in front of her
than wait while it's checked. Testing is the slowest part of prototyping here,
so the default is to write the change, commit it, and say what to look at.

Spend a minute on a check only when a mistake would be invisible to the eye
that reviews it: a chained flow, a cancellation path, a measured hit target.
Then it's one look, not a pass over the app — don't start a server to confirm
a colour, don't re-run a flow that already worked, and don't re-read a file you
just edited. Say plainly what was and wasn't verified rather than implying
more than was done.

**Commit messages** are lowercase, scoped, and describe the user-visible change:
`home: sequential playback with beeps between messages`.

---

## Conventions that matter

- **The cassette is Kat's, and there is still exactly one place it exists.**
  `app/utils/tape-parts.js` holds `TAPE_SHELLS`, `LABEL_DESIGNS`, `TAPE_FONTS`,
  `STICKERS` and the geometry; `app/assets/css/tape-parts.css` holds the
  artwork's styles; `app/components/Cassette.vue` — with `ShellArt`,
  `HubAssembly`, `AnimatedName` and `ShellSwatch` beside it — is the one
  renderer. Every screen reads those, which is the point of them being files, so
  edit them there and never fork a copy into a component. Swap values freely;
  don't restructure the maps or move colour definitions out into a template.
- **Every cassette in the app is `Cassette`, sized by `width`.** One renderer,
  one 1.604 ratio: the deck, the shelf, your own tape, the customiser preview and
  a friend's sheet differ by a number in `TAPE_W` and nothing else. Don't add a
  second silhouette or a wrapper that redraws artwork — if a tape needs to behave
  differently, that goes on `TapeSlot` around it. `TapeSpine` is the one
  exception and it exists only because a spine is the object turned ninety
  degrees, which no width can produce; it still takes every colour from
  `tapeParts()`.
- **A tape's width is chosen in JS, not CSS.** `Cassette` writes `width` inline,
  so a stylesheet can't reach it — `TAPE_W` is where a size changes. The deck's
  is constrained by *height*: the carousel's 176px minus its padding leaves 156,
  and 156 × 1.604 is 250. Widening that card pushes the message list off the
  machine.
- **The bottom sheet is the machine's plastic, not a system sheet.** A friend's
  tape opens `.tape-sheet` — the modal card's own body, rounded to the chassis
  radius at the top and clipped by the device at the bottom, with an inert moulded
  rail. Tapping off it is the only way out: no close key, no CANCEL row. Keep new
  sheets to that recipe rather than reaching for a native-looking one, and don't
  make the rail draggable — the gesture belongs to the carousel.
- **You customise your own tape and nobody else's.** A cassette is that person's
  avatar: `myTape` (seeded from `ME`) is the only tape the customiser opens, and a
  friend's tape is drawn read-only from their own record — they made it. Don't put
  an EDIT TAPE key on a friend's cassette; if a tape needs to change, it changes for
  its owner.
- **`FRIENDS` is the single source of truth for people.** Message rows carry a
  `friendId`, stamped on by matching their name. Only friends leave messages —
  there's no unknown-caller path, and no row can name someone without a tape.
- **One `at` timestamp per message row.** `withMeta()` derives the row's `time`
  label and its `month` band from it, then sorts newest-first. Don't hand-write a
  display date alongside the timestamp it came from — that's how a tape's order
  and the month headings down it drift apart.
- **The first carousel card is the machine's own tape**, `ALL_CARD_ID` — what's
  still waiting across everyone, oldest first, loaded at boot and whenever home
  opens. It's a queue, not a history: a message leaves it when it's been heard,
  and the friend's tape is where it keeps living. Don't give it month bands and
  don't let it hold a message with no tape to swap to.
- **One chain function.** `playChain(list, waitingOnly)` is the only route into
  home playback — a friend's tape and everything-waiting are the same code path
  with a different list, and both get the beeps. Add a new way to play by building
  a list and handing it over, not by writing a second walker.
- **Don't lock the carousel while a chain is playing.** `.locked` is
  `overflow-x:hidden` and Chrome won't animate a smooth programmatic scroll on
  one, so the shelf stops travelling — and an instant scroll still works, so it
  tests fine and fails in the demo. What keeps the settle handler from reading the
  machine's own scrolling as a swipe is the `chainSessionIdRef` check in it. Keep
  that a state check; a timing window loses to a slow scroll.
- **REC records for whoever's cassette is in the deck.** No picker on a friend's
  tape, no "who for?" step; loading a tape is how you choose. The exception is the
  machine's own tape, which is everyone's messages and so has no one recipient to
  infer: there REC raises the sheet of spines and the tape you pick is loaded *and*
  recorded to on that tap. An empty collection is the only thing left it refuses,
  via `REC_PROMPTS` and the same amber LCD treatment as the mic error. New refusals
  go in that map, not hardcoded into the LCD.
- **A programmatic carousel scroll must be instant whenever the deck is busy.**
  `.locked` is `overflow-x:hidden` and Chrome won't animate a smooth scroll on one
  — the mirror of the don't-lock-mid-chain rule below. Starting a take from the REC
  picker has to move the shelf, so the centring effect switches to `behavior:'auto'`
  on `deckBusy`. Don't "fix" a shelf that won't travel by unlocking it.
- **Outgoing replies stay subordinate.** Inset, dimmer, labelled `YOU`, no
  unread LED, excluded from counts and from the PLAY chain. A real answering
  machine never plays your own voice back at you.
- **Cancellation flags for anything chained.** `chainSessionIdRef`,
  `radioSessionIdRef` and `recSessionIdRef` exist so STOP halts cleanly
  mid-sequence. Reuse the pattern rather than inventing a new one.
- **Keyed by message id, never list position.** Messages get deleted; indices
  drift and play the wrong audio.
- **The transport and the nav are different materials.** Two rows of controls sit
  stacked at the foot of the device: the transport is a raised light-plastic panel
  inset from the sides, the nav is a dark slot cut full-bleed into the chassis
  with its seam facing up. Keep them distinct — flatten either one and they read
  as a single control strip.
- **Titles are Newsreader, ink is Figtree, light is VT323.** A modal's or a
  sheet's title uses `var(--head-font)` at regular weight and 0 tracking.
  Everything else printed on the machine uses `var(--ui-font)`. Anything behind
  glass stays VT323 — the LCD and its sub-lines, the invite readout, the ON AIR
  panel — and so does a row's running time, which is a readout and not copy. The
  cassette labels are artwork and keep the tape's own fonts. Don't hardcode a
  family for new copy and don't put either ink face on anything that glows.
- **Tracked caps is for what's stamped on the machine**, sentence case for what's
  set in a screen. Keys, the silkscreen, and the header bars and month bands cut
  into the chassis are bold caps with wide letter-spacing; titles, labels and hints are
  sentence case at ~0% — `Name on tape`, not `NAME ON TAPE`. The bar above a list
  is furniture, not a heading: it was tried in Newsreader and read as a magazine
  standfirst over the messages under it.
- **A header bar names its list and stops.** No count, no hint, nothing
  right-aligned in `.messages-header` or its RADIO twin. Counts belong on the
  LCD, which is the machine's own display and one glance up; a right-aligned
  scrap in a walnut bar reads as a second column that never fills.

---

## Native interaction rules

This is a fixed frame pretending to be a physical object, and it gets held in
hands — Patrick ports it to iOS and Android, and the iPad is how anyone sees it
at scale. **The look is skeuomorphic; the behaviour is native.** Those aren't in
tension and the guidelines below are about the second one only. Nothing here
licenses restyling the machine — no flat surfaces, no tonal palette, no system
type. Wood, plastic, metal and glass stay exactly as they are.

Follow these on anything new. They're cheap up front and invisible when they
work, which is why they get skipped.

- **Every target a finger uses is 44×44 at minimum — both dimensions**, measured
  in the frame's own 402×874 coordinates. That's the HIG number and 44 on one
  axis doesn't satisfy it. Note the frame is scaled to fit the window, so on a
  viewport shorter than 874px a 44px target lands as fewer than 44 physical px —
  0.82× at 720px, and so on. Keep designing against 44: the scale is a property
  of the demo surface, and Patrick's native port renders at real device size.
  What this does mean is that **a target that's only just 44 has no margin left**
  — the ✓/✕ pair is the one to watch. Reach it with an
  invisible `::after` (or `::before`) that opens the hit area out without
  touching the artwork wherever you can: the key keeps its size and the machine
  looks unchanged. `.seg-tab-btn`, the nav keys and the review buttons are the
  worked examples. Where an ancestor clips its overflow — `.my-tape-panel` does —
  an expander is thrown away against that edge, so the control carries the 44
  itself instead: that's why the SHARE LINK / EDIT TAPE keys are 44px tall rather
  than the nav's 36 plus a pseudo-element.
- **When the geometry won't allow it, move the controls — don't settle for a
  smaller target.** Two 44px targets need 88px plus a gap between them. ✓ and ✕
  at 19px and 10px apart spanned 48, so no expander could fit: the keys grew to
  24px and their gap to 28. That is the *only* place on the machine where the
  minimum cost visible artwork, and it's the option of last resort.
- **Targets keep at least 8px of dead space between them and never overlap** —
  unless neither outcome destroys anything. The keys under your own tape are the
  one pair that sits closer — 3px apart, because that's the nav's gap and they're
  the nav's construction, and the floor showing between two keys in a slot is what
  makes them separate objects. Both are a modal with CANCEL on it, so a near-miss
  costs a tap. Anything destructive keeps the 8px: one of ✓ / ✕
  deletes a message for real, and hitting 44px by letting those two run into each
  other swaps a small target for an ambiguous one, which is worse — so that pair is
  sized *and* separated, not one or the other. Where the neighbour is genuinely
  inert, take the space freely.
- **A tappable row carries the minimum itself.** Outgoing message rows and radio
  responder rows both had to grow, because a 16px key can't claim 44px of height
  inside a 38px row without stealing taps from the rows either side. Give the row
  `min-height: 44px` rather than deriving it from padding — font metrics will
  leave you a pixel short.
- **Measure the target; don't compute it.** Two things quietly eat an expander:
  `overflow: hidden` on an ancestor clips it (that's why `.seg-tabs` no longer
  has it), and `inset` resolves against the *padding* box, so a 1px border costs
  2px on each axis. Every number in the README's table was read off the served
  page, not worked out on paper.
- **Hover is a mouse affordance and nothing else.** Every `:hover` rule lives
  behind `@media (hover: hover)`. A touch browser fires hover on tap and leaves
  it on until you touch something else, so an unguarded rule sticks — a row
  still lit after it played, a ✕ still filled red, a ghost cassette still
  lifted.
- **So every hover state needs a pressed twin outside that query.** `:active` is
  what a finger actually gets. If a surface responds to a mouse and not to a
  thumb, it's unfinished.
- **A control mustn't need hover to look like a control.** Don't hide or
  half-hide an affordance and reveal it on hover; there's no hover to reveal it
  with. Dim it if it's subordinate, but keep it legible at rest — and remember
  nested opacity multiplies.
- **Scroll surfaces don't get pressed states.** The carousel is dragged as often
  as it's tapped, and a press flash that fires mid-swipe reads as a bug. Its
  response is the tape loading, which is feedback enough.
- **Contain every scroller.** `overscroll-behavior` on each one — the device
  sliding under a swipe, or a horizontal swipe at the end of the shelf
  triggering a page-back, is the loudest "this is a web page" tell there is.
- **Kill the tap flash and the tap delay, keep pinch zoom.** Global
  `-webkit-tap-highlight-color: transparent` (the buttons have their own pressed
  states and the blue flash lands 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 exactly where someone might
  need to zoom, so we remove the gesture that misfires and leave the one that
  helps.
- **Safe-area insets go on `body`, never inside `.device`.** The 874px is
  entirely spoken for (see the height budget under Gotchas). Pad the page so the
  machine sits clear of a notch or home indicator; take nothing off the machine.
- **The frame fits the window by scaling, not by reflowing.** `--fit` on
  `.device` is the largest scale that still fits, capped at 1, computed in pure
  CSS by dividing a length by a length — no JS, no resize listener. It's built
  from `svh` so a mobile URL bar showing or hiding can't rescale the machine
  mid-demo. Anything you add to the page around the machine has to come out of
  `--fit-h` too, or the machine will be scaled to fit a height it no longer has.

## Deliberate non-goals

Don't add these unprompted:

- **No separate archive or history screen.** A friend's cassette holds their whole
  history — newest at the top, older below in month bands. The tape *is* the
  archive; don't reintroduce a second place to read the same messages. The
  machine's own tape isn't that second place: it holds nothing once you've heard
  it, so there's only ever one place to go back and re-read a message.
- **No storage or persistence.** State resets on reload, on purpose. Patrick
  owns the backend; the prototype stays local so the two don't tangle.
- **No dependencies beyond Nuxt.** `package.json` is nuxt / vue / vue-router and
  nothing else, and the prototype is better off that way: no UI kit, no CSS
  framework, no state library, no drag-and-drop package, no icon set. The
  machine's materials are hand-written CSS and its state is refs in one
  composable — reach for a package and the day goes on fighting its opinions
  about what a control should look like.
- **No native OS UI.** No `navigator.share`, no system dialogs — a native sheet
  breaks the aesthetic mid-demo. Build retro equivalents. This is about borrowed
  *chrome*, not borrowed *behaviour*: it doesn't override the native interaction
  rules above, which are how the machine earns the illusion rather than breaking
  it.
- **No Material or Human Interface visual language.** We follow platform
  guidance on how things behave under a finger and ignore it entirely on how
  they look. No elevation levels, tonal surfaces, ripples, system fonts or
  standard components — the machine's own materials do that job.

## Gotchas

- **`.device` is 402×874 — iPhone 17 logical points — and its vertical space is
  entirely spoken for**: 58px wood panel, 762px screen body, 44px nav,
  10px foot. Those numbers never change: the frame is *scaled* to fit a smaller
  window (`--fit`, see the DEVICE SHELL comment) rather than reflowed, so write
  layout against 874px and let the transform deal with the viewport. Don't
  "improve" it into a fluid frame — the fixed px are the point. Inside the body the 176px carousel and the 117px transport panel are
  fixed, the LCD sizes itself to its state, and the message list flexes into
  whatever is left. The LCD is **73px idle, 88px with an amber prompt up, 154px
  playing / recording / in review**, which leaves the list **354px, 339px or
  273px** of scroll. A row is 44–47.5px, so that's about seven rows down to five.
  Anything you add at the top or bottom comes out of the list, so measure before
  adding furniture.
- **The walnut is three SVGs in one variable, `--walnut`, and the frame's width is
  written inside it six times.** Each data URI carries `width='402'` twice — on the
  `<svg>` and on its `<rect>`. They have no `background-size`, so they paint at
  their intrinsic width and *repeat*: leave them at a smaller number than
  `.device` and the grain restarts partway across with a visible vertical seam.
  Change the frame width, change all of them. Both the wood panel and the list's
  header bar draw from the variable, so don't paste the layers into a third place
  — add a surface to the variable's users and restate only the sheen over it.
- **Audio is fetched as a blob and re-wrapped as `audio/mp4` before playback.
  Keep that path.** 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 — but it costs nothing and
  it's what lets a fixture and a just-recorded take play through one code path.
- Chrome reports `duration: Infinity` for its own WebM recordings until played
  through — take length is measured with `performance.now()` instead.
- Mic access needs a secure context, so recording won't work over
  `http://<lan-ip>:3456` on the iPad — and the dev server only answers on the LAN
  at all if you add `--host`. Test recording on the laptop; use the iPad for
  layout only.
