← back to Crazy News Channel Shadowman
P24: add article tags on every post, with a clickable #tag= filter + tag cloud
318cde7cc9ec62e6dff2b9388822ae50212abdfd · 2026-09-24 14:05:00 -0700 · Steve Abrams
Adds a `tags` array (3-6 short, shared-vocabulary tags) to every entry in
stories-data.js (44), real-news-data.js (42), cartoons/manifest.js (23),
and the 4 inline FEATURES in index.html (109 posts total, 0 missing).
Cartoons inherit their linked story's tags plus "editorial cartoon" (+
"paul conrad style" for the 5 Conrad-manner strips).
UI: tag chips render on every story/feature card, in the article view
under the dek, and on every cartoon-gallery card (as a sibling of the
card's own <a>, not nested inside it). Clicking a chip filters to every
post carrying that tag across all categories, shows a "Tagged: X ✕"
indicator, and persists in the URL as #tag=<tag> (works under file://,
unlike localStorage cross-page). A tag cloud (top tags with counts,
tags used only once excluded) sits above the grid when no filter is
active.
Real-news tagging is deterministic (scripts/lib/auto-tag-real-news.mjs:
category + outlet + ~30 keyword rules, always 3-6 tags, no LLM/API) and
is now called by fetch-real-news.mjs on every refresh, so a future run
can never write an untagged entry. build-cartoon-batch.sh's FAIL check
is extended the same way it already enforces story_id, so a cartoon (or
the story it's paired with) built without tags fails the batch. Each
data file's header now states the tags rule.
Also fixes the last 6 P24 pages still carrying their own inline theme
CSS instead of the shared assets/style.css from TK-12151's unification
(cartoons/reference/index.html + the come-in-and-get-me-meter,
row-by-row-marshal, gold-tier-treadmill, conrad-filibuster-ink, and
filibuster-marathon strips) — same link+data-mood treatment as the
other 18, so every page's background now matches its mood site-wide
(verified: index.html and cartoons/reference/index.html compute the
identical "all"-mood background gradient).
Verified: node --check on every edited JS file; a vm-context script
(scripts/verify-tags.mjs) asserting 0 of 109 posts have missing/empty
tags and printing vocabulary stats; a 30-check Playwright pass over
file:// (front page article + tag-click filtering, cartoon gallery +
tag-click filtering, 390px mobile with 0 horizontal overflow, and the
6 CSS-unified pages) — 0 console/page errors throughout.
TK-12165
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V29xaoBwcFwJyR4KC43ptn
Files touched
M .gitignoreM assets/style.cssM cartoons/20260924-come-in-and-get-me-meter.htmlM cartoons/20260924-conrad-filibuster-ink.htmlM cartoons/20260924-filibuster-marathon.htmlM cartoons/20260924-gold-tier-treadmill.htmlM cartoons/20260924-row-by-row-marshal.htmlM cartoons/index.htmlM cartoons/manifest.jsM cartoons/reference/index.htmlM index.htmlM real-news-data.jsM scripts/build-cartoon-batch.shM scripts/fetch-real-news.mjsA scripts/lib/auto-tag-real-news.mjsA scripts/verify-tags.mjsM stories-data.js
Diff
commit 318cde7cc9ec62e6dff2b9388822ae50212abdfd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 24 14:05:00 2026 -0700
P24: add article tags on every post, with a clickable #tag= filter + tag cloud
Adds a `tags` array (3-6 short, shared-vocabulary tags) to every entry in
stories-data.js (44), real-news-data.js (42), cartoons/manifest.js (23),
and the 4 inline FEATURES in index.html (109 posts total, 0 missing).
Cartoons inherit their linked story's tags plus "editorial cartoon" (+
"paul conrad style" for the 5 Conrad-manner strips).
UI: tag chips render on every story/feature card, in the article view
under the dek, and on every cartoon-gallery card (as a sibling of the
card's own <a>, not nested inside it). Clicking a chip filters to every
post carrying that tag across all categories, shows a "Tagged: X ✕"
indicator, and persists in the URL as #tag=<tag> (works under file://,
unlike localStorage cross-page). A tag cloud (top tags with counts,
tags used only once excluded) sits above the grid when no filter is
active.
Real-news tagging is deterministic (scripts/lib/auto-tag-real-news.mjs:
category + outlet + ~30 keyword rules, always 3-6 tags, no LLM/API) and
is now called by fetch-real-news.mjs on every refresh, so a future run
can never write an untagged entry. build-cartoon-batch.sh's FAIL check
is extended the same way it already enforces story_id, so a cartoon (or
the story it's paired with) built without tags fails the batch. Each
data file's header now states the tags rule.
Also fixes the last 6 P24 pages still carrying their own inline theme
CSS instead of the shared assets/style.css from TK-12151's unification
(cartoons/reference/index.html + the come-in-and-get-me-meter,
row-by-row-marshal, gold-tier-treadmill, conrad-filibuster-ink, and
filibuster-marathon strips) — same link+data-mood treatment as the
other 18, so every page's background now matches its mood site-wide
(verified: index.html and cartoons/reference/index.html compute the
identical "all"-mood background gradient).
Verified: node --check on every edited JS file; a vm-context script
(scripts/verify-tags.mjs) asserting 0 of 109 posts have missing/empty
tags and printing vocabulary stats; a 30-check Playwright pass over
file:// (front page article + tag-click filtering, cartoon gallery +
tag-click filtering, 390px mobile with 0 horizontal overflow, and the
6 CSS-unified pages) — 0 console/page errors throughout.
TK-12165
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V29xaoBwcFwJyR4KC43ptn
---
.gitignore | 4 +
assets/style.css | 104 +++++++++++
cartoons/20260924-come-in-and-get-me-meter.html | 14 +-
cartoons/20260924-conrad-filibuster-ink.html | 28 ++-
cartoons/20260924-filibuster-marathon.html | 23 +--
cartoons/20260924-gold-tier-treadmill.html | 16 +-
cartoons/20260924-row-by-row-marshal.html | 14 +-
cartoons/index.html | 121 +++++++++++-
cartoons/manifest.js | 203 +++++++++++++++++---
cartoons/reference/index.html | 26 +--
index.html | 133 ++++++++++++-
real-news-data.js | 226 ++++++++++++++++++++++
scripts/build-cartoon-batch.sh | 52 +++++-
scripts/fetch-real-news.mjs | 15 +-
scripts/lib/auto-tag-real-news.mjs | 100 ++++++++++
scripts/verify-tags.mjs | 71 +++++++
stories-data.js | 238 ++++++++++++++++++++++++
17 files changed, 1273 insertions(+), 115 deletions(-)
diff --git a/.gitignore b/.gitignore
index 85357c2..eb94275 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,7 @@ build/
# committed pre-dates this rule — see the untrack in the same change).
*.webm
*.mp4
+
+# Verification screenshots (headless-Chrome/Playwright proof runs) —
+# regenerable QA artifacts, not source.
+shots/
diff --git a/assets/style.css b/assets/style.css
index 40a01f6..3ce99c5 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -961,3 +961,107 @@ html[data-theme="dark"] body {
--text: #eef7f0; --text-dim: #c3d4c9aa;
}
}
+
+/* ============================================================
+ PART 4 — TAGS (TK-12165)
+ Shared tag-chip/filter-bar/cloud styling for the front page
+ (story cards + article view), the cartoon gallery, and every
+ individual cartoon strip page. Reuses the existing mood tokens
+ so tag chips retheme with body[data-mood="..."] like everything
+ else, instead of carrying their own fixed palette.
+ ============================================================ */
+.tag-chips, .tag-cloud-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.card-tags { margin: 8px 0 0; }
+.article-tags { margin: 4px 0 0; }
+.tag-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
+ font-size: .72rem;
+ font-weight: 600;
+ letter-spacing: .01em;
+ text-decoration: none;
+ color: var(--text-dim);
+ background: color-mix(in srgb, var(--panel) 88%, var(--text) 4%);
+ border: 1px solid var(--panel-border);
+ border-radius: 999px;
+ padding: 3px 10px;
+ line-height: 1.4;
+ transition: color .15s ease, border-color .15s ease, background-color .15s ease;
+}
+.tag-chip::before { content: "#"; opacity: .55; }
+.tag-chip:hover, .tag-chip:focus-visible {
+ color: var(--accent);
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 12%, var(--panel));
+}
+.tag-chip.active {
+ color: var(--chrome, var(--panel));
+ background: var(--accent);
+ border-color: var(--accent);
+}
+.tag-count {
+ font-size: .68rem;
+ opacity: .75;
+}
+
+/* "Tagged: X ✕" indicator — replaces the tag cloud while a filter is active. */
+.tag-filter-bar {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin: 10px 0 14px;
+ padding: 8px 14px;
+ background: color-mix(in srgb, var(--accent) 10%, var(--panel));
+ border: 1px solid var(--panel-border);
+ border-radius: 8px;
+ font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
+ font-size: .85rem;
+ color: var(--text);
+}
+.tag-filter-label strong { color: var(--accent); }
+.tag-filter-clear {
+ color: var(--text-dim);
+ text-decoration: none;
+ border: 1px solid var(--panel-border);
+ border-radius: 999px;
+ padding: 2px 10px;
+ font-size: .78rem;
+}
+.tag-filter-clear:hover, .tag-filter-clear:focus-visible {
+ color: var(--accent);
+ border-color: var(--accent);
+}
+
+/* Tag cloud — top tags with counts, hidden while a filter is active. */
+.tag-cloud {
+ margin: 8px 0 16px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 10px;
+}
+.tag-cloud:empty { margin: 0; }
+.tag-cloud-label {
+ font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
+ font-size: .78rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .06em;
+ color: var(--text-dim);
+}
+
+/* Cartoon gallery: the card is an <a> (whole thumb+body is clickable), so
+ the tag row sits as a SIBLING inside .card-wrap instead of nesting
+ another interactive element inside that anchor. */
+.card-wrap { display: flex; flex-direction: column; }
+.card-tags-row { margin-top: 6px; }
diff --git a/cartoons/20260924-come-in-and-get-me-meter.html b/cartoons/20260924-come-in-and-get-me-meter.html
index 7b8c0b4..a925e46 100644
--- a/cartoons/20260924-come-in-and-get-me-meter.html
+++ b/cartoons/20260924-come-in-and-get-me-meter.html
@@ -5,14 +5,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Come In and Get Me — P24 Cartoon Desk</title>
<meta name="description" content="Original P24 editorial cartoon in the manner of Paul Conrad: the one-day honorary mayor — a parking meter — refuses to vacate the office." />
+<link rel="stylesheet" href="../assets/style.css" />
<style>
- :root { --bg:#2a0b0b; --ink:#12100f; --paper:#efe7d2; --accent:#ff3b3b; --accent2:#ffb703; --text:#ffe9e9; --focus:#ffd166; }
- * { box-sizing:border-box; }
- body { margin:0; min-height:100vh; background:var(--bg); color:var(--text);
+ /* --ink/--paper are this strip's fixed newsprint-clipping ink+parchment
+ colors (the illustration always reads as dark ink on cream paper,
+ regardless of site mood) — everything else (page background/accent/
+ focus) now comes from the shared stylesheet's body[data-mood] palette. */
+ :root { --ink:#12100f; --paper:#efe7d2; }
+ body { margin:0; min-height:100vh;
font-family:'Iowan Old Style','Palatino Linotype',Georgia,serif;
display:flex; flex-direction:column; align-items:center; justify-content:center; padding:24px 16px; }
- a { color:var(--accent2); }
- a:focus-visible { outline:3px solid var(--focus); outline-offset:2px; }
figure { margin:0; width:100%; max-width:900px; }
.frame { background:var(--paper); border:6px solid var(--ink); border-radius:4px; box-shadow:0 12px 40px #0009; }
svg { display:block; width:100%; height:auto; }
@@ -23,7 +25,7 @@
.back { margin-top:18px; font-size:13px; font-family:system-ui,sans-serif; }
</style>
</head>
-<body>
+<body data-mood="politics">
<figure>
<div class="frame">
<svg viewBox="0 0 900 620" role="img" aria-label="A parking meter, wearing a mayoral sash and an expired-flag crown, barricaded behind an overturned desk in the mayor's office, snarling 'Come in and get me!' at officials peering around the door.">
diff --git a/cartoons/20260924-conrad-filibuster-ink.html b/cartoons/20260924-conrad-filibuster-ink.html
index 63c3854..1d0ae57 100644
--- a/cartoons/20260924-conrad-filibuster-ink.html
+++ b/cartoons/20260924-conrad-filibuster-ink.html
@@ -4,42 +4,38 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Filibuster Marathon</title>
+<link rel="stylesheet" href="../assets/style.css" />
<style>
+ /* --cream/--ink/--mint are this strip's fixed newsprint-clipping frame
+ colors (the AI-generated illustration sits in a parchment frame that
+ doesn't change with site mood); the page's own background/text now
+ come straight from the shared stylesheet's body[data-mood] palette
+ (via --bg/--text) instead of a locally hardcoded copy of it, and its
+ dark-mode toggle (data-theme on <html>, unchanged below) is handled by
+ the shared stylesheet's own html[data-theme="dark"] body rule. */
:root{
--cream:#cdf5e0;
--ink:#0b0b0c;
--mint:#0f9d78;
--mint-dim:#0c7d5f;
- --page-bg:#cdf5e0;
- --chrome-fg:#0b0b0c;
--frame-shadow: 0 18px 45px rgba(0,0,0,.28);
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
- --page-bg:#0e1512;
- --chrome-fg:#e9fbf1;
--frame-shadow: 0 22px 60px rgba(0,0,0,.6);
}
}
:root[data-theme="dark"]{
- --page-bg:#0e1512;
- --chrome-fg:#e9fbf1;
--frame-shadow: 0 22px 60px rgba(0,0,0,.6);
}
- *{box-sizing:border-box;}
- html,body{margin:0;padding:0;}
body{
- background:var(--page-bg);
- color:var(--chrome-fg);
font-family:"Georgia","Times New Roman",Cambria,serif;
- min-height:100vh;
display:flex;
flex-direction:column;
align-items:center;
justify-content:flex-start;
padding:clamp(16px,4vw,48px) clamp(12px,3vw,32px) clamp(28px,5vw,56px);
- transition:background .3s ease,color .3s ease;
}
.masthead{
@@ -64,13 +60,13 @@
font-size:clamp(26px,5vw,52px);
margin:0;
line-height:1.02;
- color:var(--chrome-fg);
+ color:var(--text);
}
.masthead .deck{
font-style:italic;
font-weight:400;
font-size:clamp(12px,1.8vw,16px);
- color:var(--chrome-fg);
+ color:var(--text);
opacity:.72;
margin:8px 0 0;
}
@@ -113,7 +109,7 @@
font-size:clamp(11px,1.5vw,13px);
font-style:italic;
opacity:.65;
- color:var(--chrome-fg);
+ color:var(--text);
}
.caption-row .stamp{
font-size:clamp(10px,1.4vw,12px);
@@ -146,7 +142,7 @@
}
</style>
</head>
-<body>
+<body data-mood="politics">
<button class="theme-toggle" id="themeToggle" type="button" aria-label="Toggle light / dark chrome">Dark mode</button>
diff --git a/cartoons/20260924-filibuster-marathon.html b/cartoons/20260924-filibuster-marathon.html
index 601b697..7e72c98 100644
--- a/cartoons/20260924-filibuster-marathon.html
+++ b/cartoons/20260924-filibuster-marathon.html
@@ -5,26 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>The Filibuster Marathon — P24 Political Cartoon Desk</title>
<meta name="description" content="An original invented-satire strip: Delegate Holloway filibusters the fictional Assembly by reading anything within arm's reach, while a Dali-style clock melts and the chamber falls asleep standing up." />
+<link rel="stylesheet" href="../assets/style.css" />
<style>
-:root{
- --bg:#f4efe4; --panel:#fffdf6; --ink:#241c14; --ink-dim:#6b5f4d; --rule:#241c14;
- --accent:#b5302f; --accent2:#0f6b52; --focus:#0b3d91; --seal:#c9a227;
-}
-@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){
- --bg:#1b160f; --panel:#241d14; --ink:#f2ead9; --ink-dim:#c9bda3; --rule:#f2ead9;
- --accent:#ff6b64; --accent2:#3ddcb0; --focus:#8ab4ff; --seal:#e0c25a;
-}}
-:root[data-theme="dark"]{
- --bg:#1b160f; --panel:#241d14; --ink:#f2ead9; --ink-dim:#c9bda3; --rule:#f2ead9;
- --accent:#ff6b64; --accent2:#3ddcb0; --focus:#8ab4ff; --seal:#e0c25a;
-}
-*,*::before,*::after{box-sizing:border-box}
-html{overflow-x:hidden}
-[hidden]{display:none!important}
-body{margin:0;min-height:100vh;background:var(--bg);color:var(--ink);font-family:Georgia,"Times New Roman",serif;line-height:1.4;padding-bottom:2rem}
-h1,h2,p{margin:0}
-:focus-visible{outline:3px solid var(--focus); outline-offset:2px}
-a{color:var(--accent2)}
header{padding:16px 20px 10px;border-bottom:3px solid var(--rule);display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px}
header a.back{font-family:-apple-system,sans-serif;font-size:.8rem;color:var(--ink-dim);text-decoration:none;border:1px solid var(--ink-dim);border-radius:6px;padding:5px 10px}
.kicker{font-family:-apple-system,sans-serif;font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;color:var(--accent)}
@@ -52,12 +34,11 @@ button.theme{margin-left:auto;background:transparent;border:1px solid var(--ink-
#status.info{border-color:var(--focus);color:var(--focus)}
footer{max-width:900px;margin:20px auto 0;padding:0 20px;font-family:-apple-system,sans-serif;font-size:.72rem;color:var(--ink-dim)}
.hint{font-family:-apple-system,sans-serif;font-size:.72rem;color:var(--ink-dim);margin-top:6px}
-@media (prefers-reduced-motion: reduce){ * { animation-duration:0.001ms !important; transition-duration:0.001ms !important; } }
.seal-anim{transition:transform .3s ease}
@media (prefers-reduced-motion: reduce){ .seal-anim{transition:none} }
</style>
</head>
-<body>
+<body data-mood="politics">
<header>
<div>
<div class="kicker">P24 Political Cartoon Desk · Invented Satire</div>
diff --git a/cartoons/20260924-gold-tier-treadmill.html b/cartoons/20260924-gold-tier-treadmill.html
index 5275fa2..37dbf6b 100644
--- a/cartoons/20260924-gold-tier-treadmill.html
+++ b/cartoons/20260924-gold-tier-treadmill.html
@@ -5,14 +5,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gold Commuter — P24 Cartoon Desk</title>
<meta name="description" content="Original P24 editorial cartoon in the manner of Paul Conrad: motorists circle a toll bridge all night on a treadmill of asphalt, chasing 'Gold Commuter' status." />
+<link rel="stylesheet" href="../assets/style.css" />
<style>
- :root { --bg:#08210f; --ink:#0b1a0e; --paper:#eafff0; --accent:#2ecc71; --accent2:#f1c40f; --text:#eafff0; --focus:#f1c40f; }
- * { box-sizing:border-box; }
- body { margin:0; min-height:100vh; background:var(--bg); color:var(--text);
+ /* --ink/--paper are this strip's fixed newsprint-clipping ink+parchment
+ colors (the illustration always reads as dark ink on cream paper,
+ regardless of site mood) — everything else (page background/accent/
+ focus) now comes from the shared stylesheet's body[data-mood] palette
+ (this cartoon's manifest.js section is "politics", matching its
+ political-cartoon siblings, not the green it used to hardcode). */
+ :root { --ink:#0b1a0e; --paper:#eafff0; }
+ body { margin:0; min-height:100vh;
font-family:'Iowan Old Style','Palatino Linotype',Georgia,serif;
display:flex; flex-direction:column; align-items:center; justify-content:center; padding:24px 16px; }
- a { color:var(--accent2); }
- a:focus-visible { outline:3px solid var(--focus); outline-offset:2px; }
figure { margin:0; width:100%; max-width:900px; }
.frame { background:var(--paper); border:6px solid var(--ink); border-radius:4px; box-shadow:0 12px 40px #0009; }
svg { display:block; width:100%; height:auto; }
@@ -23,7 +27,7 @@
.back { margin-top:18px; font-size:13px; font-family:system-ui,sans-serif; }
</style>
</head>
-<body>
+<body data-mood="politics">
<figure>
<div class="frame">
<svg viewBox="0 0 900 620" role="img" aria-label="A toll booth sits at the hub of a giant hamster-wheel of bridge asphalt turning in the night; exhausted cars trudge around and around it chasing a dangling gold 'GOLD TIER' carrot, headlights tracing an endless loop under a crescent moon.">
diff --git a/cartoons/20260924-row-by-row-marshal.html b/cartoons/20260924-row-by-row-marshal.html
index cd4e7f6..6231673 100644
--- a/cartoons/20260924-row-by-row-marshal.html
+++ b/cartoons/20260924-row-by-row-marshal.html
@@ -5,14 +5,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Row by Row, Citizen — P24 Cartoon Desk</title>
<meta name="description" content="Original P24 editorial cartoon in the manner of Paul Conrad: a state Corn Marshal confiscates a cob and outlaws the cob holder as a 'cheating device.'" />
+<link rel="stylesheet" href="../assets/style.css" />
<style>
- :root { --bg:#2a0b0b; --ink:#12100f; --paper:#efe7d2; --accent:#ff3b3b; --accent2:#ffb703; --text:#ffe9e9; --focus:#ffd166; }
- * { box-sizing:border-box; }
- body { margin:0; min-height:100vh; background:var(--bg); color:var(--text);
+ /* --ink/--paper are this strip's fixed newsprint-clipping ink+parchment
+ colors (the illustration always reads as dark ink on cream paper,
+ regardless of site mood) — everything else (page background/accent/
+ focus) now comes from the shared stylesheet's body[data-mood] palette. */
+ :root { --ink:#12100f; --paper:#efe7d2; }
+ body { margin:0; min-height:100vh;
font-family:'Iowan Old Style','Palatino Linotype',Georgia,serif;
display:flex; flex-direction:column; align-items:center; justify-content:center; padding:24px 16px; }
- a { color:var(--accent2); }
- a:focus-visible { outline:3px solid var(--focus); outline-offset:2px; }
figure { margin:0; width:100%; max-width:900px; }
.frame { background:var(--paper); border:6px solid var(--ink); border-radius:4px; box-shadow:0 12px 40px #0009; }
svg { display:block; width:100%; height:auto; }
@@ -23,7 +25,7 @@
.back { margin-top:18px; font-size:13px; font-family:system-ui,sans-serif; }
</style>
</head>
-<body>
+<body data-mood="politics">
<figure>
<div class="frame">
<svg viewBox="0 0 900 620" role="img" aria-label="A towering, jackbooted Corn Marshal in a badge and peaked cap looms over a small trembling diner, confiscating a corn cob with tongs while a cob holder lies stamped 'CHEATING DEVICE' at his boot.">
diff --git a/cartoons/index.html b/cartoons/index.html
index 4d898e7..3e65dbd 100644
--- a/cartoons/index.html
+++ b/cartoons/index.html
@@ -57,6 +57,15 @@
</fieldset>
</nav>
+ <!-- Active tag filter indicator (#tag=<tag> route) — "Tagged: X ✕". -->
+ <div id="tagFilterBar" class="tag-filter-bar" hidden>
+ <span class="tag-filter-label">Tagged: <strong id="tagFilterName"></strong></span>
+ <a href="#" id="tagFilterClear" class="tag-filter-clear">✕ Clear</a>
+ </div>
+
+ <!-- Tag cloud — top tags with counts, click to filter. -->
+ <div id="tagCloud" class="tag-cloud" aria-label="Browse by tag"></div>
+
<div class="controls" role="group" aria-label="Sort and display density">
<label for="sortSel">Sort
<select id="sortSel">
@@ -186,6 +195,80 @@
try { return decodeURIComponent(m[1]); } catch { return null; }
}
+ /* ---------- Tags (TK-12165) ----------
+ A #tag=<tag> route spans every category, same model as ../index.html —
+ it takes over from the category filter rather than combining with it. */
+ const tagFilterBar = document.getElementById('tagFilterBar');
+ const tagFilterName = document.getElementById('tagFilterName');
+ const tagFilterClear = document.getElementById('tagFilterClear');
+ const tagCloud = document.getElementById('tagCloud');
+
+ function normalizeTag(t){ return String(t == null ? '' : t).trim().toLowerCase(); }
+ function tagHref(tag){ return `#tag=${encodeURIComponent(normalizeTag(tag))}`; }
+ function getTagFromHash(){
+ const m = location.hash.match(/^#tag=(.+)$/);
+ if (!m) return null;
+ try { return normalizeTag(decodeURIComponent(m[1])); } catch { return null; }
+ }
+
+ let currentTag = getTagFromHash();
+
+ function setTag(tag, opts){
+ opts = opts || {};
+ currentTag = tag || null;
+ if (!opts.fromHash) {
+ const target = currentTag ? tagHref(currentTag) : '';
+ if (location.hash !== target) location.hash = target;
+ }
+ renderTagUI();
+ render();
+ }
+
+ function tagCounts(){
+ const counts = new Map();
+ for (const c of cartoons) {
+ if (!Array.isArray(c.tags)) continue;
+ for (const t of c.tags) {
+ const key = normalizeTag(t);
+ if (!key) continue;
+ const entry = counts.get(key) || { label: t, count: 0 };
+ entry.count++;
+ counts.set(key, entry);
+ }
+ }
+ return counts;
+ }
+
+ function tagChipsHTML(tags){
+ if (!Array.isArray(tags) || !tags.length) return '';
+ return '<ul class="tag-chips card-tags" aria-label="Tags">' +
+ tags.map((t) => `<li><a href="${tagHref(t)}" class="tag-chip${normalizeTag(t) === currentTag ? ' active' : ''}">${String(t).replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}</a></li>`).join('') +
+ '</ul>';
+ }
+
+ function renderTagUI(){
+ if (currentTag) {
+ tagFilterBar.hidden = false;
+ tagFilterName.textContent = currentTag;
+ tagCloud.hidden = true;
+ tagCloud.innerHTML = '';
+ return;
+ }
+ tagFilterBar.hidden = true;
+ tagCloud.hidden = false;
+ const counts = [...tagCounts().values()]
+ .filter((e) => e.count > 1)
+ .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
+ .slice(0, 20);
+ tagCloud.innerHTML = counts.length ? (
+ '<span class="tag-cloud-label">Browse by tag:</span><ul class="tag-cloud-list">' +
+ counts.map((e) => `<li><a href="${tagHref(e.label)}" class="tag-chip">${e.label} <span class="tag-count">${e.count}</span></a></li>`).join('') +
+ '</ul>'
+ ) : '';
+ }
+
+ tagFilterClear.addEventListener('click', (e) => { e.preventDefault(); setTag(null); });
+
let currentCategory = getCategoryFromHash();
if (!currentCategory || !isValidCategory(currentCategory)) {
currentCategory = safeGet(LS_CATEGORY, 'all');
@@ -221,6 +304,11 @@
});
window.addEventListener('hashchange', () => {
+ const tagFromHash = getTagFromHash();
+ if (tagFromHash !== currentTag) {
+ setTag(tagFromHash, { fromHash: true });
+ return; // tag route spans categories — don't also reconcile the category route below
+ }
const fromHash = getCategoryFromHash();
if (fromHash && isValidCategory(fromHash) && fromHash !== currentCategory) {
setCategory(fromHash, { fromHash: true });
@@ -235,9 +323,11 @@
let cartoons = [];
function render(){
- const filtered = currentCategory === 'all'
- ? cartoons
- : cartoons.filter((c) => categoryOf(c) === currentCategory);
+ // A tag filter spans every category (same model as ../index.html), so
+ // it replaces the category filter rather than combining with it.
+ let filtered = currentTag
+ ? cartoons.filter((c) => Array.isArray(c.tags) && c.tags.some((t) => normalizeTag(t) === currentTag))
+ : (currentCategory === 'all' ? cartoons : cartoons.filter((c) => categoryOf(c) === currentCategory));
const sorted = [...filtered];
if (sortSel.value === 'newest') sorted.sort((a,b)=> (b.created_at||'').localeCompare(a.created_at||''));
@@ -246,12 +336,21 @@
grid.innerHTML = '';
empty.hidden = sorted.length !== 0;
- emptyMsg.textContent = currentCategory === 'all'
- ? 'No cartoons on file yet — the Desk is between assignments.'
- : `No cartoons in ${CATEGORY_LABEL[currentCategory] || currentCategory} yet.`;
+ emptyMsg.textContent = currentTag
+ ? `No cartoons tagged "${currentTag}."`
+ : (currentCategory === 'all'
+ ? 'No cartoons on file yet — the Desk is between assignments.'
+ : `No cartoons in ${CATEGORY_LABEL[currentCategory] || currentCategory} yet.`);
count.textContent = sorted.length ? `${sorted.length} strip${sorted.length===1?'':'s'} on file` : '';
for (const c of sorted) {
const cat = categoryOf(c);
+ // The whole card is a link to the strip file — tag chips must NOT
+ // nest inside that anchor (nested <a> is invalid HTML and click
+ // behavior on the inner link is unreliable across engines), so the
+ // tags row is a SIBLING of the card anchor inside a wrapper div.
+ const wrap = document.createElement('div');
+ wrap.className = 'card-wrap'; wrap.dataset.cat = cat;
+
const a = document.createElement('a');
a.className = 'card'; a.href = c.file; a.role = 'listitem';
a.dataset.cat = cat;
@@ -272,7 +371,14 @@
body.append(badge, title, meta, blurb);
a.append(thumb, body);
- grid.appendChild(a);
+ wrap.appendChild(a);
+ if (Array.isArray(c.tags) && c.tags.length) {
+ const tagsRow = document.createElement('div');
+ tagsRow.className = 'card-tags-row';
+ tagsRow.innerHTML = tagChipsHTML(c.tags);
+ wrap.appendChild(tagsRow);
+ }
+ grid.appendChild(wrap);
}
}
@@ -283,6 +389,7 @@
});
cartoons = window.P24_CARTOONS || [];
+ renderTagUI();
render();
})();
</script>
diff --git a/cartoons/manifest.js b/cartoons/manifest.js
index f036253..c815627 100644
--- a/cartoons/manifest.js
+++ b/cartoons/manifest.js
@@ -22,6 +22,13 @@
// duplicate story. TK-12158 backfilled 9 cartoons that had shipped with
// story_id: null (6 new story entries covering them, one shared by 3
// cartoons) — do not reintroduce that gap.
+//
+// HARD RULE (TK-12165, 2026-09-24): every cartoon entry below MUST also
+// carry a non-empty `tags` array — inherit its linked story's tags
+// (window.P24_EXTRA_STORIES / window.P24_REAL_STORIES via story_id)
+// plus at most 1-2 cartoon-specific tags (e.g. "editorial cartoon",
+// "paul conrad style"). build-cartoon-batch.sh's FAIL check enforces
+// this the same way it enforces story_id.
window.P24_CARTOONS = [
{
"id": "commission-turtles",
@@ -32,7 +39,13 @@ window.P24_CARTOONS = [
"section": "politics",
"blurb": "A blue-ribbon commission recommends forming a commission to study its own recommendation. It keeps going.",
"thumb": null,
- "story_id": "pw-commission-turtles"
+ "story_id": "pw-commission-turtles",
+ "tags": [
+ "bureaucracy",
+ "city council",
+ "small town news",
+ "editorial cartoon"
+ ]
},
{
"id": "meter-that-wouldnt-yield",
@@ -43,7 +56,14 @@ window.P24_CARTOONS = [
"section": "politics",
"blurb": "Erie's most reliable parking meter is honored, seated on the council, re-elected, and sworn in as one-day mayor — and now won't leave the office.",
"thumb": null,
- "story_id": "pw-meter-street"
+ "story_id": "pw-meter-street",
+ "tags": [
+ "city council",
+ "parking",
+ "bureaucracy",
+ "small town news",
+ "editorial cartoon"
+ ]
},
{
"id": "row-by-row-only",
@@ -54,7 +74,14 @@ window.P24_CARTOONS = [
"section": "politics",
"blurb": "Kansas legislates the one true method for eating corn on the cob, deputizes Corn Marshals, and bans cob holders as cheating devices.",
"thumb": null,
- "story_id": "pw-corn-etiquette"
+ "story_id": "pw-corn-etiquette",
+ "tags": [
+ "state legislature",
+ "bureaucracy",
+ "food traditions",
+ "ordinance",
+ "editorial cartoon"
+ ]
},
{
"id": "gnome-who-wouldnt-hush",
@@ -65,7 +92,14 @@ window.P24_CARTOONS = [
"section": "politics",
"blurb": "Duluth taxes loud lawn gnomes, registers them, ankle-monitors repeat offenders, and finally convenes a Gnome Parole Board.",
"thumb": null,
- "story_id": "pw-lawn-gnome-tax"
+ "story_id": "pw-lawn-gnome-tax",
+ "tags": [
+ "lawn ornaments",
+ "city council",
+ "ordinance",
+ "bureaucracy",
+ "editorial cartoon"
+ ]
},
{
"id": "four-minutes-ahead",
@@ -76,7 +110,13 @@ window.P24_CARTOONS = [
"section": "politics",
"blurb": "Bozeman sets its town clock four minutes fast, declares its own time zone, and petitions to secede from Mountain Time entirely.",
"thumb": null,
- "story_id": "pw-clock-secession"
+ "story_id": "pw-clock-secession",
+ "tags": [
+ "city council",
+ "bureaucracy",
+ "small town news",
+ "editorial cartoon"
+ ]
},
{
"id": "casserole-succession",
@@ -87,7 +127,13 @@ window.P24_CARTOONS = [
"section": "politics",
"blurb": "Savannah imposes term limits on its ceremonial Bean Queen, the sash won't come off, and a courthouse finally settles it by casserole, not bloodline.",
"thumb": null,
- "story_id": "pw-bean-queen"
+ "story_id": "pw-bean-queen",
+ "tags": [
+ "pageantry",
+ "city council",
+ "food traditions",
+ "editorial cartoon"
+ ]
},
{
"id": "hedge-non-aggression-pact",
@@ -98,7 +144,13 @@ window.P24_CARTOONS = [
"section": "politics",
"blurb": "Washington regulates statue-shaped hedges, fines a homeowner, gets accused of hedge espionage, and signs a topiary treaty with Idaho.",
"thumb": null,
- "story_id": "pw-hedge-statue"
+ "story_id": "pw-hedge-statue",
+ "tags": [
+ "monuments",
+ "city council",
+ "ordinance",
+ "editorial cartoon"
+ ]
},
{
"id": "budget-pie-fight",
@@ -109,7 +161,13 @@ window.P24_CARTOONS = [
"section": "politics",
"blurb": "An invented town's budget pie gets re-sliced smaller across three courses while five invented departments hold their forks; drag the slider and watch who ends up with crumbs.",
"thumb": null,
- "story_id": "pw-budget-pie-slice"
+ "story_id": "pw-budget-pie-slice",
+ "tags": [
+ "budget",
+ "city council",
+ "bureaucracy",
+ "editorial cartoon"
+ ]
},
{
"id": "pork-barrel-train",
@@ -120,7 +178,14 @@ window.P24_CARTOONS = [
"section": "politics",
"blurb": "An invented infrastructure bill leaves committee as a single train car and picks up wildly unrelated riders — a desert lighthouse, a ferris wheel, a spoon museum — at every stop; click a car for its invented cost.",
"thumb": null,
- "story_id": "pw-pork-barrel-express"
+ "story_id": "pw-pork-barrel-express",
+ "tags": [
+ "state legislature",
+ "infrastructure",
+ "budget",
+ "lobbying",
+ "editorial cartoon"
+ ]
},
{
"id": "quorum-call-hide-seek",
@@ -131,7 +196,13 @@ window.P24_CARTOONS = [
"section": "politics",
"blurb": "An invented legislative chamber plays hide-and-seek to dodge a quorum call, propping paper cutouts in empty chairs while the Sergeant-at-Arms searches a suspiciously form-shaped closet; each plot twist relocates the hiding spot.",
"thumb": null,
- "story_id": "pw-quorum-hide-seek"
+ "story_id": "pw-quorum-hide-seek",
+ "tags": [
+ "state legislature",
+ "quorum tricks",
+ "bureaucracy",
+ "editorial cartoon"
+ ]
},
{
"id": "cloture-oclock",
@@ -142,7 +213,13 @@ window.P24_CARTOONS = [
"section": "politics",
"blurb": "An invented chamber's ceremonial floor clock is wound tight by one side and wound right back by the other, ticking forward and backward until it just shrugs; each plot twist changes its face.",
"thumb": null,
- "story_id": "pw-cloture-oclock"
+ "story_id": "pw-cloture-oclock",
+ "tags": [
+ "state legislature",
+ "filibuster",
+ "quorum tricks",
+ "editorial cartoon"
+ ]
},
{
"id": "tollpoints-diamond-status",
@@ -153,7 +230,13 @@ window.P24_CARTOONS = [
"section": "business",
"blurb": "Ashtabula's toll bridge rewards E-ZPass loyalty with points, gift cards, a Gold Lounge, and eventually its own currency and chartered flight.",
"thumb": null,
- "story_id": "bs-toll-loyalty"
+ "story_id": "bs-toll-loyalty",
+ "tags": [
+ "loyalty programs",
+ "infrastructure",
+ "small town news",
+ "editorial cartoon"
+ ]
},
{
"id": "jackalope-custody-battle",
@@ -164,7 +247,13 @@ window.P24_CARTOONS = [
"section": "entertainment",
"blurb": "Story City unveils a 14-foot jackalope statue; neighboring Radcliffe claims the legend, and a governor's accord ends with the statue on a biweekly trailer tour.",
"thumb": null,
- "story_id": "es-jackalope-statue-town-rivalry"
+ "story_id": "es-jackalope-statue-town-rivalry",
+ "tags": [
+ "monuments",
+ "tourism",
+ "rivalry",
+ "editorial cartoon"
+ ]
},
{
"id": "tuber-bank-of-chugwater",
@@ -175,7 +264,14 @@ window.P24_CARTOONS = [
"section": "scitech",
"blurb": "A Wyoming lab's potato-powered soil sensors outlast lithium cells, get their own custom-bred spud, and end up wired into the county's emergency power grid.",
"thumb": null,
- "story_id": "sci-battery-potato"
+ "story_id": "sci-battery-potato",
+ "tags": [
+ "agriculture",
+ "science oddity",
+ "renewable energy",
+ "research",
+ "editorial cartoon"
+ ]
},
{
"id": "the-zipper-that-ended-an-inning",
@@ -186,7 +282,13 @@ window.P24_CARTOONS = [
"section": "sports",
"blurb": "A minor-league mascot's zipper fails mid-dance, and the fallout escalates through a reinforced costume, an 11-point checklist, and a league-wide Mascot Integrity Officer.",
"thumb": null,
- "story_id": "sw-mascot-costume"
+ "story_id": "sw-mascot-costume",
+ "tags": [
+ "mascots",
+ "local sports",
+ "small town news",
+ "editorial cartoon"
+ ]
},
{
"id": "the-corner-with-its-own-weather",
@@ -197,7 +299,13 @@ window.P24_CARTOONS = [
"section": "weather",
"blurb": "A stubborn crosswind at one Montana intersection earns a windsock, a paid Wind Spotter, a proposed windbreak, and finally its own festival.",
"thumb": null,
- "story_id": "sw-persistent-crosswind"
+ "story_id": "sw-persistent-crosswind",
+ "tags": [
+ "weather anomaly",
+ "science oddity",
+ "small town news",
+ "editorial cartoon"
+ ]
},
{
"id": "lobbyist-revolving-door",
@@ -208,7 +316,13 @@ window.P24_CARTOONS = [
"section": "politics",
"blurb": "An invented capitol official spins out of the revolving door as a regulator and spins back in seconds later as a lobbyist, carrying a slightly bigger briefcase every revolution.",
"thumb": null,
- "story_id": "pw-lobbyist-revolving-door"
+ "story_id": "pw-lobbyist-revolving-door",
+ "tags": [
+ "lobbying",
+ "state legislature",
+ "bureaucracy",
+ "editorial cartoon"
+ ]
},
{
"id": "conrad-filibuster-ink",
@@ -221,7 +335,14 @@ window.P24_CARTOONS = [
"thumb": "../images/filibuster-marathon-conrad.jpg",
"story_id": "pw-filibuster-holloway",
"ai_generated": true,
- "model": "flux-kontext"
+ "model": "flux-kontext",
+ "tags": [
+ "filibuster",
+ "state legislature",
+ "bureaucracy",
+ "editorial cartoon",
+ "paul conrad style"
+ ]
},
{
"id": "conrad-filibuster-qwen25",
@@ -235,7 +356,14 @@ window.P24_CARTOONS = [
"story_id": "pw-filibuster-holloway",
"ai_generated": true,
"model": "qwen25-7b",
- "challenge_id": "56406f116d2a"
+ "challenge_id": "56406f116d2a",
+ "tags": [
+ "filibuster",
+ "state legislature",
+ "bureaucracy",
+ "editorial cartoon",
+ "paul conrad style"
+ ]
},
{
"id": "filibuster-marathon",
@@ -247,7 +375,13 @@ window.P24_CARTOONS = [
"blurb": "Delegate Holloway holds the floor of the invented Assembly by reading anything within arm's reach — a phone book, a cereal box, furniture instructions, his own bill — while a Dali-style clock melts and the chamber falls asleep standing up.",
"thumb": null,
"story_id": "pw-filibuster-holloway",
- "ai_generated": false
+ "ai_generated": false,
+ "tags": [
+ "filibuster",
+ "state legislature",
+ "bureaucracy",
+ "editorial cartoon"
+ ]
},
{
"id": "come-in-and-get-me-meter",
@@ -260,7 +394,15 @@ window.P24_CARTOONS = [
"thumb": null,
"story_id": "pw-meter-street",
"ai_generated": false,
- "style_reference": "paul-conrad"
+ "style_reference": "paul-conrad",
+ "tags": [
+ "city council",
+ "parking",
+ "bureaucracy",
+ "small town news",
+ "editorial cartoon",
+ "paul conrad style"
+ ]
},
{
"id": "row-by-row-marshal",
@@ -273,7 +415,15 @@ window.P24_CARTOONS = [
"thumb": null,
"story_id": "pw-corn-etiquette",
"ai_generated": false,
- "style_reference": "paul-conrad"
+ "style_reference": "paul-conrad",
+ "tags": [
+ "state legislature",
+ "bureaucracy",
+ "food traditions",
+ "ordinance",
+ "editorial cartoon",
+ "paul conrad style"
+ ]
},
{
"id": "gold-tier-treadmill",
@@ -286,6 +436,13 @@ window.P24_CARTOONS = [
"thumb": null,
"story_id": "bs-toll-loyalty",
"ai_generated": false,
- "style_reference": "paul-conrad"
+ "style_reference": "paul-conrad",
+ "tags": [
+ "loyalty programs",
+ "infrastructure",
+ "small town news",
+ "editorial cartoon",
+ "paul conrad style"
+ ]
}
];
diff --git a/cartoons/reference/index.html b/cartoons/reference/index.html
index e1faaec..24b0028 100644
--- a/cartoons/reference/index.html
+++ b/cartoons/reference/index.html
@@ -5,24 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Masters: Paul Conrad — P24 Political Cartoon Desk</title>
<meta name="description" content="The style reference the P24 Political Cartoon Desk draws on: Paul Conrad (1924–2010), three-time Pulitzer editorial cartoonist. His devices, creed, and works — metadata and link-out only." />
+<link rel="stylesheet" href="../../assets/style.css" />
<style>
- /* Conrad = political. Default to the Desk's "politics" red/black mood (its most
- Conrad-like palette), with the same token names as ../index.html so it feels
- native. Dark-friendly by construction. */
- :root {
- --bg: #2a0b0b; --bg2: #4a0f0f; --panel: #3a1212; --panel-border: #ff3b3b55;
- --accent: #ff3b3b; --accent2: #ffb703; --text: #ffe9e9; --text-dim: #ffd7d7aa;
- --chrome: #220808; --focus: #ffd166;
- --maxw: 1000px;
- }
- * { box-sizing: border-box; }
- body {
- margin: 0; background: var(--bg); color: var(--text);
- font-family: 'Iowan Old Style', 'Palatino Linotype', Georgia, serif;
- line-height: 1.5;
- }
- a { color: var(--accent2); }
- a:focus-visible, button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
+ /* General reference page, not tied to one category — uses the shared
+ stylesheet's default "all" mood (../../assets/style.css) instead of a
+ locally hardcoded palette, so its background matches every other P24
+ page under the "all" mood. --maxw is page-specific layout, kept local. */
+ :root { --maxw: 1000px; }
+ body { font-family: 'Iowan Old Style', 'Palatino Linotype', Georgia, serif; }
.chrome {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
background: var(--chrome); border-bottom: 3px solid var(--accent);
@@ -63,7 +53,7 @@
@media (max-width: 620px) { dl.style-grid { grid-template-columns: 1fr; gap: 2px 0; } dl.style-grid dt { margin-top: 8px; } }
</style>
</head>
-<body>
+<body data-mood="all">
<header class="chrome">
<span class="logo-badge" aria-hidden="true">P24</span>
<h1>Masters: Paul Conrad</h1>
diff --git a/index.html b/index.html
index 92f72a9..d1dc142 100644
--- a/index.html
+++ b/index.html
@@ -208,6 +208,16 @@
<span class="mood-readout" id="moodReadout">Channel mood: Calm-ish (Default)</span>
</div>
+ <!-- Active tag filter indicator (#tag=<tag> route) — "Tagged: X ✕". -->
+ <div id="tagFilterBar" class="tag-filter-bar" hidden>
+ <span class="tag-filter-label">Tagged: <strong id="tagFilterName"></strong></span>
+ <a href="#/" id="tagFilterClear" class="tag-filter-clear" aria-label="Clear tag filter">✕ Clear</a>
+ </div>
+
+ <!-- Tag cloud — top tags with counts, click to filter. Hidden while a
+ tag filter is already active (the bar above takes its place). -->
+ <div id="tagCloud" class="tag-cloud" aria-label="Browse by tag"></div>
+
<div id="noStoriesState" class="empty-state" hidden>
<span class="es-emoji" aria-hidden="true">📡</span>
<h3>No stories currently on the network.</h3>
@@ -445,6 +455,7 @@ const FEATURES = [
byline: "Warren Otsby, Contributing Columnist",
publishedLabel: "Filed 2 hours ago",
headline: "Opinion: As Someone Personally Affected By Time, I Have Some Concerns About Flexday",
+ tags: ["opinion", "bureaucracy", "small town news"],
article: {
dek: "A concerned citizen weighs in on the ongoing weekday crisis, using only his own feelings as evidence.",
photoCaption: "The author, pictured shortly after being wrong about something.",
@@ -466,6 +477,7 @@ const FEATURES = [
byline: "Renata Suk, Staff Writer",
publishedLabel: "Filed yesterday",
headline: "Local Man Achieves Personal Best, Parallel Parks in Only Four Attempts",
+ tags: ["parking", "small town news", "community event"],
article: {
dek: "Witnesses describe the fourth attempt as 'the good one.'",
photoCaption: "Panzer's Civic, seen here at a respectful, glancing angle.",
@@ -487,6 +499,7 @@ const FEATURES = [
byline: "Marisol Feng, Consumer Affairs Desk",
publishedLabel: "Filed 3 days ago",
headline: "Consumer Report: New Cereal Marketed Directly to People Who Already Feel Guilty",
+ tags: ["consumer culture", "retail", "food traditions"],
article: {
dek: "Fiber-Plus Redemption Flakes promises 'a second chance, in bowl form.'",
photoCaption: "Fiber-Plus Redemption Flakes, pictured next to a spoon it did not ask to be judged by.",
@@ -508,6 +521,7 @@ const FEATURES = [
byline: "Theo Marsh, Arts & Culture",
publishedLabel: "Filed this weekend",
headline: "Local Community Theater's Feline-Themed Musical Enters Third Unplanned Encore",
+ tags: ["community event", "local theater", "small town news"],
article: {
dek: "Audience members describe the second encore as 'the one we thought was the last one.'",
photoCaption: "The cast of 'Whisker & Bone,' mid-ascension.",
@@ -649,6 +663,7 @@ let STORIES = stampLastUpdated(loadStories());
/* ---------- B. State ---------- */
const state = {
category: "all",
+ tag: null,
breaking: false,
soundOn: false,
elapsedSec: 0,
@@ -705,6 +720,10 @@ const els = {
channelHeading: document.getElementById("channelHeading"),
categoryBackLink: document.getElementById("categoryBackLink"),
categoryBlurb: document.getElementById("categoryBlurb"),
+ tagFilterBar: document.getElementById("tagFilterBar"),
+ tagFilterName: document.getElementById("tagFilterName"),
+ tagFilterClear: document.getElementById("tagFilterClear"),
+ tagCloud: document.getElementById("tagCloud"),
featuresGrid: document.getElementById("featuresGrid"),
featuresEmptyNote: document.getElementById("featuresEmptyNote"),
chaosRow: document.getElementById("chaosRow"),
@@ -738,9 +757,96 @@ function drainAnnounce() {
});
}
+/* ---------- C0. Tags (TK-12165) ----------
+ Every post (breaking story, feature, cartoon) carries a `tags` array.
+ Clicking a tag chip navigates to #tag=<tag> — a route independent of the
+ category filter, so a tag search spans every category at once. The tag
+ itself is normalized (trimmed, lowercased) for matching/URLs, but the
+ ORIGINAL casing from the data is what's displayed in chips/labels. */
+function normalizeTag(t) {
+ return String(t == null ? "" : t).trim().toLowerCase();
+}
+
+function tagHref(tag) {
+ return `#tag=${encodeURIComponent(normalizeTag(tag))}`;
+}
+
+function getTagRoute() {
+ const m = location.hash.match(/^#tag=(.+)$/);
+ if (!m) return null;
+ try {
+ return normalizeTag(decodeURIComponent(m[1]));
+ } catch (e) {
+ return null;
+ }
+}
+
+// Tag chips shared by story cards, feature cards, and the article view.
+// `active` (the currently-filtered tag, if any) gets a distinguishing class
+// so a card matching the active filter shows which tag matched.
+function tagChipsHTML(tags, opts) {
+ opts = opts || {};
+ if (!Array.isArray(tags) || !tags.length) return "";
+ const cls = opts.className || "tag-chips";
+ return `
+ <ul class="${cls}" aria-label="Tags">
+ ${tags.map((t) => `<li><a href="${tagHref(t)}" class="tag-chip${normalizeTag(t) === state.tag ? " active" : ""}">${escapeHTML(t)}</a></li>`).join("")}
+ </ul>
+ `;
+}
+
+// Every post that can appear on the front page/article view, tags-eligible —
+// used for both the tag filter itself and the tag-cloud counts, so the
+// cloud's counts always match what a click on that tag will actually show.
+function allTaggablePosts() {
+ return [...STORIES, ...FEATURES];
+}
+
+function computeTagCounts() {
+ const counts = new Map(); // normalized tag -> { label, count }
+ for (const post of allTaggablePosts()) {
+ if (!Array.isArray(post.tags)) continue;
+ for (const t of post.tags) {
+ const key = normalizeTag(t);
+ if (!key) continue;
+ const entry = counts.get(key) || { label: t, count: 0 };
+ entry.count++;
+ counts.set(key, entry);
+ }
+ }
+ return counts;
+}
+
+function renderTagFilterBar() {
+ if (state.tag) {
+ els.tagFilterBar.hidden = false;
+ els.tagFilterName.textContent = state.tag;
+ els.tagCloud.hidden = true;
+ } else {
+ els.tagFilterBar.hidden = true;
+ els.tagCloud.hidden = false;
+ }
+}
+
+function renderTagCloud() {
+ if (state.tag) { els.tagCloud.innerHTML = ""; return; }
+ const counts = [...computeTagCounts().values()]
+ .filter((e) => e.count > 1) // a tag used once adds noise, not a useful browse path
+ .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
+ .slice(0, 20);
+ if (!counts.length) { els.tagCloud.innerHTML = ""; return; }
+ els.tagCloud.innerHTML = `
+ <span class="tag-cloud-label">Browse by tag:</span>
+ <ul class="tag-cloud-list">
+ ${counts.map((e) => `<li><a href="${tagHref(e.label)}" class="tag-chip">${escapeHTML(e.label)} <span class="tag-count">${e.count}</span></a></li>`).join("")}
+ </ul>
+ `;
+}
+
function visibleStories() {
- if (state.category === "all") return STORIES;
- return STORIES.filter((s) => s.category === state.category);
+ let list = state.category === "all" ? STORIES : STORIES.filter((s) => s.category === state.category);
+ if (state.tag) list = list.filter((s) => Array.isArray(s.tags) && s.tags.some((t) => normalizeTag(t) === state.tag));
+ return list;
}
function computeLead(list) {
@@ -806,6 +912,7 @@ function storyCardHTML(story, isLead) {
<span>🖋 ${escapeHTML(story.byline)}</span>
<span>🕒 Updated ${escapeHTML(story.lastUpdated || "—")}</span>
</p>
+ ${tagChipsHTML(story.tags, { className: "tag-chips card-tags" })}
<a class="story-read-more" href="#/article/${escapeHTML(story.id)}" data-open-id="${escapeHTML(story.id)}">Read full article →</a>
</article>
`;
@@ -1782,13 +1889,15 @@ function featureCardHTML(feature) {
<span>🖋 ${escapeHTML(feature.byline)}</span>
<span>🕒 ${escapeHTML(feature.publishedLabel || "")}</span>
</p>
+ ${tagChipsHTML(feature.tags, { className: "tag-chips card-tags" })}
<a class="story-read-more" href="#/article/${escapeHTML(feature.id)}" data-open-id="${escapeHTML(feature.id)}">Read full article →</a>
</article>
`;
}
function renderFeatures() {
- const list = state.category === "all" ? FEATURES : FEATURES.filter((f) => f.category === state.category);
+ let list = state.category === "all" ? FEATURES : FEATURES.filter((f) => f.category === state.category);
+ if (state.tag) list = list.filter((f) => Array.isArray(f.tags) && f.tags.some((t) => normalizeTag(t) === state.tag));
if (!list.length) {
els.featuresGrid.hidden = true;
els.featuresEmptyNote.hidden = false;
@@ -1907,6 +2016,7 @@ function renderArticleContent(source) {
<p class="badge-cat article-cat-badge">${escapeHTML(source.categoryLabel)}</p>
<h2 class="article-headline">${escapeHTML(headline)}</h2>
${art.dek ? `<p class="article-dek">${escapeHTML(art.dek)}</p>` : ""}
+ ${tagChipsHTML(source.tags, { className: "tag-chips article-tags" })}
<p class="article-meta-row">
<span class="article-dateline">${escapeHTML(dateline)}</span>
<span>By ${escapeHTML(source.byline || "P24 Staff")}</span>
@@ -2009,22 +2119,33 @@ function renderRoute(opts) {
els.articleView.hidden = true;
els.gridView.hidden = false;
state.currentRouteId = null;
- const requested = getCategoryRoute();
+ // A tag route (#tag=<tag>) spans every category — it takes over from
+ // the category route entirely rather than combining with it, so
+ // "Tagged: parking" always means every parking post, not just the ones
+ // in whichever category chip happened to be active before.
+ const tagRoute = getTagRoute();
+ const prevTag = state.tag;
+ state.tag = tagRoute || null;
+ const requested = tagRoute ? null : 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");
+ const changed = state.category !== (key || "all") || state.tag !== prevTag;
state.category = key || "all";
syncCategoryRadio(state.category);
renderCategoryBanner(key);
+ renderTagFilterBar();
+ renderTagCloud();
updateMoodUI();
renderStories();
renderFeatures();
document.body.dataset.mood = state.category;
- if (changed) {
+ if (tagRoute) {
+ if (changed) announce(`Now viewing posts tagged "${tagRoute}."`);
+ } else if (changed) {
announce(key
? `Now viewing ${CATEGORY_LABELS[key]}. Channel mood: ${MOODS[key].label}.`
: "Now viewing All Categories. Channel mood: Calm-ish (Default).");
diff --git a/real-news-data.js b/real-news-data.js
index e86cc97..4303639 100644
--- a/real-news-data.js
+++ b/real-news-data.js
@@ -2,6 +2,11 @@
// Google News RSS (no API key, $0). Do not hand-edit; re-run the script to
// refresh. Each story is single-stage (real news doesn't escalate) and links
// out to its real source article. Generated 2026-09-24T17:37:30.953Z.
+//
+// HARD RULE (TK-12165, 2026-09-24): every entry MUST carry a non-empty
+// `tags` array — see scripts/lib/auto-tag-real-news.mjs, which this
+// file's generator (fetch-real-news.mjs) calls on every refresh so a
+// future run can never write an untagged entry.
window.P24_REAL_STORIES = [
{
"id": "real-politics-1",
@@ -29,6 +34,11 @@ window.P24_REAL_STORIES = [
"headline": "Politics latest: Record number of small boat migrants for a single day in 2026 cross Channel",
"detail": "Via Sky News, Sep 21, 2026, 2:00 PM."
}
+ ],
+ "tags": [
+ "politics",
+ "sky news",
+ "immigration"
]
},
{
@@ -57,6 +67,12 @@ window.P24_REAL_STORIES = [
"headline": "Mary Peltola’s Campaign in Alaska Senate Race Is Rocked by Tumult, With Angry Outbursts and Slurs",
"detail": "Via The New York Times, Sep 23, 2026, 5:34 PM."
}
+ ],
+ "tags": [
+ "politics",
+ "the new york times",
+ "elections",
+ "congress"
]
},
{
@@ -85,6 +101,12 @@ window.P24_REAL_STORIES = [
"headline": "Why the Kansas Senate race is surprisingly competitive",
"detail": "Via CNN, Sep 23, 2026, 5:05 PM."
}
+ ],
+ "tags": [
+ "politics",
+ "cnn",
+ "elections",
+ "congress"
]
},
{
@@ -113,6 +135,12 @@ window.P24_REAL_STORIES = [
"headline": "Cook Political Report shifts Kansas, South Carolina Senate races toward Democrats",
"detail": "Via The Hill, Sep 23, 2026, 6:30 AM."
}
+ ],
+ "tags": [
+ "politics",
+ "the hill",
+ "elections",
+ "congress"
]
},
{
@@ -141,6 +169,11 @@ window.P24_REAL_STORIES = [
"headline": "See the moment White House reporter gets access",
"detail": "Via CNN, Sep 24, 2026, 9:44 AM."
}
+ ],
+ "tags": [
+ "politics",
+ "cnn",
+ "white house"
]
},
{
@@ -169,6 +202,11 @@ window.P24_REAL_STORIES = [
"headline": "FAA: No political interference in Trump arch review",
"detail": "Via The Hill, Sep 24, 2026, 10:16 AM."
}
+ ],
+ "tags": [
+ "politics",
+ "the hill",
+ "aviation"
]
},
{
@@ -197,6 +235,12 @@ window.P24_REAL_STORIES = [
"headline": "Nor'easter bringing flooding, strong winds from NC to New England",
"detail": "Nor'easter bringing flooding, strong winds from NC to New England The Weather Channel • East Coast eyes strengthening nor’easter already battering Mid-Atlantic shores CNN • First Alert Weather: Calm before the coastal storm CBS News"
}
+ ],
+ "tags": [
+ "weather",
+ "the weather channel",
+ "storms",
+ "flooding"
]
},
{
@@ -225,6 +269,12 @@ window.P24_REAL_STORIES = [
"headline": "Nor’easter to bring major coastal flooding; potential for hurricane-force gusts in New England",
"detail": "Via AccuWeather, Sep 24, 2026, 2:18 AM."
}
+ ],
+ "tags": [
+ "weather",
+ "accuweather",
+ "storms",
+ "flooding"
]
},
{
@@ -253,6 +303,11 @@ window.P24_REAL_STORIES = [
"headline": "Jo Koy delivers morning weather forecast on ABC7",
"detail": "Via ABC7 Los Angeles, Sep 24, 2026, 10:23 AM."
}
+ ],
+ "tags": [
+ "weather",
+ "abc7",
+ "forecast"
]
},
{
@@ -281,6 +336,11 @@ window.P24_REAL_STORIES = [
"headline": "WEATHER: Overcast and damp",
"detail": "Via kare11.com, Sep 24, 2026, 10:19 AM."
}
+ ],
+ "tags": [
+ "weather",
+ "kare 11",
+ "forecast"
]
},
{
@@ -309,6 +369,11 @@ window.P24_REAL_STORIES = [
"headline": "What is a 'super El Niño' and how will it affect the weather?",
"detail": "Via Reuters, Sep 24, 2026, 2:34 AM."
}
+ ],
+ "tags": [
+ "weather",
+ "reuters",
+ "climate patterns"
]
},
{
@@ -337,6 +402,13 @@ window.P24_REAL_STORIES = [
"headline": "Hawaii braces for life-threatening flooding again as El Niño supercharges Pacific hurricane season",
"detail": "Via CNN, Sep 23, 2026, 9:46 AM."
}
+ ],
+ "tags": [
+ "weather",
+ "cnn",
+ "storms",
+ "flooding",
+ "climate patterns"
]
},
{
@@ -365,6 +437,12 @@ window.P24_REAL_STORIES = [
"headline": "Zuckerberg says Meta will lead on smartglasses privacy despite ‘pervert glasses’ criticism",
"detail": "Zuckerberg says Meta will lead on smartglasses privacy despite ‘pervert glasses’ criticism NBC News • Introducing Ray-Ban Meta Audio and More AI Glasses Styles meta.com • Meta’s New AI Device Is This Tamagotchi-Like Muse Charm Keychain cnet.com • Here's what Wall Street thinks of Meta's new smartglass lineup and first Muse 'holdable' Yahoo Finance • Introducing Meta VR Glasses, A New Era for Virtual Reality meta.com"
}
+ ],
+ "tags": [
+ "sci-tech",
+ "nbc news",
+ "consumer tech",
+ "meta"
]
},
{
@@ -393,6 +471,11 @@ window.P24_REAL_STORIES = [
"headline": "Pixel 11 starts testing ‘Call for Me’ to have Gemini phone on your behalf",
"detail": "Pixel 11 starts testing ‘Call for Me’ to have Gemini phone on your behalf 9to5Google • Google’s Gemini Can Now Make Calls for You on Pixel Phones WIRED • Gemini can now call businesses for you so you don’t have to wait on hold The Verge • Google tests letting Gemini call businesses for you TechCrunch • Pixel 11's Call For Me Feature Begins Preliminary Rollout Engadget"
}
+ ],
+ "tags": [
+ "sci-tech",
+ "9to5google",
+ "google"
]
},
{
@@ -421,6 +504,11 @@ window.P24_REAL_STORIES = [
"headline": "Nikon unveils Z5IIC camera, aimed at making photography less intimidating",
"detail": "Nikon unveils Z5IIC camera, aimed at making photography less intimidating Mashable • You might not miss what Nikon removed from its cheaper full-frame Z5 IIC The Verge • Nikon releases the Z5IIC full-frame mirrorless camera Nikon • Nikon Z5 IIC Review: Smaller, Prettier, and Mostly Capable PetaPixel • Nikon Made a $1,400 Full-Frame Camera for Gen Z and I Might Be Too Old for It pcmag.com"
}
+ ],
+ "tags": [
+ "sci-tech",
+ "mashable",
+ "cameras"
]
},
{
@@ -449,6 +537,11 @@ window.P24_REAL_STORIES = [
"headline": "The Apple Watch Has a Hidden New Setting You Probably Didn’t Know About",
"detail": "The Apple Watch Has a Hidden New Setting You Probably Didn’t Know About gearpatrol.com • These Are the Best Apple Watch Ultra 4 Deals You Can Shop Today cnet.com • The new Apple Watch Series 12 just landed at Target, and it’s made for your everyday routine Syracuse.com • Apple Watch Series 10 vs. Series 12 Buyer's Guide: Should You Upgrade? MacRumors • I Tested the New Apple Ultra 4’s Readiness Score and Battery Life. Here’s Why It's Worth the Upgrade. Outside Magazine"
}
+ ],
+ "tags": [
+ "sci-tech",
+ "gear patrol",
+ "apple"
]
},
{
@@ -477,6 +570,11 @@ window.P24_REAL_STORIES = [
"headline": "Brand-New M6 Mac mini Starts at $879.99 at Amazon",
"detail": "Brand-New M6 Mac mini Starts at $879.99 at Amazon AppleInsider • Nvidia Slips as Four Macs Attack Cloud Inference Economics Yahoo Finance • Apple M6 Mac mini review: $300 price hike spoils a nice upgrade arstechnica.com • The M5 Ultra Mac Studio is so insane it’s worth waiting 4 months to get one Macworld • The new Mac mini and Mac Studio are available today Apple"
}
+ ],
+ "tags": [
+ "sci-tech",
+ "appleinsider",
+ "apple"
]
},
{
@@ -505,6 +603,12 @@ window.P24_REAL_STORIES = [
"headline": "'The Best-Sounding Headphones We've Ever Built': Beats President Talks Beats 360, Customization, And Apple",
"detail": "'The Best-Sounding Headphones We've Ever Built': Beats President Talks Beats 360, Customization, And Apple bgr.com • I tried the new Beats 360 headphones. Here's what I liked USA Today • Beats Announces Lightweight Headphones With Customizable Design Bloomberg.com • The new Beats headphones are made for workouts The Verge • Beats 360 Vs Sony WH-1000XM6: How These High-End Wireless Headphones Compare Engadget"
}
+ ],
+ "tags": [
+ "sci-tech",
+ "bgr",
+ "apple",
+ "audio gear"
]
},
{
@@ -533,6 +637,11 @@ window.P24_REAL_STORIES = [
"headline": "Matthew Golden surging, O-line concerns grow for Packers ahead of game vs. Falcons - The Athletic",
"detail": "Matthew Golden surging, O-line concerns grow for Packers ahead of game vs. Falcons - The Athletic The New York Times • Falcons and Packers both searching for answers heading into 'Thursday Night Football' in NFL Week 3 Fox News • Falcons-Packers predictions for Thursday night, plus NFL Week 3 picks and ranking 2-0 teams CBS Sports • How to bet Falcons-Packers on TNF: Analysis, tips and top plays ESPN • Packers Week 3 Predictions: Will Green Bay cover against Falcons on Thursday? Yahoo Sports"
}
+ ],
+ "tags": [
+ "sports",
+ "the new york times",
+ "nfl"
]
},
{
@@ -561,6 +670,11 @@ window.P24_REAL_STORIES = [
"headline": "Week 4 college football viewer's guide: Loaded Saturday slate features 6 games between ranked teams, including Texas at Tennessee",
"detail": "Week 4 college football viewer's guide: Loaded Saturday slate features 6 games between ranked teams, including Texas at Tennessee Yahoo Sports • The Six Pack: Picks for Florida vs. Ole Miss, USC vs. Oregon and Tom Fornelli's lock of Week 4 CBS Sports • College football picks ATS Week 4: Texas-Tennessee, Ole Miss-Florida and more - The Athletic The New York Times • College football Week 4 best bets: Plays to make on Missouri, Indiana, Ole Miss and more ESPN • College football TV schedule: Watchability tiers for Week 4's best games USA Today"
}
+ ],
+ "tags": [
+ "sports",
+ "yahoo sports",
+ "college football"
]
},
{
@@ -589,6 +703,11 @@ window.P24_REAL_STORIES = [
"headline": "NFL news, live updates: Falcons QB Michael Penix Jr. to make season debut on Thursday Night Football; Jameis Winston to start for Giants after season-ending injury to Jaxson Dart",
"detail": "NFL news, live updates: Falcons QB Michael Penix Jr. to make season debut on Thursday Night Football; Jameis Winston to start for Giants after season-ending injury to Jaxson Dart Yahoo Sports • NFL Thursday Night Football preview: Michael Penix Jr. returns for Falcons, who face similarly desperate Packers Yahoo Sports • Falcons to start QB Michael Penix on Thursday vs. Packers ESPN • Can Michael Penix save the Falcons season? Falcoholic Live, Ep390 The Falcoholic • Michael Penix Jr. set to return to the field for Falcons: ‘I’m where I want to be’ - The Athletic The New York Times"
}
+ ],
+ "tags": [
+ "sports",
+ "yahoo sports",
+ "nfl"
]
},
{
@@ -617,6 +736,11 @@ window.P24_REAL_STORIES = [
"headline": "A first for Ben Johnson at quarterback and more lessons from the 5 biggest NFL injuries",
"detail": "A first for Ben Johnson at quarterback and more lessons from the 5 biggest NFL injuries The New York Times • Next man up: Ranking the NFL's backup quarterbacks for 6 teams that have had QB1 chaos Yahoo Sports • An Epidemic of Quarterback Injuries Is Already Rewriting the NFL Season WSJ • Resetting expectations for these six NFL teams with injured quarterbacks: Giants eyeing draft again CBS Sports • 10 Thoughts on the NFL: Injuries suck windycitygridiron.com"
}
+ ],
+ "tags": [
+ "sports",
+ "the new york times",
+ "nfl"
]
},
{
@@ -645,6 +769,11 @@ window.P24_REAL_STORIES = [
"headline": "Fury vs Joshua: Cardiff to host heavyweight fight on 11 December",
"detail": "Fury vs Joshua: Cardiff to host heavyweight fight on 11 December BBC • Tyson Fury vs. Anthony Joshua confirmed for Dec. 11 in Cardiff ESPN • Fury vs Joshua 2am start time an insult to British boxing fans The Telegraph • Fury vs Joshua: The ‘wild’ inside story of how a $200m megafight was made The New York Times • Fury vs Joshua finally official: All you need to know Yahoo"
}
+ ],
+ "tags": [
+ "sports",
+ "bbc",
+ "combat sports"
]
},
{
@@ -673,6 +802,11 @@ window.P24_REAL_STORIES = [
"headline": "Why Arch Manning never seriously considered Tennessee despite Peyton Manning's legacy",
"detail": "Why Arch Manning never seriously considered Tennessee despite Peyton Manning's legacy USA Today • Josh Heupel explains why Tennessee football didn't get Arch Manning over Texas Knoxville News Sentinel • Fox & ESPN Analysts Growing Frustrated With Arch Manning Despite Efforts to Change His Off-Field Behavior: “Not Good at All” Yahoo Sports • Accuracy, processing issues defined Arch Manning’s performance against UTSA Burnt Orange Nation • The Insider: The experts assess Arch Manning through 18 starts and Texas hoops' payroll north of $20 million 247Sports"
}
+ ],
+ "tags": [
+ "sports",
+ "usa today",
+ "game day"
]
},
{
@@ -701,6 +835,11 @@ window.P24_REAL_STORIES = [
"headline": "Macklemore Announces ‘Free Palestine’ Tour With All Ticket Sales Being Donated to Charity",
"detail": "Macklemore Announces ‘Free Palestine’ Tour With All Ticket Sales Being Donated to Charity Variety See more headlines & perspectives on Google News"
}
+ ],
+ "tags": [
+ "entertainment",
+ "variety",
+ "music"
]
},
{
@@ -729,6 +868,11 @@ window.P24_REAL_STORIES = [
"headline": "Taylor Swift's 'Patient Zero' Music Video To Debut At VMAs",
"detail": "Taylor Swift's 'Patient Zero' Music Video To Debut At VMAs Deadline • Taylor Swift Ditches Her Signature Glam and Red Lip for Surprising Makeunder in ‘Patient Zero’ Music Video People.com • Taylor Swift to debut \"Patient Zero\" music video at Sunday's VMAs. Watch an exclusive preview. CBS News • Taylor Swift announces release of 4 new songs Good Morning America • Taylor Swift’s ‘Patient Zero’ Music Video, Starring Dakota Johnson and Colin Farrell, to Debut at VMAs Variety"
}
+ ],
+ "tags": [
+ "entertainment",
+ "deadline",
+ "music"
]
},
{
@@ -757,6 +901,11 @@ window.P24_REAL_STORIES = [
"headline": "'Survivor 51' cast: Meet the 20 castaways battling to win after last night's elimination",
"detail": "'Survivor 51' cast: Meet the 20 castaways battling to win after last night's elimination Yahoo • Jeff Probst Made ‘Survivor’ His Life’s Work. He’s Not Done Yet. The New York Times • Who was voted off 'Survivor' tonight? Recap of Season 51 premiere Yahoo • 'Survivor' begins new 'Open Era' with Season 51. How to watch USA Today • ‘Survivor’ castoff Aaliyah Puglia on redefining the ‘first boot narrative’ and the ‘sassy Jeff’ moment you didn’t see on TV"
}
+ ],
+ "tags": [
+ "entertainment",
+ "yahoo",
+ "reality tv"
]
},
{
@@ -785,6 +934,11 @@ window.P24_REAL_STORIES = [
"headline": "Paramount Merger Delayed As Judge OKs Motion To Block Antitrust Settlement",
"detail": "Paramount Merger Delayed As Judge OKs Motion To Block Antitrust Settlement Deadline • Opinion | Hollywood Surrenders to the Ellison Empire The New York Times • L.A. Comes to Grips With a Studio Merger Many Didn’t Want WSJ • Paramount to Raise $7.5 Billion More in Debt to Fund Warner Bros. Deal Variety • Paramount-WBD Merger Hits a Delay as Judge Grants Last-Ditch Effort IndieWire"
}
+ ],
+ "tags": [
+ "entertainment",
+ "deadline",
+ "corporate mergers"
]
},
{
@@ -813,6 +967,11 @@ window.P24_REAL_STORIES = [
"headline": "U2 Announce New Album, ‘Carnaval de Luz,’ Out Nov. 13",
"detail": "U2 Announce New Album, ‘Carnaval de Luz,’ Out Nov. 13 Rolling Stone • U2 announce new album Carnaval de Luz, featuring final recording by Dolly Parton The Guardian • Inside Dolly Parton’s Final Recording — A Duet With Bono on the New U2 Album Rolling Stone • U2’s First Album in Nine Years, ‘Carnaval De Luz,’ Will Feature a Dolly Parton Duet With Bono, Said to Be Her Last Recorded Vocal Variety • U2 to Mark 50 Years As a Band With New Album ‘Carnaval De Luz,’ Featuring Dolly Parton’s Final Vocals Yahoo"
}
+ ],
+ "tags": [
+ "entertainment",
+ "rolling stone",
+ "music"
]
},
{
@@ -841,6 +1000,11 @@ window.P24_REAL_STORIES = [
"headline": "Britney Spears, Kevin Federline's Son Jayden James Details Pressure to Prove Himself in Rare Interview",
"detail": "Britney Spears, Kevin Federline's Son Jayden James Details Pressure to Prove Himself in Rare Interview E! News • Britney Spears’ Son Jayden James On Being Chip Off the Pop Block: ‘Expressing Myself Through Music Has Always Been My Best Therapy’ Yahoo • What Does It Feel Like to Be Jayden James? interviewmagazine.com • Jayden James reveals questions he wants people to stop Minute Mirror • Britney Spears’ Son Admits He Feels Like He Always Has ‘Something to Prove’ Reality Tea"
}
+ ],
+ "tags": [
+ "entertainment",
+ "e! news",
+ "pop culture"
]
},
{
@@ -869,6 +1033,11 @@ window.P24_REAL_STORIES = [
"headline": "Mortgage Rates Hit 7% as Iran War Fallout Crushes a Weak Housing Market",
"detail": "Mortgage Rates Hit 7% as Iran War Fallout Crushes a Weak Housing Market The New York Times • Should you buy a home when rates are over 7%? Here are the best lenders for finding a deal CNBC • Mortgage rates climb for 5th straight week, pushing average rate on a 30-year home loan above 7% AP News • Mortgage rates top 7%, dealing a further blow to the frozen housing market | CNN Business CNN • Mortgage rates have just surpassed 7% for the first time in well over a year NPR"
}
+ ],
+ "tags": [
+ "business",
+ "the new york times",
+ "housing market"
]
},
{
@@ -897,6 +1066,12 @@ window.P24_REAL_STORIES = [
"headline": "S&P 500 bounces slightly on report U.S. and Iran discussing reopening of Strait of Hormuz: Live updates",
"detail": "S&P 500 bounces slightly on report U.S. and Iran discussing reopening of Strait of Hormuz: Live updates CNBC • 30-year Treasury yield hits highest level since 2004 as bond market rout continues CNBC • Stock Market Today: Dow, S&P 500 and Nasdaq fall as investors anticipate further Fed rate hikes; 10-year Treasury yield remains above 5%; Trump meets with Xi marketwatch.com • Market Indexes Fall Again as the 30-Year Yield Hits a 2004 High Yahoo Finance • Update: US Equity Indexes Drop as 30-Year Treasury Yield Surpasses Two-Decade High Amid Surging Crude Oil Prices Yahoo Finance"
}
+ ],
+ "tags": [
+ "business",
+ "cnbc",
+ "stock market",
+ "energy markets"
]
},
{
@@ -925,6 +1100,11 @@ window.P24_REAL_STORIES = [
"headline": "Starbucks to close 250 stores: 'Difficult decision'",
"detail": "Starbucks to close 250 stores: 'Difficult decision' Fox Business • Beloved coffee chain makes ‘difficult decision’ to close 250 locations in North America MLive.com • Starbucks to shutter 250 stores in North America this week in 2nd wave of closings AP News • Starbucks to close 250 underperforming stores The Seattle Times • Starbucks to close 250 stores this week Yahoo Finance"
}
+ ],
+ "tags": [
+ "business",
+ "fox business",
+ "markets"
]
},
{
@@ -953,6 +1133,11 @@ window.P24_REAL_STORIES = [
"headline": "Oracle cites 'force majeure' to shield itself on data center, Bloomberg News reports",
"detail": "Oracle cites 'force majeure' to shield itself on data center, Bloomberg News reports Reuters • Oracle Cites ‘Force Majeure’ to Shield Itself on Controversial Data Center Bloomberg.com • Here’s Why Oracle Stock Is Dropping Today Forbes • Oracle’s $18bn data centre debt under strain amid local pushback Financial Times • Oracle’s Project Jupiter Loans Are Trading at 90 Cents. Here’s What the Q1 Numbers Say TIKR.com"
}
+ ],
+ "tags": [
+ "business",
+ "reuters",
+ "space & data centers"
]
},
{
@@ -981,6 +1166,11 @@ window.P24_REAL_STORIES = [
"headline": "New York sues prediction market Polymarket, calling it an unlicensed gambling operation",
"detail": "New York sues prediction market Polymarket, calling it an unlicensed gambling operation AP News • New York sues Polymarket U.S., two months after filing lawsuit against Kalshi CNBC • New York Attorney General Letitia James files suit against Polymarket ABC News - Breaking News, Latest News and Videos • New York lawsuit says Polymarket's prediction markets are illegal gambling Reuters • New York Accuses Polymarket US of Running ‘Unlicensed Gambling’ Bloomberg.com"
}
+ ],
+ "tags": [
+ "business",
+ "ap news",
+ "prediction markets"
]
},
{
@@ -1009,6 +1199,12 @@ window.P24_REAL_STORIES = [
"headline": "Google’s first Suncatcher orbital data center test launches October 1",
"detail": "Google’s first Suncatcher orbital data center test launches October 1 arstechnica.com • Google Is Sending an A.I. Data Center to Outer Space The New York Times • Behind Project Suncatcher, our moonshot to put AI in space blog.google • Google is sending an AI satellite into space next week The Verge • Google plans first test of AI chips in space under Project Suncatcher Reuters"
}
+ ],
+ "tags": [
+ "business",
+ "ars technica",
+ "google",
+ "space & data centers"
]
},
{
@@ -1037,6 +1233,11 @@ window.P24_REAL_STORIES = [
"headline": "Saudi Arabia thwarts Houthi ballistic missiles, Yemen's Saudi-led coalition says",
"detail": "Saudi Arabia thwarts Houthi ballistic missiles, Yemen's Saudi-led coalition says Reuters • Saudi-led coalition says 6 ballistic missiles are intercepted and blames Houthi rebels AP News • Saudi-led coalition says shot down 6 ballistic missiles launched by Houthis Al Jazeera • Saudi Arabia Downs Houthi Missiles Targeting Kingdom, Official Says The New York Times • Another \"Potential Danger\" Warning For Mecca, 2nd Time In A Month NDTV"
}
+ ],
+ "tags": [
+ "uncategorized",
+ "reuters",
+ "military conflict"
]
},
{
@@ -1065,6 +1266,11 @@ window.P24_REAL_STORIES = [
"headline": "Police in Poland Arrest Ukrainian Citizen Over Fatal Stabbing",
"detail": "Police in Poland Arrest Ukrainian Citizen Over Fatal Stabbing The New York Times • Ukrainian knifeman kills one, wounds four at abbey in Poland Al Jazeera • Knifeman kills priest, injures four in attack at Polish abbey Reuters • Priest Killed and 4 Others Injured in Monastery Knife Attack During Mass: ‘Total Panic’ People.com • 1 dead, 4 injured in Catholic convent stabbing in Poland AL.com"
}
+ ],
+ "tags": [
+ "uncategorized",
+ "the new york times",
+ "crime"
]
},
{
@@ -1093,6 +1299,11 @@ window.P24_REAL_STORIES = [
"headline": "Ethiopia's army says it has repelled TPLF attacks in first comment on fresh Tigray fighting",
"detail": "Ethiopia's army says it has repelled TPLF attacks in first comment on fresh Tigray fighting BBC • A disastrous new war threatens in Africa The Economist • Renewed fighting in northern Ethiopia effectively shatters 2022 peace deal that ended war Los Angeles Times • Fighting widens across Ethiopia as Tigray clashes escalate Al Jazeera • Ethiopia’s last civil war killed over half a million. Another may be starting. The Washington Post"
}
+ ],
+ "tags": [
+ "uncategorized",
+ "bbc",
+ "world news"
]
},
{
@@ -1121,6 +1332,11 @@ window.P24_REAL_STORIES = [
"headline": "Hitler's possible role in niece's death under scrutiny after documents raise questions about alibi, report says",
"detail": "Hitler's possible role in niece's death under scrutiny after documents raise questions about alibi, report says CBS News • Hitler may have murdered his niece, new evidence suggests The Telegraph • Did Hitler kill his niece? New documents challenge alibi in Geli Raubal’s mysterious death Ynetnews • Hitler ‘lied’ about alibi as new clues expose niece's time of death and 'cover up' Daily Star • Did Hitler kill his niece? The evidence examined The Times"
}
+ ],
+ "tags": [
+ "uncategorized",
+ "cbs news",
+ "history"
]
},
{
@@ -1149,6 +1365,11 @@ window.P24_REAL_STORIES = [
"headline": "Russia could attack Nato country within months, Denmark warns",
"detail": "Russia could attack Nato country within months, Denmark warns Financial Times • As Russia Steps Up Shadow War, Europeans Waver on How to Respond The New York Times • Russia could attack a Nato country within months, Danish intelligence says The Guardian • Russia is unlikely to break through in Ukraine, but risks to NATO are rising, Western intel warns Business Insider • Denmark expects Russia to escalate hybrid warfare in coming months, intelligence service says Reuters"
}
+ ],
+ "tags": [
+ "uncategorized",
+ "financial times",
+ "geopolitics"
]
},
{
@@ -1177,6 +1398,11 @@ window.P24_REAL_STORIES = [
"headline": "Pakistani airstrikes target Afghanistan after Islamabad accuses Kabul of drone attacks",
"detail": "Pakistani airstrikes target Afghanistan after Islamabad accuses Kabul of drone attacks AP News • Pakistan air strikes kill 4 civilians in Afghanistan, Kabul says Reuters • Dangerous border Dawn • What's behind the Pakistani strikes on Afghanistan? Yahoo • Pakistan says airstrikes in Afghanistan kill 28 militants; Kabul reports 3 civilians killed NBC News"
}
+ ],
+ "tags": [
+ "uncategorized",
+ "ap news",
+ "military conflict"
]
}
];
diff --git a/scripts/build-cartoon-batch.sh b/scripts/build-cartoon-batch.sh
index e9370ed..60b9852 100644
--- a/scripts/build-cartoon-batch.sh
+++ b/scripts/build-cartoon-batch.sh
@@ -23,6 +23,14 @@
# written for it. See the requirement comment at the top of
# cartoons/manifest.js. Step 90-98 below enforces this: a built cartoon
# with story_id: null (or missing) is logged FAIL, not PASS.
+# HARD RULE (TK-12165, 2026-09-24) — the manifest entry's tags array must
+# also be non-empty (inherit the linked story's tags plus at most 1-2
+# cartoon-specific tags, e.g. "editorial cartoon"), and if a NEW
+# stories-data.js entry is written for this brief, IT must carry a
+# non-empty tags array too (3-6 short, lowercase-ish, human-readable
+# tags drawn from the vocabulary already in use there). Enforced the
+# same way as story_id below: a built cartoon with tags: [] or missing
+# is logged FAIL, not PASS.
# 3. Logs the whole run to yolo/cartoon-batch-log.jsonl.
#
# COST: each `claude -p` build is a real paid Anthropic API session. Today's single
@@ -80,16 +88,22 @@ for b in q['briefs']:
f"+ reduced-motion + dark (require playwright via ~/Projects/animals/node_modules/playwright, "
f"launch chromium with channel:'chrome'). Fix any real defects found, then add an entry to "
f"~/Projects/crazy-news-channel/cartoons/manifest.js (as a new object appended to the window.P24_CARTOONS array: id, title, file, created_at ISO now, "
- f"category 'Political Cartoon', blurb, thumb:null) and commit with author "
+ f"category 'Political Cartoon', blurb, thumb:null, tags) and commit with author "
f"steve@designerwallcoverings.com. HARD RULE — Steve's standing instruction is 'always link "
f"images cartoons to the real article': the manifest entry's story_id field must be a real, "
f"non-null id. If no existing entry in ~/Projects/crazy-news-channel/stories-data.js or "
f"real-news-data.js already covers this brief's topic, write ONE new satirical story object "
f"matching the exact schema of an existing window.P24_EXTRA_STORIES entry (id, category, "
f"categoryLabel, location, byline, image, intervalSec, offsetSec, stageIndex, article.dek, "
- f"article.photoCaption, article.paragraphs, stages) and append it to stories-data.js, then set "
- f"this cartoon's story_id to that new id. Never leave story_id: null. Do not deploy, push, or "
- f"touch anything outside this repo."
+ f"article.photoCaption, article.paragraphs, stages, tags) and append it to stories-data.js, then set "
+ f"this cartoon's story_id to that new id. Never leave story_id: null. HARD RULE (TK-12165) — "
+ f"every post carries tags: the new (or reused) story object's tags array must be non-empty (3-6 "
+ f"short, lowercase-ish, human-readable tags reusing the shared vocabulary already in "
+ f"stories-data.js where it fits, e.g. 'city council', 'bureaucracy', 'parking', 'small town "
+ f"news' — so tags actually recur across posts), and this cartoon's own manifest.js entry must "
+ f"set tags to that story's tags PLUS at most 1-2 cartoon-specific tags (e.g. 'editorial "
+ f"cartoon', and 'paul conrad style' only if drawn in that manner). Never leave tags: [] or "
+ f"omit the field on either object. Do not deploy, push, or touch anything outside this repo."
)
if dry == "1":
print(f"[DRY RUN] would build: {b['id']} — {b['title']}")
@@ -112,6 +126,13 @@ for b in q['briefs']:
# entry's object body between its "file" key and the object's closing
# " }" line, and require a non-null "story_id" inside it.
has_story_id = False
+ # HARD RULE (TK-12165): a cartoon with tags: [] (or missing) is not a
+ # valid end state either — same substring-check approach as story_id
+ # above (this is a cheap FAIL gate, not a full JSON parse). A non-empty
+ # tags array always contains at least one quoted string between the
+ # brackets, so "tags": [] (empty) or a missing key both fail the regex.
+ has_tags = False
+ story_tags_ok = False
marker = f'"file": "{b["slug"]}.html"'
mi = man.find(marker)
if mi != -1:
@@ -119,10 +140,31 @@ for b in q['briefs']:
obj_body = man[mi: obj_end if obj_end != -1 else mi + 2000]
m = __import__("re").search(r'"story_id"\s*:\s*"([^"]+)"', obj_body)
has_story_id = bool(m)
- ok = rc == 0 and has_file and in_manifest and has_story_id
+ tm = __import__("re").search(r'"tags"\s*:\s*\[\s*"[^\]]*\]', obj_body)
+ has_tags = bool(tm)
+ # If a NEW story object was appended to stories-data.js for this
+ # brief, it must also carry non-empty tags — check the story's own
+ # object body there the same way (find its id, then its own tags
+ # field before the next top-level "},\n {" or the array's closing).
+ if has_story_id:
+ story_id_val = m.group(1)
+ try:
+ sd = open(os.path.join(root, "stories-data.js")).read()
+ sid_marker = f'"id": "{story_id_val}"'
+ si = sd.find(sid_marker)
+ if si != -1:
+ s_end = sd.find('\n }', si)
+ s_body = sd[si: s_end if s_end != -1 else si + 4000]
+ story_tags_ok = bool(__import__("re").search(r'"tags"\s*:\s*\[\s*"', s_body))
+ else:
+ story_tags_ok = True # story_id resolves to real-news-data.js instead — out of this script's scope, don't false-FAIL
+ except FileNotFoundError:
+ story_tags_ok = True
+ ok = rc == 0 and has_file and in_manifest and has_story_id and has_tags and story_tags_ok
with open(log, "a") as f:
f.write(json.dumps({"ts": ts, "id": b["id"], "title": b["title"], "exit": rc,
"file": has_file, "manifest": in_manifest, "story_id": has_story_id,
+ "tags": has_tags, "story_tags": story_tags_ok,
"ok": ok, "verdict": "PASS" if ok else "FAIL"}) + "\n")
b['status'] = 'built' if ok else 'failed'
json.dump(q, open(qfile, 'w'), indent=2)
diff --git a/scripts/fetch-real-news.mjs b/scripts/fetch-real-news.mjs
index 15fc6eb..686c235 100644
--- a/scripts/fetch-real-news.mjs
+++ b/scripts/fetch-real-news.mjs
@@ -13,10 +13,16 @@
// `stages`) on purpose — real news doesn't escalate into Onion-style
// absurdity, so giving it only one stage means tick()'s stage-advance loop
// treats it as already at its final stage and never touches it again.
+//
+// HARD RULE (TK-12165, 2026-09-24): every entry this writes MUST carry a
+// non-empty `tags` array — see toStory()'s call into
+// scripts/lib/auto-tag-real-news.mjs, the one shared place both this
+// fetcher and the one-time backfill read the tag vocabulary from.
import { writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
+import { autoTagRealNews } from "./lib/auto-tag-real-news.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_FILE = path.join(__dirname, "..", "real-news-data.js");
@@ -158,7 +164,7 @@ function toStory(categoryKey, n, raw) {
`Read the full story at ${sourceName}.`,
];
- return {
+ const story = {
id: `real-${categoryKey}-${n}`,
category: categoryKey,
categoryLabel: CATEGORY_LABELS[categoryKey],
@@ -183,6 +189,13 @@ function toStory(categoryKey, n, raw) {
{ headline, detail },
],
};
+ // HARD RULE (TK-12165): every real-news entry MUST carry a non-empty
+ // tags array. autoTagRealNews() (scripts/lib/auto-tag-real-news.mjs) is
+ // deterministic and always returns 3-6 tags (category + outlet + keyword
+ // matches, padded with a category fallback if nothing else matched), so
+ // this can never write an untagged entry.
+ story.tags = autoTagRealNews(story);
+ return story;
}
async function main() {
diff --git a/scripts/lib/auto-tag-real-news.mjs b/scripts/lib/auto-tag-real-news.mjs
new file mode 100644
index 0000000..abab32c
--- /dev/null
+++ b/scripts/lib/auto-tag-real-news.mjs
@@ -0,0 +1,100 @@
+// scripts/lib/auto-tag-real-news.mjs — shared real-news auto-tagging
+// heuristic. Deterministic, $0, no API call, no LLM.
+//
+// HARD RULE (TK-12165, 2026-09-24): every entry in real-news-data.js MUST
+// carry a non-empty `tags` array (3-6 short, lowercase-ish, human-readable
+// tags). fetch-real-news.mjs imports autoTagRealNews() from here on every
+// refresh so a future run can never write an untagged entry. If the
+// keyword vocabulary below needs a new rule for a topic that keeps
+// recurring untagged, add it here — this is the ONE place both the
+// backfill and the live fetcher read from, so the vocabulary stays shared
+// and tags actually recur across posts instead of drifting apart.
+
+const KEYWORD_RULES = [
+ [/migrant|migration|border cross|channel cross/i, "immigration"],
+ [/senate race|house race|campaign trail|primary election|\bballot\b|\bcandidate\b/i, "elections"],
+ [/\bsenate\b|\bcongress\b|\bcapitol\b|lawmakers|legislation|\bbill\b/i, "congress"],
+ [/white house|the administration\b/i, "white house"],
+ [/\bfaa\b|\bairline\b|\bairport\b|aviation/i, "aviation"],
+ [/nor'?easter|hurricane|tropical storm|storm surge/i, "storms"],
+ [/flooding|\bflood\b/i, "flooding"],
+ [/el ni[nñ]o|la ni[nñ]a/i, "climate patterns"],
+ [/heat wave|heatwave|triple-digit/i, "heat wave"],
+ [/wildfire|\bdrought\b/i, "climate patterns"],
+ [/privacy|smartglasses|smart glasses/i, "consumer tech"],
+ [/\bapple\b|iphone|apple watch|mac mini|macbook/i, "apple"],
+ [/\bgoogle\b|pixel \d|\bgemini\b/i, "google"],
+ [/\bmeta\b|zuckerberg|\bfacebook\b/i, "meta"],
+ [/\bcamera\b|\bnikon\b|\bcanon\b|sony a\d/i, "cameras"],
+ [/headphones|earbuds|\bbeats\b|audio gear/i, "audio gear"],
+ [/\bnfl\b|quarterback|touchdown|packers|falcons|\bgiants\b|chiefs|cowboys/i, "nfl"],
+ [/college football|\bncaa\b/i, "college football"],
+ [/\bboxing\b|heavyweight|\bufc\b|\bmma\b/i, "combat sports"],
+ [/music video|\balbum\b|\btour\b|\bvmas\b|\bgrammy\b|billboard/i, "music"],
+ [/box office|\bfilm\b|\bmovie\b|\bstudio\b/i, "film & tv"],
+ [/streaming|netflix|\bhbo\b|paramount\+/i, "streaming tv"],
+ [/survivor|reality (tv|show)/i, "reality tv"],
+ [/\bmerger\b|antitrust/i, "corporate mergers"],
+ [/mortgage|housing market|home prices/i, "housing market"],
+ [/stock market|s&p 500|dow jones|nasdaq/i, "stock market"],
+ [/prediction market|polymarket|\bgambling\b/i, "prediction markets"],
+ [/data center|\borbital\b|space launch|\brocket\b/i, "space & data centers"],
+ [/\boil\b|\bopec\b|energy prices|strait of hormuz/i, "energy markets"],
+ [/missile|airstrike|drone attack|\bmilitary\b/i, "military conflict"],
+ [/\bnato\b|\brussia\b|\bukraine\b|geopolit/i, "geopolitics"],
+ [/\barrest\b|stabbing|\bpolice\b|\bcrime\b/i, "crime"],
+ [/hitler|\bnazi\b|world war/i, "history"],
+];
+
+const CATEGORY_FALLBACK = {
+ politics: "washington",
+ weather: "forecast",
+ scitech: "tech news",
+ sports: "game day",
+ entertainment: "pop culture",
+ business: "markets",
+ uncategorized: "world news",
+};
+
+function normalizeOutlet(name) {
+ if (!name) return null;
+ let n = String(name).trim();
+ n = n.replace(/\.com$/i, "");
+ const MAP = {
+ "9to5Google": "9to5google",
+ "bgr": "bgr",
+ "gearpatrol": "gear patrol",
+ "arstechnica": "ars technica",
+ "kare11": "kare 11",
+ "ABC7 Los Angeles": "abc7",
+ "E! News": "e! news",
+ };
+ if (MAP[n]) return MAP[n];
+ return n.toLowerCase();
+}
+
+// Returns a tags array, always length 3-6, for one real-news story object
+// { category, categoryLabel, sourceName, stages: [{headline}] }.
+export function autoTagRealNews(story) {
+ const headline = (story.stages && story.stages[0] && story.stages[0].headline) || "";
+ const tags = [];
+ const seen = new Set();
+ const push = (t) => {
+ if (!t) return;
+ const k = t.toLowerCase();
+ if (seen.has(k)) return;
+ seen.add(k);
+ tags.push(t);
+ };
+ push((story.categoryLabel || story.category || "").toLowerCase());
+ push(normalizeOutlet(story.sourceName));
+ for (const [re, tag] of KEYWORD_RULES) {
+ if (tags.length >= 6) break;
+ if (re.test(headline)) push(tag);
+ }
+ if (tags.length < 3) {
+ push(CATEGORY_FALLBACK[story.category] || "wire report");
+ }
+ if (tags.length < 3) push("wire report");
+ return tags.slice(0, 6);
+}
diff --git a/scripts/verify-tags.mjs b/scripts/verify-tags.mjs
new file mode 100644
index 0000000..155b175
--- /dev/null
+++ b/scripts/verify-tags.mjs
@@ -0,0 +1,71 @@
+#!/usr/bin/env node
+// scripts/verify-tags.mjs — TK-12165 proof script.
+// Loads stories-data.js, real-news-data.js, and cartoons/manifest.js in a
+// node vm Context (same "plain <script src> globals" shape the pages use,
+// no DOM/browser needed) and asserts every post carries a non-empty `tags`
+// array. Prints tag-vocabulary stats (distinct tags, top 15 by count,
+// singleton count) so drift is visible at a glance.
+//
+// Usage: node scripts/verify-tags.mjs
+import fs from "node:fs";
+import path from "node:path";
+import vm from "node:vm";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.join(__dirname, "..");
+
+const ctx = { window: {}, console };
+vm.createContext(ctx);
+for (const f of ["stories-data.js", "real-news-data.js", "cartoons/manifest.js"]) {
+ vm.runInContext(fs.readFileSync(path.join(ROOT, f), "utf8"), ctx, { filename: f });
+}
+
+const buckets = [
+ ["stories-data.js (window.P24_EXTRA_STORIES)", ctx.window.P24_EXTRA_STORIES || []],
+ ["real-news-data.js (window.P24_REAL_STORIES)", ctx.window.P24_REAL_STORIES || []],
+ ["cartoons/manifest.js (window.P24_CARTOONS)", ctx.window.P24_CARTOONS || []],
+];
+
+let totalPosts = 0;
+let missing = [];
+const freq = new Map();
+
+for (const [label, arr] of buckets) {
+ console.log(`${label}: ${arr.length} entries`);
+ for (const post of arr) {
+ totalPosts++;
+ const tags = post.tags;
+ if (!Array.isArray(tags) || tags.length === 0) {
+ missing.push(`${label} :: ${post.id}`);
+ continue;
+ }
+ for (const t of tags) {
+ const k = String(t).toLowerCase();
+ freq.set(k, (freq.get(k) || 0) + 1);
+ }
+ }
+}
+
+console.log("");
+console.log(`Total posts checked: ${totalPosts}`);
+console.log(`Posts with missing/empty tags: ${missing.length}`);
+if (missing.length) {
+ console.log("MISSING:");
+ missing.forEach((m) => console.log(" - " + m));
+}
+
+const entries = [...freq.entries()].sort((a, b) => b[1] - a[1]);
+const singletons = entries.filter(([, c]) => c === 1);
+console.log("");
+console.log(`Distinct tags: ${entries.length}`);
+console.log(`Singleton tags (used exactly once): ${singletons.length}`);
+console.log("");
+console.log("Top 15 tags by count:");
+for (const [t, c] of entries.slice(0, 15)) console.log(` ${String(c).padStart(3)} ${t}`);
+
+if (missing.length > 0) {
+ console.error("\nFAIL: one or more posts have no tags.");
+ process.exit(1);
+}
+console.log("\nPASS: every post has a non-empty tags array.");
diff --git a/stories-data.js b/stories-data.js
index b427884..08c2bb7 100644
--- a/stories-data.js
+++ b/stories-data.js
@@ -1,3 +1,14 @@
+// stories-data.js — invented P24 satire stories (window.P24_EXTRA_STORIES).
+//
+// HARD RULE (TK-12165, 2026-09-24): every entry below MUST carry a
+// non-empty `tags` array (3-6 short, lowercase-ish, human-readable tags,
+// drawn from the shared vocabulary already in use here — e.g. "city
+// council", "bureaucracy", "parking", "small town news" — so tags
+// actually recur across posts instead of every story minting its own
+// one-off word). A story with tags: [] or a missing tags field is a gap
+// to fix, never a valid end state — see build-cartoon-batch.sh's FAIL
+// check and index.html's tag-chip rendering/filtering, both of which
+// assume every story has tags.
window.P24_EXTRA_STORIES = [
{
"id": "pw-meter-street",
@@ -45,6 +56,12 @@ window.P24_EXTRA_STORIES = [
"detail": "Meter No. 114 cited 'term ambiguity' in the original ordinance and has occupied the mayor's chair for six days, City Hall staff said.",
"conclusion": "Erie's charter, it turns out, never defined 'a day.'"
}
+ ],
+ "tags": [
+ "city council",
+ "parking",
+ "bureaucracy",
+ "small town news"
]
},
{
@@ -93,6 +110,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The designation, 93650-R, makes the six-story building the smallest self-contained climate zone in California postal history, officials said.",
"conclusion": "Somewhere, a building finally got the alone time it always wanted."
}
+ ],
+ "tags": [
+ "weather anomaly",
+ "science oddity",
+ "small town news"
]
},
{
@@ -141,6 +163,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The Bureau of Land Management approved a research station after instruments confirmed the formation's deepest measured layer emits a faint, steady low-frequency hum.",
"conclusion": "Researchers say the hum is almost certainly geological; almost is doing a lot of work in that sentence."
}
+ ],
+ "tags": [
+ "science oddity",
+ "research",
+ "small town news"
]
},
{
@@ -189,6 +216,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The library unveiled a climate-controlled case for the bat, ribbon-cut before 61 people. The league voted 9-2 to rename its rack 'The Vault.'",
"conclusion": "The bat hasn't swung since — and neither, reportedly, has the Otters' win streak."
}
+ ],
+ "tags": [
+ "youth sports",
+ "local sports",
+ "superstition"
]
},
{
@@ -237,6 +269,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Mayor Verlene Thoms unveiled a city seal update and held a synchronized 900-person silent disco downtown, cued by a countdown flashed on the courthouse clock.",
"conclusion": "The whole town now dances in near-total silence, which several residents agree is an improvement."
}
+ ],
+ "tags": [
+ "community event",
+ "trend",
+ "small town news"
]
},
{
@@ -285,6 +322,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The Authority confirmed 'TollCoin' will fund a twice-daily chartered puddle-jumper between the bridge and a nearby waffle house exclusively for Diamond members.",
"conclusion": "Somewhere, a toll booth dreams of becoming a country."
}
+ ],
+ "tags": [
+ "loyalty programs",
+ "infrastructure",
+ "small town news"
]
},
{
@@ -333,6 +375,12 @@ window.P24_EXTRA_STORIES = [
"detail": "Enforcement begins Monday; violators face cob impoundment and mandatory viewing of a 40-minute etiquette film, officials said.",
"conclusion": "Kansas: where the only thing more regulated than corn is opinions about corn."
}
+ ],
+ "tags": [
+ "state legislature",
+ "bureaucracy",
+ "food traditions",
+ "ordinance"
]
},
{
@@ -381,6 +429,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The proposed title would require Farraday to file a daily windspeed report alongside his regular delivery log, per draft legislation.",
"conclusion": "Neil Farraday just wanted to deliver the mail."
}
+ ],
+ "tags": [
+ "weather anomaly",
+ "small town news",
+ "science oddity"
]
},
{
@@ -429,6 +482,12 @@ window.P24_EXTRA_STORIES = [
"detail": "The Kansas Agriculture Innovation Board approved a small line-item grant for the system and listed it as a co-investigator on the resulting yield study, a first for the program.",
"conclusion": "The wheat yields are up 9 percent; the paperwork trail is, politely, unprecedented."
}
+ ],
+ "tags": [
+ "agriculture",
+ "artificial intelligence",
+ "automation",
+ "research"
]
},
{
@@ -477,6 +536,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The state prep association unveiled the Sound Bowl, a tuba-shaped trophy awarded regardless of score. Millbrook won it 4-1 despite losing the game 21-14.",
"conclusion": "The football team says it's fine with that. The football team is not fine with that."
}
+ ],
+ "tags": [
+ "rivalry",
+ "local sports",
+ "small town news"
]
},
{
@@ -525,6 +589,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The first 'Traveling Three Day' drew 2,000 spectators who followed a replica van along the original path, escorted by the parade committee on foot.",
"conclusion": "The film itself has yet to find a distributor, but the shuttle route now has its own merchandise."
}
+ ],
+ "tags": [
+ "film production",
+ "bureaucracy",
+ "small town news"
]
},
{
@@ -573,6 +642,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Corliss confirmed the Low Buck now clears mortgage payments in Navajo and Apache counties, with a fourth county reportedly negotiating a currency peg.",
"conclusion": "Parking, it turns out, was the economy all along."
}
+ ],
+ "tags": [
+ "parking",
+ "loyalty programs",
+ "infrastructure"
]
},
{
@@ -621,6 +695,12 @@ window.P24_EXTRA_STORIES = [
"detail": "The five-member board granted early release to two gnomes for 'demonstrated quietude,' Chair Marsh announced Tuesday.",
"conclusion": "The gnomes, for their part, have remained silent — for now."
}
+ ],
+ "tags": [
+ "lawn ornaments",
+ "city council",
+ "ordinance",
+ "bureaucracy"
]
},
{
@@ -669,6 +749,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Signage now warns swimmers to stay 15 feet back whenever condensation is visibly present, per the newly adopted ordinance.",
"conclusion": "The strawberry, city officials insist, takes its duties very seriously."
}
+ ],
+ "tags": [
+ "heat wave",
+ "monuments",
+ "tourism"
]
},
{
@@ -717,6 +802,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Feld confirmed the National Weather Service has begun tracking 'Sunland Estates Storm Cell' as a recurring weekly system with its own rotating cloud-cover schedule.",
"conclusion": "The HOA newsletter now includes a five-day forecast it personally authorized."
}
+ ],
+ "tags": [
+ "homeowners association",
+ "weather anomaly",
+ "science oddity"
]
},
{
@@ -765,6 +855,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The state bowling congress adopted Elm Grove's nine-second wobble rule statewide and now requires judges pass a 20-question certification exam.",
"conclusion": "Pin No. 7, for the record, now sits in a shadowbox behind the front desk."
}
+ ],
+ "tags": [
+ "recreational sports",
+ "rivalry",
+ "small town news"
]
},
{
@@ -813,6 +908,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Chapter now holds an honorary library card and a reserved parking spot outside the county building, per board chair Ashcombe, 'he's earned both, frankly.'",
"conclusion": "Circulation is up countywide, and nobody has yet disputed the llama's taste in mysteries."
}
+ ],
+ "tags": [
+ "local business",
+ "wildlife",
+ "small town news"
]
},
{
@@ -861,6 +961,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Ambrose confirmed the top scorer on this quarter's productivity leaderboard is Desk 14's unassigned ficus, which has processed zero claims but wilted zero times.",
"conclusion": "HR says the ficus is up for a corner office."
}
+ ],
+ "tags": [
+ "office culture",
+ "corporate policy",
+ "small town news"
]
},
{
@@ -909,6 +1014,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The city council voted 6-1 to request federal recognition of a standalone time zone, citing 'four minutes of hard-won sovereignty.'",
"conclusion": "Somewhere, a federal timekeeping clerk just requested a transfer."
}
+ ],
+ "tags": [
+ "city council",
+ "bureaucracy",
+ "small town news"
]
},
{
@@ -957,6 +1067,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The town unveiled a viewing platform, a souvenir stand selling tethered hats, and an annual Hat-Chasing Festival at the new Wind Zone. Mayor Alton Reyes cut the ribbon sideways.",
"conclusion": "Next year's festival adds a category for hats recovered more than three blocks away."
}
+ ],
+ "tags": [
+ "weather anomaly",
+ "science oddity",
+ "small town news"
]
},
{
@@ -1005,6 +1120,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Ferran said the AI network produced a 40-page interfloor 'thermal accord' Tuesday and is withholding heat from the lobby until management formally signs it.",
"conclusion": "Facilities is reportedly reviewing the treaty's terms very, very slowly."
}
+ ],
+ "tags": [
+ "artificial intelligence",
+ "office culture",
+ "automation"
]
},
{
@@ -1053,6 +1173,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The national body codified the 'Tumbleweed Standard,' granting automatic replay for confirmed squirrel deflection, and now certifies club 'squirrel liaisons.'",
"conclusion": "Whitcomb passed the exam on his first try, though he admits the essay section 'got weird.'"
}
+ ],
+ "tags": [
+ "recreational sports",
+ "rivalry",
+ "wildlife"
]
},
{
@@ -1101,6 +1226,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Texas regulators established a formal registry protecting the winning recipe's name, requiring future entrants in that category to file ingredients with the state.",
"conclusion": "The chili itself, per most tasters, is 'fine, honestly, kind of ordinary.'"
}
+ ],
+ "tags": [
+ "county fair",
+ "food traditions",
+ "small town news"
]
},
{
@@ -1149,6 +1279,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Tolliver confirmed the newly installed robotic-arm units filed for their own labor recognition Friday, demanding shorter shifts and a say in snack selection.",
"conclusion": "The machines, it turns out, wanted representation too."
}
+ ],
+ "tags": [
+ "vending machines",
+ "labor union",
+ "corporate policy"
]
},
{
@@ -1197,6 +1332,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The ruling ordered a blind taste-off among 14 challengers, with results due by Friday, according to the court's order.",
"conclusion": "In Savannah, democracy now runs on butter beans."
}
+ ],
+ "tags": [
+ "pageantry",
+ "city council",
+ "food traditions"
]
},
{
@@ -1245,6 +1385,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The governor's office issued a non-binding Sundae Advisory urging 'reasonable topping restraint,' and put the Guard's cold-storage unit on standby for dairy logistics.",
"conclusion": "No troops were dispatched, though the trucks reportedly idled suspiciously close to the sprinkles."
}
+ ],
+ "tags": [
+ "heat wave",
+ "local business",
+ "small town news"
]
},
{
@@ -1293,6 +1438,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Nkemelu confirmed the company has scheduled a two-hour dusk shutdown after the flock 'demonstrated' three consecutive nights it would trigger the dimming regardless.",
"conclusion": "The birds, unlike most stakeholders, showed up to every single meeting."
}
+ ],
+ "tags": [
+ "renewable energy",
+ "wildlife",
+ "science oddity"
]
},
{
@@ -1341,6 +1491,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The new Mascot Integrity Officer, ex-equipment manager Sal Untermeyer, can halt any game for a costume malfunction. He calls it a job he 'never wanted less.'",
"conclusion": "Chuck the Chipmunk, for what it's worth, has not lost his head since."
}
+ ],
+ "tags": [
+ "mascots",
+ "local sports",
+ "small town news"
]
},
{
@@ -1389,6 +1544,12 @@ window.P24_EXTRA_STORIES = [
"detail": "Chillicothe approved a weekend excavation permit, and the family now seeds the lawn with real polished gemstones for a $5 ticketed dig, drawing bus tours from three counties.",
"conclusion": "The original garage sale items, it should be noted, are still unsold."
}
+ ],
+ "tags": [
+ "community event",
+ "consumer culture",
+ "trend",
+ "small town news"
]
},
{
@@ -1437,6 +1598,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Voss confirmed Siskiyou County judges have begun referring unrelated furniture disputes to the mattress tribunal, calling its docket 'frankly more organized than ours.'",
"conclusion": "All rise for the Honorable Judge of Pillow-Top."
}
+ ],
+ "tags": [
+ "retail",
+ "consumer culture",
+ "trend"
]
},
{
@@ -1485,6 +1651,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The agreement caps all cross-border statuary topiary at six feet and creates a joint hedge inspection committee, both governors' offices confirmed.",
"conclusion": "Diplomacy, it turns out, can be pruned into shape."
}
+ ],
+ "tags": [
+ "monuments",
+ "city council",
+ "ordinance"
]
},
{
@@ -1533,6 +1704,12 @@ window.P24_EXTRA_STORIES = [
"detail": "Castellan confirmed the co-op has installed a permanent 'tuber bank' of 60,000 wired potatoes that automatically feeds the grid during peak demand, with fresh potatoes rotated in weekly.",
"conclusion": "The county's backup generator is, technically, a very large baked potato."
}
+ ],
+ "tags": [
+ "agriculture",
+ "science oddity",
+ "renewable energy",
+ "research"
]
},
{
@@ -1581,6 +1758,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The season ended with a Golden Orange Slice trophy for top score, while one parent's fruit cups were disqualified over an alleged 'strategic sugar-dusting.'",
"conclusion": "The league says an investigation is ongoing. The fruit cups have been impounded."
}
+ ],
+ "tags": [
+ "youth sports",
+ "community event",
+ "small town news"
]
},
{
@@ -1629,6 +1811,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Under the new agreement the statue travels between towns on a flatbed trailer every two weeks, escorted by a state trooper for the ceremonial handoff.",
"conclusion": "Both towns now claim victory, and the trailer has its own regional fan following."
}
+ ],
+ "tags": [
+ "monuments",
+ "tourism",
+ "rivalry"
]
},
{
@@ -1677,6 +1864,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Ranquist confirmed each floor button now triggers its own licensing fee, so a ride from lobby to the 10th floor can rack up nine distinct music charges.",
"conclusion": "Going up, apparently, now means paying up."
}
+ ],
+ "tags": [
+ "office culture",
+ "consumer culture",
+ "corporate policy"
]
},
{
@@ -1725,6 +1917,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The regional body made Sparrow Lake's protocol the standard and created an annual Ice Technician trophy shaped like a 42-pound curling stone.",
"conclusion": "Lindqvist won the inaugural trophy and reportedly won't let anyone else touch the can."
}
+ ],
+ "tags": [
+ "recreational sports",
+ "rivalry",
+ "small town news"
]
},
{
@@ -1773,6 +1970,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Ocean Springs unveiled a small plaque at the exact curb spot, and the chamber of commerce now runs an annual reenactment festival on the anniversary date.",
"conclusion": "The spot remains, by all accounts, a completely unremarkable curb."
}
+ ],
+ "tags": [
+ "community event",
+ "parking",
+ "small town news"
]
},
{
@@ -1820,6 +2022,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Council members voted 6-1 to fund the new body, whose sole task is confirming attendance records for the previous three.",
"conclusion": "The original $40,000 in projected savings has not been mentioned in a public meeting since February."
}
+ ],
+ "tags": [
+ "bureaucracy",
+ "city council",
+ "small town news"
]
},
{
@@ -1867,6 +2074,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Mayor Carol Dunmire again pledged a larger pie next year, a promise made in four consecutive budget cycles.",
"conclusion": "City archives show no record of the pie ever actually growing."
}
+ ],
+ "tags": [
+ "budget",
+ "city council",
+ "bureaucracy"
]
},
{
@@ -1914,6 +2126,12 @@ window.P24_EXTRA_STORIES = [
"detail": "County commissioner Ida Ruskin, the bill's original requester, said she is no longer certain the road repaving is still included.",
"conclusion": "The Legislative Budget Office says the road now represents about two percent of the bill's total spending."
}
+ ],
+ "tags": [
+ "state legislature",
+ "infrastructure",
+ "budget",
+ "lobbying"
]
},
{
@@ -1961,6 +2179,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Leadership called off the search rather than continue, floor staff said, ending the session with the disguised coat rack still seated at its desk.",
"conclusion": "At least one aide has reportedly begun referring to the coat rack by a lawmaker's first name."
}
+ ],
+ "tags": [
+ "state legislature",
+ "quorum tricks",
+ "bureaucracy"
]
},
{
@@ -2008,6 +2231,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Neither caucus has ruled out the paper-clock proposal, according to floor staff, as the original mechanism remains stalled.",
"conclusion": "The Assembly's opening-day tradition of winding the clock once a year has not been observed since Monday."
}
+ ],
+ "tags": [
+ "state legislature",
+ "filibuster",
+ "quorum tricks"
]
},
{
@@ -2055,6 +2283,11 @@ window.P24_EXTRA_STORIES = [
"detail": "Capitol staff say the door has become a minor tourist attraction, with visitors timing their visits to watch a rotation.",
"conclusion": "The Ethics Office says it has no rule against watching."
}
+ ],
+ "tags": [
+ "lobbying",
+ "state legislature",
+ "bureaucracy"
]
},
{
@@ -2102,6 +2335,11 @@ window.P24_EXTRA_STORIES = [
"detail": "The frozen chamber clock was carried out on a hand truck as the filibuster continued into a second full day, with no vote yet called.",
"conclusion": "Holloway's office says he has packed a second phone book in case the first one 'comes up again.'"
}
+ ],
+ "tags": [
+ "filibuster",
+ "state legislature",
+ "bureaucracy"
]
}
];
← 6765454 auto-data-snapshot: 2026-09-24T13:51:00 (1 data files) — cta
·
back to Crazy News Channel Shadowman
·
P24: every category page uses the same photo-card layout as b6a6016 →