← back to Model Arena
crazy-news.html
782 lines
<!DOCTYPE html>
<html lang="en" data-mood="all">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WNN Live</title>
<meta name="description" content="Whatever News Network — a satirical, entirely invented 24-hour news channel where three breaking stories escalate absurdly in real time.">
<style>
/* ---------- mood tokens (the category filter swaps the whole channel) ---------- */
:root{
--bg:#0b1020; --bg2:#10183a; --panel:#131b36; --panel2:#1a2447; --ink:#eef2ff; --muted:#a9b4d6;
--line:#2a3563; --accent:#e11d48; --accent-ink:#fff; --accent2:#fbbf24; --focus:#fbbf24;
--head:"Helvetica Neue",Arial,sans-serif; --body:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--radius:10px; --tilt-step:0deg;
color-scheme:dark;
}
[data-mood="politics"]{ --bg:#1a0c10; --bg2:#2a1017; --panel:#2b1319; --panel2:#3a1a21; --ink:#fdf3e7; --muted:#d9bfae;
--line:#5a2a31; --accent:#c8102e; --accent-ink:#fff; --accent2:#f5d58a; --focus:#f5d58a; --head:Georgia,"Times New Roman",serif; }
[data-mood="science"]{ --bg:#020d07; --bg2:#051a0d; --panel:#07200f; --panel2:#0b2c16; --ink:#b9ffd0; --muted:#7fcf98;
--line:#155d2e; --accent:#22c55e; --accent-ink:#021006; --accent2:#d9f99d; --focus:#d9f99d;
--head:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --body:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
[data-mood="food"]{ --bg:#fff4df; --bg2:#ffe8bf; --panel:#fffdf7; --panel2:#fff3dc; --ink:#2b1a0e; --muted:#6b4a2f;
--line:#e6c79a; --accent:#c2410c; --accent-ink:#fff; --accent2:#9a3412; --focus:#1d4ed8;
--head:"Cooper Black","Rockwell","Georgia",serif; color-scheme:light; }
[data-mood="weather"]{ --bg:#0d1a24; --bg2:#12263a; --panel:#152a3d; --panel2:#1c3650; --ink:#e0f2fe; --muted:#9cc3dc;
--line:#2c5173; --accent:#38bdf8; --accent-ink:#04121c; --accent2:#cbd5e1; --focus:#fde047; }
*{box-sizing:border-box}
html,body{margin:0}
body{
background:radial-gradient(1200px 600px at 10% -10%,var(--bg2),transparent 60%),var(--bg);
color:var(--ink); font:16px/1.5 var(--body); min-height:100vh; padding-bottom:64px; overflow-x:hidden;
transition:background-color .6s,color .6s;
}
@media (prefers-reduced-motion:reduce){ body{transition:none} }
button,input{font:inherit;color:inherit}
:focus-visible{outline:3px solid var(--focus);outline-offset:3px;border-radius:6px}
.sr-only{position:absolute!important;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.skip{position:absolute;left:-999px;top:8px;background:var(--accent);color:var(--accent-ink);padding:8px 12px;border-radius:6px;z-index:50}
.skip:focus{left:8px}
.wrap{max-width:1440px;margin:0 auto;padding:0 16px}
/* ---------- masthead ---------- */
.masthead{display:flex;flex-wrap:wrap;align-items:center;gap:12px 24px;padding:16px 0 10px;border-bottom:1px solid var(--line)}
.brand{display:flex;align-items:center;gap:12px;min-width:0;flex:1 1 280px}
.logo{font:900 26px/1 var(--head);letter-spacing:-.04em;background:var(--accent);color:var(--accent-ink);padding:10px 12px;border-radius:8px;
box-shadow:4px 4px 0 var(--accent2);flex:none}
.net{margin:0;font:800 clamp(17px,2.2vw,22px)/1.15 var(--head);letter-spacing:-.01em}
.tag{margin:2px 0 0;color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.08em}
.live{display:flex;align-items:center;gap:8px;font-weight:800;letter-spacing:.1em;font-size:14px}
.live .dot{width:10px;height:10px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 0 var(--accent);animation:pulse 2s infinite}
.live time{font-variant-numeric:tabular-nums;color:var(--muted);letter-spacing:.04em;font-weight:600}
.meta{color:var(--muted);font-size:13px}
.meta b{color:var(--ink);font-variant-numeric:tabular-nums}
@keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--accent) 70%,transparent)}70%{box-shadow:0 0 0 10px transparent}100%{box-shadow:0 0 0 0 transparent}}
/* ---------- controls ---------- */
.controls{display:grid;gap:12px;padding:14px 0;border-bottom:1px solid var(--line)}
.moods{border:0;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:8px;align-items:center;min-width:0}
.moods legend{float:left;margin-right:6px;font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);padding:0}
.chip{position:relative}
.chip input{position:absolute;opacity:0;inset:0;margin:0;cursor:pointer}
.chip span{display:inline-flex;align-items:center;gap:7px;padding:7px 12px;border:1px solid var(--line);border-radius:999px;background:var(--panel);
font-size:14px;font-weight:600;cursor:pointer;transition:background-color .2s,border-color .2s}
.chip span i{width:12px;height:12px;border-radius:50%;display:inline-block;border:1px solid rgba(0,0,0,.25)}
.chip input:checked + span{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
.chip input:focus-visible + span{outline:3px solid var(--focus);outline-offset:2px}
.chip span:hover{border-color:var(--accent)}
.switches{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.btn{appearance:none;border:1px solid var(--line);background:var(--panel);padding:8px 14px;border-radius:8px;font-weight:700;font-size:14px;cursor:pointer;
display:inline-flex;align-items:center;gap:8px;min-height:40px}
.btn:hover{border-color:var(--accent)}
.btn:disabled{opacity:.55;cursor:not-allowed}
.btn[aria-pressed="true"]{background:var(--ink);color:var(--bg);border-color:var(--ink)}
.klaxon-btn{background:#b91c1c;color:#fff;border-color:#7f1d1d;text-transform:uppercase;letter-spacing:.06em}
.klaxon-btn[aria-pressed="true"]{background:#fff;color:#b91c1c;border-color:#fff;box-shadow:0 0 0 3px #b91c1c inset}
.klaxon-btn .st{font-size:11px;background:rgba(0,0,0,.25);padding:2px 6px;border-radius:4px}
.klaxon-btn[aria-pressed="true"] .st{background:#b91c1c;color:#fff}
.check{display:inline-flex;align-items:center;gap:6px;font-size:14px;color:var(--muted);min-height:40px;cursor:pointer}
.check input{width:18px;height:18px;accent-color:var(--accent)}
.cycle{display:flex;align-items:center;gap:12px;font-size:13px;color:var(--muted);flex-wrap:wrap}
.cycle .bar{flex:1 1 160px;height:6px;background:var(--panel2);border-radius:99px;overflow:hidden;max-width:420px}
.cycle .bar div{height:100%;width:0;background:var(--accent);border-radius:99px}
.status{margin:0;min-height:1.4em;font-size:14px;font-weight:600;color:var(--accent2)}
[data-mood="food"] .status{color:#9a3412}
/* ---------- klaxon mode ---------- */
.beacon{position:fixed;inset:0;pointer-events:none;z-index:40;opacity:0;box-shadow:inset 0 0 0 6px #dc2626,inset 0 0 90px rgba(220,38,38,.45)}
body.klaxon .beacon{opacity:1;animation:beacon 1.1s ease-in-out infinite} /* <1 flash/sec, well under WCAG 3/sec */
@keyframes beacon{50%{opacity:.35}}
.breaking{display:none;margin:12px 0 0;background:#dc2626;color:#fff;border-radius:8px;overflow:hidden;align-items:stretch}
body.klaxon .breaking{display:flex}
.breaking strong{background:#fff;color:#b91c1c;padding:8px 12px;font:900 15px/1.2 var(--head);letter-spacing:.08em;display:flex;align-items:center;flex:none}
.breaking span{padding:8px 12px;font-weight:700;min-width:0}
@media (prefers-reduced-motion:reduce){
body.klaxon .beacon{animation:none;opacity:.8}
.live .dot{animation:none}
}
/* ---------- layout ---------- */
.layout{display:grid;grid-template-columns:minmax(0,1fr);gap:20px;padding:20px 0}
@media (min-width:1100px){ .layout{grid-template-columns:minmax(0,1fr) 300px} }
.stories{display:grid;gap:16px;grid-template-columns:minmax(0,1fr);align-items:start}
@media (min-width:760px){
.stories{grid-template-columns:minmax(0,1.6fr) minmax(0,1fr)}
.stories .story.lead{grid-row:span 2}
.stories.single{grid-template-columns:minmax(0,1fr)}
}
/* ---------- story cards ---------- */
.story{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0;
rotate:var(--tilt,0deg);transition:rotate .6s,border-color .3s;display:flex;flex-direction:column;gap:10px}
@media (prefers-reduced-motion:reduce){ .story{transition:none} }
.story.lead{border-top:6px solid var(--accent);padding:20px}
.story-top{display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;align-items:center}
.kicker{font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--accent)}
[data-mood="food"] .kicker{color:#9a3412}
.lead-tag{display:none;font-size:11px;font-weight:900;letter-spacing:.12em;background:var(--accent);color:var(--accent-ink);padding:2px 7px;border-radius:4px;margin-right:6px}
.story.lead .lead-tag{display:inline-block}
.level{font-size:12px;font-weight:700;color:var(--muted);font-variant-numeric:tabular-nums}
.pips{display:flex;gap:4px}
.pips i{flex:1;height:6px;border-radius:3px;background:var(--panel2)}
.pips i.on{background:var(--accent)}
.pips i.hot{background:#dc2626}
.revisions{display:flex;flex-direction:column;gap:2px}
.revisions del{color:var(--muted);font-size:13px;text-decoration-color:var(--accent);text-decoration-thickness:2px}
.revisions del small{font-size:10px;letter-spacing:.08em;text-transform:uppercase;margin-right:6px;text-decoration:none;display:inline-block}
.headline{margin:0;font:800 clamp(20px,2.3vw,24px)/1.15 var(--head);letter-spacing:-.015em;overflow-wrap:anywhere}
.story.lead .headline{font-size:clamp(24px,3.4vw,38px)}
.headline.fresh{animation:fresh 1.2s ease-out}
@keyframes fresh{0%{background:color-mix(in srgb,var(--accent2) 55%,transparent)}100%{background:transparent}}
@media (prefers-reduced-motion:reduce){ .headline.fresh{animation:none;text-decoration:underline 3px var(--accent2);text-underline-offset:6px} }
.dek{margin:0;color:var(--muted)}
.story.lead .dek{font-size:17px}
.timeline{list-style:none;margin:0;padding:0;border-left:2px solid var(--line);display:grid;gap:6px}
.timeline li{padding-left:10px;font-size:13px;position:relative}
.timeline li::before{content:"";position:absolute;left:-6px;top:6px;width:10px;height:10px;border-radius:50%;background:var(--panel);border:2px solid var(--line)}
.timeline li:first-child::before{background:var(--accent);border-color:var(--accent)}
.timeline time{font-variant-numeric:tabular-nums;color:var(--muted);margin-right:6px;font-weight:700}
.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:auto}
.reporter:empty{display:none}
.reporter{border-radius:8px;padding:10px 12px;font-size:14px;background:var(--panel2);border:1px solid var(--line)}
.reporter.err{border-color:#dc2626;background:color-mix(in srgb,#dc2626 14%,var(--panel))}
.reporter.ok blockquote{margin:0;font-style:italic}
.reporter.ok cite{display:block;margin-top:4px;font-style:normal;font-size:12px;color:var(--muted)}
.reporter .btn{margin-top:8px}
.stamp{align-self:flex-start;font:900 13px/1 var(--head);letter-spacing:.14em;text-transform:uppercase;color:#dc2626;
border:3px solid #dc2626;padding:6px 8px;border-radius:6px;rotate:-4deg}
.stamp[hidden]{display:none}
.story.done{border-style:dashed}
.story.flash{box-shadow:0 0 0 3px var(--accent2)}
/* ---------- sidebar ---------- */
.side{display:grid;gap:16px;align-content:start}
@media (min-width:760px) and (max-width:1099px){ .side{grid-template-columns:repeat(3,minmax(0,1fr))} }
.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:14px}
.panel h2{margin:0 0 8px;font:800 13px/1.2 var(--body);text-transform:uppercase;letter-spacing:.12em;color:var(--muted)}
.anchor q{font:600 17px/1.35 var(--head);display:block;quotes:"“" "”"}
.anchor p{margin:8px 0 0;font-size:12px;color:var(--muted)}
.sweat{display:inline-block;margin-left:4px}
.meter{height:14px;border-radius:99px;background:var(--panel2);overflow:hidden;border:1px solid var(--line)}
.meter div{height:100%;width:0;background:linear-gradient(90deg,#22c55e,#fbbf24 45%,#ef4444 80%,#a855f7);background-size:420px 100%;transition:width .6s}
@media (prefers-reduced-motion:reduce){ .meter div{transition:none} }
.meter-label{display:flex;justify-content:space-between;margin-top:6px;font-size:13px;font-weight:700}
.markets{list-style:none;margin:0;padding:0;display:grid;gap:4px;font-size:13px;font-variant-numeric:tabular-nums}
.markets li{display:flex;justify-content:space-between;gap:8px;border-bottom:1px dotted var(--line);padding:3px 0}
.up{color:#16a34a}.down{color:#dc2626}
[data-mood="science"] .up{color:#86efac}
.keys dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:4px 10px;font-size:13px}
.keys dt kbd{font:700 12px ui-monospace,Menlo,monospace;border:1px solid var(--line);border-bottom-width:3px;border-radius:5px;padding:1px 6px;background:var(--panel2)}
.keys dd{margin:0;color:var(--muted)}
/* ---------- empty + sign-off ---------- */
.empty{margin:20px 0;text-align:center;padding:40px 16px;border:2px dashed var(--line);border-radius:var(--radius);background:var(--panel)}
.empty .icon{font-size:48px;line-height:1}
.empty h2{font:800 24px/1.2 var(--head);margin:10px 0 6px}
.empty p{color:var(--muted);max-width:520px;margin:0 auto 16px}
.signoff{margin:20px 0;border-radius:var(--radius);overflow:hidden;border:1px solid var(--line);background:var(--panel)}
.signoff:focus{outline:none}
.bars{display:grid;grid-template-columns:repeat(7,1fr);height:120px}
.bars i:nth-child(1){background:#c0c0c0}.bars i:nth-child(2){background:#c0c000}.bars i:nth-child(3){background:#00c0c0}
.bars i:nth-child(4){background:#00c000}.bars i:nth-child(5){background:#c000c0}.bars i:nth-child(6){background:#c00000}.bars i:nth-child(7){background:#0000c0}
.bars2{display:grid;grid-template-columns:repeat(7,1fr);height:18px}
.bars2 i:nth-child(odd){background:#131313}.bars2 i:nth-child(1),.bars2 i:nth-child(3){background:#0000c0}.bars2 i:nth-child(5){background:#c000c0}.bars2 i:nth-child(7){background:#c0c0c0}
.signoff-body{padding:20px;text-align:center}
.signoff-body h2{font:900 clamp(24px,4vw,40px)/1.1 var(--head);margin:0 0 6px}
.signoff-body p{color:var(--muted);margin:0 0 14px}
.stats{display:flex;flex-wrap:wrap;justify-content:center;gap:10px;margin:0 0 16px;padding:0;list-style:none}
.stats li{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:13px}
.stats b{display:block;font-size:22px;font-variant-numeric:tabular-nums}
.btn.primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
/* ---------- ticker ---------- */
.ticker{position:fixed;left:0;right:0;bottom:0;z-index:30;display:flex;align-items:stretch;height:48px;background:#000;color:#fff;border-top:3px solid var(--accent)}
.ticker-label{flex:none;display:flex;align-items:center;padding:0 12px;background:var(--accent);color:var(--accent-ink);font:900 14px/1 var(--head);letter-spacing:.08em}
.ticker-view{flex:1;overflow:hidden;position:relative;min-width:0}
.ticker-track{position:absolute;left:0;top:0;height:100%;display:flex;align-items:center;white-space:nowrap;will-change:transform}
.ticker-track span{padding:0 22px;font-weight:700;font-size:15px;letter-spacing:.02em}
.ticker-track span::after{content:"◆";margin-left:22px;color:var(--accent2);font-size:10px;vertical-align:middle}
.ticker-track span.news{color:#fde68a}
.ticker-track span.static{position:static}
.ticker.static .ticker-track{position:static;padding:0 12px;overflow:hidden;text-overflow:ellipsis}
.ticker.static .ticker-track span{padding:0;overflow:hidden;text-overflow:ellipsis}
.ticker.static .ticker-track span::after{content:none}
.ticker-btn{flex:none;width:48px;border:0;background:#111;color:#fff;cursor:pointer;font-size:16px;border-left:1px solid #333}
.ticker-btn:focus-visible{outline-offset:-4px}
</style>
</head>
<body>
<div style="background:#111;color:#fff;padding:10px 16px;font:600 14px system-ui;text-align:center">This prototype grew into <a style="color:#ffcc00" href="file:///Users/macstudio3/Projects/crazy-news-channel/index.html">PANDEMONIUM-24</a> (~/Projects/crazy-news-channel), the one live version of the Crazy News channel.</div>
<a class="skip" href="#stories">Skip to stories</a>
<div class="beacon" aria-hidden="true"></div>
<div class="wrap">
<header class="masthead">
<div class="brand">
<div class="logo" aria-hidden="true">WNN</div>
<div>
<h1 class="net" id="netName">Whatever News Network</h1>
<p class="tag" id="tagline">24 hours · Facts, mostly</p>
</div>
</div>
<div class="live" aria-label="Live broadcast"><span class="dot" aria-hidden="true"></span>LIVE <time id="clock"></time></div>
<div class="meta"><b id="viewers">1,204,331</b> watching · <span id="moodLabel">Main desk</span></div>
</header>
<section class="controls" aria-label="Broadcast controls">
<fieldset class="moods">
<legend>Channel mood</legend>
<label class="chip"><input type="radio" name="mood" value="all" checked><span><i style="background:#e11d48"></i>All desks</span></label>
<label class="chip"><input type="radio" name="mood" value="politics"><span><i style="background:#c8102e"></i>Politics</span></label>
<label class="chip"><input type="radio" name="mood" value="science"><span><i style="background:#22c55e"></i>Science</span></label>
<label class="chip"><input type="radio" name="mood" value="food"><span><i style="background:#f59e0b"></i>Food</span></label>
<label class="chip"><input type="radio" name="mood" value="weather"><span><i style="background:#38bdf8"></i>Weather</span></label>
</fieldset>
<div class="switches">
<button type="button" class="btn klaxon-btn" id="klaxonBtn" aria-pressed="false">🚨 Breaking klaxon <span class="st" id="klaxonState">Off</span></button>
<label class="check"><input type="checkbox" id="soundChk" checked> Klaxon sound</label>
<button type="button" class="btn" id="pauseBtn" aria-pressed="false">⏸ Pause news cycle</button>
<button type="button" class="btn" id="resetBtn">↺ New news cycle</button>
</div>
<div class="cycle">
<span id="cycleText">Next development in 9s</span>
<div class="bar" aria-hidden="true"><div id="cycleBar"></div></div>
</div>
<p class="status" id="status" role="status"></p>
<div class="breaking" id="breaking"><strong>BREAKING</strong><span id="breakingText"></span></div>
</section>
<main id="main">
<section class="empty" id="empty" hidden aria-labelledby="emptyH">
<div class="icon" aria-hidden="true">🌫️</div>
<h2 id="emptyH">No weather tonight.</h2>
<p>Our meteorologist, Tom Brisket, was pre-empted by a goose. All weather has been postponed until the news calms down. The sky has been asked to hold.</p>
<button type="button" class="btn primary" id="emptyBack">Return to all desks</button>
</section>
<section class="signoff" id="signoff" hidden tabindex="-1" aria-labelledby="signoffH">
<div class="bars" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
<div class="bars2" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
<div class="signoff-body">
<h2 id="signoffH">That's the news. Tomorrow has been cancelled.</h2>
<p>All three stories reached their conclusion. WNN is now off the air until something else happens, which, statistically, is soon.</p>
<ul class="stats" id="stats"></ul>
<button type="button" class="btn primary" id="restartBtn">↺ Start a new news cycle</button>
</div>
</section>
<div class="layout">
<section id="stories" class="stories" aria-label="Top stories" tabindex="-1"></section>
<aside class="side" aria-label="Channel sidebar">
<section class="panel anchor" aria-labelledby="anchorH">
<h2 id="anchorH">At the desk</h2>
<q id="anchorQuote">Good evening. Some news is happening. We'll have it for you all night.</q>
<p>— Brenda Kowalczyk, anchor <span class="sweat" id="sweat" aria-hidden="true"></span></p>
</section>
<section class="panel" aria-labelledby="indexH">
<h2 id="indexH">Absurdity Index</h2>
<div class="meter" id="meter" role="meter" aria-labelledby="indexH" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div id="meterFill"></div></div>
<div class="meter-label"><span id="meterText">Calm</span><span id="meterPct">0%</span></div>
</section>
<section class="panel" aria-labelledby="mktH">
<h2 id="mktH">WNN Markets</h2>
<ul class="markets" id="markets"></ul>
</section>
<section class="panel keys" aria-labelledby="keysH">
<h2 id="keysH">Keyboard</h2>
<dl>
<dt><kbd>K</kbd></dt><dd>Toggle breaking klaxon</dd>
<dt><kbd>P</kbd></dt><dd>Pause / resume news cycle</dd>
<dt><kbd>T</kbd></dt><dd>Pause / resume ticker</dd>
<dt><kbd>E</kbd></dt><dd>Escalate the lead story</dd>
<dt><kbd>1</kbd>–<kbd>5</kbd></dt><dd>Switch channel mood</dd>
</dl>
</section>
</aside>
</div>
</main>
</div>
<footer class="ticker" id="ticker" aria-label="News ticker">
<span class="ticker-label">WNN WIRE</span>
<div class="ticker-view" id="tickerView"><div class="ticker-track" id="tickerTrack" aria-hidden="true"></div></div>
<button type="button" class="ticker-btn" id="tickerBtn" aria-pressed="false" aria-label="Pause ticker">❚❚</button>
</footer>
<div class="sr-only" id="announcer" aria-live="polite"></div>
<script>
(() => {
'use strict';
const $ = (s, r = document) => r.querySelector(s);
const MAX = 7; // escalation stages per story
const BASE_MS = 9000, KLAXON_MS = 4500;
const reduceMQ = matchMedia('(prefers-reduced-motion: reduce)');
let reduced = reduceMQ.matches;
/* ------------------------------ content ------------------------------ */
const STORIES = [
{ id:'goose', cat:'politics', catLabel:'Politics', place:'Duluth, Minn.', reporter:'Dale Minks', from:'the pond outside City Hall',
fail:'Signal lost — Dale has been chased off the pond. He is fine. He is running.',
quotes:['The goose has not taken questions. The goose has taken my sandwich.',
'I’m standing where the bread aisle used to be. It is now a moat.',
'Officials tell me the new anthem is one honk, held for four minutes. People are weeping.'],
stages:[
['Goose narrowly wins Duluth City Council seat in write-in upset','Councilmember Honk defeated a 12-year incumbent by 11 votes. The concession speech was interrupted by hissing from the winner.','GOOSE WINS DULUTH COUNCIL SEAT BY 11 VOTES'],
['Councilmember Honk’s first act: a ban on bread litter, and then on bread','The ordinance passed 5–4 after the goose sat on the vote-counting machine. Bakeries have 48 hours to comply.','DULUTH BANS BREAD; BAKERS GIVEN 48 HOURS'],
['Goose filibusters for 14 hours; transcript is entirely “HONK”','The city stenographer was treated for wrist strain. Parliamentarians confirm the filibuster was, technically, on topic.','14-HOUR GOOSE FILIBUSTER ENTERS RECORD BOOKS'],
['Duluth secedes from Minnesota on a procedural technicality','The motion passed while the other council members were “just checking on their cars.” Minnesota has requested the city back.','DULUTH SECEDES; MINNESOTA "WOULD LIKE IT BACK"'],
['Canada recognizes the Sovereign Goosedom of Duluth','Ottawa offers a trade deal in lettuce and an honorary seat at hockey games. The goose has not returned the call.','CANADA RECOGNIZES GOOSEDOM, OFFERS LETTUCE'],
['UN emergency session: goose delegation demands a seat, and a pond','Security council members report being “aggressively followed” in the lobby. France abstained.','UN EMERGENCY SESSION ON GOOSE; FRANCE ABSTAINS'],
['Goose resigns abruptly, flies south for winter; Duluth quietly rejoins Minnesota','No explanation was given. City officials have asked residents not to bring it up. Bread is legal again.','GOOSE RESIGNS, FLIES SOUTH. DULUTH IS BACK.']
]},
{ id:'tuesday', cat:'science', catLabel:'Science', place:'Boulder, Colo.', reporter:'Priya Anand', from:'the atomic clock lab at NIST',
fail:'Signal lost — Priya’s feed arrived eleven minutes late and was therefore technically Wednesday.',
quotes:['The clocks here all agree, which the scientists say is the scariest part.',
'I’ve been inside the extra time. It smells faintly like a dentist’s office.',
'Gary waved at me. I waved back. I think we are friends now.'],
stages:[
['Physicists confirm Tuesdays now run 11 minutes long','Atomic clocks at three labs independently measured the surplus. “It’s just sort of… there,” said one researcher.','TUESDAY MEASURED 11 MINUTES LONG'],
['Tuesday gap grows to 47 minutes; payroll departments demand answers','Hourly workers are asking whether the extra time counts. HR has scheduled a meeting about it for, ominously, Tuesday.','TUESDAY NOW +47 MIN; PAYROLL IN CRISIS'],
['Researchers find a small room inside the extra time. It has a chair.','Team members describe beige walls, a ficus, and “a very specific hum.” No one has sat in the chair.','ROOM FOUND INSIDE TUESDAY. IT HAS A CHAIR.'],
['Someone is sitting in the chair. Scientists “would rather not say more”','A follow-up expedition returned early and “will need a minute.” The lab has put up a paper sign reading PRIVATE.','SOMEONE IS IN THE CHAIR'],
['Person in the chair identified as Gary, 52, who “just needed a minute”','Gary, an accounts receivable manager from Ohio, says he found the room in March and “didn’t think it was a big deal.”','CHAIR OCCUPANT IS GARY, 52. HE NEEDED A MINUTE.'],
['Petition to make Gary’s minute permanent passes 40 million signatures','Economists warn a universal extra hour could “fundamentally improve everyone’s mood.” Markets are cautious.','GARY PETITION HITS 40 MILLION'],
['Congress grants every citizen one weekly “Gary Hour”; Tuesdays return to normal','The bill passed unanimously in a session that ran exactly the right length. Gary is thriving.','GARY HOUR SIGNED INTO LAW. TUESDAY FIXED.']
]},
{ id:'dough', cat:'food', catLabel:'Food', place:'Tacoma, Wash.', reporter:'Marcus Bell', from:'a studio apartment kitchen in Tacoma',
fail:'Signal lost — the starter unplugged our camera. Its attorney says this was “self-expression.”',
quotes:['The starter is bubbling in what I can only describe as a confident rhythm.',
'The fridge has been declared neutral territory. Nobody is touching the butter.',
'Doughmitri’s campaign slogan is simply “Rise.” It is testing extremely well.'],
stages:[
['Nine-year-old sourdough starter begins bubbling in Morse code','Owner Kevin Olsen, 34, noticed the pattern while making toast. A ham radio club confirmed the dots and dashes.','SOURDOUGH STARTER TAPPING MORSE CODE'],
['Message decoded: “FEED ME BETTER FLOUR.” Local bakeries sell out of rye','Olsen says he had been using “the store brand, which, fair.” Rye futures jumped on the news.','STARTER DEMANDS BETTER FLOUR; RYE SELLS OUT'],
['Starter, now going by “Doughmitri,” retains an attorney','Counsel confirmed the retainer was paid in a single, excellent boule. Olsen has declined to comment through his own lawyer.','DOUGHMITRI LAWYERS UP'],
['Doughmitri sues owner for emotional neglect, seeks custody of the fridge','Court filings cite “months on the counter, unfed, during a heat wave.” The fridge has not taken sides.','STARTER SUES OWNER FOR FRIDGE CUSTODY'],
['Judge rules starter is “legally a roommate,” owes a third of rent in bread','Legal scholars call the ruling “unprecedented” and “honestly kind of fair.” Rent is due on the first.','JUDGE: STARTER IS A ROOMMATE'],
['Doughmitri announces mayoral run; polls at 38%, ahead of a goose','The campaign has raised $2.1M, mostly in carbs. Opponents question whether it can be trusted near an oven.','DOUGHMITRI RUNS FOR MAYOR, POLLING 38%'],
['Doughmitri withdraws to “focus on rising”; starter and owner reconcile','The pair were seen sharing a Sunday bake. Sources describe the loaf as “the best thing to come out of this.”','DOUGHMITRI WITHDRAWS. THE LOAF WAS GREAT.']
]}
];
const TIERS = {
net:['Whatever News Network','Whatever?? News Network','We Honestly Don’t Know Network','WNN — Please Remain Seated'],
tag:['24 hours · Facts, mostly','24 hours · Facts, occasionally','25 hours (it’s Tuesday) · Facts, pending','∞ hours · Facts have left the building'],
anchor:['Good evening. Some news is happening. We’ll have it for you all night.',
'I’m being told these are real stories. I have asked twice.',
'Our producers have left the building. I am now also the producer.',
'I’m going to read this exactly as written: “honk.” Back to you, me.'],
sweat:['','💧','💧💧','💧💧💧🫠'],
meter:['Calm','Elevated','Severe','Unprecedented','HONK']
};
const MOODS = { all:'Main desk', politics:'Politics desk', science:'Science desk', food:'Food desk', weather:'Weather desk' };
const FILLER = () => {
const L = state.levels;
const tMin = [11,47,63,63,63,63,0][L.tuesday];
return [
`GOOSE FUTURES ${L.goose > 3 ? '▲' : '▼'} ${(L.goose * 7.3 + 1.2).toFixed(1)}%`,
L.tuesday < 6 ? `TUESDAY LENGTH: 24H ${tMin}M` : 'TUESDAY LENGTH: 24H 0M (NORMAL)',
`RYE FLOUR ▲ ${4 + L.dough * 9}% ON STARTER DEMAND`,
'SPORTS: LOCAL TEAM WINS, LOSES; REFEREE “NOT SURE”',
L.goose >= 2 ? 'TRAFFIC: I-35 BACKED UP BY 400 GEESE AND ONE REPORTER' : 'TRAFFIC: LIGHT, SUSPICIOUSLY',
'CORRECTION: YESTERDAY’S CORRECTION WAS CORRECT',
'WEATHER: POSTPONED',
`VIEWERS ADVISED TO REMAIN CALM (${TIERS.meter[meterTier()].toUpperCase()})`
];
};
/* ------------------------------ state ------------------------------ */
const state = {};
function freshState(){
Object.assign(state, {
levels:{goose:0,tuesday:0,dough:0}, touched:{goose:0,tuesday:0,dough:0},
revisions:{goose:[],tuesday:[],dough:[]}, updates:{goose:[],tuesday:[],dough:[]},
attempts:{goose:0,tuesday:0,dough:0}, liveShots:0, escalations:0, byYou:0, klaxonCount:0,
paused:false, nextAt:0, remaining:BASE_MS, startedAt:Date.now(), finished:false
});
const now = new Date();
for (const s of STORIES) state.updates[s.id].push({t:now, text:s.stages[0][2]});
}
freshState();
state.mood = 'all'; state.klaxon = false; state.sound = true;
try {
const saved = JSON.parse(localStorage.getItem('wnn-prefs') || '{}');
if (MOODS[saved.mood]) state.mood = saved.mood;
if (typeof saved.sound === 'boolean') state.sound = saved.sound;
} catch (_) {}
const savePrefs = () => { try { localStorage.setItem('wnn-prefs', JSON.stringify({mood:state.mood, sound:state.sound})); } catch (_) {} };
/* ------------------------------ helpers ------------------------------ */
const PROPER = /\b(gary|duluth|doughmitri|minnesota|canada|un|france|tuesday|goosedom|nist)\b/g;
const sentence = t => (t.charAt(0) + t.slice(1).toLowerCase()).replace(PROPER, w => w === 'un' ? 'UN' : w === 'nist' ? 'NIST' : w[0].toUpperCase() + w.slice(1));
const fmtTime = d => d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});
const total = () => STORIES.reduce((n, s) => n + state.levels[s.id], 0);
const maxTotal = STORIES.length * (MAX - 1);
const tier = () => Math.min(3, Math.floor(total() / 5));
const meterTier = () => Math.min(4, Math.floor(total() / maxTotal * 4.999));
const isDone = id => state.levels[id] >= MAX - 1;
const allDone = () => STORIES.every(s => isDone(s.id));
const visible = () => STORIES.filter(s => state.mood === 'all' || s.cat === state.mood);
const statusEl = $('#status'), announcer = $('#announcer');
let statusTimer;
function status(msg){ statusEl.textContent = msg; clearTimeout(statusTimer); statusTimer = setTimeout(() => statusEl.textContent = '', 6000); }
function announce(msg){ announcer.textContent = ''; setTimeout(() => announcer.textContent = msg, 30); }
/* ------------------------------ audio (Web Audio, no files) ------------------------------ */
let actx = null, audioFailed = false;
function ctx(){
if (audioFailed) return null;
try {
if (!actx) { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) throw 0; actx = new AC(); }
if (actx.state === 'suspended') actx.resume();
return actx;
} catch (_) { audioFailed = true; return null; }
}
function siren(dur = 2.4){
if (!state.klaxon || !state.sound) return;
const c = ctx();
if (!c) { status('Klaxon is visual-only: this browser has no audio support.'); return; }
const o = c.createOscillator(), f = c.createBiquadFilter(), g = c.createGain(), t = c.currentTime;
o.type = 'sawtooth'; f.type = 'lowpass'; f.frequency.value = 1800;
for (let i = 0; i < dur / .6; i++) {
o.frequency.setValueAtTime(620, t + i * .6);
o.frequency.linearRampToValueAtTime(960, t + i * .6 + .3);
o.frequency.linearRampToValueAtTime(620, t + i * .6 + .6);
}
g.gain.setValueAtTime(.0001, t); g.gain.exponentialRampToValueAtTime(.05, t + .05);
g.gain.setValueAtTime(.05, t + dur - .25); g.gain.exponentialRampToValueAtTime(.0001, t + dur);
o.connect(f).connect(g).connect(c.destination); o.start(t); o.stop(t + dur);
state.klaxonCount++;
}
function tone(){
if (!state.klaxon || !state.sound) return;
const c = ctx(); if (!c) return;
const o = c.createOscillator(), g = c.createGain(), t = c.currentTime;
o.frequency.value = 1000; g.gain.setValueAtTime(.0001, t); g.gain.exponentialRampToValueAtTime(.04, t + .05);
g.gain.setValueAtTime(.04, t + 1.4); g.gain.exponentialRampToValueAtTime(.0001, t + 1.6);
o.connect(g).connect(c.destination); o.start(t); o.stop(t + 1.7);
}
/* ------------------------------ story cards ------------------------------ */
const storiesEl = $('#stories');
const cards = {};
for (const s of STORIES) {
const a = document.createElement('article');
a.className = 'story'; a.id = 'story-' + s.id; a.setAttribute('aria-labelledby', 'h-' + s.id);
a.innerHTML = `
<div class="story-top">
<span class="kicker"><span class="lead-tag">LEAD</span>${s.catLabel} · ${s.place}</span>
<span class="level">Escalation <b></b>/${MAX}</span>
</div>
<div class="pips" aria-hidden="true">${'<i></i>'.repeat(MAX)}</div>
<div class="stamp" hidden>✓ Story concluded</div>
<div class="revisions"></div>
<h2 class="headline" id="h-${s.id}"></h2>
<p class="dek"></p>
<ol class="timeline" aria-label="Latest updates"></ol>
<div class="actions">
<button type="button" class="btn esc">⚡ Escalate now</button>
<button type="button" class="btn go">📡 Go live to ${s.reporter}</button>
</div>
<div class="reporter" aria-live="polite"></div>`;
a.querySelector('.esc').addEventListener('click', () => escalate(s.id, true));
a.querySelector('.go').addEventListener('click', () => goLive(s.id));
storiesEl.appendChild(a);
cards[s.id] = a;
}
function renderCard(s, fresh){
const a = cards[s.id], lvl = state.levels[s.id], [h, dek] = s.stages[lvl];
a.querySelector('.level b').textContent = lvl + 1;
a.querySelectorAll('.pips i').forEach((p, i) => { p.className = i <= lvl ? (i >= 5 ? 'on hot' : 'on') : ''; });
a.querySelector('.revisions').innerHTML = state.revisions[s.id].slice(-2).map(r =>
`<del><small>Updated ${fmtTime(r.t)}</small>${r.h}</del>`).join('');
const hEl = a.querySelector('.headline');
hEl.textContent = h;
if (fresh) { hEl.classList.remove('fresh'); void hEl.offsetWidth; hEl.classList.add('fresh'); }
a.querySelector('.dek').textContent = dek;
a.querySelector('.timeline').innerHTML = state.updates[s.id].slice(-4).reverse().map(u =>
`<li><time>${fmtTime(u.t)}</time>${sentence(u.text)}</li>`).join('');
const done = isDone(s.id), esc = a.querySelector('.esc');
esc.disabled = done; esc.textContent = done ? '✓ Concluded' : '⚡ Escalate now';
a.querySelector('.stamp').hidden = !done;
a.classList.toggle('done', done);
}
let lastVisKey = '';
/* reorder so the most-escalated visible story leads; FLIP-animated unless reduced motion */
function layout(){
const vis = visible();
STORIES.forEach(s => cards[s.id].hidden = !vis.includes(s));
const before = new Map(vis.map(s => [s.id, cards[s.id].getBoundingClientRect()]));
const order = [...vis].sort((a, b) => (state.levels[b.id] - state.levels[a.id]) || (state.touched[b.id] - state.touched[a.id]));
order.forEach((s, i) => { cards[s.id].style.order = i; cards[s.id].classList.toggle('lead', i === 0); });
storiesEl.classList.toggle('single', vis.length === 1);
const empty = vis.length === 0;
$('#empty').hidden = !empty;
const key = vis.map(s => s.id).join();
const sameSet = key === lastVisKey; lastVisKey = key;
if (reduced || !sameSet) return; // only animate pure reorders, never filter swaps
for (const s of vis) {
const el = cards[s.id], b = before.get(s.id), a = el.getBoundingClientRect();
if (!b || !b.width) continue;
const dx = b.left - a.left, dy = b.top - a.top;
if (Math.abs(dx) < 2 && Math.abs(dy) < 2) continue;
el.style.transition = 'none'; el.style.transform = `translate(${dx}px,${dy}px)`;
requestAnimationFrame(() => { el.style.transition = 'transform .55s cubic-bezier(.2,.8,.2,1), rotate .6s'; el.style.transform = ''; });
}
}
/* the channel itself degrades as absurdity rises */
function renderChrome(){
const t = tier(), mt = meterTier(), pct = Math.round(total() / maxTotal * 100);
$('#netName').textContent = TIERS.net[t];
$('#tagline').textContent = TIERS.tag[t];
$('#anchorQuote').textContent = TIERS.anchor[t];
$('#sweat').textContent = TIERS.sweat[t];
$('#meterFill').style.width = pct + '%';
const meter = $('#meter');
meter.setAttribute('aria-valuenow', pct);
meter.setAttribute('aria-valuetext', `${pct}% — ${TIERS.meter[mt]}`);
$('#meterText').textContent = TIERS.meter[mt];
$('#meterPct').textContent = pct + '%';
$('#viewers').textContent = (1204331 + total() * 587113 + state.liveShots * 40211).toLocaleString();
STORIES.forEach((s, i) => cards[s.id].style.setProperty('--tilt', `${(i % 2 ? -1 : 1) * t * 0.35}deg`));
const L = state.levels, mk = [
['GOOSE', (L.goose * 7.3 + 1.2), L.goose > 3],
['BREAD', -(L.goose * 4.1 + .4) + L.dough * 3, L.dough * 3 > L.goose * 4.1],
['RYE', 4 + L.dough * 9, true],
['TUE-DUR', [11,47,63,63,63,63,0][L.tuesday] / 14.4, L.tuesday < 6],
['GARY', L.tuesday * 11.1, L.tuesday >= 4]
];
$('#markets').innerHTML = mk.map(([n, v, up]) =>
`<li><span>${n}</span><span class="${up ? 'up' : 'down'}">${up ? '▲' : '▼'} ${Math.abs(v).toFixed(1)}%</span></li>`).join('');
const lead = visible().sort((a, b) => state.levels[b.id] - state.levels[a.id])[0] || STORIES[0];
$('#breakingText').textContent = lead.stages[state.levels[lead.id]][0];
}
function renderAll(){ STORIES.forEach(s => renderCard(s, false)); layout(); renderChrome(); }
/* ------------------------------ escalation ------------------------------ */
function escalate(id, manual){
if (isDone(id)) { status(`That story has already concluded. There is nothing left to escalate.`); return; }
const s = STORIES.find(x => x.id === id);
const prev = s.stages[state.levels[id]][0];
state.revisions[id].push({t:new Date(), h:prev});
state.levels[id]++; state.touched[id] = Date.now();
state.escalations++; if (manual) state.byYou++;
const [h, , tick] = s.stages[state.levels[id]];
state.updates[id].push({t:new Date(), text:tick});
ticker.push(`${s.catLabel.toUpperCase()} · ${tick}`);
cards[id].querySelector('.reporter').textContent = '';
cards[id].querySelector('.reporter').className = 'reporter';
renderCard(s, true); layout(); renderChrome();
const done = isDone(id);
announce(`${done ? 'Story concluded' : 'Breaking, escalation ' + (state.levels[id] + 1) + ' of ' + MAX}: ${h}`);
if (manual) status(done ? `You pushed “${s.catLabel}” to its conclusion.` : `Escalated: ${s.catLabel} is now at level ${state.levels[id] + 1} of ${MAX}.`);
if (!visible().includes(s)) status(`Meanwhile, off-desk: ${s.catLabel} just escalated (switch to All desks to see it).`);
siren(done ? 1.2 : 2.4);
scheduleNext();
if (allDone()) finish();
}
function autoEscalate(){
const open = STORIES.filter(s => !isDone(s.id));
if (!open.length) return;
const vis = open.filter(s => visible().includes(s));
const pool = vis.length ? vis : open;
escalate(pool[Math.floor(Math.random() * pool.length)].id, false);
}
const interval = () => state.klaxon ? KLAXON_MS : BASE_MS;
function scheduleNext(){ state.nextAt = Date.now() + interval(); state.remaining = interval(); }
/* ------------------------------ field reporters (loading / error / success) ------------------------------ */
function goLive(id){
const s = STORIES.find(x => x.id === id), a = cards[id], btn = a.querySelector('.go'), box = a.querySelector('.reporter');
btn.disabled = true; btn.textContent = '📡 Connecting…'; a.setAttribute('aria-busy', 'true');
box.className = 'reporter'; box.textContent = `Establishing satellite link to ${s.from}…`;
setTimeout(() => {
a.removeAttribute('aria-busy'); btn.disabled = false; btn.textContent = `📡 Go live to ${s.reporter}`;
const attempt = state.attempts[id]++;
if (attempt === 0) { // first live shot always drops — it's that kind of night
box.className = 'reporter err';
box.innerHTML = `<strong>⚠ ${s.fail}</strong><br><button type="button" class="btn retry">↻ Retry the satellite</button>`;
box.querySelector('.retry').addEventListener('click', () => { goLive(id); btn.focus(); });
box.querySelector('.retry').focus();
status(`Live shot failed: ${s.reporter} is unavailable. Retry is available.`);
return;
}
state.liveShots++;
const q = s.quotes[Math.min(s.quotes.length - 1, Math.floor(state.levels[id] / 2.4))];
box.className = 'reporter ok';
box.innerHTML = `<blockquote>“${q}”</blockquote><cite>— ${s.reporter}, live from ${s.from}</cite>`;
status(`Now live: ${s.reporter} from ${s.place}.`);
renderChrome();
}, 1100);
}
/* ------------------------------ completion ------------------------------ */
function finish(){
if (state.finished) return;
state.finished = true;
const secs = Math.round((Date.now() - state.startedAt) / 1000);
$('#stats').innerHTML = [
[state.escalations, 'escalations'], [state.byYou, 'escalated by you'], [state.liveShots, 'live shots'],
[state.klaxonCount, 'klaxon blasts'], [`${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`, 'broadcast length']
].map(([v, l]) => `<li><b>${v}</b>${l}</li>`).join('');
const so = $('#signoff'); so.hidden = false;
ticker.push('END OF BROADCAST · PLEASE STAND BY · TOMORROW HAS BEEN CANCELLED');
$('#cycleText').textContent = 'Broadcast complete — no further developments.';
$('#cycleBar').style.width = '100%';
$('#pauseBtn').disabled = true;
tone();
announce('All three stories have concluded. Broadcast complete.');
setTimeout(() => { so.focus({preventScroll:true}); so.scrollIntoView({behavior: reduced ? 'auto' : 'smooth', block:'start'}); }, 400);
}
function restart(){
freshState();
$('#signoff').hidden = true; $('#pauseBtn').disabled = false;
setPaused(false, true);
STORIES.forEach(s => { const r = cards[s.id].querySelector('.reporter'); r.className = 'reporter'; r.textContent = ''; });
ticker.reset();
scheduleNext(); renderAll();
status('A new news cycle has begun. Everything is fine, for now.');
$('#stories').focus({preventScroll:true});
}
/* ------------------------------ controls ------------------------------ */
function setMood(m, fromKey){
state.mood = m; document.documentElement.dataset.mood = m; savePrefs();
const r = document.querySelector(`input[name=mood][value=${m}]`); if (r) r.checked = true;
if (fromKey && r) r.focus();
$('#moodLabel').textContent = MOODS[m];
layout(); renderChrome();
const n = visible().length;
status(n ? `Tuned to the ${MOODS[m]}. Showing ${n} ${n === 1 ? 'story' : 'stories'}.` : `Tuned to the ${MOODS[m]}. Nothing is airing here.`);
}
document.querySelectorAll('input[name=mood]').forEach(r => r.addEventListener('change', () => setMood(r.value)));
$('#emptyBack').addEventListener('click', () => setMood('all', true));
function setKlaxon(on){
state.klaxon = on;
const b = $('#klaxonBtn'); b.setAttribute('aria-pressed', on); $('#klaxonState').textContent = on ? 'On' : 'Off';
document.body.classList.toggle('klaxon', on);
if (!state.paused && !state.finished) { const left = Math.max(0, state.nextAt - Date.now()); state.nextAt = Date.now() + Math.min(left, interval()); }
ticker.speed = on ? 130 : 80;
if (on) { siren(); status(`Klaxon ON: developments now arrive twice as fast${state.sound ? '' : ' (sound muted)'}.`); }
else status('Klaxon off. The newsroom exhales.');
}
$('#klaxonBtn').addEventListener('click', () => setKlaxon(!state.klaxon));
const soundChk = $('#soundChk'); soundChk.checked = state.sound;
soundChk.addEventListener('change', () => {
state.sound = soundChk.checked; savePrefs();
status(state.sound ? 'Klaxon sound enabled.' : 'Klaxon sound muted — the alarm will be visual only.');
if (state.sound) siren(1.2);
});
function setPaused(p, quiet){
if (state.finished && p) return;
state.paused = p;
if (p) state.remaining = Math.max(0, state.nextAt - Date.now()); else state.nextAt = Date.now() + state.remaining;
const b = $('#pauseBtn'); b.setAttribute('aria-pressed', p); b.textContent = p ? '▶ Resume news cycle' : '⏸ Pause news cycle';
if (!quiet) status(p ? 'News cycle paused. Reality is holding still.' : 'News cycle resumed.');
}
$('#pauseBtn').addEventListener('click', () => setPaused(!state.paused));
$('#resetBtn').addEventListener('click', restart);
$('#restartBtn').addEventListener('click', restart);
document.addEventListener('keydown', e => {
if (e.metaKey || e.ctrlKey || e.altKey || e.repeat) return;
const k = e.key.toLowerCase();
if (k === 'k') setKlaxon(!state.klaxon);
else if (k === 'p') { if (!state.finished) setPaused(!state.paused); }
else if (k === 't') ticker.toggle();
else if (k === 'e') { const lead = visible().filter(s => !isDone(s.id)).sort((a, b) => state.levels[b.id] - state.levels[a.id])[0];
lead ? escalate(lead.id, true) : status('Nothing left to escalate on this desk.'); }
else if ('12345'.includes(k) && k.length === 1) setMood(Object.keys(MOODS)[+k - 1], true);
else return;
e.preventDefault();
});
/* ------------------------------ ticker ------------------------------ */
const ticker = {
root: $('#ticker'), view: $('#tickerView'), track: $('#tickerTrack'), btn: $('#tickerBtn'),
offset:0, last:0, paused:false, speed:80, news:[], staticIdx:0, staticTimer:null, raf:0,
span(text, news){ const s = document.createElement('span'); s.textContent = text; if (news) { s.className = 'news'; s.dataset.loops = 0; } return s; },
fillerIdx:0,
nextFiller(){ const f = FILLER(); return f[this.fillerIdx++ % f.length]; },
push(text){
this.news.push(text); if (this.news.length > 10) this.news.shift();
if (reduced) { this.staticIdx = -1; this.showStatic(text, true); return; }
// insert just past the visible edge so fresh news arrives within seconds
const vw = this.view.clientWidth, el = this.span(text, true);
const after = [...this.track.children].find(c => c.offsetLeft - this.offset > vw);
after ? this.track.insertBefore(el, after) : this.track.appendChild(el);
},
fill(){ while (this.track.scrollWidth - this.offset < this.view.clientWidth * 2) this.track.appendChild(this.span(this.nextFiller(), false)); },
frame(ts){
const dt = this.last ? Math.min(100, ts - this.last) : 0; this.last = ts;
if (!this.paused) {
this.offset += this.speed * dt / 1000;
let first = this.track.firstElementChild;
while (first && first.offsetWidth && first.offsetWidth < this.offset) {
this.offset -= first.offsetWidth; first.remove();
if (first.classList.contains('news') && +first.dataset.loops < 2) { first.dataset.loops++; this.track.appendChild(first); }
first = this.track.firstElementChild;
}
this.fill();
this.track.style.transform = `translateX(${-this.offset}px)`;
}
this.raf = requestAnimationFrame(t => this.frame(t));
},
showStatic(text, isNews){
this.track.innerHTML = ''; const s = this.span(text, isNews); s.classList.add('static'); this.track.appendChild(s);
},
rotateStatic(){
if (this.paused) return;
const pool = [...this.news.slice(-4), ...FILLER()];
this.staticIdx = (this.staticIdx + 1) % pool.length;
this.showStatic(pool[this.staticIdx], this.staticIdx < Math.min(4, this.news.length));
},
setMode(){
cancelAnimationFrame(this.raf); clearInterval(this.staticTimer);
this.track.innerHTML = ''; this.offset = 0; this.last = 0; this.track.style.transform = '';
this.root.classList.toggle('static', reduced);
if (reduced) { this.staticIdx = -1; this.rotateStatic(); this.staticTimer = setInterval(() => this.rotateStatic(), 6000); }
else { this.news.slice(-3).forEach(n => this.track.appendChild(this.span(n, true))); this.fill(); this.raf = requestAnimationFrame(t => this.frame(t)); }
},
toggle(){
this.paused = !this.paused;
this.btn.setAttribute('aria-pressed', this.paused);
this.btn.setAttribute('aria-label', this.paused ? 'Resume ticker' : 'Pause ticker');
this.btn.textContent = this.paused ? '▶' : '❚❚';
status(this.paused ? 'Ticker paused.' : 'Ticker resumed.');
},
reset(){ this.news = []; STORIES.forEach(s => this.news.push(`${s.catLabel.toUpperCase()} · ${s.stages[0][2]}`)); this.setMode(); }
};
ticker.btn.addEventListener('click', () => ticker.toggle());
ticker.view.addEventListener('mouseenter', () => { ticker.hover = ticker.paused; if (!ticker.paused) { ticker.paused = true; ticker.hoverPaused = true; } });
ticker.view.addEventListener('mouseleave', () => { if (ticker.hoverPaused) { ticker.paused = false; ticker.hoverPaused = false; } });
reduceMQ.addEventListener && reduceMQ.addEventListener('change', e => { reduced = e.matches; ticker.setMode(); });
/* ------------------------------ clock + cycle loop ------------------------------ */
function tick(){
$('#clock').textContent = fmtTime(new Date());
if (state.finished) return;
if (state.paused) { $('#cycleText').textContent = `News cycle paused — next development held at ${Math.ceil(state.remaining / 1000)}s`; return; }
const left = state.nextAt - Date.now();
if (left <= 0) { autoEscalate(); return; }
$('#cycleText').textContent = `Next development in ${Math.ceil(left / 1000)}s${state.klaxon ? ' (klaxon speed)' : ''}`;
$('#cycleBar').style.width = (100 - left / interval() * 100).toFixed(1) + '%';
}
/* ------------------------------ boot ------------------------------ */
document.documentElement.dataset.mood = state.mood;
document.querySelector(`input[name=mood][value=${state.mood}]`).checked = true;
$('#moodLabel').textContent = MOODS[state.mood];
scheduleNext(); renderAll(); ticker.reset();
setInterval(tick, 250); tick();
window.WNN = { state, escalate, finish }; // debug handle for testing
})();
</script>
</body>
</html>