Back to Shiftfield
SOURCE / PINNED RELEASE

Made of little things.

Shiftfield

Release
5a170c505f6b…
Author-recorded commit
a2e23e0171aa…
License
LICENSE
Author’s source reference
nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-e4fb6341ccd

Archive hash verified: 66de7623efd91da1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

tests/browser.test.mjs
/* Minesweeper DX — automated browser smoke test.
 *
 * Boots the BUILT dist/ in a real headless Chromium (the Playwright install
 * that @napplet/conformance-cli already uses) and asserts the integrated game
 * works end-to-end: engine boots through the real DOM, HUD tracks state,
 * reveal/flag/radar work, death shows the overlay, new run resets, wave
 * advance increments depth, and hazard/telegraph UI renders at depth 11.
 *
 * Run: pnpm test:browser   (requires pnpm build first; `verify` chains both)
 *
 * This covers the automated tier. The MANUAL in-browser checklist in
 * docs/browser-testing.md (touch gestures, real shell frame, soyli preview)
 * still applies before any release — see AGENTS.md "Playability rule".
 */
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { dirname, join, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';

const root = dirname(dirname(fileURLToPath(import.meta.url)));
const dist = join(root, 'dist');

const MIME = {
  '.html': 'text/html',
  '.js': 'text/javascript',
  '.css': 'text/css',
  '.json': 'application/json',
};

async function serveDist() {
  const server = createServer(async (req, res) => {
    const path = req.url === '/' ? '/index.html' : req.url.split('?')[0];
    try {
      const body = await readFile(join(dist, path.slice(1)));
      res.writeHead(200, { 'content-type': MIME[extname(path)] ?? 'application/octet-stream' });
      res.end(body);
    } catch {
      res.writeHead(404).end('not found');
    }
  });
  await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
  const { port } = server.address();
  return { url: `http://127.0.0.1:${port}/`, close: () => server.close() };
}

const service = await serveDist();
const browser = await chromium.launch();

try {
  const page = await browser.newPage({ viewport: { width: 900, height: 600 } });
  const errors = [];
  page.on('pageerror', (e) => errors.push(String(e)));
  page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });

  await page.goto(service.url, { waitUntil: 'load' });
  assert.equal(errors.length, 0, `boot errors: ${errors.join('; ')}`);

  // 1. Real game boots: 100 cells and the QA hook exist (no template shell),
  //    sitting on the Phase-3 MENU screen with no run in flight.
  assert.equal(await page.locator('.cell').count(), 100, 'board grid');
  assert.ok(await page.evaluate('window.minesweeperDx !== undefined'), 'game hook');
  const boot = await page.evaluate(() => ({
    menu: !document.querySelector('#menuScreen').hidden,
    decision: !document.querySelector('#decisionScreen').hidden,
    summary: !document.querySelector('#summaryScreen').hidden,
    shop: !document.querySelector('#shopScreen').hidden,
    boardControlsHidden:
      document.querySelector('#menuLeaderboardBtn').hidden &&
      document.querySelector('#summaryBoardBtn').hidden &&
      document.querySelector('#boardScreen').hidden,
    saveNote: document.querySelector('#saveNote').textContent,
    startRunLabel: document.querySelector('#startRunBtn').textContent,
  }));
  assert.ok(boot.menu, 'game boots into the menu screen');
  assert.ok(!boot.decision && !boot.summary && !boot.shop, 'only the menu is visible at boot');
  // cvm is OPTIONAL (O tag, ruling D3): the bare host never injected it, so
  // every leaderboard entry point must be hidden at boot.
  assert.ok(boot.boardControlsHidden, 'bare host hides all leaderboard entry points');
  assert.match(boot.saveNote, /storage/i, 'menu states the missing storage domain honestly');
  assert.equal(boot.startRunLabel, 'Enter The Depths', 'menu CTA reads Enter The Depths (ruling 2026-10-28, menu_start_cta_rename_ruling_2026-10-28.md)');
  assert.doesNotMatch(boot.startRunLabel, /Start Run/, 'old "Start Run" copy stays retired');
  await page.click('#startRunBtn');
  assert.equal(
    await page.evaluate(() => window.minesweeperDx.phase),
    'running',
    'Enter The Depths starts the run',
  );
  assert.ok(
    await page.evaluate(() => document.querySelector('#nameGate').hidden),
    'the one-time name gate NEVER interrupts on a bare host without cvm ' +
      '(first-run gate ruling 2026-10-29; positive side pinned in board-scenario)',
  );
  assert.equal(
    await page.evaluate(() => window.minesweeperDx.board.game.shieldCharges),
    0,
    'fresh save: the run starts with ZERO shields (regression — the old ' +
      'MAX_UPGRADES constructor default handed free 5 charges to every new engine)',
  );

  // 2. Clicking a safe tile flood-opens a chunk and pays Flow ONCE.
  const clicked = await page.evaluate(() => {
    const g = window.minesweeperDx.board.game;
    // Seal a mine pocket around (9,9) so no flood can clear the board and
    // pop the Sector-clear overlay in the middle of the smoke sequence.
    for (const [x, y] of [[8,8],[9,8],[0,8],[8,9],[0,9],[8,0],[9,0],[0,0]])
      g.world.tileAt(x, y).hasMine = true;
    const t =
      g.world.tiles.find((x) => !x.hasMine && x.state === 'HIDDEN' && g.world.adjacentMineCount(x.coord[0], x.coord[1]) === 0) ??
      g.world.tiles.find((x) => !x.hasMine && x.state === 'HIDDEN');
    document.querySelector(`.cell[data-x="${t.coord[0]}"][data-y="${t.coord[1]}"]`).click();
    return { turn: g.turn, flow: g.flow, revealed: g.world.revealedCount() };
  });
  assert.equal(clicked.turn, 1, 'turn ticks');
  assert.equal(clicked.flow, 5, 'one action pays flowPerSafe once');
  assert.ok(clicked.revealed > 5, `flood opened a chunk (${clicked.revealed} tiles), not one`);

  // 2b. Turn law: clicking already-revealed ground is a no-op — it must NOT
  //     increment the turn counter (regression: every click used to tick it).
  const noopClick = await page.evaluate(() => {
    const g = window.minesweeperDx.board.game;
    const open = g.world.tiles.find((t) => t.state === 'REVEALED');
    const before = g.turn;
    document.querySelector(`.cell[data-x=\"${open.coord[0]}\"][data-y=\"${open.coord[1]}\"]`).click();
    document.querySelector(`.cell[data-x=\"${open.coord[0]}\"][data-y=\"${open.coord[1]}\"]`).click();
    return { before, after: g.turn, hud: document.querySelector('#turn').textContent };
  });
  assert.equal(noopClick.after, noopClick.before, 'no-op clicks do not tick the engine turn');
  assert.equal(noopClick.hud, String(noopClick.before), 'HUD turn unchanged after no-op clicks');

  // 3. Right-click places a flag; right-click AGAIN removes it (classic
  //    toggle); flags cannot be placed on REVEALED tiles.
  const flagClass = () => page.locator('.cell[data-x="0"][data-y="0"]').getAttribute('class');
  await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
  assert.match(await flagClass(), /cell-flagged/, 'right-click places flag');
  await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
  assert.doesNotMatch(await flagClass(), /cell-flagged/, 'right-click again removes flag');
  assert.match(await flagClass(), /cell-hidden/, 'removed flag returns tile to hidden');
  // Reveal a tile through the UI, then try to flag it: must stay revealed.
  const flagReject = await page.evaluate(() => {
    const g = window.minesweeperDx.board.game;
    const t = g.world.tiles.find(
      (c) => !c.hasMine && c.state === 'HIDDEN' && !(c.coord[0] === 0 && c.coord[1] === 0),
    );
    document.querySelector(`.cell[data-x="${t.coord[0]}"][data-y="${t.coord[1]}"]`).click();
    const turnBefore = g.turn;
    const outcome = g.flag(t.coord[0], t.coord[1]);
    return {
      outcome,
      state: g.tile(t.coord[0], t.coord[1]).state,
      flags: g.world.flaggedCount(),
      turnTicked: g.turn === turnBefore + 1,
    };
  });
  assert.equal(flagReject.outcome, 'rejected', 'flag on revealed tile is rejected');
  assert.equal(flagReject.state, 'REVEALED', 'revealed tile keeps its state');
  assert.equal(flagReject.flags, 0, 'no flag was created on revealed ground');
  assert.ok(!flagReject.turnTicked, 'rejected flag changes nothing: no turn');
  // SLOW right-click (press held >400ms, like a deliberate mouse hold): the
  // contextmenu flag at press time must SURVIVE — the long-press toggle is
  // primary-button only — and the player's NEXT left-click must not be
  // swallowed by a stale once-capture click suppressor.
  const box00 = await page.locator('.cell[data-x="0"][data-y="0"]').boundingBox();
  const hold = { x: box00.x + box00.width / 2, y: box00.y + box00.height / 2 };
  await page.mouse.move(hold.x, hold.y);
  await page.mouse.down({ button: 'right' });
  await page.waitForTimeout(550);
  await page.mouse.up({ button: 'right' });
  assert.match(await flagClass(), /cell-flagged/, 'slow right-hold keeps the flag placed');
  // Next left-click on fresh hidden ground reveals (not swallowed).
  const afterHold = await page.evaluate(() => {
    const g = window.minesweeperDx.board.game;
    const t = g.world.tiles.find((c) => c.state === 'HIDDEN' && !c.hasMine);
    document.querySelector(`.cell[data-x="${t.coord[0]}"][data-y="${t.coord[1]}"]`).click();
    return g.tile(t.coord[0], t.coord[1]).state;
  });
  assert.equal(afterHold, 'REVEALED', 'click after a slow right-hold is not swallowed');

  // 3b. MOBILE long-press flag (device-reported bug 2026-10-29): Android
  // Chrome / iOS fire a native `contextmenu` MID-HOLD (flag placed at press),
  // then pointerup + sometimes a synthesized click land on the SAME tile.
  // The old long-press toggle flagged again on release — place→remove meant
  // the flag vanished exactly when the finger lifted and two turns burned
  // (players learned to slide off the tile to dodge it). Replay that exact
  // event order on a fresh hidden tile: exactly ONE flag, exactly ONE turn,
  // flag survives release, the trailing click must not reveal anything.
  const lp = await page.evaluate(async () => {
    const g = window.minesweeperDx.board.game;
    const t = g.world.tiles.find((c) => c.state === 'HIDDEN' && !c.hasMine && !c.flagged);
    const cell = document.querySelector(`.cell[data-x="${t.coord[0]}"][data-y="${t.coord[1]}"]`);
    const before = { flags: g.world.flaggedCount(), turn: g.turn };
    const p = (type, up) =>
      cell.dispatchEvent(new PointerEvent(type, {
        bubbles: true, cancelable: true, composed: true,
        button: 0, buttons: up ? 0 : 1, pointerId: 7, pointerType: 'touch', isPrimary: true,
      }));
    p('pointerdown', false);
    await new Promise((r) => setTimeout(r, 450)); // cross the 400ms long-press window
    cell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
    p('pointerup', true);
    cell.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
    const out = {
      flagged: g.tile(t.coord[0], t.coord[1]).state === 'FLAGGED',
      domFlag: cell.classList.contains('cell-flagged'),
      flagDelta: g.world.flaggedCount() - before.flags,
      turnDelta: g.turn - before.turn,
      revealed: g.tile(t.coord[0], t.coord[1]).state === 'REVEALED',
    };
    // Cleanup: long-press AGAIN on the same tile removes the flag (the same
    // mobile gesture in reverse — one flag per gesture, both directions) and
    // leaves the board unblocked for the board-clear phase further down the
    // suite. Same replay shape, contextmenu-less (dedupe latch stays null).
    const before2 = g.world.flaggedCount();
    cell.dispatchEvent(new PointerEvent('pointerdown', {
      bubbles: true, cancelable: true, composed: true,
      button: 0, buttons: 1, pointerId: 8, pointerType: 'touch', isPrimary: true,
    }));
    await new Promise((r) => setTimeout(r, 450));
    cell.dispatchEvent(new PointerEvent('pointerup', {
      bubbles: true, cancelable: true, composed: true,
      button: 0, buttons: 0, pointerId: 8, pointerType: 'touch', isPrimary: true,
    }));
    out.untoggled = g.world.flaggedCount() === before2 - 1;
    // Drain the once-capture suppressor this gesture also armed, so the next
    // real UI click in the suite is not swallowed.
    cell.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
    return out;
  });
  assert.ok(lp.flagged && lp.domFlag, 'long-press flag SURVIVES the finger release on the same tile');
  assert.equal(lp.flagDelta, 1, 'the long-press gesture flags exactly once (one flag per gesture)');
  assert.equal(lp.turnDelta, 1, 'one turn per long-press (the double-flag burned two)');
  assert.ok(!lp.revealed, 'the synthesized post-long-press click does not reveal');
  assert.ok(lp.untoggled, 'a contextmenu-less long-press (dedupe latch idle) still flags on release — toggle removal works both ways');

  // 3c. Workshop gating (player rulings 2026-10-24 + 2026-10-27): Radar,
  //     Seeker and Stabilizer START LOCKED at level 0 — the bar shows
  //     🔒 instead of a cost and the buttons are disabled even with Flow to
  //     spare. Shield left the bar entirely (ruling 2026-10-27,
  //     shop_flow_rebalance): its Workshop LEVEL grants run-start charges.
  //     Levels come from the Workshop shop, which check 5b drives with
  //     real clicks; here the QA hook grants them so the ability-behaviour
  //     checks below can run.
  const gating = await page.evaluate(() => {
    const app = window.minesweeperDx;
    const g = app.board.game;
    const snap = () => ['radar', 'seeker', 'stabilizer'].map((id) => {
      const btn = document.querySelector(`[data-ability="${id}"]`);
      return {
        disabled: btn.disabled,
        cost: btn.querySelector('.cost').textContent,
        lockedClass: btn.classList.contains('ability-locked'),
      };
    });
    g.flow = 70; // affordability is NOT what should be holding these back (max cost 50f)
    app.sync(g);
    const before = snap();
    app.progression.save.upgrades = { radar: 1, seeker: 1, shield: 1, stabilizer: 1, economy: 0, flow_cap: 0 };
    app.applyUpgrades(g);
    app.sync(g);
    return {
      before,
      after: snap(),
      unlocked: ['radar', 'seeker', 'shield', 'stabilizer'].map((id) => g.isUnlocked(id)),
    };
  });
  for (const b of gating.before) {
    assert.ok(b.disabled, 'locked ability is disabled despite full Flow');
    assert.equal(b.cost, '🔒', 'locked ability shows the lock glyph, not a cost');
    assert.ok(b.lockedClass, 'locked ability carries the ability-locked class');
  }
  assert.deepEqual(
    gating.unlocked,
    [true, true, true, true],
    'purchased levels unlock the abilities (Stabilizer now gated too, ruling 2026-10-27)',
  );
  for (const a of gating.after) assert.ok(!a.disabled, 'unlocked ability is usable when affordable');

  // 4. Radar is TARGETED: selecting it arms without firing; the NEXT click
  //    spends it on the clicked tile (not the previously focused one), pays
  //    Flow cost, and sets cooldown.
  const radar = await page.evaluate(() => {
    const g = window.minesweeperDx.board.game;
    // deterministic: aim at a hidden, mine-free tile
    // (flood may have opened every safe tile — fall back to any safe tile;
    // clicking opened ground is inert, and radar still pays its cost).
    const safe =
      g.world.tiles.find((t) => t.hasMine === false && t.state === 'HIDDEN') ??
      g.world.tiles.find((t) => t.hasMine === false);
    // Focus a DIFFERENT safe tile first so a stale-focus bug would fire the
    // 3×3 around the wrong place / at the wrong time.
    const other = g.world.tiles.find((t) => !t.hasMine && t !== safe) ?? g.world.tiles[0];
    document.querySelector(`.cell[data-x="${other.coord[0]}"][data-y="${other.coord[1]}"]`).click();
    document.querySelector('[data-ability="radar"]').click(); // arm only
    const armed = {
      fired: g.cooldowns.get('radar') !== undefined, // engine untouched by arming
      flow: g.flow,
      pending: window.minesweeperDx.board.pendingTarget !== null,
      status: document.querySelector('#status').textContent,
    };
    const revealedBefore = g.world.revealedCount();
    document.querySelector(`.cell[data-x="${safe.coord[0]}"][data-y="${safe.coord[1]}"]`).click(); // fires here
    // Radar is a pure sink: net flow change is exactly the cost, whatever it revealed.
    return {
      armed,
      net: armed.flow - g.flow,
      gained: g.world.revealedCount() - revealedBefore,
      cd: g.cooldowns.get('radar') ?? 0,
      pendingAfter: window.minesweeperDx.board.pendingTarget !== null,
      // Center tile of the 3×3 must have been revealed (it is safe + was HIDDEN
      // or just-clicked safe; either way it is REVEALED after the radar click).
      centerRevealed: g.tile(safe.coord[0], safe.coord[1]).state === 'REVEALED',
    };
  });
  assert.equal(radar.armed.pending, true, 'radar arms on select without firing');
  assert.equal(radar.armed.fired, false, 'arming radar does not touch the engine');
  assert.match(radar.armed.status, /armed/i, 'status prompts for target');
  assert.equal(radar.net, 50, 'next click fires radar: costs 50 flow (ruling 2026-10-29), grants nothing');
  assert.ok(radar.cd > 0, 'radar on cooldown after use');
  assert.ok(radar.centerRevealed, 'radar fired at the CLICKED tile');
  assert.equal(radar.pendingAfter, false, 'radar disarms after firing');

  // 4b. Seeker through the ability bar: seeker's internal flag must NOT
  //     double-tick — exactly ONE turn for the whole use (regression).
  const seekerTurn = await page.evaluate(() => {
    const app = window.minesweeperDx;
    const g = app.board.game;
    g.flow = 70; // seeker costs 50f now (ruling 2026-10-29) — affordable regardless of test-2 economy
    app.sync(g); // recompute ability-bar gating so the button is enabled
    const before = g.turn;
    const flagsBefore = g.world.flaggedCount();
    const btn = document.querySelector('[data-ability="seeker"]');
    const wasEnabled = !btn.disabled;
    btn.click();
    return {
      wasEnabled,
      delta: g.turn - before,
      flagged: g.world.flaggedCount() - flagsBefore,
      hudMatches: document.querySelector('#turn').textContent === String(g.turn),
    };
  });
  assert.ok(seekerTurn.wasEnabled, 'seeker button enabled once affordable');
  assert.equal(seekerTurn.flagged, 1, 'seeker placed a flag');
  assert.equal(seekerTurn.delta, 1, 'seeker (ability + its flag) = exactly 1 turn');
  assert.ok(seekerTurn.hudMatches, 'HUD turn matches the engine after seeker');

  // 5. Clearing via engine shows the DECISION screen — and the clear PAYS at
  //    that moment (ruling 2026-10-24: scrap accrues per board clear; the
  //    vault only moves on Bank/salvage). Go Deeper advances depth without
  //    crediting the same clear a second time.
  const advance = await page.evaluate(() => {
    const g = window.minesweeperDx;
    const eng = g.board.game;
    // test 3 locked (0,0); engine reveal is inert on FLAGGED — unlock first.
    const locked = eng.world.tileAt(0, 0);
    if (locked.state === 'FLAGGED' && !locked.hasMine) locked.state = 'HIDDEN';
    for (const t of eng.world.tiles) {
      if (!t.hasMine && t.state === 'HIDDEN') eng.reveal(t.coord[0], t.coord[1]);
    }
    g.board.refresh();
    g.sync(eng);
    return {
      shown: !document.querySelector('#decisionScreen').hidden,
      title: document.querySelector('#decisionTitle').textContent,
      body: document.querySelector('#decisionBody').textContent,
      runHud: document.querySelector('#runScrap').textContent,
      vault: window.minesweeperDx.progression.scrap,
      phase: window.minesweeperDx.phase,
    };
  });
  assert.ok(advance.shown, 'board clear shows the decision screen');
  assert.equal(advance.phase, 'decision', 'phase moves to decision');
  // Player ruling 2026-10-27 (screen_copy_rewrite): Draft A salvage voice.
  assert.equal(advance.title, 'Sector 0 Cleared', 'decision title names the sector depth');
  assert.match(advance.body, /\+10 scrap salvaged\./, 'depth-0 clear pays 10 scrap');
  assert.match(advance.body, /You're hauling \d+ total\./, 'body reports the total haul');
  assert.equal(advance.runHud, '10', 'run-scrap HUD reflects the ledger');
  assert.equal(advance.vault, 0, 'earned scrap stays unbanked until Bank');
  await page.click('#continueBtn');
  const deeper = await page.evaluate(() => ({
    depth: Number(document.querySelector('#depth').textContent),
    run: window.minesweeperDx.progression.runScrap,
    phase: window.minesweeperDx.phase,
  }));
  assert.equal(deeper.depth, 1, 'Go Deeper advances depth');
  assert.equal(deeper.phase, 'running', 'Go Deeper resumes the run');
  assert.equal(deeper.run, 10, 'the same clear is never credited twice');

  // 5b. Depth scaling pays more (10 × 1.1^Depth); Bank & Exit ends the run
  //     with the ledger SAFE. Then the Workshop: real-click purchases debit
  //     the vault, gate on affordability, and Enter The Depths enters a fresh run.
  await page.evaluate(() => {
    const g = window.minesweeperDx;
    const eng = g.board.game;
    for (const t of eng.world.tiles) {
      if (!t.hasMine && t.state === 'HIDDEN') eng.reveal(t.coord[0], t.coord[1]);
    }
    g.board.refresh();
    g.sync(eng);
  });
  const secondClear = await page.evaluate(() => ({
    body: document.querySelector('#decisionBody').textContent,
    run: window.minesweeperDx.progression.runScrap,
  }));
  assert.match(secondClear.body, /\+11 scrap/, 'depth-1 clear pays 11 (10 × 1.1)');
  assert.equal(secondClear.run, 21, 'ledger accumulates across boards');
  await page.click('#bankBtn');
  const banked = await page.evaluate(() => ({
    title: document.querySelector('#summaryTitle').textContent,
    vault: window.minesweeperDx.progression.scrap,
    run: window.minesweeperDx.progression.runScrap,
    ledger: document.querySelector('#summaryLedger').textContent,
    boardNote: document.querySelector('#summaryBoardNote').textContent,
    boardBtnHidden: document.querySelector('#summaryBoardBtn').hidden,
  }));
  assert.equal(banked.title, 'Banked', 'Bank & Exit shows the run summary');
  assert.equal(banked.vault, 21, 'banked scrap is in the vault');
  assert.equal(banked.run, 0, 'ledger emptied by the bank');
  assert.match(banked.ledger, /\+21 ⛃/, 'summary records the banked amount');
  // Degradation site 1 (bank run-end): cvm absent → honest unavailable note,
  // no throw, no submit attempted (depth-v1 ruling 2026-10-28).
  assert.equal(
    banked.boardNote,
    'Leaderboard unavailable on this host.',
    'bank-end summary states the leaderboard is unavailable on a bare host',
  );
  assert.ok(banked.boardBtnHidden, 'no summary leaderboard shortcut without cvm');

  await page.click('#summaryWorkshopBtn');
  assert.ok(
    await page.evaluate(() => !document.querySelector('#shopScreen').hidden),
    'Workshop opens from the summary screen',
  );
  // QA-grant vault scrap, then re-enter the shop so the rows re-render
  // against the new balance (rows are rebuilt on every open/purchase).
  await page.evaluate(() => { window.minesweeperDx.progression.save.scrap += 500; });
  await page.click('#shopBackBtn');
  await page.click('#summaryWorkshopBtn');
  // Grouped-card layout (ruling 2026-10-28, shop_layout_ruling): three
  // archetype sections, all six lines, and the footer CTAs must fit inside
  // the frame — the unstyled build clipped them below a 437px viewport.
  const shopLayout = await page.evaluate(() => {
    const inFrame = (sel) => {
      const r = document.querySelector(sel).getBoundingClientRect();
      return r.top >= 0 && r.bottom <= innerHeight + 1;
    };
    return {
      titles: [...document.querySelectorAll('.shop-group-title')].map((e) => e.textContent),
      cards: document.querySelectorAll('.upgrade-row').length,
      ctaVisible: inFrame('#shopStartBtn') && inFrame('#shopBackBtn'),
      listScrollable: getComputedStyle(document.querySelector('.upgrade-list')).overflowY === 'auto',
      pipsVisible: (() => {
        const pip = document.querySelector('.upgrade-row[data-upgrade="radar"] .pip');
        return pip.getBoundingClientRect().width >= 6 && pip.getBoundingClientRect().height >= 6;
      })(),
    };
  });
  assert.deepEqual(shopLayout.titles, ['Intel', 'Survival', 'Economy'], 'shop is grouped by archetype');
  assert.equal(shopLayout.cards, 6, 'all six Workshop lines render');
  assert.ok(shopLayout.ctaVisible, 'Enter The Depths / Back stay inside the frame');
  assert.ok(shopLayout.listScrollable, 'the upgrade list scrolls inside the panel');
  assert.ok(shopLayout.pipsVisible, 'level pips render as visible diamonds');
  await page.click('.upgrade-row[data-upgrade="radar"] [data-buy]'); // Lv1→2: 400 (521 → 121)
  const bought = await page.evaluate(() => ({
    scrap: document.querySelector('#shopScrap').textContent,
    pips: document.querySelectorAll('.upgrade-row[data-upgrade="radar"] .pip-on').length,
    radarState: document.querySelector('.upgrade-row[data-upgrade="radar"]').classList.contains('unaffordable') ? 'unaffordable' : 'other',
    economyState: document.querySelector('.upgrade-row[data-upgrade="economy"]').classList.contains('affordable') ? 'affordable' : 'other',
    status: document.querySelector('#status').textContent,
    seekerDisabled: document.querySelector('.upgrade-row[data-upgrade="seeker"] [data-buy]').disabled,
    stabilizerDisabled: document.querySelector('.upgrade-row[data-upgrade="stabilizer"] [data-buy]').disabled,
    economyDisabled: document.querySelector('.upgrade-row[data-upgrade="economy"] [data-buy]').disabled,
    levels: window.minesweeperDx.progression.levels,
  }));
  assert.equal(bought.scrap, '121', 'purchase debits the vault');
  assert.equal(bought.pips, 2, 'pip row reflects the purchased level');
  assert.equal(bought.radarState, 'unaffordable', 'radar card dims when the next level outprices the vault');
  assert.equal(bought.economyState, 'affordable', 'unlocked + affordable cards get the amber outline');
  assert.match(bought.status, /Radar Array → Lv\.2/, 'purchase confirms in status');
  assert.equal(bought.levels.radar, 2);
  assert.ok(bought.seekerDisabled, 'Seeker Lv2 (200) is unaffordable at 121');
  assert.ok(bought.stabilizerDisabled, 'Stabilizer Lv2 (200) is unaffordable at 121');
  assert.ok(!bought.economyDisabled, 'Data Ledger Lv1 (50, ruling 2026-10-27) is affordable');
  await page.click('.upgrade-row[data-upgrade="economy"] [data-buy]'); // 121 → 71
  assert.equal(
    await page.evaluate(() => document.querySelector('#shopScrap').textContent),
    '71',
    'second purchase debits again',
  );
  // Flow Capacitor Lv0→1 (ruling 2026-10-27, flow_cap_ruling): 50 scrap,
  // the row exists in the generic shop and the level lands on the save.
  await page.click('.upgrade-row[data-upgrade="flow_cap"] [data-buy]'); // 71 → 21
  const capped = await page.evaluate(() => ({
    scrap: document.querySelector('#shopScrap').textContent,
    level: window.minesweeperDx.progression.levels.flow_cap,
    pips: document.querySelectorAll('.upgrade-row[data-upgrade="flow_cap"] .pip-on').length,
  }));
  assert.equal(capped.scrap, '21', 'Flow Capacitor Lv1 debits 50');
  assert.equal(capped.level, 1, 'flow_cap_lvl persisted on the live save');
  assert.equal(capped.pips, 1, 'pip row reflects the purchased ceiling level');
  await page.click('#shopStartBtn');
  const freshRun = await page.evaluate(() => {
    const app = window.minesweeperDx;
    return {
      phase: app.phase,
      depth: app.board.game.depth,
      turn: app.board.game.turn,
      run: app.progression.runScrap,
      radarLevel: app.board.game.upgrades.radar,
      shieldCharges: app.board.game.shieldCharges,
      flowCap: app.board.game.flowCap(),
      hudFlow: document.querySelector('#flow').textContent,
    };
  });
  assert.deepEqual(freshRun, { phase: 'running', depth: 0, turn: 0, run: 0, radarLevel: 2, shieldCharges: 1, flowCap: 200, hudFlow: '0/200' },
    'Enter The Depths from the shop: fresh board, fresh ledger, purchased levels live, run-start shield grant applied (shield Lv1 → exactly 1 charge), and the bought Flow ceiling (200) binds the fresh engine and shows in the HUD');

  // 6. Death via engine shows the run SUMMARY; nothing was earned on this
  //    run, so the vault is untouched. Menu → Enter The Depths begins a fresh run
  //    (depth, turn AND ledger reset).
  const death = await page.evaluate(() => {
    const g = window.minesweeperDx;
    const eng = g.board.game;
    // First-click protection is OFF on the fresh run so the engine can be
    // detonated deliberately: this check is about the DEATH SUMMARY, the
    // opening-reveal rule is check 6c.
    eng.firstClickSafe = false;
    // Shield Lv1 (granted in 3c, purchased levels persist): the fresh run
    // STARTS with one charge (ruling 2026-10-27) — spend it via QA so this
    // check exercises the lethal path, not the absorb.
    eng.shieldCharges = 0;
    const mine = eng.world.tiles.find((t) => t.hasMine && t.state === 'HIDDEN');
    eng.reveal(mine.coord[0], mine.coord[1]);
    g.board.refresh();
    g.sync(eng);
    return {
      title: document.querySelector('#summaryTitle').textContent,
      cause: document.querySelector('#summaryCause').textContent,
      status: document.querySelector('#status').textContent,
      ledger: document.querySelector('#summaryLedger').textContent,
      boardNote: document.querySelector('#summaryBoardNote').textContent,
      vault: g.progression.scrap,
      phase: g.phase,
      titles: [...g.deathTitles],
    };
  });
  // Degradation site 2 (death run-end): same honest unavailable note; the
  // death screen must work fully without cvm (depth-v1 ruling 2026-10-28).
  assert.equal(
    death.boardNote,
    'Leaderboard unavailable on this host.',
    'death-end summary states the leaderboard is unavailable on a bare host',
  );
  // Player ruling 2026-10-27: death title is a random pool one-liner —
  // assert membership (guards against 'Run over' regressing back).
  assert.ok(death.titles.includes(death.title), 'death shows a pool death-title');
  assert.notEqual(death.title, 'Run over', "legacy 'Run over' title is retired");
  assert.equal(death.phase, 'summary', 'phase moves to summary');
  assert.match(death.cause, /Depth 0/, 'summary names the depth where it ended');
  // Player ruling 2026-10-27: UI text never shows the raw ExplosionCause enum.
  assert.match(death.cause, /Depth 0 · mine detonated\./, 'summary names the cause in player words');
  assert.doesNotMatch(death.cause, /MINE_ON_/, 'no engine enum leaks into the summary');
  assert.match(death.status, /Boom — mine detonated/, 'death status line uses player words');
  assert.match(death.ledger, /Run scrap0 ◈/, 'ledger records zero earned this run');
  assert.match(death.ledger, /Destroyed−0/, 'nothing to destroy when nothing was held');
  assert.match(death.ledger, /Vault total21/, 'banked scrap survives death untouched (71 − 50 Flow Capacitor, ruling flow_cap_ruling)');
  assert.equal(death.vault, 21, 'a zero-earnings death never touches the vault');
  await page.click('#summaryMenuBtn');
  const menu = await page.evaluate(() => ({
    shown: !document.querySelector('#menuScreen').hidden,
    stats: document.querySelector('#menuStats').textContent,
  }));
  assert.ok(menu.shown, 'summary returns to the menu');
  assert.match(menu.stats, /⛃ 21 scrap banked/, 'menu shows the vault total (21 after the Flow Capacitor purchase)');
  assert.match(menu.stats, /deepest depth 2/, 'menu records the deepest sector cleared');
  await page.click('#startRunBtn');
  const menuReset = await page.evaluate(() => ({
    depth: window.minesweeperDx.board.game.depth,
    turn: window.minesweeperDx.board.game.turn,
    run: window.minesweeperDx.progression.runScrap,
    hudDepth: document.querySelector('#depth').textContent,
  }));
  assert.deepEqual(menuReset, { depth: 0, turn: 0, run: 0, hudDepth: '0' },
    'new run resets depth, turn AND the run ledger');

  // 6b. Regression: after a REAL death → New Run, one right-click must place
  //     exactly ONE flag and tick EXACTLY +1 turn. Input listeners used to
  //     re-bind on every rebuild() (stacking N sets on the same root), so a
  //     single right-click ran flag() N times: with even N the flag vanished
  //     ("flags can no longer be placed after New Run"), with odd N the turn
  //     jumped +N (user saw +3).
  await page.locator('.cell[data-x="3"][data-y="3"]').click({ button: 'right' });
  const postRun = await page.evaluate(() => {
    const g = window.minesweeperDx.board.game;
    return {
      state: g.tile(3, 3).state,
      flags: g.world.flaggedCount(),
      fromZero: g.turn, // New Run resets turn: one flag ⇒ exactly 1
      hudMatches: document.querySelector('#turn').textContent === String(g.turn),
    };
  });
  assert.equal(postRun.state, 'FLAGGED', 'first right-click after New Run places the flag');
  assert.equal(postRun.flags, 1, 'exactly one flag created by one right-click');
  assert.equal(postRun.fromZero, 1, 'one flag on a fresh run = exactly +1 turn');
  assert.ok(postRun.hudMatches, 'HUD turn matches engine after post-New-Run flag');
  // And removal toggles symmetrically: exactly +1 turn, flag gone.
  const turnBeforeUnflag = await page.evaluate(() => window.minesweeperDx.board.game.turn);
  await page.locator('.cell[data-x="3"][data-y="3"]').click({ button: 'right' });
  const postUnflag = await page.evaluate(
    () => window.minesweeperDx.board.game.tile(3, 3).state,
  );
  assert.equal(postUnflag, 'HIDDEN', 'second right-click removes the flag');
  const unflagDelta = (await page.evaluate(() => window.minesweeperDx.board.game.turn)) - turnBeforeUnflag;
  assert.equal(unflagDelta, 1, 'flag removal is exactly +1 turn');

  // 6c. First-click safety through the REAL UI: this fresh run has opened no
  //     tile yet (flag toggles don't count). A DOM click that LANDS ON A MINE
  //     must relocate the mine and open the tile safely — classic Minesweeper
  //     never lets the opening click detonate.
  const firstClick = await page.evaluate(() => {
    const g = window.minesweeperDx.board.game;
    const mine = g.world.tiles.find((t) => t.hasMine && t.state === 'HIDDEN');
    const before = { turn: g.turn, mines: g.world.mineCount() };
    document.querySelector(`.cell[data-x="${mine.coord[0]}"][data-y="${mine.coord[1]}"]`).click();
    return {
      armed: g.firstClickSafe,
      spent: g.firstClickSafe === false,
      exploded: g.isDead(),
      state: g.tile(mine.coord[0], mine.coord[1]).state,
      stillMine: g.tile(mine.coord[0], mine.coord[1]).hasMine,
      mines: g.world.mineCount(),
      minesBefore: before.mines,
      turnDelta: g.turn - before.turn,
      summaryShown: !document.querySelector('#summaryScreen').hidden,
    };
  });
  assert.ok(firstClick.armed === false || firstClick.spent, 'guarantee spent by the opening click');
  assert.equal(firstClick.exploded, false, 'first click on a mine never detonates');
  assert.equal(firstClick.state, 'REVEALED', 'the clicked tile opened');
  assert.equal(firstClick.stillMine, false, 'the mine moved off the clicked tile');
  assert.equal(firstClick.mines, firstClick.minesBefore, 'relocation preserves the mine count');
  assert.equal(firstClick.turnDelta, 1, 'the protected opening still costs one turn');
  assert.equal(firstClick.summaryShown, false, 'no run summary on the protected opening');
  // The guarantee is per-board and spent: the SAME run's next mine click dies.
  const secondClick = await page.evaluate(() => {
    const app = window.minesweeperDx;
    const g = app.board.game;
    if (g.isBoardCleared) return { skipped: true };
    // Shield Lv1 was granted in check 3c — this run STARTS with one charge
    // (ruling 2026-10-27). This check is about lethality once the opening
    // guarantee is spent, so spend the run's charges via QA first.
    g.shieldCharges = 0;
    const mine = g.world.tiles.find((t) => t.hasMine && t.state === 'HIDDEN');
    document.querySelector(`.cell[data-x=\"${mine.coord[0]}\"][data-y=\"${mine.coord[1]}\"]`).click();
    return { skipped: false, exploded: g.isDead(), summaryTitle: document.querySelector('#summaryTitle').textContent, titles: [...window.minesweeperDx.deathTitles] };
  });
  if (!secondClick.skipped) {
    assert.equal(secondClick.exploded, true, 'after the opening, mine clicks are lethal again');
    assert.ok(secondClick.titles.includes(secondClick.summaryTitle), 'run summary shows on the second mine hit');
  }

  // 7. Depth 11 shows the shift telegraph and hazard render classes.
  const chaos = await page.evaluate(() => {
    const g = window.minesweeperDx;
    const Ctor = Object.getPrototypeOf(g.board.game).constructor;
    // The Shifter intro modal (blocking, ruling 2026-10-28) is pinned by the
    // dedicated 10d block; suppress it here so this fixture's clicks land.
    g.shifterIntroShown = true;
    document.querySelector('#shifterIntro').hidden = true;
    g.board.game = new Ctor(11, undefined, 7);
    g.board.rebuild();
    let ice = 0, fire = 0;
    for (const t of g.board.game.world.tiles) {
      if (ice === 0 && g.board.game.spawnIce(t.coord[0], t.coord[1])) { ice++; continue; }
      if (fire === 0 && g.board.game.spawnFire(t.coord[0], t.coord[1])) { fire++; break; }
    }
    g.board.refresh();
    g.sync(g.board.game);
    return {
      telegraph: !document.querySelector('#shiftStat').hidden,
      ice: document.querySelectorAll('.cell-ice').length,
      fire: document.querySelectorAll('.cell-fire').length,
    };
  });
  assert.ok(chaos.telegraph, 'shift telegraph visible at depth 11');
  assert.equal(chaos.ice, 1, 'ice cell rendered');
  assert.equal(chaos.fire, 1, 'fire cell rendered');

  // 8. Responsive contract: board square, no page overflow at tier sizes.
  for (const [width, height] of [[200, 160], [320, 560], [900, 600]]) {
    await page.setViewportSize({ width, height });
    const fit = await page.evaluate(() => {
      // Phase 4 juice: the death boom shakes #app via transform; the layout
      // contract measures geometry, so settle transient effects first (QA
      // hook: fx.reset() clears particles, shake offset and chroma).
      window.minesweeperDx.fx?.reset();
      const b = document.querySelector('.board').getBoundingClientRect();
      return {
        square: Math.abs(b.width - b.height) <= 2,
        overflowY: document.documentElement.scrollHeight > innerHeight + 1,
        inView: b.top >= 0 && b.bottom <= innerHeight + 1,
      };
    });
    assert.ok(fit.square, `board square at ${width}x${height}`);
    assert.ok(!fit.overflowY, `no vertical overflow at ${width}x${height}`);
    assert.ok(fit.inView, `board in view at ${width}x${height}`);
  }

  // 8b. Menu-actions stacking (clipping fix 2026-10-28): the three menu CTAs
  // must never share an edge or overlap — the pre-fix inline flow put the
  // wrapped Leaderboard button flush against the row above it. Measured with
  // the button un-hidden (bare host keeps it hidden; the geometry rule is
  // what regresses, so the probe unhides it deliberately).
  await page.evaluate(() => {
    const g = window.minesweeperDx;
    g.showScreen('menu');
    document.querySelector('#menuLeaderboardBtn').hidden = false;
  });
  for (const [width, height] of [[320, 560], [900, 600]]) {
    await page.setViewportSize({ width, height });
    const menuFit = await page.evaluate(() => {
      const rs = ['startRunBtn', 'menuWorkshopBtn', 'menuLeaderboardBtn'].map(
        (id) => document.querySelector('#' + id).getBoundingClientRect(),
      );
      return {
        gaps: rs.slice(1).map((r, i) => Math.round(r.top - rs[i].bottom)),
        overlaps: rs.some((a, i) =>
          rs.some((b, j) => i < j && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom),
        ),
      };
    });
    assert.ok(!menuFit.overlaps, `menu buttons never overlap at ${width}x${height}`);
    assert.ok(menuFit.gaps.every((gp) => gp >= 4), `menu button gaps >=4px at ${width}x${height}: ${menuFit.gaps}`);
  }
  await page.evaluate(() => {
    document.querySelector('#menuLeaderboardBtn').hidden = true; // restore bare-host state
    window.minesweeperDx.showScreen('running');
  });

  // 8c. Field Manual (ruling 2026-10-28, how_to_play_menu_ruling): MENU
  // opens Basics; the HUD `?` lands mid-run on Tools & Workshop without
  // disturbing the phase; tab clicks swap panels; Back hands the screen
  // straight back. Static-copy invariants are pinned here (approved copy,
  // ratified explosion words), and engine enums must never leak into the
  // manual — same guard family as the ui_status_copy ruling. Geometry: at
  // the 200x160 floor the Back CTA stays in view (capped-panel contract).
  await page.evaluate(() => window.minesweeperDx.showScreen('menu'));
  await page.click('#menuFieldManualBtn');
  const menuHelp = await page.evaluate(() => ({
    shown: !document.querySelector('#helpScreen').hidden,
    heading: document.querySelector('#helpTitle').textContent,
    basicsActive: document.querySelector('#helpTabBasics').classList.contains('active'),
    toolsActive: document.querySelector('#helpTabTools').classList.contains('active'),
    toolsHidden: document.querySelector('#helpPanelTools').hidden,
    menuStillShown: !document.querySelector('#menuScreen').hidden,
    text: document.querySelector('#helpScreen').textContent,
  }));
  assert.ok(menuHelp.shown && menuHelp.menuStillShown, 'Field Manual opens over the menu');
  assert.equal(menuHelp.heading, 'Field Manual');
  assert.ok(menuHelp.basicsActive && !menuHelp.toolsActive && menuHelp.toolsHidden, 'menu entry lands on Basics');
  assert.match(menuHelp.text, /Death destroys half of what you carry\. Bank your scrap to protect it\./, 'bank/death copy');
  assert.match(menuHelp.text, /wrap is always on/, 'wrap copy');
  assert.match(menuHelp.text, /A mine detonated/, 'explosion word 1 (ratified label)');
  assert.match(menuHelp.text, /Mines collided/, 'explosion word 2 (ratified label)');
  assert.match(menuHelp.text, /A mine ignited/, 'explosion word 3 (ratified label)');
  assert.match(menuHelp.text, /50f, 2T/, 'Radar cost/cooldown numbers (ruling 2026-10-29)');
  assert.match(menuHelp.text, /50f, 3T/, 'Seeker cost/cooldown numbers (ruling 2026-10-29)');
  assert.match(menuHelp.text, /30f, 10T/, 'Stabilizer cost/cooldown numbers (ruling 2026-10-29)');
  assert.doesNotMatch(menuHelp.text, /30f, 2T|10f, 10T/, 'old ability prices never survive a re-price');
  assert.doesNotMatch(menuHelp.text, /MINE_ON_/, 'engine enums never leak into the manual');
  // Player copy revision (2026-10-28, second pass): Basics carries no exact
  // economy numbers — no +5/+20 Flow, no ×1.1 scrap math, no capacitor levels.
  // Scoped to the Basics panel; Tools keeps its ratified tool costs.
  const basicsText = await page.evaluate(
    () => document.querySelector('#helpPanelBasics').textContent,
  );
  assert.match(basicsText, /one sector at a time, and maybe get some loot/, 'revised opening line');
  assert.match(basicsText, /A safe move rewards you with/, 'revised left-click line');
  assert.match(basicsText, /locks down the tile/, 'revised right-click line');
  assert.match(basicsText, /Death destroys half of what you carry\. Bank your scrap to protect it\./, 'revised death line');
  assert.equal(basicsText.split('Death destroys half').length - 1, 1, 'death mechanic stated exactly once on Basics');
  assert.doesNotMatch(basicsText, /\+5|\+20|×1\.1|First click/, 'no exact economy numbers or cut lines on Basics');
  // Hazards tab copy revision (2026-10-28, second pass): ice duration and fire
  // spread counts are deliberately unspecific — nothing to drift when hazards
  // reprice. The flood-walls closer was cut entirely.
  const hazardsText = await page.evaluate(
    () => document.querySelector('#helpPanelHazards').textContent,
  );
  assert.match(hazardsText, /Sectors past depth 10 come with additional hazards/, 'revised hazards opener');
  assert.match(hazardsText, /frozen ground that is too hard to dig through for a time/, 'revised ice line');
  assert.match(hazardsText, /trigger an explosion unless a shield charge eats it/, 'revised shift-collision line');
  assert.doesNotMatch(hazardsText, /3 turns|up to two|whole shift aborts|flood cascade|flooded open/, 'no exact hazard numbers or cut lines on Hazards');
  // Tools tab copy revision (2026-10-28, third pass): plainer opener, shield
  // line without the 1-5 charge range or "not an ability" aside, and the two
  // economy upgrades as their own bullets instead of one lumped paragraph.
  const toolsText = await page.evaluate(
    () => document.querySelector('#helpPanelTools').textContent,
  );
  assert.match(toolsText, /buy upgrades in the Workshop to use them/, 'revised tools opener');
  assert.match(toolsText, /increases the number of shield charges the run starts with/, 'revised shield line');
  assert.match(toolsText, /Charges carry across sectors\./, 'shield carry-across-sectors line');
  assert.match(toolsText, /Data Ledger \u2014 \+25% run scrap per level/, 'Data Ledger own bullet');
  assert.match(toolsText, /Flow Capacitor \u2014 \+100 Flow ceiling per level/, 'Flow Capacitor own bullet');
  assert.doesNotMatch(toolsText, /Workshop Economy line|not an ability|never refill|1\u20135|Lv\.1/, 'cut asides gone from Tools');
  await page.click('#helpTabTools');
  const switched = await page.evaluate(() => ({
    toolsActive: document.querySelector('#helpTabTools').classList.contains('active'),
    basicsHidden: document.querySelector('#helpPanelBasics').hidden,
  }));
  assert.ok(switched.toolsActive && switched.basicsHidden, 'tab click swaps panels');
  await page.click('#helpBackBtn');
  const closed = await page.evaluate(() => ({
    helpHidden: document.querySelector('#helpScreen').hidden,
    menuStillShown: !document.querySelector('#menuScreen').hidden,
  }));
  assert.ok(closed.helpHidden && closed.menuStillShown, 'Back hands the menu straight back');
  // Mid-run entry: `?` opens on Tools, phase untouched; Back leaves the run view intact.
  // (showScreen only paints screens; phase is set separately, so drive both.)
  await page.evaluate(() => {
    window.minesweeperDx.phase = 'running';
    window.minesweeperDx.showScreen('running');
  });
  await page.click('#helpToggle');
  const runHelp = await page.evaluate(() => ({
    toolsActive: document.querySelector('#helpTabTools').classList.contains('active'),
    basicsActive: document.querySelector('#helpTabBasics').classList.contains('active'),
    phase: window.minesweeperDx.phase,
    hudShown: (document.querySelector('header.hud')?.getClientRects().length ?? 0) > 0,
  }));
  assert.ok(runHelp.toolsActive && !runHelp.basicsActive, 'mid-run `?` lands on Tools & Workshop');
  assert.equal(runHelp.phase, 'running', 'Field Manual never changes the phase');
  assert.ok(runHelp.hudShown, 'run HUD stays mounted under the overlay');
  // 200x160 floor with the manual open: CTA in view, no page-level overflow.
  // Settle first: panel-anim's 0.34 s entrance transform was still running
  // after the open click, and the probe measures static geometry.
  await page.waitForTimeout(400);
  await page.setViewportSize({ width: 200, height: 160 });
  const helpFit = await page.evaluate(() => {
    const r = document.querySelector('#helpBackBtn').getBoundingClientRect();
    return {
      backInView: r.top >= 0 && r.bottom <= innerHeight + 1,
      overflowY: document.documentElement.scrollHeight > innerHeight + 1,
    };
  });
  assert.ok(helpFit.backInView, 'Field Manual Back is in view at 200x160');
  assert.ok(!helpFit.overflowY, 'Field Manual never overflows the page at 200x160');
  await page.evaluate(() => {
    document.querySelector('#helpBackBtn').click();
    window.minesweeperDx.fx?.reset();
  });
  await page.setViewportSize({ width: 900, height: 600 });

  // 9. Proximity numbers render on revealed tiles AND update live when
  //    mines shift (depth >= 6): revealed tiles are never re-rendered by
  //    the shift itself, so their numbers must reflect the NEW layout on
  //    the next refresh.
  const numbers = await page.evaluate(() => {
    const g = window.minesweeperDx;
    const Ctor = Object.getPrototypeOf(g.board.game).constructor;
    g.board.game = new Ctor(0, undefined, 123);
    const eng = g.board.game;
    const world = eng.world;
    for (const t of world.tiles) t.hasMine = false;
    world.tileAt(5, 5).hasMine = true;
    // Reveal the mine's neighbors directly (engine reveal, no UI gating) —
    // each is a revealed safe tile that must show "1".
    for (const [nx, ny] of [[4,4],[5,4],[6,4],[6,6]]) world.reveal(nx, ny, { flood: false });
    g.board.rebuild(); // full DOM rebuild from engine
    const shown = document.querySelector('.cell[data-x="4"][data-y="4"]').textContent;
    // Shift the mine away from that corner and sync the DOM the way an
    // action would (shift + refresh).
    world.tileAt(5, 5).hasMine = false;
    world.tileAt(6, 5).hasMine = true; // now 0,0-adjacent tiles stay 0; (6,4) keeps its neighbor
    g.board.refresh();
    const updated = {
      stale: document.querySelector('.cell[data-x="4"][data-y="4"]').textContent,
      still: document.querySelector('.cell[data-x="6"][data-y="4"]').textContent,
    };
    return { shown, ...updated };
  });
  assert.equal(numbers.shown, '1', 'revealed tile shows proximity number');
  assert.equal(numbers.stale, '', 'number clears when the neighbor mine shifts away');
  assert.equal(numbers.still, '1', 'number persists while still adjacent to a mine');

  // 10. Numbers never stretch the grid: with digits rendered, all cells
  //     must stay uniform (1fr tracks are minmax(0,1fr); an `auto` min-
  //     content would let a digit grow its row/column).
  const geometry = await page.evaluate(() => {
    const g = window.minesweeperDx;
    const world = g.board.game.world;
    for (const t of world.tiles) t.hasMine = false;
    world.tileAt(5, 5).hasMine = true;
    for (const [x, y] of [[4,4],[5,4],[6,4],[4,5],[6,5],[4,6],[5,6],[6,6]])
      world.reveal(x, y, { flood: false });
    g.board.refresh();
    const heights = new Set();
    const widths = new Set();
    let digitCells = 0;
    for (const c of document.querySelectorAll('.cell')) {
      const b = c.getBoundingClientRect();
      heights.add(Math.round(b.height * 10) / 10);
      widths.add(Math.round(b.width * 10) / 10);
      if (c.textContent.trim()) digitCells++;
    }
    return { rowHeights: heights.size, colWidths: widths.size, digitCells };
  });
  assert.ok(geometry.digitCells > 0, 'digits on screen for geometry check');
  assert.equal(geometry.rowHeights, 1, 'all rows equal height with digits shown');
  assert.equal(geometry.colWidths, 1, 'all columns equal width with digits shown');

  // 10b. Fire-spread + shield-absorb regression (rules re-ruled 2026-10-27:
  //      spread fires on ODD per-board turns, up to 2 random candidates per
  //      burning tile; only fire landing ON the mine tile detonates —
  //      adjacency is harmless; the absorb bills ONE charge, extinguishes
  //      the fire over the mine, FLAGS the mine to lock it, and the rest of
  //      the wave commits).
  //      Fixture: fire at (5,5), mine at (6,5); reveal all neighbors except
  //      the mine and safe (4,5) → turn 1's wave ignites exactly those two.
  await page.evaluate(() => {
    const g = window.minesweeperDx;
    // A previous death check left the summary overlay up — drop it back to
    // the board so real clicks reach the cells (QA-only, mirrors the runtime
    // path a player takes on New run).
    g.phase = 'running';
    g.showScreen('running');
    const Ctor = Object.getPrototypeOf(g.board.game).constructor;
    g.board.game = new Ctor(0, undefined, 5);
    g.board.rebuild();
    const eng = g.board.game;
    for (const t of eng.world.tiles) t.hasMine = false;
    eng.world.tileAt(6, 5).hasMine = true;
    for (const [x, y] of [[4,4],[5,4],[6,4],[4,6],[5,6],[6,6]])
      eng.world.reveal(x, y, { flood: false }); // leave (4,5)+(6,5) hidden
    eng.spawnFire(5, 5);
    eng.shieldCharges = 2;
    g.board.refresh();
    g.sync(eng);
  });
  // one real right-click toggle → effective turn 1 (odd: the wave lands)
  await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
  const absorb = await page.evaluate(() => {
    const eng = window.minesweeperDx.board.game;
    return {
      status: document.querySelector('#status').textContent,
      chargeWarn: document.querySelector('#status').className.includes('warn'),
      explosion: eng.world.explosion,
      dead: eng.isDead(),
      charges: eng.shieldCharges,
      onMine: eng.fireAt(6, 5), // fire OVER the mine: must be extinguished
      mineAlive: eng.world.tileAt(6, 5).hasMine,
      survivor: eng.fireAt(4, 5), // the rest of the wave committed and burns
    };
  });
  assert.match(absorb.status, /Shield charge smothered a burning mine/, 'absorb surfaces a status line');
  assert.ok(absorb.chargeWarn, 'absorb status uses the warn tone');
  assert.equal(absorb.explosion, null, 'charge consumed the detonation');
  assert.equal(absorb.dead, false, 'run continues');
  assert.equal(absorb.charges, 1, 'exactly ONE charge billed');
  assert.equal(absorb.onMine, false, 'the fire over the mine tile was extinguished');
  assert.ok(absorb.mineAlive, 'the mine survives the absorb');
  assert.ok(absorb.survivor, 'the non-lethal part of the wave ignited (no discarded spread)');
  // turn 2 (even): no spread — the surviving fire touching the mine bills
  // NOTHING (adjacency is harmless under the current ruling)
  await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
  const settle = await page.evaluate(() => {
    const eng = window.minesweeperDx.board.game;
    return { charges: eng.shieldCharges, explosion: eng.world.explosion, fire: eng.fireCount() };
  });
  assert.equal(settle.charges, 1, 'even turns: no spread, no drain, no re-detonation');
  assert.equal(settle.explosion, null, 'no phantom re-detonation from adjacency');
  assert.equal(settle.fire, 2, 'fire holds at {(5,5),(4,5)} across an even turn');
  // turn 3 (odd): the absorb FLAGGED the saved mine (ruling 2026-10-27: the
  // absorb locks the tile like a normal flag). A flagged tile is not a
  // spread candidate — the mine cannot re-ignite and the second charge is
  // never touched.
  await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
  const lock = await page.evaluate(() => {
    const eng = window.minesweeperDx.board.game;
    return {
      charges: eng.shieldCharges,
      explosion: eng.world.explosion,
      dead: eng.isDead(),
      onMine: eng.fireAt(6, 5),
      flag: eng.world.tileAt(6, 5).state,
    };
  });
  assert.equal(lock.flag, 'FLAGGED', 'the absorb flagged the mine — tile locked');
  assert.equal(lock.charges, 1, 'no fresh bill: flag-locked mine is no spread candidate');
  assert.equal(lock.explosion, null, 'no re-detonation against the flagged mine');
  assert.equal(lock.dead, false, 'still alive');
  assert.equal(lock.onMine, false, 'no fire re-lands on the flagged mine');
  // turn 4 (even): fire keeps burning and expanding from the survivors
  await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
  const burn = await page.evaluate(() => {
    const eng = window.minesweeperDx.board.game;
    return { charges: eng.shieldCharges, explosion: eng.world.explosion, fire: eng.fireCount() };
  });
  assert.equal(burn.charges, 1, 'the saved second charge stands untouched; even turns bill nothing');
  assert.equal(burn.explosion, null);
  assert.ok(burn.fire >= 4, 'fire kept spreading on odd turns (stationary-fire regression)');

  // 10c. Mine-on-mine shift-collision shield absorb (ruling 2026-10-27): a
  //      shift collision must be absorbable like any other mine hit — one
  //      charge billed, the collision tile FLAGGED (anchor rule freezes the
  //      chain, so it can never re-collide), run survives, status line shows.
  //      Fixture: depth-6 board, mines at (3,3)+(2,3), (4,3) flagged so the
  //      shift east strands (3,3) and (2,3) would land on it.
  await page.evaluate(() => {
    const g = window.minesweeperDx;
    g.phase = 'running';
    g.showScreen('running');
    g.shifterIntroShown = true; // modal pinned in 10d; keep clicks flowing
    document.querySelector('#shifterIntro').hidden = true;
    const Ctor = Object.getPrototypeOf(g.board.game).constructor;
    g.board.game = new Ctor(6, undefined, 5);
    g.board.rebuild();
    const eng = g.board.game;
    for (const t of eng.world.tiles) t.hasMine = false;
    eng.world.tileAt(3, 3).hasMine = true;
    eng.world.tileAt(2, 3).hasMine = true;
    eng.world.flag(4, 3);
    eng.telegraph = [1, 0];
    eng.nextShiftIn = 1;
    eng.shieldCharges = 1;
    g.board.refresh();
    g.sync(eng);
  });
  // one real right-click toggle → commit → shift fires → collision → absorb
  await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
  const boom = await page.evaluate(() => {
    const eng = window.minesweeperDx.board.game;
    return {
      status: document.querySelector('#status').textContent,
      explosion: eng.world.explosion,
      dead: eng.isDead(),
      charges: eng.shieldCharges,
      flag: eng.world.tileAt(3, 3).state,
      mine: eng.world.tileAt(3, 3).hasMine,
      mover: eng.world.tileAt(2, 3).hasMine,
    };
  });
  assert.match(boom.status, /Shield charge absorbed a mine collision/, 'collision absorb surfaces its status line');
  assert.equal(boom.explosion, null, 'charge consumed the collision');
  assert.equal(boom.dead, false, 'the run survives the collision');
  assert.equal(boom.charges, 0, 'exactly ONE charge billed');
  assert.equal(boom.flag, 'FLAGGED', 'the absorb flagged the collision tile');
  assert.ok(boom.mine && boom.mover, 'both mines survive; the aborted shift never stacked');
  // next shift: the flagged tile anchors the chain — no repeat collision and
  // no phantom drain even with zero charges left.
  await page.evaluate(() => { window.minesweeperDx.board.game.nextShiftIn = 1; });
  await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
  const anchor = await page.evaluate(() => {
    const eng = window.minesweeperDx.board.game;
    return { explosion: eng.world.explosion, dead: eng.isDead(), charges: eng.shieldCharges };
  });
  assert.equal(anchor.explosion, null, 'flag-anchored chain never re-collides');
  assert.equal(anchor.dead, false, 'still alive with no charges left');
  assert.equal(anchor.charges, 0, 'no silent charge drain');

  // 10d. Shifter telegraph feedback (ruling 2026-10-28): the arm/shift/hold
  //      beats must be legible ON the board — wind overlay + depth-6 intro
  //      banner while armed, a final-3-turns warning beat, a count-pulse
  //      afterimage once the shift lands, and an ice-blue freeze readout
  //      while the Stabilizer holds. Fixture: depth-6 board, telegraph west,
  //      one mine NW of a revealed tile (its digit dies when the mine drifts
  //      away), countdown set mid-cycle.
  await page.evaluate(() => {
    const g = window.minesweeperDx;
    g.phase = 'running';
    g.showScreen('running');
    g.shifterIntroShown = false; // simulate a fresh run reaching depth 6
    document.querySelector('#shifterIntro').hidden = true;
    const Ctor = Object.getPrototypeOf(g.board.game).constructor;
    g.board.game = new Ctor(6, undefined, 9);
    g.board.rebuild();
    const eng = g.board.game;
    for (const t of eng.world.tiles) t.hasMine = false;
    eng.world.tileAt(2, 2).hasMine = true;
    eng.world.tileAt(8, 8).hasMine = true;
    eng.world.reveal(3, 3, { flood: false }); // digit '1' from (2,2)
    eng.telegraph = [-1, 0];                  // mines drift west
    eng.nextShiftIn = 3;                      // the next step enters the warning band
    eng.stabilizedFor = 0;
    g.board.refresh();
    g.sync(eng);
  });
  const armed = await page.evaluate(() => {
    const app = window.minesweeperDx;
    return {
      wind: app.fx.telegraphWind,
      banner: !document.querySelector('#shifterIntro').hidden,
      title: document.querySelector('#shifterIntroTitle').textContent,
      body: document.querySelector('#shifterIntro .intro-panel span')
        .textContent.replace(/\s+/g, ' ').trim(),
    };
  });
  assert.ok(
    armed.wind && armed.wind.dx === -1 && armed.wind.dy === 0 && armed.wind.frozen === false,
    'wind overlay armed in the telegraph direction',
  );
  assert.ok(armed.banner, 'depth-6 intro modal shows when the Shifter first arms');
  // Ratified modal copy (ruling 2026-10-28 follow-up): pin the exact strings
  // so a future rewrite that quietly drops the wrap warning fails loudly.
  assert.equal(armed.title, 'You sense a tremble beneath the earth', 'modal heading is the ratified line');
  assert.equal(
    armed.body,
    'The mines are shifting. Watch the telegraphed movement \u2014 ' +
    "and know that your actions aren't the only thing that wraps around the board...", 
    'modal body is the ratified (polished) line',
  );
  // BLOCKING ack (ruling follow-up 2026-10-28): the run continues only past
  // the acknowledge button — and only then does the backdrop release the board.
  await page.locator('#shifterIntroOk').click();
  const acked = await page.evaluate(() => ({
    hidden: document.querySelector('#shifterIntro').hidden,
  }));
  assert.ok(acked.hidden, 'intro modal closes only on acknowledgement');
  // one real right-click → countdown 3\u21922 enters the final-3 band \u2192 warning beat
  await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
  const warn = await page.evaluate(() => {
    const s = document.querySelector('#status');
    return { status: s.textContent, tone: s.className.includes('warn') };
  });
  assert.match(warn.status, /Mines drift \u2190 in 2 turns/, 'final-3-turn warning beat sets a status line');
  assert.ok(warn.tone, 'warning beat uses the warn tone');
  // set the shift itself up: one mine drifts out of (3,3)'s neighborhood \u2192 digit dies
  await page.evaluate(() => { window.minesweeperDx.board.game.nextShiftIn = 1; });
  await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
  const pulse = await page.evaluate(() => ({
    pulsing: document.querySelectorAll('.cell-count-pulse').length,
    digit: document.querySelector('.cell[data-x="3"][data-y="3"]').textContent,
    // B1 restyle pin (audit 2026-10-28): the glow must ride a pseudo-element
    // overlay animating opacity — a box-shadow/background-color animation on
    // the cell itself is paint-only and reopens the paint-storm.
    overlay: (() => {
      const el = document.querySelector('.cell-count-pulse');
      if (!el) return 'none';
      const cs = getComputedStyle(el, '::before');
      return cs.animationName === 'cell-count-pulse' && cs.backgroundColor !== 'rgba(0, 0, 0, 0)'
        ? 'composited'
        : 'paint-only';
    })(),
    cap: window.countPulseCap,
  }));
  assert.ok(pulse.pulsing >= 1, 'changed proximity digits pulse after the shift lands');
  assert.equal(pulse.digit, '', 'the drifted-away mine\u2019s number cleared legibly');
  assert.equal(pulse.overlay, 'composited', 'count-pulse rides a compositable ::before overlay (B1)');
  assert.ok(
    pulse.cap > 0 && pulse.pulsing <= pulse.cap,
    `count-pulse storm is capped: ${pulse.pulsing} <= window.countPulseCap=${pulse.cap} (B1)`,
  );
  // D4 (audit 2026-10-28): ONE delegated animationend strips the class when
  // the pulse ends — no per-cell listener churn. After the 620ms window,
  // zero cells may still carry the class.
  await page.waitForTimeout(750);
  const stripped = await page.evaluate(() => document.querySelectorAll('.cell-count-pulse').length);
  assert.equal(stripped, 0, 'pulsing cells self-strip after the animation (delegated listener, D4)');
  // Stabilizer hold reads on the wind layer (G): freeze + ice tint, chip agrees
  const frozen = await page.evaluate(() => {
    const app = window.minesweeperDx;
    app.board.game.stabilizedFor = 4;
    app.sync(app.board.game);
    const w = app.fx.telegraphWind;
    return { frozen: w ? w.frozen : null, chip: document.querySelector('#shiftIn').textContent };
  });
  assert.equal(frozen.frozen, true, 'Stabilizer hold freezes the wind overlay readout');
  assert.match(frozen.chip, /held 4/, 'HUD chip still names the hold');

  // T2 hardening (audit 2026-10-28): one exception escaping draw() must halt
  // the rAF loop AND clear the handle, and a later fx action must re-arm it —
  // juice may never be dead-for-the-run from a dangling non-zero raf id.
  await page.evaluate(() => {
    const fx = window.minesweeperDx.fx;
    fx.draw = () => { throw new Error('qa forced draw throw'); }; // live wind loop hits it within a frame
  });
  await page.waitForTimeout(150);
  const rafAfterThrow = await page.evaluate(() => window.minesweeperDx.fx.raf);
  assert.equal(rafAfterThrow, 0, 'draw exception halts the fx loop and clears the raf handle (T2)');
  // While halted, wind must NOT keep repainting (proves the chain is really dead):
  const haltedSig = await page.evaluate(async () => {
    const c = document.querySelector('#fx');
    const sig = () => { const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; let s = 0; for (let i = 3; i < d.length; i += 4004) s += d[i]; return s; };
    const a = sig();
    await new Promise((r) => setTimeout(r, 200));
    return { a, b: sig() };
  });
  assert.equal(haltedSig.a, haltedSig.b, 'halted loop leaves the canvas frozen (chain dead, not spinning)');
  await page.evaluate(() => { delete window.minesweeperDx.fx.draw; });
  await page.evaluate(() => {
    const app = window.minesweeperDx;
    const w = app.fx.telegraphWind; // QA-only re-arm, never game logic
    if (w) app.fx.setTelegraph({ dx: w.dx, dy: w.dy, urgency: w.urgency, frozen: w.frozen });
  });
  await page.waitForTimeout(150);
  const rearmed = await page.evaluate(() => {
    const fx = window.minesweeperDx.fx;
    return { raf: fx.raf, wind: fx.telegraphWind !== null };
  });
  assert.notEqual(rearmed.raf, 0, 'next fx action re-arms the loop after a draw exception (T2)');
  assert.equal(rearmed.wind, true, 'wind layer reads again after exception recovery (T2)');

  // T4 hardening (audit 2026-10-28): canvas context loss must pause painting
  // (stale pixels, not a dead loop), and restore must repaint without reload.
  const ctxLoss = await page.evaluate(async () => {
    const fx = window.minesweeperDx.fx;
    const c = document.querySelector('#fx');
    const sig = () => { const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; let s = 0; for (let i = 3; i < d.length; i += 32) s += d[i]; return s; };
    c.dispatchEvent(new Event('contextlost', { cancelable: true }));
    const dropped = fx.ctx2d === null;
    await new Promise((r) => setTimeout(r, 250));
    const a = sig();
    await new Promise((r) => setTimeout(r, 250));
    const b = sig();
    c.dispatchEvent(new Event('contextrestored'));
    await new Promise((r) => setTimeout(r, 300));
    return { dropped, frozenWithPixels: a > 0 && a === b, restored: fx.ctx2d !== null, sigAfter: sig() };
  });
  assert.equal(ctxLoss.dropped, true, 'contextlost drops ctx2d + dead-context gradient caches (T4)');
  assert.equal(ctxLoss.frozenWithPixels, true, 'painting pauses with the last frame intact while context is lost (T4)');
  assert.equal(ctxLoss.restored, true, 'contextrestored re-acquires the 2D context (T4)');
  assert.ok(ctxLoss.sigAfter > 0, 'wind repaints after context restore without a page reload (T4)');

  // Wrap cue (ruling wrap_cue_ruling_2026-10-28): static amber portal frame
  // on the board pseudo — four chevron SVGs + eight bracket arms, all pinned
  // with edge-keyword offsets (calc(100% - Npx) in background-position
  // DETACHES by Npx: the % anchors against box-minus-image; that was the
  // NE/SE/SW corner break), and the border stays solid (dashed seam was
  // player-rejected).
  const wrapCue = await page.evaluate(() => {
    const b = document.querySelector('.board');
    const cs = getComputedStyle(b, '::after');
    const pos = cs.backgroundPosition.split(/,\s(?![^(]*\))/).map((s) => s.trim());
    const arms = pos.slice(0, 8);
    const arrows = pos.slice(8);
    return {
      urls: (cs.backgroundImage.match(/url\(/g) ?? []).length,
      grads: (cs.backgroundImage.match(/linear-gradient/g) ?? []).length,
      posCount: pos.length,
      armsFlush: arms.every((p) => /^((0(px)?|100%)){1,2}(\s((0(px)?|100%)){1,2})?$/.test(p.replace(/\s+/g, ' '))),
      arrowsInset: arrows.filter((p) => p.includes('6px')).length,
      arrowCalc: arrows.filter((p) => p.includes('calc(100% - 6px)')).length,
      border: getComputedStyle(b).borderStyle.split(' ')[0],
    };
  });
  assert.equal(wrapCue.urls, 4, 'wrap cue keeps four portal chevron SVGs');
  assert.equal(wrapCue.grads, 8, 'wrap cue keeps eight corner-bracket arms');
  assert.equal(wrapCue.posCount, 12, 'wrap cue keeps 12 background layers (8 arms + 4 arrows)');
  assert.ok(wrapCue.armsFlush, 'corner-bracket arms ride flush 0/100% anchors (calc-100% corners detach: NE/SE/SW break)');
  assert.equal(wrapCue.arrowsInset, 4, 'all four portal arrows pinned at the same 6px inset from the frame');
  assert.equal(wrapCue.arrowCalc, 2, 'E/S arrows serialize as calc(100% - 6px) (edge-keyword offsets, not box-%)');
  assert.equal(wrapCue.border, 'solid', 'board border stays solid (dashed seam rejected by player)');

  // 11. Still zero runtime errors after the whole playthrough.
  assert.equal(errors.length, 0, `runtime errors: ${errors.join('; ')}`);

  console.log('browser smoke: 38/38 checks passed (boot→menu, start run, name-gate-absent-bare-host, click flood, no-op-clicks-no-turn, flag+rejected-no-turn, mobile-longpress-flag-survives-release, locked-abilities, radar, seeker-one-turn, decision-pays-once, no-double-credit, depth-scaled-payout, bank-safe-exit, workshop-purchases+flow-cap-lv1+hud-ceiling, fresh-run-reset, death-summary+vault-safe, menu-stats, flag-regression, first-click-safe+second-click-lethal, hazards+telegraph, responsive, menu-actions-stacked-no-clip, field-manual-tabs, proximity numbers live-update, digits do not stretch grid, shield-absorb-visible+fire-burns-on, collision-absorb-anchors-chain, shifter-wind+intro-ack-required, shift-warning-beat, count-pulse-afterimage, stabilizer-freeze-readout, raf-exception-recovery, context-loss-recovery, wrap-cue-pseudo-layer, leaderboard-degrades-on-bare-host, error-free)');
} finally {
  await browser.close();
  await service.close();
}