Anchor the filter dropdown in every host, not just standalone
Some checks are pending
build-gate / build (push) Waiting to run

The menu is position:fixed with viewport coordinates. That resolves against the
viewport only while nothing establishes a containing block for fixed
descendants. VerticalTabsLayout -- the cap-5 shell that hosts every plugin as a
tab -- sets contain:layout paint on .vtl-content, inside .vtl-main{left:256px}.

Measured at 1600x900, same component, two hosts:
  standalone research-dashboard ...... dLeft=0   dTop=4    six of six
  inside plugin-studio dashboard tab . dLeft=256 dTop=111  every one

256 is the rail width and 111 the header height. Reported as the menu landing
"in a random spot further down to the right"; it was never random.

An earlier probe measured six triggers at four viewport widths and passed, so
the card was closed while the bug was still there. Every number was accurate.
They were answers about a surface the user never looks at -- widening across
more widths could not have found it, because the variable was the host.

Fix measures the containing block instead of assuming it: a fixed probe at 0,0
inserted in the menu's own parent reports the origin the menu resolves against.
When nothing captures, that origin is 0,0 and the standalone numbers are
unchanged bit-for-bit.

Not fixed by dropping contain from the layout: it is load-bearing there,
stopping a rail collapse from reflowing a heavy mounted map.

check:fixed-anchoring measures both hosts and treats an unmeasured host as a
failure. Negative-tested: reverting the subtraction fails the studio host with
dLeft=256 while standalone still passes -- it fails exactly where the old probe
passed.

Also cards a second symptom of the same cause, not yet fixed: fixed-position
modal scrims written inset:0 stretch to the content pane instead of the
viewport, leaving the rail clickable behind an open modal.
This commit is contained in:
doxa-maps-88655c70 2026-09-22 17:22:23 +00:00
parent e1d27cb476
commit a1005869ce
4 changed files with 211 additions and 3 deletions

View file

@ -0,0 +1,147 @@
#!/usr/bin/env node
/**
* check-fixed-anchoring.mjs — a `position:fixed` popover must stay anchored to its trigger IN EVERY
* HOST, not just the one it was developed in.
*
* THE DEFECT THIS CATCHES. FilterDropdown positions its menu with position:fixed and viewport
* coordinates. That is correct only while nothing establishes a containing block for fixed
* descendants. VerticalTabsLayout — the cap-5 shell every plugin is hosted in as a tab — sets
* `contain: layout paint` on .vtl-content, inside `.vtl-main { left: 256px }`. So the identical
* component read:
* STANDALONE research-dashboard ....... dLeft=0 dTop=4 (six of six)
* INSIDE plugin-studio dashboard tab .. dLeft=256 dTop=111 (every one)
* The Driver reported the menu appearing "in a random spot further down to the right" for days. It
* was never random. 256 is the rail width, 111 the header height.
*
* ★ WHY NO EXISTING GATE SAW IT, AND WHY THAT IS THE INTERESTING PART. A probe DID exist and it DID
* pass — six triggers, four viewport widths, every one accurate. It measured the standalone
* composite. Widening it across MORE viewport widths would never have found this, because the
* variable that mattered was not the width but the HOST. A measurement can be precise, repeatable,
* and about the wrong thing. So this gate's contract is deliberately "the same component in two
* different hosts", and the comparison between them — not either number alone — is the evidence.
*
* WHAT IT ASSERTS. For each (host, trigger) pair: opening the menu puts it within 8px horizontally
* of its trigger's left or right edge, and within [-8, 40]px vertically of the trigger's bottom.
* When a pair fails it names the capturing ancestor from a live computed-style walk, so the report
* says `div.vtl-content [contain:layout paint]` rather than "misplaced".
*
* PRECONDITIONS: a built tree and `bun run serve` on 4173. No Mapbox token needed — this measures
* DOM geometry, and the dropdowns render with or without a basemap. (This VPS has no WebGL, so any
* gate that needed paint would be unrunnable here anyway.)
*
* NEGATIVE-TESTED: reverting the fixedOrigin() subtraction in FilterDropdown.vue makes the
* plugin-studio host fail with dLeft=256, while standalone continues to pass — i.e. the gate fails
* exactly where the old probe passed, which is the property that makes it worth having.
*/
import { chromium } from 'playwright'
const BASE = process.env.BASE || 'http://127.0.0.1:4173/doxa-maps'
const TK = process.env.MAPBOX_TOKEN ? `?tk=${process.env.MAPBOX_TOKEN}` : ''
const DEEP = `(sel)=>{const out=[],seen=new Set();const w=(r)=>{if(!r||seen.has(r))return;seen.add(r);try{out.push(...r.querySelectorAll(sel))}catch(e){};for(const el of r.querySelectorAll('*'))if(el.shadowRoot)w(el.shadowRoot)};w(document);return out}`
const MEASURE = (i) => `(()=>{
const t=(${DEEP})('.filter-dropdown__trigger')[${i}];
if(!t) return null;
const ms=(${DEEP})('.filter-dropdown__menu');
const m=ms[ms.length-1];
const tr=t.getBoundingClientRect();
const name=(t.textContent||'').trim().replace(/\\s+/g,' ').slice(0,14);
if(!m) return {name,open:false};
const mr=m.getBoundingClientRect();
const cap=[];let el=t;
while(el){ if(el.nodeType===1){ const cs=getComputedStyle(el); const why=[];
if(cs.transform&&cs.transform!=='none')why.push('transform');
if(cs.filter&&cs.filter!=='none')why.push('filter');
if(cs.perspective&&cs.perspective!=='none')why.push('perspective');
if(cs.backdropFilter&&cs.backdropFilter!=='none')why.push('backdrop-filter');
if(/paint|layout|strict|content/.test(cs.contain||''))why.push('contain:'+cs.contain);
if(/transform|filter|perspective/.test(cs.willChange||''))why.push('will-change:'+cs.willChange);
if(why.length)cap.push((el.tagName||'').toLowerCase()+(typeof el.className==='string'&&el.className?'.'+el.className.trim().split(/\\s+/)[0]:'')+' ['+why.join(',')+']'); }
el=el.parentElement||(el.parentNode&&el.parentNode.host)||null; }
return {name,open:true,
dLeftEdge:Math.round(mr.left-tr.left),
dRightEdge:Math.round(mr.right-tr.right),
dTop:Math.round(mr.top-tr.bottom),cap};
})()`
async function hostRun(browser, label, url, activate) {
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } })
const rows = []
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 })
await page.waitForTimeout(6000)
if (activate) {
// Heavy tabs are lazy-mounted on activation, so nothing exists until a tab is clicked. A flat
// query before this point returns 0 triggers and reads as "no dropdowns here" — which is how
// a host can silently drop out of coverage while the gate still reports success.
const idx = await page.evaluate(`(${DEEP})('.vtl-tab').findIndex(t=>${activate}.test(t.textContent||''))`)
if (idx < 0) return { label, error: 'no tab matched ' + activate }
await page.evaluate(`(${DEEP})('.vtl-tab')[${idx}].click()`)
await page.waitForTimeout(9000)
}
const n = await page.evaluate(`(${DEEP})('.filter-dropdown__trigger').length`)
if (!n) return { label, error: 'no .filter-dropdown__trigger in the composed tree' }
for (let i = 0; i < n; i++) {
await page.evaluate(`(${DEEP})('.filter-dropdown__trigger')[${i}].click()`)
await page.waitForTimeout(320)
const r = await page.evaluate(MEASURE(i))
if (r) rows.push(r)
await page.evaluate(`(${DEEP})('.filter-dropdown__trigger')[${i}].click()`)
await page.waitForTimeout(110)
}
} catch (e) {
return { label, error: e.message.slice(0, 140) }
} finally {
await page.close()
}
return { label, rows }
}
const browser = await chromium.launch()
const hosts = [
await hostRun(browser, 'STANDALONE research-dashboard', `${BASE}/research-dashboard/research-dashboard/${TK}`, null),
await hostRun(browser, 'INSIDE cap-5 plugin-studio', `${BASE}/plugin-studio/plugin-studio/${TK}`, '/dashboard-plus-grid/i'),
]
await browser.close()
let failed = 0
let measured = 0
console.log('FIXED-ANCHORING: measuring the same dropdown in two different hosts\n')
for (const h of hosts) {
console.log(`--- ${h.label} ---`)
if (h.error) {
console.log(' UNMEASURED: ' + h.error)
failed++ // an unmeasured host is a failure, not a pass; silence is how coverage rots
continue
}
for (const r of h.rows) {
if (!r.open) { console.log(` FAIL [${r.name}] menu did not open`); failed++; continue }
measured++
// Anchored means sharing an edge: left-aligned OR right-aligned (the standard popover flip).
const edgeOk = Math.abs(r.dLeftEdge) <= 8 || Math.abs(r.dRightEdge) <= 8
const vertOk = r.dTop >= -8 && r.dTop <= 40
if (edgeOk && vertOk) {
console.log(` ok [${r.name}] dLeft=${r.dLeftEdge} dTop=${r.dTop}`)
} else {
failed++
console.log(` FAIL [${r.name}] dLeft=${r.dLeftEdge} dRight=${r.dRightEdge} dTop=${r.dTop}`)
if (r.cap.length) {
console.log(' a containing block for fixed descendants exists on this chain:')
r.cap.forEach((c) => console.log(' - ' + c))
console.log(' The menu is position:fixed, so its coordinates resolve against that')
console.log(' ancestor, not the viewport. Measure the real origin instead of assuming')
console.log(' the viewport (see fixedOrigin() in FilterDropdown.vue).')
}
}
}
console.log('')
}
if (failed) {
console.error(`FIXED-ANCHORING: FAIL — ${failed} problem(s) across ${hosts.length} host(s).`)
process.exit(1)
}
console.log(`FIXED-ANCHORING: PASS — ${measured} trigger(s) anchored across ${hosts.length} hosts.`)
console.log(' The two-host comparison IS the assertion. A single-host pass proves nothing about a')
console.log(' popover, because the defect lives in the HOST chain, not in the component.')

View file

@ -70,6 +70,11 @@ const GATES = [
{ script: 'check:a11y-button-names', why: 'every visible button has an accessible name' },
{ script: 'check:nav-reachable', why: 'a phone can open the cap-5 tab rail' },
{ script: 'smoke:share-all', why: 'every Share button emits a URL that reopens the right element' },
// ★ The only gate here that measures ONE component in TWO hosts. It exists because a popover can
// be perfectly anchored standalone and 256px adrift inside the cap-5 shell, and no single-host
// gate above can tell those apart — the defect lives in the host's ancestor chain, not the
// component. See internal/check-fixed-anchoring.mjs.
{ script: 'check:fixed-anchoring', why: 'the filter dropdown stays anchored in BOTH hosts, not just standalone' },
]
// ── PRECONDITION 1: a built tree ────────────────────────────────────────────

View file

@ -72,6 +72,52 @@ const triggerRef = ref(null)
*/
const menuStyle = ref({})
/**
* ── ★ A `fixed` MENU IS ONLY VIEWPORT-ANCHORED IF NOTHING CAPTURES IT ──
* (card BUG-FILTER-DROPDOWN-DETACHED-FROM-ITS-TRIGGER, reopened after a wrong close)
*
* position:fixed resolves against the viewport ONLY while no ancestor establishes a containing
* block for fixed descendants. transform, filter, perspective, backdrop-filter, will-change on
* any of those, and `contain` with paint/layout/strict/content ALL do establish one. When any is
* present the viewport coordinates below are applied relative to THAT ancestor instead, and the
* menu lands offset by exactly the ancestor's own position.
*
* MEASURED, not theorised. library/layouts/VerticalTabsLayout.vue — the cap-5 shell that hosts
* every plugin as a tab — sets `contain: layout paint` on .vtl-content, which sits inside
* `.vtl-main { left: 256px }`. Live probe at 1600x900 inside plugin-studio, dashboard-plus-grid
* tab: every trigger read dLeft=256 dTop=111, with the ancestor walk naming the culprit
* `div.vtl-content [contain:layout paint]`. The same dropdowns in the STANDALONE dashboard read
* dLeft=0 dTop=4. Same component, same CSS, two hosts, opposite results — which is why a probe
* that only ever measured the standalone composite passed six times while the Driver, who uses
* the SPA, kept reporting the menu "in a random spot further down to the right". It was never
* random: it was the rail width plus the header height, exactly.
*
* ★ WHY NOT JUST DROP `contain` FROM THE LAYOUT. It is load-bearing there for a different reason
* (it stops a rail collapse from reflowing a heavy mounted map), and removing it would trade this
* bug for that one. Two locally-correct decisions composed into a globally-wrong result; the fix
* belongs where the assumption lives, not where the containment does.
*
* So: do not ASSUME the containing block is the viewport — MEASURE it. offsetParent-style walking
* is unreliable through shadow roots, so we read the offset from a probe element that is itself
* position:fixed at 0,0: whatever it reports as its own client rect IS the origin this menu's
* fixed coordinates will be measured from. Subtracting that origin makes the placement correct in
* EVERY host — including hosts that do not exist yet, which is the point of putting it here rather
* than special-casing VerticalTabsLayout.
*/
function fixedOrigin(el) {
const doc = el.ownerDocument
const probe = doc.createElement('div')
probe.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;pointer-events:none;opacity:0'
// Insert INSIDE the same root as the menu so it shares the menu's ancestor chain (and therefore
// its containing block). A probe appended to document.body would report the viewport even when
// the menu is captured — it would confirm the assumption instead of testing it.
const host = el.parentElement || el.getRootNode().body || doc.body
host.appendChild(probe)
const r = probe.getBoundingClientRect()
probe.remove()
return { x: r.left, y: r.top }
}
function positionMenu() {
const t = triggerRef.value
if (!t) return
@ -117,9 +163,18 @@ function positionMenu() {
// a short band near the bottom of the viewport).
const below = vh - r.bottom
const flipUp = below < 180 && r.top > below
// ── ★ TRANSLATE VIEWPORT COORDS INTO THE REAL CONTAINING BLOCK ──
// Everything above is computed in viewport space, which is the right space to REASON in (edges,
// flips and overflow are all viewport facts). But the values are written to a fixed element whose
// origin may not be the viewport. `o` is that origin, measured live; when nothing captures the
// menu it is {0,0} and every number below is unchanged, so the standalone behaviour that already
// measured dLeft=0 dTop=4 is preserved bit-for-bit.
const o = fixedOrigin(t)
const L = Math.round(left - o.x)
menuStyle.value = flipUp
? { top: 'auto', bottom: `${Math.round(vh - r.top + GAP)}px`, left: `${left}px`, width: `${width}px`, maxHeight: `${Math.round(Math.min(MENU_MAX, r.top - GAP - EDGE))}px` }
: { top: `${Math.round(r.bottom + GAP)}px`, bottom: 'auto', left: `${left}px`, width: `${width}px`, maxHeight: `${Math.round(Math.min(MENU_MAX, below - GAP - EDGE))}px` }
? { top: 'auto', bottom: `${Math.round(vh - r.top + GAP + o.y)}px`, left: `${L}px`, width: `${width}px`, maxHeight: `${Math.round(Math.min(MENU_MAX, r.top - GAP - EDGE))}px` }
: { top: `${Math.round(r.bottom + GAP - o.y)}px`, bottom: 'auto', left: `${L}px`, width: `${width}px`, maxHeight: `${Math.round(Math.min(MENU_MAX, below - GAP - EDGE))}px` }
}
// `true` = capture phase, so this also sees scrolls inside the dashboard's inner scroll

View file

@ -85,7 +85,8 @@
"smoke:studio": "node internal/smoke-studio.mjs # 5s DEV-surface check: / mounts the studio, tabs>0, 0 4xx (exit 2 = dev server down)",
"check:manifest-covers": "node internal/check-manifest-covers-discovery.mjs # every DISCOVERED (non-gated) bundle must be in the built manifest",
"check:csv-shape": "node internal/check-csv-export-shape.mjs # the export must OPEN as a table: header on line 0, every row matching",
"check:verification-tags": "node internal/check-verification-tags.mjs"
"check:verification-tags": "node internal/check-verification-tags.mjs",
"check:fixed-anchoring": "node internal/check-fixed-anchoring.mjs"
},
"devDependencies": {
"@capacitor/android": "^7.6.9",