← 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
A 5x/REPORT.mdA 5x/sweep-1.mdM index.htmlA screenrecord/rec/chaos-run0/page@6ff94c2829a1a9c68f2e3141160400ef.webmA screenrecord/rec/chaos-run0/page@adc9e2c18cc195199e47c81061ae6850.webmA screenrecord/rec/chaos-run0/page@f74fa311aa59037d3103a799756d60ff.webmA screenrecord/rec/chaos-run0/run0-final.mp4A screenrecord/rec/chaos-run0/run0-final.webmA screenrecord/rec/chaos-run1/page@ce0271e448959d2115d35a375693054b.webmA screenrecord/rec/chaos-run1/page@e83ad34a61b573fa78b48110976e9f6a.webmA screenrecord/rec/chaos-run1/run1-final.mp4A screenrecord/rec/chaos-run1/run1-final.webmA screenrecord/rec/chaos-run2/page@4e4b70363f551541f4edd054b93f8d5c.webmA screenrecord/rec/chaos-run2/page@7e11170122614d52fbff20b446759027.webmA screenrecord/rec/chaos-run2/run2-final.mp4A screenrecord/rec/chaos-run2/run2-final.webmA screenrecord/rec/chaos-run3/page@b3aad4d41d1a9adae787ebd872a72585.webmA screenrecord/rec/chaos-run3/page@b658e8806e1b9aa93bc1c89d41a6a382.webmA screenrecord/rec/chaos-run3/run3-final.mp4A screenrecord/rec/chaos-run3/run3-final.webmA screenrecord/rec/chaos-run4/page@0f3957c29a1d485b5ee2ae22f43126a9.webmA screenrecord/rec/chaos-run4/run4-final.mp4A screenrecord/rec/chaos-run4/run4-final.webmA scripts/chaos-screenrecord.jsA tools/wire-category-pages.mjs
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 →