← 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 →