doxa-maps/plugin-designs/research-dashboard/research-dashboard.vue
Doxa Maps dbead061ed
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source 810ffa26
2026-09-27 07:05:27 +00:00

892 lines
70 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
/**
* @description Coordinated-Multiple-Views research dashboard for 10/40-window UPGs (live pray.doxa.life): docked facet rail + map-as-hero + linked table/charts, brushing-and-linking over one in-memory store.
* @profile research-dashboard
* @bundle research-dashboard
* @element research-dashboard-map
*
* DOXA-LIB-2 thin profile: the page composition + grid CSS + a CONFIG import. All logic
* (bus, store, FACET_ACC, filteredRows, KPI math, CSV, hash, responsive) lives in the shared
* library via useDashboardShell(config); the panels come from @map/components/dashboards and
* read their per-dashboard data from inject('dashboardConfig'). Behaviour is identical to the
* original research-dashboard — only the duplication was hoisted into the library.
*
* RESEARCH-FILTERS (DX-life clone): DashboardResearchFilters sits between the topbar and the
* KPI strip as header-1 (search bar) + header-2 (the expand-filters dropdown SET — ONE row,
* horizontal-scroll on overflow). It is a THIN ADAPTER over the SHARED, parameterized
* @map/components/search/SearchFilterHeader.vue (cloned from the DX-life research page's
* UupgsList filter block), coupling it to the selection bus: the search bar → bus.setSearch,
* a dropdown pick → bus.setFilter(facetKey, label). Every linked panel (table/map/charts/
* facet-rail/KPIs) honors bus.searchTerm via the shared makeSearchMatcher, so searching AND
* filtering re-derive the whole surface with no panel edits. config.researchFilters +
* config.searchKeys drive which axes show + what the text search matches. The geocoder-based
* DashboardSearchHeader (DOXA-LIB-3) remains available in the kit but is not mounted here.
*/
import { computed, watch, inject, ref, unref, onMounted, onBeforeUnmount } from 'vue'
import { tStatic } from '@map/i18n/index.js'
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in and
// takes the subtree with it. The English stays as the fallback, so a missing key degrades to what
// shipped rather than to a key name.
const T = (key, en) => tStatic(key) || en
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useDashboardShell, fmt } from './src/composables/useDashboardShell.js'
import { useDashboardLayout } from '@map/composables/useDashboardLayout.js'
// The facet rail is a KIT component now (landing 4c-3) and is imported from the library directly.
// The plugin barrel re-exported it for one cycle so the move and the import change were separate
// landings; that shim is gone, so this is the only line that names its location.
import DashboardFacetRail from '@map/components/dashboards/DashboardFacetRail.vue'
import {
DashboardTopBar,
DashboardResearchFilters,
DashboardMapPanel,
DashboardDataTable,
DashboardChartsRail,
DashboardDetailDrawer,
} from './src/components/dashboards'
import config from './dashboard.config.js'
// ── HOST OVERRIDES ───────────────────────────────────────────────────────────────────────────
// `config` above is a STATIC import, so until now nothing a host passed could reach the dashboard's
// own settings. That made the new `provider` option settable IN SOURCE but not reachable by an
// EMBEDDER — real, and unusable, which is the same presence-vs-reachability trap as a nav control
// that exists off-canvas. "Hot-swappable" has to mean a partner can choose without a rebuild.
//
// profileConfig is the channel every other plugin already uses for exactly this
// (doxa-simple-map.vue:275, poster-wall.vue:45), so this adds no new mechanism — it just stops the
// dashboard from being the one plugin that ignores it.
//
// SHALLOW MERGE, HOST WINS, and shallow is deliberate: the static config's nested objects (facets,
// columns, charts) are a coherent set that a partial override would corrupt. What a host legitimately
// sets is top-level scalars — `provider`, `mapStyle`, a title. A deep merge would invite half-replaced
// facet maps, which fail far from the edit.
// ★ ONE DELIBERATE EXCEPTION TO "SHALLOW", MEASURED 2026-09-22. `map` is merged ONE LEVEL DEEP.
// The reasoning above is sound for facets/columns/charts — a coherent set a partial override would
// corrupt — but it produced a REAL, reachable bug for `map`, which is not that kind of object. It
// is a BAG OF INDEPENDENT SCALARS (mapStyle, geoFacet, noDataColor, selectedColor, outlineColor,
// heartbeatThreshold), each read separately as `cfg.map?.x || default`. Under a purely shallow
// merge, a host passing the single key it cares about —
// { map: { heartbeatThreshold: 40 } }
// — REPLACES the whole object, silently dropping mapStyle:'paper' and reverting the basemap. So
// the one override a host is most likely to write was the one that broke the map. That is the same
// presence-vs-reachability trap this block was added to close, one level down.
//
// Scoped to `map` ONLY, and deliberately NOT a generic deep merge: the original warning about
// half-replaced facet maps still stands for every other key.
const hostConfig = inject('profileConfig', ref(null))
// ── HOW AN EMBEDDER SETS THE HEARTBEAT THRESHOLD (and why this is NOT a defineProps) ─────────
// ★ MEASURED, and the obvious answer was WRONG. The instinct is to add
// `defineProps({ heartbeatThreshold })` here so a partner can write
// `<research-dashboard-map heartbeat-threshold="40">`. THAT PROP WOULD NEVER BE POPULATED. The
// custom element registered by index.js is ProfileLoader — not this component — and ProfileLoader
// renders the profile as `<component :is="ProfileComponent" />` (library/ProfileLoader.vue:244)
// with NO props bound. A prop declared here is unreachable from the tag: it would read as
// configurable, type-check clean, build clean, and silently never fire. That is the exact
// presence-vs-reachability trap described above, and adding the prop would have re-created it
// while looking like the fix for it.
//
// THE REAL, ALREADY-WORKING ATTRIBUTE PATH is ProfileLoader's `profile-config` JSON string
// (library/ProfileLoader.vue:39), which it parses and provide()s as 'profileConfig' — the very
// channel `hostConfig` reads above. So an embedder writes:
//
// <research-dashboard-map
// profile-config='{"profile":"research-dashboard","map":{"heartbeatThreshold":40}}'>
// </research-dashboard-map>
//
// and cap-2 instances.json entries set the same thing under their `props` block. No new mechanism
// is needed — but it only became USABLE with the one-level `map` merge below, because until then
// that exact snippet also wiped mapStyle:'paper'.
const mergedConfig = computed(() => {
const h = unref(hostConfig)
if (!h || typeof h !== 'object') return config
const merged = { ...config, ...h }
if (h.map && typeof h.map === 'object') merged.map = { ...config.map, ...h.map }
return merged
})
// ── PAGE-SCROLL LAYOUT MODE (BUG-CANNOT-SCROLL-DOWN-TO-THE-GRID) ─────────────────────────────
// ★ THE BUG THIS EXISTS FOR, after four wrong diagnoses that each blamed something nearer the
// cursor. This dashboard is a FIXED-VIEWPORT app: .rd-shell is a CSS grid with height:100% whose
// last row is the table at var(--table-h,34%), and the table scrolls INTERNALLY. Standalone that is
// right. But the cap-4 composite stacks it ABOVE a people-groups grid and expects the PAGE to
// scroll, pinning this band to 88svh so height:100% resolves. At a 900px viewport that makes the
// table 269px over 54834px of rows — so a wheel anywhere in the lower third lands on the table,
// which has 54 thousand pixels of its own to consume and never passes anything upward. MEASURED:
// the same gesture moved the page 2311px lower down and 300px over the dashboard. The grid below
// was unreachable by the gesture the agent actually makes.
//
// It is a LAYOUT MISMATCH, not a CSS property — two philosophies stacked. overscroll-behavior does
// not help (it governs chaining OUT of an element that has bottomed out; this one never bottoms
// out), and it was tried and reverted at b40fd5b.
//
// In page-scroll mode the shell stops being one screen tall and the table stops being an internal
// scrollport: rows render at full height and the PAGE carries them. There is then nothing under the
// cursor competing for the wheel.
//
// ★ WHY THIS RIDES profileConfig AND NOT A NEW PROP — see the heartbeat-threshold note above: the
// registered custom element is ProfileLoader, which renders the profile with NO props bound, so a
// defineProps here would type-check, build clean, and never fire. profile-config is the proven
// channel. A host opts in with:
// <research-dashboard-map profile-config='{"profile":"research-dashboard","layout":"page-scroll"}'>
// Default is unset, so every existing embed keeps the fixed-viewport behaviour byte-for-byte.
const pageScroll = computed(() => unref(hostConfig)?.layout === 'page-scroll')
// The assembler: creates+provides the bus/data/config, computes the linked surface.
// Passed the MERGED config so a host-set `provider` reaches DashboardMapPanel via
// inject('dashboardConfig'). With no host config this is byte-for-byte the previous behaviour.
const { bus, data, kpis, exporter, responsive, filteredRows } = useDashboardShell(mergedConfig.value)
// ── ★ ANNOUNCE THE RESULT OF A FILTER (the dashboard's largest screen-reader gap) ─────────────
// THE DEFECT, measured 2026-09-26: this element contained exactly ONE role="status" (the table's
// selection pill) and ZERO aria-live regions. That is the specific way a cross-filtering dashboard
// fails a screen-reader user, and it is invisible to everyone else: the whole POINT of
// brushing-and-linking is that acting on ONE control changes a number somewhere ELSE. A sighted
// user picks "Islam" in the left rail and watches 2,107 become 693 in the KPI strip, the map and
// the table. A screen-reader user presses the same control, hears the control's own state change,
// and is told NOTHING about the 1,414 rows that just left the board. The filter worked; the
// feedback never existed.
//
// WHY IT LIVES IN THE SHELL AND NOT IN A PANEL. Six surfaces change on every brush (KPI strip,
// map, table, charts, chips, breadcrumb). A live region per panel would announce the same event
// up to six times, which is worse than silence — screen readers queue polite announcements, so the
// user would wait through five repetitions before reaching the next control. The shell is the one
// place that is above all six and mounted exactly once.
//
// POLITE, not assertive: this is a result, not an alert. `aria-atomic` so the whole sentence is
// re-read rather than the changed word alone ("693" announced by itself is not information).
//
// ★ THE NODE MUST EXIST BEFORE IT HAS TEXT. A live region that is INSERTED already containing its
// message is frequently not announced at all — the accessibility tree has to be observing the node
// when the text changes. So the element is always rendered and starts EMPTY; it is never v-if'd,
// never v-show'd, and never unmounted. (This is also why it is not inside any of the v-show'd
// region cells above.)
const liveMessage = ref('')
let liveTimer = null
let liveArmed = false
// Debounced, for the same reason useSelectionCamera debounces the camera: typing in the search box
// re-derives filteredRows on every keystroke, and an un-debounced polite region would queue one
// announcement PER CHARACTER and then read them all back in order. 700ms is past a normal typing
// cadence, so the user hears the settled answer once.
const LIVE_DEBOUNCE_MS = 700
function announce() {
const shown = filteredRows.value.length
const all = (data.rows.value || []).length
// fmt() is the shell's own formatter. NUMERALS ARE HELD — this is deliberately the same
// en-US grouping every visible surface already prints, so the announcement and the KPI strip
// can never read as two different numbers.
const unit = T('dashboard.groups', 'groups')
let msg = T('dashboard.results_live', '{count} of {total} {unit} shown')
.replace('{count}', fmt(shown)).replace('{total}', fmt(all)).replace('{unit}', unit)
// The active filters, named. "693 of 2,107 groups shown" on its own does not say WHY, and the
// chips that carry that answer are exactly what a screen-reader user cannot glance at.
const active = Object.keys(bus.filterSet)
if (active.length) {
const names = active.map((k) => mergedConfig.value.facets?.[k]?.label || k).join(', ')
msg += ' · ' + T('dashboard.filters_live', 'Filters: {list}').replace('{list}', names)
}
const q = (bus.searchTerm?.value || '').trim()
if (q) msg += ' · ' + T('dashboard.search_live', 'Search: {term}').replace('{term}', q)
liveMessage.value = msg
}
watch(
// Every input that can move the count, in one key. filteredRows is the truth (it already folds
// facets AND the tiered search), and the filter/search keys are included so that a change which
// happens to leave the count identical — swapping one 693-row facet for another — still speaks.
() => [filteredRows.value.length, JSON.stringify(bus.filterSet), bus.searchTerm?.value || ''].join('|'),
() => {
// ★ DO NOT SPEAK ON ARRIVAL. The first settled value is the page loading, not the user doing
// something, and an element that announces itself the moment it mounts is noise a screen-reader
// user cannot prevent — on an embed with several elements it is noise several times over. The
// region arms itself after the first data-driven settle, so it only ever reports a CHANGE.
if (!liveArmed) { liveArmed = true; return }
clearTimeout(liveTimer)
liveTimer = setTimeout(announce, LIVE_DEBOUNCE_MS)
}
)
// A pending announcement must not outlive the element. Unmounting a dashboard mid-debounce (the
// SPA swaps tabs, a composite is torn down) would otherwise fire announce() against a dead
// instance — the same unmount-race class the map panel already guards.
onBeforeUnmount(() => clearTimeout(liveTimer))
// ── OUTWARD FILTER SIGNAL ────────────────────────────────────────────────────────────────
// WHY THIS EXISTS (measured 2026-09-22). This component was a SEALED BOX: zero defineProps,
// zero defineEmits. Its bus comes from createSelectionBus() inside useDashboardShell — a
// PER-INSTANCE factory, never provide()d and never on window — so nothing outside could observe
// a filter change. Measured consequence in the cap-4 composite: the dashboard reacted correctly
// (rows 22→21 on search, 21→6 on a facet pick) while the people-groups-grid BELOW it stayed at a
// byte-identical 24 cards every time. The grid was not broken; nobody was telling it anything.
//
// The sibling composite already does this correctly — big-picture's header declares
// `defineEmits(['research-filter'])` and the parent binds the result onto the grid's props. This
// mirrors that contract so the two cap-4 composites work the same way.
//
// PIGGYBACKS ON A PROVEN REACTIVE HOOK: useDashboardShell already runs
// `watch(() => ({ ...bus.filterSet }), syncHash, { deep: true })` to serialize filters into the
// URL hash, so filterSet is known to be reactive in exactly this shape. searchTerm is a separate
// ref and needs its own source in the watch.
const emit = defineEmits(['dashboard-filter'])
watch(
() => ({ filters: { ...bus.filterSet }, q: bus.searchTerm?.value || '' }),
({ filters, q }) => emit('dashboard-filter', { filterSet: filters, searchTerm: q }),
{ deep: true }
)
// row-store flags for the template (auto-unwrapped in bindings)
const rowCount = data.total
const isLoading = data.loading
const loadError = data.error
// responsive flags + collapsible dock + segmented mobile view (from the shell)
const { shell, isNarrow, slimRail, slimFacet, mobileView, SEG, tableCollapsed, facetCollapsed, railCollapsed,
setView, showFacets, showMap, showTable, showCharts } = responsive
// Drag-resize + persistence engine (desktop-only). Owns --facet-w / --rail-w / --table-h on the
// shell root; restores the saved layout on mount, persists on drag-end, and reset() (fired by the
// TopBar's "Align Dashboard" button via bus.emit('resetLayout')) snaps back to the exact defaults.
// Defaults MUST match the CSS spine below (facet 248, rail 312, table 34%) so an untouched board is
// pixel-identical and the responsive slim-rail classes keep working until the user drags.
const { startFacetResize, startRailResize, startTableResize, startShellResize, onResizeKeydown, facetW, railW, tableH, mapH, shellH } = useDashboardLayout({
shellRef: shell,
bus,
isNarrow,
// ★ BUG-TABLE-RESIZE-HANDLE-DOES-NOTHING: in page-scroll mode --table-h is force-set to `auto`
// by the stylesheet and no longer drives any layout (see the pageScroll note above); the
// composable needs to know so the table-axis handle can drive --rd-map-h instead.
pageScroll,
// ★ NAMESPACED PER DASHBOARD (kit landing 2). This was the single constant every dashboard
// shared, which was harmless while there was only one and becomes a collision the moment a page
// carries two — each would read and write the other's divider positions. config.id is the
// profile's own identity, so a second dashboard on the same page gets its own drawer without its
// author having to know this key exists. useDashboardLayout reads the old shared key once, so no
// reader loses the layout they had.
storageKey: 'rd-dashboard-layout:' + (mergedConfig.value?.id || 'default'),
// MUST mirror the `:host` --facet-w/--rail-w defaults, or reset() would snap the rails to a
// different balance than a fresh load shows. Tree wider than charts — see the rail-balance note.
// mapH mirrors the page-scroll CSS fallback 40svh (~360px at a 900px viewport — see
// BUG-DASHBOARD-BAND-GROWS-ENORMOUS: 58svh left the default band taller than one viewport;
// 40svh is the value that makes chrome(137px) + map(X) + table(X+32px bar) sum to <=900px).
// ★ NO LOCAL defaults OVERRIDE — deliberately. This line used to pass its own copy of the
// numbers ({ mapH:360, shellH:720 }), which silently OVERRODE the composable's DEFAULTS via the
// { ...DEFAULTS, ...options.defaults } merge. Result, measured 2026-09-23: the composable said
// mapH 260 / shellH 502, this line said 360 / 720, the bundle carried BOTH, and this one won --
// so two height-reclaim passes changed the shell by exactly nothing while every CSS fallback
// moved. The one-quantity-one-owner rule, third costume: a duplicated DEFAULT is still a second
// owner. The composable's DEFAULTS (useDashboardLayout.js:98, with the row arithmetic comment)
// is the single home; anything this host needs to differ must be a conscious, single-key
// override with a reason, never a full mirrored copy that drifts.
})
// KPI strip — generic formatting per kind (mirrors the original inline strip).
function kpiText(k) {
if (k.kind === 'pct') return k.value
if (k.kind === 'distinct') return k.value || '—'
return fmt(k.value) // count + sum
}
// sticky export bar — only when a selection exists. Uses the ALL-FIELDS async path, same as the
// table header's Export CSV button, so both doors emit the identical full-width record.
// `selectionVersion` is the reactive handle on the (deliberately non-reactive) selectionSet —
// see the performance contract in useSelectionBus.js. Touch it or this never updates.
const selCount = computed(() => { bus.selectionVersion?.value; return bus.selectionSet ? bus.selectionSet.size : 0 })
function exportSelection() { exporter.exportCsvAsync() }
function clearSelection() { bus.clearSelection() }
function toggleFacet() { bus.emit('toggleFacet', !facetCollapsed.value) }
function toggleRail() { bus.emit('toggleRail', !railCollapsed.value) }
function onBarClick(e) {
const el = e.target && e.target.closest && e.target.closest('[data-bar-export],[data-bar-clear]')
if (!el) return
if (el.hasAttribute('data-bar-export')) exportSelection(); else clearSelection()
}
// The grid spine — injected into the shadow root (scoped <style> is dropped in the
// defineCustomElement build). Verbatim from the original research-dashboard.
useShadowStyles(`
/* ── DX-LIFE PALETTE TOKENS (card BUILD-DASHBOARD-PALETTE-DRIFT-TOKENIZE) ──
THE ONE PLACE the dashboard's colours are decided. Every panel must resolve its colour
through a token below — no raw hex in a component. The drift this fixes: 69 blue-grey
literals (line/muted/surface/text blue-greys) had accumulated across 8 standalone-
authored panels, so the dashboard rendered a COOLER palette than the light shell hosting
it. Nobody chose that; each panel simply invented its own hex.
Values are the live DX-life site's material-light palette (see
internal-tracker/01-ideation-research/REFERENCE-slim-material-ui-standards.md, REF-slim-material-ui-standards).
LIGHT ONLY — there is no dark mode in this product and none may be added.
★ SCOPE: this law governs UI CHROME — panels, cards, type, borders, controls; everything
whose colour comes from a token above. It does NOT govern BASEMAP TILES. The map panel
renders a dark basemap ('galaxy' — black ground, blue water, set in dashboard.config.js on
the agent's 2026-09-23 ruling), and that is DELIBERATE, not drift: a basemap is imagery the
data is drawn ON, chosen for choropleth contrast, not a surface the UI is themed with. A
dark map inside light chrome is the intended composition. Do not "fix" it to match the
palette, and do not cite it as precedent for darkening any chrome.
--dx-scroll-thumb is a DELIBERATE neutral, not a sage: it must read between line and muted
on both white and surface, which a sage tint does not do. It is named, therefore chosen. */
:host{ display:block; width:100%; height:100%; min-height:560px; box-sizing:border-box;
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px;
--dx-surface:#f3f3f1; --dx-elevated:#ffffff; --dx-text:#3b463d; --dx-muted:#6d766e;
--dx-line:#d1d4cd; --dx-accent:#92b195; --dx-scroll-thumb:#c4c8c0;
--dx-accent-weak:rgba(146,177,149,.18); --dx-hover:rgba(59,70,61,.06);
/* --rd-* are the dashboard's semantic aliases; they RESOLVE to the tokens above so a panel
written against either name inherits the same palette. */
--rd-accent:#e67e22; --rd-info:#2b6cb0;
--rd-line:var(--dx-line); --rd-bg:var(--dx-surface);
--rd-ink:var(--dx-text); --rd-muted:var(--dx-muted); --rd-panel:var(--dx-elevated);
--rd-primary:var(--dx-accent);
/* ── RAIL BALANCE (card BUILD-REBALANCE-THE-TWO-SIDEBARS) ────────────────────────────
The charts rail used to be 312px against the tree rail's 248px — the CHARTS rail was
64px WIDER than the tree, which is exactly what the agent saw ("it looks like the
CHART sidebar is LARGER than the TREE sidebar"). That was backwards for the content:
the tree carries long multi-word labels (bloc/cluster/region names) that must read on
ONE line, while the charts carry a donut plus a legend that can wrap.
So the widths SWAP ROLES: the tree takes the wider column, the charts the narrower.
The pair still sums to 560px, so the centre map cell is unchanged. */
/* ★ SYMMETRIC SIDEBARS (the agent 2026-09-23: "the charts sidebar can be wider so it is the
same width as the left sidebar, which will put the map in the exact center"). rail
264 -> 296 = facet width; equal flanks centre the middle column by construction. */
--facet-w:296px; --rail-w:296px;
/* ── ★ ONE EDGE GUTTER FOR THE WHOLE HEADER STACK ────────────────────────────────────
(card BUILD-THE-FOUR-HEADERS-ARE-ONE-CENTRED-STACK: "on the edges there should be padding
so that everything lines up perfectly.")
MEASURED BEFORE at 1600x900: topbar padding 14px, filters band 14px, KPI strip 16px. The
KPI band was inset 2px further than the two bands above it, so the stack's left edge was a
2px staircase rather than a line. Nobody chose that — each band picked its own number
(.rd-stats reached for the generic --s4 spacing step, the other two were hand-set to 14).
--rd-edge is now the single source; all three bands resolve it, so they cannot drift again
and a consumer can retune the whole stack's margin with one value. */
--rd-edge:14px;
/* ── ★ STATS-ROW HEIGHT, TOKENIZED (card BUILD-SLIM-THE-THREE-HEADERS) ─────────────────
DRIVER, 2026-09-23: "the three headers ... can be potentially slimmer ... I don't want a
super tall big picture dashboard. I want it to be visible all at once" — the goal is an
AFFORDANCE (the people-groups grid peeking below the fold), not a size limit, so this is a
density pass, not a deletion.
MEASURED at 1440x900: the KPI strip's own content (.kpi — a single baseline row, .n at
15px/800 beside .l at 10px) is 18px tall, centred via align-items:center inside a grid row
FIXED at 42px by .rd-shell's grid-template-rows — 24px of that 42 is empty space around an
18px line, the largest untouched margin of the three header bands (the topbar and filters
band were both already driven to the agent-set floor in earlier cards; see their own notes).
30px keeps 6px of breathing room above and below the 18px content — visibly padded, not
clipped — and recovers 12px with zero information loss.
Tokenized (not a bare literal) because 42px is repeated in THREE places that must stay in
sync — the default row template below, .rd-table-collapsed's, and .rd-narrow's — the same
failure mode --rd-edge was introduced to prevent for the horizontal gutter. */
/* ★ HEIGHT RECLAIM 2026-09-23: topbar 52->44, stats 30->26. Both are now VARS so a host can
tune them without editing three grid-template-rows declarations that must agree. */
--rd-topbar-h:38px;
--rd-stats-h:22px;
--fs-xs:10px; --fs-sm:11px; --fs-md:12px; --fs-lg:13px; --fs-xl:16px;
--r-lg:10px; --r-md:6px; --r-pill:999px; }
.profile-loader{ width:100%; height:100%; }
.rd-shell{ position:relative; display:grid; width:100%; height:100%; min-height:560px; box-sizing:border-box;
grid-template-columns: var(--facet-w,296px) minmax(0,1fr) var(--rail-w,296px);
grid-template-rows: var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px) 1fr var(--table-h,34%);
grid-template-areas: "topbar topbar topbar" "filters filters filters" "stats stats stats" "facets map rail" "facets table rail";
gap:1px; background:var(--rd-line, #d1d4cd); container-type:inline-size;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
color:var(--rd-ink, #3b463d); }
.rd-shell > [data-region="topbar"]{ grid-area:topbar; min-width:0; }
.rd-shell > [data-region="filters"]{ grid-area:filters; min-width:0; }
.rd-shell > [data-region="stats"]{ grid-area:stats; min-width:0; }
.rd-shell > [data-region="facets"]{ grid-area:facets; min-width:0; min-height:0; }
.rd-shell > [data-region="map"]{ grid-area:map; min-width:0; min-height:0; overflow:hidden; position:relative; }
.rd-shell > [data-region="table"]{ grid-area:table; min-width:0; min-height:0; overflow:hidden; }
.rd-shell > [data-region="rail"]{ grid-area:rail; min-width:0; min-height:0; }
.rd-rail{ display:flex; flex-direction:column; min-height:0; height:100%; background:var(--rd-bg, #f3f3f1); }
.rd-rail > [data-region="charts-rail"]{ flex:1 1 auto; min-height:0; }
/* Table collapsed → the grid+footer hide but the table HEADER BAR stays docked under the map,
still showing the live matched count and the Export CSV button (card
BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS). The row shrinks to the bar's height
instead of 0, so the map still takes back nearly the whole cell via its 1fr. */
.rd-shell.rd-table-collapsed{ grid-template-rows:var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px) 1fr var(--table-bar-h,30px); }
/* Facet/rail collapse — override the COLUMN template (not the --*-w vars) so collapse wins
over a dragged inline width, exactly like the table row-template above. Map is minmax(0,1fr)
and self-resizes via DashboardMapPanel's ResizeObserver → map.resize(). */
.rd-shell.rd-facet-collapsed{ grid-template-columns:0 minmax(0,1fr) var(--rail-w,296px); }
.rd-shell.rd-rail-collapsed{ grid-template-columns:var(--facet-w,296px) minmax(0,1fr) 0; }
.rd-shell.rd-facet-collapsed.rd-rail-collapsed{ grid-template-columns:0 minmax(0,1fr) 0; }
/* Always-reachable edge carets pinned to the map, toggle each rail (desktop only). */
.rd-edge-caret{ position:absolute; top:50%; transform:translateY(-50%); z-index:6; width:16px;
height:46px; display:flex; align-items:center; justify-content:center; padding:0; cursor:pointer;
border:1px solid var(--dx-line,#d1d4cd); background:var(--dx-elevated,#fff); color:var(--dx-text,#3b463d);
border-radius:5px; font-size:11px;
line-height:1; box-shadow:0 1px 4px rgba(0,0,0,.14); }
.rd-edge-caret:hover{ background:var(--dx-hover,rgba(59,70,61,.06)); }
.rd-edge-facet{ left:5px; } .rd-edge-rail{ right:5px; }
/* (the floating .rd-table-restore pill is retired — the table's own always-docked header bar
carries the chevron, the live count and Export CSV while collapsed) */
/* Medium-width niceties. These keep the SAME rail balance as the defaults above (tree wider
than charts) — slim-facet used to drop the tree to 196px against a 240px charts rail, which
re-created the inverted proportion this card fixed, just at a smaller screen size. The tree
gives up less because it is the rail whose labels must not wrap. */
.rd-shell.rd-slim-rail{ --rail-w:228px; }
.rd-shell.rd-slim-facet{ --facet-w:250px; }
/* Desktop drag-resize handles (useDashboardLayout). Thin, transparent until hover/active; each is
a grid item pinned to the edge of its cell so no pixel offsets are needed. Straddle the 1px grid
gap via a small negative margin for a comfortable hit target. Never rendered on narrow/mobile. */
.rd-rz{ position:relative; z-index:6; background:transparent; touch-action:none; transition:background .15s; }
.rd-rz::after{ content:''; position:absolute; background:transparent; border-radius:var(--r-pill,999px); transition:background .15s; }
/* Logical margins: the grid mirrors under dir=rtl (grid-template-columns + justify-self both
follow the inline direction), so a physical margin-right put the handle's 4px overhang on the
wrong side of its own column in Arabic. go-int's board already used the logical pair. */
.rd-rz-facet{ grid-area:facets; justify-self:end; align-self:stretch; width:8px; margin-inline-end:-4px; cursor:col-resize; }
.rd-rz-rail{ grid-area:rail; justify-self:start; align-self:stretch; width:8px; margin-inline-start:-4px; cursor:col-resize; }
.rd-rz-table{ grid-area:map; align-self:end; justify-self:stretch; height:8px; margin-bottom:-4px; cursor:row-resize; }
.rd-rz-facet::after, .rd-rz-rail::after{ top:50%; left:50%; transform:translate(-50%,-50%); width:3px; height:34px; }
.rd-rz-table::after{ top:50%; left:50%; transform:translate(-50%,-50%); width:34px; height:3px; }
/* ── SHELL HANDLE (page-scroll mode only) — the boundary between the WHOLE research-dashboard
element and whatever a host stacks below it (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER: this is
the control the agent actually asked for — "resize the height of the page" — which a previous
pass mistakenly bound to .rd-rz-table above instead). Positioned OUTSIDE the grid (the shell is
already position:relative) rather than given a grid-area, because it must span all THREE
columns (facets + map/table + rail) regardless of their widths, and no single grid-area does
that without also claiming cells .rd-rz-table already owns. Pinned to the shell's own bottom
edge — not the table cell's — so it tracks whichever row happens to be last. Grid-mode has no
concept of "the element's own height" (the shell IS the viewport there), so this handle only
ever renders in .rd-page-scroll — see the template's v-show. */
.rd-rz-shell{ position:absolute; left:0; right:0; bottom:0; height:10px; margin-bottom:-5px; cursor:row-resize; }
.rd-rz-shell::after{ top:50%; left:50%; transform:translate(-50%,-50%); width:48px; height:3px; }
.rd-rz:hover::after{ background:var(--rd-accent,#e67e22); }
.rd-rz:active{ background:color-mix(in srgb, var(--rd-accent,#e67e22) 18%, transparent); }
.rd-rz:active::after{ background:var(--rd-accent,#e67e22); }
/* Keyboard focus ring (tabindex="0" makes these real operable widgets, not just a mouse
affordance) — same accent as hover/active so keyboard and mouse read as one visual language. */
.rd-rz:focus-visible{ outline:2px solid var(--rd-accent,#e67e22); outline-offset:1px; }
.rd-rz:focus-visible::after{ background:var(--rd-accent,#e67e22); }
/* ★ BAND 4 OF THE CENTRED STACK — the PROGRESS header (UPGs / scope / population / reach /
countries / unreached / unadopted). Two changes, both from the one-centred-stack card:
- padding now resolves --rd-edge instead of --s4, so its gutter matches the topbar and the
filters band exactly (it was 16 against their 14 — measured).
- justify-content:safe center, so the KPI tiles read as a centred group like the bands
above rather than a left-justified run. MEASURED BEFORE: the 6 tiles spanned x=16..852
inside a 1600px band — i.e. they occupied the left half and left ~750px of white space on
the right, which is the "justified left" the agent is describing.
safe matters here as much as on the filter row: this band is overflow-x:auto, and plain
centring on an overflowing scroller pushes the first tile to a negative, unreachable offset. */
.rd-stats{ display:flex; align-items:center; gap:var(--s5,24px); height:100%; padding:0 var(--rd-edge,14px);
justify-content:center; justify-content:safe center;
background:var(--rd-panel,#fff); overflow-x:auto; box-sizing:border-box; }
.rd-stats .kpi{ display:flex; align-items:baseline; gap:6px; white-space:nowrap; }
.rd-stats .kpi .n{ font-weight:800; font-size:15px; color:var(--rd-ink, #3b463d); transition:color .2s; }
.rd-stats .kpi .l{ font-size:10px; text-transform:uppercase; letter-spacing:.4px; color:var(--rd-muted, #6d766e); }
.rd-stats .kpi .d{ color:var(--rd-accent,#e67e22); font-weight:700; font-size:11px; }
/* ── PAGE-SCROLL MODE — see the pageScroll note in the script block. Opt-in via
profile-config layout:page-scroll; unset everywhere else, so existing embeds are unchanged.
Three changes, each undoing one thing that makes this a fixed-viewport app:
height:auto — the shell stops being exactly as tall as its host band
--table-h:auto — the last grid row sizes to its rows instead of taking 34% of a screen
map gets a real px height — a 1fr map row with no definite parent height collapses, which
is the ORIGINAL defect the 88svh pin was introduced to avoid; giving
the map an explicit height keeps it while freeing the table.
The table's own overflow is released to visible in this mode, which is the actual fix: with
no internal scrollport under the cursor there is nothing to capture the wheel.
★ --rd-shell-h vs --rd-map-h (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER). Two INDEPENDENT
variables, answering two different questions, where there used to be only one:
--rd-shell-h = the ELEMENT's own total height (map + table combined) — the NEW bottom
handle (.rd-rz-shell) is the only thing that writes this.
--rd-map-h = the map's SHARE of that total — the existing map/table divider
(.rd-rz-table) writes this, redistributing within --rd-shell-h without
changing it (useDashboardLayout.js clamps mapH so it can never exceed the
shell budget). Every derived measurement below (table budget, facets/rail
span) now roots in --rd-shell-h so the total stays the single source of
truth and the two handles cannot fight over the same pixels. */
/* ★ THE SHELL TOTAL NOW DERIVES FROM THE TABLE, NOT THE OTHER WAY ROUND (regression I introduced
in a693865 and the agent caught immediately: "the height of the table is smaller now, but that
introduced a GAP, a grey spot ... which also broke the ability to resize the table").
WHAT I BROKE. The design was one budget with two consumers: --rd-shell-h was the total, and the
table's keyhole was DERIVED as shell - map - bar - foot. Capping the keyhole at 7 rows replaced
that derivation with an independent constant, so the table stopped answering to the shell. The
shell kept asserting its old ~720px total while the table gave back 118px, and since nothing
else consumed the slack it showed up as dead space — the grey spot. The resize handle broke for
the same reason: it writes --rd-shell-h, which the table no longer reads, so dragging moved a
number that nothing downstream obeyed.
THE FIX IS TO RESTORE ONE SOURCE OF TRUTH, but pointing the other way: the TABLE's seven rows
are now the declared quantity and --rd-shell-h is COMPUTED from it (map + bar + rows + foot).
Both handles keep working because both still write inputs to that sum: the map/table divider
writes --rd-map-h, and the shell handle writes --rd-shell-h, which overrides this default.
★ The var indirection matters: an explicit --rd-shell-h from the drag still wins, so this is a
DEFAULT, not a lock. That is what keeps the resize alive rather than pinning the element. */
/* ★ THE MAP ROW MUST NOT BE 1fr IN PAGE-SCROLL — this is the grey gap, found by measurement.
The base grid (line 290) sets the map row to 1fr, which is right in FIXED-VIEWPORT: the shell
has a real bounded height and 1fr means "take what is left". In PAGE-SCROLL the shell is
height:auto, so there is no fixed total to divide — 1fr instead becomes "stretch to whatever
the content implies", and the map ELEMENT is separately pinned to --rd-map-h. The row grows,
the element does not, and the difference paints as dead space between the map and the table.
MEASURED in a fresh context: shell 849 while its parts sum to 602 — a 117px gap, exactly the
"grey spot between the height of the map" the agent reported twice.
Pinning the row to the SAME var the element uses makes row and element one quantity again, so
the slack cannot exist. Both drags keep working because both write that var (or --rd-shell-h,
which the table's keyhole below derives from). */
/* ★ THE GREY GAP BETWEEN MAP AND TABLE = THE 1fr ROW STRETCHING PAST ITS PINNED OCCUPANT.
The base grid gives the map row 1fr, which is correct in fixed-viewport (a real bounded total
to divide) and wrong in page-scroll, where the shell is height:auto: 1fr then stretches to
whatever the content implies while the map ELEMENT stays pinned at --rd-map-h. The row grows,
the element does not, and the difference paints as dead space above the table — reported by
the agent three separate times. Pinning the ROW to the same var the ELEMENT uses makes them
one quantity, so the slack cannot exist; the table row goes auto for the same reason.
★ HISTORY, so this does not get reverted on a false signal again: this exact rule was applied
earlier today and reverted when a probe reported "no shell" — the real cause was a DEAD DEV
SERVER, not this CSS. Verified this time against a live server with a screenshot. */
.rd-shell.rd-page-scroll{ height:auto; --table-h:auto;
grid-template-rows: var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px) var(--rd-map-h,340px) auto; }
/* ★ COLLAPSE GIVES ITS SPACE TO THE MAP (the agent 2026-09-23: "collapsing the table is supposed to
collapse down and increase the size of the map -- instead it collapsed up and introduced white
space under the collapsed table"). The base .rd-table-collapsed template still says 1fr for the
map row, and 1fr cannot grow in an auto-height shell -- there is no fixed total to divide -- so
the freed space pooled under the bar as white nothing. In page-scroll the collapsed map takes
the SAME shell budget the open state uses, minus only the bar: the one-owner derivation again,
so collapse/expand and both drag handles all move the same quantity instead of fighting.
Both the ROW and the ELEMENT get the height, for the same reason they share --rd-map-h when
open: two owners of one height is the bug this file keeps re-learning. */
.rd-shell.rd-page-scroll.rd-table-collapsed{
grid-template-rows: var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px)
calc(var(--rd-shell-h, 562px) - var(--table-bar-h,30px)) var(--table-bar-h,30px); }
.rd-shell.rd-page-scroll.rd-table-collapsed > [data-region="map"]{
height:calc(var(--rd-shell-h, 562px) - var(--table-bar-h,30px)); }
.rd-shell.rd-page-scroll > [data-region="map"]{ height:var(--rd-map-h, 340px); overflow:hidden; }
.rd-shell.rd-page-scroll > [data-region="table"]{ overflow:visible; }
.rd-shell.rd-page-scroll .rd-table-wrap{ height:auto; }
/* ★ THE TABLE KEEPS AN INTERNAL SCROLL, BUT A GENEROUS ONE — and that is the whole fix.
MEASURED, both extremes are wrong. Pinned to 34% of one screen it is a ~269px keyhole over
54834px of rows, so it captures every wheel event and the page moves 300px where it should
move 3000. Released to overflow:visible it renders all 54834px and the grid below lands at
y=57777 — reachable in principle, useless in practice. Two failures of the same trade.
A tall bounded panel is neither: 62svh is ~558px at a 900px viewport, roughly double the old
keyhole, so the table is genuinely usable on its own, while the section stays about one screen
and the page still scrolls past to the grid. The wheel does get consumed while the cursor is
inside the table, which is correct and expected for a scrollable panel — the difference is the
panel is now large enough that a user reads it deliberately rather than falling into it. */
/* ── ★ CAPPED TO MATCH THE MAP ROW, NOT A ROUND FRACTION OF THE SCREEN (BUG-DASHBOARD-BAND-
GROWS-ENORMOUS, same card as the facets/rail rule below). The original 62svh cap sized the
INNER SCROLLPORT only; the table's HEADER BAR (.rd-table-bar, the count + Export CSV strip)
sits on top of it and is not part of that budget, so the OUTER table CELL was always
header-bar-height taller than the map row beside it. MEASURED at 1440x900 (prior to the
40svh default below): bar 32px, grid 558px, cell 590px against a 522px map row — a stair-step
the agent's "visible on one desktop page" invariant does not allow. calc() derives the
grid's budget from --rd-map-h MINUS the bar's own rendered height, so the two rows land at
the SAME total and can never drift apart again the way a second hardcoded svh number could.
--rd-table-bar-h is a var, not a literal, so DashboardDataTable can correct it if the bar's
height ever changes without this rule going stale. --rd-table-max-h remains the escape hatch
for a host wanting a different table budget entirely (it wins over the calc() via the
var()'s own fallback).
★ THE DEFAULT ALSO DROPPED 58svh -> 40svh (this file + useDashboardLayout.js's mapH default,
kept in lockstep per that file's own comment). MEASURED, matching this composite ONLY: even
with the header-bar fix above, chrome(137px: topbar 52 + filters 55 + stats 30) + map(522px)
+ table(554px) summed to 1213px against a 900px viewport — internal/check-fits-the-screen.mjs
clause A FAILs above 900px, and B/C then fail too because a fixed test cursor lands on the
dashboard's own internal scrollers instead of the open page below it (the band is still
taller than where the gesture happens). 40svh (~360px at 900px) brings chrome + map(360) +
table(392) to ~889px, clearing all three clauses with an 11px margin. The resize handle
(useDashboardLayout's mapH drag) still lets a large-monitor user drag it back up afterward —
this only changes what a FIRST LOAD shows.
★ RE-ROOTED IN --rd-shell-h (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER). The table's budget used
to be map-h minus the bar, which made the map/table divider ALSO the element's total-height
control (growing the map grew the total, since nothing capped it). Now the table's budget is
the SHELL's total minus whatever the map is currently taking minus the bar — so dragging the
map/table divider (which changes --rd-map-h, never --rd-shell-h) redistributes between the two
rows while their SUM stays exactly --rd-shell-h. Fallback 80svh mirrors useDashboardLayout.js's
shellH default (720px ≈ 2×40svh at 900px) — the map+table combined budget --rd-map-h alone used
to imply, so first paint is unchanged.
★ ALSO SUBTRACTS --rd-table-foot-h NOW — MEASURED, a real gap this exposed. The table cell has
THREE stacked children (.rd-table-bar, .rd-grid, .rd-foot — the "Σ" summary row,
DashboardDataTable.vue's height:28px), but the old formula only ever budgeted for the bar. That
was invisible before this card: with nothing else depending on map-h + table-cell summing to an
exact number, the extra 28px just meant the table cell was quietly 28px taller than the map row
beside it. It became visible the moment facets/rail needed to span EXACTLY map+table (below) —
without this term they undershot by the footer's height, leaving them 28-30px short of the
table's real bottom edge, i.e. the sidebar grey-gap bug this card is fixing. --rd-table-foot-h
is a var (not a literal) for the same reason --rd-table-bar-h is: DashboardDataTable can correct
it if the footer's height ever changes, without this rule going stale. */
/* ★ SEVEN DATA ROWS BY DEFAULT (the agent 2026-09-23, and he did the arithmetic himself):
"the table needs to only show SEVEN rows by default height. 7 people group rows. That's NOT
including the header. If you include the field, there'll be 8 rows. If you include the footer,
that'd be 9. If you include the table's header, 10. But 7 actual data people groups."
That also settles an earlier ambiguity: he had said 77 and 7 in one breath; 7 is DATA ROWS.
★ STATED IN ROWS, NOT PIXELS, deliberately. The old formula derived the keyhole by subtraction
(shell - map - bar - foot), so the row count was whatever fell out — MEASURED 300px = 11 rows,
not 7. Deriving from --rd-table-rows x --rd-table-row-h means the promise is the declaration:
change the row height and 7 rows stays 7 rows. --rd-table-max-h remains the escape hatch for a
host that wants an explicit pixel cap.
GOAL CONTEXT: "we are trying to RECLAIM HEIGHT." 7 rows is 182px against today's 300px keyhole,
so this alone returns 118px of the ~147px available. */
.rd-shell.rd-page-scroll .rd-grid{
max-height:var(--rd-table-max-h, calc(var(--rd-shell-h, 562px) - var(--rd-map-h, 340px) - var(--rd-table-bar-h, 32px) - var(--rd-table-foot-h, 28px))); }
/* ── FACETS/RAIL COLUMNS BOUNDED IN PAGE-SCROLL MODE (BUG-DASHBOARD-BAND-GROWS-ENORMOUS) ──────
THE ROOT CAUSE, one level up from the table rule above. .rd-facets/.rd-rail are authored
height:100%, which is correct in the FIXED-VIEWPORT grid (rows sum to a real, bounded
height) but resolves to INTRINSIC content height once page-scroll sets --table-h:auto — the
grid's rows are no longer a fixed sum, so "100%" cascades into "as tall as my content wants".
MEASURED at 1440x900 with all 5 tree sections open: .fr-body clientHeight === scrollHeight
=== 2117px — its OWN overflow-y:auto never engaged, because it was never actually
constrained, and the whole .rd-shell measured 2325px. Collapsing sections by default (see
DashboardFacetRail's open state) hides the symptom on first paint; re-opening even two or
three trees reproduces the same unbounded growth, because nothing upstream of the scroller
was ever fixed. This rule is that fix: give the two side columns a real cap so their own
internal scrollbars do the job they were built for, exactly like the table row above them.
Reuses --rd-map-h (the same explicit height the map row already pins in this mode) rather
than inventing a third height variable — the three regions read as one bounded row. This
selector is scoped to .rd-page-scroll only; the fixed-viewport grid already has a bounded
row via 1fr/34% and never reaches it. */
/* ★ CORRECTED 2026-09-23 — THE CAP WAS RIGHT, THE HEIGHT IT CHOSE WAS NOT.
Capping at --rd-map-h bounded the side columns to the MAP's height, so the rail stopped at the
map's bottom edge and the table below it sat outside the sidebars entirely. MEASURED at
1440x900: facets 297..657 (h=360, identical to the map), table 658..1050. The sidebar ended
where the table began.
★ THE DRIVER HAS ASKED FOR THIS FOUR TIMES, and each earlier pass adjusted the rail's CONTENTS
instead of its BOUND: "the sidebars are supposed to pass the bottom of the map down to the
bottom of the table, so they span the full sides of the map AND the table — you have a map and
table in the middle surrounded by sidebars."
The grid already expresses exactly that: grid-template-areas is "facets map rail" /
"facets table rail", so facets and rail SPAN BOTH ROWS by construction. This one max-height
was overriding a layout that was already correct — which is why adjusting what the rail
CONTAINED never fixed it.
Bounding to the two spanned rows instead of the map row keeps the original intent (a real cap,
so .fr-body's own overflow-y:auto finally engages rather than the column growing without
limit — the 2117px unbounded case below) while restoring the span. The rail's scrollbar now
appears when its content exceeds map+table, not when it exceeds the map alone.
★ CORRECTED AGAIN, SAME DAY — "let the grid do the bounding" measured FALSE. min-height:0 +
overflow:hidden with NO max-height reintroduced the original bug in a new shape: MEASURED with
4 of 5 trees open by default (the agent's own correction, see DashboardFacetRail's
OPEN_BY_DEFAULT), the map ROW TRACK itself grew to 1627px (gridTemplateRows became
"52px 55px 30px 1627px 390px") and the whole band hit 2159px. Root cause: the map/table ROW
TRACKS are 1fr/auto (line 275, --table-h:auto in this mode) — flexible tracks that size to
their TALLEST occupant. --rd-map-h is set on the [data-region="map"] CHILD's own height, not on
the grid track, so it bounds the map element without bounding the TRACK; a spanning sidebar with
no cap of its own is exactly the kind of taller occupant that stretches a flexible track. CSS
Grid does not intrinsically bound a spanning item to what its co-occupants explicitly request —
only to what the TRACK resolves to, and an unbounded occupant is part of that resolution.
So the cap has to live on facets/rail after all, computed as map + table (both budgets this file
already owns) rather than map alone — that is what actually matches "span the map AND the
table". +1px absorbs the 1px grid gap between the two rows this element spans.
★ THE ORIGINAL VERSION OF THIS COMMENT CLAIMED reusing --rd-table-max-h here "keeps the two in
lockstep by construction" — THAT WAS AN ASSERTION, NOT A MEASUREMENT, AND IT WAS FALSE.
MEASURED (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER, sampled once per second for 16s on the
built dashboard-plus-grid composite, not a single post-load snapshot — a single snapshot had
already missed this same defect twice): facets/rail sat a STABLE -62px above table.bottom on
every sample. --rd-table-max-h (line 434) caps the table's INNER scrollport only (.rd-grid);
the table's OUTER cell is that plus its header bar AND its "Σ" summary footer — two elements
this formula never accounted for — so "the two budgets already owned" were never actually the
same number. The two were never in lockstep; nothing here recomputed either at runtime, so the
old -62px was permanent on every load, not a timing artifact.
★ THE FIX: re-root BOTH this rule and the table rule (line 434) in ONE shared variable,
--rd-shell-h, instead of deriving the sidebar span from a second, independently-drifting
estimate. map-h + table-cell(bar + grid-max-h + foot) now sums to EXACTLY --rd-shell-h BY
CONSTRUCTION (table-max-h at line 434 is defined as shell-h - map-h - bar-h - foot-h, so
bar-h + foot-h + table-max-h = shell-h - map-h, and adding map-h back cancels it algebraically
— not just on this data set, for any value of any of the four terms). This also means the
sidebars automatically track the NEW shell handle (.rd-rz-shell) the same tick it moves, with
no separate wiring. RE-VERIFIED after this fix: -2px (grid-gap/border rounding) on every one of
17 samples across 16s, and check:fits-screen clause A (force-expand every facet section) still
shows growth=0px against its 24px tolerance — the cap still stops the flexible map/table row
TRACKS from stretching to an uncapped sidebar's content height (CSS Grid does not intrinsically
bound a spanning item to its co-occupants — see the CORRECTED AGAIN note above); only the
NUMBER the cap computes was wrong, not the need for a cap at all. */
.rd-shell.rd-page-scroll > [data-region="facets"],
.rd-shell.rd-page-scroll > [data-region="rail"]{ min-height:0; overflow:hidden;
/* ★ FALLBACK UNIFIED AT 502px (was 80svh = 720px). Two rules read --rd-shell-h and their
FALLBACKS disagreed: the table keyhole fell back to 502px (seven rows) while this cap fell
back to 720px. Nothing sets the var at rest, so the sidebars believed the shell was 119px
taller than the table did -- the spanning columns stretched the auto table row to fit, and
the surplus painted as the grey band the agent reported between/under map and table, and
made a divider drag look broken (the row would not follow, the slack just moved).
ONE QUANTITY, ONE OWNER applies to fallbacks too: same var, same fallback, everywhere. */
max-height:calc(var(--rd-shell-h, 562px) + 1px); }
/* ★ THE ELEMENT MUST SIZE TO ITS CONTENT IN PAGE-SCROLL (the agent 2026-09-23: "massive grey gap
between map and table and the map overflows into the people-group grid page — the dashboard
element is not one unified element above the grid element, it is overflowing").
MEASURED in the cap-5 shell at 1440x900: INSIDE the dashboard everything was tight (map bottom
536, table top 537, gap 1px, table flush with .rd-shell at 899) while the HOST element measured
3510px tall — 2611px of empty element below the content. That empty tail is the grey gap, and
it is what pushes the people-groups grid down and makes the two elements look like they
overlap rather than stack.
CAUSE: :host carries height:100%, which is right in the FIXED-VIEWPORT host (the pane gives it
a real bounded height to fill) but wrong in page-scroll, where the ancestor is a tall scroller
and 100% of it is thousands of px. The shell inside already switched to height:auto for exactly
this reason (line 361); the HOST never did, so the element kept claiming the scroller's height
no matter how short its content was.
A host cannot see its own .rd-page-scroll class (that lives on a descendant), so the switch is
keyed on the same pageScroll flag via a host attribute the template sets. height:auto + the
min-height dropped: in page-scroll the content IS the height.
★ SCOPE, HONESTLY: this is NOT reachable from dashboard-plus-grid, which imports this file as a
plain Vue COMPONENT (dashboard-plus-grid.vue:19) rather than mounting <research-dashboard> as a
custom element -- there is no shadow host for :host to match there, and MEASURED, that composite
stacks correctly anyway (dashboard section 0..864, grid section 864..3031, gap 0). The rule
applies where research-dashboard IS mounted as its own element, which includes the studio's new
per-composite folders. Kept because the reasoning holds and the cost is one attribute; labelled
UNPROVEN because no host has yet been measured before-and-after through this path. */
:host([data-page-scroll="true"]){ height:auto; min-height:0; }
.rd-shell.rd-narrow{ grid-template-columns:1fr; grid-template-rows:var(--rd-topbar-h,38px) auto var(--rd-stats-h,22px) minmax(0,1fr) 46px;
grid-template-areas:"topbar" "filters" "stats" "body" "seg"; }
.rd-shell.rd-narrow > [data-region]{ grid-area:body; min-width:0; min-height:0; }
.rd-shell.rd-narrow > [data-region="topbar"]{ grid-area:topbar; }
.rd-shell.rd-narrow > [data-region="filters"]{ grid-area:filters; }
.rd-shell.rd-narrow > [data-region="stats"]{ grid-area:stats; }
.rd-seg{ grid-area:seg; display:none; }
.rd-shell.rd-narrow .rd-seg{ display:flex; }
.rd-seg button{ flex:1; border:0; background:var(--dx-elevated,#fff); border-top:1px solid var(--rd-line,#d1d4cd); font-size:12px; font-weight:700; color:var(--dx-muted,#6d766e); cursor:pointer; }
.rd-seg button.on{ color:var(--rd-ink, #3b463d); box-shadow:inset 0 -3px 0 var(--rd-accent,#e67e22); }
/* VISUALLY HIDDEN, NOT display:none. display:none (and visibility:hidden, and width/height:0 with
overflow visible) removes the node from the ACCESSIBILITY tree too, which would silence the one
region that exists to be heard. The clip-path + 1px box is the standard visually-hidden recipe:
the node keeps a real box, stays in the a11y tree, and paints nothing. white-space:nowrap stops
a long sentence from forcing a scrollbar on the shell while it is clipped. */
.rd-sr-live{ position:absolute; width:1px; height:1px; margin:-1px; padding:0; border:0;
overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
.rd-exportbar{ position:absolute; left:0; right:0; bottom:0; display:flex; align-items:center; gap:10px; padding:8px 14px;
background:var(--rd-ink, #3b463d); color:#fff; z-index:50; box-shadow:0 -2px 10px #0003; animation:rd-slideup .2s ease; }
.rd-exportbar .sp{ flex:1; } .rd-exportbar b{ font-size:13px; }
.rd-exportbar button{ border:0; border-radius:6px; padding:6px 14px; font-weight:700; font-size:12px; cursor:pointer; }
.rd-exportbar .x-go{ background:var(--rd-accent,#e67e22); color:#fff; } .rd-exportbar .x-clr{ background:#ffffff22; color:#fff; }
@keyframes rd-slideup{ from{ transform:translateY(100%); } to{ transform:translateY(0); } }
@media (prefers-reduced-motion: reduce){ .rd-shell *{ transition:none !important; animation:none !important; } }
/* A11Y / PHOTOSENSITIVITY (BUG-RESIZE-STROBE): while a divider is being dragged the grid tracks
change every frame. Any transition on a descendant would be restarted every frame and never
reach its end state, which reads as flicker. useDashboardLayout sets data-rd-resizing for the
duration of the drag; kill all motion underneath it and re-enable on drag end. */
.rd-shell[data-rd-resizing] *{ transition:none !important; animation:none !important; }
/* Dragging over the map/table would otherwise start a text selection and repaint the selection
highlight each frame — another flashing source. */
.rd-shell[data-rd-resizing]{ user-select:none; -webkit-user-select:none; cursor:inherit; }
/* ── Thin overlay scrollbars (BUILD-MATERIAL-THIN-OVERLAY-SCROLLBARS) ──
Material-light palette. Transparent track so the bar sits flush to the panel edge and costs
no visual space; low-contrast thumb that darkens on hover. Applied to every scroll region in
the shell so the rails, table and charts all match. */
.rd-shell, .rd-shell *{ scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent; }
.rd-shell ::-webkit-scrollbar{ width:8px; height:8px; }
.rd-shell ::-webkit-scrollbar-track{ background:transparent; }
.rd-shell ::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:8px; }
.rd-shell ::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
.rd-shell ::-webkit-scrollbar-corner{ background:transparent; }
`, 'rd-shell-layout')
// ★ MIRROR pageScroll ONTO THE HOST ELEMENT (BUG-DASHBOARD-ELEMENT-CLAIMS-SCROLLER-HEIGHT).
// :host carries height:100%, which the fixed-viewport host needs and the page-scroll host must
// NOT have — in a tall scroller, 100% is thousands of px, so the element kept a 2611px empty tail
// below its own content (MEASURED in the cap-5 shell: content ended at 899, host measured 3510).
// A host cannot be selected by a class on its own descendant, so the flag is reflected outward as
// an attribute and the :host([data-page-scroll="true"]) rule above switches to height:auto.
// Reflected rather than passed as a prop because pageScroll is already derived inside this
// component; a second prop would be a second source of truth that can disagree with the first.
onMounted(() => {
const host = shell.value && shell.value.getRootNode && shell.value.getRootNode().host
if (!host) return
const sync = (on) => host.setAttribute('data-page-scroll', on ? 'true' : 'false')
sync(unref(pageScroll))
watch(() => unref(pageScroll), sync)
})
</script>
<template>
<div ref="shell" class="rd-shell"
:class="{ 'rd-narrow': isNarrow, 'rd-slim-rail': slimRail, 'rd-slim-facet': slimFacet, 'rd-table-collapsed': tableCollapsed, 'rd-facet-collapsed': facetCollapsed, 'rd-rail-collapsed': railCollapsed, 'rd-page-scroll': pageScroll }"
:data-row-count="rowCount" :data-loading="isLoading ? '1' : '0'" :data-error="loadError || ''" :data-narrow="isNarrow ? '1' : '0'">
<DashboardTopBar />
<DashboardResearchFilters />
<div class="rd-stats" data-region="stats" data-sub="stat-strip">
<div class="kpi" v-for="k in kpis" :key="k.id">
<span class="n" :data-kpi="k.id">{{ k.kind === 'count' && isLoading ? '…' : kpiText(k) }}</span>
<span class="l">{{ k.label }}</span>
<span v-if="k.delta" class="d" data-kpi-delta>↓{{ fmt(k.delta) }}</span>
</div>
</div>
<DashboardFacetRail v-show="showFacets" />
<div class="rd-cell-map" data-region="map" v-show="showMap">
<DashboardMapPanel />
<!-- (the floating "▴ Show table" restore button is gone: the table's own header bar — with its
chevron, live count and Export CSV — now stays docked under the map while collapsed) -->
<button v-if="!isNarrow" class="rd-edge-caret rd-edge-facet" type="button" data-toggle-facet
:title="facetCollapsed ? 'Show filters' : 'Hide filters — grow the map'"
@click="toggleFacet">{{ facetCollapsed ? '▸' : '◂' }}</button>
<button v-if="!isNarrow" class="rd-edge-caret rd-edge-rail" type="button" data-toggle-rail
:title="railCollapsed ? 'Show charts' : 'Hide charts — grow the map'"
@click="toggleRail">{{ railCollapsed ? '◂' : '▸' }}</button>
</div>
<!-- The table CELL stays mounted+visible when collapsed; DashboardDataTable hides only its own
grid+footer and keeps its header bar (count + Export CSV) docked below the map. -->
<div class="rd-cell-table" data-region="table" v-show="showTable"><DashboardDataTable /></div>
<aside class="rd-rail" data-region="rail" v-show="showCharts">
<DashboardDetailDrawer />
<DashboardChartsRail />
</aside>
<!-- Desktop drag-resize handles — never on narrow/mobile; the map/table divider also hides
while the table is collapsed (nothing to resize). @pointerdown starts the drag.
★ KEYBOARD A11Y: `title` is a TOOLTIP, not an accessible name (this repo's own
check:a11y-button-names comment says so), and a role="separator" with no tabindex is not
focusable — so none of these three were keyboard-operable. tabindex="0" + aria-label +
@keydown (Arrow keys step, Home/End jump to the clamp extremes) makes each one a real
operable widget; aria-valuenow/min/max report the LIVE ref the pointer-drag path already
owns, so the two input methods can never disagree about the current value. -->
<div v-show="!isNarrow && !facetCollapsed" class="rd-rz rd-rz-facet" data-resize="facet"
role="separator" aria-orientation="vertical" tabindex="0"
:title="T('aria.resizeFiltersRailHint', 'Drag or use arrow keys to resize the filters rail')" :aria-label="T('aria.resizeFiltersRail', 'Resize filters rail')"
:aria-valuenow="facetW" aria-valuemin="160" aria-valuemax="420"
@pointerdown="startFacetResize" @keydown="onResizeKeydown('facet', $event)"></div>
<div v-show="!isNarrow && !railCollapsed" class="rd-rz rd-rz-rail" data-resize="rail"
role="separator" aria-orientation="vertical" tabindex="0"
:title="T('aria.resizeChartsRailHint', 'Drag or use arrow keys to resize the charts rail')" :aria-label="T('aria.resizeChartsRail', 'Resize charts rail')"
:aria-valuenow="railW" aria-valuemin="200" aria-valuemax="520"
@pointerdown="startRailResize" @keydown="onResizeKeydown('rail', $event)"></div>
<!-- ★ FIXED (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER): this handle is the MAP/TABLE SPLIT
ONLY, in both modes — it must never change the element's overall height, which is now the
NEW .rd-rz-shell handle's job below. In page-scroll mode it still drives --rd-map-h (the
map's SHARE of --rd-shell-h, not the total — see the CSS note above .rd-shell.rd-page-scroll),
because --table-h is force-set to `auto` and no longer drives any layout — see the
PAGE-SCROLL AXIS SWITCH note in useDashboardLayout.js.
★ THE ONE HANDLE WHOSE NAME NEVER REACHED THE CATALOGUE. Its four siblings above and
below read their title and aria-label through T(); these two were RAW ENGLISH literals,
so this separator announced itself in English in all 11 locales while the handles on
either side of it translated. It survived review because each half looks correct in
isolation — the ternary is right, the strings are right, and only the MISSING T( is
wrong. Two keys per handle, the same shape as the siblings: the *Hint is the tooltip,
the plain key is the accessible name. -->
<div v-show="!isNarrow && !tableCollapsed" class="rd-rz rd-rz-table" data-resize="table"
role="separator" aria-orientation="horizontal" tabindex="0"
:title="pageScroll ? T('aria.resizeMapTableSplitHint', 'Drag or use arrow keys to resize the map/table split') : T('aria.resizeTableHint', 'Drag or use arrow keys to resize the table')"
:aria-label="pageScroll ? T('aria.resizeMapTableSplit', 'Resize map/table split') : T('aria.resizeTable', 'Resize table height')"
:aria-valuenow="pageScroll ? mapH : (parseFloat(tableH) || 34)"
:aria-valuemin="pageScroll ? 220 : 12" :aria-valuemax="pageScroll ? 1400 : 70"
@pointerdown="startTableResize" @keydown="onResizeKeydown('table', $event)"></div>
<!-- ★ THE NEW HANDLE (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER) — a bar at the SHELL's own
bottom edge, spanning all three columns, on the boundary between the whole
research-dashboard element and whatever a host stacks below it (e.g. the people-groups
grid in dashboard-plus-grid.vue). This is what actually answers "resize the height of the
page": it is the ONLY handle that writes --rd-shell-h. Page-scroll only — grid-mode has no
concept of the element's own height (the shell IS the viewport there, sized by its host
band), so there is nothing for this handle to do outside page-scroll. -->
<div v-show="!isNarrow && pageScroll" class="rd-rz rd-rz-shell" data-resize="shell"
role="separator" aria-orientation="horizontal" tabindex="0"
:title="T('aria.resizeDashboardHeightHint', `Drag or use arrow keys to resize the dashboard's height`)"
:aria-label="T('aria.resizeDashboardHeight', 'Resize dashboard height')"
:aria-valuenow="shellH" aria-valuemin="400" aria-valuemax="2600"
@pointerdown="startShellResize" @keydown="onResizeKeydown('shell', $event)"></div>
<!-- ★ THE POLITE LIVE REGION (see the announce() block in the script). ALWAYS RENDERED and
starting EMPTY — no v-if, no v-show, never inside a collapsible cell: a live region that
is inserted already carrying its message is routinely not announced, because the
accessibility tree has to be watching the node when the text changes. It is visually
hidden rather than display:none, because display:none removes it from the accessibility
tree as well and would silence it for the very users it exists for. -->
<div class="rd-sr-live" data-live-region role="status" aria-live="polite" aria-atomic="true">{{ liveMessage }}</div>
<div class="rd-seg" data-segmented @click="setView">
<button v-for="s in SEG" :key="s.k" :class="{ on: mobileView === s.k }" data-view-btn :data-view="s.k">{{ s.l }}</button>
</div>
<div v-if="selCount" class="rd-exportbar" data-export-bar @click="onBarClick">
<b>{{ selCount }} selected</b><span class="sp"></span>
<button class="x-clr" data-bar-clear>{{ T('controls.clear', 'Clear') }}</button>
<!-- The glyph stays a glyph and the words come from the catalogue — one spelling of this
action across the dashboard, not two. -->
<button class="x-go" data-bar-export>{{ T('dashboard.export_csv', 'Export CSV') }} ↓</button>
</div>
</div>
</template>