[object Object]

← back to Crazy News Channel

Fix story-grid click/focus race: keyed in-place patch + stable Updated stamp

e1d52f1533c349aa065a7f279286475a89a9a38d · 2026-09-23 18:30:57 -0700 · Steve Abrams

screenrecord found cards detaching mid-click (run3, both passes). Root cause
was two things: renderStories() rebuilt #storyGrid.innerHTML on every
escalation tick, and never-escalated stories rendered "Updated <now>", so all
40 cards' markup changed every tick. Now patchStoryGrid() replaces only cards
whose markup changed and restores focus if the focused card was replaced, and
lastUpdated is stamped once. Measured old vs new over 30s: cards destroyed
560 -> 19, keyboard focus losses 15 -> 0, real mouse clicks landing 20/30 ->
30/30. Earlier Cody-fix and malformed-hash regression checks still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iuURMrsPBwAqMc5LgvFLH

Files touched

Diff

commit e1d52f1533c349aa065a7f279286475a89a9a38d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 23 18:30:57 2026 -0700

    Fix story-grid click/focus race: keyed in-place patch + stable Updated stamp
    
    screenrecord found cards detaching mid-click (run3, both passes). Root cause
    was two things: renderStories() rebuilt #storyGrid.innerHTML on every
    escalation tick, and never-escalated stories rendered "Updated <now>", so all
    40 cards' markup changed every tick. Now patchStoryGrid() replaces only cards
    whose markup changed and restores focus if the focused card was replaced, and
    lastUpdated is stamped once. Measured old vs new over 30s: cards destroyed
    560 -> 19, keyboard focus losses 15 -> 0, real mouse clicks landing 20/30 ->
    30/30. Earlier Cody-fix and malformed-hash regression checks still pass.
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_011iuURMrsPBwAqMc5LgvFLH
---
 index.html | 51 ++++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 46 insertions(+), 5 deletions(-)

diff --git a/index.html b/index.html
index b4567c1..c4ee823 100644
--- a/index.html
+++ b/index.html
@@ -1238,7 +1238,7 @@ function storyCardHTML(story, isLead) {
       <p class="story-meta">
         <span>📍 ${escapeHTML(story.location)}</span>
         <span>🖋 ${escapeHTML(story.byline)}</span>
-        <span>🕒 Updated ${story.lastUpdated || new Date().toLocaleTimeString()}</span>
+        <span>🕒 Updated ${story.lastUpdated || (story.lastUpdated = new Date().toLocaleTimeString())}</span>
       </p>
       <a class="story-read-more" href="#/article/${escapeHTML(story.id)}" data-open-id="${escapeHTML(story.id)}">Read full article →</a>
     </article>
@@ -1251,6 +1251,49 @@ function escapeHTML(str) {
   return div.innerHTML;
 }
 
+// Keyed in-place reconcile for the story grid. tick() re-renders roughly once
+// a second while stories escalate; rebuilding innerHTML each time detached
+// every card, so a click landing mid-rebuild was lost and keyboard focus was
+// thrown back to <body> (screenrecord run3, both passes). Now only cards whose
+// markup actually changed are replaced, unchanged nodes are kept, and focus is
+// restored if the focused card was one of the replaced ones.
+function patchStoryGrid(entries) {
+  const grid = els.storyGrid;
+  const active = document.activeElement;
+  const activeCard = active && grid.contains(active) ? active.closest("[data-id]") : null;
+  const focusKey = activeCard && {
+    id: activeCard.dataset.id,
+    openId: active.dataset && active.dataset.openId,
+  };
+
+  const existing = new Map();
+  grid.querySelectorAll(":scope > [data-id]").forEach((el) => existing.set(el.dataset.id, el));
+  const tpl = document.createElement("template");
+  const wanted = new Set();
+
+  entries.forEach(([id, html], i) => {
+    wanted.add(id);
+    let node = existing.get(id);
+    tpl.innerHTML = html.trim();
+    const fresh = tpl.content.firstElementChild;
+    if (!node || node.outerHTML !== fresh.outerHTML) {
+      if (node) node.replaceWith(fresh); else grid.appendChild(fresh);
+      node = fresh;
+    }
+    if (grid.children[i] !== node) grid.insertBefore(node, grid.children[i] || null);
+  });
+  existing.forEach((el, id) => { if (!wanted.has(id)) el.remove(); });
+
+  if (focusKey && document.activeElement !== active) {
+    const card = grid.querySelector(`[data-id="${CSS.escape(focusKey.id)}"]`);
+    const target = card && ((grid.contains(active) && active) ||
+      (focusKey.openId && card.querySelector(`[data-open-id="${CSS.escape(focusKey.openId)}"]`)) ||
+      card.querySelector("a, button")
+    );
+    if (target) target.focus({ preventScroll: true });
+  }
+}
+
 function renderStories() {
   // Top-level completion state: every story has been deleted (a state that
   // didn't exist before the admin panel could delete the seeded 3) — this
@@ -1278,9 +1321,7 @@ function renderStories() {
   const lead = computeLead(list);
   const done = (s) => s.stageIndex === s.stages.length - 1;
   const ordered = [lead, ...list.filter((s) => s !== lead && !done(s)), ...list.filter((s) => s !== lead && done(s))];
-  els.storyGrid.innerHTML = ordered
-    .map((s) => storyCardHTML(s, s === lead))
-    .join("");
+  patchStoryGrid(ordered.map((s) => [s.id, storyCardHTML(s, s === lead)]));
 
   // completion / meltdown state: every story has reached its final stage.
   // (STORIES.length is guaranteed > 0 here, so .every() can't vacuously fire.)
@@ -1795,7 +1836,7 @@ function buildLiveUpdateInnerHTML(story) {
   const stage = story.stages[story.stageIndex];
   const concluded = story.stageIndex === story.stages.length - 1;
   return `
-    <span class="live-update-label">🔴 Live Update — ${escapeHTML(story.lastUpdated || new Date().toLocaleTimeString())}</span>
+    <span class="live-update-label">🔴 Live Update — ${escapeHTML(story.lastUpdated || (story.lastUpdated = new Date().toLocaleTimeString()))}</span>
     <p class="live-update-headline">${escapeHTML(stage.headline)}</p>
     <p class="live-update-detail">${escapeHTML(stage.detail)}</p>
     ${concluded ? `<p class="live-update-concluded"><strong>STORY CONCLUDED</strong> — ${escapeHTML(stage.conclusion || "")}</p>` : ""}

← 8e9575f Add screenrecord DEBUG-REPORT: confirms real story-grid DOM-  ·  back to Crazy News Channel  ·  Stamp lastUpdated at state-entry, not in render; clear grid de89fd4 →