[object Object]

← back to Crazy News Channel

Morph escalated cards in place; close articles synchronously on Escape

43aba19948cbc86952bbf153d167e7868188046f · 2026-09-23 20:12:31 -0700 · Steve Abrams

Screenrecord (chaos pass) reproduced two leftovers, both confirmed on the
prior HEAD before fixing:
- patchStoryGrid() still replaceWith()'d any card whose markup changed, so a
  click in flight on a card that just escalated hit a detached node. Now
  morphNode() syncs attributes/text and recurses into children in place;
  the card's <article> and <a> nodes survive a stage change. Cards
  destroyed per 30s: 19 -> 0.
- goBackToGrid() closed the article only via the async hashchange, and the
  Escape handler checked the not-yet-rendered DOM, so click -> Esc -> Esc
  could leave the article open. Now it renders synchronously, Escape checks
  route intent, and hashchange skips when the view already matches.
Verified: article closed after rapid Esc x2, back/forward still work, focus
returns to the originating card, and every prior regression passes
(overflow sweep 4 widths x Chrome/WebKit, grid race 30/30, hash freeze,
Cody fixes 4/4, arcs, escaping, standby, reduced motion, WebKit smoke).

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

Files touched

Diff

commit 43aba19948cbc86952bbf153d167e7868188046f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 23 20:12:31 2026 -0700

    Morph escalated cards in place; close articles synchronously on Escape
    
    Screenrecord (chaos pass) reproduced two leftovers, both confirmed on the
    prior HEAD before fixing:
    - patchStoryGrid() still replaceWith()'d any card whose markup changed, so a
      click in flight on a card that just escalated hit a detached node. Now
      morphNode() syncs attributes/text and recurses into children in place;
      the card's <article> and <a> nodes survive a stage change. Cards
      destroyed per 30s: 19 -> 0.
    - goBackToGrid() closed the article only via the async hashchange, and the
      Escape handler checked the not-yet-rendered DOM, so click -> Esc -> Esc
      could leave the article open. Now it renders synchronously, Escape checks
      route intent, and hashchange skips when the view already matches.
    Verified: article closed after rapid Esc x2, back/forward still work, focus
    returns to the originating card, and every prior regression passes
    (overflow sweep 4 widths x Chrome/WebKit, grid race 30/30, hash freeze,
    Cody fixes 4/4, arcs, escaping, standby, reduced motion, WebKit smoke).
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_011iuURMrsPBwAqMc5LgvFLH
---
 index.html | 45 ++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 38 insertions(+), 7 deletions(-)

diff --git a/index.html b/index.html
index 0caaa48..7f0af23 100644
--- a/index.html
+++ b/index.html
@@ -1447,6 +1447,31 @@ function escapeHTML(str) {
 // 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.
+// Bring `node` in line with `fresh` in place: sync attributes, update text,
+// recurse into children by position, and only swap a subtree when the tag
+// itself differs. A card that just escalated keeps its <article> and <a>
+// nodes, so a click already in flight on it still lands (screenrecord:
+// replaceWith() on the changed card detached the click target mid-press).
+function morphNode(node, fresh) {
+  if (node.nodeType !== fresh.nodeType || node.nodeName !== fresh.nodeName) {
+    node.replaceWith(fresh);
+    return;
+  }
+  if (node.nodeType !== 1) {
+    if (node.nodeValue !== fresh.nodeValue) node.nodeValue = fresh.nodeValue;
+    return;
+  }
+  [...node.attributes].forEach((a) => { if (!fresh.hasAttribute(a.name)) node.removeAttribute(a.name); });
+  [...fresh.attributes].forEach((a) => { if (node.getAttribute(a.name) !== a.value) node.setAttribute(a.name, a.value); });
+  const oldKids = [...node.childNodes];
+  const newKids = [...fresh.childNodes];
+  newKids.forEach((kid, i) => {
+    if (i < oldKids.length) morphNode(oldKids[i], kid);
+    else node.appendChild(kid);
+  });
+  for (let i = newKids.length; i < oldKids.length; i++) oldKids[i].remove();
+}
+
 function patchStoryGrid(entries) {
   const grid = els.storyGrid;
   const active = document.activeElement;
@@ -1466,10 +1491,8 @@ function patchStoryGrid(entries) {
     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 (!node) { grid.appendChild(fresh); node = fresh; }
+    else if (node.outerHTML !== fresh.outerHTML) morphNode(node, fresh);
     if (grid.children[i] !== node) grid.insertBefore(node, grid.children[i] || null);
   });
   existing.forEach((el, id) => { if (!wanted.has(id)) el.remove(); });
@@ -2368,9 +2391,12 @@ function restoreGridFocus(fromId) {
   if (el && typeof el.focus === "function") el.focus();
 }
 
+// Close synchronously. location.hash updates immediately but hashchange
+// fires later, so waiting on it let a rapid click -> Esc -> Esc leave the
+// article open (the second Esc saw a not-yet-rendered view and did nothing).
 function goBackToGrid() {
   if (location.hash) location.hash = "";
-  else renderRoute();
+  renderRoute();
 }
 
 function renderRoute(opts) {
@@ -2458,7 +2484,11 @@ function init() {
   els.articleView.addEventListener("click", (e) => {
     if (e.target.closest("[data-back]")) goBackToGrid();
   });
-  window.addEventListener("hashchange", () => renderRoute());
+  // Skip when the view already matches the URL (goBackToGrid rendered it
+  // synchronously), so focus isn't restored twice.
+  window.addEventListener("hashchange", () => {
+    if (getRouteId() !== state.currentRouteId) renderRoute();
+  });
 
   // Escape: closes whichever "layer" is currently open — admin panel
   // takes priority over the article view, which takes priority over the
@@ -2468,7 +2498,8 @@ function init() {
   document.addEventListener("keydown", (e) => {
     if (e.key !== "Escape") return;
     if (!els.adminPanel.hidden) { setAdminOpen(false); return; }
-    if (!els.articleView.hidden) { goBackToGrid(); return; }
+    // Route intent, not the DOM: the article may be requested but not yet rendered.
+    if (getRouteId() || !els.articleView.hidden) { goBackToGrid(); return; }
     if (state.breaking) { setBreaking(false); els.klaxonBtn.focus(); return; }
   });
 

← 5d6d91c auto-data-snapshot: 2026-09-23T20:04:48 (1 data files) — scr  ·  back to Crazy News Channel  ·  Keyed child morph + failed-image memory: stop cascading card 30c5077 →