← 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 &middot; Invented Satire</div>
    <h1>The Thermostat Standoff</h1>
    <p class="dek">One dial. Two rooms. The server room needs it cold. The break room needs it warm. Neither will speak to the other, so they write notes.</p>
  </div>
  <div class="header-actions">
    <a class="back" href="index.html">&larr; Cartoon Desk</a>
  </div>
</header>
<main>
  <div class="strip" id="strip">
    <figure class="panel" data-i="0">
      <div class="phead"><span>Panel 1 &middot; The Dial</span><span id="stamp0"></span></div>
      <svg class="art" id="art0" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 1: one thermostat on a corridor wall between a server room door and a break room door."></svg>
      <button class="cover" id="cover0" type="button">Click, tap, or press N to begin</button>
      <figcaption id="cap0"></figcaption>
    </figure>
    <figure class="panel" data-i="1">
      <div class="phead"><span>Panel 2 &middot; The Notes</span><span id="stamp1"></span></div>
      <svg class="art" id="art1" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 2: sticky notes begin stacking on both sides of the thermostat."></svg>
      <button class="cover" id="cover1" type="button" disabled>In queue</button>
      <figcaption id="cap1"></figcaption>
    </figure>
    <figure class="panel" data-i="2">
      <div class="phead"><span>Panel 3 &middot; The Standoff</span><span id="stamp2"></span></div>
      <svg class="art" id="art2" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 3: two towers of sticky-note demands flank the dial, which reports who is currently winning."></svg>
      <button class="cover" id="cover2" type="button" disabled>In queue</button>
      <figcaption id="cap2"></figcaption>
    </figure>
  </div>

  <div class="slider-row" id="dialRow" hidden>
    <div class="row1">
      <label for="dial">Drag the dial</label>
      <span class="val" id="dialVal">68&deg;F &middot; contested</span>
    </div>
    <input type="range" id="dial" min="58" max="80" step="1" value="68"
           aria-describedby="dialWho" />
    <div class="val" id="dialWho" role="status" aria-live="polite">Currently winning: nobody. The dial is contested.</div>
  </div>

  <div class="dock">
    <button class="primary" id="nextBtn" type="button">Reveal Panel 1</button>
    <button class="twist" id="twistBtn" type="button">Plot Twist!</button>
    <div id="status" role="status" aria-live="polite"></div>
  </div>
  <p class="hint">Keys: N/&rarr; next panel &middot; T plot twist &middot; click a panel to advance &middot; after panel 3, arrow keys on the dial set the temperature</p>
</main>
<footer>The Halliwell Building, its floors, its facilities staff, and every sticky note quoted here are invented. No real office, employee, or thermostat was consulted. Part of PANDEMONIUM-24.</footer>

<script>
(function(){
  'use strict';
  const NS = 'http://www.w3.org/2000/svg';
  function el(tag, attrs, parent){
    const n = document.createElementNS(NS, tag);
    for (const k in attrs) n.setAttribute(k, attrs[k]);
    if (parent) parent.appendChild(n);
    return n;
  }
  function txt(parent, x, y, s, opts){
    opts = opts || {};
    const t = el('text', {
      x: x, y: y,
      'text-anchor': opts.anchor || 'middle',
      'font-size': opts.size || 11,
      'font-weight': opts.weight || '400',
      fill: opts.fill || 'currentColor'
    }, parent);
    t.textContent = s;
    return t;
  }
  function reduced(){ return matchMedia('(prefers-reduced-motion: reduce)').matches; }

  // ---- the two invented sides. Not people: departments. ----
  const COLD = { key:'cold', label:'SERVER ROOM', color:'var(--focus)', want:'colder' };
  const WARM = { key:'warm', label:'BREAK ROOM',  color:'var(--accent)', want:'warmer' };

  // Escalating sticky-note demands. Each plot twist unlocks one more rung.
  const DEMANDS = {
    cold: [
      'DO NOT TOUCH',
      'The racks do not have sweaters.',
      'Every degree up is $41/mo.',
      'We were here first (2011).',
      'Facilities has been notified.',
      'We have logged all 46 changes.',
    ],
    warm: [
      'It is 64 in here.',
      'People also do not have sweaters.',
      'Coffee is going cold in the cup.',
      'We were here first (2009).',
      'Facilities has ALSO been notified.',
      'We keep a log too. Ours is longer.',
    ]
  };

  const CAPTIONS = [
    'One dial serves a corridor with a server room on one end and a break room on the other. It was installed in 2011 and has never been discussed out loud.',
    'Neither side will raise it in a meeting, so the negotiation moves to adhesive paper. Each note is signed only with a department.',
    'The demands are now taller than the thermostat. The dial reports whichever room last got to it; nobody has conceded anything.',
  ];

  const state = { revealed: 0, twists: 0, temp: 68 };

  // 68 is the declared neutral. Below = server room winning, above = break room.
  const NEUTRAL = 68;
  function winner(t){
    if (t < NEUTRAL - 1) return COLD;
    if (t > NEUTRAL + 1) return WARM;
    return null;
  }
  function rungs(){ return Math.min(2 + state.twists, 6); }

  // ---------- drawing helpers ----------
  function wall(svg){
    el('line', { x1:0, y1:236, x2:300, y2:236, stroke:'currentColor', 'stroke-width':2, opacity:.55 }, svg);
  }

  function door(svg, x, label, color){
    const g = el('g', { 'data-kind':'door' }, svg);
    el('rect', { x:x, y:150, width:52, height:86, fill:'none', stroke:color, 'stroke-width':2.6, rx:2 }, g);
    el('circle', { cx:x+44, cy:196, r:2.6, fill:color }, g);
    // The label is wider than the 52px door by design (it names the room, it is
    // not a sign ON the door). In panel 3 the doors sit hard against the 0/300
    // viewBox edges, so a naively centred label runs off-canvas. Re-centre from
    // the MEASURED bbox rather than an estimate — character-width guesses are
    // font-dependent and were off by ~8px here, which still clipped the label.
    const t = txt(g, x + 26, 250, label, { size: 8.5, weight:'800', fill:color });
    t.setAttribute('letter-spacing', '.04em');
    try {
      const b = t.getBBox();
      const half = b.width / 2;
      const want = Math.min(Math.max(x + 26, half + 2), 298 - half);
      if (want !== x + 26) t.setAttribute('x', want);
    } catch (_) { /* getBBox throws if not yet laid out; centred fallback is fine */ }
    return g;
  }

  // The thermostat itself: a round dial on a plate, needle set by temp.
  function thermostat(svg, cx, cy, r, temp, hot){
    const g = el('g', {}, svg);
    el('rect', { x:cx-r-9, y:cy-r-9, width:(r+9)*2, height:(r+9)*2, rx:5,
                 fill:'var(--panel)', stroke:'currentColor', 'stroke-width':2 }, g);
    el('circle', { cx, cy, r, fill:'none', stroke:'currentColor', 'stroke-width':2.2, opacity:.8 }, g);
    // tick marks
    for (let i=0;i<=10;i++){
      const a = (-210 + i*24) * Math.PI/180;
      el('line', {
        x1: cx + Math.cos(a)*(r-3), y1: cy + Math.sin(a)*(r-3),
        x2: cx + Math.cos(a)*(r-8), y2: cy + Math.sin(a)*(r-8),
        stroke:'currentColor', 'stroke-width':1.4, opacity:.6
      }, g);
    }
    // needle
    const frac = (temp - 58) / (80 - 58);
    const ang = (-210 + frac * 240) * Math.PI/180;
    el('line', { x1:cx, y1:cy, x2: cx + Math.cos(ang)*(r-11), y2: cy + Math.sin(ang)*(r-11),
                 stroke: hot ? WARM.color : COLD.color, 'stroke-width':3.4, 'stroke-linecap':'round' }, g);
    el('circle', { cx, cy, r:3.4, fill:'currentColor' }, g);
    txt(g, cx, cy + r + 1, temp + '°', { size:11, weight:'800' });
    return g;
  }

  // One sticky note. Text is clipped to the note by hand-wrapping at ~15 chars.
  function sticky(svg, x, y, w, color, line, rot){
    const g = el('g', { 'data-kind':'note', transform: `rotate(${rot} ${x+w/2} ${y+16})` }, svg);
    el('rect', { x, y, width:w, height:32, fill:color, 'fill-opacity':'.16',
                 stroke:color, 'stroke-width':1.6 }, g);
    const words = String(line).split(' ');
    const lines = [];
    let cur = '';
    words.forEach(function(word){
      if ((cur + ' ' + word).trim().length > 16 && cur) { lines.push(cur); cur = word; }
      else cur = (cur + ' ' + word).trim();
    });
    if (cur) lines.push(cur);
    const show = lines.slice(0, 3);
    show.forEach(function(ln, i){
      txt(g, x + w/2, y + 12 + i*9, ln, { size:6.6, fill:color, weight:'700' });
    });
    return g;
  }

  function noteStack(svg, side, xLeft, w, count, color){
    for (let i=0;i<count;i++){
      const line = DEMANDS[side][i % DEMANDS[side].length];
      sticky(svg, xLeft, 196 - i*34, w, color, line, (i%2 ? 1.6 : -1.9));
    }
  }

  // ---------- the three panels ----------
  function panel0(svg){
    svg.innerHTML = '';
    wall(svg);
    door(svg, 16, COLD.label, COLD.color);
    door(svg, 232, WARM.label, WARM.color);
    thermostat(svg, 150, 168, 30, NEUTRAL, false);
    txt(svg, 150, 44, 'ONE DIAL. ONE CORRIDOR.', { size:12, weight:'800' });
    txt(svg, 150, 62, 'Installed 2011. Never discussed.', { size:9.5, fill:'var(--ink-dim)' });
    txt(svg, 150, 276, 'Set point: ' + NEUTRAL + '°F. Agreed by nobody.', { size:9, fill:'var(--ink-dim)' });
  }

  function panel1(svg){
    svg.innerHTML = '';
    wall(svg);
    door(svg, 16, COLD.label, COLD.color);
    door(svg, 232, WARM.label, WARM.color);
    thermostat(svg, 150, 168, 26, NEUTRAL, false);
    noteStack(svg, 'cold', 74, 62, 2, COLD.color);
    noteStack(svg, 'warm', 164, 62, 2, WARM.color);
    txt(svg, 150, 40, 'THE NEGOTIATION BEGINS', { size:11.5, weight:'800' });
    txt(svg, 150, 58, 'in writing, unsigned, one department at a time', { size:8.5, fill:'var(--ink-dim)' });
    txt(svg, 150, 276, 'Two notes each. Neither room has spoken.', { size:9, fill:'var(--ink-dim)' });
  }

  function panel2(svg){
    svg.innerHTML = '';
    const n = rungs();
    wall(svg);
    door(svg, 6, COLD.label, COLD.color);
    door(svg, 242, WARM.label, WARM.color);
    const w = winner(state.temp);
    thermostat(svg, 150, 176, 24, state.temp, !!(w && w.key === 'warm'));
    noteStack(svg, 'cold', 66, 58, n, COLD.color);
    noteStack(svg, 'warm', 176, 58, n, WARM.color);
    // The verdict goes at the BOTTOM, not the top: the stack grows UPWARD with
    // each twist, and at full height (6 rungs) the topmost note reaches y=26 —
    // exactly where a top headline sits. Bottom also reads better, as the
    // standings line under the standoff.
    const head = w ? ('WINNING: ' + w.label) : 'CONTESTED — NOBODY WINNING';
    txt(svg, 150, 16, n * 2 + ' notes on file. 0 conversations held.', { size:8.5, fill:'var(--ink-dim)' });
    txt(svg, 150, 288, head, { size:11.5, weight:'800', fill: w ? w.color : 'currentColor' });
  }

  // ---------- state plumbing ----------
  const STAMPS = ['INSTALLED','IN WRITING','STANDOFF'];

  function renderPanel(i){
    const svg = document.getElementById('art' + i);
    if (i === 0) panel0(svg);
    if (i === 1) panel1(svg);
    if (i === 2) panel2(svg);
    document.getElementById('cap' + i).textContent = CAPTIONS[i];
    document.getElementById('stamp' + i).textContent = i < state.revealed ? STAMPS[i] : '';
  }

  function syncCovers(){
    for (let i=0;i<3;i++){
      const cover = document.getElementById('cover' + i);
      cover.hidden = i < state.revealed;
      cover.disabled = i > state.revealed;
      if (i === state.revealed) {
        cover.textContent = i === 0 ? 'Click, tap, or press N to begin' : 'Click, tap, or press N to reveal';
      }
      document.getElementById('art' + i).setAttribute('tabindex', i === state.revealed ? '0' : '-1');
    }
  }

  function syncDial(){
    const row = document.getElementById('dialRow');
    row.hidden = state.revealed < 3;
    const w = winner(state.temp);
    document.getElementById('dial').value = String(state.temp);
    document.getElementById('dialVal').textContent =
      state.temp + '°F · ' + (w ? w.label.toLowerCase() + ' ahead' : 'contested');
    document.getElementById('dialWho').textContent = w
      ? ('Currently winning: the ' + w.label.toLowerCase() + '. It wants it ' + w.want + ' and right now it is.')
      : 'Currently winning: nobody. The dial is within a degree of the 68° set point.';
  }

  function syncButtons(){
    const b = document.getElementById('nextBtn');
    if (state.revealed >= 3) { b.textContent = 'All 3 panels revealed ✓'; b.disabled = true; }
    else { b.textContent = 'Reveal Panel ' + (state.revealed + 1); b.disabled = false; }
  }

  function say(kind, msg){
    const s = document.getElementById('status');
    s.className = kind;
    s.textContent = msg;
  }

  function paintAll(){
    for (let i=0;i<3;i++) renderPanel(i);
    syncCovers(); syncDial(); syncButtons();
  }

  function next(){
    if (state.revealed >= 3) { say('info', 'All 3 panels are already up. Drag the dial, or try Plot Twist!'); return; }
    state.revealed++;
    renderPanel(state.revealed - 1);
    syncCovers(); syncDial(); syncButtons();
    if (state.revealed >= 3) {
      say('ok', 'All 3 panels revealed. Drag the dial to see who is winning — or press Plot Twist!');
    } else {
      say('ok', 'Panel ' + state.revealed + ' revealed.');
    }
  }

  function twist(){
    if (state.revealed < 3) { say('err', 'ERROR TS-409 · Premature escalation — reveal all 3 panels first.'); return; }
    state.twists++;
    // Each twist: both sides add a rung, and whoever was losing lunges for the dial.
    const w = winner(state.temp);
    if (!w) state.temp = state.twists % 2 ? 62 : 74;
    else state.temp = w.key === 'cold' ? Math.min(80, state.temp + 7) : Math.max(58, state.temp - 7);
    renderPanel(2);
    syncDial();
    const nw = winner(state.temp);
    say('ok', 'Plot twist #' + state.twists + ': ' + rungs() * 2 + ' notes now on the wall, and the '
      + (nw ? nw.label.toLowerCase() : 'corridor') + ' got to the dial last.');
  }

  function setTemp(v){
    const t = Math.max(58, Math.min(80, parseInt(v, 10) || NEUTRAL));
    if (t === state.temp) return;
    state.temp = t;
    if (state.revealed >= 3) renderPanel(2);
    syncDial();
  }

  // ---------- wiring ----------
  document.getElementById('nextBtn').addEventListener('click', next);
  document.getElementById('twistBtn').addEventListener('click', twist);
  document.getElementById('dial').addEventListener('input', function(e){ setTemp(e.target.value); });

  for (let i=0;i<3;i++){
    document.getElementById('cover' + i).addEventListener('click', function(){ if (i === state.revealed) next(); });
    document.getElementById('art' + i).addEventListener('click', function(){ if (i === state.revealed) next(); });
    document.getElementById('art' + i).addEventListener('keydown', function(e){
      if ((e.key === 'Enter' || e.key === ' ') && i === state.revealed) { e.preventDefault(); next(); }
    });
  }

  document.addEventListener('keydown', function(e){
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    const tag = (e.target.tagName || '').toLowerCase();
    if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
    const k = e.key.toLowerCase();
    if (k === 'n' || e.key === 'ArrowRight') { e.preventDefault(); next(); }
    else if (k === 't') { e.preventDefault(); twist(); }
  });

  // reduced motion: nothing here animates, but keep the hook honest for
  // anything added later (the strip is state-swap only, no transitions).
  if (reduced()) document.documentElement.setAttribute('data-reduced-motion', 'true');

  paintAll();
  say('info', 'Corridor on file. Press N or click Panel 1 to begin.');
})();
</script>
</body>
</html>