From 85014aad11ac5df588cd3e33742b3627687f97f1 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Sun, 20 Sep 2026 04:49:25 +0000 Subject: [PATCH] Session work: strobe fix, grid layout, stacked tree legend, selection camera, chip bounding, asset inheritance, gates --- .../acceptance/big-picture-acceptance.mjs | 114 ++++- internal/atomic-public-swap.sh | 104 +++++ internal/generate-tree.mjs | 247 ++++++++++- library/ProfileLoader.vue | 36 +- library/components/hints/PinHint.vue | 119 +++++- .../components/map-controls/ZoomInButton.vue | 18 +- .../components/map-controls/ZoomOutButton.vue | 17 +- library/components/search/README.md | 52 ++- .../components/search/SearchFilterHeader.vue | 235 +++++++++++ library/composables/useMapFly.js | 7 +- library/composables/useMapInstance.js | 49 ++- library/layouts/StackLayout.vue | 250 +++++++++++ library/layouts/VerticalTabsLayout.vue | 338 ++++++++++++--- .../big-picture/big-picture.instances.json | 2 +- plugin-designs/big-picture/big-picture.vue | 12 +- .../src/components/BpGroupsTable.vue | 169 -------- .../src/components/BpResearchHeader.vue | 21 +- .../big-picture/src/data/people-groups.js | 81 ---- plugin-designs/cities-internet/index.html | 8 +- plugin-designs/country-posters/index.html | 8 +- .../dashboard-plus-grid.instances.json | 1 + .../dashboard-plus-grid.meta.json | 6 + .../dashboard-plus-grid.vue | 76 ++++ plugin-designs/dashboard-plus-grid/index.html | 37 ++ plugin-designs/dashboard-plus-grid/index.js | 51 +++ .../doxa-maps-spa/doxa-maps-spa.vue | 391 ++++++++++++++--- plugin-designs/gospel-resources/index.html | 8 +- .../gospel-resources/resources-map.vue | 60 ++- plugin-designs/my-upg-100-list/index.html | 5 + .../my-upg-100-list/upg-100-mfe.vue | 51 ++- .../people-groups-grid/people-groups-grid.vue | 9 +- .../plugin-studio/plugin-studio.vue | 201 ++++++--- .../research-dashboard/dashboard.config.js | 119 +++++- plugin-designs/research-dashboard/index.js | 23 + .../research-dashboard/research-dashboard.vue | 127 ++++-- .../dashboards/DashboardBreadcrumb.vue | 14 +- .../dashboards/DashboardChartsRail.vue | 54 ++- .../dashboards/DashboardDataTable.vue | 396 ++++++++++++++++-- .../dashboards/DashboardDetailDrawer.vue | 48 ++- .../dashboards/DashboardFacetRail.vue | 294 +++++++++++-- .../dashboards/DashboardFilterChips.vue | 62 ++- .../dashboards/DashboardMapPanel.vue | 222 +++++++++- .../dashboards/DashboardResearchFilters.vue | 138 ++++++ .../dashboards/DashboardSearchHeader.vue | 22 +- .../components/dashboards/DashboardTopBar.vue | 175 ++++++-- .../src/components/dashboards/index.js | 4 + .../src/composables/useCsvExport.js | 158 ++++++- .../src/composables/useDashboardLayout.js | 60 ++- .../src/composables/useDashboardShell.js | 57 ++- .../src/composables/useSelectionBus.js | 101 ++++- .../src/composables/useSelectionCamera.js | 235 +++++++++++ .../src/composables/useTreeLegendColors.js | 134 ++++++ .../src/composables/useTreeLegendLabels.js | 87 ++++ plugin-designs/stack-demo/index.html | 35 ++ plugin-designs/stack-demo/index.js | 28 ++ .../stack-demo/stack-demo.instances.json | 4 + .../stack-demo/stack-demo.meta.json | 6 + plugin-designs/stack-demo/stack-demo.vue | 51 +++ 58 files changed, 4636 insertions(+), 801 deletions(-) create mode 100755 internal/atomic-public-swap.sh create mode 100644 library/components/search/SearchFilterHeader.vue create mode 100644 library/layouts/StackLayout.vue delete mode 100644 plugin-designs/big-picture/src/components/BpGroupsTable.vue delete mode 100644 plugin-designs/big-picture/src/data/people-groups.js create mode 100644 plugin-designs/dashboard-plus-grid/dashboard-plus-grid.instances.json create mode 100644 plugin-designs/dashboard-plus-grid/dashboard-plus-grid.meta.json create mode 100644 plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue create mode 100644 plugin-designs/dashboard-plus-grid/index.html create mode 100644 plugin-designs/dashboard-plus-grid/index.js create mode 100644 plugin-designs/research-dashboard/src/components/dashboards/DashboardResearchFilters.vue create mode 100644 plugin-designs/research-dashboard/src/composables/useSelectionCamera.js create mode 100644 plugin-designs/research-dashboard/src/composables/useTreeLegendColors.js create mode 100644 plugin-designs/research-dashboard/src/composables/useTreeLegendLabels.js create mode 100644 plugin-designs/stack-demo/index.html create mode 100644 plugin-designs/stack-demo/index.js create mode 100644 plugin-designs/stack-demo/stack-demo.instances.json create mode 100644 plugin-designs/stack-demo/stack-demo.meta.json create mode 100644 plugin-designs/stack-demo/stack-demo.vue diff --git a/internal/acceptance/big-picture-acceptance.mjs b/internal/acceptance/big-picture-acceptance.mjs index d891cacfe..724946a2e 100644 --- a/internal/acceptance/big-picture-acceptance.mjs +++ b/internal/acceptance/big-picture-acceptance.mjs @@ -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) diff --git a/internal/atomic-public-swap.sh b/internal/atomic-public-swap.sh new file mode 100755 index 000000000..65c976448 --- /dev/null +++ b/internal/atomic-public-swap.sh @@ -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 +# must contain a fully-built /doxa-maps/ tree (with manifest.json) and an +# /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 (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 /.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." diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index 14563f494..9393a6453 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -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, 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 `/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 + * `/` 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: /// = 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: ["/", …]` — 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 .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 "/" or a bare ""; 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 ` : '' @@ -458,14 +524,34 @@ function mapPageHtml(opts) { ${peerBlock} @@ -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 `/` + // (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 `/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/.webp`, `${assetsBase}/posters/.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/.webp + // (line 80) and only fetches posters/.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) { diff --git a/library/ProfileLoader.vue b/library/ProfileLoader.vue index 2b11b07f2..5347a0862 100644 --- a/library/ProfileLoader.vue +++ b/library/ProfileLoader.vue @@ -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; } diff --git a/library/components/hints/PinHint.vue b/library/components/hints/PinHint.vue index 9943f5c21..d528ec27c 100644 --- a/library/components/hints/PinHint.vue +++ b/library/components/hints/PinHint.vue @@ -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 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 diff --git a/library/composables/useMapFly.js b/library/composables/useMapFly.js index e00c34fee..6153d1a64 100644 --- a/library/composables/useMapFly.js +++ b/library/composables/useMapFly.js @@ -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 }); } /** diff --git a/library/composables/useMapInstance.js b/library/composables/useMapInstance.js index 4080a3af8..6e83c244f 100644 --- a/library/composables/useMapInstance.js +++ b/library/composables/useMapInstance.js @@ -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() diff --git a/library/layouts/StackLayout.vue b/library/layouts/StackLayout.vue new file mode 100644 index 000000000..080e87784 --- /dev/null +++ b/library/layouts/StackLayout.vue @@ -0,0 +1,250 @@ + + + + + + diff --git a/library/layouts/VerticalTabsLayout.vue b/library/layouts/VerticalTabsLayout.vue index 05966d561..8a91291cf 100644 --- a/library/layouts/VerticalTabsLayout.vue +++ b/library/layouts/VerticalTabsLayout.vue @@ -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. -->