← back to Crazy News Channel
tools/wire-category-pages.mjs
232 lines
#!/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);