lumbda/www/playground/app.js
russell@unturf.com bc13b056ac
playground streaming: per-line divs with inline display:block
Reapplies the per-line-div approach lost in the autosave revert
(c2d9eca) and pins display:block + white-space:pre as INLINE styles
on each div instead of relying on the .stream-line CSS rule. We
spent a long stretch chasing what looked like a deeper rendering
bug — turned out the previous .stream-line class rule in style.css
was loaded but the cached stylesheet bundle in fox's tab didn't
include it, so each div kept its default inline display and the
chunks still rendered horizontally even though the DOM had a clean
sibling-div column.

Each chunk now: walk byte-by-byte, accumulate text in a pending
div, every \n closes the pending div with display:block already
set and spawns a fresh sibling. Empty lines get a single space so
they still take a row. finalize() reconciles against the full eval
output (asm tier fallback) using the joined per-line text rather
than comparing pre.textContent.
2026-06-15 06:19:04 -04:00

461 lines
18 KiB
JavaScript

// wasm/app/app.js
// Single-page app shell — CodeMirror 6 editor + Web-Worker-backed tier
// runner. The main thread stays responsive: the live ms counter ticks
// every animation frame, and Cancel terminates the worker mid-eval.
import { EditorState } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, drawSelection } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { syntaxHighlighting, defaultHighlightStyle, StreamLanguage } from "@codemirror/language";
import { scheme } from "@codemirror/legacy-modes/mode/scheme";
import { oneDark } from "@codemirror/theme-one-dark";
import { openVault } from "./crypto.js";
const TIERS = { python: "python (pyodide)", c: "c (emcc)", asm: "asm (wat)" };
const FREE_FORM_DEFAULT = `; free-form mode — unlock the vault below to persist this code
; encrypted in localStorage with your password
(print "67")
(/ 42 6)
`;
const demoSources = {};
// Vault state. When unlocked, free-form code auto-saves on every edit.
const vaultState = { vault: null, freeForm: null, saveTimer: null };
async function loadDemoSource(name) {
if (name === "free-form") {
return vaultState.freeForm != null ? vaultState.freeForm : FREE_FORM_DEFAULT;
}
if (!demoSources[name]) {
const resp = await fetch(`demos/${name}.lsp`);
demoSources[name] = await resp.text();
}
return demoSources[name];
}
const editorParent = document.getElementById("editor");
const outputEl = document.getElementById("output");
const statusEl = document.getElementById("status");
const runBtn = document.getElementById("run");
const cancelBtn = document.getElementById("cancel");
const editorView = new EditorView({
state: EditorState.create({
doc: "",
extensions: [
lineNumbers(),
history(),
drawSelection(),
syntaxHighlighting(defaultHighlightStyle),
StreamLanguage.define(scheme),
keymap.of([...defaultKeymap, ...historyKeymap]),
oneDark,
EditorView.updateListener.of((u) => {
if (u.docChanged) scheduleFreeFormSave();
}),
],
}),
parent: editorParent,
});
function setEditorText(text) {
editorView.dispatch({
changes: { from: 0, to: editorView.state.doc.length, insert: text },
});
}
function getEditorText() {
return editorView.state.doc.toString();
}
async function loadCurrentDemo() {
const sel = document.querySelector('input[name="program"]:checked').value;
const vaultBar = document.getElementById("vault-bar");
vaultBar.hidden = sel !== "free-form";
const src = await loadDemoSource(sel);
setEditorText(src);
}
// ─── Free-form vault ───────────────────────────────────────────────
const vaultBarEl = document.getElementById("vault-bar");
const vaultPwEl = document.getElementById("vault-pw");
const vaultUnlockBtn = document.getElementById("vault-unlock");
const vaultLockBtn = document.getElementById("vault-lock");
const vaultStateEl = document.getElementById("vault-state");
const vaultNoteEl = document.getElementById("vault-note");
function setVaultNote(text, isErr) {
vaultNoteEl.textContent = text || "";
vaultNoteEl.className = "vault-note" + (isErr ? " err" : "");
}
async function unlockVault() {
const pw = vaultPwEl.value;
if (!pw) { setVaultNote("password required", true); return; }
setVaultNote("");
try {
vaultState.vault = await openVault(pw);
const data = await vaultState.vault.read();
if (data && data.__decryptionFailed) {
setVaultNote("vault exists but password is wrong", true);
vaultState.vault = null;
return;
}
vaultState.freeForm = (data && typeof data.freeForm === "string")
? data.freeForm
: FREE_FORM_DEFAULT;
vaultStateEl.textContent = "unlocked";
vaultStateEl.classList.add("unlocked");
vaultUnlockBtn.hidden = true;
vaultLockBtn.hidden = false;
vaultPwEl.value = "";
vaultPwEl.disabled = true;
// If free-form is the current program, swap the editor in.
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel === "free-form") setEditorText(vaultState.freeForm);
setVaultNote("ok — edits auto-save");
} catch (e) {
setVaultNote("unlock failed: " + e.message, true);
}
}
function lockVault() {
vaultState.vault = null;
vaultState.freeForm = null;
if (vaultState.saveTimer) { clearTimeout(vaultState.saveTimer); vaultState.saveTimer = null; }
vaultStateEl.textContent = "locked";
vaultStateEl.classList.remove("unlocked");
vaultUnlockBtn.hidden = false;
vaultLockBtn.hidden = true;
vaultPwEl.disabled = false;
vaultPwEl.value = "";
setVaultNote("");
// If free-form is current program, swap to the placeholder.
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel === "free-form") setEditorText(FREE_FORM_DEFAULT);
}
function scheduleFreeFormSave() {
if (!vaultState.vault) return;
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel !== "free-form") return;
if (vaultState.saveTimer) clearTimeout(vaultState.saveTimer);
vaultState.saveTimer = setTimeout(async () => {
const text = getEditorText();
vaultState.freeForm = text;
try { await vaultState.vault.write({ freeForm: text, savedAt: Date.now() }); }
catch (e) { setVaultNote("save failed: " + e.message, true); }
}, 350);
}
function selectedTiers() {
const sel = document.querySelector('input[name="tier"]:checked').value;
return sel === "all" ? ["python", "c", "asm"] : [sel];
}
function setStatus(text, cls) {
statusEl.textContent = text || "";
statusEl.className = "status" + (cls ? " " + cls : "");
}
// ─── Worker plumbing ───────────────────────────────────────────────
// ONE worker per tier so the three tiers run on independent threads —
// in "All three" mode they race, and a slow tier never blocks a fast one.
// Cancel terminates every active worker; next eval recreates them.
const workerState = {
workers: { python: null, c: null, asm: null },
pending: { python: null, c: null, asm: null },
nextRunId: 0,
};
function spawnWorker() {
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
if (bendState.url) w.postMessage({ kind: "config", bendUrl: bendState.url });
return w;
}
function ensureWorker(tier) {
if (!workerState.workers[tier]) workerState.workers[tier] = spawnWorker();
return workerState.workers[tier];
}
// ─── Bend URL ──────────────────────────────────────────────────────
const bendState = { url: localStorage.getItem("lumbda_bend_url") || "" };
const bendUrlEl = document.getElementById("bend-url");
const bendSaveBtn = document.getElementById("bend-save");
const bendStateEl = document.getElementById("bend-state");
if (bendState.url) {
bendUrlEl.value = bendState.url;
bendStateEl.textContent = "saved";
}
function saveBendUrl() {
bendState.url = bendUrlEl.value.trim();
if (bendState.url) {
localStorage.setItem("lumbda_bend_url", bendState.url);
bendStateEl.textContent = "saved";
} else {
localStorage.removeItem("lumbda_bend_url");
bendStateEl.textContent = "";
}
// Push config to any already-spawned workers.
for (const w of Object.values(workerState.workers)) {
if (w) w.postMessage({ kind: "config", bendUrl: bendState.url });
}
}
bendSaveBtn.addEventListener("click", saveBendUrl);
bendUrlEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); saveBendUrl(); }
});
function runOnTierInWorker(tier, src, onLoading) {
return new Promise((resolve, reject) => {
const w = ensureWorker(tier);
const myRunId = ++workerState.nextRunId;
let liveBlock = null;
workerState.pending[tier] = { runId: myRunId, resolve, reject };
const handler = (e) => {
if (e.data.runId !== myRunId) return;
if (e.data.kind === "loading") {
onLoading && onLoading(e.data.tier);
} else if (e.data.kind === "chunk") {
// First chunk spawns the in-progress block; subsequent
// chunks append to it. User sees displays land
// immediately, not just at the end of the run.
if (!liveBlock) liveBlock = startLiveBlock(tier);
liveBlock.append(e.data.chunk);
} else if (e.data.kind === "done") {
w.removeEventListener("message", handler);
workerState.pending[tier] = null;
resolve({ output: e.data.output, liveBlock });
} else if (e.data.kind === "error") {
w.removeEventListener("message", handler);
workerState.pending[tier] = null;
const err = new Error(e.data.message);
err.liveBlock = liveBlock;
reject(err);
}
};
w.addEventListener("message", handler);
w.postMessage({ kind: "eval", runId: myRunId, tier, src });
});
}
// Spawn a tier-block in the output panel immediately on first streamed
// chunk. Returns handles to append further chunks and to finalize the
// timing header once the eval reports done. Mirrors appendBlock's
// structure so finalized live blocks look identical to non-streamed
// ones — same DOM, same CSS.
function startLiveBlock(tierName) {
const block = document.createElement("div");
block.className = "tier-block";
const h = document.createElement("h3");
h.textContent = TIERS[tierName] || tierName;
const t = document.createElement("span");
t.className = "time";
t.textContent = " (running…)";
h.appendChild(t);
block.appendChild(h);
const pre = document.createElement("pre");
pre.textContent = "";
block.appendChild(pre);
outputEl.appendChild(block);
// Each chunk gets split on \n: every completed line becomes its
// own block-level <div>, the trailing fragment without a newline
// accumulates in a pending div that's updated in place until the
// next \n closes it. display:block + white-space:pre set INLINE
// so a stale cached stylesheet can't suppress the fix — we kept
// chasing what turned out to be CSS not reloading.
const makeLine = () => {
const d = document.createElement("div");
d.style.display = "block";
d.style.whiteSpace = "pre";
return d;
};
let pendingLine = makeLine();
pre.appendChild(pendingLine);
let pendingText = "";
return {
append(chunk) {
if (!chunk) return;
let start = 0;
for (let i = 0; i < chunk.length; i++) {
if (chunk.charCodeAt(i) === 10) {
pendingText += chunk.slice(start, i);
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
pre.appendChild(pendingLine);
start = i + 1;
}
}
if (start < chunk.length) {
pendingText += chunk.slice(start);
pendingLine.textContent = pendingText;
}
outputEl.scrollTop = outputEl.scrollHeight;
},
finalize(elapsedMs, kind, fullOutput) {
if (kind === "cancelled") t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`;
else t.textContent = ` (${elapsedMs.toFixed(0)} ms)`;
if (kind === "error") pre.className = "err";
// Reconcile against the full output if the streamed
// per-line divs don't add up — e.g. asm-tier fallback
// where streaming isn't wired yet. Otherwise the joined
// per-line text should match the eval's full output.
if (fullOutput) {
const got = Array.from(pre.children).map((d) =>
d.textContent === " " ? "" : d.textContent).join("\n");
if (got !== fullOutput.replace(/\n$/, "")) {
pre.textContent = "";
const lines = fullOutput.split("\n");
for (const ln of lines) {
const d = makeLine();
d.textContent = ln || " ";
pre.appendChild(d);
}
}
}
},
};
}
function cancelCurrentRun() {
for (const t of Object.keys(workerState.workers)) {
if (workerState.workers[t]) {
workerState.workers[t].terminate();
workerState.workers[t] = null;
}
if (workerState.pending[t]) {
workerState.pending[t].reject(new Error("cancelled"));
workerState.pending[t] = null;
}
}
}
// Append a finished tier-block to the output. Order = finish order, so
// the fastest tier appears first naturally.
function appendBlock(tierName, elapsedMs, text, kind) {
const block = document.createElement("div");
block.className = "tier-block";
const h = document.createElement("h3");
h.textContent = TIERS[tierName] || tierName;
const t = document.createElement("span");
t.className = "time";
if (kind === "cancelled") t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`;
else t.textContent = ` (${elapsedMs.toFixed(0)} ms)`;
h.appendChild(t);
block.appendChild(h);
const pre = document.createElement("pre");
if (kind === "error") pre.className = "err";
pre.textContent = text;
block.appendChild(pre);
outputEl.appendChild(block);
}
// ─── Run / cancel ──────────────────────────────────────────────────
let inFlight = false;
async function runAll() {
if (inFlight) return;
// bend demo guard: refuse to run locally without a configured bend URL.
// A CPU-impossible workload would lock the customer's tab.
const program = document.querySelector('input[name="program"]:checked').value;
if (program === "bend-gpu" && !bendState.url) {
setStatus("set a bend URL first — this demo is GPU-only by design", "err");
return;
}
inFlight = true;
runBtn.disabled = true;
cancelBtn.disabled = false;
setStatus("running…", "busy");
outputEl.innerHTML = "";
const tiers = selectedTiers();
const src = getEditorText();
// Each tier in its own worker. Append-only output: as each tier finishes,
// we append its block — so the fastest tier shows up first.
const startTimes = {};
const tickStatus = () => {
const parts = [];
for (const t of tiers) {
if (startTimes[t] !== undefined) {
parts.push(`${t} ${((performance.now() - startTimes[t]) | 0)}ms`);
}
}
setStatus(parts.join(" · "), "busy");
};
let raf = requestAnimationFrame(function loop() {
tickStatus();
raf = requestAnimationFrame(loop);
});
const tierPromises = tiers.map((t) => {
startTimes[t] = performance.now();
return runOnTierInWorker(t, src, (loadingTier) => {
setStatus(`loading ${loadingTier}…`, "busy");
})
.then(({ output, liveBlock }) => {
const elapsed = performance.now() - startTimes[t];
delete startTimes[t];
// Streamed tiers already painted via liveBlock; just
// finalize the timing header. Non-streaming tiers
// (today: asm) get a fresh appendBlock at the end.
if (liveBlock) liveBlock.finalize(elapsed, "ok", output);
else appendBlock(t, elapsed, output, "ok");
return { tier: t, ok: true, elapsed };
})
.catch((e) => {
const elapsed = performance.now() - startTimes[t];
delete startTimes[t];
if (e.message === "cancelled") {
if (e.liveBlock) e.liveBlock.finalize(elapsed, "cancelled");
else appendBlock(t, elapsed, "(cancelled)", "cancelled");
return { tier: t, cancelled: true, elapsed };
}
if (e.liveBlock) e.liveBlock.finalize(elapsed, "error", e.message);
else appendBlock(t, elapsed, e.message || String(e), "error");
return { tier: t, error: e.message, elapsed };
});
});
try {
const results = await Promise.all(tierPromises);
cancelAnimationFrame(raf);
const cancelled = results.some((r) => r.cancelled);
const anyErr = results.some((r) => r.error);
if (cancelled) setStatus("cancelled", "warn");
else if (anyErr) setStatus("completed with errors", "err");
else {
const fastest = results.reduce((a, b) => (a.elapsed < b.elapsed ? a : b));
setStatus(`ok — ${fastest.tier} won in ${fastest.elapsed.toFixed(0)} ms`, "ok");
}
} catch (e) {
cancelAnimationFrame(raf);
setStatus(`fatal: ${e.message}`, "err");
} finally {
inFlight = false;
runBtn.disabled = false;
cancelBtn.disabled = true;
}
}
function onCancel() {
if (!inFlight) return;
setStatus("cancelling…", "warn");
cancelCurrentRun();
}
document.querySelectorAll('input[name="program"]').forEach((el) => {
el.addEventListener("change", loadCurrentDemo);
});
runBtn.addEventListener("click", runAll);
cancelBtn.addEventListener("click", onCancel);
cancelBtn.disabled = true;
vaultUnlockBtn.addEventListener("click", unlockVault);
vaultLockBtn.addEventListener("click", lockVault);
vaultPwEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); unlockVault(); }
});
loadCurrentDemo();