Do not merge yet — a human taps through this branch on an iPhone first. (Branch note: the brief said dev, but PR #2 main→dev is stuck in Gitea's "mergeable: false / try again later" state, so split was branched from main instead — same content as a synced dev, main untouched. Point dev.gandora.lol at it, or fast-forward dev to it.)
Behaviour unchanged; every line moved, not rewritten (checked mechanically: every non-import line of every module occurs verbatim in main's index.html except the edits listed under "mechanical changes"). Plain <script type="module">, static files, view-source works, CSP unchanged, no dependencies.
the state machine and everything touching state.audio: setSource, toCooking…toIdle, playJob, advance, step, prime, crackle/bed, SILENCE, scrubbing, Media Session, analyser, transport buttons — moved as-is (the only edits in it: two ++seq → ++ids.seq)
render.js
venice() behind an injectable fetcher (setFetcher), renderTrack, makeNoise/noiseSpec, makeTone/toneSpec, wavBlob
tooling (tools/split.js line map + split-post.py, check-pure.mjs, harness fragments as inline modules over window.g, harness.sh loop mode over http/CDP, a split.yml workflow that performs a step on the runner — delete tools/split*, .gitea/workflows/split.yml after merge if you like)
Steps 1–3 leave the remainder as an inline module in index.html, so each commit is a working page. Step 4 is player + ui + app together, not player alone as the brief asked: player ↔ ui/player ↔ app are one import cycle, and the first commit that extracts any of them must extract all of them. player.js itself is a pure cut, so the review of "did the state machine change" is diff of its lines against main's index.html (none).
Mechanical changes (everything that is not a pure move)
venice → veniceFetch + let fetcher, export const setFetcher, export const venice = (...a) => fetcher(...a) (render.js). Module bindings can't be reassigned from outside; the harness stubs through setFetcher.
let seq → export const ids = { seq: 0 } (core.js) and ++seq → ++ids.seq in 7 places (an imported let can't be incremented). brain.js keeps its ++seq through a window.seq accessor onto ids.seq: one counter.
makeTone/makeNoise exported (for the node check).
brain.js loads with defer so it runs after the module (hooks are assigned a few ms later than before, still before any interaction).
Modules are strict mode; scanned main's script for undeclared assignments: none.
sw.js FILES lists the modules (one cache name keyed by REV, addAll is atomic, old caches dropped on activate — a half-updated mix can't happen; REV is stamped by shots.yml on push to main as before).
window globals exposed (app.js), and who uses them
state, hooks, $, db, playJob, drawCover, hash32, prng, keyHue, KEYS, reactionWeight, drawTaste, openTaste, reactionEmoji and the seq accessor — exactly the names brain.js uses (grep'd); brain.js unchanged.
Left unsplit / not changed, and why
Residual state.audio touches outside player.js, left because moving them is a behaviour change or a rewrite: the station sheet's volume slider (state.audio.volume), ui/player.js reading currentTime/duration for ticks and reactions and seeking on a tick tap, playTape/step/the library tile pausing before playJob, brain.js pausing from the taste view. Listed in HANDOFF.
covers.js is "pure" in the sense of no state and no $: it still uses document.createElement("canvas") for its offscreen caches and getComputedStyle for the ON AIR colours, as before. tools/check-pure.mjs runs it in node under a 10-line DOM stub.
prof temp dir in harness.sh is now unused (harness-cdp makes its own); harmless.
Harness, before (main @ 0786964) and after (split @ 4aa94d8), on hb-5
All three modes byte-identical before/after, after every step (1, 2, 3, 4, tip). Baseline run twice: stable.
Note on the Chrome loop mode: --dump-dom --virtual-time-budget hangs on hb-5 (known, HANDOFF), so the baseline loop was taken through the same http+CDP path as resume. In real time its lines from "track landed" on differ from the fragment's expect text (a 2 s test wav really ends); that is main's behaviour under real time, reproduced exactly after the split. Safari matches every expect line.
# loop (Chrome, CDP, autoplay allowed) — identical before/after
after first expect phase=playing cur=T1 playing, next=T2 nextblob=true
phase=playing src=http://12 paused=false playing=true holding=false cur=T1 next=T2 nextblob=true crackle=false loop=false
after ended expect cur=T2 playing, holding=false
phase=playing src=http://12 paused=false playing=true holding=false cur=T2 next=T3 nextblob=true crackle=false loop=false
slow cook started expect next cooking, nextblob=false
phase=playing src=http://12 paused=false playing=true holding=false cur=T2 next=T4 nextblob=false crackle=false loop=false
ended, nothing ready expect phase=holding holding=true loop=true
phase=holding src=http://12 paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=true
track landed expect holding=true loop=false (queued behind the crackle)
phase=playing src=http://12 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false
crackle pass ended expect phase=playing cur=that track playing, holding=false
phase=holding src=http://12 paused=false playing=true holding=true cur=T5 next=T6 nextblob=false crackle=false loop=true
after pause expect paused=true crackle=true
phase=holding src=http://12 paused=true playing=false holding=true cur=T5 next=T6 nextblob=false crackle=false loop=true
after resume expect paused=false crackle=false
phase=holding src=http://12 paused=false playing=true holding=true cur=T5 next=T6 nextblob=false crackle=false loop=true
after skip expect a new cur
phase=playing src=http://12 paused=false playing=true holding=false cur=T6 next=T7 nextblob=false crackle=false loop=false
END
# resume (Chrome, CDP, autoplay blocked) — identical before/after
before resume expect phase=idle cur=null paused=true needTap=false
phase=idle needTap=false tap=false veil="COOKING" paused=true playing=false holding=false cur=null curblob=false next=null nextblob=false crackle=false
resume, no gesture expect phase=playing needTap=true tap=true veil="TAP TO PLAY" paused=true cur=R1 curblob=true next=T1 nextblob=true
phase=playing needTap=true tap=true veil="TAP TO PLAY" paused=true playing=false holding=false cur=R1 curblob=true next=T1 nextblob=true crackle=false
(the tap that starts it needs a real user activation; not asserted here)
END
# safari (WebKit, safaridriver, real click) — identical before/after
after first expect phase=playing cur=T1 playing, next=T2 nextblob=true
phase=playing src=http://12 paused=false playing=true holding=false cur=T1 next=T2 nextblob=true crackle=false loop=false
after ended expect cur=T2 playing, holding=false
phase=playing src=http://12 paused=false playing=true holding=false cur=T2 next=T3 nextblob=true crackle=false loop=false
slow cook started expect next cooking, nextblob=false
phase=playing src=http://12 paused=false playing=true holding=false cur=T2 next=T4 nextblob=false crackle=false loop=false
ended, nothing ready expect phase=holding holding=true loop=true
phase=holding src=http://12 paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=true
track landed expect holding=true loop=false (queued behind the crackle)
phase=holding src=http://12 paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=false
crackle pass ended expect phase=playing cur=that track playing, holding=false
phase=playing src=http://12 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false
after pause expect paused=true crackle=true
phase=playing src=http://12 paused=true playing=false holding=false cur=T4 next=T5 nextblob=false crackle=true loop=false
after resume expect paused=false crackle=false
phase=playing src=http://12 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false
after skip expect a new cur
phase=cooking src=http://12 paused=true playing=false holding=false cur=T5 next=T6 nextblob=true crackle=true loop=false
END
Also: node --check on every module at every step; node tools/check-pure.mjs (covers: every look + ON AIR; render: wav/noise/tone) green; page load over CDP with brain.js: no uncaught exceptions, hooks assigned, window.g present.
**Do not merge yet** — a human taps through this branch on an iPhone first. (Branch note: the brief said `dev`, but PR #2 main→dev is stuck in Gitea's "mergeable: false / try again later" state, so `split` was branched from `main` instead — same content as a synced dev, main untouched. Point dev.gandora.lol at it, or fast-forward dev to it.)
Behaviour unchanged; every line moved, not rewritten (checked mechanically: every non-import line of every module occurs verbatim in main's index.html except the edits listed under "mechanical changes"). Plain `<script type="module">`, static files, view-source works, CSP unchanged, no dependencies.
## File map
| File | Owns |
|---|---|
| `index.html` | markup + CSS, `<script type="module" src="app.js">`, `<script src="brain.js" defer>` |
| `core.js` | `state` (incl. the one `<audio>`), `hooks`, `$`, `LS`, `toast`, `sleep`, `asDataUrl`, `mmss`, the job counter `ids` |
| `covers.js` | pure: `hash32`, `prng`, `keyHue`/`KEYS`, palettes, `drawCover` + the looks, `drawMark`, `drawOnAir` |
| `player.js` | the state machine and everything touching `state.audio`: `setSource`, `toCooking`…`toIdle`, `playJob`, `advance`, `step`, `prime`, crackle/bed, `SILENCE`, scrubbing, Media Session, analyser, transport buttons — **moved as-is** (the only edits in it: two `++seq` → `++ids.seq`) |
| `render.js` | `venice()` behind an injectable fetcher (`setFetcher`), `renderTrack`, `makeNoise`/`noiseSpec`, `makeTone`/`toneSpec`, `wavBlob` |
| `dj.js` | `djPrompt`/`askDJ`, `askBreak`, `askMash`, schema, modes, breaks, voices, personas, `speak` |
| `store.js` | `db` (STORAGE.md contract unchanged), `saveStation`, `remember`, `logEvent`, tapes incl. virtual KEPT, `tapeSpec` |
| `mashup.js` | `mashReference`, `mashup` |
| `ui/player.js`, `ui/library.js`, `ui/sheet.js`, `ui/taste.js` | cover/title/lyrics/reactions/emoji; stations row + library + now-playing + tapes; station sheet; taste view |
| `app.js` | boot: `openStation`, `makeJob`/`cookNext`, links/share, SW + update pill, theme, key box; sets `window.g` and brain's globals |
| `brain.js`, `webamp.html`, `stats.html`, `manifest.json`, icons | untouched |
## Commits
1. tooling (`tools/split.js` line map + `split-post.py`, `check-pure.mjs`, harness fragments as inline modules over `window.g`, `harness.sh` loop mode over http/CDP, a `split.yml` workflow that performs a step on the runner — delete `tools/split*`, `.gitea/workflows/split.yml` after merge if you like)
2. `split 1/4` covers.js + core.js · 3. `split 2/4` store.js · 4. `split 3/4` dj.js + render.js (+ a one-line fix commit) · 5. `split 4/4` player.js + ui/* + app.js + mashup.js · 6. docs.
Steps 1–3 leave the remainder as an inline module in index.html, so each commit is a working page. **Step 4 is player + ui + app together**, not player alone as the brief asked: `player` ↔ `ui/player` ↔ `app` are one import cycle, and the first commit that extracts any of them must extract all of them. player.js itself is a pure cut, so the review of "did the state machine change" is `diff` of its lines against main's index.html (none).
## Mechanical changes (everything that is not a pure move)
- `venice` → `veniceFetch` + `let fetcher`, `export const setFetcher`, `export const venice = (...a) => fetcher(...a)` (render.js). Module bindings can't be reassigned from outside; the harness stubs through `setFetcher`.
- `let seq` → `export const ids = { seq: 0 }` (core.js) and `++seq` → `++ids.seq` in 7 places (an imported `let` can't be incremented). brain.js keeps its `++seq` through a `window.seq` accessor onto `ids.seq`: one counter.
- `makeTone`/`makeNoise` exported (for the node check).
- `brain.js` loads with `defer` so it runs after the module (hooks are assigned a few ms later than before, still before any interaction).
- Modules are strict mode; scanned main's script for undeclared assignments: none.
- `sw.js` FILES lists the modules (one cache name keyed by REV, `addAll` is atomic, old caches dropped on activate — a half-updated mix can't happen; REV is stamped by shots.yml on push to main as before).
## `window` globals exposed (app.js), and who uses them
- `window.g = { state, openStation, cookNext, crackle, hooks, db, wavBlob, $, hash32, render: { setFetcher } }` — the three harness fragments.
- `state, hooks, $, db, playJob, drawCover, hash32, prng, keyHue, KEYS, reactionWeight, drawTaste, openTaste, reactionEmoji` and the `seq` accessor — exactly the names brain.js uses (grep'd); brain.js unchanged.
## Left unsplit / not changed, and why
- Residual `state.audio` touches outside player.js, left because moving them is a behaviour change or a rewrite: the station sheet's volume slider (`state.audio.volume`), `ui/player.js` reading `currentTime`/`duration` for ticks and reactions and seeking on a tick tap, `playTape`/`step`/the library tile pausing before `playJob`, brain.js pausing from the taste view. Listed in HANDOFF.
- `covers.js` is "pure" in the sense of no `state` and no `$`: it still uses `document.createElement("canvas")` for its offscreen caches and `getComputedStyle` for the ON AIR colours, as before. `tools/check-pure.mjs` runs it in node under a 10-line DOM stub.
- `prof` temp dir in harness.sh is now unused (harness-cdp makes its own); harmless.
## Harness, before (main @ 0786964) and after (split @ 4aa94d8), on hb-5
All three modes **byte-identical** before/after, after every step (1, 2, 3, 4, tip). Baseline run twice: stable.
Note on the Chrome loop mode: `--dump-dom --virtual-time-budget` hangs on hb-5 (known, HANDOFF), so the baseline loop was taken through the same http+CDP path as resume. In real time its lines from "track landed" on differ from the fragment's `expect` text (a 2 s test wav really ends); that is main's behaviour under real time, reproduced exactly after the split. Safari matches every `expect` line.
```
# loop (Chrome, CDP, autoplay allowed) — identical before/after
after first expect phase=playing cur=T1 playing, next=T2 nextblob=true
phase=playing src=http://12 paused=false playing=true holding=false cur=T1 next=T2 nextblob=true crackle=false loop=false
after ended expect cur=T2 playing, holding=false
phase=playing src=http://12 paused=false playing=true holding=false cur=T2 next=T3 nextblob=true crackle=false loop=false
slow cook started expect next cooking, nextblob=false
phase=playing src=http://12 paused=false playing=true holding=false cur=T2 next=T4 nextblob=false crackle=false loop=false
ended, nothing ready expect phase=holding holding=true loop=true
phase=holding src=http://12 paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=true
track landed expect holding=true loop=false (queued behind the crackle)
phase=playing src=http://12 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false
crackle pass ended expect phase=playing cur=that track playing, holding=false
phase=holding src=http://12 paused=false playing=true holding=true cur=T5 next=T6 nextblob=false crackle=false loop=true
after pause expect paused=true crackle=true
phase=holding src=http://12 paused=true playing=false holding=true cur=T5 next=T6 nextblob=false crackle=false loop=true
after resume expect paused=false crackle=false
phase=holding src=http://12 paused=false playing=true holding=true cur=T5 next=T6 nextblob=false crackle=false loop=true
after skip expect a new cur
phase=playing src=http://12 paused=false playing=true holding=false cur=T6 next=T7 nextblob=false crackle=false loop=false
END
# resume (Chrome, CDP, autoplay blocked) — identical before/after
before resume expect phase=idle cur=null paused=true needTap=false
phase=idle needTap=false tap=false veil="COOKING" paused=true playing=false holding=false cur=null curblob=false next=null nextblob=false crackle=false
resume, no gesture expect phase=playing needTap=true tap=true veil="TAP TO PLAY" paused=true cur=R1 curblob=true next=T1 nextblob=true
phase=playing needTap=true tap=true veil="TAP TO PLAY" paused=true playing=false holding=false cur=R1 curblob=true next=T1 nextblob=true crackle=false
(the tap that starts it needs a real user activation; not asserted here)
END
# safari (WebKit, safaridriver, real click) — identical before/after
after first expect phase=playing cur=T1 playing, next=T2 nextblob=true
phase=playing src=http://12 paused=false playing=true holding=false cur=T1 next=T2 nextblob=true crackle=false loop=false
after ended expect cur=T2 playing, holding=false
phase=playing src=http://12 paused=false playing=true holding=false cur=T2 next=T3 nextblob=true crackle=false loop=false
slow cook started expect next cooking, nextblob=false
phase=playing src=http://12 paused=false playing=true holding=false cur=T2 next=T4 nextblob=false crackle=false loop=false
ended, nothing ready expect phase=holding holding=true loop=true
phase=holding src=http://12 paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=true
track landed expect holding=true loop=false (queued behind the crackle)
phase=holding src=http://12 paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=false
crackle pass ended expect phase=playing cur=that track playing, holding=false
phase=playing src=http://12 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false
after pause expect paused=true crackle=true
phase=playing src=http://12 paused=true playing=false holding=false cur=T4 next=T5 nextblob=false crackle=true loop=false
after resume expect paused=false crackle=false
phase=playing src=http://12 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false
after skip expect a new cur
phase=cooking src=http://12 paused=true playing=false holding=false cur=T5 next=T6 nextblob=true crackle=true loop=false
END
```
Also: `node --check` on every module at every step; `node tools/check-pure.mjs` (covers: every look + ON AIR; render: wav/noise/tone) green; page load over CDP with brain.js: no uncaught exceptions, hooks assigned, `window.g` present.
## sw.js FILES
```
["/", "/index.html", "/app.js", "/core.js", "/covers.js", "/dj.js", "/mashup.js", "/player.js", "/render.js", "/store.js", "/ui/library.js", "/ui/player.js", "/ui/sheet.js", "/ui/taste.js", "/stats.html", "/favicon.svg", "/manifest.json", "/icons/icon.svg"]
```
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
The harness fragments now run as inline modules (after app.js) and stub the renderer through render.setFetcher; harness.sh's loop mode goes over http + DevTools like resume (file:// cannot load modules; --dump-dom also hangs on current Chrome). No page change yet.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Do not merge yet — a human taps through this branch on an iPhone first. (Branch note: the brief said
dev, but PR #2 main→dev is stuck in Gitea's "mergeable: false / try again later" state, sosplitwas branched frommaininstead — same content as a synced dev, main untouched. Point dev.gandora.lol at it, or fast-forward dev to it.)Behaviour unchanged; every line moved, not rewritten (checked mechanically: every non-import line of every module occurs verbatim in main's index.html except the edits listed under "mechanical changes"). Plain
<script type="module">, static files, view-source works, CSP unchanged, no dependencies.File map
index.html<script type="module" src="app.js">,<script src="brain.js" defer>core.jsstate(incl. the one<audio>),hooks,$,LS,toast,sleep,asDataUrl,mmss, the job counteridscovers.jshash32,prng,keyHue/KEYS, palettes,drawCover+ the looks,drawMark,drawOnAirplayer.jsstate.audio:setSource,toCooking…toIdle,playJob,advance,step,prime, crackle/bed,SILENCE, scrubbing, Media Session, analyser, transport buttons — moved as-is (the only edits in it: two++seq→++ids.seq)render.jsvenice()behind an injectable fetcher (setFetcher),renderTrack,makeNoise/noiseSpec,makeTone/toneSpec,wavBlobdj.jsdjPrompt/askDJ,askBreak,askMash, schema, modes, breaks, voices, personas,speakstore.jsdb(STORAGE.md contract unchanged),saveStation,remember,logEvent, tapes incl. virtual KEPT,tapeSpecmashup.jsmashReference,mashupui/player.js,ui/library.js,ui/sheet.js,ui/taste.jsapp.jsopenStation,makeJob/cookNext, links/share, SW + update pill, theme, key box; setswindow.gand brain's globalsbrain.js,webamp.html,stats.html,manifest.json, iconsCommits
tools/split.jsline map +split-post.py,check-pure.mjs, harness fragments as inline modules overwindow.g,harness.shloop mode over http/CDP, asplit.ymlworkflow that performs a step on the runner — deletetools/split*,.gitea/workflows/split.ymlafter merge if you like)split 1/4covers.js + core.js · 3.split 2/4store.js · 4.split 3/4dj.js + render.js (+ a one-line fix commit) · 5.split 4/4player.js + ui/* + app.js + mashup.js · 6. docs.Steps 1–3 leave the remainder as an inline module in index.html, so each commit is a working page. Step 4 is player + ui + app together, not player alone as the brief asked:
player↔ui/player↔appare one import cycle, and the first commit that extracts any of them must extract all of them. player.js itself is a pure cut, so the review of "did the state machine change" isdiffof its lines against main's index.html (none).Mechanical changes (everything that is not a pure move)
venice→veniceFetch+let fetcher,export const setFetcher,export const venice = (...a) => fetcher(...a)(render.js). Module bindings can't be reassigned from outside; the harness stubs throughsetFetcher.let seq→export const ids = { seq: 0 }(core.js) and++seq→++ids.seqin 7 places (an importedletcan't be incremented). brain.js keeps its++seqthrough awindow.seqaccessor ontoids.seq: one counter.makeTone/makeNoiseexported (for the node check).brain.jsloads withdeferso it runs after the module (hooks are assigned a few ms later than before, still before any interaction).sw.jsFILES lists the modules (one cache name keyed by REV,addAllis atomic, old caches dropped on activate — a half-updated mix can't happen; REV is stamped by shots.yml on push to main as before).windowglobals exposed (app.js), and who uses themwindow.g = { state, openStation, cookNext, crackle, hooks, db, wavBlob, $, hash32, render: { setFetcher } }— the three harness fragments.state, hooks, $, db, playJob, drawCover, hash32, prng, keyHue, KEYS, reactionWeight, drawTaste, openTaste, reactionEmojiand theseqaccessor — exactly the names brain.js uses (grep'd); brain.js unchanged.Left unsplit / not changed, and why
state.audiotouches outside player.js, left because moving them is a behaviour change or a rewrite: the station sheet's volume slider (state.audio.volume),ui/player.jsreadingcurrentTime/durationfor ticks and reactions and seeking on a tick tap,playTape/step/the library tile pausing beforeplayJob, brain.js pausing from the taste view. Listed in HANDOFF.covers.jsis "pure" in the sense of nostateand no$: it still usesdocument.createElement("canvas")for its offscreen caches andgetComputedStylefor the ON AIR colours, as before.tools/check-pure.mjsruns it in node under a 10-line DOM stub.proftemp dir in harness.sh is now unused (harness-cdp makes its own); harmless.Harness, before (main @
0786964) and after (split @4aa94d8), on hb-5All three modes byte-identical before/after, after every step (1, 2, 3, 4, tip). Baseline run twice: stable.
Note on the Chrome loop mode:
--dump-dom --virtual-time-budgethangs on hb-5 (known, HANDOFF), so the baseline loop was taken through the same http+CDP path as resume. In real time its lines from "track landed" on differ from the fragment'sexpecttext (a 2 s test wav really ends); that is main's behaviour under real time, reproduced exactly after the split. Safari matches everyexpectline.Also:
node --checkon every module at every step;node tools/check-pure.mjs(covers: every look + ON AIR; render: wav/noise/tone) green; page load over CDP with brain.js: no uncaught exceptions, hooks assigned,window.gpresent.sw.js FILES
🤖 Generated with Claude Code
https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.