doxa-maps/internal/check-fixed-anchoring.mjs
doxa-maps-88655c70 236d289cc7 Point the anchoring gate at index.html too, so it can actually measure
Second gate caught by the strict server. check-fixed-anchoring loaded both hosts
by bare-directory URL, which the old permissive local server resolved and the
live layer does not, so it was green only because local was more forgiving than
production.

Worth noting how it failed: UNMEASURED for both hosts, not a false pass. It
refuses to report success when it could not measure, which is the difference
between this gate and one that would have found zero dropdowns and called that
zero anchoring problems.

Now passing with 16 triggers anchored across both hosts, Status among them --
so the eighth filter anchors correctly in the composite as well as standalone.

A sweep of internal/ finds no remaining bare-directory leaf URLs.
2026-09-23 03:27:10 +00:00

153 lines
8.3 KiB
JavaScript

#!/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()
// ★ index.html IS EXPLICIT. These were bare-directory URLs. The old local server resolved a
// directory to its index; the live static layer returns 404, so this gate was only ever green
// because the local server was more forgiving than production. Under serve-strict.mjs it reported
// UNMEASURED for BOTH hosts the first time it ran — correctly refusing to claim a pass it could not
// measure, which is the difference between this gate and one that would have found 0 dropdowns and
// called that 0 problems.
const hosts = [
await hostRun(browser, 'STANDALONE research-dashboard', `${BASE}/research-dashboard/research-dashboard/index.html${TK}`, null),
await hostRun(browser, 'INSIDE cap-5 plugin-studio', `${BASE}/plugin-studio/plugin-studio/index.html${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.')