repl: /repl/ page with encrypted multi-tab sessions

Interactive REPL at lumbda.com/repl with:
  - multi-tab sessions (click + to add, × to close, double-click to rename)
  - per-tab tier selector (python/c/asm/all-three race)
  - persistent transcripts encrypted in localStorage via Web Crypto
    (PBKDF2 + AES-GCM, vault id = SHA-256(password || device-salt) —
    same pattern as unsandbox's vault-encryption-design.md, native
    crypto.subtle API instead of CryptoJS)
  - ephemeral mode (skip vault, transcripts vanish on reload)
  - one worker per (tab × tier) — state persists across evals in a tab
  - reboot tier button (terminate this tab's worker, fresh state next eval)
  - cancel button (kills the running worker in active tab)

Home page now links to both /playground/ and /repl/.

Tier state itself does NOT persist across reloads — the transcript does,
but defines/set!/hash-tables vanish with the worker. Portal save/resume
in WAT (deferred) will let a tier session survive close+reopen.
This commit is contained in:
russell@unturf.com 2026-06-14 12:50:35 -04:00
parent 1c9d74b407
commit d8ffab5ea6
No known key found for this signature in database
42 changed files with 12859 additions and 5 deletions

View file

@ -142,6 +142,29 @@ dist-bundle: app
@mkdir -p $(DIST)/demos
@cp app/demos/*.lsp $(DIST)/demos/ 2>/dev/null || true
# ─── REPL bundle (separate page sharing tier assets) ───────────────
# The REPL deploys to www/repl/ and shares /playground/'s tier loaders
# via copied symlinks so we don't ship the WASM bytes twice.
REPL_DIST := $(DIST)-repl
repl: build
@mkdir -p $(REPL_DIST)/c $(REPL_DIST)/asm $(REPL_DIST)/python $(REPL_DIST)/fonts/chunkfive
@cp $(DIST)/c/lumbda-c.js $(REPL_DIST)/c/
@cp $(DIST)/c/lumbda-c.wasm $(REPL_DIST)/c/
@cp $(DIST)/c/lumbda-c.loader.js $(REPL_DIST)/c/
@cp $(DIST)/asm/lumbda-asm.wasm $(REPL_DIST)/asm/
@cp $(DIST)/asm/lumbda-asm.loader.js $(REPL_DIST)/asm/
@cp $(DIST)/python/lumbda.py $(REPL_DIST)/python/
@cp $(DIST)/python/stdlib.lsp $(REPL_DIST)/python/
@cp $(DIST)/python/lumbda-py.js $(REPL_DIST)/python/
@cp $(DIST)/runner.js $(REPL_DIST)/
@cp $(DIST)/worker.mjs $(REPL_DIST)/
@cp repl/index.html repl/repl.css repl/repl.js repl/crypto.js $(REPL_DIST)/
@cp $(DIST)/style.css $(REPL_DIST)/style.css
@cp $(DIST)/lumbda-logo-green.png $(REPL_DIST)/lumbda-logo-green.png
@cp $(DIST)/fonts/chunkfive/*.woff* $(REPL_DIST)/fonts/chunkfive/
# ─── Tests ─────────────────────────────────────────────────────────
test: test-unit test-integration
@ -168,10 +191,11 @@ serve: build
# ─── Deploy ────────────────────────────────────────────────────────
deploy: build
@mkdir -p $(REPO_ROOT)/www/playground
deploy: build repl
@mkdir -p $(REPO_ROOT)/www/playground $(REPO_ROOT)/www/repl
@cp -r $(DIST)/* $(REPO_ROOT)/www/playground/
@echo "Deployed to www/playground/"
@cp -r $(REPL_DIST)/* $(REPO_ROOT)/www/repl/
@echo "Deployed to www/playground/ and www/repl/"
# ─── Clean ─────────────────────────────────────────────────────────

View file

@ -0,0 +1,45 @@
// wasm/asm/lumbda-asm.loader.js
// Asm tier loader — instantiates lumbda-asm.wasm and exposes evalLisp.
//
// Memory layout (mirrors lumbda.wat):
// 0x10000 output buffer (read after each call)
// 0x20000 source buffer (write before each call)
async function _bootstrap() {
const wasmURL = new URL("./lumbda-asm.wasm", import.meta.url).href;
const resp = await fetch(wasmURL);
const bytes = await resp.arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes);
const exp = instance.exports;
exp.lumbda_init();
const enc = new TextEncoder();
const dec = new TextDecoder();
return {
async evalLisp(src) {
const srcBytes = enc.encode(src);
const srcPtr = exp.lumbda_source_ptr();
const mem = new Uint8Array(exp.memory.buffer);
mem.set(srcBytes, srcPtr);
try {
exp.lumbda_eval(srcBytes.length);
} catch (e) {
return `error: ${e.message}`;
}
const outPtr = exp.lumbda_output_ptr();
const outLen = exp.lumbda_output_len();
return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen));
},
};
}
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createAsmTier = (() => {
let tier = null;
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();

Binary file not shown.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,56 @@
// wasm/c/lumbda-c.loader.js
// C tier loader — Emscripten ES module factory.
//
// Exports createCTier({ baseURL }) -> Promise<{ evalLisp(src) -> Promise<string> }>.
// Output capture: Emscripten routes stdout/stderr through Module.print /
// Module.printErr callbacks. We accumulate per-eval and return joined.
async function _bootstrap() {
// Use import.meta.url so paths resolve relative to THIS loader file —
// not the caller. Works in both window and Worker contexts because both
// have a defined import.meta.url for ES modules.
const factoryURL = new URL("./lumbda-c.js", import.meta.url).href;
const wasmDir = new URL("./", import.meta.url).href;
const { default: createLumbdaC } = await import(/* @vite-ignore */ factoryURL);
let outBuf = [];
let errBuf = [];
const module = await createLumbdaC({
locateFile: (p) => wasmDir + p,
print: (line) => outBuf.push(line),
printErr: (line) => errBuf.push(line),
});
const _init = module.cwrap("lumbda_wasm_init", null, []);
const _eval = module.cwrap("lumbda_wasm_eval", "number", ["string"]);
const _free = module.cwrap("lumbda_wasm_free_result", null, ["number"]);
_init();
return {
async evalLisp(src) {
outBuf = [];
errBuf = [];
const errPtr = _eval(src);
let errMsg = "";
if (errPtr) {
errMsg = module.UTF8ToString(errPtr);
_free(errPtr);
}
let out = outBuf.join("\n");
if (out) out += "\n";
if (errBuf.length) out += errBuf.join("\n") + "\n";
if (errMsg) out += errMsg + "\n";
return out;
},
};
}
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createCTier = (() => {
let tier = null;
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();

BIN
wasm/dist-repl/c/lumbda-c.wasm Executable file

Binary file not shown.

104
wasm/dist-repl/crypto.js Normal file
View file

@ -0,0 +1,104 @@
// wasm/repl/crypto.js
// Encrypted-at-rest localStorage. Modeled after unsandbox crypto-utils.js
// (priv/static/js/crypto-utils.js) but uses the native Web Crypto API
// — no CryptoJS dep — so the playground and the REPL share one tiny
// vault primitive.
//
// Vault layout in localStorage:
// lumbda_salt — random 32 bytes, generated once per device
// lumbda_vault:<id> — { iv: base64, data: base64 } AES-GCM encrypted JSON
//
// vaultId = SHA-256(password || salt), hex. Same password yields the same
// vault id on this device; different password yields a different (and
// independent) vault.
const SALT_KEY = "lumbda_salt";
function buf2b64(buf) {
let binary = "";
const bytes = new Uint8Array(buf);
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
function b642buf(b64) {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function hex(buf) {
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export function getDeviceSalt() {
let salt = localStorage.getItem(SALT_KEY);
if (!salt) {
const random = crypto.getRandomValues(new Uint8Array(32));
salt = buf2b64(random);
localStorage.setItem(SALT_KEY, salt);
}
return new Uint8Array(b642buf(salt));
}
async function deriveKey(password, salt) {
const enc = new TextEncoder();
const baseKey = await crypto.subtle.importKey(
"raw", enc.encode(password), { name: "PBKDF2" }, false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 200_000, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]);
}
export async function getVaultId(password) {
const salt = getDeviceSalt();
const enc = new TextEncoder();
const data = new Uint8Array(password.length + salt.length);
data.set(enc.encode(password), 0);
data.set(salt, password.length);
const digest = await crypto.subtle.digest("SHA-256", data);
return hex(digest);
}
// Open a vault under the given password. Returns { read, write, vaultId }.
export async function openVault(password) {
const salt = getDeviceSalt();
const key = await deriveKey(password, salt);
const vaultId = await getVaultId(password);
const lsKey = "lumbda_vault:" + vaultId;
async function read() {
const raw = localStorage.getItem(lsKey);
if (!raw) return null;
let envelope;
try { envelope = JSON.parse(raw); } catch { return null; }
if (!envelope || !envelope.iv || !envelope.data) return null;
try {
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: b642buf(envelope.iv) },
key,
b642buf(envelope.data));
return JSON.parse(new TextDecoder().decode(plain));
} catch {
return { __decryptionFailed: true };
}
}
async function write(value) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = new TextEncoder();
const cipher = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
enc.encode(JSON.stringify(value)));
localStorage.setItem(lsKey, JSON.stringify({
iv: buf2b64(iv),
data: buf2b64(cipher),
}));
}
function destroy() { localStorage.removeItem(lsKey); }
return { read, write, destroy, vaultId };
}

95
wasm/dist-repl/index.html Normal file
View file

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>lumbda repl — three tiers, persistent sessions</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="repl.css">
<script type="importmap">
{
"imports": {
"@codemirror/state": "https://esm.sh/*@codemirror/state@6.4.1",
"@codemirror/view": "https://esm.sh/*@codemirror/view@6.34.1",
"@codemirror/commands": "https://esm.sh/*@codemirror/commands@6.6.2",
"@codemirror/language": "https://esm.sh/*@codemirror/language@6.10.3",
"@codemirror/legacy-modes/mode/scheme": "https://esm.sh/*@codemirror/legacy-modes@6.4.1/mode/scheme",
"@codemirror/theme-one-dark": "https://esm.sh/*@codemirror/theme-one-dark@6.1.2",
"@lezer/highlight": "https://esm.sh/*@lezer/highlight@1.2.1",
"@lezer/common": "https://esm.sh/*@lezer/common@1.2.3",
"style-mod": "https://esm.sh/*style-mod@4.1.2",
"crelt": "https://esm.sh/*crelt@1.0.6",
"w3c-keyname": "https://esm.sh/*w3c-keyname@2.2.8"
}
}
</script>
</head>
<body class="repl">
<header>
<a class="brand" href="../" aria-label="lumbda home">
<img class="logo" src="lumbda-logo-green.png" alt="" aria-hidden="true">
<h1 aria-label="lumbda.">lumbda<span class="period" aria-hidden="true">.</span></h1>
</a>
<p class="tagline">three tiers, persistent sessions</p>
<p class="lede">
interactive repl — each tab keeps its own tier state. <kbd>enter</kbd>
sends, <kbd>shift+enter</kbd> for newline. transcripts persist in
encrypted localStorage; supply a password and the same password
unlocks them after a reload. for one-shot demos, see the
<a href="../playground/">playground</a>.
</p>
</header>
<div id="lock-screen" class="lock-screen">
<div class="lock-card">
<h2>unlock</h2>
<p>enter a password — transcripts are encrypted with it locally.
a fresh password starts a fresh vault.</p>
<input id="password" type="password" autocomplete="off" autofocus placeholder="password">
<div class="lock-row">
<button id="unlock" class="primary">unlock vault</button>
<button id="fresh" class="secondary" title="skip the vault, run ephemerally — transcripts vanish on reload">ephemeral</button>
</div>
<p class="lock-note" id="lock-note"></p>
</div>
</div>
<section class="tabbar" id="tabbar" hidden>
<div class="tabs" id="tabs"></div>
<button id="new-tab" class="ghost" title="new repl session">+</button>
<span class="spacer"></span>
<button id="reset-tier" class="ghost" title="reboot the worker for the current tab — state lost">↻ reboot tier</button>
<button id="clear-log" class="ghost" title="clear the transcript">clear log</button>
<button id="cancel" class="ghost" disabled>cancel</button>
<button id="lock" class="ghost" title="lock vault — passes back to lock screen">lock</button>
</section>
<main id="transcript-wrap" class="transcript-wrap" hidden>
<div id="transcript" class="transcript"></div>
</main>
<section class="prompt-bar" id="prompt-bar" hidden>
<span class="prompt-sigil">λ&gt;</span>
<textarea id="input" rows="1" spellcheck="false"
placeholder="(+ 1 2) — enter to send, shift+enter for newline"></textarea>
<select id="tier-select" title="which tier evaluates this input">
<option value="python">python (pyodide)</option>
<option value="c" selected>c (emcc)</option>
<option value="asm">asm (wat)</option>
<option value="all">all three (race)</option>
</select>
<button id="send" class="primary">send</button>
</section>
<footer>
<p>
transcripts saved encrypted with your password. tier state (defines,
set!, hash table contents) lives in the worker; lost on reload. lift
a session via portal save when that lands in WAT. <a href="../">lumbda.</a>
</p>
</footer>
<script type="module" src="repl.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,69 @@
// wasm/python/lumbda-py.js
// 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> }>.
const PYODIDE_VERSION = "0.27.2";
const PYODIDE_INDEX_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
async function _bootstrap() {
// Dynamic ES-module import works in both window and Worker (module type)
// contexts. The CDN ships pyodide.mjs alongside pyodide.js.
const { loadPyodide } = await import(PYODIDE_INDEX_URL + "pyodide.mjs");
const pyodide = await loadPyodide({ indexURL: PYODIDE_INDEX_URL });
// Pull lumbda.py + stdlib.lsp into Pyodide's virtual FS. Paths resolve
// relative to THIS loader (under python/) for both window and Worker.
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/stdlib.lsp", stdlibSrc);
await pyodide.runPythonAsync(`
import sys, io
sys.path.insert(0, "/home/pyodide")
import lumbda
_env = lumbda.make_global_env()
for _e in lumbda.read_all(lumbda.PRELUDE):
lumbda.leval(_e, _env)
def _lumbda_eval(src):
buf = io.StringIO()
old = sys.stdout
sys.stdout = buf
last = None
try:
for e in lumbda.read_all(src):
last = lumbda.leval(e, _env)
except Exception as ex:
sys.stdout = old
return f"{buf.getvalue()}error: {ex}"
sys.stdout = old
out = buf.getvalue()
if last is not None and not isinstance(last, lumbda._Void):
rep = lumbda.show(last)
if out and not out.endswith("\\n"):
out += "\\n"
out += rep
return out
`);
return {
async evalLisp(src) {
pyodide.globals.set("_src_in", src);
return await pyodide.runPythonAsync("_lumbda_eval(_src_in)");
},
};
}
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createPythonTier = (() => {
let tier = null;
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,385 @@
;;; stdlib.lsp — standard library for lumbda
;;; Load with: (load "stdlib.lsp")
;;; Automatically loaded by the interpreter if found next to lumbda.py.
;;;; ── Syntax-rules versions of core macros ────────────────────────────────
(define-syntax my-let
(syntax-rules ()
((my-let ((var val) ...) body ...)
((lambda (var ...) body ...) val ...))))
(define-syntax my-let*
(syntax-rules ()
((my-let* () body ...)
(begin body ...))
((my-let* ((var val) rest ...) body ...)
(let ((var val)) (my-let* (rest ...) body ...)))))
(define-syntax my-and
(syntax-rules ()
((my-and) #t)
((my-and e) e)
((my-and e1 e2 ...)
(if e1 (my-and e2 ...) #f))))
(define-syntax my-or
(syntax-rules ()
((my-or) #f)
((my-or e) e)
((my-or e1 e2 ...)
(let ((t e1))
(if t t (my-or e2 ...))))))
(define-syntax my-cond
(syntax-rules (else =>)
((my-cond (else e ...)) (begin e ...))
((my-cond (test => f) rest ...)
(let ((t test)) (if t (f t) (my-cond rest ...))))
((my-cond (test e ...) rest ...)
(if test (begin e ...) (my-cond rest ...)))
((my-cond) (void))))
(define-syntax my-case
(syntax-rules (else)
((my-case key (else e ...)) (begin e ...))
((my-case key ((datum ...) e ...) rest ...)
(if (memv key '(datum ...))
(begin e ...)
(my-case key rest ...)))
((my-case key) (void))))
(define-syntax my-when
(syntax-rules ()
((my-when test body ...)
(if test (begin body ...) (void)))))
(define-syntax my-unless
(syntax-rules ()
((my-unless test body ...)
(if test (void) (begin body ...)))))
(define-syntax my-do
(syntax-rules ()
((my-do ((var init step ...) ...)
(test result ...)
body ...)
(let loop ((var init) ...)
(if test
(begin result ...)
(begin body ...
(loop (if (null? '(step ...)) var (car '(step ...))) ...)))))))
;;;; ── Pattern-matched swap ────────────────────────────────────────────────
(define-syntax swap!
(syntax-rules ()
((swap! a b)
(let ((tmp a))
(set! a b)
(set! b tmp)))))
;;;; ── fluid-let ──────────────────────────────────────────────────────────
(define-syntax fluid-let
(syntax-rules ()
((fluid-let ((var val) ...) body ...)
(let ((old-var var) ...)
(set! var val) ...
(let ((result (begin body ...)))
(set! var old-var) ...
result)))))
;;;; ── receive (SRFI-8) ───────────────────────────────────────────────────
(define-syntax receive
(syntax-rules ()
((receive formals expression body ...)
(call-with-values (lambda () expression)
(lambda formals body ...)))))
;;;; ── begin0 ────────────────────────────────────────────────────────────
(define-syntax begin0
(syntax-rules ()
((begin0 first rest ...)
(let ((result first))
rest ...
result))))
;;;; ── while / until ──────────────────────────────────────────────────────
(define-syntax while
(syntax-rules ()
((while test body ...)
(let loop ()
(when test body ... (loop))))))
(define-syntax until
(syntax-rules ()
((until test body ...)
(let loop ()
(unless test body ... (loop))))))
;;;; ── dotimes / dolist ───────────────────────────────────────────────────
(define-syntax dotimes
(syntax-rules ()
((dotimes (var n result ...) body ...)
(let loop ((var 0))
(if (= var n)
(begin result ...)
(begin body ... (loop (+ var 1))))))))
(define-syntax dolist
(syntax-rules ()
((dolist (var lst result ...) body ...)
(begin
(for-each (lambda (var) body ...) lst)
result ...))))
;;;; ── push! / pop! ───────────────────────────────────────────────────────
(define-syntax push!
(syntax-rules ()
((push! val lst)
(set! lst (cons val lst)))))
(define-syntax pop!
(syntax-rules ()
((pop! lst)
(let ((top (car lst)))
(set! lst (cdr lst))
top))))
;;;; ── and-let* (SRFI-2) ─────────────────────────────────────────────────
(define-syntax and-let*
(syntax-rules ()
((and-let* () body ...) (begin body ...))
((and-let* ((var expr) rest ...) body ...)
(let ((var expr))
(if var (and-let* (rest ...) body ...) #f)))
((and-let* ((expr) rest ...) body ...)
(if expr (and-let* (rest ...) body ...) #f))))
;;;; ── string utilities ───────────────────────────────────────────────────
(define (string-repeat s n)
(apply string-append (map (lambda (_) s) (iota n))))
(define (string-pad-left s len ch)
(let ((pad (- len (string-length s))))
(if (<= pad 0) s
(string-append (make-string pad ch) s))))
(define (string-pad-right s len ch)
(let ((pad (- len (string-length s))))
(if (<= pad 0) s
(string-append s (make-string pad ch)))))
(define (string->chars s) (string->list s))
(define (chars->string cs) (list->string cs))
;;;; ── list utilities ─────────────────────────────────────────────────────
(define (list-update! lst i val)
(list-set! lst i val)
lst)
(define (enumerate lst)
(map list (iota (length lst)) lst))
(define (transpose lsts)
(apply map list lsts))
(define (interleave lst sep)
(if (or (null? lst) (null? (cdr lst)))
lst
(cons (car lst) (cons sep (interleave (cdr lst) sep)))))
(define (chunks lst n)
(if (null? lst)
'()
(cons (take lst (min n (length lst)))
(chunks (drop lst n) n))))
(define (repeat-list x n)
(map (lambda (_) x) (iota n)))
(define (zip-with f . lsts)
(apply map f lsts))
(define (sum lst) (fold-left + 0 lst))
(define (product lst) (fold-left * 1 lst))
(define (maximum lst) (fold-left max (car lst) (cdr lst)))
(define (minimum lst) (fold-left min (car lst) (cdr lst)))
(define (average lst) (/ (sum lst) (length lst)))
;;;; ── numeric utilities ──────────────────────────────────────────────────
(define (clamp x lo hi) (max lo (min hi x)))
(define (between? x lo hi) (and (>= x lo) (<= x hi)))
(define (factorial n)
(let loop ((i n) (acc 1))
(if (<= i 1) acc (loop (- i 1) (* acc i)))))
(define (fib n)
(let loop ((a 0) (b 1) (i 0))
(if (= i n) a (loop b (+ a b) (+ i 1)))))
(define (prime? n)
(if (< n 2) #f
(let loop ((i 2))
(cond ((> (* i i) n) #t)
((= (remainder n i) 0) #f)
(else (loop (+ i 1)))))))
(define (primes-up-to n)
(filter prime? (range 2 (+ n 1))))
;;;; ── I/O utilities ──────────────────────────────────────────────────────
(define (println . args)
(for-each (lambda (x) (display x) (display " ")) args)
(newline))
(define (print-table rows)
(for-each (lambda (row)
(for-each (lambda (cell) (display cell) (display "\t")) row)
(newline))
rows))
(define (with-output-string thunk)
(with-output-to-string thunk))
;;;; ── association-list utilities ─────────────────────────────────────────
(define (alist-get key alist . default)
(let ((pair (assoc key alist)))
(if pair (cdr pair)
(if (null? default) #f (car default)))))
(define (alist-set key val alist)
(cons (cons key val)
(filter (lambda (p) (not (equal? (car p) key))) alist)))
(define (alist-remove key alist)
(filter (lambda (p) (not (equal? (car p) key))) alist))
(define (alist-keys alist) (map car alist))
(define (alist-values alist) (map cdr alist))
;;;; ── hash-table utilities ───────────────────────────────────────────────
(define (hash-table-map h f)
(let ((result (make-hash-table)))
(hash-table-walk h (lambda (k v) (hash-table-set! result k (f v))))
result))
(define (hash-table-filter h pred)
(let ((result (make-hash-table)))
(hash-table-walk h (lambda (k v) (when (pred k v) (hash-table-set! result k v))))
result))
(define (hash-table-from-lists keys vals)
(let ((h (make-hash-table)))
(for-each (lambda (k v) (hash-table-set! h k v)) keys vals)
h))
;;;; ── tree utilities ─────────────────────────────────────────────────────
(define (tree-map f tree)
(if (pair? tree)
(cons (tree-map f (car tree)) (tree-map f (cdr tree)))
(f tree)))
(define (tree-fold f init tree)
(if (pair? tree)
(tree-fold f (tree-fold f init (car tree)) (cdr tree))
(f init tree)))
(define (tree-member? x tree)
(cond ((null? tree) #f)
((equal? x tree) #t)
((pair? tree) (or (tree-member? x (car tree))
(tree-member? x (cdr tree))))
(else #f)))
;;;; ── simple object system ───────────────────────────────────────────────
;;; (make-object methods-alist) → an object
;;; (send obj 'method arg...) → dispatch
(define (make-object methods)
(lambda (msg . args)
(let ((m (assoc msg methods)))
(if m
(apply (cdr m) args)
(error "unknown method" msg)))))
(define (send obj msg . args)
(apply obj msg args))
;;;; ── coroutine via call/cc ───────────────────────────────────────────────
(define (make-generator thunk)
(let ((k #f) (done #f))
(lambda ()
(if done 'done
(call/cc
(lambda (return)
(if k
(k return)
(begin
(thunk (lambda (val)
(call/cc (lambda (next)
(set! k next)
(return val)))))
(set! done #t)
(return 'done)))))))))
;;; ─── SRFI-64 lightweight test framework ─────────────────────────────────────
(define *test-pass* 0)
(define *test-fail* 0)
(define *test-group* "")
(define *test-verbose* #f)
(define (test-begin name)
(set! *test-group* name)
(set! *test-pass* 0)
(set! *test-fail* 0)
(display (string-append "--- " name " ---\n")))
(define (test-end)
(display (string-append *test-group* ": "
(number->string *test-pass*) " passed, "
(number->string *test-fail*) " failed\n"))
(= *test-fail* 0))
(define (test-assert msg val)
(if val
(begin (set! *test-pass* (+ *test-pass* 1))
(when *test-verbose* (display (string-append " OK " msg "\n"))))
(begin (set! *test-fail* (+ *test-fail* 1))
(display (string-append " FAIL " msg "\n")))))
(define-macro (test-equal msg expected expr)
(let ((r (gensym)) (e (gensym)))
`(let ((,r ,expr) (,e ,expected))
(if (equal? ,r ,e)
(begin (set! *test-pass* (+ *test-pass* 1))
(when *test-verbose* (display (string-append " OK " ,msg "\n"))))
(begin (set! *test-fail* (+ *test-fail* 1))
(display (string-append " FAIL " ,msg ": got " (write-to-string ,r)
" expected " (write-to-string ,e) "\n")))))))
(define-macro (test-error msg expr)
(let ((ok (gensym)))
`(let ((,ok (guard (e (#t #t)) ,expr #f)))
(if ,ok
(begin (set! *test-pass* (+ *test-pass* 1))
(when *test-verbose* (display (string-append " OK " ,msg " (error)\n"))))
(begin (set! *test-fail* (+ *test-fail* 1))
(display (string-append " FAIL " ,msg " (expected error)\n")))))))

226
wasm/dist-repl/repl.css Normal file
View file

@ -0,0 +1,226 @@
/* lumbda repl specific styles — inherits style.css palette. */
body.repl { display: flex; flex-direction: column; min-height: 100vh; }
.lock-screen {
position: fixed; inset: 0; z-index: 1000;
display: flex; align-items: center; justify-content: center;
background: rgba(15, 17, 21, 0.85);
backdrop-filter: blur(4px);
}
.lock-card {
background: var(--pane-bg);
border: 1px solid var(--rule);
border-radius: 6px;
padding: 1.5rem 2rem;
max-width: 28rem;
width: 90%;
}
.lock-card h2 {
margin: 0 0 0.5rem;
font-family: 'chunkfive', Georgia, serif;
font-size: 1.4rem;
color: var(--green);
font-weight: normal;
}
.lock-card p {
margin: 0 0 0.8rem;
font-size: 0.85rem;
color: var(--muted);
line-height: 1.5;
}
.lock-card input[type=password] {
width: 100%;
padding: 0.5rem 0.7rem;
margin: 0.4rem 0;
background: var(--code-bg);
color: var(--fg);
border: 1px solid var(--rule);
border-radius: 3px;
font-family: var(--mono);
font-size: 0.95rem;
}
.lock-card input[type=password]:focus {
outline: none;
border-color: var(--green);
}
.lock-row { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
.lock-row button {
font-family: var(--mono);
font-size: 0.85em; font-weight: 600;
padding: 0.45rem 1.1rem;
border-radius: 3px;
cursor: pointer;
border: 1px solid var(--green);
}
.lock-row .primary { background: var(--green); color: var(--bg); }
.lock-row .secondary { background: transparent; color: var(--green); }
.lock-row .primary:hover { filter: brightness(1.1); }
.lock-row .secondary:hover { background: var(--code-bg); }
.lock-note {
margin: 0.6rem 0 0;
color: var(--err);
font-size: 0.8rem;
min-height: 1.2em;
}
.tabbar {
display: flex;
gap: 0.3rem;
align-items: center;
padding: 0.4rem 1rem;
border-bottom: 1px solid var(--rule);
background: var(--bg);
}
.tabs {
display: flex;
gap: 0.3rem;
overflow-x: auto;
flex: 0 1 auto;
}
.tab {
background: var(--pane-bg);
border: 1px solid var(--rule);
padding: 0.25rem 0.6rem;
border-radius: 3px 3px 0 0;
font-size: 0.85em;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.3rem;
white-space: nowrap;
}
.tab.active {
background: var(--code-bg);
border-bottom-color: var(--code-bg);
color: var(--green);
}
.tab .tab-close {
opacity: 0.5;
cursor: pointer;
padding: 0 0.2rem;
}
.tab .tab-close:hover { opacity: 1; color: var(--err); }
.tabbar .ghost {
background: transparent;
border: 1px solid var(--rule);
color: var(--muted);
font-family: var(--mono);
font-size: 0.8em;
padding: 0.2rem 0.6rem;
border-radius: 3px;
cursor: pointer;
}
.tabbar .ghost:hover:not(:disabled) {
border-color: var(--green); color: var(--green);
}
.tabbar .ghost:disabled { opacity: 0.4; cursor: default; }
.tabbar .spacer { flex: 1; }
.transcript-wrap {
flex: 1;
overflow: auto;
padding: 1rem;
background: var(--code-bg);
}
.transcript {
font-family: var(--mono);
font-size: 0.9em;
line-height: 1.5;
max-width: 90rem;
margin: 0 auto;
}
.transcript .entry {
margin-bottom: 0.8rem;
padding: 0.4rem 0.6rem;
border-left: 2px solid var(--rule);
}
.transcript .entry .prompt-line {
color: var(--green);
white-space: pre-wrap;
margin-bottom: 0.2rem;
}
.transcript .entry .tier-output {
display: flex;
gap: 0.6rem;
padding: 0.25rem 0;
border-top: 1px dashed var(--rule);
}
.transcript .entry .tier-output:first-of-type { border-top: none; }
.transcript .entry .tier-label {
color: var(--muted);
font-size: 0.7em;
text-transform: uppercase;
letter-spacing: 0.08em;
min-width: 7rem;
flex-shrink: 0;
padding-top: 0.15rem;
}
.transcript .entry .tier-result {
white-space: pre-wrap;
flex: 1;
}
.transcript .entry .tier-time {
color: var(--muted);
font-size: 0.8em;
margin-left: 0.4rem;
}
.transcript .entry.error .tier-result { color: var(--err); }
.prompt-bar {
display: flex;
align-items: flex-end;
gap: 0.5rem;
padding: 0.5rem 1rem 0.8rem;
border-top: 1px solid var(--rule);
background: var(--bg);
}
.prompt-bar .prompt-sigil {
color: var(--green);
font-weight: 600;
padding: 0.4rem 0 0;
}
.prompt-bar textarea {
flex: 1;
resize: none;
background: var(--code-bg);
border: 1px solid var(--rule);
color: var(--fg);
padding: 0.45rem 0.7rem;
font-family: var(--mono);
font-size: 0.9em;
border-radius: 3px;
min-height: 1.6em;
max-height: 30vh;
}
.prompt-bar textarea:focus { outline: none; border-color: var(--green); }
.prompt-bar select {
background: var(--code-bg);
border: 1px solid var(--rule);
color: var(--fg);
padding: 0.35rem 0.5rem;
font-family: var(--mono);
font-size: 0.85em;
border-radius: 3px;
}
.prompt-bar button.primary {
background: var(--green);
color: var(--bg);
border: 1px solid var(--green);
padding: 0.4rem 1rem;
font-family: var(--mono);
font-weight: 600;
border-radius: 3px;
cursor: pointer;
}
.prompt-bar button.primary:hover { filter: brightness(1.1); }
.prompt-bar button.primary:disabled { opacity: 0.4; cursor: wait; }
kbd {
font-family: var(--mono);
background: var(--code-bg);
padding: 0.05em 0.3em;
border: 1px solid var(--rule);
border-radius: 2px;
font-size: 0.85em;
}

360
wasm/dist-repl/repl.js Normal file
View file

@ -0,0 +1,360 @@
// wasm/repl/repl.js
// Lumbda interactive REPL with multi-tab sessions + encrypted persistence.
import { openVault } from "./crypto.js";
const TIER_LABEL = {
python: "python (pyodide)",
c: "c (emcc)",
asm: "asm (wat)",
};
// ─── DOM ────────────────────────────────────────────────────────────
const lockScreen = document.getElementById("lock-screen");
const passwordEl = document.getElementById("password");
const unlockBtn = document.getElementById("unlock");
const freshBtn = document.getElementById("fresh");
const lockNote = document.getElementById("lock-note");
const tabbar = document.getElementById("tabbar");
const tabsEl = document.getElementById("tabs");
const newTabBtn = document.getElementById("new-tab");
const resetTierBtn = document.getElementById("reset-tier");
const clearLogBtn = document.getElementById("clear-log");
const cancelBtn = document.getElementById("cancel");
const lockBtn = document.getElementById("lock");
const transcriptWrap = document.getElementById("transcript-wrap");
const transcriptEl = document.getElementById("transcript");
const promptBar = document.getElementById("prompt-bar");
const inputEl = document.getElementById("input");
const tierSelectEl = document.getElementById("tier-select");
const sendBtn = document.getElementById("send");
// ─── State ──────────────────────────────────────────────────────────
const state = {
vault: null, // { read, write, destroy, vaultId } or null (ephemeral)
tabs: [], // [{ id, name, tier, transcript: [{kind, text, results?}] }]
activeTabId: null,
workers: {}, // { [tabId+":"+tier]: Worker }
pending: {}, // same key → { runId, reject }
nextRunId: 1,
nextTabId: 1,
saveTimer: null,
};
// ─── Vault ──────────────────────────────────────────────────────────
async function saveSoon() {
if (!state.vault) return;
if (state.saveTimer) clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(async () => {
try {
await state.vault.write({
tabs: state.tabs,
activeTabId: state.activeTabId,
savedAt: Date.now(),
});
} catch (e) {
console.error("vault write failed:", e);
}
}, 400);
}
async function tryUnlock() {
const pw = passwordEl.value;
if (!pw) { lockNote.textContent = "password required (or click ephemeral)"; return; }
lockNote.textContent = "";
try {
state.vault = await openVault(pw);
const data = await state.vault.read();
if (data && data.__decryptionFailed) {
// Wrong password for an existing vault under this id.
lockNote.textContent = "vault exists but password is wrong";
state.vault = null;
return;
}
if (data && Array.isArray(data.tabs) && data.tabs.length > 0) {
state.tabs = data.tabs;
state.activeTabId = data.activeTabId || data.tabs[0].id;
state.nextTabId = Math.max(...state.tabs.map((t) => t.id)) + 1;
} else {
state.tabs = [];
state.activeTabId = null;
state.nextTabId = 1;
}
enterRepl();
} catch (e) {
lockNote.textContent = "unlock failed: " + e.message;
}
}
function enterEphemeral() {
state.vault = null;
state.tabs = [];
state.activeTabId = null;
state.nextTabId = 1;
enterRepl();
}
function enterRepl() {
lockScreen.hidden = true;
tabbar.hidden = false;
transcriptWrap.hidden = false;
promptBar.hidden = false;
if (state.tabs.length === 0) newTab();
else renderAll();
inputEl.focus();
}
function relock() {
// Terminate all workers, clear in-memory state, return to lock screen.
for (const k of Object.keys(state.workers)) {
try { state.workers[k].terminate(); } catch {}
}
state.workers = {};
state.pending = {};
state.vault = null;
state.tabs = [];
state.activeTabId = null;
transcriptEl.innerHTML = "";
tabsEl.innerHTML = "";
tabbar.hidden = true;
transcriptWrap.hidden = true;
promptBar.hidden = true;
lockScreen.hidden = false;
passwordEl.value = "";
passwordEl.focus();
}
// ─── Tabs ───────────────────────────────────────────────────────────
function newTab(name) {
const id = state.nextTabId++;
name = name || `session ${id}`;
state.tabs.push({ id, name, tier: "c", transcript: [] });
state.activeTabId = id;
renderAll();
saveSoon();
}
function closeTab(id) {
const idx = state.tabs.findIndex((t) => t.id === id);
if (idx < 0) return;
// Terminate this tab's workers.
for (const k of Object.keys(state.workers)) {
if (k.startsWith(id + ":")) {
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
state.tabs.splice(idx, 1);
if (state.activeTabId === id) {
state.activeTabId = state.tabs.length ? state.tabs[Math.max(0, idx - 1)].id : null;
}
if (state.tabs.length === 0) newTab();
else renderAll();
saveSoon();
}
function setActiveTab(id) {
state.activeTabId = id;
renderAll();
saveSoon();
}
function activeTab() {
return state.tabs.find((t) => t.id === state.activeTabId);
}
// ─── Workers ────────────────────────────────────────────────────────
function workerKey(tabId, tier) { return `${tabId}:${tier}`; }
function spawnWorker(tabId, tier) {
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
state.workers[workerKey(tabId, tier)] = w;
return w;
}
function ensureWorker(tabId, tier) {
const k = workerKey(tabId, tier);
if (!state.workers[k]) spawnWorker(tabId, tier);
return state.workers[k];
}
function evalInTier(tabId, tier, src) {
return new Promise((resolve, reject) => {
const w = ensureWorker(tabId, tier);
const runId = state.nextRunId++;
const pendingKey = workerKey(tabId, tier);
state.pending[pendingKey] = { runId, reject };
const handler = (e) => {
if (e.data.runId !== runId) return;
if (e.data.kind === "done") {
w.removeEventListener("message", handler);
delete state.pending[pendingKey];
resolve(e.data.output);
} else if (e.data.kind === "error") {
w.removeEventListener("message", handler);
delete state.pending[pendingKey];
reject(new Error(e.data.message));
}
};
w.addEventListener("message", handler);
w.postMessage({ kind: "eval", runId, tier, src });
});
}
function rebootTier(tabId, tier) {
const k = workerKey(tabId, tier);
if (state.workers[k]) {
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
if (state.pending[k]) {
try { state.pending[k].reject(new Error("rebooted")); } catch {}
delete state.pending[k];
}
}
function cancelAllPendingInActiveTab() {
const tab = activeTab();
if (!tab) return;
for (const k of Object.keys(state.pending)) {
if (k.startsWith(tab.id + ":")) {
try { state.pending[k].reject(new Error("cancelled")); } catch {}
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
delete state.pending[k];
}
}
}
// ─── Render ─────────────────────────────────────────────────────────
function renderAll() {
// Tabs
tabsEl.innerHTML = "";
for (const t of state.tabs) {
const el = document.createElement("div");
el.className = "tab" + (t.id === state.activeTabId ? " active" : "");
const name = document.createElement("span");
name.textContent = t.name;
name.addEventListener("click", () => setActiveTab(t.id));
name.addEventListener("dblclick", (e) => {
e.stopPropagation();
const newName = prompt("rename tab:", t.name);
if (newName) { t.name = newName.slice(0, 40); renderAll(); saveSoon(); }
});
const close = document.createElement("span");
close.className = "tab-close";
close.textContent = "×";
close.title = "close tab";
close.addEventListener("click", (e) => { e.stopPropagation(); closeTab(t.id); });
el.appendChild(name);
el.appendChild(close);
tabsEl.appendChild(el);
}
// Transcript
transcriptEl.innerHTML = "";
const tab = activeTab();
if (!tab) return;
tierSelectEl.value = tab.tier;
for (const entry of tab.transcript) {
const block = document.createElement("div");
block.className = "entry" + (entry.kind === "error" ? " error" : "");
const prompt = document.createElement("div");
prompt.className = "prompt-line";
prompt.textContent = "λ> " + entry.input;
block.appendChild(prompt);
for (const r of (entry.results || [])) {
const row = document.createElement("div");
row.className = "tier-output";
const lab = document.createElement("div");
lab.className = "tier-label";
lab.textContent = TIER_LABEL[r.tier] || r.tier;
const tm = document.createElement("span");
tm.className = "tier-time";
tm.textContent = ` ${(r.elapsed | 0)}ms`;
lab.appendChild(tm);
const out = document.createElement("div");
out.className = "tier-result";
out.textContent = r.output || (r.error ? "error: " + r.error : "");
row.appendChild(lab);
row.appendChild(out);
block.appendChild(row);
}
transcriptEl.appendChild(block);
}
transcriptWrap.scrollTop = transcriptWrap.scrollHeight;
}
// ─── Input handling ─────────────────────────────────────────────────
async function sendInput() {
const src = inputEl.value.trim();
if (!src) return;
const tab = activeTab();
if (!tab) return;
tab.tier = tierSelectEl.value;
const tiers = tab.tier === "all" ? ["python", "c", "asm"] : [tab.tier];
const entry = { input: src, results: [], kind: "ok" };
tab.transcript.push(entry);
inputEl.value = "";
sendBtn.disabled = true;
cancelBtn.disabled = false;
renderAll();
saveSoon();
const startTimes = {};
for (const t of tiers) startTimes[t] = performance.now();
const promises = tiers.map((t) =>
evalInTier(tab.id, t, src)
.then((output) => ({ tier: t, output, elapsed: performance.now() - startTimes[t] }))
.catch((e) => ({ tier: t, error: e.message, elapsed: performance.now() - startTimes[t] })));
// Append results as they arrive so user sees them in race order.
let remaining = promises.length;
for (const p of promises) {
p.then((r) => {
entry.results.push(r);
if (r.error) entry.kind = "error";
renderAll();
saveSoon();
remaining--;
if (remaining === 0) {
sendBtn.disabled = false;
cancelBtn.disabled = true;
inputEl.focus();
}
});
}
}
// ─── Wire up ────────────────────────────────────────────────────────
unlockBtn.addEventListener("click", tryUnlock);
freshBtn.addEventListener("click", enterEphemeral);
passwordEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") tryUnlock();
});
newTabBtn.addEventListener("click", () => newTab());
resetTierBtn.addEventListener("click", () => {
const tab = activeTab();
if (tab) rebootTier(tab.id, tab.tier);
});
clearLogBtn.addEventListener("click", () => {
const tab = activeTab();
if (tab) { tab.transcript = []; renderAll(); saveSoon(); }
});
cancelBtn.addEventListener("click", cancelAllPendingInActiveTab);
lockBtn.addEventListener("click", relock);
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendInput();
}
});
sendBtn.addEventListener("click", sendInput);
tierSelectEl.addEventListener("change", () => {
const tab = activeTab();
if (tab) { tab.tier = tierSelectEl.value; saveSoon(); }
});

25
wasm/dist-repl/runner.js Normal file
View file

@ -0,0 +1,25 @@
// wasm/app/runner.js
// Tier runner — wraps the three loaders. Exposes a per-tier API so the
// 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 { createCTier } from "./c/lumbda-c.loader.js";
import { createAsmTier } from "./asm/lumbda-asm.loader.js";
const cache = {};
export async function getTier(name, onLoad) {
if (cache[name]) return cache[name];
if (onLoad) onLoad(name);
if (name === "python") cache[name] = await createPythonTier();
else if (name === "c") cache[name] = await createCTier();
else if (name === "asm") cache[name] = await createAsmTier();
else throw new Error(`unknown tier: ${name}`);
return cache[name];
}
export async function evalOnTier(name, src, onLoad) {
const tier = await getTier(name, onLoad);
return tier.evalLisp(src);
}

282
wasm/dist-repl/style.css Normal file
View file

@ -0,0 +1,282 @@
/* 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 {
--fg: #1a1a1a;
--bg: #fafaf7;
--muted: #666;
--accent: #227842; /* single brand green — matches homepage */
--green: #227842;
--rule: #d4d4d0;
--code-bg: #f0ede4;
--pane-bg: #ffffff;
--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; }
html, body {
margin: 0; padding: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--mono);
font-size: 14px;
line-height: 1.55;
min-height: 100%;
}
/* ─── Header ────────────────────────────────────────────────────── */
header {
border-bottom: 1px solid var(--rule);
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 {
font-family: 'chunkfive', Georgia, serif;
font-size: 2.4rem;
margin: 0;
letter-spacing: -0.01em;
line-height: 1;
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 {
background: var(--code-bg);
padding: 0 0.2em;
border-radius: 2px;
font-size: 0.9em;
}
/* ─── Controls ──────────────────────────────────────────────────── */
.controls {
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--rule);
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
background: var(--bg);
}
.controls fieldset {
border: 1px solid var(--rule);
border-radius: 3px;
padding: 3px 10px 5px;
margin: 0;
background: var(--pane-bg);
}
.controls fieldset legend {
color: var(--muted);
font-size: 0.7rem;
padding: 0 0.4em;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.controls label {
margin-right: 0.7em;
cursor: pointer;
user-select: none;
font-size: 0.85em;
}
.controls label input {
margin-right: 0.3em;
accent-color: var(--green);
}
.controls button {
font-family: var(--mono);
font-size: 0.85em;
font-weight: 600;
padding: 6px 16px;
border-radius: 3px;
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 .status {
color: var(--muted);
font-size: 0.8em;
margin-left: auto;
}
.controls .status.busy { color: var(--busy); }
.controls .status.warn { color: var(--busy); }
.controls .status.err { color: var(--err); }
.controls .status.ok { color: var(--green); }
/* ─── Panes ─────────────────────────────────────────────────────── */
.panes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--rule);
height: calc(100vh - 260px);
min-height: 360px;
}
.pane {
background: var(--pane-bg);
padding: 0.5rem 0.75rem 0.75rem;
overflow: hidden;
display: flex;
flex-direction: column;
}
.pane h2 {
margin: 0 0 0.4rem;
font-size: 0.7rem;
font-weight: 500;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.1em;
}
#editor {
flex: 1;
overflow: hidden;
border-radius: 2px;
}
.cm-editor { height: 100%; font-size: 13px; }
.cm-editor.cm-focused { outline: none; }
#output {
flex: 1;
white-space: pre;
overflow: auto;
background: var(--code-bg);
border-radius: 2px;
padding: 0.6rem 0.8rem;
font-size: 13px;
line-height: 1.35;
}
/* Append-only: results arrive in finish order — fastest tier shows up first. */
#output .tier-block { margin-bottom: 1rem; }
#output .tier-block:last-child { margin-bottom: 0; }
#output .tier-block h3 {
margin: 0 0 0.25rem;
font-size: 0.7rem;
color: var(--green);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 600;
}
#output .tier-block .time {
color: var(--muted);
font-size: 0.65rem;
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
#output .tier-block pre {
margin: 0;
white-space: pre;
font-family: var(--mono);
}
#output .err {
color: var(--err);
}
/* ─── Footer ────────────────────────────────────────────────────── */
footer {
padding: 0.7rem 1.5rem;
border-top: 1px solid var(--rule);
color: var(--muted);
font-size: 0.75rem;
}
footer code {
background: var(--code-bg);
padding: 0 0.2em;
border-radius: 2px;
}
footer a {
color: var(--green);
text-decoration: none;
}
footer a:hover { text-decoration: underline; }
footer strong { color: var(--fg); }

19
wasm/dist-repl/worker.mjs Normal file
View 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) });
}
};

104
wasm/repl/crypto.js Normal file
View file

@ -0,0 +1,104 @@
// wasm/repl/crypto.js
// Encrypted-at-rest localStorage. Modeled after unsandbox crypto-utils.js
// (priv/static/js/crypto-utils.js) but uses the native Web Crypto API
// — no CryptoJS dep — so the playground and the REPL share one tiny
// vault primitive.
//
// Vault layout in localStorage:
// lumbda_salt — random 32 bytes, generated once per device
// lumbda_vault:<id> — { iv: base64, data: base64 } AES-GCM encrypted JSON
//
// vaultId = SHA-256(password || salt), hex. Same password yields the same
// vault id on this device; different password yields a different (and
// independent) vault.
const SALT_KEY = "lumbda_salt";
function buf2b64(buf) {
let binary = "";
const bytes = new Uint8Array(buf);
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
function b642buf(b64) {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function hex(buf) {
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export function getDeviceSalt() {
let salt = localStorage.getItem(SALT_KEY);
if (!salt) {
const random = crypto.getRandomValues(new Uint8Array(32));
salt = buf2b64(random);
localStorage.setItem(SALT_KEY, salt);
}
return new Uint8Array(b642buf(salt));
}
async function deriveKey(password, salt) {
const enc = new TextEncoder();
const baseKey = await crypto.subtle.importKey(
"raw", enc.encode(password), { name: "PBKDF2" }, false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 200_000, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]);
}
export async function getVaultId(password) {
const salt = getDeviceSalt();
const enc = new TextEncoder();
const data = new Uint8Array(password.length + salt.length);
data.set(enc.encode(password), 0);
data.set(salt, password.length);
const digest = await crypto.subtle.digest("SHA-256", data);
return hex(digest);
}
// Open a vault under the given password. Returns { read, write, vaultId }.
export async function openVault(password) {
const salt = getDeviceSalt();
const key = await deriveKey(password, salt);
const vaultId = await getVaultId(password);
const lsKey = "lumbda_vault:" + vaultId;
async function read() {
const raw = localStorage.getItem(lsKey);
if (!raw) return null;
let envelope;
try { envelope = JSON.parse(raw); } catch { return null; }
if (!envelope || !envelope.iv || !envelope.data) return null;
try {
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: b642buf(envelope.iv) },
key,
b642buf(envelope.data));
return JSON.parse(new TextDecoder().decode(plain));
} catch {
return { __decryptionFailed: true };
}
}
async function write(value) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = new TextEncoder();
const cipher = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
enc.encode(JSON.stringify(value)));
localStorage.setItem(lsKey, JSON.stringify({
iv: buf2b64(iv),
data: buf2b64(cipher),
}));
}
function destroy() { localStorage.removeItem(lsKey); }
return { read, write, destroy, vaultId };
}

95
wasm/repl/index.html Normal file
View file

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>lumbda repl — three tiers, persistent sessions</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="repl.css">
<script type="importmap">
{
"imports": {
"@codemirror/state": "https://esm.sh/*@codemirror/state@6.4.1",
"@codemirror/view": "https://esm.sh/*@codemirror/view@6.34.1",
"@codemirror/commands": "https://esm.sh/*@codemirror/commands@6.6.2",
"@codemirror/language": "https://esm.sh/*@codemirror/language@6.10.3",
"@codemirror/legacy-modes/mode/scheme": "https://esm.sh/*@codemirror/legacy-modes@6.4.1/mode/scheme",
"@codemirror/theme-one-dark": "https://esm.sh/*@codemirror/theme-one-dark@6.1.2",
"@lezer/highlight": "https://esm.sh/*@lezer/highlight@1.2.1",
"@lezer/common": "https://esm.sh/*@lezer/common@1.2.3",
"style-mod": "https://esm.sh/*style-mod@4.1.2",
"crelt": "https://esm.sh/*crelt@1.0.6",
"w3c-keyname": "https://esm.sh/*w3c-keyname@2.2.8"
}
}
</script>
</head>
<body class="repl">
<header>
<a class="brand" href="../" aria-label="lumbda home">
<img class="logo" src="lumbda-logo-green.png" alt="" aria-hidden="true">
<h1 aria-label="lumbda.">lumbda<span class="period" aria-hidden="true">.</span></h1>
</a>
<p class="tagline">three tiers, persistent sessions</p>
<p class="lede">
interactive repl — each tab keeps its own tier state. <kbd>enter</kbd>
sends, <kbd>shift+enter</kbd> for newline. transcripts persist in
encrypted localStorage; supply a password and the same password
unlocks them after a reload. for one-shot demos, see the
<a href="../playground/">playground</a>.
</p>
</header>
<div id="lock-screen" class="lock-screen">
<div class="lock-card">
<h2>unlock</h2>
<p>enter a password — transcripts are encrypted with it locally.
a fresh password starts a fresh vault.</p>
<input id="password" type="password" autocomplete="off" autofocus placeholder="password">
<div class="lock-row">
<button id="unlock" class="primary">unlock vault</button>
<button id="fresh" class="secondary" title="skip the vault, run ephemerally — transcripts vanish on reload">ephemeral</button>
</div>
<p class="lock-note" id="lock-note"></p>
</div>
</div>
<section class="tabbar" id="tabbar" hidden>
<div class="tabs" id="tabs"></div>
<button id="new-tab" class="ghost" title="new repl session">+</button>
<span class="spacer"></span>
<button id="reset-tier" class="ghost" title="reboot the worker for the current tab — state lost">↻ reboot tier</button>
<button id="clear-log" class="ghost" title="clear the transcript">clear log</button>
<button id="cancel" class="ghost" disabled>cancel</button>
<button id="lock" class="ghost" title="lock vault — passes back to lock screen">lock</button>
</section>
<main id="transcript-wrap" class="transcript-wrap" hidden>
<div id="transcript" class="transcript"></div>
</main>
<section class="prompt-bar" id="prompt-bar" hidden>
<span class="prompt-sigil">λ&gt;</span>
<textarea id="input" rows="1" spellcheck="false"
placeholder="(+ 1 2) — enter to send, shift+enter for newline"></textarea>
<select id="tier-select" title="which tier evaluates this input">
<option value="python">python (pyodide)</option>
<option value="c" selected>c (emcc)</option>
<option value="asm">asm (wat)</option>
<option value="all">all three (race)</option>
</select>
<button id="send" class="primary">send</button>
</section>
<footer>
<p>
transcripts saved encrypted with your password. tier state (defines,
set!, hash table contents) lives in the worker; lost on reload. lift
a session via portal save when that lands in WAT. <a href="../">lumbda.</a>
</p>
</footer>
<script type="module" src="repl.js"></script>
</body>
</html>

226
wasm/repl/repl.css Normal file
View file

@ -0,0 +1,226 @@
/* lumbda repl specific styles — inherits style.css palette. */
body.repl { display: flex; flex-direction: column; min-height: 100vh; }
.lock-screen {
position: fixed; inset: 0; z-index: 1000;
display: flex; align-items: center; justify-content: center;
background: rgba(15, 17, 21, 0.85);
backdrop-filter: blur(4px);
}
.lock-card {
background: var(--pane-bg);
border: 1px solid var(--rule);
border-radius: 6px;
padding: 1.5rem 2rem;
max-width: 28rem;
width: 90%;
}
.lock-card h2 {
margin: 0 0 0.5rem;
font-family: 'chunkfive', Georgia, serif;
font-size: 1.4rem;
color: var(--green);
font-weight: normal;
}
.lock-card p {
margin: 0 0 0.8rem;
font-size: 0.85rem;
color: var(--muted);
line-height: 1.5;
}
.lock-card input[type=password] {
width: 100%;
padding: 0.5rem 0.7rem;
margin: 0.4rem 0;
background: var(--code-bg);
color: var(--fg);
border: 1px solid var(--rule);
border-radius: 3px;
font-family: var(--mono);
font-size: 0.95rem;
}
.lock-card input[type=password]:focus {
outline: none;
border-color: var(--green);
}
.lock-row { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
.lock-row button {
font-family: var(--mono);
font-size: 0.85em; font-weight: 600;
padding: 0.45rem 1.1rem;
border-radius: 3px;
cursor: pointer;
border: 1px solid var(--green);
}
.lock-row .primary { background: var(--green); color: var(--bg); }
.lock-row .secondary { background: transparent; color: var(--green); }
.lock-row .primary:hover { filter: brightness(1.1); }
.lock-row .secondary:hover { background: var(--code-bg); }
.lock-note {
margin: 0.6rem 0 0;
color: var(--err);
font-size: 0.8rem;
min-height: 1.2em;
}
.tabbar {
display: flex;
gap: 0.3rem;
align-items: center;
padding: 0.4rem 1rem;
border-bottom: 1px solid var(--rule);
background: var(--bg);
}
.tabs {
display: flex;
gap: 0.3rem;
overflow-x: auto;
flex: 0 1 auto;
}
.tab {
background: var(--pane-bg);
border: 1px solid var(--rule);
padding: 0.25rem 0.6rem;
border-radius: 3px 3px 0 0;
font-size: 0.85em;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.3rem;
white-space: nowrap;
}
.tab.active {
background: var(--code-bg);
border-bottom-color: var(--code-bg);
color: var(--green);
}
.tab .tab-close {
opacity: 0.5;
cursor: pointer;
padding: 0 0.2rem;
}
.tab .tab-close:hover { opacity: 1; color: var(--err); }
.tabbar .ghost {
background: transparent;
border: 1px solid var(--rule);
color: var(--muted);
font-family: var(--mono);
font-size: 0.8em;
padding: 0.2rem 0.6rem;
border-radius: 3px;
cursor: pointer;
}
.tabbar .ghost:hover:not(:disabled) {
border-color: var(--green); color: var(--green);
}
.tabbar .ghost:disabled { opacity: 0.4; cursor: default; }
.tabbar .spacer { flex: 1; }
.transcript-wrap {
flex: 1;
overflow: auto;
padding: 1rem;
background: var(--code-bg);
}
.transcript {
font-family: var(--mono);
font-size: 0.9em;
line-height: 1.5;
max-width: 90rem;
margin: 0 auto;
}
.transcript .entry {
margin-bottom: 0.8rem;
padding: 0.4rem 0.6rem;
border-left: 2px solid var(--rule);
}
.transcript .entry .prompt-line {
color: var(--green);
white-space: pre-wrap;
margin-bottom: 0.2rem;
}
.transcript .entry .tier-output {
display: flex;
gap: 0.6rem;
padding: 0.25rem 0;
border-top: 1px dashed var(--rule);
}
.transcript .entry .tier-output:first-of-type { border-top: none; }
.transcript .entry .tier-label {
color: var(--muted);
font-size: 0.7em;
text-transform: uppercase;
letter-spacing: 0.08em;
min-width: 7rem;
flex-shrink: 0;
padding-top: 0.15rem;
}
.transcript .entry .tier-result {
white-space: pre-wrap;
flex: 1;
}
.transcript .entry .tier-time {
color: var(--muted);
font-size: 0.8em;
margin-left: 0.4rem;
}
.transcript .entry.error .tier-result { color: var(--err); }
.prompt-bar {
display: flex;
align-items: flex-end;
gap: 0.5rem;
padding: 0.5rem 1rem 0.8rem;
border-top: 1px solid var(--rule);
background: var(--bg);
}
.prompt-bar .prompt-sigil {
color: var(--green);
font-weight: 600;
padding: 0.4rem 0 0;
}
.prompt-bar textarea {
flex: 1;
resize: none;
background: var(--code-bg);
border: 1px solid var(--rule);
color: var(--fg);
padding: 0.45rem 0.7rem;
font-family: var(--mono);
font-size: 0.9em;
border-radius: 3px;
min-height: 1.6em;
max-height: 30vh;
}
.prompt-bar textarea:focus { outline: none; border-color: var(--green); }
.prompt-bar select {
background: var(--code-bg);
border: 1px solid var(--rule);
color: var(--fg);
padding: 0.35rem 0.5rem;
font-family: var(--mono);
font-size: 0.85em;
border-radius: 3px;
}
.prompt-bar button.primary {
background: var(--green);
color: var(--bg);
border: 1px solid var(--green);
padding: 0.4rem 1rem;
font-family: var(--mono);
font-weight: 600;
border-radius: 3px;
cursor: pointer;
}
.prompt-bar button.primary:hover { filter: brightness(1.1); }
.prompt-bar button.primary:disabled { opacity: 0.4; cursor: wait; }
kbd {
font-family: var(--mono);
background: var(--code-bg);
padding: 0.05em 0.3em;
border: 1px solid var(--rule);
border-radius: 2px;
font-size: 0.85em;
}

360
wasm/repl/repl.js Normal file
View file

@ -0,0 +1,360 @@
// wasm/repl/repl.js
// Lumbda interactive REPL with multi-tab sessions + encrypted persistence.
import { openVault } from "./crypto.js";
const TIER_LABEL = {
python: "python (pyodide)",
c: "c (emcc)",
asm: "asm (wat)",
};
// ─── DOM ────────────────────────────────────────────────────────────
const lockScreen = document.getElementById("lock-screen");
const passwordEl = document.getElementById("password");
const unlockBtn = document.getElementById("unlock");
const freshBtn = document.getElementById("fresh");
const lockNote = document.getElementById("lock-note");
const tabbar = document.getElementById("tabbar");
const tabsEl = document.getElementById("tabs");
const newTabBtn = document.getElementById("new-tab");
const resetTierBtn = document.getElementById("reset-tier");
const clearLogBtn = document.getElementById("clear-log");
const cancelBtn = document.getElementById("cancel");
const lockBtn = document.getElementById("lock");
const transcriptWrap = document.getElementById("transcript-wrap");
const transcriptEl = document.getElementById("transcript");
const promptBar = document.getElementById("prompt-bar");
const inputEl = document.getElementById("input");
const tierSelectEl = document.getElementById("tier-select");
const sendBtn = document.getElementById("send");
// ─── State ──────────────────────────────────────────────────────────
const state = {
vault: null, // { read, write, destroy, vaultId } or null (ephemeral)
tabs: [], // [{ id, name, tier, transcript: [{kind, text, results?}] }]
activeTabId: null,
workers: {}, // { [tabId+":"+tier]: Worker }
pending: {}, // same key → { runId, reject }
nextRunId: 1,
nextTabId: 1,
saveTimer: null,
};
// ─── Vault ──────────────────────────────────────────────────────────
async function saveSoon() {
if (!state.vault) return;
if (state.saveTimer) clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(async () => {
try {
await state.vault.write({
tabs: state.tabs,
activeTabId: state.activeTabId,
savedAt: Date.now(),
});
} catch (e) {
console.error("vault write failed:", e);
}
}, 400);
}
async function tryUnlock() {
const pw = passwordEl.value;
if (!pw) { lockNote.textContent = "password required (or click ephemeral)"; return; }
lockNote.textContent = "";
try {
state.vault = await openVault(pw);
const data = await state.vault.read();
if (data && data.__decryptionFailed) {
// Wrong password for an existing vault under this id.
lockNote.textContent = "vault exists but password is wrong";
state.vault = null;
return;
}
if (data && Array.isArray(data.tabs) && data.tabs.length > 0) {
state.tabs = data.tabs;
state.activeTabId = data.activeTabId || data.tabs[0].id;
state.nextTabId = Math.max(...state.tabs.map((t) => t.id)) + 1;
} else {
state.tabs = [];
state.activeTabId = null;
state.nextTabId = 1;
}
enterRepl();
} catch (e) {
lockNote.textContent = "unlock failed: " + e.message;
}
}
function enterEphemeral() {
state.vault = null;
state.tabs = [];
state.activeTabId = null;
state.nextTabId = 1;
enterRepl();
}
function enterRepl() {
lockScreen.hidden = true;
tabbar.hidden = false;
transcriptWrap.hidden = false;
promptBar.hidden = false;
if (state.tabs.length === 0) newTab();
else renderAll();
inputEl.focus();
}
function relock() {
// Terminate all workers, clear in-memory state, return to lock screen.
for (const k of Object.keys(state.workers)) {
try { state.workers[k].terminate(); } catch {}
}
state.workers = {};
state.pending = {};
state.vault = null;
state.tabs = [];
state.activeTabId = null;
transcriptEl.innerHTML = "";
tabsEl.innerHTML = "";
tabbar.hidden = true;
transcriptWrap.hidden = true;
promptBar.hidden = true;
lockScreen.hidden = false;
passwordEl.value = "";
passwordEl.focus();
}
// ─── Tabs ───────────────────────────────────────────────────────────
function newTab(name) {
const id = state.nextTabId++;
name = name || `session ${id}`;
state.tabs.push({ id, name, tier: "c", transcript: [] });
state.activeTabId = id;
renderAll();
saveSoon();
}
function closeTab(id) {
const idx = state.tabs.findIndex((t) => t.id === id);
if (idx < 0) return;
// Terminate this tab's workers.
for (const k of Object.keys(state.workers)) {
if (k.startsWith(id + ":")) {
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
state.tabs.splice(idx, 1);
if (state.activeTabId === id) {
state.activeTabId = state.tabs.length ? state.tabs[Math.max(0, idx - 1)].id : null;
}
if (state.tabs.length === 0) newTab();
else renderAll();
saveSoon();
}
function setActiveTab(id) {
state.activeTabId = id;
renderAll();
saveSoon();
}
function activeTab() {
return state.tabs.find((t) => t.id === state.activeTabId);
}
// ─── Workers ────────────────────────────────────────────────────────
function workerKey(tabId, tier) { return `${tabId}:${tier}`; }
function spawnWorker(tabId, tier) {
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
state.workers[workerKey(tabId, tier)] = w;
return w;
}
function ensureWorker(tabId, tier) {
const k = workerKey(tabId, tier);
if (!state.workers[k]) spawnWorker(tabId, tier);
return state.workers[k];
}
function evalInTier(tabId, tier, src) {
return new Promise((resolve, reject) => {
const w = ensureWorker(tabId, tier);
const runId = state.nextRunId++;
const pendingKey = workerKey(tabId, tier);
state.pending[pendingKey] = { runId, reject };
const handler = (e) => {
if (e.data.runId !== runId) return;
if (e.data.kind === "done") {
w.removeEventListener("message", handler);
delete state.pending[pendingKey];
resolve(e.data.output);
} else if (e.data.kind === "error") {
w.removeEventListener("message", handler);
delete state.pending[pendingKey];
reject(new Error(e.data.message));
}
};
w.addEventListener("message", handler);
w.postMessage({ kind: "eval", runId, tier, src });
});
}
function rebootTier(tabId, tier) {
const k = workerKey(tabId, tier);
if (state.workers[k]) {
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
if (state.pending[k]) {
try { state.pending[k].reject(new Error("rebooted")); } catch {}
delete state.pending[k];
}
}
function cancelAllPendingInActiveTab() {
const tab = activeTab();
if (!tab) return;
for (const k of Object.keys(state.pending)) {
if (k.startsWith(tab.id + ":")) {
try { state.pending[k].reject(new Error("cancelled")); } catch {}
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
delete state.pending[k];
}
}
}
// ─── Render ─────────────────────────────────────────────────────────
function renderAll() {
// Tabs
tabsEl.innerHTML = "";
for (const t of state.tabs) {
const el = document.createElement("div");
el.className = "tab" + (t.id === state.activeTabId ? " active" : "");
const name = document.createElement("span");
name.textContent = t.name;
name.addEventListener("click", () => setActiveTab(t.id));
name.addEventListener("dblclick", (e) => {
e.stopPropagation();
const newName = prompt("rename tab:", t.name);
if (newName) { t.name = newName.slice(0, 40); renderAll(); saveSoon(); }
});
const close = document.createElement("span");
close.className = "tab-close";
close.textContent = "×";
close.title = "close tab";
close.addEventListener("click", (e) => { e.stopPropagation(); closeTab(t.id); });
el.appendChild(name);
el.appendChild(close);
tabsEl.appendChild(el);
}
// Transcript
transcriptEl.innerHTML = "";
const tab = activeTab();
if (!tab) return;
tierSelectEl.value = tab.tier;
for (const entry of tab.transcript) {
const block = document.createElement("div");
block.className = "entry" + (entry.kind === "error" ? " error" : "");
const prompt = document.createElement("div");
prompt.className = "prompt-line";
prompt.textContent = "λ> " + entry.input;
block.appendChild(prompt);
for (const r of (entry.results || [])) {
const row = document.createElement("div");
row.className = "tier-output";
const lab = document.createElement("div");
lab.className = "tier-label";
lab.textContent = TIER_LABEL[r.tier] || r.tier;
const tm = document.createElement("span");
tm.className = "tier-time";
tm.textContent = ` ${(r.elapsed | 0)}ms`;
lab.appendChild(tm);
const out = document.createElement("div");
out.className = "tier-result";
out.textContent = r.output || (r.error ? "error: " + r.error : "");
row.appendChild(lab);
row.appendChild(out);
block.appendChild(row);
}
transcriptEl.appendChild(block);
}
transcriptWrap.scrollTop = transcriptWrap.scrollHeight;
}
// ─── Input handling ─────────────────────────────────────────────────
async function sendInput() {
const src = inputEl.value.trim();
if (!src) return;
const tab = activeTab();
if (!tab) return;
tab.tier = tierSelectEl.value;
const tiers = tab.tier === "all" ? ["python", "c", "asm"] : [tab.tier];
const entry = { input: src, results: [], kind: "ok" };
tab.transcript.push(entry);
inputEl.value = "";
sendBtn.disabled = true;
cancelBtn.disabled = false;
renderAll();
saveSoon();
const startTimes = {};
for (const t of tiers) startTimes[t] = performance.now();
const promises = tiers.map((t) =>
evalInTier(tab.id, t, src)
.then((output) => ({ tier: t, output, elapsed: performance.now() - startTimes[t] }))
.catch((e) => ({ tier: t, error: e.message, elapsed: performance.now() - startTimes[t] })));
// Append results as they arrive so user sees them in race order.
let remaining = promises.length;
for (const p of promises) {
p.then((r) => {
entry.results.push(r);
if (r.error) entry.kind = "error";
renderAll();
saveSoon();
remaining--;
if (remaining === 0) {
sendBtn.disabled = false;
cancelBtn.disabled = true;
inputEl.focus();
}
});
}
}
// ─── Wire up ────────────────────────────────────────────────────────
unlockBtn.addEventListener("click", tryUnlock);
freshBtn.addEventListener("click", enterEphemeral);
passwordEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") tryUnlock();
});
newTabBtn.addEventListener("click", () => newTab());
resetTierBtn.addEventListener("click", () => {
const tab = activeTab();
if (tab) rebootTier(tab.id, tab.tier);
});
clearLogBtn.addEventListener("click", () => {
const tab = activeTab();
if (tab) { tab.transcript = []; renderAll(); saveSoon(); }
});
cancelBtn.addEventListener("click", cancelAllPendingInActiveTab);
lockBtn.addEventListener("click", relock);
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendInput();
}
});
sendBtn.addEventListener("click", sendInput);
tierSelectEl.addEventListener("change", () => {
const tab = activeTab();
if (tab) { tab.tier = tierSelectEl.value; saveSoon(); }
});

View file

@ -89,8 +89,11 @@ make test-all</code></pre>
<section id="playground">
<h2>Try it in your browser</h2>
<p>All three tiers compiled to WebAssembly &mdash; Python (Pyodide hosting <code>lumbda.py</code>), C (Emscripten), and a hand-written WAT parallel to <code>asm/lumbda.s</code>. Pick a demo, pick a tier (or run all three side by side), and watch the same Lisp source evaluate three different ways.</p>
<p><a class="cta" href="playground/">Open the playground &rarr;</a></p>
<p>All three tiers compiled to WebAssembly &mdash; Python (Pyodide hosting <code>lumbda.py</code>), C (Emscripten), and a hand-written WAT parallel to <code>asm/lumbda.s</code>. Pick a demo, pick a tier (or race all three at once), and watch the same Lisp source evaluate three different ways.</p>
<p>
<a class="cta" href="playground/">Open the playground &rarr;</a>
<a class="cta" href="repl/">Open the REPL &rarr;</a>
</p>
</section>
</main>
@ -101,6 +104,8 @@ make test-all</code></pre>
&middot;
<a href="playground/">playground</a>
&middot;
<a href="repl/">repl</a>
&middot;
<a href="lumbda-whitepaper.html">whitepaper (HTML)</a>
&middot;
<a href="lumbda-whitepaper.pdf">whitepaper (PDF)</a>

View file

@ -0,0 +1,45 @@
// wasm/asm/lumbda-asm.loader.js
// Asm tier loader — instantiates lumbda-asm.wasm and exposes evalLisp.
//
// Memory layout (mirrors lumbda.wat):
// 0x10000 output buffer (read after each call)
// 0x20000 source buffer (write before each call)
async function _bootstrap() {
const wasmURL = new URL("./lumbda-asm.wasm", import.meta.url).href;
const resp = await fetch(wasmURL);
const bytes = await resp.arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes);
const exp = instance.exports;
exp.lumbda_init();
const enc = new TextEncoder();
const dec = new TextDecoder();
return {
async evalLisp(src) {
const srcBytes = enc.encode(src);
const srcPtr = exp.lumbda_source_ptr();
const mem = new Uint8Array(exp.memory.buffer);
mem.set(srcBytes, srcPtr);
try {
exp.lumbda_eval(srcBytes.length);
} catch (e) {
return `error: ${e.message}`;
}
const outPtr = exp.lumbda_output_ptr();
const outLen = exp.lumbda_output_len();
return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen));
},
};
}
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createAsmTier = (() => {
let tier = null;
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();

Binary file not shown.

2
www/repl/c/lumbda-c.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,56 @@
// wasm/c/lumbda-c.loader.js
// C tier loader — Emscripten ES module factory.
//
// Exports createCTier({ baseURL }) -> Promise<{ evalLisp(src) -> Promise<string> }>.
// Output capture: Emscripten routes stdout/stderr through Module.print /
// Module.printErr callbacks. We accumulate per-eval and return joined.
async function _bootstrap() {
// Use import.meta.url so paths resolve relative to THIS loader file —
// not the caller. Works in both window and Worker contexts because both
// have a defined import.meta.url for ES modules.
const factoryURL = new URL("./lumbda-c.js", import.meta.url).href;
const wasmDir = new URL("./", import.meta.url).href;
const { default: createLumbdaC } = await import(/* @vite-ignore */ factoryURL);
let outBuf = [];
let errBuf = [];
const module = await createLumbdaC({
locateFile: (p) => wasmDir + p,
print: (line) => outBuf.push(line),
printErr: (line) => errBuf.push(line),
});
const _init = module.cwrap("lumbda_wasm_init", null, []);
const _eval = module.cwrap("lumbda_wasm_eval", "number", ["string"]);
const _free = module.cwrap("lumbda_wasm_free_result", null, ["number"]);
_init();
return {
async evalLisp(src) {
outBuf = [];
errBuf = [];
const errPtr = _eval(src);
let errMsg = "";
if (errPtr) {
errMsg = module.UTF8ToString(errPtr);
_free(errPtr);
}
let out = outBuf.join("\n");
if (out) out += "\n";
if (errBuf.length) out += errBuf.join("\n") + "\n";
if (errMsg) out += errMsg + "\n";
return out;
},
};
}
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createCTier = (() => {
let tier = null;
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();

BIN
www/repl/c/lumbda-c.wasm Executable file

Binary file not shown.

104
www/repl/crypto.js Normal file
View file

@ -0,0 +1,104 @@
// wasm/repl/crypto.js
// Encrypted-at-rest localStorage. Modeled after unsandbox crypto-utils.js
// (priv/static/js/crypto-utils.js) but uses the native Web Crypto API
// — no CryptoJS dep — so the playground and the REPL share one tiny
// vault primitive.
//
// Vault layout in localStorage:
// lumbda_salt — random 32 bytes, generated once per device
// lumbda_vault:<id> — { iv: base64, data: base64 } AES-GCM encrypted JSON
//
// vaultId = SHA-256(password || salt), hex. Same password yields the same
// vault id on this device; different password yields a different (and
// independent) vault.
const SALT_KEY = "lumbda_salt";
function buf2b64(buf) {
let binary = "";
const bytes = new Uint8Array(buf);
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
function b642buf(b64) {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function hex(buf) {
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export function getDeviceSalt() {
let salt = localStorage.getItem(SALT_KEY);
if (!salt) {
const random = crypto.getRandomValues(new Uint8Array(32));
salt = buf2b64(random);
localStorage.setItem(SALT_KEY, salt);
}
return new Uint8Array(b642buf(salt));
}
async function deriveKey(password, salt) {
const enc = new TextEncoder();
const baseKey = await crypto.subtle.importKey(
"raw", enc.encode(password), { name: "PBKDF2" }, false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 200_000, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]);
}
export async function getVaultId(password) {
const salt = getDeviceSalt();
const enc = new TextEncoder();
const data = new Uint8Array(password.length + salt.length);
data.set(enc.encode(password), 0);
data.set(salt, password.length);
const digest = await crypto.subtle.digest("SHA-256", data);
return hex(digest);
}
// Open a vault under the given password. Returns { read, write, vaultId }.
export async function openVault(password) {
const salt = getDeviceSalt();
const key = await deriveKey(password, salt);
const vaultId = await getVaultId(password);
const lsKey = "lumbda_vault:" + vaultId;
async function read() {
const raw = localStorage.getItem(lsKey);
if (!raw) return null;
let envelope;
try { envelope = JSON.parse(raw); } catch { return null; }
if (!envelope || !envelope.iv || !envelope.data) return null;
try {
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: b642buf(envelope.iv) },
key,
b642buf(envelope.data));
return JSON.parse(new TextDecoder().decode(plain));
} catch {
return { __decryptionFailed: true };
}
}
async function write(value) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = new TextEncoder();
const cipher = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
enc.encode(JSON.stringify(value)));
localStorage.setItem(lsKey, JSON.stringify({
iv: buf2b64(iv),
data: buf2b64(cipher),
}));
}
function destroy() { localStorage.removeItem(lsKey); }
return { read, write, destroy, vaultId };
}

95
www/repl/index.html Normal file
View file

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>lumbda repl — three tiers, persistent sessions</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="repl.css">
<script type="importmap">
{
"imports": {
"@codemirror/state": "https://esm.sh/*@codemirror/state@6.4.1",
"@codemirror/view": "https://esm.sh/*@codemirror/view@6.34.1",
"@codemirror/commands": "https://esm.sh/*@codemirror/commands@6.6.2",
"@codemirror/language": "https://esm.sh/*@codemirror/language@6.10.3",
"@codemirror/legacy-modes/mode/scheme": "https://esm.sh/*@codemirror/legacy-modes@6.4.1/mode/scheme",
"@codemirror/theme-one-dark": "https://esm.sh/*@codemirror/theme-one-dark@6.1.2",
"@lezer/highlight": "https://esm.sh/*@lezer/highlight@1.2.1",
"@lezer/common": "https://esm.sh/*@lezer/common@1.2.3",
"style-mod": "https://esm.sh/*style-mod@4.1.2",
"crelt": "https://esm.sh/*crelt@1.0.6",
"w3c-keyname": "https://esm.sh/*w3c-keyname@2.2.8"
}
}
</script>
</head>
<body class="repl">
<header>
<a class="brand" href="../" aria-label="lumbda home">
<img class="logo" src="lumbda-logo-green.png" alt="" aria-hidden="true">
<h1 aria-label="lumbda.">lumbda<span class="period" aria-hidden="true">.</span></h1>
</a>
<p class="tagline">three tiers, persistent sessions</p>
<p class="lede">
interactive repl — each tab keeps its own tier state. <kbd>enter</kbd>
sends, <kbd>shift+enter</kbd> for newline. transcripts persist in
encrypted localStorage; supply a password and the same password
unlocks them after a reload. for one-shot demos, see the
<a href="../playground/">playground</a>.
</p>
</header>
<div id="lock-screen" class="lock-screen">
<div class="lock-card">
<h2>unlock</h2>
<p>enter a password — transcripts are encrypted with it locally.
a fresh password starts a fresh vault.</p>
<input id="password" type="password" autocomplete="off" autofocus placeholder="password">
<div class="lock-row">
<button id="unlock" class="primary">unlock vault</button>
<button id="fresh" class="secondary" title="skip the vault, run ephemerally — transcripts vanish on reload">ephemeral</button>
</div>
<p class="lock-note" id="lock-note"></p>
</div>
</div>
<section class="tabbar" id="tabbar" hidden>
<div class="tabs" id="tabs"></div>
<button id="new-tab" class="ghost" title="new repl session">+</button>
<span class="spacer"></span>
<button id="reset-tier" class="ghost" title="reboot the worker for the current tab — state lost">↻ reboot tier</button>
<button id="clear-log" class="ghost" title="clear the transcript">clear log</button>
<button id="cancel" class="ghost" disabled>cancel</button>
<button id="lock" class="ghost" title="lock vault — passes back to lock screen">lock</button>
</section>
<main id="transcript-wrap" class="transcript-wrap" hidden>
<div id="transcript" class="transcript"></div>
</main>
<section class="prompt-bar" id="prompt-bar" hidden>
<span class="prompt-sigil">λ&gt;</span>
<textarea id="input" rows="1" spellcheck="false"
placeholder="(+ 1 2) — enter to send, shift+enter for newline"></textarea>
<select id="tier-select" title="which tier evaluates this input">
<option value="python">python (pyodide)</option>
<option value="c" selected>c (emcc)</option>
<option value="asm">asm (wat)</option>
<option value="all">all three (race)</option>
</select>
<button id="send" class="primary">send</button>
</section>
<footer>
<p>
transcripts saved encrypted with your password. tier state (defines,
set!, hash table contents) lives in the worker; lost on reload. lift
a session via portal save when that lands in WAT. <a href="../">lumbda.</a>
</p>
</footer>
<script type="module" src="repl.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,69 @@
// wasm/python/lumbda-py.js
// 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> }>.
const PYODIDE_VERSION = "0.27.2";
const PYODIDE_INDEX_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
async function _bootstrap() {
// Dynamic ES-module import works in both window and Worker (module type)
// contexts. The CDN ships pyodide.mjs alongside pyodide.js.
const { loadPyodide } = await import(PYODIDE_INDEX_URL + "pyodide.mjs");
const pyodide = await loadPyodide({ indexURL: PYODIDE_INDEX_URL });
// Pull lumbda.py + stdlib.lsp into Pyodide's virtual FS. Paths resolve
// relative to THIS loader (under python/) for both window and Worker.
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/stdlib.lsp", stdlibSrc);
await pyodide.runPythonAsync(`
import sys, io
sys.path.insert(0, "/home/pyodide")
import lumbda
_env = lumbda.make_global_env()
for _e in lumbda.read_all(lumbda.PRELUDE):
lumbda.leval(_e, _env)
def _lumbda_eval(src):
buf = io.StringIO()
old = sys.stdout
sys.stdout = buf
last = None
try:
for e in lumbda.read_all(src):
last = lumbda.leval(e, _env)
except Exception as ex:
sys.stdout = old
return f"{buf.getvalue()}error: {ex}"
sys.stdout = old
out = buf.getvalue()
if last is not None and not isinstance(last, lumbda._Void):
rep = lumbda.show(last)
if out and not out.endswith("\\n"):
out += "\\n"
out += rep
return out
`);
return {
async evalLisp(src) {
pyodide.globals.set("_src_in", src);
return await pyodide.runPythonAsync("_lumbda_eval(_src_in)");
},
};
}
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createPythonTier = (() => {
let tier = null;
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();

4352
www/repl/python/lumbda.py Normal file

File diff suppressed because it is too large Load diff

385
www/repl/python/stdlib.lsp Normal file
View file

@ -0,0 +1,385 @@
;;; stdlib.lsp — standard library for lumbda
;;; Load with: (load "stdlib.lsp")
;;; Automatically loaded by the interpreter if found next to lumbda.py.
;;;; ── Syntax-rules versions of core macros ────────────────────────────────
(define-syntax my-let
(syntax-rules ()
((my-let ((var val) ...) body ...)
((lambda (var ...) body ...) val ...))))
(define-syntax my-let*
(syntax-rules ()
((my-let* () body ...)
(begin body ...))
((my-let* ((var val) rest ...) body ...)
(let ((var val)) (my-let* (rest ...) body ...)))))
(define-syntax my-and
(syntax-rules ()
((my-and) #t)
((my-and e) e)
((my-and e1 e2 ...)
(if e1 (my-and e2 ...) #f))))
(define-syntax my-or
(syntax-rules ()
((my-or) #f)
((my-or e) e)
((my-or e1 e2 ...)
(let ((t e1))
(if t t (my-or e2 ...))))))
(define-syntax my-cond
(syntax-rules (else =>)
((my-cond (else e ...)) (begin e ...))
((my-cond (test => f) rest ...)
(let ((t test)) (if t (f t) (my-cond rest ...))))
((my-cond (test e ...) rest ...)
(if test (begin e ...) (my-cond rest ...)))
((my-cond) (void))))
(define-syntax my-case
(syntax-rules (else)
((my-case key (else e ...)) (begin e ...))
((my-case key ((datum ...) e ...) rest ...)
(if (memv key '(datum ...))
(begin e ...)
(my-case key rest ...)))
((my-case key) (void))))
(define-syntax my-when
(syntax-rules ()
((my-when test body ...)
(if test (begin body ...) (void)))))
(define-syntax my-unless
(syntax-rules ()
((my-unless test body ...)
(if test (void) (begin body ...)))))
(define-syntax my-do
(syntax-rules ()
((my-do ((var init step ...) ...)
(test result ...)
body ...)
(let loop ((var init) ...)
(if test
(begin result ...)
(begin body ...
(loop (if (null? '(step ...)) var (car '(step ...))) ...)))))))
;;;; ── Pattern-matched swap ────────────────────────────────────────────────
(define-syntax swap!
(syntax-rules ()
((swap! a b)
(let ((tmp a))
(set! a b)
(set! b tmp)))))
;;;; ── fluid-let ──────────────────────────────────────────────────────────
(define-syntax fluid-let
(syntax-rules ()
((fluid-let ((var val) ...) body ...)
(let ((old-var var) ...)
(set! var val) ...
(let ((result (begin body ...)))
(set! var old-var) ...
result)))))
;;;; ── receive (SRFI-8) ───────────────────────────────────────────────────
(define-syntax receive
(syntax-rules ()
((receive formals expression body ...)
(call-with-values (lambda () expression)
(lambda formals body ...)))))
;;;; ── begin0 ────────────────────────────────────────────────────────────
(define-syntax begin0
(syntax-rules ()
((begin0 first rest ...)
(let ((result first))
rest ...
result))))
;;;; ── while / until ──────────────────────────────────────────────────────
(define-syntax while
(syntax-rules ()
((while test body ...)
(let loop ()
(when test body ... (loop))))))
(define-syntax until
(syntax-rules ()
((until test body ...)
(let loop ()
(unless test body ... (loop))))))
;;;; ── dotimes / dolist ───────────────────────────────────────────────────
(define-syntax dotimes
(syntax-rules ()
((dotimes (var n result ...) body ...)
(let loop ((var 0))
(if (= var n)
(begin result ...)
(begin body ... (loop (+ var 1))))))))
(define-syntax dolist
(syntax-rules ()
((dolist (var lst result ...) body ...)
(begin
(for-each (lambda (var) body ...) lst)
result ...))))
;;;; ── push! / pop! ───────────────────────────────────────────────────────
(define-syntax push!
(syntax-rules ()
((push! val lst)
(set! lst (cons val lst)))))
(define-syntax pop!
(syntax-rules ()
((pop! lst)
(let ((top (car lst)))
(set! lst (cdr lst))
top))))
;;;; ── and-let* (SRFI-2) ─────────────────────────────────────────────────
(define-syntax and-let*
(syntax-rules ()
((and-let* () body ...) (begin body ...))
((and-let* ((var expr) rest ...) body ...)
(let ((var expr))
(if var (and-let* (rest ...) body ...) #f)))
((and-let* ((expr) rest ...) body ...)
(if expr (and-let* (rest ...) body ...) #f))))
;;;; ── string utilities ───────────────────────────────────────────────────
(define (string-repeat s n)
(apply string-append (map (lambda (_) s) (iota n))))
(define (string-pad-left s len ch)
(let ((pad (- len (string-length s))))
(if (<= pad 0) s
(string-append (make-string pad ch) s))))
(define (string-pad-right s len ch)
(let ((pad (- len (string-length s))))
(if (<= pad 0) s
(string-append s (make-string pad ch)))))
(define (string->chars s) (string->list s))
(define (chars->string cs) (list->string cs))
;;;; ── list utilities ─────────────────────────────────────────────────────
(define (list-update! lst i val)
(list-set! lst i val)
lst)
(define (enumerate lst)
(map list (iota (length lst)) lst))
(define (transpose lsts)
(apply map list lsts))
(define (interleave lst sep)
(if (or (null? lst) (null? (cdr lst)))
lst
(cons (car lst) (cons sep (interleave (cdr lst) sep)))))
(define (chunks lst n)
(if (null? lst)
'()
(cons (take lst (min n (length lst)))
(chunks (drop lst n) n))))
(define (repeat-list x n)
(map (lambda (_) x) (iota n)))
(define (zip-with f . lsts)
(apply map f lsts))
(define (sum lst) (fold-left + 0 lst))
(define (product lst) (fold-left * 1 lst))
(define (maximum lst) (fold-left max (car lst) (cdr lst)))
(define (minimum lst) (fold-left min (car lst) (cdr lst)))
(define (average lst) (/ (sum lst) (length lst)))
;;;; ── numeric utilities ──────────────────────────────────────────────────
(define (clamp x lo hi) (max lo (min hi x)))
(define (between? x lo hi) (and (>= x lo) (<= x hi)))
(define (factorial n)
(let loop ((i n) (acc 1))
(if (<= i 1) acc (loop (- i 1) (* acc i)))))
(define (fib n)
(let loop ((a 0) (b 1) (i 0))
(if (= i n) a (loop b (+ a b) (+ i 1)))))
(define (prime? n)
(if (< n 2) #f
(let loop ((i 2))
(cond ((> (* i i) n) #t)
((= (remainder n i) 0) #f)
(else (loop (+ i 1)))))))
(define (primes-up-to n)
(filter prime? (range 2 (+ n 1))))
;;;; ── I/O utilities ──────────────────────────────────────────────────────
(define (println . args)
(for-each (lambda (x) (display x) (display " ")) args)
(newline))
(define (print-table rows)
(for-each (lambda (row)
(for-each (lambda (cell) (display cell) (display "\t")) row)
(newline))
rows))
(define (with-output-string thunk)
(with-output-to-string thunk))
;;;; ── association-list utilities ─────────────────────────────────────────
(define (alist-get key alist . default)
(let ((pair (assoc key alist)))
(if pair (cdr pair)
(if (null? default) #f (car default)))))
(define (alist-set key val alist)
(cons (cons key val)
(filter (lambda (p) (not (equal? (car p) key))) alist)))
(define (alist-remove key alist)
(filter (lambda (p) (not (equal? (car p) key))) alist))
(define (alist-keys alist) (map car alist))
(define (alist-values alist) (map cdr alist))
;;;; ── hash-table utilities ───────────────────────────────────────────────
(define (hash-table-map h f)
(let ((result (make-hash-table)))
(hash-table-walk h (lambda (k v) (hash-table-set! result k (f v))))
result))
(define (hash-table-filter h pred)
(let ((result (make-hash-table)))
(hash-table-walk h (lambda (k v) (when (pred k v) (hash-table-set! result k v))))
result))
(define (hash-table-from-lists keys vals)
(let ((h (make-hash-table)))
(for-each (lambda (k v) (hash-table-set! h k v)) keys vals)
h))
;;;; ── tree utilities ─────────────────────────────────────────────────────
(define (tree-map f tree)
(if (pair? tree)
(cons (tree-map f (car tree)) (tree-map f (cdr tree)))
(f tree)))
(define (tree-fold f init tree)
(if (pair? tree)
(tree-fold f (tree-fold f init (car tree)) (cdr tree))
(f init tree)))
(define (tree-member? x tree)
(cond ((null? tree) #f)
((equal? x tree) #t)
((pair? tree) (or (tree-member? x (car tree))
(tree-member? x (cdr tree))))
(else #f)))
;;;; ── simple object system ───────────────────────────────────────────────
;;; (make-object methods-alist) → an object
;;; (send obj 'method arg...) → dispatch
(define (make-object methods)
(lambda (msg . args)
(let ((m (assoc msg methods)))
(if m
(apply (cdr m) args)
(error "unknown method" msg)))))
(define (send obj msg . args)
(apply obj msg args))
;;;; ── coroutine via call/cc ───────────────────────────────────────────────
(define (make-generator thunk)
(let ((k #f) (done #f))
(lambda ()
(if done 'done
(call/cc
(lambda (return)
(if k
(k return)
(begin
(thunk (lambda (val)
(call/cc (lambda (next)
(set! k next)
(return val)))))
(set! done #t)
(return 'done)))))))))
;;; ─── SRFI-64 lightweight test framework ─────────────────────────────────────
(define *test-pass* 0)
(define *test-fail* 0)
(define *test-group* "")
(define *test-verbose* #f)
(define (test-begin name)
(set! *test-group* name)
(set! *test-pass* 0)
(set! *test-fail* 0)
(display (string-append "--- " name " ---\n")))
(define (test-end)
(display (string-append *test-group* ": "
(number->string *test-pass*) " passed, "
(number->string *test-fail*) " failed\n"))
(= *test-fail* 0))
(define (test-assert msg val)
(if val
(begin (set! *test-pass* (+ *test-pass* 1))
(when *test-verbose* (display (string-append " OK " msg "\n"))))
(begin (set! *test-fail* (+ *test-fail* 1))
(display (string-append " FAIL " msg "\n")))))
(define-macro (test-equal msg expected expr)
(let ((r (gensym)) (e (gensym)))
`(let ((,r ,expr) (,e ,expected))
(if (equal? ,r ,e)
(begin (set! *test-pass* (+ *test-pass* 1))
(when *test-verbose* (display (string-append " OK " ,msg "\n"))))
(begin (set! *test-fail* (+ *test-fail* 1))
(display (string-append " FAIL " ,msg ": got " (write-to-string ,r)
" expected " (write-to-string ,e) "\n")))))))
(define-macro (test-error msg expr)
(let ((ok (gensym)))
`(let ((,ok (guard (e (#t #t)) ,expr #f)))
(if ,ok
(begin (set! *test-pass* (+ *test-pass* 1))
(when *test-verbose* (display (string-append " OK " ,msg " (error)\n"))))
(begin (set! *test-fail* (+ *test-fail* 1))
(display (string-append " FAIL " ,msg " (expected error)\n")))))))

226
www/repl/repl.css Normal file
View file

@ -0,0 +1,226 @@
/* lumbda repl specific styles — inherits style.css palette. */
body.repl { display: flex; flex-direction: column; min-height: 100vh; }
.lock-screen {
position: fixed; inset: 0; z-index: 1000;
display: flex; align-items: center; justify-content: center;
background: rgba(15, 17, 21, 0.85);
backdrop-filter: blur(4px);
}
.lock-card {
background: var(--pane-bg);
border: 1px solid var(--rule);
border-radius: 6px;
padding: 1.5rem 2rem;
max-width: 28rem;
width: 90%;
}
.lock-card h2 {
margin: 0 0 0.5rem;
font-family: 'chunkfive', Georgia, serif;
font-size: 1.4rem;
color: var(--green);
font-weight: normal;
}
.lock-card p {
margin: 0 0 0.8rem;
font-size: 0.85rem;
color: var(--muted);
line-height: 1.5;
}
.lock-card input[type=password] {
width: 100%;
padding: 0.5rem 0.7rem;
margin: 0.4rem 0;
background: var(--code-bg);
color: var(--fg);
border: 1px solid var(--rule);
border-radius: 3px;
font-family: var(--mono);
font-size: 0.95rem;
}
.lock-card input[type=password]:focus {
outline: none;
border-color: var(--green);
}
.lock-row { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
.lock-row button {
font-family: var(--mono);
font-size: 0.85em; font-weight: 600;
padding: 0.45rem 1.1rem;
border-radius: 3px;
cursor: pointer;
border: 1px solid var(--green);
}
.lock-row .primary { background: var(--green); color: var(--bg); }
.lock-row .secondary { background: transparent; color: var(--green); }
.lock-row .primary:hover { filter: brightness(1.1); }
.lock-row .secondary:hover { background: var(--code-bg); }
.lock-note {
margin: 0.6rem 0 0;
color: var(--err);
font-size: 0.8rem;
min-height: 1.2em;
}
.tabbar {
display: flex;
gap: 0.3rem;
align-items: center;
padding: 0.4rem 1rem;
border-bottom: 1px solid var(--rule);
background: var(--bg);
}
.tabs {
display: flex;
gap: 0.3rem;
overflow-x: auto;
flex: 0 1 auto;
}
.tab {
background: var(--pane-bg);
border: 1px solid var(--rule);
padding: 0.25rem 0.6rem;
border-radius: 3px 3px 0 0;
font-size: 0.85em;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.3rem;
white-space: nowrap;
}
.tab.active {
background: var(--code-bg);
border-bottom-color: var(--code-bg);
color: var(--green);
}
.tab .tab-close {
opacity: 0.5;
cursor: pointer;
padding: 0 0.2rem;
}
.tab .tab-close:hover { opacity: 1; color: var(--err); }
.tabbar .ghost {
background: transparent;
border: 1px solid var(--rule);
color: var(--muted);
font-family: var(--mono);
font-size: 0.8em;
padding: 0.2rem 0.6rem;
border-radius: 3px;
cursor: pointer;
}
.tabbar .ghost:hover:not(:disabled) {
border-color: var(--green); color: var(--green);
}
.tabbar .ghost:disabled { opacity: 0.4; cursor: default; }
.tabbar .spacer { flex: 1; }
.transcript-wrap {
flex: 1;
overflow: auto;
padding: 1rem;
background: var(--code-bg);
}
.transcript {
font-family: var(--mono);
font-size: 0.9em;
line-height: 1.5;
max-width: 90rem;
margin: 0 auto;
}
.transcript .entry {
margin-bottom: 0.8rem;
padding: 0.4rem 0.6rem;
border-left: 2px solid var(--rule);
}
.transcript .entry .prompt-line {
color: var(--green);
white-space: pre-wrap;
margin-bottom: 0.2rem;
}
.transcript .entry .tier-output {
display: flex;
gap: 0.6rem;
padding: 0.25rem 0;
border-top: 1px dashed var(--rule);
}
.transcript .entry .tier-output:first-of-type { border-top: none; }
.transcript .entry .tier-label {
color: var(--muted);
font-size: 0.7em;
text-transform: uppercase;
letter-spacing: 0.08em;
min-width: 7rem;
flex-shrink: 0;
padding-top: 0.15rem;
}
.transcript .entry .tier-result {
white-space: pre-wrap;
flex: 1;
}
.transcript .entry .tier-time {
color: var(--muted);
font-size: 0.8em;
margin-left: 0.4rem;
}
.transcript .entry.error .tier-result { color: var(--err); }
.prompt-bar {
display: flex;
align-items: flex-end;
gap: 0.5rem;
padding: 0.5rem 1rem 0.8rem;
border-top: 1px solid var(--rule);
background: var(--bg);
}
.prompt-bar .prompt-sigil {
color: var(--green);
font-weight: 600;
padding: 0.4rem 0 0;
}
.prompt-bar textarea {
flex: 1;
resize: none;
background: var(--code-bg);
border: 1px solid var(--rule);
color: var(--fg);
padding: 0.45rem 0.7rem;
font-family: var(--mono);
font-size: 0.9em;
border-radius: 3px;
min-height: 1.6em;
max-height: 30vh;
}
.prompt-bar textarea:focus { outline: none; border-color: var(--green); }
.prompt-bar select {
background: var(--code-bg);
border: 1px solid var(--rule);
color: var(--fg);
padding: 0.35rem 0.5rem;
font-family: var(--mono);
font-size: 0.85em;
border-radius: 3px;
}
.prompt-bar button.primary {
background: var(--green);
color: var(--bg);
border: 1px solid var(--green);
padding: 0.4rem 1rem;
font-family: var(--mono);
font-weight: 600;
border-radius: 3px;
cursor: pointer;
}
.prompt-bar button.primary:hover { filter: brightness(1.1); }
.prompt-bar button.primary:disabled { opacity: 0.4; cursor: wait; }
kbd {
font-family: var(--mono);
background: var(--code-bg);
padding: 0.05em 0.3em;
border: 1px solid var(--rule);
border-radius: 2px;
font-size: 0.85em;
}

360
www/repl/repl.js Normal file
View file

@ -0,0 +1,360 @@
// wasm/repl/repl.js
// Lumbda interactive REPL with multi-tab sessions + encrypted persistence.
import { openVault } from "./crypto.js";
const TIER_LABEL = {
python: "python (pyodide)",
c: "c (emcc)",
asm: "asm (wat)",
};
// ─── DOM ────────────────────────────────────────────────────────────
const lockScreen = document.getElementById("lock-screen");
const passwordEl = document.getElementById("password");
const unlockBtn = document.getElementById("unlock");
const freshBtn = document.getElementById("fresh");
const lockNote = document.getElementById("lock-note");
const tabbar = document.getElementById("tabbar");
const tabsEl = document.getElementById("tabs");
const newTabBtn = document.getElementById("new-tab");
const resetTierBtn = document.getElementById("reset-tier");
const clearLogBtn = document.getElementById("clear-log");
const cancelBtn = document.getElementById("cancel");
const lockBtn = document.getElementById("lock");
const transcriptWrap = document.getElementById("transcript-wrap");
const transcriptEl = document.getElementById("transcript");
const promptBar = document.getElementById("prompt-bar");
const inputEl = document.getElementById("input");
const tierSelectEl = document.getElementById("tier-select");
const sendBtn = document.getElementById("send");
// ─── State ──────────────────────────────────────────────────────────
const state = {
vault: null, // { read, write, destroy, vaultId } or null (ephemeral)
tabs: [], // [{ id, name, tier, transcript: [{kind, text, results?}] }]
activeTabId: null,
workers: {}, // { [tabId+":"+tier]: Worker }
pending: {}, // same key → { runId, reject }
nextRunId: 1,
nextTabId: 1,
saveTimer: null,
};
// ─── Vault ──────────────────────────────────────────────────────────
async function saveSoon() {
if (!state.vault) return;
if (state.saveTimer) clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(async () => {
try {
await state.vault.write({
tabs: state.tabs,
activeTabId: state.activeTabId,
savedAt: Date.now(),
});
} catch (e) {
console.error("vault write failed:", e);
}
}, 400);
}
async function tryUnlock() {
const pw = passwordEl.value;
if (!pw) { lockNote.textContent = "password required (or click ephemeral)"; return; }
lockNote.textContent = "";
try {
state.vault = await openVault(pw);
const data = await state.vault.read();
if (data && data.__decryptionFailed) {
// Wrong password for an existing vault under this id.
lockNote.textContent = "vault exists but password is wrong";
state.vault = null;
return;
}
if (data && Array.isArray(data.tabs) && data.tabs.length > 0) {
state.tabs = data.tabs;
state.activeTabId = data.activeTabId || data.tabs[0].id;
state.nextTabId = Math.max(...state.tabs.map((t) => t.id)) + 1;
} else {
state.tabs = [];
state.activeTabId = null;
state.nextTabId = 1;
}
enterRepl();
} catch (e) {
lockNote.textContent = "unlock failed: " + e.message;
}
}
function enterEphemeral() {
state.vault = null;
state.tabs = [];
state.activeTabId = null;
state.nextTabId = 1;
enterRepl();
}
function enterRepl() {
lockScreen.hidden = true;
tabbar.hidden = false;
transcriptWrap.hidden = false;
promptBar.hidden = false;
if (state.tabs.length === 0) newTab();
else renderAll();
inputEl.focus();
}
function relock() {
// Terminate all workers, clear in-memory state, return to lock screen.
for (const k of Object.keys(state.workers)) {
try { state.workers[k].terminate(); } catch {}
}
state.workers = {};
state.pending = {};
state.vault = null;
state.tabs = [];
state.activeTabId = null;
transcriptEl.innerHTML = "";
tabsEl.innerHTML = "";
tabbar.hidden = true;
transcriptWrap.hidden = true;
promptBar.hidden = true;
lockScreen.hidden = false;
passwordEl.value = "";
passwordEl.focus();
}
// ─── Tabs ───────────────────────────────────────────────────────────
function newTab(name) {
const id = state.nextTabId++;
name = name || `session ${id}`;
state.tabs.push({ id, name, tier: "c", transcript: [] });
state.activeTabId = id;
renderAll();
saveSoon();
}
function closeTab(id) {
const idx = state.tabs.findIndex((t) => t.id === id);
if (idx < 0) return;
// Terminate this tab's workers.
for (const k of Object.keys(state.workers)) {
if (k.startsWith(id + ":")) {
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
state.tabs.splice(idx, 1);
if (state.activeTabId === id) {
state.activeTabId = state.tabs.length ? state.tabs[Math.max(0, idx - 1)].id : null;
}
if (state.tabs.length === 0) newTab();
else renderAll();
saveSoon();
}
function setActiveTab(id) {
state.activeTabId = id;
renderAll();
saveSoon();
}
function activeTab() {
return state.tabs.find((t) => t.id === state.activeTabId);
}
// ─── Workers ────────────────────────────────────────────────────────
function workerKey(tabId, tier) { return `${tabId}:${tier}`; }
function spawnWorker(tabId, tier) {
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
state.workers[workerKey(tabId, tier)] = w;
return w;
}
function ensureWorker(tabId, tier) {
const k = workerKey(tabId, tier);
if (!state.workers[k]) spawnWorker(tabId, tier);
return state.workers[k];
}
function evalInTier(tabId, tier, src) {
return new Promise((resolve, reject) => {
const w = ensureWorker(tabId, tier);
const runId = state.nextRunId++;
const pendingKey = workerKey(tabId, tier);
state.pending[pendingKey] = { runId, reject };
const handler = (e) => {
if (e.data.runId !== runId) return;
if (e.data.kind === "done") {
w.removeEventListener("message", handler);
delete state.pending[pendingKey];
resolve(e.data.output);
} else if (e.data.kind === "error") {
w.removeEventListener("message", handler);
delete state.pending[pendingKey];
reject(new Error(e.data.message));
}
};
w.addEventListener("message", handler);
w.postMessage({ kind: "eval", runId, tier, src });
});
}
function rebootTier(tabId, tier) {
const k = workerKey(tabId, tier);
if (state.workers[k]) {
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
if (state.pending[k]) {
try { state.pending[k].reject(new Error("rebooted")); } catch {}
delete state.pending[k];
}
}
function cancelAllPendingInActiveTab() {
const tab = activeTab();
if (!tab) return;
for (const k of Object.keys(state.pending)) {
if (k.startsWith(tab.id + ":")) {
try { state.pending[k].reject(new Error("cancelled")); } catch {}
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
delete state.pending[k];
}
}
}
// ─── Render ─────────────────────────────────────────────────────────
function renderAll() {
// Tabs
tabsEl.innerHTML = "";
for (const t of state.tabs) {
const el = document.createElement("div");
el.className = "tab" + (t.id === state.activeTabId ? " active" : "");
const name = document.createElement("span");
name.textContent = t.name;
name.addEventListener("click", () => setActiveTab(t.id));
name.addEventListener("dblclick", (e) => {
e.stopPropagation();
const newName = prompt("rename tab:", t.name);
if (newName) { t.name = newName.slice(0, 40); renderAll(); saveSoon(); }
});
const close = document.createElement("span");
close.className = "tab-close";
close.textContent = "×";
close.title = "close tab";
close.addEventListener("click", (e) => { e.stopPropagation(); closeTab(t.id); });
el.appendChild(name);
el.appendChild(close);
tabsEl.appendChild(el);
}
// Transcript
transcriptEl.innerHTML = "";
const tab = activeTab();
if (!tab) return;
tierSelectEl.value = tab.tier;
for (const entry of tab.transcript) {
const block = document.createElement("div");
block.className = "entry" + (entry.kind === "error" ? " error" : "");
const prompt = document.createElement("div");
prompt.className = "prompt-line";
prompt.textContent = "λ> " + entry.input;
block.appendChild(prompt);
for (const r of (entry.results || [])) {
const row = document.createElement("div");
row.className = "tier-output";
const lab = document.createElement("div");
lab.className = "tier-label";
lab.textContent = TIER_LABEL[r.tier] || r.tier;
const tm = document.createElement("span");
tm.className = "tier-time";
tm.textContent = ` ${(r.elapsed | 0)}ms`;
lab.appendChild(tm);
const out = document.createElement("div");
out.className = "tier-result";
out.textContent = r.output || (r.error ? "error: " + r.error : "");
row.appendChild(lab);
row.appendChild(out);
block.appendChild(row);
}
transcriptEl.appendChild(block);
}
transcriptWrap.scrollTop = transcriptWrap.scrollHeight;
}
// ─── Input handling ─────────────────────────────────────────────────
async function sendInput() {
const src = inputEl.value.trim();
if (!src) return;
const tab = activeTab();
if (!tab) return;
tab.tier = tierSelectEl.value;
const tiers = tab.tier === "all" ? ["python", "c", "asm"] : [tab.tier];
const entry = { input: src, results: [], kind: "ok" };
tab.transcript.push(entry);
inputEl.value = "";
sendBtn.disabled = true;
cancelBtn.disabled = false;
renderAll();
saveSoon();
const startTimes = {};
for (const t of tiers) startTimes[t] = performance.now();
const promises = tiers.map((t) =>
evalInTier(tab.id, t, src)
.then((output) => ({ tier: t, output, elapsed: performance.now() - startTimes[t] }))
.catch((e) => ({ tier: t, error: e.message, elapsed: performance.now() - startTimes[t] })));
// Append results as they arrive so user sees them in race order.
let remaining = promises.length;
for (const p of promises) {
p.then((r) => {
entry.results.push(r);
if (r.error) entry.kind = "error";
renderAll();
saveSoon();
remaining--;
if (remaining === 0) {
sendBtn.disabled = false;
cancelBtn.disabled = true;
inputEl.focus();
}
});
}
}
// ─── Wire up ────────────────────────────────────────────────────────
unlockBtn.addEventListener("click", tryUnlock);
freshBtn.addEventListener("click", enterEphemeral);
passwordEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") tryUnlock();
});
newTabBtn.addEventListener("click", () => newTab());
resetTierBtn.addEventListener("click", () => {
const tab = activeTab();
if (tab) rebootTier(tab.id, tab.tier);
});
clearLogBtn.addEventListener("click", () => {
const tab = activeTab();
if (tab) { tab.transcript = []; renderAll(); saveSoon(); }
});
cancelBtn.addEventListener("click", cancelAllPendingInActiveTab);
lockBtn.addEventListener("click", relock);
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendInput();
}
});
sendBtn.addEventListener("click", sendInput);
tierSelectEl.addEventListener("change", () => {
const tab = activeTab();
if (tab) { tab.tier = tierSelectEl.value; saveSoon(); }
});

25
www/repl/runner.js Normal file
View file

@ -0,0 +1,25 @@
// wasm/app/runner.js
// Tier runner — wraps the three loaders. Exposes a per-tier API so the
// 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 { createCTier } from "./c/lumbda-c.loader.js";
import { createAsmTier } from "./asm/lumbda-asm.loader.js";
const cache = {};
export async function getTier(name, onLoad) {
if (cache[name]) return cache[name];
if (onLoad) onLoad(name);
if (name === "python") cache[name] = await createPythonTier();
else if (name === "c") cache[name] = await createCTier();
else if (name === "asm") cache[name] = await createAsmTier();
else throw new Error(`unknown tier: ${name}`);
return cache[name];
}
export async function evalOnTier(name, src, onLoad) {
const tier = await getTier(name, onLoad);
return tier.evalLisp(src);
}

282
www/repl/style.css Normal file
View file

@ -0,0 +1,282 @@
/* 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 {
--fg: #1a1a1a;
--bg: #fafaf7;
--muted: #666;
--accent: #227842; /* single brand green — matches homepage */
--green: #227842;
--rule: #d4d4d0;
--code-bg: #f0ede4;
--pane-bg: #ffffff;
--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; }
html, body {
margin: 0; padding: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--mono);
font-size: 14px;
line-height: 1.55;
min-height: 100%;
}
/* ─── Header ────────────────────────────────────────────────────── */
header {
border-bottom: 1px solid var(--rule);
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 {
font-family: 'chunkfive', Georgia, serif;
font-size: 2.4rem;
margin: 0;
letter-spacing: -0.01em;
line-height: 1;
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 {
background: var(--code-bg);
padding: 0 0.2em;
border-radius: 2px;
font-size: 0.9em;
}
/* ─── Controls ──────────────────────────────────────────────────── */
.controls {
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--rule);
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
background: var(--bg);
}
.controls fieldset {
border: 1px solid var(--rule);
border-radius: 3px;
padding: 3px 10px 5px;
margin: 0;
background: var(--pane-bg);
}
.controls fieldset legend {
color: var(--muted);
font-size: 0.7rem;
padding: 0 0.4em;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.controls label {
margin-right: 0.7em;
cursor: pointer;
user-select: none;
font-size: 0.85em;
}
.controls label input {
margin-right: 0.3em;
accent-color: var(--green);
}
.controls button {
font-family: var(--mono);
font-size: 0.85em;
font-weight: 600;
padding: 6px 16px;
border-radius: 3px;
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 .status {
color: var(--muted);
font-size: 0.8em;
margin-left: auto;
}
.controls .status.busy { color: var(--busy); }
.controls .status.warn { color: var(--busy); }
.controls .status.err { color: var(--err); }
.controls .status.ok { color: var(--green); }
/* ─── Panes ─────────────────────────────────────────────────────── */
.panes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--rule);
height: calc(100vh - 260px);
min-height: 360px;
}
.pane {
background: var(--pane-bg);
padding: 0.5rem 0.75rem 0.75rem;
overflow: hidden;
display: flex;
flex-direction: column;
}
.pane h2 {
margin: 0 0 0.4rem;
font-size: 0.7rem;
font-weight: 500;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.1em;
}
#editor {
flex: 1;
overflow: hidden;
border-radius: 2px;
}
.cm-editor { height: 100%; font-size: 13px; }
.cm-editor.cm-focused { outline: none; }
#output {
flex: 1;
white-space: pre;
overflow: auto;
background: var(--code-bg);
border-radius: 2px;
padding: 0.6rem 0.8rem;
font-size: 13px;
line-height: 1.35;
}
/* Append-only: results arrive in finish order — fastest tier shows up first. */
#output .tier-block { margin-bottom: 1rem; }
#output .tier-block:last-child { margin-bottom: 0; }
#output .tier-block h3 {
margin: 0 0 0.25rem;
font-size: 0.7rem;
color: var(--green);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 600;
}
#output .tier-block .time {
color: var(--muted);
font-size: 0.65rem;
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
#output .tier-block pre {
margin: 0;
white-space: pre;
font-family: var(--mono);
}
#output .err {
color: var(--err);
}
/* ─── Footer ────────────────────────────────────────────────────── */
footer {
padding: 0.7rem 1.5rem;
border-top: 1px solid var(--rule);
color: var(--muted);
font-size: 0.75rem;
}
footer code {
background: var(--code-bg);
padding: 0 0.2em;
border-radius: 2px;
}
footer a {
color: var(--green);
text-decoration: none;
}
footer a:hover { text-decoration: underline; }
footer strong { color: var(--fg); }

19
www/repl/worker.mjs Normal file
View 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) });
}
};