Files
site/docs/v2/REFACTOR-PROMPT.md
T

8.0 KiB

Split gandora's index.html into ES modules — brief for a fresh agent

Superseded. The split already exists on the split branch (see docs/v2/NOTES-FROM-POC.md). Kept for its rules and checklist, which still describe how to review that branch before it merges.

You are refactoring gandora/site on teahub.xyz (Gitea; use the Teahub MCP tools: tea_file_get, tea_file_edit, tea_file_change, tea_commit, tea_branch_create, tea_pull_create; there is no git access from the container). The Mac hb-5 is reachable through boring_mac_exec (120 s per call; long jobs with nohup … & then poll); a clone lives at ~/scratch/gsite (git pull -q to refresh). Node is at /opt/homebrew/bin/node.

Work on the dev branch only. Do not push to main. Open one PR at the end.

Goal

index.html is a ~2,100-line single file holding the player, the DJ, rendering, covers, storage, the UI sheets and the usage ledger in one scope. Split it into ES modules with no build step: plain <script type="module">, static files served as-is by Pages, view-source still works, the service worker still caches a fixed list. Behaviour must be unchanged. The point is blast radius: after this, the player is the only file that needs the dev-branch-and-iPhone treatment.

Read first, in this order

  1. HANDOFF.md — especially "Player rules" (iOS invariants learned the hard way) and "What a refactor should keep".
  2. ARCHITECTURE.md — the state machine and playback invariants.
  3. STORAGE.md — the db adapter contract (chai's adapter targets it; keep the shape).
  4. tools/harness.sh, tools/harness.html, tools/harness-resume.html, tools/harness-cdp.py, tools/harness-safari.py — these are your tests.
  5. index.html, brain.js, sw.js, stats.html, webamp.html (the last two read IndexedDB and localStorage directly; don't change their contracts).

Target layout

index.html      markup + CSS only, plus <script type="module" src="app.js"> and <script src="brain.js">
app.js          boot: wires modules, sets window.g, hooks for brain.js, URL/share handling, SW + update pill, theme
player.js       the state machine and the ONE <audio>: state, setSource, toCooking, toHolding, queueBehindHold,
                handOver, startNow, playNow, toPlaying, toEnded, toIdle, playJob, advance, prime, bed/crackle/bedStart,
                makeCrackle, SILENCE, Media Session, vizBeat/analyser
dj.js           djPrompt, djText, askDJ, askBreak, askMash, DJ_SCHEMA, BREAKS/BREAK_BRIEF, MODES/MODE_BRIEF,
                PERSONAS, VOICES/voiceParts/voiceLabel, speak, remixText, stationFrom helpers that only build specs
render.js       venice() (as an injectable fetcher), renderTrack, hedge/queue/poll path, makeNoise, noiseSpec,
                makeTone, toneSpec, wavBlob, wavBlob2, cost bookkeeping
covers.js       pure: hash32, prng, keyHue/palettes, drawCover, drawRings, drawChord, attractor, drawMark,
                drawOnAir, tape covers. No DOM lookups, no state — take a canvas and a spec.
store.js        db (open/get/put/all/del), stations (saveStation, listStations data), tapes incl. the KEPT virtual
                tape, tapeSpec, logEvent (the ledger), LS keys
ui/sheet.js     stationSheet (name, look, palette, mode, dj details, sliders, actions)
ui/library.js   library view, in-place play, now-playing bar, 🦙 link
ui/player.js    cover/title/tags/lyrics rendering, keep/share buttons, scrubbing, drawTicks, markReaction, emoji
                tray + picker, react(), toasts, tap-to-play veil
mashup.js       mashup() (OfflineAudioContext stitch) and askMash wiring
brain.js        unchanged (it is a classic script; keep its globals and the hooks contract)

Names are suggestions; the boundaries are not. Hard boundaries:

  • Only player.js touches state.audio (or any Audio element that carries the station). Everything else calls its exports or listens to its events.
  • covers.js and the generators in render.js are pure (no document, no state), so they can be checked in node.
  • brain.js keeps working as-is: it reads hooks, state, cssv(), db; expose exactly what it needs on window from app.js (see below).
  • The venice fetcher must be injectable (e.g. render.setFetcher(fn)), because the harness fragments replace it. Reassigning a module binding from outside won't work.

Globals for the harness and brain.js

The harness fragments and brain.js reach into state, crackle, openStation, cookNext, venice, db, hooks, wavBlob, $. In app.js, after wiring, set

window.g = { state, player, dj, render, store, covers, openStation, cookNext, crackle, hooks, db, wavBlob, $ };

and additionally put on window the exact names brain.js uses today (grep it). Then update the three harness fragments to read through window.g (minimal edits; same assertions). tools/harness-cdp.py and tools/harness-safari.py already serve over http; modules need http, so the file:// loop mode of harness.sh is retired: make sh tools/harness.sh run the loop fragment through the CDP path (it already does for resume).

Service worker

sw.js precaches a fixed FILES list and stamps REV from the commit. Add every new module to FILES. Make sure a half-updated cache can't mix versions: the update pill / RELOAD path must refresh all of them together (one cache name keyed by REV is enough; check it already is).

CSP

index.html carries a strict CSP (script-src 'self' 'unsafe-inline'). Module scripts from 'self' are fine; don't add inline module code and don't add any CDN.

Method — in order of distance from the audio element

Do it as a sequence of small commits on dev, each leaving the page working and the harnesses green:

  1. covers.js + the generators (pure) → node check: node -e import them and draw/generate once.
  2. store.js (db, tapes, ledger). stats.html and webamp.html must still read the same IndexedDB/localStorage shapes.
  3. dj.js and render.js with the injectable fetcher.
  4. ui/*.
  5. player.js last and alone, in its own commit, touching nothing else. Re-read HANDOFF "Player rules" before this step. Do not "improve" the state machine while moving it.
  6. app.js wiring, window.g, harness fragment edits, sw.js list, HANDOFF.md/ARCHITECTURE.md updated to the new layout (short; keep the invariants section intact and add "file map").

After each step run, on hb-5 in ~/scratch/gsite (after git pull -q):

/opt/homebrew/bin/node --check app.js player.js dj.js render.js covers.js store.js ui/*.js   # syntax
sh tools/harness.sh            # loop, Chrome over CDP
sh tools/harness.sh resume     # resume with autoplay blocked, expects TAP TO PLAY
sh tools/harness.sh safari     # WebKit (iPhone's engine) with a real activation

All three must print the same expectations as before the split (compare against a run on main first and save that output). A diff in any expect … line is a regression; fix or revert the step.

Rules

  • No behaviour changes. No TypeScript, no bundler, no framework, no new dependencies, no reformatting of lines you aren't moving (keep diffs reviewable).
  • Preserve every comment that explains an invariant; move it with the code.
  • Keep index.html the entry URL; keep the share link formats (/#seed, /?t=), the IndexedDB name/stores/record shapes, and all localStorage keys exactly.
  • Don't touch brain.js, webamp.html, stats.html, tools/icons.py, .gitea/workflows/shots.yml, manifest.json, icons.
  • If something can't be split without a behaviour change, leave it in place and say so in the PR rather than changing behaviour.
  • Commit messages: one line saying what moved, with the attribution lines in use on this repo.

Deliver

A PR dev → main with: the file map; the harness outputs before and after (all three modes); a list of every window global exposed and who uses it; anything you left unsplit and why; and the exact sw.js FILES list. Stop there — a human taps through dev.gandora.lol on an iPhone before merge.