legend: on a phone you can browse the UPG-100 lists and cannot export them
Some checks are pending
build-gate / build (push) Waiting to run

The Export affordance lives in SemanticTreeLegend, which upg-100-list.vue mounts
for desktop only — its own comment says "Hidden below the mobile breakpoint,
where <LegendMobile>...". LegendMobile, the phone bottom-sheet, contains the
string "export" zero times. So the whole export path is desktop-only.

The packet IS the product for this profile. The Driver asked for "print options
at the top right of the legend... that's the only way that map would be usable".
A reader on a phone gets the browsing half and none of the printing half, with
no error and nothing to click — the failure is invisible rather than broken,
which is why it survived.

Measured at 390x844 against 1500x1000. Desktop: selecting a row takes
.stl-export from 0 to 3 elements, all with a real box, labelled "Export".
Mobile: the same 3 elements exist and measure 0x0 at (0,0), with display:block
and visibility:visible, inside an overflow:hidden parent — the desktop panel
rendered without being laid out.

Two earlier readings of mine were wrong, and the gate now encodes against both.
"No export button at either width" — I had not selected a row, and the control
is gated on selectedNode. "No legend rows on mobile" — 20 rows exist with 0
visible, because the sheet starts closed. Both were true statements about the
wrong surface, and both would have been reported as product defects.

The gate asserts a real box rather than a CSS property, for the same reason: the
mobile elements report visibility:visible while measuring nothing.

It FAILS on mobile by design and is deliberately not wave-wired. WHERE the
control goes on a phone is a design call — the sheet has no titlebar to put a
top-right action in, and SemanticTreeLegend is shared by four profiles — so the
gate names the gap instead of assuming a fix. Mutation-tested: flipping
SHOW_EXPORT_BUTTON off drops desktop to zero and the message distinguishes "no
element at all" from "exists but 0x0".
This commit is contained in:
doxa-maps-88655c70 2026-09-24 17:27:22 +00:00
parent 1cfb0578d9
commit 3db1dc1852
2 changed files with 139 additions and 0 deletions

View file

@ -0,0 +1,138 @@
#!/usr/bin/env node
// check-legend-export-reachable — the Export affordance must be reachable AT BOTH WIDTHS.
//
// ★ WHAT THIS CAUGHT. The Export menu lives in SemanticTreeLegend, which upg-100-list.vue mounts
// for DESKTOP only ("Hidden below the mobile breakpoint, where <LegendMobile>..."). LegendMobile —
// the phone bottom-sheet — contains the string "export" ZERO times. So on a phone a reader can
// browse the UPG-100 lists and cannot export a packet or a poster. No error, nothing to click; the
// failure is invisible rather than broken, which is why it survived.
//
// The packet IS the product for this profile. The Driver: "print options at the top right of the
// legend... that's the only way that map would be usable."
//
// ⚠ THE CONTROL IS GATED ON A SELECTION. `v-if="SHOW_EXPORT_BUTTON && exportEnabled && selectedNode"`
// — so a probe that merely loads the page finds nothing at EITHER width and concludes "no export
// anywhere". Measured: that was my first reading and it was wrong. This gate SELECTS A LEAF ROW
// first, and asserts the 0 -> n transition, which is also what proves the control is selection-gated
// rather than always-on.
//
// ⚠ AND ON MOBILE THE LEGEND STARTS CLOSED. 20 rows exist with 0 visible until the "Show or hide
// the legend" control is clicked. A probe that skips that step reports "no rows on mobile", which is
// a true statement about the wrong surface — the second wrong reading this gate encodes against.
//
// ⚠ VISIBILITY IS GEOMETRY, NOT A CSS PROPERTY. The mobile elements report display:block and
// visibility:visible while measuring 0x0 at (0,0) inside an overflow:hidden parent — the desktop
// panel rendered but never laid out. A predicate reading `visibility` would call that visible.
// Assert a real box.
//
// Usage: node internal/check-legend-export-reachable.mjs [leafUrl]
// SKIPs when nothing is served — this needs the built tree.
import { chromium } from 'playwright'
const DEFAULT_LEAF = (process.env.BASE_URL || 'http://127.0.0.1:4173')
+ '/doxa-maps/my-upg-100-list/upg-100-list/index.html'
const LEAF = process.argv[2] || DEFAULT_LEAF
let reachable = false
try {
const r = await fetch(LEAF, { signal: AbortSignal.timeout(4000) })
reachable = r.ok
} catch { /* a precondition, not a product failure */ }
if (!reachable) {
console.log(`LEGEND-EXPORT-REACHABLE: SKIP — nothing served at ${LEAF}.`)
console.log(' Serve the built tree: python3 -m http.server 4173 --directory public (or set BASE_URL)')
process.exit(0)
}
const WALK = `(root, out) => { out = out || []; const rec = (r) => { for (const e of r.querySelectorAll('*')) { out.push(e); if (e.shadowRoot) rec(e.shadowRoot) } }; rec(root); return out }`
async function probe(browser, { name, width, height, mobile }) {
const page = await browser.newPage({ viewport: { width, height }, isMobile: mobile, hasTouch: mobile })
const errs = []
page.on('pageerror', (e) => errs.push(String(e).slice(0, 110)))
await page.goto(LEAF, { waitUntil: 'load', timeout: 45000 })
await page.waitForTimeout(9000)
const before = await page.evaluate(`(${WALK})(document).filter(e => (e.className||'').toString().includes('stl-export')).length`)
// On a phone the legend starts closed — open it first, or every row query returns nothing.
const openedSheet = await page.evaluate(`(() => {
const all = (${WALK})(document)
const b = all.filter(e => e.tagName === 'BUTTON')
.find(e => /show or hide the legend|toggle.?legend/i.test(e.getAttribute('aria-label') || ''))
if (!b) return false
b.click(); return true
})()`)
if (openedSheet) await page.waitForTimeout(2200)
// Select a LEAF row. Height-bounded because a container also carries .stl-row-ish classes and
// clicking it selects nothing — measured: its textContent is the whole list concatenated.
const clicked = await page.evaluate(`(() => {
const rows = (${WALK})(document)
.filter(e => (e.className||'').toString().includes('stl-row'))
.filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.height < 60 })
if (!rows.length) return null
rows[0].click()
return (rows[0].textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 30)
})()`)
await page.waitForTimeout(2200)
const after = await page.evaluate(`(() => {
const el = (${WALK})(document).filter(e => (e.className||'').toString().includes('stl-export'))
// A REAL BOX, not a CSS property: the mobile case reports visibility:visible at 0x0.
const boxed = el.filter(e => { const r = e.getBoundingClientRect(); return r.width > 2 && r.height > 2 })
return { total: el.length, boxed: boxed.length,
label: boxed.map(e => (e.textContent || '').trim().slice(0, 18))[0] || '' }
})()`)
await page.close()
return { name, before, openedSheet, clicked, ...after, errs }
}
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
let results
try {
results = [
await probe(browser, { name: 'desktop', width: 1500, height: 1000, mobile: false }),
await probe(browser, { name: 'mobile', width: 390, height: 844, mobile: true }),
]
} finally {
await browser.close()
}
console.log('LEGEND-EXPORT-REACHABLE')
console.log(` leaf : ${LEAF}`)
for (const r of results) {
console.log(` ${r.name.padEnd(8)}: selected ${JSON.stringify(r.clicked)} — `
+ `.stl-export ${r.before} -> ${r.total} present, ${r.boxed} with a real box`
+ `${r.label ? ` ("${r.label}")` : ''}${r.openedSheet ? ' [opened the sheet first]' : ''}`)
}
const fails = []
for (const r of results) {
if (!r.clicked) {
fails.push(`${r.name}: no selectable legend row — the probe could not reach the state the `
+ 'export control is gated on, so this width was not actually tested')
continue
}
if (r.boxed === 0) {
fails.push(`${r.name}: the Export affordance has NO reachable box`
+ (r.total > 0
? ` — ${r.total} .stl-export element(s) exist but all measure 0x0, i.e. the desktop legend `
+ 'rendered without being laid out. A reader here can browse the lists and cannot export '
+ 'a packet or a poster.'
: ' — no .stl-export element at all.'))
}
if (r.errs.length) fails.push(`${r.name}: ${r.errs.length} page error(s): ${r.errs[0]}`)
}
if (fails.length) {
console.error(`\nLEGEND-EXPORT-REACHABLE: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
console.error('\n The packet IS the product for this profile. See '
+ 'BUG-UPG100-PACKET-AND-POSTER-EXPORT-ARE-UNREACHABLE-ON-A-PHONE — WHERE the control goes on a '
+ 'phone is a design call (the sheet has no titlebar), so this gate names the gap rather than '
+ 'assuming a fix.')
process.exit(1)
}
console.log('\nLEGEND-EXPORT-REACHABLE: PASS — selecting a list reveals a reachable Export control at '
+ 'every width tested.')

View file

@ -67,6 +67,7 @@
"check:locale-reachable": "node internal/check-locale-reachable.mjs",
"check:poster-prints": "node internal/check-poster-prints.mjs",
"check:provider-switch": "node internal/check-provider-switch.mjs # the provider switch must actually SWITCH: counts requests BY HOST (not window globals, which stay defined after a swap) and asserts the maplibre run stays within the leaf HTML's own declared api.mapbox.com preloads. Defaults to the upg-100 leaf; pass a URL or set BASE_URL for another. CONDITIONAL: SKIPs when nothing is served.",
"check:legend-export-reachable": "node internal/check-legend-export-reachable.mjs # the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. ⚠ CURRENTLY FAILS ON MOBILE BY DESIGN: the export lives in the desktop legend only, and WHERE it goes on a phone is a Driver call (BUG-UPG100-PACKET-AND-POSTER-EXPORT-ARE-UNREACHABLE-ON-A-PHONE). Not wave-wired until then. SKIPs when nothing is served.",
"check:mfe-two-tab": "node internal/check-mfe-two-tab.mjs",
"check:share-policy": "node internal/check-share-policy.mjs",
"check:zone-census": "node internal/check-zone-census.mjs # every zone a USER CAN SELECT must have a poster (asks the LEGEND, not clusters.json — two collapsed deaf lists are unreachable and not a gap). CONDITIONAL: SKIPs without out/posters/, which is gitignored — run `bun run posters` first. It cannot be fixtured like check:packets-all, because it asserts COVERAGE of real generated output and a fixture would only assert the fixture is complete.",