[object Object]

← back to Crazy News Channel

P24 daily cartoons 2026-09-26: 6 built + registered

7eee427cea0fe62217365673642c40d130b03dd2 · 2026-09-26 03:19:52 -0700 · Steve Abrams

Files touched

Diff

commit 7eee427cea0fe62217365673642c40d130b03dd2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Sep 26 03:19:52 2026 -0700

    P24 daily cartoons 2026-09-26: 6 built + registered
---
 cartoons/20260926-bleacher-superstition-tower.html | 333 +++++++++++++++++++
 cartoons/20260926-festival-mascot-election.html    | 298 +++++++++++++++++
 cartoons/20260926-halftime-show-runaway.html       | 304 +++++++++++++++++
 cartoons/20260926-rainbow-permit-office.html       | 341 +++++++++++++++++++
 cartoons/20260926-server-room-thermostat-war.html  | 366 +++++++++++++++++++++
 cartoons/20260926-statue-selfie-line.html          | 327 ++++++++++++++++++
 cartoons/manifest.js                               | 118 ++++++-
 stories-data.js                                    | 271 +++++++++++++++
 8 files changed, 2352 insertions(+), 6 deletions(-)

diff --git a/cartoons/20260926-bleacher-superstition-tower.html b/cartoons/20260926-bleacher-superstition-tower.html
new file mode 100644
index 0000000..b1fbc0a
--- /dev/null
+++ b/cartoons/20260926-bleacher-superstition-tower.html
@@ -0,0 +1,333 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>The Lucky Seat — P24 Cartoon Desk</title>
+<meta name="description" content="An original invented-satire strip: a fan's lucky-seat ritual grows one more superstition object with every win, until the stack on the bleacher bench is taller than the fan." />
+<link rel="stylesheet" href="../assets/style.css" />
+</head>
+<body data-mood="sports" class="cartoon-page cartoon-page-wide">
+<header>
+  <div>
+    <div class="kicker">P24 Cartoon Desk &middot; Invented Satire</div>
+    <h1>The Lucky Seat</h1>
+    <p class="dek">One invented fan, one bleacher bench, and one rule: every win earns another object that can never be moved.</p>
+  </div>
+  <div class="header-actions">
+    <a class="back" href="index.html">&larr; Cartoon Desk</a>
+  </div>
+</header>
+<main>
+  <div class="strip" id="strip">
+    <figure class="panel" data-i="0">
+      <div class="phead"><span>Panel 1 &middot; The First Win</span><span id="stamp0"></span></div>
+      <svg class="art" id="art0" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 1, not yet revealed."></svg>
+      <button class="cover" id="cover0" type="button">Click, tap, or press N to begin</button>
+      <figcaption id="cap0"></figcaption>
+    </figure>
+    <figure class="panel" data-i="1">
+      <div class="phead"><span>Panel 2 &middot; The Streak</span><span id="stamp1"></span></div>
+      <svg class="art" id="art1" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 2, in queue."></svg>
+      <button class="cover" id="cover1" type="button" disabled>In queue</button>
+      <figcaption id="cap1"></figcaption>
+    </figure>
+    <figure class="panel" data-i="2">
+      <div class="phead"><span>Panel 3 &middot; The Tower</span><span id="stamp2"></span></div>
+      <svg class="art" id="art2" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 3, in queue."></svg>
+      <button class="cover" id="cover2" type="button" disabled>In queue</button>
+      <figcaption id="cap2"></figcaption>
+    </figure>
+  </div>
+
+  <div class="dock">
+    <button class="primary" id="nextBtn" type="button">Reveal Panel 1</button>
+    <button class="twist" id="twistBtn" type="button">Plot Twist!</button>
+    <div id="status" role="status" aria-live="polite"></div>
+  </div>
+  <p class="hint">Keys: N/&rarr; next panel &middot; once the strip is open, N or a click on Panel 3 stacks the next relic &middot; T plot twist</p>
+</main>
+<footer>The fan, the bench, the league, and every relic on the pile are invented. No real supporter, team, or seat was consulted. Part of PANDEMONIUM-24.</footer>
+
+<script>
+(function(){
+  'use strict';
+  const NS = 'http://www.w3.org/2000/svg';
+  function el(tag, attrs, parent){
+    const n = document.createElementNS(NS, tag);
+    for (const k in attrs) n.setAttribute(k, attrs[k]);
+    if (parent) parent.appendChild(n);
+    return n;
+  }
+  function reduced(){
+    return !!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches);
+  }
+
+  // ---- the invented fan (rotated by Plot Twist) ----
+  const FANS = [
+    { name: 'Season-ticket holder Odell Prusk', seat: 'Row F, Seat 12', color: '#b5302f' },
+    { name: 'Season-ticket holder Winnie Barlowe', seat: 'Row C, Seat 3',  color: '#0f6b52' },
+    { name: 'Season-ticket holder Teddy Munce',   seat: 'Row K, Seat 20', color: '#6b4fa0' },
+    { name: 'Season-ticket holder Junie Halstead', seat: 'Row B, Seat 7',  color: '#a35b12' },
+  ];
+  let fanIdx = 0;
+
+  // ---- the relic pile: every entry is an invented superstition object ----
+  const RELICS = [
+    { label: 'rally cap',        color: '#b5302f' },
+    { label: 'thunder-stick',   color: '#c9a227' },
+    { label: 'borrowed jersey', color: '#0f6b52' },
+    { label: 'unwashed scarf',  color: '#6b4fa0' },
+    { label: 'third sock',      color: '#a35b12' },
+    { label: 'folded program',  color: '#2f6fb5' },
+    { label: 'cold thermos',    color: '#b5302f' },
+    { label: 'foam finger',     color: '#0f6b52' },
+    { label: 'dented cowbell',  color: '#c9a227' },
+    { label: 'seat cushion',    color: '#6b4fa0' },
+    { label: 'spare whistle',   color: '#2f6fb5' },
+    { label: 'lucky clipboard', color: '#a35b12' },
+    { label: 'second rally cap',color: '#b5302f' },
+    { label: 'the old ticket',  color: '#0f6b52' },
+  ];
+  const MAX_RELICS = RELICS.length;
+
+  // ---- geometry: one shared stage so all three panels read as one bench ----
+  const BENCH_Y = 252;   // top surface of the bench plank
+  const BAND_H  = 12;    // height of one stacked relic
+  const BAND_GAP= 1;
+  const STACK_X = 168, STACK_W = 118;
+  const FAN_X   = 84;
+  const HEAD_TOP = 196;  // top of the seated fan's head — the "taller than the fan" line
+
+  function bleachers(svg){
+    // three receding plank rows behind, then the bench being sat on
+    for (let r = 0; r < 3; r++) {
+      const y = BENCH_Y - 40 - r * 26;
+      el('rect', { x: 14 + r * 6, y: y, width: 272 - r * 12, height: 7, rx: 2,
+                   fill: 'currentColor', opacity: String(0.10 + r * 0.02) }, svg);
+    }
+    el('rect', { x: 10, y: BENCH_Y, width: 280, height: 12, rx: 3,
+                 fill: 'currentColor', opacity: '0.45' }, svg);
+    el('rect', { x: 26, y: BENCH_Y + 12, width: 8, height: 30, fill: 'currentColor', opacity: '0.3' }, svg);
+    el('rect', { x: 266, y: BENCH_Y + 12, width: 8, height: 30, fill: 'currentColor', opacity: '0.3' }, svg);
+  }
+
+  function fan(svg, color, withCap){
+    const g = el('g', {}, svg);
+    // seated body: hips on the bench, torso up, knees forward
+    el('path', { d: `M ${FAN_X - 16} ${BENCH_Y} L ${FAN_X - 16} ${BENCH_Y - 34} Q ${FAN_X} ${BENCH_Y - 46} ${FAN_X + 16} ${BENCH_Y - 34} L ${FAN_X + 16} ${BENCH_Y}`,
+                 fill: color, 'fill-opacity': '0.22', stroke: color, 'stroke-width': 3, 'stroke-linejoin': 'round' }, g);
+    el('circle', { cx: FAN_X, cy: HEAD_TOP + 14, r: 14, fill: color, 'fill-opacity': '0.2', stroke: color, 'stroke-width': 3 }, g);
+    if (withCap) { // rally cap: worn backwards, brim pointing away from the field
+      el('path', { d: `M ${FAN_X - 14} ${HEAD_TOP + 6} Q ${FAN_X} ${HEAD_TOP - 6} ${FAN_X + 14} ${HEAD_TOP + 6} Z`,
+                   fill: color, stroke: color, 'stroke-width': 2 }, g);
+      el('rect', { x: FAN_X - 27, y: HEAD_TOP + 4, width: 14, height: 4, rx: 2, fill: color }, g);
+    }
+    // legs down to the row below
+    el('path', { d: `M ${FAN_X - 10} ${BENCH_Y} L ${FAN_X - 12} ${BENCH_Y + 30} M ${FAN_X + 10} ${BENCH_Y} L ${FAN_X + 12} ${BENCH_Y + 30}`,
+                 fill: 'none', stroke: color, 'stroke-width': 4, 'stroke-linecap': 'round' }, g);
+    return g;
+  }
+
+  // one stacked relic band; index 0 sits directly on the bench
+  function relicBand(svg, i, relic, animate){
+    const y = BENCH_Y - (i + 1) * (BAND_H + BAND_GAP);
+    const lean = (i % 2 === 0 ? 1 : -1) * Math.min(6, i * 0.7); // the pile starts to list
+    const g = el('g', { transform: `translate(${lean} 0)` }, svg);
+    el('rect', { x: STACK_X, y: y, width: STACK_W, height: BAND_H, rx: 3,
+                 fill: relic.color, 'fill-opacity': '0.28', stroke: relic.color, 'stroke-width': 1.6 }, g);
+    el('rect', { x: STACK_X + 4, y: y + 3, width: 6, height: BAND_H - 6, rx: 1.5, fill: relic.color }, g);
+    const t = el('text', { x: STACK_X + 15, y: y + BAND_H - 3.4,
+                           'font-family': '-apple-system, sans-serif', 'font-size': '8',
+                           'font-weight': '600', fill: 'currentColor' }, g);
+    t.textContent = relic.label;
+    if (animate && !reduced() && g.animate) {
+      g.animate([{ transform: `translate(${lean}px, -14px)`, opacity: 0 },
+                 { transform: `translate(${lean}px, 0px)`,  opacity: 1 }],
+                { duration: 260, easing: 'ease-out' });
+    }
+    return g;
+  }
+
+  function caption(svg, y, text, bold){
+    const t = el('text', { x: 150, y: y, 'text-anchor': 'middle',
+                           'font-family': '-apple-system, sans-serif',
+                           'font-size': bold ? '12' : '11',
+                           'font-weight': bold ? '700' : '400',
+                           fill: bold ? 'currentColor' : 'var(--ink-dim)' }, svg);
+    t.textContent = text;
+    return t;
+  }
+
+  function heightRule(svg, topY){
+    // The dotted "taller than the fan" datum. It stops short of STACK_X on purpose:
+    // run it the full width and it strikes through whichever relic label sits at
+    // head height, which reads as a crossed-out item rather than a measuring line.
+    el('path', { d: `M 30 ${HEAD_TOP} L ${STACK_X - 8} ${HEAD_TOP}`, fill: 'none', stroke: 'currentColor',
+                 'stroke-width': 1.2, 'stroke-dasharray': '4 4', opacity: '0.55' }, svg);
+    const t = el('text', { x: 32, y: HEAD_TOP - 5, 'font-family': '-apple-system, sans-serif',
+                           'font-size': '8', 'font-weight': '700', fill: 'var(--ink-dim)' }, svg);
+    t.textContent = 'top of the fan';
+    if (topY < HEAD_TOP) {
+      el('path', { d: `M 292 ${HEAD_TOP} L 292 ${topY}`, fill: 'none', stroke: 'currentColor',
+                   'stroke-width': 1.2, opacity: '0.55' }, svg);
+    }
+  }
+
+  const state = { revealed: 0, twists: 0, stacked: 8, lastAdded: -1 };
+
+  const COUNTS = [1, 4, () => state.stacked];
+  function countFor(i){ return typeof COUNTS[i] === 'function' ? COUNTS[i]() : COUNTS[i]; }
+
+  const CAPTIONS = [
+    'An invented fan wins one game while wearing a cap backwards, and sets the cap down on the bench rather than risk moving it.',
+    'Four wins, four objects. Nothing on the bench may be washed, shifted, or taken home, per a rule nobody wrote down.',
+    'The pile now outranks the fan in both height and seniority. Stacking one more relic is the only permitted response to a win.',
+  ];
+  const STAMPS = ['ONE WIN', 'ON A STREAK', 'UNMOVABLE'];
+
+  function renderPanel(i){
+    const svg = document.getElementById('art' + i);
+    svg.innerHTML = '';
+    const revealedHere = i < state.revealed;
+    const f = FANS[fanIdx];
+
+    bleachers(svg);
+    fan(svg, f.color, i === 0 ? revealedHere : true);
+
+    if (!revealedHere) {
+      caption(svg, 40, f.seat, false);
+      caption(svg, 60, i === 0 ? 'Nothing on the bench. Yet.' : 'Reveal the earlier panels first.', false);
+      svg.setAttribute('aria-label', `Panel ${i + 1}, not yet revealed.`);
+    } else {
+      const n = Math.min(countFor(i), MAX_RELICS);
+      for (let k = 0; k < n; k++) {
+        relicBand(svg, k, RELICS[k], i === 2 && k === state.lastAdded);
+      }
+      const topY = BENCH_Y - n * (BAND_H + BAND_GAP);
+      if (i === 2) heightRule(svg, topY);
+      caption(svg, 30, f.seat, false);
+      caption(svg, i === 2 ? 292 : 48,
+        i === 0 ? 'One win. One relic.'
+        : i === 1 ? 'Four wins. Four relics. No washing.'
+        : `${n} relics stacked. The fan is ${HEAD_TOP > topY ? 'shorter' : 'still taller'} than the pile.`,
+        i === 2);
+      svg.setAttribute('aria-label',
+        `Panel ${i + 1}: ${f.name} on the bleacher bench at ${f.seat}, with ${n} superstition ` +
+        `object${n === 1 ? '' : 's'} stacked beside them — ${RELICS.slice(0, n).map(r => r.label).join(', ')}. ` +
+        (topY < HEAD_TOP ? 'The stack is taller than the seated fan.' : 'The stack is not yet taller than the fan.'));
+    }
+
+    document.getElementById('cap' + i).textContent = CAPTIONS[i];
+    document.getElementById('stamp' + i).textContent = revealedHere ? STAMPS[i] : '';
+  }
+
+  function syncCovers(){
+    for (let i = 0; i < 3; i++) {
+      const cover = document.getElementById('cover' + i);
+      const active = (i === state.revealed);
+      cover.hidden = i < state.revealed;
+      cover.disabled = i > state.revealed;
+      cover.textContent = active ? (i === 0 ? 'Click, tap, or press N to begin' : 'Click, tap, or press N to reveal') : 'In queue';
+      const art = document.getElementById('art' + i);
+      // Panel 3 stays the keyboard target once the strip is open — it is where relics stack.
+      const focusable = active || (state.revealed >= 3 && i === 2);
+      art.setAttribute('tabindex', focusable ? '0' : '-1');
+    }
+  }
+
+  function syncButtons(){
+    const nextBtn = document.getElementById('nextBtn');
+    if (state.revealed < 3) {
+      nextBtn.textContent = `Reveal Panel ${state.revealed + 1}`;
+      nextBtn.disabled = false;
+    } else if (state.stacked < MAX_RELICS) {
+      nextBtn.textContent = `Stack relic #${state.stacked + 1}`;
+      nextBtn.disabled = false;
+    } else {
+      nextBtn.textContent = 'Bench at capacity ✓';
+      nextBtn.disabled = true;
+    }
+  }
+
+  function paintAll(){
+    for (let i = 0; i < 3; i++) renderPanel(i);
+    syncCovers();
+    syncButtons();
+  }
+
+  function say(kind, msg){
+    const s = document.getElementById('status');
+    s.className = kind;
+    s.textContent = msg;
+  }
+
+  // one action: reveal the next panel, or — once all three are open — stack the next relic
+  function advance(){
+    if (state.revealed < 3) {
+      state.revealed++;
+      state.lastAdded = -1;
+      renderPanel(state.revealed - 1);
+      if (state.revealed < 3) renderPanel(state.revealed);
+      syncCovers();
+      syncButtons();
+      if (state.revealed >= 3) {
+        say('ok', `All 3 panels open. ${state.stacked} relics on the bench — press N or click Panel 3 to stack the next one.`);
+      } else {
+        say('ok', `Panel ${state.revealed} revealed.`);
+      }
+      return;
+    }
+    if (state.stacked >= MAX_RELICS) {
+      say('err', 'ERROR LS-451 · Bench at capacity. The league has asked the fan to stop. Try Plot Twist!');
+      return;
+    }
+    state.lastAdded = state.stacked;
+    state.stacked++;
+    renderPanel(2);
+    syncButtons();
+    say('ok', `Relic #${state.stacked} stacked: ${RELICS[state.stacked - 1].label}. Nothing may be moved.`);
+  }
+
+  function twist(){
+    if (state.revealed < 3) {
+      say('err', 'ERROR LS-409 · Premature twist — reveal all 3 panels first.');
+      return;
+    }
+    fanIdx = (fanIdx + 1) % FANS.length;
+    state.twists++;
+    state.stacked = 8;
+    state.lastAdded = -1;
+    paintAll();
+    say('ok', `Plot twist #${state.twists}: the seat changed hands. ${FANS[fanIdx].name} inherited the pile, the rule, and ${FANS[fanIdx].seat}.`);
+  }
+
+  document.getElementById('nextBtn').addEventListener('click', advance);
+  document.getElementById('twistBtn').addEventListener('click', twist);
+
+  for (let i = 0; i < 3; i++) {
+    const hit = () => { if (i === state.revealed || (state.revealed >= 3 && i === 2)) advance(); };
+    document.getElementById('cover' + i).addEventListener('click', hit);
+    document.getElementById('art' + i).addEventListener('click', hit);
+    document.getElementById('art' + i).addEventListener('keydown', (e) => {
+      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); hit(); }
+    });
+  }
+
+  document.addEventListener('keydown', (e) => {
+    if (e.metaKey || e.ctrlKey || e.altKey) return;
+    const tag = (e.target.tagName || '').toLowerCase();
+    if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
+    // Enter/Space on a focused panel or button is handled by those elements themselves.
+    if ((e.key === 'Enter' || e.key === ' ') ) return;
+    const k = e.key.toLowerCase();
+    if (k === 'n' || e.key === 'ArrowRight') { e.preventDefault(); advance(); }
+    else if (k === 't') { e.preventDefault(); twist(); }
+  });
+
+  paintAll();
+  say('info', 'Bench empty. Press N or click Panel 1 to begin the streak.');
+})();
+</script>
+</body>
+</html>
diff --git a/cartoons/20260926-festival-mascot-election.html b/cartoons/20260926-festival-mascot-election.html
new file mode 100644
index 0000000..346d08f
--- /dev/null
+++ b/cartoons/20260926-festival-mascot-election.html
@@ -0,0 +1,298 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>The Mascot Who Ran Unopposed — P24 Cartoon Desk</title>
+<meta name="description" content="An original invented-satire strip: a small-town festival's costumed mascot is accidentally entered on the Grand Marshal ballot, wins unopposed, and is sworn in still wearing the felt head." />
+<link rel="stylesheet" href="../assets/style.css" />
+</head>
+<body data-mood="entertainment" class="cartoon-page cartoon-page-wide">
+<header>
+  <div>
+    <div class="kicker">P24 Cartoon Desk &middot; Invented Satire</div>
+    <h1>The Mascot Who Ran Unopposed</h1>
+    <p class="dek">A festival mascot is typed onto the Grand Marshal ballot by clerical accident, wins with every vote cast, and takes the oath without removing the head.</p>
+  </div>
+  <div class="header-actions">
+    <a class="back" href="index.html">&larr; Cartoon Desk</a>
+  </div>
+</header>
+<main>
+  <div class="strip" id="strip">
+    <figure class="panel" data-i="0">
+      <div class="phead"><span>Panel 1 &middot; The Filing</span><span id="stamp0"></span></div>
+      <svg class="art" id="art0" viewBox="0 0 300 200" role="img" tabindex="0"></svg>
+      <button class="cover" id="cover0" type="button">Click, tap, or press N to begin</button>
+      <figcaption id="cap0"></figcaption>
+    </figure>
+    <figure class="panel" data-i="1">
+      <div class="phead"><span>Panel 2 &middot; The Count</span><span id="stamp1"></span></div>
+      <svg class="art" id="art1" viewBox="0 0 300 200" role="img" tabindex="0"></svg>
+      <button class="cover" id="cover1" type="button" disabled>In queue</button>
+      <figcaption id="cap1"></figcaption>
+    </figure>
+    <figure class="panel" data-i="2">
+      <div class="phead"><span>Panel 3 &middot; The Oath</span><span id="stamp2"></span></div>
+      <svg class="art" id="art2" viewBox="0 0 300 200" role="img" tabindex="0"></svg>
+      <button class="cover" id="cover2" type="button" disabled>In queue</button>
+      <figcaption id="cap2"></figcaption>
+    </figure>
+  </div>
+
+  <div class="dock">
+    <button class="primary" id="nextBtn" type="button">Certify Panel 1</button>
+    <button class="twist" id="twistBtn" type="button">Plot Twist!</button>
+    <div id="status" role="status" aria-live="polite"></div>
+  </div>
+  <p class="hint">Keys: N/&rarr; next panel &middot; T plot twist &middot; click a panel to advance</p>
+</main>
+<footer>Every critter, festival, clerk, and oath here is invented. No real mascot, town, party, or officeholder is depicted. Part of PANDEMONIUM-24.</footer>
+
+<script>
+(function(){
+  const NS = 'http://www.w3.org/2000/svg';
+  function el(tag, attrs, parent){
+    const n = document.createElementNS(NS, tag);
+    for (const k in attrs) n.setAttribute(k, attrs[k]);
+    if (parent) parent.appendChild(n);
+    return n;
+  }
+  function txt(parent, x, y, s, opts){
+    opts = opts || {};
+    const t = el('text', {
+      x: x, y: y, 'text-anchor': opts.anchor || 'middle',
+      'font-size': opts.size || 11,
+      'font-weight': opts.weight || '400',
+      fill: opts.fill || 'currentColor'
+    }, parent);
+    t.textContent = s;
+    return t;
+  }
+  function reduced(){ return matchMedia('(prefers-reduced-motion: reduce)').matches; }
+
+  /* ---- the invented critters. The Plot Twist button swaps WHICH one
+     got elected, so the same clerical accident replays with a
+     different felt head. Flat iconographic shapes only — the joke is
+     the paperwork, not the costume detail. ---- */
+  const CRITTERS = [
+    { name: 'Chomper the Rivermuskrat', fest: 'Bracken Hollow Silt Festival',  ears: 'round',  color: 'var(--rule)' },
+    { name: 'Pip the Orchard Moth',     fest: 'Quillan Falls Blossom Days',    ears: 'antenna', color: 'var(--seal)' },
+    { name: 'Dozer the Quarry Badger',  fest: 'Marlow Gap Gravel Jubilee',     ears: 'flat',   color: 'var(--rule)' },
+    { name: 'Sprocket the Ditch Heron', fest: 'Tumbleside Drainage Carnival',  ears: 'crest',  color: 'var(--seal)' },
+  ];
+  let critterIdx = 0;
+
+  // A costumed mascot: oversized felt head + small body. cy is the head center.
+  function mascot(svg, cx, cy, r, critter, opts){
+    opts = opts || {};
+    const g = el('g', {}, svg);
+    const c = critter.color;
+    // body
+    el('path', {
+      d: `M ${cx - r*0.62} ${cy + r*2.1} L ${cx - r*0.44} ${cy + r*0.78} L ${cx + r*0.44} ${cy + r*0.78} L ${cx + r*0.62} ${cy + r*2.1}`,
+      fill: 'none', stroke: 'currentColor', 'stroke-width': 2.2, 'stroke-linejoin': 'round'
+    }, g);
+    // ears / antennae, drawn behind the head
+    if (critter.ears === 'round') {
+      el('circle', { cx: cx - r*0.78, cy: cy - r*0.62, r: r*0.34, fill: c, 'fill-opacity': '0.25', stroke: c, 'stroke-width': 2 }, g);
+      el('circle', { cx: cx + r*0.78, cy: cy - r*0.62, r: r*0.34, fill: c, 'fill-opacity': '0.25', stroke: c, 'stroke-width': 2 }, g);
+    } else if (critter.ears === 'antenna') {
+      el('path', { d: `M ${cx - r*0.3} ${cy - r*0.9} Q ${cx - r*0.9} ${cy - r*1.7} ${cx - r*1.1} ${cy - r*1.1}`, fill: 'none', stroke: c, 'stroke-width': 2.2, 'stroke-linecap': 'round' }, g);
+      el('path', { d: `M ${cx + r*0.3} ${cy - r*0.9} Q ${cx + r*0.9} ${cy - r*1.7} ${cx + r*1.1} ${cy - r*1.1}`, fill: 'none', stroke: c, 'stroke-width': 2.2, 'stroke-linecap': 'round' }, g);
+    } else if (critter.ears === 'flat') {
+      el('rect', { x: cx - r*1.12, y: cy - r*0.72, width: r*0.5, height: r*0.34, rx: 3, fill: c, 'fill-opacity': '0.25', stroke: c, 'stroke-width': 2 }, g);
+      el('rect', { x: cx + r*0.62, y: cy - r*0.72, width: r*0.5, height: r*0.34, rx: 3, fill: c, 'fill-opacity': '0.25', stroke: c, 'stroke-width': 2 }, g);
+    } else { // crest
+      el('path', { d: `M ${cx - r*0.4} ${cy - r*0.92} L ${cx} ${cy - r*1.6} L ${cx + r*0.4} ${cy - r*0.92}`, fill: c, 'fill-opacity': '0.25', stroke: c, 'stroke-width': 2, 'stroke-linejoin': 'round' }, g);
+    }
+    // the felt head — never removed, in any panel
+    el('circle', { cx, cy, r, fill: c, 'fill-opacity': '0.18', stroke: c, 'stroke-width': 3 }, g);
+    el('circle', { cx: cx - r*0.34, cy: cy - r*0.08, r: Math.max(1.6, r*0.11), fill: c }, g);
+    el('circle', { cx: cx + r*0.34, cy: cy - r*0.08, r: Math.max(1.6, r*0.11), fill: c }, g);
+    // fixed permanent grin — the costume cannot stop smiling through any of this
+    el('path', { d: `M ${cx - r*0.4} ${cy + r*0.34} Q ${cx} ${cy + r*0.76} ${cx + r*0.4} ${cy + r*0.34}`, fill: 'none', stroke: c, 'stroke-width': 2.2, 'stroke-linecap': 'round' }, g);
+    if (opts.hand) { // a raised paw, for oath-taking
+      el('path', { d: `M ${cx + r*0.44} ${cy + r*1.0} L ${cx + r*1.05} ${cy + r*0.1}`, fill: 'none', stroke: 'currentColor', 'stroke-width': 2.2, 'stroke-linecap': 'round' }, g);
+      el('circle', { cx: cx + r*1.14, cy: cy - r*0.02, r: r*0.2, fill: c, 'fill-opacity': '0.3', stroke: c, 'stroke-width': 1.8 }, g);
+    }
+    if (opts.sash) { // GRAND MARSHAL sash
+      el('path', { d: `M ${cx - r*0.44} ${cy + r*0.9} L ${cx + r*0.5} ${cy + r*1.7}`, fill: 'none', stroke: c, 'stroke-width': r*0.36, 'stroke-linecap': 'round', 'stroke-opacity': '0.35' }, g);
+    }
+    return g;
+  }
+
+  // A clerk: plain official, no costume, so the contrast reads instantly.
+  function clerk(svg, cx, cy, r, label){
+    const g = el('g', {}, svg);
+    el('circle', { cx, cy, r: r*0.42, fill: 'none', stroke: 'currentColor', 'stroke-width': 2.2 }, g);
+    el('path', {
+      d: `M ${cx - r*0.62} ${cy + r*1.9} Q ${cx} ${cy + r*0.5} ${cx + r*0.62} ${cy + r*1.9}`,
+      fill: 'none', stroke: 'currentColor', 'stroke-width': 2.2
+    }, g);
+    if (label) txt(g, cx, cy + r*2.4, label, { size: 9, fill: 'var(--ink-dim)' });
+    return g;
+  }
+
+  function form(svg, x, y, w, h, lines, color){
+    const g = el('g', {}, svg);
+    el('rect', { x, y, width: w, height: h, rx: 3, fill: 'none', stroke: 'currentColor', 'stroke-width': 1.6 }, g);
+    for (let i = 0; i < lines; i++) {
+      const ly = y + h*0.26 + i * (h*0.52/Math.max(1, lines-1 || 1));
+      el('line', { x1: x + w*0.14, y1: ly, x2: x + w*0.86, y2: ly, stroke: color, 'stroke-width': 1.4, 'stroke-opacity': '0.65' }, g);
+    }
+    return g;
+  }
+
+  function seal(svg, cx, cy, r, lines, color){
+    const g = el('g', { transform: `rotate(-11 ${cx} ${cy})` }, svg);
+    el('circle', { cx, cy, r, fill: 'none', stroke: color, 'stroke-width': 3, 'stroke-dasharray': '4 3' }, g);
+    const size = Math.max(7, r*0.3);
+    const start = cy + size*0.36 - (lines.length - 1) * size*0.58;
+    lines.forEach((ln, i) => txt(g, cx, start + i*size*1.16, ln, { size, weight: '800', fill: color }));
+    return g;
+  }
+
+  /* --- Panel 1: the clerical accident. The mascot's costume name is
+     typed into the Grand Marshal field of the wrong form. --- */
+  function panel0(svg, critter){
+    svg.innerHTML = '';
+    clerk(svg, 62, 74, 22, 'Festival Clerk');
+    form(svg, 104, 52, 84, 76, 4, critter.color);
+    txt(svg, 146, 46, 'FORM GM-1 · GRAND MARSHAL', { size: 8, weight: '700', fill: 'var(--ink-dim)' });
+    mascot(svg, 244, 72, 20, critter);
+    txt(svg, 150, 152, 'The costume name goes in the candidate field.', { size: 11, weight: '700' });
+    txt(svg, 150, 170, 'Nobody notices until the ballots are printed.', { size: 10, fill: 'var(--ink-dim)' });
+    txt(svg, 150, 188, critter.fest, { size: 9, fill: 'var(--ink-dim)' });
+  }
+
+  /* --- Panel 2: the count. One name on the ballot, every vote for it,
+     and a turnout number that makes it worse. --- */
+  function panel1(svg, critter, votes){
+    svg.innerHTML = '';
+    form(svg, 26, 44, 92, 96, 1, critter.color);
+    txt(svg, 72, 38, 'OFFICIAL BALLOT', { size: 8, weight: '700', fill: 'var(--ink-dim)' });
+    txt(svg, 72, 78, critter.name.split(' ')[0], { size: 10, weight: '700' });
+    txt(svg, 72, 94, '(no other candidate filed)', { size: 7.5, fill: 'var(--ink-dim)' });
+    el('rect', { x: 34, y: 66, width: 9, height: 9, fill: critter.color }, svg);
+    seal(svg, 200, 84, 44, ['ELECTED', 'UNOPPOSED'], critter.color);
+    txt(svg, 150, 160, votes + ' votes cast. ' + votes + ' votes for the mascot.', { size: 11, weight: '700' });
+    txt(svg, 150, 178, 'The costume was not eligible to vote for itself.', { size: 10, fill: 'var(--ink-dim)' });
+  }
+
+  /* --- Panel 3: the oath. The head stays on, because the oath is
+     administered to the name on the certificate. --- */
+  function panel2(svg, critter, depth){
+    svg.innerHTML = '';
+    clerk(svg, 66, 70, 22, 'Town Attorney');
+    mascot(svg, 196, 68, 26, critter, { hand: true, sash: true });
+    txt(svg, 196, 130, 'GRAND MARSHAL', { size: 8, weight: '800', fill: critter.color });
+    el('line', { x1: 96, y1: 72, x2: 160, y2: 72, stroke: 'currentColor', 'stroke-width': 1.4, 'stroke-dasharray': '3 3' }, svg);
+    txt(svg, 150, 152, '"Raise your right paw."', { size: 11, weight: '700' });
+    txt(svg, 150, 170, 'The head stays on. The oath is to the name on the form.', { size: 9.5, fill: 'var(--ink-dim)' });
+    if (depth > 0) {
+      txt(svg, 150, 190, 'Sworn-in term extended ' + depth + ' more year' + (depth === 1 ? '' : 's') + ' for continuity.', { size: 9, fill: 'var(--ink-dim)' });
+    }
+  }
+
+  function captions(critter, votes, depth){
+    return [
+      'A clerk at the ' + critter.fest + ' types ' + critter.name + ' into the Grand Marshal candidate field of Form GM-1, where the costume performer’s legal name was supposed to go.',
+      'Ballots print with one name on them. All ' + votes + ' votes cast go to the mascot, which is the only option and is not itself eligible to vote.',
+      'The oath is administered to the name on the certificate, so the felt head stays on for the swearing-in' + (depth > 0 ? ', and the term is extended ' + depth + ' year' + (depth === 1 ? '' : 's') + ' to avoid a second printing.' : '.'),
+    ];
+  }
+
+  const STAMPS = ['FILED', 'CERTIFIED', 'SWORN IN'];
+  const state = { revealed: 0, twists: 0 };
+
+  function votesFor(){ return 214 + state.twists * 37; }
+
+  function renderPanel(i){
+    const svg = document.getElementById('art' + i);
+    const critter = CRITTERS[critterIdx];
+    const votes = votesFor();
+    const depth = state.twists;
+    if (i === 0) panel0(svg, critter);
+    if (i === 1) panel1(svg, critter, votes);
+    if (i === 2) panel2(svg, critter, depth);
+    svg.setAttribute('aria-label', 'Panel ' + (i+1) + ' of 3. ' + captions(critter, votes, depth)[i]);
+    document.getElementById('cap' + i).textContent = captions(critter, votes, depth)[i];
+    document.getElementById('stamp' + i).textContent = i < state.revealed ? STAMPS[i] : '';
+  }
+
+  function syncCovers(){
+    for (let i = 0; i < 3; i++) {
+      const cover = document.getElementById('cover' + i);
+      cover.hidden = i < state.revealed;
+      cover.disabled = i > state.revealed;
+      if (i === state.revealed) {
+        cover.textContent = i === 0 ? 'Click, tap, or press N to begin' : 'Click, tap, or press N to certify';
+      } else if (i > state.revealed) {
+        cover.textContent = 'In queue';
+      }
+      document.getElementById('art' + i).setAttribute('tabindex', i === state.revealed || (state.revealed >= 3 && i === 2) ? '0' : '-1');
+    }
+  }
+
+  function syncButtons(){
+    const nextBtn = document.getElementById('nextBtn');
+    if (state.revealed >= 3) { nextBtn.textContent = 'All 3 panels certified ✓'; nextBtn.disabled = true; }
+    else { nextBtn.textContent = 'Certify Panel ' + (state.revealed + 1); nextBtn.disabled = false; }
+  }
+
+  function paintAll(){
+    for (let i = 0; i < 3; i++) renderPanel(i);
+    syncCovers();
+    syncButtons();
+  }
+
+  function say(kind, msg){
+    const s = document.getElementById('status');
+    s.className = kind; s.textContent = msg;
+  }
+
+  function next(){
+    if (state.revealed >= 3) { say('info', 'All 3 panels are already certified. Try Plot Twist!'); return; }
+    state.revealed++;
+    renderPanel(state.revealed - 1);
+    syncCovers();
+    syncButtons();
+    if (state.revealed >= 3) say('ok', 'All 3 panels certified. Press Plot Twist! to swap which critter won.');
+    else say('ok', 'Panel ' + state.revealed + ' certified.');
+  }
+
+  function twist(){
+    if (state.revealed < 3) { say('err', 'ERROR GM-409 · Premature twist — certify all 3 panels first.'); return; }
+    critterIdx = (critterIdx + 1) % CRITTERS.length;
+    state.twists++;
+    for (let i = 0; i < 3; i++) renderPanel(i);
+    const c = CRITTERS[critterIdx];
+    say('ok', 'Plot twist #' + state.twists + ': it was ' + c.name + ' all along, at the ' + c.fest + ' — same form, same result.');
+  }
+
+  document.getElementById('nextBtn').addEventListener('click', next);
+  document.getElementById('twistBtn').addEventListener('click', twist);
+  for (let i = 0; i < 3; i++) {
+    document.getElementById('cover' + i).addEventListener('click', function(){ if (i === state.revealed) next(); });
+    document.getElementById('art' + i).addEventListener('click', function(){ if (i === state.revealed) next(); });
+    document.getElementById('art' + i).addEventListener('keydown', function(e){
+      if ((e.key === 'Enter' || e.key === ' ') && i === state.revealed) { e.preventDefault(); next(); }
+    });
+  }
+
+  document.addEventListener('keydown', function(e){
+    if (e.metaKey || e.ctrlKey || e.altKey) return;
+    const tag = (e.target.tagName || '').toLowerCase();
+    if (tag === 'input' || tag === 'textarea') return;
+    const k = e.key.toLowerCase();
+    if (k === 'n' || e.key === 'ArrowRight') { e.preventDefault(); next(); }
+    else if (k === 't') { e.preventDefault(); twist(); }
+  });
+
+  paintAll();
+  say('info', 'Election file open. Press N or click Panel 1 to begin.' + (reduced() ? '' : ''));
+})();
+</script>
+</body>
+</html>
diff --git a/cartoons/20260926-halftime-show-runaway.html b/cartoons/20260926-halftime-show-runaway.html
new file mode 100644
index 0000000..3a5db88
--- /dev/null
+++ b/cartoons/20260926-halftime-show-runaway.html
@@ -0,0 +1,304 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>The Halftime Show That Kept Adding Acts — P24 Cartoon Desk</title>
+<meta name="description" content="An original invented-satire strip: a small-stadium halftime show books just one more act between innings until the game is delayed by its own entertainment." />
+<link rel="stylesheet" href="../assets/style.css" />
+</head>
+<body data-mood="sports" class="cartoon-page cartoon-page-wide">
+<header>
+  <div>
+    <div class="kicker">P24 Cartoon Desk &middot; Invented Satire</div>
+    <h1>The Halftime Show That Kept Adding Acts</h1>
+    <p class="dek">An invented small-stadium entertainment committee books &ldquo;just one more act&rdquo; between innings. The acts keep arriving. The game does not resume.</p>
+  </div>
+  <div class="header-actions">
+    <a class="back" href="index.html">&larr; Cartoon Desk</a>
+  </div>
+</header>
+<main>
+  <div class="strip" id="strip">
+    <figure class="panel" data-i="0">
+      <div class="phead"><span>Panel 1 &middot; The Booking</span><span id="stamp0"></span></div>
+      <svg class="art" id="art0" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 1, not yet revealed."></svg>
+      <button class="cover" id="cover0" type="button">Click, tap, or press N to begin</button>
+      <figcaption id="cap0"></figcaption>
+    </figure>
+    <figure class="panel" data-i="1">
+      <div class="phead"><span>Panel 2 &middot; The Queue</span><span id="stamp1"></span></div>
+      <svg class="art" id="art1" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 2, not yet revealed."></svg>
+      <button class="cover" id="cover1" type="button" disabled>In queue</button>
+      <figcaption id="cap1"></figcaption>
+    </figure>
+    <figure class="panel" data-i="2">
+      <div class="phead"><span>Panel 3 &middot; The Delay</span><span id="stamp2"></span></div>
+      <svg class="art" id="art2" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 3, not yet revealed."></svg>
+      <button class="cover" id="cover2" type="button" disabled>In queue</button>
+      <figcaption id="cap2"></figcaption>
+    </figure>
+  </div>
+
+  <div class="dock">
+    <button class="primary" id="nextBtn" type="button">Announce Act 1</button>
+    <button class="twist" id="twistBtn" type="button">Plot Twist!</button>
+    <div id="status" role="status" aria-live="polite"></div>
+  </div>
+  <p class="hint">Keys: N/&rarr; next panel &middot; T plot twist &middot; click a panel to advance</p>
+</main>
+<footer>The Glenhaven Municipal Field, its entertainment committee, and every act named here are invented. No actual ballgame was delayed. Part of PANDEMONIUM-24.</footer>
+
+<script>
+(function(){
+  const NS = 'http://www.w3.org/2000/svg';
+  function el(tag, attrs, parent){
+    const n = document.createElementNS(NS, tag);
+    for (const k in attrs) n.setAttribute(k, attrs[k]);
+    if (parent) parent.appendChild(n);
+    return n;
+  }
+  function txt(svg, x, y, s, opts){
+    opts = opts || {};
+    const t = el('text', {
+      x: x, y: y, 'text-anchor': opts.anchor || 'middle',
+      'font-size': opts.size || 12,
+      'font-weight': opts.weight || '400',
+      fill: opts.fill || 'currentColor'
+    }, svg);
+    t.textContent = s;
+    return t;
+  }
+  function reduced(){ return matchMedia('(prefers-reduced-motion: reduce)').matches; }
+
+  // ---- invented acts booked by the entertainment committee ----
+  const ACTS = [
+    { name: 'Municipal Kazoo Corps',   short: 'kazoo band',       kind: 'kazoo',    color: '#b5302f' },
+    { name: 'Airborne Retrievers',     short: 'dog frisbee team', kind: 'frisbee',  color: '#0f6b52' },
+    { name: 'Wheelmen’s Relay',   short: 'unicycle relay',   kind: 'unicycle', color: '#6b4fa0' },
+  ];
+  let actIdx = 0;
+  function act(){ return ACTS[actIdx % ACTS.length]; }
+  function nextAct(n){ return ACTS[(actIdx + n) % ACTS.length]; }
+
+  /* ---- flat iconographic performers (no real people, no logos) ---- */
+  function performer(svg, cx, cy, s, color, kind){
+    const g = el('g', {}, svg);
+    if (kind === 'frisbee') {
+      // quadruped + a disc in the air
+      el('ellipse', { cx: cx, cy: cy, rx: s*0.62, ry: s*0.3, fill: color, 'fill-opacity': '0.85' }, g);
+      el('circle', { cx: cx + s*0.66, cy: cy - s*0.24, r: s*0.24, fill: color }, g);         // head
+      el('path', { d: `M ${cx-s*0.4} ${cy+s*0.26} v ${s*0.45} M ${cx+s*0.34} ${cy+s*0.26} v ${s*0.45}`,
+                   stroke: color, 'stroke-width': Math.max(2, s*0.15), 'stroke-linecap': 'round' }, g); // legs
+      el('path', { d: `M ${cx-s*0.6} ${cy-s*0.1} q ${-s*0.3} ${-s*0.3} ${-s*0.1} ${-s*0.5}`,
+                   fill: 'none', stroke: color, 'stroke-width': Math.max(2, s*0.13), 'stroke-linecap': 'round' }, g); // tail
+      el('ellipse', { cx: cx + s*0.2, cy: cy - s*1.15, rx: s*0.42, ry: s*0.14,
+                      fill: 'none', stroke: color, 'stroke-width': Math.max(2, s*0.15) }, g); // disc
+      return g;
+    }
+    if (kind === 'unicycle') {
+      el('circle', { cx: cx, cy: cy + s*0.62, r: s*0.46, fill: 'none', stroke: color, 'stroke-width': Math.max(2, s*0.16) }, g);
+      el('circle', { cx: cx, cy: cy + s*0.62, r: Math.max(1.5, s*0.07), fill: color }, g);    // hub
+      el('path', { d: `M ${cx} ${cy+s*0.16} v ${s*0.3}`, stroke: color, 'stroke-width': Math.max(2, s*0.14) }, g); // post
+      el('circle', { cx: cx, cy: cy - s*0.62, r: s*0.26, fill: color }, g);                   // head
+      el('path', { d: `M ${cx} ${cy-s*0.32} v ${s*0.48}`, stroke: color, 'stroke-width': Math.max(2, s*0.2), 'stroke-linecap':'round' }, g); // torso
+      el('path', { d: `M ${cx-s*0.5} ${cy-s*0.2} h ${s} `, stroke: color, 'stroke-width': Math.max(2, s*0.14), 'stroke-linecap':'round' }, g); // arms
+      return g;
+    }
+    // kazoo: marching figure with a short bar at the mouth + a plume hat
+    el('circle', { cx: cx, cy: cy - s*0.5, r: s*0.26, fill: color }, g);                      // head
+    el('path', { d: `M ${cx-s*0.28} ${cy-s*0.78} h ${s*0.56}`, stroke: color, 'stroke-width': Math.max(2, s*0.18), 'stroke-linecap':'round' }, g); // hat brim
+    el('path', { d: `M ${cx} ${cy-s*0.96} v ${s*0.18}`, stroke: color, 'stroke-width': Math.max(2, s*0.13), 'stroke-linecap':'round' }, g);        // plume
+    el('path', { d: `M ${cx+s*0.24} ${cy-s*0.5} h ${s*0.4}`, stroke: color, 'stroke-width': Math.max(2, s*0.15), 'stroke-linecap':'round' }, g);  // kazoo
+    el('path', { d: `M ${cx} ${cy-s*0.22} v ${s*0.6}`, stroke: color, 'stroke-width': Math.max(2, s*0.22), 'stroke-linecap':'round' }, g);        // torso
+    el('path', { d: `M ${cx-s*0.34} ${cy+s*0.82} l ${s*0.34} ${-s*0.4} l ${s*0.34} ${s*0.4}`,
+                 fill: 'none', stroke: color, 'stroke-width': Math.max(2, s*0.16), 'stroke-linecap':'round' }, g); // legs mid-march
+    return g;
+  }
+
+  function ground(svg, y){
+    el('path', { d: `M 10 ${y} H 290`, stroke: 'var(--ink-dim)', 'stroke-width': 2, opacity: 0.7 }, svg);
+  }
+
+  function board(svg, x, y, w, h, line1, line2, color){
+    const g = el('g', {}, svg);
+    el('rect', { x, y, width: w, height: h, rx: 4, fill: 'none', stroke: color, 'stroke-width': 3 }, g);
+    el('path', { d: `M ${x + w*0.3} ${y+h} v 14 M ${x + w*0.7} ${y+h} v 14`,
+                 stroke: color, 'stroke-width': 3 }, g);
+    txt(g, x + w/2, y + h*0.42, line1, { size: 11, weight: '800', fill: color });
+    txt(g, x + w/2, y + h*0.78, line2, { size: 10, fill: 'var(--ink-dim)' });
+    return g;
+  }
+
+  function official(svg, cx, cy, r, color){
+    const g = el('g', {}, svg);
+    el('circle', { cx, cy: cy - r*0.9, r: r*0.4, fill: color }, g);
+    el('path', { d: `M ${cx} ${cy-r*0.45} v ${r}`, stroke: color, 'stroke-width': r*0.34, 'stroke-linecap':'round' }, g);
+    // clipboard
+    el('rect', { x: cx + r*0.42, y: cy - r*0.3, width: r*0.66, height: r*0.86, rx: 2,
+                 fill: 'none', stroke: color, 'stroke-width': 2 }, g);
+    return g;
+  }
+
+  /* ---- Panel 1: the booking ---- */
+  function panel0(svg){
+    svg.innerHTML = '';
+    const a = act();
+    board(svg, 92, 18, 116, 40, 'INNING 4 · 2–1', 'NEXT: ONE MORE ACT', 'var(--seal)');
+    ground(svg, 246);
+    official(svg, 84, 224, 34, 'var(--ink)');
+    performer(svg, 196, 208, 34, a.color, a.kind);
+    txt(svg, 150, 96, '"We have room for one more act."', { size: 12, weight: '700' });
+    txt(svg, 150, 114, 'The committee books the ' + a.short + '.', { size: 11, fill: 'var(--ink-dim)' });
+    txt(svg, 196, 266, a.name, { size: 10, fill: 'var(--ink-dim)' });
+    svg.setAttribute('aria-label',
+      'Panel 1: a stadium scoreboard reads inning four, two to one, next: one more act. ' +
+      'An official with a clipboard books the ' + a.short + '.');
+  }
+
+  /* ---- Panel 2: the queue keeps growing ---- */
+  function panel1(svg, extra){
+    svg.innerHTML = '';
+    const n = 3 + extra;
+    board(svg, 80, 16, 140, 38, 'INTERMISSION ' + n, 'GAME: STILL INNING 4', 'var(--seal)');
+    ground(svg, 232);
+    const step = 250 / n;
+    for (let i = 0; i < n; i++) {
+      const a = nextAct(i);
+      const cx = 30 + step * i + step/2;
+      performer(svg, cx, 200, Math.max(13, 26 - n), a.color, a.kind);
+      el('path', { d: `M ${cx} 226 v 8`, stroke: a.color, 'stroke-width': 2, opacity: 0.6 }, svg);
+    }
+    txt(svg, 150, 90, n + ' acts are now queued on the baseline.', { size: 12, weight: '700' });
+    txt(svg, 150, 108, 'Each was booked to follow the previous one.', { size: 11, fill: 'var(--ink-dim)' });
+    txt(svg, 150, 262, 'The grounds crew has stopped raking.', { size: 11, fill: 'var(--ink-dim)' });
+    svg.setAttribute('aria-label',
+      'Panel 2: ' + n + ' invented acts stand queued along the baseline, each booked to follow the last, ' +
+      'while the scoreboard still reads inning four.');
+  }
+
+  /* ---- Panel 3: the game is delayed by its own entertainment ---- */
+  function panel2(svg, extra){
+    svg.innerHTML = '';
+    const a = act();
+    const mins = 40 + extra * 25;
+    board(svg, 74, 14, 152, 44, 'GAME DELAYED', 'CAUSE: ENTERTAINMENT', '#b5302f');
+    ground(svg, 240);
+    // the act, still performing, center field
+    performer(svg, 150, 196, 34, a.color, a.kind);
+    // the two teams, seated on the steps, waiting
+    for (let i = 0; i < 3; i++) {
+      const cx = 36 + i * 22;
+      el('circle', { cx, cy: 216, r: 6, fill: 'var(--ink-dim)' }, svg);
+      el('path', { d: `M ${cx} 224 v 12`, stroke: 'var(--ink-dim)', 'stroke-width': 4, 'stroke-linecap':'round' }, svg);
+    }
+    for (let i = 0; i < 3; i++) {
+      const cx = 220 + i * 22;
+      el('circle', { cx, cy: 216, r: 6, fill: 'var(--ink-dim)' }, svg);
+      el('path', { d: `M ${cx} 224 v 12`, stroke: 'var(--ink-dim)', 'stroke-width': 4, 'stroke-linecap':'round' }, svg);
+    }
+    txt(svg, 150, 90, 'Delay: ' + mins + ' minutes and counting.', { size: 12, weight: '800' });
+    txt(svg, 150, 108, 'Both dugouts are seated. The ' + a.short + ' is not finished.', { size: 10, fill: 'var(--ink-dim)' });
+    txt(svg, 150, 266, '"We can’t stop mid-act," the committee said.', { size: 11, weight: '700' });
+    svg.setAttribute('aria-label',
+      'Panel 3: the scoreboard reads game delayed, cause: entertainment, at ' + mins + ' minutes. ' +
+      'Both dugouts sit waiting while the ' + a.short + ' performs at center field.');
+  }
+
+  const CAPTIONS = [
+    'The Glenhaven entertainment committee finds an open slot between innings and books an act to fill it.',
+    'Every act is booked to follow the one before it, so the baseline queue only ever grows by one.',
+    'The game is now delayed by the entertainment scheduled to fill its delays. Nobody will stop mid-act.',
+  ];
+  const STAMPS = ['BOOKED', 'QUEUED', 'DELAYED'];
+
+  const state = { revealed: 0, twists: 0 };
+
+  function renderPanel(i){
+    const svg = document.getElementById('art' + i);
+    if (i === 0) panel0(svg);
+    // The queue only ever GROWS — never wrap the escalation back to zero,
+    // which would have the strip contradict its own premise. Capped at 5 so
+    // the baseline row stays legible at 390px.
+    const extra = Math.min(state.twists, 5);
+    if (i === 1) panel1(svg, extra);
+    if (i === 2) panel2(svg, extra);
+    document.getElementById('cap' + i).textContent = CAPTIONS[i];
+    document.getElementById('stamp' + i).textContent = i < state.revealed ? STAMPS[i] : '';
+  }
+
+  function syncCovers(){
+    for (let i = 0; i < 3; i++) {
+      const cover = document.getElementById('cover' + i);
+      cover.hidden = i < state.revealed;
+      cover.disabled = i > state.revealed;
+      if (i === state.revealed) {
+        cover.textContent = i === 0 ? 'Click, tap, or press N to begin' : 'Click, tap, or press N to announce';
+      }
+      document.getElementById('art' + i).setAttribute('tabindex', i === state.revealed ? '0' : (i < state.revealed ? '0' : '-1'));
+    }
+  }
+
+  function syncButtons(){
+    const nextBtn = document.getElementById('nextBtn');
+    if (state.revealed >= 3) { nextBtn.textContent = 'All 3 acts announced ✓'; nextBtn.disabled = true; }
+    else { nextBtn.textContent = `Announce Act ${state.revealed + 1}`; nextBtn.disabled = false; }
+  }
+
+  function paintAll(){
+    for (let i = 0; i < 3; i++) renderPanel(i);
+    syncCovers();
+    syncButtons();
+  }
+
+  function say(kind, msg){
+    const s = document.getElementById('status');
+    s.className = kind; s.textContent = msg;
+  }
+
+  function next(){
+    if (state.revealed >= 3) { say('info', 'All 3 acts are on the card. Try Plot Twist!'); return; }
+    state.revealed++;
+    renderPanel(state.revealed - 1);
+    syncCovers();
+    syncButtons();
+    if (state.revealed >= 3) say('ok', 'All 3 panels announced. Press Plot Twist! to swap the headline act.');
+    else say('ok', `Act ${state.revealed} announced. The next one is already booked.`);
+  }
+
+  function twist(){
+    if (state.revealed < 3) { say('err', 'ERROR HT-409 · Premature twist — announce all 3 acts first.'); return; }
+    actIdx = (actIdx + 1) % ACTS.length;
+    state.twists++;
+    paintAll();
+    const a = act();
+    say('ok', state.twists <= 5
+      ? `Plot twist #${state.twists}: the headline act is now the ${a.short}, and one more act joins the queue.`
+      : `Plot twist #${state.twists}: the headline act is now the ${a.short}. The baseline is full; the rest wait in the parking lot.`);
+  }
+
+  document.getElementById('nextBtn').addEventListener('click', next);
+  document.getElementById('twistBtn').addEventListener('click', twist);
+  for (let i = 0; i < 3; i++) {
+    document.getElementById('cover' + i).addEventListener('click', () => { if (i === state.revealed) next(); });
+    document.getElementById('art' + i).addEventListener('click', () => { if (i === state.revealed) next(); });
+    document.getElementById('art' + i).addEventListener('keydown', (e) => {
+      if ((e.key === 'Enter' || e.key === ' ') && i === state.revealed) { e.preventDefault(); next(); }
+    });
+  }
+
+  document.addEventListener('keydown', (e) => {
+    if (e.metaKey || e.ctrlKey || e.altKey) return;
+    const tag = (e.target.tagName || '').toLowerCase();
+    if (tag === 'input' || tag === 'textarea') return;
+    const k = e.key.toLowerCase();
+    if (k === 'n' || e.key === 'ArrowRight') { e.preventDefault(); next(); }
+    else if (k === 't') { e.preventDefault(); twist(); }
+  });
+
+  paintAll();
+  say('info', 'Program card open. Press N or click Panel 1 to begin.');
+})();
+</script>
+</body>
+</html>
diff --git a/cartoons/20260926-rainbow-permit-office.html b/cartoons/20260926-rainbow-permit-office.html
new file mode 100644
index 0000000..147ef7e
--- /dev/null
+++ b/cartoons/20260926-rainbow-permit-office.html
@@ -0,0 +1,341 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>The Rainbow That Needed a Permit — P24 Cartoon Desk</title>
+<meta name="description" content="An original invented-satire strip: a recurring afternoon rainbow over an invented town is asked to file for a standing-display permit, and a clipboard-wielding clerk measures its arc." />
+<link rel="stylesheet" href="../assets/style.css" />
+</head>
+<body data-mood="weather" class="cartoon-page cartoon-page-wide">
+<header>
+  <div>
+    <div class="kicker">P24 Cartoon Desk &middot; Invented Satire</div>
+    <h1>The Rainbow That Needed a Permit</h1>
+    <p class="dek">An invented town's rainbow keeps turning up in the same spot every afternoon, so the permit office sends a clerk with a clipboard to measure the arc.</p>
+  </div>
+  <div class="header-actions">
+    <a class="back" href="index.html">&larr; Cartoon Desk</a>
+  </div>
+</header>
+<main>
+  <div class="strip" id="strip">
+    <figure class="panel" data-i="0">
+      <div class="phead"><span>Panel 1 &middot; The Sighting</span><span id="stamp0"></span></div>
+      <svg class="art" id="art0" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 1, not yet revealed."></svg>
+      <button class="cover" id="cover0" type="button">Click, tap, or press N to begin</button>
+      <figcaption id="cap0"></figcaption>
+    </figure>
+    <figure class="panel" data-i="1">
+      <div class="phead"><span>Panel 2 &middot; The Filing</span><span id="stamp1"></span></div>
+      <svg class="art" id="art1" viewBox="0 0 300 300" role="img" tabindex="-1" aria-label="Panel 2, in queue."></svg>
+      <button class="cover" id="cover1" type="button" disabled>In queue</button>
+      <figcaption id="cap1"></figcaption>
+    </figure>
+    <figure class="panel" data-i="2">
+      <div class="phead"><span>Panel 3 &middot; The Conditions</span><span id="stamp2"></span></div>
+      <svg class="art" id="art2" viewBox="0 0 300 300" role="img" tabindex="-1" aria-label="Panel 3, in queue."></svg>
+      <button class="cover" id="cover2" type="button" disabled>In queue</button>
+      <figcaption id="cap2"></figcaption>
+    </figure>
+  </div>
+
+  <div class="dock">
+    <button class="primary" id="nextBtn" type="button">Advance Review Stage 1</button>
+    <button class="twist" id="twistBtn" type="button">Plot Twist!</button>
+    <div id="status" role="status" aria-live="polite"></div>
+  </div>
+  <p class="hint">Keys: N/&rarr; next panel &middot; T plot twist &middot; click a panel to advance</p>
+</main>
+<footer>The town of Pellow Bend, its permit office, its clerks, and its rainbow are all invented. No actual atmospheric phenomenon was cited. Part of PANDEMONIUM-24.</footer>
+
+<script>
+(function(){
+  const NS = 'http://www.w3.org/2000/svg';
+  function el(tag, attrs, parent){
+    const n = document.createElementNS(NS, tag);
+    for (const k in attrs) n.setAttribute(k, attrs[k]);
+    if (parent) parent.appendChild(n);
+    return n;
+  }
+  function txt(svg, x, y, s, opts){
+    const o = opts || {};
+    const t = el('text', {
+      x: x, y: y,
+      'text-anchor': o.anchor || 'middle',
+      'font-size': o.size || 12,
+      'font-weight': o.weight || '400',
+      fill: o.fill || 'currentColor'
+    }, svg);
+    t.textContent = s;
+    return t;
+  }
+  function reduced(){ return matchMedia('(prefers-reduced-motion: reduce)').matches; }
+
+  // ---- invented clerks of the Pellow Bend permit office ----
+  const CLERKS = [
+    { name: 'Clerk Ordway',  color: '#b5302f' },
+    { name: 'Clerk Vestine', color: '#0f6b52' },
+    { name: 'Clerk Habersham', color: '#6b4fa0' },
+    { name: 'Clerk Nunley',  color: '#c9a227' },
+  ];
+  let clerkIdx = 0;
+
+  // The rainbow's own bands are content, not theme: a rainbow that
+  // renders in one ink is not a rainbow. Everything else uses the
+  // site's palette vars so the strip matches the rest of the desk.
+  const BANDS = ['#c0392b','#d98324','#d9b310','#2e7d4f','#2b6cb0','#5b3fa0'];
+
+  function skyline(svg, y){
+    el('line', { x1: 0, y1: y, x2: 300, y2: y, stroke: 'currentColor', 'stroke-width': 2, opacity: 0.55 }, svg);
+    const roofs = [[18,26,16],[50,34,22],[90,20,13],[196,30,19],[236,24,15],[266,32,20]];
+    roofs.forEach(function(r){
+      const x = r[0], w = r[1], h = r[2];
+      el('rect', { x: x, y: y - h, width: w, height: h, fill: 'currentColor', opacity: 0.18 }, svg);
+      el('path', { d: 'M ' + (x-3) + ' ' + (y-h) + ' L ' + (x + w/2) + ' ' + (y-h-8) + ' L ' + (x+w+3) + ' ' + (y-h) + ' Z',
+                   fill: 'currentColor', opacity: 0.3 }, svg);
+    });
+  }
+
+  // Half-arc rainbow centred on (cx, baseY), outer radius r.
+  function rainbow(svg, cx, baseY, r, opts){
+    const o = opts || {};
+    const g = el('g', {}, svg);
+    const bw = (o.bandWidth || 7);
+    BANDS.forEach(function(c, i){
+      const rr = r - i * bw;
+      if (rr <= bw) return;
+      el('path', {
+        d: 'M ' + (cx - rr) + ' ' + baseY + ' A ' + rr + ' ' + rr + ' 0 0 1 ' + (cx + rr) + ' ' + baseY,
+        fill: 'none', stroke: c, 'stroke-width': bw - 1,
+        'stroke-opacity': o.faded ? 0.45 : 0.9, 'stroke-linecap': 'butt'
+      }, g);
+    });
+    return g;
+  }
+
+  // Flat iconographic clerk: body, head, and a clipboard held out front.
+  function clerk(svg, cx, footY, scale, color, label){
+    const s = scale;
+    const g = el('g', {}, svg);
+    el('circle', { cx: cx, cy: footY - 42*s, r: 9*s, fill: color }, g);                    // head
+    el('path', { d: 'M ' + cx + ' ' + (footY - 33*s) + ' L ' + cx + ' ' + (footY - 12*s),
+                 stroke: color, 'stroke-width': 7*s, 'stroke-linecap': 'round' }, g);      // torso
+    el('path', { d: 'M ' + cx + ' ' + (footY - 12*s) + ' L ' + (cx - 7*s) + ' ' + footY,
+                 stroke: color, 'stroke-width': 4*s, 'stroke-linecap': 'round' }, g);      // leg
+    el('path', { d: 'M ' + cx + ' ' + (footY - 12*s) + ' L ' + (cx + 7*s) + ' ' + footY,
+                 stroke: color, 'stroke-width': 4*s, 'stroke-linecap': 'round' }, g);      // leg
+    el('path', { d: 'M ' + cx + ' ' + (footY - 28*s) + ' L ' + (cx + 14*s) + ' ' + (footY - 24*s),
+                 stroke: color, 'stroke-width': 4*s, 'stroke-linecap': 'round' }, g);      // arm
+    // clipboard
+    el('rect', { x: cx + 12*s, y: footY - 34*s, width: 14*s, height: 18*s, rx: 1.5*s,
+                 fill: 'var(--panel)', stroke: color, 'stroke-width': 2*s }, g);
+    el('rect', { x: cx + 16*s, y: footY - 36*s, width: 6*s, height: 3*s, rx: 1*s, fill: color }, g);
+    for (var i = 0; i < 3; i++) {
+      el('line', { x1: cx + 14.5*s, y1: footY - 29*s + i*4.5*s, x2: cx + 23.5*s, y2: footY - 29*s + i*4.5*s,
+                   stroke: color, 'stroke-width': 1.2*s, opacity: 0.7 }, g);
+    }
+    // Right-anchored so a long clerk name can never clip the viewBox edge.
+    if (label) txt(svg, 296, footY + 15, label, { size: 11, anchor: 'end', fill: 'var(--ink-dim)' });
+    return g;
+  }
+
+  function stampMark(svg, cx, cy, r, lines, color){
+    const g = el('g', { transform: 'rotate(-12 ' + cx + ' ' + cy + ')' }, svg);
+    el('circle', { cx: cx, cy: cy, r: r, fill: 'none', stroke: color, 'stroke-width': 3.5, 'stroke-dasharray': '5 3' }, g);
+    const t = el('text', { x: cx, y: cy - (lines.length - 1) * 6 + 4, 'text-anchor': 'middle',
+                           'font-weight': '800', 'font-size': Math.max(9, r * 0.3), fill: color }, g);
+    lines.forEach(function(line, i){
+      const ts = el('tspan', { x: cx, dy: i === 0 ? 0 : 12 }, t);
+      ts.textContent = line;
+    });
+    return g;
+  }
+
+  // ---- Panel 1: the rainbow turns up again; the clerk measures it ----
+  function panel0(svg, clerkDef, day){
+    svg.innerHTML = '';
+    const baseY = 238, r = 96;
+    skyline(svg, baseY);
+    rainbow(svg, 150, baseY, r);
+    // Apex measurement: a vertical dashed line inside the arc, so it
+    // never crosses (and muddies) the colored bands.
+    el('line', { x1: 150, y1: baseY, x2: 150, y2: baseY - r + 6, stroke: clerkDef.color,
+                 'stroke-width': 1.6, 'stroke-dasharray': '5 4' }, svg);
+    el('line', { x1: 142, y1: baseY - r + 6, x2: 158, y2: baseY - r + 6,
+                 stroke: clerkDef.color, 'stroke-width': 1.6 }, svg);
+    txt(svg, 150, baseY - r - 8, '41° at apex', { size: 11, weight: '700', fill: clerkDef.color });
+    clerk(svg, 264, baseY, 0.8, clerkDef.color, clerkDef.name);
+    txt(svg, 150, 34, 'Day ' + day + '. Same field. Same 4:10 p.m.', { size: 13, weight: '700' });
+    txt(svg, 150, 54, 'Pellow Bend Permit Office opens a file.', { size: 11, fill: 'var(--ink-dim)' });
+  }
+
+  // ---- Panel 2: the paperwork ----
+  function panel1(svg, clerkDef, permitNo){
+    svg.innerHTML = '';
+    el('rect', { x: 34, y: 44, width: 166, height: 196, rx: 4, fill: 'var(--panel)',
+                 stroke: 'currentColor', 'stroke-width': 2 }, svg);
+    txt(svg, 117, 66, 'FORM W-7', { size: 13, weight: '800' });
+    txt(svg, 117, 82, 'STANDING DISPLAY', { size: 10, fill: 'var(--ink-dim)' });
+    txt(svg, 117, 94, '(ATMOSPHERIC)', { size: 10, fill: 'var(--ink-dim)' });
+    el('line', { x1: 48, y1: 104, x2: 186, y2: 104, stroke: 'currentColor', 'stroke-width': 1, opacity: 0.4 }, svg);
+    const rows = [
+      ['Applicant', 'unreachable'],
+      ['Height', '41° at apex'],
+      ['Span', 'field to silo'],
+      ['Owner', 'none listed'],
+      ['Duration', 'nightly, 9 min'],
+      ['Fee', '$40.00 unpaid']
+    ];
+    rows.forEach(function(r, i){
+      const y = 122 + i * 19;
+      txt(svg, 48, y, r[0], { size: 10, anchor: 'start', fill: 'var(--ink-dim)' });
+      txt(svg, 186, y, r[1], { size: 10, anchor: 'end', weight: '700' });
+      el('line', { x1: 48, y1: y + 5, x2: 186, y2: y + 5, stroke: 'currentColor', 'stroke-width': 0.8, opacity: 0.22 }, svg);
+    });
+    txt(svg, 117, 232, 'No. ' + permitNo, { size: 10, fill: 'var(--ink-dim)' });
+    // a small, faded rainbow filed as an exhibit
+    rainbow(svg, 242, 184, 40, { bandWidth: 5, faded: true });
+    txt(svg, 242, 202, 'EXHIBIT A', { size: 9, fill: 'var(--ink-dim)' });
+    stampMark(svg, 238, 88, 38, ['UNDER', 'REVIEW'], clerkDef.color);
+    txt(svg, 150, 268, 'The rainbow is asked to appear in person.', { size: 12, weight: '700' });
+  }
+
+  // ---- Panel 3: approved, with conditions ----
+  function panel2(svg, clerkDef, permitNo, conditions){
+    svg.innerHTML = '';
+    const baseY = 244;
+    skyline(svg, baseY);
+    rainbow(svg, 150, baseY, 100);
+    // permit placard zip-tied to the west leg of the arc
+    el('line', { x1: 38, y1: baseY, x2: 38, y2: baseY - 46, stroke: 'currentColor',
+                 'stroke-width': 1.2, 'stroke-dasharray': '3 3', opacity: 0.6 }, svg);
+    el('rect', { x: 14, y: baseY - 80, width: 62, height: 34, rx: 3, fill: 'var(--panel)',
+                 stroke: clerkDef.color, 'stroke-width': 2 }, svg);
+    txt(svg, 45, baseY - 66, 'PERMIT', { size: 9, weight: '800', fill: clerkDef.color });
+    txt(svg, 45, baseY - 54, permitNo, { size: 9, fill: 'var(--ink-dim)' });
+    // the conditions, stacked
+    txt(svg, 150, 34, 'APPROVED, WITH ' + conditions.length + ' CONDITIONS', { size: 12, weight: '800' });
+    conditions.forEach(function(c, i){
+      txt(svg, 150, 54 + i * 15, c, { size: 10, fill: 'var(--ink-dim)' });
+    });
+    clerk(svg, 266, baseY, 0.8, clerkDef.color, clerkDef.name);
+    txt(svg, 150, 282, 'It appeared anyway, at 4:10, as always.', { size: 12, weight: '700' });
+  }
+
+  const ALL_CONDITIONS = [
+    'Must not obstruct the water tower.',
+    'Renewable every 90 days, in person.',
+    'No colors added without review.',
+    'Apex may not exceed 41°.',
+    'Display limited to weekday afternoons.',
+    'One arc per parcel.',
+    'Subject to inspection without notice.'
+  ];
+
+  const CAPTIONS = [
+    'A recurring afternoon rainbow over an invented town shows up in the same field for the twelfth day running, and a clerk arrives to measure its arc.',
+    'A Form W-7 standing-display application is opened on the rainbow’s behalf. The applicant cannot be reached for the $40 fee.',
+    'The permit is granted with conditions the rainbow has no way to read, and it appears on schedule regardless.'
+  ];
+  const STAMPS = ['LOGGED', 'FILED', 'APPROVED'];
+
+  const state = { revealed: 0, twists: 0 };
+
+  function clerkFor(i){ return CLERKS[(clerkIdx + i) % CLERKS.length]; }
+  function permitNo(){ return 'W7-' + (1104 + state.twists * 3); }
+  function day(){ return 12 + state.twists; }
+  function conditions(){ return ALL_CONDITIONS.slice(0, 3 + (state.twists % 4)); }
+
+  function renderPanel(i){
+    const svg = document.getElementById('art' + i);
+    const done = i < state.revealed;
+    if (!done) {
+      svg.innerHTML = '';
+      svg.setAttribute('aria-label', 'Panel ' + (i + 1) + ', ' + (i === state.revealed ? 'not yet revealed.' : 'in queue.'));
+      document.getElementById('cap' + i).textContent = '';
+      document.getElementById('stamp' + i).textContent = '';
+      return;
+    }
+    const c = clerkFor(i);
+    if (i === 0) panel0(svg, c, day());
+    if (i === 1) panel1(svg, c, permitNo());
+    if (i === 2) panel2(svg, c, permitNo(), conditions());
+    svg.setAttribute('aria-label', 'Panel ' + (i + 1) + '. ' + CAPTIONS[i]);
+    document.getElementById('cap' + i).textContent = CAPTIONS[i];
+    document.getElementById('stamp' + i).textContent = STAMPS[i];
+  }
+
+  function syncCovers(){
+    for (let i = 0; i < 3; i++) {
+      const cover = document.getElementById('cover' + i);
+      const art = document.getElementById('art' + i);
+      cover.hidden = i < state.revealed;
+      cover.disabled = i !== state.revealed;
+      cover.textContent = i === 0 ? 'Click, tap, or press N to begin' : 'Click, tap, or press N to review';
+      art.setAttribute('tabindex', i === state.revealed || (state.revealed >= 3 && i === 2) ? '0' : '-1');
+    }
+  }
+
+  function syncButtons(){
+    const nextBtn = document.getElementById('nextBtn');
+    if (state.revealed >= 3) { nextBtn.textContent = 'All 3 stages reviewed ✓'; nextBtn.disabled = true; }
+    else { nextBtn.textContent = 'Advance Review Stage ' + (state.revealed + 1); nextBtn.disabled = false; }
+  }
+
+  function say(kind, msg){
+    const s = document.getElementById('status');
+    s.className = kind;
+    s.textContent = msg;
+  }
+
+  function paintAll(){
+    for (let i = 0; i < 3; i++) renderPanel(i);
+    syncCovers();
+    syncButtons();
+  }
+
+  function next(){
+    if (state.revealed >= 3) { say('info', 'All 3 stages are already on file. Try Plot Twist!'); return; }
+    state.revealed++;
+    renderPanel(state.revealed - 1);
+    syncCovers();
+    syncButtons();
+    if (state.revealed >= 3) say('ok', 'Review complete. Press Plot Twist! to reassign the file.');
+    else say('ok', 'Stage ' + state.revealed + ' reviewed.');
+  }
+
+  function twist(){
+    if (state.revealed < 3) { say('err', 'ERROR PT-409 · Premature twist — advance all 3 stages first.'); return; }
+    clerkIdx = (clerkIdx + 1) % CLERKS.length;
+    state.twists++;
+    for (let i = 0; i < 3; i++) renderPanel(i);
+    say('ok', 'Plot twist #' + state.twists + ': the file is reassigned to ' + clerkFor(0).name +
+              ', who finds ' + conditions().length + ' conditions and starts the count at day ' + day() + '.');
+  }
+
+  document.getElementById('nextBtn').addEventListener('click', next);
+  document.getElementById('twistBtn').addEventListener('click', twist);
+  for (let i = 0; i < 3; i++) {
+    document.getElementById('cover' + i).addEventListener('click', function(){ if (i === state.revealed) next(); });
+    document.getElementById('art' + i).addEventListener('click', function(){ if (i === state.revealed) next(); });
+    document.getElementById('art' + i).addEventListener('keydown', function(e){
+      if ((e.key === 'Enter' || e.key === ' ') && i === state.revealed) { e.preventDefault(); next(); }
+    });
+  }
+
+  document.addEventListener('keydown', function(e){
+    if (e.metaKey || e.ctrlKey || e.altKey) return;
+    const tag = (e.target.tagName || '').toLowerCase();
+    if (tag === 'input' || tag === 'textarea') return;
+    const k = e.key.toLowerCase();
+    if (k === 'n' || e.key === 'ArrowRight') { e.preventDefault(); next(); }
+    else if (k === 't') { e.preventDefault(); twist(); }
+  });
+
+  paintAll();
+  say('info', 'File opened on an unlicensed rainbow. Press N or click Panel 1 to begin.');
+})();
+</script>
+</body>
+</html>
diff --git a/cartoons/20260926-server-room-thermostat-war.html b/cartoons/20260926-server-room-thermostat-war.html
new file mode 100644
index 0000000..7dd8d77
--- /dev/null
+++ b/cartoons/20260926-server-room-thermostat-war.html
@@ -0,0 +1,366 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>The Thermostat Standoff — P24 Editorial Cartoon Desk</title>
+<meta name="description" content="An original invented-satire strip: an invented office's server room and break room fight a silent war over one shared thermostat dial, stacking sticky-note demands ever higher." />
+<link rel="stylesheet" href="../assets/style.css" />
+</head>
+<body data-mood="scitech" class="cartoon-page cartoon-page-wide">
+<header>
+  <div>
+    <div class="kicker">P24 Editorial Cartoon Desk &middot; Invented Satire</div>
+    <h1>The Thermostat Standoff</h1>
+    <p class="dek">One dial. Two rooms. The server room needs it cold. The break room needs it warm. Neither will speak to the other, so they write notes.</p>
+  </div>
+  <div class="header-actions">
+    <a class="back" href="index.html">&larr; Cartoon Desk</a>
+  </div>
+</header>
+<main>
+  <div class="strip" id="strip">
+    <figure class="panel" data-i="0">
+      <div class="phead"><span>Panel 1 &middot; The Dial</span><span id="stamp0"></span></div>
+      <svg class="art" id="art0" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 1: one thermostat on a corridor wall between a server room door and a break room door."></svg>
+      <button class="cover" id="cover0" type="button">Click, tap, or press N to begin</button>
+      <figcaption id="cap0"></figcaption>
+    </figure>
+    <figure class="panel" data-i="1">
+      <div class="phead"><span>Panel 2 &middot; The Notes</span><span id="stamp1"></span></div>
+      <svg class="art" id="art1" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 2: sticky notes begin stacking on both sides of the thermostat."></svg>
+      <button class="cover" id="cover1" type="button" disabled>In queue</button>
+      <figcaption id="cap1"></figcaption>
+    </figure>
+    <figure class="panel" data-i="2">
+      <div class="phead"><span>Panel 3 &middot; The Standoff</span><span id="stamp2"></span></div>
+      <svg class="art" id="art2" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 3: two towers of sticky-note demands flank the dial, which reports who is currently winning."></svg>
+      <button class="cover" id="cover2" type="button" disabled>In queue</button>
+      <figcaption id="cap2"></figcaption>
+    </figure>
+  </div>
+
+  <div class="slider-row" id="dialRow" hidden>
+    <div class="row1">
+      <label for="dial">Drag the dial</label>
+      <span class="val" id="dialVal">68&deg;F &middot; contested</span>
+    </div>
+    <input type="range" id="dial" min="58" max="80" step="1" value="68"
+           aria-describedby="dialWho" />
+    <div class="val" id="dialWho" role="status" aria-live="polite">Currently winning: nobody. The dial is contested.</div>
+  </div>
+
+  <div class="dock">
+    <button class="primary" id="nextBtn" type="button">Reveal Panel 1</button>
+    <button class="twist" id="twistBtn" type="button">Plot Twist!</button>
+    <div id="status" role="status" aria-live="polite"></div>
+  </div>
+  <p class="hint">Keys: N/&rarr; next panel &middot; T plot twist &middot; click a panel to advance &middot; after panel 3, arrow keys on the dial set the temperature</p>
+</main>
+<footer>The Halliwell Building, its floors, its facilities staff, and every sticky note quoted here are invented. No real office, employee, or thermostat was consulted. Part of PANDEMONIUM-24.</footer>
+
+<script>
+(function(){
+  'use strict';
+  const NS = 'http://www.w3.org/2000/svg';
+  function el(tag, attrs, parent){
+    const n = document.createElementNS(NS, tag);
+    for (const k in attrs) n.setAttribute(k, attrs[k]);
+    if (parent) parent.appendChild(n);
+    return n;
+  }
+  function txt(parent, x, y, s, opts){
+    opts = opts || {};
+    const t = el('text', {
+      x: x, y: y,
+      'text-anchor': opts.anchor || 'middle',
+      'font-size': opts.size || 11,
+      'font-weight': opts.weight || '400',
+      fill: opts.fill || 'currentColor'
+    }, parent);
+    t.textContent = s;
+    return t;
+  }
+  function reduced(){ return matchMedia('(prefers-reduced-motion: reduce)').matches; }
+
+  // ---- the two invented sides. Not people: departments. ----
+  const COLD = { key:'cold', label:'SERVER ROOM', color:'var(--focus)', want:'colder' };
+  const WARM = { key:'warm', label:'BREAK ROOM',  color:'var(--accent)', want:'warmer' };
+
+  // Escalating sticky-note demands. Each plot twist unlocks one more rung.
+  const DEMANDS = {
+    cold: [
+      'DO NOT TOUCH',
+      'The racks do not have sweaters.',
+      'Every degree up is $41/mo.',
+      'We were here first (2011).',
+      'Facilities has been notified.',
+      'We have logged all 46 changes.',
+    ],
+    warm: [
+      'It is 64 in here.',
+      'People also do not have sweaters.',
+      'Coffee is going cold in the cup.',
+      'We were here first (2009).',
+      'Facilities has ALSO been notified.',
+      'We keep a log too. Ours is longer.',
+    ]
+  };
+
+  const CAPTIONS = [
+    'One dial serves a corridor with a server room on one end and a break room on the other. It was installed in 2011 and has never been discussed out loud.',
+    'Neither side will raise it in a meeting, so the negotiation moves to adhesive paper. Each note is signed only with a department.',
+    'The demands are now taller than the thermostat. The dial reports whichever room last got to it; nobody has conceded anything.',
+  ];
+
+  const state = { revealed: 0, twists: 0, temp: 68 };
+
+  // 68 is the declared neutral. Below = server room winning, above = break room.
+  const NEUTRAL = 68;
+  function winner(t){
+    if (t < NEUTRAL - 1) return COLD;
+    if (t > NEUTRAL + 1) return WARM;
+    return null;
+  }
+  function rungs(){ return Math.min(2 + state.twists, 6); }
+
+  // ---------- drawing helpers ----------
+  function wall(svg){
+    el('line', { x1:0, y1:236, x2:300, y2:236, stroke:'currentColor', 'stroke-width':2, opacity:.55 }, svg);
+  }
+
+  function door(svg, x, label, color){
+    const g = el('g', { 'data-kind':'door' }, svg);
+    el('rect', { x:x, y:150, width:52, height:86, fill:'none', stroke:color, 'stroke-width':2.6, rx:2 }, g);
+    el('circle', { cx:x+44, cy:196, r:2.6, fill:color }, g);
+    // The label is wider than the 52px door by design (it names the room, it is
+    // not a sign ON the door). In panel 3 the doors sit hard against the 0/300
+    // viewBox edges, so a naively centred label runs off-canvas. Re-centre from
+    // the MEASURED bbox rather than an estimate — character-width guesses are
+    // font-dependent and were off by ~8px here, which still clipped the label.
+    const t = txt(g, x + 26, 250, label, { size: 8.5, weight:'800', fill:color });
+    t.setAttribute('letter-spacing', '.04em');
+    try {
+      const b = t.getBBox();
+      const half = b.width / 2;
+      const want = Math.min(Math.max(x + 26, half + 2), 298 - half);
+      if (want !== x + 26) t.setAttribute('x', want);
+    } catch (_) { /* getBBox throws if not yet laid out; centred fallback is fine */ }
+    return g;
+  }
+
+  // The thermostat itself: a round dial on a plate, needle set by temp.
+  function thermostat(svg, cx, cy, r, temp, hot){
+    const g = el('g', {}, svg);
+    el('rect', { x:cx-r-9, y:cy-r-9, width:(r+9)*2, height:(r+9)*2, rx:5,
+                 fill:'var(--panel)', stroke:'currentColor', 'stroke-width':2 }, g);
+    el('circle', { cx, cy, r, fill:'none', stroke:'currentColor', 'stroke-width':2.2, opacity:.8 }, g);
+    // tick marks
+    for (let i=0;i<=10;i++){
+      const a = (-210 + i*24) * Math.PI/180;
+      el('line', {
+        x1: cx + Math.cos(a)*(r-3), y1: cy + Math.sin(a)*(r-3),
+        x2: cx + Math.cos(a)*(r-8), y2: cy + Math.sin(a)*(r-8),
+        stroke:'currentColor', 'stroke-width':1.4, opacity:.6
+      }, g);
+    }
+    // needle
+    const frac = (temp - 58) / (80 - 58);
+    const ang = (-210 + frac * 240) * Math.PI/180;
+    el('line', { x1:cx, y1:cy, x2: cx + Math.cos(ang)*(r-11), y2: cy + Math.sin(ang)*(r-11),
+                 stroke: hot ? WARM.color : COLD.color, 'stroke-width':3.4, 'stroke-linecap':'round' }, g);
+    el('circle', { cx, cy, r:3.4, fill:'currentColor' }, g);
+    txt(g, cx, cy + r + 1, temp + '°', { size:11, weight:'800' });
+    return g;
+  }
+
+  // One sticky note. Text is clipped to the note by hand-wrapping at ~15 chars.
+  function sticky(svg, x, y, w, color, line, rot){
+    const g = el('g', { 'data-kind':'note', transform: `rotate(${rot} ${x+w/2} ${y+16})` }, svg);
+    el('rect', { x, y, width:w, height:32, fill:color, 'fill-opacity':'.16',
+                 stroke:color, 'stroke-width':1.6 }, g);
+    const words = String(line).split(' ');
+    const lines = [];
+    let cur = '';
+    words.forEach(function(word){
+      if ((cur + ' ' + word).trim().length > 16 && cur) { lines.push(cur); cur = word; }
+      else cur = (cur + ' ' + word).trim();
+    });
+    if (cur) lines.push(cur);
+    const show = lines.slice(0, 3);
+    show.forEach(function(ln, i){
+      txt(g, x + w/2, y + 12 + i*9, ln, { size:6.6, fill:color, weight:'700' });
+    });
+    return g;
+  }
+
+  function noteStack(svg, side, xLeft, w, count, color){
+    for (let i=0;i<count;i++){
+      const line = DEMANDS[side][i % DEMANDS[side].length];
+      sticky(svg, xLeft, 196 - i*34, w, color, line, (i%2 ? 1.6 : -1.9));
+    }
+  }
+
+  // ---------- the three panels ----------
+  function panel0(svg){
+    svg.innerHTML = '';
+    wall(svg);
+    door(svg, 16, COLD.label, COLD.color);
+    door(svg, 232, WARM.label, WARM.color);
+    thermostat(svg, 150, 168, 30, NEUTRAL, false);
+    txt(svg, 150, 44, 'ONE DIAL. ONE CORRIDOR.', { size:12, weight:'800' });
+    txt(svg, 150, 62, 'Installed 2011. Never discussed.', { size:9.5, fill:'var(--ink-dim)' });
+    txt(svg, 150, 276, 'Set point: ' + NEUTRAL + '°F. Agreed by nobody.', { size:9, fill:'var(--ink-dim)' });
+  }
+
+  function panel1(svg){
+    svg.innerHTML = '';
+    wall(svg);
+    door(svg, 16, COLD.label, COLD.color);
+    door(svg, 232, WARM.label, WARM.color);
+    thermostat(svg, 150, 168, 26, NEUTRAL, false);
+    noteStack(svg, 'cold', 74, 62, 2, COLD.color);
+    noteStack(svg, 'warm', 164, 62, 2, WARM.color);
+    txt(svg, 150, 40, 'THE NEGOTIATION BEGINS', { size:11.5, weight:'800' });
+    txt(svg, 150, 58, 'in writing, unsigned, one department at a time', { size:8.5, fill:'var(--ink-dim)' });
+    txt(svg, 150, 276, 'Two notes each. Neither room has spoken.', { size:9, fill:'var(--ink-dim)' });
+  }
+
+  function panel2(svg){
+    svg.innerHTML = '';
+    const n = rungs();
+    wall(svg);
+    door(svg, 6, COLD.label, COLD.color);
+    door(svg, 242, WARM.label, WARM.color);
+    const w = winner(state.temp);
+    thermostat(svg, 150, 176, 24, state.temp, !!(w && w.key === 'warm'));
+    noteStack(svg, 'cold', 66, 58, n, COLD.color);
+    noteStack(svg, 'warm', 176, 58, n, WARM.color);
+    // The verdict goes at the BOTTOM, not the top: the stack grows UPWARD with
+    // each twist, and at full height (6 rungs) the topmost note reaches y=26 —
+    // exactly where a top headline sits. Bottom also reads better, as the
+    // standings line under the standoff.
+    const head = w ? ('WINNING: ' + w.label) : 'CONTESTED — NOBODY WINNING';
+    txt(svg, 150, 16, n * 2 + ' notes on file. 0 conversations held.', { size:8.5, fill:'var(--ink-dim)' });
+    txt(svg, 150, 288, head, { size:11.5, weight:'800', fill: w ? w.color : 'currentColor' });
+  }
+
+  // ---------- state plumbing ----------
+  const STAMPS = ['INSTALLED','IN WRITING','STANDOFF'];
+
+  function renderPanel(i){
+    const svg = document.getElementById('art' + i);
+    if (i === 0) panel0(svg);
+    if (i === 1) panel1(svg);
+    if (i === 2) panel2(svg);
+    document.getElementById('cap' + i).textContent = CAPTIONS[i];
+    document.getElementById('stamp' + i).textContent = i < state.revealed ? STAMPS[i] : '';
+  }
+
+  function syncCovers(){
+    for (let i=0;i<3;i++){
+      const cover = document.getElementById('cover' + i);
+      cover.hidden = i < state.revealed;
+      cover.disabled = i > state.revealed;
+      if (i === state.revealed) {
+        cover.textContent = i === 0 ? 'Click, tap, or press N to begin' : 'Click, tap, or press N to reveal';
+      }
+      document.getElementById('art' + i).setAttribute('tabindex', i === state.revealed ? '0' : '-1');
+    }
+  }
+
+  function syncDial(){
+    const row = document.getElementById('dialRow');
+    row.hidden = state.revealed < 3;
+    const w = winner(state.temp);
+    document.getElementById('dial').value = String(state.temp);
+    document.getElementById('dialVal').textContent =
+      state.temp + '°F · ' + (w ? w.label.toLowerCase() + ' ahead' : 'contested');
+    document.getElementById('dialWho').textContent = w
+      ? ('Currently winning: the ' + w.label.toLowerCase() + '. It wants it ' + w.want + ' and right now it is.')
+      : 'Currently winning: nobody. The dial is within a degree of the 68° set point.';
+  }
+
+  function syncButtons(){
+    const b = document.getElementById('nextBtn');
+    if (state.revealed >= 3) { b.textContent = 'All 3 panels revealed ✓'; b.disabled = true; }
+    else { b.textContent = 'Reveal Panel ' + (state.revealed + 1); b.disabled = false; }
+  }
+
+  function say(kind, msg){
+    const s = document.getElementById('status');
+    s.className = kind;
+    s.textContent = msg;
+  }
+
+  function paintAll(){
+    for (let i=0;i<3;i++) renderPanel(i);
+    syncCovers(); syncDial(); syncButtons();
+  }
+
+  function next(){
+    if (state.revealed >= 3) { say('info', 'All 3 panels are already up. Drag the dial, or try Plot Twist!'); return; }
+    state.revealed++;
+    renderPanel(state.revealed - 1);
+    syncCovers(); syncDial(); syncButtons();
+    if (state.revealed >= 3) {
+      say('ok', 'All 3 panels revealed. Drag the dial to see who is winning — or press Plot Twist!');
+    } else {
+      say('ok', 'Panel ' + state.revealed + ' revealed.');
+    }
+  }
+
+  function twist(){
+    if (state.revealed < 3) { say('err', 'ERROR TS-409 · Premature escalation — reveal all 3 panels first.'); return; }
+    state.twists++;
+    // Each twist: both sides add a rung, and whoever was losing lunges for the dial.
+    const w = winner(state.temp);
+    if (!w) state.temp = state.twists % 2 ? 62 : 74;
+    else state.temp = w.key === 'cold' ? Math.min(80, state.temp + 7) : Math.max(58, state.temp - 7);
+    renderPanel(2);
+    syncDial();
+    const nw = winner(state.temp);
+    say('ok', 'Plot twist #' + state.twists + ': ' + rungs() * 2 + ' notes now on the wall, and the '
+      + (nw ? nw.label.toLowerCase() : 'corridor') + ' got to the dial last.');
+  }
+
+  function setTemp(v){
+    const t = Math.max(58, Math.min(80, parseInt(v, 10) || NEUTRAL));
+    if (t === state.temp) return;
+    state.temp = t;
+    if (state.revealed >= 3) renderPanel(2);
+    syncDial();
+  }
+
+  // ---------- wiring ----------
+  document.getElementById('nextBtn').addEventListener('click', next);
+  document.getElementById('twistBtn').addEventListener('click', twist);
+  document.getElementById('dial').addEventListener('input', function(e){ setTemp(e.target.value); });
+
+  for (let i=0;i<3;i++){
+    document.getElementById('cover' + i).addEventListener('click', function(){ if (i === state.revealed) next(); });
+    document.getElementById('art' + i).addEventListener('click', function(){ if (i === state.revealed) next(); });
+    document.getElementById('art' + i).addEventListener('keydown', function(e){
+      if ((e.key === 'Enter' || e.key === ' ') && i === state.revealed) { e.preventDefault(); next(); }
+    });
+  }
+
+  document.addEventListener('keydown', function(e){
+    if (e.metaKey || e.ctrlKey || e.altKey) return;
+    const tag = (e.target.tagName || '').toLowerCase();
+    if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
+    const k = e.key.toLowerCase();
+    if (k === 'n' || e.key === 'ArrowRight') { e.preventDefault(); next(); }
+    else if (k === 't') { e.preventDefault(); twist(); }
+  });
+
+  // reduced motion: nothing here animates, but keep the hook honest for
+  // anything added later (the strip is state-swap only, no transitions).
+  if (reduced()) document.documentElement.setAttribute('data-reduced-motion', 'true');
+
+  paintAll();
+  say('info', 'Corridor on file. Press N or click Panel 1 to begin.');
+})();
+</script>
+</body>
+</html>
diff --git a/cartoons/20260926-statue-selfie-line.html b/cartoons/20260926-statue-selfie-line.html
new file mode 100644
index 0000000..f00f5ad
--- /dev/null
+++ b/cartoons/20260926-statue-selfie-line.html
@@ -0,0 +1,327 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>The Line for the Statue for the Line — P24 Cartoon Desk</title>
+<meta name="description" content="An original invented-satire strip: the queue to photograph a roadside attraction gets so popular a statue is erected of the queue, which draws a queue of its own." />
+<link rel="stylesheet" href="../assets/style.css" />
+</head>
+<body data-mood="entertainment" class="cartoon-page cartoon-page-wide">
+<header>
+  <div>
+    <div class="kicker">P24 Cartoon Desk &middot; Invented Satire</div>
+    <h1>The Line for the Statue for the Line</h1>
+    <p class="dek">An invented roadside attraction draws a photo line. The line draws a statue. The statue draws a line. Nobody has reached the front.</p>
+  </div>
+  <div class="header-actions">
+    <a class="back" href="index.html">&larr; Cartoon Desk</a>
+  </div>
+</header>
+<main>
+  <div class="strip" id="strip">
+    <figure class="panel" data-i="0">
+      <div class="phead"><span>Panel 1 &middot; The Attraction</span><span id="stamp0"></span></div>
+      <svg class="art" id="art0" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 1, not yet revealed."></svg>
+      <button class="cover" id="cover0" type="button">Click, tap, or press N to begin</button>
+      <figcaption id="cap0"></figcaption>
+    </figure>
+    <figure class="panel" data-i="1">
+      <div class="phead"><span>Panel 2 &middot; The Unveiling</span><span id="stamp1"></span></div>
+      <svg class="art" id="art1" viewBox="0 0 300 300" role="img" tabindex="-1" aria-label="Panel 2, in queue."></svg>
+      <button class="cover" id="cover1" type="button" disabled>In queue</button>
+      <figcaption id="cap1"></figcaption>
+    </figure>
+    <figure class="panel" data-i="2">
+      <div class="phead"><span>Panel 3 &middot; The Queue of Queues</span><span id="stamp2"></span></div>
+      <svg class="art" id="art2" viewBox="0 0 300 300" role="img" tabindex="-1" aria-label="Panel 3, in queue."></svg>
+      <button class="cover" id="cover2" type="button" disabled>In queue</button>
+      <figcaption id="cap2"></figcaption>
+    </figure>
+  </div>
+
+  <div class="dock">
+    <button class="primary" id="nextBtn" type="button">Unveil Panel 1</button>
+    <button class="twist" id="twistBtn" type="button">Plot Twist!</button>
+    <div id="status" role="status" aria-live="polite"></div>
+  </div>
+  <p class="hint">Keys: N/&rarr; next panel &middot; T plot twist (erects the next mini-statue) &middot; click a panel to advance</p>
+</main>
+<footer>The attraction, the town, the tour buses, and every statue-of-a-statue here are invented. No real monument, artist, or municipality is depicted. Part of PANDEMONIUM-24.</footer>
+
+<script>
+(function(){
+  const NS = 'http://www.w3.org/2000/svg';
+  function el(tag, attrs, parent){
+    const n = document.createElementNS(NS, tag);
+    for (const k in attrs) n.setAttribute(k, attrs[k]);
+    if (parent) parent.appendChild(n);
+    return n;
+  }
+  // prefers-reduced-motion: respected by construction — every panel is drawn as a
+  // static SVG with no transitions, transforms-over-time, or timers, so there is no
+  // motion to reduce. (Per TK-12226 this page adds no <style> block of its own, so
+  // an animation + its reduced-motion override would have nowhere to live anyway.)
+
+  // ---- invented roadside attractions — flat iconographic silhouettes, never a real monument ----
+  // `s0` is the per-shape hero scale: each silhouette has a different natural
+  // height, so this normalises them all to roughly fill the panel without
+  // colliding with the sign at the top.
+  const ATTRACTIONS = [
+    { name: 'The Enormous Pinto Bean', short: 'Pinto Bean', color: '#b5302f', shape: 'bean', s0: 2.6 },
+    { name: 'The Great Galvanized Pelican', short: 'Galvanized Pelican', color: '#0f6b52', shape: 'bird', s0: 2.0 },
+    { name: "The World's Second-Largest Thermos", short: 'Second-Largest Thermos', color: '#c9a227', shape: 'thermos', s0: 1.9 },
+    { name: 'The Colossal Wind-Chime', short: 'Colossal Wind-Chime', color: '#6b4fa0', shape: 'chime', s0: 2.1 },
+  ];
+  let attrIdx = 0;
+  const state = { revealed: 0, spawns: 0 };
+
+  // ---------- drawing primitives ----------
+  function plinth(svg, cx, topY, w, h, color){
+    el('rect', { x: cx - w/2, y: topY, width: w, height: h, fill: color, 'fill-opacity': '0.15', stroke: color, 'stroke-width': 2.5 }, svg);
+  }
+
+  // The invented attraction itself, drawn as a flat silhouette on a plinth.
+  function attraction(svg, cx, baseY, scale, a){
+    const g = el('g', {}, svg);
+    const s = scale, c = a.color;
+    const topY = baseY - 18 * s;
+    plinth(svg, cx, topY, 46 * s, 18 * s, c);
+    if (a.shape === 'bean') {
+      el('path', { d: `M ${cx - 26*s} ${topY - 26*s} q ${12*s} ${-30*s} ${26*s} ${-26*s} q ${20*s} ${6*s} ${24*s} ${26*s} q ${-6*s} ${18*s} ${-26*s} ${18*s} q ${-20*s} ${0} ${-24*s} ${-18*s} z`,
+        fill: c, 'fill-opacity': '0.55', stroke: c, 'stroke-width': 2.5*s }, g);
+      el('path', { d: `M ${cx - 12*s} ${topY - 34*s} q ${12*s} ${8*s} ${22*s} ${2*s}`, fill: 'none', stroke: c, 'stroke-width': 2*s, 'stroke-linecap': 'round' }, g);
+    } else if (a.shape === 'bird') {
+      el('ellipse', { cx, cy: topY - 24*s, rx: 20*s, ry: 24*s, fill: c, 'fill-opacity': '0.5', stroke: c, 'stroke-width': 2.5*s }, g);
+      el('circle', { cx: cx + 4*s, cy: topY - 50*s, r: 10*s, fill: c, 'fill-opacity': '0.7', stroke: c, 'stroke-width': 2*s }, g);
+      el('path', { d: `M ${cx + 13*s} ${topY - 50*s} l ${18*s} ${5*s} l ${-18*s} ${5*s} z`, fill: c }, g);
+    } else if (a.shape === 'thermos') {
+      el('rect', { x: cx - 16*s, y: topY - 58*s, width: 32*s, height: 58*s, rx: 7*s, fill: c, 'fill-opacity': '0.5', stroke: c, 'stroke-width': 2.5*s }, g);
+      el('rect', { x: cx - 19*s, y: topY - 68*s, width: 38*s, height: 12*s, rx: 4*s, fill: c, 'fill-opacity': '0.8', stroke: c, 'stroke-width': 2*s }, g);
+      el('line', { x1: cx - 16*s, y1: topY - 30*s, x2: cx + 16*s, y2: topY - 30*s, stroke: c, 'stroke-width': 2*s }, g);
+    } else {
+      el('rect', { x: cx - 24*s, y: topY - 62*s, width: 48*s, height: 7*s, rx: 3*s, fill: c, 'fill-opacity': '0.8', stroke: c, 'stroke-width': 2*s }, g);
+      [-14, 0, 14].forEach((dx, i) => {
+        el('line', { x1: cx + dx*s, y1: topY - 55*s, x2: cx + dx*s, y2: topY - (28 - i*6)*s, stroke: c, 'stroke-width': 2*s }, g);
+        el('rect', { x: cx + (dx - 4)*s, y: topY - (28 - i*6)*s, width: 8*s, height: 16*s, rx: 2*s, fill: c, 'fill-opacity': '0.55', stroke: c, 'stroke-width': 1.6*s }, g);
+      });
+    }
+    return g;
+  }
+
+  // One flat visitor icon; `cam` adds a raised camera, `looking` faces left/right.
+  function visitor(svg, cx, baseY, scale, color, cam){
+    const g = el('g', {}, svg);
+    const s = scale;
+    el('circle', { cx, cy: baseY - 20*s, r: 5.5*s, fill: color }, g);
+    el('path', { d: `M ${cx - 7*s} ${baseY} q ${0} ${-13*s} ${7*s} ${-13*s} q ${7*s} ${0} ${7*s} ${13*s}`,
+      fill: color, 'fill-opacity': '0.45', stroke: color, 'stroke-width': 1.8*s }, g);
+    if (cam) el('rect', { x: cx + 4*s, y: baseY - 26*s, width: 9*s, height: 6.5*s, rx: 1.5*s, fill: 'none', stroke: color, 'stroke-width': 1.8*s }, g);
+    return g;
+  }
+
+  // A queue of visitors walking toward `towardX`.
+  function queue(svg, startX, baseY, count, step, scale, color, cam){
+    const g = el('g', {}, svg);
+    for (let i = 0; i < count; i++) visitor(g, startX + i*step, baseY, scale, color, cam && i % 2 === 0);
+    return g;
+  }
+
+  // A *statue of a queue*: the queue silhouette, frozen, up on its own plinth.
+  function queueStatue(svg, cx, baseY, scale, color, people){
+    const g = el('g', {}, svg);
+    const s = scale;
+    plinth(g, cx, baseY - 11*s, (14*people + 10)*s, 11*s, color);
+    const startX = cx - (people - 1) * 7 * s;
+    for (let i = 0; i < people; i++) {
+      visitor(g, startX + i*14*s, baseY - 11*s, 0.85*s, color, i % 2 === 0);
+    }
+    return g;
+  }
+
+  function ground(svg, y){
+    el('line', { x1: 8, y1: y, x2: 292, y2: y, stroke: 'var(--rule)', 'stroke-width': 2, opacity: 0.55 }, svg);
+  }
+
+  function caption(svg, y, text, opts){
+    opts = opts || {};
+    const t = el('text', { x: 150, y, 'text-anchor': 'middle', 'font-size': opts.size || '12',
+      'font-weight': opts.bold ? '700' : '400', fill: opts.dim ? 'var(--ink-dim)' : 'currentColor' }, svg);
+    t.textContent = text;
+    return t;
+  }
+
+  function signpost(svg, cx, y, label, color){
+    const g = el('g', {}, svg);
+    // Box is sized off the label so longer signs ("STATUE OF THE LINE") don't
+    // spill outside their own dashed border.
+    const w = Math.min(276, Math.max(76, label.length * 6.1 + 20));
+    el('rect', { x: cx - w/2, y: y - 13, width: w, height: 19, rx: 3, fill: 'none', stroke: color, 'stroke-width': 2, 'stroke-dasharray': '4 3' }, g);
+    const t = el('text', { x: cx, y: y + 1, 'text-anchor': 'middle', 'font-size': '9', 'font-weight': '800', fill: color }, g);
+    t.textContent = label;
+    return g;
+  }
+
+  // ---------- panels ----------
+  // Raises a statue onto a display pedestal reaching down to the ground line.
+  function pedestal(svg, cx, topY, groundY, w, color){
+    el('rect', { x: cx - w/2, y: topY, width: w, height: groundY - topY, fill: color, 'fill-opacity': '0.1', stroke: color, 'stroke-width': 2 }, svg);
+  }
+
+  const GY = 252; // shared ground line, so all three panels sit on the same horizon
+
+  function panel0(svg, a){
+    svg.textContent = '';
+    signpost(svg, 150, 40, 'PHOTO LINE', a.color);
+    ground(svg, GY);
+    attraction(svg, 78, GY, a.s0, a);
+    queue(svg, 158, GY, 4, 28, 1.7, 'currentColor', true);
+    caption(svg, 284, 'Wait time: 40 minutes. Everyone agrees it is worth it.', { dim: true });
+  }
+
+  function panel1(svg, a){
+    svg.textContent = '';
+    signpost(svg, 150, 40, 'STATUE OF THE LINE', a.color);
+    ground(svg, GY);
+    // the original attraction, now upstaged and receding
+    attraction(svg, 38, GY, a.s0 * 0.44, a);
+    // the original line, still standing, now itself the thing being photographed
+    queue(svg, 74, GY, 3, 19, 1.35, 'var(--ink-dim)', false);
+    // the brand-new statue OF that line, up on a pedestal
+    pedestal(svg, 238, 190, GY, 32, a.color);
+    queueStatue(svg, 238, 190, 1.3, a.color, 4);
+    // and the line that has already formed to photograph it
+    queue(svg, 140, GY, 3, 18, 1.3, 'currentColor', true);
+    caption(svg, 284, 'The line is now the attraction. A plaque is ordered.', { dim: true });
+  }
+
+  function panel2(svg, a, spawns){
+    svg.textContent = '';
+    const depth = 2 + spawns;   // statues-of-a-queue standing in the row
+    signpost(svg, 150, 40, 'QUEUE OF QUEUES', a.color);
+    ground(svg, GY);
+    // Each statue depicts the queue for the statue to its left; they recede to the right.
+    let cx = 60, s = 1.25, people = 5, drawn = 0;
+    for (let d = 0; d < depth; d++) {
+      const w = (14 * people + 10) * s;
+      if (cx + w/2 > 230 || s < 0.3) break;                 // leave the right edge for the live queue
+      pedestal(svg, cx, GY - 44*s, GY, Math.max(12, 22*s), d % 2 === 0 ? a.color : 'var(--seal)');
+      queueStatue(svg, cx, GY - 44*s, s, d % 2 === 0 ? a.color : 'var(--seal)', people);
+      drawn++;
+      const nextS = s * 0.74, nextPeople = Math.max(2, people - 1);
+      cx += w/2 + ((14 * nextPeople + 10) * nextS)/2 + 8;
+      s = nextS; people = nextPeople;
+    }
+    // the only living people left in the picture: the line for the smallest statue
+    queue(svg, 242, GY, 3, 14, 1.05, 'currentColor', true);
+    if (drawn < depth) caption(svg, 268, `(+${depth - drawn} more off-frame)`, { size: '9', dim: true });
+    caption(svg, 284, `Statues of lines standing: ${depth}. Lines for them: ${depth}.`, { bold: true });
+  }
+
+  // ---------- captions / a11y text ----------
+  function captions(a, depth){
+    return [
+      `Visitors queue down the shoulder of an invented county highway to photograph ${a.name}, an attraction of no fixed purpose.`,
+      `The photo line outgrows the statue, so the invented town unveils a statue of the line — which immediately forms a line of its own.`,
+      `Queue-of-queues count: ${depth}. Each new statue commemorates the people waiting for the last one.`,
+    ];
+  }
+  function alts(a, depth){
+    return [
+      `Flat cartoon: ${a.name} on a plinth beside a highway, with seven visitors queued up holding cameras under a sign reading photo line.`,
+      `Flat cartoon: the original photo line now stands next to a smaller statue of that same line on its own plinth, with four new visitors queueing to photograph the statue of the line.`,
+      `Flat cartoon: a receding row of ${depth} progressively smaller statues, each depicting the queue for the one before it, each with a small queue of its own.`,
+    ];
+  }
+
+  // ---------- render ----------
+  const STAMPS = ['PHOTOGRAPHED', 'UNVEILED', 'RECURSIVE'];
+
+  function renderPanel(i){
+    const a = ATTRACTIONS[attrIdx];
+    const depth = 2 + state.spawns;
+    const svg = document.getElementById('art' + i);
+    if (i === 0) panel0(svg, a);
+    if (i === 1) panel1(svg, a);
+    if (i === 2) panel2(svg, a, state.spawns);
+    const shown = i < state.revealed;
+    svg.setAttribute('aria-label', shown ? alts(a, depth)[i] : `Panel ${i+1}, not yet revealed.`);
+    document.getElementById('cap' + i).textContent = shown ? captions(a, depth)[i] : '';
+    document.getElementById('stamp' + i).textContent = shown ? STAMPS[i] : '';
+    const cover = document.getElementById('cover' + i);
+    cover.hidden = shown;
+    cover.disabled = i !== state.revealed;
+    if (i === state.revealed) cover.textContent = i === 0 ? 'Click, tap, or press N to begin' : 'Click, tap, or press N to unveil';
+    else if (!shown) cover.textContent = 'In queue';
+    svg.setAttribute('tabindex', i === state.revealed || (state.revealed >= 3 && i === 2) ? '0' : '-1');
+  }
+
+  function paintAll(){ for (let i = 0; i < 3; i++) renderPanel(i); syncButtons(); }
+
+  function syncButtons(){
+    const nextBtn = document.getElementById('nextBtn');
+    if (state.revealed >= 3) { nextBtn.textContent = 'All 3 panels unveiled ✓'; nextBtn.disabled = true; }
+    else { nextBtn.textContent = `Unveil Panel ${state.revealed + 1}`; nextBtn.disabled = false; }
+  }
+
+  function say(kind, msg){
+    const s = document.getElementById('status');
+    s.className = kind; s.textContent = msg;
+  }
+
+  function next(){
+    if (state.revealed >= 3) { spawn(); return; }   // brief: clicking the finished strip erects the next mini-statue
+    state.revealed++;
+    paintAll();
+    if (state.revealed >= 3) say('ok', 'All 3 panels unveiled. Press Plot Twist! to erect the next statue of the line.');
+    else say('ok', `Panel ${state.revealed} unveiled.`);
+    const active = document.getElementById('art' + Math.min(state.revealed, 2));
+    if (active && document.activeElement && document.activeElement.classList.contains('cover')) active.focus();
+  }
+
+  // Plot twist = a new invented attraction takes over AND the recursion grows one statue deeper.
+  function twist(){
+    if (state.revealed < 3) { say('err', 'ERROR SL-409 · Premature twist — unveil all 3 panels first.'); return; }
+    attrIdx = (attrIdx + 1) % ATTRACTIONS.length;
+    spawn(true);
+  }
+
+  function spawn(fromTwist){
+    if (state.revealed < 3) { say('err', 'ERROR SL-409 · Nothing to commemorate yet — unveil all 3 panels first.'); return; }
+    state.spawns++;
+    paintAll();
+    const depth = 2 + state.spawns;
+    const a = ATTRACTIONS[attrIdx];
+    say('ok', fromTwist
+      ? `Plot twist #${state.spawns}: the ${a.short} is the new headline attraction, and statue #${depth} of the line goes up. Queue-of-queues count: ${depth}.`
+      : `Statue #${depth} of the line unveiled. Queue-of-queues count: ${depth}.`);
+  }
+
+  document.getElementById('nextBtn').addEventListener('click', next);
+  document.getElementById('twistBtn').addEventListener('click', twist);
+  for (let i = 0; i < 3; i++) {
+    document.getElementById('cover' + i).addEventListener('click', () => { if (i === state.revealed) next(); });
+    const art = document.getElementById('art' + i);
+    art.addEventListener('click', () => { if (i === state.revealed) next(); else if (state.revealed >= 3 && i === 2) spawn(); });
+    art.addEventListener('keydown', (e) => {
+      if (e.key !== 'Enter' && e.key !== ' ') return;
+      if (i === state.revealed) { e.preventDefault(); next(); }
+      else if (state.revealed >= 3 && i === 2) { e.preventDefault(); spawn(); }
+    });
+  }
+
+  document.addEventListener('keydown', (e) => {
+    if (e.metaKey || e.ctrlKey || e.altKey) return;
+    const tag = (e.target.tagName || '').toLowerCase();
+    if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable) return;
+    const k = e.key.toLowerCase();
+    if (k === 'n' || e.key === 'ArrowRight') { e.preventDefault(); next(); }
+    else if (k === 't') { e.preventDefault(); twist(); }
+  });
+
+  paintAll();
+  say('info', 'Photo line open. Press N or click Panel 1 to begin.');
+})();
+</script>
+</body>
+</html>
diff --git a/cartoons/manifest.js b/cartoons/manifest.js
index 8b2ebb4..3a4ca61 100644
--- a/cartoons/manifest.js
+++ b/cartoons/manifest.js
@@ -54,7 +54,7 @@ window.P24_CARTOONS = [
     "created_at": "2026-09-24T17:10:26Z",
     "category": "Political Cartoon",
     "section": "politics",
-    "blurb": "Erie's most reliable parking meter is honored, seated on the council, re-elected, and sworn in as one-day mayor \u2014 and now won't leave the office.",
+    "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",
     "tags": [
@@ -176,7 +176,7 @@ window.P24_CARTOONS = [
     "created_at": "2026-09-24T22:15:00Z",
     "category": "Political Cartoon",
     "section": "politics",
-    "blurb": "An invented infrastructure bill leaves committee as a single train car and picks up wildly unrelated riders \u2014 a desert lighthouse, a ferris wheel, a spoon museum \u2014 at every stop; click a car for its invented cost.",
+    "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",
     "tags": [
@@ -326,7 +326,7 @@ window.P24_CARTOONS = [
   },
   {
     "id": "filibuster-ink",
-    "title": "The Filibuster Marathon \u2014 Ink Edition",
+    "title": "The Filibuster Marathon — Ink Edition",
     "file": "20260924-filibuster-ink.html",
     "created_at": "2026-09-24T19:45:00Z",
     "category": "Political Cartoon",
@@ -346,7 +346,7 @@ window.P24_CARTOONS = [
   },
   {
     "id": "filibuster-qwen25",
-    "title": "The Filibuster Marathon \u2014 Editorial Ink Style (AI Model Contest)",
+    "title": "The Filibuster Marathon — Editorial Ink Style (AI Model Contest)",
     "file": "20260924-filibuster-qwen25-7b.html",
     "created_at": "2026-09-24T18:58:05Z",
     "category": "Political Cartoon",
@@ -372,7 +372,7 @@ window.P24_CARTOONS = [
     "created_at": "2026-09-24T19:45:00Z",
     "category": "Political Cartoon",
     "section": "politics",
-    "blurb": "Erie's one-day honorary mayor \u2014 a parking meter, sash on, expired flags for eyebrows \u2014 barricades itself behind an overturned desk and dares officials to take the office back. Drawn in classic editorial-ink style (the barricade device).",
+    "blurb": "Erie's one-day honorary mayor — a parking meter, sash on, expired flags for eyebrows — barricades itself behind an overturned desk and dares officials to take the office back. Drawn in classic editorial-ink style (the barricade device).",
     "thumb": null,
     "story_id": "pw-meter-street",
     "ai_generated": false,
@@ -414,7 +414,7 @@ window.P24_CARTOONS = [
     "created_at": "2026-09-24T19:49:00Z",
     "category": "Political Cartoon",
     "section": "politics",
-    "blurb": "Motorists trudge in an endless nighttime loop around a toll-booth hub \u2014 a hamster wheel of asphalt \u2014 chasing a dangling gold 'GOLD TIER' carrot as the crossing tally reads infinity. Drawn in classic editorial-ink style (the doomed-loop device).",
+    "blurb": "Motorists trudge in an endless nighttime loop around a toll-booth hub — a hamster wheel of asphalt — chasing a dangling gold 'GOLD TIER' carrot as the crossing tally reads infinity. Drawn in classic editorial-ink style (the doomed-loop device).",
     "thumb": null,
     "story_id": "bs-toll-loyalty",
     "ai_generated": false,
@@ -483,5 +483,111 @@ window.P24_CARTOONS = [
       "editorial cartoon",
       "shadow man"
     ]
+  },
+  {
+    "id": "statue-selfie-line",
+    "title": "The Line for the Statue for the Line",
+    "file": "20260926-statue-selfie-line.html",
+    "created_at": "2026-09-26T10:19:52Z",
+    "category": "Editorial Cartoon",
+    "section": "entertainment",
+    "blurb": "The photo line outgrows the roadside statue, so the town erects a statue of the line — which promptly grows a line of its own.",
+    "thumb": null,
+    "story_id": "es-roadside-statue-photo-line",
+    "tags": [
+      "monuments",
+      "tourism",
+      "small town news",
+      "editorial cartoon"
+    ]
+  },
+  {
+    "id": "rainbow-permit-office",
+    "title": "The Rainbow That Needed a Permit",
+    "file": "20260926-rainbow-permit-office.html",
+    "created_at": "2026-09-26T10:19:52Z",
+    "category": "Editorial Cartoon",
+    "section": "weather",
+    "blurb": "A rainbow that keeps appearing over the same field every afternoon is asked to file for a standing-display permit, and a clerk arrives with a clipboard to measure its arc.",
+    "thumb": null,
+    "story_id": "pw-rainbow-permit",
+    "tags": [
+      "weather anomaly",
+      "bureaucracy",
+      "small town news",
+      "ordinance",
+      "editorial cartoon"
+    ]
+  },
+  {
+    "id": "bleacher-superstition-tower",
+    "title": "The Lucky Seat",
+    "file": "20260926-bleacher-superstition-tower.html",
+    "created_at": "2026-09-26T10:19:52Z",
+    "category": "Editorial Cartoon",
+    "section": "sports",
+    "blurb": "A fan's lucky-seat ritual earns one more unmovable object with every win, until the stack on the bleacher bench is taller than the fan sitting beside it.",
+    "thumb": null,
+    "story_id": "sw-lucky-seat-relics",
+    "tags": [
+      "superstition",
+      "local sports",
+      "small town news",
+      "editorial cartoon"
+    ]
+  },
+  {
+    "id": "halftime-show-runaway",
+    "title": "The Halftime Show That Kept Adding Acts",
+    "file": "20260926-halftime-show-runaway.html",
+    "created_at": "2026-09-26T10:19:52Z",
+    "category": "Editorial Cartoon",
+    "section": "sports",
+    "blurb": "A small-stadium entertainment committee books \"just one more act\" between innings until the ballgame is delayed by the entertainment scheduled to fill its delays.",
+    "thumb": null,
+    "story_id": "sw-halftime-act-creep",
+    "tags": [
+      "local sports",
+      "community event",
+      "pageantry",
+      "small town news",
+      "editorial cartoon"
+    ]
+  },
+  {
+    "id": "festival-mascot-election",
+    "title": "The Mascot Who Ran Unopposed",
+    "file": "20260926-festival-mascot-election.html",
+    "created_at": "2026-09-26T10:19:52Z",
+    "category": "Editorial Cartoon",
+    "section": "entertainment",
+    "blurb": "A festival mascot's costume name is typed into the Grand Marshal candidate field by clerical accident, wins every vote cast because nothing else is on the ballot, and is sworn in without removing the felt head.",
+    "thumb": null,
+    "story_id": "es-festival-mascot-grand-marshal",
+    "tags": [
+      "mascots",
+      "community event",
+      "small town news",
+      "bureaucracy",
+      "pageantry",
+      "editorial cartoon"
+    ]
+  },
+  {
+    "id": "server-room-thermostat-war",
+    "title": "The Thermostat Standoff",
+    "file": "20260926-server-room-thermostat-war.html",
+    "created_at": "2026-09-26T10:19:52Z",
+    "category": "Editorial Cartoon",
+    "section": "scitech",
+    "blurb": "An invented office's server room and break room fight a silent war over one shared thermostat, stacking sticky-note demands higher than the dial itself while never once speaking to each other.",
+    "thumb": null,
+    "story_id": "sci-thermostat-ai-office",
+    "tags": [
+      "artificial intelligence",
+      "office culture",
+      "automation",
+      "editorial cartoon"
+    ]
   }
 ];
diff --git a/stories-data.js b/stories-data.js
index 08c2bb7..d13f644 100644
--- a/stories-data.js
+++ b/stories-data.js
@@ -2341,5 +2341,276 @@ window.P24_EXTRA_STORIES = [
    "state legislature",
    "bureaucracy"
   ]
+ },
+ {
+  "id": "es-roadside-statue-photo-line",
+  "category": "entertainment",
+  "categoryLabel": "Entertainment",
+  "location": "Vandalia, IL",
+  "byline": "Roadside Attractions Correspondent Marva Teasley",
+  "image": "images/es-roadside-statue-photo-line.jpg",
+  "intervalSec": 43,
+  "offsetSec": 10,
+  "stageIndex": 0,
+  "article": {
+   "dek": "The queue to photograph Vandalia's 22-foot fiberglass pinto bean has grown longer than the attraction is tall, and tourists have started photographing the queue instead.",
+   "photoCaption": "Visitors wait along the shoulder of Route 51 to photograph Vandalia's fiberglass pinto bean, with the line stretching past the far end of the gravel lot.",
+   "paragraphs": [
+    "VANDALIA, IL— The photo line at the Enormous Pinto Bean, a 22-foot fiberglass roadside attraction installed off Route 51 in 2019, now routinely runs 40 minutes on weekends, according to figures kept by the volunteers who staff the gravel lot.",
+    "Lot attendant Rosalind Kerbey said she began counting after Labor Day, when the queue reached the mailbox of the farm property next door. 'Forty-one minutes, that was the record,' Kerbey said. 'People were pretty good-natured about it.'",
+    "What organizers did not expect was that the line itself would become a subject. Kerbey said she now sees roughly a dozen visitors a day who never join the queue at all, photograph it from the shoulder, and drive off.",
+    "'They take a picture of everybody waiting and then they leave,' Kerbey said. 'I asked one fella why and he said the line was the more interesting part.'",
+    "The Vandalia tourism committee, which oversees the site on a $3,100 annual budget, voted 4-1 this month to commission a small companion sculpture depicting four people standing in line, to be installed near the entrance.",
+    "Committee member Dale Frenette cast the lone dissenting vote. 'I don't object to the bean,' Frenette said. 'I object to commemorating the inconvenience of visiting the bean.'",
+    "The companion piece was unveiled Saturday. By midafternoon, according to Kerbey's count, eleven people were queued up to photograph it.",
+    "The committee has scheduled a special session to consider what it is calling, in the meeting notice, 'the second line question.'"
+   ]
+  },
+  "stages": [
+   {
+    "headline": "Roadside Bean Statue Draws 40-Minute Photo Line",
+    "detail": "Weekend waits at Vandalia's 22-foot fiberglass pinto bean now run past 40 minutes, said lot attendant Rosalind Kerbey, with the queue reaching the neighboring farm's mailbox."
+   },
+   {
+    "headline": "Tourists Begin Photographing The Line Instead Of The Bean",
+    "detail": "About a dozen visitors a day now shoot the queue from the shoulder and drive off without joining it. 'The line was the more interesting part,' one told Kerbey."
+   },
+   {
+    "headline": "Tourism Committee Votes To Commission A Statue Of The Line",
+    "detail": "The committee approved a companion sculpture of four people waiting, 4-1. Dissenting member Dale Frenette objected to 'commemorating the inconvenience of visiting the bean.'"
+   },
+   {
+    "headline": "Statue Of The Line Draws Its Own Line Within A Day",
+    "detail": "Eleven people queued to photograph the new sculpture by midafternoon Saturday, prompting a special session on what the notice calls 'the second line question.'"
+   },
+   {
+    "headline": "Vandalia Now Maintains Four Statues Of Lines And Four Lines",
+    "detail": "Each new sculpture commemorates the people who waited for the one before it, and the committee has stopped numbering them in the minutes.",
+    "conclusion": "Nobody has photographed the original bean since August, though volunteers confirm it is still there."
+   }
+  ],
+  "tags": [
+   "monuments",
+   "tourism",
+   "small town news"
+  ]
+ },
+ {
+  "id": "pw-rainbow-permit",
+  "category": "weather",
+  "categoryLabel": "Weather",
+  "location": "Pellow Bend, OR",
+  "byline": "Weather Correspondent Ines Barrow",
+  "image": "images/pw-rainbow-permit.jpg",
+  "intervalSec": 31,
+  "offsetSec": 7,
+  "stageIndex": 0,
+  "article": {
+   "dek": "A rainbow has appeared over the same hayfield at roughly 4:10 p.m. for twelve consecutive days, and the Pellow Bend permit office has opened a standing-display file on it.",
+   "photoCaption": "The recurring arc over the Ardmore hayfield east of Pellow Bend, photographed at 4:12 p.m. on the eleventh consecutive afternoon.",
+   "paragraphs": [
+    "PELLOW BEND, OR— A rainbow has formed over the same 40-acre hayfield east of this town at approximately 4:10 p.m. on twelve consecutive afternoons, according to residents and a log kept by the county road crew.",
+    "The arc rises from the eastern fence line and terminates near a grain silo, and has been photographed in the same position on each of the twelve days, said road foreman Dell Ardmore, who owns the field.",
+    "'It is not that a rainbow is unusual,' Ardmore said. 'It is that this one keeps its appointment.'",
+    "The consistency drew the attention of the Pellow Bend permit office, which regulates signage, banners, and what the municipal code calls 'standing displays visible from a county road.' Office supervisor Nelda Ordway said a file was opened on the eleventh day.",
+    "'A thing that occupies the same visual space at the same hour every day for two weeks meets the definition,' Ordway said. 'I did not write the definition.'",
+    "A clerk was sent to the field with a clipboard and an inclinometer and recorded the apex at 41 degrees, Ordway said. A Form W-7 standing-display application was opened on the rainbow's behalf, listing the applicant as unreachable and the $40 filing fee as unpaid.",
+    "Town attorney Bess Kimbrough said the office is on defensible ground procedurally but declined to speculate on enforcement. 'The code does not contemplate a respondent that cannot be served,' Kimbrough said.",
+    "Ardmore said he has no objection to the rainbow and would prefer the file be closed. 'Nobody complained about it,' he said. 'The complaint is that it was noticed.'"
+   ]
+  },
+  "stages": [
+   {
+    "headline": "Same Rainbow Appears Over Pellow Bend Hayfield 12 Days Running",
+    "detail": "An arc has formed over the same 40-acre field at roughly 4:10 p.m. for twelve consecutive afternoons, according to a log kept by the county road crew."
+   },
+   {
+    "headline": "Permit Office Opens Standing-Display File On The Rainbow",
+    "detail": "Supervisor Nelda Ordway said the arc meets the municipal code's definition of a standing display visible from a county road. 'I did not write the definition,' she said."
+   },
+   {
+    "headline": "Clerk Sent To Measure The Arc Records Apex At 41 Degrees",
+    "detail": "A Form W-7 application was opened on the rainbow's behalf, listing the applicant as unreachable and the $40 filing fee as unpaid."
+   },
+   {
+    "headline": "Town Attorney Says Code Cannot Serve A Respondent It Cannot Reach",
+    "detail": "Bess Kimbrough called the office procedurally sound but declined to speculate on enforcement against a phenomenon that cannot be notified."
+   },
+   {
+    "headline": "Permit Granted With Three Conditions The Rainbow Cannot Read",
+    "detail": "The approved display may not obstruct the water tower, must renew every 90 days in person, and may add no colors without review.",
+    "conclusion": "It appeared the following afternoon at 4:10, unchanged, and the file was marked compliant."
+   }
+  ],
+  "tags": [
+   "weather anomaly",
+   "bureaucracy",
+   "small town news",
+   "ordinance"
+  ]
+ },
+ {
+  "id": "sw-lucky-seat-relics",
+  "category": "sports",
+  "categoryLabel": "Sports",
+  "location": "Chadron, NE",
+  "byline": "Sports Desk Reporter Odessa Lunt",
+  "image": "images/sw-lucky-seat-relics.jpg",
+  "intervalSec": 37,
+  "offsetSec": 12,
+  "stageIndex": 0,
+  "article": {
+   "dek": "A season-ticket holder at a junior-college ballpark has added one object to the bench beside him after every home win, and the stack has now passed his seated height.",
+   "photoCaption": "The accumulation in Row F of the third-base bleachers at Pike Field, photographed before Saturday's first pitch, with its owner seated to the left.",
+   "paragraphs": [
+    "CHADRON, NE— A pile of personal effects occupying roughly four feet of bench in the third-base bleachers at Pike Field has grown to a height of 51 inches, according to a measurement taken this month by the groundskeeping staff.",
+    "The pile belongs to Odell Prusk, a retired grain elevator operator who has held the same season ticket in Row F since 2011. Prusk said he began the practice after a home win in May, when he left a cap on the bench rather than move it.",
+    "'They won with the cap sitting there,' Prusk said. 'I am not going to be the one who finds out what happens if I pick it up.'",
+    "Each subsequent home victory has added an item. The stack now includes, by Prusk's own accounting, a thunder-stick, a borrowed jersey he has not returned, an unwashed scarf, a single sock, a folded program from June, a thermos he no longer opens, and a foam finger.",
+    "Groundskeeper Iva Deschene, who measured the pile at the request of the facilities office, said the issue is not the height but the footprint. 'It is two and a half seats,' Deschene said. 'Those are seats we sell.'",
+    "Pike Field operations manager Curt Bramhall said the stadium has no ordinance covering the situation and has not sought one. 'There is nothing in the fan conduct policy about accumulation,' Bramhall said.",
+    "Prusk has offered to purchase the adjacent seats outright. The club has not responded to the offer, according to Bramhall, who said the matter is 'being looked at by somebody.'",
+    "Asked what would happen at the end of the season, when the bleachers are pressure-washed, Prusk declined to speculate. 'I have thought about it,' he said. 'I would rather not have thought about it.'"
+   ]
+  },
+  "stages": [
+   {
+    "headline": "Chadron Fan Leaves Cap On Bench After Win, Declines To Move It",
+    "detail": "Season-ticket holder Odell Prusk left a cap where it sat following a May home victory at Pike Field. 'I am not going to be the one who finds out what happens if I pick it up,' he said."
+   },
+   {
+    "headline": "One Object Added Per Home Win, Stack Reaches Eight Items",
+    "detail": "A thunder-stick, a borrowed jersey, an unwashed scarf and a single sock have joined the cap. Prusk keeps the order intact and washes none of it."
+   },
+   {
+    "headline": "Groundskeeper Measures Pile At 51 Inches, Two And A Half Seats",
+    "detail": "Iva Deschene said the concern is footprint, not height. 'Those are seats we sell,' she said, after measuring at the facilities office's request."
+   },
+   {
+    "headline": "Stadium Finds No Rule Against Accumulation, Declines To Write One",
+    "detail": "Operations manager Curt Bramhall said nothing in the fan conduct policy covers the pile. Prusk's offer to buy the adjacent seats has gone unanswered."
+   },
+   {
+    "headline": "Pile Now Taller Than Its Owner Seated, Season-End Washing Looms",
+    "detail": "The stack has passed Prusk's seated height, and the bleachers are pressure-washed each October.",
+    "conclusion": "Asked what happens then, Prusk said he had thought about it and would rather not have."
+   }
+  ],
+  "tags": [
+   "superstition",
+   "local sports",
+   "small town news"
+  ]
+ },
+ {
+  "id": "sw-halftime-act-creep",
+  "category": "sports",
+  "categoryLabel": "Sports",
+  "location": "Zanesville, Ohio",
+  "byline": "Sports Desk Reporter Tanner Voss",
+  "image": null,
+  "intervalSec": 44,
+  "offsetSec": 12,
+  "stageIndex": 0,
+  "tags": [
+   "local sports",
+   "community event",
+   "pageantry",
+   "small town news"
+  ],
+  "article": {
+   "dek": "The between-innings entertainment at Glenhaven Municipal Field has grown to eight booked acts, and Tuesday's game was held at the fourth inning for two hours and forty-five minutes while they all went on.",
+   "photoCaption": "P24 illustration.",
+   "paragraphs": [
+    "ZANESVILLE, OH— Tuesday's game at Glenhaven Municipal Field was suspended at the middle of the fourth inning for two hours and forty-five minutes, according to the official scorer's log, while eight scheduled between-innings acts performed in sequence on the infield.",
+    "The delay was not weather-related. It was entertainment.",
+    "The Glenhaven entertainment committee, a five-member volunteer body that books the field's between-innings programming, began the season with one act: a 90-second kazoo number performed by the Municipal Kazoo Corps after the third inning.",
+    "\"It went over,\" said committee chair Arlene Pockrus. \"So somebody asked if we had room for one more.\"",
+    "They did. By the end of June the card included a dog frisbee team, the Airborne Retrievers, and a four-rider unicycle relay that circles the warning track twice. Each act was booked, Pockrus confirmed, to follow the act before it, which is how the committee's minutes describe every addition since May.",
+    "Groundskeeper Milo Fenwright said he stopped raking the infield between innings in early August because there was no longer a point. \"I'd get out there and there'd be a dog in the air,\" Fenwright said. \"You can't rake under a dog.\"",
+    "Manager Dee Stallworth said both dugouts have adjusted. Players now sit on the steps with their gloves in their laps, and the bullpen has been instructed not to begin warming until the second-to-last act. \"You can't ask a guy to stay loose for two hours,\" Stallworth said. \"So we don't ask.\"",
+    "Asked Tuesday whether the committee had considered simply stopping partway through the card to resume play, Pockrus said the question had come up and been settled. \"We can't stop mid-act,\" she said. \"These people rehearsed.\"",
+    "The committee meets Thursday to review a ninth proposal, a precision lawn-chair drill team, which has offered to perform free of charge. Pockrus said the committee is inclined to approve it and slot it after the unicycles.",
+    "Tuesday's game was eventually completed. The final score was 2-1, the same as it had been in the fourth."
+   ]
+  },
+  "stages": [
+   {
+    "headline": "Glenhaven Field Adds Between-Innings Kazoo Act, Crowd Response Positive",
+    "detail": "The Municipal Kazoo Corps performed a 90-second number after the third inning Friday. Committee chair Arlene Pockrus called the response 'the best thing we've done all year.'"
+   },
+   {
+    "headline": "Committee Books Dog Frisbee Team To Follow Kazoo Corps",
+    "detail": "The Airborne Retrievers were added to the card this week, scheduled immediately after the kazoo number. Pockrus said the committee 'had room for one more.'"
+   },
+   {
+    "headline": "Unicycle Relay Added; Groundskeeper Stops Raking Between Innings",
+    "detail": "A four-rider relay now circles the warning track twice. Groundskeeper Milo Fenwright said raking became impractical: 'You can't rake under a dog.'"
+   },
+   {
+    "headline": "Card Reaches Eight Acts, Bullpen Told Not To Warm Until Second-To-Last",
+    "detail": "Manager Dee Stallworth ordered relievers to stay cold through most of the program. 'You can't ask a guy to stay loose for two hours,' Stallworth said."
+   },
+   {
+    "headline": "Game Held At Fourth Inning For Two Hours, 45 Minutes As Full Card Performs",
+    "detail": "The official scorer logged the delay as entertainment, not weather. Pockrus declined to cut the program short, saying 'we can't stop mid-act — these people rehearsed.'",
+    "conclusion": "The game was completed. The final score was 2-1, the same as it had been in the fourth."
+   }
+  ]
+ },
+ {
+  "id": "es-festival-mascot-grand-marshal",
+  "category": "entertainment",
+  "categoryLabel": "Entertainment",
+  "location": "Bracken Hollow, PA",
+  "byline": "Civic Traditions Reporter Odell Thistlewood",
+  "image": null,
+  "intervalSec": 59,
+  "offsetSec": 5,
+  "stageIndex": 0,
+  "article": {
+   "dek": "A clerical error placed the Bracken Hollow Silt Festival's costumed mascot on the Grand Marshal ballot as the only candidate, and the borough has now sworn it in with the felt head still on.",
+   "photoCaption": "P24 illustration.",
+   "paragraphs": [
+    "BRACKEN HOLLOW, PA—The costumed mascot of this borough's annual Silt Festival was sworn in as Grand Marshal on Tuesday after appearing on the ballot as the only candidate, the result of what the festival committee has described in writing as a filing error.",
+    "The committee's Form GM-1 asks for the legal name of the Grand Marshal nominee. Festival clerk Bernice Ottweiler said she entered 'Chomper the Rivermuskrat' — the costume's name — in the field where the performer inside it was supposed to be listed.",
+    "'The form asks who is going to be the Grand Marshal,' Ottweiler said. 'I answered the question the form asked.'",
+    "No other nominations were filed before the deadline, which the committee attributed to a widely held assumption that the slot was already taken. Ballots went to the printer with a single name and a single checkbox on them.",
+    "All 214 ballots cast were marked for the mascot, according to the borough's canvass. The costume itself was not eligible to vote, borough solicitor Hadley Prewitt confirmed, on the grounds that it is not a registered elector and, separately, is not a person.",
+    "Prewitt said the office reviewed whether the result could be set aside and concluded it could not. 'The ballot was printed correctly, the count was accurate, and the winner received every vote available,' he said. 'There is no defect to cure. There is only a mascot.'",
+    "The oath was administered at Tuesday's committee meeting to the name printed on the certificate, which meant the performer inside was never formally identified and the head was never removed. Prewitt said this was deliberate and that removing it 'would have raised a question nobody wanted asked on the record.'",
+    "Ottweiler said Form GM-1 will be revised for next year to add a second line reading 'and who is inside that.' The mascot's term runs one year and, under the committee's continuity rule, renews automatically if no one else files."
+   ]
+  },
+  "stages": [
+   {
+    "headline": "Silt Festival Mascot Lands On Grand Marshal Ballot By Filing Error",
+    "detail": "Festival clerk Bernice Ottweiler entered the costume's name, 'Chomper the Rivermuskrat,' in the field meant for the performer's legal name."
+   },
+   {
+    "headline": "Nomination Deadline Passes With No Other Candidate Filed",
+    "detail": "The committee attributed the empty field to a widespread assumption that the Grand Marshal slot was already taken. Ballots printed with one name and one checkbox."
+   },
+   {
+    "headline": "Mascot Wins All 214 Votes Cast; Costume Ruled Ineligible To Vote For Itself",
+    "detail": "Borough solicitor Hadley Prewitt confirmed the costume is not a registered elector and, separately, is not a person."
+   },
+   {
+    "headline": "Solicitor Says There Is No Defect To Cure, 'Only A Mascot'",
+    "detail": "Prewitt found the ballot correctly printed and the count accurate, leaving the borough no procedural basis to set the result aside."
+   },
+   {
+    "headline": "Grand Marshal Sworn In With Felt Head On, Performer Never Named",
+    "detail": "The oath was administered to the name on the certificate, so the costume stayed intact through the swearing-in.",
+    "conclusion": "Form GM-1 will add a line reading 'and who is inside that,' and the term renews automatically if no one else files."
+   }
+  ],
+  "tags": [
+   "mascots",
+   "community event",
+   "small town news",
+   "bureaucracy",
+   "pageantry"
+  ]
  }
 ];

← b33a8cf auto-data-snapshot: 2026-09-26T03:13:13 (3 data files) — car  ·  back to Crazy News Channel  ·  auto-data-snapshot: 2026-09-26T03:43:38 (5 data files) — car 7868d09 →