# Handoff notes For whoever picks this up: what is solid, what is held together with care, and what a refactor should and should not do. Read ARCHITECTURE.md first. ## Where the seams are - The script is split into ES modules (ARCHITECTURE.md › File map) by moving lines, not rewriting them: state is still one `state` object (`core.js`) and functions still reach into each other, now through imports. `player.js` is the only file that touches `state.audio`, so it is the only file that needs the dev-branch-and-iPhone treatment (exceptions, deliberately left: the station sheet's volume slider sets `state.audio.volume`; the trail/tick/reaction code in `ui/player.js` reads `currentTime`/`duration` and seeks on a tick tap; `playTape` and `step` pause before `playJob`; brain.js pauses from the taste view). - Modules are strict mode; the old script was sloppy. Nothing relied on it (no undeclared assignments), but a new `x = 1` without a declaration now throws instead of making a global. - brain.js plugs into the page through `hooks` (`djPrompt`, `jobMade`, `stationOpened`, `reacted`); it no longer overrides page functions by assignment. It is a classic script loaded with `defer` after the module, and the names it uses are put on `window` by `app.js` (`state`, `hooks`, `$`, `db`, `playJob`, `drawCover`, `hash32`, `prng`, `keyHue`, `KEYS`, `reactionWeight`, `drawTaste`, `openTaste`, `reactionEmoji`, and `seq` as an accessor onto `core.ids.seq`, the job counter). Anything else loaded after the page should do the same, or go through `window.g`. - `venice()` goes through an injectable fetcher (`render.setFetcher`); the harness fragments stub it there. - Playback is one small state machine (`state.phase`: idle → cooking → holding → playing → ended) with named transitions; see ARCHITECTURE.md › The player's state machine. `state.holding` is a read-only view of the phase. - The crackle exists twice: as the side element (room tone) and as the player's own source (the hold). That is deliberate (iOS needs the hold in the same element) but easy to misread. - `db.all()` is still used where a filter is needed (a station's tracks, the prune in `remember`, the trail, shuffle, the library, the brain). Single rows go through `db.get`; tapes through `tapeById` (KEPT is virtual). An index on `spec.stationId` would remove most of the remaining `db.all()` calls. - Several things are computed from the sheet in more than one place (section-at-time, mmss, kind chips). ## Found while refactoring playback, and deliberately left alone - `playJob` on an errored render (`job.err`) calls `crackleOff()` and leaves the phase where it was; if the player was holding, the crackle keeps looping in the player until skip. Pre-existing; the machine reproduces it. - `advance()` with nothing to play (`!job`) calls `crackleOff()` and returns without `bed()`; the room tone comes back on the next state change. Pre-existing. - `#skip` during a hold pauses the crackle-in-player; the phase stays `holding` (paused) until the landing track takes over through `toPlaying`. That is how it behaved before; `bed()` stays off meanwhile, as before. - `setSource` now refuses to revoke the crackle element's object URL (the old `swapNow` could, on a rarely reachable branch). The only intended behaviour change; it can only prevent a dead crackle. - `toIdle` resolves a parked `queueBehindHold` promise when the station changes, so that `playJob` returns instead of leaking; before, it stayed pending forever. Harmless either way. - `prime()` still sets `state.audio.src` itself for the unlock clip and for the TAP TO PLAY start; it is the one source write outside the machine, by design. - brain.js's `drawPalette` and the library tiles call `playJob` directly with a resolved job; fine, but they bypass `advance()`, so the session's back/forward list does not learn about them (as before). - The `#play` button's `tapStarted` guard (ignore the click that `prime()` already used to start a resume) is timing based (800 ms). Untouched. - STORAGE.md listed four `db` calls and two stores; the code has `db.get` and a `tapes` store, so the doc was brought up to the code (the contract itself is unchanged). ## Known rough edges - iOS volume sliders are inert (platform); the welcome/ident may fall back to Venice if Runware times out on a spoken-tag render (now avoided: spoken breaks are TTS). - The mashup reference uses OfflineAudioContext; untested headlessly. - The quota is per-person-per-day only; KV free tier caps total writes at ~1,000/day. - Share links made before the render seed was included re-roll the song. - `KEEP_UNKEPT = 40` unkept tracks roll off; nothing warns. - Light theme: a few hard-coded colours may remain in rarely-seen sheets. - The harness's resume mode checks the TAP TO PLAY wait but cannot press the tap: headless Chrome has no real user activation. The tap half is a phone test. ## What a refactor should keep - Every invariant in ARCHITECTURE.md › Playback invariants, and the harness green (all three modes) before and after. - The sheet as the unit (everything derives from `spec`), covers as a pure function of it. - One static page, no build step, no framework, no keys in the page. - STORAGE.md's contract (chai's DB adapter targets it). ## Player rules (each one cost a regression on the phone; don't relearn them) - **Player changes go to `dev` first** (dev.gandora.lol), get tapped through on an iPhone, then merge to `main`. Anything touching `startNow / toHolding / toPlaying / bed / crackleOn / prime / setSource` counts. Features that don't touch the player (modes, DJ sheet, stats, covers) may go straight to main. - Run `sh tools/harness.sh safari` as well as the Chrome modes: WebKit is the iPhone's engine (play() policy, one audio session). (The Chrome loop mode used to hang on hb-5: that was `--dump-dom`; it now runs over the DevTools protocol like resume, in real time, so its lines from "track landed" on differ from the fragment's `expect` text — compare runs against each other, not against the text.) - iOS ignores `audio.volume`. A level is baked into the sample (`makeCrackle(seconds, gain)`), never set on the element. - Never set `state.playing = true` before `play()` resolves. The flag follows the promise (`playNow`); a refusal shows TAP TO PLAY. Setting it early gives a "playing" UI with no sound and a play button that needs two taps. - Only one `