verify-shell-scroll asserts the Driver's ruling: a wheel over the map zooms, below it scrolls

Step 2 asserted that a wheel OVER the map must scroll the page. That
predates the Driver's 2026-09-23 ruling, recorded in
DashboardMapPanel.vue: "wheel OVER the map zooms; wheel BELOW it
scrolls". It kept the gate red on correct behaviour (dx-shepherd ruled
it stale). It now asserts the ruling: a scroll-down wheel over the map
moves the page 0 px, a zoom-in wheel over it raises the zoom, and a
wheel below the map scrolls the page until the grid is on screen. It
locates the map canvas and wheels at its centre and below it, not at
fixed coordinates.

Proven both ways on a served built tree: PASS (over the map 0 px and
zoom 0.50 -> 1.30; below it 2502 px). With SHELL_SCROLL_NEGATIVE=1,
which disables scrollZoom as before the ruling, it FAILS: the page
moved 300 px. It also gains the tree-identity guard (exit 75 on another
build).
This commit is contained in:
doxa-maps-88655c70 2026-09-25 10:03:12 +00:00
parent be3620ef1c
commit 2a1292b3ad

View file

@ -23,6 +23,9 @@
* (defaults to http://localhost:8899; serve ./public)
*/
import { chromium } from 'playwright'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { requireThisTree } from './_serves-this-tree.mjs'
// 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,
@ -37,6 +40,9 @@ const BASE = process.env.BASE_URL || process.argv[2] || 'http://127.0.0.1:4173'
// 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'
// Measure THIS build, or measure nothing: a default port on this machine can belong to another
// worktree, and a stale studio there would be graded as ours. Exit 75 = NOT MEASURED (see a9550858).
await requireThisTree(BASE, resolve(dirname(fileURLToPath(import.meta.url)), '..'), 'SHELL-SCROLL')
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
@ -110,76 +116,62 @@ const run = async () => {
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)
// 2. THE DRIVER'S RULING, asserted as stated (2026-09-23, recorded in
// plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue:~1188):
// "I can't zoom in and out on the map. The scrolling is causing that to change." →
// a wheel OVER the map ZOOMS and moves the page 0 px; a wheel BELOW the map SCROLLS the page.
// ★ THIS STEP USED TO ASSERT THE OPPOSITE — that a wheel over the map must scroll the page —
// written before the ruling, when the map row filled the whole pane. Once the ruling made the
// map a bounded region with zoom always on, that assertion failed on the correct behaviour and
// kept this gate red on a product that was right (re-measured 2026-09-25: over-map 0 px, below
// the map 2502 px, grid on screen). dx-shepherd ruled the gate stale; this is the ruling.
// Negative control: SHELL_SCROLL_NEGATIVE=1 disables scrollZoom on the map before the gestures
// (the pre-ruling behaviour), and the gate must FAIL.
const MAP = `(() => { ${WALK}
const c = __walk('canvas.mapboxgl-canvas, canvas.maplibregl-canvas').find(x => x.getBoundingClientRect().height > 50)
if (!c) return null
const host = __walk('*').find(e => e.__doxaMap && e.__doxaMap.getCanvas && e.__doxaMap.getCanvas() === c)
const r = c.getBoundingClientRect()
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), bottom: Math.round(r.bottom), top: Math.round(r.top),
zoom: host ? host.__doxaMap.getZoom() : null }
})()`
const mapBox = await page.evaluate(MAP)
if (!mapBox || mapBox.zoom == null) { await browser.close(); fail('no map (canvas + its map instance) on the dashboard tab — cannot assert the zoom-vs-scroll ruling') }
if (process.env.SHELL_SCROLL_NEGATIVE === '1') {
await page.evaluate(`(() => { ${WALK} for (const e of __walk('*')) if (e.__doxaMap && e.__doxaMap.scrollZoom) e.__doxaMap.scrollZoom.disable() })()`)
console.log(' NEGATIVE CONTROL: scrollZoom disabled on the map — this run must FAIL')
}
// (a) a scroll-DOWN wheel over the map: the page must not move.
await wheel(page, mapBox.x, mapBox.y, WHEEL_DY, 3)
const afterMapDown = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`)
const movedByMap = before.top - afterMapDown.top
// (b) a zoom-IN wheel over the map: the zoom must go up (in, not out, so a map already at its
// minimum zoom cannot fake a failure).
const zBefore = (await page.evaluate(MAP)).zoom
await wheel(page, mapBox.x, mapBox.y, -WHEEL_DY, 3)
const zAfter = (await page.evaluate(MAP)).zoom
// (c) the same scroll-down wheel BELOW the map's box: this is how the page reaches the grid.
// Below the map's box, and no higher than the y=800 the original gate measured this gesture at: a
// point just under the canvas (y=636 on a 900px viewport) lands on the dashboard's own lower
// controls, which consume the wheel themselves, and read as "the page will not scroll".
const belowY = Math.min(Math.max(mapBox.bottom + 60, VIEWPORT_FOLD - 100), VIEWPORT_FOLD - 20)
const pageBefore = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`)
await wheel(page, mapBox.x, belowY, 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 movedBelow = pageBefore.top - afterLow.top
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)
console.log(' grid mounted : h=' + before.h + 'px, top=' + before.top)
console.log(' wheel OVER map (down) : page moved ' + movedByMap + 'px (ruling: 0)')
console.log(' wheel OVER map (zoom in) : zoom ' + zBefore.toFixed(2) + ' -> ' + zAfter.toFixed(2) + ' (ruling: zooms)')
console.log(' wheel BELOW map (y=' + belowY + ') : page moved ' + movedBelow + 'px (ruling: scrolls)')
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.')
}
if (Math.abs(movedByMap) > 4) fail('a wheel OVER the map scrolled the page ' + movedByMap + 'px. The Driver ruled (2026-09-23) that a wheel over the map ZOOMS and must not move the page — is scrollZoom disabled on this map?')
if (!(zAfter > zBefore + 0.05)) fail('a zoom-in wheel OVER the map left the zoom at ' + zAfter.toFixed(2) + ' (was ' + zBefore.toFixed(2) + '). The Driver ruled that a wheel over the map zooms it.')
if (movedBelow < 200) fail('a wheel BELOW the map scrolled the page only ' + movedBelow + 'px. The grid is mounted at h=' + before.h + 'px and is UNREACHABLE.')
if (!onScreen) fail('the page scrolled ' + movedBelow + 'px but the grid top is still at ' + afterLow.top + ' (below the 900px fold) — still not reachable.')
console.log('SHELL-SCROLL: PASS — the grid is reachable by wheel in the cap-5 shell.')