From 4642e78d668d1ffa251307a562f0d3be9e78caad Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sun, 27 Sep 2026 02:07:10 +0000 Subject: [PATCH] The pill over the posters: tell the stacked pane it is not the one being read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My half of PILLRISK (studio's PinHint half is 2e375530). `inert` on every pane except the active one, which is the signal studio's contract asked for (END-STU-PILL / UPGINERT). ★ PinHint'S OBSERVER WAS NOT BROKEN, AND THAT IS THE WHOLE DIAGNOSIS. The panes are `position:absolute; inset:0` in one scroll-snap column, so the map pane sits STACKED BEHIND the active view at full size: 100% of its geometry is inside the viewport while none of it is visible. An IntersectionObserver measures INTERSECTION, not OCCLUSION, and no tuning can separate those two states — they are identical to it. The missing information was never geometric. Only the composite knows which pane the reader chose, so only the composite can say it. ⚠ inert, NOT display:none or v-show, and this constraint decides the approach. Hiding the pane would tear down a live map: a MapLibre/Mapbox canvas in a zero-size container loses its size and re-initialises on the way back — the cold-start path rega had to verify separately in row 52. inert leaves the pane mounted, painted and warm and only declares it non-interactive; unlike aria-hidden it also removes it from tab order and the a11y tree, so a keyboard user cannot tab into a map they cannot see. ALL FOUR PATHS THAT CHANGE THE VIEW ARE COVERED, which took reading rather than guessing: - FRESH LOAD — marked in onMounted from column.current(). This is the case the Driver actually hit: without it no pane carries the attribute until the reader touches the switcher, so the pill is over the posters on ARRIVAL. A signal that only fires on transitions never describes the initial state. - THE SWITCHER — marked inside go(), immediately rather than via the scroll callback, because scrollTo is SMOOTH and onScroll debounces 120ms: waiting would leave the pill over the posters for the length of the animation, a shorter version of the same bug. - A DRAG/SCROLL — marked in onScroll, UNCONDITIONALLY rather than inside the existing `cur !== view.value` guard. go() has already set view.value by then, so a switcher-driven scroll takes that early exit and would skip the marking; a scroll-driven one needs it, and a switcher-driven one is idempotent. - "Show on map" from the poster wall — routes through go(), so it inherits it. Plugin-only: upg-100-mfe.vue, no library change. mountEl(paneId) already exposes the container, so the attribute goes on from my side of the fence. Correct at EVERY viewport, not just the phone: pane-wrapper renders ONE column with `scroll-snap-type: y mandatory` and `.pane { height:100% }`, so exactly one pane fills the stage at any width and there is no v1 side-by-side mode whose second pane would be wrongly marked (pane-wrapper.js:56-59, :101 — read, not inferred from the phone report). check:sfc-parse PASS, check:no-free-identifiers PASS. The one-pane-non-inert invariant is proven across switches including the return trip; the browser assertion is rega's (fresh load: no pill -> Map: pill -> Prayer Resources: no pill). --- .../my-upg-100-list/upg-100-mfe.vue | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/plugin-designs/my-upg-100-list/upg-100-mfe.vue b/plugin-designs/my-upg-100-list/upg-100-mfe.vue index 1e6e3525c..295b9b199 100644 --- a/plugin-designs/my-upg-100-list/upg-100-mfe.vue +++ b/plugin-designs/my-upg-100-list/upg-100-mfe.vue @@ -198,9 +198,46 @@ function paneElement(pane) { return el } +// ★ TELL THE INVISIBLE PANE THAT IT IS INVISIBLE. `inert` on every pane except the active one. +// +// THE DEFECT, reported by the Driver and traced by repo (PILLRISK, 2026-09-27): PinHint's "Tap a +// coloured dot" pill appeared OVER the Prayer Resources posters on a phone. Its IntersectionObserver +// was working correctly — that is the point. The panes are `position:absolute; inset:0` in one +// scroll-snap column, so the map pane is STACKED BEHIND the active view at full size: 100% of its +// geometry is inside the viewport while none of it is visible. An IntersectionObserver measures +// INTERSECTION, not OCCLUSION, and no amount of tuning it can distinguish those two states. The +// missing information was never geometric — only the composite knows which pane the reader chose. +// +// ⚠ inert, NOT display:none OR v-show, and this is the constraint that decides the whole approach: +// hiding the pane would tear down the live map. A MapLibre/Mapbox canvas in a zero-size container +// loses its size and re-initialises on the way back, which is exactly the cold-start path rega had to +// verify in row 52. inert leaves the pane mounted, painted and warm; it only declares it non-interactive +// — and, unlike aria-hidden, also removes it from tab order and the accessibility tree, so a keyboard +// user cannot tab into a map they cannot see. (studio's contract, END-STU-PILL: PinHint walks up through +// shadow roots for inert/hidden/aria-hidden and watches that chain with a MutationObserver, so no event +// is needed and switching views brings the pill straight back.) +// +// ★ CORRECT AT EVERY VIEWPORT, not just the phone. pane-wrapper renders ONE column with +// `scroll-snap-type: y mandatory` and `.pane { height: 100% }`, so exactly one pane fills the stage on +// any width — there is no side-by-side mode in v1 whose second pane would be wrongly marked. Verified +// in pane-wrapper.js:56-59 + :101 rather than assumed from the phone report. +function markActivePane(activeId) { + if (!column) return + for (const pane of column.panes) { + const el = column.mountEl(pane.id) + if (!el) continue + if (pane.id === activeId) el.removeAttribute('inert') + else el.setAttribute('inert', '') + } +} + function go(id) { view.value = id column?.scrollTo(id) + // Immediately, not on the scroll callback: scrollTo is SMOOTH, so onScroll's 120ms debounce fires + // only after the animation settles. Waiting for it would leave the pill visible over the posters for + // the length of the scroll — a shorter version of the same bug. + markActivePane(id) } // Cross-pane action (shell seam): the poster wall dispatches a scoped window event @@ -253,10 +290,21 @@ onMounted(() => { paneEls[pane.id] = el column.mountEl(pane.id)?.appendChild(el) } + // ★ THE FRESH-LOAD CASE, which is the one the Driver actually hit. Without this the panes carry no + // inert attribute at all until the reader touches the switcher, so the pill is over the posters on + // ARRIVAL — before any view change has happened. A signal that only fires on transitions never + // describes the initial state. + markActivePane(column.current()) + // Tabs are a readout as well as a control — follow the user's scroll. offScroll = column.onScroll(() => { const cur = column.current() if (cur && cur !== view.value) view.value = cur + // Unconditionally, NOT inside the `cur !== view.value` guard above: go() has already set view.value + // before this fires, so a switcher-driven scroll takes the early exit and would skip the marking. A + // scroll-driven one needs it and a switcher-driven one is idempotent, so doing it always is both + // cheaper to reason about and the only version that is correct on both paths. + if (cur) markActivePane(cur) }) window.addEventListener('doxa-upg-100-list:show-on-map', onShowOnMap) })