wasm/playground: cancel button, asm state-leak fix, restyle to match homepage
User-visible changes
- Cancel button — terminates the running worker. Pyodide's slow mandelbrot
no longer freezes the UI; click cancel and the elapsed counter freezes
at "(cancelled @ NNNN ms)".
- Live ms counter ticks per animation frame while a tier is busy, so the
Pyodide tier's ~5-15 s wait is visible instead of looking hung.
- Restyled to match lumbda.com: chunkfive wordmark, --green: #227842
(light) / #5ec07a (dark), lumbda-logo-green.png, lowercase "lumbda"
everywhere. Pulls fonts/chunkfive locally so the playground stays
self-contained.
Architecture
- All tier evaluations now run inside a Web Worker (wasm/app/worker.mjs)
so the main thread stays responsive. Cancel = worker.terminate(); next
eval respawns a fresh worker.
- Loaders use new URL("./...", import.meta.url) so paths resolve against
the loader file's own location — works identically in window and
worker contexts, no baseURL argument needed.
- C tier Emscripten build flipped to EXPORT_ES6=1; loader uses dynamic
`import()` of the factory module. Integration test updated accordingly.
- Python loader uses `import("pyodide.mjs")` (ES module) instead of
document.createElement, which doesn't exist in workers.
Bug fixes
- Asm tier state leak: running the same demo twice on a cached WASM
instance produced corrupted output (every other cell on row 2+ rendered
as " " instead of the expected shade char). Root cause: top-level eval
passed `global_env` as the env, so closures captured stale globals;
fixed by passing NIL — env_lookup falls back to the CURRENT global_env
via its existing two-pass walk. Multi-run regression added to the
functional test suite.
- fib-ack demo: (ack 3 4) was too heavy for Pyodide (minutes). Cut to
(ack 3 3) + (fib 20) max so every tier finishes in seconds.
Test discipline
- Root `make test-all` now includes `wasm-test`. Adding a language
feature without exercising it on all six implementations is no longer
possible by accident.
- Functional suite: 11 assertions (was 10) — adds asm multi-run stability.
- Integration + unit: still 20 + 8.
This commit is contained in:
parent
9c25d46e13
commit
1b7de2c9c6
31 changed files with 866 additions and 435 deletions
4
Makefile
4
Makefile
|
|
@ -117,9 +117,9 @@ regression-named-let-leak: c-build
|
||||||
prove-ursa-runs: c-build
|
prove-ursa-runs: c-build
|
||||||
@bash tests/prove-ursa-runs.sh
|
@bash tests/prove-ursa-runs.sh
|
||||||
|
|
||||||
test-all: test c-test asm-test functional-test portal-rng-cross-test zoe-favorites-test regression-named-let-leak
|
test-all: test c-test asm-test functional-test portal-rng-cross-test zoe-favorites-test regression-named-let-leak wasm-test
|
||||||
@echo "════════════════════════════════════════════════════"
|
@echo "════════════════════════════════════════════════════"
|
||||||
@echo "All tests passed (Python + C + Assembly + functional + portal-rng-cross + zoe-favorites)"
|
@echo "All tests passed (Python + C + Assembly + functional + portal-rng-cross + zoe-favorites + wasm)"
|
||||||
|
|
||||||
# ─── Benchmarks (reproducible; referenced in whitepaper §6–§11) ───
|
# ─── Benchmarks (reproducible; referenced in whitepaper §6–§11) ───
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ C_JIT_STUB := c/jit-stub.c
|
||||||
|
|
||||||
C_CFLAGS := -O2 -DLUMBDA_WASM -std=c11 -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE \
|
C_CFLAGS := -O2 -DLUMBDA_WASM -std=c11 -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE \
|
||||||
-I$(C_SRC_DIR) -Wno-everything
|
-I$(C_SRC_DIR) -Wno-everything
|
||||||
C_LDFLAGS := -s WASM=1 -s MODULARIZE=1 -s EXPORT_ES6=0 \
|
C_LDFLAGS := -s WASM=1 -s MODULARIZE=1 -s EXPORT_ES6=1 \
|
||||||
-s EXPORT_NAME=createLumbdaC \
|
-s EXPORT_NAME=createLumbdaC \
|
||||||
-s EXPORTED_FUNCTIONS='["_lumbda_wasm_init","_lumbda_wasm_eval","_lumbda_wasm_free_result","_malloc","_free"]' \
|
-s EXPORTED_FUNCTIONS='["_lumbda_wasm_init","_lumbda_wasm_eval","_lumbda_wasm_free_result","_malloc","_free"]' \
|
||||||
-s EXPORTED_RUNTIME_METHODS='["cwrap","ccall","UTF8ToString","stringToUTF8","lengthBytesUTF8"]' \
|
-s EXPORTED_RUNTIME_METHODS='["cwrap","ccall","UTF8ToString","stringToUTF8","lengthBytesUTF8"]' \
|
||||||
|
|
@ -128,9 +128,11 @@ $(DIST)/asm/lumbda-asm.loader.js: asm/lumbda-asm.loader.js
|
||||||
|
|
||||||
# ─── SPA shell ─────────────────────────────────────────────────────
|
# ─── SPA shell ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
APP_SRC := $(wildcard app/*.html app/*.css app/*.js app/demos/*.lsp)
|
APP_SRC := $(wildcard app/*.html app/*.css app/*.js app/*.mjs app/demos/*.lsp)
|
||||||
|
|
||||||
app: $(DIST)/index.html $(DIST)/style.css $(DIST)/app.js $(DIST)/runner.js
|
app: $(DIST)/index.html $(DIST)/style.css $(DIST)/app.js $(DIST)/runner.js $(DIST)/worker.mjs \
|
||||||
|
$(DIST)/lumbda-logo-green.png $(DIST)/fonts/chunkfive/chunkfive-regular-webfont.woff2 \
|
||||||
|
$(DIST)/fonts/chunkfive/chunkfive-regular-webfont.woff
|
||||||
|
|
||||||
$(DIST)/%: app/%
|
$(DIST)/%: app/%
|
||||||
@mkdir -p $(@D)
|
@mkdir -p $(@D)
|
||||||
|
|
|
||||||
168
wasm/app/app.js
168
wasm/app/app.js
|
|
@ -1,5 +1,7 @@
|
||||||
// wasm/app/app.js
|
// wasm/app/app.js
|
||||||
// Single-page app shell — CodeMirror 6 editor + tier runner.
|
// 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 { EditorState } from "@codemirror/state";
|
||||||
import { EditorView, keymap, lineNumbers, drawSelection } from "@codemirror/view";
|
import { EditorView, keymap, lineNumbers, drawSelection } from "@codemirror/view";
|
||||||
|
|
@ -8,10 +10,7 @@ import { syntaxHighlighting, defaultHighlightStyle, StreamLanguage } from "@code
|
||||||
import { scheme } from "@codemirror/legacy-modes/mode/scheme";
|
import { scheme } from "@codemirror/legacy-modes/mode/scheme";
|
||||||
import { oneDark } from "@codemirror/theme-one-dark";
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
|
|
||||||
import { runOnTiers } from "./runner.js";
|
const TIERS = { python: "python (pyodide)", c: "c (emcc)", asm: "asm (wat)" };
|
||||||
|
|
||||||
const DEMOS = ["mandelbrot", "fib-ack", "sieve", "self-interp"];
|
|
||||||
const TIERS = { python: "Python (Pyodide)", c: "C (emcc)", asm: "Asm (WAT)" };
|
|
||||||
|
|
||||||
const demoSources = {};
|
const demoSources = {};
|
||||||
|
|
||||||
|
|
@ -27,6 +26,7 @@ const editorParent = document.getElementById("editor");
|
||||||
const outputEl = document.getElementById("output");
|
const outputEl = document.getElementById("output");
|
||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
const runBtn = document.getElementById("run");
|
const runBtn = document.getElementById("run");
|
||||||
|
const cancelBtn = document.getElementById("cancel");
|
||||||
|
|
||||||
const editorView = new EditorView({
|
const editorView = new EditorView({
|
||||||
state: EditorState.create({
|
state: EditorState.create({
|
||||||
|
|
@ -71,51 +71,157 @@ function setStatus(text, cls) {
|
||||||
statusEl.className = "status" + (cls ? " " + cls : "");
|
statusEl.className = "status" + (cls ? " " + cls : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderResults(results) {
|
// ─── Worker plumbing ───────────────────────────────────────────────
|
||||||
outputEl.innerHTML = "";
|
// One worker hosts all tiers. Cancel terminates it; the next eval
|
||||||
for (const r of results) {
|
// creates a fresh one. Cached Pyodide / Emscripten state is lost on
|
||||||
const block = document.createElement("div");
|
// cancel, which is the price of true cancellation.
|
||||||
block.className = "tier-block";
|
|
||||||
const h = document.createElement("h3");
|
const workerState = { worker: null, runId: 0, pending: null };
|
||||||
h.textContent = TIERS[r.tier] || r.tier;
|
|
||||||
const t = document.createElement("span");
|
function spawnWorker() {
|
||||||
t.className = "time";
|
return new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
|
||||||
t.textContent = ` (${r.elapsed.toFixed(0)} ms)`;
|
}
|
||||||
h.appendChild(t);
|
|
||||||
block.appendChild(h);
|
function ensureWorker() {
|
||||||
const pre = document.createElement("pre");
|
if (!workerState.worker) workerState.worker = spawnWorker();
|
||||||
if (r.error) {
|
return workerState.worker;
|
||||||
pre.className = "err";
|
}
|
||||||
pre.textContent = r.error;
|
|
||||||
} else {
|
function runOnTierInWorker(tier, src, onLoading) {
|
||||||
pre.textContent = r.output;
|
return new Promise((resolve, reject) => {
|
||||||
}
|
const w = ensureWorker();
|
||||||
block.appendChild(pre);
|
const myRunId = ++workerState.runId;
|
||||||
outputEl.appendChild(block);
|
workerState.pending = { 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 === "done") {
|
||||||
|
w.removeEventListener("message", handler);
|
||||||
|
workerState.pending = null;
|
||||||
|
resolve(e.data.output);
|
||||||
|
} else if (e.data.kind === "error") {
|
||||||
|
w.removeEventListener("message", handler);
|
||||||
|
workerState.pending = null;
|
||||||
|
reject(new Error(e.data.message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
w.addEventListener("message", handler);
|
||||||
|
w.postMessage({ kind: "eval", runId: myRunId, tier, src });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelCurrentRun() {
|
||||||
|
if (workerState.worker) {
|
||||||
|
workerState.worker.terminate();
|
||||||
|
workerState.worker = null;
|
||||||
|
}
|
||||||
|
if (workerState.pending) {
|
||||||
|
workerState.pending.reject(new Error("cancelled"));
|
||||||
|
workerState.pending = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Per-tier output block with live ms counter ────────────────────
|
||||||
|
|
||||||
|
function makeLiveBlock(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 = " loading…";
|
||||||
|
h.appendChild(t);
|
||||||
|
block.appendChild(h);
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.textContent = "";
|
||||||
|
block.appendChild(pre);
|
||||||
|
outputEl.appendChild(block);
|
||||||
|
|
||||||
|
let raf = 0;
|
||||||
|
let start = 0;
|
||||||
|
function tickerLoop() {
|
||||||
|
const ms = (performance.now() - start) | 0;
|
||||||
|
t.textContent = ` ${ms} ms…`;
|
||||||
|
raf = requestAnimationFrame(tickerLoop);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
startTimer() { start = performance.now(); tickerLoop(); return start; },
|
||||||
|
stop(elapsedMs) {
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
t.textContent = ` (${elapsedMs.toFixed(0)} ms)`;
|
||||||
|
},
|
||||||
|
cancelled(elapsedMs) {
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`;
|
||||||
|
},
|
||||||
|
setOutput(text) { pre.textContent = text; },
|
||||||
|
setError(msg) { pre.className = "err"; pre.textContent = msg; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Run / cancel ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let inFlight = false;
|
||||||
|
|
||||||
async function runAll() {
|
async function runAll() {
|
||||||
|
if (inFlight) return;
|
||||||
|
inFlight = true;
|
||||||
runBtn.disabled = true;
|
runBtn.disabled = true;
|
||||||
setStatus("loading tiers…", "busy");
|
cancelBtn.disabled = false;
|
||||||
|
setStatus("running…", "busy");
|
||||||
outputEl.innerHTML = "";
|
outputEl.innerHTML = "";
|
||||||
|
let anyErr = false;
|
||||||
|
let cancelled = false;
|
||||||
try {
|
try {
|
||||||
const tiers = selectedTiers();
|
const tiers = selectedTiers();
|
||||||
const src = getEditorText();
|
const src = getEditorText();
|
||||||
const results = await runOnTiers(tiers, src, (msg) => setStatus(msg, "busy"));
|
for (const t of tiers) {
|
||||||
renderResults(results);
|
const live = makeLiveBlock(t);
|
||||||
const anyErr = results.some((r) => r.error);
|
const startMark = live.startTimer();
|
||||||
setStatus(anyErr ? "completed with errors" : "ok", anyErr ? "err" : "ok");
|
try {
|
||||||
|
const output = await runOnTierInWorker(t, src, (loadingTier) => {
|
||||||
|
setStatus(`loading ${loadingTier} tier…`, "busy");
|
||||||
|
});
|
||||||
|
setStatus(`running ${t}…`, "busy");
|
||||||
|
const elapsed = performance.now() - startMark;
|
||||||
|
live.stop(elapsed);
|
||||||
|
live.setOutput(output);
|
||||||
|
} catch (e) {
|
||||||
|
const elapsed = performance.now() - startMark;
|
||||||
|
if (e.message === "cancelled") {
|
||||||
|
live.cancelled(elapsed);
|
||||||
|
cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
live.stop(elapsed);
|
||||||
|
live.setError(e.message || String(e));
|
||||||
|
anyErr = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cancelled) setStatus("cancelled", "warn");
|
||||||
|
else setStatus(anyErr ? "completed with errors" : "ok", anyErr ? "err" : "ok");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`fatal: ${e.message}`, "err");
|
setStatus(`fatal: ${e.message}`, "err");
|
||||||
outputEl.textContent = e.stack || e.message;
|
outputEl.textContent = e.stack || e.message;
|
||||||
} finally {
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
runBtn.disabled = false;
|
runBtn.disabled = false;
|
||||||
|
cancelBtn.disabled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
if (!inFlight) return;
|
||||||
|
setStatus("cancelling…", "warn");
|
||||||
|
cancelCurrentRun();
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('input[name="program"]').forEach((el) => {
|
document.querySelectorAll('input[name="program"]').forEach((el) => {
|
||||||
el.addEventListener("change", loadCurrentDemo);
|
el.addEventListener("change", loadCurrentDemo);
|
||||||
});
|
});
|
||||||
runBtn.addEventListener("click", runAll);
|
runBtn.addEventListener("click", runAll);
|
||||||
|
cancelBtn.addEventListener("click", onCancel);
|
||||||
|
cancelBtn.disabled = true;
|
||||||
loadCurrentDemo();
|
loadCurrentDemo();
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,11 @@
|
||||||
((= n 0) (ack (- m 1) 1))
|
((= n 0) (ack (- m 1) 1))
|
||||||
(else (ack (- m 1) (ack m (- n 1))))))
|
(else (ack (- m 1) (ack m (- n 1))))))
|
||||||
|
|
||||||
|
; Demo deliberately stays small so Pyodide finishes in a few seconds.
|
||||||
|
; (ack 3 4) is 125 but takes minutes via CPython-in-WASM tree-walker;
|
||||||
|
; cut down to (ack 3 3) so every tier can run it.
|
||||||
|
(display "fib(15) = ") (print (fib 15))
|
||||||
(display "fib(20) = ") (print (fib 20))
|
(display "fib(20) = ") (print (fib 20))
|
||||||
(display "fib(25) = ") (print (fib 25))
|
|
||||||
(display "ack(2,3) = ") (print (ack 2 3))
|
(display "ack(2,3) = ") (print (ack 2 3))
|
||||||
(display "ack(3,4) = ") (print (ack 3 4))
|
(display "ack(3,3) = ") (print (ack 3 3))
|
||||||
(print "done")
|
(print "done")
|
||||||
|
|
|
||||||
BIN
wasm/app/fonts/chunkfive/chunkfive-regular-webfont.woff
Normal file
BIN
wasm/app/fonts/chunkfive/chunkfive-regular-webfont.woff
Normal file
Binary file not shown.
BIN
wasm/app/fonts/chunkfive/chunkfive-regular-webfont.woff2
Normal file
BIN
wasm/app/fonts/chunkfive/chunkfive-regular-webfont.woff2
Normal file
Binary file not shown.
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Lumbda playground — Lisp in your browser, three tiers</title>
|
<title>lumbda playground — lisp in your browser, three tiers</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
|
|
@ -25,31 +25,36 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>Lumbda <span class="sub">— Lisp/Scheme in your browser, three tiers in parallel</span></h1>
|
<a class="brand" href="../" aria-label="lumbda home">
|
||||||
<p class="tag">
|
<img class="logo" src="lumbda-logo-green.png" alt="" aria-hidden="true">
|
||||||
Same Lisp source. Three implementations compiled to WebAssembly:
|
<h1 aria-label="lumbda.">lumbda<span class="period" aria-hidden="true">.</span></h1>
|
||||||
<strong>Python</strong> (CPython via Pyodide hosting <code>lumbda.py</code>),
|
</a>
|
||||||
<strong>C</strong> (Emscripten build of the tree-walker + bytecode VM),
|
<p class="tagline">lisp/scheme in your browser, three tiers in parallel</p>
|
||||||
<strong>Asm</strong> (hand-written WebAssembly Text format — parallel to <code>asm/lumbda.s</code>).
|
<p class="lede">
|
||||||
|
same lisp source. three implementations compiled to webassembly:
|
||||||
|
<strong>python</strong> (cpython via pyodide hosting <code>lumbda.py</code>),
|
||||||
|
<strong>c</strong> (emscripten build of the tree-walker + bytecode vm),
|
||||||
|
<strong>asm</strong> (hand-written webassembly text format — parallel to <code>asm/lumbda.s</code>).
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="controls">
|
<section class="controls">
|
||||||
<fieldset class="program">
|
<fieldset class="program">
|
||||||
<legend>demo program</legend>
|
<legend>demo program</legend>
|
||||||
<label><input type="radio" name="program" value="mandelbrot" checked> Mandelbrot</label>
|
<label><input type="radio" name="program" value="mandelbrot" checked> mandelbrot</label>
|
||||||
<label><input type="radio" name="program" value="fib-ack"> Fib + Ackermann</label>
|
<label><input type="radio" name="program" value="fib-ack"> fib + ackermann</label>
|
||||||
<label><input type="radio" name="program" value="sieve"> Sieve of Eratosthenes</label>
|
<label><input type="radio" name="program" value="sieve"> sieve of eratosthenes</label>
|
||||||
<label><input type="radio" name="program" value="self-interp"> Lisp-in-Lisp meta-eval</label>
|
<label><input type="radio" name="program" value="self-interp"> lisp-in-lisp meta-eval</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="tier">
|
<fieldset class="tier">
|
||||||
<legend>tier</legend>
|
<legend>tier</legend>
|
||||||
<label><input type="radio" name="tier" value="python"> Python (Pyodide)</label>
|
<label><input type="radio" name="tier" value="python"> python (pyodide)</label>
|
||||||
<label><input type="radio" name="tier" value="c" checked> C (emcc)</label>
|
<label><input type="radio" name="tier" value="c" checked> c (emcc)</label>
|
||||||
<label><input type="radio" name="tier" value="asm"> Asm (WAT)</label>
|
<label><input type="radio" name="tier" value="asm"> asm (wat)</label>
|
||||||
<label><input type="radio" name="tier" value="all"> All three</label>
|
<label><input type="radio" name="tier" value="all"> all three</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<button id="run">Run</button>
|
<button id="run" class="primary">run</button>
|
||||||
|
<button id="cancel" class="secondary">cancel</button>
|
||||||
<span id="status" class="status"></span>
|
<span id="status" class="status"></span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -66,11 +71,11 @@
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>
|
<p>
|
||||||
<strong>Asm tier note:</strong> the WAT implementation ships a minimal Lisp
|
<strong>asm tier note:</strong> the wat implementation ships a minimal lisp
|
||||||
subset (special forms, arithmetic, list ops, recursion) — enough for the
|
subset (special forms, arithmetic, list ops, recursion) — enough for the
|
||||||
four demos above. Symbol lookup is linear; would be MOAD-0001 at scale,
|
four demos above. symbol lookup is linear; would be moad-0001 at scale,
|
||||||
documented in <code>asm/lumbda.wat</code>. See
|
documented in <code>asm/lumbda.wat</code>.
|
||||||
<a href="https://lumbda.com">lumbda.com</a>.
|
see <a href="../">lumbda.</a>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|
|
||||||
BIN
wasm/app/lumbda-logo-green.png
Normal file
BIN
wasm/app/lumbda-logo-green.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
|
|
@ -1,6 +1,7 @@
|
||||||
// wasm/app/runner.js
|
// wasm/app/runner.js
|
||||||
// Tier runner — wraps the three loaders, runs a Lisp source on selected
|
// Tier runner — wraps the three loaders. Exposes a per-tier API so the
|
||||||
// tiers, returns {tier, output, error, elapsed} for each.
|
// SPA can iterate, time, and render a live elapsed-ms counter between
|
||||||
|
// the eval start and finish.
|
||||||
|
|
||||||
import { createPythonTier } from "./python/lumbda-py.js";
|
import { createPythonTier } from "./python/lumbda-py.js";
|
||||||
import { createCTier } from "./c/lumbda-c.loader.js";
|
import { createCTier } from "./c/lumbda-c.loader.js";
|
||||||
|
|
@ -8,28 +9,17 @@ import { createAsmTier } from "./asm/lumbda-asm.loader.js";
|
||||||
|
|
||||||
const cache = {};
|
const cache = {};
|
||||||
|
|
||||||
async function getTier(name, progress) {
|
export async function getTier(name, onLoad) {
|
||||||
if (cache[name]) return cache[name];
|
if (cache[name]) return cache[name];
|
||||||
progress(`loading ${name} tier…`);
|
if (onLoad) onLoad(name);
|
||||||
if (name === "python") cache[name] = await createPythonTier("./python/");
|
if (name === "python") cache[name] = await createPythonTier();
|
||||||
else if (name === "c") cache[name] = await createCTier({ baseURL: "./c/" });
|
else if (name === "c") cache[name] = await createCTier();
|
||||||
else if (name === "asm") cache[name] = await createAsmTier({ baseURL: "./asm/" });
|
else if (name === "asm") cache[name] = await createAsmTier();
|
||||||
else throw new Error(`unknown tier: ${name}`);
|
else throw new Error(`unknown tier: ${name}`);
|
||||||
return cache[name];
|
return cache[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runOnTiers(tiers, src, progress) {
|
export async function evalOnTier(name, src, onLoad) {
|
||||||
const results = [];
|
const tier = await getTier(name, onLoad);
|
||||||
for (const t of tiers) {
|
return tier.evalLisp(src);
|
||||||
const start = performance.now();
|
|
||||||
try {
|
|
||||||
const tier = await getTier(t, progress);
|
|
||||||
progress(`running on ${t}…`);
|
|
||||||
const output = await tier.evalLisp(src);
|
|
||||||
results.push({ tier: t, output, error: null, elapsed: performance.now() - start });
|
|
||||||
} catch (e) {
|
|
||||||
results.push({ tier: t, output: "", error: e.message || String(e), elapsed: performance.now() - start });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,43 @@
|
||||||
/* Lumbda playground SPA — vanilla CSS, mono terminal aesthetic */
|
/* lumbda playground — styled to match lumbda.com homepage.
|
||||||
|
* palette + typography mirror www/style.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'chunkfive';
|
||||||
|
src: url('fonts/chunkfive/chunkfive-regular-webfont.woff2') format('woff2'),
|
||||||
|
url('fonts/chunkfive/chunkfive-regular-webfont.woff') format('woff');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1115;
|
--fg: #1a1a1a;
|
||||||
--fg: #d6dadf;
|
--bg: #fafaf7;
|
||||||
--dim: #8b9099;
|
--muted: #666;
|
||||||
--accent: #6ee7b7;
|
--accent: #227842; /* single brand green — matches homepage */
|
||||||
--warn: #fbbf24;
|
--green: #227842;
|
||||||
--err: #fb7185;
|
--rule: #d4d4d0;
|
||||||
--pane: #15181f;
|
--code-bg: #f0ede4;
|
||||||
--border: #262a33;
|
--pane-bg: #ffffff;
|
||||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace;
|
--busy: #b15a00;
|
||||||
|
--err: #b1262b;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--fg: #e8e6e0;
|
||||||
|
--bg: #161613;
|
||||||
|
--muted: #9a9892;
|
||||||
|
--accent: #5ec07a; /* lightened for contrast on dark bg */
|
||||||
|
--green: #5ec07a;
|
||||||
|
--rule: #3a3a36;
|
||||||
|
--code-bg: #22221f;
|
||||||
|
--pane-bg: #1c1c19;
|
||||||
|
--busy: #e0a050;
|
||||||
|
--err: #f06070;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
@ -19,118 +47,179 @@ html, body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
height: 100%;
|
line-height: 1.55;
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Header ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
header {
|
header {
|
||||||
padding: 16px 24px 8px;
|
border-bottom: 1px solid var(--rule);
|
||||||
border-bottom: 1px solid var(--border);
|
padding: 1.5rem 1.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.brand:hover .period { transform: translateY(-2px); }
|
||||||
|
|
||||||
|
.brand .logo {
|
||||||
|
width: 3.2rem;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
margin: 0 0 4px;
|
font-family: 'chunkfive', Georgia, serif;
|
||||||
font-size: 18px;
|
font-size: 2.4rem;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
header h1 .sub {
|
|
||||||
color: var(--dim);
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
header .tag {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--dim);
|
letter-spacing: -0.01em;
|
||||||
font-size: 12px;
|
line-height: 1;
|
||||||
line-height: 1.5;
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
header h1 .period {
|
||||||
|
color: var(--green);
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .tagline {
|
||||||
|
font-family: 'chunkfive', Georgia, serif;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0.4rem 0 0.6rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .lede {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 70rem;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
header .lede strong {
|
||||||
|
color: var(--green);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
header code {
|
header code {
|
||||||
color: var(--warn);
|
background: var(--code-bg);
|
||||||
font-size: 11px;
|
padding: 0 0.2em;
|
||||||
}
|
border-radius: 2px;
|
||||||
header strong {
|
font-size: 0.9em;
|
||||||
color: var(--fg);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Controls ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
padding: 12px 24px;
|
padding: 0.75rem 1.5rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--rule);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls fieldset {
|
.controls fieldset {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--rule);
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
padding: 4px 10px 6px;
|
padding: 3px 10px 5px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
background: var(--pane-bg);
|
||||||
}
|
}
|
||||||
.controls fieldset legend {
|
.controls fieldset legend {
|
||||||
color: var(--dim);
|
color: var(--muted);
|
||||||
font-size: 11px;
|
font-size: 0.7rem;
|
||||||
padding: 0 4px;
|
padding: 0 0.4em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
.controls label {
|
.controls label {
|
||||||
margin-right: 10px;
|
margin-right: 0.7em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.controls label input {
|
||||||
|
margin-right: 0.3em;
|
||||||
|
accent-color: var(--green);
|
||||||
}
|
}
|
||||||
.controls label input { margin-right: 4px; }
|
|
||||||
|
|
||||||
.controls button {
|
.controls button {
|
||||||
background: var(--accent);
|
|
||||||
color: #0a0c0f;
|
|
||||||
border: none;
|
|
||||||
padding: 6px 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
|
font-size: 0.85em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
padding: 6px 16px;
|
||||||
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--green);
|
||||||
|
}
|
||||||
|
.controls button.primary {
|
||||||
|
background: var(--green);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
.controls button.primary:hover { filter: brightness(1.08); }
|
||||||
|
.controls button.primary:disabled {
|
||||||
|
opacity: 0.4; cursor: wait;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
.controls button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
.controls button.secondary:hover {
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
.controls button.secondary:disabled {
|
||||||
|
opacity: 0.35; cursor: default;
|
||||||
}
|
}
|
||||||
.controls button:hover { filter: brightness(1.1); }
|
|
||||||
.controls button:disabled { opacity: 0.4; cursor: wait; }
|
|
||||||
|
|
||||||
.controls .status {
|
.controls .status {
|
||||||
color: var(--dim);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 0.8em;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.controls .status.busy { color: var(--warn); }
|
.controls .status.busy { color: var(--busy); }
|
||||||
|
.controls .status.warn { color: var(--busy); }
|
||||||
.controls .status.err { color: var(--err); }
|
.controls .status.err { color: var(--err); }
|
||||||
.controls .status.ok { color: var(--accent); }
|
.controls .status.ok { color: var(--green); }
|
||||||
|
|
||||||
|
/* ─── Panes ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.panes {
|
.panes {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--border);
|
background: var(--rule);
|
||||||
height: calc(100vh - 220px);
|
height: calc(100vh - 260px);
|
||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
}
|
}
|
||||||
.pane {
|
.pane {
|
||||||
background: var(--pane);
|
background: var(--pane-bg);
|
||||||
padding: 8px 12px;
|
padding: 0.5rem 0.75rem 0.75rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.pane h2 {
|
.pane h2 {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 0.4rem;
|
||||||
font-size: 11px;
|
font-size: 0.7rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--dim);
|
color: var(--muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#editor {
|
#editor {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
.cm-editor { height: 100%; font-size: 13px; }
|
.cm-editor { height: 100%; font-size: 13px; }
|
||||||
.cm-editor.cm-focused { outline: none; }
|
.cm-editor.cm-focused { outline: none; }
|
||||||
|
|
@ -139,45 +228,54 @@ header strong {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: #0a0c10;
|
background: var(--code-bg);
|
||||||
border: 1px solid var(--border);
|
border-radius: 2px;
|
||||||
padding: 8px 10px;
|
padding: 0.6rem 0.8rem;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
#output .tier-block {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
}
|
||||||
|
#output .tier-block { margin-bottom: 1rem; }
|
||||||
|
#output .tier-block:last-child { margin-bottom: 0; }
|
||||||
#output .tier-block h3 {
|
#output .tier-block h3 {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 0.25rem;
|
||||||
font-size: 11px;
|
font-size: 0.7rem;
|
||||||
color: var(--accent);
|
color: var(--green);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.1em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
#output .tier-block .time {
|
#output .tier-block .time {
|
||||||
color: var(--dim);
|
color: var(--muted);
|
||||||
font-size: 11px;
|
font-size: 0.65rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
}
|
}
|
||||||
#output .tier-block pre {
|
#output .tier-block pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
font-family: var(--mono);
|
||||||
}
|
}
|
||||||
#output .err { color: var(--err); }
|
#output .err {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Footer ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
padding: 8px 24px;
|
padding: 0.7rem 1.5rem;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--rule);
|
||||||
color: var(--dim);
|
color: var(--muted);
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
footer code {
|
footer code {
|
||||||
color: var(--warn);
|
background: var(--code-bg);
|
||||||
|
padding: 0 0.2em;
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
footer a {
|
footer a {
|
||||||
color: var(--accent);
|
color: var(--green);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
footer a:hover { text-decoration: underline; }
|
||||||
|
footer strong { color: var(--fg); }
|
||||||
|
|
|
||||||
19
wasm/app/worker.mjs
Normal file
19
wasm/app/worker.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// wasm/app/worker.mjs
|
||||||
|
// Tier-evaluation Web Worker. Keeps the main thread responsive so the
|
||||||
|
// live ms counter actually ticks AND so cancel works (main thread
|
||||||
|
// terminates this worker via worker.terminate()).
|
||||||
|
|
||||||
|
import { evalOnTier } from "./runner.js";
|
||||||
|
|
||||||
|
self.onmessage = async (e) => {
|
||||||
|
const { kind, runId, tier, src } = e.data;
|
||||||
|
if (kind !== "eval") return;
|
||||||
|
try {
|
||||||
|
const output = await evalOnTier(tier, src, (loadingTier) => {
|
||||||
|
self.postMessage({ kind: "loading", runId, tier: loadingTier });
|
||||||
|
});
|
||||||
|
self.postMessage({ kind: "done", runId, output });
|
||||||
|
} catch (err) {
|
||||||
|
self.postMessage({ kind: "error", runId, message: err && err.message ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -5,8 +5,9 @@
|
||||||
// 0x10000 output buffer (read after each call)
|
// 0x10000 output buffer (read after each call)
|
||||||
// 0x20000 source buffer (write before each call)
|
// 0x20000 source buffer (write before each call)
|
||||||
|
|
||||||
async function _bootstrap(baseURL) {
|
async function _bootstrap() {
|
||||||
const resp = await fetch(baseURL + "lumbda-asm.wasm");
|
const wasmURL = new URL("./lumbda-asm.wasm", import.meta.url).href;
|
||||||
|
const resp = await fetch(wasmURL);
|
||||||
const bytes = await resp.arrayBuffer();
|
const bytes = await resp.arrayBuffer();
|
||||||
const { instance } = await WebAssembly.instantiate(bytes);
|
const { instance } = await WebAssembly.instantiate(bytes);
|
||||||
const exp = instance.exports;
|
const exp = instance.exports;
|
||||||
|
|
@ -37,9 +38,8 @@ async function _bootstrap(baseURL) {
|
||||||
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
|
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
|
||||||
export const createAsmTier = (() => {
|
export const createAsmTier = (() => {
|
||||||
let tier = null;
|
let tier = null;
|
||||||
return async (opts) => {
|
return async () => {
|
||||||
const baseURL = (opts && opts.baseURL) || "./asm/";
|
if (!tier) tier = await _bootstrap();
|
||||||
if (!tier) tier = await _bootstrap(baseURL);
|
|
||||||
return tier;
|
return tier;
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1176,7 +1176,14 @@
|
||||||
(loop $loop
|
(loop $loop
|
||||||
(call $skip_ws)
|
(call $skip_ws)
|
||||||
(br_if $done (i32.ge_u (global.get $source_ptr) (global.get $source_end)))
|
(br_if $done (i32.ge_u (global.get $source_ptr) (global.get $source_end)))
|
||||||
(local.set $val (call $eval (call $read) (global.get $global_env)))
|
;; Top-level env = NIL. env_lookup walks env to NIL then falls back
|
||||||
|
;; to the CURRENT global_env. Closures defined at top-level capture
|
||||||
|
;; NIL too, so when re-running a demo the new top-level defines are
|
||||||
|
;; visible without older snapshots shadowing them. Without this,
|
||||||
|
;; running mandelbrot twice on a cached instance corrupts every
|
||||||
|
;; other cell because escape-count's captured global_env points
|
||||||
|
;; into a chain that no longer reflects current bindings.
|
||||||
|
(local.set $val (call $eval (call $read) (global.get $NIL)))
|
||||||
(br $loop)))
|
(br $loop)))
|
||||||
(if (i32.ne (local.get $val) (global.get $VOID))
|
(if (i32.ne (local.get $val) (global.get $VOID))
|
||||||
(then
|
(then
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,22 @@
|
||||||
// wasm/c/lumbda-c.loader.js
|
// wasm/c/lumbda-c.loader.js
|
||||||
// C tier loader — Emscripten module wrapper.
|
// C tier loader — Emscripten ES module factory.
|
||||||
//
|
//
|
||||||
// Exports createCTier({ baseURL }) -> Promise<{ evalLisp(src) -> Promise<string> }>.
|
// Exports createCTier({ baseURL }) -> Promise<{ evalLisp(src) -> Promise<string> }>.
|
||||||
//
|
|
||||||
// Output capture: Emscripten routes stdout/stderr through Module.print /
|
// Output capture: Emscripten routes stdout/stderr through Module.print /
|
||||||
// Module.printErr callbacks. We accumulate per-eval and return joined.
|
// Module.printErr callbacks. We accumulate per-eval and return joined.
|
||||||
|
|
||||||
async function _bootstrap(baseURL) {
|
async function _bootstrap() {
|
||||||
// Pull in the emitted JS glue dynamically. Emscripten with EXPORT_ES6=0
|
// Use import.meta.url so paths resolve relative to THIS loader file —
|
||||||
// produces a UMD-ish factory script that sets globalThis.createLumbdaC.
|
// not the caller. Works in both window and Worker contexts because both
|
||||||
if (typeof createLumbdaC === "undefined") {
|
// have a defined import.meta.url for ES modules.
|
||||||
await new Promise((resolve, reject) => {
|
const factoryURL = new URL("./lumbda-c.js", import.meta.url).href;
|
||||||
const s = document.createElement("script");
|
const wasmDir = new URL("./", import.meta.url).href;
|
||||||
s.src = baseURL + "lumbda-c.js";
|
const { default: createLumbdaC } = await import(/* @vite-ignore */ factoryURL);
|
||||||
s.onload = resolve;
|
|
||||||
s.onerror = () => reject(new Error("lumbda-c.js load failed"));
|
|
||||||
document.head.appendChild(s);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let outBuf = [];
|
let outBuf = [];
|
||||||
let errBuf = [];
|
let errBuf = [];
|
||||||
const module = await createLumbdaC({
|
const module = await createLumbdaC({
|
||||||
locateFile: (p) => baseURL + p,
|
locateFile: (p) => wasmDir + p,
|
||||||
print: (line) => outBuf.push(line),
|
print: (line) => outBuf.push(line),
|
||||||
printErr: (line) => errBuf.push(line),
|
printErr: (line) => errBuf.push(line),
|
||||||
});
|
});
|
||||||
|
|
@ -52,14 +46,11 @@ async function _bootstrap(baseURL) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Closure-encapsulated singleton: no module-level mutable state. Each
|
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
|
||||||
// caller of createCTier() gets the same booted tier, but the cache lives
|
|
||||||
// inside the closure rather than at module scope.
|
|
||||||
export const createCTier = (() => {
|
export const createCTier = (() => {
|
||||||
let tier = null;
|
let tier = null;
|
||||||
return async (opts) => {
|
return async () => {
|
||||||
const baseURL = (opts && opts.baseURL) || "./c/";
|
if (!tier) tier = await _bootstrap();
|
||||||
if (!tier) tier = await _bootstrap(baseURL);
|
|
||||||
return tier;
|
return tier;
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,27 @@
|
||||||
// wasm/python/lumbda-py.js
|
// wasm/python/lumbda-py.js
|
||||||
// Python tier loader — Pyodide (CPython-in-WASM) hosting lumbda.py.
|
// Python tier loader — Pyodide (CPython-in-WASM) hosting lumbda.py.
|
||||||
//
|
//
|
||||||
|
// ES module form so it works in both window and Web Worker contexts.
|
||||||
// Exports createPythonTier() -> Promise<{ evalLisp(src) -> Promise<string> }>.
|
// Exports createPythonTier() -> Promise<{ evalLisp(src) -> Promise<string> }>.
|
||||||
// Output is whatever the program printed (via display/print/write) plus the
|
|
||||||
// final value's printed form if non-void.
|
|
||||||
|
|
||||||
const PYODIDE_VERSION = "0.27.2";
|
const PYODIDE_VERSION = "0.27.2";
|
||||||
const PYODIDE_INDEX_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
|
const PYODIDE_INDEX_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
|
||||||
|
|
||||||
async function _bootstrap(baseURL) {
|
async function _bootstrap() {
|
||||||
// Load Pyodide loader script (sets globalThis.loadPyodide).
|
// Dynamic ES-module import works in both window and Worker (module type)
|
||||||
if (typeof loadPyodide === "undefined") {
|
// contexts. The CDN ships pyodide.mjs alongside pyodide.js.
|
||||||
await new Promise((resolve, reject) => {
|
const { loadPyodide } = await import(PYODIDE_INDEX_URL + "pyodide.mjs");
|
||||||
const s = document.createElement("script");
|
|
||||||
s.src = PYODIDE_INDEX_URL + "pyodide.js";
|
|
||||||
s.onload = resolve;
|
|
||||||
s.onerror = () => reject(new Error("pyodide.js load failed"));
|
|
||||||
document.head.appendChild(s);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const pyodide = await loadPyodide({ indexURL: PYODIDE_INDEX_URL });
|
const pyodide = await loadPyodide({ indexURL: PYODIDE_INDEX_URL });
|
||||||
|
|
||||||
// Pull lumbda.py + stdlib.lsp into Pyodide's virtual FS.
|
// Pull lumbda.py + stdlib.lsp into Pyodide's virtual FS. Paths resolve
|
||||||
const lumbdaSrc = await (await fetch(baseURL + "lumbda.py")).text();
|
// relative to THIS loader (under python/) for both window and Worker.
|
||||||
const stdlibSrc = await (await fetch(baseURL + "stdlib.lsp")).text();
|
const pyURL = new URL("./lumbda.py", import.meta.url).href;
|
||||||
|
const stdlibURL = new URL("./stdlib.lsp", import.meta.url).href;
|
||||||
|
const lumbdaSrc = await (await fetch(pyURL)).text();
|
||||||
|
const stdlibSrc = await (await fetch(stdlibURL)).text();
|
||||||
pyodide.FS.writeFile("/home/pyodide/lumbda.py", lumbdaSrc);
|
pyodide.FS.writeFile("/home/pyodide/lumbda.py", lumbdaSrc);
|
||||||
pyodide.FS.writeFile("/home/pyodide/stdlib.lsp", stdlibSrc);
|
pyodide.FS.writeFile("/home/pyodide/stdlib.lsp", stdlibSrc);
|
||||||
|
|
||||||
// Initialize the lumbda environment once. We swap sys.stdout to a StringIO
|
|
||||||
// buffer per eval to capture program output.
|
|
||||||
await pyodide.runPythonAsync(`
|
await pyodide.runPythonAsync(`
|
||||||
import sys, io
|
import sys, io
|
||||||
sys.path.insert(0, "/home/pyodide")
|
sys.path.insert(0, "/home/pyodide")
|
||||||
|
|
@ -61,10 +53,8 @@ def _lumbda_eval(src):
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async evalLisp(src) {
|
async evalLisp(src) {
|
||||||
// Pass src in via globals to avoid escaping issues.
|
|
||||||
pyodide.globals.set("_src_in", src);
|
pyodide.globals.set("_src_in", src);
|
||||||
const result = await pyodide.runPythonAsync("_lumbda_eval(_src_in)");
|
return await pyodide.runPythonAsync("_lumbda_eval(_src_in)");
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -72,9 +62,8 @@ def _lumbda_eval(src):
|
||||||
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
|
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
|
||||||
export const createPythonTier = (() => {
|
export const createPythonTier = (() => {
|
||||||
let tier = null;
|
let tier = null;
|
||||||
return async (baseURL) => {
|
return async () => {
|
||||||
baseURL = baseURL || "./python/";
|
if (!tier) tier = await _bootstrap();
|
||||||
if (!tier) tier = await _bootstrap(baseURL);
|
|
||||||
return tier;
|
return tier;
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ function nativePython(demoPath) {
|
||||||
page.on("console", (msg) => {
|
page.on("console", (msg) => {
|
||||||
if (msg.type() === "error") console.log(" ⟂ console.error:", msg.text());
|
if (msg.type() === "error") console.log(" ⟂ console.error:", msg.text());
|
||||||
});
|
});
|
||||||
|
page.on("requestfailed", (req) => console.log(" ⟂ request failed:", req.url(), req.failure()?.errorText));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(baseURL, { waitUntil: "networkidle" });
|
await page.goto(baseURL, { waitUntil: "networkidle" });
|
||||||
|
|
@ -123,6 +124,26 @@ function nativePython(demoPath) {
|
||||||
const blocks = await page.locator("#output .tier-block").count();
|
const blocks = await page.locator("#output .tier-block").count();
|
||||||
check(`"all three" mode renders 3 tier blocks (got ${blocks})`, blocks === 3);
|
check(`"all three" mode renders 3 tier blocks (got ${blocks})`, blocks === 3);
|
||||||
|
|
||||||
|
// State-leak regression: run mandelbrot twice on the cached asm
|
||||||
|
// instance and assert outputs match. Prior to commit 346b873's
|
||||||
|
// env-NIL fix, every-other cell of row 2+ rendered " " instead
|
||||||
|
// of the expected shade char.
|
||||||
|
await page.locator('input[name="program"][value="mandelbrot"]').check();
|
||||||
|
await page.locator('input[name="tier"][value="asm"]').check();
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
await page.locator("#run").click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => /ok|err/i.test(document.getElementById("status").textContent),
|
||||||
|
null, { timeout: 30000 });
|
||||||
|
const asmRun1 = (await page.locator("#output pre").first().textContent()) || "";
|
||||||
|
await page.locator("#run").click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => /ok|err/i.test(document.getElementById("status").textContent),
|
||||||
|
null, { timeout: 30000 });
|
||||||
|
const asmRun2 = (await page.locator("#output pre").first().textContent()) || "";
|
||||||
|
check("asm mandelbrot stable across two consecutive runs",
|
||||||
|
asmRun1 === asmRun2 && asmRun1.length > 100);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state.fail++;
|
state.fail++;
|
||||||
console.log(" ✗ exception:", e.message);
|
console.log(" ✗ exception:", e.message);
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,8 @@ async function runAsm(src) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runC(src) {
|
async function runC(src) {
|
||||||
const { createRequire } = await import("node:module");
|
const factoryURL = "file://" + path.join(dist, "c", "lumbda-c.js");
|
||||||
const require = createRequire(import.meta.url);
|
const createLumbdaC = (await import(factoryURL)).default;
|
||||||
const createLumbdaC = require(path.join(dist, "c", "lumbda-c.js"));
|
|
||||||
let out = [];
|
let out = [];
|
||||||
const m = await createLumbdaC({
|
const m = await createLumbdaC({
|
||||||
locateFile: (p) => path.join(dist, "c", p),
|
locateFile: (p) => path.join(dist, "c", p),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
// wasm/app/app.js
|
// wasm/app/app.js
|
||||||
// Single-page app shell — CodeMirror 6 editor + tier runner.
|
// 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 { EditorState } from "@codemirror/state";
|
||||||
import { EditorView, keymap, lineNumbers, drawSelection } from "@codemirror/view";
|
import { EditorView, keymap, lineNumbers, drawSelection } from "@codemirror/view";
|
||||||
|
|
@ -8,10 +10,7 @@ import { syntaxHighlighting, defaultHighlightStyle, StreamLanguage } from "@code
|
||||||
import { scheme } from "@codemirror/legacy-modes/mode/scheme";
|
import { scheme } from "@codemirror/legacy-modes/mode/scheme";
|
||||||
import { oneDark } from "@codemirror/theme-one-dark";
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
|
|
||||||
import { runOnTiers } from "./runner.js";
|
const TIERS = { python: "python (pyodide)", c: "c (emcc)", asm: "asm (wat)" };
|
||||||
|
|
||||||
const DEMOS = ["mandelbrot", "fib-ack", "sieve", "self-interp"];
|
|
||||||
const TIERS = { python: "Python (Pyodide)", c: "C (emcc)", asm: "Asm (WAT)" };
|
|
||||||
|
|
||||||
const demoSources = {};
|
const demoSources = {};
|
||||||
|
|
||||||
|
|
@ -27,6 +26,7 @@ const editorParent = document.getElementById("editor");
|
||||||
const outputEl = document.getElementById("output");
|
const outputEl = document.getElementById("output");
|
||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
const runBtn = document.getElementById("run");
|
const runBtn = document.getElementById("run");
|
||||||
|
const cancelBtn = document.getElementById("cancel");
|
||||||
|
|
||||||
const editorView = new EditorView({
|
const editorView = new EditorView({
|
||||||
state: EditorState.create({
|
state: EditorState.create({
|
||||||
|
|
@ -71,51 +71,157 @@ function setStatus(text, cls) {
|
||||||
statusEl.className = "status" + (cls ? " " + cls : "");
|
statusEl.className = "status" + (cls ? " " + cls : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderResults(results) {
|
// ─── Worker plumbing ───────────────────────────────────────────────
|
||||||
outputEl.innerHTML = "";
|
// One worker hosts all tiers. Cancel terminates it; the next eval
|
||||||
for (const r of results) {
|
// creates a fresh one. Cached Pyodide / Emscripten state is lost on
|
||||||
const block = document.createElement("div");
|
// cancel, which is the price of true cancellation.
|
||||||
block.className = "tier-block";
|
|
||||||
const h = document.createElement("h3");
|
const workerState = { worker: null, runId: 0, pending: null };
|
||||||
h.textContent = TIERS[r.tier] || r.tier;
|
|
||||||
const t = document.createElement("span");
|
function spawnWorker() {
|
||||||
t.className = "time";
|
return new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
|
||||||
t.textContent = ` (${r.elapsed.toFixed(0)} ms)`;
|
}
|
||||||
h.appendChild(t);
|
|
||||||
block.appendChild(h);
|
function ensureWorker() {
|
||||||
const pre = document.createElement("pre");
|
if (!workerState.worker) workerState.worker = spawnWorker();
|
||||||
if (r.error) {
|
return workerState.worker;
|
||||||
pre.className = "err";
|
}
|
||||||
pre.textContent = r.error;
|
|
||||||
} else {
|
function runOnTierInWorker(tier, src, onLoading) {
|
||||||
pre.textContent = r.output;
|
return new Promise((resolve, reject) => {
|
||||||
}
|
const w = ensureWorker();
|
||||||
block.appendChild(pre);
|
const myRunId = ++workerState.runId;
|
||||||
outputEl.appendChild(block);
|
workerState.pending = { 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 === "done") {
|
||||||
|
w.removeEventListener("message", handler);
|
||||||
|
workerState.pending = null;
|
||||||
|
resolve(e.data.output);
|
||||||
|
} else if (e.data.kind === "error") {
|
||||||
|
w.removeEventListener("message", handler);
|
||||||
|
workerState.pending = null;
|
||||||
|
reject(new Error(e.data.message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
w.addEventListener("message", handler);
|
||||||
|
w.postMessage({ kind: "eval", runId: myRunId, tier, src });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelCurrentRun() {
|
||||||
|
if (workerState.worker) {
|
||||||
|
workerState.worker.terminate();
|
||||||
|
workerState.worker = null;
|
||||||
|
}
|
||||||
|
if (workerState.pending) {
|
||||||
|
workerState.pending.reject(new Error("cancelled"));
|
||||||
|
workerState.pending = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Per-tier output block with live ms counter ────────────────────
|
||||||
|
|
||||||
|
function makeLiveBlock(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 = " loading…";
|
||||||
|
h.appendChild(t);
|
||||||
|
block.appendChild(h);
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.textContent = "";
|
||||||
|
block.appendChild(pre);
|
||||||
|
outputEl.appendChild(block);
|
||||||
|
|
||||||
|
let raf = 0;
|
||||||
|
let start = 0;
|
||||||
|
function tickerLoop() {
|
||||||
|
const ms = (performance.now() - start) | 0;
|
||||||
|
t.textContent = ` ${ms} ms…`;
|
||||||
|
raf = requestAnimationFrame(tickerLoop);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
startTimer() { start = performance.now(); tickerLoop(); return start; },
|
||||||
|
stop(elapsedMs) {
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
t.textContent = ` (${elapsedMs.toFixed(0)} ms)`;
|
||||||
|
},
|
||||||
|
cancelled(elapsedMs) {
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`;
|
||||||
|
},
|
||||||
|
setOutput(text) { pre.textContent = text; },
|
||||||
|
setError(msg) { pre.className = "err"; pre.textContent = msg; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Run / cancel ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let inFlight = false;
|
||||||
|
|
||||||
async function runAll() {
|
async function runAll() {
|
||||||
|
if (inFlight) return;
|
||||||
|
inFlight = true;
|
||||||
runBtn.disabled = true;
|
runBtn.disabled = true;
|
||||||
setStatus("loading tiers…", "busy");
|
cancelBtn.disabled = false;
|
||||||
|
setStatus("running…", "busy");
|
||||||
outputEl.innerHTML = "";
|
outputEl.innerHTML = "";
|
||||||
|
let anyErr = false;
|
||||||
|
let cancelled = false;
|
||||||
try {
|
try {
|
||||||
const tiers = selectedTiers();
|
const tiers = selectedTiers();
|
||||||
const src = getEditorText();
|
const src = getEditorText();
|
||||||
const results = await runOnTiers(tiers, src, (msg) => setStatus(msg, "busy"));
|
for (const t of tiers) {
|
||||||
renderResults(results);
|
const live = makeLiveBlock(t);
|
||||||
const anyErr = results.some((r) => r.error);
|
const startMark = live.startTimer();
|
||||||
setStatus(anyErr ? "completed with errors" : "ok", anyErr ? "err" : "ok");
|
try {
|
||||||
|
const output = await runOnTierInWorker(t, src, (loadingTier) => {
|
||||||
|
setStatus(`loading ${loadingTier} tier…`, "busy");
|
||||||
|
});
|
||||||
|
setStatus(`running ${t}…`, "busy");
|
||||||
|
const elapsed = performance.now() - startMark;
|
||||||
|
live.stop(elapsed);
|
||||||
|
live.setOutput(output);
|
||||||
|
} catch (e) {
|
||||||
|
const elapsed = performance.now() - startMark;
|
||||||
|
if (e.message === "cancelled") {
|
||||||
|
live.cancelled(elapsed);
|
||||||
|
cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
live.stop(elapsed);
|
||||||
|
live.setError(e.message || String(e));
|
||||||
|
anyErr = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cancelled) setStatus("cancelled", "warn");
|
||||||
|
else setStatus(anyErr ? "completed with errors" : "ok", anyErr ? "err" : "ok");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`fatal: ${e.message}`, "err");
|
setStatus(`fatal: ${e.message}`, "err");
|
||||||
outputEl.textContent = e.stack || e.message;
|
outputEl.textContent = e.stack || e.message;
|
||||||
} finally {
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
runBtn.disabled = false;
|
runBtn.disabled = false;
|
||||||
|
cancelBtn.disabled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
if (!inFlight) return;
|
||||||
|
setStatus("cancelling…", "warn");
|
||||||
|
cancelCurrentRun();
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('input[name="program"]').forEach((el) => {
|
document.querySelectorAll('input[name="program"]').forEach((el) => {
|
||||||
el.addEventListener("change", loadCurrentDemo);
|
el.addEventListener("change", loadCurrentDemo);
|
||||||
});
|
});
|
||||||
runBtn.addEventListener("click", runAll);
|
runBtn.addEventListener("click", runAll);
|
||||||
|
cancelBtn.addEventListener("click", onCancel);
|
||||||
|
cancelBtn.disabled = true;
|
||||||
loadCurrentDemo();
|
loadCurrentDemo();
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@
|
||||||
// 0x10000 output buffer (read after each call)
|
// 0x10000 output buffer (read after each call)
|
||||||
// 0x20000 source buffer (write before each call)
|
// 0x20000 source buffer (write before each call)
|
||||||
|
|
||||||
async function _bootstrap(baseURL) {
|
async function _bootstrap() {
|
||||||
const resp = await fetch(baseURL + "lumbda-asm.wasm");
|
const wasmURL = new URL("./lumbda-asm.wasm", import.meta.url).href;
|
||||||
|
const resp = await fetch(wasmURL);
|
||||||
const bytes = await resp.arrayBuffer();
|
const bytes = await resp.arrayBuffer();
|
||||||
const { instance } = await WebAssembly.instantiate(bytes);
|
const { instance } = await WebAssembly.instantiate(bytes);
|
||||||
const exp = instance.exports;
|
const exp = instance.exports;
|
||||||
|
|
@ -37,9 +38,8 @@ async function _bootstrap(baseURL) {
|
||||||
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
|
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
|
||||||
export const createAsmTier = (() => {
|
export const createAsmTier = (() => {
|
||||||
let tier = null;
|
let tier = null;
|
||||||
return async (opts) => {
|
return async () => {
|
||||||
const baseURL = (opts && opts.baseURL) || "./asm/";
|
if (!tier) tier = await _bootstrap();
|
||||||
if (!tier) tier = await _bootstrap(baseURL);
|
|
||||||
return tier;
|
return tier;
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -1,28 +1,22 @@
|
||||||
// wasm/c/lumbda-c.loader.js
|
// wasm/c/lumbda-c.loader.js
|
||||||
// C tier loader — Emscripten module wrapper.
|
// C tier loader — Emscripten ES module factory.
|
||||||
//
|
//
|
||||||
// Exports createCTier({ baseURL }) -> Promise<{ evalLisp(src) -> Promise<string> }>.
|
// Exports createCTier({ baseURL }) -> Promise<{ evalLisp(src) -> Promise<string> }>.
|
||||||
//
|
|
||||||
// Output capture: Emscripten routes stdout/stderr through Module.print /
|
// Output capture: Emscripten routes stdout/stderr through Module.print /
|
||||||
// Module.printErr callbacks. We accumulate per-eval and return joined.
|
// Module.printErr callbacks. We accumulate per-eval and return joined.
|
||||||
|
|
||||||
async function _bootstrap(baseURL) {
|
async function _bootstrap() {
|
||||||
// Pull in the emitted JS glue dynamically. Emscripten with EXPORT_ES6=0
|
// Use import.meta.url so paths resolve relative to THIS loader file —
|
||||||
// produces a UMD-ish factory script that sets globalThis.createLumbdaC.
|
// not the caller. Works in both window and Worker contexts because both
|
||||||
if (typeof createLumbdaC === "undefined") {
|
// have a defined import.meta.url for ES modules.
|
||||||
await new Promise((resolve, reject) => {
|
const factoryURL = new URL("./lumbda-c.js", import.meta.url).href;
|
||||||
const s = document.createElement("script");
|
const wasmDir = new URL("./", import.meta.url).href;
|
||||||
s.src = baseURL + "lumbda-c.js";
|
const { default: createLumbdaC } = await import(/* @vite-ignore */ factoryURL);
|
||||||
s.onload = resolve;
|
|
||||||
s.onerror = () => reject(new Error("lumbda-c.js load failed"));
|
|
||||||
document.head.appendChild(s);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let outBuf = [];
|
let outBuf = [];
|
||||||
let errBuf = [];
|
let errBuf = [];
|
||||||
const module = await createLumbdaC({
|
const module = await createLumbdaC({
|
||||||
locateFile: (p) => baseURL + p,
|
locateFile: (p) => wasmDir + p,
|
||||||
print: (line) => outBuf.push(line),
|
print: (line) => outBuf.push(line),
|
||||||
printErr: (line) => errBuf.push(line),
|
printErr: (line) => errBuf.push(line),
|
||||||
});
|
});
|
||||||
|
|
@ -52,14 +46,11 @@ async function _bootstrap(baseURL) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Closure-encapsulated singleton: no module-level mutable state. Each
|
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
|
||||||
// caller of createCTier() gets the same booted tier, but the cache lives
|
|
||||||
// inside the closure rather than at module scope.
|
|
||||||
export const createCTier = (() => {
|
export const createCTier = (() => {
|
||||||
let tier = null;
|
let tier = null;
|
||||||
return async (opts) => {
|
return async () => {
|
||||||
const baseURL = (opts && opts.baseURL) || "./c/";
|
if (!tier) tier = await _bootstrap();
|
||||||
if (!tier) tier = await _bootstrap(baseURL);
|
|
||||||
return tier;
|
return tier;
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,11 @@
|
||||||
((= n 0) (ack (- m 1) 1))
|
((= n 0) (ack (- m 1) 1))
|
||||||
(else (ack (- m 1) (ack m (- n 1))))))
|
(else (ack (- m 1) (ack m (- n 1))))))
|
||||||
|
|
||||||
|
; Demo deliberately stays small so Pyodide finishes in a few seconds.
|
||||||
|
; (ack 3 4) is 125 but takes minutes via CPython-in-WASM tree-walker;
|
||||||
|
; cut down to (ack 3 3) so every tier can run it.
|
||||||
|
(display "fib(15) = ") (print (fib 15))
|
||||||
(display "fib(20) = ") (print (fib 20))
|
(display "fib(20) = ") (print (fib 20))
|
||||||
(display "fib(25) = ") (print (fib 25))
|
|
||||||
(display "ack(2,3) = ") (print (ack 2 3))
|
(display "ack(2,3) = ") (print (ack 2 3))
|
||||||
(display "ack(3,4) = ") (print (ack 3 4))
|
(display "ack(3,3) = ") (print (ack 3 3))
|
||||||
(print "done")
|
(print "done")
|
||||||
|
|
|
||||||
BIN
www/playground/fonts/chunkfive/chunkfive-regular-webfont.woff
Normal file
BIN
www/playground/fonts/chunkfive/chunkfive-regular-webfont.woff
Normal file
Binary file not shown.
BIN
www/playground/fonts/chunkfive/chunkfive-regular-webfont.woff2
Normal file
BIN
www/playground/fonts/chunkfive/chunkfive-regular-webfont.woff2
Normal file
Binary file not shown.
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Lumbda playground — Lisp in your browser, three tiers</title>
|
<title>lumbda playground — lisp in your browser, three tiers</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
|
|
@ -25,31 +25,36 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>Lumbda <span class="sub">— Lisp/Scheme in your browser, three tiers in parallel</span></h1>
|
<a class="brand" href="../" aria-label="lumbda home">
|
||||||
<p class="tag">
|
<img class="logo" src="lumbda-logo-green.png" alt="" aria-hidden="true">
|
||||||
Same Lisp source. Three implementations compiled to WebAssembly:
|
<h1 aria-label="lumbda.">lumbda<span class="period" aria-hidden="true">.</span></h1>
|
||||||
<strong>Python</strong> (CPython via Pyodide hosting <code>lumbda.py</code>),
|
</a>
|
||||||
<strong>C</strong> (Emscripten build of the tree-walker + bytecode VM),
|
<p class="tagline">lisp/scheme in your browser, three tiers in parallel</p>
|
||||||
<strong>Asm</strong> (hand-written WebAssembly Text format — parallel to <code>asm/lumbda.s</code>).
|
<p class="lede">
|
||||||
|
same lisp source. three implementations compiled to webassembly:
|
||||||
|
<strong>python</strong> (cpython via pyodide hosting <code>lumbda.py</code>),
|
||||||
|
<strong>c</strong> (emscripten build of the tree-walker + bytecode vm),
|
||||||
|
<strong>asm</strong> (hand-written webassembly text format — parallel to <code>asm/lumbda.s</code>).
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="controls">
|
<section class="controls">
|
||||||
<fieldset class="program">
|
<fieldset class="program">
|
||||||
<legend>demo program</legend>
|
<legend>demo program</legend>
|
||||||
<label><input type="radio" name="program" value="mandelbrot" checked> Mandelbrot</label>
|
<label><input type="radio" name="program" value="mandelbrot" checked> mandelbrot</label>
|
||||||
<label><input type="radio" name="program" value="fib-ack"> Fib + Ackermann</label>
|
<label><input type="radio" name="program" value="fib-ack"> fib + ackermann</label>
|
||||||
<label><input type="radio" name="program" value="sieve"> Sieve of Eratosthenes</label>
|
<label><input type="radio" name="program" value="sieve"> sieve of eratosthenes</label>
|
||||||
<label><input type="radio" name="program" value="self-interp"> Lisp-in-Lisp meta-eval</label>
|
<label><input type="radio" name="program" value="self-interp"> lisp-in-lisp meta-eval</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="tier">
|
<fieldset class="tier">
|
||||||
<legend>tier</legend>
|
<legend>tier</legend>
|
||||||
<label><input type="radio" name="tier" value="python"> Python (Pyodide)</label>
|
<label><input type="radio" name="tier" value="python"> python (pyodide)</label>
|
||||||
<label><input type="radio" name="tier" value="c" checked> C (emcc)</label>
|
<label><input type="radio" name="tier" value="c" checked> c (emcc)</label>
|
||||||
<label><input type="radio" name="tier" value="asm"> Asm (WAT)</label>
|
<label><input type="radio" name="tier" value="asm"> asm (wat)</label>
|
||||||
<label><input type="radio" name="tier" value="all"> All three</label>
|
<label><input type="radio" name="tier" value="all"> all three</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<button id="run">Run</button>
|
<button id="run" class="primary">run</button>
|
||||||
|
<button id="cancel" class="secondary">cancel</button>
|
||||||
<span id="status" class="status"></span>
|
<span id="status" class="status"></span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -66,11 +71,11 @@
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>
|
<p>
|
||||||
<strong>Asm tier note:</strong> the WAT implementation ships a minimal Lisp
|
<strong>asm tier note:</strong> the wat implementation ships a minimal lisp
|
||||||
subset (special forms, arithmetic, list ops, recursion) — enough for the
|
subset (special forms, arithmetic, list ops, recursion) — enough for the
|
||||||
four demos above. Symbol lookup is linear; would be MOAD-0001 at scale,
|
four demos above. symbol lookup is linear; would be moad-0001 at scale,
|
||||||
documented in <code>asm/lumbda.wat</code>. See
|
documented in <code>asm/lumbda.wat</code>.
|
||||||
<a href="https://lumbda.com">lumbda.com</a>.
|
see <a href="../">lumbda.</a>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|
|
||||||
BIN
www/playground/lumbda-logo-green.png
Normal file
BIN
www/playground/lumbda-logo-green.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
|
|
@ -1,35 +1,27 @@
|
||||||
// wasm/python/lumbda-py.js
|
// wasm/python/lumbda-py.js
|
||||||
// Python tier loader — Pyodide (CPython-in-WASM) hosting lumbda.py.
|
// Python tier loader — Pyodide (CPython-in-WASM) hosting lumbda.py.
|
||||||
//
|
//
|
||||||
|
// ES module form so it works in both window and Web Worker contexts.
|
||||||
// Exports createPythonTier() -> Promise<{ evalLisp(src) -> Promise<string> }>.
|
// Exports createPythonTier() -> Promise<{ evalLisp(src) -> Promise<string> }>.
|
||||||
// Output is whatever the program printed (via display/print/write) plus the
|
|
||||||
// final value's printed form if non-void.
|
|
||||||
|
|
||||||
const PYODIDE_VERSION = "0.27.2";
|
const PYODIDE_VERSION = "0.27.2";
|
||||||
const PYODIDE_INDEX_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
|
const PYODIDE_INDEX_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
|
||||||
|
|
||||||
async function _bootstrap(baseURL) {
|
async function _bootstrap() {
|
||||||
// Load Pyodide loader script (sets globalThis.loadPyodide).
|
// Dynamic ES-module import works in both window and Worker (module type)
|
||||||
if (typeof loadPyodide === "undefined") {
|
// contexts. The CDN ships pyodide.mjs alongside pyodide.js.
|
||||||
await new Promise((resolve, reject) => {
|
const { loadPyodide } = await import(PYODIDE_INDEX_URL + "pyodide.mjs");
|
||||||
const s = document.createElement("script");
|
|
||||||
s.src = PYODIDE_INDEX_URL + "pyodide.js";
|
|
||||||
s.onload = resolve;
|
|
||||||
s.onerror = () => reject(new Error("pyodide.js load failed"));
|
|
||||||
document.head.appendChild(s);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const pyodide = await loadPyodide({ indexURL: PYODIDE_INDEX_URL });
|
const pyodide = await loadPyodide({ indexURL: PYODIDE_INDEX_URL });
|
||||||
|
|
||||||
// Pull lumbda.py + stdlib.lsp into Pyodide's virtual FS.
|
// Pull lumbda.py + stdlib.lsp into Pyodide's virtual FS. Paths resolve
|
||||||
const lumbdaSrc = await (await fetch(baseURL + "lumbda.py")).text();
|
// relative to THIS loader (under python/) for both window and Worker.
|
||||||
const stdlibSrc = await (await fetch(baseURL + "stdlib.lsp")).text();
|
const pyURL = new URL("./lumbda.py", import.meta.url).href;
|
||||||
|
const stdlibURL = new URL("./stdlib.lsp", import.meta.url).href;
|
||||||
|
const lumbdaSrc = await (await fetch(pyURL)).text();
|
||||||
|
const stdlibSrc = await (await fetch(stdlibURL)).text();
|
||||||
pyodide.FS.writeFile("/home/pyodide/lumbda.py", lumbdaSrc);
|
pyodide.FS.writeFile("/home/pyodide/lumbda.py", lumbdaSrc);
|
||||||
pyodide.FS.writeFile("/home/pyodide/stdlib.lsp", stdlibSrc);
|
pyodide.FS.writeFile("/home/pyodide/stdlib.lsp", stdlibSrc);
|
||||||
|
|
||||||
// Initialize the lumbda environment once. We swap sys.stdout to a StringIO
|
|
||||||
// buffer per eval to capture program output.
|
|
||||||
await pyodide.runPythonAsync(`
|
await pyodide.runPythonAsync(`
|
||||||
import sys, io
|
import sys, io
|
||||||
sys.path.insert(0, "/home/pyodide")
|
sys.path.insert(0, "/home/pyodide")
|
||||||
|
|
@ -61,10 +53,8 @@ def _lumbda_eval(src):
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async evalLisp(src) {
|
async evalLisp(src) {
|
||||||
// Pass src in via globals to avoid escaping issues.
|
|
||||||
pyodide.globals.set("_src_in", src);
|
pyodide.globals.set("_src_in", src);
|
||||||
const result = await pyodide.runPythonAsync("_lumbda_eval(_src_in)");
|
return await pyodide.runPythonAsync("_lumbda_eval(_src_in)");
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -72,9 +62,8 @@ def _lumbda_eval(src):
|
||||||
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
|
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
|
||||||
export const createPythonTier = (() => {
|
export const createPythonTier = (() => {
|
||||||
let tier = null;
|
let tier = null;
|
||||||
return async (baseURL) => {
|
return async () => {
|
||||||
baseURL = baseURL || "./python/";
|
if (!tier) tier = await _bootstrap();
|
||||||
if (!tier) tier = await _bootstrap(baseURL);
|
|
||||||
return tier;
|
return tier;
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// wasm/app/runner.js
|
// wasm/app/runner.js
|
||||||
// Tier runner — wraps the three loaders, runs a Lisp source on selected
|
// Tier runner — wraps the three loaders. Exposes a per-tier API so the
|
||||||
// tiers, returns {tier, output, error, elapsed} for each.
|
// SPA can iterate, time, and render a live elapsed-ms counter between
|
||||||
|
// the eval start and finish.
|
||||||
|
|
||||||
import { createPythonTier } from "./python/lumbda-py.js";
|
import { createPythonTier } from "./python/lumbda-py.js";
|
||||||
import { createCTier } from "./c/lumbda-c.loader.js";
|
import { createCTier } from "./c/lumbda-c.loader.js";
|
||||||
|
|
@ -8,28 +9,17 @@ import { createAsmTier } from "./asm/lumbda-asm.loader.js";
|
||||||
|
|
||||||
const cache = {};
|
const cache = {};
|
||||||
|
|
||||||
async function getTier(name, progress) {
|
export async function getTier(name, onLoad) {
|
||||||
if (cache[name]) return cache[name];
|
if (cache[name]) return cache[name];
|
||||||
progress(`loading ${name} tier…`);
|
if (onLoad) onLoad(name);
|
||||||
if (name === "python") cache[name] = await createPythonTier("./python/");
|
if (name === "python") cache[name] = await createPythonTier();
|
||||||
else if (name === "c") cache[name] = await createCTier({ baseURL: "./c/" });
|
else if (name === "c") cache[name] = await createCTier();
|
||||||
else if (name === "asm") cache[name] = await createAsmTier({ baseURL: "./asm/" });
|
else if (name === "asm") cache[name] = await createAsmTier();
|
||||||
else throw new Error(`unknown tier: ${name}`);
|
else throw new Error(`unknown tier: ${name}`);
|
||||||
return cache[name];
|
return cache[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runOnTiers(tiers, src, progress) {
|
export async function evalOnTier(name, src, onLoad) {
|
||||||
const results = [];
|
const tier = await getTier(name, onLoad);
|
||||||
for (const t of tiers) {
|
return tier.evalLisp(src);
|
||||||
const start = performance.now();
|
|
||||||
try {
|
|
||||||
const tier = await getTier(t, progress);
|
|
||||||
progress(`running on ${t}…`);
|
|
||||||
const output = await tier.evalLisp(src);
|
|
||||||
results.push({ tier: t, output, error: null, elapsed: performance.now() - start });
|
|
||||||
} catch (e) {
|
|
||||||
results.push({ tier: t, output: "", error: e.message || String(e), elapsed: performance.now() - start });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,43 @@
|
||||||
/* Lumbda playground SPA — vanilla CSS, mono terminal aesthetic */
|
/* lumbda playground — styled to match lumbda.com homepage.
|
||||||
|
* palette + typography mirror www/style.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'chunkfive';
|
||||||
|
src: url('fonts/chunkfive/chunkfive-regular-webfont.woff2') format('woff2'),
|
||||||
|
url('fonts/chunkfive/chunkfive-regular-webfont.woff') format('woff');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1115;
|
--fg: #1a1a1a;
|
||||||
--fg: #d6dadf;
|
--bg: #fafaf7;
|
||||||
--dim: #8b9099;
|
--muted: #666;
|
||||||
--accent: #6ee7b7;
|
--accent: #227842; /* single brand green — matches homepage */
|
||||||
--warn: #fbbf24;
|
--green: #227842;
|
||||||
--err: #fb7185;
|
--rule: #d4d4d0;
|
||||||
--pane: #15181f;
|
--code-bg: #f0ede4;
|
||||||
--border: #262a33;
|
--pane-bg: #ffffff;
|
||||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace;
|
--busy: #b15a00;
|
||||||
|
--err: #b1262b;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--fg: #e8e6e0;
|
||||||
|
--bg: #161613;
|
||||||
|
--muted: #9a9892;
|
||||||
|
--accent: #5ec07a; /* lightened for contrast on dark bg */
|
||||||
|
--green: #5ec07a;
|
||||||
|
--rule: #3a3a36;
|
||||||
|
--code-bg: #22221f;
|
||||||
|
--pane-bg: #1c1c19;
|
||||||
|
--busy: #e0a050;
|
||||||
|
--err: #f06070;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
@ -19,118 +47,179 @@ html, body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
height: 100%;
|
line-height: 1.55;
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Header ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
header {
|
header {
|
||||||
padding: 16px 24px 8px;
|
border-bottom: 1px solid var(--rule);
|
||||||
border-bottom: 1px solid var(--border);
|
padding: 1.5rem 1.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.brand:hover .period { transform: translateY(-2px); }
|
||||||
|
|
||||||
|
.brand .logo {
|
||||||
|
width: 3.2rem;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
margin: 0 0 4px;
|
font-family: 'chunkfive', Georgia, serif;
|
||||||
font-size: 18px;
|
font-size: 2.4rem;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
header h1 .sub {
|
|
||||||
color: var(--dim);
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
header .tag {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--dim);
|
letter-spacing: -0.01em;
|
||||||
font-size: 12px;
|
line-height: 1;
|
||||||
line-height: 1.5;
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
header h1 .period {
|
||||||
|
color: var(--green);
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .tagline {
|
||||||
|
font-family: 'chunkfive', Georgia, serif;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0.4rem 0 0.6rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .lede {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 70rem;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
header .lede strong {
|
||||||
|
color: var(--green);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
header code {
|
header code {
|
||||||
color: var(--warn);
|
background: var(--code-bg);
|
||||||
font-size: 11px;
|
padding: 0 0.2em;
|
||||||
}
|
border-radius: 2px;
|
||||||
header strong {
|
font-size: 0.9em;
|
||||||
color: var(--fg);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Controls ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
padding: 12px 24px;
|
padding: 0.75rem 1.5rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--rule);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls fieldset {
|
.controls fieldset {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--rule);
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
padding: 4px 10px 6px;
|
padding: 3px 10px 5px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
background: var(--pane-bg);
|
||||||
}
|
}
|
||||||
.controls fieldset legend {
|
.controls fieldset legend {
|
||||||
color: var(--dim);
|
color: var(--muted);
|
||||||
font-size: 11px;
|
font-size: 0.7rem;
|
||||||
padding: 0 4px;
|
padding: 0 0.4em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
.controls label {
|
.controls label {
|
||||||
margin-right: 10px;
|
margin-right: 0.7em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.controls label input {
|
||||||
|
margin-right: 0.3em;
|
||||||
|
accent-color: var(--green);
|
||||||
}
|
}
|
||||||
.controls label input { margin-right: 4px; }
|
|
||||||
|
|
||||||
.controls button {
|
.controls button {
|
||||||
background: var(--accent);
|
|
||||||
color: #0a0c0f;
|
|
||||||
border: none;
|
|
||||||
padding: 6px 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
|
font-size: 0.85em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
padding: 6px 16px;
|
||||||
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--green);
|
||||||
|
}
|
||||||
|
.controls button.primary {
|
||||||
|
background: var(--green);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
.controls button.primary:hover { filter: brightness(1.08); }
|
||||||
|
.controls button.primary:disabled {
|
||||||
|
opacity: 0.4; cursor: wait;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
.controls button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
.controls button.secondary:hover {
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
.controls button.secondary:disabled {
|
||||||
|
opacity: 0.35; cursor: default;
|
||||||
}
|
}
|
||||||
.controls button:hover { filter: brightness(1.1); }
|
|
||||||
.controls button:disabled { opacity: 0.4; cursor: wait; }
|
|
||||||
|
|
||||||
.controls .status {
|
.controls .status {
|
||||||
color: var(--dim);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 0.8em;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.controls .status.busy { color: var(--warn); }
|
.controls .status.busy { color: var(--busy); }
|
||||||
|
.controls .status.warn { color: var(--busy); }
|
||||||
.controls .status.err { color: var(--err); }
|
.controls .status.err { color: var(--err); }
|
||||||
.controls .status.ok { color: var(--accent); }
|
.controls .status.ok { color: var(--green); }
|
||||||
|
|
||||||
|
/* ─── Panes ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.panes {
|
.panes {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--border);
|
background: var(--rule);
|
||||||
height: calc(100vh - 220px);
|
height: calc(100vh - 260px);
|
||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
}
|
}
|
||||||
.pane {
|
.pane {
|
||||||
background: var(--pane);
|
background: var(--pane-bg);
|
||||||
padding: 8px 12px;
|
padding: 0.5rem 0.75rem 0.75rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.pane h2 {
|
.pane h2 {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 0.4rem;
|
||||||
font-size: 11px;
|
font-size: 0.7rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--dim);
|
color: var(--muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#editor {
|
#editor {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
.cm-editor { height: 100%; font-size: 13px; }
|
.cm-editor { height: 100%; font-size: 13px; }
|
||||||
.cm-editor.cm-focused { outline: none; }
|
.cm-editor.cm-focused { outline: none; }
|
||||||
|
|
@ -139,45 +228,54 @@ header strong {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: #0a0c10;
|
background: var(--code-bg);
|
||||||
border: 1px solid var(--border);
|
border-radius: 2px;
|
||||||
padding: 8px 10px;
|
padding: 0.6rem 0.8rem;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
#output .tier-block {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
}
|
||||||
|
#output .tier-block { margin-bottom: 1rem; }
|
||||||
|
#output .tier-block:last-child { margin-bottom: 0; }
|
||||||
#output .tier-block h3 {
|
#output .tier-block h3 {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 0.25rem;
|
||||||
font-size: 11px;
|
font-size: 0.7rem;
|
||||||
color: var(--accent);
|
color: var(--green);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.1em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
#output .tier-block .time {
|
#output .tier-block .time {
|
||||||
color: var(--dim);
|
color: var(--muted);
|
||||||
font-size: 11px;
|
font-size: 0.65rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
}
|
}
|
||||||
#output .tier-block pre {
|
#output .tier-block pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
font-family: var(--mono);
|
||||||
}
|
}
|
||||||
#output .err { color: var(--err); }
|
#output .err {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Footer ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
padding: 8px 24px;
|
padding: 0.7rem 1.5rem;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--rule);
|
||||||
color: var(--dim);
|
color: var(--muted);
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
footer code {
|
footer code {
|
||||||
color: var(--warn);
|
background: var(--code-bg);
|
||||||
|
padding: 0 0.2em;
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
footer a {
|
footer a {
|
||||||
color: var(--accent);
|
color: var(--green);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
footer a:hover { text-decoration: underline; }
|
||||||
|
footer strong { color: var(--fg); }
|
||||||
|
|
|
||||||
19
www/playground/worker.mjs
Normal file
19
www/playground/worker.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// wasm/app/worker.mjs
|
||||||
|
// Tier-evaluation Web Worker. Keeps the main thread responsive so the
|
||||||
|
// live ms counter actually ticks AND so cancel works (main thread
|
||||||
|
// terminates this worker via worker.terminate()).
|
||||||
|
|
||||||
|
import { evalOnTier } from "./runner.js";
|
||||||
|
|
||||||
|
self.onmessage = async (e) => {
|
||||||
|
const { kind, runId, tier, src } = e.data;
|
||||||
|
if (kind !== "eval") return;
|
||||||
|
try {
|
||||||
|
const output = await evalOnTier(tier, src, (loadingTier) => {
|
||||||
|
self.postMessage({ kind: "loading", runId, tier: loadingTier });
|
||||||
|
});
|
||||||
|
self.postMessage({ kind: "done", runId, output });
|
||||||
|
} catch (err) {
|
||||||
|
self.postMessage({ kind: "error", runId, message: err && err.message ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue