doxa-maps/internal/verify-shell-scroll.mjs
doxa-maps-88655c70 0f0bebd9c2 Serve locally the way production serves, so a bad URL fails here instead of live
bun run serve was python3 -m http.server, which resolves a directory request to
its index.html. The live static layer returns 404 for the same request. So a gate
could pass locally on a URL that does not work in production, and nothing local
could ever catch it.

That is not a harness quirk, it is a blind spot with a green light on it: every
defect inside the permissive gap passes locally by construction and can only be
found in production, which is the worst place to find it. It cost a false
"the publish failed, zero filters live" reading on a publish that was
byte-for-byte correct.

serve-strict.mjs returns 404 for a directory, exactly like production, with no
index fallback and no directory listing. It also refuses to read outside its
root. Loopback only, inherited from the script it replaces, whose own comment
records that the bind was once 0.0.0.0 and answered on the box's public IP while
a gate was running. The permissive server is kept as serve:permissive rather
than deleted, so the difference can be demonstrated.

It earned itself immediately: verify-shell-scroll held two bare-directory URLs
and failed the first time it ran against the strict server. Both now ask for
index.html, which is the form the manifest already ships for all 28 profiles,
and the gate passes again -- both wheel gestures moving the grid 1500px onto
screen.

Fixing the server rather than the URLs is the point. The repo was already
production-correct: 28 of 28 manifest URLs and 82 gate references use
index.html. What was missing was anything that could fail when one did not. A
rule you have to remember fails under time pressure; a server that returns 404
does not.
2026-09-23 03:09:25 +00:00

218 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* verify-shell-scroll.mjs — THE GATE FOR BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID.
*
* ★ WHAT IT ASSERTS, and why each assertion exists rather than the obvious one.
*
* The obvious assertion — `scrollHeight > innerHeight` — is a FALSE PASS and was used twice to
* close this bug while the Driver could still not scroll. In the cap-5 shell the document measures
* scrollHeight 900 === clientHeight 900 while the composite element measures 3380/769: the
* overflow is real, but it belongs to an element with `overflow:visible` that no scroller owns.
* Geometry said "tall"; the user's wheel moved nothing.
*
* So this gate asserts the ONLY thing the Driver actually cares about: that a REAL WHEEL GESTURE
* over the map area moves the grid up the screen and brings it into view. It measures the grid's
* position before and after, in the SHELL, which is the surface he uses — not the standalone leaf,
* which passed all along and is why the two previous fixes looked green.
*
* ★ NEGATIVE-TESTED. Reverting the fix (restoring `.ps-pane`/`.vtl-host` to `position:absolute;
* inset:0` with no overflow, and `.vtl-content` to `contain:layout paint`) makes this exit 1 with
* "grid did not move". A gate nobody has broken is a gate nobody knows works.
*
* Usage: node internal/verify-shell-scroll.mjs [baseUrl]
* (defaults to http://localhost:8899; serve ./public)
*/
import { chromium } from 'playwright'
// Default matches every other browser gate in this repo: `bun run serve` on loopback 4173.
// It was `localhost:8899` — one of the ad-hoc ports a previous session happened to have running,
// which meant the gate failed with ERR_CONNECTION_REFUSED for anyone else and looked like a broken
// gate rather than a missing server. Prefer BASE_URL (env) over a positional argument, because
// `bun run <script> <arg>` cannot forward arguments past an inline `#` comment — see
// check:script-flags. The positional form is kept as a fallback for direct `node` invocation.
const BASE = process.env.BASE_URL || process.argv[2] || 'http://127.0.0.1:4173'
// ★ ASK FOR index.html EXPLICITLY. These were bare-directory paths, which the OLD local server
// (python3 -m http.server) resolved to index.html while the LIVE static layer returns 404 — so this
// gate passed for a whole flight on URLs that do not work in production. `bun run serve` is now
// internal/serve-strict.mjs, which 404s a directory exactly as production does, and it caught these
// two the first time it ran. The manifest already ships 28/28 iframeUrl values in this form.
const SHELL = '/doxa-maps/plugin-studio/plugin-studio/index.html'
const LEAF = '/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/index.html'
// Shadow DOM DOES NOT PIERCE. A single-level querySelector lies about every composite, so every
// lookup here is a recursive walk with a seen-Set, re-injected after each interaction (a page
// navigation or re-render drops anything we defined earlier).
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;
}
// ★ ANCHOR ON .pgg-root, NOT the <people-groups-grid> TAG. In the cap-4 composite the grid is
// IMPORTED as a Vue child component and compiled into one bundle — so there is no custom-element
// tag to find, and a tag-based lookup reports "not mounted" for a grid that is right there. That
// wrong answer cost a run of this very gate. .pgg-root is the grid's own root div and exists in
// both shapes (standalone element and baked-in child).
function __gridTop() {
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 fail = (m) => { console.error('SHELL-SCROLL: FAIL — ' + m); process.exit(1) }
// The gesture's shape, named ONCE. The assertion below derives its threshold from these, so the
// check and the gesture can never drift apart — the failure mode that let `movedByMap < 200` sit
// beside a measured-broken value of 300 and pass.
const WHEEL_TICKS = 10
const WHEEL_DY = 300
const VIEWPORT_FOLD = 900 // the viewport height this gate opens the page at
async function wheel(page, x, y, dy, times = WHEEL_TICKS) {
await page.mouse.move(x, y)
for (let i = 0; i < times; i++) { await page.mouse.wheel(0, dy); await page.waitForTimeout(80) }
await page.waitForTimeout(500)
}
const run = async () => {
const browser = await chromium.launch({
args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--use-angle=swiftshader']
})
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
await page.goto(BASE + SHELL, { waitUntil: 'load' })
await page.waitForTimeout(6000)
// HEAVY TABS LAZY-MOUNT ON ACTIVATION. Before this click the composite does not exist and every
// query returns 0 — which reads as "no bug" if you stop here. This is the step both previous
// investigations skipped.
const tab = await page.evaluate(`(() => { ${WALK}
for (const t of __walk('.vtl-tab')) {
if ((t.textContent || '').toLowerCase().includes('dashboard-plus-grid')) { t.click(); return t.textContent.trim() }
}
return null
})()`)
if (!tab) { await browser.close(); fail('could not find the dashboard-plus-grid tab in the shell rail') }
await page.waitForTimeout(9000)
// 1. The grid must be MOUNTED. If it is not rendered at all, "cannot scroll to it" has a
// completely different cause and this gate must say so rather than blaming the scroll.
const before = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`)
if (!before) { await browser.close(); fail('.pgg-root is NOT MOUNTED in the shell — different bug, not a scroll bug') }
if (before.h < 400) { await browser.close(); fail('.pgg-root mounted but collapsed (h=' + before.h + ') — not a scroll bug') }
// 2. A REAL WHEEL GESTURE over the MAP AREA must move the grid up the screen. This is the
// gesture the Driver makes, over the surface he makes it on. Mapbox scrollZoom eating the
// wheel would show up here as no movement.
await wheel(page, 850, 420, WHEEL_DY)
const afterMap = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`)
const movedByMap = before.top - afterMap.top
// 3. ...and a gesture BELOW the map must too (in case the map swallows its own area).
await wheel(page, 850, 800, WHEEL_DY)
const afterLow = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`)
const movedTotal = before.top - afterLow.top
// 4. THE REAL QUESTION: is the grid actually ON SCREEN now? A grid that moved 50px but still
// sits below the fold is still unreachable. Require its top inside the viewport.
const onScreen = afterLow.top < VIEWPORT_FOLD
console.log('SHELL-SCROLL measurements (cap-5 shell, tab="' + tab + '"):')
console.log(' grid mounted : h=' + before.h + 'px, top=' + before.top)
console.log(' wheel over MAP area : grid moved ' + movedByMap + 'px (top ' + before.top + ' -> ' + afterMap.top + ')')
console.log(' wheel BELOW map : grid moved ' + movedTotal + 'px total (top -> ' + afterLow.top + ')')
console.log(' grid on screen : ' + onScreen)
if (movedTotal < 200) fail('grid did not move — a real wheel gesture in the SHELL scrolled ' + movedTotal + 'px. The grid is mounted at h=' + before.h + 'px and is UNREACHABLE.')
if (!onScreen) fail('grid moved ' + movedTotal + 'px but its top is still at ' + afterLow.top + ' (below the 900px fold) — still not reachable.')
// ★ ASSERT ON THE NUMBER WE ALREADY MEASURED. This gate computed `movedByMap` at step 2 — the
// gesture the Driver actually makes — and then graded only `movedTotal`, which sums in the
// step-3 gesture BELOW the map. So it reported PASS (2611px total) while wheeling OVER the map
// moved just 300px and the Driver still could not reach the grid: Mapbox scrollZoom was eating
// the wheel because syncScrollZoomToHost sampled the layout on three fixed timeouts and froze a
// stale answer. Passing on the total rewarded an escape hatch the user will never find — his
// cursor is over the map, which fills most of the band.
//
// The failure mode to remember: a gate that MEASURES the right thing and then ASSERTS on a
// different variable. Distinct from a gate that cannot fail; it fails honestly, just never on
// the condition it was built for. Audit every gate for values it computes but never checks.
// ★ THE THRESHOLD IS DERIVED, NOT GUESSED. This was `movedByMap < 200` — a number picked as a
// vague "did it move meaningfully" floor WITHOUT comparing it to the value already measured as
// broken, which was 300. So the assertion passed on the exact measurement that demonstrates the
// bug, and the gate reported PASS across three ticks while the Driver still could not scroll.
//
// The gesture requests WHEEL_TICKS * WHEEL_DY px. A healthy scrollport delivers most of that
// (capped only by the scroll range actually available). Delivering one tick's worth means the
// other nine were swallowed by a nested scrollport under the cursor. Comparing DELIVERED against
// REQUESTED is self-calibrating: change the tick count or delta and the assertion still holds,
// where a hardcoded constant silently rots.
// ★ THE BASELINE IS MEASURED, NOT COMPUTED. A first attempt derived the expected travel as
// `before.top - VIEWPORT_FOLD`, which is 911-900 = 11px — a plausible-looking quantity that is
// simply the wrong one (it asks how far until the grid reaches the fold, not how much scroll the
// PANE has). With expected=11 the assertion passed on the broken 300px, repeating the exact
// mistake it was written to fix.
//
// The honest baseline is the SAME gesture performed somewhere it is known to work: step 3 wheels
// identically lower down the page and delivers `movedTotal - movedByMap`. Two identical gestures
// on one surface should move the page comparably; if the one over the dashboard moves far less,
// something under that cursor is eating it. No constant to rot, and it self-adjusts to whatever
// scroll range the layout actually has.
const requested = WHEEL_TICKS * WHEEL_DY
const movedByLower = movedTotal - movedByMap
if (movedByLower > 100 && movedByMap < movedByLower * 0.5) {
fail('a nested scrollport ATE THE WHEEL — the SAME ' + requested + 'px gesture moved the grid ' +
movedByLower + 'px lower down the page but only ' + movedByMap + 'px over the dashboard' +
'. The grid is technically reachable but NOT by ' +
'the gesture the Driver makes.\n' +
' ★ Do NOT assume the map. MEASURED 2026-09-22: the culprit was .rd-grid — a virtualized table ' +
'with scrollHeight 54834 in a ~207px window — consuming the wheel by normal scroll-chaining, ' +
'while the Mapbox canvas sat ABOVE the cursor and was never involved. Fix was ' +
'overscroll-behavior:contain on .rd-grid. Identify the element UNDER THE CURSOR before blaming ' +
'the one nearby: compare each scrollport\'s scrollTop before and after the gesture.')
}
console.log('SHELL-SCROLL: PASS — the grid is reachable by wheel in the cap-5 shell.')
// ── 5. THE OTHER SIDE OF THE FIX: a MAP tab must NOT gain a scrollbar. ─────────────────────
// Making the pane scrollable is only correct if it stays a NO-OP for the ~19 fixed-viewport map
// tabs. It did not, at first: a hosted map ships min-height:100dvh (resolved against the 900px
// VIEWPORT) beside height:100% (resolved against the 769px PANE), so the floor made it 131px
// taller than its container and the new overflow:auto faithfully grew a scrollbar on it. That
// regression was invisible to every assertion above — the grid test passed while a map tab broke —
// so it is asserted here, in the same gate, rather than left to be re-found by hand.
const page2 = await browser.newPage({ viewport: { width: 1440, height: 900 } })
await page2.goto(BASE + SHELL, { waitUntil: 'load' })
await page2.waitForTimeout(6000)
const mapTab = await page2.evaluate(`(() => { ${WALK}
for (const t of __walk('.vtl-tab')) {
if ((t.textContent || '').toLowerCase().includes('doxa-research-map')) { t.click(); return t.textContent.trim() }
}
return null
})()`)
if (mapTab) {
await page2.waitForTimeout(9000)
const panes = await page2.evaluate(`(() => { ${WALK}
return __walk('.ps-pane, .vtl-host').filter(e => e.getBoundingClientRect().height > 0)
.map(e => ({ sh: e.scrollHeight, ch: e.clientHeight, over: e.scrollHeight - e.clientHeight }))
})()`)
const leaking = panes.filter(p => p.over > 4)
console.log(' map tab "' + mapTab + '" pane overflow: ' + JSON.stringify(panes))
await browser.close()
if (leaking.length) fail('a MAP tab grew a scrollbar (' + leaking[0].over + 'px overflow: ' + leaking[0].sh + '/' + leaking[0].ch + '). The pane scroll must be a NO-OP for fixed-viewport elements — check the dvh floor neutraliser.')
console.log('MAP-TAB-NO-SCROLLBAR: PASS — fixed-viewport map tabs gained no scrollbar.')
} else {
await browser.close()
console.log('MAP-TAB-NO-SCROLLBAR: SKIPPED — no doxa-research-map tab in this shell.')
}
}
run().catch(e => { console.error('SHELL-SCROLL: FAIL — ' + e.message); process.exit(1) })