Land the prayer facet and the scrim helper
Some checks are pending
build-gate / build (push) Waiting to run

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.
This commit is contained in:
doxa-maps-88655c70 2026-09-22 21:50:32 +00:00
parent ad6da54674
commit 9533cca050
2 changed files with 47 additions and 8 deletions

View file

@ -27,8 +27,7 @@
* 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. (And the size is not even
* reliable: in one pane those units measured 1324x944 inside a 1280x900 window — see below.)
* 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.
@ -103,11 +102,10 @@ export function useViewportScrim(openRef) {
// 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, NOT vw/vh. 100vw/100vh are not reliably "the viewport" here:
// in the upg poster-wall pane the same declarations produced a rect of 1324x944 inside a
// 1280x900 window (computed width WAS 1280px, so the units resolved against a different
// viewport, not a transform — the ancestor chain had none). documentElement.clientWidth/
// Height is the actual laid-out viewport box and matched the window in every pane measured.
// 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
@ -119,6 +117,13 @@ export function useViewportScrim(openRef) {
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',
}
}

View file

@ -12,6 +12,7 @@ import { lbl } from './src/composables/useDashboardAggregations.js'
import { RELIGION_FAMILIES } from '@map/colors/religion.js'
import { canonicalFamilyName } from '@map/colors/language-family.js'
import langFamilyByIso from '@map/data/langFamilyByIso.json'
import { getPrayerLevel, LABELS as PRAYER_LABELS } from '@map/colors/prayer-progress.js'
// same numeric coercion the DataTable uses for its num columns
const num = (v) => { if (typeof v === 'number') return v; const n = parseFloat(String(v ?? '').replace(/[^0-9.\-]/g, '')); return isNaN(n) ? null : n }
@ -43,6 +44,36 @@ export default {
// ── facet accessors ── THE single former-duplicated FACET_ACC, declared ONCE.
facets: {
// ── PRAYER (card BUILD-ADD-A-PRAYER-FILTER-FIRST-IN-THE-ROW) ──
// `people_praying` is a LIVE per-row pray-tools field, already read by three shipping
// consumers in this very config: the KPI sum (kpis 'praying'), the table column
// ('Praying', num(r.people_praying)) and detail.fields. No derived boolean was invented.
//
// It is a COUNT, not a flag, and the app already splits that count THREE ways in
// library/colors/prayer-progress.js (the prayer map's colour strategy):
// none (0/null) · partial (0 < n < 144) · full (n >= 144)
// Three levels => a DROPDOWN. We reuse getPrayerLevel + its LABELS instead of re-deriving
// the split, so the filter can never disagree with the colour of the pin it filters to —
// one threshold, one owner.
//
// ★ WHY THIS ACCESSOR MUST LIVE IN THE CONFIG AND NOT IN THE ADAPTER. useDashboardShell
// builds FACET_ACC from `config.facets` ALONE, and filteredRows FAILS OPEN on a key it
// cannot resolve (`return a ? a(r) === filterSet[k] : true`). A facet defined only in
// DashboardResearchFilters.vue therefore renders a working dropdown, lands a chip, and
// filters NOTHING — silently. A filter that filters nothing is worse than no filter,
// because the user reads the chip as proof the scope narrowed. The adapter keeps a local
// default and merges config-first, so THIS definition is the one that takes effect.
//
// Returns the human LABEL, which is both the display text and the bus value: the same
// value-is-label contract every non-People axis follows.
prayer_level: {
label: 'Prayer',
get: (r) => {
const n = r?.people_praying
const raw = n && typeof n === 'object' ? n.value : n
return PRAYER_LABELS[getPrayerLevel({ peoplePraying: raw ?? null })]
},
},
religion: { label: 'Religion', get: (r) => lbl(r.religion) },
// ── Religion TIER 1 (card BUILD-STACKED-TREE-LEGEND) ──
// The Doxa API's religion code is 1-3 chars whose FIRST LETTER is the family
@ -267,7 +298,10 @@ export default {
// People = `affinity_bloc`, the SAME key the tree's top tier filters on, so a pick here and
// a click there are one selection (card BUG-PEOPLE-AND-AFFINITY-BLOC-ARE-THE-SAME-AXIS-TWICE).
// It stores the code and displays the name via the facet's `optionLabel`.
dropdowns: ['affinity_bloc', 'wagf_region', 'wagf_block', 'country_code', 'primary_language', 'religion'],
// `prayer_level` listed FIRST so the config owns the order rather than the adapter forcing
// it. DashboardResearchFilters de-duplicates on that key, so naming it here cannot render
// it twice — and if the adapter's forcing is ever removed, the order still holds.
dropdowns: ['prayer_level', 'affinity_bloc', 'wagf_region', 'wagf_block', 'country_code', 'primary_language', 'religion'],
searchPlaceholder: 'Search people groups, place, language, religion…',
},
// `rop1_name` (not `affinity_bloc`) so free-text search matches the People NAME — DX-life's