The pill over the posters: tell the stacked pane it is not the one being read

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).
This commit is contained in:
Doxa Maps 2026-09-27 02:07:10 +00:00
parent 05995927dd
commit 4642e78d66

View file

@ -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)
})