[object Object]

← back to Crazy News Channel Shadowman

Add CHAOS INDEX: a global escalation gauge that degrades the broadcast itself

0e2f7d85699f100c094b20ac9a4ab410418bbb22 · 2026-09-23 19:31:22 -0700 · Steve Abrams

Reviewer feedback: writing lands, UI reads as a plain card-grid dashboard.
This adds one bold interaction tying the whole channel's state together
instead of another isolated widget.

- Chaos % (0-100) computed from the average escalation-stage position across
  every story, bumped +10 while the BREAKING klaxon is on. Four in-voice
  bands (Nominal/Concerning/Unprecedented/Post-Journalism) shown as a
  broadcast-style gauge in the header chrome, retunes with category mood.
- As chaos climbs, the channel degrades in designed tiers, driven entirely
  by body.chaos-tier-0..3 classes + CSS custom properties (zero story-card
  markup changes, so patchStoryGrid()'s keyed reconcile is untouched):
  ticker speeds up + starts stuttering on its own lead item, headlines pick
  up [DEVELOPING]/[CORRECTION] stamps via ::before, a lower-third anchor
  caption appears with escalating "anchor notes", the broadcast bezel tilts
  0.6deg, and a pointer-events:none PLEASE STAND BY overlay flashes (~1.4Hz,
  capped at 4 iterations) once the channel fully melts down.
- Integrates with every existing system: mood theming, klaxon, admin
  create/delete/reset (reset returns chaos to 0/Nominal), NETWORK OVERLOAD.
- Accessibility: gauge box is fixed-width (no header reflow), band changes
  announce via the existing throttled #srLive region (not every tick, not
  on first paint), bezel tilt is gated behind
  prefers-reduced-motion:no-preference with a static red-edge equivalent,
  standby flash never arms under reduced motion (existing meltdown banner
  text is that audience's equivalent), nothing exceeds 3 flashes/sec.

Verified with Playwright (chromium + webkit): drove chaos 0->max through
the app's own STORIES/renderStories()/updateChaosUI() calls, screenshotted
every tier, asserted readout+band+tier classes, confirmed klaxon/category
chip/admin/article-link all stay clickable at max tier, confirmed 0
console/page errors and no horizontal overflow at 390/768/1024/1440,
confirmed reduced-motion has no transform and never arms the flash.
Negative-tested the same suite against the pre-change HEAD copy: the
chaos-specific assertions fail 20/20 (ReferenceError: updateChaosUI is not
defined, plus the missing DOM), proving the tests exercise real new code.
Also re-ran a regression pass on the untouched DON'T BREAK list (malformed
hash, mood/article decoupling, Escape priority, stable lastUpdated, 0
console errors, no overflow, no Safari backdrop-filter) — all still green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iuURMrsPBwAqMc5LgvFLH

Files touched

Diff

commit 0e2f7d85699f100c094b20ac9a4ab410418bbb22
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 23 19:31:22 2026 -0700

    Add CHAOS INDEX: a global escalation gauge that degrades the broadcast itself
    
    Reviewer feedback: writing lands, UI reads as a plain card-grid dashboard.
    This adds one bold interaction tying the whole channel's state together
    instead of another isolated widget.
    
    - Chaos % (0-100) computed from the average escalation-stage position across
      every story, bumped +10 while the BREAKING klaxon is on. Four in-voice
      bands (Nominal/Concerning/Unprecedented/Post-Journalism) shown as a
      broadcast-style gauge in the header chrome, retunes with category mood.
    - As chaos climbs, the channel degrades in designed tiers, driven entirely
      by body.chaos-tier-0..3 classes + CSS custom properties (zero story-card
      markup changes, so patchStoryGrid()'s keyed reconcile is untouched):
      ticker speeds up + starts stuttering on its own lead item, headlines pick
      up [DEVELOPING]/[CORRECTION] stamps via ::before, a lower-third anchor
      caption appears with escalating "anchor notes", the broadcast bezel tilts
      0.6deg, and a pointer-events:none PLEASE STAND BY overlay flashes (~1.4Hz,
      capped at 4 iterations) once the channel fully melts down.
    - Integrates with every existing system: mood theming, klaxon, admin
      create/delete/reset (reset returns chaos to 0/Nominal), NETWORK OVERLOAD.
    - Accessibility: gauge box is fixed-width (no header reflow), band changes
      announce via the existing throttled #srLive region (not every tick, not
      on first paint), bezel tilt is gated behind
      prefers-reduced-motion:no-preference with a static red-edge equivalent,
      standby flash never arms under reduced motion (existing meltdown banner
      text is that audience's equivalent), nothing exceeds 3 flashes/sec.
    
    Verified with Playwright (chromium + webkit): drove chaos 0->max through
    the app's own STORIES/renderStories()/updateChaosUI() calls, screenshotted
    every tier, asserted readout+band+tier classes, confirmed klaxon/category
    chip/admin/article-link all stay clickable at max tier, confirmed 0
    console/page errors and no horizontal overflow at 390/768/1024/1440,
    confirmed reduced-motion has no transform and never arms the flash.
    Negative-tested the same suite against the pre-change HEAD copy: the
    chaos-specific assertions fail 20/20 (ReferenceError: updateChaosUI is not
    defined, plus the missing DOM), proving the tests exercise real new code.
    Also re-ran a regression pass on the untouched DON'T BREAK list (malformed
    hash, mood/article decoupling, Escape priority, stable lastUpdated, 0
    console errors, no overflow, no Safari backdrop-filter) — all still green.
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_011iuURMrsPBwAqMc5LgvFLH
---
 index.html | 267 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 266 insertions(+), 1 deletion(-)

diff --git a/index.html b/index.html
index 0cac330..453bae5 100644
--- a/index.html
+++ b/index.html
@@ -541,6 +541,114 @@ a, button, input, [tabindex] { outline-offset: 2px; }
 }
 .klaxon-btn:focus-visible { outline: 3px solid var(--focus); }
 
+/* ---------- 8b. CHAOS INDEX — global escalation gauge + broadcast decay ----------
+   One computed number (0-100) drives everything below via body.chaos-tier-0..3
+   classes + a couple of CSS custom properties. Every rule here targets the
+   chrome/body/container level, NEVER the story-card markup itself, so
+   patchStoryGrid()'s keyed reconcile never has to re-render a card just
+   because chaos ticked up (headline stamps are pure ::before content). */
+.chaos-row {
+  /* flex-basis 100% inside .chrome's flex-wrap:wrap always forces this onto
+     its own line, so its own width/content changes can never reflow the
+     brand/live-clock/controls line above it. flex-wrap:wrap on ITS OWN
+     children is the overflow safety valve at narrow widths (390px) — if the
+     fixed-width band/pct pieces don't fit alongside the track, they wrap to
+     a second line inside this row instead of ever overflowing horizontally. */
+  flex: 1 1 100%;
+  display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .6rem;
+  padding: .4rem 0 0;
+  margin-top: .4rem;
+  border-top: 1px dashed var(--panel-border);
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: .72rem;
+}
+.chaos-tag { flex: 0 0 auto; font-weight: 900; letter-spacing: .6px; color: var(--text-dim); }
+/* Fixed min-width sized to the longest label ("Post-Journalism") so the
+   readout's own box never changes width as the label text changes length —
+   the one thing that could otherwise nudge the controls sitting above it. */
+.chaos-band { flex: 0 0 auto; min-width: 10.5rem; font-weight: 800; letter-spacing: .3px; color: var(--text); }
+.chaos-band-icon { margin-right: .3rem; }
+.chaos-track {
+  flex: 1 1 auto; max-width: 14rem; height: .55rem; border-radius: 999px;
+  background: var(--panel-border); overflow: hidden; position: relative;
+}
+.chaos-fill {
+  position: absolute; inset: 0 auto 0 0; width: 0%;
+  background: var(--accent); border-radius: inherit;
+  transition: width .5s ease, background-color .4s ease;
+}
+.chaos-pct { flex: 0 0 auto; min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; color: var(--text-dim); }
+
+body.chaos-tier-1 .chaos-fill { background: #e0a300; }
+body.chaos-tier-2 .chaos-fill { background: #ff8c00; }
+body.chaos-tier-3 .chaos-fill { background: #ff2222; }
+
+/* Ticker degradation: each tier shortens the scroll loop so the chyron
+   visibly speeds up. The reduced-motion path never uses this animation at
+   all (ticker-track animation is fully disabled + JS swaps to the static
+   frame renderer), so this is motion-safe by construction. */
+.ticker-track { animation: ticker-scroll var(--ticker-dur, 38s) linear infinite; }
+body.chaos-tier-1 { --ticker-dur: 24s; }
+body.chaos-tier-2 { --ticker-dur: 14s; }
+body.chaos-tier-3 { --ticker-dur: 8s; }
+
+/* Broadcast losing its grip: headline stamps via generated content only —
+   zero markup change, so these can flip every tick without ever touching
+   patchStoryGrid()'s diffing. */
+body.chaos-tier-1 .story-card:not(.concluded) h3 a::before,
+body.chaos-tier-2 .story-card:not(.concluded) h3 a::before,
+body.chaos-tier-3 .story-card:not(.concluded) h3 a::before {
+  content: "[DEVELOPING] "; color: var(--accent2); font-weight: 900;
+}
+body.chaos-tier-2 .story-card.concluded h3 a::before,
+body.chaos-tier-3 .story-card.concluded h3 a::before {
+  content: "[CORRECTION] "; color: var(--accent2); font-weight: 900;
+}
+
+/* Lower-third anchor caption — appears at tier 2+, cycles a fixed set of
+   "anchor notes" that get more unhinged per tier. position:fixed, so
+   showing/hiding it never reflows anything in normal document flow. */
+.anchor-caption {
+  position: fixed; left: 0; right: 0; bottom: 3rem; z-index: 19;
+  padding: .4rem .9rem;
+  background: color-mix(in srgb, var(--chrome) 88%, #000 12%);
+  color: var(--text);
+  border-top: 1px dashed var(--accent2);
+  font-size: .78rem; font-weight: 700; font-style: italic;
+  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+
+/* TV-bezel tilt — the container physically drifts a fraction of a degree at
+   max tier. Gated entirely behind (prefers-reduced-motion: no-preference)
+   so reduced-motion users NEVER get the transform; the unconditional
+   box-shadow rule right below is their static equivalent (a hot red edge
+   instead of a tilt). Only the broadcast container tilts — chrome/nav/admin
+   controls are untouched so their click targets never move. */
+body.chaos-tier-3 main#channelContainer {
+  box-shadow: inset 0 0 0 1px var(--panel-border), inset 0 40px 80px -40px #00000055, inset 0 0 0 3px #ff222277;
+}
+@media (prefers-reduced-motion: no-preference) {
+  main#channelContainer { transition: transform .6s ease; }
+  body.chaos-tier-3 main#channelContainer { transform: rotate(.6deg); }
+}
+
+/* PLEASE STAND BY — full-screen flash at chaos 100 (all stories concluded).
+   pointer-events:none the whole time, so it can cover the entire viewport
+   without ever blocking a click on the klaxon/chips/admin/article links
+   underneath it. JS never arms this under reduced motion — the existing
+   static #meltdownBanner text is that audience's equivalent. Blink cycle is
+   .7s (~1.4Hz, well under the 3-flashes-per-second ceiling) x4 then it's
+   hidden again by JS, so it's transient, not a permanent strobe. */
+.standby-flash {
+  position: fixed; inset: 0; z-index: 30; pointer-events: none;
+  display: flex; align-items: center; justify-content: center;
+  background: rgba(0,0,0,.6); color: #fff; text-align: center;
+  font-weight: 900; letter-spacing: 2px;
+  font-size: clamp(1.1rem, 1rem + 2vw, 2.2rem);
+}
+.standby-flash.flashing { animation: standby-blink .7s steps(1, end) 4; }
+@keyframes standby-blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
+
 /* ---------- 9. Responsive breakpoints ---------- */
 @media (min-width: 560px) {
   .controls { margin-left: 0; }
@@ -620,8 +728,34 @@ a, button, input, [tabindex] { outline-offset: 2px; }
       ⚙ Admin
     </button>
   </div>
+
+  <!-- CHAOS INDEX — global escalation readout. aria-label carries the exact
+       value + band on demand (screen-reader users who tab/read here get the
+       accurate number); the throttled #srLive ping (see announce() calls in
+       updateChaosUI) covers unsolicited band-change notifications so this
+       itself is NOT aria-live (that would fire on every 1s tick). -->
+  <div class="chaos-row" id="chaosRow" role="group" aria-label="Chaos index: 0 percent, Nominal">
+    <span class="chaos-tag" aria-hidden="true">CHAOS INDEX</span>
+    <span class="chaos-band" aria-hidden="true">
+      <span class="chaos-band-icon" id="chaosBandIcon">🟢</span><span id="chaosBandLabel">Nominal</span>
+    </span>
+    <span class="chaos-track" aria-hidden="true"><span class="chaos-fill" id="chaosFill"></span></span>
+    <span class="chaos-pct" id="chaosPct" aria-hidden="true">0%</span>
+  </div>
 </header>
 
+<!-- Lower-third anchor caption — appears at CHAOS tier 2+ ("Unprecedented").
+     position:fixed, hidden by default; JS toggles [hidden] + cycles text.
+     Purely decorative broadcast flavor, so it's aria-hidden; the same
+     escalation is already conveyed through the readout above + the
+     throttled #srLive announcements. -->
+<div id="anchorCaption" class="anchor-caption" hidden aria-hidden="true"></div>
+
+<!-- PLEASE STAND BY — transient full-screen flash at CHAOS 100 (all stories
+     concluded). pointer-events:none always, so it never blocks a click on
+     anything underneath it; JS never arms it under reduced motion. -->
+<div id="standbyFlash" class="standby-flash" hidden aria-hidden="true">📡 PLEASE STAND BY</div>
+
 <!-- ============================================================
      CATEGORY FILTER — retunes the whole channel color mood
      ============================================================ -->
@@ -1158,6 +1292,10 @@ const state = {
   klaxonAudioTimer: null,
   justCreatedId: null,
   currentRouteId: null,
+  chaosPct: 0,
+  chaosTierIdx: 0,
+  lastChaosBandKey: undefined,
+  standbyFlashShown: false,
 };
 
 /* ---------- C. Rendering ---------- */
@@ -1196,6 +1334,13 @@ const els = {
   channelHeading: document.getElementById("channelHeading"),
   featuresGrid: document.getElementById("featuresGrid"),
   featuresEmptyNote: document.getElementById("featuresEmptyNote"),
+  chaosRow: document.getElementById("chaosRow"),
+  chaosBandIcon: document.getElementById("chaosBandIcon"),
+  chaosBandLabel: document.getElementById("chaosBandLabel"),
+  chaosFill: document.getElementById("chaosFill"),
+  chaosPct: document.getElementById("chaosPct"),
+  anchorCaption: document.getElementById("anchorCaption"),
+  standbyFlash: document.getElementById("standbyFlash"),
 };
 
 function announce(text) {
@@ -1360,10 +1505,120 @@ function updateMoodUI() {
   }
 }
 
+/* ---------- C2. CHAOS INDEX — global escalation gauge ----------
+   One number (0-100), derived from the SAME escalation stages every story
+   already carries, drives body.chaos-tier-0..3 classes that the CSS above
+   reads. Nothing here touches story-card markup — patchStoryGrid()'s keyed
+   reconcile is untouched by chaos changing every tick. */
+const CHAOS_BANDS = [
+  { key: "nominal", label: "Nominal", icon: "🟢", max: 24 },
+  { key: "concerning", label: "Concerning", icon: "🟡", max: 54 },
+  { key: "unprecedented", label: "Unprecedented", icon: "🟠", max: 84 },
+  { key: "post-journalism", label: "Post-Journalism", icon: "🔴", max: 100 },
+];
+
+// In-voice "anchor notes" for the lower-third caption, keyed by tier index.
+// Tier 2 (Unprecedented) gets a merely-antsy pool; tier 3 (Post-Journalism)
+// gets the fully unhinged one. Cycled on a slow clock (every 6s) off the
+// existing elapsedSec counter — no extra timers, no per-tick allocation.
+const ANCHOR_NOTES = {
+  2: [
+    "ANCHOR NOTE: send more coffee.",
+    "ANCHOR NOTE: is the graphic supposed to do that?",
+    "ANCHOR NOTE: who approved this chyron?",
+    "ANCHOR NOTE: we are, in fact, still live.",
+  ],
+  3: [
+    "ANCHOR NOTE: I no longer trust the teleprompter.",
+    "ANCHOR NOTE: someone let the goose into the control room.",
+    "ANCHOR NOTE: I don't think 'Post-Journalism' is a real desk, but here we are.",
+    "ANCHOR NOTE: please stop handing me new pages.",
+    "ANCHOR NOTE: is this still a news channel?",
+  ],
+};
+
+// Average, across every story, of how far along its own 5-stage ladder it
+// is (0 at stage 0, 1 at its final stage) — then folded to 0-100. A story
+// with only one stage can't have a ladder position, so it counts as fully
+// escalated (1) rather than causing a divide-by-zero.
+function computeChaos() {
+  if (!STORIES.length) return 0;
+  const total = STORIES.reduce((sum, s) => {
+    const span = s.stages.length - 1;
+    return sum + (span > 0 ? s.stageIndex / span : 1);
+  }, 0);
+  let pct = Math.round((total / STORIES.length) * 100);
+  // BREAKING klaxon integration: an active klaxon reads as its own bump on
+  // the escalation gauge, independent of story stages.
+  if (state.breaking) pct += 10;
+  return Math.max(0, Math.min(100, pct));
+}
+
+function chaosBandFor(pct) {
+  return CHAOS_BANDS.find((b) => pct <= b.max) || CHAOS_BANDS[CHAOS_BANDS.length - 1];
+}
+
+function updateAnchorCaption(tierIdx) {
+  const pool = ANCHOR_NOTES[tierIdx];
+  if (!pool) { els.anchorCaption.hidden = true; return; }
+  els.anchorCaption.hidden = false;
+  const idx = Math.floor(state.elapsedSec / 6) % pool.length;
+  els.anchorCaption.textContent = pool[idx];
+}
+
+function updateChaosUI() {
+  const pct = computeChaos();
+  const band = chaosBandFor(pct);
+  const tierIdx = CHAOS_BANDS.indexOf(band);
+  state.chaosPct = pct;
+  state.chaosTierIdx = tierIdx;
+
+  els.chaosFill.style.width = pct + "%";
+  els.chaosPct.textContent = pct + "%";
+  els.chaosBandIcon.textContent = band.icon;
+  els.chaosBandLabel.textContent = band.label;
+  els.chaosRow.setAttribute("aria-label", `Chaos index: ${pct} percent, ${band.label}`);
+
+  CHAOS_BANDS.forEach((b, i) => document.body.classList.toggle(`chaos-tier-${i}`, i === tierIdx));
+  updateAnchorCaption(tierIdx);
+
+  // Announce real band CHANGES only (throttled by definition — a band only
+  // changes a handful of times across a whole broadcast), never on every
+  // 1s tick, and never on the very first paint (that would read as a
+  // spurious "changed to Nominal" the instant the page loads).
+  if (state.lastChaosBandKey !== undefined && state.lastChaosBandKey !== band.key) {
+    announce(`Chaos index: ${band.label}, ${pct} percent.`);
+  }
+  state.lastChaosBandKey = band.key;
+
+  // PLEASE STAND BY: fires once when the channel first hits total
+  // meltdown (every story concluded == chaos 100), never under reduced
+  // motion (the existing static #meltdownBanner text is that audience's
+  // equivalent), and re-arms if the channel ever un-melts (reset/new story).
+  const allConcluded = STORIES.length > 0 && STORIES.every((s) => s.stageIndex === s.stages.length - 1);
+  if (allConcluded && !state.standbyFlashShown && !state.reducedMotion) {
+    state.standbyFlashShown = true;
+    els.standbyFlash.hidden = false;
+    els.standbyFlash.classList.add("flashing");
+    setTimeout(() => {
+      els.standbyFlash.hidden = true;
+      els.standbyFlash.classList.remove("flashing");
+    }, 2900);
+  }
+  if (!allConcluded) state.standbyFlashShown = false;
+}
+
 /* ---------- D. Ticker engine ---------- */
 function buildTickerItems() {
   const storyItems = STORIES.map((s) => `BREAKING: ${s.stages[s.stageIndex].headline}`);
-  return [...storyItems, ...FILLERS];
+  let items = [...storyItems, ...FILLERS];
+  // Broadcast losing its grip: from CHAOS tier 2 up, the ticker starts
+  // stuttering on its own lead item; tier 3 stutters harder. Pure content
+  // change (works identically whether the reduced-motion static-frame
+  // cycler or the scrolling marquee is rendering it).
+  if (state.chaosTierIdx >= 2 && items.length) items = [items[0], items[0], ...items.slice(1)];
+  if (state.chaosTierIdx >= 3 && items.length) items = [items[0], ...items];
+  return items;
 }
 
 function renderTickerScroll() {
@@ -1410,6 +1665,8 @@ function setBreaking(on) {
 
   if (on && state.soundOn) startSiren();
   else stopSiren();
+
+  updateChaosUI(); // BREAKING klaxon integration: bumps/unbumps the gauge immediately
 }
 
 function ensureAudioCtx() {
@@ -1501,6 +1758,12 @@ function tick() {
     state.lastAnnounceSec = state.elapsedSec;
   }
 
+  // Recompute every tick (cheap: arithmetic + a handful of textContent/class
+  // writes, not a grid re-render) so the gauge/ticker-speed/anchor-caption
+  // are always current, including ticks where no story advanced (e.g. the
+  // klaxon toggled this second).
+  updateChaosUI();
+
   if (anyStoryAdvanced) {
     persistStories();
     renderStories();
@@ -1808,6 +2071,7 @@ function populateAdminCategorySelect() {
 
 function refreshAfterStoriesChanged() {
   persistStories();
+  updateChaosUI(); // admin create/delete/reset integration: recompute before the ticker rebuilds
   renderStories();
   refreshTicker();
   renderAdminStoryList();
@@ -2171,6 +2435,7 @@ function init() {
   });
 
   updateMoodUI();
+  updateChaosUI(); // paint the gauge at its correct starting value before first tick
   setTickerMode(state.reducedMotion);
   refreshTicker();
   persistStories(); // lock in the load-time lastUpdated stamps so a reload shows the same times

← 87e501c Escape quotes in escapeHTML: close admin-headline attribute  ·  back to Crazy News Channel Shadowman  ·  Fix real horizontal overflow from the chaos-tier-3 broadcast f961c67 →