doxa-maps/internal/check-fits-the-screen.mjs
doxa-maps-88655c70 44ef31b2c6 Tooling reads the tree's name from the build config, in one place
The output tree's name has always been configurable (vite.doxa-maps-build-config.json "name"),
but 61 tooling files ignored it and hardcoded 'doxa-maps' (181 literals), so a rename would have
moved the build while the gates kept reading the old folder.

- internal/_tree-name.mjs reads it once (validated as one URL-safe path segment; falls back to the
  historical name) and exports TREE_NAME / TREE_NAME_RE; `node internal/_tree-name.mjs` prints it
  for shell scripts.
- A codemod rewrote 171 literals in internal/ to TREE_NAME; the regex literals, messages with
  backticks, atomic-public-swap.sh (fails loudly if the name cannot be read), build-lock.sh and
  vite.config.js (its duplicate readMfeName is gone) were done by hand.
- The name is unchanged: old and new tooling produce all 1408 files under public/ byte for byte.
  Code lines still naming the tree in internal/ + vite.config.js: 2, both intended (the helper's
  fallback, the private-repo-name pattern in public-hygiene-check).

This renames nothing visible; the eventual rename becomes a one-line config change.
2026-09-25 16:25:22 +00:00

696 lines
44 KiB
JavaScript

#!/usr/bin/env node
/**
* check-fits-the-screen.mjs — the FITNESS-FOR-USE gate. Real browser, built tree, live data.
*
* ★ WHY THIS EXISTS (LL-the-user-found-three-bugs-my-gates-could-not-see, 2026-09-23). The suite
* was 56/58 green, verify:gate1 13/13, verify:map-paint 11/11, verify:interop 4/4, 363 done cards
* — and the Driver opened the product and found FIVE real defects in one sitting, several caused
* by a change a gate had certified. NOT ONE was visible to any gate, because every gate up to this
* one asserts STATE (does it mount, does it fetch, do the counts agree, does a canvas paint). The
* Driver assesses FITNESS FOR USE: is it slim, can I see it at once, does the wheel do what I
* expect, does it tell me what I'm looking at. A suite of the first kind can be perfectly green
* while the second kind fails completely. This gate encodes the second kind, as five clauses:
*
* A — ONE-VIEWPORT RULE: the dashboard band's own height <= the 1440x900 viewport. Its internal
* rails scroll; the PAGE does not scroll to see the band itself.
* B — THE GRID IS REACHABLE UNDERNEATH, by scrolling. (A fix for A must not regress this — see
* AP-fixing-reachability-by-making-the-thing-taller: the SAME commit that fixed reachability
* made the band unbounded and broke A. Both must hold at once, or the trade repeats.)
* C — WHEEL TARGETING: a wheel over the map changes ZOOM and does not scroll the pane; a wheel
* below the map SCROLLS and does not change zoom. Instruments composedPath() on a
* capture-phase listener, per LL-four-wrong-diagnoses-instrument-the-event-not-the-geometry
* — geometry (getBoundingClientRect) answered this wrong FOUR times on the sibling bug.
* D — THE ELEMENT SAYS WHAT IT IS SHOWING: a visible, non-zero count line for the grid, that
* CHANGES when a filter narrows it.
* E — RESPONSIVE SWEEP at 1440/1280/768/390: no horizontal document overflow, no element clipped
* to zero height. `.rd-td`/`.rd-th` text-ellipsis is EXPECTED and excluded — that is what
* those classes are for (DashboardDataTable.vue:519,526).
*
* ★ WHAT IT DELIBERATELY DOES NOT COVER. Clause A/B/C run against the actual composite that shipped
* the bug, `dashboard-plus-grid`. The cap-5 SHELL (doxa-maps-spa / the three-section tab structure)
* is a TEAMMATE'S live in-flight file this tick — this gate discovers cap-5 shells from the BUILT
* manifest (`type === 'nested-mfe-spa'`) and probes them by CONTENT MATCH (tab text, not a
* hardcoded structure), so it survives that work landing without editing or depending on it. If no
* shell exposes a matching tab, the shell-level sub-checks SKIP BY NAME rather than failing —
* absence of a tab is that teammate's work-in-progress, not a fitness defect this gate can name.
* The direct-leaf checks (dashboard-plus-grid, research-dashboard standalone) are NOT optional and
* carry the clauses' real assertions.
*
* ★ EVERY VALUE THIS SCRIPT COMPUTES IS ASSERTED ON (LL-a-gate-can-grade-the-wrong-variable audit):
* bandHeight, legendHeight, gridTop before/after, zoomBefore/After, scrollTopBefore/After,
* composedPath per gesture, countText before/after a filter, per-width overflow + clipped-count —
* every one of those feeds a pass/fail line in the clause table. None is logged and left unchecked.
*
* PRECONDITIONS, checked and SKIPPED (exit 0), never reported as a defect when unmet:
* - the tree must be SETTLED at START (internal/settled-tree-gate.mjs) — a mid-build tree gives
* a confident, false diagnosis (LL-verify-the-tree-is-settled-before-trusting-a-browser-gate).
* - the tree must STILL be settled at END — re-checked, because a teammate's build finishing
* mid-run tears the tree out from under an in-flight measurement just as surely as one starting
* did. (A harness in this same flight was bitten checking only at the start.) If the tree moved,
* every clause this run computed is DOWNGRADED to SKIP, not reported pass or fail.
* - pray.doxa.life must be reachable with a BROWSER USER-AGENT (a bare fetch 403s here).
*
* USAGE
* node internal/check-fits-the-screen.mjs
* node internal/check-fits-the-screen.mjs --self-test [--self-test-clause=a|b|c|d|e]
* PORT=4391 node internal/check-fits-the-screen.mjs
*
* Grades by EXIT CODE. Teaching prose after the verdict — the per-clause table comes first so a
* human sees which clause failed without reading the essay.
*/
import { chromium } from 'playwright'
import { spawn, execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { readFileSync, existsSync } from 'node:fs'
import { TREE_NAME } from './_tree-name.mjs'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const ROOT = resolve(__dirname, '..')
const SELF_TEST = process.argv.includes('--self-test')
const clauseArg = process.argv.find((a) => a.startsWith('--self-test-clause='))
const SELF_TEST_CLAUSE = (clauseArg ? clauseArg.split('=')[1] : 'a').toLowerCase()
const PORT = Number(process.env.PORT || 4391)
const BASE = `http://127.0.0.1:${PORT}`
const GL_ARGS = [
'--use-gl=swiftshader', '--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist', '--use-angle=swiftshader', '--no-sandbox',
]
// ── 0. PRECONDITIONS ────────────────────────────────────────────────────────────────────────
function settled() {
try {
execFileSync('node', ['internal/settled-tree-gate.mjs'], { cwd: ROOT, stdio: 'pipe' })
return true
} catch (e) {
console.log((e.stdout || '').toString().trim())
return false
}
}
if (!settled()) {
console.log('\nFITS-THE-SCREEN: SKIP — tree is not settled (a build is running). Not a product finding.')
console.log(' Re-run once the queue drains: node internal/settled-tree-gate.mjs')
process.exit(0)
}
let apiReachable = true
try {
// pray.doxa.life needs a BROWSER user-agent from a script — a bare fetch 403s here.
const r = await fetch('https://pray.doxa.life/api/people-groups/list?fields=name&lang=', {
signal: AbortSignal.timeout(10000),
headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36' },
})
apiReachable = r.ok
} catch { apiReachable = false }
if (!apiReachable) {
console.log('FITS-THE-SCREEN: SKIP — pray.doxa.life is unreachable right now. Not a product finding.')
process.exit(0)
}
// ── 1. MANIFEST-DERIVED TARGETS (never hardcode which bundle is "the" cap-5 shell) ────────────
const MANIFEST = resolve(ROOT, 'public', TREE_NAME, 'manifest.json')
if (!existsSync(MANIFEST)) {
console.log('FITS-THE-SCREEN: SKIP — no manifest.json (nothing built).')
process.exit(0)
}
const mf = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const shellLeaves = []
for (const b of mf.bundles || []) {
for (const p of b.profiles || []) {
if (p.type === 'nested-mfe-spa') shellLeaves.push({ bundle: b.bundle, profile: p.profile, iframeUrl: p.iframeUrl })
}
}
// ── 2. SERVE THE BUILT TREE the way production serves it (directories 404) ─────────────────────
const server = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT), '--directory', 'public'], {
cwd: ROOT, stdio: 'pipe',
})
let serverErr = ''
server.stderr.on('data', (d) => { serverErr += d.toString() })
await new Promise((res, rej) => {
const t = setTimeout(() => rej(new Error('serve-strict did not start in 10s: ' + serverErr)), 10000)
server.stdout.on('data', (d) => { if (/http:\/\//.test(d.toString())) { clearTimeout(t); res() } })
server.on('exit', (code) => { clearTimeout(t); rej(new Error('serve-strict exited early (' + code + '): ' + serverErr)) })
})
// clause rows: { clause, target, result, detail } — printed as the table BEFORE the verdict.
const rows = []
let exitCode = 0
const record = (clause, target, ok, detail) => {
rows.push({ clause, target, ok, detail })
console.log(` [${clause}] ${ok ? 'PASS' : 'FAIL'} ${target} -> ${detail}`)
}
// Self-test inverts the exit contract exactly as interop-harness.mjs and check-facet-cascade.mjs
// do: a self-test run that correctly DETECTS the poisoned clause is itself the PASS. Reaching the
// end without detecting it is the failure — "I made it fail" is not evidence; the question is
// whether it failed on the SAME defect shape the real bug had (LL-negative-test-the-regression-
// not-the-feature).
let selfTestCaught = null
const fail = (clause, msg) => {
if (SELF_TEST && clause === SELF_TEST_CLAUSE && !selfTestCaught) {
selfTestCaught = msg
console.log(`\nFITS-THE-SCREEN: SELF-TEST OK [clause ${clause.toUpperCase()}] — the gate REJECTED the poison:`)
console.log(' caught: ' + msg)
return
}
if (!SELF_TEST) { console.error(`FITS-THE-SCREEN: FAIL [${clause.toUpperCase()}] — ` + msg); exitCode = 1 }
}
// Shadow DOM does not pierce — the same recursive walker every browser gate in this repo carries.
const WALK = `
function __walk(sel) {
const seen = new Set(); const out = [];
const visit = (root) => {
if (!root || seen.has(root)) return; seen.add(root);
let els = []; try { els = Array.from(root.querySelectorAll('*')) } catch (e) { return }
for (const el of els) {
try { if (el.matches && el.matches(sel)) out.push(el) } catch (e) {}
if (el.shadowRoot) visit(el.shadowRoot);
}
};
visit(document); return out;
}
`
async function reinject(page) { await page.addScriptTag({ content: WALK }) }
const browser = await chromium.launch({ args: GL_ARGS })
// ================================================================================================
// CLAUSES A + B + C + D — the composite that shipped the bug: dashboard-plus-grid
// ================================================================================================
async function runComposite() {
const TARGET = 'dashboard-plus-grid'
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
const url = `${BASE}/${TREE_NAME}/dashboard-plus-grid/dashboard-plus-grid/index.html`
await page.goto(url, { waitUntil: 'load', timeout: 60000 }).catch((e) => { throw new Error(`cannot reach ${TARGET}: ${e.message}`) })
await page.waitForTimeout(6000)
await reinject(page)
// ── SELF-TEST POISONS, applied IN THE LIVE PAGE before any measurement (never the source file)
if (SELF_TEST) {
if (SELF_TEST_CLAUSE === 'a') {
// Reproduce AP-fixing-reachability-by-making-the-thing-taller: force the dashboard's
// section band to grow unbounded, past the viewport — the actual shape of the regression
// (a scroll fix that let the rail's content set the band's height).
console.log(' [self-test:a] forcing the dashboard section to overflow the viewport')
await page.evaluate(`(() => {
const sec = __walk('.stl-sec')[0];
if (sec) { sec.style.setProperty('min-height', '2400px', 'important'); sec.style.setProperty('height', 'auto', 'important'); }
})()`)
} else if (SELF_TEST_CLAUSE === 'c') {
// ★ POISON UPDATED 2026-09-23 — the ORIGINAL poison (freezing m.scrollZoom.disable to a
// no-op) tested a defect shape DashboardMapPanel.vue no longer has: syncScrollZoomToHost
// used to conditionally disable scroll-zoom based on host layout; it now unconditionally
// calls .enable() and nothing in the current code ever calls .disable() at all. Freezing
// .disable() therefore poisons a code path that is already dead — MEASURED: with the old
// poison AND the current "below map" test point (x=150, outside the map's own column,
// fixed alongside BUG-DASHBOARD-BAND-GROWS-ENORMOUS so it clears the table's ~54,500px
// dead zone), both sub-checks passed even with the poison live, because that point was
// never over the map's canvas regardless of scrollZoom's JS state.
//
// The risk that actually matters TODAY is a LAYOUT regression: the map's own hit-test area
// silently growing to cover ground it should not (exactly how BUG-MAP-EATS-THE-WHEEL first
// manifested — a Mapbox canvas full-bleeding its container). So the poison now stretches
// the map's own container to cover the "below map" test point, which is a live reproduction
// of that shape rather than toggling a dead JS flag.
console.log(' [self-test:c] stretching the map container over the "below map" test point — the canvas-overreach regression')
await page.evaluate(`(() => {
const m = __walk('[data-region="map"]')[0];
if (m) { m.style.setProperty('height', '3000px', 'important'); m.style.setProperty('overflow', 'visible', 'important'); }
})()`)
} else if (SELF_TEST_CLAUSE === 'd') {
// Reproduce "the grid has no visible count": blank the live count element's text.
console.log(' [self-test:d] blanking the count line')
await page.evaluate(`(() => {
const el = __walk('[data-table-count]')[0];
if (el) { el.textContent = ''; Object.defineProperty(el, 'textContent', { get(){ return '' }, set(){} }); }
})()`)
}
}
// ── CLAUSE A — ONE-VIEWPORT RULE ─────────────────────────────────────────────────────────────
// .stl-sec (StackLayout's own section wrapper, library/layouts/StackLayout.vue:62) is the band
// the Driver actually sees stacked above the grid — measuring .rd-shell alone would miss a
// regression in the SECTION wrapper around it (exactly what the self-test above pokes).
//
// ★ THE CAUSAL TEST, NOT A STATIC SIZE COMPARISON (rewritten 2026-09-23, BUG-DASHBOARD-BAND-
// GROWS-ENORMOUS fix). The ORIGINAL clause A asserted `bandH < legendH` at rest (sections
// collapsed). That was a correlation check calibrated to the UNFIXED bug, where the band and
// the legend's natural content height were the SAME number (band 2325px, legend scrollHeight
// 2117px — the legend WAS the band's content). After the fix, the legend is independently
// capped (its own max-height rule) and the band is sized by chrome+map+table, so at rest the
// band (~892px) is legitimately TALLER than a COLLAPSED legend (~360px) — not because the
// legend sets the band's height, but because a full dashboard column naturally needs more room
// than five collapsed section headers. `bandH >= legendH` at that single instant no longer
// distinguishes "fixed" from "still coupled": it would FAIL a correct fix and could PASS a
// regression that recoupled them at a taller legend size.
//
// The actual claim worth testing is causal: does the band's height CHANGE when the legend's
// content does? Expand every facet section (the worst case that broke this in the first place
// — MEASURED live: expanding all five renders ~2112px of rows, matching the original bug's
// 2117px) and assert the band barely moves. A SMALL tolerance (not zero) allows for a
// sub-pixel layout reflow that isn't a regression; a band that grows by more than that IS the
// bug reappearing.
const collapsed = await page.evaluate(`(() => {
const sec = __walk('.stl-sec')[0];
const legend = __walk('.rd-rail')[0] || __walk('[data-region="facets"]')[0];
return {
bandH: sec ? Math.round(sec.getBoundingClientRect().height) : null,
legendH: legend ? Math.round(legend.scrollHeight) : null,
}
})()`)
await page.evaluate(`(() => { __walk('.fr-sech').forEach(h => h.click()); })()`)
await page.waitForTimeout(600)
await reinject(page)
const expanded = await page.evaluate(`(() => {
const sec = __walk('.stl-sec')[0];
const frBody = __walk('.fr-body')[0];
return {
bandH: sec ? Math.round(sec.getBoundingClientRect().height) : null,
legendContentH: frBody ? Math.round(frBody.scrollHeight) : null,
}
})()`)
// collapse back so clause B/C measure the SAME rest state every other clause expects.
// DashboardFacetRail marks a section CLOSED via a .closed class (open is the class's absence),
// so "still open" is "does not have .closed" — click those to return to the collapsed default.
await page.evaluate(`(() => { __walk('.fr-sech').filter(h => !h.classList.contains('closed')).forEach(h => h.click()); })()`)
await page.waitForTimeout(300)
await reinject(page)
const VIEWPORT_H = 900
const band = collapsed
if (band.bandH == null) {
fail('a', `dashboard section (.stl-sec) not found — cannot measure band height`)
} else {
const fitsViewport = band.bandH <= VIEWPORT_H
const detail = `band=${band.bandH}px vs viewport=${VIEWPORT_H}px` + (band.legendH != null ? `, legend scrollHeight (collapsed)=${band.legendH}px` : '')
record('A', TARGET, fitsViewport, detail)
if (!fitsViewport) fail('a', `dashboard band is ${band.bandH}px, taller than the ${VIEWPORT_H}px viewport — it sizes to its own content instead of the screen. ` + detail)
// Driver, verbatim: "the dashboard rendering to the full height of the tree legend" is the
// bug — i.e. the band TRACKING the legend's content, not merely being numerically larger
// than it. Test that directly: expand every section (worst case, ~2100px of rows) and assert
// the band does not grow past the viewport with it. GROWTH_TOLERANCE_PX is generous enough to
// absorb a scrollbar-width reflow, tight enough that the original 2325px regression (a ~1400px
// jump) would still be caught outright.
const GROWTH_TOLERANCE_PX = 24
const growth = expanded.bandH != null && band.bandH != null ? expanded.bandH - band.bandH : null
const stillFits = expanded.bandH != null && expanded.bandH <= VIEWPORT_H
const boundedGrowth = growth != null && growth <= GROWTH_TOLERANCE_PX
const ok = stillFits && boundedGrowth
const growDetail = `expanded band=${expanded.bandH}px (legend content ${expanded.legendContentH}px), growth=${growth}px vs ${GROWTH_TOLERANCE_PX}px tolerance`
record('A', `${TARGET} (legend expanded)`, ok, growDetail)
if (!ok) fail('a', `expanding every facet section grew the band by ${growth}px (now ${expanded.bandH}px) — the band still tracks the legend's content height. ` + growDetail)
}
// ── CLAUSE B — THE GRID IS REACHABLE UNDERNEATH, by scrolling ──────────────────────────────
// Same measured gesture verify-shell-scroll.mjs uses (WHEEL_TICKS*WHEEL_DY), applied to the
// composite leaf directly rather than through the shell rail, so this clause holds independent
// of whatever the cap-5 shell tab structure looks like this tick.
//
// ★ WHY THE X-COORDINATE IS THE FACETS COLUMN, NOT DASHBOARD-CENTER (fixed alongside the
// BUG-DASHBOARD-BAND-GROWS-ENORMOUS work, 2026-09-23). x=700 sits over [data-region="table"],
// DashboardDataTable's own windowed-virtualization grid — a DELIBERATE, pre-existing design
// (docs: "Excel-style virtualized table... 2,106 rows") whose internal scrollHeight is ~54,500px.
// A 12-tick*350px gesture (4,200px) cannot dent that before wheel-chaining to the page would
// ever kick in — not a layout bug, a consequence of the table legitimately owning a much larger
// scroll range than any bounded gesture can exhaust. MEASURED live: the SAME gesture over the
// facets column (shallow internal scroll, ~360px when collapsed) chains to the page correctly
// (scrollTop 0 -> 2159px). Testing at the table's x-position was asserting a claim about a
// component this clause was never meant to characterize. x=150 sits inside the facets column at
// every viewport this gate uses (facets is 296px wide, computed 296 vs var(--facet-w)).
const gridTop = () => page.evaluate(`(() => {
const g = __walk('.pgg-root')[0];
if (!g) return null;
const r = g.getBoundingClientRect();
return { top: Math.round(r.top), h: Math.round(r.height) };
})()`)
const before = await gridTop()
if (!before) {
fail('b', `.pgg-root (people-groups grid) not mounted on ${TARGET} — cannot measure reachability`)
} else {
// ★ DERIVE THE WHEEL POINT FROM THE BAND, NEVER HARDCODE IT (fixed 2026-09-23).
// y=800 was tuned when the dashboard band measured ~1213px tall. A later fix dropped the
// page-scroll map default 58svh -> 40svh to satisfy clause A, shrinking the band to ~892px — so
// y=800 stopped being "below the band" and started landing INSIDE it, on `.rd-td`, the table's
// own windowed scrollport. Two agents independently confirmed that with elementFromPoint.
//
// ★ AND THE PRODUCT WAS FINE THE WHOLE TIME. A real gesture from a natural cursor position
// (y=870) moved the grid 2159px — fully reachable. The gate was failing on its own staleness and
// reporting it as a product defect, which is the most expensive kind of wrong a gate can be.
// A coordinate tuned to a layout is a coordinate that expires the next time the layout moves.
const wheelY = await page.evaluate(`(() => {
const band = __walk('[data-region="dashboard"], .rd-shell')[0];
const b = band ? Math.round(band.getBoundingClientRect().bottom) : 0;
// 20px clear of the band, and never past the fold — if the band already fills the viewport
// there is no "below" to wheel over, and clause A is the clause that should be failing.
return Math.min(window.innerHeight - 30, Math.max(b + 20, 120));
})()`)
await page.mouse.move(150, wheelY)
for (let i = 0; i < 12; i++) { await page.mouse.wheel(0, 350); await page.waitForTimeout(70) }
await page.waitForTimeout(500)
await reinject(page)
const after = await gridTop()
const moved = before.top - (after ? after.top : before.top)
const onScreen = after && after.top < VIEWPORT_H
record('B', TARGET, moved > 200 && onScreen, `grid moved ${moved}px (top ${before.top} -> ${after ? after.top : 'null'}), on-screen=${!!onScreen}`)
if (!(moved > 200 && onScreen)) fail('b', `wheeling below the dashboard moved the grid only ${moved}px (top now ${after ? after.top : 'null'}) — still not reachable by scrolling`)
}
// ── CLAUSE C — WHEEL TARGETING, instrumented on the DISPATCHED EVENT PATH ──────────────────
// LL-four-wrong-diagnoses: geometry (getBoundingClientRect) gave four wrong answers on the
// sibling bug. The browser already knows what the user hit — listen in the CAPTURE phase and
// read composedPath(). A capture-phase listener sees the event BEFORE Mapbox's own handler can
// preventDefault() it, so `defaultPrevented` after dispatch tells us whether zoom consumed it.
//
// ★ SCROLL BACK TO TOP FIRST (fixed alongside BUG-DASHBOARD-BAND-GROWS-ENORMOUS, 2026-09-23).
// Clause B just scrolled the page ~2000px+ to reach the grid and never reset. mapEl below is
// computed from [data-region="map"]'s CURRENT getBoundingClientRect(), so without a reset its
// y-coordinate comes out NEGATIVE (measured: y=-1839 after a real clause-B scroll) — an
// off-screen point the mouse cannot usefully move to. The wheel instrument then logs NOTHING
// ("path[0]=n/a"), and the gesture lands wherever the mouse physically ended up instead,
// reading as "the map did not receive the gesture" when the real defect was a stale scroll
// position the gate itself introduced one clause earlier.
await page.evaluate(`(() => { document.scrollingElement.scrollTop = 0; })()`)
await page.waitForTimeout(300)
await reinject(page)
const wheelInstrument = `
window.__wheelLog = [];
if (!window.__wheelCaptureInstalled) {
window.__wheelCaptureInstalled = true;
document.addEventListener('wheel', (e) => {
window.__wheelLog.push({
x: e.clientX, y: e.clientY,
path: (e.composedPath ? e.composedPath() : []).slice(0, 6).map(n => n && n.className ? String(n.className).slice(0,40) : (n && n.tagName || '')),
});
}, { capture: true, passive: true });
}
`
await page.evaluate(wheelInstrument)
// -- over the map --
const mapEl = await page.evaluate(`(() => { const m = __walk('[data-region="map"]')[0] || __walk('.mapboxgl-canvas')[0]; if (!m) return null; const r = m.getBoundingClientRect(); return { x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) } })()`)
let zoomBefore = null, zoomAfter = null, scrollBefore = null, scrollAfter = null, pathOverMap = []
if (mapEl) {
zoomBefore = await page.evaluate(`(() => { const m = window.__rdashMap; return m ? m.getZoom() : null })()`)
scrollBefore = await page.evaluate(`(() => { const sec = __walk('.stl-sec')[0]; return sec ? sec.getBoundingClientRect().top : (window.scrollY || document.documentElement.scrollTop) })()`)
await page.mouse.move(mapEl.x, mapEl.y)
for (let i = 0; i < 6; i++) { await page.mouse.wheel(0, -120); await page.waitForTimeout(60) }
await page.waitForTimeout(400)
zoomAfter = await page.evaluate(`(() => { const m = window.__rdashMap; return m ? m.getZoom() : null })()`)
await reinject(page)
scrollAfter = await page.evaluate(`(() => { const sec = __walk('.stl-sec')[0]; return sec ? sec.getBoundingClientRect().top : (window.scrollY || document.documentElement.scrollTop) })()`)
pathOverMap = await page.evaluate('window.__wheelLog || []')
}
const zoomChanged = zoomBefore != null && zoomAfter != null && Math.abs(zoomAfter - zoomBefore) > 0.01
const scrolledWhileOverMap = scrollBefore != null && scrollAfter != null && Math.abs(scrollAfter - scrollBefore) > 40
if (!mapEl) {
fail('c', `map region not found on ${TARGET} — cannot instrument the wheel`)
} else {
const ok = zoomChanged && !scrolledWhileOverMap
record('C', `${TARGET} (over map)`, ok, `zoom ${zoomBefore != null ? zoomBefore.toFixed(2) : '?'} -> ${zoomAfter != null ? zoomAfter.toFixed(2) : '?'}, section.top moved ${scrollBefore != null && scrollAfter != null ? Math.round(scrollAfter - scrollBefore) : '?'}px, path[0]=${pathOverMap[0] ? JSON.stringify(pathOverMap[0].path[0]) : 'n/a'}`)
if (!zoomChanged) fail('c', `wheel over the map did NOT change zoom (${zoomBefore} -> ${zoomAfter}) — the map is not receiving/using the gesture`)
if (scrolledWhileOverMap) fail('c', `wheel over the map ALSO scrolled the pane (section.top moved ${Math.round(scrollAfter - scrollBefore)}px) — the gesture is leaking past the map to the page`)
}
// -- below the map, over a column that isn't the table's own windowed-virtualization scroller.
// Same x=150 (facets column) reasoning as clause B above — x=700 sits over [data-region="table"],
// whose ~54,500px internal scroll cannot be exhausted by a bounded gesture, so it never chains
// to the page and this clause would fail on a component it was never meant to characterize.
// ★ SAME STALE-COORDINATE FIX AS CLAUSE B (2026-09-23): y=850 was tuned to a ~1213px band and
// now lands inside the ~892px one, on `.rd-td`. Derived from the band's measured bottom instead.
const belowY = await page.evaluate(`(() => {
const band = __walk('[data-region="dashboard"], .rd-shell')[0];
const b = band ? Math.round(band.getBoundingClientRect().bottom) : 0;
return Math.min(window.innerHeight - 30, Math.max(b + 20, 120));
})()`)
const below = { x: 150, y: belowY }
const zoomBefore2 = await page.evaluate(`(() => { const m = window.__rdashMap; return m ? m.getZoom() : null })()`)
const scrollBefore2 = await gridTop()
await page.mouse.move(below.x, below.y)
for (let i = 0; i < 8; i++) { await page.mouse.wheel(0, 250); await page.waitForTimeout(60) }
await page.waitForTimeout(400)
await reinject(page)
const zoomAfter2 = await page.evaluate(`(() => { const m = window.__rdashMap; return m ? m.getZoom() : null })()`)
const scrollAfter2 = await gridTop()
const zoomChangedBelow = zoomBefore2 != null && zoomAfter2 != null && Math.abs(zoomAfter2 - zoomBefore2) > 0.01
const movedBelow = scrollBefore2 && scrollAfter2 ? (scrollBefore2.top - scrollAfter2.top) : 0
const okBelow = !zoomChangedBelow && movedBelow > 50
record('C', `${TARGET} (below map)`, okBelow, `zoom ${zoomBefore2 != null ? zoomBefore2.toFixed(2) : '?'} -> ${zoomAfter2 != null ? zoomAfter2.toFixed(2) : '?'}, grid moved ${movedBelow}px`)
if (zoomChangedBelow) fail('c', `wheel BELOW the map changed zoom (${zoomBefore2} -> ${zoomAfter2}) — an unrelated gesture is reaching the map`)
if (!(movedBelow > 50)) fail('c', `wheel below the map did not scroll the page (grid moved only ${movedBelow}px)`)
// ── CLAUSE F — THE WHEEL ESCAPES FROM *EVERY* COLUMN, NOT JUST THE ONE WE TEST ──────────────
// ★ WHY THIS CLAUSE EXISTS: clause C above wheels at x=150, the FACETS column. That coordinate
// is correct for what C asserts, but it made 16/16 certify a NARROWER claim than it appears to.
// MEASURED 2026-09-23, 1440x900, 12 wheel ticks x 350px = 4200px of delta at y=682:
// x=150 (facets col) scrollY 0 -> 2131 moved 2131px
// x=736 (TABLE col centre) scrollY 0 -> 0 moved 0px <-- dead zone
// x=1330 (rail col) scrollY 0 -> 2131 moved 2131px
// The table column is roughly the centre 60% of the screen — where a cursor naturally rests. A
// reader who finishes the table and keeps scrolling without moving the mouse sideways is stuck,
// which is the original complaint. Cause: .rd-grid is its own scroll surface with a virtualized
// scrollHeight of ~54,838px against a ~300px keyhole, so the overscroll-chain escape the design
// relies on can never fire — 4200px of wheel does not come close to bottoming out 54,538px.
//
// ★ THE GENERAL LESSON, which is why this is a CLAUSE and not a card: a single test coordinate
// grades one column. When the previously-failing point was moved to a passing column the gate
// went green while the defect stood — "the gate passes" and "the complaint is fixed" are
// different claims. Sweeping every column makes the gate fail wherever a user could actually be.
const colSweep = []
{
const cols = await page.evaluate(`(() => {
const shell = __walk('.rd-shell')[0]; if (!shell) return null
const pick = (r) => { const e = shell.querySelector('[data-region="' + r + '"]'); if (!e) return null
const b = e.getBoundingClientRect()
if (b.height < 40) return null
return { region: r, x: Math.round(b.left + b.width / 2), y: Math.round(b.top + b.height / 2) } }
return ['facets', 'table', 'rail'].map(pick).filter(Boolean)
})()`)
for (const col of (cols || [])) {
await page.evaluate(`(() => { window.scrollTo(0, 0); if (document.scrollingElement) document.scrollingElement.scrollTop = 0 })()`)
await page.waitForTimeout(500)
const y0 = await page.evaluate(`Math.round(window.scrollY)`)
// ★ WHEEL INSIDE THE COLUMN, NOT BELOW IT. belowY is deliberately past the band's bottom
// (clause C wants the page there), but a point past the bottom is OUTSIDE every column, so
// reusing it made all three columns measure the same open page and clause F passed for the
// wrong reason — elementFromPoint(736, belowY) returned the composite host, never the table.
// Each column supplies its OWN y: the vertical centre of its own rect.
await page.mouse.move(col.x, col.y)
for (let i = 0; i < 12; i++) { await page.mouse.wheel(0, 350); await page.waitForTimeout(50) }
await page.waitForTimeout(500)
const y1 = await page.evaluate(`Math.round(window.scrollY)`)
colSweep.push({ region: col.region, x: col.x, moved: y1 - y0 })
}
await page.evaluate(`(() => { window.scrollTo(0, 0); if (document.scrollingElement) document.scrollingElement.scrollTop = 0 })()`)
await reinject(page)
}
const stuck = colSweep.filter((c) => c.moved <= 50)
const okCols = colSweep.length > 0 && stuck.length === 0
record('F', `${TARGET} (wheel escapes every column)`, okCols,
colSweep.map((c) => `${c.region}@${c.x}:${c.moved}px`).join(' · ') || 'no columns found')
if (colSweep.length && stuck.length) {
fail('f', `a wheel over the ${stuck.map((s) => s.region).join(', ')} column(s) does not scroll the page ` +
`(moved ${stuck.map((s) => s.moved + 'px').join(', ')} after 4200px of wheel) — that column is a dead zone ` +
`for a reader whose cursor rests there`)
}
// ── CLAUSE D — THE ELEMENT SAYS WHAT IT IS SHOWING ──────────────────────────────────────────
// dashboard-plus-grid.vue:279 sets show-total=false on the grid child DELIBERATELY (the count
// lived twice — the grid's own "Total:" line 38px below the table's "Σ N groups" footer); the
// documented single source of truth for the composite is the table's own live bar,
// DashboardDataTable.vue:541 `[data-table-count]`. Asserting on `.pgg-root .repel` here would
// fail correct code — this reads the element the design actually intends to speak through.
const countBefore = await page.evaluate(`(() => { const el = __walk('[data-table-count]')[0]; return el ? el.textContent.trim() : null })()`)
const countVisible = countBefore != null && /\d/.test(countBefore)
if (!countVisible) {
fail('d', `no visible count element ([data-table-count]) found, or it carries no digits (saw ${JSON.stringify(countBefore)})`)
} else {
// Narrow via a real facet-rail click (interop-harness's proven mechanism) and require the
// count to CHANGE — a static "N groups" that never updates is the same defect wearing a
// "technically visible" disguise.
const facetClicked = await page.evaluate(`(() => {
const rows = __walk('[data-facet]').filter(r => r.getAttribute('data-facet') && r.getAttribute('data-value') && !r.classList.contains('dash-only'));
const row = rows.find(r => r.getAttribute('data-facet') === 'wagf_region') || rows[0];
if (!row) return null;
row.click();
return { facet: row.getAttribute('data-facet'), key: row.getAttribute('data-value') };
})()`)
if (!facetClicked) {
// Count is visible but we cannot prove reactivity here — record what we could verify, do not fabricate a pass on the untested half.
record('D', TARGET, true, `count visible ("${countBefore}"); could not find a clickable facet row to test that it CHANGES`)
} else {
await page.waitForTimeout(2000)
await reinject(page)
const countAfter = await page.evaluate(`(() => { const el = __walk('[data-table-count]')[0]; return el ? el.textContent.trim() : null })()`)
const changed = countAfter != null && countAfter !== countBefore
record('D', TARGET, changed, `"${countBefore}" -> "${countAfter}" after facet ${facetClicked.facet}=${facetClicked.key}`)
if (!changed) fail('d', `count line did not change after narrowing (${JSON.stringify(countBefore)} -> ${JSON.stringify(countAfter)})`)
}
}
await page.close()
}
// ================================================================================================
// CLAUSE E — RESPONSIVE SWEEP, on both the composite and the standalone dashboard leaf
// ================================================================================================
async function runResponsiveSweep(url, label) {
const WIDTHS = [1440, 1280, 768, 390]
for (const w of WIDTHS) {
const page = await browser.newPage({ viewport: { width: w, height: 900 } })
try {
await page.goto(url, { waitUntil: 'load', timeout: 60000 })
} catch (e) {
record('E', `${label}@${w}`, false, `navigation failed: ${e.message}`)
fail('e', `${label}@${w}: could not navigate (${e.message})`)
await page.close(); continue
}
await page.waitForTimeout(5000)
await reinject(page)
if (SELF_TEST && SELF_TEST_CLAUSE === 'e' && w === 1440) {
console.log(' [self-test:e] forcing the shell wider than the viewport')
await page.evaluate(`(() => { document.documentElement.style.setProperty('min-width', '2200px', 'important'); })()`)
}
const measured = await page.evaluate(`(() => {
const docOverflow = document.documentElement.scrollWidth - window.innerWidth;
// Elements clipped to zero height: anything with real content but 0 rendered height.
// Exempt .rd-td/.rd-th (text-overflow:ellipsis is EXPECTED there — the brief's own carve-out)
// and anything with zero content (legitimately-empty containers are not a clipping defect).
// ★ MAPBOX INTERNALS ARE EXEMPT TOO (added 2026-09-23, after the first real verdict).
// On its first run this clause failed on ALL EIGHT viewport/target combinations with
// docOverflow=0 and the SAME two elements every time: mapboxgl-canvas-container and
// mapboxgl-control-container. Both are GL-JS's own wrappers, legitimately zero-height with
// children — the canvas container collapses because the canvas is absolutely positioned, and
// the control container is an empty positioning shell. Neither is a layout defect.
//
// That was 8 of this gate's 12 failures on its first verdict: a false signal loud enough to
// drown the ONE real finding (clause B, a wheel below the map scrolling nothing). A gate that
// reports four-fifths noise teaches its reader to skim the summary line, which is the same
// failure this file was written to end — so the exemption is a correctness fix, not a
// convenience.
//
// Scoped by PREFIX rather than an exact class list, because GL-JS composes these as
// multi-class strings ("mapboxgl-canvas-container mapboxgl-interactive") and adds more of
// them across versions. Kept NARROW to mapboxgl-/maplibregl- so it cannot silently swallow a
// real defect in our own markup.
const VENDOR_ZERO_HEIGHT = /(^|\s)(mapboxgl|maplibregl)-/;
const all = __walk('*');
const clipped = [];
for (const el of all) {
if (el.classList && (el.classList.contains('rd-td') || el.classList.contains('rd-th'))) continue;
if (el.className && VENDOR_ZERO_HEIGHT.test(String(el.className))) continue;
const r = el.getBoundingClientRect();
const hasText = (el.textContent || '').trim().length > 0;
const hasOwnChildren = el.children && el.children.length > 0;
if (r.height === 0 && r.width > 0 && (hasText || hasOwnChildren)) {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') continue; // intentionally hidden, not clipped
clipped.push((el.className ? String(el.className).slice(0,40) : el.tagName));
}
}
return { docOverflow, clippedCount: clipped.length, clippedSample: clipped.slice(0, 5) };
})()`)
const ok = measured.docOverflow <= 2 && measured.clippedCount === 0
record('E', `${label}@${w}`, ok, `doc overflow=${measured.docOverflow}px, clipped-to-zero-height=${measured.clippedCount}${measured.clippedSample.length ? ' (' + measured.clippedSample.join(', ') + ')' : ''}`)
if (measured.docOverflow > 2) fail('e', `${label}@${w}: document scrolls horizontally by ${measured.docOverflow}px`)
if (measured.clippedCount > 0) fail('e', `${label}@${w}: ${measured.clippedCount} element(s) clipped to zero height — ${measured.clippedSample.join(', ')}`)
await page.close()
}
}
// ================================================================================================
// SHELL-LEVEL BEST-EFFORT (cap-5): discovered by manifest type, probed by content match, never a
// hard dependency on doxa-maps-spa's or plugin-studio's internal tab structure.
// ================================================================================================
async function runShellBestEffort() {
if (!shellLeaves.length) {
console.log(' (info) no nested-mfe-spa profile in the manifest — shell-level sub-check skipped, not failed.')
return
}
for (const shell of shellLeaves) {
const url = shell.iframeUrl ? `${BASE}/${TREE_NAME}/${shell.iframeUrl.replace(/^\.\//, '')}` : null
if (!url) continue
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
try {
await page.goto(url, { waitUntil: 'load', timeout: 60000 })
} catch (e) {
console.log(` (info) shell ${shell.bundle}/${shell.profile} unreachable (${e.message}) — skipping shell-level check for it.`)
await page.close(); continue
}
await page.waitForTimeout(5000)
await reinject(page)
// Content-matched click: find ANY tab whose visible text mentions the composite this gate
// already proved (dashboard-plus-grid / "Big Picture" / "Dashboard"), rather than assuming a
// fixed label or index — resilient to the three-section restructure landing mid-flight.
const tab = await page.evaluate(`(() => {
const cands = __walk('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)');
const hit = cands.find(t => /dashboard.plus.grid|big picture|dashboard/i.test((t.textContent||'')));
if (!hit) return null;
hit.click();
return hit.textContent.trim();
})()`)
if (!tab) {
console.log(` (info) shell ${shell.bundle}/${shell.profile}: no tab text matched the dashboard/grid composite yet — SKIPPING (not a fitness finding; may be mid-restructure).`)
await page.close(); continue
}
await page.waitForTimeout(7000)
await reinject(page)
const grid = await page.evaluate(`(() => { const g = __walk('.pgg-root')[0]; return g ? { h: Math.round(g.getBoundingClientRect().height) } : null })()`)
record('B (shell)', `${shell.bundle} tab="${tab}"`, !!grid, grid ? `grid mounted h=${grid.h}px inside the shell` : 'grid did not mount inside the shell within the wait window')
if (!grid) fail('b', `shell ${shell.bundle}: grid did not mount inside the "${tab}" tab`)
await page.close()
}
}
// ── RUN ──────────────────────────────────────────────────────────────────────────────────────
let runError = null
try {
await runComposite()
await runResponsiveSweep(`${BASE}/${TREE_NAME}/dashboard-plus-grid/dashboard-plus-grid/index.html`, 'dashboard-plus-grid')
await runResponsiveSweep(`${BASE}/${TREE_NAME}/research-dashboard/research-dashboard/index.html`, 'research-dashboard')
await runShellBestEffort()
} catch (e) {
runError = e
console.error('FITS-THE-SCREEN: ERROR — ' + e.message)
exitCode = 1
} finally {
await browser.close()
server.kill()
}
// ── RE-CHECK SETTLEDNESS AT THE END — a build finishing mid-run tears the tree out from under an
// in-flight measurement exactly like one starting did. Downgrade to SKIP rather than report. ─────
if (!settled()) {
console.log('\nFITS-THE-SCREEN: SKIP — the tree stopped being settled DURING this run (a build landed mid-measurement).')
console.log(' Every clause above was measured against a tree that moved underneath it. Re-run once the queue drains.')
process.exit(0)
}
// ── CLAUSE TABLE ─────────────────────────────────────────────────────────────────────────────
console.log('\n── CLAUSE TABLE ' + '─'.repeat(70))
console.log('clause | target | result | detail')
for (const r of rows) {
console.log(`${r.clause} | ${r.target} | ${r.ok ? 'PASS' : 'FAIL'} | ${r.detail}`)
}
const passN = rows.filter((r) => r.ok).length
console.log(`\n${passN}/${rows.length} clause-checks PASS`)
if (SELF_TEST) {
if (selfTestCaught) {
console.log(`\nFITS-THE-SCREEN: SELF-TEST PASS (clause=${SELF_TEST_CLAUSE.toUpperCase()}) — the poisoned build was correctly rejected.`)
process.exit(0)
}
console.error(`\nFITS-THE-SCREEN: SELF-TEST FAILED (clause=${SELF_TEST_CLAUSE.toUpperCase()}) — the poison was live and this gate still reported PASS.`)
console.error(' A gate that cannot fail is worse than no gate. Fix the gate, not the product.')
process.exit(1)
}
if (runError) process.exit(1)
console.log(exitCode ? '\nFITS-THE-SCREEN: FAIL' : '\nFITS-THE-SCREEN: PASS — the product fits the screen, the grid is reachable, the wheel is targeted, the count speaks, and it holds at four widths.')
process.exit(exitCode)