← back to Crazy News Channel

cartoons/20260926-rainbow-permit-office.html

342 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>The Rainbow That Needed a Permit — P24 Cartoon Desk</title>
<meta name="description" content="An original invented-satire strip: a recurring afternoon rainbow over an invented town is asked to file for a standing-display permit, and a clipboard-wielding clerk measures its arc." />
<link rel="stylesheet" href="../assets/style.css" />
</head>
<body data-mood="weather" class="cartoon-page cartoon-page-wide">
<header>
  <div>
    <div class="kicker">P24 Cartoon Desk &middot; Invented Satire</div>
    <h1>The Rainbow That Needed a Permit</h1>
    <p class="dek">An invented town's rainbow keeps turning up in the same spot every afternoon, so the permit office sends a clerk with a clipboard to measure the arc.</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 Sighting</span><span id="stamp0"></span></div>
      <svg class="art" id="art0" viewBox="0 0 300 300" role="img" tabindex="0" aria-label="Panel 1, not yet revealed."></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 Filing</span><span id="stamp1"></span></div>
      <svg class="art" id="art1" viewBox="0 0 300 300" role="img" tabindex="-1" aria-label="Panel 2, in queue."></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 Conditions</span><span id="stamp2"></span></div>
      <svg class="art" id="art2" viewBox="0 0 300 300" role="img" tabindex="-1" aria-label="Panel 3, in queue."></svg>
      <button class="cover" id="cover2" type="button" disabled>In queue</button>
      <figcaption id="cap2"></figcaption>
    </figure>
  </div>

  <div class="dock">
    <button class="primary" id="nextBtn" type="button">Advance Review Stage 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</p>
</main>
<footer>The town of Pellow Bend, its permit office, its clerks, and its rainbow are all invented. No actual atmospheric phenomenon was cited. Part of PANDEMONIUM-24.</footer>

<script>
(function(){
  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(svg, x, y, s, opts){
    const o = opts || {};
    const t = el('text', {
      x: x, y: y,
      'text-anchor': o.anchor || 'middle',
      'font-size': o.size || 12,
      'font-weight': o.weight || '400',
      fill: o.fill || 'currentColor'
    }, svg);
    t.textContent = s;
    return t;
  }
  function reduced(){ return matchMedia('(prefers-reduced-motion: reduce)').matches; }

  // ---- invented clerks of the Pellow Bend permit office ----
  const CLERKS = [
    { name: 'Clerk Ordway',  color: '#b5302f' },
    { name: 'Clerk Vestine', color: '#0f6b52' },
    { name: 'Clerk Habersham', color: '#6b4fa0' },
    { name: 'Clerk Nunley',  color: '#c9a227' },
  ];
  let clerkIdx = 0;

  // The rainbow's own bands are content, not theme: a rainbow that
  // renders in one ink is not a rainbow. Everything else uses the
  // site's palette vars so the strip matches the rest of the desk.
  const BANDS = ['#c0392b','#d98324','#d9b310','#2e7d4f','#2b6cb0','#5b3fa0'];

  function skyline(svg, y){
    el('line', { x1: 0, y1: y, x2: 300, y2: y, stroke: 'currentColor', 'stroke-width': 2, opacity: 0.55 }, svg);
    const roofs = [[18,26,16],[50,34,22],[90,20,13],[196,30,19],[236,24,15],[266,32,20]];
    roofs.forEach(function(r){
      const x = r[0], w = r[1], h = r[2];
      el('rect', { x: x, y: y - h, width: w, height: h, fill: 'currentColor', opacity: 0.18 }, svg);
      el('path', { d: 'M ' + (x-3) + ' ' + (y-h) + ' L ' + (x + w/2) + ' ' + (y-h-8) + ' L ' + (x+w+3) + ' ' + (y-h) + ' Z',
                   fill: 'currentColor', opacity: 0.3 }, svg);
    });
  }

  // Half-arc rainbow centred on (cx, baseY), outer radius r.
  function rainbow(svg, cx, baseY, r, opts){
    const o = opts || {};
    const g = el('g', {}, svg);
    const bw = (o.bandWidth || 7);
    BANDS.forEach(function(c, i){
      const rr = r - i * bw;
      if (rr <= bw) return;
      el('path', {
        d: 'M ' + (cx - rr) + ' ' + baseY + ' A ' + rr + ' ' + rr + ' 0 0 1 ' + (cx + rr) + ' ' + baseY,
        fill: 'none', stroke: c, 'stroke-width': bw - 1,
        'stroke-opacity': o.faded ? 0.45 : 0.9, 'stroke-linecap': 'butt'
      }, g);
    });
    return g;
  }

  // Flat iconographic clerk: body, head, and a clipboard held out front.
  function clerk(svg, cx, footY, scale, color, label){
    const s = scale;
    const g = el('g', {}, svg);
    el('circle', { cx: cx, cy: footY - 42*s, r: 9*s, fill: color }, g);                    // head
    el('path', { d: 'M ' + cx + ' ' + (footY - 33*s) + ' L ' + cx + ' ' + (footY - 12*s),
                 stroke: color, 'stroke-width': 7*s, 'stroke-linecap': 'round' }, g);      // torso
    el('path', { d: 'M ' + cx + ' ' + (footY - 12*s) + ' L ' + (cx - 7*s) + ' ' + footY,
                 stroke: color, 'stroke-width': 4*s, 'stroke-linecap': 'round' }, g);      // leg
    el('path', { d: 'M ' + cx + ' ' + (footY - 12*s) + ' L ' + (cx + 7*s) + ' ' + footY,
                 stroke: color, 'stroke-width': 4*s, 'stroke-linecap': 'round' }, g);      // leg
    el('path', { d: 'M ' + cx + ' ' + (footY - 28*s) + ' L ' + (cx + 14*s) + ' ' + (footY - 24*s),
                 stroke: color, 'stroke-width': 4*s, 'stroke-linecap': 'round' }, g);      // arm
    // clipboard
    el('rect', { x: cx + 12*s, y: footY - 34*s, width: 14*s, height: 18*s, rx: 1.5*s,
                 fill: 'var(--panel)', stroke: color, 'stroke-width': 2*s }, g);
    el('rect', { x: cx + 16*s, y: footY - 36*s, width: 6*s, height: 3*s, rx: 1*s, fill: color }, g);
    for (var i = 0; i < 3; i++) {
      el('line', { x1: cx + 14.5*s, y1: footY - 29*s + i*4.5*s, x2: cx + 23.5*s, y2: footY - 29*s + i*4.5*s,
                   stroke: color, 'stroke-width': 1.2*s, opacity: 0.7 }, g);
    }
    // Right-anchored so a long clerk name can never clip the viewBox edge.
    if (label) txt(svg, 296, footY + 15, label, { size: 11, anchor: 'end', fill: 'var(--ink-dim)' });
    return g;
  }

  function stampMark(svg, cx, cy, r, lines, color){
    const g = el('g', { transform: 'rotate(-12 ' + cx + ' ' + cy + ')' }, svg);
    el('circle', { cx: cx, cy: cy, r: r, fill: 'none', stroke: color, 'stroke-width': 3.5, 'stroke-dasharray': '5 3' }, g);
    const t = el('text', { x: cx, y: cy - (lines.length - 1) * 6 + 4, 'text-anchor': 'middle',
                           'font-weight': '800', 'font-size': Math.max(9, r * 0.3), fill: color }, g);
    lines.forEach(function(line, i){
      const ts = el('tspan', { x: cx, dy: i === 0 ? 0 : 12 }, t);
      ts.textContent = line;
    });
    return g;
  }

  // ---- Panel 1: the rainbow turns up again; the clerk measures it ----
  function panel0(svg, clerkDef, day){
    svg.innerHTML = '';
    const baseY = 238, r = 96;
    skyline(svg, baseY);
    rainbow(svg, 150, baseY, r);
    // Apex measurement: a vertical dashed line inside the arc, so it
    // never crosses (and muddies) the colored bands.
    el('line', { x1: 150, y1: baseY, x2: 150, y2: baseY - r + 6, stroke: clerkDef.color,
                 'stroke-width': 1.6, 'stroke-dasharray': '5 4' }, svg);
    el('line', { x1: 142, y1: baseY - r + 6, x2: 158, y2: baseY - r + 6,
                 stroke: clerkDef.color, 'stroke-width': 1.6 }, svg);
    txt(svg, 150, baseY - r - 8, '41° at apex', { size: 11, weight: '700', fill: clerkDef.color });
    clerk(svg, 264, baseY, 0.8, clerkDef.color, clerkDef.name);
    txt(svg, 150, 34, 'Day ' + day + '. Same field. Same 4:10 p.m.', { size: 13, weight: '700' });
    txt(svg, 150, 54, 'Pellow Bend Permit Office opens a file.', { size: 11, fill: 'var(--ink-dim)' });
  }

  // ---- Panel 2: the paperwork ----
  function panel1(svg, clerkDef, permitNo){
    svg.innerHTML = '';
    el('rect', { x: 34, y: 44, width: 166, height: 196, rx: 4, fill: 'var(--panel)',
                 stroke: 'currentColor', 'stroke-width': 2 }, svg);
    txt(svg, 117, 66, 'FORM W-7', { size: 13, weight: '800' });
    txt(svg, 117, 82, 'STANDING DISPLAY', { size: 10, fill: 'var(--ink-dim)' });
    txt(svg, 117, 94, '(ATMOSPHERIC)', { size: 10, fill: 'var(--ink-dim)' });
    el('line', { x1: 48, y1: 104, x2: 186, y2: 104, stroke: 'currentColor', 'stroke-width': 1, opacity: 0.4 }, svg);
    const rows = [
      ['Applicant', 'unreachable'],
      ['Height', '41° at apex'],
      ['Span', 'field to silo'],
      ['Owner', 'none listed'],
      ['Duration', 'nightly, 9 min'],
      ['Fee', '$40.00 unpaid']
    ];
    rows.forEach(function(r, i){
      const y = 122 + i * 19;
      txt(svg, 48, y, r[0], { size: 10, anchor: 'start', fill: 'var(--ink-dim)' });
      txt(svg, 186, y, r[1], { size: 10, anchor: 'end', weight: '700' });
      el('line', { x1: 48, y1: y + 5, x2: 186, y2: y + 5, stroke: 'currentColor', 'stroke-width': 0.8, opacity: 0.22 }, svg);
    });
    txt(svg, 117, 232, 'No. ' + permitNo, { size: 10, fill: 'var(--ink-dim)' });
    // a small, faded rainbow filed as an exhibit
    rainbow(svg, 242, 184, 40, { bandWidth: 5, faded: true });
    txt(svg, 242, 202, 'EXHIBIT A', { size: 9, fill: 'var(--ink-dim)' });
    stampMark(svg, 238, 88, 38, ['UNDER', 'REVIEW'], clerkDef.color);
    txt(svg, 150, 268, 'The rainbow is asked to appear in person.', { size: 12, weight: '700' });
  }

  // ---- Panel 3: approved, with conditions ----
  function panel2(svg, clerkDef, permitNo, conditions){
    svg.innerHTML = '';
    const baseY = 244;
    skyline(svg, baseY);
    rainbow(svg, 150, baseY, 100);
    // permit placard zip-tied to the west leg of the arc
    el('line', { x1: 38, y1: baseY, x2: 38, y2: baseY - 46, stroke: 'currentColor',
                 'stroke-width': 1.2, 'stroke-dasharray': '3 3', opacity: 0.6 }, svg);
    el('rect', { x: 14, y: baseY - 80, width: 62, height: 34, rx: 3, fill: 'var(--panel)',
                 stroke: clerkDef.color, 'stroke-width': 2 }, svg);
    txt(svg, 45, baseY - 66, 'PERMIT', { size: 9, weight: '800', fill: clerkDef.color });
    txt(svg, 45, baseY - 54, permitNo, { size: 9, fill: 'var(--ink-dim)' });
    // the conditions, stacked
    txt(svg, 150, 34, 'APPROVED, WITH ' + conditions.length + ' CONDITIONS', { size: 12, weight: '800' });
    conditions.forEach(function(c, i){
      txt(svg, 150, 54 + i * 15, c, { size: 10, fill: 'var(--ink-dim)' });
    });
    clerk(svg, 266, baseY, 0.8, clerkDef.color, clerkDef.name);
    txt(svg, 150, 282, 'It appeared anyway, at 4:10, as always.', { size: 12, weight: '700' });
  }

  const ALL_CONDITIONS = [
    'Must not obstruct the water tower.',
    'Renewable every 90 days, in person.',
    'No colors added without review.',
    'Apex may not exceed 41°.',
    'Display limited to weekday afternoons.',
    'One arc per parcel.',
    'Subject to inspection without notice.'
  ];

  const CAPTIONS = [
    'A recurring afternoon rainbow over an invented town shows up in the same field for the twelfth day running, and a clerk arrives to measure its arc.',
    'A Form W-7 standing-display application is opened on the rainbow’s behalf. The applicant cannot be reached for the $40 fee.',
    'The permit is granted with conditions the rainbow has no way to read, and it appears on schedule regardless.'
  ];
  const STAMPS = ['LOGGED', 'FILED', 'APPROVED'];

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

  function clerkFor(i){ return CLERKS[(clerkIdx + i) % CLERKS.length]; }
  function permitNo(){ return 'W7-' + (1104 + state.twists * 3); }
  function day(){ return 12 + state.twists; }
  function conditions(){ return ALL_CONDITIONS.slice(0, 3 + (state.twists % 4)); }

  function renderPanel(i){
    const svg = document.getElementById('art' + i);
    const done = i < state.revealed;
    if (!done) {
      svg.innerHTML = '';
      svg.setAttribute('aria-label', 'Panel ' + (i + 1) + ', ' + (i === state.revealed ? 'not yet revealed.' : 'in queue.'));
      document.getElementById('cap' + i).textContent = '';
      document.getElementById('stamp' + i).textContent = '';
      return;
    }
    const c = clerkFor(i);
    if (i === 0) panel0(svg, c, day());
    if (i === 1) panel1(svg, c, permitNo());
    if (i === 2) panel2(svg, c, permitNo(), conditions());
    svg.setAttribute('aria-label', 'Panel ' + (i + 1) + '. ' + CAPTIONS[i]);
    document.getElementById('cap' + i).textContent = CAPTIONS[i];
    document.getElementById('stamp' + i).textContent = STAMPS[i];
  }

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

  function syncButtons(){
    const nextBtn = document.getElementById('nextBtn');
    if (state.revealed >= 3) { nextBtn.textContent = 'All 3 stages reviewed ✓'; nextBtn.disabled = true; }
    else { nextBtn.textContent = 'Advance Review Stage ' + (state.revealed + 1); nextBtn.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();
    syncButtons();
  }

  function next(){
    if (state.revealed >= 3) { say('info', 'All 3 stages are already on file. Try Plot Twist!'); return; }
    state.revealed++;
    renderPanel(state.revealed - 1);
    syncCovers();
    syncButtons();
    if (state.revealed >= 3) say('ok', 'Review complete. Press Plot Twist! to reassign the file.');
    else say('ok', 'Stage ' + state.revealed + ' reviewed.');
  }

  function twist(){
    if (state.revealed < 3) { say('err', 'ERROR PT-409 · Premature twist — advance all 3 stages first.'); return; }
    clerkIdx = (clerkIdx + 1) % CLERKS.length;
    state.twists++;
    for (let i = 0; i < 3; i++) renderPanel(i);
    say('ok', 'Plot twist #' + state.twists + ': the file is reassigned to ' + clerkFor(0).name +
              ', who finds ' + conditions().length + ' conditions and starts the count at day ' + day() + '.');
  }

  document.getElementById('nextBtn').addEventListener('click', next);
  document.getElementById('twistBtn').addEventListener('click', twist);
  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') return;
    const k = e.key.toLowerCase();
    if (k === 'n' || e.key === 'ArrowRight') { e.preventDefault(); next(); }
    else if (k === 't') { e.preventDefault(); twist(); }
  });

  paintAll();
  say('info', 'File opened on an unlicensed rainbow. Press N or click Panel 1 to begin.');
})();
</script>
</body>
</html>