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:
parent
1c9d74b407
commit
d8ffab5ea6
42 changed files with 12859 additions and 5 deletions
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
45
wasm/dist-repl/asm/lumbda-asm.loader.js
Normal file
45
wasm/dist-repl/asm/lumbda-asm.loader.js
Normal 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;
|
||||
};
|
||||
})();
|
||||
BIN
wasm/dist-repl/asm/lumbda-asm.wasm
Normal file
BIN
wasm/dist-repl/asm/lumbda-asm.wasm
Normal file
Binary file not shown.
2
wasm/dist-repl/c/lumbda-c.js
Normal file
2
wasm/dist-repl/c/lumbda-c.js
Normal file
File diff suppressed because one or more lines are too long
56
wasm/dist-repl/c/lumbda-c.loader.js
Normal file
56
wasm/dist-repl/c/lumbda-c.loader.js
Normal 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
BIN
wasm/dist-repl/c/lumbda-c.wasm
Executable file
Binary file not shown.
104
wasm/dist-repl/crypto.js
Normal file
104
wasm/dist-repl/crypto.js
Normal 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 };
|
||||
}
|
||||
BIN
wasm/dist-repl/fonts/chunkfive/chunkfive-regular-webfont.woff
Normal file
BIN
wasm/dist-repl/fonts/chunkfive/chunkfive-regular-webfont.woff
Normal file
Binary file not shown.
BIN
wasm/dist-repl/fonts/chunkfive/chunkfive-regular-webfont.woff2
Normal file
BIN
wasm/dist-repl/fonts/chunkfive/chunkfive-regular-webfont.woff2
Normal file
Binary file not shown.
95
wasm/dist-repl/index.html
Normal file
95
wasm/dist-repl/index.html
Normal 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">λ></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>
|
||||
BIN
wasm/dist-repl/lumbda-logo-green.png
Normal file
BIN
wasm/dist-repl/lumbda-logo-green.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
69
wasm/dist-repl/python/lumbda-py.js
Normal file
69
wasm/dist-repl/python/lumbda-py.js
Normal 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
wasm/dist-repl/python/lumbda.py
Normal file
4352
wasm/dist-repl/python/lumbda.py
Normal file
File diff suppressed because it is too large
Load diff
385
wasm/dist-repl/python/stdlib.lsp
Normal file
385
wasm/dist-repl/python/stdlib.lsp
Normal 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
226
wasm/dist-repl/repl.css
Normal 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
360
wasm/dist-repl/repl.js
Normal 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
25
wasm/dist-repl/runner.js
Normal 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
282
wasm/dist-repl/style.css
Normal 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
19
wasm/dist-repl/worker.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// wasm/app/worker.mjs
|
||||
// Tier-evaluation Web Worker. Keeps the main thread responsive so the
|
||||
// live ms counter actually ticks AND so cancel works (main thread
|
||||
// terminates this worker via worker.terminate()).
|
||||
|
||||
import { evalOnTier } from "./runner.js";
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { kind, runId, tier, src } = e.data;
|
||||
if (kind !== "eval") return;
|
||||
try {
|
||||
const output = await evalOnTier(tier, src, (loadingTier) => {
|
||||
self.postMessage({ kind: "loading", runId, tier: loadingTier });
|
||||
});
|
||||
self.postMessage({ kind: "done", runId, output });
|
||||
} catch (err) {
|
||||
self.postMessage({ kind: "error", runId, message: err && err.message ? err.message : String(err) });
|
||||
}
|
||||
};
|
||||
104
wasm/repl/crypto.js
Normal file
104
wasm/repl/crypto.js
Normal 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
95
wasm/repl/index.html
Normal 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">λ></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
226
wasm/repl/repl.css
Normal 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
360
wasm/repl/repl.js
Normal 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(); }
|
||||
});
|
||||
|
|
@ -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 — 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 →</a></p>
|
||||
<p>All three tiers compiled to WebAssembly — 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 →</a>
|
||||
<a class="cta" href="repl/">Open the REPL →</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
|
@ -101,6 +104,8 @@ make test-all</code></pre>
|
|||
·
|
||||
<a href="playground/">playground</a>
|
||||
·
|
||||
<a href="repl/">repl</a>
|
||||
·
|
||||
<a href="lumbda-whitepaper.html">whitepaper (HTML)</a>
|
||||
·
|
||||
<a href="lumbda-whitepaper.pdf">whitepaper (PDF)</a>
|
||||
|
|
|
|||
45
www/repl/asm/lumbda-asm.loader.js
Normal file
45
www/repl/asm/lumbda-asm.loader.js
Normal 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;
|
||||
};
|
||||
})();
|
||||
BIN
www/repl/asm/lumbda-asm.wasm
Normal file
BIN
www/repl/asm/lumbda-asm.wasm
Normal file
Binary file not shown.
2
www/repl/c/lumbda-c.js
Normal file
2
www/repl/c/lumbda-c.js
Normal file
File diff suppressed because one or more lines are too long
56
www/repl/c/lumbda-c.loader.js
Normal file
56
www/repl/c/lumbda-c.loader.js
Normal 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
BIN
www/repl/c/lumbda-c.wasm
Executable file
Binary file not shown.
104
www/repl/crypto.js
Normal file
104
www/repl/crypto.js
Normal 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 };
|
||||
}
|
||||
BIN
www/repl/fonts/chunkfive/chunkfive-regular-webfont.woff
Normal file
BIN
www/repl/fonts/chunkfive/chunkfive-regular-webfont.woff
Normal file
Binary file not shown.
BIN
www/repl/fonts/chunkfive/chunkfive-regular-webfont.woff2
Normal file
BIN
www/repl/fonts/chunkfive/chunkfive-regular-webfont.woff2
Normal file
Binary file not shown.
95
www/repl/index.html
Normal file
95
www/repl/index.html
Normal 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">λ></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>
|
||||
BIN
www/repl/lumbda-logo-green.png
Normal file
BIN
www/repl/lumbda-logo-green.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
69
www/repl/python/lumbda-py.js
Normal file
69
www/repl/python/lumbda-py.js
Normal 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
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
385
www/repl/python/stdlib.lsp
Normal 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
226
www/repl/repl.css
Normal 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
360
www/repl/repl.js
Normal 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
25
www/repl/runner.js
Normal 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
282
www/repl/style.css
Normal 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
19
www/repl/worker.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// wasm/app/worker.mjs
|
||||
// Tier-evaluation Web Worker. Keeps the main thread responsive so the
|
||||
// live ms counter actually ticks AND so cancel works (main thread
|
||||
// terminates this worker via worker.terminate()).
|
||||
|
||||
import { evalOnTier } from "./runner.js";
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { kind, runId, tier, src } = e.data;
|
||||
if (kind !== "eval") return;
|
||||
try {
|
||||
const output = await evalOnTier(tier, src, (loadingTier) => {
|
||||
self.postMessage({ kind: "loading", runId, tier: loadingTier });
|
||||
});
|
||||
self.postMessage({ kind: "done", runId, output });
|
||||
} catch (err) {
|
||||
self.postMessage({ kind: "error", runId, message: err && err.message ? err.message : String(err) });
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue