← back to Crazy News Channel
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
·
Fix real horizontal overflow from the chaos-tier-3 broadcast f961c67 →