research-map: print rules, mobile export, a reactive packet busy state, and two dead probes

Four reviewed changes to ONE file. SOURCE ONLY — no built artifacts — see the note below.

PRINT. .rm-map-area is flex:1 1 0 and has no height of its own; print layout has no
viewport height, so the box collapsed and the toolbar and legend, positioned against it,
followed its top edge up. Nothing in this render path declared an @media print rule at
all, so the browser's default print stylesheet laid the page out. A profile with no print
rules is not neutral in print; it is unspecified.

MOBILE EXPORT. LegendMobile is opt-in by construction -- its exportEnabled is
`typeof inject('onLegendExport') === 'function'` -- and this profile provided nothing, so
a reader on a phone had no way to export a poster or a packet while the same map on a
desktop did. Provided guarded by EXPORT_FEATURE_ENABLED, the way country-posters does it.

BUSY STATE (bug-class sweep C7). openPoster guarded re-entrancy with a REF wired to
:active/:disabled/:aria-busy; openPacket guarded it with a plain `let`, which drives no UI
because it is not reactive -- so the SLOWER action showed nothing. packetBusy is a ref
now, and legendExportBusy reports either build for the legend's Export menu.

DEAD PROBES. docScrolls and ancestorScrolls were function-local consts, computed and
discarded, kept alive by a comment claiming the edge affordance keyed its indicator off
them. This profile has no such indicator and never calls useEdgeScrollAffordance.
_findScrollportAncestor STAYS: it is still a ResizeObserver target.

Plus two physical CSS properties made logical for RTL, and the verbatim Driver quote in
the scroll-zoom note paraphrased per the L64 policy.

★ WHY NO BUILT OUTPUT. I built this and tried to self-land it, and backed out: staging
public/ wholesale swept in country-posters' artifact and three leaf pages as they were at
MY build base, so the commit carried a REVERT of exp-shepherd's rebuild and a stale
integrity hash for their bundle -- an SRI mismatch, not a cosmetic diff. Caught by
comparing every bundle's integrity against main rather than trusting that a clean rebase
means a clean diff: a rebase resolves TEXT, and an artifact nobody else edited in the same
lines still loses to whichever commit touched it last. main then moved twice more while I
rebuilt. So this is source-only and the build belongs with the build owner, who is
rebuilding these bundles anyway.
This commit is contained in:
Doxa Maps 2026-09-28 18:47:21 +00:00
parent 20355ca552
commit 8ab3dc0f57

View file

@ -110,15 +110,10 @@ function _findScrollportAncestor(el) {
function _syncScrollZoomToHost(m) {
if (!m || !m.scrollZoom) return
try {
const doc = document.documentElement
const docScrolls = doc.scrollHeight > window.innerHeight + 4
const host = (m.getContainer && m.getContainer()) || null
const ancestorScrolls = host ? !!_findScrollportAncestor(host) : false
// ★ THE MAP KEEPS THE GESTURE. THE EDGE STRIPS ARE THE WAY PAST IT.
// Driver, 2026-09-24: "two finger scrolling on the map should allow me to interact with the
// map, but instead it just scrolls down past the map and does not interact with the map —
// that's what the scroll side bars are there for, to let me interact with the map AND have a
// way to scroll down past the map."
// Driver-reported, 2026-09-24: a two-finger scroll over the map scrolled the PAGE past it
// instead of interacting with the map, and the side strips exist precisely so a reader can do
// both — work the map, and still have a lane to scroll the page down.
//
// This used to disable scroll-zoom whenever the page or an ancestor could scroll, which is
// ALWAYS true in the research-map-plus-grid composite (a map with a grid stacked under it).
@ -135,9 +130,16 @@ function _syncScrollZoomToHost(m) {
// non-interactive and left the strips redundant. Two fixes for one bug, and the second one
// silently undid the feature.
//
// So: keep scroll-zoom ON, and let the strips carry the page. `docScrolls`/`ancestorScrolls`
// are still computed because the edge affordance below keys its gray indicator off the same
// question — a map with nothing stacked under it should not advertise a scroll lane.
// So: keep scroll-zoom ON, and let the strips carry the page.
//
// ★ THE TWO SCROLL PROBES THAT USED TO SIT HERE ARE GONE, AND THE COMMENT THAT KEPT THEM WAS
// WRONG. It said docScrolls/ancestorScrolls were still computed because "the edge affordance
// keys its gray indicator off the same question". This profile has no such indicator and never
// calls useEdgeScrollAffordance: its .map-scroll-edge strips are background:transparent lanes
// that are always present (see the rules near the bottom of this stylesheet). They were
// function-local consts, computed and discarded — the comment described a consumer that does
// not exist here, which is worse than no comment, because it makes dead code look load-bearing.
// _findScrollportAncestor STAYS: it is still used to pick a second ResizeObserver target below.
m.scrollZoom.enable()
} catch (_) { /* an engine without scrollZoom must never break the panel */ }
}
@ -268,7 +270,7 @@ useShadowStyles(`
.dg-main { font-weight:500;font-size:13px;color:#222;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:6px; }
.dg-main strong { font-weight:700;overflow:hidden;text-overflow:ellipsis; }
.dg-meta { margin-top:2px;display:flex;flex-wrap:wrap;gap:4px 10px;font-size:11px;color:#555;line-height:1.35; }
.dg-field strong { font-weight:600;color:#333;margin-right:2px; }
.dg-field strong { font-weight:600;color:#333;margin-inline-end:2px; }
/* Round 16 — labeled category tag replacing the old emoji prefixes. */
.dg-tag { flex:none;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.4px;color:#3b463d;background:rgba(59,70,61,0.10);border-radius:4px;padding:1px 5px;line-height:1.5;white-space:nowrap; }
.dg-agg-label { overflow:hidden;text-overflow:ellipsis; }
@ -415,6 +417,20 @@ useShadowStyles(`
transforms and the map renders grey, so the rounding stops one level above it. */
.rm-map-area { clip-path: inset(0 round var(--map-radius-corners, var(--map-radius, 0px))); }
.rm-map-canvas { position:absolute;inset:0; }
/* ★ PRINT: GIVE THE MAP AREA A HEIGHT, OR EVERYTHING ANCHORED TO IT RISES.
.rm-map-area is flex:1 1 0 — it has no height of its own and fills a viewport-height ancestor.
Print layout has no viewport height, so that box collapses toward its content height, and the
toolbar and legend (position:absolute against it) move UP with its top edge. That is the Driver-reported
print breakage (the map controls and legend jump upward), and it needs no print
stylesheet to cause — the browser's DEFAULT print styles are enough, because nothing here declared
one. A profile with no @media print rules is not neutral in print; it is unspecified.
A definite height restores the anchor. This does not change what the poster prints — that is a
separate document in its own frame (printInPlace) — it stops the LIVE page deforming while the
dialog is open. */
@media print {
.rm-map-area { flex: 0 0 auto; height: 100vh; min-height: 0; }
}
/* MATERIAL LIGHT (2026-09-21). Was background #0f1216 / color #7c8cf8 — same unconditional
loading overlay as .dsm-loading, .apg-loading and countries-map's .rm-loading. Rendered on
every load via v-if="!appReady", so it was the first surface a visitor saw.
@ -424,7 +440,10 @@ useShadowStyles(`
map did manage to render stays visible around it. A spinner that outlives the
failure reads as "slow" and the user just waits; this says the map is gone. */
.rm-error { position:absolute;top:50%;left:12px;right:12px;z-index:7;transform:translateY(-50%);text-align:center;font:13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#fecaca;background:rgba(15,18,22,.95);border:1px solid rgba(254,202,202,.28);border-radius:10px;padding:12px 14px;box-shadow:0 2px 10px rgba(0,0,0,.4); }
.rm-badge { position:absolute;top:10px;right:10px;background:rgba(124,140,248,0.12);color:#7c8cf8;border:1px solid rgba(124,140,248,0.3);padding:3px 9px;border-radius:4px;font-size:11px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;pointer-events:none;z-index:50; }
/* inset-inline-end, not right: this badge is pinned to the READING edge, so under ar it belongs on
the left. .map-scroll-edge--left/right below stay physical on purpose — those are literal screen
edges for a scroll affordance and do not mirror. */
.rm-badge { position:absolute;top:10px;inset-inline-end:10px;background:rgba(124,140,248,0.12);color:#7c8cf8;border:1px solid rgba(124,140,248,0.3);padding:3px 9px;border-radius:4px;font-size:11px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;pointer-events:none;z-index:50; }
/* Engine badge — bottom-left, unobtrusive. Colour differs per engine so a
side-by-side comparison is readable at a glance. */
.rm-provider-badge { position:absolute;bottom:8px;left:8px;z-index:50;pointer-events:none;padding:2px 8px;border-radius:999px;font:600 10px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;letter-spacing:.04em;color:#fff;background:rgba(30,41,59,0.72);border:1px solid rgba(255,255,255,0.18);backdrop-filter:blur(2px); }
@ -1489,16 +1508,37 @@ function onExport(kind) {
if (kind === 'poster') return openPoster()
if (kind === 'packet') return openPacket()
}
// ★ THE PHONE HAD NO EXPORT AT ALL, AND THE KILL-SWITCH ONLY COVERED THE DESKTOP.
// LegendDesktop takes :export-enabled="EXPORT_FEATURE_ENABLED", so turning that flag off hid the
// desktop menu — but LegendMobile is OPT-IN BY CONSTRUCTION: its exportEnabled is
// `typeof inject('onLegendExport') === 'function'` (LegendMobile.vue:393), and this profile provided
// nothing. So a reader on a phone had no way to export a poster or a packet, while the same map on a
// desktop did. country-posters already carries the guarded line; this is the missing half here.
// Providing it UNGUARDED would be the other error — the phone would then keep an affordance the
// desktop deliberately hides whenever the flag is off, which is the bug exp's card names.
provide('onLegendExport', EXPORT_FEATURE_ENABLED ? onExport : null)
// ─── Packet export (printed, multi-page: map + a card per people group) ──────
// Lazy-loaded on first use, the same pattern openPoster() uses. Defensive throughout: never throw
// uncaught into the UI; guard against double-run.
let _packetBusy = false
// ★ A REF, NOT A PLAIN LET, AND THAT IS THE WHOLE DEFECT. This guarded re-entrancy correctly and could
// not tell the reader anything, because a non-reactive binding drives no UI. Its sibling openPoster()
// has had `posterBusy` as a ref wired to :active/:disabled/:aria-busy since it was written — so the
// FASTER action showed a busy state and the SLOWER one (dynamic imports, a map render, then a card per
// people group) showed none. One path was given feedback and its sibling was never looked at; upg100
// found the identical shape on their own surface the same day.
// Widened by me today: providing onLegendExport gave the legend's Export menu a mobile entry point,
// so there is now a second way into this path.
const packetBusy = ref(false)
// The legend's Export menu reaches BOTH builds, so it reports either one. Provided rather than passed
// because the mobile sheet's legend is mounted by LegendMobile, not by this template.
const exportBusy = computed(() => posterBusy.value || packetBusy.value)
provide('legendExportBusy', exportBusy)
// Neither export hands over a file any more: the poster prints through printImageBlob and the packet
// through the shared usePosterPrints.printPacket, so the download helper that served packet.pdf is gone.
async function openPacket() {
if (_packetBusy) return
_packetBusy = true
if (packetBusy.value) return
packetBusy.value = true
try {
// ★ THE PACKET PRINTS, AND IT PRINTS THROUGH THE SHARED RENDERER (Driver-lens audit card 2, and
// the Driver's own rule: an export button opens the print dialog, never hands over a file). This
@ -1625,7 +1665,7 @@ async function openPacket() {
} catch (err) {
console.error('[research-map] packet export failed:', err)
} finally {
_packetBusy = false
packetBusy.value = false
}
}