[object Object]

← back to Crazy News Channel

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

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 &middot; 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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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  ·  P24: every category page uses the same photo-card layout as b6a6016 →