doxa-maps/library/utils/viewportScrim.js
doxa-maps-88655c70 9533cca050
Some checks are pending
build-gate / build (push) Waiting to run
Land the prayer facet and the scrim helper
The prayer filter the Driver asked for, first in the dropdown order as he
specified. Keyed on people_praying, a live per-row pray-tools field already
read by three shipping consumers, and it reuses getPrayerLevel plus its LABELS
from the prayer-progress colour strategy rather than re-deriving the split --
so the filter can never disagree with the colour of the pin it filters to.

It is a real facet in config.facets with a get() accessor, which is the part
that matters: useDashboardShell builds FACET_ACC from config.facets alone and
filteredRows FAILS OPEN on an unknown key, so a facet declared only in an
adapter would render a working dropdown, land a chip, and filter nothing,
silently.

Also lands library/utils/viewportScrim.js, shared by DataTable,
SemanticTreeLegend and PosterDialog for the modal-scrim fix -- one helper
rather than six copies of the same workaround.

Committed by the coordinator rather than the authoring agents, same reason as
ad6da54: verified work sitting in the working tree is how the shell-scroll fix
was lost earlier today.
2026-09-22 21:50:32 +00:00

145 lines
9.2 KiB
JavaScript

/**
* viewportScrim.js — make a `position:fixed` full-screen SCRIM actually cover the VIEWPORT,
* in every host, including one that has captured the fixed containing block.
*
* ── THE DEFECT ────────────────────────────────────────────────────────────────────────────────
* A modal scrim is written `position:fixed; inset:0` on the assumption that fixed coordinates are
* viewport coordinates. That holds only while NOTHING in the ancestor chain establishes a
* containing block for fixed descendants. VerticalTabsLayout — the cap-5 shell every plugin is
* hosted in as a tab — sets `contain:layout` on .vtl-content, so inside the studio the SAME scrim
* stretches to the CONTENT PANE instead. MEASURED 2026-09-22 at a 1280x900 viewport, probe
* inserted into each component's real parent chain in the running studio:
*
* upg poster-wall .modal-scrim ......... 1024x793 @ (256,107)
* PosterWall .modal-scrim .............. 1024x793 @ (256,107)
* DataTable .dt-scrim .................. 1024x793 @ (256,107)
* SemanticTreeLegend / LegendTools ..... 1024x793 @ (256,107)
* ...all captured by: div.vtl-content [contain:layout]
*
* 256 is the rail width and 107 the header height. The scrim still LOOKS like a modal — it dims
* the pane it sits in — while leaving the rail and header UNCOVERED AND CLICKABLE behind it. That
* is why this reads as cosmetic and is not: a modal that does not capture input is not modal.
*
* ── WHY NOT THE OBVIOUS FIXES ─────────────────────────────────────────────────────────────────
* ✗ Drop `contain` from the layout. It is load-bearing for a different reason (it stops a rail
* collapse from reflowing a heavy mounted map) and removing it trades this bug for that one.
* Identical reasoning to FilterDropdown.fixedOrigin() — the fix belongs where the ASSUMPTION
* lives, not where the containment does.
* ✗ width:100vw; height:100vh. MEASURED: fixes the SIZE but NOT the offset — the box still starts
* at (256,64), so it overflows off-screen right and bottom while the rail stays uncovered. A
* viewport-sized scrim in the wrong place is still the wrong scrim.
* ✗ <Teleport to="body">. These components render INSIDE SHADOW ROOTS (measured: .dt-wrap's root
* is a ShadowRoot whose host is <cities-internet-map>). Teleporting to document.body leaves the
* shadow-scoped CSS behind and the modal renders UNSTYLED.
*
* ── THE FIX ───────────────────────────────────────────────────────────────────────────────────
* Do not ASSUME the containing block is the viewport — MEASURE it, exactly as fixedOrigin() does,
* then subtract it and size to the viewport. Verified to produce 0,0 1280x900 inside the captured
* pane. Correct in EVERY host, including hosts that do not exist yet: when nothing captures, the
* measured origin is (0,0) and the style collapses to plain inset:0.
*
* ── SHADOW-DOM NOTE (a false reassurance this replaces) ───────────────────────────────────────
* poster-wall.vue carried a comment saying "position:fixed inside a shadow root is still
* viewport-relative, so this covers the whole app". The premise is TRUE and IRRELEVANT: a shadow
* boundary scopes STYLES and SELECTORS, it does not create a containing block. What creates one is
* transform / filter / perspective / backdrop-filter / will-change of those — or contain:layout|
* paint|strict|content on an ancestor, which is exactly what the shell sets. Reasoning about the
* shadow boundary answers a question nobody asked and hides the one that matters.
*
* USAGE (in any component that owns a fixed full-screen scrim):
* import { useViewportScrim } from '<path>/library/utils/viewportScrim.js'
* const { scrimRef, scrimStyle } = useViewportScrim(someOpenRef)
* <div v-if="open" ref="scrimRef" :style="scrimStyle" class="modal-scrim">
* Keep the class: it still carries background, z-index, display/centring. Only the GEOMETRY moves
* into the inline style, because only the geometry depends on the host.
*/
import { ref, shallowRef, watch, onBeforeUnmount } from 'vue'
/**
* Measure the origin a `position:fixed` box inside `el` is laid out from.
*
* Reads the offset from a probe that is ITSELF position:fixed at 0,0: whatever it reports as its
* client rect IS the origin this element's fixed coordinates will be measured from. offsetParent
* walking is unreliable through shadow roots, so we measure rather than traverse.
*
* The probe MUST be inserted into the same parent as the real scrim so it shares the scrim's
* ancestor chain, and therefore its containing block. A probe appended to document.body would
* report the viewport even when the scrim is captured — confirming the assumption instead of
* testing it.
*/
export function measureFixedOrigin(el) {
const parent = el?.parentElement || el
if (!parent || !parent.ownerDocument) return { x: 0, y: 0 }
const probe = parent.ownerDocument.createElement('div')
probe.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;pointer-events:none;opacity:0'
parent.appendChild(probe)
const r = probe.getBoundingClientRect()
probe.remove()
return { x: r.left, y: r.top }
}
/**
* The geometry half of a full-viewport scrim, as an inline style object.
* Re-measured whenever the scrim opens and on resize — the rail can collapse while a modal is up,
* which moves the pane origin without remounting anything.
*
* @param {import('vue').Ref} openRef truthy while the scrim is rendered
* @returns {{ scrimRef: import('vue').Ref, scrimStyle: import('vue').Ref<object> }}
*/
export function useViewportScrim(openRef) {
const scrimRef = ref(null)
const scrimStyle = shallowRef({ position: 'fixed', inset: '0' })
const remeasure = () => {
const el = scrimRef.value
if (!el) return
const o = measureFixedOrigin(el)
// ★ NO `inset` KEY HERE. `inset` is a SHORTHAND for top/right/bottom/left; listing it beside
// the longhands in one style object lets it win on serialization order and SILENTLY DROP them.
// Measured exactly that: the element rendered `position:fixed; inset:auto; width:100vw;
// height:100vh` — right size (1280x900), wrong place (287,895), because top/left never made it
// into the inline style. Setting right/bottom to auto expresses "anchor by top/left only"
// without ever naming the shorthand.
// No capture (o is 0,0): this collapses to a box at 0,0 sized to the viewport — i.e. exactly
// what inset:0 meant in the first place.
// PIXELS FROM A MEASUREMENT rather than vw/vh: documentElement.clientWidth/Height is the
// laid-out viewport box and excludes any classic scrollbar gutter, which is what a scrim
// should cover. (Both forms measured identically once box-sizing was fixed below — the
// 1324x944 reading that first looked like a unit problem was padding, not units.)
const doc = el.ownerDocument || (typeof document !== 'undefined' ? document : null)
const vw = doc?.documentElement?.clientWidth || 0
const vh = doc?.documentElement?.clientHeight || 0
scrimStyle.value = {
position: 'fixed',
top: `${-o.y}px`,
left: `${-o.x}px`,
right: 'auto',
bottom: 'auto',
width: `${vw}px`,
height: `${vh}px`,
// ★ REQUIRED, because box-sizing is NOT inherited into a shadow root from the host page's
// reset (poster-wall.vue's own CSS says so at .tape). Several scrims carry padding — the upg
// .modal-scrim has padding:22px to inset its dialog — so under the default content-box the
// border box became width+44 x height+44: MEASURED 1324x944 for an explicit 1280px/900px.
// inset:0 never exposed this because pinning all four edges sizes the BORDER box directly;
// switching to width/height is what made box-sizing start to matter.
boxSizing: 'border-box',
}
}
// Measure AFTER the scrim is in the DOM — its own ancestors are what we are measuring.
// TWO frames, not one: PosterDialog renders its backdrop inside a <transition>, so on the first
// frame the element can be present but not yet laid out at its final place. Measuring once gave
// a stale origin there. The second pass is idempotent when the first was already right, so this
// costs nothing in the common case and removes a per-component timing assumption.
watch(openRef, (v) => {
if (!v) return
requestAnimationFrame(() => { remeasure(); requestAnimationFrame(remeasure) })
}, { flush: 'post' })
const onResize = () => { if (openRef.value) remeasure() }
if (typeof window !== 'undefined') window.addEventListener('resize', onResize)
onBeforeUnmount(() => { if (typeof window !== 'undefined') window.removeEventListener('resize', onResize) })
return { scrimRef, scrimStyle }
}