← All Pull Requests

Preview text edits immediately while debouncing saves #4

Merged Approved opened by AdityaAngajala 2026-10-02

Headline and subtitle edits now update the hero immediately, so the circuit contours start rerouting on each input instead of waiting for the 350 ms save debounce. The existing debounce still controls persistence and change-log traffic. Pending text drafts survive older SSE/poll responses until the server acknowledges the same value. This covers the wizard's headline/subtitle and Classic's headline input; the circuit geometry is unchanged.

Colby (@kcknox), please review this small follow-up to the already merged PR #3. Verified the complete latest contour revision is present in the published 0.23.0 source: circuit adapter, app, wizard, server defaults, README and circuit tests all match byte-for-byte. No replacement of the merged integration is needed.

Validation in the required Ubuntu WSL live mirror: 23 syntax checks and 43 circuit/UI checks pass, including immediate preview before persistence, stale-state protection, saved text reaching the API, later server edits taking effect, and editor/export geometry parity. The existing read-only render-view suite also passes (11 checks). git diff --check passes.

This proposes only public/js/app.js, public/js/wizard.js, and test/circuit-traces.mjs against 0.23.0 (e13ab87c70fbedfa9f598bede7066a0bf3f9416b). No direct push, merge, release or version change.

Diff Skip to comments

--- a/public/js/app.js+++ b/public/js/app.js@@ -1,1820 +1,1839 @@⋯ 50 unchanged lines ⋯   : fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(withThread(body)) }); const send = (patch) => post('state', patch).catch(() => {}); +// Paint typing immediately; keep the existing debounce for persistence. An+// older SSE/poll response must not roll back text whose save is still pending.+const textDrafts = {};+function previewText(patch) {+  Object.assign(textDrafts, patch);+  Object.assign(state, patch);+  render();+}+function receiveState(next) {+  for (const [key, value] of Object.entries(textDrafts)) {+    if (next[key] === value) delete textDrafts[key];+    else next[key] = value;+  }+  state = next;+}+ function canonType(t) { return t === 'hardware' ? 'component' : (t || 'app'); } // the LAUNCH spelling for display + bake: the wiki's public name is hardware function wireLabel(t) { return canonType(t) === 'component' ? 'hardware' : (t || 'app'); }⋯ 1690 unchanged lines ⋯ $('headline-input').addEventListener('input', e => {   clearTimeout(hlTimer);   const v = e.target.value;+  previewText({ headline: v });   hlTimer = setTimeout(() => { send({ headline: v }); setTimeout(() => runFit(null), 250); }, 350); }); for (const [id, key] of [['fs-title', 'title'], ['fs-sub', 'sub'], ['fs-desc', 'desc'], ['fs-leading', 'leading'], ['fs-gap', 'gap']]) {⋯ 38 unchanged lines ⋯   }   if (RENDER_VIEW) return;   // render view: the state it loaded is the state it renders   const es = new EventSource('events');-  es.addEventListener('state', e => { state = JSON.parse(e.data); syncPagesIfStale(state); render(); });+  es.addEventListener('state', e => { receiveState(JSON.parse(e.data)); syncPagesIfStale(state); render(); });   es.addEventListener('fit', e => { let m = null; try { m = (JSON.parse(e.data) || {}).max; } catch {} setTimeout(() => runFit(m), 80); });   es.addEventListener('toast', e => { handleToast(JSON.parse(e.data)); });   es.addEventListener('generated', e => { handleGen(JSON.parse(e.data)); });⋯ 9 unchanged lines ⋯       if (!st) return;       handleToast(st._toast);       handleGen(st._gen);-      if (JSON.stringify(st) !== JSON.stringify(state)) { state = st; render(); }+      if (JSON.stringify(st) !== JSON.stringify(state)) { receiveState(st); render(); }     }).catch(()=>{});   }, 1000); });⋯ 4 unchanged lines ⋯   window._abDismissed = (state.approved || {}).ts;   $('approvedbar').style.display = 'none'; });++--- a/public/js/wizard.js+++ b/public/js/wizard.js@@ -1,979 +1,982 @@⋯ 211 unchanged lines ⋯       let ht = null;       hin.addEventListener('input', () => {         clearTimeout(ht);-        ht = setTimeout(() => { wizSend({ headline: hin.value }); setTimeout(() => runFit(null), 300); }, 350);+        previewText({ headline: hin.value });+        ht = setTimeout(() => { send({ headline: hin.value }); setTimeout(() => runFit(null), 300); }, 350);       });       const fitb = document.createElement('button');       fitb.className = 'btn small'; fitb.textContent = 'Make it as big as fits';⋯ 37 unchanged lines ⋯       const liveWords = () => (sin.value || firstSentence(wp().brief, 80)).split(/\s+/).filter(Boolean);       let st = null;       sin.addEventListener('input', () => {+        previewText({ subtitle: sin.value });         renderPick();         clearTimeout(st);         st = setTimeout(() => {⋯ 715 unchanged lines ⋯     positionWizRing();   }); })();+--- a/test/circuit-traces.mjs+++ b/test/circuit-traces.mjs@@ -1,182 +1,211 @@⋯ 162 unchanged lines ⋯   await ui.bringToFront();   const status=await (await fetch(base+'/changes')).json();   ok('change ledger records seed',JSON.stringify(status).includes('circuitSeed'));+  await ui.evaluate(()=>{wizStep='headline';wizardRender();});+  ok('wizard headline paints before its debounced save',await ui.$eval('.wiz-headline input.hin',el=>{+    el.focus();el.value='Instant circuit';el.dispatchEvent(new Event('input',{bubbles:true}));+    return document.querySelector('#el-title').textContent==='Instant circuit' && window.__tracePending;+  }));+  // An unrelated server update can arrive while a text save is pending.+  await patch({guides:false});+  ok('incoming state does not roll back the active draft',await ui.$eval('#el-title',el=>el.textContent==='Instant circuit'));+  await ui.waitForFunction(()=>!window.__tracePending && Object.keys(textDrafts).length===0);+  ok('headline save reaches the API and reroutes',+    (await (await fetch(base+'/state')).json()).headline==='Instant circuit' && uiField!==await ui.$eval('#trace-art',im=>im.src));+  ok('wizard subtitle paints immediately',await ui.$$eval('.wiz-headline input.hin',els=>{+    const el=els[1];el.focus();el.value='Updates as you type';el.dispatchEvent(new Event('input',{bubbles:true}));+    return document.querySelector('#el-sub').textContent==='Updates as you type';+  }));+  await ui.waitForFunction(()=>Object.keys(textDrafts).length===0);+  ok('subtitle save reaches the API',(await (await fetch(base+'/state')).json()).subtitle==='Updates as you type');+  await ui.click('#wiz-classic');+  ok('Classic headline also paints immediately',await ui.$eval('#headline-input',el=>{+    el.focus();el.value='Fast in Classic';el.dispatchEvent(new Event('input',{bubbles:true}));+    return document.querySelector('#el-title').textContent==='Fast in Classic';+  }));+  await ui.waitForFunction(()=>Object.keys(textDrafts).length===0);+  await ui.evaluate(()=>{document.activeElement.blur();});+  await patch({headline:'Traces with purpose',subtitle:'Make room for the story',fontSize:{title:8}});+  await ui.waitForFunction(()=>state.headline==='Traces with purpose' && !window.__tracePending);+  ok('saved drafts yield to later server edits',await ui.$eval('#el-title',el=>el.textContent==='Traces with purpose'));+  await ui.evaluate(()=>{document.body.classList.add('wizard');wizStep='background';wizardRender();});   if(process.env.TRACE_SHOWCASE) {     await patch({showcase:{src:'data:image/png;base64,'+fs.readFileSync(process.env.TRACE_SHOWCASE).toString('base64'),full:false},headline:'Circuit traces',subtitle:'Made for your composition',fontSize:{title:8},guide:{status:'confirmed',brand:{colors:{primary:'#00b8b1',background:'#0d1117'}}}});     await ui.waitForFunction(()=>state.headline==='Circuit traces' && !window.__tracePending);⋯ 11 unchanged lines ⋯ } finally {   if(browser) await browser.close(); server.kill(); registry.close(); }+

Reviews

Colby Knox approved · 15d ago

Reviewed and merging, thanks Aditya. Checked against 0.23.0:

  • All six suites pass: syntax 23, brand 62, render-view 11, renderer 15, platform 18, circuit 43 (the seven new live-typing checks included).
  • The server stores headline and subtitle exactly as sent, so a draft clears on the first state that echoes it back. A newer keystroke supersedes an in-flight reroute before it draws.
  • Calling send instead of wizSend in the headline handler is right: the preview has already applied the text, so it avoids a second render.

One note for later: send swallows errors, so a save that never lands keeps its draft on screen and hides any later server edit to that field until the page reloads. Unlikely against a local server, so not blocking.

Releasing as hero-studio 0.23.1.

Comments

No comments yet.

Log in to comment.