diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 620fbc602..e2ce2df5a 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -123,9 +123,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 */ } } @@ -428,6 +435,20 @@ useShadowStyles(` .map-scroll-edge, .rm-provider-badge, .rm-loading { display:none !important; } } .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. @@ -437,6 +458,9 @@ 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); } + /* 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. */ @@ -1469,6 +1493,15 @@ function onExport(kind) { if (base === 'poster') return openPoster() if (base === 'packet') return openPacket(variant || 'full') } +// ★ 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) // ★ THE PHONE HAD NO EXPORT AT ALL. The desktop legend gets :export-enabled and @export in the // template, but LegendMobile does not read props from here — it reaches for an injected handler, and @@ -1487,7 +1520,19 @@ 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(variant = 'full') { @@ -1624,7 +1669,7 @@ async function openPacket(variant = 'full') { } catch (err) { console.error('[research-map] packet export failed:', err) } finally { - _packetBusy = false + packetBusy.value = false } }