doxa-maps/internal/check-filter-width-stable.mjs
doxa-maps-88655c70 7afbb9187c
Some checks are pending
build-gate / build (push) Waiting to run
Assert a filter trigger keeps its width when an option is selected
.filter-dropdown was flex:1 1 160px with min-width:160px and max-width:240px
-- a range, not a width -- so flex-grow renegotiated the moment the selected
label appeared. The row grew 960 to 1294px and triggers reached 240px.

Not cosmetic. The selections row with Clear All sits underneath the filters,
so a filter that grows on selection re-flows the row below it and the band
stops being a fixed block. The stable width is what lets the centred stack
hold still.

Independently reproduced before gating rather than taken from the agent
report: 168px across all six triggers, identical after selecting the longest
option in each. The negative test then reproduced the broken widths
[240,240,240,184,171,219] -- the same array the original report cited, which
confirms that measurement was real.

Asserts the observable outcome, not the CSS. A grep for --dx-filter-w would
pass while a missing min-width:0 on the label still pushed the box open: as a
flex item without it the label is floored at min-content and refuses to
ellipsise. Both halves are needed, so the gate measures the width.

Selecting the LONGEST option is load-bearing. A short option fits inside the
old range too, so a probe that clicks the first option passes on a build that
still has the bug.

Two hosts, and an unmeasurable host fails rather than skips -- three defects
this week were invisible standalone and appeared only in the shell.

Negative-tested: restoring the flex range fails both hosts by name, naming the
before and after arrays and the total growth.
2026-09-22 19:46:49 +00:00

125 lines
6.3 KiB
JavaScript

#!/usr/bin/env node
/**
* check-filter-width-stable.mjs — a filter trigger must not resize when an option is selected.
*
* THE DEFECT THIS CATCHES. `.filter-dropdown` was `flex: 1 1 160px; min-width:160px; max-width:240px`
* — a RANGE, not a width — so flex-grow re-negotiated the moment
* `<strong>{{ selectedOption.label }}</strong>` appeared in the label. Measured before the fix: the
* row grew 960 → 1294px and individual triggers reached 240px.
*
* ★ WHY IT IS NOT COSMETIC, in the Driver's own reasoning: the selections row with Clear All sits
* UNDERNEATH the filters. If a filter grows on selection, the row below re-flows and the whole group
* stops being the aligned block he asked for. The stable width is what makes the centred stack hold
* still — so this is a layout invariant, not a preference about widths.
*
* The ellipsis rules were already present and doing nothing: `.filter-dropdown__label` lacked
* `min-width: 0`, so as a flex item it was floored at min-content and refused to truncate. Both
* halves are needed, which is why this asserts the OBSERVABLE outcome (width unchanged) rather than
* the presence of either CSS property — a source grep for `--dx-filter-w` would pass while the
* missing `min-width:0` still let the label push the box open.
*
* WHAT IT ASSERTS. On the standalone dashboard AND inside the cap-5 shell: record every trigger's
* width with nothing selected, then select the LONGEST option in each dropdown, and require every
* width to be unchanged.
*
* Selecting the longest option is the point. A short option fits inside the old range too, so a
* probe that picks the first option passes on the broken build — the bug only appears when the
* label exceeds the flex-basis.
*
* TWO HOSTS, because three defects this week were invisible standalone and only appeared in the
* shell (dropdown containing-block, wheel scroll, grid affordances). An unmeasurable host is a
* FAILURE here, not a skip: silence is how coverage rots.
*
* PRECONDITION: a built tree and `bun run serve` on 4173. No Mapbox token needed — this measures
* DOM geometry, and the filter row renders with or without a basemap.
*
* NEGATIVE-TESTED: restoring `flex:1 1 160px; max-width:240px` on .filter-dropdown makes widths
* diverge and this fails by name, naming the before/after arrays.
*/
import { chromium } from 'playwright'
const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/doxa-maps'
const WALK = `(sel)=>{const o=[],s=new Set();const w=(r)=>{if(!r||s.has(r))return;s.add(r);try{o.push(...r.querySelectorAll(sel))}catch(e){};for(const e of r.querySelectorAll('*'))if(e.shadowRoot)w(e.shadowRoot)};w(document);return o}`
async function measure(page, label, activateTab) {
const widths = () =>
page.evaluate(`(${WALK})('.filter-dropdown__trigger').map(t=>Math.round(t.getBoundingClientRect().width))`)
if (activateTab) {
// Heavy tabs lazy-mount on activation: before the click nothing exists and every query returns
// 0, which reads as "no filters here" rather than "not mounted yet".
const idx = await page.evaluate(`(${WALK})('.vtl-tab').findIndex(t=>${activateTab}.test(t.textContent||''))`)
if (idx < 0) return { label, error: 'no tab matched ' + activateTab }
await page.evaluate(`(${WALK})('.vtl-tab')[${idx}].click()`)
await page.waitForTimeout(9000)
}
const before = await widths()
if (!before.length) return { label, error: 'no .filter-dropdown__trigger found in the composed tree' }
for (let i = 0; i < before.length; i++) {
await page.evaluate(`(${WALK})('.filter-dropdown__trigger')[${i}].click()`)
await page.waitForTimeout(300)
// The LONGEST option — a short one fits the old range and passes on a broken build.
await page.evaluate(`(()=>{const o=(${WALK})('.filter-dropdown__option');if(!o.length)return;
let best=o[0];for(const x of o) if((x.textContent||'').length>(best.textContent||'').length) best=x;
best.click()})()`)
await page.waitForTimeout(350)
}
const after = await widths()
return { label, before, after }
}
const browser = await chromium.launch()
const hosts = []
for (const [label, path, tab] of [
['STANDALONE research-dashboard', '/research-dashboard/research-dashboard/', null],
['INSIDE cap-5 plugin-studio', '/plugin-studio/plugin-studio/', '/dashboard-plus-grid/i'],
]) {
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } })
try {
await page.goto(BASE + path, { waitUntil: 'load', timeout: 45000 })
await page.waitForTimeout(7000)
hosts.push(await measure(page, label, tab))
} catch (e) {
hosts.push({ label, error: e.message.slice(0, 140) })
}
await page.close()
}
await browser.close()
let failed = 0
console.log('FILTER-WIDTH: a trigger must not resize when an option is selected\n')
for (const h of hosts) {
console.log(`--- ${h.label} ---`)
if (h.error) {
// An unmeasured host is a failure. A gate that quietly drops a host reports success about
// something it never looked at.
console.log(' UNMEASURED: ' + h.error)
failed++
continue
}
const same = JSON.stringify(h.before) === JSON.stringify(h.after)
console.log(` empty : ${JSON.stringify(h.before)}`)
console.log(` selected : ${JSON.stringify(h.after)}`)
if (same) {
console.log(' ok — identical\n')
} else {
failed++
const grew = h.after.reduce((a, w, i) => a + Math.max(0, w - h.before[i]), 0)
console.log(` FAIL — widths changed; the row grew ${grew}px in total\n`)
}
}
if (failed) {
console.error(`FILTER-WIDTH: FAIL — ${failed} host(s).`)
console.error(' A trigger that grows on selection re-flows the selections row beneath it, so the')
console.error(' filter band stops being a fixed block and the centred stack shifts under the user.')
console.error(' Give .filter-dropdown a fixed width (var(--dx-filter-w)) rather than a flex RANGE,')
console.error(' and give .filter-dropdown__label min-width:0 so it is allowed to ellipsise — a flex')
console.error(' item without it is floored at min-content and pushes the box open regardless.')
process.exit(1)
}
console.log('FILTER-WIDTH: PASS — widths identical before and after selecting the longest option,')
console.log(' in both hosts. The longest option is deliberate: a short one fits the old broken')
console.log(' range too, so picking the first option would pass on a build that still has the bug.')