diff --git a/wasm/Makefile b/wasm/Makefile index 66e70d2..af36b50 100644 --- a/wasm/Makefile +++ b/wasm/Makefile @@ -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 diff --git a/wasm/app/app.js b/wasm/app/app.js index 5504c0e..a739eac 100644 --- a/wasm/app/app.js +++ b/wasm/app/app.js @@ -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(); diff --git a/wasm/app/crypto.js b/wasm/app/crypto.js new file mode 100644 index 0000000..d2e4b0b --- /dev/null +++ b/wasm/app/crypto.js @@ -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: — { 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 }; +} diff --git a/wasm/app/index.html b/wasm/app/index.html index c2bc429..03a27f3 100644 --- a/wasm/app/index.html +++ b/wasm/app/index.html @@ -45,6 +45,7 @@ +
tier @@ -58,6 +59,15 @@ + +

code

diff --git a/wasm/app/style.css b/wasm/app/style.css index 7760cc1..2082544 100644 --- a/wasm/app/style.css +++ b/wasm/app/style.css @@ -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; diff --git a/wasm/dist-repl/style.css b/wasm/dist-repl/style.css index 7760cc1..2082544 100644 --- a/wasm/dist-repl/style.css +++ b/wasm/dist-repl/style.css @@ -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; diff --git a/www/index.html b/www/index.html index 7b3d36a..00c6884 100644 --- a/www/index.html +++ b/www/index.html @@ -88,12 +88,15 @@ make test-all
-

Try it in your browser

-

All three tiers compiled to WebAssembly — Python (Pyodide hosting lumbda.py), C (Emscripten), and a hand-written WAT parallel to asm/lumbda.s. Pick a demo, pick a tier (or race all three at once), and watch the same Lisp source evaluate three different ways.

-

- Open the playground → - Open the REPL → -

+

Demo — play with the language

+

All three tiers compiled to WebAssembly — Python (Pyodide hosting lumbda.py), C (Emscripten), and a hand-written WAT parallel to asm/lumbda.s. 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.

+

Open the playground →

+
+ +
+

REPL — persistent interactive sessions

+

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 — supply a password, reload, same password unlocks the same vault. Each input can race all three tiers at once.

+

Open the REPL →

diff --git a/www/playground/app.js b/www/playground/app.js index 5504c0e..a739eac 100644 --- a/www/playground/app.js +++ b/www/playground/app.js @@ -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(); diff --git a/www/playground/crypto.js b/www/playground/crypto.js new file mode 100644 index 0000000..d2e4b0b --- /dev/null +++ b/www/playground/crypto.js @@ -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: — { 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 }; +} diff --git a/www/playground/index.html b/www/playground/index.html index c2bc429..03a27f3 100644 --- a/www/playground/index.html +++ b/www/playground/index.html @@ -45,6 +45,7 @@ +
tier @@ -58,6 +59,15 @@ + +

code

diff --git a/www/playground/style.css b/www/playground/style.css index 7760cc1..2082544 100644 --- a/www/playground/style.css +++ b/www/playground/style.css @@ -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; diff --git a/www/repl/style.css b/www/repl/style.css index 7760cc1..2082544 100644 --- a/www/repl/style.css +++ b/www/repl/style.css @@ -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;