/**
 * SHADES — the face. Lane A owns this file.
 *
 * Everything the game already simulates is invisible to a stranger without this:
 * loads, the gust warning, whether the garden is actually getting hit. So the
 * rule here is that the HUD only ever READS. It takes no decisions, owns no
 * state worth keeping, and if you deleted it the sim would run identically —
 * which is also why it can be this chatty without anything drifting.
 *
 * Two things earn their place as world-anchored rather than screen-anchored:
 * the corner load bars (a number in a corner of the screen can't tell you WHICH
 * shackle is about to go, and that's the whole "…the shackle, I knew about the
 * shackle" moment) and the repair prompt, which Lane D already owns.
 */

import * as THREE from '../vendor/three.module.js';
import { STORM_LEN } from './contracts.js';
import { forecastLines, leadFor } from './weather.js';
// SPRINT16 — the end card compares the final rep against neutral. A constant,
// not state: the HUD still only READS. week.js has no DOM and no THREE, so
// this import costs nothing it didn't already cost main.js.
import { REP } from './week.js';

const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const kmh = (ms) => ms * 3.6;

/**
 * SPRINT12 gate 3 — the letterhead, and the two rulings E left with it
 * (tools/jobsheet/index.html; full reasoning in THREADS [A]).
 *
 * RULING 1 — the masthead is HARD YARDS, adopted as proposed. It's the joke the
 * game already tells ("nobody thanks you for the storm that did nothing") said
 * as a business name, it's literally about yards, and it's one mark at
 * letterhead size. E's own caveat stands with it: the day the player gets a
 * name, a surname mark is the better answer, and this constant is the only
 * thing that changes.
 *
 * RULING 2 — the ABN stays sequential, exactly as E shipped it. Any plausible
 * 11-digit number is potentially a REAL business's tax identity, and we are not
 * printing a stranger's ABN on a game card. Sequential digits read as "example"
 * the way 555 phone numbers do — deliberate, not lazy. DO NOT "fix" this into
 * something realistic; that is the bug, not the polish.
 */
const BUSINESS = { mark: 'HARD YARDS', trade: 'landscaping · shade · drainage' };
const ABN = 'ABN 12 345 678 901';

/**
 * Docket numbers. 1040 + night: a running count off a small outfit's pad, and
 * the job sheet and its invoice share the number — JOB 1043's morning is
 * INV 1043, which is how the two documents answer each other line for line.
 */
const docketNo = (night) => 1040 + night;

/**
 * SPRINT16 gate 1 — the letterhead's number, formatted once. Stars, one
 * decimal (week.js REP moves in half-steps, so the decimal is always honest).
 * It prints under the ABN on job sheet AND invoice — E's letterhead contract:
 * the business is the constant, and this is the one number the business has.
 */
const repFmt = (r) => `★ ${Number(r).toFixed(1)}`;

// Load bar colours: green → amber → red. The amber band is deliberately wide;
// a corner at 60% in a lull is one gust from 100% and the player should feel
// that before the bar turns red on them.
const BAR_OK = 0x6ee06e, BAR_WARN = 0xffc24a, BAR_HOT = 0xff5b4a, BAR_DEAD = 0x7a2f2f;

const CSS = `
#hud { position:fixed; inset:0; pointer-events:none; z-index:10;
  font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea;
  text-shadow:0 1px 3px #000a; user-select:none; }
#hud .panel { position:absolute; background:#0d1418d9; border:1px solid #2c3a44;
  border-radius:6px; padding:8px 10px; }
#hud-top { top:10px; left:50%; transform:translateX(-50%); text-align:center; min-width:280px; }
#hud-wind { font-size:15px; font-weight:700; letter-spacing:.04em; }
#hud-clock { color:#8ba0ad; }
#hud-gust { position:absolute; top:78px; left:50%; transform:translateX(-50%);
  font:700 20px/1 ui-monospace,Menlo,monospace; letter-spacing:.16em; color:#ffd27a;
  background:#3a2a0ce0; border:1px solid #7a5a1a; border-radius:5px; padding:8px 16px;
  opacity:0; transition:opacity .12s; }
#hud-gust.on { opacity:1; }
#hud-garden { bottom:12px; left:12px; min-width:210px; }
#hud-bar { height:9px; background:#00000066; border-radius:5px; overflow:hidden; margin-top:5px; }
#hud-bar i { display:block; height:100%; width:100%; background:#6ee06e; transition:width .2s,background .3s; }
#hud-carry { bottom:12px; right:12px; text-align:right; }
#hud-events { position:absolute; bottom:70px; left:12px; max-width:420px; }
#hud-events div { color:#ffd27a; margin-top:2px; }
#hud-help { position:absolute; bottom:12px; left:50%; transform:translateX(-50%);
  color:#93a6b2; opacity:.85; white-space:nowrap; }

/* SPRINT13 gate 3 — pause and mute.
   The veil sits UNDER #hud-card (z 30) so a card always wins: pausing can never
   hide the job sheet or the invoice. It is a veil and not a card because the
   yard behind it is the point — you paused to LOOK at something. */
#hud-pause { position:fixed; inset:0; z-index:20; display:none; place-items:center;
  background:#060a0d99; backdrop-filter:blur(2px); pointer-events:none; }
#hud-pause.on { display:grid; }
#hud-pause .word { font:700 34px/1 ui-monospace,Menlo,monospace; letter-spacing:.34em;
  color:#dde5ea; text-indent:.34em; }
#hud-pause .sub { margin-top:10px; text-align:center; color:#8ba0ad; letter-spacing:.1em; }
/* Muted has to be visible from anywhere or the player thinks the sound broke. */
#hud-mute { position:absolute; top:12px; right:12px; display:none;
  padding:4px 9px; border:1px solid #3a4a56; border-radius:4px;
  color:#8ba0ad; letter-spacing:.16em; font-size:11px; }
#hud-mute.on { display:block; }

#hud-card { position:fixed; inset:0; z-index:30; display:none; place-items:center;
  /* SPRINT12: E's letterhead + brief + conditions made the tall cards taller
     than a short window, and a RIG IT button below the fold with no scroll is a
     soft-lock with good typography. 'safe center' keeps the top reachable when
     the card overflows (plain 'center' clips BOTH ends); the value above is the
     fallback for engines that don't know 'safe'. Seen, not reasoned: the button
     was gone at 800x450. (And no backticks in this comment — this whole CSS
     block is a JS template literal, as the dawn block already warns.) */
  place-items:safe center; overflow-y:auto;
  background:#060a0dc4; backdrop-filter:blur(3px);
  font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea; }
#hud-card.on { display:grid; }
#hud-card .card { background:#0d1418; border:1px solid #2f3f4a; border-radius:9px;
  padding:22px 26px; margin:20px 0; min-width:440px; max-width:620px; pointer-events:auto; }
#hud-card h1 { margin:0 0 2px; font-size:19px; letter-spacing:.16em; color:#fff; }
#hud-card h2 { margin:0 0 16px; font-size:12px; font-weight:400; color:#8ba0ad; letter-spacing:.04em; }
#hud-card .row { display:flex; justify-content:space-between; gap:18px; padding:3px 0;
  border-bottom:1px solid #1c262d; }
#hud-card .row b { font-weight:700; color:#fff; }
#hud-card .storm { display:block; width:100%; text-align:left; margin:7px 0; padding:10px 12px;
  background:#121c23; border:1px solid #2f3f4a; border-radius:6px; color:#dde5ea; cursor:pointer;
  font:inherit; transition:border-color .15s,background .15s; }
#hud-card .storm:hover { border-color:#7ee0ff; background:#16242c; }
#hud-card .storm .name { font-weight:700; color:#fff; letter-spacing:.08em; }
#hud-card .storm .stat { color:#8ba0ad; }
#hud-card .go { margin-top:16px; padding:9px 18px; background:#1d3d2a; border:1px solid #3f7a52;
  border-radius:6px; color:#a8f0b8; cursor:pointer; font:inherit; font-weight:700; letter-spacing:.1em; }
#hud-card .go:hover { background:#255033; }
#hud-card .verdict { margin:14px 0 0; padding:10px 12px; border-radius:6px; font-weight:700;
  letter-spacing:.05em; }
#hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; }
#hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; }

/* --- the week ---------------------------------------------------------- */
#hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; }
#hud-card .pip.now { color:#7ee0ff; }
#hud-card .pip.held { color:#7fce6a; }
#hud-card .pip.lost { color:#ff8f86; }
#hud-card .ledger { margin-top:14px; padding-top:10px; border-top:1px solid #24343d; }
#hud-card .ledger .row b { color:#a8f0b8; }
#hud-card .ledger .row.bad b { color:#ff8f86; }
#hud-card .ledger .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
#hud-card .ledger .row.total b { color:#fff; }

/* SPRINT12 — E's letterhead/invoice kit, pasted from tools/jobsheet/index.html
   (between the CUT markers, verbatim). Sprint 11's placeholder rules for
   .letterhead/.brief are gone — E designed against those class names and this
   is the styling they were holding the seat for. E's design commentary kept:
   the reasoning is why the rules look like this. */

/* --- the letterhead ----------------------------------------------------
   One business, two documents. This is the whole trick of the sprint: the
   job sheet and the invoice carry the SAME letterhead, so they read as a
   pair of papers off the same outfit's printer rather than as two game
   screens. The player's business is the constant; the client changes every
   night. That's the framing DESIGN.md opens with — "you run a small
   landscaping outfit" — finally showing up on the glass.

   Sits above the existing .card content, inside the same .card box. */
#hud-card .letterhead {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 16px; margin: -4px 0 14px; padding-bottom: 10px;
  border-bottom: 2px solid #2f3f4a;
}
#hud-card .letterhead .mark {
  font-weight: 800; font-size: 15px; letter-spacing: .22em; color: #fff;
  white-space: nowrap;
}
/* The trade line does the work a logo would. Lowercase and spaced out so it
   reads as printed rule, not as a heading competing with the mark. */
#hud-card .letterhead .trade {
  font-size: 10px; letter-spacing: .13em; color: #6d818e; margin-top: 3px;
}
#hud-card .letterhead .docket { text-align: right; white-space: nowrap; }
#hud-card .letterhead .docket .kind {
  font-weight: 700; font-size: 11px; letter-spacing: .18em; color: #8ba0ad;
}
#hud-card .letterhead .docket .no { font-size: 10px; color: #55677a; letter-spacing: .06em; }
/* SPRINT16 — the reputation line, under the ABN (the spec's words). Slightly
   brighter than the docket number because it is the one thing in this corner
   that MOVES, and a number that moves is a number the eye should find. */
#hud-card .letterhead .docket .rep { font-size: 10px; color: #8ba0ad; letter-spacing: .08em; margin-top: 2px; }

/* SPRINT16 — the invoice speaks rep: one quiet line under the verdict, where
   the movement and its reason live. Not part of the ledger because it is not
   money — it is the thing that PRICES tomorrow's money. */
#hud-card .repline { margin: 8px 0 0; font-size: 11px; color: #8ba0ad; letter-spacing: .04em; }

/* --- bill-to / job-for block -------------------------------------------
   Deliberately the same component on both documents. On the job sheet it's
   who you're working for; on the invoice it's who's paying. Same box, and
   the fact that it doesn't move between the two is the joke landing. */
#hud-card .billto { margin: 0 0 12px; }
#hud-card .billto .who { font-weight: 700; color: #fff; letter-spacing: .04em; }
#hud-card .billto .addr { font-size: 11px; color: #6d818e; }

/* --- the brief ---------------------------------------------------------
   The client's own words. Italic and rule-marked because it is the one bit
   of text on the card that isn't the game talking to the player — it's a
   person who wants their seedlings alive. It should feel quoted, and it
   should be the thing the player's eye lands on after the letterhead,
   because it is the only place the stakes are ever said out loud. */
#hud-card .brief {
  margin: 0 0 14px; padding: 9px 0 9px 12px;
  border-left: 2px solid #3f5561;
  color: #c3d0d9; font-style: italic; line-height: 1.6;
}
#hud-card .brief .attrib {
  display: block; margin-top: 5px; font-style: normal;
  font-size: 10px; letter-spacing: .1em; color: #55677a;
}

/* --- section rules ------------------------------------------------------ */
#hud-card .sect {
  margin: 16px 0 7px; font-size: 10px; font-weight: 700; letter-spacing: .18em;
  color: #55677a; border-bottom: 1px solid #1c262d; padding-bottom: 5px;
}

/* --- the splash (SPRINT13 gate 3) ---------------------------------------
   Built ON E's letterhead rather than beside it: the masthead is the game's
   name, so the front door is the same sheet of paper as everything else. The
   only rules here are the ones the letterhead doesn't already own. */
#hud-card .splash .letterhead { border-bottom-width: 2px; padding-bottom: 12px; }
#hud-card .splash .mark { font-size: 30px; }
#hud-card .premise { margin: 14px 0 4px; color: #dde5ea; font-size: 14px; line-height: 1.6; }
#hud-card .fineprint { margin: 14px 0 0; color: #6d8494; font-style: italic; line-height: 1.55; }
#hud-card .keys { display: grid; gap: 3px; }
#hud-card .keyrow { display: flex; gap: 12px; align-items: baseline; padding: 2px 0; }
#hud-card .keyrow kbd {
  flex: none; min-width: 62px; text-align: center; padding: 3px 7px;
  background: #16222a; border: 1px solid #33454f; border-bottom-width: 2px;
  border-radius: 4px; color: #cfe0ea; font: inherit; font-size: 11px;
}
#hud-card .keyrow span { color: #93a6b2; }
#hud-card .splash .go { margin-top: 18px; width: 100%; padding: 12px; font-size: 14px; }

/* --- the pay schedule (job sheet) --------------------------------------
   This is a QUOTE, not a receipt: it's what the night is worth if you do it
   right. It reuses your .row shape so it lines up with everything else, but
   the money is dimmed — these are amounts you have NOT earned yet, and they
   should not read with the same confidence as the bank line. The conditions
   are the point; the numbers are just what the conditions are worth. */
#hud-card .sched .row b { color: #9fb3bf; font-weight: 700; }
#hud-card .sched .cond {
  font-size: 10px; color: #55677a; letter-spacing: .02em;
  padding: 0 0 5px; border-bottom: 1px solid #1c262d; margin-top: -2px;
}
#hud-card .sched .row.headline b { color: #fff; }
#hud-card .sched .row.max { border-bottom: 0; margin-top: 6px; padding-top: 7px;
  border-top: 1px solid #24343d; }
#hud-card .sched .row.max span { color: #8ba0ad; }
#hud-card .sched .row.max b { color: #ffd9a3; }

/* --- the invoice -------------------------------------------------------
   Your .ledger already IS an invoice; it just never said so. These rules sit
   ON TOP of it — paste them and .ledger keeps every behaviour it has, it
   just gains a rule above the total and a due line under it.

   .earned/.bad you already colour. The one thing I've added is .void: a
   bonus that was on the job sheet and did NOT survive the night. Struck
   through, not hidden. The player must see the money they didn't get sitting
   in the same column as the money they did — a bonus you never see fail is a
   bonus you never learn to protect. This is the single highest-value line in
   the whole handover, and it's four CSS declarations. */
#hud-card .ledger .row.void span { color: #55677a; }
#hud-card .ledger .row.void b {
  color: #55677a; font-weight: 400; text-decoration: line-through;
  text-decoration-color: #7d2b2b; text-decoration-thickness: 1px;
}
/* The amount due. An invoice's whole rhetorical shape is that everything
   above is argument and this is the conclusion. Give it the weight. */
#hud-card .ledger .row.due {
  margin-top: 8px; padding-top: 9px; border-top: 2px solid #2f3f4a; border-bottom: 0;
  align-items: baseline;
}
#hud-card .ledger .row.due span { color: #8ba0ad; letter-spacing: .12em; font-weight: 700;
  font-size: 11px; }
#hud-card .ledger .row.due b { color: #fff; font-size: 20px; letter-spacing: .02em; }
#hud-card .ledger .row.due.neg b { color: #ff8f86; }

/* The terms line. Pure flavour, and the cheapest joke on the card: a real
   invoice's most boring sentence, printed under a night that nearly killed
   you. Left in as its own class so you can delete it in one line if it
   doesn't land. */
#hud-card .terms {
  margin-top: 10px; font-size: 10px; color: #45566a; letter-spacing: .04em;
}
/* ---- end of E's kit ----------------------------------------------------- */

/* Tomorrow is a rumour, and it should look like one next to tonight's numbers.
   (Mine, not E's — the kit predates the tomorrow line and doesn't restyle it.) */
#hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px;
  font-size:12px; color:#6d8494; }
#hud-card .tomorrow .tlabel { letter-spacing:.14em; text-transform:uppercase; color:#55707f; }

/* E's diptych. The art is the card; the words sit in the left third they left
   clear for exactly this. */
#hud-card .endcard { background-size:cover; background-position:center right;
  max-width:none; width:min(92vw,900px); min-height:min(70vh,520px);
  display:flex; align-items:center; padding:0; border:1px solid #24343d; }
#hud-card .endcard .endtext { width:min(46%,380px); padding:28px 26px;
  background:linear-gradient(to right, rgba(9,14,18,.92) 65%, rgba(9,14,18,0)); }
#hud-card .endcard h1 { font-size:26px; }
#hud-card .endcard .kicker { margin:16px 0 0; color:#8ba0ad; font-style:italic; line-height:1.5; }

/* --- dawn (Lane E, pasted from their THREADS note) ---------------------- */
/* The screen blend is the point: it lifts the black storm scene into morning
   without touching the card on top. The 2.2s ease matters more than the
   colours. (No backticks in here — this whole block is a JS template literal.) */
#dawn { position:fixed; inset:0; pointer-events:none; opacity:0; z-index:5;
        transition:opacity 2.2s ease; mix-blend-mode:screen;
        background:linear-gradient(to top,
          rgba(255,178,102,.30) 0%,    /* the sun that finally turned up */
          rgba(255,150,96,.16) 22%,
          rgba(120,132,168,.10) 55%,
          rgba(38,48,84,.16) 100%); }  /* night still up there */
#dawn.on { opacity:1; }
`;

/**
 * @param {object} d
 * @param {THREE.Scene} d.scene
 * @param {THREE.Camera} d.camera
 * @param {object} d.game     the phase machine
 * @param {object} d.world
 * @param {object} d.wind     the router
 * @param {object} d.player
 * @param {object} d.rig      SailRig
 * @param {object} d.garden   {hp}
 * @param {() => object} d.getSky      skyfx is rebuilt per storm, so read it late
 * @param {() => number} d.getWindTime
 * @param {{text:string,t:number}[]} d.events
 */
export function createHud(d) {
  const style = document.createElement('style');
  style.textContent = CSS;
  document.head.appendChild(style);

  const root = document.createElement('div');
  root.id = 'hud';
  root.innerHTML = `
    <div class="panel" id="hud-top">
      <div id="hud-wind">--</div>
      <div id="hud-clock">--</div>
    </div>
    <div id="hud-gust">GUST INCOMING</div>
    <div class="panel" id="hud-garden">
      <div><span id="hud-garden-label">GARDEN</span> <b id="hud-garden-pct">100%</b></div>
      <div id="hud-bar"><i></i></div>
      <div id="hud-shade" style="color:#8ba0ad"></div>
    </div>
    <div class="panel" id="hud-carry" style="display:none"></div>
    <div id="hud-events"></div>
    <div id="hud-help"></div>
    <div id="hud-mute">MUTED</div>
    <div id="hud-pause"><div class="word">PAUSED</div><div class="sub">P to carry on</div></div>
  `;
  document.body.appendChild(root);

  const card = document.createElement('div');
  card.id = 'hud-card';
  document.body.appendChild(card);

  // The morning. Sits UNDER the card and over the frozen storm scene — see the
  // `screen` blend in the CSS, which lifts the black night into dawn without
  // touching the card on top of it. Lane E shipped this as ten lines rather than
  // a ninth texture nobody loads, and they were right.
  const dawn = document.createElement('div');
  dawn.id = 'dawn';
  document.body.appendChild(dawn);

  const $ = (id) => root.querySelector(id);
  const elWind = $('#hud-wind'), elClock = $('#hud-clock'), elGust = $('#hud-gust');
  const elPct = $('#hud-garden-pct'), elBar = $('#hud-bar i'), elShade = $('#hud-shade');
  const elGardenLabel = $('#hud-garden-label');
  const elCarry = $('#hud-carry'), elEvents = $('#hud-events'), elHelp = $('#hud-help');
  const elPause = $('#hud-pause'), elMute = $('#hud-mute');
  /** Set by main.js once it has asked skyfx whether the bus has a tap. */
  let muteAvailable = false;

  // --- world-anchored corner load bars ------------------------------------
  // A bar per corner, floating at the corner it describes. Geometry rather than
  // a redrawn canvas: the fill is a scaled quad, so animating it is free and a
  // flogging corner's bar can track it at 60 Hz without touching a texture.
  const barGroup = new THREE.Group();
  barGroup.visible = false;
  d.scene.add(barGroup);

  const quad = new THREE.PlaneGeometry(1, 1);
  quad.translate(0.5, 0, 0);              // pivot on the left edge, so scale.x grows rightward
  const BAR_W = 0.9, BAR_H = 0.1;
  /**
   * When two corner bars count as "on top of each other", in clip space (NDC
   * spans -1..1 across the viewport), and how far apart to stack them.
   *
   * Measured off the collision the QA pass actually hit — CB1/CB2 on the carport
   * beam, viewed from where a player stands to rig them. A bar is ~0.9 world
   * units wide and scaled to hold constant screen size, so these are roughly
   * "one bar wide, one line tall" and don't need to track the bar's dimensions.
   * Y is the tighter of the two on purpose: labels stack vertically, so a pair
   * side-by-side is already readable and only a vertical overlap is mush.
   */
  const BAR_NDC_X = 0.10, BAR_NDC_Y = 0.045, BAR_STACK_Y = 0.34;

  const bars = [];
  function ensureBars(n) {
    while (bars.length < n) {
      const holder = new THREE.Group();
      const bg = new THREE.Mesh(quad, new THREE.MeshBasicMaterial({
        color: 0x0a1016, transparent: true, opacity: 0.75, depthTest: false,
      }));
      bg.scale.set(BAR_W, BAR_H, 1);
      bg.position.x = -BAR_W / 2;
      const fill = new THREE.Mesh(quad, new THREE.MeshBasicMaterial({
        color: BAR_OK, depthTest: false,
      }));
      fill.position.x = -BAR_W / 2;
      fill.scale.set(0.001, BAR_H * 0.72, 1);
      const label = makeLabel();
      label.position.y = 0.19;
      holder.add(bg, fill, label);
      holder.renderOrder = 999;
      barGroup.add(holder);
      bars.push({ holder, fill, label, lastText: '' });
    }
    for (let i = 0; i < bars.length; i++) bars[i].holder.visible = i < n;
  }

  function makeLabel() {
    const c = document.createElement('canvas');
    c.width = 256; c.height = 64;
    const tex = new THREE.CanvasTexture(c);
    const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }));
    sp.scale.set(1.5, 0.375, 1);
    sp.userData.ctx = c.getContext('2d');
    sp.userData.tex = tex;
    return sp;
  }

  function drawLabel(sprite, text, color) {
    const g = sprite.userData.ctx;
    g.clearRect(0, 0, 256, 64);
    g.font = 'bold 30px ui-monospace, Menlo, monospace';
    g.textAlign = 'center';
    g.textBaseline = 'middle';
    g.lineWidth = 6;
    g.strokeStyle = '#0a1016';
    g.strokeText(text, 128, 32);
    g.fillStyle = color;
    g.fillText(text, 128, 32);
    sprite.userData.tex.needsUpdate = true;
  }

  // Labels are the only per-frame texture cost here, so they redraw on a slow
  // tick and only when the shown value actually changes.
  let labelT = 0;

  const _p = new THREE.Vector3();
  const _q = new THREE.Quaternion();
  const _cam = new THREE.Vector3();
  // Corner-bar de-collision (SPRINT13). Scratch + a per-frame record of where
  // each bar landed in clip space, so bars that typeset on top of each other can
  // be stacked instead. Pooled rather than reallocated — this runs every frame —
  // and each entry carries `live`, because a corner that has no position this
  // frame must not leave last frame's ghost for the others to stack against.
  const _ndc = new THREE.Vector3();
  const _placed = [];
  const placedSlot = (i) => (_placed[i] || (_placed[i] = { x: 0, y: 0, live: false }));

  // --- helpers ------------------------------------------------------------
  const barColor = (frac, broken) => (broken ? BAR_DEAD : frac > 0.85 ? BAR_HOT : frac > 0.55 ? BAR_WARN : BAR_OK);

  function cornerPos(i) {
    if (d.rig.cornerPos) return d.rig.cornerPos(i);
    const c = d.rig.corners[i];
    return c?.anchor?.pos ?? null;
  }

  const hud = {
    /** Set false while a card is up — the storm HUD shouldn't peer through it. */
    setVisible(on) { root.style.display = on ? '' : 'none'; },

    setHelp(text) { elHelp.textContent = text; },

    /** P — the pause veil. Render keeps running; main.js stops the accumulator. */
    setPaused(on) { elPause.classList.toggle('on', !!on); },

    /** M — reads the state main.js owns. Only shown once the bus is real. */
    setMuted(on) { elMute.classList.toggle('on', !!on); },

    /**
     * Does Lane C's audio bus have a mute tap yet? main.js asks skyfx and tells
     * us. Until it does, M is not advertised on the splash or the help line —
     * a promised key that does nothing is worse than no key, and this game is
     * public now. Flips itself on the day C lands `setMute`.
     */
    setAudioMuteAvailable(on) { muteAvailable = !!on; },

    /** The comfort keys, listed only where they're real. See setAudioMuteAvailable. */
    comfortKeysHint() { return muteAvailable ? 'P pause · M mute' : 'P pause'; },

    /**
     * THE FRONT DOOR (SPRINT13 gate 3).
     *
     * partly.party's arcade can send a stranger straight here, and before this
     * they landed on a job sheet: an invoice-looking card from a business they'd
     * never heard of, quoting money for a job nobody had explained, with the
     * controls appearing only later on prep's help line. The premise of the game
     * was never once stated on the glass.
     *
     * It's one more `.card` — not a menu system. There is nothing to configure:
     * one week, five jobs, one button. E's letterhead does the work, because the
     * masthead IS the game's name and the joke lands before the premise does.
     *
     * @param {() => void} onStart
     */
    showSplash(onStart) {
      card.innerHTML = `<div class="card splash">
        <div class="letterhead">
          <div>
            <div class="mark">${BUSINESS.mark}</div>
            <div class="trade">${BUSINESS.trade}</div>
          </div>
        </div>
        <p class="premise">Rig shade sails, then keep the client's garden alive
          through the storm. Five nights, five jobs, one wallet.</p>
        <div class="sect">THE CONTROLS</div>
        <div class="keys">
          ${[
            ['WASD', 'walk the yard'],
            ['mouse', 'orbit the camera'],
            ['click', 'pick an anchor · again to unpick'],
            ['[ ]', 'tension the rig — tighter holds shape, and breaks harder'],
            ['S', 'buy a spare shackle'],
            ['F', 'choose the fabric — cloth breathes, membrane stops the rain'],
            ['E', 'repair a blown corner (ladder if it is up high)'],
            ['C', 'brace against the wind'],
            ['Enter', 'commit the rig and start the night'],
            ...(muteAvailable ? [['P · M', 'pause · mute']] : [['P', 'pause']]),
          ].map(([k, v]) => `<div class="keyrow"><kbd>${k}</kbd><span>${v}</span></div>`).join('')}
        </div>
        <p class="fineprint">The forecast is a band, not a promise. Nothing you
          tie to is as strong as it looks.</p>
        <button class="go">START THE WEEK</button>
      </div>`;
      card.classList.add('on');
      hud.setVisible(false);
      card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onStart(); });
    },

    /**
     * The phone visitor. A public URL gets phones — DESIGN.md's game is WASD and
     * a mouse orbit, so there is no touch story to tell and pretending otherwise
     * would waste their time. Before this they got a dead canvas and no reason.
     *
     * No START: this is the one card without a way through, on purpose. Sending
     * someone into a yard they cannot walk is worse than telling them plainly.
     */
    showTouchNotice() {
      card.innerHTML = `<div class="card splash">
        <div class="letterhead">
          <div>
            <div class="mark">${BUSINESS.mark}</div>
            <div class="trade">${BUSINESS.trade}</div>
          </div>
        </div>
        <p class="premise">${BUSINESS.mark} needs a keyboard and a mouse —
          you walk the yard and orbit the camera to rig a sail, and there's no
          honest way to do that with a thumb.</p>
        <p class="fineprint">Grab a laptop and come back. It'll be here.</p>
      </div>`;
      card.classList.add('on');
      hud.setVisible(false);
    },

    /**
     * @param {number} dt
     * @param {number} t  wind/storm time
     */
    update(dt, t) {
      const phase = d.game.phase;
      const storm = phase === 'storm';

      // --- wind + clock ---
      const speed = d.wind.speedAt(d.player.pos, t);
      elWind.textContent = `${speed.toFixed(1)} m/s   ${kmh(speed).toFixed(0)} km/h`;
      elWind.style.color = speed > 25 ? '#ff8f86' : speed > 15 ? '#ffc24a' : '#dde5ea';
      elClock.textContent = storm
        ? `STORM   ${Math.max(0, STORM_LEN - d.game.phaseT).toFixed(0)}s left`
        : phase.toUpperCase();

      // --- gust telegraph ---
      // The contract promises >=1.2 s of warning; this is where the player
      // actually gets to spend it.
      const tel = storm ? d.wind.gustTelegraph(t) : null;
      elGust.classList.toggle('on', !!tel);
      if (tel) elGust.textContent = `GUST INCOMING  ${tel.eta.toFixed(1)}s  ·  ${kmh(tel.power).toFixed(0)} km/h`;

      // --- garden ---
      const hp = d.garden.hp;
      elPct.textContent = `${hp.toFixed(0)}%`;
      elBar.style.width = `${clamp01(hp / 100) * 100}%`;
      elBar.style.background = hp > 66 ? '#6ee06e' : hp > 33 ? '#ffc24a' : '#ff5b4a';
      // Decision 7: rain shadow is what matters at night, sun coverage by day.
      // Showing whichever is load-bearing right now stops the number being a
      // fact about nothing.
      const sky = d.getSky?.();
      if (storm && sky?.rainShadowOver) {
        const dry = sky.rainShadowOver(d.world.gardenBed);
        elGardenLabel.textContent = 'GARDEN';
        elShade.textContent = `${(dry * 100).toFixed(0)}% kept dry by the sail`;
      } else {
        const shade = d.rig.rigged ? d.rig.coverageOver(d.world.gardenBed, d.world.sunDir, d.world.heightAt) : 0;
        elGardenLabel.textContent = 'GARDEN';
        elShade.textContent = d.rig.rigged ? `${(shade * 100).toFixed(0)}% in shade` : 'no sail rigged';
      }

      // --- carried item ---
      const carrying = d.player.carrying;
      elCarry.style.display = carrying ? '' : 'none';
      if (carrying) elCarry.textContent = `carrying: ${carrying}`;

      // --- event ticker ---
      const live = d.events.filter((e) => t - e.t < 6 || e.t > t);
      elEvents.innerHTML = live.slice(-4).map((e) => `<div>${e.text}</div>`).join('');

      // --- corner bars ---
      barGroup.visible = d.rig.rigged && (storm || phase === 'prep');
      if (!barGroup.visible) return;

      ensureBars(d.rig.corners.length);
      labelT += dt;
      const redraw = labelT >= 0.12;
      if (redraw) labelT = 0;
      d.camera.getWorldQuaternion(_q);
      d.camera.getWorldPosition(_cam);

      for (let i = 0; i < d.rig.corners.length; i++) {
        const c = d.rig.corners[i];
        const b = bars[i];
        const p = cornerPos(i);
        if (!p) { b.holder.visible = false; placedSlot(i).live = false; continue; }

        _p.set(p.x, p.y, p.z);
        b.holder.position.copy(_p);
        b.holder.position.y += 0.45;
        b.holder.quaternion.copy(_q);

        // Hold roughly constant screen size. A house corner is ~18 m away when
        // you're standing at the posts, and at that range a world-sized bar is
        // a few unreadable pixels — which defeats the entire point of putting it
        // out there rather than in a corner of the screen. Clamped so it doesn't
        // balloon when you walk right up to a corner to repair it.
        const dist = _p.distanceTo(_cam);
        const scale = Math.max(0.75, Math.min(3.2, dist / 7));
        b.holder.scale.setScalar(scale);

        /**
         * Stack bars that would typeset on top of each other.  [B's flag, SPRINT13]
         *
         * The QA pass: "CB1/CB2 died together and typeset on top of each other".
         * The carport's two beam anchors are 2.82 m apart and the sail hangs
         * between them, so from most of the yard their bars land in the same
         * handful of pixels — and they are exactly the pair most likely to blow
         * together, because they share a beam and a 0.22 ratingHint. The one
         * moment the HUD has to be legible ("both beam corners went, that's why
         * the sail is on the lawn") was the one moment it turned to mush.
         *
         * Screen space, not world space: whether two labels collide is a fact
         * about the camera, and two corners a metre apart are unreadable from
         * the fence and perfectly clear from underneath. Compared at the anchor
         * point rather than after nudging, so the test is order-independent and
         * a stack of three can't chase itself up the screen. Deterministic —
         * corner index order, no clock — and O(n²) on n=4.
         *
         * The offset rides `scale` so the gap is constant on screen at any range,
         * the same reason the bar itself is scaled.
         */
        _ndc.copy(b.holder.position).project(d.camera);
        const onScreen = _ndc.z < 1;          // behind the camera projects to nonsense
        let clash = 0;
        if (onScreen) {
          for (let k = 0; k < i; k++) {
            const o = _placed[k];
            if (!o || !o.live) continue;
            if (Math.abs(_ndc.x - o.x) < BAR_NDC_X && Math.abs(_ndc.y - o.y) < BAR_NDC_Y) clash++;
          }
        }
        const slot = placedSlot(i);
        slot.x = _ndc.x; slot.y = _ndc.y; slot.live = onScreen;
        if (clash) b.holder.position.y += clash * BAR_STACK_Y * scale;

        // SPRINT12 — B's flag, actioned: the bar reads the EFFECTIVE rating,
        // rating × ratingHint, because that's the number sail.js fails on now.
        // Off the bare rating a fascia corner showed 35% margin it didn't have
        // and blew mid-green — the HUD's one job at that moment is the "…the
        // shackle, I KNEW about the shackle" beat, and a bar that lies about
        // the margin steals it. The label prints the same effective number:
        // load-vs-what-it-breaks-at, one truth, both places.
        const effRating = c.hw.rating * (c.anchor?.ratingHint ?? 1);
        const frac = clamp01((c.load || 0) / effRating);
        b.fill.scale.x = Math.max(0.001, (c.broken ? 1 : frac) * BAR_W);
        b.fill.material.color.setHex(barColor(frac, c.broken));

        if (redraw) {
          const text = c.broken
            ? `${c.anchorId.toUpperCase()} BLOWN`
            : `${c.anchorId.toUpperCase()} ${((c.load || 0) / 1000).toFixed(1)}/${(effRating / 1000).toFixed(1)}kN`;
          if (text !== b.lastText) {
            b.lastText = text;
            drawLabel(b.label, text, c.broken ? '#ff8f86' : frac > 0.85 ? '#ffb1ab' : '#dde5ea');
          }
        }
      }
    },

    // --- cards ------------------------------------------------------------

    /**
     * The forecast. Its job is to sell the dread and, incidentally, to be the
     * difficulty select for free — three authored storms already bracket the
     * range, so letting the player pick one IS the choice.
     *
     * @param {{key:string, def:object}[]} storms
     * @param {(key:string) => void} onPick
     */
showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
      // Integrator merge (Sprint 8): A's week card shape + C's forecastLines —
      // MEASURED numbers, banded by lead (the old inline estimate read 30 m/s
      // for a storm that really gusts 32.3). Tonight is lead 0 = exact; when
      // the week wants to show tomorrow's card, pass its lead and it hedges.
      const f = forecastLines(def, 0);
      const change = (def.events ?? []).find((e) => e.type === 'windchange');
      const night = (def.sky?.darkness ?? 0) > 0.6;
      const hail = !!def.hail;

      // The ladder, as pips. You can see what you've survived and how far is
      // left — which is most of what makes night four frightening.
      const pips = Array.from({ length: wk.nights }, (_, i) => {
        const done = i < wk.night - 1;
        const now = i === wk.night - 1;
        const held = done && wk.log[i]?.won;
        return `<span class="pip ${now ? 'now' : done ? (held ? 'held' : 'lost') : ''}">${
          now ? '◆' : done ? (held ? '●' : '○') : '·'}</span>`;
      }).join('');

      // A different yard announces itself — the corner block is somewhere you've
      // never rigged, and that's most of what makes night three land.
      const siteLine = site
        ? `<div class="stat" style="color:#7ee0ff">${site.name}${site.blurb ? ` — ${site.blurb}` : ''}</div>`
        : '';

      // SPRINT11 — THE JOB SHEET. DESIGN.md's core loop opens with "the brief:
      // client wants X, budget Y, forecast Z", and this card had Z and nothing
      // else. The client and the brief are data (week.js NIGHTS), so an
      // unbriefed night degrades to the old storm card rather than an empty
      // letterhead — which is also what keeps every existing test honest.
      //
      // SPRINT12 — the letterhead is E's kit now: the outfit's mark on the
      // left, the docket on the right, the client demoted to a bill-to block —
      // because the business is the constant and the client changes nightly.
      const job = wk.job ?? {};
      const letterhead = job.client ? `
        <div class="letterhead">
          <div>
            <div class="mark">${BUSINESS.mark}</div>
            <div class="trade">${BUSINESS.trade}</div>
          </div>
          <div class="docket">
            <div class="kind">JOB SHEET</div>
            <div class="no">JOB ${docketNo(wk.night)} · ${ABN}</div>
            ${wk.rep != null ? `<div class="rep">standing ${repFmt(wk.rep)}</div>` : ''}
          </div>
        </div>
        <div class="billto">
          <div class="who">${job.client}</div>
          ${job.addr ? `<div class="addr">${job.addr}</div>` : ''}
        </div>` : '';
      const brief = job.brief
        ? `<div class="brief">${job.brief}</div>` : '';

      // BUDGET Y — and this is the actual feature, not the flavour. The fee has
      // existed since Sprint 8 and the player has never seen it until the money
      // was already spent: you decided what to rig without knowing what the job
      // was worth, which is a reveal, not a decision. Quoting the maxima up front
      // makes the shop a judgement ("is $45 of garden bonus worth a $30
      // shackle?") instead of a guess.
      // E's schedule shape: each amount carries its CONDITION underneath,
      // because the conditions are the point — the numbers are just what the
      // conditions are worth. The garden's condition says "scales", honestly:
      // E's preview copy read "bed above 80% at dawn" and the mechanic pays
      // linearly, and a quote that misstates its own terms is a lie on paper.
      const q = wk.quote;
      // SPRINT16 — the multiplier is on the sheet the night it applies, and
      // only then: ×1.00 every night would teach that the number is decoration.
      // The warranty lines are the other half of gate 1's paper: booked by last
      // dawn's broken corners, worded as the spec words them ("no charge to the
      // client" IS the sentence — unpaid labour is the point), priced at the
      // parts' shop price, and already inside q.total so the sheet's conclusion
      // is the deducted number the invoice will pay. Quote==settle is week.js's
      // construction; this card just refuses to hide the deduction.
      const feeTag = q && q.feeMult !== 1 ? ` · standing ×${q.feeMult.toFixed(2)}` : '';
      const warrantyRows = (q?.warranty ?? []).map((it) => `
          <div class="row bad"><span>warranty — ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client</span><b>−$${it.cost}</b></div>
          <div class="cond">your corner, down at dawn${it.addr ? ` — ${it.addr}` : ''} · parts at shop price, labour on you</div>`).join('');
      const schedule = q ? `
        <div class="sect">WHAT IT PAYS</div>
        <div class="sched">
          <div class="row headline"><span>callout fee${feeTag}</span><b>$${q.base}</b></div>
          <div class="cond">the night, rigged and stood up${q.feeMult !== 1 ? ' — priced on the letterhead number' : ''}</div>
          <div class="row"><span>garden bonus</span><b>up to $${q.garden}</b></div>
          <div class="cond">${q.beyondSaving
            // SPRINT14 — B's pool item. On a gardenBeyondSaving night the bed
            // cannot reach the win line at any price (week.js's flag, A's S13
            // ruling), so "paid on what's left of the bed at dawn" quotes a
            // maximum nobody can earn and reads as a rigging problem the
            // player could solve. The AMOUNT stays honest — settle() really
            // does pay this proportionally, and zeroing it here would make the
            // sheet lie in the other direction — but the CONDITION now says
            // what kind of night this is, up front, where the brief already
            // levels with you. The steel is still yours to save.
            ? 'scales with the bed at dawn — and tonight the ice takes it whatever you rig'
            : "paid on what's left of the bed at dawn"}</div>
          <div class="row"><span>clean site</span><b>$${q.clean}</b></div>
          <div class="cond">nothing of theirs broken</div>${warrantyRows}
          <div class="row max"><span>the night, done right</span><b>$${q.total}</b></div>
        </div>` : '';

      // Lane C's forecast lead (SPRINT11 gate 2): tomorrow, hedged. Their
      // `forecastLines(def, lead)` has carried the lead param for two sprints
      // with nothing ever calling it above 0 — this is the call site.
      //
      // `lead` is a 0..1 haze dial (confidence = 1 − lead), NOT "nights out" — I
      // read it as nights first, passed 1, and the card advertised "forecast
      // confidence 0%": a forecast admitting it knows nothing, printed as news.
      //
      // C's `leadFor` maps "N nights out" onto that dial — 0.25 for tomorrow of
      // five (75% confidence). Safe to print because the band RESOLVES: verified
      // 4020 samples, 0 violations — tomorrow's band always contains tonight's,
      // so the number tightens toward the truth and never rules out what it
      // previously allowed. (Integrated at SPRINT11 merge; was a 0.6 placeholder.)
      const tf = tomorrowDef ? forecastLines(tomorrowDef, leadFor(1, wk.nights)) : null;
      const tomorrow = tf ? `
        <div class="tomorrow">
          <span class="tlabel">tomorrow</span>
          <span class="tline">${tf.wind}${tf.confidence ? ` · ${tf.confidence}` : ''}</span>
        </div>` : '';

      // E's document order: letterhead, who it's for, their words, THEN the
      // night — the paper establishes whose job this is before it says what the
      // weather will do about it. The terms line under the button is E's
      // cheapest joke and their best: quoted on the forecast, weather is not a
      // variation, printed above a night that may cost you a carport.
      card.innerHTML = `<div class="card jobcard">
        ${letterhead}
        ${brief}
        <h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
        <div class="pips">${pips}</div>
        <h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? '  ·  NIGHT' : ''}</h2>
        ${siteLine}
        <div class="stat">${f.wind}</div>
        <div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
        ${/* SPRINT16 — C's gate 3.3 seam (THREADS [A] seam contract): when
             forecastLines exposes ready-to-print stone-size and rain-rate
             lines, the sheet prints them verbatim; until then this renders
             nothing. The bet (cloth vs membrane) is argued HERE or nowhere —
             the forecast is the only place the player chooses from. */''}
        ${f.stones ? `<div class="stat">${f.stones}</div>` : ''}
        ${f.rainRate ? `<div class="stat">${f.rainRate}</div>` : ''}
        ${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
        ${tomorrow}
        ${schedule}
        <div class="row" style="margin-top:10px"><span>in the bank</span><b>$${wk.bank}</b></div>
        <div class="stat" style="color:#8ba0ad;margin-top:10px">
          ${hail
            ? 'Hail is what kills a garden, and cloth stops hail. Get the sail over the bed.'
            : 'Nothing tonight can hurt the garden. Learn the anchors.'}
        </div>
        ${change ? `<div class="stat" style="color:#8ba0ad">
          A change means the corners that were slack all storm are the loaded ones after it.
        </div>` : ''}
        <button class="go">RIG IT</button>
        ${job.client ? '<div class="terms">Quoted on the forecast at time of callout. Weather is not a variation.</div>' : ''}
      </div>`;
      card.classList.add('on');
      hud.setVisible(false);
      card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onGo(); });
    },

    /**
     * E's dawn tint, pasted from their THREADS note. The 2.2 s ease is the point,
     * not the colours: the storm ends, the light comes up, and THEN you're told
     * how you did. Sell the survival before the scoreboard.
     */
    setDawn(on) { dawn.classList.toggle('on', !!on); },

    /**
     * The end of the week, either way. E's diptych is on disk and their words are
     * in THREADS — I took the primaries verbatim; the trade talking, no triumph.
     *
     * @param {object} s week.js settlement
     * @param {() => void} onRestart
     */
    showEndCard(s, onRestart) {
      const won = s.outcome === 'win';
      const art = won ? 'card_win.jpg' : 'card_gameover.jpg';

      // The words scale with what was actually held — reaching night five is not
      // the same as saving five gardens, and the end card is the last thing the
      // game says. E's primary copy for a clean week, E's own alternate for a
      // scraped one, and a third for the ending nobody knew existed until the
      // economy was measured: solvent, with the gardens dead behind you.
      const COPY = {
        clean: ['THE WEEK HELD', "Five nights. Everything's still where you put it.",
          "Nobody thanks you for the storm that did nothing. That's the job."],
        scraped: ['FIVE NIGHTS, FIVE MORNINGS', 'You made the least wrong call five times running.',
          'Some of it held. You know which bits didn\'t.'],
        solvent: ['STILL TRADING', 'The books balanced. The garden didn\'t.',
          'You can be paid all week and still have nothing to point at.'],
        gameover: ['OFF THE JOB', 'Broke, with the week still running.',
          'The wind never sent an invoice. Everyone else did.'],
      };
      const [head, sub, kick] = COPY[won ? (s.grade ?? 'clean') : 'gameover'];
      const nights = s.night;

      // SPRINT16 — the end of the week speaks rep. A week that ends
      // broke-but-clean and a week that ends rich-with-warranties are
      // different endings (the gate's own words), and the difference is the
      // number the next client sees — so the last thing the game says carries
      // it. Keyed on where the letterhead number LANDED relative to neutral
      // (REP.START), crossed with whether you're still trading; the row above
      // it shows the warranty count so "rich" has its receipts.
      const repKick = s.rep == null ? '' : s.rep > REP.START
        ? (won ? ' The name on the letterhead is worth more than it was on Monday.'
               : ' The book says broke. The number says the phone rings again.')
        : s.rep < REP.START
        ? (won ? ' The money came in. The mornings after are what gets remembered.'
               : ' And word of the mornings after got there first.')
        : '';

      card.innerHTML = `<div class="card endcard ${won ? 'win' : 'lose'}"
           style="background-image:url(./models/textures/${art})">
        <div class="endtext">
          <h1>${head}</h1>
          <h2>${sub}</h2>
          <div class="row"><span>nights survived</span><b>${nights}/5</b></div>
          <div class="row"><span>gardens held</span><b>${s.held ?? 0}/5</b></div>
          <div class="row"><span>in the bank</span><b>$${s.bankAfter}</b></div>
          ${s.rep != null ? `<div class="row"><span>reputation</span><b>${repFmt(s.rep)}</b></div>` : ''}
          ${s.warrantiesToDate ? `<div class="row"><span>warranty jobs booked</span><b>${s.warrantiesToDate}</b></div>` : ''}
          <div class="kicker">${kick}${repKick}</div>
          <button class="go">${won ? 'ANOTHER WEEK' : 'TRY AGAIN'}</button>
        </div>
      </div>`;
      card.classList.add('on');
      hud.setVisible(false);
      card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onRestart(); });
    },

    /**
     * @param {object} r  {hp, cornersLost, cornersTotal, bill, collateral, budgetLeft, win}
     * @param {() => void} onAgain
     */
    showAftermath(r, onAgain) {
      const w = r.week;
      // What happened. The money is the ledger's business — collateral used to be
      // listed here AND priced there, which is one fact told twice; the invoice
      // itemises it now, so this stays the job and that stays the bill.
      const rows = [
        ['garden', `${r.hp.toFixed(0)}%`],
        ['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
        ['what got through', r.hailBlocked],
        ['hardware lost', r.bill ? `$${r.bill}` : 'none'],
      ].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');

      // The money, itemised. A settlement you can't read is a number you can't
      // argue with, and arguing with it is how you learn the shop.
      //
      // SPRINT11 — this is the INVOICE now, so it answers the job sheet line for
      // line: base, garden, clean. Each row names what it was FOR, and the two
      // that can come up empty say why rather than vanishing — a bonus that
      // silently isn't there teaches nothing, and "clean bonus — you took the
      // carport" is the sentence that makes the trap land. E's copy for the
      // collateral row, verbatim from THREADS: "the carport — $180".
      const collateralRows = w && w.collateral
        ? (r.collateral ?? []).map((c) => `
            <div class="row bad"><span>${c.what}</span><b>−$${c.cost}</b></div>`).join('')
        : '';
      // SPRINT12 — E's .void: the forfeited bonus is STRUCK THROUGH, not zeroed.
      // The quoted $${cleanMax} stays on the paper with a line through it — the
      // player must see the money they didn't get sitting in the same column as
      // the money they did. A bonus you never see fail is a bonus you never
      // learn to protect. (E's words; the single highest-value line in the kit.)
      const cleanRow = w ? (w.clean
        ? `<div class="row"><span>clean site</span><b>+$${w.clean}</b></div>`
        : `<div class="row void"><span>clean site</span><b>$${w.cleanMax ?? 0}</b></div>`) : '';

      // AMOUNT DUE is what tonight's paper actually says: everything above it is
      // argument, this is the conclusion. pay − spent, so it goes negative the
      // night you take the carport with you — and the bank line under it stays,
      // because "what this night was worth" and "where that leaves you" are two
      // different facts.
      const due = w ? w.pay - w.spent : 0;
      // SPRINT16 — the invoice answers the sheet's warranty lines word for
      // word (same items, same derivation in week.js), and the fee carries the
      // same standing multiplier the sheet quoted. The ledger must keep
      // summing exactly as shown — a.test pins pay to these rows.
      const invWarrantyRows = w ? (w.warranty ?? []).map((it) => `
          <div class="row bad"><span>warranty — ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client</span><b>−$${it.cost}</b></div>`).join('') : '';
      const invFeeTag = w && w.feeMult && w.feeMult !== 1 ? ` · standing ×${w.feeMult.toFixed(2)}` : '';
      const money = w ? `
        <div class="ledger">
          <div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}${invFeeTag}</span><b>+$${w.fee}</b></div>
          <div class="row"><span>garden bonus — ${r.hp.toFixed(0)}% of the bed${
            // SPRINT14 — B's pool item, the invoice half. On a beyond-saving
            // night this row is always small, and next to "callout fee — night
            // lost" it reads as the player's failure. It isn't: the bed was
            // gone at the forecast (week.js gardenBeyondSaving). Naming that
            // HERE, in the ledger, matters more than in the verdict prose —
            // the ledger is the part players re-read, and an unexplained small
            // number is the most persuasive lie on the card.
            w.gardenBeyondSaving ? ', beyond saving from the start' : ''
          }</span><b>+$${w.bonus}</b></div>
          ${cleanRow}
          <div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
          ${collateralRows}${invWarrantyRows}
          <div class="row bad"><span>spent on the rig</span><b>−$${w.spent}</b></div>
          <div class="row due ${due < 0 ? 'neg' : ''}"><span>AMOUNT DUE</span><b>${due < 0 ? '−' : '+'}$${Math.abs(due)}</b></div>
          <div class="row total"><span>in the bank</span><b>$${w.bankBefore} → $${w.bankAfter}</b></div>
        </div>` : '';

      const next = !w ? 'PLAY AGAIN'
        : w.outcome === 'continue' ? `NIGHT ${w.night + 1} →`
        : w.outcome === 'win' ? 'SEE THE WEEK'
        : 'SEE THE DAMAGE';

      // The same letterhead as the job sheet, same docket number, and the kind
      // flips to TAX INVOICE — one outfit, two papers, one night apart. The
      // terms line: E's flavour when your failure broke something of theirs,
      // the world's most boring sentence otherwise.
      const letterhead = w?.client ? `
        <div class="letterhead">
          <div>
            <div class="mark">${BUSINESS.mark}</div>
            <div class="trade">${BUSINESS.trade}</div>
          </div>
          <div class="docket">
            <div class="kind">TAX INVOICE</div>
            <div class="no">INV ${docketNo(w.night)} · ${ABN}</div>
            ${w.rep != null ? `<div class="rep">standing ${repFmt(w.rep)}</div>` : ''}
          </div>
        </div>
        <div class="billto">
          <div class="who">${w.client}</div>
          ${w.addr ? `<div class="addr">${w.addr}</div>` : ''}
        </div>` : '';

      card.innerHTML = `<div class="card jobcard">
        ${letterhead}
        <h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1>
        <h2>${r.subtitle}</h2>
        ${rows}
        ${money}
        <div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
        ${/* SPRINT16 — the verdict speaks rep: the movement AND its reason, so
             the number on the letterhead is never a number that changed for no
             stated cause. week.js writes the reasons (including the flat
             "broke, but repaired by dawn" case) — this line just reads them. */''}
        ${w && w.rep != null ? `<div class="repline">reputation ${repFmt(w.repBefore)} → ${repFmt(w.rep)}${
          w.repReasons?.length ? ` — ${w.repReasons.join(' · ')}` : ''}</div>` : ''}
        <button class="go">${next}</button>
        ${w?.client ? `<div class="terms">Payable 14 days. ${w.collateral ? 'They’ll want that made right by Friday.' : 'Thank you for your business.'}</div>` : ''}
      </div>`;
      card.classList.add('on');
      hud.setVisible(false);
      card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onAgain(); });
    },

    hideCard() {
      card.classList.remove('on');
      card.innerHTML = '';
      hud.setVisible(true);
    },

    get cardOpen() { return card.classList.contains('on'); },

    dispose() {
      root.remove(); card.remove(); style.remove(); dawn.remove();
      d.scene.remove(barGroup);
      quad.dispose();
      for (const b of bars) {
        b.fill.material.dispose();
        b.label.material.map?.dispose();
        b.label.material.dispose();
      }
    },
  };

  return hud;
}
