← back to Crazy News Channel Shadowman

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);