From 5a73e4d37dba9f92d80ba9ec6336ae3df89c9ab8 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 13:04:51 +0000 Subject: [PATCH] Guard the pan's control clearance, which a cut-height check cannot see The popup auto-pan had no gate. Its founding bug was invisible to the obvious one: measured at 390 on a 366x732 map, the pan fitted the popup inside the container, the cut really was zero, and a real click on Full Profile still did nothing, because the top element at the button's centre was div.maplibregl-ctrl-attrib-inner on one pin and span.pin-hint-pill on the other. Two pins that looked fixed and were not. So this asserts the property that matters -- where the popup ENDS UP relative to the band that takes the click -- rather than whether it was clipped. It runs the SHIPPED panPlanFor, read out of the SFC with new Function because it is not exported, the same way check:optional-t-equivalence reaches useOptionalT. A re-typed copy of the arithmetic would pass forever while the component drifted, which is the one failure a geometry gate must not have. POPUP_OFFSET is read from the source too rather than assumed. Each case states the resulting geometry independently instead of re-deriving the expected number from the same formula: a self-consistent oracle cannot see an error that shifts both sides, which is how a 12px offset hid here once already. The band is measured at runtime, never hard-coded, so the rule is swept across 0-120px rather than pinned to one number -- 120 is far beyond anything observed and is there to prove the rule, not the value. Also swept across every width the component renders at, since below 768px the container is 2x width. Mutation-tested three ways, each failing the case written for it: ignoring bottomInset (the pre-fix behaviour) fails 15 cases; reserving only half the band fails; dropping the Math.max clamp fails exactly the negative-inset case. 24 cases, registered in all three places, verified by re-reading the parsed file. --- internal/check-popup-pan-clears-controls.mjs | 143 +++++++++++++++++++ package.json | 6 +- 2 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 internal/check-popup-pan-clears-controls.mjs diff --git a/internal/check-popup-pan-clears-controls.mjs b/internal/check-popup-pan-clears-controls.mjs new file mode 100644 index 000000000..a01d703e9 --- /dev/null +++ b/internal/check-popup-pan-clears-controls.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/** + * check-popup-pan-clears-controls — the popup auto-pan reserves the map's bottom control band, so a + * popup that clears the CLIP is also clear of the things that take the click. + * + * ★ THE BUG THIS GUARDS, measured in a browser at 390 on a 366x732 map. The pan fitted the popup + * inside the CONTAINER and stopped there. The attribution and the pin hint are painted over the + * canvas and accept pointer events, so both NER pins panned, both popups ended fully inside the + * frame (cut 0), and a real click on Full Profile did NOTHING — at y~701 the top element was + * div.maplibregl-ctrl-attrib-inner on one pin and span.pin-hint-pill on the other. Two pins that + * looked fixed and were not: a cut-height check cannot see this, because the cut really was zero. + * + * Attribution is a LICENCE TERM of the basemap, so raising the popup above it is not available to + * us. The usable frame is simply shorter than the container, and the fit test has to say so. + * + * ★ IT RUNS THE SHIPPED FUNCTION, NOT A COPY. panPlanFor is not exported — it lives inside the SFC + * — so the source is read and the function evaluated with `new Function`, the same way + * check-optional-t-equivalence reaches useOptionalT. A re-typed copy of the arithmetic would pass + * forever while the component drifted, which is the one failure a geometry gate must not have. + * + * ★ AND IT ASSERTS THE GEOMETRY, NOT THE RETURN VALUE. Re-deriving the expected number from the + * same formula would be a self-consistent oracle: an error that shifted both sides would stay + * invisible. So each case states where the popup ENDS UP and requires that to clear the band. + * + * Exit 0 pass · 1 fail · 2 the function could not be found (never a silent pass). + */ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const SRC = join(ROOT, 'library/components/maps/RegionCountryMap.vue') +const src = readFileSync(SRC, 'utf8') + +// The whole function body, from its signature to the closing brace at column 0. +const m = src.match(/function panPlanFor\(\{[\s\S]*?\n\}/) +if (!m) { + console.error('POPUP-PAN-CONTROLS: NOT MEASURED — panPlanFor was not found in RegionCountryMap.vue.') + console.error(' It may have been renamed or moved. Point this gate at it rather than deleting the gate:') + console.error(' a geometry rule with no test is how the bottom-control bug shipped the first time.') + process.exit(2) +} +let panPlanFor +try { panPlanFor = new Function(`${m[0]}\nreturn panPlanFor`)() } catch (e) { + console.error(`POPUP-PAN-CONTROLS: NOT MEASURED — panPlanFor did not evaluate: ${e.message}`) + process.exit(2) +} + +// POPUP_OFFSET is the component's own gap between pin and popup; read it rather than assume 12. +const offMatch = src.match(/const POPUP_OFFSET = (\d+)/) +const OFFSET = offMatch ? Number(offMatch[1]) : 12 + +let bad = 0 +const ok = (cond, what) => { if (!cond) bad++; console.log(` ${cond ? 'ok ' : 'FAIL'} ${what}`) } + +/** + * Where the popup's far edge lands after the plan is applied. + * A positive plan moves the PIN down the screen and the popup opens ABOVE it; a negative plan moves + * the pin up and the popup opens BELOW. Stated here from the geometry, independently of panPlanFor. + */ +function popupBottomAfter({ containerH, popupH, pinY, bottomInset }) { + const dy = panPlanFor({ containerH, popupH, pinY, offset: OFFSET, bottomInset }) + const pin = pinY + dy + return { dy, bottom: dy > 0 ? pin - OFFSET : pin + popupH + OFFSET } +} + +// ── 1. THE MEASURED CASE. NER at 390: a 366x732 map, a ~452px popup, a pin in the lower half. +{ + const c = { containerH: 732, popupH: 452, pinY: 345 } + const band = 44 + const withoutBand = popupBottomAfter({ ...c, bottomInset: 0 }) + const withBand = popupBottomAfter({ ...c, bottomInset: band }) + ok(withoutBand.bottom > 732 - band, + `without the band reserved the popup ends at ${Math.round(withoutBand.bottom)}, inside the control strip — the bug`) + ok(withBand.bottom <= 732 - band + 0.5, + `with the band reserved it ends at ${Math.round(withBand.bottom)}, clear of a ${band}px strip`) +} + +// ── 2. THE BAND HEIGHT IS NOT HARD-CODED ANYWHERE. It is measured at runtime, so the rule has to +// hold across the range the bottom chrome really occupies. +// +// ★ THE RANGE IS WIDER THAN THE ATTRIBUTION, and guessing it was my mistake. This list stopped at +// 120px, described as "far beyond anything observed". Then the band was MEASURED in a browser at +// 390: the attribution is only 44px (y678..722), but the PIN-HINT PILL wraps to several lines and +// is 118px tall (y577..699) — an effective band of about 150px while the hint is showing, a quarter +// again beyond the ceiling I had invented. At 1440 the same two are 24px and 38px. +// So the sweep now runs past what has been measured rather than past what I expected, and 200px is +// deliberately unreasonable: the point is that the RULE holds for any band, since the only number +// the component ever uses is the one it measures at runtime. +for (const band of [0, 24, 35, 38, 44, 57, 70, 90, 118, 120, 150, 200]) { + const { bottom } = popupBottomAfter({ containerH: 732, popupH: 452, pinY: 345, bottomInset: band }) + ok(bottom <= 732 - band + 0.5, `band ${String(band).padStart(3)}px: popup ends at ${Math.round(bottom)}, clear of ${732 - band}`) +} + +// ── 2b. THE MEASURED WORST CASE, and the one width where it does NOT fit. With the pin hint +// showing at 390 the band is ~150px, so the popup needs 464 + 150 = 614px of container. Below that +// the pan must REFUSE (plan 0) rather than move the camera to no purpose — the popup stays clipped, +// which is the honest failure, instead of becoming a clipped popup AND a jumped map. +// 296px of container is a real case: a 320px viewport with 12px margins on a host page. +{ + const band = 150 + for (const [containerW, containerH, shouldFit] of [[320, 640, true], [296, 592, false], [390, 780, true]]) { + const dy = panPlanFor({ containerH, popupH: 452, pinY: Math.round(containerH * 0.47), offset: OFFSET, bottomInset: band }) + const fits = (452 + OFFSET) <= containerH - band + ok(fits === shouldFit, `${containerW}px wide (container ${containerH}) with the hint showing: ${fits ? 'fits' : 'does not fit'}`) + if (!fits) ok(dy === 0, ` and an unfittable frame plans NO pan (${dy}) rather than a partial one`) + } +} + +// ── 3. WHAT MUST NOT CHANGE. The band only ever removes space; it must never start a pan that the +// old code would not have made, and never move a popup that already fits. +{ + ok(panPlanFor({ containerH: 780, popupH: 452, pinY: 390, offset: OFFSET, bottomInset: 0 }) + === panPlanFor({ containerH: 780, popupH: 452, pinY: 390, offset: OFFSET }), + 'bottomInset 0 is identical to the behaviour before this rule existed') + ok(panPlanFor({ containerH: 732, popupH: 452, pinY: 600, offset: OFFSET, bottomInset: 44 }) === 0, + 'a pin whose anchor already fits is not moved (measured: pan 0 in both arms on those pins)') + ok(panPlanFor({ containerH: 500, popupH: 480, pinY: 250, offset: OFFSET, bottomInset: 44 }) === 0, + 'a popup taller than the USABLE frame refuses rather than jumping the camera to no purpose') + ok(panPlanFor({ containerH: 732, popupH: 452, pinY: 345, offset: OFFSET, bottomInset: -50 }) + === panPlanFor({ containerH: 732, popupH: 452, pinY: 345, offset: OFFSET }), + 'a negative inset is clamped, never widening the frame beyond the container') + for (const bad of [NaN, undefined, null]) { + ok(panPlanFor({ containerH: 732, popupH: 452, pinY: bad, offset: OFFSET, bottomInset: 44 }) === 0, + `an unmeasurable pin (${String(bad)}) plans no pan rather than NaN`) + } +} + +// ── 4. EVERY WIDTH THIS COMPONENT RENDERS AT still resolves. Container height is 2x width below +// 768px (aspect-ratio 1/2) and max(780, 16/9) at or above it, so a phone is TALLER relative to its +// width, not shorter. With the band reserved the popup must still fit at all of them. +for (const w of [320, 360, 390, 414, 767, 768, 1024, 1440]) { + const containerH = w < 768 ? 2 * w : Math.max(780, Math.round(w * 9 / 16)) + const { bottom } = popupBottomAfter({ containerH, popupH: 452, pinY: Math.round(containerH * 0.47), bottomInset: 57 }) + ok(bottom <= containerH - 57 + 0.5, `${String(w).padStart(4)}px wide (container ${containerH}): popup clears the band`) +} + +if (bad) { + console.error(`\nPOPUP-PAN-CONTROLS: FAIL — ${bad} case(s). The pan no longer guarantees the popup's buttons`) + console.error('are clear of the map\'s bottom controls, so a click on them can land on the attribution instead.') + process.exit(1) +} +console.log('\nPOPUP-PAN-CONTROLS: PASS — the pan clears the bottom control band at every width and band height tested.') diff --git a/package.json b/package.json index a89addb24..24896ea99 100644 --- a/package.json +++ b/package.json @@ -234,6 +234,7 @@ "check:pin-selection": "node internal/check-pin-selection.mjs", "check:poster-action-print": "node internal/check-poster-action-print.mjs", "check:popup-href-escaped": "node internal/check-popup-href-escaped.mjs", + "check:popup-pan-controls": "node internal/check-popup-pan-clears-controls.mjs", "check:viewport-meta": "node internal/check-viewport-meta.mjs", "check:viewport-meta:self-test": "VIEWPORT_META_SELFTEST=1 node internal/check-viewport-meta.mjs", "check:poster-attribution": "node internal/check-poster-attribution.mjs", @@ -332,7 +333,7 @@ "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight", - "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:template-i18n", + "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:template-i18n", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs", @@ -631,7 +632,8 @@ "check:poster-browser-equiv": "a map sheet GENERATED IN THE BROWSER (library/packet/posterSheets.js, the print standard: generate at the click) is byte-identical to the one the Node generator writes, given the same records: it runs the real generate.mjs for one UPG-100 zone (the map-only packet sheet, an India zone so the ADM1 path runs) and one country, draws both through the browser feed (the SERVED boundaries, data/zone-sheet.js, the library name table) and compares bytes. The drawing code is one factory (poster-render.js); this guards the FEED, which is what can still drift. Measures on a fresh checkout: it reads the API-written cache (out/_records.json) when present and otherwise the committed fixture (library/scripts/posters/fixtures/equiv-records-npl-ind.json, the 124 public records the two arms consume), feeds BOTH sides from that same file via RECORDS_FILE, and says which input it measured.", "check:poster-render-pinned": "the map-sheet generator a browser imports at print time (served at assets/lib/poster-render.js, not bundled) is integrity-pinned like every embed, because it executes on partner pages: the pin in library/packet/poster-render.integrity.js equals the sha384 of the source and of the served copy (a stale pin names `bun run pin:poster-render`), a TAMPERED copy is refused and its code never runs (a planted side effect stays unset), and the genuine copy loads and draws.", "pin:poster-render": "rewrite library/packet/poster-render.integrity.js from library/packet/poster-render.js — run after any change to the generator; check:poster-render-pinned fails until it is.", - "check:board-map-options": "the GEOGRAPHIC map caller must state whiteboard/boundaries EXPLICITLY, never inherit them. library/maps/whiteboard-map.js defaults to whiteboard:true/boundaries:false (it was extracted for the whiteboard case), the inverse of what a real map needs, so a caller relying on defaults renders a blank basemap. check:gospel-whiteboard cannot see this — it asserts chart geometry and imports no component." + "check:board-map-options": "the GEOGRAPHIC map caller must state whiteboard/boundaries EXPLICITLY, never inherit them. library/maps/whiteboard-map.js defaults to whiteboard:true/boundaries:false (it was extracted for the whiteboard case), the inverse of what a real map needs, so a caller relying on defaults renders a blank basemap. check:gospel-whiteboard cannot see this — it asserts chart geometry and imports no component.", + "check:popup-pan-controls": "the popup auto-pan reserves the map's bottom control band, so a popup that clears the CLIP is also clear of the things that take the click. Measured at 390 on a 366x732 map: the pan fitted the popup inside the container, cut 0, and a real click on Full Profile still did nothing because the top element at the button's centre was div.maplibregl-ctrl-attrib-inner (and span.pin-hint-pill on the sibling pin) - a cut-height check cannot see this, because the cut really was zero. Attribution is a licence term of the basemap, so the popup may not be raised over it; the usable frame is shorter than the container and the fit test says so. Runs the SHIPPED panPlanFor, read out of the SFC with new Function (it is not exported), never a re-typed copy, and asserts where the popup ENDS UP rather than re-deriving the expected number from the same formula. Sweeps band heights 0-120px and every width the component renders at. Source-only, headless." }, "devDependencies": { "@capacitor/android": "^7.6.9",