[object Object]

← back to Contact Mailer Ideas

viewer: preserve scrollY across re-renders, signature-gated re-render (no DOM thrash if state unchanged), pause auto-refresh while typing/dialog open, debounce chat input

946b12a86500975b453d74f65fa21938ba2f392f · 2026-05-11 13:05:13 -0700 · Steve Abrams

Files touched

Diff

commit 946b12a86500975b453d74f65fa21938ba2f392f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 13:05:13 2026 -0700

    viewer: preserve scrollY across re-renders, signature-gated re-render (no DOM thrash if state unchanged), pause auto-refresh while typing/dialog open, debounce chat input
---
 viewer/server.js | 32 ++++++++++++++++++++++++++++----
 1 file changed, 28 insertions(+), 4 deletions(-)

diff --git a/viewer/server.js b/viewer/server.js
index 23934a5..c5336f8 100644
--- a/viewer/server.js
+++ b/viewer/server.js
@@ -372,14 +372,26 @@ function renderBanner(list){
     : '<b>No batch yet for today</b> · daily generator fires at 5:00 AM PT';
   banner.innerHTML = '<span class="dot"></span><span>' + status + '</span><span class="next">Next auto-batch: tomorrow 5 AM PT (in ' + hrs + 'h ' + mins + 'm)</span>';
 }
+let stateSig = '';
 async function load(){
   const url = currentDate ? '/api/state?date='+encodeURIComponent(currentDate) : '/api/state';
   const r = await fetch(url); const j = await r.json();
+  // Compare a cheap signature so background refreshes don't disturb DOM/scroll when nothing changed
+  const sig = JSON.stringify({
+    sel: j.selected && j.selected.date,
+    count: Object.keys(j.byIdx || {}).length,
+    ticks: (j.allTicks || []).length,
+    notes: Object.keys(j.notes || {}).join(',')
+  });
   state = j;
+  if (sig === stateSig) return;
+  stateSig = sig;
   render();
 }
 function render(){
   if (!state) return;
+  // Preserve scroll position across re-renders
+  const sy = window.scrollY;
   const grid = document.getElementById('grid'); grid.innerHTML = '';
   let ideas = Object.values(state.byIdx);
   // Search across every meaningful field
@@ -417,6 +429,8 @@ function render(){
     for (const i of ideas) grid.appendChild(renderCard(i, query));
     bindCardEvents();
   }
+  // Restore scroll
+  requestAnimationFrame(() => window.scrollTo({ top: sy, behavior: 'instant' }));
 }
 function lastVerdictForIdea(i){
   const ticks = (i.ticks || []).slice().sort((a,b) => (b.tick||0) - (a.tick||0));
@@ -612,14 +626,17 @@ function colorFor(name){
 }
 // Universal chat — search + slash commands
 const universalChat = document.getElementById('universalChat');
+let chatDebounce = null;
 universalChat.addEventListener('input', () => {
   const v = universalChat.value;
-  if (!v.startsWith('/')) {
-    // Mirror into search box
+  if (v.startsWith('/')) return;
+  // Debounce so render doesn't fire on every keystroke
+  clearTimeout(chatDebounce);
+  chatDebounce = setTimeout(() => {
     q.value = v;
     LS.setItem('cmi.q', v);
     render();
-  }
+  }, 150);
 });
 universalChat.addEventListener('keydown', async e => {
   if (e.key !== 'Enter') return;
@@ -660,7 +677,14 @@ universalChat.addEventListener('keydown', async e => {
   toast('unknown command /' + cmd, true);
 });
 loadBatches().then(load);
-setInterval(() => { loadBatches(); load(); }, 15000);
+setInterval(() => {
+  // Don't disturb the page while the user is typing, has the dialog open, or is editing a note
+  const dlg = document.getElementById('ideaDialog');
+  if (dlg && dlg.open) return;
+  const ae = document.activeElement;
+  if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT')) return;
+  loadBatches(); load();
+}, 15000);
 </script>
 </body></html>`);
 });

← bd4f006 chip-grid UI: collapsed name-only chips by default, click op  ·  back to Contact Mailer Ideas  ·  viewer: score-based chip coloring (green=top/31+, neutral=st 4dff97e →