playground: free-form code option + encrypted vault, single-scroll layout

Free-form radio adds a 5th demo slot. When selected, a vault bar appears
under the controls: enter a password, "unlock" derives a per-device
vault and decrypts (or creates fresh). Edits in the editor auto-save
350ms after typing stops. Reload + same password restores the code.

Same Web Crypto stack as /repl/ (PBKDF2 + AES-GCM, vault id =
SHA-256(password || device-salt)).

Layout: one shared vertical scroller — code pane and output pane both
grow with content, the body scrolls. No more independent in-pane
scrollers fighting the page.

Home page split into "Demo" and "REPL" sections with their own CTAs.
This commit is contained in:
russell@unturf.com 2026-06-14 12:56:25 -04:00
parent d8ffab5ea6
commit 3d6d5d1010
No known key found for this signature in database
12 changed files with 572 additions and 44 deletions

View file

@ -131,6 +131,7 @@ $(DIST)/asm/lumbda-asm.loader.js: asm/lumbda-asm.loader.js
APP_SRC := $(wildcard app/*.html app/*.css app/*.js app/*.mjs app/demos/*.lsp)
app: $(DIST)/index.html $(DIST)/style.css $(DIST)/app.js $(DIST)/runner.js $(DIST)/worker.mjs \
$(DIST)/crypto.js \
$(DIST)/lumbda-logo-green.png $(DIST)/fonts/chunkfive/chunkfive-regular-webfont.woff2 \
$(DIST)/fonts/chunkfive/chunkfive-regular-webfont.woff

View file

@ -9,12 +9,19 @@ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { syntaxHighlighting, defaultHighlightStyle, StreamLanguage } from "@codemirror/language";
import { scheme } from "@codemirror/legacy-modes/mode/scheme";
import { oneDark } from "@codemirror/theme-one-dark";
import { openVault } from "./crypto.js";
const TIERS = { python: "python (pyodide)", c: "c (emcc)", asm: "asm (wat)" };
const FREE_FORM_DEFAULT = "; free-form mode — unlock the vault below to persist this code\n; encrypted in localStorage with your password\n\n(+ 1 2)\n";
const demoSources = {};
// Vault state. When unlocked, free-form code auto-saves on every edit.
const vaultState = { vault: null, freeForm: null, saveTimer: null };
async function loadDemoSource(name) {
if (name === "free-form") {
return vaultState.freeForm != null ? vaultState.freeForm : FREE_FORM_DEFAULT;
}
if (!demoSources[name]) {
const resp = await fetch(`demos/${name}.lsp`);
demoSources[name] = await resp.text();
@ -39,7 +46,9 @@ const editorView = new EditorView({
StreamLanguage.define(scheme),
keymap.of([...defaultKeymap, ...historyKeymap]),
oneDark,
EditorView.theme({ "&": { height: "100%" } }),
EditorView.updateListener.of((u) => {
if (u.docChanged) scheduleFreeFormSave();
}),
],
}),
parent: editorParent,
@ -57,10 +66,84 @@ function getEditorText() {
async function loadCurrentDemo() {
const sel = document.querySelector('input[name="program"]:checked').value;
const vaultBar = document.getElementById("vault-bar");
vaultBar.hidden = sel !== "free-form";
const src = await loadDemoSource(sel);
setEditorText(src);
}
// ─── Free-form vault ───────────────────────────────────────────────
const vaultBarEl = document.getElementById("vault-bar");
const vaultPwEl = document.getElementById("vault-pw");
const vaultUnlockBtn = document.getElementById("vault-unlock");
const vaultLockBtn = document.getElementById("vault-lock");
const vaultStateEl = document.getElementById("vault-state");
const vaultNoteEl = document.getElementById("vault-note");
function setVaultNote(text, isErr) {
vaultNoteEl.textContent = text || "";
vaultNoteEl.className = "vault-note" + (isErr ? " err" : "");
}
async function unlockVault() {
const pw = vaultPwEl.value;
if (!pw) { setVaultNote("password required", true); return; }
setVaultNote("");
try {
vaultState.vault = await openVault(pw);
const data = await vaultState.vault.read();
if (data && data.__decryptionFailed) {
setVaultNote("vault exists but password is wrong", true);
vaultState.vault = null;
return;
}
vaultState.freeForm = (data && typeof data.freeForm === "string")
? data.freeForm
: FREE_FORM_DEFAULT;
vaultStateEl.textContent = "unlocked";
vaultStateEl.classList.add("unlocked");
vaultUnlockBtn.hidden = true;
vaultLockBtn.hidden = false;
vaultPwEl.value = "";
vaultPwEl.disabled = true;
// If free-form is the current program, swap the editor in.
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel === "free-form") setEditorText(vaultState.freeForm);
setVaultNote("ok — edits auto-save");
} catch (e) {
setVaultNote("unlock failed: " + e.message, true);
}
}
function lockVault() {
vaultState.vault = null;
vaultState.freeForm = null;
if (vaultState.saveTimer) { clearTimeout(vaultState.saveTimer); vaultState.saveTimer = null; }
vaultStateEl.textContent = "locked";
vaultStateEl.classList.remove("unlocked");
vaultUnlockBtn.hidden = false;
vaultLockBtn.hidden = true;
vaultPwEl.disabled = false;
vaultPwEl.value = "";
setVaultNote("");
// If free-form is current program, swap to the placeholder.
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel === "free-form") setEditorText(FREE_FORM_DEFAULT);
}
function scheduleFreeFormSave() {
if (!vaultState.vault) return;
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel !== "free-form") return;
if (vaultState.saveTimer) clearTimeout(vaultState.saveTimer);
vaultState.saveTimer = setTimeout(async () => {
const text = getEditorText();
vaultState.freeForm = text;
try { await vaultState.vault.write({ freeForm: text, savedAt: Date.now() }); }
catch (e) { setVaultNote("save failed: " + e.message, true); }
}, 350);
}
function selectedTiers() {
const sel = document.querySelector('input[name="tier"]:checked').value;
return sel === "all" ? ["python", "c", "asm"] : [sel];
@ -232,4 +315,9 @@ document.querySelectorAll('input[name="program"]').forEach((el) => {
runBtn.addEventListener("click", runAll);
cancelBtn.addEventListener("click", onCancel);
cancelBtn.disabled = true;
vaultUnlockBtn.addEventListener("click", unlockVault);
vaultLockBtn.addEventListener("click", lockVault);
vaultPwEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); unlockVault(); }
});
loadCurrentDemo();

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

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

View file

@ -45,6 +45,7 @@
<label><input type="radio" name="program" value="fib-ack"> fib + ackermann</label>
<label><input type="radio" name="program" value="sieve"> sieve of eratosthenes</label>
<label><input type="radio" name="program" value="self-interp"> lisp-in-lisp meta-eval</label>
<label><input type="radio" name="program" value="free-form"> free form (encrypted)</label>
</fieldset>
<fieldset class="tier">
<legend>tier</legend>
@ -58,6 +59,15 @@
<span id="status" class="status"></span>
</section>
<section class="vault-bar" id="vault-bar" hidden>
<span class="vault-icon" title="local vault">🔒</span>
<span id="vault-state" class="vault-state">locked</span>
<input id="vault-pw" type="password" autocomplete="off" placeholder="password to unlock free-form vault">
<button id="vault-unlock" class="secondary">unlock</button>
<button id="vault-lock" class="secondary" hidden>lock</button>
<span id="vault-note" class="vault-note"></span>
</section>
<section class="panes">
<div class="pane code-pane">
<h2>code</h2>

View file

@ -49,7 +49,6 @@ html, body {
font-family: var(--mono);
font-size: 14px;
line-height: 1.55;
min-height: 100%;
}
/* ─── Header ────────────────────────────────────────────────────── */
@ -190,6 +189,42 @@ header code {
.controls .status.err { color: var(--err); }
.controls .status.ok { color: var(--green); }
.vault-bar {
padding: 0.5rem 1.5rem;
border-bottom: 1px solid var(--rule);
display: flex; align-items: center; gap: 0.6rem;
background: var(--bg);
font-size: 0.85em;
}
.vault-bar .vault-icon { font-size: 1em; }
.vault-bar .vault-state { color: var(--muted); }
.vault-bar .vault-state.unlocked { color: var(--green); }
.vault-bar input[type=password] {
background: var(--code-bg);
border: 1px solid var(--rule);
color: var(--fg);
padding: 0.3rem 0.5rem;
font-family: var(--mono);
font-size: 0.9em;
border-radius: 3px;
width: 18rem;
}
.vault-bar input[type=password]:focus { outline: none; border-color: var(--green); }
.vault-bar button {
background: transparent;
color: var(--green);
border: 1px solid var(--green);
font-family: var(--mono);
font-size: 0.85em;
font-weight: 600;
padding: 0.3rem 0.8rem;
border-radius: 3px;
cursor: pointer;
}
.vault-bar button:hover { background: var(--code-bg); }
.vault-bar .vault-note { color: var(--muted); font-size: 0.85em; }
.vault-bar .vault-note.err { color: var(--err); }
/* ─── Panes ─────────────────────────────────────────────────────── */
.panes {
@ -197,13 +232,11 @@ header code {
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--rule);
height: calc(100vh - 260px);
min-height: 360px;
align-items: start;
}
.pane {
background: var(--pane-bg);
padding: 0.5rem 0.75rem 0.75rem;
overflow: hidden;
display: flex;
flex-direction: column;
}
@ -217,17 +250,14 @@ header code {
}
#editor {
flex: 1;
overflow: hidden;
border-radius: 2px;
}
.cm-editor { height: 100%; font-size: 13px; }
.cm-editor { font-size: 13px; }
.cm-editor.cm-focused { outline: none; }
.cm-scroller { overflow: visible; }
#output {
flex: 1;
white-space: pre;
overflow: auto;
background: var(--code-bg);
border-radius: 2px;
padding: 0.6rem 0.8rem;

View file

@ -49,7 +49,6 @@ html, body {
font-family: var(--mono);
font-size: 14px;
line-height: 1.55;
min-height: 100%;
}
/* ─── Header ────────────────────────────────────────────────────── */
@ -190,6 +189,42 @@ header code {
.controls .status.err { color: var(--err); }
.controls .status.ok { color: var(--green); }
.vault-bar {
padding: 0.5rem 1.5rem;
border-bottom: 1px solid var(--rule);
display: flex; align-items: center; gap: 0.6rem;
background: var(--bg);
font-size: 0.85em;
}
.vault-bar .vault-icon { font-size: 1em; }
.vault-bar .vault-state { color: var(--muted); }
.vault-bar .vault-state.unlocked { color: var(--green); }
.vault-bar input[type=password] {
background: var(--code-bg);
border: 1px solid var(--rule);
color: var(--fg);
padding: 0.3rem 0.5rem;
font-family: var(--mono);
font-size: 0.9em;
border-radius: 3px;
width: 18rem;
}
.vault-bar input[type=password]:focus { outline: none; border-color: var(--green); }
.vault-bar button {
background: transparent;
color: var(--green);
border: 1px solid var(--green);
font-family: var(--mono);
font-size: 0.85em;
font-weight: 600;
padding: 0.3rem 0.8rem;
border-radius: 3px;
cursor: pointer;
}
.vault-bar button:hover { background: var(--code-bg); }
.vault-bar .vault-note { color: var(--muted); font-size: 0.85em; }
.vault-bar .vault-note.err { color: var(--err); }
/* ─── Panes ─────────────────────────────────────────────────────── */
.panes {
@ -197,13 +232,11 @@ header code {
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--rule);
height: calc(100vh - 260px);
min-height: 360px;
align-items: start;
}
.pane {
background: var(--pane-bg);
padding: 0.5rem 0.75rem 0.75rem;
overflow: hidden;
display: flex;
flex-direction: column;
}
@ -217,17 +250,14 @@ header code {
}
#editor {
flex: 1;
overflow: hidden;
border-radius: 2px;
}
.cm-editor { height: 100%; font-size: 13px; }
.cm-editor { font-size: 13px; }
.cm-editor.cm-focused { outline: none; }
.cm-scroller { overflow: visible; }
#output {
flex: 1;
white-space: pre;
overflow: auto;
background: var(--code-bg);
border-radius: 2px;
padding: 0.6rem 0.8rem;

View file

@ -88,12 +88,15 @@ make test-all</code></pre>
</section>
<section id="playground">
<h2>Try it in your browser</h2>
<p>All three tiers compiled to WebAssembly &mdash; Python (Pyodide hosting <code>lumbda.py</code>), C (Emscripten), and a hand-written WAT parallel to <code>asm/lumbda.s</code>. Pick a demo, pick a tier (or race all three at once), and watch the same Lisp source evaluate three different ways.</p>
<p>
<a class="cta" href="playground/">Open the playground &rarr;</a>
<a class="cta" href="repl/">Open the REPL &rarr;</a>
</p>
<h2>Demo &mdash; play with the language</h2>
<p>All three tiers compiled to WebAssembly &mdash; Python (Pyodide hosting <code>lumbda.py</code>), C (Emscripten), and a hand-written WAT parallel to <code>asm/lumbda.s</code>. Pick a demo, pick a tier (or race all three at once), and watch the same Lisp source evaluate three different ways. Free-form mode saves your custom code to an encrypted local vault.</p>
<p><a class="cta" href="playground/">Open the playground &rarr;</a></p>
</section>
<section id="repl">
<h2>REPL &mdash; persistent interactive sessions</h2>
<p>Multi-tab REPL with one worker per tier per tab. Defines, set!, hash-table mutations stick across evals within a session. Transcripts persist in encrypted localStorage &mdash; supply a password, reload, same password unlocks the same vault. Each input can race all three tiers at once.</p>
<p><a class="cta" href="repl/">Open the REPL &rarr;</a></p>
</section>
</main>

View file

@ -9,12 +9,19 @@ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { syntaxHighlighting, defaultHighlightStyle, StreamLanguage } from "@codemirror/language";
import { scheme } from "@codemirror/legacy-modes/mode/scheme";
import { oneDark } from "@codemirror/theme-one-dark";
import { openVault } from "./crypto.js";
const TIERS = { python: "python (pyodide)", c: "c (emcc)", asm: "asm (wat)" };
const FREE_FORM_DEFAULT = "; free-form mode — unlock the vault below to persist this code\n; encrypted in localStorage with your password\n\n(+ 1 2)\n";
const demoSources = {};
// Vault state. When unlocked, free-form code auto-saves on every edit.
const vaultState = { vault: null, freeForm: null, saveTimer: null };
async function loadDemoSource(name) {
if (name === "free-form") {
return vaultState.freeForm != null ? vaultState.freeForm : FREE_FORM_DEFAULT;
}
if (!demoSources[name]) {
const resp = await fetch(`demos/${name}.lsp`);
demoSources[name] = await resp.text();
@ -39,7 +46,9 @@ const editorView = new EditorView({
StreamLanguage.define(scheme),
keymap.of([...defaultKeymap, ...historyKeymap]),
oneDark,
EditorView.theme({ "&": { height: "100%" } }),
EditorView.updateListener.of((u) => {
if (u.docChanged) scheduleFreeFormSave();
}),
],
}),
parent: editorParent,
@ -57,10 +66,84 @@ function getEditorText() {
async function loadCurrentDemo() {
const sel = document.querySelector('input[name="program"]:checked').value;
const vaultBar = document.getElementById("vault-bar");
vaultBar.hidden = sel !== "free-form";
const src = await loadDemoSource(sel);
setEditorText(src);
}
// ─── Free-form vault ───────────────────────────────────────────────
const vaultBarEl = document.getElementById("vault-bar");
const vaultPwEl = document.getElementById("vault-pw");
const vaultUnlockBtn = document.getElementById("vault-unlock");
const vaultLockBtn = document.getElementById("vault-lock");
const vaultStateEl = document.getElementById("vault-state");
const vaultNoteEl = document.getElementById("vault-note");
function setVaultNote(text, isErr) {
vaultNoteEl.textContent = text || "";
vaultNoteEl.className = "vault-note" + (isErr ? " err" : "");
}
async function unlockVault() {
const pw = vaultPwEl.value;
if (!pw) { setVaultNote("password required", true); return; }
setVaultNote("");
try {
vaultState.vault = await openVault(pw);
const data = await vaultState.vault.read();
if (data && data.__decryptionFailed) {
setVaultNote("vault exists but password is wrong", true);
vaultState.vault = null;
return;
}
vaultState.freeForm = (data && typeof data.freeForm === "string")
? data.freeForm
: FREE_FORM_DEFAULT;
vaultStateEl.textContent = "unlocked";
vaultStateEl.classList.add("unlocked");
vaultUnlockBtn.hidden = true;
vaultLockBtn.hidden = false;
vaultPwEl.value = "";
vaultPwEl.disabled = true;
// If free-form is the current program, swap the editor in.
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel === "free-form") setEditorText(vaultState.freeForm);
setVaultNote("ok — edits auto-save");
} catch (e) {
setVaultNote("unlock failed: " + e.message, true);
}
}
function lockVault() {
vaultState.vault = null;
vaultState.freeForm = null;
if (vaultState.saveTimer) { clearTimeout(vaultState.saveTimer); vaultState.saveTimer = null; }
vaultStateEl.textContent = "locked";
vaultStateEl.classList.remove("unlocked");
vaultUnlockBtn.hidden = false;
vaultLockBtn.hidden = true;
vaultPwEl.disabled = false;
vaultPwEl.value = "";
setVaultNote("");
// If free-form is current program, swap to the placeholder.
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel === "free-form") setEditorText(FREE_FORM_DEFAULT);
}
function scheduleFreeFormSave() {
if (!vaultState.vault) return;
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel !== "free-form") return;
if (vaultState.saveTimer) clearTimeout(vaultState.saveTimer);
vaultState.saveTimer = setTimeout(async () => {
const text = getEditorText();
vaultState.freeForm = text;
try { await vaultState.vault.write({ freeForm: text, savedAt: Date.now() }); }
catch (e) { setVaultNote("save failed: " + e.message, true); }
}, 350);
}
function selectedTiers() {
const sel = document.querySelector('input[name="tier"]:checked').value;
return sel === "all" ? ["python", "c", "asm"] : [sel];
@ -232,4 +315,9 @@ document.querySelectorAll('input[name="program"]').forEach((el) => {
runBtn.addEventListener("click", runAll);
cancelBtn.addEventListener("click", onCancel);
cancelBtn.disabled = true;
vaultUnlockBtn.addEventListener("click", unlockVault);
vaultLockBtn.addEventListener("click", lockVault);
vaultPwEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); unlockVault(); }
});
loadCurrentDemo();

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

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

View file

@ -45,6 +45,7 @@
<label><input type="radio" name="program" value="fib-ack"> fib + ackermann</label>
<label><input type="radio" name="program" value="sieve"> sieve of eratosthenes</label>
<label><input type="radio" name="program" value="self-interp"> lisp-in-lisp meta-eval</label>
<label><input type="radio" name="program" value="free-form"> free form (encrypted)</label>
</fieldset>
<fieldset class="tier">
<legend>tier</legend>
@ -58,6 +59,15 @@
<span id="status" class="status"></span>
</section>
<section class="vault-bar" id="vault-bar" hidden>
<span class="vault-icon" title="local vault">🔒</span>
<span id="vault-state" class="vault-state">locked</span>
<input id="vault-pw" type="password" autocomplete="off" placeholder="password to unlock free-form vault">
<button id="vault-unlock" class="secondary">unlock</button>
<button id="vault-lock" class="secondary" hidden>lock</button>
<span id="vault-note" class="vault-note"></span>
</section>
<section class="panes">
<div class="pane code-pane">
<h2>code</h2>

View file

@ -49,7 +49,6 @@ html, body {
font-family: var(--mono);
font-size: 14px;
line-height: 1.55;
min-height: 100%;
}
/* ─── Header ────────────────────────────────────────────────────── */
@ -190,6 +189,42 @@ header code {
.controls .status.err { color: var(--err); }
.controls .status.ok { color: var(--green); }
.vault-bar {
padding: 0.5rem 1.5rem;
border-bottom: 1px solid var(--rule);
display: flex; align-items: center; gap: 0.6rem;
background: var(--bg);
font-size: 0.85em;
}
.vault-bar .vault-icon { font-size: 1em; }
.vault-bar .vault-state { color: var(--muted); }
.vault-bar .vault-state.unlocked { color: var(--green); }
.vault-bar input[type=password] {
background: var(--code-bg);
border: 1px solid var(--rule);
color: var(--fg);
padding: 0.3rem 0.5rem;
font-family: var(--mono);
font-size: 0.9em;
border-radius: 3px;
width: 18rem;
}
.vault-bar input[type=password]:focus { outline: none; border-color: var(--green); }
.vault-bar button {
background: transparent;
color: var(--green);
border: 1px solid var(--green);
font-family: var(--mono);
font-size: 0.85em;
font-weight: 600;
padding: 0.3rem 0.8rem;
border-radius: 3px;
cursor: pointer;
}
.vault-bar button:hover { background: var(--code-bg); }
.vault-bar .vault-note { color: var(--muted); font-size: 0.85em; }
.vault-bar .vault-note.err { color: var(--err); }
/* ─── Panes ─────────────────────────────────────────────────────── */
.panes {
@ -197,13 +232,11 @@ header code {
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--rule);
height: calc(100vh - 260px);
min-height: 360px;
align-items: start;
}
.pane {
background: var(--pane-bg);
padding: 0.5rem 0.75rem 0.75rem;
overflow: hidden;
display: flex;
flex-direction: column;
}
@ -217,17 +250,14 @@ header code {
}
#editor {
flex: 1;
overflow: hidden;
border-radius: 2px;
}
.cm-editor { height: 100%; font-size: 13px; }
.cm-editor { font-size: 13px; }
.cm-editor.cm-focused { outline: none; }
.cm-scroller { overflow: visible; }
#output {
flex: 1;
white-space: pre;
overflow: auto;
background: var(--code-bg);
border-radius: 2px;
padding: 0.6rem 0.8rem;

View file

@ -49,7 +49,6 @@ html, body {
font-family: var(--mono);
font-size: 14px;
line-height: 1.55;
min-height: 100%;
}
/* ─── Header ────────────────────────────────────────────────────── */
@ -190,6 +189,42 @@ header code {
.controls .status.err { color: var(--err); }
.controls .status.ok { color: var(--green); }
.vault-bar {
padding: 0.5rem 1.5rem;
border-bottom: 1px solid var(--rule);
display: flex; align-items: center; gap: 0.6rem;
background: var(--bg);
font-size: 0.85em;
}
.vault-bar .vault-icon { font-size: 1em; }
.vault-bar .vault-state { color: var(--muted); }
.vault-bar .vault-state.unlocked { color: var(--green); }
.vault-bar input[type=password] {
background: var(--code-bg);
border: 1px solid var(--rule);
color: var(--fg);
padding: 0.3rem 0.5rem;
font-family: var(--mono);
font-size: 0.9em;
border-radius: 3px;
width: 18rem;
}
.vault-bar input[type=password]:focus { outline: none; border-color: var(--green); }
.vault-bar button {
background: transparent;
color: var(--green);
border: 1px solid var(--green);
font-family: var(--mono);
font-size: 0.85em;
font-weight: 600;
padding: 0.3rem 0.8rem;
border-radius: 3px;
cursor: pointer;
}
.vault-bar button:hover { background: var(--code-bg); }
.vault-bar .vault-note { color: var(--muted); font-size: 0.85em; }
.vault-bar .vault-note.err { color: var(--err); }
/* ─── Panes ─────────────────────────────────────────────────────── */
.panes {
@ -197,13 +232,11 @@ header code {
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--rule);
height: calc(100vh - 260px);
min-height: 360px;
align-items: start;
}
.pane {
background: var(--pane-bg);
padding: 0.5rem 0.75rem 0.75rem;
overflow: hidden;
display: flex;
flex-direction: column;
}
@ -217,17 +250,14 @@ header code {
}
#editor {
flex: 1;
overflow: hidden;
border-radius: 2px;
}
.cm-editor { height: 100%; font-size: 13px; }
.cm-editor { font-size: 13px; }
.cm-editor.cm-focused { outline: none; }
.cm-scroller { overflow: visible; }
#output {
flex: 1;
white-space: pre;
overflow: auto;
background: var(--code-bg);
border-radius: 2px;
padding: 0.6rem 0.8rem;