[object Object]

← back to Crazy News Channel

Category selection opens a real page (#/category/<key>), not just an in-place recolor; logo links home

458b2cc2afc61fe921a2c56531296ceb5b3f8a1d · 2026-09-24 07:55:25 -0700 · Steve Abrams

- Clicking a category navigates to a distinct, deep-linkable URL with its own
  page header (name + blurb + back link), same pattern as the article view.
- Back/forward and direct links work; radios stay in sync with the route.
- Escape backs out of a category page to Home, same as it does for articles.
- The P24 logo/wordmark now links back to Home.

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

Files touched

Diff

commit 458b2cc2afc61fe921a2c56531296ceb5b3f8a1d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 07:55:25 2026 -0700

    Category selection opens a real page (#/category/<key>), not just an in-place recolor; logo links home
    
    - Clicking a category navigates to a distinct, deep-linkable URL with its own
      page header (name + blurb + back link), same pattern as the article view.
    - Back/forward and direct links work; radios stay in sync with the route.
    - Escape backs out of a category page to Home, same as it does for articles.
    - The P24 logo/wordmark now links back to Home.
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01VLjoHsyGTDeVCa1rdrAxnk
---
 5x/REPORT.md                                       |  15 +
 5x/sweep-1.md                                      |  35 ++
 index.html                                         | 141 ++++++-
 .../page@6ff94c2829a1a9c68f2e3141160400ef.webm     | Bin 0 -> 7830645 bytes
 .../page@adc9e2c18cc195199e47c81061ae6850.webm     |   0
 .../page@f74fa311aa59037d3103a799756d60ff.webm     | Bin 0 -> 2687177 bytes
 screenrecord/rec/chaos-run0/run0-final.mp4         | Bin 0 -> 8402130 bytes
 screenrecord/rec/chaos-run0/run0-final.webm        | Bin 0 -> 7830645 bytes
 .../page@ce0271e448959d2115d35a375693054b.webm     | Bin 0 -> 5199595 bytes
 .../page@e83ad34a61b573fa78b48110976e9f6a.webm     | Bin 0 -> 1926714 bytes
 screenrecord/rec/chaos-run1/run1-final.mp4         | Bin 0 -> 8063142 bytes
 screenrecord/rec/chaos-run1/run1-final.webm        | Bin 0 -> 5199595 bytes
 .../page@4e4b70363f551541f4edd054b93f8d5c.webm     | Bin 0 -> 1901249 bytes
 .../page@7e11170122614d52fbff20b446759027.webm     | Bin 0 -> 3564495 bytes
 screenrecord/rec/chaos-run2/run2-final.mp4         | Bin 0 -> 5210420 bytes
 screenrecord/rec/chaos-run2/run2-final.webm        | Bin 0 -> 3564495 bytes
 .../page@b3aad4d41d1a9adae787ebd872a72585.webm     | Bin 0 -> 3407872 bytes
 .../page@b658e8806e1b9aa93bc1c89d41a6a382.webm     | Bin 0 -> 5325768 bytes
 screenrecord/rec/chaos-run3/run3-final.mp4         | Bin 0 -> 8055021 bytes
 screenrecord/rec/chaos-run3/run3-final.webm        | Bin 0 -> 5325768 bytes
 .../page@0f3957c29a1d485b5ee2ae22f43126a9.webm     | Bin 0 -> 11628665 bytes
 screenrecord/rec/chaos-run4/run4-final.mp4         | Bin 0 -> 9984807 bytes
 screenrecord/rec/chaos-run4/run4-final.webm        | Bin 0 -> 11628665 bytes
 scripts/chaos-screenrecord.js                      | 440 +++++++++++++++++++++
 tools/wire-category-pages.mjs                      | 231 +++++++++++
 25 files changed, 844 insertions(+), 18 deletions(-)

diff --git a/5x/REPORT.md b/5x/REPORT.md
new file mode 100644
index 0000000..1d6c59c
--- /dev/null
+++ b/5x/REPORT.md
@@ -0,0 +1,15 @@
+# PANDEMONIUM-24 /5x Verification & Repair Loop
+
+**Target:** file:///Users/macstudio3/Projects/crazy-news-channel/index.html
+
+| Sweep | Passed | Caught | Fixed | Commit |
+|-------|--------|--------|-------|--------|
+| 1 | — | — | — | — |
+| 2 | — | — | — | — |
+| 3 | — | — | — | — |
+| 4 | — | — | — | — |
+| 5 | — | — | — | — |
+
+---
+
+## Sweep Results (in progress)
diff --git a/5x/sweep-1.md b/5x/sweep-1.md
new file mode 100644
index 0000000..bffa87c
--- /dev/null
+++ b/5x/sweep-1.md
@@ -0,0 +1,35 @@
+
+/3x — verifying file:///Users/macstudio3/Projects/crazy-news-channel/index.html
+
+Phase A — 3 unique methods:
+  [FAIL] M1 HTTP contract  request layer — fetch failed
+  [PASS] M2 headless render  render layer — screenshot 905KB → /var/folders/rq/j8g1f7nn6jv6_lr1cfmqym6w0000gn/T/3x-63JOgY/render.png
+  [PASS] M3 automation E2E  interaction layer — 1×"body", 0 JS errors
+
+Phase B — 3 browsers (cross-browser):
+  [PASS] B4 Google Chrome  cross-browser — automated render OK
+  [PASS] B5 Safari  cross-browser — opened in real browser (visual)
+  [PASS] B6 Firefox  cross-browser — opened in real browser (visual)
+
+Phase C — click-through agent (exercises every control):
+
+click-through agent → file:///Users/macstudio3/Projects/crazy-news-channel/index.html
+
+  [PASS] 1 hidden select state entry
+  [SKIP] search "recolor" — no #fSearch on this app
+  [SKIP] search clear — no #fSearch on this app
+  [SKIP] verdict button: dw — no .card .vbtn.dw on this app
+  [SKIP] verdict button: wpb — no .card .vbtn.wpb on this app
+  [SKIP] verdict button: both — no .card .vbtn.both on this app
+  [SKIP] verdict button: skip — no .card .vbtn.skip on this app
+  [SKIP] Next → — no #next on this app
+  [SKIP] ← Prev — no #prev on this app
+  [SKIP] switch to Board — no #mode button[data-mode="board"] on this app
+  [SKIP] density slider — no #dens on this app
+  [PASS] button[0]: 🚨 BREAKING ALERT: OFF
+  [PASS] button[1]: ⚙ Admin
+
+  CLICK-THROUGH → 3 passed, 0 failed, 10 skipped
+
+  RESULT → 6/7 passed, 1 FAILED
+  artifacts: /var/folders/rq/j8g1f7nn6jv6_lr1cfmqym6w0000gn/T/3x-63JOgY
diff --git a/index.html b/index.html
index 85bb480..6906d9b 100644
--- a/index.html
+++ b/index.html
@@ -129,7 +129,8 @@ body[data-mood="uncategorized"] {
   transform: translateZ(0);
   border-bottom: 2px solid var(--panel-border);
 }
-.brand { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
+.brand { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; color: inherit; text-decoration: none; }
+.brand:hover .wordmark { text-decoration: underline; }
 .logo-badge {
   display: inline-flex; align-items: center; justify-content: center;
   width: 2.4rem; height: 2.4rem; border-radius: .4rem;
@@ -199,6 +200,9 @@ body[data-mood="uncategorized"] {
 
 /* ---------- category filter (nav) ---------- */
 .category-filter { padding: .6rem 1rem; border-bottom: 1px solid var(--panel-border); }
+.category-back-link { display: inline-block; font-size: .8rem; font-weight: 700; color: var(--accent2); margin-bottom: .3rem; text-decoration: none; }
+.category-back-link:hover { text-decoration: underline; }
+.category-blurb { color: var(--text-dim); font-size: .88rem; margin-top: .2rem; }
 .category-filter fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
 .chip-row {
   display: flex; gap: .5rem; overflow-x: auto; padding-bottom: .25rem;
@@ -601,10 +605,10 @@ body.chaos-tier-3 .chaos-fill { background: #ff2222; }
    visibly speeds up. The reduced-motion path never uses this animation at
    all (ticker-track animation is fully disabled + JS swaps to the static
    frame renderer), so this is motion-safe by construction. */
-.ticker-track { animation: ticker-scroll var(--ticker-dur, 38s) linear infinite; }
-body.chaos-tier-1 { --ticker-dur: 24s; }
-body.chaos-tier-2 { --ticker-dur: 14s; }
-body.chaos-tier-3 { --ticker-dur: 8s; }
+.ticker-track { animation: ticker-scroll var(--ticker-dur, 60s) linear infinite; }
+body.chaos-tier-1 { --ticker-dur: 40s; }
+body.chaos-tier-2 { --ticker-dur: 32s; }
+body.chaos-tier-3 { --ticker-dur: 24s; }
 
 /* Broadcast losing its grip: headline stamps via generated content only —
    zero markup change, so these can flip every tick without ever touching
@@ -715,13 +719,13 @@ body.chaos-tier-3 main#channelContainer {
      CHROME: brand, live clock, klaxon toggle, sound toggle
      ============================================================ -->
 <header class="chrome">
-  <div class="brand">
+  <a class="brand" href="#/" aria-label="PANDEMONIUM-24 — back to all coverage">
     <span class="logo-badge" aria-hidden="true">P24</span>
     <div>
       <h1 class="wordmark">PANDEMONIUM-24</h1>
       <p class="tagline">"Wall-to-Wall. Ceiling-to-Floor. Chaos Coverage."</p>
     </div>
-  </div>
+  </a>
 
   <div class="live-badge">
     <span class="rec-dot" aria-hidden="true"></span>
@@ -880,7 +884,11 @@ body.chaos-tier-3 main#channelContainer {
        ============================================================ -->
   <main id="channelContainer">
     <div class="channel-meta">
-      <h2 id="channelHeading" tabindex="-1">On The Desk Right Now</h2>
+      <div>
+        <a id="categoryBackLink" class="category-back-link" href="#/" hidden>← All Categories</a>
+        <h2 id="channelHeading" tabindex="-1">On The Desk Right Now</h2>
+        <p id="categoryBlurb" class="category-blurb" hidden></p>
+      </div>
       <span class="mood-readout" id="moodReadout">Channel mood: Calm-ish (Default)</span>
     </div>
 
@@ -979,6 +987,13 @@ const CATEGORY_LABELS = {
 // Escalation ladder shared by every story.
 const LEVEL_LABELS = ["DEVELOPING", "ESCALATING", "SPIRALING", "CRITICAL", "TRANSCENDENT"];
 
+// Floor under every story's own intervalSec so a card can never advance
+// faster than this, however fast the source data says — the seeded stories
+// shipped at 6s, which morphed a card's headline under a reader mid-read/
+// mid-click. 75s is well clear of "read a headline + a two-line detail +
+// click" for any reading speed.
+const MIN_STAGE_INTERVAL_SEC = 75;
+
 // The original 3 seeded stories — kept as an immutable template so the
 // admin panel's "Reset to Defaults" can always restore them exactly,
 // regardless of what's been created/deleted/escalated since.
@@ -1306,6 +1321,7 @@ const state = {
   klaxonAudioTimer: null,
   justCreatedId: null,
   currentRouteId: null,
+  hoveredCardId: null,
   chaosPct: 0,
   chaosTierIdx: 0,
   lastChaosBandKey: undefined,
@@ -1346,6 +1362,8 @@ const els = {
   gridView: document.getElementById("gridView"),
   articleView: document.getElementById("articleView"),
   channelHeading: document.getElementById("channelHeading"),
+  categoryBackLink: document.getElementById("categoryBackLink"),
+  categoryBlurb: document.getElementById("categoryBlurb"),
   featuresGrid: document.getElementById("featuresGrid"),
   featuresEmptyNote: document.getElementById("featuresEmptyNote"),
   chaosRow: document.getElementById("chaosRow"),
@@ -1809,13 +1827,41 @@ function stopSiren() {
   }
 }
 
+const CATEGORY_BLURBS = {
+  politics: "Committees, subcommittees, and whatever this is now.",
+  weather: "Forecasts that stopped being reassuring a while ago.",
+  scitech: "The lab called. It has questions.",
+  sports: "Scores, standings, and mounting municipal concern.",
+  entertainment: "Local color, now with less local and more color.",
+  business: "The numbers are in. So is everyone else's business.",
+  uncategorized: "Doesn't fit anywhere. Doesn't care.",
+};
+
 /* ---------- F. Category filter ---------- */
 function onCategoryChange(value) {
-  state.category = value;
-  updateMoodUI();
-  renderStories();
-  renderFeatures();
-  announce(`Channel mood changed to ${MOODS[value].label} (${value === "all" ? "All Categories" : value}).`);
+  location.hash = value === "all" ? "" : `#/category/${value}`;
+}
+
+function syncCategoryRadio(key) {
+  const input = document.getElementById(`cat-${key || "all"}`);
+  if (input) input.checked = true;
+}
+
+// Swaps the page header between the home heading and a category page's own
+// title/blurb/back-link — this, not a recolor, is what "opening" a category
+// means now.
+function renderCategoryBanner(key) {
+  if (key) {
+    els.channelHeading.textContent = `${CATEGORY_LABELS[key]} — Live Coverage`;
+    els.categoryBackLink.hidden = false;
+    els.categoryBlurb.hidden = false;
+    els.categoryBlurb.textContent = CATEGORY_BLURBS[key] || "";
+  } else {
+    els.channelHeading.textContent = "On The Desk Right Now";
+    els.categoryBackLink.hidden = true;
+    els.categoryBlurb.hidden = true;
+    els.categoryBlurb.textContent = "";
+  }
 }
 
 /* ---------- G. Clock + master tick loop ---------- */
@@ -1823,16 +1869,27 @@ function updateClock() {
   els.clock.textContent = new Date().toLocaleTimeString([], { hour12: false });
 }
 
+// Escalation is paused entirely — no card's headline/detail/stage changes —
+// while there's an article open (reading) or the pointer is resting on a
+// card (about to click). A card that's actually on screen and in front of
+// the reader must hold perfectly still; resumes the instant the article
+// closes / the pointer leaves.
+function isEscalationPaused() {
+  return !els.articleView.hidden || !!state.hoveredCardId;
+}
+
 function tick() {
   state.elapsedSec += 1;
   updateClock();
 
+  const paused = isEscalationPaused();
   let anyStoryAdvanced = false;
   STORIES.forEach((story) => {
     const alreadyMax = story.stageIndex === story.stages.length - 1;
-    if (alreadyMax) return;
+    if (alreadyMax || paused) return;
     const t = state.elapsedSec - story.offsetSec;
-    if (t > 0 && t % story.intervalSec === 0) {
+    const interval = Math.max(story.intervalSec, MIN_STAGE_INTERVAL_SEC);
+    if (t > 0 && t % interval === 0) {
       story.stageIndex += 1;
       story.lastUpdated = new Date().toLocaleTimeString();
       anyStoryAdvanced = true;
@@ -2310,6 +2367,16 @@ function getRouteId() {
   }
 }
 
+function getCategoryRoute() {
+  const m = location.hash.match(/^#\/category\/(.+)$/);
+  if (!m) return null;
+  try {
+    return decodeURIComponent(m[1]);
+  } catch (e) {
+    return null;
+  }
+}
+
 function buildRelatedLinks(currentId) {
   const current = findArticleSource(currentId);
   const pool = [...STORIES, ...FEATURES].filter((x) => x.id !== currentId);
@@ -2441,6 +2508,7 @@ function renderRoute(opts) {
   opts = opts || {};
   const moveFocus = opts.moveFocus !== false;
   const previousId = state.currentRouteId;
+  state.lastRenderedHash = location.hash;
   const id = getRouteId();
 
   if (id) {
@@ -2462,8 +2530,26 @@ function renderRoute(opts) {
     els.articleView.hidden = true;
     els.gridView.hidden = false;
     state.currentRouteId = null;
-    // Restore the filter-selected theme now that the grid is visible again.
+    const requested = getCategoryRoute();
+    const key = requested && CATEGORY_LABELS[requested] ? requested : null;
+    if (requested && !key) {
+      // Unknown category in the URL (typo'd/stale deep link) — fall back to
+      // "all" rather than silently rendering an empty, mislabeled page.
+      announce(`Unknown category "${requested}." Showing All Categories.`);
+    }
+    const changed = state.category !== (key || "all");
+    state.category = key || "all";
+    syncCategoryRadio(state.category);
+    renderCategoryBanner(key);
+    updateMoodUI();
+    renderStories();
+    renderFeatures();
     document.body.dataset.mood = state.category;
+    if (changed) {
+      announce(key
+        ? `Now viewing ${CATEGORY_LABELS[key]}. Channel mood: ${MOODS[key].label}.`
+        : "Now viewing All Categories. Channel mood: Calm-ish (Default).");
+    }
     if (moveFocus) restoreGridFocus(previousId);
   }
 }
@@ -2474,6 +2560,12 @@ function init() {
   document.querySelectorAll('input[name="category"]').forEach((input) => {
     input.addEventListener("change", (e) => onCategoryChange(e.target.value));
   });
+  els.categoryBackLink.addEventListener("click", (e) => {
+    // Same-page hash change; goBackToGrid() renders synchronously so the
+    // heading/focus updates immediately rather than waiting on hashchange.
+    e.preventDefault();
+    goBackToGrid();
+  });
 
   // klaxon toggle
   els.klaxonBtn.addEventListener("click", () => setBreaking(!state.breaking));
@@ -2517,6 +2609,19 @@ function init() {
   els.adminResetBtn.addEventListener("click", resetToDefaults);
   renderAdminStoryList();
 
+  // story grid: pause escalation while the pointer rests on a card, so a
+  // headline mid-read/about-to-click can't change out from under it.
+  // pointerover/pointerout bubble (unlike mouseenter/mouseleave), so one
+  // delegated pair on the grid covers every card, present and future.
+  els.storyGrid.addEventListener("pointerover", (e) => {
+    const card = e.target.closest("[data-id]");
+    if (card) state.hoveredCardId = card.dataset.id;
+  });
+  els.storyGrid.addEventListener("pointerout", (e) => {
+    const card = e.target.closest("[data-id]");
+    if (card && !card.contains(e.relatedTarget)) state.hoveredCardId = null;
+  });
+
   // article view: back button (event-delegated since content is
   // regenerated on every render) + hash-based routing
   els.articleView.addEventListener("click", (e) => {
@@ -2525,7 +2630,7 @@ function init() {
   // 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();
+    if (location.hash !== state.lastRenderedHash) renderRoute();
   });
 
   // Escape: closes whichever "layer" is currently open — admin panel
@@ -2537,7 +2642,7 @@ function init() {
     if (e.key !== "Escape") return;
     if (!els.adminPanel.hidden) { setAdminOpen(false); return; }
     // Route intent, not the DOM: the article may be requested but not yet rendered.
-    if (getRouteId() || !els.articleView.hidden) { goBackToGrid(); return; }
+    if (getRouteId() || !els.articleView.hidden || getCategoryRoute()) { goBackToGrid(); return; }
     if (state.breaking) { setBreaking(false); els.klaxonBtn.focus(); return; }
   });
 
diff --git a/screenrecord/rec/chaos-run0/page@6ff94c2829a1a9c68f2e3141160400ef.webm b/screenrecord/rec/chaos-run0/page@6ff94c2829a1a9c68f2e3141160400ef.webm
new file mode 100644
index 0000000..3bb7d58
Binary files /dev/null and b/screenrecord/rec/chaos-run0/page@6ff94c2829a1a9c68f2e3141160400ef.webm differ
diff --git a/screenrecord/rec/chaos-run0/page@adc9e2c18cc195199e47c81061ae6850.webm b/screenrecord/rec/chaos-run0/page@adc9e2c18cc195199e47c81061ae6850.webm
new file mode 100644
index 0000000..e69de29
diff --git a/screenrecord/rec/chaos-run0/page@f74fa311aa59037d3103a799756d60ff.webm b/screenrecord/rec/chaos-run0/page@f74fa311aa59037d3103a799756d60ff.webm
new file mode 100644
index 0000000..5a65d3f
Binary files /dev/null and b/screenrecord/rec/chaos-run0/page@f74fa311aa59037d3103a799756d60ff.webm differ
diff --git a/screenrecord/rec/chaos-run0/run0-final.mp4 b/screenrecord/rec/chaos-run0/run0-final.mp4
new file mode 100644
index 0000000..dd43046
Binary files /dev/null and b/screenrecord/rec/chaos-run0/run0-final.mp4 differ
diff --git a/screenrecord/rec/chaos-run0/run0-final.webm b/screenrecord/rec/chaos-run0/run0-final.webm
new file mode 100644
index 0000000..3bb7d58
Binary files /dev/null and b/screenrecord/rec/chaos-run0/run0-final.webm differ
diff --git a/screenrecord/rec/chaos-run1/page@ce0271e448959d2115d35a375693054b.webm b/screenrecord/rec/chaos-run1/page@ce0271e448959d2115d35a375693054b.webm
new file mode 100644
index 0000000..8b002e3
Binary files /dev/null and b/screenrecord/rec/chaos-run1/page@ce0271e448959d2115d35a375693054b.webm differ
diff --git a/screenrecord/rec/chaos-run1/page@e83ad34a61b573fa78b48110976e9f6a.webm b/screenrecord/rec/chaos-run1/page@e83ad34a61b573fa78b48110976e9f6a.webm
new file mode 100644
index 0000000..0482278
Binary files /dev/null and b/screenrecord/rec/chaos-run1/page@e83ad34a61b573fa78b48110976e9f6a.webm differ
diff --git a/screenrecord/rec/chaos-run1/run1-final.mp4 b/screenrecord/rec/chaos-run1/run1-final.mp4
new file mode 100644
index 0000000..20d0057
Binary files /dev/null and b/screenrecord/rec/chaos-run1/run1-final.mp4 differ
diff --git a/screenrecord/rec/chaos-run1/run1-final.webm b/screenrecord/rec/chaos-run1/run1-final.webm
new file mode 100644
index 0000000..8b002e3
Binary files /dev/null and b/screenrecord/rec/chaos-run1/run1-final.webm differ
diff --git a/screenrecord/rec/chaos-run2/page@4e4b70363f551541f4edd054b93f8d5c.webm b/screenrecord/rec/chaos-run2/page@4e4b70363f551541f4edd054b93f8d5c.webm
new file mode 100644
index 0000000..37787e0
Binary files /dev/null and b/screenrecord/rec/chaos-run2/page@4e4b70363f551541f4edd054b93f8d5c.webm differ
diff --git a/screenrecord/rec/chaos-run2/page@7e11170122614d52fbff20b446759027.webm b/screenrecord/rec/chaos-run2/page@7e11170122614d52fbff20b446759027.webm
new file mode 100644
index 0000000..fd9fd04
Binary files /dev/null and b/screenrecord/rec/chaos-run2/page@7e11170122614d52fbff20b446759027.webm differ
diff --git a/screenrecord/rec/chaos-run2/run2-final.mp4 b/screenrecord/rec/chaos-run2/run2-final.mp4
new file mode 100644
index 0000000..6992e74
Binary files /dev/null and b/screenrecord/rec/chaos-run2/run2-final.mp4 differ
diff --git a/screenrecord/rec/chaos-run2/run2-final.webm b/screenrecord/rec/chaos-run2/run2-final.webm
new file mode 100644
index 0000000..fd9fd04
Binary files /dev/null and b/screenrecord/rec/chaos-run2/run2-final.webm differ
diff --git a/screenrecord/rec/chaos-run3/page@b3aad4d41d1a9adae787ebd872a72585.webm b/screenrecord/rec/chaos-run3/page@b3aad4d41d1a9adae787ebd872a72585.webm
new file mode 100644
index 0000000..6680a66
Binary files /dev/null and b/screenrecord/rec/chaos-run3/page@b3aad4d41d1a9adae787ebd872a72585.webm differ
diff --git a/screenrecord/rec/chaos-run3/page@b658e8806e1b9aa93bc1c89d41a6a382.webm b/screenrecord/rec/chaos-run3/page@b658e8806e1b9aa93bc1c89d41a6a382.webm
new file mode 100644
index 0000000..38cbed3
Binary files /dev/null and b/screenrecord/rec/chaos-run3/page@b658e8806e1b9aa93bc1c89d41a6a382.webm differ
diff --git a/screenrecord/rec/chaos-run3/run3-final.mp4 b/screenrecord/rec/chaos-run3/run3-final.mp4
new file mode 100644
index 0000000..2996ab0
Binary files /dev/null and b/screenrecord/rec/chaos-run3/run3-final.mp4 differ
diff --git a/screenrecord/rec/chaos-run3/run3-final.webm b/screenrecord/rec/chaos-run3/run3-final.webm
new file mode 100644
index 0000000..38cbed3
Binary files /dev/null and b/screenrecord/rec/chaos-run3/run3-final.webm differ
diff --git a/screenrecord/rec/chaos-run4/page@0f3957c29a1d485b5ee2ae22f43126a9.webm b/screenrecord/rec/chaos-run4/page@0f3957c29a1d485b5ee2ae22f43126a9.webm
new file mode 100644
index 0000000..f656937
Binary files /dev/null and b/screenrecord/rec/chaos-run4/page@0f3957c29a1d485b5ee2ae22f43126a9.webm differ
diff --git a/screenrecord/rec/chaos-run4/run4-final.mp4 b/screenrecord/rec/chaos-run4/run4-final.mp4
new file mode 100644
index 0000000..e979e36
Binary files /dev/null and b/screenrecord/rec/chaos-run4/run4-final.mp4 differ
diff --git a/screenrecord/rec/chaos-run4/run4-final.webm b/screenrecord/rec/chaos-run4/run4-final.webm
new file mode 100644
index 0000000..f656937
Binary files /dev/null and b/screenrecord/rec/chaos-run4/run4-final.webm differ
diff --git a/scripts/chaos-screenrecord.js b/scripts/chaos-screenrecord.js
new file mode 100644
index 0000000..0f6f45e
--- /dev/null
+++ b/scripts/chaos-screenrecord.js
@@ -0,0 +1,440 @@
+// chaos-screenrecord.js — 5-run, 5-order screen-record harness focused on
+// the CHAOS INDEX feature (commits through f961c67), interleaved with the
+// existing checks (category filters, admin CRUD, article open/close,
+// Escape paths, resets). Appends to the SAME persistent debug-log.jsonl as
+// prior screenrecord cycles. See ~/.claude/skills/screenrecord/SKILL.md.
+const { chromium } = require('playwright');
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = '/Users/macstudio3/Projects/crazy-news-channel';
+const URL = 'file://' + ROOT + '/index.html';
+const LOG = path.join(ROOT, 'screenrecord', 'debug-log.jsonl');
+const REC_DIR = path.join(ROOT, 'screenrecord', 'rec');
+
+const prior = fs.existsSync(LOG)
+  ? fs.readFileSync(LOG, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
+  : [];
+const priorErrorSelectors = new Set(
+  prior.filter((r) => r.errors && r.errors.length).map((r) => r.selector)
+);
+console.log(`[chaos-screenrecord] loaded ${prior.length} prior log lines, ${priorErrorSelectors.size} distinct prior-error selectors`);
+
+function append(o) { fs.appendFileSync(LOG, JSON.stringify(o) + '\n'); }
+
+function mulberry32(seed) {
+  return function () {
+    seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
+    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
+    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+  };
+}
+function seededShuffle(arr, seed) {
+  const rnd = mulberry32(seed);
+  const a = [...arr];
+  for (let i = a.length - 1; i > 0; i--) {
+    const j = Math.floor(rnd() * (i + 1));
+    [a[i], a[j]] = [a[j], a[i]];
+  }
+  return a;
+}
+
+async function snap(page) {
+  return page.evaluate(() => {
+    const body = document.body;
+    const adminPanel = document.getElementById('adminPanel');
+    const articleView = document.getElementById('articleView');
+    const klaxonBtn = document.getElementById('klaxonBtn');
+    const gridView = document.getElementById('gridView');
+    const anchorCaption = document.getElementById('anchorCaption');
+    const standbyFlash = document.getElementById('standbyFlash');
+    const meltdownBanner = document.getElementById('meltdownBanner');
+    const headlineEl = articleView && articleView.querySelector('.article-headline');
+    const tierClasses = [0, 1, 2, 3].filter((i) => body.classList.contains('chaos-tier-' + i));
+    return {
+      hash: location.hash,
+      mood: body.getAttribute('data-mood'),
+      breakingMode: body.classList.contains('breaking-mode'),
+      klaxonPressed: klaxonBtn ? klaxonBtn.getAttribute('aria-pressed') : null,
+      klaxonText: klaxonBtn ? klaxonBtn.textContent.trim() : null,
+      adminHidden: adminPanel ? adminPanel.hidden : null,
+      adminExpanded: document.getElementById('adminToggleBtn')
+        ? document.getElementById('adminToggleBtn').getAttribute('aria-expanded')
+        : null,
+      articleHidden: articleView ? articleView.hidden : null,
+      articleHeadline: headlineEl ? headlineEl.textContent.trim().slice(0, 70) : null,
+      gridHidden: gridView ? gridView.hidden : null,
+      adminStoryCount: document.getElementById('adminStoryCount')
+        ? document.getElementById('adminStoryCount').textContent
+        : null,
+      storyCardCount: document.querySelectorAll('#storyGrid .story-card').length,
+      concludedCount: document.querySelectorAll('#storyGrid .story-card.concluded').length,
+      focused: document.activeElement
+        ? document.activeElement.tagName + (document.activeElement.id ? '#' + document.activeElement.id : '')
+        : null,
+      soundChecked: document.getElementById('soundToggle')
+        ? document.getElementById('soundToggle').checked
+        : null,
+      chaosPct: document.getElementById('chaosPct') ? document.getElementById('chaosPct').textContent : null,
+      chaosBandLabel: document.getElementById('chaosBandLabel') ? document.getElementById('chaosBandLabel').textContent : null,
+      chaosTierClasses: tierClasses,
+      anchorCaptionHidden: anchorCaption ? anchorCaption.hidden : null,
+      anchorCaptionText: anchorCaption && !anchorCaption.hidden ? anchorCaption.textContent.trim() : null,
+      standbyFlashHidden: standbyFlash ? standbyFlash.hidden : null,
+      standbyFlashFlashing: standbyFlash ? standbyFlash.classList.contains('flashing') : null,
+      meltdownBannerHidden: meltdownBanner ? meltdownBanner.hidden : null,
+      bodyHasOverflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
+    };
+  });
+}
+
+function effectDiff(before, after) {
+  const diffs = [];
+  for (const k of Object.keys(after)) {
+    if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
+      diffs.push(`${k}: ${JSON.stringify(before[k])} -> ${JSON.stringify(after[k])}`);
+    }
+  }
+  return diffs.length ? diffs.join('; ') : 'no visible state change';
+}
+
+// ---------- step catalog ----------
+// Each step: { id, label, fn(page, ctx) -> effect string (or throws) }
+// ctx carries per-run mutable info (created story ids etc).
+function buildCatalog() {
+  const cats = ['all', 'politics', 'weather', 'scitech', 'sports', 'entertainment', 'business', 'uncategorized'];
+  const catSteps = cats.map((c) => ({
+    id: `cat-${c}`,
+    label: `category mood filter -> ${c}`,
+    kind: 'toggle',
+    async fn(page) {
+      await page.click(`label[for="cat-${c}"]`);
+    },
+  }));
+
+  const steps = [];
+
+  steps.push({
+    id: 'openAdmin', label: 'open admin panel', kind: 'toggle',
+    async fn(page) {
+      const hidden = await page.locator('#adminPanel').isHidden();
+      if (hidden) await page.click('#adminToggleBtn');
+    },
+  });
+  steps.push({
+    id: 'closeAdmin', label: 'close admin panel', kind: 'toggle',
+    async fn(page) {
+      const hidden = await page.locator('#adminPanel').isHidden();
+      if (!hidden) await page.click('#adminToggleBtn');
+    },
+  });
+
+  steps.push(...catSteps);
+
+  steps.push({
+    id: 'klaxonOn', label: 'klaxon toggle ON', kind: 'toggle',
+    async fn(page) {
+      const pressed = await page.locator('#klaxonBtn').getAttribute('aria-pressed');
+      if (pressed !== 'true') await page.click('#klaxonBtn');
+    },
+  });
+  steps.push({
+    id: 'klaxonOff', label: 'klaxon toggle OFF', kind: 'toggle',
+    async fn(page) {
+      const pressed = await page.locator('#klaxonBtn').getAttribute('aria-pressed');
+      if (pressed === 'true') await page.click('#klaxonBtn');
+    },
+  });
+  steps.push({
+    id: 'klaxonRapid4x', label: 'klaxon rapid on/off/on/off x4 (interval-leak probe)', kind: 'toggle',
+    async fn(page) {
+      for (let i = 0; i < 4; i++) await page.click('#klaxonBtn');
+    },
+  });
+  steps.push({
+    id: 'soundOn', label: 'sound toggle ON', kind: 'toggle',
+    async fn(page) {
+      const checked = await page.locator('#soundToggle').isChecked();
+      if (!checked) await page.click('#soundToggle');
+    },
+  });
+  steps.push({
+    id: 'soundOff', label: 'sound toggle OFF', kind: 'toggle',
+    async fn(page) {
+      const checked = await page.locator('#soundToggle').isChecked();
+      if (checked) await page.click('#soundToggle');
+    },
+  });
+  steps.push({
+    id: 'sirenCycle', label: 'sound ON + klaxon ON (siren start) then klaxon OFF (siren stop) - audio timer leak probe', kind: 'toggle',
+    async fn(page) {
+      const soundChecked = await page.locator('#soundToggle').isChecked();
+      if (!soundChecked) await page.click('#soundToggle');
+      const pressed = await page.locator('#klaxonBtn').getAttribute('aria-pressed');
+      if (pressed !== 'true') await page.click('#klaxonBtn');
+      await page.waitForTimeout(150);
+      await page.click('#klaxonBtn'); // off, siren should stop
+      await page.click('#soundToggle'); // back off
+    },
+  });
+
+  steps.push({
+    id: 'openArticle-tuesdays', label: 'open story card "tuesdays"', kind: 'article',
+    async fn(page) {
+      const hidden = await page.locator('#adminPanel').isHidden();
+      if (!hidden) await page.click('#adminToggleBtn');
+      // Known harness quirk from the prior screenrecord cycle: a non-"all"
+      // category filter left set by an earlier step hides most story cards
+      // from the DOM, which looks like an app bug but is just the filter
+      // correctly doing its job. Defensively reset to "all" before trying
+      // to open a specific story by id.
+      const checked = await page.locator('#cat-all').isChecked().catch(() => true);
+      if (!checked) await page.click('label[for="cat-all"]');
+      const el = page.locator('[data-open-id="tuesdays"]').first();
+      if (await el.count() === 0) return; // genuinely absent (e.g. deleted), expected
+      await el.scrollIntoViewIfNeeded({ timeout: 4000 });
+      await el.click({ timeout: 4000 });
+    },
+  });
+  steps.push({
+    id: 'closeArticleBack', label: 'back-to-grid from article', kind: 'article',
+    async fn(page) {
+      const el = page.locator('#articleView [data-back]');
+      if (await el.count() && await el.isVisible()) await el.click({ timeout: 4000 });
+    },
+  });
+  steps.push({
+    id: 'openArticle-gerald', label: 'open story card "gerald"', kind: 'article',
+    async fn(page) {
+      const checked = await page.locator('#cat-all').isChecked().catch(() => true);
+      if (!checked) await page.click('label[for="cat-all"]');
+      const el = page.locator('[data-open-id="gerald"]').first();
+      if (await el.count() === 0) return;
+      await el.scrollIntoViewIfNeeded({ timeout: 4000 });
+      await el.click({ timeout: 4000 });
+    },
+  });
+  steps.push({
+    id: 'escapeFromArticle', label: 'Escape key from article view', kind: 'article',
+    async fn(page) {
+      await page.keyboard.press('Escape');
+    },
+  });
+  steps.push({
+    id: 'escapeBothOpenPriority', label: 'admin+article both open, Escape x2 (priority check)', kind: 'article',
+    async fn(page) {
+      const adminHidden = await page.locator('#adminPanel').isHidden();
+      if (adminHidden) await page.click('#adminToggleBtn');
+      const checked = await page.locator('#cat-all').isChecked().catch(() => true);
+      if (!checked) await page.click('label[for="cat-all"]');
+      const el = page.locator('[data-open-id="tuesdays"]').first();
+      if (await el.count()) {
+        await el.scrollIntoViewIfNeeded({ timeout: 4000 }).catch(() => {});
+        await el.click({ timeout: 4000 }).catch(() => {});
+      }
+      await page.keyboard.press('Escape'); // admin should close first
+      await page.keyboard.press('Escape'); // article should close second
+    },
+  });
+
+  steps.push({
+    id: 'adminCreateStory1', label: 'admin create story #1 (chaos-swing-up test)', kind: 'admin-crud',
+    async fn(page, ctx) {
+      const hidden = await page.locator('#adminPanel').isHidden();
+      if (hidden) await page.click('#adminToggleBtn');
+      const headline = `Chaos Swing Story A run${ctx.run} ${Date.now()}`;
+      await page.fill('#adminHeadline', headline, { timeout: 6000 });
+      await page.selectOption('#adminCategory', 'scitech');
+      await page.click('#adminCreateForm .admin-submit-btn', { timeout: 6000 });
+      ctx.createdIds.push(headline);
+      ctx.lastCreatedHeadline = headline;
+    },
+  });
+  steps.push({
+    id: 'adminCreateStory2', label: 'admin create story #2 (chaos-swing-up test)', kind: 'admin-crud',
+    async fn(page, ctx) {
+      const hidden = await page.locator('#adminPanel').isHidden();
+      if (hidden) await page.click('#adminToggleBtn');
+      const headline = `Chaos Swing Story B run${ctx.run} ${Date.now()}`;
+      await page.fill('#adminHeadline', headline, { timeout: 6000 });
+      await page.selectOption('#adminCategory', 'business');
+      await page.click('#adminCreateForm .admin-submit-btn', { timeout: 6000 });
+      ctx.createdIds.push(headline);
+      ctx.lastCreatedHeadline = headline;
+    },
+  });
+  steps.push({
+    id: 'adminCreateEmptyValidation', label: 'admin create story (empty headline validation)', kind: 'admin-crud',
+    async fn(page) {
+      const hidden = await page.locator('#adminPanel').isHidden();
+      if (hidden) await page.click('#adminToggleBtn');
+      await page.fill('#adminHeadline', '', { timeout: 6000 });
+      await page.click('#adminCreateForm .admin-submit-btn', { timeout: 6000 });
+    },
+  });
+  steps.push({
+    id: 'adminDeleteCreated', label: 'admin delete a created chaos-swing story (chaos-swing-down test)', kind: 'admin-crud',
+    async fn(page, ctx) {
+      const hidden = await page.locator('#adminPanel').isHidden();
+      if (hidden) await page.click('#adminToggleBtn');
+      if (!ctx.createdIds.length) return; // nothing to delete this pass
+      const headline = ctx.createdIds.shift();
+      const row = page.locator('.admin-story-row', { hasText: headline });
+      if (await row.count() === 0) return; // may already be gone (reset ran earlier)
+      await row.locator('[data-delete-id]').first().click({ timeout: 6000 });
+    },
+  });
+  steps.push({
+    id: 'adminResetToDefaults', label: 'admin reset to defaults (chaos reset test)', kind: 'admin-crud',
+    async fn(page, ctx) {
+      const hidden = await page.locator('#adminPanel').isHidden();
+      if (hidden) await page.click('#adminToggleBtn');
+      await page.click('#adminResetBtn', { timeout: 6000 });
+      ctx.createdIds = [];
+    },
+  });
+
+  // ---- Chaos Index escalation via fake clock ----
+  // IMPORTANT: use clock.runFor(), not clock.fastForward(). fastForward()
+  // simulates "the machine slept" and fires each due timer AT MOST ONCE at
+  // the jump point — a repeating setInterval(tick,1000) would NOT get to
+  // fire once per virtual second, so state.elapsedSec would barely move and
+  // stories would never escalate. runFor() fires every intermediate timer
+  // (like sinon's clock.tick), which is what's needed to actually drive the
+  // app's real per-second tick() loop forward. (Confirmed empirically: an
+  // earlier fastForward-based version of this harness only moved chaosPct
+  // by 1-2% across 500+ virtual seconds — a harness bug, not an app bug.)
+  steps.push({
+    id: 'chaosFF-tier1', label: 'advance clock ~20s via runFor (expect chaos tier to start rising toward Concerning)', kind: 'chaos',
+    async fn(page) { await page.clock.runFor(20 * 1000); },
+  });
+  steps.push({
+    id: 'chaosFF-tier2', label: 'advance clock +90s more via runFor (expect Unprecedented tier / anchor caption)', kind: 'chaos',
+    async fn(page) { await page.clock.runFor(90 * 1000); },
+  });
+  steps.push({
+    id: 'chaosFF-tier3', label: 'advance clock +150s more via runFor (expect Post-Journalism tier, ticker stutter, tilt)', kind: 'chaos',
+    async fn(page) { await page.clock.runFor(150 * 1000); },
+  });
+  steps.push({
+    id: 'chaosFF-concluded', label: 'advance clock in 25s chunks via runFor to drive ALL stories to concluded (chaos=100, PLEASE STAND BY) — pauses on REAL wall-clock once flash is caught mid-flight so the recording actually shows it', kind: 'chaos',
+    async fn(page, ctx) {
+      let caught = false;
+      for (let i = 0; i < 12 && !caught; i++) {
+        await page.clock.runFor(25 * 1000);
+        const flashing = await page.evaluate(() => {
+          const el = document.getElementById('standbyFlash');
+          return !el.hidden && el.classList.contains('flashing');
+        });
+        if (flashing) {
+          caught = true;
+          // Real (non-fake-clock) pause so Playwright's video recorder
+          // actually captures a frame of the flash while it's up — the
+          // flash's own hide is gated on the FAKE clock's setTimeout, so a
+          // real wait here does not prematurely dismiss it.
+          await page.waitForTimeout(1200);
+        }
+      }
+      ctx.standbyCaughtVisible = caught;
+    },
+  });
+  steps.push({
+    id: 'chaosStandbyAutoHide', label: 'advance clock +3s via runFor (standby flash should auto-hide after its 2.9s timer)', kind: 'chaos',
+    async fn(page) { await page.clock.runFor(3 * 1000); },
+  });
+
+  return steps;
+}
+
+function orderFor(run, steps, ctx) {
+  if (run === 0) return steps; // DOM/catalog order
+  if (run === 1) return [...steps].reverse();
+  if (run === 2) {
+    // "toggles/klaxon/chaos-drivers first" (no literal <input type=range> in
+    // this app; per prior screenrecord cycle's convention, toggle-ish
+    // controls + the chaos fast-forward steps stand in for "sliders").
+    const weight = (s) => (s.kind === 'toggle' || s.kind === 'chaos' ? 0 : 1);
+    return [...steps].sort((a, b) => weight(a) - weight(b));
+  }
+  if (run === 3) return seededShuffle(steps, 12345 + run * 97);
+  if (run === 4) {
+    const bad = new Set([...priorErrorSelectors].map((s) => s));
+    // errored-first: put any step whose id/label matches a selector/label
+    // that previously produced an error at the front.
+    const isBad = (s) => [...bad].some((b) => b && (s.id.includes(b) || s.label.includes(b)));
+    return [...steps].sort((a, b) => (isBad(b) ? 1 : 0) - (isBad(a) ? 1 : 0));
+  }
+  return steps;
+}
+
+async function runOne(run) {
+  const recDir = path.join(REC_DIR, `chaos-run${run}`);
+  fs.mkdirSync(recDir, { recursive: true });
+  const browser = await chromium.launch();
+  const context = await browser.newContext({
+    viewport: { width: 1600, height: 900 },
+    recordVideo: { dir: recDir, size: { width: 1600, height: 900 } },
+  });
+  const page = await context.newPage();
+
+  let pendingErrors = [];
+  page.on('console', (m) => { if (m.type() === 'error') pendingErrors.push('console: ' + m.text()); });
+  page.on('pageerror', (e) => pendingErrors.push('pageerror: ' + String(e)));
+
+  // Install a fake clock BEFORE navigation so PANDEMONIUM-24's setInterval(tick,1000)
+  // master loop, Date.now()/new Date() clock readout, and the standby-flash
+  // setTimeout(...,2900) are all fast-forwardable deterministically (no real
+  // multi-minute wait needed to drive 44 staggered stories to full escalation).
+  await page.clock.install({ time: new Date('2026-09-23T12:00:00Z') });
+
+  await page.goto(URL, { waitUntil: 'domcontentloaded' });
+  await page.waitForTimeout(200);
+
+  const before0 = await snap(page);
+  append({ run, ts: new Date().toISOString(), selector: 'document', label: `chaos-pass run${run} initial load (file://index.html)`, action: 'goto', ok: true, effect: JSON.stringify(before0), errors: pendingErrors.splice(0) });
+
+  const catalog = buildCatalog();
+  const ctx = { run, createdIds: [], lastCreatedHeadline: null };
+  const ordered = orderFor(run, catalog, ctx);
+
+  for (const step of ordered) {
+    const before = await snap(page).catch(() => ({}));
+    let ok = true;
+    let effect;
+    try {
+      await step.fn(page, ctx);
+      await page.waitForTimeout(120);
+      const after = await snap(page).catch(() => ({}));
+      effect = effectDiff(before, after);
+    } catch (err) {
+      ok = false;
+      effect = 'EXCEPTION: ' + String(err && err.message ? err.message : err);
+    }
+    const errs = pendingErrors.splice(0);
+    append({ run, ts: new Date().toISOString(), selector: step.id, label: step.label, action: step.kind, ok, effect, errors: errs });
+    console.log(`[run${run}] ${ok ? 'OK  ' : 'FAIL'} ${step.id.padEnd(28)} ${effect.slice(0, 140)}`);
+  }
+
+  // Final full-state snapshot for this run, useful for the report's
+  // order-dependent-bug comparison across runs.
+  const finalSnap = await snap(page).catch(() => ({}));
+  append({ run, ts: new Date().toISOString(), selector: 'document', label: `chaos-pass run${run} FINAL state`, action: 'final-snapshot', ok: true, effect: JSON.stringify(finalSnap), errors: pendingErrors.splice(0) });
+
+  await context.close();
+  await browser.close();
+  return recDir;
+}
+
+(async () => {
+  const recDirs = [];
+  for (let run = 0; run < 5; run++) {
+    console.log(`\n=== CHAOS-PASS RUN ${run} ===`);
+    const dir = await runOne(run);
+    recDirs.push(dir);
+  }
+  console.log('\nDone. Recording dirs:', recDirs);
+})().catch((e) => {
+  console.error('FATAL', e);
+  process.exit(1);
+});
diff --git a/tools/wire-category-pages.mjs b/tools/wire-category-pages.mjs
new file mode 100644
index 0000000..96eee0b
--- /dev/null
+++ b/tools/wire-category-pages.mjs
@@ -0,0 +1,231 @@
+#!/usr/bin/env node
+// Patch (Steve, 2026-09-24): category selection navigates to a real page
+// (#/category/<key>) instead of just filtering + recoloring in place, and
+// the P24 logo links back home. Exact-anchor replacement, same pattern as
+// wire-stories.mjs — fails loudly on a drifted file instead of half-patching.
+import fs from "node:fs";
+
+const file = process.argv[2] || new URL("../index.html", import.meta.url).pathname;
+let src = fs.readFileSync(file, "utf8");
+
+function edit(name, find, replace) {
+  const n = src.split(find).length - 1;
+  if (n !== 1) throw new Error(`${name}: anchor matched ${n} times (expected 1)`);
+  src = src.replace(find, () => replace);
+}
+
+// 1. Logo/wordmark links home. `.brand` becomes an <a>; a heading inside an
+//    anchor is valid and is the standard "logo goes home" pattern.
+edit("brand markup",
+  `<div class="brand">
+    <span class="logo-badge" aria-hidden="true">P24</span>
+    <div>
+      <h1 class="wordmark">PANDEMONIUM-24</h1>
+      <p class="tagline">"Wall-to-Wall. Ceiling-to-Floor. Chaos Coverage."</p>
+    </div>
+  </div>`,
+  `<a class="brand" href="#/" aria-label="PANDEMONIUM-24 — back to all coverage">
+    <span class="logo-badge" aria-hidden="true">P24</span>
+    <div>
+      <h1 class="wordmark">PANDEMONIUM-24</h1>
+      <p class="tagline">"Wall-to-Wall. Ceiling-to-Floor. Chaos Coverage."</p>
+    </div>
+  </a>`);
+edit("brand css",
+  ".brand { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }",
+  ".brand { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; color: inherit; text-decoration: none; }\n.brand:hover .wordmark { text-decoration: underline; }");
+
+// 2. Category page banner markup: a back link + blurb above the existing
+//    heading, hidden on the home route, populated on a category route.
+edit("channel-meta markup",
+  `    <div class="channel-meta">
+      <h2 id="channelHeading" tabindex="-1">On The Desk Right Now</h2>
+      <span class="mood-readout" id="moodReadout">Channel mood: Calm-ish (Default)</span>
+    </div>`,
+  `    <div class="channel-meta">
+      <div>
+        <a id="categoryBackLink" class="category-back-link" href="#/" hidden>← All Categories</a>
+        <h2 id="channelHeading" tabindex="-1">On The Desk Right Now</h2>
+        <p id="categoryBlurb" class="category-blurb" hidden></p>
+      </div>
+      <span class="mood-readout" id="moodReadout">Channel mood: Calm-ish (Default)</span>
+    </div>`);
+edit("channel-meta css",
+  ".category-filter { padding: .6rem 1rem; border-bottom: 1px solid var(--panel-border); }",
+  `.category-filter { padding: .6rem 1rem; border-bottom: 1px solid var(--panel-border); }
+.category-back-link { display: inline-block; font-size: .8rem; font-weight: 700; color: var(--accent2); margin-bottom: .3rem; text-decoration: none; }
+.category-back-link:hover { text-decoration: underline; }
+.category-blurb { color: var(--text-dim); font-size: .88rem; margin-top: .2rem; }`);
+
+// 3. els: register the two new nodes.
+edit("els additions",
+  '  channelHeading: document.getElementById("channelHeading"),',
+  `  channelHeading: document.getElementById("channelHeading"),
+  categoryBackLink: document.getElementById("categoryBackLink"),
+  categoryBlurb: document.getElementById("categoryBlurb"),`);
+
+// 4. One-line-per-category blurb for the page header.
+edit("category blurbs",
+  '/* ---------- F. Category filter ---------- */',
+  `const CATEGORY_BLURBS = {
+  politics: "Committees, subcommittees, and whatever this is now.",
+  weather: "Forecasts that stopped being reassuring a while ago.",
+  scitech: "The lab called. It has questions.",
+  sports: "Scores, standings, and mounting municipal concern.",
+  entertainment: "Local color, now with less local and more color.",
+  business: "The numbers are in. So is everyone else's business.",
+  uncategorized: "Doesn't fit anywhere. Doesn't care.",
+};
+
+/* ---------- F. Category filter ---------- */`);
+
+// 5. Replace the direct-filter handler with a navigation: selecting a
+// category is a route change (#/category/<key>), not an in-place mutation.
+// onCategoryChange's old body moves into renderRoute's home/category branch
+// below, so it's the hash router — not the radio's change event — that owns
+// state.category from here on (deep links and back/forward now work).
+edit("onCategoryChange -> navigation",
+  `function onCategoryChange(value) {
+  state.category = value;
+  updateMoodUI();
+  renderStories();
+  renderFeatures();
+  announce(\`Channel mood changed to \${MOODS[value].label} (\${value === "all" ? "All Categories" : value}).\`);
+}`,
+  `function onCategoryChange(value) {
+  location.hash = value === "all" ? "" : \`#/category/\${value}\`;
+}
+
+function syncCategoryRadio(key) {
+  const input = document.getElementById(\`cat-\${key || "all"}\`);
+  if (input) input.checked = true;
+}
+
+// Swaps the page header between the home heading and a category page's own
+// title/blurb/back-link — this, not a recolor, is what "opening" a category
+// means now.
+function renderCategoryBanner(key) {
+  if (key) {
+    els.channelHeading.textContent = \`\${CATEGORY_LABELS[key]} — Live Coverage\`;
+    els.categoryBackLink.hidden = false;
+    els.categoryBlurb.hidden = false;
+    els.categoryBlurb.textContent = CATEGORY_BLURBS[key] || "";
+  } else {
+    els.channelHeading.textContent = "On The Desk Right Now";
+    els.categoryBackLink.hidden = true;
+    els.categoryBlurb.hidden = true;
+    els.categoryBlurb.textContent = "";
+  }
+}`);
+
+// 6. Router: parse #/category/<key> alongside the existing #/article/<id>.
+edit("getCategoryRoute",
+  `function getRouteId() {
+  const m = location.hash.match(/^#\\/article\\/(.+)$/);
+  if (!m) return null;
+  try {
+    return decodeURIComponent(m[1]);
+  } catch (e) {
+    // Malformed URI (e.g., %XX with no valid hex) — treat as "not found"
+    return null;
+  }
+}`,
+  `function getRouteId() {
+  const m = location.hash.match(/^#\\/article\\/(.+)$/);
+  if (!m) return null;
+  try {
+    return decodeURIComponent(m[1]);
+  } catch (e) {
+    // Malformed URI (e.g., %XX with no valid hex) — treat as "not found"
+    return null;
+  }
+}
+
+function getCategoryRoute() {
+  const m = location.hash.match(/^#\\/category\\/(.+)$/);
+  if (!m) return null;
+  try {
+    return decodeURIComponent(m[1]);
+  } catch (e) {
+    return null;
+  }
+}`);
+
+// 7. renderRoute: the home/else branch now also handles a category route —
+// picks the page header, filters the grid, retints the mood, and announces
+// the navigation, all from one place (so a deep link or back/forward lands
+// in the exact same state a click would have).
+edit("renderRoute category branch",
+  `  } else {
+    els.articleView.hidden = true;
+    els.gridView.hidden = false;
+    state.currentRouteId = null;
+    // Restore the filter-selected theme now that the grid is visible again.
+    document.body.dataset.mood = state.category;
+    if (moveFocus) restoreGridFocus(previousId);
+  }
+}`,
+  `  } else {
+    els.articleView.hidden = true;
+    els.gridView.hidden = false;
+    state.currentRouteId = null;
+    const requested = getCategoryRoute();
+    const key = requested && CATEGORY_LABELS[requested] ? requested : null;
+    if (requested && !key) {
+      // Unknown category in the URL (typo'd/stale deep link) — fall back to
+      // "all" rather than silently rendering an empty, mislabeled page.
+      announce(\`Unknown category "\${requested}." Showing All Categories.\`);
+    }
+    const changed = state.category !== (key || "all");
+    state.category = key || "all";
+    syncCategoryRadio(state.category);
+    renderCategoryBanner(key);
+    updateMoodUI();
+    renderStories();
+    renderFeatures();
+    document.body.dataset.mood = state.category;
+    if (changed) {
+      announce(key
+        ? \`Now viewing \${CATEGORY_LABELS[key]}. Channel mood: \${MOODS[key].label}.\`
+        : "Now viewing All Categories. Channel mood: Calm-ish (Default).");
+    }
+    if (moveFocus) restoreGridFocus(previousId);
+  }
+}`);
+
+// 8. Radios navigate instead of mutating state directly.
+edit("radio listener",
+  `  document.querySelectorAll('input[name="category"]').forEach((input) => {
+    input.addEventListener("change", (e) => onCategoryChange(e.target.value));
+  });`,
+  `  document.querySelectorAll('input[name="category"]').forEach((input) => {
+    input.addEventListener("change", (e) => onCategoryChange(e.target.value));
+  });
+  els.categoryBackLink.addEventListener("click", (e) => {
+    // Same-page hash change; goBackToGrid() renders synchronously so the
+    // heading/focus updates immediately rather than waiting on hashchange.
+    e.preventDefault();
+    goBackToGrid();
+  });`);
+
+// 9. hashchange dedupe must cover category changes too, not just article
+// id changes (both #/category/politics -> #/category/weather previously
+// had getRouteId() === null on both sides, so the old check never fired).
+edit("hashchange dedupe",
+  `  window.addEventListener("hashchange", () => {
+    if (getRouteId() !== state.currentRouteId) renderRoute();
+  });`,
+  `  window.addEventListener("hashchange", () => {
+    if (location.hash !== state.lastRenderedHash) renderRoute();
+  });`);
+edit("track last rendered hash",
+  "function renderRoute(opts) {\n  opts = opts || {};\n  const moveFocus = opts.moveFocus !== false;\n  const previousId = state.currentRouteId;\n  const id = getRouteId();",
+  "function renderRoute(opts) {\n  opts = opts || {};\n  const moveFocus = opts.moveFocus !== false;\n  const previousId = state.currentRouteId;\n  state.lastRenderedHash = location.hash;\n  const id = getRouteId();");
+
+// 10. Escape backs out of a category page too, not just an article.
+edit("escape includes category",
+  "    if (getRouteId() || !els.articleView.hidden) { goBackToGrid(); return; }",
+  "    if (getRouteId() || !els.articleView.hidden || getCategoryRoute()) { goBackToGrid(); return; }");
+
+fs.writeFileSync(file, src);
+console.log("patched", file);

← d583f26 Reset to Defaults clears FAILED_IMAGES  ·  back to Crazy News Channel  ·  Article view: render a real outbound link to a story's sourc f79b2f1 →