diff --git a/wasm/app/app.js b/wasm/app/app.js index 1f9d71e..4482392 100644 --- a/wasm/app/app.js +++ b/wasm/app/app.js @@ -72,7 +72,20 @@ 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"; + // Vault bar visible whenever the vault gate has been engaged + // (locked or unlocked) — drafts live per-program now, not just + // for free-form, so the lock/unlock affordance stays useful on + // every demo. Hide entirely only if the user explicitly hasn't + // set up a vault yet (vault.exists()) — kept as-is to avoid + // a "first-paint shows lock UI to a fresh customer" surprise. + vaultBar.hidden = sel !== "free-form" && !vaultState.vault; + // If the vault is unlocked and has a saved draft for this + // program, use it; otherwise fall back to the demo's source. + if (vaultState.vault && vaultState.drafts + && typeof vaultState.drafts[sel] === "string") { + setEditorText(vaultState.drafts[sel]); + return; + } const src = await loadDemoSource(sel); setEditorText(src); } @@ -102,18 +115,30 @@ async function unlockVault() { vaultState.vault = null; return; } - vaultState.freeForm = (data && typeof data.freeForm === "string") - ? data.freeForm - : FREE_FORM_DEFAULT; + // Per-program draft map. Back-compat: lift any legacy + // top-level freeForm field into drafts['free-form'] so users + // who already had a free-form draft don't lose it. + vaultState.drafts = (data && data.drafts && typeof data.drafts === "object") + ? Object.assign({}, data.drafts) + : {}; + if (data && typeof data.freeForm === "string" && !vaultState.drafts["free-form"]) { + vaultState.drafts["free-form"] = data.freeForm; + } + vaultState.freeForm = vaultState.drafts["free-form"] || 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. + // If the currently-selected program has a saved draft, load it. + // Otherwise leave the demo's default source in place. const sel = document.querySelector('input[name="program"]:checked').value; - if (sel === "free-form") setEditorText(vaultState.freeForm); + if (typeof vaultState.drafts[sel] === "string") { + setEditorText(vaultState.drafts[sel]); + } else if (sel === "free-form") { + setEditorText(vaultState.freeForm); + } setVaultNote("ok — edits auto-save"); } catch (e) { setVaultNote("unlock failed: " + e.message, true); @@ -122,6 +147,7 @@ async function unlockVault() { function lockVault() { vaultState.vault = null; + vaultState.drafts = null; vaultState.freeForm = null; if (vaultState.saveTimer) { clearTimeout(vaultState.saveTimer); vaultState.saveTimer = null; } vaultStateEl.textContent = "locked"; @@ -131,21 +157,38 @@ function lockVault() { 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); + // Reset the editor to whatever the current program ships with + // (drafts only live while the vault is unlocked). + loadCurrentDemo(); } +// Debounced autosave — fires on every editor doc change while the +// vault is unlocked. Saves under drafts[program-name] so each demo's +// edits persist independently. Previously this was free-form-only, +// which surprised users who edited a demo, switched tabs, and came +// back to their original demo source untouched. 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); } + vaultState.drafts = vaultState.drafts || {}; + vaultState.drafts[sel] = text; + // Keep the legacy freeForm field in sync so older builds that + // only know about that field still see the user's free-form + // edits if they re-open the vault from a different tab. + if (sel === "free-form") vaultState.freeForm = text; + try { + await vaultState.vault.write({ + drafts: vaultState.drafts, + freeForm: vaultState.drafts["free-form"] || "", + savedAt: Date.now(), + }); + setVaultNote(`saved ${sel} @ ${new Date().toLocaleTimeString()}`); + } catch (e) { + setVaultNote("save failed: " + e.message, true); + } }, 350); } diff --git a/wasm/app/style.css b/wasm/app/style.css index 01e4413..d2401f8 100644 --- a/wasm/app/style.css +++ b/wasm/app/style.css @@ -350,6 +350,16 @@ header code { white-space: pre; font-family: var(--mono); } +/* Streamed output: each emit_chunk slice that ended with \n becomes + its own
child of the
. Block display
+   guarantees vertical stacking regardless of how the browser handles
+   raw \n inside a sequence of text nodes — the bug we kept chasing
+   in firefox where "tick 0\ntick 10000\n..." rendered horizontally
+   even though each text node clearly contained the newline byte. */
+#output .tier-block .stream-line {
+  display: block;
+  white-space: pre;
+}
 #output .err { color: var(--err); }
 
 /* ─── Footer ────────────────────────────────────────────────────── */
diff --git a/wasm/c/lumbda-c.loader.js b/wasm/c/lumbda-c.loader.js
index bfde8d3..1e2e8e2 100644
--- a/wasm/c/lumbda-c.loader.js
+++ b/wasm/c/lumbda-c.loader.js
@@ -30,19 +30,13 @@ async function _bootstrap() {
     let outBuf = [];
     let errBuf = [];
     let currentOnChunk = null;
-    // Temporary debug — log every Module.print invocation so we can
-    // see exactly what Emscripten passes us (with/without trailing
-    // newline). Remove once the streaming-newline bug is resolved.
-    const DEBUG_STREAM = true;
     const module = await createLumbdaC({
         locateFile: (p) => wasmDir + p,
         print: (line) => {
-            if (DEBUG_STREAM) console.log("[c-tier print]", JSON.stringify(line));
             outBuf.push(line);
             if (currentOnChunk) currentOnChunk(line + "\n");
         },
         printErr: (line) => {
-            if (DEBUG_STREAM) console.log("[c-tier printErr]", JSON.stringify(line));
             errBuf.push(line);
             if (currentOnChunk) currentOnChunk(line + "\n");
         },
diff --git a/wasm/dist-repl/c/lumbda-c.loader.js b/wasm/dist-repl/c/lumbda-c.loader.js
index bfde8d3..1e2e8e2 100644
--- a/wasm/dist-repl/c/lumbda-c.loader.js
+++ b/wasm/dist-repl/c/lumbda-c.loader.js
@@ -30,19 +30,13 @@ async function _bootstrap() {
     let outBuf = [];
     let errBuf = [];
     let currentOnChunk = null;
-    // Temporary debug — log every Module.print invocation so we can
-    // see exactly what Emscripten passes us (with/without trailing
-    // newline). Remove once the streaming-newline bug is resolved.
-    const DEBUG_STREAM = true;
     const module = await createLumbdaC({
         locateFile: (p) => wasmDir + p,
         print: (line) => {
-            if (DEBUG_STREAM) console.log("[c-tier print]", JSON.stringify(line));
             outBuf.push(line);
             if (currentOnChunk) currentOnChunk(line + "\n");
         },
         printErr: (line) => {
-            if (DEBUG_STREAM) console.log("[c-tier printErr]", JSON.stringify(line));
             errBuf.push(line);
             if (currentOnChunk) currentOnChunk(line + "\n");
         },
diff --git a/www/playground/app.js b/www/playground/app.js
index 1f9d71e..4482392 100644
--- a/www/playground/app.js
+++ b/www/playground/app.js
@@ -72,7 +72,20 @@ 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";
+    // Vault bar visible whenever the vault gate has been engaged
+    // (locked or unlocked) — drafts live per-program now, not just
+    // for free-form, so the lock/unlock affordance stays useful on
+    // every demo. Hide entirely only if the user explicitly hasn't
+    // set up a vault yet (vault.exists()) — kept as-is to avoid
+    // a "first-paint shows lock UI to a fresh customer" surprise.
+    vaultBar.hidden = sel !== "free-form" && !vaultState.vault;
+    // If the vault is unlocked and has a saved draft for this
+    // program, use it; otherwise fall back to the demo's source.
+    if (vaultState.vault && vaultState.drafts
+        && typeof vaultState.drafts[sel] === "string") {
+        setEditorText(vaultState.drafts[sel]);
+        return;
+    }
     const src = await loadDemoSource(sel);
     setEditorText(src);
 }
@@ -102,18 +115,30 @@ async function unlockVault() {
             vaultState.vault = null;
             return;
         }
-        vaultState.freeForm = (data && typeof data.freeForm === "string")
-            ? data.freeForm
-            : FREE_FORM_DEFAULT;
+        // Per-program draft map. Back-compat: lift any legacy
+        // top-level freeForm field into drafts['free-form'] so users
+        // who already had a free-form draft don't lose it.
+        vaultState.drafts = (data && data.drafts && typeof data.drafts === "object")
+            ? Object.assign({}, data.drafts)
+            : {};
+        if (data && typeof data.freeForm === "string" && !vaultState.drafts["free-form"]) {
+            vaultState.drafts["free-form"] = data.freeForm;
+        }
+        vaultState.freeForm = vaultState.drafts["free-form"] || 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.
+        // If the currently-selected program has a saved draft, load it.
+        // Otherwise leave the demo's default source in place.
         const sel = document.querySelector('input[name="program"]:checked').value;
-        if (sel === "free-form") setEditorText(vaultState.freeForm);
+        if (typeof vaultState.drafts[sel] === "string") {
+            setEditorText(vaultState.drafts[sel]);
+        } else if (sel === "free-form") {
+            setEditorText(vaultState.freeForm);
+        }
         setVaultNote("ok — edits auto-save");
     } catch (e) {
         setVaultNote("unlock failed: " + e.message, true);
@@ -122,6 +147,7 @@ async function unlockVault() {
 
 function lockVault() {
     vaultState.vault = null;
+    vaultState.drafts = null;
     vaultState.freeForm = null;
     if (vaultState.saveTimer) { clearTimeout(vaultState.saveTimer); vaultState.saveTimer = null; }
     vaultStateEl.textContent = "locked";
@@ -131,21 +157,38 @@ function lockVault() {
     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);
+    // Reset the editor to whatever the current program ships with
+    // (drafts only live while the vault is unlocked).
+    loadCurrentDemo();
 }
 
+// Debounced autosave — fires on every editor doc change while the
+// vault is unlocked. Saves under drafts[program-name] so each demo's
+// edits persist independently. Previously this was free-form-only,
+// which surprised users who edited a demo, switched tabs, and came
+// back to their original demo source untouched.
 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); }
+        vaultState.drafts = vaultState.drafts || {};
+        vaultState.drafts[sel] = text;
+        // Keep the legacy freeForm field in sync so older builds that
+        // only know about that field still see the user's free-form
+        // edits if they re-open the vault from a different tab.
+        if (sel === "free-form") vaultState.freeForm = text;
+        try {
+            await vaultState.vault.write({
+                drafts: vaultState.drafts,
+                freeForm: vaultState.drafts["free-form"] || "",
+                savedAt: Date.now(),
+            });
+            setVaultNote(`saved ${sel} @ ${new Date().toLocaleTimeString()}`);
+        } catch (e) {
+            setVaultNote("save failed: " + e.message, true);
+        }
     }, 350);
 }
 
diff --git a/www/playground/c/lumbda-c.loader.js b/www/playground/c/lumbda-c.loader.js
index bfde8d3..1e2e8e2 100644
--- a/www/playground/c/lumbda-c.loader.js
+++ b/www/playground/c/lumbda-c.loader.js
@@ -30,19 +30,13 @@ async function _bootstrap() {
     let outBuf = [];
     let errBuf = [];
     let currentOnChunk = null;
-    // Temporary debug — log every Module.print invocation so we can
-    // see exactly what Emscripten passes us (with/without trailing
-    // newline). Remove once the streaming-newline bug is resolved.
-    const DEBUG_STREAM = true;
     const module = await createLumbdaC({
         locateFile: (p) => wasmDir + p,
         print: (line) => {
-            if (DEBUG_STREAM) console.log("[c-tier print]", JSON.stringify(line));
             outBuf.push(line);
             if (currentOnChunk) currentOnChunk(line + "\n");
         },
         printErr: (line) => {
-            if (DEBUG_STREAM) console.log("[c-tier printErr]", JSON.stringify(line));
             errBuf.push(line);
             if (currentOnChunk) currentOnChunk(line + "\n");
         },
diff --git a/www/playground/style.css b/www/playground/style.css
index 01e4413..d2401f8 100644
--- a/www/playground/style.css
+++ b/www/playground/style.css
@@ -350,6 +350,16 @@ header code {
   white-space: pre;
   font-family: var(--mono);
 }
+/* Streamed output: each emit_chunk slice that ended with \n becomes
+   its own 
child of the
. Block display
+   guarantees vertical stacking regardless of how the browser handles
+   raw \n inside a sequence of text nodes — the bug we kept chasing
+   in firefox where "tick 0\ntick 10000\n..." rendered horizontally
+   even though each text node clearly contained the newline byte. */
+#output .tier-block .stream-line {
+  display: block;
+  white-space: pre;
+}
 #output .err { color: var(--err); }
 
 /* ─── Footer ────────────────────────────────────────────────────── */
diff --git a/www/repl/c/lumbda-c.loader.js b/www/repl/c/lumbda-c.loader.js
index bfde8d3..1e2e8e2 100644
--- a/www/repl/c/lumbda-c.loader.js
+++ b/www/repl/c/lumbda-c.loader.js
@@ -30,19 +30,13 @@ async function _bootstrap() {
     let outBuf = [];
     let errBuf = [];
     let currentOnChunk = null;
-    // Temporary debug — log every Module.print invocation so we can
-    // see exactly what Emscripten passes us (with/without trailing
-    // newline). Remove once the streaming-newline bug is resolved.
-    const DEBUG_STREAM = true;
     const module = await createLumbdaC({
         locateFile: (p) => wasmDir + p,
         print: (line) => {
-            if (DEBUG_STREAM) console.log("[c-tier print]", JSON.stringify(line));
             outBuf.push(line);
             if (currentOnChunk) currentOnChunk(line + "\n");
         },
         printErr: (line) => {
-            if (DEBUG_STREAM) console.log("[c-tier printErr]", JSON.stringify(line));
             errBuf.push(line);
             if (currentOnChunk) currentOnChunk(line + "\n");
         },