← back to Crazy News Channel
cartoons/20260926-server-room-thermostat-war.html
367 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>The Thermostat Standoff — P24 Editorial Cartoon Desk</title>
<meta name="description" content="An original invented-satire strip: an invented office's server room and break room fight a silent war over one shared thermostat dial, stacking sticky-note demands ever higher." />
<link rel="stylesheet" href="../assets/style.css" />
</head>
<body data-mood="scitech" class="cartoon-page cartoon-page-wide">
<header>
<div>
<div class="kicker">P24 Editorial Cartoon Desk · Invented Satire</div>
<h1>The Thermostat Standoff</h1>
<p class="dek">One dial. Two rooms. The server room needs it cold. The break room needs it warm. Neither will speak to the other, so they write notes.</p>
</div>
<div class="header-actions">
<a class="back" href="index.html">← Cartoon Desk</a>
</div>
</header>
<main>
<div class="strip" id="strip">
<figure class="panel" data-i="0">
<div class="phead"><span>Panel 1 · The Dial</span><span id="stamp0"></span></div>
<svg class="art" id="art0" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 1: one thermostat on a corridor wall between a server room door and a break room door."></svg>
<button class="cover" id="cover0" type="button">Click, tap, or press N to begin</button>
<figcaption id="cap0"></figcaption>
</figure>
<figure class="panel" data-i="1">
<div class="phead"><span>Panel 2 · The Notes</span><span id="stamp1"></span></div>
<svg class="art" id="art1" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 2: sticky notes begin stacking on both sides of the thermostat."></svg>
<button class="cover" id="cover1" type="button" disabled>In queue</button>
<figcaption id="cap1"></figcaption>
</figure>
<figure class="panel" data-i="2">
<div class="phead"><span>Panel 3 · The Standoff</span><span id="stamp2"></span></div>
<svg class="art" id="art2" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 3: two towers of sticky-note demands flank the dial, which reports who is currently winning."></svg>
<button class="cover" id="cover2" type="button" disabled>In queue</button>
<figcaption id="cap2"></figcaption>
</figure>
</div>
<div class="slider-row" id="dialRow" hidden>
<div class="row1">
<label for="dial">Drag the dial</label>
<span class="val" id="dialVal">68°F · contested</span>
</div>
<input type="range" id="dial" min="58" max="80" step="1" value="68"
aria-describedby="dialWho" />
<div class="val" id="dialWho" role="status" aria-live="polite">Currently winning: nobody. The dial is contested.</div>
</div>
<div class="dock">
<button class="primary" id="nextBtn" type="button">Reveal Panel 1</button>
<button class="twist" id="twistBtn" type="button">Plot Twist!</button>
<div id="status" role="status" aria-live="polite"></div>
</div>
<p class="hint">Keys: N/→ next panel · T plot twist · click a panel to advance · after panel 3, arrow keys on the dial set the temperature</p>
</main>
<footer>The Halliwell Building, its floors, its facilities staff, and every sticky note quoted here are invented. No real office, employee, or thermostat was consulted. Part of PANDEMONIUM-24.</footer>
<script>
(function(){
'use strict';
const NS = 'http://www.w3.org/2000/svg';
function el(tag, attrs, parent){
const n = document.createElementNS(NS, tag);
for (const k in attrs) n.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(n);
return n;
}
function txt(parent, x, y, s, opts){
opts = opts || {};
const t = el('text', {
x: x, y: y,
'text-anchor': opts.anchor || 'middle',
'font-size': opts.size || 11,
'font-weight': opts.weight || '400',
fill: opts.fill || 'currentColor'
}, parent);
t.textContent = s;
return t;
}
function reduced(){ return matchMedia('(prefers-reduced-motion: reduce)').matches; }
// ---- the two invented sides. Not people: departments. ----
const COLD = { key:'cold', label:'SERVER ROOM', color:'var(--focus)', want:'colder' };
const WARM = { key:'warm', label:'BREAK ROOM', color:'var(--accent)', want:'warmer' };
// Escalating sticky-note demands. Each plot twist unlocks one more rung.
const DEMANDS = {
cold: [
'DO NOT TOUCH',
'The racks do not have sweaters.',
'Every degree up is $41/mo.',
'We were here first (2011).',
'Facilities has been notified.',
'We have logged all 46 changes.',
],
warm: [
'It is 64 in here.',
'People also do not have sweaters.',
'Coffee is going cold in the cup.',
'We were here first (2009).',
'Facilities has ALSO been notified.',
'We keep a log too. Ours is longer.',
]
};
const CAPTIONS = [
'One dial serves a corridor with a server room on one end and a break room on the other. It was installed in 2011 and has never been discussed out loud.',
'Neither side will raise it in a meeting, so the negotiation moves to adhesive paper. Each note is signed only with a department.',
'The demands are now taller than the thermostat. The dial reports whichever room last got to it; nobody has conceded anything.',
];
const state = { revealed: 0, twists: 0, temp: 68 };
// 68 is the declared neutral. Below = server room winning, above = break room.
const NEUTRAL = 68;
function winner(t){
if (t < NEUTRAL - 1) return COLD;
if (t > NEUTRAL + 1) return WARM;
return null;
}
function rungs(){ return Math.min(2 + state.twists, 6); }
// ---------- drawing helpers ----------
function wall(svg){
el('line', { x1:0, y1:236, x2:300, y2:236, stroke:'currentColor', 'stroke-width':2, opacity:.55 }, svg);
}
function door(svg, x, label, color){
const g = el('g', { 'data-kind':'door' }, svg);
el('rect', { x:x, y:150, width:52, height:86, fill:'none', stroke:color, 'stroke-width':2.6, rx:2 }, g);
el('circle', { cx:x+44, cy:196, r:2.6, fill:color }, g);
// The label is wider than the 52px door by design (it names the room, it is
// not a sign ON the door). In panel 3 the doors sit hard against the 0/300
// viewBox edges, so a naively centred label runs off-canvas. Re-centre from
// the MEASURED bbox rather than an estimate — character-width guesses are
// font-dependent and were off by ~8px here, which still clipped the label.
const t = txt(g, x + 26, 250, label, { size: 8.5, weight:'800', fill:color });
t.setAttribute('letter-spacing', '.04em');
try {
const b = t.getBBox();
const half = b.width / 2;
const want = Math.min(Math.max(x + 26, half + 2), 298 - half);
if (want !== x + 26) t.setAttribute('x', want);
} catch (_) { /* getBBox throws if not yet laid out; centred fallback is fine */ }
return g;
}
// The thermostat itself: a round dial on a plate, needle set by temp.
function thermostat(svg, cx, cy, r, temp, hot){
const g = el('g', {}, svg);
el('rect', { x:cx-r-9, y:cy-r-9, width:(r+9)*2, height:(r+9)*2, rx:5,
fill:'var(--panel)', stroke:'currentColor', 'stroke-width':2 }, g);
el('circle', { cx, cy, r, fill:'none', stroke:'currentColor', 'stroke-width':2.2, opacity:.8 }, g);
// tick marks
for (let i=0;i<=10;i++){
const a = (-210 + i*24) * Math.PI/180;
el('line', {
x1: cx + Math.cos(a)*(r-3), y1: cy + Math.sin(a)*(r-3),
x2: cx + Math.cos(a)*(r-8), y2: cy + Math.sin(a)*(r-8),
stroke:'currentColor', 'stroke-width':1.4, opacity:.6
}, g);
}
// needle
const frac = (temp - 58) / (80 - 58);
const ang = (-210 + frac * 240) * Math.PI/180;
el('line', { x1:cx, y1:cy, x2: cx + Math.cos(ang)*(r-11), y2: cy + Math.sin(ang)*(r-11),
stroke: hot ? WARM.color : COLD.color, 'stroke-width':3.4, 'stroke-linecap':'round' }, g);
el('circle', { cx, cy, r:3.4, fill:'currentColor' }, g);
txt(g, cx, cy + r + 1, temp + '°', { size:11, weight:'800' });
return g;
}
// One sticky note. Text is clipped to the note by hand-wrapping at ~15 chars.
function sticky(svg, x, y, w, color, line, rot){
const g = el('g', { 'data-kind':'note', transform: `rotate(${rot} ${x+w/2} ${y+16})` }, svg);
el('rect', { x, y, width:w, height:32, fill:color, 'fill-opacity':'.16',
stroke:color, 'stroke-width':1.6 }, g);
const words = String(line).split(' ');
const lines = [];
let cur = '';
words.forEach(function(word){
if ((cur + ' ' + word).trim().length > 16 && cur) { lines.push(cur); cur = word; }
else cur = (cur + ' ' + word).trim();
});
if (cur) lines.push(cur);
const show = lines.slice(0, 3);
show.forEach(function(ln, i){
txt(g, x + w/2, y + 12 + i*9, ln, { size:6.6, fill:color, weight:'700' });
});
return g;
}
function noteStack(svg, side, xLeft, w, count, color){
for (let i=0;i<count;i++){
const line = DEMANDS[side][i % DEMANDS[side].length];
sticky(svg, xLeft, 196 - i*34, w, color, line, (i%2 ? 1.6 : -1.9));
}
}
// ---------- the three panels ----------
function panel0(svg){
svg.innerHTML = '';
wall(svg);
door(svg, 16, COLD.label, COLD.color);
door(svg, 232, WARM.label, WARM.color);
thermostat(svg, 150, 168, 30, NEUTRAL, false);
txt(svg, 150, 44, 'ONE DIAL. ONE CORRIDOR.', { size:12, weight:'800' });
txt(svg, 150, 62, 'Installed 2011. Never discussed.', { size:9.5, fill:'var(--ink-dim)' });
txt(svg, 150, 276, 'Set point: ' + NEUTRAL + '°F. Agreed by nobody.', { size:9, fill:'var(--ink-dim)' });
}
function panel1(svg){
svg.innerHTML = '';
wall(svg);
door(svg, 16, COLD.label, COLD.color);
door(svg, 232, WARM.label, WARM.color);
thermostat(svg, 150, 168, 26, NEUTRAL, false);
noteStack(svg, 'cold', 74, 62, 2, COLD.color);
noteStack(svg, 'warm', 164, 62, 2, WARM.color);
txt(svg, 150, 40, 'THE NEGOTIATION BEGINS', { size:11.5, weight:'800' });
txt(svg, 150, 58, 'in writing, unsigned, one department at a time', { size:8.5, fill:'var(--ink-dim)' });
txt(svg, 150, 276, 'Two notes each. Neither room has spoken.', { size:9, fill:'var(--ink-dim)' });
}
function panel2(svg){
svg.innerHTML = '';
const n = rungs();
wall(svg);
door(svg, 6, COLD.label, COLD.color);
door(svg, 242, WARM.label, WARM.color);
const w = winner(state.temp);
thermostat(svg, 150, 176, 24, state.temp, !!(w && w.key === 'warm'));
noteStack(svg, 'cold', 66, 58, n, COLD.color);
noteStack(svg, 'warm', 176, 58, n, WARM.color);
// The verdict goes at the BOTTOM, not the top: the stack grows UPWARD with
// each twist, and at full height (6 rungs) the topmost note reaches y=26 —
// exactly where a top headline sits. Bottom also reads better, as the
// standings line under the standoff.
const head = w ? ('WINNING: ' + w.label) : 'CONTESTED — NOBODY WINNING';
txt(svg, 150, 16, n * 2 + ' notes on file. 0 conversations held.', { size:8.5, fill:'var(--ink-dim)' });
txt(svg, 150, 288, head, { size:11.5, weight:'800', fill: w ? w.color : 'currentColor' });
}
// ---------- state plumbing ----------
const STAMPS = ['INSTALLED','IN WRITING','STANDOFF'];
function renderPanel(i){
const svg = document.getElementById('art' + i);
if (i === 0) panel0(svg);
if (i === 1) panel1(svg);
if (i === 2) panel2(svg);
document.getElementById('cap' + i).textContent = CAPTIONS[i];
document.getElementById('stamp' + i).textContent = i < state.revealed ? STAMPS[i] : '';
}
function syncCovers(){
for (let i=0;i<3;i++){
const cover = document.getElementById('cover' + i);
cover.hidden = i < state.revealed;
cover.disabled = i > state.revealed;
if (i === state.revealed) {
cover.textContent = i === 0 ? 'Click, tap, or press N to begin' : 'Click, tap, or press N to reveal';
}
document.getElementById('art' + i).setAttribute('tabindex', i === state.revealed ? '0' : '-1');
}
}
function syncDial(){
const row = document.getElementById('dialRow');
row.hidden = state.revealed < 3;
const w = winner(state.temp);
document.getElementById('dial').value = String(state.temp);
document.getElementById('dialVal').textContent =
state.temp + '°F · ' + (w ? w.label.toLowerCase() + ' ahead' : 'contested');
document.getElementById('dialWho').textContent = w
? ('Currently winning: the ' + w.label.toLowerCase() + '. It wants it ' + w.want + ' and right now it is.')
: 'Currently winning: nobody. The dial is within a degree of the 68° set point.';
}
function syncButtons(){
const b = document.getElementById('nextBtn');
if (state.revealed >= 3) { b.textContent = 'All 3 panels revealed ✓'; b.disabled = true; }
else { b.textContent = 'Reveal Panel ' + (state.revealed + 1); b.disabled = false; }
}
function say(kind, msg){
const s = document.getElementById('status');
s.className = kind;
s.textContent = msg;
}
function paintAll(){
for (let i=0;i<3;i++) renderPanel(i);
syncCovers(); syncDial(); syncButtons();
}
function next(){
if (state.revealed >= 3) { say('info', 'All 3 panels are already up. Drag the dial, or try Plot Twist!'); return; }
state.revealed++;
renderPanel(state.revealed - 1);
syncCovers(); syncDial(); syncButtons();
if (state.revealed >= 3) {
say('ok', 'All 3 panels revealed. Drag the dial to see who is winning — or press Plot Twist!');
} else {
say('ok', 'Panel ' + state.revealed + ' revealed.');
}
}
function twist(){
if (state.revealed < 3) { say('err', 'ERROR TS-409 · Premature escalation — reveal all 3 panels first.'); return; }
state.twists++;
// Each twist: both sides add a rung, and whoever was losing lunges for the dial.
const w = winner(state.temp);
if (!w) state.temp = state.twists % 2 ? 62 : 74;
else state.temp = w.key === 'cold' ? Math.min(80, state.temp + 7) : Math.max(58, state.temp - 7);
renderPanel(2);
syncDial();
const nw = winner(state.temp);
say('ok', 'Plot twist #' + state.twists + ': ' + rungs() * 2 + ' notes now on the wall, and the '
+ (nw ? nw.label.toLowerCase() : 'corridor') + ' got to the dial last.');
}
function setTemp(v){
const t = Math.max(58, Math.min(80, parseInt(v, 10) || NEUTRAL));
if (t === state.temp) return;
state.temp = t;
if (state.revealed >= 3) renderPanel(2);
syncDial();
}
// ---------- wiring ----------
document.getElementById('nextBtn').addEventListener('click', next);
document.getElementById('twistBtn').addEventListener('click', twist);
document.getElementById('dial').addEventListener('input', function(e){ setTemp(e.target.value); });
for (let i=0;i<3;i++){
document.getElementById('cover' + i).addEventListener('click', function(){ if (i === state.revealed) next(); });
document.getElementById('art' + i).addEventListener('click', function(){ if (i === state.revealed) next(); });
document.getElementById('art' + i).addEventListener('keydown', function(e){
if ((e.key === 'Enter' || e.key === ' ') && i === state.revealed) { e.preventDefault(); next(); }
});
}
document.addEventListener('keydown', function(e){
if (e.metaKey || e.ctrlKey || e.altKey) return;
const tag = (e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
const k = e.key.toLowerCase();
if (k === 'n' || e.key === 'ArrowRight') { e.preventDefault(); next(); }
else if (k === 't') { e.preventDefault(); twist(); }
});
// reduced motion: nothing here animates, but keep the hook honest for
// anything added later (the strip is state-swap only, no transitions).
if (reduced()) document.documentElement.setAttribute('data-reduced-motion', 'true');
paintAll();
say('info', 'Corridor on file. Press N or click Panel 1 to begin.');
})();
</script>
</body>
</html>