From f855ee15b7bda427524ac20fb989c1c199b638ef Mon Sep 17 00:00:00 2001
From: teahubxyz
Date: Wed, 21 Oct 2026 22:17:50 +0000
Subject: [PATCH] split tooling: line-map splitter, post edits, node checks,
harness fragments as modules over window.g, a workflow that performs a step
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
Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
---
.gitea/workflows/split.yml | 46 ++++++++++++++++++++++++++
tools/check-pure.mjs | 12 +++++++
tools/harness-resume.html | 8 +++--
tools/harness.html | 8 +++--
tools/harness.sh | 48 ++++++++++-----------------
tools/split-globals.txt | 5 +++
tools/split-map.json | 1 +
tools/split-post.py | 26 +++++++++++++++
tools/split.js | 67 ++++++++++++++++++++++++++++++++++++++
9 files changed, 184 insertions(+), 37 deletions(-)
create mode 100644 .gitea/workflows/split.yml
create mode 100644 tools/check-pure.mjs
create mode 100644 tools/split-globals.txt
create mode 100644 tools/split-map.json
create mode 100644 tools/split-post.py
create mode 100644 tools/split.js
diff --git a/.gitea/workflows/split.yml b/.gitea/workflows/split.yml
new file mode 100644
index 0000000..f8a6f97
--- /dev/null
+++ b/.gitea/workflows/split.yml
@@ -0,0 +1,46 @@
+name: split
+# One step of the module split, performed on the runner: rebuild from main's index.html with tools/split.js (the
+# line map), apply tools/split-post.py, check, commit to the branch the workflow was dispatched on. Throwaway once
+# the split has landed.
+on:
+ workflow_dispatch:
+ inputs:
+ step:
+ description: "comma list of modules to extract (the rest stays inline), or all"
+ required: true
+ message:
+ description: "commit message (first line)"
+ required: true
+permissions:
+ contents: write
+jobs:
+ split:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - name: rebuild from main's index.html
+ env:
+ STEP_IN: ${{ github.event.inputs.step }}
+ run: |
+ set -eu
+ git show origin/main:index.html > index.html
+ if [ "$STEP_IN" = all ]; then WRITE=1 node tools/split.js > /dev/null; else STEP="$STEP_IN" WRITE=1 node tools/split.js > /dev/null; fi
+ python3 tools/split-post.py
+ node --check $(ls *.js ui/*.js 2>/dev/null | grep -v -e brain.js -e sw.js)
+ if [ -f covers.js ] && [ -f render.js ]; then node tools/check-pure.mjs; fi
+ wc -l index.html *.js ui/*.js 2>/dev/null || true
+ - name: commit
+ env:
+ MSG: ${{ github.event.inputs.message }}
+ run: |
+ set -eu
+ git add -A
+ if git diff --cached --quiet; then echo "nothing changed"; exit 0; fi
+ git -c user.name='Claude Fable 5.1' -c user.email='noreply@anthropic.com' commit -q -m "$MSG" -m "Co-Authored-By: Claude Fable 5.1 " -m "Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG"
+ git push -q origin "HEAD:${GITHUB_REF_NAME}"
diff --git a/tools/check-pure.mjs b/tools/check-pure.mjs
new file mode 100644
index 0000000..3636e84
--- /dev/null
+++ b/tools/check-pure.mjs
@@ -0,0 +1,12 @@
+// tools/check-pure.mjs: covers.js and render.js's generators load and run in node (a canvas stub stands in for the DOM).
+const noop = () => {}; const ctx = new Proxy({}, { get: (_, k) => k === "measureText" ? () => ({ width: 10 }) : k === "createLinearGradient" || k === "createRadialGradient" ? () => ({ addColorStop: noop }) : noop, set: () => true });
+const canvas = () => ({ width: 64, height: 64, getContext: () => ctx });
+globalThis.document = { createElement: canvas, documentElement: {} }; globalThis.getComputedStyle = () => ({ getPropertyValue: () => "" });
+globalThis.localStorage = { getItem: () => null, setItem: noop }; globalThis.Audio = class { constructor() {} };
+const covers = await import("../covers.js");
+for (const look of covers.LOOKS) covers.drawCover(canvas(), { seed: "s", title: "t", tags: "x", bpm: 100, key: "A minor", energy: 0.5, look });
+covers.drawCover(canvas(), { seed: "s", title: "t", tags: "x", bpm: 100, key: "C", energy: 0.5, break: "ident" });
+const render = await import("../render.js");
+const w = render.wavBlob(new Int16Array(80), 8000); if (w.size !== 44 + 160) throw new Error("wavBlob size " + w.size);
+const n = await render.makeNoise("pink", 1), t = await render.makeTone({ kind: "binaural", hz: 200, beat: 10 }, 1);
+console.log("covers ok, wav", w.size, "noise", n.size, "tone", t.size);
diff --git a/tools/harness-resume.html b/tools/harness-resume.html
index 42252b2..0812299 100644
--- a/tools/harness-resume.html
+++ b/tools/harness-resume.html
@@ -9,15 +9,17 @@ link. Expected: the track is loaded but autoplay is refused, so the player waits
and the next track cooks behind it. The other half — the tap starting it — needs a real user activation, which
headless Chrome cannot fake; it is documented in ARCHITECTURE.md, not asserted here.
-', '') # after the module, which is deferred too
+h.write_text(s)
+sw = ROOT / "sw.js"; s = sw.read_text()
+mods = sorted(str(p.relative_to(ROOT)) for p in list(ROOT.glob("*.js")) + list(ROOT.glob("ui/*.js")) if p.name not in ("brain.js", "sw.js"))
+s = re.sub(r'const FILES = \["/", "/index.html",( "/[a-z/]+\.js",)*', 'const FILES = ["/", "/index.html",' + "".join(f' "/{m}",' for m in mods), s)
+sw.write_text(s)
+print("post ok:", mods)
diff --git a/tools/split.js b/tools/split.js
new file mode 100644
index 0000000..9ff2e90
--- /dev/null
+++ b/tools/split.js
@@ -0,0 +1,67 @@
+// tools/split.js — one-off: cut index.html's script into ES modules by line ranges. Analysis mode by default; WRITE=1 writes.
+// STEP=a.js,b.js extracts only those modules and leaves the rest as an inline module in index.html (the intermediate commits).
+const fs = require("fs");
+const src = fs.readFileSync("index.html", "utf8").split("\n");
+const S = 402, E = 2199; // at E
+const cuts = JSON.parse(fs.readFileSync("tools/split-map.json", "utf8")); // [[startLine, "file"], ...] sorted
+const body = src.slice(S, E - 1); // lines S+1 .. E-1 (1-based)
+const at = (n) => n - (S + 1); // index into body
+const chunks = [];
+for (let i = 0; i < cuts.length; i++) {
+ const [a, f] = cuts[i], b = i + 1 < cuts.length ? cuts[i + 1][0] : E;
+ chunks.push({ file: f, from: a, to: b - 1, lines: body.slice(at(a), at(b)) });
+}
+const STEP = process.env.STEP ? new Set(process.env.STEP.split(",")) : null;
+for (const c of chunks) if (STEP && !STEP.has(c.file)) c.file = "index.html";
+const mods = {};
+for (const c of chunks) (mods[c.file] ||= { file: c.file, lines: [], decl: new Map(), lets: new Set() }).lines.push(...c.lines, "");
+const declRe = /^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|^(const|let)\s+(.+?)(?:\s*=|;|$)/;
+for (const m of Object.values(mods)) {
+ m.lines.forEach((ln, i) => {
+ let r = declRe.exec(ln); if (!r) return;
+ if (r[1]) m.decl.set(r[1], i);
+ else {
+ // const a = 1, b = 2; const { x } = y; const [a, b] = z
+ const head = ln.slice(r[2].length).trim();
+ const names = [...head.matchAll(/(?:^|,\s*)([A-Za-z_$][\w$]*)\s*=/g)].map((x) => x[1]);
+ if (/^\s*[\[{]/.test(head)) for (const n of head.slice(0, head.indexOf("=")).match(/[A-Za-z_$][\w$]*/g) || []) names.push(n);
+ if (/=>|function/.test(head)) names.length = 1;
+ for (const n of names) { m.decl.set(n, i); if (r[2] === "let") m.lets.add(n); }
+ }
+ });
+}
+const owner = {}; for (const m of Object.values(mods)) for (const n of m.decl.keys()) { if (owner[n]) console.error("DUP", n, owner[n], m.file); owner[n] = m.file; }
+const strip = (s) => s.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/g, (t) => t[0] === "`" ? (t.match(/\$\{[^}]*\}/g) || []).map((x) => x.slice(2, -1)).join(" ") : " ");
+const GLOBALS = fs.readFileSync("tools/split-globals.txt", "utf8");
+const report = {};
+for (const m of Object.values(mods)) {
+ const text = strip(m.lines.join("\n") + (m.file === "app.js" || m.file === "index.html" ? GLOBALS : ""));
+ const used = new Set(text.replace(/(? [f, [...s]])), assignsForeign: m.assignsForeign };
+}
+for (const m of Object.values(mods)) report[m.file].exports = [...m.decl.keys()].filter((n) => Object.values(mods).some((o) => o !== m && o.imports[m.file] && o.imports[m.file].has(n)));
+// window / brain.js / harness needs
+const brain = strip(fs.readFileSync("brain.js", "utf8"));
+const bUsed = [...new Set(brain.match(/(? owner[n]);
+console.log(JSON.stringify({ report, brainUses: bUsed.map((n) => n + "@" + owner[n]) }, null, 1));
+if (!process.env.WRITE) process.exit(0);
+const rel = (from, to) => { const d = from.includes("/") ? "../" : "./"; return d + to; };
+let inline = "";
+for (const m of Object.values(mods)) {
+ const ex = new Set(report[m.file].exports);
+ const out = m.lines.map((ln, i) => { for (const [n, li] of m.decl) if (li === i && ex.has(n) && /^(const|let|async function|function)\b/.test(ln)) return "export " + ln; return ln; });
+ const head = Object.entries(m.imports).map(([f, s]) => `import { ${[...s].sort().join(", ")} } from "${rel(m.file, f)}";`);
+ const text = (head.length ? head.join("\n") + "\n" : "") + out.join("\n").replace(/\n+$/, "\n");
+ if (m.file === "index.html") { inline = text; continue; }
+ fs.mkdirSync(require("path").dirname(m.file), { recursive: true });
+ fs.writeFileSync(m.file, text + (m.file === "app.js" ? GLOBALS : ""));
+}
+const tag = inline ? [``] : [``];
+const html = src.slice(0, S - 1).concat(tag, src.slice(E));
+fs.writeFileSync("index.html", html.join("\n"));
+console.error("written");