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