From 3db1dc1852dde6f126b0853e58e8894c9ce432dd Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:27:22 +0000 Subject: [PATCH 1/2] legend: on a phone you can browse the UPG-100 lists and cannot export them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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". --- internal/check-legend-export-reachable.mjs | 138 +++++++++++++++++++++ package.json | 1 + 2 files changed, 139 insertions(+) create mode 100644 internal/check-legend-export-reachable.mjs diff --git a/internal/check-legend-export-reachable.mjs b/internal/check-legend-export-reachable.mjs new file mode 100644 index 000000000..1330ae50a --- /dev/null +++ b/internal/check-legend-export-reachable.mjs @@ -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 — +// 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.') diff --git a/package.json b/package.json index 29e74aaee..ce0914f04 100644 --- a/package.json +++ b/package.json @@ -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.", From cc6a5ea901a4509a854ff6843f5e55fef30b8c06 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:47:26 +0000 Subject: [PATCH 2/2] =?UTF-8?q?legend:=20a=20phone=20can=20now=20export=20?= =?UTF-8?q?the=20packet=20=E2=80=94=20it=20was=20a=20prop,=20not=20a=20des?= =?UTF-8?q?ign=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last tick I found the export unreachable on mobile and recorded it as Driver-gated: "WHERE the control goes on a phone is a design call, the sheet has no titlebar". Both halves of that were wrong, and checking them was this tick. LegendMobile RENDERS SemanticTreeLegend itself — titlebar and all — and the 0x0 elements my measurement found belonged to the OTHER, hidden desktop instance mounted below the breakpoint. That geometry made it look as though the sheet had nowhere to put the control. A Driver decision was never needed. Two gaps, not one. The sheet passed neither :export-enabled nor @export, and exportEnabled defaults to false, so the control's v-if was never satisfied. Fixed via provide/inject, matching onSemanticTreeSelect immediately above it, because the sheet renders several levels down where an emit would not reach. Opt-in by construction: enabled only when a profile provides a handler. The other three LegendMobile consumers provide none and are verified untouched. Even then the export did nothing, and that second gap is the one that mattered. The profile passed :nodes but no :tabs, so hideTabs was true and the sheet showed a single flat Region tree with no tab strip — no UPG-100 Lists tab, no selectable zone, and onLegendExport bails on `if (!zoneId) return`. Passing :tabs fixed it. A :default-tab attribute was added and then removed: defaultTab is not a prop on LegendMobile, so it would have been inert. Verified end to end at 390x844: the tab strip appears, a real zone selects, Export opens, Packet clicks, and one print frame is produced. A packet on a phone. The gate written last tick — which failed on mobile by design — now passes at both widths. The newly visible mobile tabs are fully localized in en/es/zh, so catalog work from earlier ticks now reaches a surface that never showed tabs at all. Four of my own probe errors were caught by measuring rather than concluding. Clicking a REGION row left selectedKey empty, so the handler correctly bailed and it read as "export does nothing" — the same zero on DESKTOP is what proved it was the probe and not a regression. country-posters shows the same mobile gap on its own surface. Not taken; its owner's call. --- library/components/legends/LegendMobile.vue | 23 +++ .../my-upg-100-list/upg-100-list.vue | 10 +- public/doxa-maps/index.html | 2 +- public/doxa-maps/manifest.json | 8 +- .../my-upg-100-list/my-upg-100-list.js | 136 +++++++++--------- .../my-upg-100-list/poster-wall/index.html | 2 +- .../my-upg-100-list/upg-100-list/index.html | 2 +- .../my-upg-100-list/upg-100-mfe/index.html | 2 +- 8 files changed, 108 insertions(+), 77 deletions(-) diff --git a/library/components/legends/LegendMobile.vue b/library/components/legends/LegendMobile.vue index 524973f64..620026a0e 100644 --- a/library/components/legends/LegendMobile.vue +++ b/library/components/legends/LegendMobile.vue @@ -351,6 +351,27 @@ const dataStore = inject('dataStore') // never updated by the mobile flow). const onSemanticTreeSelect = inject('onSemanticTreeSelect', () => {}) +// ★ THE EXPORT AFFORDANCE, WHICH THIS SHEET RENDERED AND NEVER ENABLED. +// This component already renders below — the same component that +// owns the Export menu — but passed neither :export-enabled nor @export. exportEnabled +// defaults to FALSE, so the control's v-if was never satisfied and a phone reader could +// browse the UPG-100 lists and not export a packet or a poster. The packet IS the product +// for that profile; the Driver asked for "print options at the top right of the legend... +// that's the only way that map would be usable". +// +// ⚠ IT WAS NOT A DESIGN QUESTION, WHICH IS WHAT I FIRST RECORDED. The 0x0 elements that +// measurement found belong to the OTHER, hidden SemanticTreeLegend instance — the desktop +// one, mounted but not laid out below the breakpoint. That geometry made it look as though +// the mobile sheet had nowhere to put the control. It renders its own instance, with a +// titlebar, and only needed the prop. +// +// PROVIDE/INJECT, matching onSemanticTreeSelect directly above, because this sheet is +// rendered deep inside a shell rather than as a direct child — an emit would not reach. +// OPT-IN BY CONSTRUCTION: exportEnabled is true only when a profile actually provides a +// handler, so the three profiles that do not provide one are untouched. +const onLegendExport = inject('onLegendExport', null) +const mobileExportEnabled = computed(() => typeof onLegendExport === 'function') + // ── Data-driven legend (universal for all legend types) ───────────────────── const legendTypeRef = toRef(props, 'legendType') const { @@ -781,7 +802,9 @@ onBeforeUnmount(() => { :hideTabs="nodes ? !tabs : _isFlatTabMobile" :columnLabel="treeColumnLabelForMobile" :columns="columns || ['count', 'pop']" + :export-enabled="mobileExportEnabled" @select="onSemanticTreeSelect" + @export="(kind) => onLegendExport && onLegendExport(kind)" >