892 lines
70 KiB
Vue
892 lines
70 KiB
Vue
<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>
|