From 8ab3dc0f57eadaf75f26c70c0bf52b919c882ade Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 18:47:21 +0000 Subject: [PATCH] research-map: print rules, mobile export, a reactive packet busy state, and two dead probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../doxa-research-map/research-map.vue | 74 ++++++++++++++----- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 0429ed9f6..223d75570 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -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 } }