Some checks are pending
build-gate / build (push) Waiting to run
The studio's rail was hand-rolled vertical tabs stretched into a tree. It is now an Explorer tree modelled on the VS Code Explorer, keyboarded per the WAI-ARIA tree pattern: - CAP 5 -> CAP 1 sections with sticky headers; one-line rows with the name and a muted element tag; twisty on the left; indent guides. - Composites fold; their parts hang beneath, one level at a time, each part opening the plugin's one canonical entry. - A filter box on top shows matches together with the path to them. - One view action, Collapse all. - Auto-reveal: opening a plugin by click, URL hash or remembered tab scrolls its row into view and opens its section. - Arrow keys walk the tree, Right/Left open, close and move between parent and child, Enter opens, a letter jumps to the next name. - The slim rail becomes an activity bar with one icon per section. - Fold state is remembered per page. The tree lives in plugin-designs/plugin-studio/src/StudioTree.vue and fills VerticalTabsLayout's new #rail slot, so the layout still owns the collapse toggle, mobile drawer and slim strip. The alias, depth and branch row fields added to the layout earlier today are removed again: nothing else used them. The studio host now has a default height (85vh, min 480px), as doxa-maps-spa does. Pasting the published embed snippet into a plain page mounted a complete studio 0px tall. A page's own height or --plugin-studio-height still wins. The studio gates that click tabs by text now also match tree rows. Rebuilt the plugin-studio bundle and re-baked its SRI.
218 lines
14 KiB
JavaScript
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, .st-row[role=treeitem]:not(.st-sec)')) {
|
|
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, .st-row[role=treeitem]:not(.st-sec)')) {
|
|
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) })
|