diff --git a/internal/check-fixed-anchoring.mjs b/internal/check-fixed-anchoring.mjs new file mode 100644 index 000000000..3e640a30c --- /dev/null +++ b/internal/check-fixed-anchoring.mjs @@ -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.') diff --git a/internal/verify-browser-all.mjs b/internal/verify-browser-all.mjs index d2d6b0d7c..c9edeb66c 100644 --- a/internal/verify-browser-all.mjs +++ b/internal/verify-browser-all.mjs @@ -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 ──────────────────────────────────────────── diff --git a/library/components/search/FilterDropdown.vue b/library/components/search/FilterDropdown.vue index 588abb88d..410f532ba 100644 --- a/library/components/search/FilterDropdown.vue +++ b/library/components/search/FilterDropdown.vue @@ -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 diff --git a/package.json b/package.json index eef829520..05e2958f9 100644 --- a/package.json +++ b/package.json @@ -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",