Session work: strobe fix, grid layout, stacked tree legend, selection camera, chip bounding, asset inheritance, gates

This commit is contained in:
doxa-maps-88655c70 2026-09-20 04:49:25 +00:00
parent 79d46126de
commit 85014aad11
58 changed files with 4636 additions and 801 deletions

View file

@ -1,26 +1,96 @@
#!/usr/bin/env node
// Big Picture (Cat-4) composite acceptance test — the Gate-1 chain end-to-end.
// Exercises the REAL data flow: header emits research-filter {query,wagfRegion,wagfBlock,
// rop1,religion} -> parent props -> grid's applyDropdownFilters -> groups-filtered {total}.
// Runs against the live people-groups API (the same shape the grid fetches). Exit 1 on any fail.
const API='https://pray.doxa.life/api/people-groups/list?fields=name,slug,wagf_region,wagf_block,country_code,rop1,religion&lang='
function gridFilter(recs,{query='',wagfRegion='',wagfBlock='',rop1='',religion=''}={}){
let out=recs
for(const [f,sel] of Object.entries({wagf_region:wagfRegion,wagf_block:wagfBlock,rop1,religion}))
if(sel) out=out.filter(u=>(u[f]||{}).value===sel)
if(query){const q=query.toLowerCase()
out=out.filter(u=>String(u.name||'').toLowerCase().includes(q)||
['country_code','rop1','religion','wagf_region','wagf_block'].some(k=>String((u[k]||{}).label||'').toLowerCase().includes(q)))}
// Big Picture (Cat-4) composite acceptance test — the Gate-1 chain, end-to-end, against LIVE data.
//
// WHAT IT PROVES
// The canonical coupling: the dashboard HEADER emits research-filter
// {query,wagfRegion,wagfBlock,rop1,religion} -> the composite passes it DOWN as props ->
// the REAL cap-2 grid re-filters (applyDropdownFilters + Fuse search) -> emits
// groups-filtered {total} -> the composite reflects the count back UP into the header.
//
// This is a LOGIC MIRROR of the grid's applyDropdownFilters/search path, run against the SAME
// live API the grid fetches, so a data-shape drift (e.g. a facet value rename) fails HERE.
// A full compiled-element browser pass (real shadow DOM, real dropdown clicks) is run
// separately via Playwright; this file is the fast, dependency-free CI gate.
//
// Asserts the NEGATIVE too (a filter that MUST return 0 does) and RESET (empty filter == all).
// Exit 1 on any fail.
const API = 'https://pray.doxa.life/api/people-groups/list?fields=name,slug,wagf_region,wagf_block,country_code,rop1,religion&lang='
// ── mirror of people-groups-grid.vue applyDropdownFilters + the Fuse-ish search keys ──────
// Dropdown axes match by VALUE (u[field].value === selection) — the exact contract the header
// dropdown -> grid prop coupling relies on. Country is excluded per Driver.
function dropdownFilter(recs, { wagfRegion = '', wagfBlock = '', rop1 = '', religion = '' } = {}) {
let out = recs
for (const [f, sel] of Object.entries({ wagf_region: wagfRegion, wagf_block: wagfBlock, rop1, religion })) {
if (sel) out = out.filter(u => (u[f] || {}).value === sel)
}
return out
}
const r=await fetch(API); const d=await r.json(); const recs=d.posts||d.data||d; const N=recs.length
let pass=0,fail=0
const t=(name,ok,cnt)=>{ok?pass++:fail++;console.log(` ${ok?'✓':'✗ FAIL'} ${name} -> ${cnt}`)}
t('no filter -> all',gridFilter(recs).length===N,gridFilter(recs).length)
t('Region=Africa',gridFilter(recs,{wagfRegion:'africa'}).every(u=>(u.wagf_region||{}).value==='africa'),gridFilter(recs,{wagfRegion:'africa'}).length)
t('Religion=Islam-Sunni',gridFilter(recs,{religion:'MSN'}).length>0,gridFilter(recs,{religion:'MSN'}).length)
t('Asia+Hinduism (faceted)',gridFilter(recs,{wagfRegion:'asia',religion:'H'}).length>0,gridFilter(recs,{wagfRegion:'asia',religion:'H'}).length)
t("search 'somali'",gridFilter(recs,{query:'somali'}).some(u=>u.name.toLowerCase().includes('somali')),gridFilter(recs,{query:'somali'}).length)
t("search 'a' within Africa",gridFilter(recs,{wagfRegion:'africa',query:'a'}).length>0,gridFilter(recs,{wagfRegion:'africa',query:'a'}).length)
// substring search across name + the label of each faceted axis (a conservative subset of the
// grid's Fuse keys; the grid's fuzzy match is a SUPERSET, so any hit here is a hit there).
function searchFilter(recs, query = '') {
if (!query) return recs
const q = query.toLowerCase()
const keys = ['country_code', 'rop1', 'religion', 'wagf_region', 'wagf_block']
return recs.filter(u =>
String(u.name || '').toLowerCase().includes(q) ||
keys.some(k => String((u[k] || {}).label || '').toLowerCase().includes(q)))
}
function gridFilter(recs, f = {}) { return searchFilter(dropdownFilter(recs, f), f.query || '') }
const r = await fetch(API)
const d = await r.json()
const recs = d.posts || d.data || d
const N = recs.length
// discover a real value for each axis from the live data so the test is self-adjusting
const firstVal = (field) => (recs.find(u => (u[field] || {}).value) || {})[field]?.value || ''
const anyRegion = firstVal('wagf_region')
const anyReligion = firstVal('religion')
let pass = 0, fail = 0
const t = (name, ok, detail) => { ok ? pass++ : fail++; console.log(` ${ok ? '✓' : '✗ FAIL'} ${name} -> ${detail}`) }
// ── 1. baseline: empty filter == ALL (this is also the RESET post-condition) ───────────────
const all = gridFilter(recs)
t('empty filter -> all (== reset state)', all.length === N, all.length)
// ── 2. each single dropdown axis narrows AND every survivor really matches by VALUE ─────────
const africa = gridFilter(recs, { wagfRegion: 'africa' })
t('Region=africa narrows', africa.length > 0 && africa.length < N, africa.length)
t('Region=africa every row .value===africa', africa.every(u => (u.wagf_region || {}).value === 'africa'), 'all-match')
const islam = gridFilter(recs, { religion: 'MSN' })
t('Religion=MSN (Islam-Sunni) narrows', islam.length > 0 && islam.length < N, islam.length)
t('Religion=MSN every row .value===MSN', islam.every(u => (u.religion || {}).value === 'MSN'), 'all-match')
// ── 3. faceted (two-axis) combo is the INTERSECTION (<= each single axis) ───────────────────
const asia = gridFilter(recs, { wagfRegion: 'asia' })
const hindu = gridFilter(recs, { religion: 'H' })
const asiaHindu = gridFilter(recs, { wagfRegion: 'asia', religion: 'H' })
t('Asia+Hinduism is a subset of Asia', asiaHindu.length > 0 && asiaHindu.length <= asia.length, `${asiaHindu.length}<=${asia.length}`)
t('Asia+Hinduism is a subset of Hinduism', asiaHindu.length <= hindu.length, `${asiaHindu.length}<=${hindu.length}`)
// ── 4. search couples the header query into the grid ────────────────────────────────────────
const somali = gridFilter(recs, { query: 'somali' })
t("search 'somali' finds Somali groups", somali.length > 0 && somali.some(u => String(u.name).toLowerCase().includes('somali')), somali.length)
// ── 5. search WITHIN a dropdown selection (query + facet together) ───────────────────────────
const africaA = gridFilter(recs, { wagfRegion: 'africa', query: 'a' })
t("search 'a' within Africa <= Africa", africaA.length > 0 && africaA.length <= africa.length, `${africaA.length}<=${africa.length}`)
// ── 6. THE NEGATIVE: a nonsense query MUST return 0 (proves empty-state fires) ───────────────
const none = gridFilter(recs, { query: 'zzzznotarealpeoplegroupxyz' })
t('nonsense query -> 0 (empty-state path)', none.length === 0, none.length)
// ── 7. a nonsense DROPDOWN value MUST return 0 (proves value-alignment, not silent pass-through) ─
const badFacet = gridFilter(recs, { wagfRegion: '__no_such_region__' })
t('unknown region value -> 0 (value-alignment)', badFacet.length === 0, badFacet.length)
// ── 8. self-check the axes actually exist in live data (guards a facet rename upstream) ──────
t('live data exposes a wagf_region facet', Boolean(anyRegion), anyRegion || '(none!)')
t('live data exposes a religion facet', Boolean(anyReligion), anyReligion || '(none!)')
console.log(`\n Big Picture acceptance: ${pass} passed, ${fail} failed (${N} live records)`)
process.exit(fail?1:0)
process.exit(fail ? 1 : 0)

104
internal/atomic-public-swap.sh Executable file
View file

@ -0,0 +1,104 @@
#!/usr/bin/env bash
# atomic-public-swap.sh — swap a fully-built STAGE tree into the served public/ web-root atomically.
#
# WHY: `public/` is THREE things at once — the committed served web-root (`serve` = http.server on public/),
# vite's outDir, AND the holder of the flock build mutex at `public/.build.lock`. A naive whole-dir swap
# (`mv public public.old && mv staging public`) moves the lock file out from under the flock fd → silently
# voids the concurrent-build mutex, and briefly leaves NO web-root (a reader hits a half-populated or missing
# tree — the class of failure behind the live-down incident: an rsync --delete into a live web-root mid-churn).
#
# THE FIX (from RESULT-source-hygiene.md Card 3b): build fully into a STAGE dir, verify, then swap ONLY the
# swappable parts using same-filesystem rename(2) (POSIX-atomic — a reader sees old-tree or new-tree, never a
# half-tree):
# 1. rename public/doxa-maps -> public/doxa-maps.old (instant; old still fully valid)
# 2. rename STAGE/doxa-maps -> public/doxa-maps (instant; new fully valid, built+verified)
# 3. cp STAGE/index.html -> public/index.html (root gallery page)
# 4. rm -rf public/doxa-maps.old
# public/assets/, public/.gitignore, and public/.build.lock are NEVER touched — the flock handle stays valid.
#
# This is the LOCAL form of the atomic-publish discipline; the catalog MERGE (into platform/mfe-index) has its
# own hard-gate runbook. This script only makes the LOCAL public/ swap safe.
#
# USAGE:
# internal/atomic-public-swap.sh <STAGE_DIR>
# <STAGE_DIR> must contain a fully-built <STAGE_DIR>/doxa-maps/ tree (with manifest.json) and an
# <STAGE_DIR>/index.html. Typically produced by a build with BUILD_STAGE_DIR pointed here.
# Env:
# PUBLIC_DIR (default: public) the served web-root to swap into
# DRY_RUN=1 print the plan, make no changes
set -euo pipefail
STAGE_DIR="${1:-}"
PUBLIC_DIR="${PUBLIC_DIR:-public}"
DRY_RUN="${DRY_RUN:-0}"
die() { echo "❌ atomic-public-swap: $*" >&2; exit 1; }
[ -n "$STAGE_DIR" ] || die "usage: atomic-public-swap.sh <STAGE_DIR> (dir holding a built doxa-maps/ + index.html)"
[ -d "$STAGE_DIR" ] || die "STAGE_DIR '$STAGE_DIR' is not a directory"
[ -d "$PUBLIC_DIR" ] || die "PUBLIC_DIR '$PUBLIC_DIR' is not a directory (run from the studio root)"
STAGE_TREE="$STAGE_DIR/doxa-maps"
STAGE_INDEX="$STAGE_DIR/index.html"
# --- GATE: the stage must be a COMPLETE, verified tree before we touch the live web-root ---------------------
# (This is the same "verify is a GATE that aborts, never chained after the destructive op" lesson.)
[ -d "$STAGE_TREE" ] || die "stage tree missing: $STAGE_TREE"
[ -f "$STAGE_TREE/manifest.json" ] || die "stage tree has no manifest.json — refusing to swap an incomplete build"
[ -f "$STAGE_INDEX" ] || die "stage index.html missing: $STAGE_INDEX"
# a complete tree has >=15 bundle dirs (the known catalog size floor); guard against a mid-churn/empty stage.
tree_dirs=$(find "$STAGE_TREE" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')
[ "$tree_dirs" -ge 15 ] || die "stage tree has only $tree_dirs bundle dirs (<15) — looks incomplete; refusing swap"
# every bundle referenced in the manifest must resolve to a real .js in the stage (no dangling entry).
missing=$(python3 - "$STAGE_TREE" <<'PY'
import json,os,sys
tree=sys.argv[1]
man=json.load(open(os.path.join(tree,'manifest.json')))
profiles=man.get('profiles',man) if isinstance(man,dict) else {}
missing=[]
# manifest shape: {"profiles": {name: {...embed.script...}}} — resolve each bundle's js if present
def bundles(m):
if isinstance(m,dict):
for k,v in m.items():
if isinstance(v,dict):
yield k,v
for name,prof in bundles(profiles):
# each bundle folder should hold <name>/<name>.js (best-effort: check the dir has a .js)
d=os.path.join(tree,name)
if os.path.isdir(d) and not any(f.endswith('.js') for f in os.listdir(d)):
missing.append(name)
print(",".join(missing))
PY
)
[ -z "$missing" ] || die "stage bundles missing a .js: $missing — refusing to swap a broken tree"
echo "✅ stage verified: $tree_dirs bundle dirs + manifest.json + index.html, all bundles resolve."
# --- GUARD: stage and public must be on the SAME filesystem for rename() to be atomic ------------------------
stage_dev=$(stat -c '%d' "$STAGE_DIR")
pub_dev=$(stat -c '%d' "$PUBLIC_DIR")
[ "$stage_dev" = "$pub_dev" ] || die "STAGE_DIR and PUBLIC_DIR are on different filesystems ($stage_dev != $pub_dev) — rename() would not be atomic. Stage under the same volume as public/."
OLD_TREE="$PUBLIC_DIR/doxa-maps.old.$$"
if [ "$DRY_RUN" = "1" ]; then
echo "DRY_RUN — plan (no changes):"
echo " 1. mv $PUBLIC_DIR/doxa-maps -> $OLD_TREE"
echo " 2. mv $STAGE_TREE -> $PUBLIC_DIR/doxa-maps"
echo " 3. cp $STAGE_INDEX -> $PUBLIC_DIR/index.html"
echo " 4. rm -rf $OLD_TREE"
echo " (NEVER touched: $PUBLIC_DIR/assets/ $PUBLIC_DIR/.gitignore $PUBLIC_DIR/.build.lock)"
exit 0
fi
# --- THE SWAP (scoped; rename-based; public/ itself and the lock never move) ---------------------------------
if [ -d "$PUBLIC_DIR/doxa-maps" ]; then
mv "$PUBLIC_DIR/doxa-maps" "$OLD_TREE" # 1. instant; old tree still fully valid under new name
fi
mv "$STAGE_TREE" "$PUBLIC_DIR/doxa-maps" # 2. instant; new tree (built+verified) becomes live
cp "$STAGE_INDEX" "$PUBLIC_DIR/index.html" # 3. fresh root gallery page
rm -rf "$OLD_TREE" # 4. drop the superseded tree
echo "🔀 atomic swap complete — $PUBLIC_DIR/doxa-maps is the freshly-built tree; assets/ + .build.lock untouched."

View file

@ -92,9 +92,13 @@ function readBuildConfig() {
// QA corner widget baked into generated embed pages (the go-base plugin surface). Default true.
// Partner embeds use the copy-embed SNIPPET with their own config, so they never get this.
const qaWidget = cfg.qaWidget !== false
return { name, paths, embedBaseUrl, tokenUrl, token, assetsBaseUrl, apiBaseUrl, gatedProfiles, qaWidget }
// Ship the 42 MB print-resolution posters/ inside the tree? DEFAULT FALSE. The wall renders
// from previews/, so the tree stays visually complete without them; bundling both pushed the
// tree to 74 MB and the catalog server refused the pack. Turn on only for a fully offline drop.
const bundlePosters = cfg.bundlePosters === true
return { name, paths, embedBaseUrl, tokenUrl, token, assetsBaseUrl, apiBaseUrl, gatedProfiles, qaWidget, bundlePosters }
} catch {
return { name: 'doxa-maps', paths: [], embedBaseUrl: '', tokenUrl: '', token: '', assetsBaseUrl: '', apiBaseUrl: '', gatedProfiles: [], qaWidget: true }
return { name: 'doxa-maps', paths: [], embedBaseUrl: '', tokenUrl: '', token: '', assetsBaseUrl: '', apiBaseUrl: '', gatedProfiles: [], qaWidget: true, bundlePosters: false }
}
}
const DEST_CONFIG = readBuildConfig()
@ -103,16 +107,27 @@ const TREE_NAME = DEST_CONFIG.name
/**
* Where a generated page should fetch shared assets from.
*
* The served web-root holds `assets/` NEXT TO the `<TREE_NAME>/` tree, so a page
* nested `depth` levels below that root reaches them by climbing `depth` times.
* An absolute `assetsBaseUrl` always wins — it is the only form that works for a
* bundle embedded on a partner site or loaded inside the mobile app, where there
* is no shared web-root to climb to.
* TREE-RELATIVE, NOT WEB-ROOT-RELATIVE (BUG-DATA-ASSETS-NOT-SHIPPED-TREE-NOT-PORTABLE,
* 2026-09-20). Assets live INSIDE the tree at `<TREE_NAME>/assets/`, so a page nested
* `depth` levels below the web-root is `depth - 1` levels below the TREE ROOT and climbs
* that many times. The tree is then the complete shipping unit: whoever rsyncs ONLY
* `<TREE_NAME>/` still gets the data, and the folder resolves identically under file://,
* a foreign subpath, Cloudflare-Drop or Railway.
*
* The previous form climbed `depth` times to a SIBLING `assets/` at the web-root. That is
* correct only when the publisher also ships that sibling — ours ships the tree alone, so
* every data fetch 404'd (all-people-groups, gospel-resources, cities-internet, upg-100).
* A leaf could never reach a shared folder ABOVE the tree once the tree moved.
*
* An absolute `assetsBaseUrl` still always wins — the only form that works for a bundle
* embedded on a partner site or loaded inside the mobile app, where there is no tree to
* climb within (the page is on a foreign origin).
*
* @param {number} depth levels from the web-root: <tree>/<bundle>/<profile>/ = 3
*/
function assetsBaseFor(depth) {
return DEST_CONFIG.assetsBaseUrl || `${'../'.repeat(depth)}assets`
// depth is measured from the WEB-ROOT; the tree root is one level in, hence depth - 1.
return DEST_CONFIG.assetsBaseUrl || `${'../'.repeat(Math.max(0, depth - 1))}assets`
}
// design note 2026-07-11: the staging TOP must not mention deployment orgs (doxa/GO/...). The visible
// title is the PRODUCT title (config displayTitle, default org-free), never the deployment TREE_NAME
@ -163,6 +178,57 @@ function isMapLeaf(filePath) {
catch { return false }
}
/**
* Does a cap-4 composite (`nested-elements-mfe`) compose a child that paints a map?
*
* A cap-4 composite mounts its children as custom elements in ITS OWN document, so it
* needs the map engine + token that the child would otherwise have got from its own
* iframe leaf page. Map-ness is therefore INHERITED from the composed children.
*
* Resolution order, most-declared to most-forgiving:
* 1. meta.json `composes: ["<bundle>/<profile>", …]` — the author's explicit statement of
* what this composite nests. Each entry is resolved to a sibling .vue and scanned.
* 2. the composite's OWN source — a composite may still touch a map engine directly.
* 3. ANY OTHER profile in the same bundle. A cap-4 composite composes siblings from its
* own bundle by construction (one download serves every pane), so if the bundle ships
* a map at all, a composite in it can mount one. This is the SAFE default: a spurious
* peer on a genuinely map-free composite costs one cached script, whereas a MISSING
* peer costs the whole view (no canvas is ever created) — so the failure modes are
* deliberately asymmetric.
*
* @param {string} dir the bundle's plugin-designs directory
* @param {string} profileName the composite profile (e.g. 'upg-100-mfe')
* @param {string} file the composite's own .vue filename
*/
function composesAMapLeaf(dir, profileName, file) {
// 1. explicit `composes` from <profile>.meta.json
try {
const metaPath = join(dir, `${profileName}.meta.json`)
if (existsSync(metaPath)) {
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
const composes = Array.isArray(meta?.composes) ? meta.composes : []
// Entries are "<bundle>/<profile>" or a bare "<profile>"; only the last segment
// names the .vue, and a composite composes within its own bundle.
const named = composes
.map((c) => String(c).split('/').pop())
.filter(Boolean)
if (named.length) {
return named.some((p) => isMapLeaf(join(dir, `${p}.vue`)))
}
}
} catch (_) { /* malformed meta → fall through to the source scans */ }
// 2. the composite's own source
if (isMapLeaf(join(dir, file))) return true
// 3. any sibling profile in the same bundle
try {
return readdirSync(dir)
.filter((f) => f.endsWith('.vue') && f !== file)
.some((f) => isMapLeaf(join(dir, f)))
} catch { return false }
}
/** Element tag names a bundle registers, read from its index.js (order preserved). */
function extractElements(indexJsPath) {
if (!existsSync(indexJsPath)) return []
@ -394,7 +460,7 @@ function bundleHtml(b, index, manifest) {
* opts.bakedToken opt-in literal token baked from build-config (ladder rung 3)
*/
function mapPageHtml(opts) {
const { title, tag, config, jsSrc, apiBaseUrl, tokenUrlExpr, bakedToken, integrity, shellTokenFree, isMap } = opts
const { title, tag, config, jsSrc, apiBaseUrl, tokenUrlExpr, bakedToken, integrity, shellTokenFree, isMap, scrollPage } = opts
const apiScript = apiBaseUrl
? `\n <script>window.MAP_APP_API_URL = ${JSON.stringify(apiBaseUrl)};</script>`
: ''
@ -458,14 +524,34 @@ function mapPageHtml(opts) {
<link rel="icon" href="data:,">
${peerBlock}
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
${scrollPage
? `/* SCROLL-PAGE leaf (cap-4 nested-elements-mfe composite: a StackLayout scroll-stack). The DOCUMENT
owns the scroll — the element takes its content's NATURAL height and grows the page, so a reader
scrolls PAST one stacked section down to the next (BUG-DASHBOARD-CANT-SCROLL-DOWN). This is the
OPPOSITE of the fixed-viewport map/single-element leaf below: NO position:fixed, NO inset:0,
height:auto so the page can grow. See StackLayout.vue's "THE LAYOUT OWNS THE SCROLL" contract. */
html { height: 100%; }
html, body { margin: 0; background: #0a0e15; }
body { min-height: 100%; overflow-y: auto; }
/* MIN-HEIGHT IN VIEWPORT UNITS, NOT PERCENT (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT).
min-height:100% here resolved against BODY's computed height, which is auto (body only has
min-height:100%, never a definite height) — and a percentage of auto collapses to 0. Any
element whose own chrome is position:absolute / height:100% (upg-100-mfe -> .pane-col -> .pane)
then measured 0 and the leaf looked empty. 100dvh depends on NO ancestor, keeps the page
growable (it is a MIN), and is correct on mobile with dynamic toolbars. */
#slot, ${esc(tag)} { position: static; inset: auto; width: 100%; min-height: 100dvh; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }`
: `/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, ${esc(tag)} { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }
#status.err { color: #f87171; }`}
</style>
</head>
<body>
@ -495,6 +581,10 @@ ${apiScript}
var TOKEN_FREE = ${JSON.stringify(tokenFree)};
var TOKEN_SRC = ${JSON.stringify(tokenUrlExpr)};
var BAKED_TOKEN = ${JSON.stringify(bakedToken || '')};
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
@ -618,6 +708,12 @@ function profileHtml(b, profile, tokenUrlExpr, bakedToken) {
// Whether this leaf actually draws map tiles (computed in scanBundles → isMapLeaf). A
// non-map leaf renders token-free with no map peer; only a real map runs the token ladder.
isMap: profile.isMap,
// A cap-4 composite (nested-elements-mfe) is a StackLayout SCROLL-STACK: its leaf must let the
// DOCUMENT own the scroll (height:auto, no position:fixed) so the page grows and a reader scrolls
// PAST one section to the next. A fixed-viewport leaf traps a 100vh dashboard and nothing scrolls
// (BUG-DASHBOARD-CANT-SCROLL-DOWN). Cap-5 (nested-mfe-spa) stays fixed — it is a tabs shell, not a
// scroll-stack; any scroll-stack it hosts is its OWN iframe leaf, which gets this flag itself.
scrollPage: profile.type === 'nested-elements-mfe',
})
}
@ -642,6 +738,8 @@ function instanceHtml(b, profile, inst, tokenUrlExpr, bakedToken) {
// Instance pages inherit the profile's map-ness: a preset of a non-map leaf is still a
// non-map (token-free, no peer); a preset of a real map still runs the token ladder.
isMap: profile.isMap,
// A preset of a cap-4 composite scroll-stack is still a scroll page (see profileHtml).
scrollPage: profile.type === 'nested-elements-mfe',
})
}
@ -759,13 +857,37 @@ function scanBundles() {
// an *.instances.json → parameterized; else single. (detectProfileType, same as the gallery.)
const { type: profileType } = detectProfileType(dir, profileName, tag, [], instances.length > 0)
// Does this leaf actually draw map tiles? Composite tiers (cap-4 nested-elements-mfe,
// cap-5 nested-mfe-spa) are chrome that nests OTHER elements — they never paint a map
// themselves (any real map is its own iframe leaf), so they are non-maps regardless of
// source. Leaf tiers (single / parameterized / monolithic) are a map only if their .vue
// references a map engine. This drives the token-gate/peer decision in mapPageHtml.
const composite = profileType === 'nested-elements-mfe' || profileType === 'nested-mfe-spa'
const isMap = composite ? false : isMapLeaf(join(dir, file))
// Does this page need a map ENGINE? Not "does this .vue call mapbox itself" — the
// question the peer/token gate actually asks is "will a map be drawn IN THIS DOCUMENT".
// The two composite tiers answer it DIFFERENTLY, and conflating them was
// BUG-UPG100MFE-MAP-TAB-MOUNTS-NOTHING (2026-09-20):
//
// cap-5 nested-mfe-spa nests children as IFRAMES. Each child is its own leaf
// PAGE with its own peer + its own token ladder, so the
// shell document itself draws nothing and needs nothing.
// → non-map, always.
//
// cap-4 nested-elements-mfe nests children as CUSTOM ELEMENTS IN THIS SAME DOCUMENT
// AND REALM (deliberately — one bundle download serves
// every pane; see upg-100-mfe.vue "Why nested ELEMENTS").
// There is no inner page to run a token ladder or load a
// peer, so if ANY composed child paints a map, THIS page
// must ship the engine and resolve a token. Marking it a
// non-map emitted "NO map peer" → window.mapboxgl was
// undefined → useMapInstance bailed at its
// "mapboxgl is not available" guard and the map pane's
// container was never created. The canvas was not hidden
// or unsized; it never existed.
//
// So a cap-4 composite INHERITS map-ness from the siblings it composes. That is
// derived from meta.json `composes` (and falls back to scanning the bundle's own
// profiles), never from a per-leaf special case — the next author who composes a map
// gets a working peer without knowing this rule exists.
const isMap = profileType === 'nested-mfe-spa'
? false
: profileType === 'nested-elements-mfe'
? composesAMapLeaf(dir, profileName, file)
: isMapLeaf(join(dir, file))
return {
profile: profileName,
@ -922,6 +1044,95 @@ function main() {
cpSync(src, destRel, { recursive: true })
}
console.log('[generate-tree] copied data/ sources → web-root/assets (people-groups, vendor, posters, tables)')
// ── THE TREE IS THE SHIPPING UNIT: stage the fetched assets INSIDE it ──────
// BUG-DATA-ASSETS-NOT-SHIPPED-TREE-NOT-PORTABLE (2026-09-20). The web-root copy
// above is a SIBLING of the tree; a publisher that rsyncs only `<TREE_NAME>/`
// (ours does) never carries it, so every data fetch 404'd live and four elements
// rendered a black page. Pages now resolve assets TREE-RELATIVE (assetsBaseFor),
// so the data must live at `<TREE_NAME>/assets/` for the tree to be genuinely
// self-contained and position-independent.
//
// ONLY WHAT IS FETCHED AT RUNTIME. This is an allowlist, not a mirror: the
// web-root assets/ is 94 MB, dominated by posters (42 MB) and previews (10 MB)
// that no leaf fetches over HTTP, plus the 16 MB people_groups.csv that nothing
// reads (already in dropin.exclude). Copying all of it would bloat every deploy.
// Each entry below is traceable to a literal fetch() in a .vue — add a row here
// when an element starts fetching something new, or it will 404 in the tree.
const treeAssets = join(DEFAULT_ROOT, 'assets')
rmSync(treeAssets, { recursive: true, force: true })
let staged = 0
for (const rel of [
// boundaries — choropleth geometry
// gospel-resources/resources-map.vue, cities-internet/connectivity-map.vue → world-adm0
// my-upg-100-list/upg-100-list.vue → all three (adm0 + IND/CHN adm1)
'boundaries/world-adm0.geojson',
'boundaries/gb-IND-ADM1.geojson',
'boundaries/gb-CHN-ADM1.geojson',
// people-groups — all-people-groups.vue (the slim JSON only; NOT the 16 MB CSV,
// which no element fetches and which dropin.exclude already withholds).
'people-groups/people-groups-slim.json',
// vendor — DataTable/TabulatorTable inject Tabulator from assetsBase at runtime.
'vendor/tabulator/tabulator.min.js',
'vendor/tabulator/tabulator.min.css',
]) {
const from = join(pubAssets, rel)
if (!existsSync(from)) { console.warn(`[generate-tree] WARN missing tree asset: assets/${rel}`); continue }
const to = join(treeAssets, rel)
mkdirSync(dirname(to), { recursive: true })
cpSync(from, to)
staged++
}
// tables/*.json — the data-tables + map layers fetch these by name; ship the whole
// (small, ~14 MB total on disk but only the fetched ones matter) folder MINUS the
// internal-only files dropin.exclude already names, so a new table works with no
// build change. Kept as a directory copy because the fetch name is dynamic
// (`${assetsBase}/tables/${file}.json`).
const TABLES_SKIP = new Set(['open-doors.json', 'platform-reachability.json', 'index.json'])
const tablesSrc = join(pubAssets, 'tables')
if (existsSync(tablesSrc)) {
const tablesDest = join(treeAssets, 'tables')
mkdirSync(tablesDest, { recursive: true })
for (const f of readdirSync(tablesSrc)) {
if (TABLES_SKIP.has(f)) continue
cpSync(join(tablesSrc, f), join(tablesDest, f))
staged++
}
}
// previews/ + posters/ — the poster walls fetch these PER CARD, lazily, by id
// (`${assetsBase}/previews/<id>.webp`, `${assetsBase}/posters/<id>.webp`), so the
// name is dynamic and the whole folder ships. They are the bulk of the tree
// (previews ~10 MB, posters ~42 MB) but both are really fetched and dropping
// either is a VISIBLE regression, not a saving:
// previews → the my-upg-100-list / country-posters wall thumbnails. Without
// them every card falls back to a text placeholder (the reported
// "UPG-100 poster preview unavailable").
// posters → the full-size download behind the wall's export button, which
// fails closed with "poster not found (404)" if absent.
// Excluded on purpose instead: assets/people_groups.csv (16 MB, fetched by
// nothing) and the internal-only tables above.
// POSTERS ARE OPT-IN (bundlePosters, default false). MEASURED 2026-09-20: staging both dirs
// made the tree 74 MB, which git-packed to 57 MiB and the catalog server REJECTED outright
// ("remote end hung up"), so nothing could publish at all. The split that resolves it comes
// from the components themselves: PosterWall renders the wall from previews/<id>.webp
// (line 80) and only fetches posters/<id>.webp ON DEMAND behind the export button (line 159).
// So previews (10 MB, 378 files) are what RENDER and must ship; full posters (42 MB) are
// print-resolution originals needed only when a user actually exports one. Shipping previews
// keeps every element visually complete in a dropped folder — the self-contained promise —
// while an absolute assetsBaseUrl serves the originals on demand (the same escape hatch used
// for partner-site and mobile embeds). Set bundlePosters:true to ship them anyway for a
// fully offline drop, accepting the size.
const assetDirs = DEST_CONFIG.bundlePosters ? ['previews', 'posters'] : ['previews']
for (const dirName of assetDirs) {
const src = join(pubAssets, dirName)
if (!existsSync(src)) { console.warn(`[generate-tree] WARN missing tree asset dir: assets/${dirName}`); continue }
cpSync(src, join(treeAssets, dirName), { recursive: true })
staged += readdirSync(src).length
}
if (!DEST_CONFIG.bundlePosters) {
console.log('[generate-tree] posters/ NOT bundled (bundlePosters:false) — the wall renders from previews; set an absolute assetsBaseUrl to serve full posters on demand')
}
console.log(`[generate-tree] staged ${staged} runtime asset(s) → ${TREE_NAME}/assets (tree is self-contained)`)
}
for (const dest of dests) {

View file

@ -44,14 +44,41 @@ const props = defineProps({
// ─── Parse profile-config ─────────────────────────────────────────────────────
// A HOST page's assetsBase, if this loader is running INSIDE another element (a cap-4
// composite pane). Undefined at the top level. See the inheritance note below.
const hostAssetsBase = inject('profileConfig', null)
const config = computed(() => {
if (!props.profileConfig) return null
let parsed
try {
return JSON.parse(props.profileConfig)
parsed = JSON.parse(props.profileConfig)
} catch (e) {
console.error('[ProfileLoader] Invalid profile-config JSON:', props.profileConfig)
return null
}
// ── assetsBase INHERITANCE (BUG-UPG100MFE-ASSET-PATH-DEPTH-404S, 2026-09-20) ──
// `assetsBase` is a PAGE-scoped value: the generator computes it once per generated
// index.html from that page's depth below the tree root, and every consumer
// (PosterWall, DataTable, boundary fetches) reads it off profileConfig.
//
// A cap-4 nested-elements composite mounts its children as custom elements in the SAME
// document, so a child has no leaf page of its own to be told the value by. If the host
// forgets to forward it, the child falls back to its own './assets' default — which
// resolves against the LEAF directory, one level too deep, and every asset 404s. That is
// exactly how 27 preview images broke on upg-100-mfe while the same images loaded fine
// from the sibling poster-wall leaf.
//
// Fixing only the one host would leave the trap armed for the next author, so the
// inheritance is enforced HERE — the single choke point every element passes through.
// An explicit assetsBase on the child still wins; this only fills an ABSENT one, and only
// when there is a host to inherit from (at the top level there is nothing to inherit, so
// the consumer's own './assets' default stands unchanged).
if (parsed && parsed.assetsBase === undefined) {
const inherited = hostAssetsBase?.value?.assetsBase
if (typeof inherited === 'string' && inherited) parsed.assetsBase = inherited
}
return parsed
})
const profileName = computed(() => config.value?.profile || null)
@ -204,6 +231,13 @@ provide('dataStore', dataStore)
border-radius: var(--map-radius-corners, var(--map-radius, 0px));
width: 100%;
height: 100%;
/* VIEWPORT-UNIT FLOOR (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT). height:100% above resolves against
the HOST — but a host sized only by min-height (the scroll-page leaf) computes height:auto, and a
percentage of auto collapses to 0. This wrapper sits between the host and EVERY profile, so that
one collapse zeroed every profile whose chrome is position:absolute/inset:0 (upg-100-mfe ->
.pane-col -> .pane all measured 0 while the header still painted). A dvh floor depends on no
ancestor, so the wrapper always has height; height:100% still wins whenever the host IS sized. */
min-height: 100dvh;
position: relative;
}

View file

@ -8,6 +8,21 @@
* detail card opens → "Pray for them" (which carries utm_source attribution,
* see PeopleGroupDetail.vue).
*
* ANCHORED TO THE MAP, NOT THE PAGE (BUG-PRAYER-TOAST-COVERS-TABLE-ROWS):
* ProfileLoader mounts this as a SIBLING of the profile, inside .profile-loader
* — which is position:relative with a 100dvh floor. So `bottom:26px` used to
* resolve against the WHOLE PAGE. On a single-map profile that is the same
* rectangle as the map, which is why it looked right for so long. On a
* DASHBOARD (map hero on top, data table below) the two diverge badly: the
* pill landed on the people-groups table and covered ~2 data rows at 1440x900.
* A hint about the map must live over the map, so on mount we RE-PARENT the
* pill into the map's own positioned container (found at runtime, the same way
* the canvas is found — no per-profile wiring, no props, works for every
* bundle). If no positioned container turns up we stay put, which is exactly
* the old behaviour — a degraded anchor, never a crash.
* It also clears the bottom-right attribution strip, which is a LICENCE
* obligation (Mapbox ToS / OSM ODbL) and must never be covered.
*
* TASTEFUL BY DESIGN (no dark patterns):
* · SELF-GATING — shows only after a map <canvas> actually exists in this
* custom element's shadow root, so list/table profiles never see it.
@ -22,7 +37,7 @@
* `pinHintLabel`. Styles via useShadowStyles — a child component's <style> is
* dropped from the custom element's shadow DOM (see WARNING.md).
*/
import { ref, inject, computed, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
import { ref, inject, computed, onMounted, onBeforeUnmount, getCurrentInstance, nextTick } from 'vue'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
const uiStore = inject('uiStore', null)
@ -32,12 +47,87 @@ const label = computed(() =>
profileConfig?.value?.pinHintLabel || 'Tap a red dot — someone still needs prayer')
const visible = ref(false)
const hintEl = ref(null)
let watchTimer = null
let movedTo = null // the element we re-parented into, so we can put ourselves back
let attribRO = null // observes the attribution control (it resizes on its own, see below)
let attribEl = null
// The reader closes it; nothing else does — no timer, and no dismissal on the first pan,
// so the invitation is still there when they come back to the map.
function dismiss() { visible.value = false }
/**
* From the map <canvas>, walk up to the element the pill should live inside: the
* nearest ancestor that ESTABLISHES A CONTAINING BLOCK for absolute positioning
* (position other than static). That is the map's own box — .rd-mapwrap on the
* dashboard, the map shell on a single-map profile.
*
* Why walk up from the canvas instead of matching a class: a class list would be a
* hardcoded registry of every profile's private markup, and this component lives in
* library/ where it must stay parameterized. The containing block is a STRUCTURAL
* fact that is true in every bundle, so it needs no per-profile knowledge.
*
* Stops at the shadow root / the loader wrapper — re-parenting into .profile-loader
* would just reproduce the bug we are fixing.
*/
function findMapBox(canvas, root) {
let el = canvas?.parentElement
for (let hops = 0; el && hops < 12; hops++, el = el.parentElement) {
if (el === root || el.classList?.contains('profile-loader')) return null
let pos = ''
try { pos = getComputedStyle(el).position } catch (_) { /* detached */ }
if (pos && pos !== 'static') {
// Guard against a container so short the pill would overflow it.
const r = el.getBoundingClientRect()
if (r.height >= 120 && r.width >= 160) return el
}
}
return null
}
/**
* Raise the pill clear of the map's attribution strip. Mapbox swaps between an expanded
* credit line and a compact round "i" button depending on map WIDTH, and the compact
* button is TALLER (24px vs 20px) — so a fixed offset that clears the strip at 1440px
* can still clip the button at 1024px. Measure the real control instead of assuming.
* Attribution is a LICENCE obligation; this only ever READS it.
*/
function syncAttribClearance(root) {
const el = hintEl.value
if (!el || !root?.querySelector) return
let h = 0
try {
// TWO attribution flavours ship in this repo and the pill must clear EITHER:
// .mapboxgl-ctrl-attrib — Mapbox's own control (useMapInstance, attributionControl:true)
// .doxa-attribution — our own credit line (useMapAttribution.js) on the profiles
// that construct with attributionControl:false
// Both are licence-required credit, so both are obstacles, never things to cover.
const a = root.querySelector(
'.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-attrib, .mapboxgl-ctrl-bottom-right .doxa-attribution, .doxa-attribution')
if (a) {
// Measure the strip the control OCCUPIES from the bottom of the box we are anchored
// in, not the control's own height: Mapbox adds a margin inside
// .mapboxgl-ctrl-bottom-right, so height alone undercounts the reserved space.
const cr = a.getBoundingClientRect()
const host = el.parentElement
const hr = host ? host.getBoundingClientRect() : null
h = hr ? Math.max(cr.height || 0, hr.bottom - cr.top) : (cr.height || 0)
// Mapbox collapses the credit to the compact "i" button AFTER first paint, and that
// swap changes the CONTROL's height without changing the map's — so observe the
// control itself or we stay pinned to the stale expanded measurement.
if (attribEl !== a && typeof ResizeObserver !== 'undefined') {
if (attribRO) { try { attribRO.disconnect() } catch (_) {} }
attribEl = a
attribRO = new ResizeObserver(() => syncAttribClearance(root))
attribRO.observe(a)
}
}
} catch (_) { /* not painted yet */ }
const clear = Math.min(Math.max(Math.ceil(h) || 20, 20), 72)
el.style.setProperty('--pin-hint-attrib-clear', clear + 'px')
}
onMounted(() => {
// Find this custom element's shadow root and wait (max ~12s) for a map canvas.
const inst = getCurrentInstance()
@ -50,6 +140,15 @@ onMounted(() => {
if (canvas) {
clearInterval(watchTimer); watchTimer = null
visible.value = true
// Re-parent AFTER the v-if has rendered the pill, so hintEl exists.
nextTick(() => {
const box = findMapBox(canvas, root)
const el = hintEl.value
if (box && el && el.parentElement !== box) {
try { box.appendChild(el); movedTo = box } catch (_) { /* keep the page anchor */ }
}
syncAttribClearance(root)
})
} else if (tries > 40) { // ~12s: no canvas → not a map profile → never show
clearInterval(watchTimer); watchTimer = null
}
@ -57,11 +156,25 @@ onMounted(() => {
})
onBeforeUnmount(() => {
if (watchTimer) clearInterval(watchTimer)
if (attribRO) { try { attribRO.disconnect() } catch (_) {} attribRO = null } attribEl = null
// We appended our own element into a node Vue does not own. Vue removes the element
// it created, but only from where IT thinks it is — so detach explicitly to be certain
// no orphan pill is left behind in the map container on profile teardown.
if (movedTo && hintEl.value?.parentElement === movedTo) {
try { movedTo.removeChild(hintEl.value) } catch (_) {}
}
movedTo = null
})
useShadowStyles(`
.pin-hint { position: absolute; left: 50%; bottom: 26px; transform: translateX(-50%);
/* bottom: clears the attribution strip along the map's bottom edge. That credit line is a
LICENCE obligation (Mapbox ToS / OSM ODbL) and must never be covered — so the pill sits
ABOVE it rather than on it. max-width keeps the pill inside narrow map panels instead of
bleeding past their edges at 1024px and below. */
.pin-hint { position: absolute; left: 50%;
bottom: calc(var(--pin-hint-attrib-clear, 20px) + 12px); transform: translateX(-50%);
z-index: 2147482000; pointer-events: none;
max-width: calc(100% - 24px); box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
animation: pinhint-in .5s ease both; }
.pin-hint-pill { pointer-events: auto; display: inline-flex; align-items: center; gap: 9px;
@ -90,7 +203,7 @@ useShadowStyles(`
</script>
<template>
<div v-if="visible" class="pin-hint" :class="{ dark: isDark }" role="status">
<div v-if="visible" ref="hintEl" class="pin-hint" :class="{ dark: isDark }" role="status">
<span class="pin-hint-pill">
<span class="pin-hint-dot" aria-hidden="true"></span>
{{ label }}

View file

@ -2,8 +2,12 @@
ZoomInButton.vue — Increments the map zoom level by one step.
Props:
map — the live mapboxgl.Map instance (Object, required)
isDark — theme flag forwarded from parent
map — the live mapboxgl.Map instance (Object, required)
isDark — theme flag forwarded from parent
disabled — grey out + block the click when the camera is already at maxZoom.
Declared EXPLICITLY (rather than relying on attribute fallthrough
reaching MapControlButton's own prop) so the contract is visible to
callers and survives any future inheritAttrs change.
-->
<script setup>
import { useI18n } from 'vue-i18n'
@ -12,17 +16,21 @@ import MapControlButton from './MapControlButton.vue'
const { t } = useI18n()
const props = defineProps({
map: { type: Object, default: null },
isDark: { type: Boolean, default: false }
map: { type: Object, default: null },
isDark: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
})
function zoomIn() {
if (props.disabled) return
// Mapbox's discrete one-step zoom (a single bounded easing), NOT a continuous
// per-frame camera loop — keep it that way (see BUG-RESIZE-STROBE).
props.map?.zoomIn()
}
</script>
<template>
<MapControlButton :is-dark="isDark" :title="t('buttons.zoomIn')" @click="zoomIn">
<MapControlButton :is-dark="isDark" :disabled="disabled" :title="t('buttons.zoomIn')" @click="zoomIn">
<span style="font-size:20px;line-height:1;font-weight:600;">+</span>
</MapControlButton>
</template>

View file

@ -2,8 +2,12 @@
ZoomOutButton.vue — Decrements the map zoom level by one step.
Props:
map — the live mapboxgl.Map instance (Object, required)
isDark — theme flag forwarded from parent
map — the live mapboxgl.Map instance (Object, required)
isDark — theme flag forwarded from parent
disabled — grey out + block the click when the camera is already at the minZoom
floor. useMapInstance computes that floor from the container aspect, so
on a short panel zoomOut() would otherwise be a silent no-op; disabling
makes "at the limit" legible instead of looking like a dead button.
-->
<script setup>
import { useI18n } from 'vue-i18n'
@ -12,17 +16,20 @@ import MapControlButton from './MapControlButton.vue'
const { t } = useI18n()
const props = defineProps({
map: { type: Object, default: null },
isDark: { type: Boolean, default: false }
map: { type: Object, default: null },
isDark: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
})
function zoomOut() {
if (props.disabled) return
// Discrete one-step zoom — see the note in ZoomInButton.vue.
props.map?.zoomOut()
}
</script>
<template>
<MapControlButton :is-dark="isDark" :title="t('buttons.zoomOut')" @click="zoomOut">
<MapControlButton :is-dark="isDark" :disabled="disabled" :title="t('buttons.zoomOut')" @click="zoomOut">
<span style="font-size:22px;line-height:1;font-weight:600;">−</span>
</MapControlButton>
</template>

View file

@ -15,11 +15,16 @@ each self-styles inside a standalone custom-element shadow root.
Import via the `@map` alias (resolves to `library/`):
```js
import SearchBar from '@map/components/search/SearchBar.vue'
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
import Fuse from '@map/components/search/fuzzy.js'
import SearchBar from '@map/components/search/SearchBar.vue'
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
import SearchFilterHeader from '@map/components/search/SearchFilterHeader.vue'
import Fuse from '@map/components/search/fuzzy.js'
```
- **research-dashboard** `plugin-designs/research-dashboard/` — bakes `SearchFilterHeader` in as
header-1 (search) + header-2 (dropdowns) via the thin adapter
`src/components/dashboards/DashboardResearchFilters.vue` (couples it to the selection bus).
---
## `SearchBar.vue`
@ -70,6 +75,47 @@ Region / Block / People-Group / Religion in the grid; Region / Status in the das
---
## `SearchFilterHeader.vue`
The DX-life research-page filter header, cloned as ONE parameterized element: header-1 (a
`SearchBar`) + header-2 (an "expand filters" row of `FilterDropdown`s + optional boolean
toggle-pills + a removable active-chip strip). Pure UI/interaction — no bus, no fetch — so the
consumer supplies option lists + selected values and reacts to the emits. The dropdown row is a
SINGLE row that **never wraps** — it horizontal-scrolls on overflow (card
`BUILD-DROPDOWNS-HORIZONTAL-SCROLL-ONE-ROW`).
| Prop | Type | Default | Purpose |
|-----------------|---------------|---------|----------------------------------------------------------------|
| `search` | String | `''` | Search-bar value (supports `v-model:search`). |
| `filters` | Array | `[]` | Dropdown axes: `[{ name, label, options, value?, placeholder? }]`. |
| `toggles` | Array | `[]` | Boolean pills: `[{ name, label, active? }]`. |
| `activeChips` | Array \| null | `null` | Removable chips `[{ name, label }]`; omit to auto-derive. |
| `searchPlaceholder` | String | `'Search…'` | header-1 placeholder. |
| `searchMaxWidth`| String | `'520px'` | header-1 search box max-width. |
| `showFilters` | Bool \| String| `true` | Render the header-2 filter row at all. |
| `startExpanded` | Boolean | `false` | Show the dropdown row without a first click. |
| `collapsible` | Boolean | `true` | Show the Show/Hide-Filters toggle; `false` = always open. |
| `labels` | Object | `{}` | i18n overrides (show/hide_filters, clear_all, type_to_search…). |
| Event | Payload | When |
|-----------------|------------------------------------|-------------------------------|
| `update:search` | `String` | Search changed (`v-model`). |
| `search` | `{ value }` | Every keystroke. |
| `filter-change` | `{ name, value, label, type }` | A dropdown option was picked. |
| `filter-clear` | `{ name }` | A dropdown selection cleared. |
| `toggle` | `{ name, active }` | A toggle-pill flipped. |
| `remove-filter` | `{ name }` | An active chip's ✕ clicked. |
| `clear-all` | — | "Clear all" clicked. |
```vue
<SearchFilterHeader
v-model:search="term" :filters="filters" :active-chips="chips" start-expanded
@search="onSearch" @filter-change="onPick" @filter-clear="onClear"
@remove-filter="onRemove" @clear-all="onClearAll" />
```
---
## `fuzzy.js`
A zero-dependency Fuse.js-shaped matcher (so no npm dep is added to the shared

View file

@ -0,0 +1,235 @@
<script setup>
/**
* SearchFilterHeader.vue — SHARED (@map/components/search/SearchFilterHeader.vue).
*
* The DX-life research-page filter header, cloned as ONE parameterized, reusable element:
* header-1 = a search bar (the SearchBar primitive)
* header-2 = an "expand filters" dropdown SET (a row of FilterDropdown primitives) plus
* optional boolean toggle-buttons (Adopted / Engaged / Exact …) and a live
* removable active-filter chip strip.
*
* Cloned (COPY-ONLY, never imported) from the DX-life research page's UupgsList.vue filter
* block (root-dx-life/.../components/public/UupgsList.vue) — the search box + "Show/Hide
* Filters" toggle + the FilterDropdown panel + the toggle-button panel + the active-chips
* strip. It reuses the already-shared SearchBar + FilterDropdown primitives so there is ONE
* source of truth for the controls; this element only ADDS the header composition + the
* expand/collapse + the one-row horizontal-scroll layout.
*
* FULLY props-driven / emit-based / framework-agnostic (no Nuxt, no i18n composable, no
* fetch, no bus, no plugin hardcoding). The consumer supplies the option lists + selected
* values and reacts to the emits — so the SAME header drives the people-groups-grid, the
* big-picture, AND the research-dashboard. The dashboard couples it to its selection bus via
* a thin adapter (DashboardResearchFilters.vue); the grid drives its in-memory Fuse filter;
* big-picture forwards to its cap-2 grid. None of that lives here.
*
* ── ★ ONE-ROW HORIZONTAL SCROLL (card BUILD-DROPDOWNS-HORIZONTAL-SCROLL-ONE-ROW) ──
* The dropdown set is a SINGLE flex row that NEVER wraps — on overflow it scrolls
* horizontally (overflow-x:auto; flex-wrap:nowrap). Each FilterDropdown is flex:0 0 auto so
* it keeps its width instead of shrinking into a second line.
*
* ── API ────────────────────────────────────────────────────────────────────────
* Props:
* search String '' — the search-bar value (supports `v-model:search`).
* filters Array [] — the dropdown axes, ONE object per dropdown:
* { name, label, options:[{value,label,count?}], value?, placeholder? }
* toggles Array [] — boolean toggle-buttons: { name, label, active? }
* activeChips Array [] — removable active-filter chips: { name, label }
* (when omitted the header derives chips from filters+toggles).
* searchPlaceholder String 'Search…'
* searchMaxWidth String '520px' — max-width of the header-1 search box.
* showSearch Boolean|String true — render the header-1 search row at all. Set false when
* the consumer hosts the search bar elsewhere (e.g. the research-
* dashboard lifts it into the topbar) so this header is FILTERS-ONLY.
* showFilters Boolean|String true — render the header-2 filter row at all.
* startExpanded Boolean false — show the dropdown row without a first click.
* collapsible Boolean true — show the Show/Hide-Filters toggle; false = always open.
* labels Object {} — i18n overrides: { show_filters, hide_filters, clear_all,
* type_to_search, filters }
* Emits:
* update:search String — new search value (enables v-model:search).
* search { value } — on every keystroke (raw string).
* filter-change { name, value, label, type } — a dropdown option was picked.
* filter-clear { name } — a dropdown selection was cleared.
* toggle { name, active } — a boolean toggle flipped.
* remove-filter { name } — an active chip's ✕ was clicked.
* clear-all — the "Clear all" chip was clicked.
*/
import { ref, computed } from 'vue'
import SearchBar from './SearchBar.vue'
import FilterDropdown from './FilterDropdown.vue'
const props = defineProps({
search: { type: String, default: '' },
filters: { type: Array, default: () => [] },
toggles: { type: Array, default: () => [] },
activeChips: { type: Array, default: null },
searchPlaceholder: { type: String, default: 'Search…' },
searchMaxWidth: { type: String, default: '520px' },
showSearch: { type: [Boolean, String], default: true },
showFilters: { type: [Boolean, String], default: true },
startExpanded: { type: Boolean, default: false },
collapsible: { type: Boolean, default: true },
labels: { type: Object, default: () => ({}) },
})
const emit = defineEmits([
'update:search', 'search', 'filter-change', 'filter-clear', 'toggle', 'remove-filter', 'clear-all',
])
const truthy = (v) => !(v === false || v === 'false' || v === '0' || v === 0)
const useSearch = computed(() => truthy(props.showSearch))
const useFilters = computed(() => truthy(props.showFilters))
const L = computed(() => ({
show_filters: 'Show Filters',
hide_filters: 'Hide Filters',
clear_all: 'Clear All',
type_to_search: 'Type to search...',
filters: 'Filters',
...props.labels,
}))
// The dropdown row is collapsed by default (DX-life behaviour) unless startExpanded /
// non-collapsible — then it's always open.
const expanded = ref(props.startExpanded || !props.collapsible)
function toggleExpanded() { expanded.value = !expanded.value }
// When the search row is hidden the Show/Hide-Filters toggle is gone too, so the dropdown row
// can only be reached if it stays open — force it open in that case.
const isOpen = computed(() => !props.collapsible || !useSearch.value || expanded.value)
// Chips: consumer-supplied, else derived from the selected dropdowns + active toggles so a
// standalone consumer gets a working removable chip strip for free.
const chips = computed(() => {
if (Array.isArray(props.activeChips)) return props.activeChips
const out = []
for (const f of props.filters) {
if (!f.value) continue
const opt = (f.options || []).find(o => o.value === f.value)
out.push({ name: f.name, label: opt ? opt.label : f.value })
}
for (const t of props.toggles) {
if (t.active) out.push({ name: t.name, label: t.label })
}
return out
})
function onSearch(payload) {
const value = payload?.value ?? ''
emit('update:search', value)
emit('search', { value })
}
function onFilterChange(detail) { emit('filter-change', detail) }
function onFilterClear(detail) { emit('filter-clear', detail) }
function onToggle(t) { emit('toggle', { name: t.name, active: !t.active }) }
function onRemoveChip(name) { emit('remove-filter', { name }) }
function onClearAll() { emit('clear-all') }
</script>
<template>
<div class="sfh">
<!-- header-1: the search bar (omitted when the consumer hosts search elsewhere) -->
<div v-if="useSearch" class="sfh__search-row">
<SearchBar
:model-value="search"
:placeholder="searchPlaceholder"
:aria-label="searchPlaceholder"
:max-width="searchMaxWidth"
@search="onSearch"
/>
<button
v-if="useFilters && collapsible"
class="sfh__toggle"
type="button"
:aria-expanded="isOpen"
@click="toggleExpanded"
>
{{ isOpen ? L.hide_filters : L.show_filters }}
<svg class="sfh__toggle-chev" width="12" height="12" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
<!-- header-2: the expand-filters dropdown SET — ONE row, horizontal-scroll on overflow -->
<div v-if="useFilters && isOpen" class="sfh__filters-row" role="group" :aria-label="L.filters">
<FilterDropdown
v-for="f in filters"
:key="f.name"
class="sfh__dd"
:label="f.label"
:name="f.name"
:options="f.options || []"
:value="f.value || ''"
:placeholder="f.placeholder || L.type_to_search"
@filter-change="onFilterChange"
@filter-clear="onFilterClear"
/>
<button
v-for="t in toggles"
:key="t.name"
class="sfh__pill"
type="button"
:data-active="t.active ? '' : undefined"
@click="onToggle(t)"
>{{ t.label }}</button>
</div>
<!-- active-filter chips (removable) -->
<div v-if="useFilters && chips.length" class="sfh__chips">
<span v-for="c in chips" :key="c.name" class="sfh__chip">
{{ c.label }}
<button class="sfh__chip-x" type="button" :aria-label="`Remove ${c.label}`" @click="onRemoveChip(c.name)">&times;</button>
</span>
<button class="sfh__clear" type="button" @click="onClearAll">{{ L.clear_all }}</button>
</div>
</div>
</template>
<style>
/* Self-contained + scoped so the header self-styles inside a standalone custom-element
shadow root (mirrors the SearchBar / FilterDropdown primitives). CUBE custom-props from the
DX-life _filters.scss are resolved to concrete values. */
.sfh {
--brand: #2563eb; --ink: #0f172a; --mut: #64748b; --line: #e2e8f0; --card: #fff;
display: flex; flex-direction: column; gap: 10px; width: 100%; box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; color: var(--ink);
}
.sfh__search-row { display: flex; align-items: center; gap: 12px; }
.sfh__toggle {
flex: 0 0 auto; background: none; border: none; cursor: pointer; font-size: 0.875rem;
color: var(--brand); display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 2px;
}
.sfh__toggle-chev { transition: transform 200ms ease; }
.sfh__toggle[aria-expanded="true"] .sfh__toggle-chev { transform: rotate(180deg); }
/* ★ ONE ROW, NEVER WRAP — horizontal-scroll on overflow. */
.sfh__filters-row {
display: flex; flex-wrap: nowrap; align-items: center; gap: 10px;
overflow-x: auto; overflow-y: visible; padding-bottom: 4px;
scrollbar-width: thin; -webkit-overflow-scrolling: touch;
}
/* keep each control at its width instead of collapsing into a 2nd line */
.sfh__dd { flex: 0 0 auto; }
/* override the primitive's mobile "flex:1 1 100%" so it never full-widths and wraps */
@media (max-width: 576px) { .sfh__dd { flex: 0 0 auto; max-width: 240px; } }
.sfh__filters-row::-webkit-scrollbar { height: 8px; }
.sfh__filters-row::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 8px; }
.sfh__filters-row::-webkit-scrollbar-track { background: transparent; }
.sfh__pill {
flex: 0 0 auto; padding: 6px 12px; cursor: pointer; font-size: 0.875rem; color: inherit;
background: var(--card); border: 1px solid #cbd5e1; border-radius: 8px; white-space: nowrap;
transition: border-color 150ms ease, background 150ms ease;
}
.sfh__pill:hover { border-color: var(--brand); }
.sfh__pill[data-active] { background-color: rgba(37, 99, 235, 0.08); border-color: var(--brand); color: var(--brand); }
.sfh__chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.sfh__chip {
display: inline-flex; gap: 6px; align-items: center; background-color: rgba(37, 99, 235, 0.1);
color: var(--brand); border-radius: 100px; padding: 4px 12px; font-size: 0.85rem;
}
.sfh__chip-x { background: none; border: none; cursor: pointer; font-size: 1.1em; line-height: 1; color: inherit; padding: 0; display: inline-flex; align-items: center; }
.sfh__chip-x:hover { opacity: 0.7; }
.sfh__clear { background: none; border: none; cursor: pointer; font-size: 0.875rem; color: var(--brand); text-decoration: underline; text-underline-offset: 4px; }
</style>

View file

@ -277,7 +277,12 @@ export function useMapFly(options = {}) {
if (!map || !bounds) return;
// maxZoom defaults to 4 (continent-scale framing). Caller can override.
const maxZoom = options.maxZoom ?? 4;
map.fitBounds(bounds, { padding, maxZoom, ...FLY_CONFIG });
// `options` is spread LAST so a caller can override any animation field — notably
// `duration: 0`, which an accessibility path needs in order to JUMP instead of ease
// for prefers-reduced-motion users. Previously only `maxZoom` was read and every
// other caller option was silently dropped, so a reduced-motion caller still got
// FLY_CONFIG's easing. Passing no options is byte-for-byte the previous behaviour.
map.fitBounds(bounds, { padding, maxZoom, ...FLY_CONFIG, ...options });
}
/**

View file

@ -263,6 +263,8 @@ export function useMapInstance({
const isMapReady = ref(false)
// Holds the dynamic-minZoom resize handler so destroy() can remove it.
let _onResize = null
// Cancels the debounced zoom-floor snap (anti-strobe) so destroy() leaves no pending timer.
let _clearSnap = null
// Holds the gray side-edge touch handlers so destroy() can remove them.
let _detachEdgeBorders = null
@ -289,8 +291,15 @@ export function useMapInstance({
// ─── API Key validation (Mapbox only — MapLibre + OpenFreeMap need no token) ──
if (!isMaplibre) {
// Reusable fallback: a host that can't thread a per-instance token (e.g. a BAKED composite that
// mounts a map component directly, with no ProfileLoader / profile-config wrapper) may expose the
// token once as `window.MAP_TOKEN`. Any map on the page then finds it. Per-instance accessToken
// still wins; this only fills the gap when none was passed.
if (!accessToken && typeof window !== 'undefined' && typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) {
accessToken = window.MAP_TOKEN
}
if (!accessToken) {
console.error('No Mapbox access token provided. Pass "tk" in profile-config JSON.')
console.error('No Mapbox access token provided. Pass "tk" in profile-config JSON or set window.MAP_TOKEN.')
return
}
if (!accessToken.startsWith('pk.')) {
@ -441,13 +450,45 @@ export function useMapInstance({
// two worlds at once, but low enough on tall/portrait phones to see the whole
// world. Portrait↔landscape flips which term binds; if the new floor is above
// the current zoom, snap up so the camera stays within the floor.
// A11Y / PHOTOSENSITIVITY (BUG-RESIZE-STROBE): this handler runs on EVERY Mapbox/MapLibre
// 'resize' event, and map.resize() itself emits one. When a container is being dragged
// (a resizable dashboard section, a window-edge drag) the height — and therefore the
// computed floor — changes on every animation frame, so the unguarded `setZoom(floor)`
// re-snapped the camera ~60x/sec and re-rendered the whole globe: visible strobing, a
// seizure risk for photosensitive users. Two guards, both required:
// 1. IDEMPOTENCE — only touch minZoom when the floor actually CHANGED. A pure width
// drag, or any resize that leaves the floor alone, now costs nothing.
// 2. DEBOUNCE the camera snap — clamping the zoom is a visible camera move, so it must
// happen ONCE when the size settles, never mid-drag. The floor itself is applied
// immediately (cheap, non-visual); only the snap waits.
let _lastFloor = initialMinZoom
let _snapT = null
_onResize = () => {
const m = map.value
if (!m) return
const floor = floorFor(el.clientWidth, el.clientHeight)
m.setMinZoom(floor)
if (m.getZoom() < floor) m.setZoom(floor)
if (floor !== _lastFloor) {
_lastFloor = floor
m.setMinZoom(floor)
}
// Defer the visible clamp until the resize stream goes quiet.
if (m.getZoom() < floor) {
if (_snapT) clearTimeout(_snapT)
_snapT = setTimeout(() => {
_snapT = null
const mm = map.value
if (!mm) return
const f = floorFor(el.clientWidth, el.clientHeight)
// jumpTo, not setZoom: no easing/animation, so the correction lands in a single
// frame instead of tweening (another motion source for reduced-motion users).
if (mm.getZoom() < f) { try { mm.jumpTo({ zoom: f }) } catch (_) { mm.setZoom(f) } }
}, 150)
} else if (_snapT) {
// the drag brought us back inside the floor — cancel the pending correction
clearTimeout(_snapT); _snapT = null
}
}
_clearSnap = () => { if (_snapT) { clearTimeout(_snapT); _snapT = null } }
// Mapbox fires 'resize' after map.resize(); also listen to window resize +
// orientationchange so panel toggles / breakpoints / device rotation recompute.
map.value.on('resize', _onResize)
@ -480,6 +521,8 @@ export function useMapInstance({
}
_onResize = null
}
// Drop any pending debounced zoom-floor snap so it can never fire on a dead map.
if (_clearSnap) { try { _clearSnap() } catch (_) {} _clearSnap = null }
// Tear down the gray side-edge touch handlers (mirrors registration above).
if (_detachEdgeBorders) { _detachEdgeBorders(); _detachEdgeBorders = null }
map.value.remove()

View file

@ -0,0 +1,250 @@
<!--
StackLayout.vue — a brand-neutral, shared SCROLL-STACK layout for grouping elements top-to-bottom.
★ WHY THIS EXISTS (the second shared layout, alongside VerticalTabsLayout). VerticalTabsLayout shows
ONE element at a time (tabs, fixed-viewport panes). StackLayout does the OPPOSITE: it renders ALL of
its elements STACKED VERTICALLY, each at its NATURAL height, so the whole thing grows the page and the
HOST page scrolls through them — exactly how a normal document / a doxa-life / nuxtinator page flows.
Use it to GROUP two (or more) elements on top of each other and embed the group on a page you scroll.
★ SAME NESTED-ELEMENT CONTRACT as VerticalTabsLayout (so a shell composes other element-plugins). Pass
:sections="[{ key, title?, sub?, element, src?, attrs? }]". For each section:
element : a custom-element tag to render, e.g. "research-dashboard-map"
src? : a bundle URL to inject (once, deduped) if `element` isn't registered yet
attrs? : a map of attributes/props set on the element BEFORE it mounts ("props in advance")
title? : an optional section heading rendered above the element (omit for a bare stack)
A section can INSTEAD carry no `element` and use the #section-<key> slot (or the default slot) for
host-rendered content — `element` is an ALTERNATIVE, not a replacement.
★ EAGER, NOT LAZY (the deliberate difference from tabs). Because the whole point is to scroll through
everything, every section's element mounts on FIRST RENDER (not on activation). If you only want the
visible ones to load, that's the tabs layout's job; a scroll-stack shows all.
★ THE LAYOUT OWNS THE SCROLL — the elements do NOT. This is the Driver's scroll-page contract: a
plugin dropped in a section only needs ROOM to display at full height; it never needs its own
scrollbar. The root is `display:flow-root` with NO fixed height and NO `position:absolute; inset:0`
— so it takes its content's natural height and the PARENT PAGE scrolls through everything. Each
nested element is given `display:block; width:100%` and whatever height IT wants (a 900px dashboard
makes the page 900px taller). Because many of our plugins ship their own `position:absolute; inset:0`
/ `height:100%` (they were authored to fill a fixed-viewport tab pane), the host pane is defensive:
it is `position:relative` and DEMOTES an absolutely/fixed-positioned direct child back to
`position:static` so it lays out in the flow instead of collapsing the section to 0px. For such a
self-sizing element that still can't measure its own height in flow, pass :min-section-height (or a
section's own `minHeight`) to reserve room. Optionally cap a section with :max-section-height to give
a genuinely tall element an inner scroll instead of an endless page (default: no cap = pure page
scroll — the doxa-life / nuxtinator page-flow model).
★ EMBEDDING A LAYOUT INSIDE A LAYOUT (scroll-page in the cap-5 main). The cap-5 SPA's shell is
VerticalTabsLayout (sidebar + a FIXED-VIEWPORT content frame whose pane is `position:absolute;
inset:0`). A raw StackLayout dropped straight into that pane would be clipped by the inset overlay,
so the page can't grow. The proven layout-inside-a-layout path is therefore the IFRAME LEAF: a tab
is registered `mode:'iframe'` and its leaf page renders THIS layout at document root, where the
document itself owns the scroll. dashboard-plus-grid does exactly this (a cap-5 tab whose leaf is a
StackLayout) — scroll-page inside the vertical-tabs main, proven end to end.
★ SHADOW-DOM SAFE. Like VerticalTabsLayout, this is typically imported as a CHILD of a customElement
root, so its <style> would be dropped from the shadow root. It injects its critical CSS via
useShadowStyles() at setup(). Keep STL_SHADOW_CSS and the <style> mirror in sync.
Slots: #brand / #meta (an optional top bar — omitted entirely if neither is used) · #section-<key>
(host content for a section without `element`) · #default (a trailing host area at the bottom).
-->
<template>
<div class="stl">
<header class="stl-top" v-if="$slots.brand || $slots.meta || title">
<span class="stl-brand"><slot name="brand" /></span>
<b class="stl-title" v-if="title">{{ title }}</b>
<span class="stl-meta"><slot name="meta" /></span>
</header>
<div class="stl-stack">
<section
v-for="sec in sections" :key="sec.key" class="stl-sec"
:class="{ 'stl-sec-fill': hasDefiniteHeight(sec) }"
:style="sectionStyle(sec)">
<div class="stl-sec-head" v-if="sec.title">
<b class="stl-sec-title">{{ sec.title }}</b>
<span class="stl-sec-sub" v-if="sec.sub">{{ sec.sub }}</span>
</div>
<!-- element-bearing section → a host pane the element is mounted into imperatively. -->
<div v-if="sec.element" class="stl-host" :ref="el => registerHost(sec.key, el)"></div>
<!-- otherwise → host-rendered content via a per-section slot. -->
<div v-else class="stl-slot"><slot :name="'section-' + sec.key" /></div>
</section>
<div class="stl-tail"><slot /></div>
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// Inject a bundle script exactly once per URL (deduped across all layout instances). Same helper the
// tabs layout + the SPA use — resolves after the IIFE's customElements.define has run.
const _stlLoaded = {}
function injectScriptOnce(url) {
if (!url) return Promise.resolve()
if (_stlLoaded[url]) return _stlLoaded[url]
_stlLoaded[url] = new Promise((ok, fail) => {
const s = document.createElement('script')
s.src = url
s.onload = () => ok()
s.onerror = () => { delete _stlLoaded[url]; fail(new Error('failed to load ' + url)) }
document.head.appendChild(s)
})
return _stlLoaded[url]
}
function waitForDefine(tag, timeoutMs = 5000) {
if (typeof customElements === 'undefined') return Promise.resolve()
if (customElements.get(tag)) return Promise.resolve()
if (customElements.whenDefined) {
return Promise.race([
customElements.whenDefined(tag),
new Promise((_, r) => setTimeout(() => r(new Error('timeout waiting for <' + tag + '>')), timeoutMs))
])
}
return Promise.resolve()
}
// Mirror of the <style> block for the shadow root (kept comment/interp-free for the strip plugin).
const STL_SHADOW_CSS = `
.stl { display:flow-root; width:100%; min-height:100%; background:#f3f3f1; color:#3b463d; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --stl-accent:#92b195; --stl-border:#d1d4cd; --stl-elevated:#ffffff; --stl-muted:#6d766e; }
.stl-top { display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--stl-border); background:#ffffff; position:sticky; top:0; z-index:5; }
.stl-brand { display:inline-flex; align-items:center; }
.stl-brand:empty { display:none; }
.stl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#3b463d; }
.stl-meta { color:var(--stl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
.stl-stack { display:flex; flex-direction:column; }
.stl-sec { display:flex; flex-direction:column; min-width:0; border-bottom:1px solid var(--stl-border); }
.stl-sec:last-child { border-bottom:0; }
.stl-sec-head { padding:12px 16px 8px; display:flex; align-items:baseline; gap:10px; }
.stl-sec-title { font-size:13px; font-weight:600; letter-spacing:.02em; color:#3b463d; }
.stl-sec-sub { font-size:12px; color:var(--stl-muted); }
.stl-host { display:block; width:100%; min-width:0; position:relative; }
.stl-host > * { display:block; width:100%; }
.stl-host > *[style*="absolute"], .stl-host > *[style*="fixed"] { position:static !important; inset:auto !important; }
.stl-slot { display:block; width:100%; }
.stl-tail:empty { display:none; }
.stl-sec-fill { position:relative; overflow:hidden; }
.stl-sec-fill > .stl-host, .stl-sec-fill > .stl-slot { position:absolute; inset:0; overflow:hidden; }
.stl-sec-fill > .stl-host > *, .stl-sec-fill > .stl-slot > * { height:100%; }
.stl, .stl * { scrollbar-width:thin; scrollbar-color:#c4c8c0 transparent; }
.stl ::-webkit-scrollbar { width:8px; height:8px; }
.stl ::-webkit-scrollbar-track { background:transparent; }
.stl ::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
.stl ::-webkit-scrollbar-thumb:hover { background:#9aa197; }
.stl ::-webkit-scrollbar-corner { background:transparent; }
`
export default defineComponent({
name: 'StackLayout',
props: {
// [{ key, title?, sub?, element?, src?, attrs? }] — rendered top-to-bottom, all eager.
sections: { type: Array, default: () => [] },
// Optional top-bar title (omit for a bare stack with no header).
title: { type: String, default: '' },
// Optional per-section max height (e.g. '70vh') → that section gets an inner scroll instead of an
// unbounded page. Default '' = no cap, pure page scroll (the doxa-life/nuxtinator page-flow model).
maxSectionHeight: { type: String, default: '' },
// Optional default MIN height for every section (e.g. '60vh') → reserves room for a self-sizing
// element that ships its own fixed layout and can't measure its natural height in flow. A section
// can override per-item with its own `minHeight`. Default '' = no floor (pure natural height).
minSectionHeight: { type: String, default: '' }
},
setup() {
useShadowStyles(STL_SHADOW_CSS, 'stack-layout')
},
data() {
return { _hosts: {}, _mounted: {} }
},
mounted() {
// Eager: mount every element-bearing section on first render (scroll-through shows all).
for (const sec of (this.sections || [])) {
if (sec.element) this.mountSection(sec)
}
},
methods: {
registerHost(key, el) { if (el) this._hosts[key] = el },
// Per-section wrapper style: an optional inner-scroll cap (maxHeight) and/or a reserved floor
// (minHeight, per-section override → layout default). Returns null when neither applies so a plain
// section stays pure natural-height page-flow.
sectionStyle(sec) {
const s = {}
const max = sec.maxHeight || this.maxSectionHeight
const min = sec.minHeight || this.minSectionHeight
// A definite-height section (min===max) is a fixed BAND: it must NOT get an inner section scroll —
// the nested element (via .stl-sec-fill) fills it and owns any inner scroll, and the PAGE scrolls
// past it. Only a cap-WITHOUT-a-matching-floor gets the maxHeight inner-scroll (`overflow:auto`).
const definite = !!(max && min && max === min)
if (max) { s.maxHeight = max; if (!definite) s.overflow = 'auto' }
if (min) s.minHeight = min
return Object.keys(s).length ? s : null
},
// A section is "definite-height" when it pins BOTH a floor and a cap to the SAME value (min===max):
// its height is fixed, not natural. Such a section adds `.stl-sec-fill` so its host/slot + the nested
// element stretch to fill it — the escape hatch for a fixed-viewport plugin (e.g. the research
// dashboard) that ships `height:100%` and needs a definite parent height to resolve against, while
// the OUTER page still grows below the section so page-scroll reaches the next section.
hasDefiniteHeight(sec) {
const max = sec.maxHeight || this.maxSectionHeight
const min = sec.minHeight || this.minSectionHeight
return !!(max && min && max === min)
},
mountSection(sec) {
if (this._mounted[sec.key]) return
this._mounted[sec.key] = true
const host = this._hosts[sec.key]
if (!host) return
injectScriptOnce(sec.src)
.then(() => waitForDefine(sec.element))
.then(() => {
if (!customElements.get(sec.element)) return
const el = document.createElement(sec.element)
const attrs = sec.attrs || {}
for (const k in attrs) {
const val = attrs[k]
// props-in-advance: set as a property AND an attribute (custom elements read either).
try { el[k] = val } catch { /* ignore */ }
try { el.setAttribute(k, val == null ? '' : String(val)) } catch { /* ignore invalid attr name */ }
}
host.innerHTML = ''
host.appendChild(el)
})
.catch(() => { /* a failed section leaves its host empty; the rest of the stack still renders */ })
}
}
})
</script>
<style>
/* Mirror of STL_SHADOW_CSS for dev / light-DOM readability. Keep the two in sync.
Brand-neutral default dark theme; consumers override via the --stl-* custom properties. */
.stl { display:flow-root; width:100%; min-height:100%; background:#f3f3f1; color:#3b463d; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --stl-accent:#92b195; --stl-border:#d1d4cd; --stl-elevated:#ffffff; --stl-muted:#6d766e; }
.stl-top { display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--stl-border); background:#ffffff; position:sticky; top:0; z-index:5; }
.stl-brand { display:inline-flex; align-items:center; }
.stl-brand:empty { display:none; }
.stl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#3b463d; }
.stl-meta { color:var(--stl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
.stl-stack { display:flex; flex-direction:column; }
.stl-sec { display:flex; flex-direction:column; min-width:0; border-bottom:1px solid var(--stl-border); }
.stl-sec:last-child { border-bottom:0; }
.stl-sec-head { padding:12px 16px 8px; display:flex; align-items:baseline; gap:10px; }
.stl-sec-title { font-size:13px; font-weight:600; letter-spacing:.02em; color:#3b463d; }
.stl-sec-sub { font-size:12px; color:var(--stl-muted); }
.stl-host { display:block; width:100%; min-width:0; position:relative; }
.stl-host > * { display:block; width:100%; }
.stl-host > *[style*="absolute"], .stl-host > *[style*="fixed"] { position:static !important; inset:auto !important; }
.stl-slot { display:block; width:100%; }
.stl-tail:empty { display:none; }
.stl-sec-fill { position:relative; overflow:hidden; }
.stl-sec-fill > .stl-host, .stl-sec-fill > .stl-slot { position:absolute; inset:0; overflow:hidden; }
.stl-sec-fill > .stl-host > *, .stl-sec-fill > .stl-slot > * { height:100%; }
.stl, .stl * { scrollbar-width:thin; scrollbar-color:#c4c8c0 transparent; }
.stl ::-webkit-scrollbar { width:8px; height:8px; }
.stl ::-webkit-scrollbar-track { background:transparent; }
.stl ::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
.stl ::-webkit-scrollbar-thumb:hover { background:#9aa197; }
.stl ::-webkit-scrollbar-corner { background:transparent; }
</style>

View file

@ -49,19 +49,46 @@
`src`, the bundle is injected lazily (once per src) at first activation, and the element is
created only after customElements.get(tag) resolves (injectScriptOnce + waitForDefine).
★ DEFAULT DARK THEME. The inlined token values are just colours: a neutral-900 canvas, a SOLID
accented active row (not a coloured gradient), subtle borders, and green-400 (#00DC82) as the one
accent. They ship as literals (a shadow-DOM custom element cannot import an app's design tokens —
that is the boundary), so the rail looks finished out of the box with zero runtime dependency. A
consumer can override the look via the CSS custom properties (--vtl-accent, --vtl-border, etc.).
★ DEFAULT MATERIAL LIGHT THEME. The inlined token values are just colours, sourced from the live
DX-life material palette (read-only): a light canvas (#f3f3f1 surface-default), white elevated
chrome (#fff), brand-dark text (#3b463d), secondary-light borders (#d1d4cd), muted (#6d766e), and
the sage brand-primary (#92b195) as the one accent. They ship as literals (a shadow-DOM custom
element cannot import an app's design tokens — that is the boundary), so the rail looks finished
out of the box with zero runtime dependency. A consumer can override the look via the CSS custom
properties (--vtl-accent, --vtl-border, --vtl-elevated, --vtl-accented, --vtl-muted).
The rail is keyboard-accessible (role="tablist", ArrowUp/Down + Home/End move focus & select), the
active row is highlighted, and the whole thing is responsive (rail collapses to an off-canvas drawer
under 760px, toggled by the ☰ button).
active row is highlighted, and the whole thing is responsive.
★ OPTIONAL HEADER (`header` prop, default true). The top bar (#brand / title / #meta) is only
rendered when `header` is true. Pass :header="false" for a sidebar-only, full-page element (e.g.
embedded in a site that already has its own header — stacked headers look bad). #toolbar is
unaffected (it lives in the content column, not the header). plugin-studio keeps its header; the
DX-Maps SPA turns it off.
★ COLLAPSIBLE RAIL (desktop + mobile). A clearly-visible toggle at the top of the rail collapses it:
- DESKTOP (≥760px): the rail shrinks to a SLIM ICON RAIL (icons stay clickable, labels/sections
hide) and expands back. State is `railCollapsed`. The rail width animates via a single CSS custom
property (--vtl-rail: 256px↔60px) with a `width` transition, and BOTH the rail and the content pane
set `contain:layout paint` so the collapse never reflows a heavy mounted map/element in the content
frame — a smooth, GPU-friendly 60fps transition rather than the per-frame layout thrash a
flex-basis animation caused. Because a header-less element is nav + content only, a slim rail keeps
every tab reachable rather than leaving a blank page.
- MOBILE (<760px): the rail is an off-canvas DRAWER toggled open/closed via `navOpen` (the ☰ lives
in the rail toggle too).
▸ STATE RECONCILIATION (the fix for the "extremely buggy" collapse). `navOpen` (mobile drawer) and
`railCollapsed` (desktop slim rail) are two boolean state machines with different meanings on each
side of the 760px breakpoint. A reactive `narrow` flag mirrors the media query via a resize
listener; on every breakpoint crossing `reconcileForWidth()` snaps the flags into a coherent state
(desktop → navOpen=true so the rail is always visible; mobile → navOpen=false + railCollapsed=false
so the drawer starts closed and reopens full-width). The rail's visibility binds to the media-aware
`railShown` computed (NOT raw navOpen), so a mobile-closed drawer can no longer hide the whole rail
after a resize back to desktop — the previous broken-restore bug.
-->
<template>
<div class="vtl" :class="{ 'nav-open': navOpen }">
<header class="vtl-top">
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header }">
<header class="vtl-top" v-if="header">
<button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
aria-label="Toggle list">☰</button>
<span class="vtl-brand"><slot name="brand" /></span>
@ -70,7 +97,24 @@
</header>
<div class="vtl-body">
<nav class="vtl-nav" :aria-label="navLabel" v-show="navOpen" role="tablist" aria-orientation="vertical">
<!-- Rail visibility is CSS-driven via `transform`, never v-show/display:none — a display:none element
cannot animate, so the drawer must stay in the DOM and only slide. DESKTOP: the rail is always
on-screen (translateX(0), or -196px as a slim icon rail when railCollapsed). MOBILE: the rail is
an off-canvas drawer parked at translateX(-100%) and slid in by the `nav-open` class. Because it
is never display:none, a mobile-closed drawer can no longer strand the rail after a resize back
to desktop (the old cross-breakpoint bug). `aria-hidden` mirrors real visibility for a11y. -->
<nav class="vtl-nav" :aria-label="navLabel" :aria-hidden="String(!railShown)" role="tablist" aria-orientation="vertical">
<!-- Rail toggle: always visible, collapses the rail (slim icon rail on desktop; off-canvas
drawer on mobile). The chevron points the way the click will move the rail. -->
<button
type="button" class="vtl-collapse"
:aria-expanded="String(!railCollapsed)"
:aria-label="railCollapsed ? 'Expand list' : 'Collapse list'"
@click="toggleRail">
<span class="vtl-collapse-ic" aria-hidden="true" v-html="iconSvg('chevron')"></span>
<span class="vtl-collapse-tx">Collapse</span>
</button>
<!-- GROUPED shape: labelled sections (always-open) or collapsible folders. -->
<template v-if="groups && groups.length">
<div class="vtl-grp" v-for="grp in groups" :key="grp.key">
@ -79,13 +123,14 @@
v-if="grp.folder" type="button" class="vtl-tier vtl-tier-btn"
:aria-expanded="String(!isFolderClosed(grp))"
@click="toggleFolder(grp.key)">
<span class="vtl-tier-ic" v-if="grp.icon" aria-hidden="true" v-html="iconSvg(grp.icon)"></span>
<!-- NO section-header logo (Driver rule: logos only on the vertical TAB rows, never on the
section dividers). The folder chevron below is an affordance, not a logo, so it stays. -->
<span class="vtl-tl">{{ grp.label }}</span>
<span class="vtl-chev" :class="{ open: !isFolderClosed(grp) }" aria-hidden="true" v-html="iconSvg('chevron')"></span>
</button>
<!-- SECTION heading = a static always-visible label (with optional badge/count). -->
<!-- SECTION heading = a static always-visible label (with optional badge/count). No logo here
either — section dividers are text-only (Driver rule); row icons live on the tabs below. -->
<div v-else class="vtl-tier">
<span class="vtl-tier-ic" v-if="grp.icon" aria-hidden="true" v-html="iconSvg(grp.icon)"></span>
<span class="vtl-cap" v-if="grp.badge">{{ grp.badge }}</span>
<span class="vtl-tl">{{ grp.label }}</span>
<span class="vtl-td" v-if="grp.count != null">{{ grp.count }}</span>
@ -101,6 +146,10 @@
@click="pick(row.key)" @keydown="onKey($event)">
<span class="vtl-tab-ic" aria-hidden="true" v-if="row.icon" v-html="iconSvg(row.icon)"></span>
<span class="vtl-tab-emoji" aria-hidden="true" v-else-if="row.emoji">{{ row.emoji }}</span>
<!-- Slim-rail fallback (desktop collapsed only): a row with NEITHER icon NOR emoji would
otherwise be an empty hit-target in the icon strip. Show the label's initial so a
general-library consumer that omits icons still gets a usable slim rail. -->
<span class="vtl-tab-fallback" aria-hidden="true" v-else>{{ initial(row.label) }}</span>
<span class="vtl-tab-text">
<span class="vtl-tab-label">{{ row.label }}<span class="vtl-tab-badge" v-if="row.badge">{{ row.badge }}</span></span>
<span class="vtl-tab-sub" v-if="row.sub"><span v-html="row.sub"></span></span>
@ -121,6 +170,7 @@
@click="pick(row.key)" @keydown="onKey($event)">
<span class="vtl-tab-ic" aria-hidden="true" v-if="row.icon" v-html="iconSvg(row.icon)"></span>
<span class="vtl-tab-emoji" aria-hidden="true" v-else-if="row.emoji">{{ row.emoji }}</span>
<span class="vtl-tab-fallback" aria-hidden="true" v-else>{{ initial(row.label) }}</span>
<span class="vtl-tab-text">
<span class="vtl-tab-label">{{ row.label }}</span>
<span class="vtl-tab-sub" v-if="row.sub">{{ row.sub }}</span>
@ -130,7 +180,11 @@
</nav>
<main class="vtl-main">
<slot name="toolbar" />
<!-- The #toolbar strip (locale switcher etc.) is part of the header chrome, so it renders ONLY
when header is on. Driver rule: a header-off, sidebar-only element (doxa-maps-spa) inherits
its language from the PARENT via the `language` prop and shows NO standalone locale dropdown;
a header-on host (plugin-studio) DOES get the toolbar/locale dropdown. -->
<slot name="toolbar" v-if="header" />
<div class="vtl-content">
<!-- Lazily-hosted nested elements: one pane per element-bearing row, rendered only after its
tab has been activated once (v-if on activatedKeys), then kept alive (v-show) so its
@ -191,18 +245,47 @@ function waitForDefine(tag, timeoutMs = 5000) {
// Injected into the shadow root at mount (see the header comment for the full why). Kept free of
// block comments and ${} substitutions so the build-time strip-shadow-css plugin ships it verbatim.
const VTL_SHADOW_CSS = `
.vtl { position:absolute; inset:0; display:flex; flex-direction:column; height:100%; width:100%; min-height:420px; background:#171717; color:#f5f5f5; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --vtl-accent:#00DC82; --vtl-border:#262626; --vtl-elevated:#1f1f1f; --vtl-accented:#404040; --vtl-muted:#a3a3a3; }
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--vtl-border); background:#1f1f1f; }
.vtl-menu { display:none; font:16px/1 system-ui; color:#f5f5f5; background:#262626; border:1px solid #404040; border-radius:8px; padding:5px 10px; cursor:pointer; }
.vtl-menu:hover { background:#404040; }
/* STRUCTURAL DISPLAY — the !important is deliberate, not a hack. This layout's display mode is
load-bearing: it CANNOT function as a block. As a library/ (high-bar) component it ships across
all 5 deployment methods, and a host (app-shell direct-mount, WordPress, mobile webview) may force
its own inline display onto the element. Declaring it non-overridable is the correct contract.
grid-template-rows: auto 1fr = header takes its natural height, .vtl-body gets a DEFINITE
remaining track — no dependence on in-flow children (nav/main are absolute for the rail slide). */
.vtl { position:absolute; inset:0; display:grid !important; grid-template-rows:auto 1fr; height:100%; width:100%; min-height:420px; background:#f3f3f1; color:#3b463d; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --vtl-accent:#92b195; --vtl-border:#d1d4cd; --vtl-elevated:#ffffff; --vtl-accented:#d1d4cd; --vtl-muted:#6d766e; --vtl-rail:256px; }
/* HEADER-OFF (header=false, e.g. the cap-5 doxa-maps-spa): the .vtl-top element is NOT rendered, so
.vtl-body becomes the ONLY grid child and auto-places into row 1 — the auto row — which has no
content to size it and collapses to 0 (the 1fr row sits empty) → the same 0-height black screen.
With no header there is only one region, so declare a single 1fr row and the body fills. */
.vtl.no-header { grid-template-rows:1fr; }
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--vtl-border); background:#ffffff; }
.vtl-menu { display:none; font:16px/1 system-ui; color:#3b463d; background:#d1d4cd; border:1px solid #d1d4cd; border-radius:8px; padding:5px 10px; cursor:pointer; }
.vtl-menu:hover { background:#d1d4cd; }
.vtl-brand { display:inline-flex; align-items:center; }
.vtl-brand:empty { display:none; }
.vtl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#f5f5f5; }
.vtl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#3b463d; }
.vtl-meta { color:var(--vtl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
.vtl-body { flex:1 1 auto; display:flex; min-height:0; }
.vtl-nav { flex:0 0 256px; overflow-y:auto; overflow-x:hidden; border-right:1px solid var(--vtl-border); padding:12px 12px 20px; background:#1f1f1f; scrollbar-width:thin; scrollbar-color:#404040 transparent; }
.vtl-nav::-webkit-scrollbar { width:8px; }
.vtl-nav::-webkit-scrollbar-thumb { background:#404040; border-radius:8px; }
/* GRID ROWS, not flex (BUG-EMBEDDED-PLUGIN-STUDIO-BLACK-SCREEN). The collapsible-rail design makes
.vtl-nav and .vtl-main position:absolute (so the rail slides by transform without reflowing the
heavy map sibling). That leaves .vtl-body with NO in-flow children → ZERO intrinsic height, so it
could only fill via flex:1 from a flex parent. A host that forces display:block on .vtl (the
app-shell direct-mounts cap-5 elements that way) made that flex inert → .vtl-body collapsed to 0
→ black screen. The auto/1fr grid on .vtl gives this row a DEFINITE height by construction, so
it no longer depends on the parent's display mode NOR on in-flow children. Header-off
(header=false) still works: the auto row collapses to 0 and 1fr takes the full height. */
.vtl-body { min-height:0; position:relative; display:flex; }
.vtl-nav { position:absolute; top:0; bottom:0; left:0; z-index:5; width:256px; box-sizing:border-box; overflow-y:auto; overflow-x:hidden; border-right:1px solid var(--vtl-border); padding:12px 12px 20px; background:#ffffff; scrollbar-width:thin; scrollbar-color:#c4c8c0 transparent; contain:layout paint; transform:translateX(0); transition:transform .2s cubic-bezier(.4,0,.2,1); will-change:transform; }
/* Thin OVERLAY scrollbar: transparent track so the bar costs no visual space and sits flush to
the nav edge; low-contrast thumb that darkens on hover. Matches the research-dashboard rails. */
.vtl-nav::-webkit-scrollbar { width:8px; height:8px; }
.vtl-nav::-webkit-scrollbar-track { background:transparent; }
.vtl-nav::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
.vtl-nav::-webkit-scrollbar-thumb:hover { background:#9aa197; }
.vtl-collapse { display:flex; align-items:center; gap:8px; width:100%; box-sizing:border-box; margin:0 0 8px; padding:7px 10px; border:1px solid var(--vtl-border); border-radius:8px; background:#d1d4cd; color:var(--vtl-muted); font:inherit; font-size:12px; cursor:pointer; }
.vtl-collapse:hover { background:var(--vtl-accented); color:#3b463d; }
.vtl-collapse:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
.vtl-collapse-ic { flex:0 0 auto; display:inline-flex; transition:transform .16s ease; transform:rotate(180deg); }
.vtl-collapse-ic svg { width:16px; height:16px; }
.vtl-collapse-tx { white-space:nowrap; }
.vtl-grp { margin-bottom:2px; }
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
@ -219,31 +302,63 @@ const VTL_SHADOW_CSS = `
.vtl-chev.open { transform:rotate(90deg); }
.vtl-rows { display:flex; flex-direction:column; gap:1px; }
.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 10px; cursor:pointer; border-radius:8px; font:inherit; margin:0; transition:background .13s ease,color .13s ease; }
.vtl-tab:hover { background:var(--vtl-accented); color:#f5f5f5; }
.vtl-tab:hover { background:var(--vtl-accented); color:#3b463d; }
.vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
.vtl-tab.active { background:var(--vtl-accented); color:#f5f5f5; font-weight:500; }
.vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; }
.vtl-tab.active .vtl-tab-ic { color:var(--vtl-accent); }
.vtl-tab-ic { flex:0 0 auto; display:inline-flex; color:var(--vtl-muted); }
.vtl-tab-ic svg { width:20px; height:20px; }
.vtl-tab-emoji { font-size:17px; line-height:1; flex:0 0 auto; }
.vtl-tab-fallback { display:none; flex:0 0 auto; width:20px; height:20px; align-items:center; justify-content:center; font-size:12px; font-weight:600; color:var(--vtl-muted); background:var(--vtl-accented); border-radius:6px; }
.vtl-tab.active .vtl-tab-fallback { color:var(--vtl-accent); }
.vtl-tab-text { display:flex; flex-direction:column; min-width:0; }
.vtl-tab-label { font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:6px; }
.vtl-tab-badge { font:600 9px/1 system-ui; letter-spacing:.3px; text-transform:uppercase; color:var(--vtl-accent); border:1px solid var(--vtl-accent); border-radius:999px; padding:2px 5px; }
.vtl-tab-sub { font-size:11px; color:#6b6b6b; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.vtl-tab-sub code { font-family:ui-monospace,Menlo,monospace; }
.vtl-tab.active .vtl-tab-sub { color:var(--vtl-muted); }
.vtl-main { flex:1 1 auto; position:relative; min-width:0; display:flex; flex-direction:column; }
.vtl-content { flex:1 1 auto; position:relative; min-height:0; }
.vtl-main { position:absolute; top:0; bottom:0; right:0; left:256px; min-width:0; display:flex; flex-direction:column; }
.vtl-content { flex:1 1 auto; position:relative; min-height:0; contain:layout paint; }
.vtl-host { position:absolute; inset:0; }
.vtl-host-slot { position:absolute; inset:0; }
.vtl-host-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#94a3b8; font-size:13px; padding:20px; }
.vtl-host-hint.err { color:#f87171; }
.vtl-host-hint small { color:#64748b; }
.vtl.rail-collapsed .vtl-nav { transform:translateX(-196px); }
.vtl.rail-collapsed .vtl-main { left:60px; }
.vtl.rail-collapsed .vtl-collapse { padding-left:196px; justify-content:flex-start; }
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(0deg); }
.vtl.rail-collapsed .vtl-collapse-tx { display:none; }
.vtl.rail-collapsed .vtl-tab { padding-left:200px; gap:0; }
.vtl.rail-collapsed .vtl-tab-text { display:none; }
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
.vtl.rail-collapsed .vtl-tab-fallback { display:inline-flex; }
.vtl.rail-collapsed .vtl-tier { padding-left:196px; }
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:none; }
.vtl.rail-collapsed .vtl-grp + .vtl-grp { margin-top:6px; padding-top:6px; }
@media (max-width: 760px) {
.vtl-menu { display:inline-block; }
.vtl-meta { display:none; }
.vtl-nav { position:absolute; z-index:20; top:0; bottom:0; left:0; box-shadow:0 0 28px rgba(0,0,0,.55); }
.vtl:not(.nav-open) .vtl-nav { display:none; }
/* MOBILE = the SAME composited-transform model, taken to its full-off-canvas extreme: a full-width
drawer that slides in/out via transform (never a layout property), so opening/closing the drawer
never reflows the content. Content is full-width (left:0) and stays put; the drawer floats over it. */
.vtl-nav { z-index:20; width:256px; box-shadow:0 0 28px rgba(0,0,0,.55); transform:translateX(-100%); }
.vtl.nav-open .vtl-nav { transform:translateX(0); }
.vtl-main { left:0; }
/* The desktop slim-rail collapse has no meaning on a phone — reconcileForWidth() clears railCollapsed
at this width, but neutralize its CSS too (belt-and-suspenders) so a full labelled drawer always
shows: no partial slide, no icon-column padding, labels/sections visible. */
.vtl.rail-collapsed .vtl-nav { transform:translateX(-100%); }
.vtl.rail-collapsed.nav-open .vtl-nav { transform:translateX(0); }
.vtl.rail-collapsed .vtl-main { left:0; }
.vtl.rail-collapsed .vtl-collapse { padding-left:10px; justify-content:flex-start; }
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(180deg); }
.vtl.rail-collapsed .vtl-collapse-tx { display:inline; }
.vtl.rail-collapsed .vtl-tab { padding-left:10px; gap:11px; }
.vtl.rail-collapsed .vtl-tab-text { display:flex; }
.vtl.rail-collapsed .vtl-tab-fallback { display:none; }
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:inline; }
}
`
@ -260,7 +375,12 @@ export default defineComponent({
groups: { type: Array, default: () => [] },
navLabel: { type: String, default: 'Sections' },
// Start with the rail open (desktop). Auto-collapses on narrow screens after mount.
initialNavOpen: { type: Boolean, default: true }
initialNavOpen: { type: Boolean, default: true },
// Render the top bar (#brand / title / #meta). Pass false for a sidebar-only, full-page element
// (embedded where a host header already exists — stacked headers look bad). #toolbar is unaffected.
header: { type: Boolean, default: true },
// Start the desktop rail collapsed (slim icon rail). Off by default so the rail opens expanded.
initialRailCollapsed: { type: Boolean, default: false }
},
emits: ['update:active'],
setup() {
@ -275,11 +395,19 @@ export default defineComponent({
// tab (so their pane is rendered + element mounted); hostState/hostError track each pane's load.
// A row not in activatedKeys is never rendered — the lazy contract (no visit → no cost).
return {
navOpen: this.initialNavOpen, _tabEls: {}, folderOverride: {},
navOpen: this.initialNavOpen, railCollapsed: this.initialRailCollapsed, _tabEls: {}, folderOverride: {},
// `narrow` mirrors the (max-width:760px) media query as REACTIVE state, kept current by a resize
// listener. It lets us reconcile the two boolean state machines (navOpen = mobile drawer,
// railCollapsed = desktop slim rail) at the breakpoint and drive rail visibility correctly.
narrow: false, _mql: null, _onMedia: null,
activatedKeys: {}, hostState: {}, hostError: {}, _hostSlots: {}, _hostMounted: {}
}
},
computed: {
// Is the rail element visible at all? DESKTOP: always (it just shrinks when railCollapsed). MOBILE:
// only when the off-canvas drawer is open (navOpen). Binding the rail's v-show to THIS (not raw
// navOpen) prevents a mobile-closed drawer from hiding the rail after a resize back to desktop.
railShown() { return this.narrow ? this.navOpen : true },
// Every row/item flattened across whichever shape is in use (groups take precedence over items).
allRows() {
if (this.groups && this.groups.length) {
@ -306,15 +434,57 @@ export default defineComponent({
}
},
mounted() {
// Track the breakpoint reactively and RECONCILE state whenever we cross it — this is the fix for the
// "extremely buggy" collapse: without it, `navOpen` (mobile drawer) and `railCollapsed` (desktop slim
// rail) drifted out of the regime they were set in, and a mobile-closed drawer could hide the whole
// desktop rail. On each change we snap the flags back into a coherent state for the new width.
try {
if (window.matchMedia && window.matchMedia('(max-width: 760px)').matches) this.navOpen = false
} catch { /* ignore */ }
if (window.matchMedia) {
this._mql = window.matchMedia('(max-width: 760px)')
this.narrow = this._mql.matches
this.reconcileForWidth()
this._onMedia = (e) => { this.narrow = e.matches; this.reconcileForWidth() }
// addEventListener is the modern API; addListener is the Safari<14 fallback.
if (this._mql.addEventListener) this._mql.addEventListener('change', this._onMedia)
else if (this._mql.addListener) this._mql.addListener(this._onMedia)
}
} catch { /* ignore — non-DOM/SSR guards */ }
// The immediate `active` watcher may have run before the pane div existed; re-run for the current
// active key now that the DOM is up, so an element-tab active at first render mounts.
this.$nextTick(() => this.activateElement(this.active))
},
beforeUnmount() {
try {
if (this._mql && this._onMedia) {
if (this._mql.removeEventListener) this._mql.removeEventListener('change', this._onMedia)
else if (this._mql.removeListener) this._mql.removeListener(this._onMedia)
}
} catch { /* ignore */ }
this._mql = null; this._onMedia = null
},
methods: {
registerTab(key, el) { if (el) this._tabEls[key] = el },
// The rail toggle. On a narrow screen the rail is an off-canvas drawer, so the toggle closes the
// drawer (navOpen). On desktop it collapses/expands the slim icon rail (railCollapsed, persisted
// in component data). Reading matchMedia at click time keeps one toggle correct in both modes.
isNarrow() { return this.narrow },
// Snap the two boolean state machines into a coherent state for the CURRENT width. Called on mount and
// on every breakpoint crossing. DESKTOP: the rail is always visible, so force navOpen=true (a stale
// mobile-closed drawer must never hide the desktop rail). MOBILE: the rail is an off-canvas drawer that
// should start CLOSED, and the desktop slim-rail collapse has no meaning here, so clear railCollapsed so
// reopening the drawer always shows a full-width, labelled list.
reconcileForWidth() {
if (this.narrow) {
this.navOpen = false
this.railCollapsed = false
} else {
this.navOpen = true
}
},
toggleRail() {
if (this.narrow) this.navOpen = !this.navOpen
else this.railCollapsed = !this.railCollapsed
},
setHostSlot(key, el) { if (el) { this._hostSlots[key] = el; this.$nextTick(() => this.mountHostElement(key)) } },
// Mark an element-row activated (renders its pane) on first activation; mount happens once the
// pane's slot div exists (setHostSlot → mountHostElement). Idempotent: re-activating is a no-op.
@ -366,6 +536,9 @@ export default defineComponent({
// Resolve a LOCAL icon name to its inline SVG string (app-wrapper-material/icons.js). Names come
// from OUR nav model (never user input); unknown names fall back to the library's own default.
iconSvg(name) { return name ? localIcon(name) : '' },
// First visible character of a label, uppercased — the slim-rail fallback glyph for a row that has
// neither `icon` nor `emoji`, so the collapsed icon strip never shows an empty hit-target.
initial(label) { const s = (label || '').trim(); return s ? s.charAt(0).toUpperCase() : '•' },
// A folder is closed unless the user toggled it open, or (no toggle yet) unless it was authored
// with open:true. `grp.open === true` opens by default; anything else starts collapsed.
isFolderClosed(grp) {
@ -381,9 +554,9 @@ export default defineComponent({
},
pick(key) {
this.$emit('update:active', key)
try {
if (window.matchMedia && window.matchMedia('(max-width: 760px)').matches) this.navOpen = false
} catch { /* ignore */ }
// On mobile, picking a tab closes the off-canvas drawer. On desktop navOpen must stay true (it gates
// the desktop rail's visibility via railShown), so we only touch it when narrow.
if (this.narrow) this.navOpen = false
},
// ArrowUp/Down cycle the rail; Home/End jump to the ends. Moves selection AND focus so a
// keyboard user drives the shell exactly like a native tablist.
@ -410,19 +583,48 @@ export default defineComponent({
/* Mirror of VTL_SHADOW_CSS for dev / light-DOM readability. Keep the two in sync.
Default dark theme (values are just colours, overridable via the --vtl-* custom properties):
neutral-900 canvas, a SOLID accented active row (not a gradient), subtle borders, green-400
(#00DC82) accent, inline SVG icons. */
.vtl { position:absolute; inset:0; display:flex; flex-direction:column; height:100%; width:100%; min-height:420px; background:#171717; color:#f5f5f5; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --vtl-accent:#00DC82; --vtl-border:#262626; --vtl-elevated:#1f1f1f; --vtl-accented:#404040; --vtl-muted:#a3a3a3; }
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--vtl-border); background:#1f1f1f; }
.vtl-menu { display:none; font:16px/1 system-ui; color:#f5f5f5; background:#262626; border:1px solid #404040; border-radius:8px; padding:5px 10px; cursor:pointer; }
.vtl-menu:hover { background:#404040; }
(#92b195) accent, inline SVG icons. */
/* STRUCTURAL DISPLAY — the !important is deliberate, not a hack. This layout's display mode is
load-bearing: it CANNOT function as a block. As a library/ (high-bar) component it ships across
all 5 deployment methods, and a host (app-shell direct-mount, WordPress, mobile webview) may force
its own inline display onto the element. Declaring it non-overridable is the correct contract.
grid-template-rows: auto 1fr = header takes its natural height, .vtl-body gets a DEFINITE
remaining track — no dependence on in-flow children (nav/main are absolute for the rail slide). */
.vtl { position:absolute; inset:0; display:grid !important; grid-template-rows:auto 1fr; height:100%; width:100%; min-height:420px; background:#f3f3f1; color:#3b463d; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --vtl-accent:#92b195; --vtl-border:#d1d4cd; --vtl-elevated:#ffffff; --vtl-accented:#d1d4cd; --vtl-muted:#6d766e; --vtl-rail:256px; }
/* HEADER-OFF (header=false, e.g. the cap-5 doxa-maps-spa): the .vtl-top element is NOT rendered, so
.vtl-body becomes the ONLY grid child and auto-places into row 1 — the auto row — which has no
content to size it and collapses to 0 (the 1fr row sits empty) → the same 0-height black screen.
With no header there is only one region, so declare a single 1fr row and the body fills. */
.vtl.no-header { grid-template-rows:1fr; }
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--vtl-border); background:#ffffff; }
.vtl-menu { display:none; font:16px/1 system-ui; color:#3b463d; background:#d1d4cd; border:1px solid #d1d4cd; border-radius:8px; padding:5px 10px; cursor:pointer; }
.vtl-menu:hover { background:#d1d4cd; }
.vtl-brand { display:inline-flex; align-items:center; }
.vtl-brand:empty { display:none; }
.vtl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#f5f5f5; }
.vtl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#3b463d; }
.vtl-meta { color:var(--vtl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
.vtl-body { flex:1 1 auto; display:flex; min-height:0; }
.vtl-nav { flex:0 0 256px; overflow-y:auto; overflow-x:hidden; border-right:1px solid var(--vtl-border); padding:12px 12px 20px; background:#1f1f1f; scrollbar-width:thin; scrollbar-color:#404040 transparent; }
.vtl-nav::-webkit-scrollbar { width:8px; }
.vtl-nav::-webkit-scrollbar-thumb { background:#404040; border-radius:8px; }
/* GRID ROWS, not flex (BUG-EMBEDDED-PLUGIN-STUDIO-BLACK-SCREEN). The collapsible-rail design makes
.vtl-nav and .vtl-main position:absolute (so the rail slides by transform without reflowing the
heavy map sibling). That leaves .vtl-body with NO in-flow children → ZERO intrinsic height, so it
could only fill via flex:1 from a flex parent. A host that forces display:block on .vtl (the
app-shell direct-mounts cap-5 elements that way) made that flex inert → .vtl-body collapsed to 0
→ black screen. The auto/1fr grid on .vtl gives this row a DEFINITE height by construction, so
it no longer depends on the parent's display mode NOR on in-flow children. Header-off
(header=false) still works: the auto row collapses to 0 and 1fr takes the full height. */
.vtl-body { min-height:0; position:relative; display:flex; }
.vtl-nav { position:absolute; top:0; bottom:0; left:0; z-index:5; width:256px; box-sizing:border-box; overflow-y:auto; overflow-x:hidden; border-right:1px solid var(--vtl-border); padding:12px 12px 20px; background:#ffffff; scrollbar-width:thin; scrollbar-color:#c4c8c0 transparent; contain:layout paint; transform:translateX(0); transition:transform .2s cubic-bezier(.4,0,.2,1); will-change:transform; }
/* Thin OVERLAY scrollbar: transparent track so the bar costs no visual space and sits flush to
the nav edge; low-contrast thumb that darkens on hover. Matches the research-dashboard rails. */
.vtl-nav::-webkit-scrollbar { width:8px; height:8px; }
.vtl-nav::-webkit-scrollbar-track { background:transparent; }
.vtl-nav::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
.vtl-nav::-webkit-scrollbar-thumb:hover { background:#9aa197; }
.vtl-collapse { display:flex; align-items:center; gap:8px; width:100%; box-sizing:border-box; margin:0 0 8px; padding:7px 10px; border:1px solid var(--vtl-border); border-radius:8px; background:#d1d4cd; color:var(--vtl-muted); font:inherit; font-size:12px; cursor:pointer; }
.vtl-collapse:hover { background:var(--vtl-accented); color:#3b463d; }
.vtl-collapse:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
.vtl-collapse-ic { flex:0 0 auto; display:inline-flex; transition:transform .16s ease; transform:rotate(180deg); }
.vtl-collapse-ic svg { width:16px; height:16px; }
.vtl-collapse-tx { white-space:nowrap; }
.vtl-grp { margin-bottom:2px; }
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
@ -439,30 +641,62 @@ export default defineComponent({
.vtl-chev.open { transform:rotate(90deg); }
.vtl-rows { display:flex; flex-direction:column; gap:1px; }
.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 10px; cursor:pointer; border-radius:8px; font:inherit; margin:0; transition:background .13s ease,color .13s ease; }
.vtl-tab:hover { background:var(--vtl-accented); color:#f5f5f5; }
.vtl-tab:hover { background:var(--vtl-accented); color:#3b463d; }
.vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
.vtl-tab.active { background:var(--vtl-accented); color:#f5f5f5; font-weight:500; }
.vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; }
.vtl-tab.active .vtl-tab-ic { color:var(--vtl-accent); }
.vtl-tab-ic { flex:0 0 auto; display:inline-flex; color:var(--vtl-muted); }
.vtl-tab-ic svg { width:20px; height:20px; }
.vtl-tab-emoji { font-size:17px; line-height:1; flex:0 0 auto; }
.vtl-tab-fallback { display:none; flex:0 0 auto; width:20px; height:20px; align-items:center; justify-content:center; font-size:12px; font-weight:600; color:var(--vtl-muted); background:var(--vtl-accented); border-radius:6px; }
.vtl-tab.active .vtl-tab-fallback { color:var(--vtl-accent); }
.vtl-tab-text { display:flex; flex-direction:column; min-width:0; }
.vtl-tab-label { font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:6px; }
.vtl-tab-badge { font:600 9px/1 system-ui; letter-spacing:.3px; text-transform:uppercase; color:var(--vtl-accent); border:1px solid var(--vtl-accent); border-radius:999px; padding:2px 5px; }
.vtl-tab-sub { font-size:11px; color:#6b6b6b; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.vtl-tab-sub code { font-family:ui-monospace,Menlo,monospace; }
.vtl-tab.active .vtl-tab-sub { color:var(--vtl-muted); }
.vtl-main { flex:1 1 auto; position:relative; min-width:0; display:flex; flex-direction:column; }
.vtl-content { flex:1 1 auto; position:relative; min-height:0; }
.vtl-main { position:absolute; top:0; bottom:0; right:0; left:256px; min-width:0; display:flex; flex-direction:column; }
.vtl-content { flex:1 1 auto; position:relative; min-height:0; contain:layout paint; }
.vtl-host { position:absolute; inset:0; }
.vtl-host-slot { position:absolute; inset:0; }
.vtl-host-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#94a3b8; font-size:13px; padding:20px; }
.vtl-host-hint.err { color:#f87171; }
.vtl-host-hint small { color:#64748b; }
.vtl.rail-collapsed .vtl-nav { transform:translateX(-196px); }
.vtl.rail-collapsed .vtl-main { left:60px; }
.vtl.rail-collapsed .vtl-collapse { padding-left:196px; justify-content:flex-start; }
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(0deg); }
.vtl.rail-collapsed .vtl-collapse-tx { display:none; }
.vtl.rail-collapsed .vtl-tab { padding-left:200px; gap:0; }
.vtl.rail-collapsed .vtl-tab-text { display:none; }
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
.vtl.rail-collapsed .vtl-tab-fallback { display:inline-flex; }
.vtl.rail-collapsed .vtl-tier { padding-left:196px; }
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:none; }
.vtl.rail-collapsed .vtl-grp + .vtl-grp { margin-top:6px; padding-top:6px; }
@media (max-width: 760px) {
.vtl-menu { display:inline-block; }
.vtl-meta { display:none; }
.vtl-nav { position:absolute; z-index:20; top:0; bottom:0; left:0; box-shadow:0 0 28px rgba(0,0,0,.55); }
.vtl:not(.nav-open) .vtl-nav { display:none; }
/* MOBILE = the SAME composited-transform model, taken to its full-off-canvas extreme: a full-width
drawer that slides in/out via transform (never a layout property), so opening/closing the drawer
never reflows the content. Content is full-width (left:0) and stays put; the drawer floats over it. */
.vtl-nav { z-index:20; width:256px; box-shadow:0 0 28px rgba(0,0,0,.55); transform:translateX(-100%); }
.vtl.nav-open .vtl-nav { transform:translateX(0); }
.vtl-main { left:0; }
/* The desktop slim-rail collapse has no meaning on a phone — reconcileForWidth() clears railCollapsed
at this width, but neutralize its CSS too (belt-and-suspenders) so a full labelled drawer always
shows: no partial slide, no icon-column padding, labels/sections visible. */
.vtl.rail-collapsed .vtl-nav { transform:translateX(-100%); }
.vtl.rail-collapsed.nav-open .vtl-nav { transform:translateX(0); }
.vtl.rail-collapsed .vtl-main { left:0; }
.vtl.rail-collapsed .vtl-collapse { padding-left:10px; justify-content:flex-start; }
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(180deg); }
.vtl.rail-collapsed .vtl-collapse-tx { display:inline; }
.vtl.rail-collapsed .vtl-tab { padding-left:10px; gap:11px; }
.vtl.rail-collapsed .vtl-tab-text { display:flex; }
.vtl.rail-collapsed .vtl-tab-fallback { display:none; }
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:inline; }
}
</style>

View file

@ -1,5 +1,5 @@
{
"_comment": "Preconfigured instances of the composite <bp-big-picture>. provider:'maplibre' flags each generated standalone page as TOKEN-FREE (see internal/generate-tree.mjs) so the element mounts INSTANTLY with no Mapbox token ladder — correct for this self-contained, mapless Cat-4 dashboard. generate-tree stamps the bundle's HEADLINE tag (bp-big-picture) on every instance page, so all preset instances render the COMPOSITE. The INNER elements (bp-research-header, bp-groups-table) are individually embeddable via their own registered tags + SRI script (see manifest.elements and the staging index.html section 2, which wires them standalone through the DOM).",
"_comment": "Preconfigured instances of the composite <bp-big-picture>. provider:'maplibre' flags each generated standalone page as TOKEN-FREE (see internal/generate-tree.mjs) so the element mounts INSTANTLY with no Mapbox token ladder — correct for this self-contained, mapless Cat-4 dashboard. generate-tree stamps the bundle's HEADLINE tag (bp-big-picture) on every instance page, so all preset instances render the COMPOSITE. The INNER elements (bp-research-header + the REAL cap-2 people-groups-grid) are individually embeddable via their own registered tags + SRI script (see manifest.elements and the staging index.html section 2, which wires them standalone through the DOM).",
"instances": [
{
"id": "big-picture",

View file

@ -108,15 +108,23 @@ function onGroupsFiltered(payload) {
/>
</div>
<!-- User-facing footer: an unreached-people-groups research dashboard. The Cap-4
architecture note (header↔grid via props/events, no shared bus, tab-1 of the SPA)
lives in this file's header block, not in the UI where an end user reads it. -->
<footer class="bp-foot">
Cap-4 nested-element plugin · header↔grid via props/events (no shared bus) · tab-1 of the SPA
Search and filter the world's people groups · least-reached shown first
</footer>
</div>
</template>
<style>
/* Self-size regardless of how a parent mounts us: a host that isn't a sized block makes
.bp-root's height:100% resolve against nothing → 0-height collapse (the black-screen class).
Declaring :host here means this element fills its slot even if the mounter forces no display
(or the wrong one). See BUG-EMBEDDED-PLUGIN-STUDIO-BLACK-SCREEN-DISPLAY-BLOCK. */
:host { display: block; width: 100%; height: 100%; }
.bp-root {
--bg: #0b0f14; --line: #1f2937; --ink: #e5e7eb; --mut: #94a3b8;
--bg: #f3f3f1; --line: #d1d4cd; --ink: #3b463d; --mut: #6d766e;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 480px; overflow: hidden;
background: var(--bg); color: var(--ink); box-sizing: border-box;

View file

@ -1,169 +0,0 @@
<script setup>
/**
* BpGroupsTable.vue — the Cat-2 people-groups PROP-TABLE (nested element of Big Picture).
*
* This is the "people-groups grid + its OWN search bar + dropdowns" from the research page,
* lifted into a self-contained, individually-embeddable custom element (<bp-groups-table>).
*
* The Cat-2 contract (the whole point of this element):
* - By DEFAULT it owns its internal search box + region/status dropdowns and filters
* itself — drop it on any page and it just works standalone.
* - Props can TURN OFF those internal controls so it is driven EXTERNALLY:
* show-search="false" hides the internal search box
* show-filters="false" hides the internal region/status dropdowns
* …and then the parent (the Big Picture dashboard header, or any host page) feeds it
* the `query` / `region` / `status` props instead. One element, two drive modes.
* - It EMITS `groups-filtered` (detail = { query, region, status, count, slugs }) every
* time the visible set changes, so an external driver can reflect the count/selection.
* Emitted BOTH as a Vue emit AND as a native bubbling+composed DOM CustomEvent, so a
* plain host page hears it when the element is embedded standalone.
*
* Precedence of a filter value = internal control (when shown) ELSE the incoming prop.
* So turning a control off cedes that axis to the prop; leaving it on lets the user drive it.
*/
import { computed, ref, watch, getCurrentInstance } from 'vue'
import {
PEOPLE_GROUPS, REGIONS, STATUSES,
regionName, statusName, statusOf, filterGroups,
} from '../data/people-groups.js'
const props = defineProps({
// externally-driven filter axes (used when the matching internal control is hidden).
query: { type: String, default: '' },
region: { type: String, default: '' },
status: { type: String, default: '' },
// toggles for the element's OWN controls. Accept string 'false' too (DOM attributes).
showSearch: { type: [Boolean, String], default: true },
showFilters: { type: [Boolean, String], default: true },
})
const emit = defineEmits(['groups-filtered'])
const truthy = (v) => !(v === false || v === 'false' || v === '0' || v === 0)
const useSearch = computed(() => truthy(props.showSearch))
const useFilters = computed(() => truthy(props.showFilters))
// internal control state (only authoritative when the control is shown).
const localQuery = ref('')
const localRegion = ref('')
const localStatus = ref('')
// effective filter = internal control (if shown) else the prop.
const effQuery = computed(() => (useSearch.value ? localQuery.value : (props.query || '')))
const effRegion = computed(() => (useFilters.value ? localRegion.value : (props.region || '')))
const effStatus = computed(() => (useFilters.value ? localStatus.value : (props.status || '')))
const rows = computed(() =>
filterGroups({ query: effQuery.value, region: effRegion.value, status: effStatus.value })
.slice()
.sort((a, b) => a.reached - b.reached) // least-reached first
)
// ── emit `groups-filtered` whenever the visible set changes (Vue + DOM) ──────────
const inst = getCurrentInstance()
function hostEl() {
const root = inst?.vnode?.el || inst?.proxy?.$el
try { const rn = root && root.getRootNode && root.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {}
return root
}
watch(rows, (r) => {
const detail = {
query: effQuery.value, region: effRegion.value, status: effStatus.value,
count: r.length, slugs: r.map((g) => g.slug),
}
emit('groups-filtered', detail)
const el = hostEl()
if (el && typeof el.dispatchEvent === 'function') {
el.dispatchEvent(new CustomEvent('groups-filtered', { detail, bubbles: true, composed: true }))
}
}, { immediate: true })
const fmtPop = (n) => {
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return (n / 1e3).toFixed(0) + 'K'
return String(n)
}
const total = PEOPLE_GROUPS.length
</script>
<template>
<div class="gt-root">
<!-- The element's OWN controls: shown by default, hidden when driven externally. -->
<div v-if="useSearch || useFilters" class="gt-controls">
<div v-if="useSearch" class="gt-search">
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="16.5" y1="16.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<input v-model="localQuery" type="search" placeholder="Search people groups…"
aria-label="Search people groups" />
</div>
<template v-if="useFilters">
<select v-model="localRegion" aria-label="Filter by region">
<option value="">All regions</option>
<option v-for="r in REGIONS" :key="r.id" :value="r.id">{{ r.name }}</option>
</select>
<select v-model="localStatus" aria-label="Filter by reach status">
<option value="">All status</option>
<option v-for="s in STATUSES" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</template>
</div>
<div class="gt-summary">
<strong>{{ rows.length }}</strong> / {{ total }} people groups
<span v-if="effRegion"> · {{ regionName(effRegion) }}</span>
<span v-if="effStatus"> · {{ statusName(effStatus) }}</span>
<span v-if="effQuery"> · “{{ effQuery }}”</span>
</div>
<div class="gt-tablewrap">
<table class="gt-table">
<thead>
<tr><th>People group</th><th>Country</th><th>Region</th><th class="num">Population</th><th class="num">Reached</th></tr>
</thead>
<tbody>
<tr v-for="pg in rows" :key="pg.slug">
<td><span class="gt-dot" :class="statusOf(pg.reached)" aria-hidden="true"></span>{{ pg.name }}</td>
<td>{{ pg.country }}</td>
<td>{{ regionName(pg.region) }}</td>
<td class="num">{{ fmtPop(pg.population) }}</td>
<td class="num" :class="statusOf(pg.reached)">{{ pg.reached }}%</td>
</tr>
<tr v-if="!rows.length"><td colspan="5" class="gt-empty">No people groups match this filter.</td></tr>
</tbody>
</table>
</div>
</div>
</template>
<style>
.gt-root { width: 100%; height: 100%; display: flex; flex-direction: column;
background: #0b0f14; color: #e5e7eb;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; box-sizing: border-box; }
.gt-controls { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 12px;
border-bottom: 1px solid #1f2937; }
.gt-search { position: relative; display: flex; align-items: center; flex: 1; min-width: 180px; color: #94a3b8; }
.gt-search svg { position: absolute; left: 9px; pointer-events: none; }
.gt-search input { width: 100%; padding: 7px 10px 7px 30px; background: #111827;
border: 1px solid #1f2937; border-radius: 8px; color: #e5e7eb; font-size: 13px; outline: none; }
.gt-search input:focus { border-color: #8b5cf6; }
.gt-controls select { padding: 7px 10px; background: #111827; border: 1px solid #1f2937;
border-radius: 8px; color: #e5e7eb; font-size: 13px; outline: none; }
.gt-controls select:focus { border-color: #8b5cf6; }
.gt-summary { padding: 8px 12px; font-size: 12px; color: #94a3b8; border-bottom: 1px solid #1f2937; }
.gt-summary strong { color: #8b5cf6; }
.gt-tablewrap { flex: 1; min-height: 0; overflow: auto; }
.gt-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.gt-table thead th { position: sticky; top: 0; z-index: 1; text-align: left;
padding: 8px 12px; background: #111827; color: #94a3b8; font: 700 11px/1 ui-monospace, monospace;
letter-spacing: .06em; text-transform: uppercase; border-bottom: 1px solid #1f2937; }
.gt-table th.num, .gt-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
.gt-table tbody td { padding: 8px 12px; border-bottom: 1px solid #141c28; }
.gt-table tbody tr:hover { background: #111827; }
.gt-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 8px; vertical-align: middle; }
.gt-dot.frontier, td.frontier { color: #ef4444; } .gt-dot.frontier { background: #ef4444; }
.gt-dot.unreached, td.unreached { color: #f59e0b; } .gt-dot.unreached { background: #f59e0b; }
.gt-dot.reached, td.reached { color: #10b981; } .gt-dot.reached { background: #10b981; }
.gt-empty { padding: 20px; text-align: center; color: #64748b; }
</style>

View file

@ -133,11 +133,18 @@ onMounted(loadOptions)
</script>
<template>
<div class="rh-root" :style="{ '--accent': accent }">
<div class="rh-root" role="banner" :style="{ '--accent': accent }">
<div class="rh-title">
<span class="rh-dot" aria-hidden="true"></span>
<h1>{{ title }}</h1>
<span v-if="count !== null && count !== ''" class="rh-count">{{ count }} shown</span>
<!-- aria-live=polite: a screen reader hears the live matched count change as the user
searches/filters ("15 shown"). role=status keeps it an unobtrusive announcement. -->
<span
v-if="count !== null && count !== ''"
class="rh-count"
role="status"
aria-live="polite"
>{{ count }} shown</span>
</div>
<!-- SHARED SearchBar on top (replaces the hand-rolled <input type="search">). -->
@ -158,7 +165,7 @@ onMounted(loadOptions)
@filter-change="onFilterChange" @filter-clear="onFilterClear"
/>
<FilterDropdown
label="Block" name="wagf_block"
label="Bloc" name="wagf_block"
:options="options.wagf_block" :value="sel.wagf_block"
@filter-change="onFilterChange" @filter-clear="onFilterClear"
/>
@ -172,7 +179,13 @@ onMounted(loadOptions)
:options="options.religion" :value="sel.religion"
@filter-change="onFilterChange" @filter-clear="onFilterClear"
/>
<button v-if="hasAny" class="rh-reset" type="button" @click="reset">Reset</button>
<button
v-if="hasAny"
class="rh-reset"
type="button"
aria-label="Reset all filters and search"
@click="reset"
>Reset</button>
</div>
</div>
</template>

View file

@ -1,81 +0,0 @@
/**
* people-groups.js — the Big Picture dataset (self-contained, token-free).
*
* Shared by BOTH inner elements of the "Big Picture" Cat-4 plugin:
* - BpGroupsTable.vue (the Cat-2 people-groups PROP-TABLE) renders + filters this.
* - BpResearchHeader.vue (the Cat-1 search/dropdown dashboard header) builds its
* dropdown option lists (regions, reach-status) from this same catalogue.
*
* `region` + `status` are the join keys the header's dropdowns filter on; the header
* pushes {query, region, status} DOWN to the table as props (or a host page drives the
* table's own attributes directly). No remote fetch, no map token — renders instantly.
*/
export const REGIONS = [
{ id: 'na', name: 'North America' },
{ id: 'sa', name: 'South America' },
{ id: 'eu', name: 'Europe' },
{ id: 'af', name: 'Africa' },
{ id: 'me', name: 'Middle East' },
{ id: 'ca', name: 'Central Asia' },
{ id: 'sas', name: 'South Asia' },
{ id: 'sea', name: 'Southeast Asia' },
{ id: 'ea', name: 'East Asia' },
]
// reach-status buckets (the second dropdown facet). `reached` is % believers proxy.
export const STATUSES = [
{ id: 'frontier', name: 'Frontier (<0.1%)' },
{ id: 'unreached', name: 'Unreached (0.1–2%)' },
{ id: 'reached', name: 'Reached (>2%)' },
]
export function statusOf(reached) {
return reached < 0.1 ? 'frontier' : reached < 2 ? 'unreached' : 'reached'
}
export const PEOPLE_GROUPS = [
{ slug: 'hui', name: 'Hui', country: 'China', region: 'ea', population: 11700000, reached: 0.1, jp: 3.1 },
{ slug: 'uyghur', name: 'Uyghur', country: 'China', region: 'ca', population: 11500000, reached: 0.05, jp: 3.2 },
{ slug: 'yadava', name: 'Yadava', country: 'India', region: 'sas', population: 60000000, reached: 0.2, jp: 3.0 },
{ slug: 'shaikh', name: 'Shaikh', country: 'India', region: 'sas', population: 96000000, reached: 0.01, jp: 3.4 },
{ slug: 'brahmin', name: 'Brahmin', country: 'India', region: 'sas', population: 58000000, reached: 0.3, jp: 2.8 },
{ slug: 'bengali', name: 'Bengali', country: 'Bangladesh', region: 'sas', population: 148000000, reached: 0.4, jp: 2.9 },
{ slug: 'javanese', name: 'Javanese', country: 'Indonesia', region: 'sea', population: 96000000, reached: 1.2, jp: 2.6 },
{ slug: 'sundanese', name: 'Sundanese', country: 'Indonesia', region: 'sea', population: 38000000, reached: 0.1, jp: 3.2 },
{ slug: 'thai', name: 'Central Thai', country: 'Thailand', region: 'sea', population: 22000000, reached: 0.5, jp: 2.8 },
{ slug: 'persian', name: 'Persian', country: 'Iran', region: 'me', population: 39000000, reached: 0.4, jp: 2.9 },
{ slug: 'arab-najdi', name: 'Najdi Arab', country: 'Saudi Arabia', region: 'me', population: 11000000, reached: 0.02, jp: 3.4 },
{ slug: 'kurd', name: 'Kurd', country: 'Iraq', region: 'me', population: 8500000, reached: 0.1, jp: 3.1 },
{ slug: 'turk', name: 'Turk', country: 'Turkey', region: 'me', population: 59000000, reached: 0.2, jp: 3.0 },
{ slug: 'kazakh', name: 'Kazakh', country: 'Kazakhstan', region: 'ca', population: 13000000, reached: 0.3, jp: 2.9 },
{ slug: 'uzbek', name: 'Uzbek', country: 'Uzbekistan', region: 'ca', population: 30000000, reached: 0.1, jp: 3.2 },
{ slug: 'somali', name: 'Somali', country: 'Somalia', region: 'af', population: 17000000, reached: 0.05, jp: 3.3 },
{ slug: 'fulani', name: 'Fulani', country: 'Nigeria', region: 'af', population: 38000000, reached: 0.2, jp: 3.1 },
{ slug: 'hausa', name: 'Hausa', country: 'Nigeria', region: 'af', population: 65000000, reached: 0.3, jp: 3.0 },
{ slug: 'berber', name: 'Shilha Berber', country: 'Morocco', region: 'af', population: 9000000, reached: 0.1, jp: 3.1 },
{ slug: 'wolof', name: 'Wolof', country: 'Senegal', region: 'af', population: 6500000, reached: 0.1, jp: 3.2 },
{ slug: 'tibetan', name: 'Tibetan', country: 'China', region: 'ea', population: 6300000, reached: 0.2, jp: 3.1 },
{ slug: 'han-chinese',name: 'Han (Mandarin)', country: 'China', region: 'ea', population: 900000000, reached: 5.1, jp: 2.1 },
{ slug: 'quechua', name: 'Quechua', country: 'Peru', region: 'sa', population: 8000000, reached: 3.0, jp: 2.3 },
{ slug: 'roma', name: 'Roma', country: 'Romania', region: 'eu', population: 3000000, reached: 1.0, jp: 2.6 },
{ slug: 'bosniak', name: 'Bosniak', country: 'Bosnia', region: 'eu', population: 2500000, reached: 0.4, jp: 2.9 },
{ slug: 'navajo', name: 'Navajo', country: 'USA', region: 'na', population: 330000, reached: 4.0, jp: 2.2 },
]
export const regionName = (id) => (REGIONS.find(r => r.id === id) || {}).name || id
export const statusName = (id) => (STATUSES.find(s => s.id === id) || {}).name || id
/**
* The ONE shared filter predicate. Both the header's live-count and the table use it, so
* the count the header shows always matches what the table renders. Pure — no state.
*/
export function filterGroups({ query = '', region = '', status = '' } = {}) {
const q = (query || '').trim().toLowerCase()
return PEOPLE_GROUPS.filter((pg) => {
if (region && pg.region !== region) return false
if (status && statusOf(pg.reached) !== status) return false
if (q && !(`${pg.name} ${pg.country}`.toLowerCase().includes(q))) return false
return true
})
}

View file

@ -46,6 +46,10 @@
<script>
window.UPG_TOKEN = (new URLSearchParams(location.search).get('tk')||(typeof window!=='undefined'&&window.MAPBOX_TOKEN)||'');
// Inherited language: the SPA/studio nests this leaf as an iframe and passes the chosen language
// as ?locale=. ProfileLoader reads profile-config.locale and drives the bundle's i18n. Empty →
// the leaf keeps its own document.documentElement.lang detection.
window.DOXA_LOCALE = (new URLSearchParams(location.search).get('locale') || '');
</script>
<div class="stage-head">
@ -69,11 +73,11 @@
slot.innerHTML = '';
slot.classList.toggle('is-wall', profile === 'connectivity-list');
var el = document.createElement('cities-internet-map');
el.setAttribute('profile-config', JSON.stringify({
el.setAttribute('profile-config', JSON.stringify(Object.assign({
profile: profile,
tk: window.UPG_TOKEN || '',
assetsBase: '/assets',
}));
}, window.DOXA_LOCALE ? { locale: window.DOXA_LOCALE } : {})));
slot.appendChild(el);
}
document.querySelectorAll('.tab').forEach(function (b) {

View file

@ -46,6 +46,10 @@
<script>
window.UPG_TOKEN = (new URLSearchParams(location.search).get('tk')||(typeof window!=='undefined'&&window.MAPBOX_TOKEN)||'');
// Inherited language: the SPA/studio nests this leaf as an iframe and passes the chosen language
// as ?locale=. ProfileLoader reads profile-config.locale and drives the bundle's i18n. Empty →
// the leaf keeps its own document.documentElement.lang detection.
window.DOXA_LOCALE = (new URLSearchParams(location.search).get('locale') || '');
</script>
<div class="stage-head">
@ -69,11 +73,11 @@
slot.innerHTML = '';
slot.classList.toggle('is-wall', profile === 'country-poster-wall');
var el = document.createElement('country-posters-map');
el.setAttribute('profile-config', JSON.stringify({
el.setAttribute('profile-config', JSON.stringify(Object.assign({
profile: profile,
tk: window.UPG_TOKEN || '',
assetsBase: '/assets',
}));
}, window.DOXA_LOCALE ? { locale: window.DOXA_LOCALE } : {})));
slot.appendChild(el);
}
document.querySelectorAll('.tab').forEach(function (b) {

View file

@ -0,0 +1 @@
{ "_comment": "One default instance.", "instances": [ { "id": "default", "label": "Dashboard + Grid", "profileConfig": {} } ] }

View file

@ -0,0 +1,6 @@
{
"_comment": "Capability-4 composite that BAKES the research-dashboard + people-groups-grid into ONE self-contained element (both components imported directly + compiled into a single bundle, NO runtime bundle injection — unlike stack-demo which composes via src). Rendered stacked via the shared StackLayout so you scroll through both. This is the 'two elements baked into one' element the Driver asked for.",
"type": "nested-elements-mfe",
"composes": ["research-dashboard", "people-groups-grid"],
"label": "Dashboard + Grid (baked into one element, stacked to scroll)"
}

View file

@ -0,0 +1,76 @@
<script setup>
/**
* dashboard-plus-grid.vue — Capability-4 composite: research-dashboard + people-groups-grid
* BAKED INTO ONE self-contained element, stacked to scroll.
*
* @element dashboard-plus-grid
* @description Both children are IMPORTED DIRECTLY (compiled into this ONE bundle — no runtime bundle
* injection, unlike stack-demo which pulls them via `src`). They render STACKED top-to-bottom via the
* shared StackLayout, so the host page scrolls through the rich Research Dashboard (facets + map +
* charts + table) and then the People Groups grid (live pray.doxa.life) as one continuous element.
* This is the "two elements baked together into one" element — embed <dashboard-plus-grid> anywhere.
*
* Same "import the REAL components, no fork" discipline as big-picture: the grid is the exact
* people-groups-grid component; the dashboard is the exact research-dashboard component.
*/
import StackLayout from '@map/layouts/StackLayout.vue'
import ResearchDashboard from '../research-dashboard/research-dashboard.vue'
import PeopleGroupsGrid from '../people-groups-grid/people-groups-grid.vue'
// One-screen height for the fixed-viewport research-dashboard band (see the section note below). Pinning
// min === max gives the dashboard a DEFINITE height to fill (so its `1fr` map-hero + `height:100%` shell
// resolve) while the OUTER page keeps growing below it → the grid is reachable by page scroll. `svh`
// (small viewport height) avoids the mobile URL-bar jump; ~88% leaves the StackLayout top bar visible.
const DASHBOARD_H = '88svh'
// Two host-rendered sections (no `element`/`src` — the components are baked in, so we render them
// through StackLayout's per-section slots instead of the imperative element-mount path).
const sections = [
// The research-dashboard leaf SELF-TITLES (its own DashboardTopBar renders a "Research Dashboard"
// title bar), so a StackLayout section title here would be a DUPLICATE — the Driver saw "Research
// Dashboard" twice. Omit title (and sub, which StackLayout only shows alongside a title) → the leaf's
// own header is the single source of truth for this section. See BUG-RESEARCH-DASHBOARD-HEADER-STACK-WRONG.
//
// BUG-DASHBOARD-CANT-SCROLL-DOWN: the research-dashboard is a FIXED-VIEWPORT app — its shell is a CSS
// grid with `height:100%` and a `1fr` map-hero plus inner-scroll (overflow:auto/hidden) regions. When
// it renders in a scroll-page section with NO definite height, `height:100%` collapses to its 560px
// floor and its inner-scroll regions swallow the wheel, so the OUTER page never grows past it → the
// Driver couldn't scroll down to the grid. Fix: give this section a definite ONE-SCREEN height
// (minHeight === maxHeight below via the composite's `dashboardHeight`). The `1fr` map now has real
// height to fill, the dashboard is one clean scroll-page band, and the PAGE grows below it so the grid
// is reachable by page scroll. `overflow:hidden` on the section keeps the dashboard's own inner scroll
// (map/table/facets) from leaking a section scrollbar — the page owns the scroll, per the StackLayout
// contract.
{ key: 'dashboard', minHeight: DASHBOARD_H, maxHeight: DASHBOARD_H },
// NO section title/sub (BUILD-BIGPIC-DROP-REDUNDANT-SECTION-HEADERS). The Driver does not want a
// black element header that repeats the element's name or tells them to scroll — one header per
// element, not two. StackLayout only renders .stl-sec-head when `title` is set, so omitting it
// removes the strip entirely. The dashboard self-titles; the grid needs no label to be legible.
{ key: 'grid' }
]
</script>
<template>
<!-- No `title` and no #meta: the layout's own top bar is omitted entirely when neither is used,
so the composite shows ONLY the nested elements' own chrome. -->
<StackLayout :sections="sections">
<template #section-dashboard>
<ResearchDashboard />
</template>
<template #section-grid>
<!-- NO explainer line here. An earlier attempt added one as its own <p>, which the Driver
correctly rejected: it created a SECOND header and re-introduced the vertical gap between
the dashboard and the grid that we had just removed. The hint belongs INSIDE the table's
existing header bar — centred, beside the live count — so it costs zero extra height.
See DashboardDataTable's .rd-table-bar. -->
<!-- The grid is DRIVEN by the research dashboard above it: the dashboard's header owns the
search + filters, so the grid hides its own duplicates and takes external filter props.
(BUILD-BIGPIC-GRID-DRIVEN-BY-DASHBOARD-PROPS-EVENTS.) -->
<PeopleGroupsGrid :show-search="false" :show-filters="false" />
</template>
</StackLayout>
</template>
<style>
:host { display:block; width:100%; }
</style>

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doxa-maps · dashboard-plus-grid staging</title>
<link rel="icon" href="data:,">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { background: #0b0f14; color: #e5e7eb; min-height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 28px 20px 48px; }
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.sub { color: #9ca3af; font-size: 13px; margin: 6px 0 24px; max-width: 820px; }
.sub code { color: #c4b5fd; }
.host { border: 1px solid #1f2937; border-radius: 12px; overflow: hidden; }
dashboard-plus-grid { display: block; width: 100%; }
</style>
</head>
<body>
<div class="wrap">
<h1>dashboard-plus-grid · staging</h1>
<p class="sub">Capability&nbsp;4 — the research-dashboard + people-groups-grid <strong>baked into ONE
element</strong> (both components compiled into this single bundle, no runtime injection) and stacked
via the shared <code>StackLayout</code>. Scroll down through both. Embed
<code>&lt;dashboard-plus-grid&gt;</code> anywhere.</p>
<div class="host">
<dashboard-plus-grid></dashboard-plus-grid>
</div>
</div>
<!-- The GENERATED leaf pages set window.MAP_TOKEN from the baked build-config token (see
generate-tree.mjs) so a baked composite's nested map finds a token. On this hand-written staging
page (which the generator doesn't touch), a real host embedding <dashboard-plus-grid> would set
window.MAP_TOKEN itself; the people-groups grid renders regardless (it needs no map token). -->
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,51 @@
/**
* dashboard-plus-grid / index.js — Bundle entry (Capability-4 nested-elements-mfe, BAKED variant).
*
* ONE folder -> ONE bundle -> ONE self-contained element:
* <dashboard-plus-grid> — bakes the research-dashboard + people-groups-grid components DIRECTLY into
* one compiled bundle (no runtime bundle injection) and stacks them via the shared StackLayout so the
* host page scrolls through both. This is "two elements baked together into one."
*
* Usage — direct-JS (method 3):
* <script src=".../dashboard-plus-grid/dashboard-plus-grid.js"></script>
* <dashboard-plus-grid></dashboard-plus-grid>
*/
import { defineCustomElement } from 'vue'
import { createPinia } from 'pinia'
import { createAppI18n } from '@map/i18n/index.js'
import DashboardPlusGrid from './dashboard-plus-grid.vue'
// ─── configureApp is NOT optional for a BAKED composite ──────────────────────
// BUG-VUE-I18N-CODE27-SYNTAXERROR-ON-EVERY-MOUNT. dashboard-plus-grid.vue imports
// research-dashboard.vue DIRECTLY, which bypasses plugin-designs/research-dashboard/index.js
// — and that entry, not the .vue, is where `app.use(createAppI18n())` lives. So the baked
// library components that call useI18n() (ShareButton, ZoomInButton, ZoomOutButton) landed in
// an appContext with no __VUE_I18N_SYMBOL__ and useI18n() threw I18nErrorCodes.NOT_INSTALLED
// (runtime code 27 — "Need to install with `app.use` function").
//
// The throw surfaces as a bare `SyntaxError` with an EMPTY message, which is badly misleading:
// vue-i18n builds every error through the message-compiler's createCompileError(), which does
// `new SyntaxError(...)`, and in a production build it passes no message table at all. Nothing
// is being parsed and nothing is being minified — read the CODE, not the error class.
//
// It was never cosmetic: setup() threw, so Vue aborted those three subtrees and the composite
// silently shipped WITHOUT its Share / Zoom in / Zoom out map controls, which cap-3 has.
//
// Installing the same Pinia + i18n pair the sibling entries install makes the baked children
// see exactly the appContext they would get when mounted through their own bundle entry.
const DashboardPlusGridElement = defineCustomElement(DashboardPlusGrid, {
configureApp(app) {
app.use(createPinia())
const i18n = createAppI18n()
app.use(i18n)
// Same contract as the map bundles: the defineCustomElement ROOT cannot call useI18n()
// (see ProfileLoader.vue), so any root-level locale driving goes through this instance.
app.provide('appI18n', i18n)
}
})
if (typeof customElements !== 'undefined') {
if (!customElements.get('dashboard-plus-grid')) customElements.define('dashboard-plus-grid', DashboardPlusGridElement)
}
export default DashboardPlusGridElement

View file

@ -40,6 +40,7 @@
<VerticalTabsLayout
:title="title"
nav-label="Research maps"
:header="showHeader"
:groups="navGroups"
:active="activeKey"
@update:active="onSelect">
@ -48,21 +49,24 @@
external asset, offline/F-Droid safe; the accent green matches the admin shell). -->
<template #brand>
<span class="dms-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#00DC82" stroke-width="2"
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#92b195" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" />
<polygon points="12 6 14 12 12 18 10 12" fill="#00DC82" stroke="none" />
<polygon points="6 12 12 10 18 12 12 14" fill="#00DC82" opacity="0.55" stroke="none" />
<polygon points="12 6 14 12 12 18 10 12" fill="#92b195" stroke="none" />
<polygon points="6 12 12 10 18 12 12 14" fill="#92b195" opacity="0.55" stroke="none" />
</svg>
</span>
</template>
<template #meta>{{ tabs.length }} maps</template>
<!-- #toolbar: the i18n locale switcher (3-dots/flag). Reuses app-wrapper-material/languages.js —
renders the flag + native name, persists the choice (loadLocale/saveLocale), and re-sets the
mounted tab-1 element's profile-config.locale (languages.js's contract). No routing, no dep. -->
<template #toolbar>
<!-- #toolbar: the i18n locale switcher (3-dots/flag). GATED by showLocaleSwitcher — the cap-5
DX-Maps SPA has NO standalone language dropdown by default; it INHERITS its language from the
parent page via the `language` prop (see effectiveLocale). The dropdown only shows when the
header is ON (a host explicitly opts the SPA into its own chrome). Reuses
app-wrapper-material/languages.js — persists the choice + re-sets the mounted tab-1 element's
profile-config.locale (languages.js's contract). No routing, no dep. -->
<template #toolbar v-if="showLocaleSwitcher">
<div class="dms-locale">
<button type="button" class="dms-locale-btn" :aria-expanded="String(localeOpen)"
aria-label="Change language" @click="localeOpen = !localeOpen">
@ -128,32 +132,84 @@ import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/la
// exist in this SPA are OMITTED (never paint a dead nav row — nav.js's own navForTarget prunes empty
// groups the same way). `folder:true` (Experimental) renders collapsible, starting closed.
const NAV_MODEL = [
// Dashboards — Big Picture stays the FIRST/default tab (its dashboard→grid coupling is the landing
// surface). Matches nav.js's `dashboards` section; kept top so tab-1 remains Big Picture.
{ key: 'dashboards', label: 'Dashboards', icon: 'monitor',
tabs: [ { id: 'big-picture', icon: 'chart' } ] },
// ── NOTE: there is deliberately NO separate "Dashboards" section. The ONE dashboard (the Driver's
// ruling: "we don't need multiple dashboards") is `dashboard-plus-grid` — the coupled
// research-dashboard + people-groups-grid on a single scroll-page, what the Driver calls the Big
// Picture. It is the FIRST row of Prayer Mobilization below, which makes it tab-1/the landing
// surface AND the opening step of the onboarding ramp.
//
// WHY IT IS NOT ALSO ITS OWN SECTION: it used to be, and that is exactly what painted "Big
// Picture" TWICE in the rail (a one-row "Dashboards" section whose single row was re-declared as
// the ramp's step 1). Two rows sharing one tab id is not merely cosmetic — the layout keys its
// tab-ref map and its `active` class BY ID, so both copies highlight at once and keyboard nav
// stalls on the repeated key. One section owning the row fixes all of that and matches the
// Driver's stated order (Big Picture → UPG-100 → Prayer Progress → Gospel Resources → Research).
//
// Prayer Mobilization FIRST among the map sections (the prayer map is the mobilisation landing).
//
// ORDER IS THE DRIVER'S, AND THE ORDER IS THE TEACHING. Big Picture → UPG-100 List → Prayer
// Progress → Gospel Resources. The non-obvious step is UPG-100 BEFORE the prayer map, and his
// rationale is worth preserving verbatim: "if you're trying to mobilize prayer, UPG-100 list
// probably comes first because it's a NEW thing to people — you want them introduced to it
// first." i.e. the rail is an ONBOARDING RAMP, not an inventory: show the big picture, hand the
// newcomer the concrete new artifact (the 100 list), THEN ask them to pray it, then give them
// the resources to act. Re-sorting these rows by any other logic (alphabetical, "maps first",
// most-used) silently breaks that ramp — so do not reorder without the Driver.
{ key: 'prayer-mobilization', label: 'Prayer Mobilization', icon: 'heart',
tabs: [
{ id: 'prayer-map', icon: 'embed' }, // Prayer Progress = the world prayer map
{ id: 'upg-100', icon: 'list' }, // My UPG 100 List
{ id: 'dashboard-plus-grid', icon: 'chart' }, // 1. Big Picture — orient first
{ id: 'upg-100', icon: 'list' }, // 2. UPG-100 List — the NEW thing, introduced early
{ id: 'prayer-map', icon: 'embed' }, // 3. Prayer Progress — now pray the list
{ id: 'gospel-resources', icon: 'book' }, // 4. Gospel Resources — then act
] },
// Research — a peer section (nav.js: "a section to the same degree as Laborer").
// Research — a peer section (nav.js: "a section to the same degree as Laborer"). The Driver's
// order: Cities & Internet FIRST, then Gospel Resources, then the simple research app at the
// bottom.
//
// ★ Gospel Resources is DECLARED here as well as in the prayer ramp above, on purpose — it is the
// "then act" step of that ramp AND a research surface in its own right. It is declared twice but
// RENDERED ONCE: dedupeRows() (see below) gives the FIRST declaration the row and drops this
// later one, so the rail holds exactly one node per tab id. Leaving the declaration here is not
// dead weight — it documents the intent, and if the prayer section is ever re-cut this section
// inherits the row automatically.
{ key: 'research', label: 'Research', icon: 'search',
tabs: [
{ id: 'research-map', icon: 'map' },
{ id: 'cities-internet', icon: 'wifi' },
{ id: 'gospel-resources', icon: 'book' },
{ id: 'research-map', icon: 'map' }, // the simple research app, at the bottom
] },
// Experimental — a collapsible FOLDER at the bottom (nav.js: kind 'folder', open:false). Holds the
// real lab maps (built bundles with their own leaf pages). Starts CLOSED; click the heading to open.
{ key: 'experimental', label: 'Experimental', icon: 'flask', folder: true, open: false,
tabs: [
{ id: 'all-people-groups', icon: 'globe' },
{ id: 'country-posters', icon: 'poster', badge: 'lab' },
] },
// Experimental — a collapsible FOLDER at the bottom (nav.js: kind 'folder', open:false). Starts
// CLOSED; click the heading to open.
//
// ★ GENERIC BY CONSTRUCTION — no hardcoded row list. Its `tabs` is deliberately EMPTY here and is
// filled at runtime by discoverExperimental() from the BUILT manifest.json: every bundle that
// exists in the tree but is not placed in a named section above lands here automatically. Add a
// new plugin to plugin-designs/ and it appears in this folder with ZERO edits to this file.
// WHY this matters: a hardcoded list fails SILENTLY — the next plugin author ships a bundle, it
// builds fine, and it is simply invisible in the app with no error to notice. Same principle the
// Driver set for the data bundler: handle every plugin generically, or the next author gets
// nothing. The named sections above stay curated (they encode the teaching order); Experimental
// is the automatic catch-all so nothing can go missing.
{ key: 'experimental', label: 'Experimental', icon: 'flask', folder: true, open: false, tabs: [] },
]
// Bundles that are NOT candidates for auto-discovery into Experimental, with the reason each is
// excluded. Everything else the manifest reports becomes an Experimental tab automatically.
// - doxa-maps-spa : this shell itself (nesting the app inside its own tab = infinite regress)
// - plugin-studio : the authoring/dev tool, not an end-user map
// - template-bundle: the copy-me scaffold for new plugin authors, not real content
// - people-groups-grid / research-dashboard / big-picture: the PARTS that are already composed
// into the ONE `dashboard-plus-grid` Big Picture tab — surfacing them separately is the exact
// redundancy the Driver removed ("we don't need multiple dashboards").
const NOT_EXPERIMENTAL = new Set([
'doxa-maps-spa',
'plugin-studio',
'template-bundle',
'people-groups-grid',
'research-dashboard',
'big-picture'
])
// The tab manifest. Each entry names a real, built bundle in this tree and how to nest it.
// mode 'element' → inject <bundleJs> once, then create <tag> (direct-JS; token-free maps only).
// mode 'iframe' → point an iframe at the bundle's standalone leaf page (its own SPA).
@ -161,10 +217,13 @@ const NAV_MODEL = [
// iframeUrl/scriptUrl shape ("./<bundle>/<profile>/index.html", "./<bundle>/<bundle>.js").
const TAB_DEFS = [
{
id: 'big-picture', label: 'Big Picture', sub: 'search + people groups', emoji: '🌍',
mode: 'element', tag: 'bp-big-picture',
bundleJs: 'big-picture/big-picture.js',
leaf: 'big-picture/big-picture/index.html'
// THE ONE DASHBOARD (the Driver's "Big Picture"): research-dashboard + people-groups-grid compiled
// into ONE element on a single scroll-page — scroll down past the dashboard to the grid. Replaces the
// former separate `big-picture` and `research-dashboard` tabs (removed as redundant). Its generated
// leaf sets window.MAP_TOKEN from the baked token so the nested map paints.
id: 'dashboard-plus-grid', label: 'Big Picture', sub: 'dashboard + people-groups grid, stacked', emoji: '🌍',
mode: 'iframe', tag: 'dashboard-plus-grid',
leaf: 'dashboard-plus-grid/dashboard-plus-grid/index.html'
},
{
id: 'research-map', label: 'Research Map', sub: 'prayer · engagement · regions', emoji: '🗺️',
@ -172,7 +231,13 @@ const TAB_DEFS = [
leaf: 'doxa-research-map/research-map/index.html'
},
{
id: 'prayer-map', label: 'Prayer Map', sub: 'simple world prayer map', emoji: '🙏',
// RENAMED (Driver): "Prayer Map" → "Prayer Progress". His words: "the prayer maps can be renamed
// Prayer Progress Map, or just Prayer Progress, and it can sit UNDERNEATH UPG-100 List." The
// shorter form won — the rail is narrow and "Progress" already implies the map. NOTE the `id`
// stays 'prayer-map': the id is the stable join key between NAV_MODEL, TAB_DEFS and the built
// leaf path, so only the human-facing LABEL changes. Renaming the id would break the leaf lookup
// for no user-visible gain.
id: 'prayer-map', label: 'Prayer Progress', sub: 'simple world prayer map', emoji: '🙏',
mode: 'iframe', tag: 'doxa-simple-map',
// `prayer` is an INSTANCE (not a profile), so its built leaf lives one level deeper under the
// bundle dir: doxa-simple-map/doxa-simple-map/prayer/ (see manifest.json). The shallow path 404s.
@ -193,20 +258,123 @@ const TAB_DEFS = [
mode: 'iframe', tag: 'my-upg-100-list-map',
leaf: 'my-upg-100-list/upg-100-mfe/index.html'
},
// ── Experimental lab maps (real, built bundles with standalone leaf pages) — surfaced under the
// collapsible Experimental folder. Kept lazy iframes like the other maps.
{
id: 'all-people-groups', label: 'All People Groups', sub: 'the full global set (~15k)', emoji: '🌐',
mode: 'iframe', tag: 'all-people-groups',
leaf: 'all-people-groups/all-people-groups/index.html'
},
{
id: 'country-posters', label: 'Country Posters', sub: 'printable country prayer sheets', emoji: '🖼️',
mode: 'iframe', tag: 'country-posters-map',
leaf: 'country-posters/countries-map/index.html'
}
// ── NOTE: there are deliberately NO hardcoded Experimental entries here. Every built bundle not
// named above is discovered from manifest.json at runtime and appended as an Experimental tab
// (see discoverExperimental + NOT_EXPERIMENTAL). That is what keeps a NEW plugin visible with
// zero edits to this file.
]
// The set of bundle ids the CURATED sections above already place, derived from NAV_MODEL + TAB_DEFS
// rather than hand-written. Deriving it means the "is this already placed?" answer can never drift
// out of sync with the actual rail — the classic failure mode of a parallel hardcoded list.
function curatedBundles() {
const byId = Object.fromEntries(TAB_DEFS.map(t => [t.id, t]))
const out = new Set()
for (const g of NAV_MODEL) {
if (g.key === 'experimental') continue // the catch-all places nothing by definition
for (const r of g.tabs || []) {
const t = byId[r.id]
if (t) out.add(t.bundle || String(t.leaf).split('/')[0])
}
}
return out
}
/**
* THE RAIL'S ONE ENUMERATION RULE — one row per TAB ID, first declaration wins.
*
* ★ THE RULE, stated once so the next plugin author inherits it deliberately rather than by accident:
* a BUNDLE contributes exactly ONE rail row. Its profiles (gospel-resources ships charts /
* data-tables / resources-list / resources-map; big-picture ships instances) are alternate VIEWS of
* that one map and are reachable INSIDE the pane — they are NOT promoted to rail rows. The rail is
* an onboarding ramp, not an inventory: per-profile rows would add four rows for gospel-resources
* alone and bury the teaching order the sections above encode.
*
* WHY A FUNCTION AND NOT JUST A CAREFULLY-WRITTEN NAV_MODEL: sections are allowed to DECLARE the same
* tab twice on purpose (Gospel Resources is both the prayer ramp's "then act" step and a research
* surface). Declaring twice is expressive; RENDERING twice is a bug. This keeps the first declaration
* — which is what makes the curated order authoritative — and silently drops later ones, so the two
* concerns stop fighting.
*
* WHY IT IS A CORRECTNESS FIX, NOT COSMETICS: VerticalTabsLayout keys its tab-element map by row key
* (`registerTab(row.key, el)`) and computes highlight as `row.key === active`. Two rows sharing an id
* means the second node overwrites the first in that map (so roving-focus can only ever reach one),
* BOTH copies render as active simultaneously, and `orderedKeys` visits the duplicate twice — arrow
* keys appear to stall. De-duplicating by id restores all three invariants at once.
*
* Generic by construction: nothing here names a bundle, so it holds for every plugin added later.
*/
function dedupeRows(rows, seen) {
const out = []
for (const r of rows) {
if (!r || seen.has(r.key)) continue // a later section yields to the first declaration
seen.add(r.key)
out.push(r)
}
return out
}
// Turn a bundle id into a human label: "all-people-groups" → "All People Groups". Small, local and
// dependency-free; a bundle may override it by shipping a profile `title` in the manifest.
function titleize(id) {
return String(id).split(/[-_]/).filter(Boolean)
.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
}
/**
* GENERIC EXPERIMENTAL DISCOVERY — the whole point of Card 2.
*
* Reads the BUILT manifest.json (the authoritative list of what actually exists in this tree — the
* same file the gallery and every bundle page read) and returns a tab def for every bundle that is
* (a) not already placed in a curated section and (b) not in NOT_EXPERIMENTAL. Nothing about any
* specific plugin is encoded here, so a brand-new bundle shows up on the next build by itself.
*
* WHY THE MANIFEST AND NOT A DIRECTORY LISTING: manifest.json is generated by the build and already
* honors `gatedProfiles` (e.g. key-cities is intentionally withheld), so reading it inherits every
* publishing decision for free. A raw listing of the served folder would resurrect gated/stale
* directories as phantom tabs.
*
* WHY RUNTIME AND NOT BUILD-TIME: baking the list in at compile time would just move the hardcoding
* one layer down — the shell would still ship a frozen list. Fetching at runtime means the SAME
* built shell adapts to whatever tree it is dropped into. manifest.json is inside the drop-in
* allowlist, so it travels with the tree everywhere the shell does.
*
* FAIL-SOFT: if the manifest is unreachable (offline file:// drop, a host that did not ship it), we
* return [] and the Experimental folder simply stays empty. The curated sections are unaffected —
* a discovery failure must never take down the app's main rail.
*/
function experimentalTabsFromManifest(manifest) {
const placed = curatedBundles()
const bundles = (manifest && Array.isArray(manifest.bundles)) ? manifest.bundles : []
const out = []
for (const b of bundles) {
const id = b && b.bundle
if (!id || placed.has(id) || NOT_EXPERIMENTAL.has(id)) continue
// Pick the bundle's entry profile: prefer a real MAP profile, else the profile named like the
// bundle, else simply the first one. Every profile in the manifest carries its own built leaf
// page (iframeUrl), which is exactly what an iframe tab needs.
const profiles = Array.isArray(b.profiles) ? b.profiles : []
if (!profiles.length) continue
const entry = profiles.find(p => p.isMap) || profiles.find(p => p.profile === id) || profiles[0]
const leaf = String(entry.iframeUrl || '').replace(/^\.\//, '')
if (!leaf) continue
out.push({
id, // bundle id doubles as the stable tab id
label: entry.title || titleize(id),
sub: (entry.description || '').trim().replace(/[,;]\s*$/, '') || 'experimental bundle',
emoji: '🧪',
mode: 'iframe',
tag: (Array.isArray(b.elements) && b.elements[0]) || id,
bundle: id,
leaf,
discovered: true // marks an auto-discovered tab (used by the rail + tests)
})
}
// Stable, predictable order so the folder does not reshuffle between builds.
out.sort((a, z) => a.label.localeCompare(z.label))
return out
}
// Load a script exactly once per URL (mirrors the gallery's once()). Resolves when the IIFE has
// executed (so its customElements.define has run). SRI is optional (attrs.integrity).
const _loaded = {}
@ -236,7 +404,15 @@ export default defineComponent({
title: { type: String, default: 'DX-Maps' },
// Optional: pin the Mapbox token URL passed down to iframe leaf pages via ?tokenUrl / ?tk.
tokenUrl: { type: String, default: '' },
tk: { type: String, default: '' }
tk: { type: String, default: '' },
// LOCALIZATION PROP (loc architecture). The SPA has NO standalone language dropdown by default;
// it INHERITS its language from the parent page via this `language` prop (canonical name). When a
// cap-5 header hosts a dropdown it sets this prop for all tabs; otherwise the host page passes it
// down. `locale`/`lang` are also accepted via profile-config (ProfileLoader's contract) — see
// effectiveLocale below, which prefers this prop, then profile-config, then the manual switcher.
// NOTE: no `locale` PROP here on purpose — `locale` is a data() field (the manual switcher's
// state); a same-named prop+data collides in Vue. The alias lives in profile-config instead.
language: { type: String, default: '' }
},
setup() {
// index.js provides the tree base it derived from the running <script> URL. Prop/attr override
@ -247,6 +423,12 @@ export default defineComponent({
data() {
return {
activeIndex: 0,
// The cap-5 DX-Maps SPA is HEADER-OFF by default (sidebar-only, full-page — no stacked headers
// when embedded under a host that already has one). This ONE flag drives BOTH the shell header
// and the standalone locale dropdown, so the Driver's rule ("header-off → no dropdown; the SPA
// inherits its language from the parent via the `language` prop") can never drift: see
// showLocaleSwitcher. A future host that wants the SPA's own chrome flips this to true.
showHeader: false,
// i18n locale switcher (reuses app-wrapper-material/languages.js — persisted, offline, no dep).
locale: loadLocale(),
localeOpen: false,
@ -284,20 +466,52 @@ export default defineComponent({
// dropped, and a group with no surviving rows is omitted (never paint a dead section/row).
navGroups() {
const byId = Object.fromEntries(this.tabs.map(t => [t.id, t]))
// ONE `seen` set threaded across ALL groups (not per-group): the rail must hold one node per tab
// id GLOBALLY, since the layout's tab-ref map and `active` highlight are keyed by id across the
// whole rail. A per-group set would still let two sections each paint the same tab. See
// dedupeRows() for the rule and why duplicates are a correctness bug, not a cosmetic one.
const seen = new Set()
return NAV_MODEL
.map(g => {
const rows = (g.tabs || [])
.filter(r => byId[r.id])
.map(r => {
const t = byId[r.id]
return { key: t.id, label: t.label, sub: t.sub, icon: r.icon, badge: r.badge }
})
let rows = dedupeRows(
(g.tabs || [])
.filter(r => byId[r.id])
.map(r => {
const t = byId[r.id]
return { key: t.id, label: t.label, sub: t.sub, icon: r.icon, badge: r.badge }
}),
seen)
// The Experimental folder is not authored — it is FILLED with every discovered tab (any
// built bundle no curated section placed). This is the one group whose rows come from the
// manifest rather than NAV_MODEL, which is what makes a new plugin appear by itself.
if (g.key === 'experimental') {
// Discovered rows go through the SAME de-dup gate as curated ones. Belt-and-braces: a
// discovered bundle should already be excluded by curatedBundles()/NOT_EXPERIMENTAL, but
// routing it through `seen` means even a mis-declared bundle can only ever paint one row.
rows = rows.concat(dedupeRows(
this.tabs.filter(t => t.discovered)
.map(t => ({ key: t.id, label: t.label, sub: t.sub, icon: 'flask', badge: 'lab' })),
seen))
}
return { key: g.key, label: g.label, icon: g.icon, folder: !!g.folder, open: g.open, rows }
})
.filter(g => g.rows.length)
},
// Whether the standalone #toolbar locale dropdown renders. The Driver's rule: the cap-5 SPA has
// NO standalone dropdown by default — it INHERITS its language from the parent via the `language`
// prop (effectiveLocale). The dropdown is only offered when the header is ON (a host opting the
// SPA into its own chrome). Keyed to the SAME flag as the shell header so the two never diverge.
showLocaleSwitcher() { return this.showHeader },
// The active language's flag/native name for the toolbar toggle button.
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } },
// The EFFECTIVE preview language pushed down to every tab. Precedence, most-authoritative first:
// 1. `language` prop / profile-config.language|locale|lang — the parent-page (inherited) value.
// 2. the manual switcher's data.locale (only meaningful when a header dropdown is present).
// A tab (direct element or iframe) always receives THIS, so the SPA honors one language prop.
effectiveLocale() {
const fromProp = (this.language || this.cfg.language || this.cfg.locale || this.cfg.lang || '').trim()
return (fromProp || this.locale || '').split('-')[0].toLowerCase()
},
activeKey() {
const t = this.tabs[this.activeIndex]
return t ? t.id : ''
@ -307,9 +521,59 @@ export default defineComponent({
// Open the first tab immediately (its map is the one load the SPA pays on open). The shared
// VerticalTabsLayout owns rail open/close + narrow-screen collapse now.
this.select(0)
// …then discover the Experimental folder's contents in the background. Deliberately AFTER
// select(0) and never awaited: the curated rail must paint instantly and must not depend on a
// network read succeeding.
this.loadExperimental()
},
watch: {
// When the parent page changes the inherited `language` prop at runtime, re-drive the CURRENTLY
// visible tab into the new language: re-set a direct element's profile-config.locale, or reload an
// iframe pane's src (which carries ?locale=). Other tabs pick it up lazily on their next open,
// since select()/mountElement() read effectiveLocale fresh. Keeps the SPA honoring one prop.
effectiveLocale(loc) {
const t = this.tabs[this.activeIndex]
if (!t) return
if (t.mode === 'iframe' && t.loaded) {
t.src = this.withToken(this.resolve(t.leaf)) // re-point → nested SPA re-mounts in the new lang
} else if (t.mode === 'element' && t.mounted) {
try {
const slot = this._slots && this._slots[this.activeIndex]
const el = slot && slot.firstElementChild
if (el && el.setAttribute) {
const pc = { profile: t.id, instanceId: 'dms-' + t.id }
if (loc) pc.locale = loc
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
el.setAttribute('profile-config', JSON.stringify(pc))
}
} catch { /* non-fatal: the tab keeps its previous language */ }
}
}
},
methods: {
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
// Fetch the built manifest and append a tab for every bundle no curated section placed, so the
// Experimental folder fills itself. Fail-soft by design: any error (no manifest shipped, a
// file:// drop where fetch is blocked, malformed JSON) leaves the folder empty and the curated
// rail untouched — discovery is an enhancement, never a dependency.
loadExperimental() {
const url = this.resolve('manifest.json')
try {
fetch(url, { credentials: 'omit' })
.then(r => (r && r.ok) ? r.json() : null)
.then(m => {
if (!m) return
const extra = experimentalTabsFromManifest(m)
if (!extra.length) return
const have = new Set(this.tabs.map(t => t.id))
for (const d of extra) {
if (have.has(d.id)) continue
this.tabs.push({ ...d, loaded: false, mounted: false, state: 'idle', error: '', src: '' })
}
})
.catch(() => { /* non-fatal: Experimental simply stays empty */ })
} catch { /* fetch unavailable (non-browser): same fail-soft path */ }
},
// Switch UI language. languages.js's contract: switching = re-setting profile-config.locale on
// the mounted element (ProfileLoader reads it, drives the bundle's i18n). We persist the choice
// and best-effort re-set the locale on tab-1's mounted <bp-big-picture> (the only direct element;
@ -361,6 +625,12 @@ export default defineComponent({
const q = []
if (tk) q.push('tk=' + encodeURIComponent(tk))
else if (tokenUrl) q.push('tokenUrl=' + encodeURIComponent(tokenUrl))
// Push the effective language down to the nested SPA as `?locale=` — the map leaf pages read it
// and inject it into their element's profile-config.locale (ProfileLoader's contract). Only when
// a concrete locale is set, so a plain drop keeps the leaf's own document.documentElement.lang
// detection.
const loc = this.effectiveLocale
if (loc) q.push('locale=' + encodeURIComponent(loc))
if (!q.length) return url
return url + (url.indexOf('?') === -1 ? '?' : '&') + q.join('&')
},
@ -378,6 +648,9 @@ export default defineComponent({
slot.innerHTML = ''
const el = document.createElement(t.tag)
const pc = { profile: t.id, instanceId: 'dms-' + t.id }
// Honor the effective language on first mount too (not only on a later manual switch), so a
// tab opened with an inherited `language` prop already renders in that language.
if (this.effectiveLocale) pc.locale = this.effectiveLocale
// Big Picture is token-free; only forward a token if the host gave one.
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
el.setAttribute('profile-config', JSON.stringify(pc))
@ -399,10 +672,10 @@ export default defineComponent({
direct-JS slot / lazy iframe / loading hint each fill their pane. */
.dms-pane { position: absolute; inset: 0; }
.dms-slot { position: absolute; inset: 0; }
.dms-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #0b0f14; }
.dms-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #f3f3f1; }
.dms-hint {
position: absolute; inset: 0; display: grid; place-items: center; text-align: center;
color: #94a3b8; font-size: 13px; padding: 20px;
color: #6d766e; font-size: 13px; padding: 20px;
}
.dms-hint.err { color: #f87171; }
.dms-hint small { color: #64748b; }
@ -415,25 +688,25 @@ export default defineComponent({
.dms-locale { position: relative; padding: 8px 12px 0; display: flex; justify-content: flex-end; }
.dms-locale-btn {
display: inline-flex; align-items: center; gap: 8px; font: inherit; font-size: 12px;
padding: 5px 10px; border: 1px solid #404040; border-radius: 8px; background: #262626;
color: #f5f5f5; cursor: pointer;
padding: 5px 10px; border: 1px solid #d1d4cd; border-radius: 8px; background: #d1d4cd;
color: #3b463d; cursor: pointer;
}
.dms-locale-btn:hover { background: #404040; }
.dms-locale-btn:hover { background: #d1d4cd; }
.dms-flag { font-size: 14px; line-height: 1; }
.dms-locale-name { max-width: 12ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dms-dots { color: #a3a3a3; margin-left: 2px; }
.dms-dots { color: #6d766e; margin-left: 2px; }
.dms-locale-menu {
position: absolute; top: 100%; right: 12px; margin-top: 4px; z-index: 30; min-width: 180px;
list-style: none; padding: 6px; border: 1px solid #404040; border-radius: 10px;
background: #1f1f1f; box-shadow: 0 12px 32px rgba(0,0,0,.5); max-height: 320px; overflow: auto;
list-style: none; padding: 6px; border: 1px solid #d1d4cd; border-radius: 10px;
background: #ffffff; box-shadow: 0 12px 32px rgba(0,0,0,.5); max-height: 320px; overflow: auto;
}
.dms-locale-opt {
display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; font: inherit;
font-size: 13px; padding: 7px 9px; border: 0; border-radius: 7px; background: none;
color: #d4d4d4; cursor: pointer;
color: #3b463d; cursor: pointer;
}
.dms-locale-opt:hover { background: #404040; color: #f5f5f5; }
.dms-locale-opt.active { background: #404040; color: #f5f5f5; }
.dms-locale-opt.active .dms-locale-name { color: #00DC82; }
.dms-locale-en { margin-left: auto; font-size: 11px; color: #737373; }
.dms-locale-opt:hover { background: #d1d4cd; color: #3b463d; }
.dms-locale-opt.active { background: #d1d4cd; color: #3b463d; }
.dms-locale-opt.active .dms-locale-name { color: #92b195; }
.dms-locale-en { margin-left: auto; font-size: 11px; color: #6d766e; }
</style>

View file

@ -46,6 +46,10 @@
<script>
window.UPG_TOKEN = (new URLSearchParams(location.search).get('tk')||(typeof window!=='undefined'&&window.MAPBOX_TOKEN)||'');
// Inherited language: the SPA/studio nests this leaf as an iframe and passes the chosen language
// as ?locale=. ProfileLoader reads profile-config.locale and drives the bundle's i18n. Empty →
// the leaf keeps its own document.documentElement.lang detection.
window.DOXA_LOCALE = (new URLSearchParams(location.search).get('locale') || '');
</script>
<div class="stage-head">
@ -69,11 +73,11 @@
slot.innerHTML = '';
slot.classList.toggle('is-wall', profile === 'resources-list');
var el = document.createElement('gospel-resources-map');
el.setAttribute('profile-config', JSON.stringify({
el.setAttribute('profile-config', JSON.stringify(Object.assign({
profile: profile,
tk: window.UPG_TOKEN || '',
assetsBase: '/assets',
}));
}, window.DOXA_LOCALE ? { locale: window.DOXA_LOCALE } : {})));
slot.appendChild(el);
}
document.querySelectorAll('.tab').forEach(function (b) {

View file

@ -354,9 +354,20 @@ useShadowStyles(
'.gm-sec{margin-top:12px}' +
'.gm-sec-h{font-weight:800;font-size:12px;letter-spacing:.02em;margin-bottom:6px}' +
'.gm-load{padding:10px 0;font-size:11.5px;opacity:.65}' +
'.gm-pg{width:100%;text-align:left;display:block;padding:8px 9px;margin-bottom:6px;border-radius:8px;' +
'border:1px solid rgba(0,0,0,.10);background:rgba(0,0,0,.02);color:inherit;cursor:pointer;font:inherit}' +
// .gm-pg is now the SHELL only. The resource rows are a <div> shell (so the chips
// inside can be real anchors) while the no-resource rows are still a <button>; both
// must look identical, so the shell carries the box and .gm-pg-btn/.gm-pg-open carry
// the interactive bits. cursor:pointer is NOT on the shell — on a resource row the
// gaps between the name and the chips are not clickable, and saying otherwise lies.
'.gm-pg{width:100%;box-sizing:border-box;text-align:left;display:block;padding:8px 9px;' +
'margin-bottom:6px;border-radius:8px;' +
'border:1px solid rgba(0,0,0,.10);background:rgba(0,0,0,.02);color:inherit;font:inherit}' +
'.is-dark .gm-pg{border-color:rgba(255,255,255,.10);background:rgba(255,255,255,.03)}' +
'.gm-pg-btn{cursor:pointer}' +
// The drill-in control on a resource row: a bare button that inherits the shell's
// look, so the row still reads as one tap-target for the name + language line.
'.gm-pg-open{display:block;width:100%;text-align:left;padding:0;margin:0;border:0;' +
'background:transparent;color:inherit;font:inherit;cursor:pointer}' +
'.gm-pg-n{display:block;font-weight:700;font-size:12.5px}' +
'.gm-pg-l{display:block;font-size:11px;opacity:.75;margin-top:1px}' +
'.gm-code{display:inline-block;font:600 10px ui-monospace,SFMono-Regular,Menlo,monospace;' +
@ -370,6 +381,10 @@ useShadowStyles(
'.gm-chip.bad{background:rgba(180,35,24,.16);border-color:rgba(180,35,24,.38)}' +
'.gm-chip.warn{background:rgba(224,122,43,.18);border-color:rgba(224,122,43,.38)}' +
'.gm-chip.meh{background:rgba(120,130,140,.16);border-color:rgba(120,130,140,.30)}' +
// Only an ANCHOR chip gets the pointer + underline. A <span> chip ("none found",
// "unknown") must keep looking inert, because it is.
'a.gm-chip{cursor:pointer}' +
'a.gm-chip:hover,a.gm-chip:focus-visible{text-decoration:underline}' +
'.gm-none{font-size:11px;opacity:.7}' +
'.gm-back{border:1px solid rgba(0,0,0,.16);background:transparent;color:inherit;' +
'font:600 11px inherit;padding:5px 11px;border-radius:8px;cursor:pointer;min-height:30px}' +
@ -469,7 +484,7 @@ useShadowStyles(
<div class="gm-sec" v-if="sameLanguage.length">
<div class="gm-sec-h">Also speak {{ selectedGroup.language }} ({{ sameLanguage.length }})</div>
<button v-for="g in sameLanguage.slice(0, 12)" :key="g.people_group + g.countries"
type="button" class="gm-pg" @click="openGroup(g)">
type="button" class="gm-pg gm-pg-btn" @click="openGroup(g)">
<span class="gm-pg-n">{{ g.people_group }}</span>
<span class="gm-pg-l">{{ fmt(g.population) }} people · {{ g.countries }}</span>
</button>
@ -511,19 +526,34 @@ useShadowStyles(
<div v-if="!withResources.length" class="gm-none">
Nothing found for any group here in the pools checked.
</div>
<button v-for="g in withResources.slice(0, shown)" :key="'r' + g.people_group + g.language"
type="button" class="gm-pg" @click="openGroup(g)">
<span class="gm-pg-n">{{ g.people_group }}</span>
<span class="gm-pg-l">{{ g.language }}<code class="gm-code">{{ g.iso639_3 || 'no code' }}</code></span>
<!-- NOT a <button> wrapper. The chips below are real <a href> links to the
resource itself, and HTML forbids interactive content inside a button —
a nested anchor is hoisted out by the parser and stops being a link.
So the row is a plain container: the NAME is the drill-in control, the
CHIPS are the outbound links, and the two no longer fight. -->
<div v-for="g in withResources.slice(0, shown)" :key="'r' + g.people_group + g.language"
class="gm-pg">
<button type="button" class="gm-pg-open" @click="openGroup(g)">
<span class="gm-pg-n">{{ g.people_group }}</span>
<span class="gm-pg-l">{{ g.language }}<code class="gm-code">{{ g.iso639_3 || 'no code' }}</code></span>
</button>
<span class="gm-chips">
<span v-if="g.scripture_url" class="gm-chip" :class="vchip(g.scripture_url).cls">
<!-- The URL the verification pass already proved. Surfacing it is the
whole point: a chip that SAYS "link checked" while hiding the link
costs middle-click, open-in-new-tab, copy-link-address and link
semantics for screen readers. -->
<a v-if="g.scripture_url" class="gm-chip" :class="vchip(g.scripture_url).cls"
:href="g.scripture_url" target="_blank" rel="noopener noreferrer"
:title="vtitle(g.scripture_url)">
Scripture · {{ vchip(g.scripture_url).label }}
</span>
<span v-if="g.film_url" class="gm-chip" :class="vchip(g.film_url).cls">
</a>
<a v-if="g.film_url" class="gm-chip" :class="vchip(g.film_url).cls"
:href="g.film_url" target="_blank" rel="noopener noreferrer"
:title="vtitle(g.film_url)">
Film · {{ vchip(g.film_url).label }}
</span>
</a>
</span>
</button>
</div>
<div v-if="withResources.length > shown" class="gm-more">
Showing {{ shown }} of {{ withResources.length }}.
<button type="button" class="gm-back" @click="shown = withResources.length">Show all</button>
@ -532,8 +562,12 @@ useShadowStyles(
<div class="gm-sec" v-if="withoutResources.length">
<div class="gm-sec-h">No resource found ({{ withoutResources.length }})</div>
<!-- These groups have NO verified URL, so NOTHING here becomes an anchor.
A dead <a> that navigates nowhere would claim a resource exists — the
exact overclaim this map refuses everywhere else. The row stays a plain
drill-in button and keeps saying, honestly, that nothing was found. -->
<button v-for="g in withoutResources.slice(0, shown)" :key="'n' + g.people_group + g.language"
type="button" class="gm-pg" @click="openGroup(g)">
type="button" class="gm-pg gm-pg-btn" @click="openGroup(g)">
<span class="gm-pg-n">{{ g.people_group }}</span>
<span class="gm-pg-l">
{{ g.language }}<code class="gm-code">{{ g.iso639_3 || 'no code' }}</code>

View file

@ -72,7 +72,12 @@
// Mount one element. Profile is selectable via ?profile= (dev convenience) so
// the composite MFE profile (upg-100-app) can be previewed too; defaults to the map.
var _profile = (new URLSearchParams(location.search).get('profile') || 'upg-100-list');
// Inherited language: the SPA/studio nests this leaf as an iframe and passes the chosen language
// as ?locale=; ProfileLoader reads profile-config.locale and drives the bundle's i18n. Empty →
// the leaf keeps its own document.documentElement.lang detection.
var _locale = (new URLSearchParams(location.search).get('locale') || '');
var config = { profile: _profile, tk: window.UPG_TOKEN || '' };
if (_locale) config.locale = _locale;
var el = document.createElement('my-upg-100-list-map');
el.setAttribute('profile-config', JSON.stringify(config));
document.getElementById('slot').appendChild(el);

View file

@ -61,12 +61,37 @@ const view = ref('map')
let column = null
let offScroll = null
// PAGE-LEVEL config keys a pane INHERITS from the host page.
//
// A cap-4 composite nests its children as custom elements in THIS document, so there is no
// inner leaf page to hand them the page-scoped values an iframe child would have received
// from its own generated index.html. Anything the generator bakes ONCE PER PAGE — where
// assets are served from, which backend to call, the theme — has to be forwarded explicitly
// or the child silently falls back to its own default.
//
// BUG-UPG100MFE-ASSET-PATH-DEPTH-404S (2026-09-20): `assetsBase` was not forwarded. The
// generator emitted the CORRECT value ("../../assets", identical to every sibling leaf), but
// the poster-wall pane never saw it and fell back to its own './assets' default — which
// resolves against the LEAF directory, so all 27 previews requested
// upg-100-mfe/assets/previews/<slug>.webp and 404'd. The depth calculation was never wrong;
// the value simply never reached the consumer.
//
// Forwarded by KEY LIST, not by spreading the whole host config: a pane's own `profile` /
// `instanceId` must NOT be overwritten by the host's, which is what makes a pane a distinct
// view at all. Anything a pane declares still wins (pane.config is spread last).
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'locale', 'lang', 'isDark', 'qa']
function paneElement(pane) {
const el = document.createElement(TAG)
const pcfg = { ...spec.value.config, ...pane.config }
const host = cfg.value || {}
const inherited = {}
for (const k of INHERITED_PAGE_KEYS) {
if (host[k] !== undefined && host[k] !== null && host[k] !== '') inherited[k] = host[k]
}
// `lang` is the alias the page may carry; children read `locale`.
if (inherited.locale === undefined && inherited.lang !== undefined) inherited.locale = inherited.lang
const pcfg = { ...inherited, ...spec.value.config, ...pane.config }
if (tk.value) pcfg.tk = tk.value
const loc = cfg.value?.locale || cfg.value?.lang
if (loc) pcfg.locale = loc
el.setAttribute('profile-config', JSON.stringify(pcfg))
return el
}
@ -114,19 +139,25 @@ const SHARE_EMBED_PATH = 'my-upg-100-list/upg-100-mfe/index.html'
// Child components' <style> blocks are dropped by defineCustomElement, so ALL CSS
// goes through useShadowStyles: the layout's grid styles + this element's chrome.
useShadowStyles(PANE_GRID_STYLES + `
/* ── palette: app-wrapper-material's --ui-* (light default, dark via system) ── */
/* SELF-SIZE (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT). Every layer inside this element is
position:absolute / height:100% (.upgmfe -> .pane-col -> .pane), so the whole chain resolves to
ZERO unless the HOST itself is sized. The standalone leaf page papered over it with a page-level
rule, but inside a tab layout no such rule exists -> the header rendered and every pane collapsed
to 0, which is the "elements are not embedding" report. Declaring :host here makes the element
fill whatever slot it is mounted into, in every deploy method. */
:host{ display:block; width:100%; height:100%; min-height:420px; }
/* ── palette: DX-life material light ── */
.upgmfe{
--ui-bg:#ffffff; --ui-bg-elevated:#f9f9f9; --ui-bg-accented:#f0f0f0;
--ui-border:#e5e5e5; --ui-text:#0f0f0f; --ui-text-muted:#606060; --ui-primary:#1a73e8;
--ui-bg:#ffffff; --ui-bg-elevated:#f3f3f1; --ui-bg-accented:#f3f3f1;
--ui-border:#d1d4cd; --ui-text:#3b463d; --ui-text-muted:#6d766e; --ui-primary:#92b195;
--header:56px;
position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;
background:var(--ui-bg); color:var(--ui-text);
font:14px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;
}
@media (prefers-color-scheme:dark){
.upgmfe{ --ui-bg:#0f0f0f; --ui-bg-elevated:#181818; --ui-bg-accented:#272727;
--ui-border:#303030; --ui-text:#f1f1f1; --ui-text-muted:#aaa; --ui-primary:#64b5f6; }
}
/* NO AUTO-DARK. The DX design pattern is material LIGHT (Driver 2026-09-20): a system set to dark
mode must NOT flip this element. The prefers-color-scheme:dark override that used to live here is
removed deliberately — the light palette above is the only theme. */
/* ── header: same height/border as the shell; switcher centred; share at right ── */
.upgmfe .hdr{
flex:0 0 var(--header); height:var(--header); position:relative;

View file

@ -9,7 +9,7 @@
* dropdowns + faceted (cross-filter) counts are copied from UupgsList.vue — NOT imported
* from DX-life. DX-life is READ-ONLY source; nothing there is edited.
*
* THE CAT-2 CONTRACT (mirrors plugin-designs/big-picture/src/components/BpGroupsTable.vue)
* THE CAT-2 CONTRACT (this IS the real cap-2 grid the big-picture Cat-4 composite nests)
* - By DEFAULT it owns its internal search box + filter dropdowns and filters itself.
* - `show-search` / `show-filters` ([Boolean,String], default true) TURN OFF its own
* controls so an external header (e.g. the Big-Picture Cat-4 shell) can drive it.
@ -417,7 +417,7 @@ onMounted(getUUPGs)
@filter-change="onFilterChange" @filter-clear="onFilterClear"
/>
<FilterDropdown
:label="t.wagf_block || 'Block'" name="wagf_block"
:label="t.wagf_block || 'Bloc'" name="wagf_block"
:options="filterOptions.wagf_block || []"
:value="activeFilters.wagf_block?.value ?? ''"
:placeholder="t.type_to_search || 'Type to search...'"
@ -510,6 +510,11 @@ onMounted(getUUPGs)
(blocks/_filters.scss + blocks/_cards.scss); those don't ship here, so the essential
layout/look is inlined and scoped to this element's shadow root. Class names are
preserved so the DX-life SCSS COULD still apply if this ever mounts inside that app. */
/* Self-size as a block regardless of how a parent mounts us — never depend on the mounter
forcing display (an unstyled host defaults to inline and mis-sizes). This grid is
natural-height (grows with content), so :host is display:block, width:100%, no fixed height.
See BUG-EMBEDDED-PLUGIN-STUDIO-BLACK-SCREEN-DISPLAY-BLOCK. */
:host { display: block; width: 100%; }
.pgg-root {
--brand: #2563eb; --ink: #0f172a; --mut: #64748b; --line: #e2e8f0; --card: #fff; --bg: #f8fafc;
width: 100%; box-sizing: border-box; color: var(--ink); background: var(--bg);

View file

@ -42,6 +42,7 @@
<VerticalTabsLayout
:title="title"
nav-label="Plugin elements · by capability 5 → 1"
:header="true"
:groups="navGroups"
:active="activeKey"
@update:active="select">
@ -50,7 +51,7 @@
Self-drawn SVG, green accent to match the admin shell; no external asset (offline safe). -->
<template #brand>
<span class="ps-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#00DC82" stroke-width="2"
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#92b195" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M10 3v3M14 3v3M7 6h10v5a5 5 0 0 1-10 0V6Z" />
<path d="M12 16v5" />
@ -117,14 +118,25 @@
</div>
</template>
<iframe
v-else
class="ps-frame"
:src="row.loaded ? row.src : 'about:blank'"
:title="row.label"
loading="lazy"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"></iframe>
<template v-else>
<!-- iframe panes: a broken leaf page (404 / script error inside the frame) used to show a
silent blank frame. @load flips a per-row flag; a short watchdog after the src is set
marks the pane errored if it never fired — so ONE bad nested plugin shows an error
overlay in ITS pane, never a blank studio. The @error alone is unreliable for cross-
document frames, hence the watchdog. -->
<iframe
class="ps-frame"
:src="row.loaded ? row.src : 'about:blank'"
:title="row.label"
loading="lazy"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"
@load="onFrameLoad(row)"
@error="onFrameError(row)"></iframe>
<div v-if="row.state === 'error'" class="ps-hint err">
Could not load {{ row.label }}.<br /><small>{{ row.error }}</small>
</div>
</template>
</section>
<div class="ps-hint" v-if="!activeKey">Choose an element from the list.</div>
</div>
@ -143,36 +155,36 @@ import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/la
const PS_SHADOW_CSS = `
.ps-logo { display:inline-flex; align-items:center; }
.ps-logo svg { display:block; }
.ps-count { color:#a3a3a3; }
.ps-count { color:#6d766e; }
.ps-locale { position:relative; display:inline-flex; }
.ps-locale-btn { display:inline-flex; align-items:center; gap:6px; font:inherit; font-size:12px; padding:4px 9px; border:1px solid #404040; border-radius:8px; background:#262626; color:#f5f5f5; cursor:pointer; }
.ps-locale-btn:hover { background:#404040; }
.ps-locale-btn { display:inline-flex; align-items:center; gap:6px; font:inherit; font-size:12px; padding:4px 9px; border:1px solid #d1d4cd; border-radius:8px; background:#d1d4cd; color:#3b463d; cursor:pointer; }
.ps-locale-btn:hover { background:#d1d4cd; }
.ps-flag { font-size:13px; line-height:1; }
.ps-locale-name { max-width:11ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ps-dots { color:#a3a3a3; }
.ps-locale-menu { position:absolute; top:100%; right:0; margin-top:4px; z-index:30; min-width:180px; list-style:none; padding:6px; border:1px solid #404040; border-radius:10px; background:#1f1f1f; box-shadow:0 12px 32px rgba(0,0,0,.5); max-height:320px; overflow:auto; }
.ps-locale-opt { display:flex; align-items:center; gap:8px; width:100%; text-align:left; font:inherit; font-size:13px; padding:7px 9px; border:0; border-radius:7px; background:none; color:#d4d4d4; cursor:pointer; }
.ps-locale-opt:hover { background:#404040; color:#f5f5f5; }
.ps-locale-opt.active { background:#404040; color:#f5f5f5; }
.ps-locale-opt.active .ps-locale-name { color:#00DC82; }
.ps-locale-en { margin-left:auto; font-size:11px; color:#737373; }
.ps-dots { color:#6d766e; }
.ps-locale-menu { position:absolute; top:100%; right:0; margin-top:4px; z-index:30; min-width:180px; list-style:none; padding:6px; border:1px solid #d1d4cd; border-radius:10px; background:#ffffff; box-shadow:0 12px 32px rgba(0,0,0,.5); max-height:320px; overflow:auto; }
.ps-locale-opt { display:flex; align-items:center; gap:8px; width:100%; text-align:left; font:inherit; font-size:13px; padding:7px 9px; border:0; border-radius:7px; background:none; color:#3b463d; cursor:pointer; }
.ps-locale-opt:hover { background:#d1d4cd; color:#3b463d; }
.ps-locale-opt.active { background:#d1d4cd; color:#3b463d; }
.ps-locale-opt.active .ps-locale-name { color:#92b195; }
.ps-locale-en { margin-left:auto; font-size:11px; color:#6d766e; }
.ps-toolbar { display:flex; flex-direction:column; gap:8px; padding:10px 12px 0; }
.ps-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.ps-bar-t { flex:1 1 auto; min-width:0; }
.ps-bar-t b { font-size:15px; color:#f5f5f5; }
.ps-bar-sub { display:block; font-size:12px; color:#a3a3a3; }
.ps-btn { font:inherit; font-size:12px; padding:5px 10px; border:1px solid #404040; border-radius:8px; background:#262626; color:#f5f5f5; cursor:pointer; white-space:nowrap; }
.ps-btn:hover { background:#404040; }
.ps-embed { display:flex; flex-direction:column; gap:6px; padding:8px; border:1px solid #262626; border-radius:8px; background:#171717; }
.ps-embed label { font-size:12px; color:#a3a3a3; display:flex; justify-content:space-between; align-items:center; }
.ps-embed textarea { width:100%; height:58px; font:11px/1.35 ui-monospace,Menlo,monospace; border:1px solid #404040; border-radius:6px; background:#0d0d0d; color:#f5f5f5; padding:6px; resize:vertical; }
.ps-stage { position:absolute; inset:10px 12px 12px; border:1px solid #262626; border-radius:10px; overflow:hidden; background:#171717; }
.ps-bar-t b { font-size:15px; color:#3b463d; }
.ps-bar-sub { display:block; font-size:12px; color:#6d766e; }
.ps-btn { font:inherit; font-size:12px; padding:5px 10px; border:1px solid #d1d4cd; border-radius:8px; background:#d1d4cd; color:#3b463d; cursor:pointer; white-space:nowrap; }
.ps-btn:hover { background:#d1d4cd; }
.ps-embed { display:flex; flex-direction:column; gap:6px; padding:8px; border:1px solid #d1d4cd; border-radius:8px; background:#f3f3f1; }
.ps-embed label { font-size:12px; color:#6d766e; display:flex; justify-content:space-between; align-items:center; }
.ps-embed textarea { width:100%; height:58px; font:11px/1.35 ui-monospace,Menlo,monospace; border:1px solid #d1d4cd; border-radius:6px; background:#ffffff; color:#3b463d; padding:6px; resize:vertical; }
.ps-stage { position:absolute; inset:10px 12px 12px; border:1px solid #d1d4cd; border-radius:10px; overflow:hidden; background:#f3f3f1; }
.ps-pane { position:absolute; inset:0; }
.ps-slot { position:absolute; inset:0; }
.ps-frame { position:absolute; inset:0; width:100%; height:100%; border:0; display:block; background:#171717; }
.ps-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#a3a3a3; font-size:13px; padding:20px; }
.ps-frame { position:absolute; inset:0; width:100%; height:100%; border:0; display:block; background:#f3f3f1; }
.ps-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#6d766e; font-size:13px; padding:20px; }
.ps-hint.err { color:#f87171; }
.ps-hint small { color:#737373; }
.ps-hint small { color:#6d766e; }
`
// Reader-facing capability names, most-composed first (5 → 1). Mirrors generate-gallery's
@ -301,12 +313,21 @@ export default defineComponent({
this.localeOpen = false
saveLocale(code)
try {
const slot = this._slots && this._slots[this.activeKey]
const el = slot && slot.firstElementChild
if (el && el.setAttribute && this.active) {
const pc = { profile: this.active.label, instanceId: 'ps-' + this.activeKey.replace(/\//g, '-'), locale: code }
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
el.setAttribute('profile-config', JSON.stringify(pc))
const row = this.active
if (row && row.mode === 'iframe') {
// iframe panes carry locale as a URL param; re-point the src so the nested SPA re-mounts in
// the new language. Re-arm the load watchdog (via loaded=false → select) so a broken
// re-load still shows this pane's error overlay, not a blank frame.
row.loaded = false
this.select(this.activeKey)
} else {
const slot = this._slots && this._slots[this.activeKey]
const el = slot && slot.firstElementChild
if (el && el.setAttribute && row) {
const pc = { profile: row.label, instanceId: 'ps-' + this.activeKey.replace(/\//g, '-'), locale: code }
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
el.setAttribute('profile-config', JSON.stringify(pc))
}
}
} catch { /* non-fatal: the switcher still persists + updates the UI */ }
},
@ -371,7 +392,17 @@ export default defineComponent({
const row = this.rows.find(r => r.key === key)
if (!row) return
if (row.mode === 'iframe') {
if (!row.loaded) { row.src = this.withToken(this.resolve(row.leaf)); row.loaded = true }
if (!row.loaded) {
row.src = this.withToken(this.resolve(row.leaf))
row.loaded = true
row.state = 'loading'
// Watchdog: if no load event arrives in 20s, show this pane's error overlay rather than a
// silent blank frame. onFrameLoad clears it (and un-errors a late load).
if (row._frameTimer) clearTimeout(row._frameTimer)
row._frameTimer = setTimeout(() => {
if (row.state !== 'ready') { row.state = 'error'; row.error = 'nested plugin did not load in time' }
}, 20000)
}
} else if (!row.mounted) {
this.mountElement(row)
}
@ -382,6 +413,10 @@ export default defineComponent({
const q = []
if (tk) q.push('tk=' + encodeURIComponent(tk))
else if (tokenUrl) q.push('tokenUrl=' + encodeURIComponent(tokenUrl))
// Push the studio's current preview language down to the nested SPA as a URL param — the
// leaf pages read `?locale=` and inject it into their element's profile-config.locale. The
// studio is the "simulate DX-life" surface, so it always carries a concrete locale.
if (this.locale) q.push('locale=' + encodeURIComponent(this.locale))
if (!q.length) return url
return url + (url.indexOf('?') === -1 ? '?' : '&') + q.join('&')
},
@ -390,21 +425,49 @@ export default defineComponent({
row.state = 'loading'
const jsUrl = this.resolve(row.bundleJs)
loadScriptOnce(jsUrl).then(() => {
const slot = this._slots && this._slots[row.key]
if (!slot) { row.state = 'error'; row.error = 'no slot'; return }
slot.innerHTML = ''
const el = document.createElement(row.tag)
const pc = { profile: row.label, instanceId: 'ps-' + row.key.replace(/\//g, '-') }
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
el.setAttribute('profile-config', JSON.stringify(pc))
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
slot.appendChild(el)
row.state = 'ready'
// Everything below is per-nested-plugin machinery; a throw here (bad tag name, a nested
// element that blows up in its connectedCallback, a serialise failure) must NOT crash the
// studio — it lands in THIS row's error pane. Hence the try/catch around the DOM mount, on
// top of the .catch() that already handles a failed SCRIPT load.
try {
const slot = this._slots && this._slots[row.key]
if (!slot) { row.state = 'error'; row.error = 'no slot'; return }
slot.innerHTML = ''
const el = document.createElement(row.tag)
// Include the current preview locale so a pane mounted before any language switch already
// renders in the studio's active language (ProfileLoader reads profile-config.locale).
const pc = { profile: row.label, instanceId: 'ps-' + row.key.replace(/\//g, '-'), locale: this.locale }
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
el.setAttribute('profile-config', JSON.stringify(pc))
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
slot.appendChild(el)
row.state = 'ready'
} catch (e) {
row.state = 'error'
row.error = 'mount failed: ' + ((e && e.message) || String(e))
}
}).catch((e) => {
row.state = 'error'
row.error = (e && e.message) || String(e)
})
},
// iframe panes: a leaf that 404s or errors inside its own document would otherwise show a
// silent blank frame. onload fires for BOTH a good load and an error document, so we can't tell
// them apart cross-origin — instead we arm a watchdog when the src is set (in select) and treat
// a load that arrives as success (clear the watchdog + any error). onFrameError catches the rare
// same-origin error event. Either way ONE bad nested plugin is contained to its own pane.
onFrameLoad(row) {
if (row._frameTimer) { clearTimeout(row._frameTimer); row._frameTimer = null }
// A load event means the frame document resolved; leave any deeper in-frame failure to the
// leaf's own error UI. Only clear a watchdog-set error if the frame genuinely loaded late.
if (row.state === 'error' && row.error === 'nested plugin did not load in time') { row.state = 'ready'; row.error = '' }
else if (row.state !== 'error') row.state = 'ready'
},
onFrameError(row) {
if (row._frameTimer) { clearTimeout(row._frameTimer); row._frameTimer = null }
row.state = 'error'
row.error = 'the nested plugin failed to load'
},
copyText(text, btn) {
const ok = () => { const t = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = t }, 1200) }
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, () => {})
@ -419,37 +482,37 @@ export default defineComponent({
<style>
/* The shell (top bar + rail + body) is the shared VerticalTabsLayout. Only the studio's own
content-area styles remain here; mirror of PS_SHADOW_CSS above. Keep the two in sync.
Reskinned to the dx-life admin tokens (neutral surfaces, green #00DC82 accent) to match the shell. */
Reskinned to the dx-life admin tokens (neutral surfaces, green #92b195 accent) to match the shell. */
.ps-logo { display: inline-flex; align-items: center; }
.ps-logo svg { display: block; }
.ps-count { color: #a3a3a3; }
.ps-count { color: #6d766e; }
.ps-locale { position: relative; display: inline-flex; }
.ps-locale-btn { display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: 12px; padding: 4px 9px; border: 1px solid #404040; border-radius: 8px; background: #262626; color: #f5f5f5; cursor: pointer; }
.ps-locale-btn:hover { background: #404040; }
.ps-locale-btn { display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: 12px; padding: 4px 9px; border: 1px solid #d1d4cd; border-radius: 8px; background: #d1d4cd; color: #3b463d; cursor: pointer; }
.ps-locale-btn:hover { background: #d1d4cd; }
.ps-flag { font-size: 13px; line-height: 1; }
.ps-locale-name { max-width: 11ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ps-dots { color: #a3a3a3; }
.ps-locale-menu { position: absolute; top: 100%; right: 0; margin-top: 4px; z-index: 30; min-width: 180px; list-style: none; padding: 6px; border: 1px solid #404040; border-radius: 10px; background: #1f1f1f; box-shadow: 0 12px 32px rgba(0,0,0,.5); max-height: 320px; overflow: auto; }
.ps-locale-opt { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; font: inherit; font-size: 13px; padding: 7px 9px; border: 0; border-radius: 7px; background: none; color: #d4d4d4; cursor: pointer; }
.ps-locale-opt:hover { background: #404040; color: #f5f5f5; }
.ps-locale-opt.active { background: #404040; color: #f5f5f5; }
.ps-locale-opt.active .ps-locale-name { color: #00DC82; }
.ps-locale-en { margin-left: auto; font-size: 11px; color: #737373; }
.ps-dots { color: #6d766e; }
.ps-locale-menu { position: absolute; top: 100%; right: 0; margin-top: 4px; z-index: 30; min-width: 180px; list-style: none; padding: 6px; border: 1px solid #d1d4cd; border-radius: 10px; background: #ffffff; box-shadow: 0 12px 32px rgba(0,0,0,.5); max-height: 320px; overflow: auto; }
.ps-locale-opt { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; font: inherit; font-size: 13px; padding: 7px 9px; border: 0; border-radius: 7px; background: none; color: #3b463d; cursor: pointer; }
.ps-locale-opt:hover { background: #d1d4cd; color: #3b463d; }
.ps-locale-opt.active { background: #d1d4cd; color: #3b463d; }
.ps-locale-opt.active .ps-locale-name { color: #92b195; }
.ps-locale-en { margin-left: auto; font-size: 11px; color: #6d766e; }
.ps-toolbar { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px 0; }
.ps-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ps-bar-t { flex: 1 1 auto; min-width: 0; }
.ps-bar-t b { font-size: 15px; color: #f5f5f5; }
.ps-bar-sub { display: block; font-size: 12px; color: #a3a3a3; }
.ps-btn { font: inherit; font-size: 12px; padding: 5px 10px; border: 1px solid #404040; border-radius: 8px; background: #262626; color: #f5f5f5; cursor: pointer; white-space: nowrap; }
.ps-btn:hover { background: #404040; }
.ps-embed { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid #262626; border-radius: 8px; background: #171717; }
.ps-embed label { font-size: 12px; color: #a3a3a3; display: flex; justify-content: space-between; align-items: center; }
.ps-embed textarea { width: 100%; height: 58px; font: 11px/1.35 ui-monospace, Menlo, monospace; border: 1px solid #404040; border-radius: 6px; background: #0d0d0d; color: #f5f5f5; padding: 6px; resize: vertical; }
.ps-stage { position: absolute; inset: 10px 12px 12px; border: 1px solid #262626; border-radius: 10px; overflow: hidden; background: #171717; }
.ps-bar-t b { font-size: 15px; color: #3b463d; }
.ps-bar-sub { display: block; font-size: 12px; color: #6d766e; }
.ps-btn { font: inherit; font-size: 12px; padding: 5px 10px; border: 1px solid #d1d4cd; border-radius: 8px; background: #d1d4cd; color: #3b463d; cursor: pointer; white-space: nowrap; }
.ps-btn:hover { background: #d1d4cd; }
.ps-embed { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid #d1d4cd; border-radius: 8px; background: #f3f3f1; }
.ps-embed label { font-size: 12px; color: #6d766e; display: flex; justify-content: space-between; align-items: center; }
.ps-embed textarea { width: 100%; height: 58px; font: 11px/1.35 ui-monospace, Menlo, monospace; border: 1px solid #d1d4cd; border-radius: 6px; background: #ffffff; color: #3b463d; padding: 6px; resize: vertical; }
.ps-stage { position: absolute; inset: 10px 12px 12px; border: 1px solid #d1d4cd; border-radius: 10px; overflow: hidden; background: #f3f3f1; }
.ps-pane { position: absolute; inset: 0; }
.ps-slot { position: absolute; inset: 0; }
.ps-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #171717; }
.ps-hint { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: #a3a3a3; font-size: 13px; padding: 20px; }
.ps-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #f3f3f1; }
.ps-hint { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: #6d766e; font-size: 13px; padding: 20px; }
.ps-hint.err { color: #f87171; }
.ps-hint small { color: #737373; }
.ps-hint small { color: #6d766e; }
</style>

View file

@ -9,6 +9,9 @@
* migrated bundle behaves identically.
*/
import { lbl } from './src/composables/useDashboardAggregations.js'
import { RELIGION_FAMILIES } from '@map/colors/religion.js'
import { canonicalFamilyName } from '@map/colors/language-family.js'
import langFamilyByIso from '@map/data/langFamilyByIso.json'
// same numeric coercion the DataTable uses for its num columns
const num = (v) => { if (typeof v === 'number') return v; const n = parseFloat(String(v ?? '').replace(/[^0-9.\-]/g, '')); return isNaN(n) ? null : n }
@ -24,7 +27,11 @@ const code = (v) => (v && typeof v === 'object' ? (v.value ?? v.label ?? '') : (
export default {
// ── identity ──
id: 'doxa-research-dashboard',
title: 'Research Dashboard',
// "Big Picture Research Dashboard" (Driver 2026-09-20). The share button embeds THIS whole
// element on a partner site, so the title is what a visitor reads there — it has to name the
// product, not just the page. "Big Picture" is also the cap-4 composite's name and tab-1 of the
// cap-5 SPA, so one term now runs consistently from the tab through to an embedded partner view.
title: 'Big Picture Research Dashboard',
element: 'doxa-research-dashboard',
// ── data source ── (consumed by useDashboardData; the arg is currently advisory —
@ -37,29 +44,104 @@ export default {
// ── facet accessors ── THE single former-duplicated FACET_ACC, declared ONCE.
facets: {
religion: { label: 'Religion', get: (r) => lbl(r.religion) },
// ── Religion TIER 1 (card BUILD-STACKED-TREE-LEGEND) ──
// The Doxa API's religion code is 1-3 chars whose FIRST LETTER is the family
// ('MSN' → 'M' → Islam), which is precisely how the cap-3 map paints religion pins
// (library/colors/religion.js:104 slices char 0). Naming the family here turns Religion
// from a flat list of ~25 codes into the 2-tier tree the other three axes already are.
//
// Returns the human family NAME, not the letter: the value an accessor returns is BOTH the
// display label and the bus filter value, and codes must never reach a user-facing menu
// (same rule the People axis follows). A separate facet key — not a second accessor on
// `religion` — because the bus matches on the accessor's exact output, so one key = one
// projection keeps the byte-match contract intact.
// Measured live (2,107 rows, 2026-09-20): 10 families, all named in RELIGION_FAMILIES,
// family totals sum exactly to the leaf-code totals.
religion_family: {
label: 'Religion Family',
get: (r) => {
const code = r.religion && typeof r.religion === 'object' ? r.religion.value : r.religion
const c = String(code || '').charAt(0).toUpperCase()
return c && RELIGION_FAMILIES[c] ? RELIGION_FAMILIES[c] : ''
},
// The family LETTER, used only to look up the map's religion palette. Never displayed,
// never sent to the bus.
famKey: (r) => {
const code = r.religion && typeof r.religion === 'object' ? r.religion.value : r.religion
const c = String(code || '').charAt(0).toUpperCase()
return RELIGION_FAMILIES[c] ? c : ''
},
},
wagf_region: { label: 'Region', get: (r) => lbl(r.wagf_region) },
wagf_block: { label: 'Block', get: (r) => lbl(r.wagf_block) },
// "Bloc" (NOT "Block") — a bloc is a SUBSET of a region, per the WAGF place hierarchy
// region > bloc > country. Display label only; the facet KEY stays `wagf_block` because the
// API field, the fieldMappings, and every consumer prop (wagfBlock) are spelled that way.
wagf_block: { label: 'Bloc', get: (r) => lbl(r.wagf_block) },
country_code: { label: 'Country', get: (r) => lbl(r.country_code), geoIso: (r) => r.country_code && r.country_code.value },
engagement_status: { label: 'Status', get: (r) => lbl(r.engagement_status) },
language_family: { label: 'Language Family', get: (r) => lbl(r.imb_language_family) },
// ── Language TIER 1 — API value FIRST, ISO lookup as FALLBACK ──
// MEASURED on the live API (2,107 rows, 2026-09-20): `imb_language_family` is null on
// 2,073 of them — it is populated for just 34 rows (1.6%). Read alone, the Language tree
// (one of the Driver's four axes) renders as a near-empty stub and looks broken.
//
// `primary_language` IS populated on 100% of rows and its `.value` is an ISO-639-3 code
// ('abf'), and the library already ships @map/data/langFamilyByIso.json (815 entries).
// Resolving through it lifts coverage from 34 → 2,031 rows (1.6% → 96.4%).
//
// The API's own value stays AUTHORITATIVE and is only fallen back on when absent — verified
// safe: across every row where BOTH exist, the two agree 8/8 with 0 disagreements, so the
// fallback never contradicts upstream, it only fills gaps.
//
// canonicalFamilyName() case-folds onto the palette's spelling (language-family.js:150) so
// the value this returns is both the display label AND a direct palette key — one projection,
// byte-match contract intact.
language_family: {
label: 'Language Family',
get: (r) => {
const api = lbl(r.imb_language_family)
if (api) return canonicalFamilyName(api)
const iso = r.primary_language && typeof r.primary_language === 'object' ? r.primary_language.value : null
const hit = iso && langFamilyByIso[iso]
return hit ? canonicalFamilyName(hit) : ''
},
},
primary_language: { label: 'Language', get: (r) => lbl(r.primary_language) },
// People tier accessors — key by CODE (DashboardFacetRail resolves code→human name via
// useAffinityBlockLegendData). Mirrors research-map's affinity-block → cluster → PG hierarchy.
affinity_bloc: { label: 'Affinity Bloc', get: (r) => code(r.rop1) },
cluster: { label: 'Cluster', get: (r) => code(r.imb_reg_of_people_2) },
people_group: { label: 'People Group', get: (r) => code(r.imb_reg_of_people_25) },
// ── the PEOPLE axis for the research filter DROPDOWNS (card BUILD-FILTER-PEOPLE-AXIS-MISSING) ──
// DX-life's research page filters people on `rop1` (UupgsList.vue: name="rop1",
// label "People Group"), showing rop1.LABEL in the menu. The facets above key People by
// CODE so the rail can cross-reference the map's semantic tree — codes must never reach a
// user-facing menu. This is the LABEL projection of the SAME rop1 field: a separate facet
// key (not a second accessor on affinity_bloc) because the selection bus matches on the
// accessor's exact output, so one key = one projection keeps the byte-match contract intact.
rop1: { label: 'People', get: (r) => lbl(r.rop1) },
},
// ── facet rail sections ── (DashboardFacetRail) — ONE centralized semantic tree mirroring the
// research-map's tree, top→bottom: People → Place → Language → Religion → Status.
// `tree:'affinity'` tells the rail to resolve the People codes to human names via the shared
// useAffinityBlockLegendData composable (single source of truth with the map's semantic tree).
// ── THE FOUR STACKED TREES (card BUILD-STACKED-TREE-LEGEND) ──
// Driver: "if you had a team of people trying to fill the globe with the gospel… they need to be
// able to search by people, place, language, religion, and find who has the gospel resources and
// who doesn't." The legend is how a researcher navigates those FOUR axes, in THIS order.
//
// `axis` names which cap-3 colour strategy paints the tree (see useTreeLegendColors.js) — the
// legend reads the map's palettes, so the two can never drift. Status is NOT one of the four
// axes; it stays below them as a flat utility list (`axis:null` = uncoloured), so the four
// trees read as the stack the Driver asked for rather than as five peers.
facetRail: [
{ id: 'people', title: 'People', drill: ['affinity_bloc', 'cluster', 'people_group'], tree: 'affinity', topN: [30, 40, 60] },
{ id: 'place', title: 'Place', drill: ['wagf_region', 'wagf_block', 'country_code'] },
{ id: 'language', title: 'Language', drill: ['language_family', 'primary_language'] },
{ id: 'religion', title: 'Religion', facet: 'religion', topN: 24 },
{ id: 'status', title: 'Status', facet: 'engagement_status', topN: 16 },
{ id: 'people', title: 'People', axis: 'people', drill: ['affinity_bloc', 'cluster', 'people_group'], tree: 'affinity', topN: [30, 40, 60] },
{ id: 'place', title: 'Place', axis: 'place', drill: ['wagf_region', 'wagf_block', 'country_code'] },
{ id: 'language', title: 'Language', axis: 'language', drill: ['language_family', 'primary_language'] },
// Religion was the odd flat axis; it is now the 2-tier tree the data always supported
// (family → religion). See the `religion_family` facet above.
{ id: 'religion', title: 'Religion', axis: 'religion', drill: ['religion_family', 'religion'], topN: [12, 24] },
{ id: 'status', title: 'Status', axis: null, facet: 'engagement_status', topN: 16 },
],
// ── KPI strip ── (computed by useDashboardShell; mirrors the original 6 stat computeds)
@ -98,6 +180,27 @@ export default {
fields: ['religion', 'primary_language', 'people_praying', 'adopted_by_churches', 'engagement_status'],
},
// ── research filters (DX-life clone) ── header-1 search bar + header-2 expand-filters
// dropdown SET, baked via DashboardResearchFilters → the shared @map SearchFilterHeader.
// `dropdowns` = the facet keys rendered as dropdowns (each returns a LABEL the bus matches on,
// so a pick byte-matches filteredRows); `searchKeys` = the facet keys the free-text search
// matches across (mirrors DX-life's Fuse keys: name/place/region/language/religion — here the
// name comes from the `columns` string set and these facet labels). One horizontal row,
// horizontal-scroll on overflow (card BUILD-DROPDOWNS-HORIZONTAL-SCROLL-ONE-ROW).
// ── AXIS ORDER: People · Place · Language · Religion ──
// Place expands to its three tiers region > bloc > country, so the row reads
// [People] [Region] [Bloc] [Country] [Language] [Religion].
// People (`rop1`) was MISSING entirely (card BUILD-FILTER-PEOPLE-AXIS-MISSING) — the data was
// always there (it is a defaultField on the pray-tools source and DX-life filtered on it), only
// the dropdown was dropped when this list was first written.
researchFilters: {
dropdowns: ['rop1', 'wagf_region', 'wagf_block', 'country_code', 'primary_language', 'religion'],
searchPlaceholder: 'Search people groups, place, language, religion…',
},
// `rop1` included so free-text search matches the People axis too (DX-life's Fuse key set
// includes 'rop1.label' — UupgsList.vue:299).
searchKeys: ['name', 'rop1', 'wagf_region', 'wagf_block', 'country_code', 'primary_language', 'religion', 'engagement_status'],
// ── search header ── (DOXA-LIB-3) the 4 facet search bars (DashboardSearchHeader).
// Each bar = one standalone GeocoderComponent consuming useGeocoderSearch via a
// busAdapter sink. `context` scopes the geocoder's result ordering; `kinds` is the

View file

@ -23,10 +23,33 @@ import { defineCustomElement } from 'vue'
import { createPinia } from 'pinia'
import ProfileLoader from '@map/ProfileLoader.vue'
import { createAppI18n } from '@map/i18n/index.js'
import { registerStrategies } from '@map/colors/_registry.js'
import affinityBlock from '../doxa-research-map/src/colors/affinity-block.js'
import doxaRegion from '../doxa-research-map/src/colors/doxa-region.js'
// ─── Bundle-private profile registry ─────────────────────────────────────────
const profileModules = import.meta.glob('./*.vue')
// ─── Bundle-private color strategies (card BUILD-STACKED-TREE-LEGEND) ────────
// The stacked tree legend colours its People and Place trees from the cap-3 research map's
// palettes, so the legend can never drift from the map's pins. Those two strategies are
// PROFILE-LOCAL to doxa-research-map (registered there by its own src/colors glob — see
// plugin-designs/doxa-research-map/index.js:38), so the dashboard bundle's registry does not
// contain them unless we register them here too.
//
// This MATTERS because the failure is silent: getColorStrategy() falls back to language-family
// for an unregistered mode, and useAffinityBlockLegendData.js:57 guards on propertyKey and
// returns {} — so without this, every People swatch would quietly be neutral grey and nothing
// would error. Registering the SAME modules means one palette, not a copied one.
//
// Imported EXPLICITLY rather than via import.meta.glob: a glob is resolved relative to this
// file by Vite and a cross-bundle relative glob that stops matching fails silently (zero
// strategies registered, grey swatches), whereas a named import fails loudly at build time.
registerStrategies({
'./affinity-block.js': { default: affinityBlock },
'./doxa-region.js': { default: doxaRegion },
})
// ─── Mapbox RTL text plugin — load ONCE before any map instance ──────────────
if (typeof window !== 'undefined' && window.mapboxgl?.setRTLTextPlugin) {
try {

View file

@ -11,11 +11,16 @@
* read their per-dashboard data from inject('dashboardConfig'). Behaviour is identical to the
* original research-dashboard — only the duplication was hoisted into the library.
*
* DOXA-LIB-3: the 4-bar DashboardSearchHeader (Search people / place / language / religion)
* sits between the topbar and the KPI strip. Each bar is a standalone GeocoderComponent
* consuming the SAME useGeocoderSearch composable the map profiles use — via a busAdapter
* sink that lands the resolved {kind,value} on the selection bus (bus.setFilter /
* toggleSelect), so a pick narrows every linked panel. config.search drives the bars.
* 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 } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
@ -23,7 +28,7 @@ import { useDashboardShell, fmt } from './src/composables/useDashboardShell.js'
import { useDashboardLayout } from './src/composables/useDashboardLayout.js'
import {
DashboardTopBar,
DashboardSearchHeader,
DashboardResearchFilters,
DashboardFacetRail,
DashboardMapPanel,
DashboardDataTable,
@ -64,13 +69,13 @@ function kpiText(k) {
return fmt(k.value) // count + sum
}
// sticky export bar — only when a selection exists.
const selCount = computed(() => (bus.selectionSet ? bus.selectionSet.size : 0))
function exportSelection() { exporter.exportCsv() }
// 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() }
// Restore the collapsed table: the chevron lives inside the now-hidden table cell, so this
// floating affordance re-opens it by emitting the same bus event the chevron uses (false = expand).
function restoreTable() { bus.emit('toggleTable', false) }
function toggleFacet() { bus.emit('toggleFacet', !facetCollapsed.value) }
function toggleRail() { bus.emit('toggleRail', !railCollapsed.value) }
function onBarClick(e) {
@ -82,10 +87,30 @@ function onBarClick(e) {
// 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
_qa-cycle/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.
--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;
--rd-accent:#e67e22; --rd-info:#2b6cb0; --rd-line:#e2e6ec; --rd-bg:#f7f9fb;
--rd-ink:#1f2733; --rd-muted:#6b7686; --rd-panel:#ffffff;
--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);
--facet-w:248px; --rail-w:312px;
--fs-xs:10px; --fs-sm:11px; --fs-md:12px; --fs-lg:13px; --fs-xl:16px;
--r-lg:10px; --r-md:6px; --r-pill:999px; }
@ -93,20 +118,24 @@ useShadowStyles(`
.rd-shell{ position:relative; display:grid; width:100%; height:100%; min-height:560px; box-sizing:border-box;
grid-template-columns: var(--facet-w,248px) minmax(0,1fr) var(--rail-w,312px);
grid-template-rows: 52px auto 42px 1fr var(--table-h,34%);
grid-template-areas: "topbar topbar topbar" "search search search" "stats stats stats" "facets map rail" "facets table rail";
gap:1px; background:var(--rd-line,#e2e6ec); container-type:inline-size;
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,#1f2733); }
color:var(--rd-ink, #3b463d); }
.rd-shell > [data-region="topbar"]{ grid-area:topbar; min-width:0; }
.rd-shell > [data-region="search"]{ grid-area:search; 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,#f7f9fb); }
.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; }
.rd-shell.rd-table-collapsed{ grid-template-rows:52px auto 42px 1fr 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:52px auto 42px 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(). */
@ -116,18 +145,13 @@ useShadowStyles(`
/* 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 #dde3ec; background:#fff; color:#3a4456; border-radius:5px; font-size:11px;
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:#eef2f7; }
.rd-edge-caret:hover{ background:var(--dx-hover,rgba(59,70,61,.06)); }
.rd-edge-facet{ left:5px; } .rd-edge-rail{ right:5px; }
/* floating restore affordance — shown only while the table is collapsed (desktop). Pinned to
the bottom edge of the center map cell so the map's 1fr can fill the whole center span. */
.rd-table-restore{ position:absolute; left:50%; bottom:10px; transform:translateX(-50%); z-index:4;
display:inline-flex; align-items:center; gap:6px; cursor:pointer; user-select:none;
font-family:inherit; font-size:var(--fs-md,12px); font-weight:700; color:#fff;
background:var(--rd-ink,#1f2733); border:1px solid #ffffff33; border-radius:var(--r-pill,999px);
padding:5px 14px; box-shadow:0 2px 10px #0003; backdrop-filter:blur(4px); }
.rd-table-restore:hover{ background:#000; color:var(--rd-accent,#e67e22); }
/* (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) */
.rd-shell.rd-slim-rail{ --rail-w:240px; }
.rd-shell.rd-slim-facet{ --facet-w:196px; }
/* Desktop drag-resize handles (useDashboardLayout). Thin, transparent until hover/active; each is
@ -146,26 +170,45 @@ useShadowStyles(`
.rd-stats{ display:flex; align-items:center; gap:var(--s5,24px); height:100%; padding:0 var(--s4,16px);
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,#1f2733); transition:color .2s; }
.rd-stats .kpi .l{ font-size:10px; text-transform:uppercase; letter-spacing:.4px; color:var(--rd-muted,#6b7686); }
.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; }
.rd-shell.rd-narrow{ grid-template-columns:1fr; grid-template-rows:52px auto 42px minmax(0,1fr) 46px;
grid-template-areas:"topbar" "search" "stats" "body" "seg"; }
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="search"]{ grid-area:search; }
.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:#fff; border-top:1px solid var(--rd-line,#e2e6ec); font-size:12px; font-weight:700; color:#5a6577; cursor:pointer; }
.rd-seg button.on{ color:var(--rd-ink,#1f2733); box-shadow:inset 0 -3px 0 var(--rd-accent,#e67e22); }
.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); }
.rd-exportbar{ position:absolute; left:0; right:0; bottom:0; display:flex; align-items:center; gap:10px; padding:8px 14px;
background:var(--rd-ink,#1f2733); color:#fff; z-index:50; box-shadow:0 -2px 10px #0003; animation:rd-slideup .2s ease; }
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')
</script>
@ -174,7 +217,7 @@ useShadowStyles(`
:class="{ 'rd-narrow': isNarrow, 'rd-slim-rail': slimRail, 'rd-slim-facet': slimFacet, 'rd-table-collapsed': tableCollapsed, 'rd-facet-collapsed': facetCollapsed, 'rd-rail-collapsed': railCollapsed }"
:data-row-count="rowCount" :data-loading="isLoading ? '1' : '0'" :data-error="loadError || ''" :data-narrow="isNarrow ? '1' : '0'">
<DashboardTopBar />
<DashboardSearchHeader />
<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>
@ -185,8 +228,8 @@ useShadowStyles(`
<DashboardFacetRail v-show="showFacets" />
<div class="rd-cell-map" data-region="map" v-show="showMap">
<DashboardMapPanel />
<button v-if="tableCollapsed && !isNarrow" class="rd-table-restore" data-table-restore
type="button" title="Show the people-groups table" @click="restoreTable">▴ Show table</button>
<!-- (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>
@ -194,7 +237,9 @@ useShadowStyles(`
:title="railCollapsed ? 'Show charts' : 'Hide charts — grow the map'"
@click="toggleRail">{{ railCollapsed ? '◂' : '▸' }}</button>
</div>
<div class="rd-cell-table" data-region="table" v-show="showTable && (isNarrow || !tableCollapsed)"><DashboardDataTable /></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 />

View file

@ -6,10 +6,18 @@
*/
import { inject, computed } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
const bus = inject('selectionBus', null)
const cfg = inject('dashboardConfig', {})
const filterSet = bus?.filterSet || {}
const crumbs = computed(() => Object.entries(filterSet).map(([k, v]) => ({ k, v: String(v) })))
// A crumb is a user-facing surface, so People CODES are resolved to names here exactly as the
// filter chips do (dashboard.config.js:54-57 — codes must never reach a user-facing menu).
// DISPLAY ONLY: `k` is what popTo() acts on and the stored value is never rewritten, so the bus's
// byte-match contract is untouched.
const { labelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject('searchSourceId', null))
const crumbs = computed(() => Object.entries(filterSet).map(([k, v]) => ({ k, v: String(labelFor(k, v)) })))
function popTo(k) {
const keys = Object.keys(filterSet); const i = keys.indexOf(k)
@ -24,10 +32,10 @@ function onClick(e) {
}
useShadowStyles(`
.rd-breadcrumb{ display:flex; align-items:center; gap:4px; font-size:11px; color:#cdd3df; overflow:hidden; }
.rd-breadcrumb{ display:flex; align-items:center; gap:4px; font-size:11px; color:var(--dx-line, #d1d4cd); overflow:hidden; }
.rd-crumb{ cursor:pointer; padding:2px 6px; border-radius:4px; white-space:nowrap; }
.rd-crumb:hover{ background:#ffffff1f; color:#fff; }
.rd-crumb.root{ color:#9aa4b8; } .rd-crumb.cur{ color:#fff; font-weight:700; }
.rd-crumb.root{ color:var(--dx-muted, #6d766e); } .rd-crumb.cur{ color:#fff; font-weight:700; }
.rd-sep{ opacity:.45; }
`, 'rd-breadcrumb')
</script>

View file

@ -9,6 +9,7 @@
import { inject, computed } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { aggregate, buildDonut, buildBars, lbl } from '../../composables/useDashboardAggregations.js'
import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDashboardShell.js'
const dash = inject('dashboardData', null)
const bus = inject('selectionBus', null)
@ -18,11 +19,13 @@ const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f
// Crossfilter rule: aggregate each chart over rows filtered by all OTHER active facets EXCEPT its
// own — so filtering a chart by its own facet does NOT collapse it to a single slice. Mirrors the
// `rowsExcluding([facet])` pattern in DashboardFacetRail.vue.
const matchSearch = makeSearchMatcher(bus, buildSearchAccessors(cfg))
function rowsExcluding(exclude) {
const rows = dash?.rows?.value || []
const keys = Object.keys(filterSet).filter((k) => !exclude.includes(k))
if (!keys.length) return rows
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
const hasText = !!(bus?.searchTerm?.value || '').trim()
if (!keys.length && !hasText) return rows
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }) && matchSearch(r))
}
const charts = computed(() => (cfg.charts || []).map((c) => {
@ -51,31 +54,52 @@ function onChartClick(e) {
}
useShadowStyles(`
.rd-charts{ display:flex; flex-direction:column; gap:var(--s3,12px); height:100%; min-height:0; padding:var(--s3,12px);
background:var(--rd-panel,#fff); overflow:auto; box-sizing:border-box;
/* SPACE ECONOMY (BUILD-SPACE-ECONOMY-SCROLLBARS-CHARTS-MAP): the rail is a narrow column, so
every horizontal pixel of padding is taken from the charts themselves. Padding is trimmed
12px -> 6px, and the RIGHT padding is dropped to 0 so the thin overlay scrollbar sits flush
against the panel edge instead of floating in a gutter. The 6px is given back to the chart
cards as their own right margin only when no scrollbar is present. */
.rd-charts{ display:flex; flex-direction:column; gap:var(--s2,8px); height:100%; min-height:0;
padding:6px 2px 6px 6px;
background:var(--rd-panel,#fff); overflow-y:auto; overflow-x:hidden; box-sizing:border-box;
scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
.rd-charts .rd-h{ font-weight:700; font-size:var(--fs-md,12px); text-transform:uppercase; letter-spacing:.5px; color:var(--rd-muted,#6b7686); }
.rd-chart{ border:1px solid var(--rd-line,#e2e6ec); border-radius:var(--r-lg,10px); background:var(--rd-bg,#f7f9fb); padding:10px; }
.rd-chart .rd-ct{ font-weight:700; font-size:var(--fs-sm,11px); color:var(--rd-ink,#1f2733); margin-bottom:6px; }
.rd-charts::-webkit-scrollbar{ width:8px; height:8px; }
.rd-charts::-webkit-scrollbar-track{ background:transparent; }
.rd-charts::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:8px; }
.rd-charts::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
.rd-charts .rd-h{ font-weight:700; font-size:var(--fs-md,12px); text-transform:uppercase; letter-spacing:.5px; color:var(--rd-muted, #6d766e); }
/* 10px -> 7px 6px: reclaims ~8px of width per card for the plot area itself. */
.rd-chart{ border:1px solid var(--rd-line, #d1d4cd); border-radius:var(--r-lg,10px); background:var(--rd-bg, #f3f3f1); padding:7px 6px; }
.rd-chart .rd-ct{ font-weight:700; font-size:var(--fs-sm,11px); color:var(--rd-ink, #3b463d); margin-bottom:6px; }
.rd-donutwrap{ display:flex; align-items:center; gap:10px; }
.rd-donut{ width:108px; height:108px; flex:0 0 auto; }
.rd-slice{ cursor:pointer; transition:opacity .18s, transform .18s; transform-box:view-box; transform-origin:center; }
.rd-slice:hover{ opacity:.82; }
/* Active slice pops out (scale) with a clear ink stroke; when a slice is selected the rest dim. */
.rd-donut.has-sel .rd-slice{ opacity:.42; }
.rd-slice.on, .rd-donut.has-sel .rd-slice.on{ opacity:1; stroke:var(--rd-ink,#1f2733); stroke-width:2.5;
.rd-slice.on, .rd-donut.has-sel .rd-slice.on{ opacity:1; stroke:var(--rd-ink, #3b463d); stroke-width:2.5;
transform:scale(1.08); filter:drop-shadow(0 1px 2px rgba(31,39,51,.35)); }
.rd-legend{ display:flex; flex-direction:column; gap:2px; font-size:10.5px; color:#3a4456; max-height:108px; overflow:auto; flex:1 1 auto; }
/* The donut legend is the WORST offender the Driver reported: a short, always-scrolling list
whose default scrollbar ate a large share of an already narrow column. Thin overlay bar,
transparent track, and no right padding so it hugs the card edge. */
.rd-legend{ display:flex; flex-direction:column; gap:2px; font-size:10.5px; color:var(--dx-text, #3b463d); max-height:108px;
overflow-y:auto; overflow-x:hidden; flex:1 1 auto; min-width:0;
scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent; }
.rd-legend::-webkit-scrollbar{ width:6px; height:6px; }
.rd-legend::-webkit-scrollbar-track{ background:transparent; }
.rd-legend::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:6px; }
.rd-legend::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
.rd-li{ display:flex; align-items:center; gap:5px; cursor:pointer; white-space:nowrap; padding:1px 2px; border-radius:3px; }
.rd-li:hover{ background:#eef2f8; } .rd-li.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink,#1f2733); }
.rd-li i{ width:9px; height:9px; border-radius:2px; flex:0 0 auto; } .rd-li b{ margin-left:auto; color:var(--rd-muted,#6b7686); font-variant-numeric:tabular-nums; }
.rd-li:hover{ background:var(--dx-surface, #f3f3f1); } .rd-li.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink, #3b463d); }
.rd-li i{ width:9px; height:9px; border-radius:2px; flex:0 0 auto; } .rd-li b{ margin-left:auto; color:var(--rd-muted, #6d766e); font-variant-numeric:tabular-nums; }
.rd-bars{ display:flex; flex-direction:column; gap:3px; }
.rd-bar{ display:flex; align-items:center; gap:6px; cursor:pointer; font-size:10.5px; padding:1px 2px; border-radius:3px; }
.rd-bar:hover{ background:#eef2f8; } .rd-bar.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink,#1f2733); }
.rd-bl{ flex:0 0 84px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#3a4456; }
.rd-bt{ flex:1 1 auto; height:11px; background:#eef1f6; border-radius:3px; overflow:hidden; }
.rd-bar:hover{ background:var(--dx-surface, #f3f3f1); } .rd-bar.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink, #3b463d); }
.rd-bl{ flex:0 0 84px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--dx-text, #3b463d); }
.rd-bt{ flex:1 1 auto; height:11px; background:var(--dx-surface, #f3f3f1); border-radius:3px; overflow:hidden; }
.rd-bf{ display:block; height:100%; border-radius:3px; transition:width .35s cubic-bezier(.4,0,.2,1); }
.rd-bv{ flex:0 0 auto; color:var(--rd-muted,#6b7686); font-variant-numeric:tabular-nums; min-width:30px; text-align:right; }
.rd-bv{ flex:0 0 auto; color:var(--rd-muted, #6d766e); font-variant-numeric:tabular-nums; min-width:30px; text-align:right; }
`, 'rd-charts')
</script>

View file

@ -11,8 +11,11 @@
* 'N selected' pill. Same functional contract as AG Grid for the verify (sort row-order + aria-sort,
* checkbox -> selectionSet).
*/
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDashboardShell.js'
import { useCsvExport } from '../../composables/useCsvExport.js'
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
const dash = inject('dashboardData', null)
const bus = inject('selectionBus', null)
@ -35,10 +38,12 @@ function matchFacet(r, key, val) {
const rv = acc(r)
return Array.isArray(val) ? val.includes(rv) : rv === val
}
const matchSearch = makeSearchMatcher(bus, buildSearchAccessors(cfg))
const filtered = computed(() => {
const keys = Object.keys(filterSet)
if (!keys.length) return allRows.value
return allRows.value.filter((r) => keys.every((k) => matchFacet(r, k, filterSet[k])))
const hasText = !!(bus?.searchTerm?.value || '').trim()
if (!keys.length && !hasText) return allRows.value
return allRows.value.filter((r) => keys.every((k) => matchFacet(r, k, filterSet[k])) && matchSearch(r))
})
// ── multi-sort (shift-click stacks; per-column 3-state none->asc->desc->none) ──
@ -65,6 +70,40 @@ const sorted = computed(() => {
})
})
// ── selection views: "show only selected" + "selected first" ────────────────────────────────
// Deliberately NOT written into filterSet: selection is ephemeral session state while filters
// are shareable view config (they serialize to the URL hash), so folding a transient pick into
// a filter would leak it into a shared link. These are view toggles layered ON TOP of the
// filter pipeline. Declared here because the pipeline below consumes them.
const onlySelected = ref(false)
const selectedFirst = ref(false)
function toggleOnlySelected() { onlySelected.value = !onlySelected.value }
function toggleSelectedFirst() { selectedFirst.value = !selectedFirst.value }
// `selectionVersion` is the reactive handle on the (deliberately non-reactive) selectionSet —
// see the performance contract in useSelectionBus.js. Every computed reading the set calls this.
const selVer = () => { if (bus && bus.selectionVersion) return bus.selectionVersion.value; return 0 }
// Pipeline: filtered -> sorted -> visibleRows (selection views) -> windowRows (virtualization).
// The views sit HERE, between sort and virtualization, so the window slices the post-view list
// and 'show only selected' genuinely removes rows instead of just hiding painted ones.
const visibleRows = computed(() => {
let list = sorted.value
if (onlySelected.value) {
selVer()
list = list.filter((r) => selectionSet && selectionSet.has(r.slug))
}
if (selectedFirst.value) {
selVer()
// Prepended sort key, stable within each group: composes with the user's column sort
// instead of overriding it (selected rows float to the top, order otherwise preserved).
const sel = [], rest = []
for (const r of list) (selectionSet && selectionSet.has(r.slug) ? sel : rest).push(r)
list = sel.concat(rest)
}
return list
})
// ── windowed virtualization (2,106 rows; render only the visible slice) ──
const ROW_H = 26
const scrollTop = ref(0)
@ -74,9 +113,9 @@ let ro = null
function onScroll(e) { scrollTop.value = e.target.scrollTop }
const start = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_H) - 6))
const visCount = computed(() => Math.ceil(viewH.value / ROW_H) + 12)
const windowRows = computed(() => sorted.value.slice(start.value, start.value + visCount.value))
const windowRows = computed(() => visibleRows.value.slice(start.value, start.value + visCount.value))
const padTop = computed(() => start.value * ROW_H)
const padBot = computed(() => Math.max(0, (sorted.value.length - (start.value + windowRows.value.length)) * ROW_H))
const padBot = computed(() => Math.max(0, (visibleRows.value.length - (start.value + windowRows.value.length)) * ROW_H))
onMounted(() => {
if (scroller.value) {
viewH.value = scroller.value.clientHeight || 360
@ -89,29 +128,201 @@ onMounted(() => {
// a map pin click (or other panel) selects a PG -> scroll the virtualized table to it + highlight.
if (bus) bus.on('focusRow', ({ slug } = {}) => {
if (!slug || !scroller.value) return
const idx = sorted.value.findIndex((r) => r.slug === slug)
// index into the RENDERED list (visibleRows), not `sorted` — with a selection view active
// the two differ, and a `sorted` index would scroll to the wrong offset.
const idx = visibleRows.value.findIndex((r) => r.slug === slug)
if (idx >= 0) scroller.value.scrollTop = Math.max(0, idx * ROW_H - (viewH.value / 2))
})
})
onBeforeUnmount(() => ro && ro.disconnect())
onBeforeUnmount(() => { ro && ro.disconnect(); clearTimeout(_toastT) })
// ── selection (writes to selectionSet via the bus) ──
const selCount = computed(() => (selectionSet ? selectionSet.size : 0))
// ── selection (writes to selectionSet via the bus) ──────────────────────────────────────────
const selCount = computed(() => { selVer(); return selectionSet ? selectionSet.size : 0 })
const isSel = (r) => !!(selectionSet && selectionSet.has(r.slug))
// Row renders read through this so the version dep is registered in the render effect.
const selTick = computed(() => selVer())
/**
* Selection model — the Windows-Explorer semantics formalized in Järvi & Parent,
* "One Way to Select Many" (ECOOP 2016). Explorer's rules are chosen over Finder's because
* Finder's anchor placement is documented there as producing user-visible asymmetries.
*
* plain click → select ONLY this row; anchor = row
* ctrl/cmd + click → toggle THIS row, keep the rest; anchor = row (ctrl DOES move the anchor);
* mode = 'select' if the row was unselected, else 'deselect'
* shift + click → REPLACE the selection with range(anchor → row); anchor stays PINNED
* ctrl+shift+click → ADD (or, in deselect mode, SUBTRACT) range(anchor → row); anchor pinned
*
* `anchorSlug` is a STABLE SLUG, never a row index: the grid is virtualized AND sortable, so an
* index would silently point at a different people group after a re-sort or a DOM recycle.
*
* `baseSelection` is the selection as it stood when the anchor was set. Every shift+click
* recomputes `base ⊕ range` from scratch instead of diffing against the previous range, which
* makes shift+click idempotent: dragging a range back SHRINKS it and restores what was there
* before, rather than leaving rows stranded (the Finder bug the paper calls out).
*/
const anchorSlug = ref(null)
const anchorMode = ref('select') // 'select' | 'deselect' — set by ctrl+click, read by ctrl+shift
let baseSelection = new Set() // plain Set, non-reactive: pure bookkeeping
function setAnchor(slug, mode) {
anchorSlug.value = slug
anchorMode.value = mode
baseSelection = new Set(selectionSet)
}
// Range over the CURRENTLY VISIBLE order (filtered + sorted + selection views) — the order the
// user actually sees on screen, never the underlying dataset order.
function rangeSlugs(fromSlug, toSlug) {
const list = visibleRows.value
const i = list.findIndex((r) => r.slug === fromSlug)
const j = list.findIndex((r) => r.slug === toSlug)
if (i < 0 || j < 0) return []
const [lo, hi] = i <= j ? [i, j] : [j, i]
const out = []
for (let k = lo; k <= hi; k++) out.push(list[k].slug)
return out
}
/**
* ONE selection gesture = ONE bulk commit. Never loops toggleSelect(): that is precisely the
* 20-second freeze (BUG-TABLE-BATCH-SELECTION-FREEZES-20S) — 2,107 synchronous map rebuilds.
*/
function applyClick(slug, ev) {
if (!bus || !slug) return
const ctrl = !!(ev && (ev.ctrlKey || ev.metaKey))
const shift = !!(ev && ev.shiftKey)
if (shift && anchorSlug.value) {
const range = rangeSlugs(anchorSlug.value, slug)
if (!range.length) return
if (ctrl) {
// ctrl+shift: apply the range ON TOP of the base snapshot, honoring the ctrl mode.
const next = new Set(baseSelection)
if (anchorMode.value === 'deselect') for (const s of range) next.delete(s)
else for (const s of range) next.add(s)
bus.setSelection(next)
} else {
// plain shift: the range REPLACES the selection (Explorer/Finder rule).
bus.setSelection(range)
}
return // anchor stays pinned so the next shift+click re-extends from the same pivot
}
if (ctrl) {
const willSelect = !selectionSet.has(slug)
setAnchor(slug, willSelect ? 'select' : 'deselect')
bus.toggleSelect(slug)
// re-snapshot AFTER the toggle so a following ctrl+shift extends from the post-toggle state
baseSelection = new Set(selectionSet)
return
}
// plain click → this row only
setAnchor(slug, 'select')
bus.setSelection([slug])
baseSelection = new Set(selectionSet)
}
// Event delegation on the stable scroll container: per-row @click is unreliable in a
// defineCustomElement bundle when the v-for is virtualized (frequently patched). The container
// receives the bubbled click; resolve the clicked row via data-slug, toggle via the bus.
// receives the bubbled click; resolve the clicked row via data-slug, then apply the gesture.
//
// The CHECKBOX CELL is the exception: it always TOGGLES one row and never clears the rest
// (AG Grid / Helios convention — the checkbox is the "add to selection" affordance, the row
// body is the "replace selection" affordance).
function onGridClick(e) {
const rowEl = e.target && e.target.closest && e.target.closest('.rd-row')
if (rowEl) { const slug = rowEl.getAttribute('data-slug'); if (slug && bus) bus.toggleSelect(slug) }
const t = e.target
if (!t || !t.closest) return
const rowEl = t.closest('.rd-row')
if (!rowEl || !bus) return
const slug = rowEl.getAttribute('data-slug')
if (!slug) return
if (t.closest('.rd-td.chk')) {
// Checkbox column. A MODIFIER always wins: shift must extend the range and ctrl must
// toggle-with-anchor-move on the checkbox exactly as it does on the row body, or this
// 30px-wide column becomes a dead zone where the keyboard is silently ignored — the
// branch below is modifier-blind, so without this line a shift+click on a checkbox is
// just a plain additive toggle (BUILD-TABLE-PRO-SELECTION-SHIFT-CTRL).
if (e.shiftKey || e.ctrlKey || e.metaKey) { applyClick(slug, e); return }
// Unmodified: plain toggle, selection preserved; still moves the anchor (ctrl-like).
setAnchor(slug, selectionSet.has(slug) ? 'deselect' : 'select')
bus.toggleSelect(slug)
baseSelection = new Set(selectionSet)
return
}
applyClick(slug, e)
}
const allVisibleSelected = computed(() => filtered.value.length > 0 && filtered.value.every((r) => isSel(r)))
// shift+click natively extends the browser's TEXT selection, smearing blue highlight across
// rows. preventDefault on mousedown kills the range extension at source while leaving the
// click event (and ordinary text copy/paste without shift) completely intact.
function onGridMouseDown(e) {
if (e.shiftKey && e.target && e.target.closest && e.target.closest('.rd-row')) e.preventDefault()
}
// Header checkbox scope = the CURRENT FILTER (AG Grid's selectAll:'filtered'). Selecting rows
// the user has filtered away is the classic bulk-action footgun.
const allVisibleSelected = computed(() => {
selVer()
const f = filtered.value
if (!f.length) return false
for (const r of f) if (!selectionSet.has(r.slug)) return false
return true
})
// Tri-state: some-but-not-all → indeterminate. Derived, never stored.
const someVisibleSelected = computed(() => {
selVer()
if (allVisibleSelected.value) return false
for (const r of filtered.value) if (selectionSet.has(r.slug)) return true
return false
})
function toggleAll() {
if (!bus) return
if (allVisibleSelected.value) filtered.value.forEach((r) => selectionSet.has(r.slug) && bus.toggleSelect(r.slug))
else filtered.value.forEach((r) => !selectionSet.has(r.slug) && bus.toggleSelect(r.slug))
const slugs = filtered.value.map((r) => r.slug)
// ONE bulk commit each way — this is the fix for the 20s freeze.
if (allVisibleSelected.value) bus.deselectMany(slugs)
else bus.selectMany(slugs)
anchorSlug.value = null
baseSelection = new Set(selectionSet)
}
function clearSel() { bus && bus.clearSelection() }
function clearSel() {
bus && bus.clearSelection()
anchorSlug.value = null
baseSelection = new Set()
}
// `indeterminate` is a JS-only property on <input type=checkbox> — there is no HTML attribute
// and it cannot be set from CSS — so bind the element and drive it imperatively. The browser
// then exposes aria-checked="mixed" itself, which is why we do NOT hand-set aria-checked.
const allChkEl = ref(null)
function setAllChkEl(el) { allChkEl.value = el || null }
watch([someVisibleSelected, allChkEl], () => {
if (allChkEl.value) allChkEl.value.indeterminate = someVisibleSelected.value
}, { immediate: true })
// A range/anchor defined over one row order is meaningless in another — drop it when the user
// re-sorts or the visible set changes underneath them. (Not watched on `selectionSet` itself:
// selecting is exactly when the anchor must SURVIVE.)
//
// Watch a cheap ORDER FINGERPRINT, never `filtered` itself. Watching the computed directly
// compares ARRAY IDENTITY, and on the filtered path `filtered` allocates a NEW array on every
// re-evaluation (the no-filter path returns `allRows.value` unchanged, which is why this only
// ever bit with a search/facet active). Committing a selection re-runs the chain, yields a
// fresh array, and nulls the anchor the very click just set — the gesture ate its own anchor.
// Measured with trusted events at 1440x1200: with a search term active, click row 3 then
// shift+click row 8 gave 1 -> 1 (no range at all); it now gives the correct 1 -> 6.
//
// `filtered.value.length` is O(1) and changes whenever the filter/search genuinely narrows the
// set; sort + the two view toggles cover every other reorder. A same-length filter swap is the
// one case this misses — far cheaper than hashing 2,107 slugs on every selection commit, which
// would put real work back on the hot path the performance contract exists to protect.
watch(
[sortState, () => filtered.value.length, onlySelected, selectedFirst],
() => { anchorSlug.value = null },
)
// "Show only selected" with nothing selected would render an empty grid and look broken —
// drop back to the full view automatically when the selection is cleared.
watch(selCount, (n) => { if (!n) onlySelected.value = false })
// ── collapsible bottom dock (P5): chevron emits to the root, which shrinks the table grid row so the
// map grows. Root is the source of truth for the grid; we mirror the state locally for the chevron. ──
@ -122,47 +333,143 @@ function toggleDock() { collapsed.value = !collapsed.value; bus && bus.emit('tog
const sumPop = computed(() => filtered.value.reduce((s, r) => s + (num(r.population) || 0), 0))
const rowCount = computed(() => filtered.value.length)
// ── CSV export (card BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS) ──
// Moved here from the dashboard topbar: the button exports THIS table, so it belongs on this
// table's header bar. The bar is deliberately rendered OUTSIDE the collapse, so the live count +
// this button stay docked under the map when the grid is hidden.
// The exporter honors selection > filter > all and fetches the ALL-FIELDS record lazily.
// labelFor resolves People CODES to names in the export's provenance COMMENT line only — the data
// columns are untouched.
const { labelFor: csvLabelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject('searchSourceId', null))
const csv = dash && bus
? useCsvExport({ rowStore: dash.rowStore, rows: dash.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC: FACETS, dataOpts: cfg.data, labelFor: csvLabelFor })
: null
const exportToast = ref('')
const exporting = ref(false)
let _toastT = null
async function onExport() {
if (!csv || exporting.value) return
exporting.value = true
try {
const r = await csv.exportCsvAsync()
const how = r.downloaded ? 'exported' : r.clipboard ? 'copied to clipboard' : 'ready'
exportToast.value = `✓ ${fmt(r.rows)} rows × ${r.fields} fields ${how}`
} catch (e) {
exportToast.value = '✕ Export failed'
} finally {
exporting.value = false
clearTimeout(_toastT); _toastT = setTimeout(() => { exportToast.value = '' }, 3200)
}
}
// Warm the all-fields cache on hover so the first real click is already wide (and keeps its
// clipboard user-activation instead of spending it on the network round-trip).
function onExportHover() { csv && csv.prefetchExportFields() }
useShadowStyles(`
.rd-table-wrap{ display:flex; flex-direction:column; height:100%; min-height:0; background:#fff; font-size:12px; }
.rd-table-bar{ display:flex; align-items:center; gap:10px; padding:4px 10px; border-bottom:1px solid #dde3ec; flex:0 0 auto; }
.rd-table-bar .ttl{ font-weight:700; color:var(--rd-ink,#1f2733); } .rd-table-bar .ct{ color:#7a8494; }
.rd-table-bar{ display:flex; align-items:center; gap:10px; padding:4px 10px; border-bottom:1px solid var(--dx-line, #d1d4cd); flex:0 0 auto; }
.rd-table-bar .ttl{ font-weight:700; color:var(--rd-ink, #3b463d); } .rd-table-bar .ct{ color:var(--dx-muted, #6d766e); }
.rd-dock{ all:unset; cursor:pointer; flex:0 0 auto; width:20px; height:20px; line-height:20px; text-align:center; border-radius:4px;
color:var(--rd-muted,#6b7686); font-size:12px; }
.rd-dock:hover{ background:var(--rd-bg,#f7f9fb); color:var(--rd-accent,#e67e22); }
.rd-pill{ margin-left:auto; display:inline-flex; align-items:center; gap:6px; font-weight:700; font-size:11px; padding:3px 9px; border-radius:999px;
background:#eef4ff; color:#2456c7; border:1px solid #cfe0ff; }
.rd-pill button{ all:unset; cursor:pointer; color:#2456c7; opacity:.7; } .rd-pill button:hover{ opacity:1; }
.rd-grid{ flex:1 1 auto; min-height:0; overflow:auto; position:relative; }
.rd-thead{ position:sticky; top:0; z-index:2; display:flex; background:#f4f6fa; border-bottom:1px solid #dde3ec; }
.rd-th{ flex:1 1 0; min-width:0; padding:3px 8px; font-weight:700; color:#3a4456; cursor:pointer; user-select:none; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
color:var(--rd-muted, #6d766e); font-size:12px; }
.rd-dock:hover{ background:var(--rd-bg, #f3f3f1); color:var(--rd-accent,#e67e22); }
/* selection-count badge — the standard tint/ink/border badge recipe, resolved through the
sage tokens instead of the blue tint-set it was authored with: weak accent fill,
primary ink, hairline border. */
.rd-pill{ display:inline-flex; align-items:center; gap:6px; font-weight:700; font-size:11px; padding:3px 9px; border-radius:999px;
background:var(--dx-accent-weak, rgba(146,177,149,.18)); color:var(--dx-text, #3b463d); border:1px solid var(--dx-line, #d1d4cd); }
.rd-pill button{ all:unset; cursor:pointer; color:var(--dx-text, #3b463d); opacity:.7; } .rd-pill button:hover{ opacity:1; }
/* Selection VIEW toggles (only-selected / selected-first). Deliberately slim + quiet: they sit
beside the count pill but must not compete with Export CSV for attention. Material-light DX
palette — surface #f3f3f1, border #d1d4cd, ink #3b463d, primary #92b195 when engaged. */
.rd-table-bar .rd-vtog{ all:unset; box-sizing:border-box; cursor:pointer; flex:0 0 auto; user-select:none;
font-size:10px; font-weight:700; letter-spacing:.2px; color:var(--dx-muted,#6d766e);
background:var(--dx-surface,#f3f3f1); border:1px solid var(--dx-line,#d1d4cd);
border-radius:var(--r-pill,999px); padding:3px 9px; line-height:14px; white-space:nowrap; }
.rd-table-bar .rd-vtog:hover{ color:var(--dx-text,#3b463d); border-color:#b9bdb4; }
.rd-table-bar .rd-vtog.on{ color:#2b352c; background:var(--rd-primary,#92b195); border-color:#84a487; }
.rd-table-bar .rd-vtog:focus-visible{ outline:2px solid #6d8a70; outline-offset:2px; }
/* pushes the export cluster to the right edge of the bar (the pill used to hold margin-left:auto) */
.rd-bar-sp{ flex:1 1 auto; }
/* Export CSV — lives on THIS bar now (moved from the dashboard topbar). Material-light DX palette:
primary #92b195 surface, deep ink label, border #d1d4cd. Stays visible while collapsed. */
.rd-table-bar .rd-csv{ display:inline-flex; align-items:center; gap:6px; flex:0 0 auto; cursor:pointer; user-select:none;
font-size:11px; font-weight:700; letter-spacing:.2px; color:#2b352c; background:var(--rd-primary,#92b195);
border:1px solid #84a487; border-radius:var(--r-md,6px); padding:4px 11px; }
.rd-table-bar .rd-csv:hover{ background:#84a487; color:#1f271f; }
.rd-table-bar .rd-csv:focus-visible{ outline:2px solid #6d8a70; outline-offset:2px; }
.rd-table-bar .rd-csv.busy{ opacity:.65; cursor:progress; }
.rd-table-bar .rd-csv-n{ background:#ffffff66; border-radius:var(--r-pill,999px); padding:0 6px; font-size:10px; }
.rd-table-bar .rd-toast{ flex:0 0 auto; font-size:11px; font-weight:700; color:#33603f; background:#eaf3ec;
border:1px solid #c3dbc7; border-radius:var(--r-md,6px); padding:3px 9px; white-space:nowrap; }
/* Thin overlay scrollbars on the virtualized grid. The table scrolls BOTH axes, so the corner
is made transparent too or it renders as a grey notch against the sticky header
(BUILD-MATERIAL-THIN-OVERLAY-SCROLLBARS). */
.rd-grid{ flex:1 1 auto; min-height:0; overflow:auto; position:relative;
scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent; }
.rd-grid::-webkit-scrollbar{ width:8px; height:8px; }
.rd-grid::-webkit-scrollbar-track{ background:transparent; }
.rd-grid::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:8px; }
.rd-grid::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
.rd-grid::-webkit-scrollbar-corner{ background:transparent; }
.rd-thead{ position:sticky; top:0; z-index:2; display:flex; background:var(--dx-surface, #f3f3f1); border-bottom:1px solid var(--dx-line, #d1d4cd); }
.rd-th{ flex:1 1 0; min-width:0; padding:3px 8px; font-weight:700; color:var(--dx-text, #3b463d); cursor:pointer; user-select:none; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.rd-th.num{ text-align:right; } .rd-th.chk{ flex:0 0 30px; cursor:default; text-align:center; padding:6px 0; }
.rd-th .ind{ font-size:9px; color:#2456c7; margin-left:3px; }
.rd-row{ display:flex; height:26px; line-height:16px; border-bottom:1px solid #f0f2f6; align-items:center; }
.rd-row:hover{ background:#f8fafd; } .rd-row.sel{ background:#fff7ec; box-shadow:inset 3px 0 0 #e67e22; }
.rd-td{ flex:1 1 0; min-width:0; padding:0 8px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#2a3240; }
/* sort indicator — the glyph carries the meaning; colour only needs to read as ACTIVE
against the muted header row, so it takes primary ink rather than a hue of its own. */
.rd-th .ind{ font-size:9px; color:var(--dx-text, #3b463d); margin-left:3px; }
.rd-row{ display:flex; height:26px; line-height:16px; border-bottom:1px solid var(--dx-surface, #f3f3f1); align-items:center; }
.rd-row:hover{ background:var(--dx-surface, #f3f3f1); } .rd-row.sel{ background:#fff7ec; box-shadow:inset 3px 0 0 #e67e22; }
.rd-td{ flex:1 1 0; min-width:0; padding:0 8px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--dx-text, #3b463d); }
.rd-td.num{ text-align:right; font-variant-numeric:tabular-nums; } .rd-td.chk{ flex:0 0 30px; text-align:center; padding:0; }
.rd-foot{ flex:0 0 auto; display:flex; border-top:2px solid #dde3ec; background:#f4f6fa; font-weight:700; height:28px; align-items:center; color:#3a4456; }
.rd-foot .rd-td{ color:#3a4456; }
.rd-foot{ flex:0 0 auto; display:flex; border-top:2px solid var(--dx-line, #d1d4cd); background:var(--dx-surface, #f3f3f1); font-weight:700; height:28px; align-items:center; color:var(--dx-text, #3b463d); }
.rd-foot .rd-td{ color:var(--dx-text, #3b463d); }
`, 'rd-datatable')
</script>
<template>
<div class="rd-table-wrap" :data-sel-count="selCount">
<div class="rd-table-bar">
<!-- Header bar — ALWAYS rendered, collapsed or not. The count + Export CSV are the two
controls the Driver asked to keep docked under the map when the grid is hidden. -->
<div class="rd-table-bar" :data-collapsed="collapsed ? '1' : '0'">
<button class="rd-dock" :title="collapsed ? 'Expand table' : 'Collapse table — grow the map'"
data-table-dock :aria-expanded="collapsed ? 'false' : 'true'" @click="toggleDock">{{ collapsed ? '▴' : '▾' }}</button>
<span class="ttl">People Groups</span>
<span class="ct" data-table-count>{{ fmt(rowCount) }} rows</span>
<span v-if="selCount" class="rd-pill" data-sel-pill>
<span>{{ selCount }} selected</span>
<button title="Clear selection" @click="clearSel">✕</button>
<span class="ct" data-table-count>{{ fmt(rowCount) }} {{ rowCount === 1 ? 'group' : 'groups' }}</span>
<!-- role=status so assistive tech announces the count as the selection changes. -->
<span v-if="selCount" class="rd-pill" data-sel-pill role="status">
<span>{{ fmt(selCount) }} selected</span>
<button title="Clear selection" aria-label="Clear selection" @click="clearSel">✕</button>
</span>
<!-- Selection VIEWS — separated from the pill because they mutate the VIEW, not the
selection. Only meaningful once something is selected, so they appear with it. -->
<template v-if="selCount">
<button class="rd-vtog" :class="{ on: onlySelected }" data-only-selected
:aria-pressed="onlySelected ? 'true' : 'false'"
title="Show only the selected people groups" @click="toggleOnlySelected">Only selected</button>
<button class="rd-vtog" :class="{ on: selectedFirst }" data-selected-first
:aria-pressed="selectedFirst ? 'true' : 'false'"
title="Float the selected people groups to the top, keeping your column sort" @click="toggleSelectedFirst">Selected first</button>
</template>
<span class="rd-bar-sp"></span>
<span v-if="exportToast" class="rd-toast" data-export-toast>{{ exportToast }}</span>
<span class="rd-csv" :class="{ busy: exporting }" role="button" tabindex="0" data-csv-btn
:aria-busy="exporting ? 'true' : 'false'"
:title="selCount ? `Export the ${selCount} selected people groups — all fields`
: `Export these ${fmt(rowCount)} people groups — all fields`"
@click="onExport" @mouseenter="onExportHover" @focus="onExportHover"
@keydown.enter.prevent="onExport" @keydown.space.prevent="onExport">
{{ exporting ? '…' : '↓' }} Export CSV<span v-if="selCount" class="rd-csv-n">{{ selCount }}</span>
</span>
</div>
<div ref="scroller" class="rd-grid" role="grid" :aria-rowcount="rowCount + 1" @scroll="onScroll" @click="onGridClick">
<div v-show="!collapsed" ref="scroller" class="rd-grid" role="grid" aria-multiselectable="true"
:aria-rowcount="visibleRows.length + 1" @scroll="onScroll" @click="onGridClick" @mousedown="onGridMouseDown">
<div class="rd-thead" role="row">
<div class="rd-th chk" style="cursor:pointer" @click="toggleAll">
<input type="checkbox" :checked="allVisibleSelected" aria-label="Select all" tabindex="-1" style="pointer-events:none" />
<div class="rd-th chk" style="cursor:pointer" :title="allVisibleSelected ? 'Deselect all ' + fmt(rowCount) : 'Select all ' + fmt(rowCount)" @click="toggleAll">
<!-- tri-state: `indeterminate` is a JS-only property (no HTML attribute), so it is set
via a ref callback; the browser then reports aria-checked="mixed" for us. -->
<input type="checkbox" :checked="allVisibleSelected" :ref="setAllChkEl"
:aria-label="allVisibleSelected ? 'Deselect all rows' : 'Select all rows'" tabindex="-1" style="pointer-events:none" />
</div>
<div v-for="col in COLUMNS" :key="col.key" class="rd-th" :class="{ num: col.type === 'num' }"
role="columnheader" :aria-sort="ariaSort(col)" :data-col="col.key" @click="onSort(col, $event)">
@ -171,8 +478,11 @@ useShadowStyles(`
</div>
<div :style="{ height: padTop + 'px' }"></div>
<div v-for="r in windowRows" :key="r.slug" class="rd-row" :class="{ sel: isSel(r) }" role="row"
:data-slug="r.slug" :aria-selected="isSel(r) ? 'true' : 'false'">
<!-- `selTick` is read in the row key-path so the render effect subscribes to
selectionVersion (selectionSet itself is a plain, non-reactive Set). -->
<div v-for="(r, i) in windowRows" :key="r.slug" class="rd-row" :class="{ sel: selTick >= 0 && isSel(r) }" role="row"
:data-slug="r.slug" :aria-selected="isSel(r) ? 'true' : 'false'"
:aria-rowindex="start + i + 2">
<div class="rd-td chk" role="gridcell">
<input type="checkbox" class="rd-rowchk" :checked="isSel(r)" :aria-label="'Select ' + r.name" tabindex="-1" style="pointer-events:none" />
</div>
@ -183,7 +493,7 @@ useShadowStyles(`
<div :style="{ height: padBot + 'px' }"></div>
</div>
<div class="rd-foot" role="row">
<div v-show="!collapsed" class="rd-foot" role="row">
<div class="rd-td chk"></div>
<div class="rd-td">Σ {{ fmt(rowCount) }} groups</div>
<div class="rd-td"></div><div class="rd-td"></div><div class="rd-td"></div><div class="rd-td"></div>

View file

@ -15,6 +15,7 @@
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useCsvExport } from '../../composables/useCsvExport.js'
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
import { lbl } from '../../composables/useDashboardAggregations.js'
const dash = inject('dashboardData', null)
@ -22,8 +23,11 @@ const bus = inject('selectionBus', null)
const cfg = inject('dashboardConfig', {})
const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
// labelFor resolves People CODES to names in the export's provenance COMMENT line only — the data
// columns are untouched.
const { labelFor: csvLabelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject('searchSourceId', null))
const exporter = (dash && bus)
? useCsvExport({ rowStore: dash.rowStore, rows: dash.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC })
? useCsvExport({ rowStore: dash.rowStore, rows: dash.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, labelFor: csvLabelFor })
: null
const activeSlug = ref(null)
@ -101,31 +105,31 @@ function clearDetail() {
}
useShadowStyles(`
.rd-detail{ box-sizing:border-box; background:var(--rd-panel,#fff); border-left:1px solid var(--rd-line,#e2e6ec);
border-bottom:1px solid var(--rd-line,#e2e6ec); padding:var(--s3,12px);
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; color:var(--rd-ink,#1f2733); flex:0 0 auto; }
.rd-detail{ box-sizing:border-box; background:var(--rd-panel,#fff); border-left:1px solid var(--rd-line, #d1d4cd);
border-bottom:1px solid var(--rd-line, #d1d4cd); padding:var(--s3,12px);
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; color:var(--rd-ink, #3b463d); flex:0 0 auto; }
.rd-detail .rd-dh{ display:flex; align-items:center; justify-content:space-between; gap:8px;
font-weight:700; font-size:var(--fs-md,12px); text-transform:uppercase; letter-spacing:.5px;
color:var(--rd-muted,#6b7686); margin-bottom:var(--s2,8px); }
color:var(--rd-muted, #6d766e); margin-bottom:var(--s2,8px); }
.rd-detail .rd-detail-close{ background:none; border:none; padding:2px 4px; cursor:pointer; line-height:1;
font-size:15px; color:var(--rd-muted,#6b7686); border-radius:var(--r-md,6px); flex-shrink:0; }
.rd-detail .rd-detail-close:hover{ color:var(--rd-ink,#1f2733); background:var(--rd-bg,#f7f9fb); }
.rd-detail .rd-empty{ font-size:var(--fs-md,12px); color:var(--rd-muted,#6b7686); padding:var(--s1,4px) 0; }
font-size:15px; color:var(--rd-muted, #6d766e); border-radius:var(--r-md,6px); flex-shrink:0; }
.rd-detail .rd-detail-close:hover{ color:var(--rd-ink, #3b463d); background:var(--rd-bg, #f3f3f1); }
.rd-detail .rd-empty{ font-size:var(--fs-md,12px); color:var(--rd-muted, #6d766e); padding:var(--s1,4px) 0; }
.rd-detail .rd-top{ display:flex; gap:var(--s3,12px); align-items:flex-start; }
.rd-detail .rd-thumb{ width:64px; height:64px; border-radius:var(--r-md,6px); object-fit:cover; flex:0 0 auto;
border:1px solid var(--rd-line,#e2e6ec); display:block; }
.rd-detail .rd-ph{ width:64px; height:64px; border-radius:var(--r-md,6px); flex:0 0 auto; background:var(--rd-bg,#f7f9fb);
border:1px solid var(--rd-line,#e2e6ec); display:flex; align-items:center; justify-content:center;
color:var(--rd-muted,#6b7686); font-size:var(--fs-xs,10px); text-transform:uppercase; letter-spacing:.4px; }
.rd-detail .rd-name{ font-weight:800; font-size:var(--fs-xl,16px); line-height:1.2; color:var(--rd-ink,#1f2733); }
.rd-detail .rd-meta{ font-size:var(--fs-sm,11px); color:var(--rd-muted,#6b7686); margin-top:2px; }
border:1px solid var(--rd-line, #d1d4cd); display:block; }
.rd-detail .rd-ph{ width:64px; height:64px; border-radius:var(--r-md,6px); flex:0 0 auto; background:var(--rd-bg, #f3f3f1);
border:1px solid var(--rd-line, #d1d4cd); display:flex; align-items:center; justify-content:center;
color:var(--rd-muted, #6d766e); font-size:var(--fs-xs,10px); text-transform:uppercase; letter-spacing:.4px; }
.rd-detail .rd-name{ font-weight:800; font-size:var(--fs-xl,16px); line-height:1.2; color:var(--rd-ink, #3b463d); }
.rd-detail .rd-meta{ font-size:var(--fs-sm,11px); color:var(--rd-muted, #6d766e); margin-top:2px; }
.rd-detail .rd-rows{ display:flex; flex-direction:column; gap:2px; margin-top:var(--s3,12px); }
.rd-detail .rd-row{ display:flex; align-items:baseline; gap:var(--s2,8px); font-size:var(--fs-md,12px);
padding:2px 0; border-bottom:1px solid var(--rd-line,#e2e6ec); }
padding:2px 0; border-bottom:1px solid var(--rd-line, #d1d4cd); }
.rd-detail .rd-row:last-child{ border-bottom:0; }
.rd-detail .rd-row .k{ color:var(--rd-muted,#6b7686); flex:0 0 auto; min-width:118px; }
.rd-detail .rd-row .v{ color:var(--rd-ink,#1f2733); margin-left:auto; text-align:right; font-variant-numeric:tabular-nums; }
.rd-detail .rd-row .v .fam{ color:var(--rd-muted,#6b7686); }
.rd-detail .rd-row .k{ color:var(--rd-muted, #6d766e); flex:0 0 auto; min-width:118px; }
.rd-detail .rd-row .v{ color:var(--rd-ink, #3b463d); margin-left:auto; text-align:right; font-variant-numeric:tabular-nums; }
.rd-detail .rd-row .v .fam{ color:var(--rd-muted, #6d766e); }
.rd-detail .rd-exp{ margin-top:var(--s3,12px); width:100%; border:0; border-radius:var(--r-md,6px); cursor:pointer;
background:var(--rd-accent,#e67e22); color:#fff; font-weight:700; font-size:var(--fs-md,12px); padding:var(--s2,8px) var(--s3,12px); }
.rd-detail .rd-exp:hover{ background:#d8741c; }
@ -134,11 +138,11 @@ useShadowStyles(`
border:0; border-radius:var(--r-md,6px); cursor:pointer; font-weight:700; font-size:var(--fs-md,12px);
padding:var(--s2,8px) var(--s3,12px); line-height:1; }
.rd-detail .rd-act svg{ flex:0 0 auto; }
.rd-detail .rd-act-pray{ flex:1 1 auto; background:var(--rd-pray,#73A17F); color:#fff; }
.rd-detail .rd-act-pray{ flex:1 1 auto; background:var(--rd-pray,#73a17f); color:#fff; }
.rd-detail .rd-act-pray:hover{ background:var(--rd-pray-hover,#5f8c6a); }
.rd-detail .rd-act-info{ flex:0 1 auto; background:var(--rd-bg,#f7f9fb); color:var(--rd-ink,#1f2733);
border:1px solid var(--rd-line,#e2e6ec); }
.rd-detail .rd-act-info:hover{ background:var(--rd-panel,#fff); border-color:var(--rd-muted,#6b7686); }
.rd-detail .rd-act-info{ flex:0 1 auto; background:var(--rd-bg, #f3f3f1); color:var(--rd-ink, #3b463d);
border:1px solid var(--rd-line, #d1d4cd); }
.rd-detail .rd-act-info:hover{ background:var(--rd-panel,#fff); border-color:var(--rd-muted, #6d766e); }
`, 'rd-detaildrawer')
</script>

View file

@ -16,7 +16,9 @@
import { inject, ref, reactive, computed, onMounted } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { aggregate, lbl } from '../../composables/useDashboardAggregations.js'
import { useAffinityBlockLegendData } from '@map/composables/legends/useAffinityBlockLegendData.js'
import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDashboardShell.js'
import { useTreeLegendColors } from '../../composables/useTreeLegendColors.js'
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
const dash = inject('dashboardData', null)
const bus = inject('selectionBus', null)
@ -30,17 +32,43 @@ const filterSet = bus?.filterSet || {}
// facet-key -> row accessor (sourced from config) — drives both the count tally and setFilter.
const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
// rows under the current filterSet, MINUS the facet(s) being aggregated (so each list stays navigable).
const matchSearch = makeSearchMatcher(bus, buildSearchAccessors(cfg))
function rowsExcluding(exclude) {
const rows = dash?.rows?.value || []
const keys = Object.keys(filterSet).filter((k) => !exclude.includes(k))
if (!keys.length) return rows
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
const hasText = !!(bus?.searchTerm?.value || '').trim()
if (!keys.length && !hasText) return rows
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }) && matchSearch(r))
}
// section collapse state (all open by default), keyed by section id.
const open = reactive(Object.fromEntries((cfg.facetRail || []).map((s) => [s.id, true])))
function toggle(sec) { open[sec] = !open[sec] }
// ── PER-NODE EXPAND/COLLAPSE, SEPARATE FROM FILTERING (card BUILD-STACKED-TREE-LEGEND) ──────
// Previously a branch opened ONLY when its row was `active`, i.e. the ONLY way to see a region's
// blocs was to FILTER the whole dashboard to that region. That conflates two different intents —
// "show me what's inside this" (navigation) vs "narrow everything to this" (selection) — and makes
// the tree impossible to browse: you cannot compare two branches without re-filtering between them.
//
// So expansion is now its OWN state, keyed by `facet:value` (globally unique across all four
// trees, since a facet key belongs to exactly one tree). The caret toggles expansion; the row
// body still toggles the filter. A node the user has never touched falls back to "expanded iff it
// is the active filter" (isExpandedRaw), so the old auto-open-the-filtered-branch behaviour is
// preserved exactly as a special case.
const expanded = reactive({})
const nodeKey = (facet, value) => facet + ':' + value
function toggleExpand(facet, value) {
const k = nodeKey(facet, value)
expanded[k] = !isExpandedRaw(facet, value)
}
function isExpandedRaw(facet, value) {
const k = nodeKey(facet, value)
// undefined = never touched → fall back to "expanded iff this node is the active filter",
// so an active branch still opens itself exactly as it always did.
return k in expanded ? expanded[k] : filterSet[facet] === value
}
function toRows(agg, facet, level) {
const max = agg.reduce((m, d) => Math.max(m, d.value), 0) || 1
return agg.map((d) => ({
@ -49,48 +77,140 @@ function toRows(agg, facet, level) {
}))
}
// ── single flat facet list (e.g. Religion / Status) ──
// ── single flat facet list (e.g. Status) ──
function buildFacetRows(facet, topN) {
return toRows(aggregate(rowsExcluding([facet]), FACET_ACC[facet], topN ?? 24), facet, 0)
}
// ── multi-level drill (e.g. Place: region → block → country), drilling under whichever level is active ──
// Excludes drill.slice(level) at each level so the list under the active parent stays navigable.
// ── multi-level drill (e.g. Place: region → bloc → country) ──────────────────────────────────
// Excludes drill.slice(level) at each level so the list under the active parent stays navigable:
// the parent's own filter REMAINS applied (it sits above `level`), so children always sum exactly
// to their parent's count. Verified against the live API — picking region "Asia" yields blocs
// summing to 1,309, which is exactly the number of Asia rows.
//
// A branch now descends when the node is EXPANDED (see isExpandedRaw) rather than only when it is
// the active filter — so several branches can be open and compared at once without filtering.
// That is only safe because of `parentChain`: rowsExcluding(drill.slice(level)) drops the parent
// tier's predicate, so without an explicit ancestor scope an expanded-but-unfiltered node would
// list the ENTIRE next level instead of its own children. The chain re-applies exactly the
// ancestors we descended through, which is what keeps children summing to their parent.
function buildDrillRows(drill, topN) {
const out = []
const walk = (level) => {
const walk = (level, parentChain) => {
const facet = drill[level]
const cap = (topN && topN[level]) ?? (level === drill.length - 1 ? 40 : 30)
const rows = toRows(aggregate(rowsExcluding(drill.slice(level)), FACET_ACC[facet], cap), facet, level)
// Rows for this level, scoped to the ancestor chain the caller descended through. The chain
// is applied as an extra predicate so an expanded-but-not-filtered parent still shows only
// ITS children — expansion never widens the set the way a missing predicate would.
let base = rowsExcluding(drill.slice(level))
if (parentChain.length) {
base = base.filter((r) => parentChain.every(([k, v]) => { const a = FACET_ACC[k]; return a ? a(r) === v : true }))
}
const rows = toRows(aggregate(base, FACET_ACC[facet], cap), facet, level)
for (const row of rows) {
// a row can be drilled into iff it is not the 'Other' bucket and a deeper level exists
row.hasChildren = !row.other && level < drill.length - 1
row.expanded = row.hasChildren && isExpandedRaw(row.facet, row.value)
out.push(row)
if (row.active && level < drill.length - 1) walk(level + 1)
if (row.expanded) walk(level + 1, [...parentChain, [facet, row.value]])
}
}
walk(0)
walk(0, [])
return out
}
// ── TREE TRUNKS (card BUILD-TREE-LEGEND-SEMANTIC-INDENTED) ────────────────────────────────
// Driver: "it is very hard for me to read the content because I cannot see that it is INDENTED
// like semantic trees. I want the semantic tree legend on the left just like on the research maps."
//
// Root cause: the rail conveyed depth with padding-left ALONE (.l0/.l1/.l2). Whitespace does not
// read as hierarchy — nothing visually ties a child row to its parent, so People > bloc > cluster
// and Place > region > bloc > country looked like one flat list of unrelated rows.
//
// Fix: reuse the research-map semantic tree's ACTUAL indentation model rather than inventing a
// second one. This is a direct port of SemanticTreeLegend.visibleRows' trunk pass
// (@map/components/legends/SemanticTreeLegend.vue) — per row it emits one segment per ancestor
// level, drawn by the SAME .stl-trunk-{pipe,tee,elbow,blank} CSS vocabulary (a 1px rule at
// left:5px, with a 7px horizontal stub at the row's midpoint):
// pipe │ an ancestor's branch continues past this row
// tee ├ this row has a later sibling
// elbow └ this row is the last child of its parent
// blank the ancestor's branch ended above
// The upstream walks a real node tree (relDepth/ancestors); the rail's rows are a FLAT list
// carrying `level`, so "has a later sibling" = the next row at the same level before any
// shallower row, and "ancestor continues" = some later row is deeper than L before the tree
// returns to L or above. Same output shape (`row.trunks`), same renderer, same look.
function addTrunks(rows) {
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const segs = []
for (let L = 0; L < row.level; L++) {
if (L === row.level - 1) {
// deepest segment: does another row share this row's parent below it?
let hasLaterSibling = false
for (let j = i + 1; j < rows.length; j++) {
if (rows[j].level < row.level) break
if (rows[j].level === row.level) { hasLaterSibling = true; break }
}
segs.push(hasLaterSibling ? 'tee' : 'elbow')
} else {
// an ancestor level: its branch continues iff a later row sits at that level again
let trunkContinues = false
for (let j = i + 1; j < rows.length; j++) {
if (rows[j].level <= L) { trunkContinues = rows[j].level === L; break }
}
segs.push(trunkContinues ? 'pipe' : 'blank')
}
}
row.trunks = segs
}
return rows
}
// ── People labels: single-sourced from the SAME resolver the research-map's semantic tree uses ──
// The People facets key by CODE (from config.facets). We resolve those codes → human names by
// The People facets key by CODE (from config.facets). Those codes → human names are resolved by
// running useAffinityBlockLegendData over the shell's normalized features (NOT a re-derived
// hierarchy), so bloc/cluster/PG names in this rail are always identical to the map's tree.
const normalizedFeatures = computed(() => {
const src = dataStore && searchSourceId && dataStore.sources && dataStore.sources[searchSourceId]
return (src && src.features) || []
})
const { aggregated: affinityAgg } = useAffinityBlockLegendData(normalizedFeatures)
// code → label lookups keyed to the People facet keys, walked from the composable's aggregate tree.
const affinityLabelMaps = computed(() => {
const bloc = new Map(), cluster = new Map(), pg = new Map()
for (const b of affinityAgg.value.values()) {
if (b.code) bloc.set(b.code, b.label)
for (const c of b.clusters.values()) {
if (c.code) cluster.set(c.code, c.label)
for (const g of c.peopleGroups.values()) { if (g.code) pg.set(g.code, g.label) }
}
// The maps were built inline here, which made this rail the ONLY surface that projected them —
// the filter chip bar rendered the raw code. They now live in useTreeLegendLabels, next to
// useTreeLegendColors, and every People-facing surface reads that one copy.
const { affinityLabelMaps } = useTreeLegendLabels(cfg, dataStore, searchSourceId)
// ── COLOURS: sourced from the cap-3 research map's colour strategies ────────────────────────
// Every hex in this rail comes from the SAME strategy modules the map's pins paint with, via
// useTreeLegendColors — so the legend can never drift from the map. Colour is applied AFTER the
// rows exist and touches only `swatch`; `facet`/`value` (what the bus filters on) are untouched.
const { colorFor } = useTreeLegendColors()
// A row inherits its ANCESTOR's colour, mirroring the map's semantic tree where one hue owns a
// whole subtree (useAffinityBlockLegendData.js:169 assigns one colour per bloc and reuses it for
// every descendant). We therefore track the nearest level-0 value while walking the flat row list.
function addColors(rows, axis, famByValue) {
if (!axis) return rows // Status: deliberately uncoloured
let rootValue = null
for (const row of rows) {
if (row.level === 0) rootValue = row.value
if (row.other) { row.swatch = null; continue } // the 'Other' bucket is not a real member
// Religion keys its palette by FAMILY LETTER, which only the level-0 row can supply; every
// other axis inherits the level-0 VALUE itself.
const anchor = axis === 'religion' ? (famByValue.get(rootValue) || '') : rootValue
row.swatch = colorFor(axis, row.facet, row.value, anchor)
}
return { affinity_bloc: bloc, cluster, people_group: pg }
return rows
}
// Religion family NAME → family LETTER, built from the config's famKey accessor over live rows.
// The name is what the user sees and what the bus filters on; the letter only ever indexes the
// map's palette, exactly as religion.js:104 does for pins.
const religionFamKeys = computed(() => {
const m = new Map()
const facet = cfg.facets && cfg.facets.religion_family
if (!facet || !facet.famKey) return m
for (const r of (dash?.rows?.value || [])) {
const name = facet.get(r)
if (name && !m.has(name)) m.set(name, facet.famKey(r))
}
return m
})
const sections = computed(() => (cfg.facetRail || []).map((s) => {
@ -101,7 +221,10 @@ const sections = computed(() => (cfg.facetRail || []).map((s) => {
const maps = affinityLabelMaps.value
rows = rows.map((r) => (r.other ? r : { ...r, label: (maps[r.facet] && maps[r.facet].get(r.value)) || r.label }))
}
return { id: s.id, title: s.title, rows }
rows = addColors(rows, s.axis, religionFamKeys.value)
// Trunks are computed LAST, over the FINAL row list, so the ├/└ glyphs reflect exactly what
// is rendered (the label swap above doesn't move rows, but any future row filtering would).
return { id: s.id, title: s.title, axis: s.axis, rows: addTrunks(rows) }
}))
// header: full-filterSet scope count (unchanged logic from the tree header).
@ -113,8 +236,27 @@ const scopeCount = computed(() => {
})
// each rendered row already carries its own facet key (data-facet/data-value).
//
// TWO INTENTS, TWO TARGETS: a click on the caret EXPANDS (navigate); a click anywhere else on the
// row FILTERS (select). The caret is checked first because it sits inside the row.
//
// ── THE BYTE-MATCH CONTRACT ────────────────────────────────────────────────────────────────
// `value` is read straight back off the DOM attribute that was rendered from `row.value`, and
// `row.value` is `aggregate()`'s map key — which is the VERBATIM return of FACET_ACC[facet](row).
// So the string handed to setFilter is byte-for-byte the accessor's own output, which is exactly
// what every consumer re-compares against (`a(r) === filterSet[k]`). Nothing in the colour or
// expand work above rewrites `value`; the People axis's label swap writes `label` only, and the
// 'Other' bucket renders a null data-facet so it can never be filtered on.
function onRowClick(e) {
const el = e.target && e.target.closest && e.target.closest('[data-facet]')
const t = e.target
if (!t || !t.closest) return
const caret = t.closest('[data-expand]')
if (caret) {
const facet = caret.getAttribute('data-expand'); const value = caret.getAttribute('data-expand-value')
if (facet && value != null) { e.stopPropagation(); toggleExpand(facet, value) }
return
}
const el = t.closest('[data-facet]')
if (!el || !bus) return
const facet = el.getAttribute('data-facet'); const value = el.getAttribute('data-value')
if (!facet || !value) return
@ -136,34 +278,84 @@ onMounted(() => {
useShadowStyles(`
.rd-facets{ display:flex; flex-direction:column; height:100%; min-height:0; box-sizing:border-box;
background:var(--rd-panel,#fff); }
.rd-facets .fr-head{ flex:0 0 auto; padding:var(--s3,12px) var(--s3,12px) var(--s2,8px); border-bottom:1px solid var(--rd-line,#e2e6ec); }
.rd-facets .fr-scope{ font-size:11px; color:var(--rd-muted,#6b7686); } .rd-facets .fr-scope b{ color:var(--rd-ink,#1f2733); font-size:13px; }
.rd-facets .fr-body{ flex:1 1 auto; min-height:0; overflow:auto; }
.fr-sec{ border-bottom:1px solid var(--rd-line,#e2e6ec); }
.rd-facets .fr-head{ flex:0 0 auto; padding:var(--s3,12px) var(--s3,12px) var(--s2,8px); border-bottom:1px solid var(--rd-line, #d1d4cd); }
.rd-facets .fr-title{ font-size:12px; font-weight:700; letter-spacing:.4px; color:var(--rd-ink,#3b463d); margin-bottom:2px; }
.rd-facets .fr-scope{ font-size:11px; color:var(--rd-muted, #6d766e); } .rd-facets .fr-scope b{ color:var(--rd-ink, #3b463d); font-size:13px; }
/* Thin overlay scrollbar flush to the rail edge — transparent track, low-contrast thumb
that darkens on hover (BUILD-MATERIAL-THIN-OVERLAY-SCROLLBARS). */
.rd-facets .fr-body{ flex:1 1 auto; min-height:0; overflow-y:auto; overflow-x:hidden;
scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent; }
.rd-facets .fr-body::-webkit-scrollbar{ width:8px; height:8px; }
.rd-facets .fr-body::-webkit-scrollbar-track{ background:transparent; }
.rd-facets .fr-body::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:8px; }
.rd-facets .fr-body::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
.fr-sec{ border-bottom:1px solid var(--rd-line, #d1d4cd); }
.fr-sech{ display:flex; align-items:center; gap:6px; padding:8px 12px; cursor:pointer; user-select:none;
font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:var(--rd-muted,#6b7686); }
.fr-sech:hover{ background:var(--rd-bg,#f7f9fb); }
.fr-sech .chev{ margin-left:auto; transition:transform .15s; font-size:10px; color:#9aa4b2; }
font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:var(--rd-muted, #6d766e); }
.fr-sech:hover{ background:var(--rd-bg, #f3f3f1); }
.fr-sech .chev{ margin-left:auto; transition:transform .15s; font-size:10px; color:var(--dx-muted, #6d766e); }
.fr-sech.closed .chev{ transform:rotate(-90deg); }
.fr-rows{ padding-bottom:6px; }
.fr-row{ position:relative; display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; font-size:12px; color:#3a4456; }
.fr-row:hover{ background:#f1f4f8; }
.fr-row.l1{ padding-left:26px; } .fr-row.l2{ padding-left:38px; }
.fr-row.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink,#1f2733); }
/* gap:0 so consecutive trunk spans butt flush and their 1px rules form ONE continuous line
(an 8px flex gap would break each level's rule into a dashed ladder). The label/bar/count
spacing that the gap used to provide is restored by margin-left on .fr-lbl below. */
.fr-row{ position:relative; display:flex; align-items:center; gap:0; padding:3px 12px 3px 14px; cursor:pointer; font-size:12px; color:var(--dx-text, #3b463d); }
.fr-row:hover{ background:var(--dx-surface, #f3f3f1); }
/* Indentation is carried by the TRUNK SPANS (one 12px span per ancestor level), not by
padding-left — so depth is drawn as a connected tree instead of implied by whitespace.
Ported from SemanticTreeLegend's .stl-trunk-* rules: a 1px vertical rule at left:5px plus
a 7px horizontal stub at the row midpoint. Colors are the Material Light border tone. */
.fr-trunk{ position:relative; flex:0 0 auto; width:12px; align-self:stretch; }
.fr-trunk-pipe::before,
.fr-trunk-tee::before{ content:''; position:absolute; left:5px; top:0; bottom:0; width:1px;
background:var(--rd-line,#d1d4cd); }
.fr-trunk-elbow::before{ content:''; position:absolute; left:5px; top:0; height:50%; width:1px;
background:var(--rd-line,#d1d4cd); }
.fr-trunk-tee::after{ content:''; position:absolute; left:5px; top:50%; height:1px; width:7px;
background:var(--rd-line,#d1d4cd); }
.fr-trunk-elbow::after{ content:''; position:absolute; left:5px; top:calc(50% - 0.5px); height:1px; width:7px;
background:var(--rd-line,#d1d4cd); }
/* the active row's own branch darkens so the selected path reads at a glance */
.fr-row.on .fr-trunk-pipe::before,
.fr-row.on .fr-trunk-tee::before,
.fr-row.on .fr-trunk-tee::after,
.fr-row.on .fr-trunk-elbow::before,
.fr-row.on .fr-trunk-elbow::after{ background:var(--rd-primary,#92b195); }
.fr-row.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink, #3b463d); }
.fr-row.on::before{ content:''; position:absolute; left:0; top:2px; bottom:2px; width:3px; background:var(--rd-accent,#e67e22); border-radius:0 2px 2px 0; }
.fr-row.other{ cursor:default; color:#9aa4b2; }
.fr-row.other{ cursor:default; color:var(--dx-muted, #6d766e); }
.fr-row.other:hover{ background:transparent; }
.fr-lbl{ flex:0 1 auto; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.fr-bar{ flex:1 1 auto; height:6px; min-width:24px; background:#eef1f6; border-radius:3px; overflow:hidden; }
.fr-bar i{ display:block; height:100%; background:#c7d6ea; border-radius:3px; transition:width .3s; }
/* caret — the EXPAND hit-target, distinct from the row body's FILTER target. Fixed 12px so
leaf rows (which render an empty .fr-caret-leaf) keep their labels on the same x as siblings
that do have children; a ragged left edge is what makes a tree unreadable. */
.fr-caret{ flex:0 0 auto; width:12px; text-align:center; font-size:9px; line-height:1;
color:var(--dx-muted, #6d766e); transition:transform .15s; cursor:pointer; user-select:none; }
.fr-caret.open{ transform:rotate(90deg); }
.fr-caret:hover{ color:var(--rd-ink, #3b463d); }
.fr-caret-leaf{ cursor:default; }
/* swatch — the cap-3 map's colour for this node, so a legend row and its pins read as one thing.
Square (not a dot) to match the charts rail's .rd-li i, which is the same idea in that panel. */
.fr-sw{ flex:0 0 auto; width:9px; height:9px; border-radius:2px; margin-left:4px;
box-shadow:inset 0 0 0 1px rgba(59,70,61,.18); }
/* re-introduce the inter-column spacing the row's gap used to supply (trunks stay flush) */
.fr-lbl{ flex:0 1 auto; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-left:6px; }
.fr-bar{ flex:1 1 auto; height:6px; min-width:24px; background:var(--dx-surface, #f3f3f1); border-radius:3px; overflow:hidden; margin-left:8px; }
/* magnitude fill — a QUANTITY bar, not a categorical series, so it takes the sage accent
rather than a hue of its own (the pale blue here was the last of the drift). */
.fr-bar i{ display:block; height:100%; background:var(--dx-accent, #92b195); border-radius:3px; transition:width .3s; }
.fr-row.on .fr-bar i{ background:var(--rd-accent,#e67e22); }
.fr-cnt{ flex:0 0 auto; font-variant-numeric:tabular-nums; color:#6b7686; min-width:30px; text-align:right; font-size:11px; }
.fr-cnt{ flex:0 0 auto; font-variant-numeric:tabular-nums; color:var(--dx-muted, #6d766e); min-width:30px; text-align:right; font-size:11px; margin-left:8px; }
`, 'rd-facetrail')
</script>
<template>
<aside class="rd-facets" data-region="facets">
<div class="fr-head">
<!-- "Tree Legend" — the Driver's naming ruling: the word "semantic" is dropped from the
user-facing label (the component/docs still describe the semantic-tree MODEL).
The four stacked trees below it are People · Place · Language · Religion, in that
order — the four axes a researcher navigates to find who has gospel resources. -->
<div class="fr-title">Tree Legend</div>
<div class="fr-scope" data-scope-count><b>{{ scopeCount.toLocaleString('en-US') }}</b> groups in scope</div>
</div>
<div class="fr-body" ref="railEl" @click="onRowClick">
@ -173,6 +365,20 @@ useShadowStyles(`
<div v-show="open[sec.id]" class="fr-rows">
<div v-for="r in sec.rows" :key="r.facet + ':' + r.value" class="fr-row" :class="['l' + r.level, { on: r.active, other: r.other }]"
:data-facet="r.other ? null : r.facet" :data-value="r.other ? null : r.value">
<!-- tree trunks: one span per ancestor level, same glyph vocabulary as the
research-map's SemanticTreeLegend so the hierarchy is DRAWN, not just padded. -->
<span v-for="(seg, i) in r.trunks" :key="'tr' + i" class="fr-trunk" :class="'fr-trunk-' + seg"></span>
<!-- caret = EXPAND (navigate). Separate hit-target from the row body, which FILTERS.
Leaf rows render an empty slot so every label in a tree stays left-aligned. -->
<span v-if="r.hasChildren" class="fr-caret" :class="{ open: r.expanded }"
:data-expand="r.facet" :data-expand-value="r.value"
role="button" tabindex="0"
:aria-expanded="r.expanded ? 'true' : 'false'"
:aria-label="(r.expanded ? 'Collapse ' : 'Expand ') + r.label">▸</span>
<span v-else class="fr-caret fr-caret-leaf" aria-hidden="true"></span>
<!-- swatch: the cap-3 map's colour for this node. Display-only — never part of the
filter value. Omitted for uncoloured sections and for the 'Other' bucket. -->
<span v-if="r.swatch" class="fr-sw" :style="{ background: r.swatch }" aria-hidden="true"></span>
<span class="fr-lbl">{{ r.label }}</span>
<span class="fr-bar"><i :style="{ width: Math.max(4, r.frac * 100) + '%' }"></i></span>
<span class="fr-cnt">{{ r.count.toLocaleString('en-US') }}</span>

View file

@ -6,11 +6,29 @@
*/
import { inject, computed } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
const bus = inject('selectionBus', null)
const cfg = inject('dashboardConfig', {})
// Same store the facet rail reads — the source useAffinityBlockLegendData resolves People
// codes to human names from.
const dataStore = inject('dataStore', null)
const searchSourceId = inject('searchSourceId', null)
const filterSet = bus?.filterSet || {}
const chips = computed(() => Object.entries(filterSet).map(([k, v]) => ({ k, text: String(v), facet: cfg.facets?.[k]?.label || k })))
// ── People codes must not reach this chip ────────────────────────────────────────────────────
// The People facets key by CODE (dashboard.config.js:54-57), so a chip built straight off the
// stored value read 'Affinity Bloc:A012' — the user clicked "South Asian Peoples" and got back an
// opaque identifier. labelFor() is the SAME projection the rail uses, so the two can never drift.
// DISPLAY ONLY: `k` (the facet key) and the stored filterSet value are never rewritten — the bus
// matches the accessor's output byte-for-byte, and a chip that merely looked right while the
// table stopped responding would be a worse bug than the one being fixed.
const { labelFor } = useTreeLegendLabels(cfg, dataStore, searchSourceId)
const chips = computed(() => Object.entries(filterSet).map(([k, v]) => ({
k,
text: String(labelFor(k, v)),
facet: cfg.facets?.[k]?.label || k,
})))
function onClick(e) {
const el = e.target && e.target.closest && e.target.closest('[data-chip]')
@ -20,19 +38,49 @@ function onClick(e) {
}
useShadowStyles(`
.rd-chips{ display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
/* BOUNDED so the chips never slide under the centred search bar (BUG-SELECTION-CHIPS-UNDER-SEARCHBAR).
MEASURED live at 1440x900 before the fix: search x 540..900, first chip x 482..718 — ~178px of
overlap, which is the Driver's "renders badly under the TOP search bar".
WHY THE CHIPS MOVE AND NOT THE SEARCH: DashboardTopBar deliberately absolute-centres the search
(left:50% + translateX(-50%)) so its centre stays fixed "regardless of the left (title/breadcrumb/
chips) or right content widths" — a flow-based margin:auto would drift as chips grow. It also
sits ON TOP of the row. So the search is not the thing to move.
THE BUDGET: the search is width:360px, max-width:min(360px,42%), centred — so its left edge is at
50% - min(180px, 21%). The chip strip starts after the brand/breadcrumb and must STOP SHORT of
that edge. min(180px,21%) + a 14px gutter gives the max-width below; 34% is the conservative
floor for narrow viewports where the 42% clamp binds instead of the 360px one.
nowrap + the per-chip cap keeps the bar at 52px — the Driver has repeatedly said NEVER add a
second header row, so growing the bar is not an option. Overflowing chips scroll horizontally
and every truncated label keeps its full text in a title tooltip. */
/* NO max-width here ON PURPOSE. The bound lives ONCE, on .rd-left in DashboardTopBar (which wraps
brand + breadcrumb + chips together). I briefly had a second cap here and it was both redundant
and a hazard: two mechanisms constraining the same axis interact, and the outer one is the only
one that can be correct — measured, the chips' natural width (297px) is already under any sane
cap, while the brand (248px) + breadcrumb (196px) push the chips' START past the search's left
edge. Capping width cannot fix a start position. */
.rd-chips{ display:flex; align-items:center; gap:6px; flex-wrap:nowrap; min-width:0;
overflow-x:auto; overflow-y:hidden; scrollbar-width:none; }
.rd-chips::-webkit-scrollbar{ height:0; width:0; }
.rd-chip{ display:inline-flex; align-items:center; gap:5px; font-size:11px; font-weight:600; padding:3px 9px; border-radius:999px;
background:#fff; color:#1a1a2e; border:1px solid #e67e22; cursor:pointer; line-height:1.2; }
.rd-chip .fc{ opacity:.55; font-weight:500; } .rd-chip i{ font-style:normal; opacity:.6; font-size:12px; }
background:#fff; color:#1a1a2e; border:1px solid #e67e22; cursor:pointer; line-height:1.2;
flex:0 0 auto; max-width:200px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* the label span is what actually truncates; the facet prefix and the x stay readable */
.rd-chip .rd-chip-val{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
.rd-chip .fc{ opacity:.55; font-weight:500; flex:0 0 auto; } .rd-chip i{ font-style:normal; opacity:.6; font-size:12px; flex:0 0 auto; }
.rd-chip:hover{ background:#fff4ea; } .rd-chip:hover i{ opacity:1; }
.rd-chip.rd-clear{ border-color:#c7ccd6; color:#5a6577; } .rd-chip.rd-clear:hover{ background:#eef1f6; }
.rd-chip.rd-clear{ border-color:var(--dx-line, #d1d4cd); color:var(--dx-muted, #6d766e); } .rd-chip.rd-clear:hover{ background:var(--dx-surface, #f3f3f1); }
`, 'rd-chips')
</script>
<template>
<div v-if="chips.length" class="rd-chips" data-chips-bar @click="onClick">
<span v-for="c in chips" :key="c.k" class="rd-chip" :data-chip="c.k" data-filter-chip>
<span class="fc">{{ c.facet }}:</span>{{ c.text }} <i>✕</i>
<!-- :title carries the FULL "Facet: Value" even when the chip truncates, so a long People or
Language label (some run 40+ chars) stays readable on hover rather than being lost to the
ellipsis. The value lives in its own span because that is the only part that should shrink:
the facet prefix and the ✕ affordance must stay legible at every width. -->
<span v-for="c in chips" :key="c.k" class="rd-chip" :data-chip="c.k" data-filter-chip
:title="c.facet + ': ' + c.text">
<span class="fc">{{ c.facet }}:</span><span class="rd-chip-val">{{ c.text }}</span> <i>✕</i>
</span>
<span class="rd-chip rd-clear" data-chip="__all__">Clear all</span>
</div>

View file

@ -8,8 +8,21 @@
*/
import { inject, ref, computed, onMounted, onBeforeUnmount, watch, unref } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useMapFly } from '@map/composables/useMapFly.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// Table selection -> map camera. The table never touches the map: it commits to the bus,
// this composable watches bus.selectionVersion and flies ONCE per settled change.
import { useSelectionCamera } from '../../composables/useSelectionCamera.js'
// ── Map controls — REUSED from the shared library, not hand-rolled. MapToolbar is a
// layout-only positioned column; each button binds :map explicitly so it can only ever
// drive THIS panel's instance (the multi-map contract in map-controls/README.md).
// Deliberately a MINIMAL subset (zoom in / zoom out): the dashboard cell is small and
// the space budget is tight, so location/fullscreen/theme/share stay out.
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
import ZoomInButton from '@map/components/map-controls/ZoomInButton.vue'
import ZoomOutButton from '@map/components/map-controls/ZoomOutButton.vue'
import { lbl } from '../../composables/useDashboardAggregations.js'
import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDashboardShell.js'
const dash = inject('dashboardData', null)
const bus = inject('selectionBus', null)
@ -18,11 +31,14 @@ const cfg = inject('dashboardConfig', {})
const filterSet = bus?.filterSet || {}
const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
// shared free-text search predicate (header-1) — one source of truth with the shell + other panels.
const matchSearch = makeSearchMatcher(bus, buildSearchAccessors(cfg))
const filteredRows = computed(() => {
const rows = dash?.rows?.value || []
const keys = Object.keys(filterSet)
if (!keys.length) return rows
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
const hasText = !!(bus?.searchTerm?.value || '').trim()
if (!keys.length && !hasText) return rows
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }) && matchSearch(r))
})
// Geo join is config-driven: geoFacet selects the facet whose `geoIso` accessor yields the ISO-3 join
// value (e.g. 'IND') for the choropleth, and whose `get` accessor yields the filter label (e.g. 'India').
@ -46,6 +62,28 @@ const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer, accessToken: unref(mapboxToken) || (mapboxToken && mapboxToken.value) || '',
center: [28, 18], zoom: 1.45, minZoom: 1,
})
// Shared library camera helper — one fly/fitBounds implementation for the whole studio.
const mapFly = useMapFly({
getMap: () => map.value,
mapId: 'rdash-map',
defaultCenter: [28, 18],
defaultZoom: 1.45,
})
// ── Table selection -> camera (the Driver's ask: "select in the table, the map zooms there") ──
// Frames the selection off the FULL row store, not filteredRows — matching selectedGeo()
// below, so the camera always frames exactly the pins the selection pen draws (a row can be
// selected and then filtered out of scope; the pen deliberately keeps showing it).
// Gated on isMapReady so a selection made during boot can't fly a half-built camera.
// Motion safety lives inside the composable: ONE debounced animation per settled selection,
// never per frame, and a jump (duration 0) under prefers-reduced-motion.
let stopSelectionCamera = null
stopSelectionCamera = useSelectionCamera({
bus,
mapFly,
getMap: () => map.value,
getRowStore: () => dash?.rowStore?.value,
isEnabled: () => isMapReady.value,
})
let added = false
// Container-size watcher: Mapbox only re-reads its canvas size on window resize / its own
// 'resize' event, so when the LAYOUT changes the cell (e.g. the table collapses and this map's
@ -54,15 +92,37 @@ let added = false
// General-correct (not table-specific): also covers window/rail/facet reflow. rAF-coalesced.
let containerRO = null
let offToggleTable = null
let offLayoutResizing = null
let _resizeRaf = 0
// BUG-RESIZE-STROBE (photosensitive-seizure risk): while a section divider is being DRAGGED the
// container changes size every frame. map.resize() makes MapLibre emit its own 'resize', which
// useMapInstance handles by recomputing the aspect-aware zoom FLOOR from the container height and
// calling setMinZoom/setZoom. On a vertical drag the height — and therefore the floor — moved every
// frame, so the camera was re-snapped ~60x/sec and the whole globe re-rendered: visible strobing.
// While `liveResize` is true we skip map.resize() entirely (the canvas is stretched by CSS, which
// is cheap and flicker-free) and run ONE real resize on drag end.
let liveResize = false
function scheduleMapResize() {
if (_resizeRaf) return
if (liveResize || _resizeRaf) return
_resizeRaf = requestAnimationFrame(() => {
_resizeRaf = 0
const m = map.value
if (m && typeof m.resize === 'function') { try { m.resize() } catch (_) {} }
})
}
// Drag start/end from useDashboardLayout. On end, do the single expensive pass that was
// suppressed for the whole drag, plus one delayed pass for the grid/transition settle.
function setLiveResize(on) {
const next = !!on
if (next === liveResize) return
liveResize = next
if (liveResize) {
if (_resizeRaf) { cancelAnimationFrame(_resizeRaf); _resizeRaf = 0 }
return
}
scheduleMapResize()
setTimeout(scheduleMapResize, 180)
}
// Choropleth bins — config-driven (cfg.map.bins) with the default 5-bin grey→black ramp
// (few people-groups = light grey, many = black). Override via cfg.map.bins.
@ -202,6 +262,15 @@ onMounted(async () => {
await initializeMap()
const m = map.value; if (!m) return
if (m.isStyleLoaded()) addLayers(); else m.on('load', addLayers)
// Keep the zoom buttons' enabled state in step with the live camera. 'zoom' fires only during
// an actual zoom; 'zoomend' catches the settle. Both just set two booleans (no camera writes).
m.on('zoom', syncZoomLimits)
m.on('zoomend', syncZoomLimits)
// The minZoom FLOOR is aspect-aware, so it moves when the cell is resized: re-read the limits
// after a resize or the zoom-out button could stay greyed once the panel grows. Read-only —
// this handler calls no camera method, so it adds nothing to the resize work per frame.
m.on('resize', syncZoomLimits)
syncZoomLimits()
// also guard: style may already be loaded by the time we attach
m.on('idle', () => { if (!added && m.isStyleLoaded()) addLayers() })
// watch the map container for size changes (table collapse/expand, rail reflow, window) and
@ -210,22 +279,117 @@ onMounted(async () => {
containerRO = new ResizeObserver(scheduleMapResize)
containerRO.observe(mapContainer.value)
}
// Keep the legend clear of the attribution. Mapbox swaps between the expanded strip and
// the compact "i" button as the map's WIDTH crosses its own breakpoint, so re-measure on
// every panel resize, not just once. 'idle' catches the control's first paint.
// syncAttribClearance attaches its own ResizeObserver to the attribution control the first
// time it finds it; these events just make sure it gets a chance to run. 'idle' covers the
// control's first paint, 'resize' covers the expanded <-> compact swap on width changes.
syncAttribClearance()
m.on('idle', syncAttribClearance)
m.on('resize', syncAttribClearance)
// Belt-and-suspenders: the RO observes the container, but when the table cell toggles
// display:none (v-show) the grid reflow can land a frame before the observer notices, so
// the 1fr map row grows without the canvas following → the reported gray gap. Resize
// explicitly the moment collapse state flips (rAF now + one delayed pass for the settle).
if (bus && typeof bus.on === 'function') {
offToggleTable = bus.on('toggleTable', () => { scheduleMapResize(); setTimeout(scheduleMapResize, 280) })
// suppress per-frame canvas resizes for the duration of a divider drag (anti-strobe)
offLayoutResizing = bus.on('layoutResizing', setLiveResize)
}
})
onBeforeUnmount(() => {
// Drop the pending selection fly FIRST: its debounce timer outlives the component, and a
// camera call against a destroyed map throws. Same discipline as _clearSnap in useMapInstance.
try { stopSelectionCamera && stopSelectionCamera() } catch (_) {} stopSelectionCamera = null
try { offToggleTable && offToggleTable() } catch (_) {} offToggleTable = null
try { offLayoutResizing && offLayoutResizing() } catch (_) {} offLayoutResizing = null
if (containerRO) { try { containerRO.disconnect() } catch (_) {} containerRO = null }
if (attribRO) { try { attribRO.disconnect() } catch (_) {} attribRO = null } attribEl = null
if (_resizeRaf) { cancelAnimationFrame(_resizeRaf); _resizeRaf = 0 }
// Detach the zoom-limit listeners before destroy() so they can never run on a dead map.
try {
const m = map.value
if (m) {
m.off('zoom', syncZoomLimits); m.off('zoomend', syncZoomLimits); m.off('resize', syncZoomLimits)
m.off('idle', syncAttribClearance); m.off('resize', syncAttribClearance)
}
} catch (_) {}
try { destroy && destroy() } catch (_) {}
})
watch([filteredRows, isMapReady], applyPaint)
// ── Zoom-limit state for the control buttons ──
// useMapInstance keeps an aspect-aware minZoom FLOOR that tracks the container height, so on a
// short dashboard cell map.zoomOut() can be silently clamped to a no-op. Mirror the live limits
// into refs so the buttons DISABLE at the ends — a greyed button reads as "at the limit" instead
// of a dead control, and it gives the verification a deterministic thing to assert on.
//
// PHOTOSENSITIVITY (BUG-RESIZE-STROBE): this is event-driven, NOT a polling/rAF loop. It listens
// to Mapbox's own 'zoom'/'zoomend' (which only fire while a zoom is actually in flight) and writes
// two booleans. It never calls resize(), setZoom, setMinZoom or any camera method, so it cannot
// feed back into _onResize's debounced floor-snap — the strobe path is untouched.
const canZoomIn = ref(true)
const canZoomOut = ref(true)
function syncZoomLimits() {
const m = map.value
if (!m) return
// Small epsilon: Mapbox parks a clamped camera a hair off the exact limit.
const z = m.getZoom()
canZoomIn.value = z < m.getMaxZoom() - 0.01
canZoomOut.value = z > m.getMinZoom() + 0.01
}
// ── ATTRIBUTION CLEARANCE (licence-critical) ───────────────────────────────────
// The legend must never cover Mapbox's "(c) Mapbox (c) OpenStreetMap Improve this map"
// credit — visible attribution is required by the Mapbox ToS and the OSM ODbL.
//
// A FIXED offset is not safe here, because the thing being cleared CHANGES SIZE at a
// breakpoint we do not control: on a narrow map Mapbox collapses the attribution into a
// compact round "i" button that is 24px tall, TALLER than the 20px expanded strip. A
// clearance tuned at 1440 was 2px short at 1024 and the boxes touched again.
// So we MEASURE the real control and publish its height as --rd-attrib-clear.
// Read-only: we only ever read the control's rect and set a variable on our own wrapper.
const mapWrap = ref(null)
let attribRO = null
let attribEl = null
function syncAttribClearance() {
const wrap = mapWrap.value; if (!wrap) return
let h = 0
try {
const root = wrap.getRootNode?.()
const el = (root && root.querySelector)
? root.querySelector('.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-attrib')
: null
if (el) {
// Measure the strip the control OCCUPIES measured from the PANEL'S BOTTOM EDGE —
// not the control's own height. Mapbox gives the control a margin inside
// .mapboxgl-ctrl-bottom-right (10px for the compact button), so height alone
// undercounts the reserved space by exactly that margin and the legend still
// clipped the button's top by 2px at 1024px even with the height measured correctly.
// Panel-bottom-minus-control-top is margin-inclusive and therefore self-correcting.
const cr = el.getBoundingClientRect()
const wr = wrap.getBoundingClientRect()
h = Math.max(cr.height || 0, wr.bottom - cr.top)
// OBSERVE THE CONTROL ITSELF, not just our panel. Mapbox collapses the credit into
// the compact "i" button AFTER first paint, and that transition changes the CONTROL's
// height while the PANEL's size never moves — so a panel-only ResizeObserver never
// fires and we would stay pinned to the stale expanded measurement (measured live:
// clearance stuck at 20px while the button was really 24px, and the boxes touched).
if (attribEl !== el && typeof ResizeObserver !== 'undefined') {
if (attribRO) { try { attribRO.disconnect() } catch (_) {} }
attribEl = el
attribRO = new ResizeObserver(syncAttribClearance)
attribRO.observe(el)
}
}
} catch (_) { /* pre-paint / detached */ }
// Floor at the expanded-strip height so we degrade to a sane gap if the control
// is not in the DOM yet, and cap it so a freak measurement cannot eat the panel.
const clear = Math.min(Math.max(Math.ceil(h) || 20, 20), 72)
wrap.style.setProperty('--rd-attrib-clear', clear + 'px')
}
// ── RDASH-ENHANCE: GLOBE (default) <-> MERCATOR projection toggle ──
const projection = ref('globe')
function applyProjection() { const m = map.value; if (m) { try { m.setProjection(projection.value) } catch (_) {} } }
@ -250,24 +414,66 @@ let _flyT = null
watch(() => JSON.stringify(filterSet), () => { clearTimeout(_flyT); _flyT = setTimeout(flyToFiltered, 260) })
useShadowStyles(`
.rd-mapwrap{ position:relative; width:100%; height:100%; min-height:0; background:#dfe6ef; }
.rd-mapwrap{ position:relative; width:100%; height:100%; min-height:0; background:var(--dx-line, #d1d4cd); }
.rd-mapwrap .rd-mapc{ position:absolute; inset:0; }
.rd-maptag{ position:absolute; left:8px; bottom:8px; z-index:2; font-size:10px; color:#33415588; background:#ffffffcc; padding:2px 6px; border-radius:4px; }
.rd-proj{ position:absolute; right:8px; top:8px; z-index:3; font-size:11px; font-weight:700; cursor:pointer; user-select:none;
background:#1a1a2ecc; color:#fff; border:1px solid #ffffff33; border-radius:6px; padding:4px 9px; backdrop-filter:blur(4px); }
.rd-proj:hover{ background:#1a1a2e; }
.rd-legend-map{ position:absolute; right:8px; bottom:8px; z-index:2; background:#ffffffe8; border:1px solid var(--rd-line,#e2e6ec);
/* Zoom cluster. MapToolbar defaults to top:6px on the inline-end edge, which is exactly where
the Globe/Flat pill already sits, so drop this instance below it. 34px clears the pill
(top:8px + roughly 22px tall) with a small breathing gap.
SPECIFICITY: MapToolbar ships .map-toolbar and .map-toolbar .mcb rules at the SAME
specificity as a plain .rd-zoomtools override, and both stylesheets are injected into the
same shadow root — so a tie would be resolved by injection ORDER, which is not something
this panel controls. Doubling the class (.rd-zoomtools.rd-zoomtools) raises specificity so
these win deterministically, without !important and without editing the shared toolbar. */
.rd-zoomtools.rd-zoomtools{ top:34px; gap:4px; }
/* Economical use of space: 26px instead of the toolbar's standard 32px. The dashboard map is
one cell among many, so the controls stay present but visually quiet. Also match the light
dashboard surface/border tokens so the cluster reads as part of this panel. */
.rd-zoomtools.rd-zoomtools .mcb{
width:26px; height:26px;
border:1px solid var(--rd-line,#d1d4cd); color:var(--dx-text,#3b463d); box-shadow:0 1px 2px rgba(0,0,0,0.10);
}
.rd-zoomtools.rd-zoomtools .mcb:hover:not(:disabled){ background:var(--dx-surface,#f3f3f1); box-shadow:0 1px 4px rgba(0,0,0,0.14); }
/* MapToolbar's own mobile breakpoint re-asserts top/gap and a 36px button, so restate here. */
@media(max-width:767px){
.rd-zoomtools.rd-zoomtools{ top:34px; gap:4px; }
.rd-zoomtools.rd-zoomtools .mcb{ width:26px; height:26px; }
}
/* LEGEND vs ATTRIBUTION — a LICENCE constraint, not a taste call.
Mapbox pins its own .mapboxgl-ctrl-attrib ("(c) Mapbox (c) OpenStreetMap Improve this map")
to the map's BOTTOM-RIGHT, and both the Mapbox ToS and the OpenStreetMap ODbL require that
credit to stay VISIBLE. This legend used to sit at right:8px/bottom:8px — the identical
anchor — so it painted straight over the credit and the two read as one garbled strip
(BUG-MAP-LEGEND-OVERLAPS-MAPBOX-ATTRIBUTION, live-verified at 1440x900).
The fix RAISES THE LEGEND, and never touches the attribution: nothing here hides, shrinks
or restyles .mapboxgl-ctrl-attrib. --rd-attrib-clear is the reserved strip along the bottom
edge that the credit line owns; the legend's bottom edge starts above it.
Bottom-LEFT was not available — .rd-maptag already holds that corner.
Stays on the right so the legend sits beside the choropleth it explains. */
.rd-mapwrap{ --rd-attrib-clear:26px; }
.rd-legend-map{ position:absolute; right:8px; bottom:calc(var(--rd-attrib-clear,26px) + 8px); z-index:2;
background:#ffffffe8; border:1px solid var(--rd-line, #d1d4cd);
border-radius:var(--r-md,6px); padding:6px 8px; backdrop-filter:blur(3px); box-sizing:border-box; max-width:148px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
.rd-legend-map .lg-h{ font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.4px; color:var(--rd-muted,#6b7686); margin-bottom:4px; }
.rd-legend-map .lg-row{ display:flex; align-items:center; gap:6px; font-size:10px; color:#3a4456; line-height:1.5; }
.rd-legend-map .lg-h{ font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.4px; color:var(--rd-muted, #6d766e); margin-bottom:4px; }
.rd-legend-map .lg-row{ display:flex; align-items:center; gap:6px; font-size:10px; color:var(--dx-text, #3b463d); line-height:1.5; }
.rd-legend-map .lg-sw{ width:11px; height:11px; border-radius:2px; flex:0 0 auto; border:1px solid #00000014; }
`, 'rd-mappanel')
</script>
<template>
<div class="rd-mapwrap" data-sub="map" :data-projection="projection">
<div ref="mapWrap" class="rd-mapwrap" data-sub="map" :data-projection="projection">
<div ref="mapContainer" class="rd-mapc" data-map-canvas></div>
<!-- Minimal control cluster: zoom in / zoom out only. Reused library components; :map binds
each button to THIS panel's instance. Both use Mapbox's discrete zoomIn()/zoomOut() step,
so a click is one bounded easing — never a per-frame camera loop. -->
<MapToolbar class="rd-zoomtools" :has-geocoder="false" data-map-zoom-controls>
<ZoomInButton :map="map" :disabled="!canZoomIn" />
<ZoomOutButton :map="map" :disabled="!canZoomOut" />
</MapToolbar>
<span class="rd-proj" role="button" tabindex="0" data-proj-toggle @click="toggleProjection">
{{ projection === 'globe' ? '🌐 Globe' : '▭ Flat' }}
</span>

View file

@ -0,0 +1,138 @@
<script setup>
/**
* DashboardResearchFilters — the DX-life research-page expand-filters dropdown set, baked into
* the research-dashboard as the FILTERS band (the header row BELOW the topbar).
*
* ── HEADER STACK (card BUG-RESEARCH-DASHBOARD-HEADER-STACK-WRONG) ──
* The search bar was LIFTED UP into the DashboardTopbar (it shares the top row with Align/CSV),
* so this band is now FILTERS-ONLY: it passes `:show-search="false"` to the shared header and
* renders just the horizontal-scroll dropdown row. The final clean stack is therefore:
* [topbar: title · search · Align · CSV] → [THIS filters/dropdowns row] → [KPI stats] → body.
* The search wiring still lands on the SAME selection bus (the topbar calls bus.setSearch), so
* every linked panel re-derives identically — only the search bar's PLACEMENT moved.
*
* This is the THIN ADAPTER (profile side): it renders the SHARED, parameterized
* @map/components/search/SearchFilterHeader.vue and COUPLES it to the dashboard's selection
* bus + in-memory row store. The header element itself is pure UI (no bus, no fetch, no
* hardcoding) so the SAME element also drives the people-groups-grid + big-picture; the wiring
* that makes searching/filtering change the dashboard data + map + table lives HERE.
*
* HOW IT COUPLES (brushing-and-linking, zero panel edits):
* - search bar → bus.setSearch(term) → every panel's local `filtered` honors bus.searchTerm
* via the shared makeSearchMatcher (table/map/charts/
* facets/KPIs all re-derive → live-filter).
* - a dropdown pick → bus.setFilter(facetKey, LABEL) — the value fed to the bus is the SAME
* string cfg.facets[facetKey].get(row) returns (the label), so filteredRows'
* `accessor(r) === filterSet[k]` matches (byte-match safe, spec §8 intent).
* - clear / remove chip → bus.setFilter(facetKey, null).
*
* OPTIONS + COUNTS: computed from the injected dashboardData rows through the config's facet
* accessors, with cross-filtered counts (each axis counted under the OTHER active filters) —
* the SAME faceted-count model DX-life's UupgsList + the people-groups-grid use, so the
* dropdown numbers agree with the facet rail.
*
* Which axes to show is config-driven: cfg.researchFilters.dropdowns = [facetKey, …]
* (defaults to the classic DX-life five: region / block / country / people-group / religion,
* mapped to this dashboard's facet keys). Nothing here is dashboard-specific beyond that map.
*/
import { inject, computed } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import SearchFilterHeader from '@map/components/search/SearchFilterHeader.vue'
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
const bus = inject('selectionBus', null)
const dash = inject('dashboardData', null)
const cfg = inject('dashboardConfig', {})
const rows = computed(() => dash?.rows?.value || [])
const filterSet = bus?.filterSet || {}
// facet key -> accessor (returns the LABEL the bus matches on).
const ACC = computed(() => Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get])))
// Which dropdown axes to render (config-driven; DX-life default five mapped to dashboard facet keys).
const rf = computed(() => cfg.researchFilters || {})
const dropdownKeys = computed(() => rf.value.dropdowns || ['wagf_region', 'wagf_block', 'country_code', 'people_group', 'religion'])
const searchPlaceholder = computed(() => rf.value.searchPlaceholder || 'Search people groups, place, language, religion…')
// Apply a filter-set (facetKey -> label) to a row list — reused for cross-filtered counts.
function applyWith(fset, list) {
const keys = Object.keys(fset)
if (!keys.length) return list
return list.filter((r) => keys.every((k) => { const a = ACC.value[k]; return a ? a(r) === fset[k] : true }))
}
// Build one axis's options with cross-filtered counts (this axis counted under the OTHER active
// filters — DX-life updateFilterOptionCounts). option.value === option.label === accessor output,
// so a pick lands a byte-match-safe value on the bus.
function optionsFor(key) {
const acc = ACC.value[key]
if (!acc) return []
const others = {}
for (const [k, v] of Object.entries(filterSet)) if (k !== key) others[k] = v
const subset = applyWith(others, rows.value)
const counts = new Map()
for (const r of subset) {
const label = acc(r)
if (label == null || label === '') continue
counts.set(label, (counts.get(label) || 0) + 1)
}
return Array.from(counts.entries())
.map(([label, count]) => ({ value: label, label, count }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
}
// The `filters` prop the shared header renders (ONE object per dropdown).
const filters = computed(() => dropdownKeys.value.map((key) => ({
name: key,
label: cfg.facets?.[key]?.label || key,
options: optionsFor(key),
value: filterSet[key] || '',
})))
// Chips derived from the live filterSet. For THIS component's own dropdown axes label === value
// (optionsFor builds options straight off the accessor output), but the filterSet is SHARED — the
// Tree Legend lands People-tier CODE filters (affinity_bloc / cluster / people_group) on the same
// bus, and those chips rendered raw ('Affinity Bloc: A012'). labelFor() resolves them the same way
// the filter chips do. DISPLAY ONLY: `name` is what onRemoveChip acts on and the stored value is
// never rewritten.
const { labelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject('searchSourceId', null))
const activeChips = computed(() =>
Object.keys(filterSet).map((k) => ({ name: k, label: `${cfg.facets?.[k]?.label || k}: ${labelFor(k, filterSet[k])}` }))
)
const searchValue = computed(() => bus?.searchTerm?.value || '')
// ── couplers ──
function onSearch(payload) { bus?.setSearch(payload?.value ?? '') }
function onFilterChange(detail) { bus?.setFilter(detail.name, detail.value) }
function onFilterClear(detail) { bus?.setFilter(detail.name, null) }
function onRemoveFilter(detail) { bus?.setFilter(detail.name, null) }
function onClearAll() { bus?.clearFilters() }
useShadowStyles(`
/* header-2 host: the research-filters band sits in the dashboard's dedicated 'filters' grid row.
z-index high so the dropdown menus render ABOVE the stats/map/table below. */
.rd-research-filters{ position:relative; z-index:55; height:100%; box-sizing:border-box;
padding:8px 14px; background:var(--rd-panel,#fff); border-bottom:1px solid var(--rd-line, #d1d4cd);
overflow:visible; }
`, 'rd-research-filters')
</script>
<template>
<div class="rd-research-filters" data-region="filters" data-sub="research-filters">
<SearchFilterHeader
:search="searchValue"
:filters="filters"
:active-chips="activeChips"
:search-placeholder="searchPlaceholder"
:show-search="false"
start-expanded
@search="onSearch"
@filter-change="onFilterChange"
@filter-clear="onFilterClear"
@remove-filter="onRemoveFilter"
@clear-all="onClearAll"
/>
</div>
</template>

View file

@ -113,19 +113,21 @@ const bars = (cfg.search || []).map((entry) => {
// exactly its own contribution: a facet filter OR a member selection.
const state = { _lastFacet: null, _selectedMembers: false }
// Land a group's member rows on the selectionSet in ONE emit. A bloc / cluster /
// Land a group's member rows on the selectionSet in ONE commit. A bloc / cluster /
// people-group AGGREGATE has no dashboard facet key (the dashboard facets are
// region / country / block / religion / language), so instead of doing nothing we
// MARK its member rows — the dashboard equivalent of research-map's "dim to just
// this group's members". Uses only the exposed bus API (selectionSet + emit), so
// useSelectionBus is untouched; one emit avoids N re-renders for a big PG (e.g.
// "Deaf" spanning 100+ countries).
// this group's members".
//
// Uses bus.setSelection() (the bulk replace primitive) rather than hand-mutating the
// raw Set: selectionSet is deliberately NOT reactive, so a direct .clear()/.add() would
// never bump selectionVersion and every dependent computed would silently go stale.
// It is also one commit instead of two — a big PG ("Deaf", 100+ countries) repaints the
// map once. See the performance contract in useSelectionBus.js.
function selectMembers(memberIds, name) {
const members = Array.isArray(memberIds) ? memberIds.filter(Boolean) : []
if (!members.length || !bus) return false
bus.selectionSet.clear()
for (const slug of members) bus.selectionSet.add(slug)
bus.emit('select', bus.selectionSet)
bus.setSelection(members)
bus.emit('focusRow', { slug: members[0], name })
return true
}
@ -178,11 +180,11 @@ useShadowStyles(`
hid the dropdown extending below the single-row header. */
.rd-searchbar-header{ position:relative; z-index:60; display:flex; align-items:center; gap:8px;
height:100%; padding:6px 14px; background:var(--rd-panel,#fff);
border-bottom:1px solid var(--rd-line,#e2e6ec); box-sizing:border-box; overflow:visible; }
border-bottom:1px solid var(--rd-line, #d1d4cd); box-sizing:border-box; overflow:visible; }
.rd-searchbar{ position:relative; flex:1 1 0; min-width:150px; }
.rd-searchbar .dg-standalone-geocoder .mapboxgl-ctrl-geocoder{ width:100%!important; max-width:none!important;
box-shadow:none!important; border:1px solid var(--rd-line,#e2e6ec); border-radius:var(--r-md,6px);
background:var(--rd-bg,#f7f9fb); }
box-shadow:none!important; border:1px solid var(--rd-line, #d1d4cd); border-radius:var(--r-md,6px);
background:var(--rd-bg, #f3f3f1); }
.rd-searchbar .dg-standalone-geocoder{ width:100%; }
/* lift the mapbox-gl-geocoder suggestions list above sibling dashboard panels */
.rd-searchbar .mapboxgl-ctrl-geocoder .suggestions,

View file

@ -1,12 +1,24 @@
<script setup>
/**
* DashboardTopBar — RDASH P4 Command Bar (topbar grid region, 52px).
* One cohesive light surface (--rd-* tokens, no dark #1a1a2e, 1px --rd-line border, no shadow):
* title · live typeahead search · inline Breadcrumb + FilterChips · CSV export.
* One cohesive header surface holding, on ONE row:
* title · the SHARED filter search bar · inline Breadcrumb + FilterChips · Align.
*
* Search is the new linking entry point: type a name → dropdown of matching people groups →
* pick (click / Enter) → bus.toggleSelect(slug) + emit('focusRow') which the existing subscribers
* already honor (MapPanel flyTo, DataTable scroll-to-row, DetailDrawer fill).
* CSV export is NOT here — it belongs to the people-groups table it exports, so it lives on
* DashboardDataTable's header bar below the map (card
* BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS).
*
* ── HEADER STACK (card BUG-RESEARCH-DASHBOARD-HEADER-STACK-WRONG) ──
* The primary search bar lives HERE now, in the topbar row, so the header stack reads:
* [THIS topbar: title · search · Align] → [filters dropdowns] → [KPI stats] → body.
* It is the SHARED SearchBar wired straight to bus.setSearch — the SAME free-text search that
* DashboardResearchFilters used to host in the filters band — so every linked panel (table/map/
* charts/facets/KPIs) re-derives via the shared makeSearchMatcher exactly as before; only the
* bar's PLACEMENT moved out of the filters band and up into this row (search-ABOVE-filters).
*
* The old LOCAL typeahead (type-a-name → pick → bus.toggleSelect) is kept behind the
* `showTopBarSearch` flag (default OFF) for dashboards that want a jump-to-record affordance
* instead of a live filter — it does NOT drive the shared filter bus.
* All CSS via useShadowStyles (scoped <style> is dropped in the defineCustomElement shadow build).
*
* Config-driven (DOXA-LIB-2): the brand title and the FACET_ACC accessors are read from
@ -14,9 +26,10 @@
*/
import { inject, computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import SearchBar from '@map/components/search/SearchBar.vue'
import DashboardFilterChips from './DashboardFilterChips.vue'
import DashboardBreadcrumb from './DashboardBreadcrumb.vue'
import { useCsvExport } from '../../composables/useCsvExport.js'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import { lbl } from '../../composables/useDashboardAggregations.js'
const dash = inject('dashboardData', null)
@ -26,23 +39,24 @@ const cfg = inject('dashboardConfig', {})
// facet bars (People/Place/Language/Religion). Feature-flag it OFF by default —
// code kept warm; a dashboard can opt back in with config `showTopBarSearch: true`.
const showTopBarSearch = computed(() => cfg.showTopBarSearch === true)
const FACET_ACC = Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))
const csv = dash && bus ? useCsvExport({ rowStore: dash.rowStore, rows: dash.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC }) : null
const selCount = computed(() => (bus?.selectionSet ? bus.selectionSet.size : 0))
const exportToast = ref('')
let _toastT = null
function onExport() {
if (!csv) return
const r = csv.exportCsv()
exportToast.value = r.downloaded ? `✓ ${r.rows} rows exported` : r.clipboard ? `✓ ${r.rows} rows copied to clipboard` : `✓ ${r.rows} rows`
clearTimeout(_toastT); _toastT = setTimeout(() => { exportToast.value = '' }, 2800)
}
// Share the WHOLE dashboard: this profile's standalone leaf page, TREE-RELATIVE
// (<bundle>/<profile>/index.html) — ShareButton joins it to the served tree root, so the snippet
// resolves from whatever host the folder was dropped on. Path read from the published catalog and
// verified 200 live, not constructed by hand.
const SHARE_EMBED_PATH = 'research-dashboard/research-dashboard/index.html'
// "Align Dashboard" — snap the drag-resized layout back to defaults. Stays generic: TopBar just
// emits on the shared selection bus; the profile's useDashboardLayout listens (bus.on('resetLayout',
// reset)) and clears its localStorage key + inline vars. Desktop-only — hidden via CSS on .rd-narrow.
function onAlignLayout() { if (bus) bus.emit('resetLayout') }
// ── primary filter search (shared SearchBar → the selection bus) ──
// The value the panels honor lives on the bus; this bar reads/writes it via bus.searchTerm /
// bus.setSearch, so it is the SAME free-text search the whole surface re-derives against.
const filterSearch = computed(() => bus?.searchTerm?.value || '')
const filterSearchPlaceholder = computed(() => cfg.researchFilters?.searchPlaceholder || 'Search people groups, place, language, religion…')
function onFilterSearch(payload) { bus?.setSearch(payload?.value ?? '') }
// ── live typeahead search over the in-memory row store ──
const q = ref('')
const open = ref(false)
@ -82,45 +96,97 @@ function onResultsClick(e) {
const rootEl = ref(null)
function onDocClick(e) { if (rootEl.value && !rootEl.value.contains(e.target)) open.value = false }
onMounted(() => { try { rootEl.value?.getRootNode()?.addEventListener('click', onDocClick) } catch (_) {} })
onBeforeUnmount(() => { try { rootEl.value?.getRootNode()?.removeEventListener('click', onDocClick) } catch (_) {}; clearTimeout(_toastT) })
onBeforeUnmount(() => { try { rootEl.value?.getRootNode()?.removeEventListener('click', onDocClick) } catch (_) {} })
useShadowStyles(`
/* DOXA green research-map header — matches research-map.vue site header (#4e594f / light ink #F3F3F1).
/* DOXA green research-map header — matches research-map.vue site header (#4e594f / light ink #f3f3f1).
Tokens stay overridable: --rd-header (green surface), --rd-header-ink (light text),
--rd-header-muted (light muted for icons/placeholders/sub-text). */
/* --rd-search-w is the ONE source of truth for the centred search's width; both the search box
itself and the chip strip's right-hand bound are derived from it, so the two can never drift
(a hardcoded second copy is what let the chips run under the search — see .rd-chips below). */
.rd-topbar{ position:relative; display:flex; align-items:center; gap:12px; height:100%; padding:0 14px;
background:var(--rd-header,#4e594f); color:var(--rd-header-ink,#F3F3F1); border-bottom:1px solid var(--rd-header-line,#3c453d);
--rd-search-w:min(360px,34%);
background:var(--rd-header,#4e594f); color:var(--rd-header-ink,#f3f3f1); border-bottom:1px solid var(--rd-header-line,#3c453d);
box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
/* LEFT GROUP (cards BUG-SELECTED-CHIPS-UGLY-UNDER-TOP-SEARCHBAR / BUG-SELECTION-CHIPS-UNDER-SEARCHBAR).
The search is position:absolute, so it is OUT OF FLOW — the flex row does not know the centre
strip is occupied, and the left content grew straight under it. The search stays centred exactly
as designed (its centre must not drift); the LEFT CONTENT is what gets bounded.
The bound is DERIVED from --rd-search-w so the two can never drift: the reserved strip is
--rd-search-w wide, so the left zone ends at 50% - half of it - one gutter (see below).
nowrap + min-width:0 keeps the 52px bar SINGLE-ROW (before the fix the chips wrapped to h=50
at 1024x768) — the Driver has said NEVER add a second header row. */
.rd-topbar .rd-left{ display:flex; align-items:center; gap:12px; flex:0 1 auto; min-width:0;
flex-wrap:nowrap; overflow:hidden;
/* MEASURED RESIDUAL — why this is ONE term and not a 12px gutter plus a 14px padding add-back.
BOTH original terms were deliberate: 12px was the gutter, and +14px added back the bar's
padding because this box starts inside the padding box. But they NET TO +2px, and the
measurement found exactly that overlap: chip-bar right 542 vs search left 540 at 1440,
462 vs 460 at 1280. The reasoning was sound; the arithmetic left no gutter at all.
The padding add-back is also not needed here: max-width resolves against this box's
CONTAINING BLOCK, which is the topbar's CONTENT box — the 14px padding is already excluded,
so adding it back double-counts and eats the gutter.
One explicit term instead. 16px > 12px on purpose: it absorbs the sub-pixel rounding you get
when two rects are rounded independently, and the narrow case where the search's percentage
clamp binds rather than its 360px (that one measured -7px, not -2px). */
max-width:calc(50% - (var(--rd-search-w) / 2) - 16px); }
/* The CHIPS are the live query state, so they get first claim on the group's space. The TITLE and
BREADCRUMB yield first and truncate with an ellipsis. Measured at 1024x768 the left zone is only
~334px while the brand alone was 248px, so WITHOUT letting the brand shrink the chip strip
collapsed to an unreadable 8px sliver. A chip the user cannot read is not a fix.
min-width:0 + the nested overflow/ellipsis is what actually lets them give way: a flex item will
not shrink past its min-content width on shrink-factor alone. */
.rd-topbar .rd-brand{ display:flex; align-items:baseline; gap:7px; flex:0 0 auto; }
.rd-topbar .rd-ttl{ font-weight:800; font-size:var(--fs-lg,13px); letter-spacing:.2px; color:var(--rd-header-ink,#F3F3F1); }
.rd-topbar .rd-left .rd-chips{ flex:0 1 auto; min-width:0; }
.rd-topbar .rd-left .rd-breadcrumb{ flex:0 1 auto; min-width:0; }
/* AFTER the .rd-brand rule above on purpose — that rule is flex:0 0 auto (never shrink) and is
equally specific, so it would win on source order and keep the brand at its full 248px. */
.rd-topbar .rd-left .rd-brand{ flex:0 1 auto; min-width:0; overflow:hidden; }
.rd-topbar .rd-left .rd-brand .rd-ttl{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
.rd-topbar .rd-ttl{ font-weight:800; font-size:var(--fs-lg,13px); letter-spacing:.2px; color:var(--rd-header-ink,#f3f3f1); }
.rd-topbar .rd-ttl .dot{ color:var(--rd-accent,#e67e22); }
.rd-topbar .rd-spacer{ flex:1; }
/* primary FILTER search bar (shared SearchBar → bus.setSearch), lifted into the topbar row so
it sits ABOVE the filters-dropdowns band (card BUG-RESEARCH-DASHBOARD-HEADER-STACK-WRONG).
Compact it for the 52px bar: shrink the primitive's padding/height so the white box fits.
CENTERED (card BUG-SEARCH-BAR-NOT-CENTERED): absolutely-positioned to the header's horizontal
centre so its centre stays dead-on regardless of the left (title/breadcrumb/chips) or right
(Align/CSV/toast) content widths. left:50% + translateX(-50%) is width-agnostic; a flow-based
margin:auto would drift as the interleaved chips grow. Sits ON TOP of the row (its neighbours
are the flow); pointer-events kept only on the box itself so it doesn't eat clicks around it. */
.rd-topbar .rd-filter-search{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
z-index:2; width:360px; max-width:var(--rd-search-w); min-width:150px; }
.rd-topbar .rd-filter-search .search-box{ margin:0; color:var(--rd-header-muted,#cbc5b9); }
.rd-topbar .rd-filter-search .search-box input{ height:32px; padding:0 10px 0 34px; font-size:var(--fs-md,12px);
border-radius:var(--r-md,6px); border-color:#ffffff3d; }
.rd-topbar .rd-filter-search .search-box input:focus{ border-color:var(--rd-accent,#e67e22); }
.rd-topbar .rd-filter-search .search-icon{ left:10px; width:15px; height:15px; }
/* narrow: return the search to normal flow (drop the absolute centring) and let it take the row
width; the align button is already hidden here, so a centred search has no right-hand anchor. */
.rd-shell.rd-narrow .rd-topbar .rd-filter-search{ position:static; transform:none;
left:auto; top:auto; flex:1 1 auto; width:auto; max-width:none; }
/* search — translucent light field so typed text stays legible on the green bar */
.rd-search{ position:relative; flex:0 1 280px; min-width:140px; }
.rd-search input{ width:100%; box-sizing:border-box; height:30px; padding:0 10px 0 28px; font-size:var(--fs-md,12px);
color:var(--rd-header-ink,#F3F3F1); background:#ffffff1f; border:1px solid #ffffff3d;
color:var(--rd-header-ink,#f3f3f1); background:#ffffff1f; border:1px solid #ffffff3d;
border-radius:var(--r-md,6px); outline:none; }
.rd-search input:focus{ border-color:var(--rd-accent,#e67e22); background:#ffffff33; }
.rd-search input::placeholder{ color:var(--rd-header-muted,#cbc5b9); }
.rd-search .ic{ position:absolute; left:8px; top:50%; transform:translateY(-50%); font-size:12px; color:var(--rd-header-muted,#cbc5b9); pointer-events:none; }
.rd-results{ position:absolute; left:0; right:0; top:34px; z-index:60; background:#fff; border:1px solid var(--rd-line,#e2e6ec);
border-radius:var(--r-md,6px); box-shadow:0 8px 24px #1f273322; overflow:hidden; max-height:320px; overflow-y:auto; }
.rd-results{ position:absolute; left:0; right:0; top:34px; z-index:60; background:#fff; border:1px solid var(--rd-line, #d1d4cd);
border-radius:var(--r-md,6px); box-shadow:0 8px 24px #1f273322; overflow:hidden; max-height:320px; overflow-y:auto;
scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent; }
.rd-results .rd-res{ display:flex; align-items:baseline; gap:8px; padding:6px 10px; cursor:pointer; font-size:var(--fs-md,12px); }
.rd-results .rd-res:hover, .rd-results .rd-res.on{ background:var(--rd-bg,#f7f9fb); }
.rd-results .rd-res .nm{ font-weight:700; color:var(--rd-ink,#1f2733); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.rd-results .rd-res .sub{ margin-left:auto; color:var(--rd-muted,#6b7686); font-size:var(--fs-sm,11px); white-space:nowrap; flex:0 0 auto; }
.rd-results .rd-empty{ padding:8px 10px; color:var(--rd-muted,#6b7686); font-size:var(--fs-sm,11px); }
/* csv */
.rd-topbar .rd-csv{ display:inline-flex; align-items:center; gap:5px; font-size:var(--fs-sm,11px); font-weight:700; cursor:pointer;
background:var(--rd-accent,#e67e22); color:#fff; border-radius:var(--r-md,6px); padding:5px 11px; user-select:none; flex:0 0 auto; }
.rd-topbar .rd-csv:hover{ background:#d8741c; }
.rd-topbar .rd-csv-n{ background:#ffffff33; border-radius:var(--r-pill,999px); padding:0 6px; font-size:10px; }
.rd-topbar .rd-toast{ font-size:var(--fs-sm,11px); font-weight:700; color:#1a7f46; background:#e7f6ed; border:1px solid #b6e3c6;
border-radius:var(--r-md,6px); padding:3px 9px; white-space:nowrap; flex:0 0 auto; }
.rd-results .rd-res:hover, .rd-results .rd-res.on{ background:var(--rd-bg, #f3f3f1); }
.rd-results .rd-res .nm{ font-weight:700; color:var(--rd-ink, #3b463d); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.rd-results .rd-res .sub{ margin-left:auto; color:var(--rd-muted, #6d766e); font-size:var(--fs-sm,11px); white-space:nowrap; flex:0 0 auto; }
.rd-results .rd-empty{ padding:8px 10px; color:var(--rd-muted, #6d766e); font-size:var(--fs-sm,11px); }
/* (csv button + toast styles moved to DashboardDataTable's header bar) */
/* Align Dashboard — subtle ghost button on the green bar; snaps the dragged layout to defaults.
Desktop-only: hidden whenever the shell is in its narrow/mobile mode (shared shadow root). */
.rd-topbar .rd-align{ display:inline-flex; align-items:center; gap:5px; font-size:var(--fs-sm,11px); font-weight:700;
cursor:pointer; user-select:none; flex:0 0 auto; color:var(--rd-header-ink,#F3F3F1);
cursor:pointer; user-select:none; flex:0 0 auto; color:var(--rd-header-ink,#f3f3f1);
background:#ffffff1f; border:1px solid #ffffff3d; border-radius:var(--r-md,6px); padding:5px 11px; }
.rd-topbar .rd-align:hover{ background:#ffffff33; border-color:var(--rd-accent,#e67e22); color:#fff; }
.rd-shell.rd-narrow .rd-topbar .rd-align{ display:none; }
@ -129,9 +195,26 @@ useShadowStyles(`
<template>
<header ref="rootEl" class="rd-topbar" data-region="topbar">
<span class="rd-brand"><span class="rd-ttl">{{ cfg.title || 'Research Dashboard' }}<span class="dot">.</span></span></span>
<DashboardBreadcrumb />
<DashboardFilterChips />
<!-- LEFT GROUP — brand + breadcrumb + chips share ONE bounded box so they collectively stop
short of the absolutely-centred search. A max-width on the chips ALONE cannot fix the
overlap: measured, the chips' natural width (297px) is already under any sane cap, while
the brand (248px) + breadcrumb (196px) push the chips' START to x=482 — past the search's
left edge at 1280 (460) and 1024 (332). A cap limits how far a box extends from where it
starts; it cannot move the start. Bounding the GROUP is what actually reserves the centre. -->
<div class="rd-left">
<span class="rd-brand"><span class="rd-ttl">{{ cfg.title || 'Research Dashboard' }}<span class="dot">.</span></span></span>
<DashboardBreadcrumb />
<DashboardFilterChips />
</div>
<div class="rd-filter-search" data-filter-search>
<SearchBar
:model-value="filterSearch"
:placeholder="filterSearchPlaceholder"
:aria-label="filterSearchPlaceholder"
max-width="360px"
@search="onFilterSearch"
/>
</div>
<span class="rd-spacer" />
<div v-if="showTopBarSearch" class="rd-search" data-search>
<span class="ic">⌕</span>
@ -152,9 +235,15 @@ useShadowStyles(`
@click="onAlignLayout" @keydown.enter.prevent="onAlignLayout" @keydown.space.prevent="onAlignLayout">
⤢ Align Dashboard
</span>
<span class="rd-csv" role="button" tabindex="0" data-csv-btn @click="onExport">
CSV ↓<span v-if="selCount" class="rd-csv-n">{{ selCount }}</span>
</span>
<span v-if="exportToast" class="rd-toast" data-export-toast>{{ exportToast }}</span>
<!-- CSV export MOVED to the people-groups table header (DashboardDataTable), below the map —
card BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS. It exports THAT table, so it now
lives on that table's header bar, which stays docked even when the table is collapsed. -->
<!-- SHARE the WHOLE dashboard (Driver 2026-09-20): "if we could actually embed this on a
partner site from one share button... they can embed this map on all their partner
websites." Reuses the SAME ShareButton the upg-100 MFE ships (no hand-rolled variant); the
embed path is THIS profile's tree-relative leaf, which ShareButton joins to the served tree
root — so the snippet it hands out works from whatever host the tree was dropped on. It
sits where the CSV button used to be, the top-right of the dashboard. -->
<ShareButton :embed-path="SHARE_EMBED_PATH" />
</header>
</template>

View file

@ -21,3 +21,7 @@ export { default as DashboardFilterChips } from './DashboardFilterChips.vue'
export { default as DashboardBreadcrumb } from './DashboardBreadcrumb.vue'
export { default as DashboardMapPanel } from './DashboardMapPanel.vue'
export { default as DashboardSearchHeader } from './DashboardSearchHeader.vue'
// DashboardResearchFilters — the DX-life research-page search bar + expand-filters dropdown SET
// (header-1 + header-2), a thin adapter over the shared @map/components/search/SearchFilterHeader
// coupling it to the selection bus (search → setSearch, dropdown pick → setFilter).
export { default as DashboardResearchFilters } from './DashboardResearchFilters.vue'

View file

@ -6,8 +6,105 @@
* with a '#' provenance header line of the active chips, RFC-ish quoting, and CSV-injection
* neutralization (leading = + - @ prefixed with a single quote). Shadow-DOM is a non-issue —
* export touches no rendered DOM/styles.
*
* ── ALL-FIELDS EXPORT (card BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS) ──
* The export must carry EVERY field the API holds, not just the 8 table columns. The limit was
* never in this file — flatten() already walks Object.keys(record). It was UPSTREAM: the store
* loads `?fields=<sources.json defaultFields>` (24 keys, ~2.3MB) because that is all the map /
* table / charts render. The detail endpoint exposes 67.
*
* So the wide record is fetched LAZILY, here, on the first export click — not at mount:
* - widening sources.json defaultFields would triple the payload (2.3MB -> 6.8MB) for EVERY
* bundle that shares that file, to serve one button;
* - the list endpoint honors an explicit `fields=` list, so one request returns all 67 keys
* for all rows (verified live: 2,107 rows x 67 keys).
* The result is cached for the session; if the fetch fails we still export the loaded record
* (24 fields) rather than producing nothing — degraded, never broken.
*/
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC }) {
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
import sourcesConfig from '@map/api/sources.json'
/**
* The full field set for the export, in a stable, human-readable order: identity first, then
* place, people/language/religion, then the engagement + IMB research columns. Sourced from the
* live /detail record shape (67 keys); `exportFields` in sources.json overrides it per-source.
*/
export const EXPORT_FIELDS = [
// identity
'slug', 'name', 'imb_alternate_name', 'doxa_wagf_uid', 'imb_uid', 'imb_peid', 'imb_pgid',
'joshua_project_id', 'imb_affinity_code',
// place
'latitude', 'longitude', 'country_code', 'region', 'wagf_region', 'wagf_block', 'wagf_member',
'imb_subregion', 'location_description',
// people / language
'rop1', 'imb_reg_of_people_2', 'imb_reg_of_people_25', 'imb_reg_of_people_3',
'primary_language', 'imb_language_family', 'imb_language_class', 'imb_language_speakers',
'work_in_local_language', 'imb_is_indigenous',
// religion
'religion', 'imb_reg_of_religion_3', 'imb_reg_of_religion_4',
// population / believers
'population', 'imb_population_class', 'believers_count', 'evangelical_pct',
'imb_evangelical_level', 'imb_congregation_existing',
// prayer + engagement
'people_praying', 'people_committed', 'committed_duration', 'adopted_by_churches',
'adopted_by_count', 'adopted_by_names', 'engagement_status', 'status', 'reason_engaged',
'reason_unlisted', 'global_start_date', 'workers_long_term', 'imb_church_planting',
'disciple_and_church_multiplication',
// scripture + media resources
'imb_bible_available', 'imb_bible_translation_level', 'imb_bible_year_published',
'imb_bible_stories_available', 'imb_audio_scripture_available', 'imb_gospel_recordings_available',
'imb_jesus_film_available', 'imb_radio_broadcast_available', 'imb_total_resources_available',
// priority indices
'imb_gsec', 'imb_lostness_priority_index', 'imb_strategic_priority_index',
// media / description
'has_photo', 'image_url', 'picture_credit', 'imb_people_description',
]
/**
* @param {(facetKey:string, value:*) => *} [labelFor] display-only projection for the provenance
* stamp. The People facets key by CODE (dashboard.config.js:54-57), so without it the header
* reads `affinity_bloc=A012` — a code on a user-facing surface, the same bug the filter chip had.
* Optional: omitted, the stamp falls back to the raw value, so existing callers keep working.
* It is applied to the COMMENT LINE ONLY — never to a data column (see buildCsv).
*/
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC, dataOpts = {}, labelFor = null }) {
// slug -> wide API record, filled by the lazy all-fields fetch below.
let wideStore = null
let widePromise = null
/**
* Fetch the FULL field set for every row, once per session. Same endpoint + base-URL
* resolution the row store uses (shared sources.json / getApiBaseUrl), only with an explicit
* `fields=` covering all of EXPORT_FIELDS instead of the render-sized defaultFields.
*/
function fetchWideRows() {
if (widePromise) return widePromise
widePromise = (async () => {
const sourceId = dataOpts.source || sourcesConfig.activeSource
const src = sourcesConfig?.sources?.[sourceId] || {}
const fields = (Array.isArray(src.exportFields) && src.exportFields.length) ? src.exportFields : EXPORT_FIELDS
const bulk = src.endpoints?.bulk || '/api/people-groups/list'
const lang = src.queryParams?.lang || 'en'
const url = `${getApiBaseUrl()}${bulk}?fields=${fields.join(',')}&lang=${lang}`
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) throw new Error(`export list HTTP ${res.status}`)
const json = await res.json()
const raw = Array.isArray(json) ? json : (json.posts || json.data || json.results || [])
const map = new Map()
for (const r of raw) { const k = r && (r.slug || r.id); if (k) map.set(k, r) }
wideStore = map
return map
})().catch((e) => { widePromise = null; throw e }) // let a later click retry
return widePromise
}
// Swap a loaded (narrow) row for its wide twin when the all-fields fetch has landed.
function widen(r) {
if (!wideStore) return r
const w = wideStore.get(r && (r.slug || r.id))
return w ? { ...r, ...w } : r
}
function pickRows() {
if (selectionSet && selectionSet.size) {
return [...selectionSet].map((s) => rowStore.value.get(s)).filter(Boolean)
@ -22,9 +119,19 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
if (selectionSet && selectionSet.size) return `selection (${selectionSet.size})`
return Object.keys(filterSet).length ? 'filter result' : 'all'
}
// {value,label} -> label; arrays -> ' | '-joined cell (picture_credit / adopted_by_names arrive as
// arrays of objects, so a bare JSON.stringify would dump unreadable braces into the sheet).
function cell(v) {
if (v === null || v === undefined) return v
if (Array.isArray(v)) return v.map((x) => cell(x)).filter((x) => x !== '' && x !== null && x !== undefined).join(' | ')
// `link` last: a picture_credit entry can be {link,text} with a null text — fall through to the
// URL rather than dumping raw JSON braces into the cell.
if (typeof v === 'object') return v.label ?? v.value ?? v.text ?? v.name ?? v.link ?? JSON.stringify(v)
return v
}
function flatten(r) {
const o = {}
for (const k of Object.keys(r)) { const v = r[k]; o[k] = (v && typeof v === 'object') ? (v.label ?? v.value ?? JSON.stringify(v)) : v }
for (const k of Object.keys(r)) o[k] = cell(r[k])
return o
}
// Quote if the field has , " CR LF; double internal quotes; neutralize leading = + - @ (CSV injection).
@ -35,9 +142,19 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
return s
}
function buildCsv() {
const picked = pickRows().map(flatten)
const cols = [...picked.reduce((set, r) => { Object.keys(r).forEach((k) => set.add(k)); return set }, new Set())]
const chips = Object.entries(filterSet).map(([k, v]) => `${k}=${v}`).join(' | ') || 'none'
const picked = pickRows().map(widen).map(flatten)
// Column order: the canonical EXPORT_FIELDS order first (so the sheet reads identity → place →
// people → engagement → resources), then any key the API returned that the list doesn't name.
// Union across rows, because a null-valued field can be absent on an individual record.
const present = picked.reduce((set, r) => { Object.keys(r).forEach((k) => set.add(k)); return set }, new Set())
const cols = [...EXPORT_FIELDS.filter((k) => present.has(k)), ...[...present].filter((k) => !EXPORT_FIELDS.includes(k))]
// Provenance stamp — a HUMAN-READABLE COMMENT line, so People codes are resolved to names
// here exactly as the filter chips do. This is the ONLY place labelFor() is applied: the data
// columns below are built from the API record via flatten()/cell() and are untouched, because
// a consumer's formulas and downstream joins key on whatever those columns already emit.
const chips = Object.entries(filterSet)
.map(([k, v]) => `${k}=${labelFor ? labelFor(k, v) : v}`)
.join(' | ') || 'none'
const provenance = `# Research Dashboard export — scope: ${scopeLabel()}; filters: ${chips}; rows: ${picked.length}`
const lines = [provenance, cols.map(esc).join(','), ...picked.map((r) => cols.map((c) => esc(r[c])).join(','))]
return lines.join('\r\n')
@ -45,10 +162,12 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
// Last-export status for UI feedback (toast). The platform gem-frame iframe is sandboxed WITHOUT
// `allow-downloads`, so the native <a download> is silently blocked there — CLIPBOARD is the robust
// primary path (the iframe `allow`s clipboard-write); native download still works outside a sandbox.
const lastExport = { rows: 0, clipboard: false, downloaded: false, at: 0 }
const lastExport = { rows: 0, clipboard: false, downloaded: false, at: 0, fields: 0, wide: false }
function exportCsv() {
const csv = buildCsv()
const rows = pickRows().length
// header line is line 2 (line 1 is the '#' provenance comment)
const fields = (csv.split('\r\n')[1] || '').split(',').length
try { if (typeof window !== 'undefined') window.__rdashLastCsv = csv } catch (_) { /* verify hook */ }
let clipboard = false, downloaded = false
// 1) clipboard — works inside the sandboxed gem-frame iframe (the guaranteed path)
@ -61,9 +180,28 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
document.body.appendChild(a); a.click(); downloaded = true
setTimeout(() => { try { URL.revokeObjectURL(url); a.remove() } catch (_) {} }, 200)
} catch (_) { /* download blocked — clipboard above already has it */ }
Object.assign(lastExport, { rows, clipboard, downloaded, at: (typeof performance !== 'undefined' ? performance.now() : 0) })
try { window.__rdashExportStatus = { rows, clipboard, downloaded } } catch (_) {}
return { csv, rows, clipboard, downloaded }
Object.assign(lastExport, { rows, clipboard, downloaded, fields, wide: !!wideStore, at: (typeof performance !== 'undefined' ? performance.now() : 0) })
try { window.__rdashExportStatus = { rows, clipboard, downloaded, fields, wide: !!wideStore } } catch (_) {}
return { csv, rows, clipboard, downloaded, fields, wide: !!wideStore }
}
return { exportCsv, buildCsv, pickRows, scopeLabel, lastExport }
/**
* The UI entry point: guarantee the ALL-FIELDS record before writing the file.
*
* Why this is async but exportCsv() stays sync — navigator.clipboard.writeText() needs the
* TRANSIENT USER ACTIVATION of the click, and awaiting a network round-trip spends it. So the
* first click on a cold cache resolves the wide rows and then writes: the download path always
* works, and the clipboard path is re-attempted. Once cached (every later click) the await
* settles in a microtask, activation survives, and clipboard works as before.
* A failed fetch still exports the loaded 24-field record — degraded, never nothing.
*/
async function exportCsvAsync() {
try { await fetchWideRows() } catch (_) { /* fall back to the loaded record */ }
return exportCsv()
}
// Let the UI warm the cache (e.g. on hover/mount) so the first real click is already wide.
function prefetchExportFields() { try { return fetchWideRows() } catch (_) { return null } }
return { exportCsv, exportCsvAsync, buildCsv, pickRows, scopeLabel, lastExport, prefetchExportFields, EXPORT_FIELDS }
}

View file

@ -123,6 +123,29 @@ export function useDashboardLayout(options = {}) {
clearVars()
}
// ── drag-live signalling (BUG-RESIZE-STROBE) ──────────────────────────────
// Two channels, because two different consumers need it:
// 1. `data-rd-resizing` on the shell root — a pure-CSS hook so transitions/animations can be
// killed for the duration of the drag (no JS needed, works inside shadow DOM).
// 2. bus 'layoutResizing' — lets the map panel downgrade to a cheap canvas resize while the
// drag is live, then do the one expensive re-fit on drag end.
// Both are no-ops when absent, so this composable stays usable without a bus.
const resizing = ref(false)
function beginDrag() {
if (resizing.value) return
resizing.value = true
const el = shellRef && shellRef.value
if (el) el.setAttribute('data-rd-resizing', '1')
if (bus && typeof bus.emit === 'function') { try { bus.emit('layoutResizing', true) } catch (_) {} }
}
function endDrag() {
if (!resizing.value) return
resizing.value = false
const el = shellRef && shellRef.value
if (el) el.removeAttribute('data-rd-resizing')
if (bus && typeof bus.emit === 'function') { try { bus.emit('layoutResizing', false) } catch (_) {} }
}
// ── generic pointer drag; axis ∈ 'facet' | 'rail' | 'table' ──
function startResize(axis, e) {
if (narrow()) return
@ -136,7 +159,25 @@ export function useDashboardLayout(options = {}) {
const handleEl = e.currentTarget
try { handleEl && handleEl.setPointerCapture && handleEl.setPointerCapture(e.pointerId) } catch (_) {}
function move(ev) {
// ── A11Y / PHOTOSENSITIVITY (BUG-RESIZE-STROBE): mark the drag as LIVE ──
// Writing the CSS vars every pointermove reflows the grid, which fires the map panel's
// ResizeObserver → map.resize() → MapLibre's own 'resize' event → useMapInstance's
// _onResize → setMinZoom/setZoom. Because the zoom FLOOR is derived from the container
// HEIGHT, a vertical drag re-snapped the camera on EVERY frame — a ~60Hz full-canvas
// re-render that reads as strobing. Flagging the drag lets the map coalesce to a single
// cheap canvas resize while dragging and do the one expensive re-fit on drag END.
beginDrag()
// Coalesce the inline-var writes to one per animation frame. pointermove can fire faster
// than the compositor on high-polling-rate mice, so without this the grid could reflow
// several times per painted frame — wasted work that makes the strobe worse.
let frame = 0
let pending = null
function flush() {
frame = 0
if (!pending) return
const ev = pending
pending = null
if (axis === 'facet') {
// left facet edge → dragging right widens the facet column
facetW.value = Math.round(clamp(startFacet + (ev.clientX - startX), clamps.facetW))
@ -151,12 +192,20 @@ export function useDashboardLayout(options = {}) {
customized.value = true
applyVars()
}
function move(ev) {
// keep only the LATEST position; the rAF does the single write for this frame
pending = { clientX: ev.clientX, clientY: ev.clientY }
if (!frame) frame = requestAnimationFrame(flush)
}
function up(ev) {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', up)
if (frame) { cancelAnimationFrame(frame); frame = 0 }
flush() // land the final position even if the last frame never ran
try { handleEl && handleEl.releasePointerCapture && handleEl.releasePointerCapture(ev.pointerId) } catch (_) {}
persist()
endDrag()
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
@ -173,10 +222,15 @@ export function useDashboardLayout(options = {}) {
if (restore()) applyVars()
if (bus && typeof bus.on === 'function') offReset = bus.on('resetLayout', reset)
})
onBeforeUnmount(() => { try { offReset && offReset() } catch (_) {} })
onBeforeUnmount(() => {
try { offReset && offReset() } catch (_) {}
// If the component tears down mid-drag, release the suppression so a re-mounted map
// is never left permanently in the cheap "resizing" mode.
try { endDrag() } catch (_) {}
})
return {
facetW, railW, tableH, customized,
facetW, railW, tableH, customized, resizing,
startFacetResize, startRailResize, startTableResize,
startResize, reset, applyVars,
}

View file

@ -27,6 +27,7 @@ import { provide, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { createSelectionBus } from './useSelectionBus.js'
import { useDashboardData } from './useDashboardData.js'
import { useCsvExport } from './useCsvExport.js'
import { useTreeLegendLabels } from './useTreeLegendLabels.js'
import { DataSourceManager } from '@map/api/DataSourceManager.js'
import sourcesConfig from '@map/api/sources.json'
@ -37,6 +38,38 @@ const mapValues = (obj, fn) =>
// number formatter (same as the old inline `fmt`); exported for the profile's KPI strip.
export const fmt = (n) => (n === null || n === undefined ? '—' : Number(n).toLocaleString('en-US'))
/**
* Build the free-text search accessors for a dashboard config (header-1 search bar).
* config.searchKeys (facet keys) → those facet accessors; else the string columns' accessors.
* ONE declaration reused by the shell (KPIs) AND every panel (table/map/charts/facets) so the
* searchable text can never drift from what the panels show, and the substring match is not
* copy-pasted into each panel (kills the very duplication DOXA-LIB-2 removed).
*/
export function buildSearchAccessors(config) {
const FACET_ACC = mapValues(config.facets, (f) => f.get)
const COL_ACC = Object.fromEntries((config.columns || []).map((c) => [c.key, c.get]))
const keys = Array.isArray(config.searchKeys) ? config.searchKeys : null
if (keys) return keys.map((k) => FACET_ACC[k] || COL_ACC[k]).filter((fn) => typeof fn === 'function')
return (config.columns || []).filter((c) => c.type !== 'num' && typeof c.get === 'function').map((c) => c.get)
}
/**
* Reusable predicate: does `row` match the bus's current free-text search term?
* Returns true when there is no term (identical to pre-search behaviour). `accessors` is the
* array from buildSearchAccessors(config). Case-insensitive substring over each accessor's value.
*/
export function makeSearchMatcher(bus, accessors) {
return (row) => {
const needle = (bus?.searchTerm?.value || '').trim().toLowerCase()
if (!needle) return true
for (const acc of accessors) {
const v = acc(row)
if (v != null && String(v).toLowerCase().includes(needle)) return true
}
return false
}
}
/**
* Generic KPI computation driven by config.kpis. Each kpi def declares a `kind`:
* count → row count of the filtered scope (+ optional delta = hidden-by-filter count)
@ -149,20 +182,24 @@ export function useDashboardShell(config) {
// THE single former-duplicated FACET_ACC, derived ONCE from config.facets (facetKey -> get).
const FACET_ACC = mapValues(config.facets, (f) => f.get)
// linked scope: rows surviving every active facet predicate (panels fade the rest).
// Free-text search (header-1 search bar) — ONE shared matcher (buildSearchAccessors +
// makeSearchMatcher), reused by every panel so the searchable text never drifts.
const matchSearch = makeSearchMatcher(bus, buildSearchAccessors(config))
// linked scope: rows surviving every active facet predicate AND the text search (panels fade the rest).
const filteredRows = computed(() => {
const rows = data.rows.value || []
const keys = Object.keys(bus.filterSet)
if (!keys.length) return rows
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === bus.filterSet[k] : true }))
const hasText = !!(bus.searchTerm?.value || '').trim()
if (!keys.length && !hasText) return rows
return rows.filter((r) =>
keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === bus.filterSet[k] : true }) && matchSearch(r)
)
})
// linked KPI strip — recomputes on every brush.
const kpis = computed(() => computeKpis(config.kpis, filteredRows.value, data.rows.value || [], FACET_ACC, config))
// selection > filter > all CSV exporter (clipboard-primary, sandbox-safe).
const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC })
// responsive flags + collapsible table dock + segmented mobile view.
const responsive = useResponsiveShell(bus)
@ -209,6 +246,14 @@ export function useDashboardShell(config) {
// rebuilds when the rows load.
const dataStore = { sources: { [searchSourceId]: { get features() { return geoFeatures.value } } } }
// selection > filter > all CSV exporter (clipboard-primary, sandbox-safe). `dataOpts` carries the
// profile's source so the exporter's lazy ALL-FIELDS fetch hits the SAME source the rows came from.
// `labelFor` resolves People CODES to names in the provenance COMMENT line only — constructed
// here, below `dataStore`, because the resolver reads the normalized features it exposes.
// Data columns are unaffected (see useCsvExport.buildCsv).
const { labelFor } = useTreeLegendLabels(config, dataStore, searchSourceId)
const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, dataOpts: config.data, labelFor })
provide('selectionBus', bus)
provide('dashboardData', data)
provide('dashboardConfig', config)

View file

@ -10,12 +10,37 @@
* Export priority (later phases): selectionSet (if non-empty) > filterSet result > full dataset.
* Every panel is both publisher and subscriber. All client-side over one in-memory row store
* keyed on slug (PK) — no per-brush round-trips.
*
* ── PERFORMANCE CONTRACT (BUG-TABLE-BATCH-SELECTION-FREEZES-20S) ──────────────────────────
* selectionSet is a PLAIN Set, not `reactive(new Set())`, and `selectionVersion` is the single
* reactive dependency that stands in for it. Read it in any computed that inspects the set:
*
* const isSel = computed(() => { bus.selectionVersion.value; return bus.selectionSet.has(x) })
*
* WHY. A `reactive(new Set())` fires Vue's ITERATE_KEY on EVERY add/delete, and the old
* per-row `toggleSelect()` loop also fired a SYNCHRONOUS `emit('select')` per row. Selecting
* all 2,107 people groups therefore ran 2,107 full map-panel rebuilds — each one re-scanning
* every row and calling MapLibre `setData()` — allocating ~2.2 MILLION GeoJSON features to
* arrive at 2,107. Measured at 5,323 ms of pure JS in node (~20 s in the browser with the real
* map + charts + DOM). See selectMany/deselectMany/setSelection: each mutates the set fully,
* THEN bumps the version and emits ONCE. Same work, measured at 2.9 ms — a ~1,800x speedup.
*
* RULE: never loop `toggleSelect()` over many slugs. Use the bulk ops. They are the only
* reason batch selection is interactive.
*/
import { reactive } from 'vue'
import { reactive, ref } from 'vue'
export function createSelectionBus() {
const filterSet = reactive({}) // { facetKey: value | value[] } — AND-stacked facets
const selectionSet = reactive(new Set()) // Set<slug> — explicit picks
// PLAIN Set (see the performance contract above) — pair every read with `selectionVersion`.
const selectionSet = new Set() // Set<slug> — explicit picks
// Bumped once per committed selection change. THE reactive handle on selectionSet: one
// dependency to invalidate instead of a collection proxy that fires per element.
const selectionVersion = ref(0)
// Free-text search term (DX-life research-page search bar → header-1). Distinct from the
// facet filterSet: it applies a substring/label match across the config's `searchKeys` in
// useDashboardShell.filteredRows. Empty string = no text predicate (identical to before).
const searchTerm = ref('')
const subs = new Map() // event -> Set<cb>
function on(event, cb) {
@ -38,15 +63,73 @@ export function createSelectionBus() {
emit('filter', filterSet)
}
// ── selectionSet ops (marks) ──
function toggleSelect(slug) {
selectionSet.has(slug) ? selectionSet.delete(slug) : selectionSet.add(slug)
emit('select', selectionSet)
// ── free-text search (header-1) ──
function setSearch(term) {
searchTerm.value = term == null ? '' : String(term)
emit('search', searchTerm.value)
}
function clearSelection() {
selectionSet.clear()
// ── selectionSet ops (marks) ──
// The ONE place a selection change becomes visible: bump the version, notify subscribers once.
// Every mutator below finishes its whole batch before calling this exactly once.
function commitSelection() {
selectionVersion.value++
emit('select', selectionSet)
}
return { filterSet, selectionSet, on, emit, setFilter, clearFilters, toggleSelect, clearSelection }
function toggleSelect(slug) {
selectionSet.has(slug) ? selectionSet.delete(slug) : selectionSet.add(slug)
commitSelection()
}
function clearSelection() {
if (!selectionSet.size) return // no-op stays silent (no wasted map rebuild)
selectionSet.clear()
commitSelection()
}
/**
* Add many slugs in ONE commit. `additive:false` replaces the selection outright.
* Returns true when something actually changed (a no-op never repaints the map).
*/
function selectMany(slugs, { additive = true } = {}) {
let changed = false
if (!additive) {
if (selectionSet.size) { selectionSet.clear(); changed = true }
}
for (const s of slugs) if (!selectionSet.has(s)) { selectionSet.add(s); changed = true }
if (changed) commitSelection()
return changed
}
/** Remove many slugs in ONE commit. Returns true when something actually changed. */
function deselectMany(slugs) {
let changed = false
for (const s of slugs) if (selectionSet.delete(s)) changed = true
if (changed) commitSelection()
return changed
}
/**
* Replace the whole selection with `slugs` in ONE commit — the primitive the table's
* shift+click range-select is built on (base snapshot ⊕ range, applied atomically).
* Skips the commit when the new set is identical to the current one.
*/
function setSelection(slugs) {
const next = slugs instanceof Set ? slugs : new Set(slugs)
if (next.size === selectionSet.size) {
let same = true
for (const s of next) if (!selectionSet.has(s)) { same = false; break }
if (same) return false
}
selectionSet.clear()
for (const s of next) selectionSet.add(s)
commitSelection()
return true
}
return {
filterSet, selectionSet, selectionVersion, searchTerm, on, emit,
setFilter, clearFilters, setSearch,
toggleSelect, clearSelection, selectMany, deselectMany, setSelection,
}
}

View file

@ -0,0 +1,235 @@
/**
* useSelectionCamera.js — "select rows in the table, the map zooms to that selection."
*
* Subscribes to the selection bus and drives the map camera ONCE per committed selection
* change. The table never talks to the map: it mutates `bus.selectionSet` and bumps
* `bus.selectionVersion`; this composable watches that version. That is exactly the
* decoupling the bus exists for — the table panel owns no camera code, and this file owns
* no table code.
*
* ── MOTION SAFETY (BUG-RESIZE-STROBE class; the Driver is photosensitive) ──────────────
* The strobe bug fixed in @map/composables/useMapInstance.js was caused by a resize handler
* re-snapping the camera on EVERY frame at 60fps. The rule learned there is applied here:
*
* 1. ONE animation per committed selection change — never per frame, never per row.
* `selectionVersion` is bumped once per BATCH (useSelectionBus's performance contract),
* so selecting 2,107 rows emits ONE version bump, not 2,107.
* 2. DEBOUNCED (CAMERA_DEBOUNCE_MS). Rapid shift+click range-dragging bumps the version
* many times in a few hundred ms; only the final state moves the camera. Each new bump
* CANCELS the pending move, so intermediate states are never animated.
* 3. REDUCED MOTION — when the user asks for less motion we JUMP (duration 0) instead of
* easing. Same destination, zero traversal, no optical flow across the viewport.
* 4. NOTHING here runs in a render path. The bbox is computed inside the debounced
* callback, i.e. at most once per settled selection, never inside a computed/watcher
* that the map redraw depends on.
*
* ── COST ──────────────────────────────────────────────────────────────────────────────
* One O(n) pass over the selected rows per settled change (n = selection size), using the
* slug-keyed rowStore for O(1) lookups rather than re-scanning all 2,107 rows per slug.
* Selecting everything is a single ~2,107-element numeric pass — sub-millisecond — and it
* happens once, after the debounce, off the render path.
*/
import { watch } from 'vue'
// ── Camera policy constants ───────────────────────────────────────────────────────────
// Debounce window. Long enough to swallow a shift+click range drag or a "select all"
// cascade, short enough that a single checkbox click still feels immediate.
const CAMERA_DEBOUNCE_MS = 150
// One eased move. Matches the panel's existing fitBounds duration so the selection camera
// and the filter camera feel like the same gesture.
const FLY_DURATION_MS = 1200
// Viewport padding so selected pins never sit flush against the panel edge.
const FIT_PADDING_PX = 70
// Ceiling for a MULTI-row fit. Without it, two rows in the same city fit at street level.
const FIT_MAX_ZOOM = 5.5
// A SINGLE selected row has a degenerate (zero-area) bbox — fitBounds would resolve it at
// maxZoom, slamming the camera to street level for a people-group whose coordinate is a
// country-scale centroid. Fly to the point at a deliberately regional zoom instead.
const SINGLE_ROW_ZOOM = 6
// Degenerate-bbox guard for the multi-row path: several rows can share one coordinate (or
// sit within metres of each other), which is a zero-area bbox by another route. Below this
// span we treat the cluster as a single point and use SINGLE_ROW_ZOOM.
const DEGENERATE_SPAN_DEG = 0.01
// Past this longitude span a selection effectively wraps the globe; framing it literally
// makes the camera animate the wrong way around the antimeridian. Ease to a world overview
// instead. Tuned to match the LEGEND_WIDE_SPAN_DEG threshold in @map/composables/useMapFly.js.
const WIDE_SPAN_DEG = 110
const WORLD_ZOOM = 1.45 // the panel's own initial zoom — a stable, familiar overview
/** True when the user has asked the OS for reduced motion. Read live (never cached): the
* preference can be toggled mid-session, and a stale `false` would animate at them. */
function prefersReducedMotion() {
try {
return typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
} catch (_) { return false }
}
/**
* Numeric coordinate pair for a row, or null when unusable.
*
* The live API returns coordinates as STRINGS ("5.68736400", verified against 2,107 live
* records on pray.doxa.life), so every read must go through Number() — a raw string compare
* would order "9.5" above "78.2" lexically and silently produce a wrong bbox.
*
* Number.isFinite (not truthiness): `Number(r.latitude) && …` rejects a legitimate 0°
* coordinate. We accept 0 per-axis and reject only the exact 0,0 pair — the null-island
* sentinel that a missing geocode degrades into. Flying there would show the camera an
* empty patch of the Gulf of Guinea.
*/
function coordsOf(row) {
if (!row) return null
const lng = Number(row.longitude)
const lat = Number(row.latitude)
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
if (lng === 0 && lat === 0) return null // null island — a missing geocode, not a place
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null // out-of-range guard
return [lng, lat]
}
/**
* Antimeridian-aware longitude extent.
*
* A Pacific selection holds longitudes near -176 and +178 (both present in the live data:
* the full range is -176.18 .. +178.48). Naive min/max calls that a ~355° span and frames
* the whole globe — or worse, animates the long way round. Re-projecting into [0,360) makes
* the same set contiguous with a ~6° span. We compute both and keep the tighter one.
*
* Returns { minLng, maxLng, span }; maxLng may exceed 180, which Mapbox's fitBounds accepts
* and wraps correctly.
*/
function lngExtent(longitudes) {
let aMin = Infinity, aMax = -Infinity // raw [-180, 180]
let bMin = Infinity, bMax = -Infinity // shifted [0, 360)
for (const lng of longitudes) {
if (lng < aMin) aMin = lng
if (lng > aMax) aMax = lng
const shifted = lng < 0 ? lng + 360 : lng
if (shifted < bMin) bMin = shifted
if (shifted > bMax) bMax = shifted
}
if (!Number.isFinite(aMin)) return null
const spanA = aMax - aMin
const spanB = bMax - bMin
if (spanB < spanA) {
const minRaw = bMin > 180 ? bMin - 360 : bMin
return { minLng: minRaw, maxLng: minRaw + spanB, span: spanB }
}
return { minLng: aMin, maxLng: aMax, span: spanA }
}
/**
* Bounding box of the selected rows.
*
* Resolves each slug through the slug-keyed rowStore (O(1)) instead of filtering the full
* row array per slug, so a 2,107-row selection stays one linear pass.
*
* @returns {null | { kind:'point', center:[lng,lat] }
* | { kind:'bounds', bounds:[[w,s],[e,n]], span:number }}
* null when NOTHING is selected or no selected row has usable coordinates —
* the caller must then leave the camera alone.
*/
export function selectionBounds(selectedSlugs, rowStore) {
if (!selectedSlugs || !selectedSlugs.size || !rowStore) return null
const lngs = []
let minLat = Infinity, maxLat = -Infinity
let first = null
let count = 0
for (const slug of selectedSlugs) {
const c = coordsOf(rowStore.get(slug))
if (!c) continue // missing/null/invalid coords — skip the row
if (count === 0) first = c
lngs.push(c[0])
if (c[1] < minLat) minLat = c[1]
if (c[1] > maxLat) maxLat = c[1]
count++
}
// Every selected row lacked coordinates (or the selection is empty) — no camera move.
if (count === 0) return null
if (count === 1) return { kind: 'point', center: first }
const ext = lngExtent(lngs)
if (!ext) return { kind: 'point', center: first }
// Co-located cluster: a zero-area bbox by another route. fitBounds would hit maxZoom.
if (ext.span < DEGENERATE_SPAN_DEG && (maxLat - minLat) < DEGENERATE_SPAN_DEG) {
return { kind: 'point', center: [(ext.minLng + ext.maxLng) / 2, (minLat + maxLat) / 2] }
}
return {
kind: 'bounds',
bounds: [[ext.minLng, minLat], [ext.maxLng, maxLat]],
span: ext.span,
}
}
/**
* Wire selection -> camera. Call once from the map panel's setup.
*
* @param {Object} o
* @param {Object} o.bus - the selection bus (selectionSet + selectionVersion)
* @param {Function} o.getMap - () => the live map instance (null before ready)
* @param {Function} o.getRowStore - () => Map<slug, row>
* @param {Object} o.mapFly - a useMapFly instance (shared library helper)
* @param {Function} [o.isEnabled] - () => boolean; skip while false (e.g. map not ready)
* @returns {Function} teardown — clears the pending timer and stops the watcher
*/
export function useSelectionCamera({ bus, getMap, getRowStore, mapFly, isEnabled = () => true }) {
if (!bus || !bus.selectionVersion) return () => {}
let timer = null
function moveCamera() {
timer = null
const map = getMap && getMap()
if (!map || !isEnabled()) return
const target = selectionBounds(bus.selectionSet, getRowStore && getRowStore())
// EDGE CASE — selection cleared, or no selected row has coordinates: hold position.
// Deliberately NOT a reset-to-world: an unrequested snap-back is both jarring and, for
// a photosensitive user, motion they did not ask for. Deselecting is not a navigation.
if (!target) return
// Reduced motion: same destination, zero traversal.
const duration = prefersReducedMotion() ? 0 : FLY_DURATION_MS
try {
if (target.kind === 'point') {
// Single row (or a co-located cluster). fitBounds on a degenerate bbox resolves at
// maxZoom; a fixed regional zoom is the honest framing for a country-scale centroid.
mapFly.flyTo(target.center, { zoom: SINGLE_ROW_ZOOM, duration, essential: true })
return
}
if (target.span > WIDE_SPAN_DEG) {
// Globe-spanning selection (e.g. "select all"). Framing it literally makes the
// camera swing the wrong way around the antimeridian — the visible "snap".
mapFly.flyTo(
[(target.bounds[0][0] + target.bounds[1][0]) / 2,
(target.bounds[0][1] + target.bounds[1][1]) / 2],
{ zoom: WORLD_ZOOM, duration, essential: true },
)
return
}
mapFly.fitBounds(target.bounds, FIT_PADDING_PX, {
maxZoom: FIT_MAX_ZOOM, duration, essential: true,
})
} catch (_) { /* fitBounds throws on degenerate bounds — never break the dashboard */ }
}
// ONE watcher on ONE reactive handle. `selectionVersion` is bumped once per committed
// batch (see useSelectionBus's performance contract), so a 2,107-row "select all" runs
// this callback ONCE — and the debounce collapses even that into a single camera move.
const stop = watch(() => bus.selectionVersion.value, () => {
if (timer) clearTimeout(timer) // a newer selection supersedes the pending move
timer = setTimeout(moveCamera, CAMERA_DEBOUNCE_MS)
})
return function teardown() {
if (timer) { clearTimeout(timer); timer = null }
try { stop() } catch (_) {}
}
}

View file

@ -0,0 +1,134 @@
/**
* useTreeLegendColors.js — the STACKED TREE LEGEND's colour resolver (card BUILD-TREE-LEGEND-COLOURS).
*
* Driver's ruling: the legend's colours must come from the cap-3 research map's semantic tree, so
* the legend can NEVER drift from the map. This module therefore declares NO palette of its own —
* every hex is read from the SAME colour-strategy modules the map's pins paint with:
*
* People plugin-designs/doxa-research-map/src/colors/affinity-block.js:38 PALETTE keyed ROP1 code 'A001'
* Place plugin-designs/doxa-research-map/src/colors/doxa-region.js:59 SLUG_PALETTE keyed slug 'middle_east'
* (+ REGION_COLOR_ALIASES:82 keyed display label, for the label-shaped facet output)
* Language library/colors/language-family.js:26 PALETTE keyed family name 'Indo-European'
* Religion library/colors/religion.js:58 PALETTE keyed family letter 'C'
*
* ── WHY ONE ADAPTER PER AXIS, NOT ONE LOOKUP ────────────────────────────────────────────────
* Each palette is keyed by a DIFFERENT shape, and only one of them matches what the matching
* dashboard facet accessor emits:
*
* axis facet accessor returns palette wants → adapter must
* People code(r.rop1) = 'A008' ROP1 code → direct hit
* Place lbl(r.wagf_region) = 'Asia' slug 'asia' → label→slug, alias fallback
* Language lbl(imb_language_family)='Mande' family display name → direct hit (may miss, see below)
* Religion lbl(r.religion) = 'Islam - Sunni' family letter → needs the CODE, not the label
*
* Religion is the sharp one: `lbl()` yields the localized LABEL ("Islam - Sunni") but religion.js
* colours off the CODE's first letter ("M"). The rail therefore colours religion leaves from the
* tree's family node, never by re-parsing the label — see the `religionFamily` facet in
* dashboard.config.js, which is a separate facet key precisely so one key = one projection and the
* selection bus's byte-match contract stays intact.
*
* ── COLOUR IS DISPLAY-ONLY ──────────────────────────────────────────────────────────────────
* Nothing here touches a row's `value`. Colour is a pure projection off the value the accessor
* already produced, so adding it cannot change what `bus.setFilter(facet, value)` sends.
*
* ── MEASURED PALETTE GAPS (live pray-tools, 2,107 rows, 2026-09-20) ─────────────────────────
* Verified against the live API rather than assumed. Unmapped keys fall back to NEUTRAL (never to
* another axis's palette — a wrong-but-plausible colour is worse than an obviously absent one):
* - ROP1 'A018' and 'A019' are LIVE but absent from affinity-block.js (which stops at A017).
* - Language families 'Atlantic-Congo', 'Mande', 'Tai-Kadai', 'None' are live but absent from
* language-family.js's PALETTE.
* These are upstream data-vs-palette gaps, reported not papered over. Fixing them means adding
* entries to the MAP's strategy files (so pins and legend move together) — not inventing a hex here.
*/
import { computed } from 'vue'
import { getColorStrategy, COLOR_MODES } from '@map/colors/_registry.js'
import { PALETTE as RELIGION_PALETTE } from '@map/colors/religion.js'
import { PALETTE as LANGFAM_PALETTE } from '@map/colors/language-family.js'
// A deliberately NEUTRAL unmapped tone, not a palette colour: an uncoloured node must read as
// "this axis has no colour for this key", never as a confident member of some other family.
// Matches the Material Light border token so it disappears into the rail rather than shouting.
export const NEUTRAL = '#c4c8c0'
/**
* Read a registered strategy's palette, GUARDED on propertyKey.
*
* getColorStrategy() silently falls back to language-family for an unregistered mode, so an
* unguarded read would paint People rows with LANGUAGE colours and look plausible while being
* entirely wrong. This is the same guard useAffinityBlockLegendData.js:57 applies, and for the same
* reason. Read lazily (at call time) so bundle-local registerStrategies() has already run.
*/
function guardedPalette(mode, propertyKey) {
const strat = getColorStrategy(mode)
return (strat && strat.propertyKey === propertyKey && strat.palette) ? strat.palette : null
}
// ── Place: label → slug ─────────────────────────────────────────────────────────────────────
// doxa-region.js:52 documents that wagf_region's LABEL is localized per `lang` while the slug is
// stable, so the slug palette is the authoritative one. The Place facet emits the label, so derive
// the slug the same way the API does (lowercase, spaces → underscores) and keep the label-keyed
// REGION_COLOR_ALIASES as the fallback. Verified: all 7 live region labels resolve by this path.
const toSlug = (label) => String(label || '').trim().toLowerCase().replace(/\s+/g, '_')
/**
* Resolve one facet value → a hex colour, per axis.
*
* @param {string} axis one of 'people' | 'place' | 'language' | 'religion'
* @param {string} facet the dashboard facet key the row was aggregated on
* @param {string} value the facet accessor's EXACT output (also the bus filter value)
* @param {string} [famLetter] religion family letter, supplied by the tree (never re-parsed here)
* @returns {string} hex colour, or NEUTRAL when this axis has no colour for the key
*/
export function useTreeLegendColors() {
// Both Place maps come off the ONE registered doxa-region strategy — palettes are never copied.
const placeMaps = computed(() => {
const strat = getColorStrategy(COLOR_MODES.DOXA_REGION)
const ok = strat && strat.propertyKey === 'doxaRegion'
return {
slug: (ok && strat.slugColors) || null,
alias: (ok && strat.aliases) || null,
}
})
function colorFor(axis, facet, value, famLetter) {
if (value == null || value === '') return NEUTRAL
if (axis === 'people') {
// Every People tier (bloc → cluster → people group) inherits its BLOC's colour — exactly
// what the map's semantic tree does (useAffinityBlockLegendData.js:169 assigns one `color`
// per bloc and reuses it for cluster/pg/pgic children). The tree passes the bloc code down
// as `famLetter`, so a cluster row is coloured by its ancestor, not by its own code.
const palette = guardedPalette(COLOR_MODES.AFFINITY_BLOCK, 'affinityBlock')
if (!palette) return NEUTRAL
return palette[famLetter || value] || NEUTRAL
}
if (axis === 'place') {
// Only the top tier (region) is coloured; bloc/country inherit via `famLetter` (the region
// label of the ancestor), mirroring the map where a region's hue owns its whole subtree.
const { slug, alias } = placeMaps.value
const key = famLetter || value
if (slug) { const hit = slug[toSlug(key)]; if (hit) return hit }
if (alias) { const hit = alias[key]; if (hit) return hit }
return NEUTRAL
}
if (axis === 'language') {
// language_family rows hit the palette directly; primary_language rows inherit their
// family's colour through `famLetter` (the ancestor family name).
return LANGFAM_PALETTE[famLetter || value] || NEUTRAL
}
if (axis === 'religion') {
// Keyed by the FAMILY LETTER only — the tree supplies it from religion.value's first char.
// Never derived from the display label, which is localized and would not match.
return RELIGION_PALETTE[famLetter] || NEUTRAL
}
return NEUTRAL
}
return { colorFor, NEUTRAL }
}
export default useTreeLegendColors

View file

@ -0,0 +1,87 @@
/**
* useTreeLegendLabels.js — the People axis's CODE → HUMAN NAME resolver
* (card BUG-FILTER-CHIP-SHOWS-RAW-CODE-NOT-HUMAN-NAME).
*
* The People facets (affinity_bloc / cluster / people_group) key by CODE on purpose — see
* dashboard.config.js:54-57 — so the rail can cross-reference the research map's semantic tree.
* The price of that choice is that EVERY user-facing surface has to project the code back to a
* name, and "codes must never reach a user-facing menu". The facet rail did that projection
* inline, so it was correct there and nowhere else: the filter chip bar rendered the raw code
* ('Affinity Bloc:A012' instead of 'South Asian Peoples'), live-verified 2026-09-20.
*
* This module is that projection, extracted ONCE, exactly as useTreeLegendColors is the colour
* projection extracted once. Both surfaces now read the same maps, built by running the SAME
* useAffinityBlockLegendData the research map's semantic tree uses over the shell's normalized
* features — so a bloc/cluster/PG name can never drift between the map, the rail and the chips.
*
* ── LABEL IS DISPLAY-ONLY ────────────────────────────────────────────────────────────────────
* Nothing here touches a filter VALUE. The selection bus matches a stored filter against the
* facet accessor's exact output byte-for-byte, so rewriting the value would leave the chip
* looking right while the table silently stopped responding. `labelFor` is a pure read: it takes
* a value and returns a string to PRINT, and falls back to the value itself when unresolvable
* (an honest code beats a blank chip).
*
* ── WHICH KEYS ARE PEOPLE KEYS IS DECLARED, NOT HARD-CODED ──────────────────────────────────
* The set is derived from config.facetRail's `tree:'affinity'` section and its `drill` list — the
* same declaration the rail already switches on. Adding a 4th People tier to that drill therefore
* fixes the chips too, with no second list to keep in sync.
*/
import { computed } from 'vue'
import { useAffinityBlockLegendData } from '@map/composables/legends/useAffinityBlockLegendData.js'
/**
* @param {object} cfg the injected dashboardConfig (read for facetRail)
* @param {object} dataStore the injected DataSourceManager store
* @param {string} searchSourceId the injected active source id
*/
export function useTreeLegendLabels(cfg, dataStore, searchSourceId) {
// DataSourceManager-normalized features — the shape useAffinityBlockLegendData resolves labels
// from (affinityBlockLabel / clusterLabel / peopleGroupLabel).
const normalizedFeatures = computed(() => {
const src = dataStore && searchSourceId && dataStore.sources && dataStore.sources[searchSourceId]
return (src && src.features) || []
})
const { aggregated: affinityAgg } = useAffinityBlockLegendData(normalizedFeatures)
// The People facet keys, in tier order, read off the declarative rail config.
const treeFacetKeys = computed(() => {
const section = (cfg && cfg.facetRail || []).find((s) => s.tree === 'affinity')
return (section && section.drill) || []
})
// code → label lookups keyed to the People facet keys, walked from the composable's aggregate
// tree. Tier order follows treeFacetKeys, so the maps line up with the config's drill.
const affinityLabelMaps = computed(() => {
const [blocKey, clusterKey, pgKey] = treeFacetKeys.value
const bloc = new Map(), cluster = new Map(), pg = new Map()
for (const b of affinityAgg.value.values()) {
if (b.code) bloc.set(b.code, b.label)
for (const c of b.clusters.values()) {
if (c.code) cluster.set(c.code, c.label)
for (const g of c.peopleGroups.values()) { if (g.code) pg.set(g.code, g.label) }
}
}
const out = {}
if (blocKey) out[blocKey] = bloc
if (clusterKey) out[clusterKey] = cluster
if (pgKey) out[pgKey] = pg
return out
})
/**
* Resolve a facet value to what a human should READ. Non-People facets already carry labels
* (their accessors run lbl()), so they pass straight through untouched.
* @returns {string} the human name, or the original value when it cannot be resolved.
*/
function labelFor(facetKey, value) {
const map = affinityLabelMaps.value[facetKey]
if (!map) return value
return map.get(value) || value
}
return { normalizedFeatures, affinityAgg, affinityLabelMaps, treeFacetKeys, labelFor }
}
export default useTreeLegendLabels

View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doxa-maps · stack-demo staging</title>
<link rel="icon" href="data:,">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { background: #0b0f14; color: #e5e7eb; min-height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 28px 20px 48px; }
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.sub { color: #9ca3af; font-size: 13px; margin: 6px 0 24px; max-width: 820px; }
.sub code { color: #c4b5fd; }
/* The whole point: stack-demo grows to its natural height and the PAGE scrolls through it. */
.host { border: 1px solid #1f2937; border-radius: 12px; overflow: hidden; }
stack-demo { display: block; width: 100%; }
</style>
</head>
<body>
<div class="wrap">
<h1>stack-demo · staging</h1>
<p class="sub">Capability&nbsp;4, STACK variant — the shared <code>StackLayout</code> renders two
elements (<code>&lt;research-dashboard-map&gt;</code> then <code>&lt;people-groups-grid&gt;</code>)
<strong>on top of each other</strong>, each at its natural height, so you
<strong>scroll down</strong> through both on one page (the doxa-life / nuxtinator page-flow model).
Embed <code>&lt;stack-demo&gt;</code> anywhere and the host page provides the scroll.</p>
<div class="host">
<stack-demo></stack-demo>
</div>
</div>
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,28 @@
/**
* stack-demo / index.js — Bundle entry (Capability-4 nested-elements-mfe, STACK variant).
*
* ONE folder -> ONE bundle -> ONE headline element:
* <stack-demo> — the COMPOSITE. Uses the shared StackLayout to render two elements
* (<research-dashboard-map> then <people-groups-grid>) STACKED top-to-bottom,
* each at natural height, so the host page scrolls through both.
*
* The two nested elements are NOT bundled in here — StackLayout injects their bundles at runtime
* (via each section's `src`, deduped), then mounts the element. This keeps stack-demo tiny and lets
* it compose ANY already-published elements by tag + src. Registers <stack-demo> first so
* discover.mjs names the bundle after it.
*
* Usage — direct-JS (method 3):
* <script src=".../stack-demo/stack-demo.js"></script>
* <stack-demo></stack-demo>
* Or iframe its leaf page and let the host page scroll.
*/
import { defineCustomElement } from 'vue'
import StackDemo from './stack-demo.vue'
const StackDemoElement = defineCustomElement(StackDemo)
if (typeof customElements !== 'undefined') {
if (!customElements.get('stack-demo')) customElements.define('stack-demo', StackDemoElement)
}
export default StackDemoElement

View file

@ -0,0 +1,4 @@
{
"_comment": "One default instance of the stacked pair.",
"instances": [ { "id": "pair", "label": "Dashboard + Grid stacked", "profileConfig": {} } ]
}

View file

@ -0,0 +1,6 @@
{
"_comment": "Capability-4 composite that STACKS two elements top-to-bottom via the shared StackLayout (library/layouts/StackLayout.vue). Unlike big-picture (which couples header<->grid via props), this simply GROUPS two independent elements vertically so you can embed them together and SCROLL through them. Demonstrates the scroll-stack layout primitive.",
"type": "nested-elements-mfe",
"composes": ["research-dashboard-map", "people-groups-grid"],
"label": "Stack Demo (two elements stacked to scroll — StackLayout)"
}

View file

@ -0,0 +1,51 @@
<script setup>
/**
* stack-demo.vue — Capability-4 composite demonstrating the shared StackLayout (scroll-stack).
*
* @element stack-demo
* @description Groups TWO independent elements TOP-TO-BOTTOM so you can embed the pair on a page and
* SCROLL down through all of it (the doxa-life / nuxtinator page-flow model), instead of switching
* between them with tabs. Section 1 = the rich <research-dashboard-map> (facets + map + charts +
* table); section 2 = the cap-2 <people-groups-grid> (live pray.doxa.life). Each renders at its
* NATURAL height; the host page scrolls.
*
* This is the STACK counterpart to big-picture (which TABS/couples). Both reuse the shared library
* layouts in library/layouts/. StackLayout mounts each section's `element` eagerly (all visible to
* scroll) via the same element/src/attrs contract VerticalTabsLayout uses.
*/
import StackLayout from '@map/layouts/StackLayout.vue'
// The two elements to stack. `src` is TREE-RELATIVE at build; the leaf pages resolve it against the
// bundle tree root. Both elements ship their own bundles in the same catalog.
const sections = [
// src is resolved by the browser relative to THIS leaf page, which is served two levels deep
// (doxa-maps/stack-demo/stack-demo/index.html) — so go UP TWO to reach a sibling bundle's tree
// (doxa-maps/<bundle>/<bundle>.js). (Off-by-one here = a 404 + an empty section, caught silently.)
{
key: 'dashboard',
title: 'Research Dashboard',
sub: 'facets · map · charts · table (live)',
element: 'research-dashboard-map',
src: '../../research-dashboard/research-dashboard.js'
},
{
key: 'grid',
title: 'People Groups',
sub: 'the full searchable list (live)',
element: 'people-groups-grid',
src: '../../people-groups-grid/people-groups-grid.js'
}
]
</script>
<template>
<StackLayout title="Stacked view" :sections="sections">
<template #meta>scroll ↓</template>
</StackLayout>
</template>
<style>
/* stack-demo is a thin composition: the shell + its scroll behaviour live in the shared StackLayout
(which self-injects its own shadow CSS). Nothing to add here beyond making the host fill width. */
:host { display:block; width:100%; }
</style>