// ui/cards.js (Lane E) — the full-screen screens: TITLE, PAUSE, and the post-run DEBRIEF.
//
// House position on what a "medal card" is here: this is not STAGE CLEAR. The fiction is that
// you are a surgical drone and the crew in comms.js are watching a feed. So the end-of-level
// screen is the operative record — an instrument printout with a par column and an attending's
// assessment. The score is still the score; it just wears a lab coat. (Design intent was
// pre-declared in LANE_E_NOTES as "a pathology report, not a Star Fox medal" — built to that.)
//
// CLOCK: self-clocked, deliberately.
//   1. player:state stops emitting the instant you die (player.js early-returns while !alive),
//      so it cannot clock anything that must animate across death or the level-complete freeze.
//   2. boot.js does now tick the UI (`ui.update(dt)` from frame(), boot.js:223), but a screen
//      whose animation depends on being ticked is a screen that shows a half-filled printout in
//      any harness or ?fly build that constructs us without a loop. The count-up is ours.
// So it runs on our own rAF, which SELF-CANCELS the moment the animation lands — no setInterval
// (House Law 5), and zero cost in the steady state where these screens are either hidden or
// holding a static readout. update() stays a no-op rather than a second driver: two clocks on
// one animation is the double-speed bug.
//
// INPUT: the window listeners are CAPTURE-phase and they consume ONLY the event they act on.
// KEYS: boot.js and flight/input.js both bind keydown, so stopPropagation()+preventDefault() on
// the key that starts/dismisses a screen is what stops it also reaching the game.
// POINTER: we listen for `pointerdown` but flight/input.js binds `mousedown` (input.js:65) — two
// different events, so stopPropagation() on ours cannot reach theirs anyway, and we do NOT need
// it to: input.js:50 early-returns on the unlocked click (`if (!intent.locked) { requestPointerLock;
// return; }`), so the click that dismisses the title could never have fired the cannon. We must
// therefore NOT preventDefault() the title click — that suppresses the compatibility `mousedown`
// and eats the one and only requestPointerLock() site in the codebase, costing the player a
// second click before mouse-look works. Earlier comment here claimed the opposite; it was wrong.
//
// POINTER-EVENTS: #ui is pointer-events:none and that inherits. Only the buttons re-enable it.
// Never put pointer-events:auto on the full-screen root — that swallows the canvas mouse-look
// and aim that flight/input.js binds to renderer.domElement, i.e. the whole game.

const C = {
  line: '#39e6ff',   // ART_BIBLE emissive code — neutral/interactive cyan
  soft: '#7fdfff',
  good: '#7dffb0',
  warn: '#ffb13a',
  bad: '#ff5a2a',
  surf: '#b06aff',
  ink: '#dff2ff',
};

// Grade table. Deterministic, no RNG, no hidden fudge — see gradeOf() for the criteria.
// The medal tier is in the comment so scoring discussions can map to the GDD's Star-Fox
// language, but what the player READS is the clinical assessment, which is the point.
const GRADES = [
  { key: 'EXEMPLARY',  medal: 'platinum', color: C.surf, note: 'i have nothing to add. file it as the reference run.' },
  { key: 'CLEAN',      medal: 'gold',     color: C.good, note: 'clean segment. the patient will never know you were there.' },
  { key: 'ACCEPTABLE', medal: 'silver',   color: C.line, note: 'acceptable. the tissue disagrees, but acceptable.' },
  { key: 'MARGINAL',   medal: 'bronze',   color: C.warn, note: 'you got through. i would not call it surgery.' },
  { key: 'SURVIVED',   medal: 'none',     color: C.bad,  note: 'the feed survived. that is the kindest reading available.' },
];

const COUNT_S = 1.35;    // seconds the printout takes to fill in
const ROW_STAG = 0.085;  // per-row head start, so the numbers land one after another
// Grade cut-offs as met/total ratios, hoisted out of gradeOf() so retuning the curve is a
// one-line edit next to the grade table it selects from.
const CUT_CLEAN = 0.75;
const CUT_ACCEPTABLE = 0.5;
// Every shipped level carries par.samples:3, but par can be absent entirely (stub level,
// ?localassets=0). ONE constant so the pause card and the grader can never disagree about
// what "all of them" means — they used to hardcode 3 independently.
const DEFAULT_SAMPLES = 3;

// Root class is `crd` — the ONLY collision surface, since (house style, per hud.js/comms.js)
// descendants are short and unprefixed but scoped under the root. `.hud` and `.comms` are
// taken. Keyframes ARE globally scoped, so every one of ours is crd-prefixed.
const CSS = `
/* z-index 5, and it is COORDINATED, not arbitrary: .fdbk was also 5 for a while, under the same
   #ui parent, which left the stacking to boot's construction order — a coin flip over whether
   the damage layer painted across the medal card. feedback.js has since taken 4 and says so in
   its own comment, naming 5 as ours. So 5 stays: it is above .fdbk and above the unlayered
   .hud/.comms, and changing it would silently falsify a sibling's stated assumption. */
.crd { position:absolute; inset:0; z-index:5; pointer-events:none;
  color:${C.line}; font:11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
  letter-spacing:.14em; text-transform:uppercase;
  font-variant-numeric:tabular-nums; font-feature-settings:"tnum" 1;
  text-shadow:0 0 6px rgba(0,0,0,.95); }

/* A screen = a full-bleed scrim + a panel. Hidden screens are display:none so they cost
   nothing — an opacity:0 overlay still composites every frame. */
.crd .scr { position:absolute; inset:0; display:none; align-items:center; justify-content:center; }
.crd .scr.on { display:flex; animation:crdIn .22s ease-out both; }
.crd .dimmer { position:absolute; inset:0; background:rgba(2,16,13,.62);
  backdrop-filter:blur(2.5px) saturate(.7); -webkit-backdrop-filter:blur(2.5px) saturate(.7); }
/* the "feed": static repeating gradient, painted once, never animated */
.crd .scan { position:absolute; inset:0; opacity:.5; mix-blend-mode:screen;
  background:repeating-linear-gradient(180deg,
    rgba(57,230,255,.055) 0 1px, rgba(0,0,0,0) 1px 3px); }

/* WIDTH, not min-width + max-width: min-width WINS over max-width in CSS, so the old
   old min-width:430px + max-width:min(560px,88vw) overflowed the viewport below ~490px and ran
   the par/verdict columns off the right edge — the 88vw was decorative. One width clamp cannot
   contradict itself. box-sizing so the 26px padding + border stay inside that width. */
.crd .panel { position:relative; box-sizing:border-box; padding:22px 26px 18px;
  width:min(560px, 92vw);
  border:1px solid rgba(57,230,255,.30); background:rgba(2,16,13,.66); }
/* two corner brackets — cheaper than four nodes and reads as an instrument frame */
.crd .panel::before, .crd .panel::after { content:''; position:absolute; width:13px; height:13px;
  border:1px solid ${C.line}; opacity:.85; }
.crd .panel::before { top:-1px; left:-1px; border-right:0; border-bottom:0; }
.crd .panel::after { bottom:-1px; right:-1px; border-left:0; border-top:0; }

.crd .hdr { display:flex; justify-content:space-between; align-items:baseline; gap:16px;
  font-size:9px; letter-spacing:.28em; opacity:.6; white-space:nowrap; }
.crd .rule { height:1px; background:currentColor; opacity:.22; margin:9px 0; }
.crd .rule.hair { opacity:.12; }
.crd .name { font-size:19px; letter-spacing:.3em; margin-top:8px; }
.crd .sub { font-size:9.5px; letter-spacing:.2em; opacity:.5; text-transform:none; margin-top:3px; }
.crd .dim { opacity:.45; }

/* readout rows: label | value | par | verdict. Grid so the columns line up like a printout
   and so no row ever reflows when a number changes width (tabular-nums + fixed tracks). */
.crd .row { display:grid; grid-template-columns:9.5em 1fr 6.5em 5.5em; align-items:baseline;
  gap:10px; padding:2.5px 0; font-size:11px; }
.crd .row .k { font-size:9px; letter-spacing:.22em; opacity:.55; }
.crd .row .v { text-align:right; font-size:13px; letter-spacing:.1em; color:${C.ink}; }
/* par and verdict were the smallest, faintest things on the card — an inverted hierarchy, since
   the par is half of the headline comparison and the verdict chip IS the "here is the criterion
   you missed" promise. Both lifted to a readable size/opacity; the value still leads. */
.crd .row .p { text-align:right; font-size:10.5px; opacity:.62; letter-spacing:.12em; }
.crd .row .t { font-size:9.5px; letter-spacing:.14em; text-align:right; opacity:.95; }
.crd .row.hero { padding:6px 0 7px; }
.crd .row.hero .v { font-size:25px; letter-spacing:.06em; }
.crd .row.hero .k { font-size:10px; opacity:.75; }

.crd .grade { display:flex; align-items:baseline; justify-content:space-between; gap:14px; }
.crd .grade .g { font-size:22px; letter-spacing:.34em; }
.crd .grade .m { font-size:8.5px; letter-spacing:.24em; opacity:.45; }
.crd .note { text-transform:none; letter-spacing:.05em; font-size:11.5px; color:${C.ink};
  opacity:.85; margin-top:7px; }
.crd .note b { font-weight:400; text-transform:uppercase; font-size:9px; letter-spacing:.22em;
  opacity:.55; margin-right:7px; }

.crd .foot { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-top:13px; }
/* pointer-events re-enabled HERE and nowhere else */
.crd .btn { pointer-events:auto; cursor:pointer; background:none; color:inherit; font:inherit;
  letter-spacing:.22em; text-transform:uppercase; font-size:10px;
  border:1px solid currentColor; padding:6px 15px; opacity:.8;
  transition:opacity .12s linear, background-color .12s linear; }
.crd .btn:hover, .crd .btn:focus-visible { opacity:1; background:rgba(57,230,255,.13); outline:none; }
.crd .hint { font-size:8.5px; letter-spacing:.24em; opacity:.35; }

/* The four fixed tracks are ~21.5em wide before the 1fr; on a phone that leaves the value
   column nothing. Tighten the tracks rather than let the printout wrap. */
@media (max-width:520px) {
  .crd .panel { padding:16px 14px 14px; }
  .crd .row { grid-template-columns:7.2em 1fr 4.6em 4.6em; gap:6px; }
  .crd .row .p, .crd .row .t { letter-spacing:.06em; }
  .crd .row.hero .v { font-size:21px; }
}

/* --- title ------------------------------------------------------------------------------ */
/* The title is the one screen with no dimmer — big type over the LIVE canal is the ART_BIBLE
   ask. But near-white 132px type and a 10px call-to-action over a lit, moving, warm interior
   need some floor under them, so instead of a flat scrim the screen gets a soft radial pool:
   it lifts contrast behind the type and fades to nothing well before the edges, so the drift
   is still the background. Painted once, never animated. */
.crd .tscrim { background:radial-gradient(ellipse 70% 55% at 50% 46%,
  rgba(2,16,13,.62) 0%, rgba(2,16,13,.34) 48%, rgba(2,16,13,0) 82%); }
.crd .title { text-align:center; }
.crd .title .big { font-size:clamp(54px, 13vw, 132px); line-height:.94; letter-spacing:.22em;
  margin-left:.22em; color:${C.ink}; text-shadow:0 0 26px rgba(57,230,255,.35), 0 0 3px #000; }
.crd .title .tag { font-size:10px; letter-spacing:.42em; opacity:.6; margin-top:14px; }
.crd .title .press { font-size:10px; letter-spacing:.34em; margin-top:34px; opacity:.75;
  animation:crdPulse 1.9s ease-in-out infinite; }
.crd .title .keys { font-size:8.5px; letter-spacing:.22em; opacity:.28; margin-top:11px; }
/* the title sits over the live view; a hairline top/bottom band frames it without hiding it */
.crd .title .band { width:min(520px, 76vw); height:1px; background:currentColor; opacity:.3;
  margin:0 auto; }

@keyframes crdIn { from { opacity:0; transform:translateY(7px) } to { opacity:1; transform:none } }
/* Floor at .55, not .28: the ONLY call-to-action on the screen must never be near-invisible
   for half of every cycle. The pulse still reads as a pulse; it just never disappears. */
@keyframes crdPulse { 0%,100% { opacity:.55 } 50% { opacity:1 } }
@media (prefers-reduced-motion:reduce) {
  .crd .scr.on { animation:none }
  .crd .title .press { animation:none; opacity:.7 }
}
`;

const el = (tag, cls, parent) => {
  const n = document.createElement(tag);
  if (cls) n.className = cls;
  if (parent) parent.appendChild(n);
  return n;
};

// textContent-on-change only. Every number on these cards ticks up over ~1.3s; writing an
// unchanged string 60×/s for 5 rows is 300 pointless DOM writes a second.
function makeCell(node) {
  let last = null;
  return (s) => { if (s !== last) { node.textContent = s; last = s; } };
}

const clamp01 = (v) => (v > 0 ? (v < 1 ? v : 1) : 0);
const easeOut = (k) => 1 - (1 - k) * (1 - k) * (1 - k);
// Two clocks on purpose: the run's own duration keeps a tenth (it is the thing being counted
// up, and a headline that ticks in whole seconds looks broken), the par is a round authored
// number and printing "3:00.0" next to it just adds a digit that never varies.
// QUANTISE FIRST, THEN SPLIT. The naive `(s/60|0) : (s%60).toFixed(1)` rounds the seconds AFTER
// choosing the minute, so 59.97 prints "0:60.0" and 119.98 prints "1:60.0" — an impossible clock.
// The count-up sweeps continuously through that window on every run over a minute, so the
// headline would visibly flash a bad time mid-animation. Round to tenths in one integer domain.
const mmss = (sec) => {
  const tenths = Math.round(Math.max(0, sec || 0) * 10);
  const m = (tenths / 600) | 0;
  const s = (tenths - m * 600) / 10;
  return `${m}:${s.toFixed(1).padStart(4, '0')}`;
};
const mmssPar = (sec) => {
  const s = Math.max(0, Math.round(sec || 0));
  return `${(s / 60) | 0}:${(s % 60).toString().padStart(2, '0')}`;
};
const num = (v) => Math.round(v || 0).toLocaleString('en-US');

/**
 * The grade rule, in one place, deterministic, and printed on the card as the four verdict
 * chips so the player can see exactly which criterion they missed.
 *
 *   1. DURATION   stats.time  <= par.time
 *   2. SCORE      stats.score >= par.score
 *   3. SPECIMENS  stats.samples >= par.samples   (3 in every shipped level)
 *   4. NO LOSSES  stats.deaths === 0             (the GDD's "damageless-gate bonus" — true
 *                 damagelessness isn't emitted anywhere, deaths is the honest proxy)
 *
 * met/total maps to: 4-of-4 => EXEMPLARY, >=.75 => CLEAN, >=.5 => ACCEPTABLE, >0 => MARGINAL,
 * 0 => SURVIVED.
 *
 * par can be null (stub level, ?localassets=0, an unpar'd level) — then criteria 1 and 2 are
 * not gradable and simply do not exist. That leaves total=2, and since EXEMPLARY requires
 * four gradable criteria you can top out at CLEAN. That is intentional: an unpar'd run has no
 * evidence for the top grade, and silently awarding it would make the medal meaningless.
 */
function gradeOf(stats, par) {
  const crit = [];
  if (par && typeof par.time === 'number') {
    crit.push({ id: 'time', pass: (stats.time ?? Infinity) <= par.time });
  }
  if (par && typeof par.score === 'number') {
    crit.push({ id: 'score', pass: (stats.score ?? 0) >= par.score });
  }
  const needS = (par && typeof par.samples === 'number') ? par.samples : DEFAULT_SAMPLES;
  crit.push({ id: 'samples', pass: (stats.samples ?? 0) >= needS });
  crit.push({ id: 'deaths', pass: (stats.deaths ?? 0) === 0 });

  const met = crit.reduce((n, c) => n + (c.pass ? 1 : 0), 0);
  const r = crit.length ? met / crit.length : 0;
  const g = (met === crit.length && crit.length >= 4) ? GRADES[0]
    : r >= CUT_CLEAN ? GRADES[1]
      : r >= CUT_ACCEPTABLE ? GRADES[2]
        : r > 0 ? GRADES[3]
          : GRADES[4];
  return { grade: g, crit, met, total: crit.length, needS };
}

export function createCards({
  bus, flags = {}, assets = null, level = null, world = null, mount = null,
} = {}) {
  const host = mount ?? document.getElementById('ui');
  // ?shots=1 => construct nothing at all (comms.js's route, not the HUD's display:none one —
  // a screen that exists but is hidden can still be un-hidden by a stray level:complete on a
  // screenshot pass, and the whole point of the flag is a guaranteed clean plate).
  if (!host || flags.shots) return { update() {}, dispose() {} };

  // world is accepted so the title can name the canal it is drifting through; world.level is
  // the fallback when boot hands us a level we didn't get directly. `assets` is accepted for
  // the factory contract and deliberately unused — these screens are pure line-work DOM, so
  // the assets-optional law costs us nothing: an empty manifest changes nothing here.
  const lvl = level ?? world?.level ?? null;

  // ONE <style> per document, refcounted. A second createCards() used to inject a duplicate
  // copy of the whole sheet; worse, either instance's dispose() would then have removed a sheet
  // the other was still using if we ever shared by id without counting. Count, then remove at
  // zero — correct under double-mount and identical to before under the normal single mount.
  const STYLE_ID = 'crd-css';
  let style = document.getElementById(STYLE_ID);
  if (style) {
    style.dataset.refs = String((Number(style.dataset.refs) || 1) + 1);
  } else {
    style = el('style');
    style.id = STYLE_ID;
    style.dataset.refs = '1';
    style.textContent = CSS;
    document.head.appendChild(style);
  }

  const root = el('div', 'crd', host);

  // ---- shared screen scaffold ------------------------------------------------------------
  function makeScreen(extraCls) {
    const scr = el('div', 'scr', root);
    el('div', 'dimmer', scr);
    el('div', 'scan', scr);
    const panel = el('div', extraCls ? `panel ${extraCls}` : 'panel', scr);
    return { scr, panel };
  }

  // ============================ TITLE =====================================================
  // Deliberately NOT a panel: the ART_BIBLE asks for big type over a live esophagus drift, so
  // this screen is transparent apart from two hairlines. It shows once, at boot, and can never
  // come back — a title that can reappear mid-run is a trap.
  const titleS = el('div', 'scr on tscrim', root);
  {
    const t = el('div', 'title', titleS);
    el('div', 'band', t);
    const big = el('div', 'big', t);
    big.textContent = 'GUTS';
    el('div', 'band', t);
    const tag = el('div', 'tag', t);
    tag.textContent = lvl?.name
      ? `${lvl.name}${lvl.tagline ? ` — ${lvl.tagline}` : ''}`
      : 'a flight through the alimentary canal';
    const press = el('div', 'press', t);
    press.textContent = 'press any key';
    const keys = el('div', 'keys', t);
    keys.textContent = 'esc / p — pause';
  }

  // ============================ PAUSE =====================================================
  const pause = makeScreen();
  const pauseRows = {};
  {
    const p = pause.panel;
    const hdr = el('div', 'hdr', p);
    el('span', null, hdr).textContent = 'feed held';
    el('span', null, hdr).textContent = lvl?.id ?? 'endo-1';
    const nm = el('div', 'name', p);
    nm.textContent = 'paused';
    el('div', 'rule', p);

    const row = (key, label) => {
      const r = el('div', 'row', p);
      el('span', 'k', r).textContent = label;
      const v = el('span', 'v', r);
      el('span', 'p', r);          // spacer — keeps the grid identical to the debrief card
      el('span', 't', r);
      pauseRows[key] = makeCell(v);
      return v;
    };
    row('pos', 'position');
    row('score', 'score');
    row('spec', 'specimens');
    row('integ', 'integrity');
    row('losses', 'feed losses');
    row('audio', 'audio');

    el('div', 'rule hair', p);
    const foot = el('div', 'foot', p);
    const resume = el('button', 'btn', foot);
    resume.type = 'button';
    resume.textContent = 'resume';
    resume.addEventListener('click', () => setPaused(false));
    el('span', 'hint', foot).textContent = 'esc / p';
    pause.resumeBtn = resume;
  }

  // ============================ DEBRIEF (the medal card) ==================================
  const debrief = makeScreen();
  const D = {};
  let nRows = 0;           // filled by row() below; the stagger remap is derived from it
  {
    const p = debrief.panel;
    const hdr = el('div', 'hdr', p);
    D.hdrL = makeCell(el('span', null, hdr));
    D.hdrR = makeCell(el('span', null, hdr));
    D.hdrL('operative record');
    D.name = makeCell(el('div', 'name', p));
    D.sub = makeCell(el('div', 'sub', p));
    el('div', 'rule', p);

    // label | value | par | verdict — the four columns of the printout.
    // Each row remembers its OWN stagger index, and nRows counts them. Both used to be
    // hardcoded at the call sites (and `4` was baked into the remap denominator), so adding or
    // removing a row silently stopped the last one ever reaching k=1 — its final number would
    // have been wrong AND its verdict chip would never have rendered, looking like a data bug.
    const row = (key, label, hero = false) => {
      const r = el('div', hero ? 'row hero' : 'row', p);
      el('span', 'k', r).textContent = label;
      const v = el('span', 'v', r);
      const par = el('span', 'p', r);
      const t = el('span', 't', r);
      D[key] = { v: makeCell(v), p: makeCell(par), t: makeCell(t), tn: t, i: nRows++, col: null };
      return D[key];
    };
    row('time', 'duration', true);      // TIME vs PAR is the headline comparison (GDD §Scoring)
    row('score', 'score');
    row('spec', 'specimens');
    row('kills', 'pathogens');
    row('deaths', 'feed losses');

    el('div', 'rule', p);
    const g = el('div', 'grade', p);
    D.gradeEl = el('span', 'g', g);
    D.gradeTxt = makeCell(D.gradeEl);
    D.medal = makeCell(el('span', 'm', g));
    const note = el('div', 'note', p);
    el('b', null, note).textContent = 'voss';
    D.note = makeCell(el('span', null, note));

    el('div', 'rule hair', p);
    const foot = el('div', 'foot', p);
    const cont = el('button', 'btn', foot);
    cont.type = 'button';
    cont.textContent = 'continue';
    cont.addEventListener('click', () => closeDebrief());
    D.contBtn = cont;
    D.next = makeCell(el('span', 'hint', foot));
  }

  // ---- state -----------------------------------------------------------------------------
  let titleUp = true;      // title is up right now
  let paused = false;
  let debriefUp = false;
  let debriefFor = null;   // level id whose record we have ALREADY printed — see openDebrief()
  let settled = false;     // the debrief count-up has reached its final numbers
  let disposed = false;
  let raf = 0;
  let animT0 = 0;
  let result = null;       // the frozen level:complete payload we are printing
  let deaths = 0;          // counted locally: no bus event carries a running death total
  const live = {           // last-seen scalars. COPIED out — player:state says don't retain it.
    s: 0, length: 0, progress: 0, hull: 1, coat: 1,
    score: 0, kills: 0, samples: 0,
  };                       // no `biome`: nothing on these screens prints it, and a dead store
                           // in a 60 Hz handler is still a write every frame.

  const now = () => performance.now() / 1000;

  // ---- pause -----------------------------------------------------------------------------
  function paintPause() {
    pauseRows.pos(live.length
      ? `s ${live.s | 0} / ${live.length | 0}  ·  ${(clamp01(live.progress) * 100) | 0}%`
      : '—');
    pauseRows.score(`${num(live.score)}  ·  ${live.kills | 0} kills`);
    pauseRows.spec(`${live.samples | 0} of ${lvl?.par?.samples ?? DEFAULT_SAMPLES}`);
    pauseRows.integ(`hull ${(clamp01(live.hull) * 100) | 0}%  ·  coat ${(clamp01(live.coat) * 100) | 0}%`);
    // No bus event carries a running death total (boot keeps run.deaths private until
    // level:complete), so this is counted locally off player:death.
    pauseRows.losses(`${deaths}`);
    pauseRows.audio(mutedNow() ? 'muted' : 'active');
  }

  // ?mute is authoritative and permanent for the session; otherwise read the SAME key the audio
  // engine persists under — audio/engine.js:219/234, `guts.audio` holding
  // JSON {master, muted} — so the indicator agrees with the engine without either module
  // importing the other. (This previously read a `guts.mute` key that nothing in the tree has
  // ever written, so the row said ACTIVE forever no matter what the player muted.)
  // Wrapped: storage throws in some privacy modes and JSON.parse throws on corrupt data, and a
  // settings readout is not worth a boot failure — the catch covers both.
  function mutedNow() {
    if (flags.mute) return true;
    try {
      const raw = localStorage.getItem('guts.audio');
      return raw ? !!JSON.parse(raw).muted : false;
    } catch { return false; }
  }

  // ── THE THREE EVENTS THIS FILE RAISES, and who owes them a consumer ───────────────────────
  // Bus-only: the screens announce and stop, they never reach into the loop.
  //   ui:pause    { paused }   — WIRED. boot.js gates frame() on it and feedback.js freezes.
  //   ui:start    {}           — the title was dismissed. STILL UNCONSUMED: boot builds the
  //                              player and steps from frame one, so the run is already live
  //                              behind the title card and run.t is already accumulating. That
  //                              corrupts THIS CARD'S OWN HEADLINE — DURATION vs PAR currently
  //                              includes however long the player spent reading the title.
  //   ui:continue { from, to } — CONTINUE was pressed; `to` is the next segment id from the
  //                              level:complete payload, null when this was the last one.
  //                              STILL UNCONSUMED: dismissing the record leaves the player
  //                              drifting in a finished level, with the foot hint naming a
  //                              segment nothing will load.
  function setPaused(v) {
    // A pause you cannot leave is the worst bug this file could ship, so the only guard on
    // UN-pausing is "are we paused". Pausing, by contrast, is refused whenever another screen
    // owns the input (title / debrief) — otherwise Escape stacks two modals.
    if (v === paused) return;
    if (v && (titleUp || debriefUp)) return;
    paused = v;
    pause.scr.classList.toggle('on', paused);
    if (paused) { paintPause(); pause.resumeBtn.focus?.(); }
    bus.emit('ui:pause', { paused });
  }

  // ---- debrief ---------------------------------------------------------------------------
  // One paint at animation progress k in [0,1]. Rows are staggered so the printout fills in
  // top-down; each row's own k is remapped, not delayed by a timer.
  function paintDebrief(k) {
    if (!result) return;
    const st = result.stats ?? {};
    const par = result.par ?? null;
    // Denominator = the span left after the LAST row's head start, derived from nRows rather
    // than a hardcoded 4, so every row still lands exactly on 1.0 whatever the row count is.
    // Floored: a huge ROW_STAG must degrade to "everything lands together", never divide by <=0.
    const span = Math.max(0.05, 1 - ROW_STAG * Math.max(0, nRows - 1));
    const rk = (i) => easeOut(clamp01((k - i * ROW_STAG) / span));

    D.time.v(mmss((st.time ?? 0) * rk(D.time.i)));
    D.score.v(num((st.score ?? 0) * rk(D.score.i)));
    D.spec.v(`${Math.round((st.samples ?? 0) * rk(D.spec.i))}`);
    D.kills.v(`${Math.round((st.kills ?? 0) * rk(D.kills.i))}`);
    D.deaths.v(`${Math.round((st.deaths ?? 0) * rk(D.deaths.i))}`);

    // Verdict chips only resolve once their row has finished counting — a chip that says
    // UNDER while the clock is still ticking past par reads as a lie.
    const g = result.g;
    const chip = (cell, on, yes, no, yesColor, noColor) => {
      if (rk(cell.i) < 1) { cell.t(''); return; }
      cell.t(on ? yes : no);
      // Same change-guard discipline as makeCell: once a row lands, this runs on every one of
      // the remaining ~28 frames, and re-assigning an identical style.color is a wasted write.
      const col = on ? yesColor : noColor;
      if (col !== cell.col) { cell.tn.style.color = col; cell.col = col; }
    };
    const pass = (id) => g.crit.find((c) => c.id === id)?.pass;
    // A criterion that could not be graded still gets a chip. Leaving the cell blank looked
    // identical to "still counting" and made the card's promise — four chips, one per criterion
    // — quietly untrue on any unpar'd level.
    if (par && typeof par.time === 'number') chip(D.time, pass('time'), 'under par', 'over par', C.good, C.warn);
    else chip(D.time, false, '', 'no par', C.soft, C.soft);
    if (par && typeof par.score === 'number') chip(D.score, pass('score'), 'par met', 'short', C.good, C.warn);
    else chip(D.score, false, '', 'no par', C.soft, C.soft);
    chip(D.spec, pass('samples'), 'complete', 'incomplete', C.good, C.warn);
    // The fail string was EMPTY: die, and the one criterion you actually missed printed nothing
    // at all — the card went silent about the exact thing it exists to tell you.
    chip(D.deaths, pass('deaths'), 'no losses', 'feed lost', C.good, C.bad);

    if (k >= 1) {
      D.gradeTxt(g.grade.key.toLowerCase());
      D.gradeEl.style.color = g.grade.color;
      D.medal(`assessment · ${g.met}/${g.total} criteria · ${g.grade.medal}`);
      D.note(g.grade.note);
    }
  }

  function tickAnim() {
    raf = 0;
    if (disposed || !debriefUp) return;
    const k = clamp01((now() - animT0) / COUNT_S);
    paintDebrief(k);
    if (k < 1) raf = requestAnimationFrame(tickAnim);   // self-cancelling: no idle rAF
    else settled = true;                                // landed on its own; input closes now
  }

  function openDebrief(e) {
    // level:complete fires exactly once (boot latches run.done), but latch HERE too — this card
    // freezes a payload and a second open would restart the count with the same numbers. The
    // old guard was `if (debriefUp)` alone, which closeDebrief() clears, so the defence-in-depth
    // the comment advertised did not actually exist: a repeat event after dismissal reopened it.
    // Latched BY LEVEL ID, not by a bare boolean: now that CONTINUE emits ui:continue and an
    // integrator can advance us, a hard once-per-instance latch would swallow every later
    // segment's record. Same id twice = the duplicate we are defending against; a new id = a
    // new segment, which is exactly what this card is for.
    const id = e?.id ?? lvl?.id ?? '—';
    if (debriefUp || debriefFor === id) return;
    debriefFor = id;
    result = {
      id: e?.id ?? lvl?.id ?? null,
      to: e?.to ?? null,        // kept so closeDebrief can name where the player asked to go
      stats: e?.stats ?? {},
      par: e?.par ?? null,
      g: gradeOf(e?.stats ?? {}, e?.par ?? null),
    };
    if (paused) setPaused(false);       // a completion beats a pause; never stack them
    titleUp = false;
    titleS.classList.remove('on');

    const st = result.stats, par = result.par;
    D.hdrR(e?.id ?? lvl?.id ?? '—');
    D.name(lvl?.name ?? e?.id ?? 'segment');
    D.sub(lvl?.tagline ?? 'segment cleared');
    D.time.p(par && typeof par.time === 'number' ? `par ${mmssPar(par.time)}` : 'par —');
    D.score.p(par && typeof par.score === 'number' ? `par ${num(par.score)}` : 'par —');
    D.spec.p(`of ${result.g.needS}`);
    D.kills.p('');
    D.deaths.p('');
    D.next(e?.to ? `next · ${e.to}` : 'any key');
    // deaths from the payload is authoritative; our local count is only for the pause card
    deaths = st.deaths ?? deaths;

    debriefUp = true;
    settled = false;
    debrief.scr.classList.add('on');
    animT0 = now();
    paintDebrief(0);
    if (!raf) raf = requestAnimationFrame(tickAnim);
    D.contBtn.focus?.();
  }

  function closeDebrief() {
    if (!debriefUp) return;
    debriefUp = false;
    debrief.scr.classList.remove('on');
    if (raf) { cancelAnimationFrame(raf); raf = 0; }
    // Dismissing the record is the player ASKING TO ADVANCE, and that intent has to leave this
    // file or CONTINUE just deletes the card and strands them in a finished level — the foot
    // hint even names the next segment. We do not load levels (bus-only, no reaching in), so we
    // announce and let boot route it. `to` is the next-level id from the level:complete payload,
    // or null when this was the last segment. INTEGRATOR: nothing consumes ui:continue yet.
    bus.emit('ui:continue', { from: result?.id ?? lvl?.id ?? null, to: result?.to ?? null });
  }

  // If the player dismisses mid-count, snap to the final numbers instead of freezing a
  // half-counted printout — the card is a record, and a record is never partial.
  function settleDebrief() {
    if (raf) { cancelAnimationFrame(raf); raf = 0; }
    // A FLAG, not a re-derived k. The input handlers used to ask "is (now-animT0)/COUNT_S < 1?"
    // on every press, which was wrong twice over: settleDebrief never advanced animT0, so the
    // second press got k<1 again and re-settled instead of closing — the card could not be
    // dismissed until 1.35s of REAL time had passed, however hard the player mashed. And even
    // backdating animT0 leaves the close gated on a float landing exactly on 1.0, which it need
    // not (a clock with a large epoch loses the last bits of a 1.35 subtraction). What the
    // handlers actually want to know is "is the printout finished", which is a fact we own.
    settled = true;
    animT0 = now() - COUNT_S;   // keep the clock agreeing with the screen for any later read
    paintDebrief(1);
  }

  // ---- input -----------------------------------------------------------------------------
  const dismissTitle = () => {
    if (!titleUp) return false;
    titleUp = false;
    titleS.classList.remove('on');
    bus.emit('ui:start', {});
    return true;
  };

  function onKey(e) {
    if (disposed || e.repeat) return;
    const code = e.code;

    if (titleUp) {
      // Any key. Modifiers alone don't count — holding shift to look around shouldn't start
      // the run, and neither should a browser shortcut the user is halfway through.
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      if (code === 'ShiftLeft' || code === 'ShiftRight' || code === 'Tab') return;
      if (dismissTitle()) { e.preventDefault(); e.stopPropagation(); }
      return;
    }

    if (debriefUp) {
      if (code === 'Tab') return;                       // leave focus traversal alone
      if (!settled) settleDebrief();                    // first press finishes the count…
      else closeDebrief();                              // …second dismisses. Never traps.
      e.preventDefault();
      e.stopPropagation();
      return;
    }

    if (code === 'Escape' || code === 'KeyP') {
      setPaused(!paused);
      e.preventDefault();
      e.stopPropagation();
    }
  }

  function onPointer(e) {
    if (disposed) return;
    // Clicks on our own buttons are handled by their click listeners; ignore them here or the
    // RESUME button would both resume and be swallowed.
    if (e.target && root.contains(e.target)) return;
    if (titleUp) {
      // stopPropagation ONLY — no preventDefault. preventDefault() on a pointerdown suppresses
      // the compatibility `mousedown`, and input.js:65 listens for exactly that, on the one and
      // only requestPointerLock() call in the codebase (input.js:50). Eating it costs the player
      // a second click before mouse-look works. It also bought nothing: that same handler
      // early-returns while unlocked, so this click could never have fired the cannon.
      if (dismissTitle()) e.stopPropagation();
      return;
    }
    if (debriefUp) {
      if (!settled) settleDebrief(); else closeDebrief();
      e.preventDefault();
      e.stopPropagation();
    }
    // While paused, a click on the world does nothing — the RESUME button and esc/p are the
    // only ways out, and both are always reachable.
  }

  // Capture phase: boot.js and flight/input.js both bind keydown/mousedown on window, and we
  // must be able to eat the one event we consume BEFORE they see it.
  addEventListener('keydown', onKey, true);
  addEventListener('pointerdown', onPointer, true);

  // ---- bus -------------------------------------------------------------------------------
  const offs = [];

  offs.push(bus.on('player:state', (p) => {
    // Hot path, every frame: copy scalars, touch no DOM. The pause card is repainted when it
    // OPENS, not while it is closed — writing five cells 60×/s into a display:none subtree is
    // pure waste, and while paused the numbers are frozen by definition.
    live.s = p.s ?? 0;
    live.length = p.length ?? 0;
    live.progress = p.progress ?? 0;
    live.hull = p.hullMax ? p.hull / p.hullMax : 0;
    live.coat = p.coatMax ? p.coat / p.coatMax : 0;
  }));

  offs.push(bus.on('combat:state', (c) => {
    live.score = c.score ?? 0;
    live.kills = c.kills ?? 0;
    live.samples = c.samples ?? 0;
  }));

  offs.push(bus.on('player:death', () => { deaths++; }));

  offs.push(bus.on('level:complete', openDebrief));

  // Safety valve: taking damage while a title card is still up means the run is live and the
  // card is in the way, so it leaves. This is the ONLY auto-dismiss, and the choice of event
  // is load-bearing — the two obvious alternatives are both wrong:
  //   player:state emits from frame one, before the player has touched anything;
  //   level:event fires off POSITION, and L2's first checkpoint sits at s=20, which the flow
  //   carries you past in about a second and a half. Either one would rip the title away
  //   while the player was still reading it.
  // Nothing else auto-dismisses: "press any key" waiting forever for a key is correct, and
  // is not a trap — every key and every click is the exit.
  offs.push(bus.on('player:damage', () => dismissTitle()));

  return {
    // No-op by design, and here to satisfy the TECH factory contract. boot.js DOES tick us
    // (frame() -> ui.update(dt)), and this staying empty is the point: the screens are bus-driven
    // plus one self-cancelling rAF for the count-up, so a second driver would only double-advance
    // that animation. Free every frame, and free while paused, which is when it runs most.
    update() {},

    dispose() {
      disposed = true;
      if (raf) { cancelAnimationFrame(raf); raf = 0; }
      removeEventListener('keydown', onKey, true);
      removeEventListener('pointerdown', onPointer, true);
      for (const off of offs) off();
      // If we disposed mid-pause, the loop is still holding for us — release it, or disposing
      // the UI freezes the game with no screen left to un-freeze it.
      if (paused) { paused = false; bus.emit('ui:pause', { paused: false }); }
      root.remove();
      const refs = (Number(style.dataset.refs) || 1) - 1;
      if (refs > 0) style.dataset.refs = String(refs); else style.remove();
    },
  };
}
