Checkpoint the swarm's in-flight work
Some checks are pending
build-gate / build (push) Waiting to run

Five lanes had 465 insertions across 18 files sitting uncommitted, some for
15 minutes. That is the state that silently lost the shell-scroll fix earlier
today: a verified fix existed only in the working tree, a second agent's edit
to the same file overwrote it, and the reverted version got committed and
described as the fix.

Nothing here is finished and no card is being closed on it. This is a
snapshot so the work survives an overwrite, taken at a moment when every file
had been quiet 15s or more, so no agent was mid-write.

Verified coherent before staging rather than committed blind: template-literal,
mjs-parse, sfc-parse, no-orphan-gates, kpi-unit, composite-reg, script-flags,
quoted-scripts and backlog-status all PASS. check:synced-css fails on
PeopleGroupDetail.vue and doxa-simple-map.vue only -- the same two files and
the same counts as before the swarm started, both untouched by any agent, both
already carded.

Two untracked files included because both are load-bearing: internal/
check-facet-one-field.mjs is wired into package.json with a self-test, and
library/utils/viewportScrim.js is imported by DataTable, SemanticTreeLegend
and PosterDialog -- a shared helper for the modal-scrim fix rather than six
copies of the same workaround.
This commit is contained in:
doxa-maps-88655c70 2026-09-22 21:42:18 +00:00
parent f00abe1e6c
commit ad6da54674
21 changed files with 885 additions and 48 deletions

View file

@ -25,7 +25,7 @@ The scripts these commands are grounded in (from this branch's `package.json`):
// claiming to be ground truth, and this one had silently stopped being it. // claiming to be ground truth, and this one had silently stopped being it.
"dev": "bun x vite", "dev": "bun x vite",
"build": "bash internal/build-lock.sh", "build": "bash internal/build-lock.sh",
"build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", "build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:facet-one-field && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",
"preview": "bun x vite preview", "preview": "bun x vite preview",
"serve": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public", "serve": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public",
"cap:sync": "./node_modules/.bin/cap sync android", "cap:sync": "./node_modules/.bin/cap sync android",

View file

@ -212,9 +212,11 @@ You need [**bun**](https://bun.sh) installed (`bun.lock` is committed, so instal
```bash ```bash
bun install # download node_modules (one time) bun install # download node_modules (one time)
bun run dev # the plugin gallery: http://localhost:5173 (or the next free port — read the terminal) bun run dev # the plugin STUDIO: http://localhost:5173 (or the next free port — read the terminal)
# an auto-generated SPA of every map, grouped by bundle and composition tier, # `/` mounts the studio; the ARCHIVED bundle index is at /__gallery — an
# each deep-linkable; `bun run smoke:gallery` proves every one of them boots # auto-generated SPA of every map, grouped by bundle and composition tier, each
# deep-linkable. `bun run smoke:studio` checks the studio (~5s); `bun run
# smoke:gallery` proves every element boots on the archived /__gallery page
bun run build # the build step: every plugin-designs/<map>/ becomes bun run build # the build step: every plugin-designs/<map>/ becomes
# public/doxa-maps/<map>/<map>.js + a standalone index.html per map # public/doxa-maps/<map>/<map>.js + a standalone index.html per map

View file

@ -58,8 +58,9 @@ produced false positives). Discovery is one pure module,
`internal/plugin-gallery/discover.mjs`; `bun run dev` renders its result as the **plugin gallery** at `/__gallery` `internal/plugin-gallery/discover.mjs`; `bun run dev` renders its result as the **plugin gallery** at `/__gallery`
(the same generated page the build writes to `public/__gallery/index.html` — see "Plugin gallery" under Build (the same generated page the build writes to `public/__gallery/index.html` — see "Plugin gallery" under Build
scripts: a list grouped by bundle, every map deep-linkable at `#/<bundle>/<profile>[/<instance>]`), and scripts: a list grouped by bundle, every map deep-linkable at `#/<bundle>/<profile>[/<instance>]`), and
`bun run smoke:gallery` proves every element actually boots inside it. **`/` itself serves the plugin studio** `bun run smoke:gallery` proves every element actually boots **inside that archived gallery page** — not in the
in both trees, not the gallery. studio, which `bun run smoke:studio` covers in ~5s. **`/` itself serves the plugin studio** in both trees, not
the gallery.
--- ---
@ -160,7 +161,7 @@ each bundle registers a **distinct** tag.
| Script | What it does | | Script | What it does |
|---|---| |---|---|
| `bun run dev` | Vite dev server + HMR (default `http://localhost:5173`, or the next free port — read the terminal). `/` is the **plugin gallery** of every element — `internal/generate-gallery.mjs` in mode `dev` over `internal/plugin-gallery/{discover,dev-plugin}.mjs`; every bundle is also served at its own html path. Dev-only routes: `/__plugin-gallery/manifest.json` (the manifest-shaped model the page fetches), `/__plugin-gallery/registry.json` (the discovery registry the smoke tools read), `/assets/*` (served from `public/assets/`, else `plugin-designs/*/data/`). **Writes nothing to `public/`** — no HMR across that boundary. | | `bun run dev` | Vite dev server + HMR (default `http://localhost:5173`, or the next free port — read the terminal). `/` is the **plugin gallery** of every element — `internal/generate-gallery.mjs` in mode `dev` over `internal/plugin-gallery/{discover,dev-plugin}.mjs`; every bundle is also served at its own html path. Dev-only routes: `/__plugin-gallery/manifest.json` (the manifest-shaped model the page fetches), `/__plugin-gallery/registry.json` (the discovery registry the smoke tools read), `/assets/*` (served from `public/assets/`, else `plugin-designs/*/data/`). **Writes nothing to `public/`** — no HMR across that boundary. |
| `bun run smoke:gallery` | Opens every element of the plugin gallery by deep link in Firefox and proves it boots: element defined, shadow DOM populated, no error card, a WebGL canvas for map profiles (checked through nested shadow roots), no HTML-for-JSON data responses. `--json <file>` writes the evidence; exit 1 on any failure. The pre-handoff gate. | | `bun run smoke:gallery` | Opens every element by deep link in Firefox **on the ARCHIVED gallery page at `/__gallery`** and proves it boots there: element defined, shadow DOM populated, no error card, a WebGL canvas for map profiles (checked through nested shadow roots), no HTML-for-JSON data responses. `--json <file>` writes the evidence; exit 1 on any failure. It is the only gate watching the archived surface — it found the `tree` base-join bug (404 `/manifest.json`) that no other gate could see, because the studio carries `tree-base` and the gallery did not. **It does NOT cover the studio**: that is `smoke:studio` (~5s), and `/` serves the studio in both trees. |
| `bun run smoke:share` | The share round-trip gate (Firefox): opens a host page (`--plugin-gallery` = every canvas element of the dev plugin gallery; `--url <page> --tag <element>` = the website or any CDN page), clicks Share, reads the URL, fetches it, reopens it in a fresh page and asserts the SAME custom element mounts with a WebGL canvas. `--json <file>`; exit 1 on any failure — proves REQ-1 ("links work wherever the tree is hosted"). | | `bun run smoke:share` | The share round-trip gate (Firefox): opens a host page (`--plugin-gallery` = every canvas element of the dev plugin gallery; `--url <page> --tag <element>` = the website or any CDN page), clicks Share, reads the URL, fetches it, reopens it in a fresh page and asserts the SAME custom element mounts with a WebGL canvas. `--json <file>`; exit 1 on any failure — proves REQ-1 ("links work wherever the tree is hosted"). |
| `bun run check:public-hygiene [<dir>] [--strict]` | Proves a served folder is fit to publish: scans for local paths, internal folder names, dev-server URLs, JWT-shaped tokens, `.env`/token files, source maps, documentation and staging/preview pages, and prints an inventory (bundles, leaf pages, assets by folder). Under `--strict` any root entry outside the drop-in contract is a finding; the allowed set is READ FROM `vite.doxa-maps-build-config.json` → `dropin.include` (`index.html`, `__gallery/`, `assets/`, `doxa-maps/`) rather than restated here, so the gate cannot disagree with the contract. Exit 1 on a finding. Run it on the website's drop-in before any public push. | | `bun run check:public-hygiene [<dir>] [--strict]` | Proves a served folder is fit to publish: scans for local paths, internal folder names, dev-server URLs, JWT-shaped tokens, `.env`/token files, source maps, documentation and staging/preview pages, and prints an inventory (bundles, leaf pages, assets by folder). Under `--strict` any root entry outside the drop-in contract is a finding; the allowed set is READ FROM `vite.doxa-maps-build-config.json` → `dropin.include` (`index.html`, `__gallery/`, `assets/`, `doxa-maps/`) rather than restated here, so the gate cannot disagree with the contract. Exit 1 on a finding. Run it on the website's drop-in before any public push. |
| `bun run sync:dropin <dest> [--from-config \| --only a,b] [--prune]` / `bun run deploy:dev` | Syncs the built `public/` into a host checkout (e.g. the website's `public/embed/doxa-maps-build/`). **The drop-in contract** lives in ONE place — `vite.doxa-maps-build-config.json` → `dropin.include`, four root entries: `index.html` (the **plugin studio** host page generated by `internal/generate-tree.mjs`, which mounts `<plugin-studio>`), `__gallery/` (the archived bundle-index gallery generated by `internal/generate-gallery.mjs`, listing every bundle/profile/instance and mounting the chosen one), `assets/` (data) and `doxa-maps/` (the bundle folders + `manifest.json`). `--from-config` copies only those, `--prune` removes everything else in the destination, so a host carries exactly the served web-root and nothing else at its root. `dropin.exclude` (same file) lists built files inside the allowlist that no page fetches — the full people-groups CSV, the tables only internal scripts read, the table index; they stay in `public/` for the scripts, are skipped by `--from-config` (counted as "excluded (not shipped)") and removed from the destination if a copy is there. Without an allowlist it is a plain full mirror. Never copies credentials, docs or staging pages; `--protect a,b` keeps host-owned files; `--dry-run` first; prints a copied/unchanged/deleted/pruned/excluded summary and the resulting top-level tree. `deploy:dev` = `build` → sync `--from-config --prune` into the website (`DOXA_DROPIN_DEST` overrides the path) — the only sanctioned way a map changes on the site; then commit in both repos. | | `bun run sync:dropin <dest> [--from-config \| --only a,b] [--prune]` / `bun run deploy:dev` | Syncs the built `public/` into a host checkout (e.g. the website's `public/embed/doxa-maps-build/`). **The drop-in contract** lives in ONE place — `vite.doxa-maps-build-config.json` → `dropin.include`, four root entries: `index.html` (the **plugin studio** host page generated by `internal/generate-tree.mjs`, which mounts `<plugin-studio>`), `__gallery/` (the archived bundle-index gallery generated by `internal/generate-gallery.mjs`, listing every bundle/profile/instance and mounting the chosen one), `assets/` (data) and `doxa-maps/` (the bundle folders + `manifest.json`). `--from-config` copies only those, `--prune` removes everything else in the destination, so a host carries exactly the served web-root and nothing else at its root. `dropin.exclude` (same file) lists built files inside the allowlist that no page fetches — the full people-groups CSV, the tables only internal scripts read, the table index; they stay in `public/` for the scripts, are skipped by `--from-config` (counted as "excluded (not shipped)") and removed from the destination if a copy is there. Without an allowlist it is a plain full mirror. Never copies credentials, docs or staging pages; `--protect a,b` keeps host-owned files; `--dry-run` first; prints a copied/unchanged/deleted/pruned/excluded summary and the resulting top-level tree. `deploy:dev` = `build` → sync `--from-config --prune` into the website (`DOXA_DROPIN_DEST` overrides the path) — the only sanctioned way a map changes on the site; then commit in both repos. |
@ -246,7 +247,7 @@ Measured 2026-09-22, by running each one:
| Gate | Needs | What a bare run does | | Gate | Needs | What a bare run does |
|---|---|---| |---|---|---|
| `smoke:gallery` (and `smoke:playground`, its alias) | a dev server: `bun x vite` on **:5174** | exits non-zero with *"cannot read http://localhost:5174/__plugin-gallery/registry.json — is `bun x vite` running?"* — a **precondition**, not a defect | | `smoke:gallery` (and `smoke:playground`, its alias) | a dev server: `bun x vite` on **:5173** (`bun run dev`'s own port) | exits non-zero with *"cannot read http://localhost:5173/__plugin-gallery/registry.json — is `bun x vite` running?"* — a **precondition**, not a defect. It audits the ARCHIVED `/__gallery` page; `--page` re-aims it, `--url` stays at the server root where the registry lives |
| `smoke:share` | `--url <page>` arguments, a served tree, a token, and **`--browser chromium` on this box** | prints `usage:` and exits non-zero; with default Firefox it stops with "Verified on a BLANK page, so it is the engine/box, not the product" and names the chromium flag itself | | `smoke:share` | `--url <page>` arguments, a served tree, a token, and **`--browser chromium` on this box** | prints `usage:` and exits non-zero; with default Firefox it stops with "Verified on a BLANK page, so it is the engine/box, not the product" and names the chromium flag itself |
#### ★ WRITING A NEW GATE: you cannot `import()` plugin or library source into plain Node #### ★ WRITING A NEW GATE: you cannot `import()` plugin or library source into plain Node
@ -350,7 +351,7 @@ The rest, grouped by what they guard — same rule (outside the build, run befor
| Script | Catches | | Script | Catches |
|---|---| |---|---|
| `bun run smoke` · `bun run smoke:gallery` · `bun run smoke:share` | the gallery page, the playground, and share-link round-tripping — each needs a browser | | `bun run smoke` · `bun run smoke:gallery` · `bun run smoke:share` | the built leaf pages, the ARCHIVED `/__gallery` page, and share-link round-tripping — each needs a browser. The studio at `/` is `bun run smoke:studio` |
| `bun run check:a11y-button-names` | an icon-only control with no accessible name (needs a browser to read the computed name) | | `bun run check:a11y-button-names` | an icon-only control with no accessible name (needs a browser to read the computed name) |
| `bun run check:capability-citations` | the README claiming a capability the manifest does not back | | `bun run check:capability-citations` | the README claiming a capability the manifest does not back |
| `bun run check:dxlife` | **any edit to the read-only DX-life clone** — the standing law that tree is never written | | `bun run check:dxlife` | **any edit to the read-only DX-life clone** — the standing law that tree is never written |

View file

@ -31,7 +31,13 @@
* 4. the provenance comment still EXISTS (it is load-bearing: it records which filters produced the * 4. the provenance comment still EXISTS (it is load-bearing: it records which filters produced the
* file) and sits at the END, where a trailing 1-column row is harmless. * file) and sits at the END, where a trailing 1-column row is harmless.
* *
* NEGATIVE-TESTED: putting the provenance line back on top fails assertion 1 by name. * ★ THE NEGATIVE TEST IS RUNNABLE, NOT A CLAIM IN A COMMENT: `--self-test`.
* This header used to assert "NEGATIVE-TESTED" with no way to re-run it, which is the one thing
* this repo has learned to distrust — a gate that cannot fail is worse than no gate, and a gate
* whose negative test exists only in prose is indistinguishable from one that never had one.
* `node internal/check-csv-export-shape.mjs --self-test` re-runs every assertion below against a
* DELIBERATELY BROKEN assembly (provenance hoisted back to line 0) held IN MEMORY — the shipped
* source file is never written to — and exits 0 only if the gate correctly REJECTED it.
*/ */
import { readFileSync, existsSync } from 'node:fs' import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path' import { resolve, dirname, join } from 'node:path'
@ -65,7 +71,10 @@ if (!linesMatch) {
process.exit(1) process.exit(1)
} }
const order = linesMatch[1].replace(/\s+/g, ' ').trim() const order = linesMatch[1].replace(/\s+/g, ' ').trim()
const provenanceFirst = /^\s*provenance\s*,/.test(order) // --self-test FORCES the broken order in memory, so every assertion below runs against a file
// that is known-bad. The shipped source is read-only here and is never modified on disk.
const SELF_TEST = process.argv.includes('--self-test')
const provenanceFirst = SELF_TEST || /^\s*provenance\s*,/.test(order)
const cols = ['slug', 'name', 'religion', 'imb_people_description'] const cols = ['slug', 'name', 'religion', 'imb_people_description']
const rows = [ const rows = [
@ -96,6 +105,13 @@ function parse(t) {
const parsed = parse(csv) const parsed = parse(csv)
const fail = (msg, detail) => { const fail = (msg, detail) => {
// Under --self-test a rejection is the DESIRED outcome: the gate just proved it can fail.
if (SELF_TEST) {
console.log('CSV-SHAPE SELF-TEST: PASS — the gate REJECTED the deliberately broken export.')
console.log(' It said: ' + msg)
console.log(' So a real regression of this shape would be caught, not waved through.')
process.exit(0)
}
console.error('CSV-SHAPE: FAIL — ' + msg) console.error('CSV-SHAPE: FAIL — ' + msg)
if (detail) console.error(detail) if (detail) console.error(detail)
process.exit(1) process.exit(1)
@ -142,6 +158,15 @@ if (!(last.length === 1 && String(last[0]).startsWith('#'))) {
' between a dataset and an anecdote. Keep it — just keep it last.') ' between a dataset and an anecdote. Keep it — just keep it last.')
} }
// Reaching here under --self-test means the gate WAVED THROUGH a known-broken export.
if (SELF_TEST) {
console.error('CSV-SHAPE SELF-TEST: FAIL — the gate PASSED a deliberately broken export.')
console.error(' The provenance comment was forced back onto line 0 and no assertion objected, so')
console.error(' this gate cannot catch the regression it exists to prevent. Fix the gate, not the')
console.error(' self-test: a check that always passes is worse than no check, because it is trusted.')
process.exit(1)
}
console.log(`CSV-SHAPE: PASS — header on line 0 (${first.length} cols), ${dataRows.length} data row(s) all matching,`) console.log(`CSV-SHAPE: PASS — header on line 0 (${first.length} cols), ${dataRows.length} data row(s) all matching,`)
console.log(' embedded comma/quote/newline round-trip intact, provenance preserved as a trailing comment.') console.log(' embedded comma/quote/newline round-trip intact, provenance preserved as a trailing comment.')
console.log(' This parses the export as a spreadsheet would; no DOM check can see this defect, because') console.log(' This parses the export as a spreadsheet would; no DOM check can see this defect, because')

View file

@ -0,0 +1,277 @@
#!/usr/bin/env node
/**
* check-facet-one-field.mjs — ONE ROW-FIELD PER FACET: no two facets may be the SAME AXIS TWICE.
*
* THE DEFECT THIS CATCHES (card BUG-PEOPLE-AND-AFFINITY-BLOC-ARE-THE-SAME-AXIS-TWICE).
* The dashboard config once declared TWO facet keys over ONE row field: `affinity_bloc`, reading
* the CODE of r.rop1, and `rop1`, reading the LABEL of the very same field. That produced two
* controls for one axis — a "People" dropdown beside an "Affinity Bloc" tree tier — which could
* never recognise each other, because filteredRows byte-matches each key against its OWN accessor.
* The tree stored affinity_bloc='A012' while the dropdown stored rop1='South Asian Peoples', and
* neither ever saw the other's selection. Driver, from behaviour alone: "there are duplicate
* affinity blocks, one called People, one called Affinity Bloc... they are the same thing."
*
* ★ WHY THIS GATE ASSERTS ON OUTPUT VALUES AND NOT ON FIELD READS — THE MEASUREMENT THAT DECIDED IT.
* The obvious gate is "no two facets may read the same r.<field>". THAT GATE WOULD FAIL CORRECT
* CODE. Measured against the live config, three fields are legitimately read twice:
*
* r.religion -> religion_family (family NAME, from the code's first letter) + religion
* r.primary_language -> language_family (family NAME, via langFamilyByIso fallback) + primary_language
* r.rop1 -> affinity_bloc (the CODE 'A012') + rop1_name
*
* Every one of those is a deliberate tier-1/tier-2 pair or a search-only projection: two DIFFERENT
* projections of one field, which is exactly how a two-tier tree is built. Field-sharing is normal.
*
* What is NOT normal is two facets that produce the SAME VALUE SET — that is one axis wearing two
* names, and it is the actual defect. So the assertion is on the projected OUTPUT:
*
* for each pair of facets, project both over the real rows; if their non-empty value sets are
* EQUAL (or one is a pure re-labelling of the other, same partition of the rows), FAIL.
*
* The tier pairs above pass cleanly: religion_family yields ~8 family names while religion yields
* ~60 religions — different partitions, no collision. The old rop1 twin would FAIL, because
* lbl(r.rop1) and code(r.rop1) partition the rows IDENTICALLY (a 1:1 relabelling), which is the
* precise signature of a duplicate axis.
*
* ASSERTIONS
* A. NO DUPLICATE PARTITION — no two facets may induce the same partition of the rows. A pure
* relabelling (bijection between the two value sets) counts as the same partition.
* B. NO FACET IS DECLARED TWICE IN THE CONTROL LISTS — a key may appear in at most one of
* facetRail.drill / facetRail.facet, so one axis can never render as two rival controls.
* C. SEARCH-ONLY PROJECTIONS STAY OUT OF THE CONTROL LISTS — rop1_name exists so free-text
* search matches "South Asian" rather than 'A012'. Adding it to a control list would
* reintroduce the exact duplicate axis this card removed, so that is asserted explicitly.
*
* Run: node internal/check-facet-one-field.mjs (--self-test for the NEGATIVE test)
*/
import { readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const SELF_TEST = process.argv.includes('--self-test')
const fail = []
const note = (s) => console.log(' ' + s)
// ── load the real config ────────────────────────────────────────────────────────────────────
// ★ WHY THE CONFIG IS EVALUATED, NOT IMPORTED. dashboard.config.js is written for VITE: its
// imports use the `@map` alias (-> library/, vite.config.js:127 — NOT a map bundle, despite the
// name), it imports .json without node's required import attribute, and its transitive graph
// reaches library/colors/_registry.js, which calls `import.meta.glob` — a vite-only primitive
// that throws under plain node. A first attempt resolved all of that with a source rewrite plus
// an ESM loader hook and STILL died on the glob, because the alias is transitive and the graph
// keeps widening with every hop.
//
// None of that graph is load-bearing HERE. This gate needs exactly one thing: the `facets`
// accessor table. So the config's module body is evaluated with its imports satisfied by the
// small set of real values the accessors actually call, and the vite-only modules never enter
// the picture. That keeps the gate fast, dependency-free, and immune to churn in unrelated
// build tooling.
//
// The values below are READ FROM THEIR REAL HOMES, never re-declared here — a hand-copied
// RELIGION_FAMILIES would drift from the map's and make this gate assert against a fiction.
function loadConfig() {
const cfgPath = resolve(ROOT, 'plugin-designs/research-dashboard/dashboard.config.js')
let src = readFileSync(cfgPath, 'utf8')
const religionSrc = readFileSync(resolve(ROOT, 'library/colors/religion.js'), 'utf8')
const famMatch = religionSrc.match(/RELIGION_FAMILIES\s*=\s*(\{[\s\S]*?\n\})/)
if (!famMatch) throw new Error('RELIGION_FAMILIES not found in library/colors/religion.js')
const RELIGION_FAMILIES = (0, eval)('(' + famMatch[1] + ')')
const langSrc = readFileSync(resolve(ROOT, 'library/colors/language-family.js'), 'utf8')
const canonMatch = langSrc.match(/function canonicalFamilyName[\s\S]*?\n\}/)
const canonicalFamilyName = canonMatch ? (0, eval)('(' + canonMatch[0] + ')') : ((s) => s)
const langFamilyByIso = JSON.parse(readFileSync(resolve(ROOT, 'library/data/langFamilyByIso.json'), 'utf8'))
// lbl(): the aggregations helper's contract — a {value,label} pair renders as its LABEL.
const lbl = (v) => (v && typeof v === 'object' ? (v.label ?? v.value ?? '') : (v ?? ''))
// Drop the import statements (the names they bind are passed in as parameters instead) and
// turn the default export into a return.
src = src.replace(/^\s*import[\s\S]*?from\s+['"][^'"]+['"]\s*;?\s*$/gm, '')
const body = src.replace(/export\s+default\s+/, 'return ')
const make = new Function('RELIGION_FAMILIES', 'canonicalFamilyName', 'langFamilyByIso', 'lbl', body)
return make(RELIGION_FAMILIES, canonicalFamilyName, langFamilyByIso, lbl)
}
// ── the rows: the live fixture the other gates use, else a synthetic stand-in ────────────────
function loadRows() {
for (const p of ['data/research-rows.json', 'data-private/research-rows.json',
'_qa-cycle/_fixtures/research-rows.json']) {
try {
const j = JSON.parse(readFileSync(resolve(ROOT, p), 'utf8'))
const rows = Array.isArray(j) ? j : (j.rows || j.data || j.results)
if (Array.isArray(rows) && rows.length) { note(`rows: ${rows.length} from ${p}`); return rows }
} catch (_) { /* try the next candidate */ }
}
return null
}
const cfg = loadConfig()
const facets = cfg.facets || {}
let rows = loadRows()
if (!rows) {
// No fixture on disk. Synthesize rows that exercise the REAL accessors over the REAL field
// shapes ({value,label} pairs), which is all assertion A needs: it compares PARTITIONS, and a
// partition is a property of the accessors, not of the live API's exact contents.
//
// ★ THE CORPUS MUST MAKE INDEPENDENT AXES INDEPENDENT, AND THE FIRST ONE DID NOT.
// The first version of this fixture indexed every field with `i % 6`. That made religion,
// country and language PERFECTLY CORRELATED — each had 6 values changing in lockstep — so
// they induced the IDENTICAL partition and the gate reported 15 duplicate axes in a config
// that has none. The logic was right and the DATA was a fiction, which is the more dangerous
// of the two failures: a gate that always fails gets muted, and a muted gate protects nothing.
//
// So each axis now cycles on a DISTINCT PRIME (7, 11, 13, 17, 19, 23, 29). Distinct primes
// share no common period below their product, so no two axes can drift into lockstep across
// the 400 rows sampled here — any equal partition this gate now reports is a property of the
// ACCESSORS, which is exactly what it claims to measure.
note('rows: no fixture found — using a synthetic {value,label} corpus (co-prime cycles)')
const PEOPLE = ['South Asian Peoples','East Asian Peoples','Sub-Saharan Peoples','Arab Peoples','Turkic Peoples','Malay Peoples','Latin Peoples']
const REGION = ['Asia','Africa','Europe','Americas','Oceania','Middle East','Eurasia','Caribbean','Arctic','Levant','Sahel']
const COUNTRY = [['IN','India'],['CN','China'],['NG','Nigeria'],['EG','Egypt'],['TR','Turkey'],['MY','Malaysia'],['BR','Brazil'],['FR','France'],['KE','Kenya'],['ID','Indonesia'],['PK','Pakistan'],['MX','Mexico'],['VN','Vietnam']]
const LANG = [['hin','Hindi'],['cmn','Mandarin'],['yor','Yoruba'],['arb','Arabic'],['tur','Turkish'],['zlm','Malay'],['por','Portuguese'],['fra','French'],['swh','Swahili'],['ind','Indonesian'],['urd','Urdu'],['spa','Spanish'],['vie','Vietnamese'],['ben','Bengali'],['tam','Tamil'],['tel','Telugu'],['mar','Marathi']]
// religion CODES whose FIRST LETTER is the family — the real shape religion_family parses.
const RELIGION = [['H1','Hinduism'],['M2','Islam - Sunni'],['C3','Christianity'],['B4','Buddhism'],['E5','Ethnoreligion'],['N6','Non-Religious'],['H7','Hinduism - Vaishnavite'],['M8','Islam - Shia'],['C9','Christianity - Orthodox'],['B10','Buddhism - Mahayana'],['A11','Atheist'],['O12','Other'],['S13','Sikhism'],['J14','Judaism'],['D15','Daoism'],['U16','Unknown'],['P17','Parsee'],['Z18','Zoroastrian'],['L19','Larger'] ]
const mk = (i) => ({
rop1: { value: 'A0' + (10 + (i % 7)), label: PEOPLE[i % 7] },
imb_reg_of_people_2: { value: 'C' + (100 + (i % 31)), label: 'Cluster ' + (i % 31) },
imb_reg_of_people_25: { value: '' + (300000 + (i % 29)), label: 'PG ' + (i % 29) },
wagf_region: { value: 'r' + (i % 11), label: REGION[i % 11] },
wagf_block: { value: 'b' + (i % 13), label: 'Bloc ' + (i % 13) },
country_code: { value: COUNTRY[i % 37 % 13][0] + (i % 37), label: COUNTRY[i % 37 % 13][1] + ' ' + (i % 37) },
engagement_status: { value: 'e' + (i % 3), label: (i % 3 === 0) ? 'Engaged' : 'Unengaged' },
primary_language: { value: LANG[i % 17][0], label: LANG[i % 17][1] },
// sparse, exactly as live (populated on a minority of rows) so the ISO fallback is exercised
imb_language_family: (i % 23 === 0) ? { value: 'ie', label: 'Indo-European' } : null,
religion: { value: RELIGION[i % 19][0], label: RELIGION[i % 19][1] },
population: 1000 + i, people_praying: i, adopted_by_churches: i % 2, name: 'Group ' + i,
})
rows = Array.from({ length: 400 }, (_, i) => mk(i))
}
// ── project every facet over the rows ───────────────────────────────────────────────────────
const projections = new Map() // facetKey -> array of per-row output values (strings)
for (const [key, f] of Object.entries(facets)) {
if (typeof f?.get !== 'function') continue
const vals = rows.map((r) => { try { const v = f.get(r); return v == null ? '' : String(v) } catch (_) { return '' } })
projections.set(key, vals)
}
note(`facets projected: ${projections.size}`)
// ── A. NO TWO FACETS MAY INDUCE THE SAME PARTITION ──────────────────────────────────────────
// Two facets are the SAME AXIS when they group the rows identically, whatever they call the
// groups. So compare partitions, not values: build each facet's row-index signature and test for
// a bijection between the two labellings. This is what distinguishes a legitimate tier pair
// (religion_family vs religion — a COARSER partition) from a duplicate axis (code vs label of one
// field — the IDENTICAL partition under a different name).
function partitionOf(vals) {
const g = new Map()
vals.forEach((v, i) => { if (v === '') return; if (!g.has(v)) g.set(v, []); g.get(v).push(i) })
return g
}
// same partition <=> every group of A is exactly a group of B (and counts match)
function samePartition(a, b) {
const A = partitionOf(a), B = partitionOf(b)
if (A.size !== B.size || A.size === 0) return false
const bSigs = new Set([...B.values()].map((ix) => ix.join(',')))
for (const ix of A.values()) if (!bSigs.has(ix.join(','))) return false
return true
}
// ★ SCOPED TO CONTROL-REACHABLE FACETS, AND THE MEASUREMENT IS WHY.
// Run unscoped, this assertion flags `affinity_bloc` vs `rop1_name` — and it is RIGHT that the two
// induce the same partition (code vs label of r.rop1, a 1:1 relabelling: the exact historical
// defect's signature). But that pair is CORRECT in the config as it stands, because rop1_name is
// search-only and is never rendered as a control. Two projections of one field only become the
// "two rival controls" bug when BOTH are reachable as controls.
//
// So the invariant is not "no two facets may share a partition" — it is "no two facets that a user
// can both SELECT may share a partition". Assertion C is the other half: it pins the search-only
// keys OUT of the control lists, so the pair cannot become reachable without this gate firing.
// Widening A to every declared facet would fail correct code, which is the same class of mistake
// as asserting on duplicate field READS.
const controlKeys = new Set()
for (const sec of cfg.facetRail || []) {
for (const k of (sec.drill ? sec.drill.filter(Boolean) : (sec.facet ? [sec.facet] : []))) controlKeys.add(k)
}
for (const k of cfg.researchFilters?.dropdowns || []) controlKeys.add(k)
for (const c of cfg.charts || []) if (c.facet) controlKeys.add(c.facet)
const keys = [...projections.keys()].filter((k) => controlKeys.has(k))
note(`control-reachable facets: ${keys.length} of ${projections.size} (rail + dropdowns + charts)`)
let dupFound = null
for (let i = 0; i < keys.length; i++) {
for (let j = i + 1; j < keys.length; j++) {
if (samePartition(projections.get(keys[i]), projections.get(keys[j]))) {
dupFound = [keys[i], keys[j]]
fail.push(`A. DUPLICATE AXIS: facets '${keys[i]}' and '${keys[j]}' induce the IDENTICAL ` +
`partition of the rows — one axis declared twice. Two controls over one axis can ` +
`never recognise each other's selection (each byte-matches its OWN accessor), so ` +
`picking one silently ignores the other. Keep ONE key; make the other a display ` +
`projection (optionLabel) or a search-only projection kept OUT of every control list.`)
}
}
}
if (!dupFound) note('A. no duplicate partition among ' + keys.length + ' facets — OK')
// ── B. NO FACET KEY APPEARS IN TWO CONTROL LISTS ────────────────────────────────────────────
const seen = new Map()
for (const sec of cfg.facetRail || []) {
const declared = sec.drill ? sec.drill.filter(Boolean) : (sec.facet ? [sec.facet] : [])
for (const k of declared) {
if (seen.has(k)) {
fail.push(`B. facet '${k}' is declared in TWO rail sections ('${seen.get(k)}' and '${sec.id}') — ` +
`one axis rendering as two rival controls is the defect this gate exists for.`)
} else seen.set(k, sec.id)
}
}
if (!fail.some((f) => f.startsWith('B.'))) note(`B. ${seen.size} rail facet keys, each declared once — OK`)
// ── C. SEARCH-ONLY PROJECTIONS STAY OUT OF THE CONTROL LISTS ────────────────────────────────
// rop1_name is a LABEL projection of the same field affinity_bloc reads as a CODE. It exists only
// so free-text search matches "South Asian". If it ever enters a control list, the duplicate axis
// is back — and assertion A would not necessarily catch it first, because A is about the config's
// facet table while this is about the RAIL. Asserted by name, with the reason attached.
const SEARCH_ONLY = ['rop1_name']
for (const k of SEARCH_ONLY) {
if (!facets[k]) continue
if (seen.has(k)) {
fail.push(`C. '${k}' is a SEARCH-ONLY projection but appears in rail section '${seen.get(k)}'. ` +
`It re-declares an axis that already has a canonical key, which is exactly the ` +
`People-vs-Affinity-Bloc duplicate the config comment warns against.`)
}
}
if (!fail.some((f) => f.startsWith('C.'))) note('C. search-only projections absent from control lists — OK')
// ── SELF-TEST: the NEGATIVE test. Re-introduce the historical duplicate and REQUIRE a failure. ──
if (SELF_TEST) {
console.log('\n--- SELF-TEST: re-introducing the historical `rop1` label twin ---')
// The exact defect that was removed: a second facet over r.rop1 reading the LABEL where
// affinity_bloc reads the CODE. A 1:1 relabelling => the identical partition.
const twin = rows.map((r) => (r.rop1 && typeof r.rop1 === 'object' ? String(r.rop1.label || '') : ''))
const canonical = projections.get('affinity_bloc')
if (!canonical) { console.error('SELF-TEST INCONCLUSIVE: affinity_bloc not projected'); process.exit(1) }
const caught = samePartition(canonical, twin)
if (!caught) {
console.error('SELF-TEST FAILED: the duplicate-axis check did NOT flag a pure relabelling of ' +
'affinity_bloc. The gate cannot detect the very defect it exists for.')
process.exit(1)
}
console.log('SELF-TEST PASS: the duplicate partition WAS detected (gate can fail).')
process.exit(0)
}
// ── verdict ─────────────────────────────────────────────────────────────────────────────────
console.log('')
if (fail.length) {
console.error('FACET-ONE-FIELD: FAIL')
for (const f of fail) console.error(' ✗ ' + f)
process.exit(1)
}
console.log('FACET-ONE-FIELD: PASS — one row-field projection per facet, no axis declared twice.')

View file

@ -381,6 +381,22 @@ ${dev ? LADDER_CSS : ''}</style>
// Manifest refs are relative to the tree root; this page sits one level above it. Refs are // Manifest refs are relative to the tree root; this page sits one level above it. Refs are
// anchored to BASE (above) so they resolve the same with or without a trailing slash. // anchored to BASE (above) so they resolve the same with or without a trailing slash.
function rel(u) { return /^([a-z]+:|\/)/i.test(u) ? u : BASE + TREE + '/' + u.replace(/^\.\//, '') } function rel(u) { return /^([a-z]+:|\/)/i.test(u) ? u : BASE + TREE + '/' + u.replace(/^\.\//, '') }
// ★ THE TREE ROOT AS ONE ABSOLUTE URL — derive it ONCE, never re-join TREE by hand.
// TREE is PAGE-RELATIVE and its value differs per surface ('../doxa-maps' in the built tree,
// '__plugin-gallery' in dev), so a bare TREE + '/x' join is only accidentally right. It was right
// in the built tree (where the '../' climbs out of /__gallery/) and WRONG in dev, where it asked
// for /__gallery/__plugin-gallery/manifest.json. MEASURED 2026-09-22, both servers running:
// dev /__gallery/__plugin-gallery/manifest.json ... 404 (what the page asked for)
// dev /__plugin-gallery/manifest.json ............. 200 (what it meant)
// built /doxa-maps/manifest.json .................... 200
// rel() never had the bug because it always anchored to BASE. Resolving through the URL parser
// (rather than string-joining) also collapses the '../' properly for BOTH surfaces at once.
// Resolve TREE *against* the page's own directory — do NOT string-join it onto BASE. TREE may be
// ROOTED ('/__plugin-gallery' in dev), and 'BASE + TREE' would glue it on to yield
// /__gallery//__plugin-gallery, which 404s just as surely as the original bug. Passing BASE as
// the base URL lets the URL parser apply the right rule per shape: a rooted TREE replaces the
// path, a '../' TREE climbs out of /__gallery/, and a bare name resolves beside it.
var TREE_URL = new URL(TREE, new URL(BASE, location.href)).href.replace(/\/+$/, '');
function qp(name) { try { return new URLSearchParams(location.search).get(name) || '' } catch (e) { return '' } } function qp(name) { try { return new URLSearchParams(location.search).get(name) || '' } catch (e) { return '' } }
function hint(msg, err) { var slot = slotEl(); var d = document.createElement('div'); d.className = 'hint' + (err ? ' err' : ''); d.textContent = msg; slot.innerHTML = ''; slot.appendChild(d); return d } function hint(msg, err) { var slot = slotEl(); var d = document.createElement('div'); d.className = 'hint' + (err ? ' err' : ''); d.textContent = msg; slot.innerHTML = ''; slot.appendChild(d); return d }
function assign(t) { for (var i = 1; i < arguments.length; i++) { var s = arguments[i]; for (var k in s) if (Object.prototype.hasOwnProperty.call(s, k)) t[k] = s[k] } return t } function assign(t) { for (var i = 1; i < arguments.length; i++) { var s = arguments[i]; for (var k in s) if (Object.prototype.hasOwnProperty.call(s, k)) t[k] = s[k] } return t }
@ -422,6 +438,17 @@ ${dev ? LADDER_CSS : ''}</style>
var cfg = x ? assign({ assetsBase: ASSETS }, x.pr) : { profile: p.p, dataSource: 'pray-tools', instanceId: b.b + '-' + p.p, assetsBase: ASSETS }; var cfg = x ? assign({ assetsBase: ASSETS }, x.pr) : { profile: p.p, dataSource: 'pray-tools', instanceId: b.b + '-' + p.p, assetsBase: ASSETS };
if (QA) cfg.qa = true; if (QA) cfg.qa = true;
cfg.shareUrl = new URL(rel((x || p).u), location.href).href; cfg.shareUrl = new URL(rel((x || p).u), location.href).href;
// ★ HAND THE TREE ROOT DOWN — an element that fetches the manifest cannot guess it from here.
// A cap-5 shell derives its tree root from location.pathname when nothing is passed, which is
// right on its own <tree>/<bundle>/<profile>/ leaf page and WRONG in this gallery, where the
// path belongs to the PAGE, not the element. doxa-maps-spa's relative fallback is '../', so
// from /__gallery/ it asked for /manifest.json. MEASURED by the smoke:gallery gate:
// ✗ doxa-maps-spa/doxa-maps-spa data: 404 http://localhost:5173/manifest.json
// The SPA's OWN nested-mount path already sets both of these for exactly this reason; this
// gallery was the one mount path that never learned the lesson. Set both, because not every
// element reads profile-config (an element mounting its own SFC has no ProfileLoader above it
// and takes tree-base as a prop); an element that declares neither ignores both.
cfg.treeBase = TREE_URL;
window.MAP_APP_API_URL = (x && x.a) || API_BASE; window.MAP_APP_API_URL = (x && x.a) || API_BASE;
var provider = cfg.provider === 'maplibre' ? 'maplibre' : 'mapbox', my = ++seq; var provider = cfg.provider === 'maplibre' ? 'maplibre' : 'mapbox', my = ++seq;
hint(provider === 'maplibre' ? 'loading map…' : 'loading token…'); hint(provider === 'maplibre' ? 'loading map…' : 'loading token…');
@ -432,6 +459,7 @@ ${dev ? LADDER_CSS : ''}</style>
try { try {
var c = token ? assign({}, cfg, { tk: token }) : cfg; var c = token ? assign({}, cfg, { tk: token }) : cfg;
var el = document.createElement(tag); el.setAttribute('profile-config', JSON.stringify(c)); var el = document.createElement(tag); el.setAttribute('profile-config', JSON.stringify(c));
el.setAttribute('tree-base', TREE_URL); // see cfg.treeBase above — both, for both kinds of element
var slot = slotEl(); slot.innerHTML = ''; slot.appendChild(el); var slot = slotEl(); slot.innerHTML = ''; slot.appendChild(el);
} catch (e) { hint(e && e.message ? e.message : String(e), true) } } catch (e) { hint(e && e.message ? e.message : String(e), true) }
}); });
@ -542,7 +570,7 @@ ${dev ? LADDER_CSS : ''}</style>
try { window.matchMedia('(max-width:700px)').addEventListener('change', function (e) { if (!e.matches) self.navOpen = true }) } catch (e) {} try { window.matchMedia('(max-width:700px)').addEventListener('change', function (e) { if (!e.matches) self.navOpen = true }) } catch (e) {}
// Live manifest first (the page stays right when bundles change); baked copy is the fallback. // Live manifest first (the page stays right when bundles change); baked copy is the fallback.
if (typeof fetch === 'function' && location.protocol !== 'file:') { if (typeof fetch === 'function' && location.protocol !== 'file:') {
fetch(TREE + '/manifest.json', { cache: 'no-cache' }).then(function (r) { return r.ok ? r.json() : null }) fetch(TREE_URL + '/manifest.json', { cache: 'no-cache' }).then(function (r) { return r.ok ? r.json() : null })
.then(function (m) { self.rebuild(m) }, function () { self.route() }); .then(function (m) { self.rebuild(m) }, function () { self.route() });
} else { self.route(); } } else { self.route(); }
} }
@ -590,7 +618,7 @@ ${dev ? LADDER_CSS : ''}</style>
} }
window.__spa = { slot: $('slot') }; window.__spa = { slot: $('slot') };
if (typeof fetch === 'function' && location.protocol !== 'file:') { if (typeof fetch === 'function' && location.protocol !== 'file:') {
fetch(TREE + '/manifest.json', { cache: 'no-cache' }).then(function (r) { return r.ok ? r.json() : null }) fetch(TREE_URL + '/manifest.json', { cache: 'no-cache' }).then(function (r) { return r.ok ? r.json() : null })
.then(function (m) { if (m) { try { REG = compact(m, false, TIER_MAP) } catch (e) { REG = BAKED } } paint(); route(); }, function () { paint(); route(); }); .then(function (m) { if (m) { try { REG = compact(m, false, TIER_MAP) } catch (e) { REG = BAKED } } paint(); route(); }, function () { paint(); route(); });
} else { paint(); route(); } } else { paint(); route(); }
window.addEventListener('hashchange', route); window.addEventListener('hashchange', route);

View file

@ -117,7 +117,17 @@ export function autoSpaDevPlugin(token = '') {
const page = () => { const page = () => {
const cfg = readBuildConfig() const cfg = readBuildConfig()
return renderGallery(galleryModel(discoverSpaRegistry()), { return renderGallery(galleryModel(discoverSpaRegistry()), {
mode: 'dev', title: cfg.title, tree: '__plugin-gallery', tokenUrl: cfg.tokenUrl, token: token || cfg.token, // ★ `/__plugin-gallery`, ABSOLUTE — the SAME move, and the same fix, as assetsBaseUrl below.
// `tree` is the folder the live manifest is fetched from and that relative refs resolve
// against. It was '__plugin-gallery' (relative), correct only while the gallery WAS the root
// page. The gallery now lives at /__gallery, so the relative form resolves one level deeper.
// MEASURED 2026-09-22 on the running dev server:
// /__plugin-gallery/manifest.json ............. 200
// /__gallery/__plugin-gallery/manifest.json ... 404
// Naming it absolutely makes the value independent of the page depth the gallery sits at —
// which is the whole reason assetsBaseUrl was made absolute in the same move. The built tree
// passes '../<name>' for the same reason; both now resolve to the real tree root.
mode: 'dev', title: cfg.title, tree: '/__plugin-gallery', tokenUrl: cfg.tokenUrl, token: token || cfg.token,
// ★ ABSOLUTE, NOT RELATIVE — the gallery no longer lives at the root. // ★ ABSOLUTE, NOT RELATIVE — the gallery no longer lives at the root.
// This was '' (relative), which was correct while the gallery WAS the root page: a map asked // This was '' (relative), which was correct while the gallery WAS the root page: a map asked
// for assets/tables/x.json and got /assets/tables/x.json. The gallery has since moved to // for assets/tables/x.json and got /assets/tables/x.json. The gallery has since moved to

View file

@ -29,6 +29,11 @@ import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js' import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useLegendData } from '../../composables/legends/useLegendData.js' import { useLegendData } from '../../composables/legends/useLegendData.js'
import { RESEARCH_LEGEND_OPTIONS } from '../../composables/legends/researchLegendOptions.js' import { RESEARCH_LEGEND_OPTIONS } from '../../composables/legends/researchLegendOptions.js'
// .lt-overlay is a TRANSPARENT click-catcher, so a too-small one looks like nothing at all — but
// it still fails: inside the cap-5 shell contain:layout on .vtl-content clips it to the content
// pane (measured 1024x793 @ (256,107) at 1280x900), so clicking the rail or header does NOT
// dismiss the popup it is supposed to close. Same mechanism as the visible scrims.
import { useViewportScrim } from '../../utils/viewportScrim.js'
// Decoupled CSS — its own namespace ('legend-tools'), so none of the // Decoupled CSS — its own namespace ('legend-tools'), so none of the
// legend-component selectors apply here. The card CSS (.legend-container, the // legend-component selectors apply here. The card CSS (.legend-container, the
@ -101,6 +106,7 @@ const { items: legendDataItems, setFilter } = useLegendData(legendTypeRef, RESEA
// Local UI state — popup visibility + auto-fly cursor. // Local UI state — popup visibility + auto-fly cursor.
const showSpeedSelector = ref(false) const showSpeedSelector = ref(false)
const showClusterSettings = ref(false) const showClusterSettings = ref(false)
const { scrimRef, scrimStyle } = useViewportScrim(showClusterSettings)
const autoFlyIndex = ref(0) const autoFlyIndex = ref(0)
const autoFlyTimer = ref(null) const autoFlyTimer = ref(null)
@ -240,7 +246,7 @@ onBeforeUnmount(() => {
<span>Minimum Spanning Tree<br><small style="opacity:0.7;font-weight:400">click a legend row to cluster only that group</small></span> <span>Minimum Spanning Tree<br><small style="opacity:0.7;font-weight:400">click a legend row to cluster only that group</small></span>
</div> </div>
</div> </div>
<div v-if="showClusterSettings" class="lt-overlay" @click.stop="showClusterSettings = false"></div> <div v-if="showClusterSettings" ref="scrimRef" :style="scrimStyle" class="lt-overlay" @click.stop="showClusterSettings = false"></div>
</div> </div>
</div> </div>
</template> </template>

View file

@ -18,6 +18,11 @@ import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js' import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useInstance } from '../../composables/usePplrInstance.js' import { useInstance } from '../../composables/usePplrInstance.js'
import LegendProgressBar from './LegendProgressBar.vue' import LegendProgressBar from './LegendProgressBar.vue'
// .stl-export-backdrop is an invisible click-catcher ("captures the next click"), so when it is
// clipped nothing LOOKS wrong — but inside the cap-5 shell contain:layout on .vtl-content limits
// it to the content pane (measured 1024x793 @ (256,107) at 1280x900), so a click on the rail or
// header fails to close the export menu.
import { useViewportScrim } from '../../utils/viewportScrim.js'
const { t, te } = useI18n() const { t, te } = useI18n()
@ -157,6 +162,7 @@ const localSelected = ref(null)
const panelOpen = ref(props.open !== null ? props.open : true) const panelOpen = ref(props.open !== null ? props.open : true)
const showInfoForTab = ref(null) const showInfoForTab = ref(null)
const exportMenuOpen = ref(false) // top-right Export dropdown (behind SHOW_EXPORT_BUTTON) const exportMenuOpen = ref(false) // top-right Export dropdown (behind SHOW_EXPORT_BUTTON)
const { scrimRef, scrimStyle } = useViewportScrim(exportMenuOpen)
// Feature flag: show the Export affordance. Gated here in the SHARED legend so // Feature flag: show the Export affordance. Gated here in the SHARED legend so
// one switch covers every bundle. It was false by an earlier coder decision; // one switch covers every bundle. It was false by an earlier coder decision;
// design note 2026-07-31 asked for the button back on the 100-list legend, and the // design note 2026-07-31 asked for the button back on the 100-list legend, and the
@ -1069,7 +1075,7 @@ useShadowStyles(`
<template v-if="exportMenuOpen"> <template v-if="exportMenuOpen">
<!-- Click-away backdrop closes the menu without a global listener. --> <!-- Click-away backdrop closes the menu without a global listener. -->
<div class="stl-export-backdrop" @click.stop="exportMenuOpen = false"></div> <div ref="scrimRef" :style="scrimStyle" class="stl-export-backdrop" @click.stop="exportMenuOpen = false"></div>
<div class="stl-export-menu" role="menu"> <div class="stl-export-menu" role="menu">
<button type="button" class="stl-export-item" role="menuitem" @click.stop="emitExport('poster')"> <button type="button" class="stl-export-item" role="menuitem" @click.stop="emitExport('poster')">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true"> <svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true">

View file

@ -21,7 +21,7 @@
- Re-click the share button - Re-click the share button
--> -->
<script setup> <script setup>
import { ref, computed, inject, unref } from 'vue' import { ref, computed, inject, unref, provide } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js' import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { resolveShareUrl } from '../../utils/embedBase.js' import { resolveShareUrl } from '../../utils/embedBase.js'
@ -416,12 +416,88 @@ async function copyEmbed() {
} }
} }
/* ------------------------------------------------------------------ */
/* Anchoring OFF a map — the "menu opens off the right edge" fix */
/* ------------------------------------------------------------------ */
/*
★ DRIVER-REPORTED TWICE (2026-09-22): "when you click the share button on the research
dashboard, the menu is showing down to the RIGHT of the share button, and the share button is at
the very top right of the page. So down-to-the-right is actually OFF the page."
THE ROOT CAUSE IS AN ABSENT ANCHOR, NOT A WRONG ALIGNMENT CHOICE — measured, and it is a
DIFFERENT bug from the FilterDropdown one it superficially resembles. That one was a
position:fixed popover whose viewport frame was stolen by `contain:layout paint` on an ancestor.
This menu is not fixed at all. MapControlMenu.place() reads an INJECTED `mapToolbar`:
const tb = toolbar?.el?.value, area = host.value
if (!tb || !area) { style.value = {}; return } // <-- this branch
`provide('mapToolbar')` comes from MapToolbar.vue, and in the research dashboard MapToolbar is
rendered by DashboardMapPanel — a SIBLING of the top bar, not an ancestor of it. inject() walks
the COMPONENT TREE, so a ShareButton mounted in the top bar resolves the fallback `null`, place()
returns an EMPTY style, and `.mcm{position:absolute}` with no top/right falls back to its STATIC
position: immediately below-and-right of a button already at the far top-right. Off the page,
exactly as reported. Nothing was ever choosing "right" — nothing was choosing anything.
SO THE FIX IS TO GIVE IT AN ANCHOR, not to add a second placement scheme. When no toolbar is
provided, this button provides one pointing at its OWN wrapper, and marks that wrapper as the
containing area. MapControlMenu's existing measured logic then does the whole job unchanged:
it compares the anchor's centre to the area's centre, opens on the INNER side, and clamps the
width to what fits. A share button at the top right is right-of-centre, so the menu opens to the
LEFT — which is what the Driver asked for, arrived at by measurement rather than by hardcoding a
side that would then be wrong for a left-hand placement.
WHY `inject` FIRST AND `provide` ONLY AS A FALLBACK: on a real map this component must keep
behaving byte-identically — the toolbar is the correct anchor there, and the menu should still
align to the control COLUMN, not to one button in it. This only engages where the injection
genuinely resolves to nothing, so no map profile changes.
*/
const providedToolbar = inject('mapToolbar', null)
const selfAnchor = ref(null)
// Re-provide UNCONDITIONALLY so the value is stable for the child's inject(); `el` stays null when
// a real toolbar was found, and MapControlMenu keeps using that toolbar instead.
provide('mapToolbar', providedToolbar || { el: selfAnchor })
const hasOwnAnchor = !providedToolbar
/*
★ THIS IS A FUNCTION REF, AND onMounted WOULD NOT HAVE WORKED — the hazard is worth naming,
because the fix silently does NOTHING if it is written the obvious way.
MapControlMenu latches its area ONCE, in its own onMounted:
host.value = toolbar?.el?.value?.closest(AREA) || null
and place() bails forever after if that is null. MapControlMenu is a CHILD of this component, and
Vue runs a child's mounted hook BEFORE its parent's. So an onMounted here would set the anchor
one tick too late: the child would have already read `null`, latched `host = null`, and no later
assignment would revive it — the menu would go on opening at its static position and the fix
would look applied while changing nothing.
A function ref is invoked during RENDER, before any mounted hook on either component, so the
anchor and the area tag are in place by the time the child looks for them.
`data-map-area` is what MapControlMenu.AREA finds via closest(). It is tagged on the OFFSET
PARENT, not on .share-wrap: the wrapper is a 32px button, and an area that small would clamp the
menu to its 200px minimum jammed against the edge. The offset parent is the top bar, which spans
the row, so the menu has the full width to open leftward into.
*/
function bindAnchor(el) {
rootEl.value = el || null
if (!hasOwnAnchor || !el) { if (!el) selfAnchor.value = null; return }
const area = el.offsetParent || el.parentElement
// Never re-tag a host that already declares itself an area — that host owns the decision.
if (area && !area.closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]')) {
area.setAttribute('data-map-area', 'share-fallback')
}
selfAnchor.value = el
}
/* Placement, stacking, outside-click and Escape live in MapControlMenu (shared by every /* Placement, stacking, outside-click and Escape live in MapControlMenu (shared by every
control menu); this button only toggles `isOpen`. */ control menu); this button only toggles `isOpen`. */
</script> </script>
<template> <template>
<div ref="rootEl" class="share-wrap"> <!-- :ref is a FUNCTION, deliberately — it must run during render, before MapControlMenu's
mounted hook latches its area. See bindAnchor() in <script> for why onMounted is too late. -->
<div :ref="bindAnchor" class="share-wrap" data-share-root>
<MapControlButton <MapControlButton
ref="btnEl" ref="btnEl"
:is-dark="isDark" :is-dark="isDark"

View file

@ -18,7 +18,7 @@
--> -->
<template> <template>
<transition name="poster-dlg"> <transition name="poster-dlg">
<div v-if="visible" class="poster-dlg-backdrop" @click.self="onClose"> <div v-if="visible" ref="scrimRef" :style="scrimStyle" class="poster-dlg-backdrop" @click.self="onClose">
<section class="poster-dlg" role="dialog" aria-modal="true" aria-label="Export Poster"> <section class="poster-dlg" role="dialog" aria-modal="true" aria-label="Export Poster">
<header class="poster-dlg__header"> <header class="poster-dlg__header">
<slot name="header"> <slot name="header">
@ -115,6 +115,9 @@ import PosterPreview from './PosterPreview.vue';
import { useMapPoster } from '../../composables/poster/useMapPoster.js'; import { useMapPoster } from '../../composables/poster/useMapPoster.js';
import { listPosterSizes, getPosterSize, DEFAULT_POSTER_ID } from '../../constants/posterSizes.js'; import { listPosterSizes, getPosterSize, DEFAULT_POSTER_ID } from '../../constants/posterSizes.js';
import { defaultPosterSpec, posterDefaults } from '../../constants/posterDefaults.js'; import { defaultPosterSpec, posterDefaults } from '../../constants/posterDefaults.js';
// Backdrop geometry — a fixed;inset:0 backdrop covers only the content pane inside the cap-5
// shell (contain:layout on .vtl-content). See library/utils/viewportScrim.js.
import { useViewportScrim } from '../../utils/viewportScrim.js';
const props = defineProps({ const props = defineProps({
/** Boolean v-model: dialog visibility. */ /** Boolean v-model: dialog visibility. */
@ -136,6 +139,8 @@ const visible = computed({
set: (v) => emit('update:modelValue', v), set: (v) => emit('update:modelValue', v),
}); });
const { scrimRef, scrimStyle } = useViewportScrim(visible);
const sizes = listPosterSizes(); const sizes = listPosterSizes();
const sizeId = ref(props.initialSize); const sizeId = ref(props.initialSize);
const orientation = ref('portrait'); const orientation = ref('portrait');

View file

@ -41,6 +41,10 @@
*/ */
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// Export-modal geometry. A fixed;inset:0 scrim covers only the CONTENT PANE in the cap-5 shell
// (contain:layout on .vtl-content). Measured 1024x793 @ (256,107) at 1280x900 — rail clickable
// behind the "modal". See library/utils/viewportScrim.js for the measurements and rejected fixes.
import { useViewportScrim } from '@map/utils/viewportScrim.js'
const props = defineProps({ const props = defineProps({
items: { type: Array, required: true }, items: { type: Array, required: true },
@ -145,6 +149,7 @@ function showOnMap(item) {
// than by keeping two renderers in sync, and it works on Android where the Node // than by keeping two renderers in sync, and it works on Android where the Node
// generator cannot run at all. // generator cannot run at all.
const exportState = ref(null) const exportState = ref(null)
const { scrimRef, scrimStyle } = useViewportScrim(exportState)
async function exportPoster(item) { async function exportPoster(item) {
if (exportState.value) return // one at a time if (exportState.value) return // one at a time
@ -384,7 +389,7 @@ useShadowStyles(
</div> </div>
</section> </section>
<div v-if="exportState" class="modal-scrim" @click.self="exportState.phase === 'error' && dismissExport()"> <div v-if="exportState" ref="scrimRef" :style="scrimStyle" class="modal-scrim" @click.self="exportState.phase === 'error' && dismissExport()">
<div class="modal" role="dialog" aria-live="polite"> <div class="modal" role="dialog" aria-live="polite">
<div class="modal-kind">{{ exportState.kind }}</div> <div class="modal-kind">{{ exportState.kind }}</div>
<div class="modal-title">{{ exportState.label }}</div> <div class="modal-title">{{ exportState.label }}</div>

View file

@ -371,8 +371,25 @@ onBeforeUnmount(() => {
its most extreme form, so it cannot come back as a "mobile exception". */ its most extreme form, so it cannot come back as a "mobile exception". */
@media (max-width: 576px) { .filter-dropdown { @media (max-width: 576px) { .filter-dropdown {
width: var(--dx-filter-w-sm, 140px); min-width: var(--dx-filter-w-sm, 140px); max-width: var(--dx-filter-w-sm, 140px); } } width: var(--dx-filter-w-sm, 140px); min-width: var(--dx-filter-w-sm, 140px); max-width: var(--dx-filter-w-sm, 140px); } }
/* ★ THE TRIGGER'S HEIGHT IS THE FILTER ROW'S HEIGHT, and it was the last unattributed term
(card BUILD-SELECTED-ROW-MUCH-THINNER-AND-CENTRED — "the filter row is way too thick").
MEASURED at 1600x900 standalone, the filters band's 67px decomposes with nothing left over:
band 67 = band padding 10 (5+5) + sfh 56
sfh 56 = filters row 32 + sfh gap 6 + selections row 18
row 32 = trigger 30 + row padding-bottom 2
trigger 30 = padding 12 (6+6) + border 2 (1+1) + a 16px line box at 0.875rem
Three of those four terms had already been trimmed in earlier rounds (the gap 10->6, the band
padding 8->5, the selections row 24->18). The trigger was never touched, and at 30px it is the
single largest contributor — which is why the band kept measuring ~67 no matter how much was
shaved off the others. Vertical padding 6 -> 4 takes the trigger to 26 and the band to 63.
★ VERTICAL ONLY. The horizontal 12px is UNCHANGED and must stay: `check:filter-width` asserts
every trigger is exactly --dx-filter-w (168px) before and after selecting the longest option,
and the label's ellipsis budget is that width minus this padding and the chevron. Touching the
horizontal value would move a gate that exists to keep the centred stack from re-flowing.
Tokenized so a consumer can trade density back without forking the component, matching how
--dx-filter-w already handles the width. */
.filter-dropdown__trigger { .filter-dropdown__trigger {
width: 100%; padding: 6px 12px; cursor: pointer; display: flex; justify-content: space-between; width: 100%; padding: var(--dx-filter-pad-y, 4px) 12px; cursor: pointer; display: flex; justify-content: space-between;
/* Pairs its own foreground with its own background for the same reason as the menu: /* Pairs its own foreground with its own background for the same reason as the menu:
`color: inherit` over a light background rendered near-white-on-white inside the `color: inherit` over a light background rendered near-white-on-white inside the
cap-4 dark header. Background and text are set together, never half-inherited. */ cap-4 dark header. Background and text are set together, never half-inherited. */

View file

@ -232,8 +232,12 @@ function onClearAll() { emit('clear-all') }
underneath the filters" (card BUILD-SELECTED-ROW-MUCH-THINNER-AND-CENTRED). 10px -> 6px. underneath the filters" (card BUILD-SELECTED-ROW-MUCH-THINNER-AND-CENTRED). 10px -> 6px.
This gap is what sits BETWEEN the filters row and the selections row, so it reads as the This gap is what sits BETWEEN the filters row and the selections row, so it reads as the
white space under the filters even though no element owns it. That is why it was easy to white space under the filters even though no element owns it. That is why it was easy to
miss when only the row's own height was being hunted. */ miss when only the row's own height was being hunted.
display: flex; flex-direction: column; gap: var(--dx-sfh-gap, 6px); width: 100%; box-sizing: border-box; ★ 6px -> 4px on the second pass. The first pass took 10 -> 6 and the band still measured 67px,
because the gap was never the dominant term — the TRIGGER's 30px was (see FilterDropdown's
--dx-filter-pad-y note). With the trigger finally trimmed, this 2px is the last of the
inter-row air and is worth taking together with it rather than alone. */
display: flex; flex-direction: column; gap: var(--dx-sfh-gap, 4px); width: 100%; box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; color: var(--ink); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; color: var(--ink);
} }
/* ★ THE SEARCH BAR IS BAND 1 OF THE CENTRED STACK, so it centres like the rest /* ★ THE SEARCH BAR IS BAND 1 OF THE CENTRED STACK, so it centres like the rest
@ -284,9 +288,35 @@ function onClearAll() { emit('clear-all') }
this is a live case here, not a hypothetical. this is a live case here, not a hypothetical.
The unprefixed fallback is listed first for engines without `safe` — they get plain centring, The unprefixed fallback is listed first for engines without `safe` — they get plain centring,
which is the pre-existing behaviour for the fitting case and no worse for the other. */ which is the pre-existing behaviour for the fitting case and no worse for the other. */
/* ★ THE SLACK GOES BETWEEN THE TRIGGERS, NOT INTO TWO EDGE VOIDS
(card BUILD-CENTRE-THE-SEARCH-BAR-AND-SPREAD-THE-FILTER-TOGGLES). DRIVER: "the dropdown
toggles for the filters need to be centred also, so that they FILL UP THE FULL WIDTH, equally
spaced to fill up the second header."
MEASURED at 1600x900 standalone BEFORE this change: the row is 1572px wide and its content is
6 triggers x 168px + 5 gaps x 10px = 1058px — so scrollWidth (1572) == clientWidth (1572),
`overflowing: false`, with 514px of slack. Centring alone therefore parks ~257px of dead space
at EACH edge, which is what "not filling the width" looks like in numbers. The row is NOT
overflowing at desktop width, so the horizontal scrollbar the Driver complained about is a
narrow-viewport artifact, not the desktop case.
`space-between` would push the first and last triggers hard against the edges, breaking the
--rd-edge gutter the stack's alignment depends on. `space-evenly` is the one that distributes
the slack into EQUAL gaps including the two end gaps — the triggers stay inside the band's
padding and the run reads as one evenly-spaced group. Equal spacing is literally the ask.
★ THE OVERFLOW GUARD IS PRESERVED, and it is why the fallback chain is three deep rather than
one line. On an OVERFLOWING scroll container every distributed justify-content value has the
same defect as plain `center`: it pushes the first item to a negative offset that a scroller
can never reach (it cannot scroll left of zero), permanently clipping the first dropdown.
`safe` degrades exactly that case to flex-start. So:
1. `space-evenly` — engines without `safe`: distributed, matching prior centring risk.
2. `safe space-evenly` — distributed while it fits, flex-start (fully scrollable) once not.
The studio host was measured overflowing once already (right gutter -40), so this is a live
case, not a hypothetical — do not simplify this to a single unguarded value. */
.sfh__filters-row { .sfh__filters-row {
display: flex; flex-wrap: nowrap; align-items: center; gap: var(--dx-filter-gap, 10px); display: flex; flex-wrap: nowrap; align-items: center; gap: var(--dx-filter-gap, 10px);
justify-content: center; justify-content: safe center; justify-content: space-evenly; justify-content: safe space-evenly;
overflow-x: auto; padding-bottom: 2px; overflow-x: auto; padding-bottom: 2px;
scrollbar-width: thin; -webkit-overflow-scrolling: touch; scrollbar-width: thin; -webkit-overflow-scrolling: touch;
} }
@ -339,9 +369,14 @@ function onClearAll() { emit('clear-all') }
scroller; Clear All has since moved INSIDE, per its own card) — it is kept because a scroll box scroller; Clear All has since moved INSIDE, per its own card) — it is kept because a scroll box
cannot also be the thing that centres its overflowing content, and because overflow here would cannot also be the thing that centres its overflowing content, and because overflow here would
re-eat the fixed FilterDropdown menu. Do not put overflow back on this element. */ re-eat the fixed FilterDropdown menu. Do not put overflow back on this element. */
/* ★ 18px -> 16px, AND THE CHIP MOVES WITH IT (the two values are equal BY CONSTRUCTION — see the
.sfh__chip note; a floor below the chip's own box stops being a floor and the chip drives the
height instead, which is the layout shift this row exists to prevent). 16px is the floor for a
0.75rem/12px glyph in a border-box chip with 1px borders: 12 + 2 border + 2 leading. Going
below it would clip the descenders rather than slim the row. */
.sfh__chips { display: flex; flex-wrap: nowrap; gap: 6px; align-items: center; .sfh__chips { display: flex; flex-wrap: nowrap; gap: 6px; align-items: center;
justify-content: center; justify-content: center;
min-height: var(--dx-sel-row-h, 18px); } min-height: var(--dx-sel-row-h, 16px); }
/* ★ THE ONLY SCROLLING ZONE. min-width:0 is REQUIRED, not decorative: a flex item defaults to /* ★ THE ONLY SCROLLING ZONE. min-width:0 is REQUIRED, not decorative: a flex item defaults to
min-width:auto, which floors it at its CONTENT width, so the strip would refuse to shrink and min-width:auto, which floors it at its CONTENT width, so the strip would refuse to shrink and
would widen the row instead of scrolling inside itself. would widen the row instead of scrolling inside itself.
@ -377,7 +412,7 @@ function onClearAll() { emit('clear-all') }
/* The empty state. Muted rather than hidden: "World" is real information (nothing is filtered), and /* The empty state. Muted rather than hidden: "World" is real information (nothing is filtered), and
rendering it keeps the row's content box occupied so the height cannot collapse. Tracks the chip rendering it keeps the row's content box occupied so the height cannot collapse. Tracks the chip
type size so the empty and full states read at the same weight. */ type size so the empty and full states read at the same weight. */
.sfh__sel-world { flex: 0 0 auto; font-size: 0.8125rem; color: var(--mut, #6d766e); } .sfh__sel-world { flex: 0 0 auto; font-size: 0.75rem; color: var(--mut, #6d766e); }
/* ★ SOURCE 3 OF THE THREE HEIGHT SOURCES — "the size of the buttons that show up whenever you /* ★ SOURCE 3 OF THE THREE HEIGHT SOURCES — "the size of the buttons that show up whenever you
select something." 22px -> 18px, and the type comes DOWN with it (0.9375rem -> 0.8125rem). select something." 22px -> 18px, and the type comes DOWN with it (0.9375rem -> 0.8125rem).
NOTE THIS REVERSES AN EARLIER, CORRECTLY-FOLLOWED INSTRUCTION. A previous round was told NOTE THIS REVERSES AN EARLIER, CORRECTLY-FOLLOWED INSTRUCTION. A previous round was told
@ -395,8 +430,8 @@ function onClearAll() { emit('clear-all') }
flex: 0 0 auto; display: inline-flex; gap: 4px; align-items: center; flex: 0 0 auto; display: inline-flex; gap: 4px; align-items: center;
background-color: var(--dx-accent-weak, rgba(146, 177, 149, .18)); background-color: var(--dx-accent-weak, rgba(146, 177, 149, .18));
color: var(--ink); border: 1px solid var(--line); border-radius: 100px; color: var(--ink); border: 1px solid var(--line); border-radius: 100px;
padding: 0 8px; font-size: 0.8125rem; line-height: 1; white-space: nowrap; padding: 0 8px; font-size: 0.75rem; line-height: 1; white-space: nowrap;
height: var(--dx-sel-row-h, 18px); box-sizing: border-box; height: var(--dx-sel-row-h, 16px); box-sizing: border-box;
} }
.sfh__chip-x { background: none; border: none; cursor: pointer; font-size: 1.05em; line-height: 1; color: inherit; padding: 0; display: inline-flex; align-items: center; } .sfh__chip-x { background: none; border: none; cursor: pointer; font-size: 1.05em; line-height: 1; color: inherit; padding: 0; display: inline-flex; align-items: center; }
.sfh__chip-x:hover { opacity: 0.7; } .sfh__chip-x:hover { opacity: 0.7; }
@ -412,7 +447,7 @@ function onClearAll() { emit('clear-all') }
out-shouting them. */ out-shouting them. */
.sfh__clear { .sfh__clear {
flex: 0 0 auto; white-space: nowrap; background: none; border: none; cursor: pointer; flex: 0 0 auto; white-space: nowrap; background: none; border: none; cursor: pointer;
font-size: 0.8125rem; line-height: 1; color: var(--mut, #6d766e); text-decoration: underline; font-size: 0.75rem; line-height: 1; color: var(--mut, #6d766e); text-decoration: underline;
text-decoration-color: var(--brand); text-underline-offset: 3px; text-decoration-color: var(--brand); text-underline-offset: 3px;
padding: 0 4px; margin-inline-start: 2px; padding: 0 4px; margin-inline-start: 2px;
} }

View file

@ -34,6 +34,11 @@
*/ */
import { ref, shallowRef, onMounted, onBeforeUnmount, watch, computed } from 'vue' import { ref, shallowRef, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import { useShadowStyles } from '../../composables/useShadowStyles.js' import { useShadowStyles } from '../../composables/useShadowStyles.js'
// A fixed;inset:0 scrim covers only the CONTENT PANE inside the cap-5 shell (contain:layout on
// .vtl-content captures the fixed containing block). Measured here: 1024x793 @ (256,107) at a
// 1280x900 viewport — the 256px rail stayed clickable behind a "modal". See the util for the
// measurements and for why 100vw/100vh and <Teleport to="body"> both fail.
import { useViewportScrim } from '../../utils/viewportScrim.js'
const props = defineProps({ const props = defineProps({
// A { meta, columns, rows } document. // A { meta, columns, rows } document.
@ -57,6 +62,8 @@ const emit = defineEmits(['row-click'])
// columns, and every row opens a detail sheet carrying the full record. // columns, and every row opens a detail sheet carrying the full record.
const isMobile = ref(false) const isMobile = ref(false)
const detail = ref(null) const detail = ref(null)
// Geometry for the row-detail scrim; the .dt-scrim class still carries look and z-index.
const { scrimRef, scrimStyle } = useViewportScrim(detail)
const host = ref(null) const host = ref(null)
const tab = shallowRef(null) const tab = shallowRef(null)
@ -202,6 +209,10 @@ useShadowStyles(
'.is-dark .dt-caveats{background:rgba(255,255,255,.06)}' + '.is-dark .dt-caveats{background:rgba(255,255,255,.06)}' +
'.dt-cav-k{font-weight:700;text-transform:capitalize}' + '.dt-cav-k{font-weight:700;text-transform:capitalize}' +
// Row detail — bottom sheet on a phone, centred panel on desktop. // Row detail — bottom sheet on a phone, centred panel on desktop.
// GEOMETRY IS SET INLINE by useViewportScrim (top/left/width/height override the inset:0 here):
// inside the cap-5 shell, contain:layout on .vtl-content makes THIS element's fixed coordinates
// pane-relative, so inset:0 dimmed 1024x793 @ (256,107) and left the rail clickable. The
// declarations below are the LOOK only; do not re-add geometry here.
'.dt-scrim{position:fixed;inset:0;z-index:1200;background:rgba(8,12,16,.55);display:flex;' + '.dt-scrim{position:fixed;inset:0;z-index:1200;background:rgba(8,12,16,.55);display:flex;' +
'align-items:flex-end;justify-content:center}' + 'align-items:flex-end;justify-content:center}' +
'@media(min-width:700px){.dt-scrim{align-items:center}}' + '@media(min-width:700px){.dt-scrim{align-items:center}}' +
@ -244,7 +255,7 @@ useShadowStyles(
<!-- ROW DETAIL — a bottom sheet on mobile, a panel on desktop. Carries EVERY <!-- ROW DETAIL — a bottom sheet on mobile, a panel on desktop. Carries EVERY
field, including the ones the list view has to hide or flatten. --> field, including the ones the list view has to hide or flatten. -->
<div v-if="detail" class="dt-scrim" @click.self="detail = null"> <div v-if="detail" ref="scrimRef" :style="scrimStyle" class="dt-scrim" @click.self="detail = null">
<div class="dt-sheet" role="dialog" aria-label="Row detail"> <div class="dt-sheet" role="dialog" aria-label="Row detail">
<div class="dt-sheet-head"> <div class="dt-sheet-head">
<strong>{{ detail[(table?.columns || [])[0]?.field] || 'Detail' }}</strong> <strong>{{ detail[(table?.columns || [])[0]?.field] || 'Detail' }}</strong>

View file

@ -0,0 +1,140 @@
/**
* viewportScrim.js — make a `position:fixed` full-screen SCRIM actually cover the VIEWPORT,
* in every host, including one that has captured the fixed containing block.
*
* ── THE DEFECT ────────────────────────────────────────────────────────────────────────────────
* A modal scrim is written `position:fixed; inset:0` on the assumption that fixed coordinates are
* viewport coordinates. That holds only while NOTHING in the ancestor chain establishes a
* containing block for fixed descendants. VerticalTabsLayout — the cap-5 shell every plugin is
* hosted in as a tab — sets `contain:layout` on .vtl-content, so inside the studio the SAME scrim
* stretches to the CONTENT PANE instead. MEASURED 2026-09-22 at a 1280x900 viewport, probe
* inserted into each component's real parent chain in the running studio:
*
* upg poster-wall .modal-scrim ......... 1024x793 @ (256,107)
* PosterWall .modal-scrim .............. 1024x793 @ (256,107)
* DataTable .dt-scrim .................. 1024x793 @ (256,107)
* SemanticTreeLegend / LegendTools ..... 1024x793 @ (256,107)
* ...all captured by: div.vtl-content [contain:layout]
*
* 256 is the rail width and 107 the header height. The scrim still LOOKS like a modal — it dims
* the pane it sits in — while leaving the rail and header UNCOVERED AND CLICKABLE behind it. That
* is why this reads as cosmetic and is not: a modal that does not capture input is not modal.
*
* ── WHY NOT THE OBVIOUS FIXES ─────────────────────────────────────────────────────────────────
* ✗ Drop `contain` from the layout. It is load-bearing for a different reason (it stops a rail
* collapse from reflowing a heavy mounted map) and removing it trades this bug for that one.
* Identical reasoning to FilterDropdown.fixedOrigin() — the fix belongs where the ASSUMPTION
* lives, not where the containment does.
* ✗ width:100vw; height:100vh. MEASURED: fixes the SIZE but NOT the offset — the box still starts
* at (256,64), so it overflows off-screen right and bottom while the rail stays uncovered. A
* viewport-sized scrim in the wrong place is still the wrong scrim. (And the size is not even
* reliable: in one pane those units measured 1324x944 inside a 1280x900 window — see below.)
* ✗ <Teleport to="body">. These components render INSIDE SHADOW ROOTS (measured: .dt-wrap's root
* is a ShadowRoot whose host is <cities-internet-map>). Teleporting to document.body leaves the
* shadow-scoped CSS behind and the modal renders UNSTYLED.
*
* ── THE FIX ───────────────────────────────────────────────────────────────────────────────────
* Do not ASSUME the containing block is the viewport — MEASURE it, exactly as fixedOrigin() does,
* then subtract it and size to the viewport. Verified to produce 0,0 1280x900 inside the captured
* pane. Correct in EVERY host, including hosts that do not exist yet: when nothing captures, the
* measured origin is (0,0) and the style collapses to plain inset:0.
*
* ── SHADOW-DOM NOTE (a false reassurance this replaces) ───────────────────────────────────────
* poster-wall.vue carried a comment saying "position:fixed inside a shadow root is still
* viewport-relative, so this covers the whole app". The premise is TRUE and IRRELEVANT: a shadow
* boundary scopes STYLES and SELECTORS, it does not create a containing block. What creates one is
* transform / filter / perspective / backdrop-filter / will-change of those — or contain:layout|
* paint|strict|content on an ancestor, which is exactly what the shell sets. Reasoning about the
* shadow boundary answers a question nobody asked and hides the one that matters.
*
* USAGE (in any component that owns a fixed full-screen scrim):
* import { useViewportScrim } from '<path>/library/utils/viewportScrim.js'
* const { scrimRef, scrimStyle } = useViewportScrim(someOpenRef)
* <div v-if="open" ref="scrimRef" :style="scrimStyle" class="modal-scrim">
* Keep the class: it still carries background, z-index, display/centring. Only the GEOMETRY moves
* into the inline style, because only the geometry depends on the host.
*/
import { ref, shallowRef, watch, onBeforeUnmount } from 'vue'
/**
* Measure the origin a `position:fixed` box inside `el` is laid out from.
*
* Reads the offset from a probe that is ITSELF position:fixed at 0,0: whatever it reports as its
* client rect IS the origin this element's fixed coordinates will be measured from. offsetParent
* walking is unreliable through shadow roots, so we measure rather than traverse.
*
* The probe MUST be inserted into the same parent as the real scrim so it shares the scrim's
* ancestor chain, and therefore its containing block. A probe appended to document.body would
* report the viewport even when the scrim is captured — confirming the assumption instead of
* testing it.
*/
export function measureFixedOrigin(el) {
const parent = el?.parentElement || el
if (!parent || !parent.ownerDocument) return { x: 0, y: 0 }
const probe = parent.ownerDocument.createElement('div')
probe.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;pointer-events:none;opacity:0'
parent.appendChild(probe)
const r = probe.getBoundingClientRect()
probe.remove()
return { x: r.left, y: r.top }
}
/**
* The geometry half of a full-viewport scrim, as an inline style object.
* Re-measured whenever the scrim opens and on resize — the rail can collapse while a modal is up,
* which moves the pane origin without remounting anything.
*
* @param {import('vue').Ref} openRef truthy while the scrim is rendered
* @returns {{ scrimRef: import('vue').Ref, scrimStyle: import('vue').Ref<object> }}
*/
export function useViewportScrim(openRef) {
const scrimRef = ref(null)
const scrimStyle = shallowRef({ position: 'fixed', inset: '0' })
const remeasure = () => {
const el = scrimRef.value
if (!el) return
const o = measureFixedOrigin(el)
// ★ NO `inset` KEY HERE. `inset` is a SHORTHAND for top/right/bottom/left; listing it beside
// the longhands in one style object lets it win on serialization order and SILENTLY DROP them.
// Measured exactly that: the element rendered `position:fixed; inset:auto; width:100vw;
// height:100vh` — right size (1280x900), wrong place (287,895), because top/left never made it
// into the inline style. Setting right/bottom to auto expresses "anchor by top/left only"
// without ever naming the shorthand.
// No capture (o is 0,0): this collapses to a box at 0,0 sized to the viewport — i.e. exactly
// what inset:0 meant in the first place.
// ★ PIXELS FROM A MEASUREMENT, NOT vw/vh. 100vw/100vh are not reliably "the viewport" here:
// in the upg poster-wall pane the same declarations produced a rect of 1324x944 inside a
// 1280x900 window (computed width WAS 1280px, so the units resolved against a different
// viewport, not a transform — the ancestor chain had none). documentElement.clientWidth/
// Height is the actual laid-out viewport box and matched the window in every pane measured.
const doc = el.ownerDocument || (typeof document !== 'undefined' ? document : null)
const vw = doc?.documentElement?.clientWidth || 0
const vh = doc?.documentElement?.clientHeight || 0
scrimStyle.value = {
position: 'fixed',
top: `${-o.y}px`,
left: `${-o.x}px`,
right: 'auto',
bottom: 'auto',
width: `${vw}px`,
height: `${vh}px`,
}
}
// Measure AFTER the scrim is in the DOM — its own ancestors are what we are measuring.
// TWO frames, not one: PosterDialog renders its backdrop inside a <transition>, so on the first
// frame the element can be present but not yet laid out at its final place. Measuring once gave
// a stale origin there. The second pass is idempotent when the first was already right, so this
// costs nothing in the common case and removes a per-component timing assumption.
watch(openRef, (v) => {
if (!v) return
requestAnimationFrame(() => { remeasure(); requestAnimationFrame(remeasure) })
}, { flush: 'post' })
const onResize = () => { if (openRef.value) remeasure() }
if (typeof window !== 'undefined') window.addEventListener('resize', onResize)
onBeforeUnmount(() => { if (typeof window !== 'undefined') window.removeEventListener('resize', onResize) })
return { scrimRef, scrimStyle }
}

View file

@ -48,7 +48,9 @@
"check:dxlife": "node internal/check-dxlife-untouched.mjs", "check:dxlife": "node internal/check-dxlife-untouched.mjs",
"check:facet-cascade": "node internal/check-facet-cascade.mjs # a child facet selection must not outlive its parent: clearing a parent facet must cascade-clear its descendants on EVERY clear path. Asserts on RESULT COUNTS, not DOM — an orphaned child silently ANDs the map to empty. run check:facet-cascade:self-test for the NEGATIVE test (reverts the cascade and requires this gate to FAIL).", "check:facet-cascade": "node internal/check-facet-cascade.mjs # a child facet selection must not outlive its parent: clearing a parent facet must cascade-clear its descendants on EVERY clear path. Asserts on RESULT COUNTS, not DOM — an orphaned child silently ANDs the map to empty. run check:facet-cascade:self-test for the NEGATIVE test (reverts the cascade and requires this gate to FAIL).",
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test", "check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
"build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", "check:facet-one-field": "node internal/check-facet-one-field.mjs # ONE AXIS = ONE CONTROL-REACHABLE FACET. Asserts on the PARTITION each facet induces over the rows, NOT on which r.<field> it reads: three fields (religion, primary_language, rop1) are legitimately read by two facets each as tier-1/tier-2 pairs, so a gate on duplicate field READS would fail correct code. Scoped to facets a user can actually select (rail + dropdowns + charts), because a search-only projection sharing a partition is fine until it becomes a control. run check:facet-one-field:self-test for the NEGATIVE test.",
"check:facet-one-field:self-test": "node internal/check-facet-one-field.mjs --self-test",
"build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:facet-one-field && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",
"check:template-literal": "node internal/template-literal-check.mjs", "check:template-literal": "node internal/template-literal-check.mjs",
"verify:map-paint": "node internal/verify-map-paint.mjs", "verify:map-paint": "node internal/verify-map-paint.mjs",
"check:sri": "node internal/sri-consistency-check.mjs", "check:sri": "node internal/sri-consistency-check.mjs",

View file

@ -38,6 +38,8 @@
*/ */
import { computed, inject, ref, watch, onBeforeUnmount } from 'vue' import { computed, inject, ref, watch, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// Export-modal geometry — see the note at .modal-scrim below, and library/utils/viewportScrim.js.
import { useViewportScrim } from '@map/utils/viewportScrim.js'
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out. // Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
import { lists as clusterLists, asia_lists as clusterAsiaLists, uncovered_lists as clusterUncoveredLists } from './data/clusters.json' import { lists as clusterLists, asia_lists as clusterAsiaLists, uncovered_lists as clusterUncoveredLists } from './data/clusters.json'
const clusters = { lists: clusterLists, asia_lists: clusterAsiaLists, uncovered_lists: clusterUncoveredLists } const clusters = { lists: clusterLists, asia_lists: clusterAsiaLists, uncovered_lists: clusterUncoveredLists }
@ -245,6 +247,7 @@ async function renderFor(list, spec) {
const EXPORT_SPEC = { widthIn: 8.5, heightIn: 12.75, dpi: 150 } // single-canvas, phone-safe const EXPORT_SPEC = { widthIn: 8.5, heightIn: 12.75, dpi: 150 } // single-canvas, phone-safe
const exportState = ref(null) // { label, phase, message, pct, kind } const exportState = ref(null) // { label, phase, message, pct, kind }
const { scrimRef, scrimStyle } = useViewportScrim(exportState)
async function exportPoster(list) { async function exportPoster(list) {
if (exportState.value) return // one at a time if (exportState.value) return // one at a time
@ -455,9 +458,17 @@ useShadowStyles(
'border-top-color:rgba(0,0,0,.45);border-radius:50%;animation:upgspin .8s linear infinite}' + 'border-top-color:rgba(0,0,0,.45);border-radius:50%;animation:upgspin .8s linear infinite}' +
'.is-dark .spinner{border-color:rgba(255,255,255,.16);border-top-color:rgba(255,255,255,.55)}' + '.is-dark .spinner{border-color:rgba(255,255,255,.16);border-top-color:rgba(255,255,255,.55)}' +
'@keyframes upgspin{to{transform:rotate(360deg)}}' + '@keyframes upgspin{to{transform:rotate(360deg)}}' +
// EXPORT MODAL. position:fixed inside a shadow root is still viewport-relative, // EXPORT MODAL. The intent is right — the render blocks the user's intent, not merely this
// so this covers the whole app rather than just the pane — which is correct: the // pane — but the old reasoning here was FALSE and worth naming, because it reads as reassuring:
// render blocks the user's intent, not merely this pane. // it said "position:fixed inside a shadow root is still viewport-relative, so this covers the
// whole app". The premise is TRUE and IRRELEVANT. A shadow boundary scopes styles and
// selectors; it does NOT establish a containing block. contain:layout|paint on an ANCESTOR
// does — and VerticalTabsLayout sets exactly that on .vtl-content, so in the cap-5 shell this
// scrim covered the PANE only: MEASURED 1024x793 @ (256,107) at a 1280x900 viewport, leaving
// the 256px rail and 107px header uncovered and CLICKABLE behind a "modal".
// GEOMETRY IS NOW SET INLINE by useViewportScrim (it measures the real origin and subtracts it;
// verified 0,0 1280x900). The declarations below are the LOOK only — do not re-add geometry,
// and do not re-derive the shadow-root reassurance: it answers a question nobody asked.
'.modal-scrim{position:fixed;inset:0;z-index:1000;background:rgba(8,12,16,.55);' + '.modal-scrim{position:fixed;inset:0;z-index:1000;background:rgba(8,12,16,.55);' +
'display:grid;place-items:center;padding:22px}' + 'display:grid;place-items:center;padding:22px}' +
'.modal{width:min(340px,100%);background:#fff;color:#0f1419;border-radius:14px;' + '.modal{width:min(340px,100%);background:#fff;color:#0f1419;border-radius:14px;' +
@ -554,7 +565,7 @@ useShadowStyles(
A poster render spins up a hidden map, waits for tiles, then composites; A poster render spins up a hidden map, waits for tiles, then composites;
on a phone that is several seconds and an unlabelled spinner reads as a on a phone that is several seconds and an unlabelled spinner reads as a
hang. Phase + message come straight from useMapPoster's progress ref. --> hang. Phase + message come straight from useMapPoster's progress ref. -->
<div v-if="exportState" class="modal-scrim" @click.self="exportState.phase === 'error' && dismissExport()"> <div v-if="exportState" ref="scrimRef" :style="scrimStyle" class="modal-scrim" @click.self="exportState.phase === 'error' && dismissExport()">
<div class="modal" role="dialog" aria-live="polite"> <div class="modal" role="dialog" aria-live="polite">
<div class="modal-kind">{{ exportState.kind }}</div> <div class="modal-kind">{{ exportState.kind }}</div>
<div class="modal-title">{{ exportState.label }}</div> <div class="modal-title">{{ exportState.label }}</div>

View file

@ -59,8 +59,44 @@ const filtered = computed(() => {
return allRows.value.filter((r) => keys.every((k) => matchFacet(r, k, filterSet[k])) && matchSearch(r)) 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) ── /**
const sortState = ref([]) // [{ key, dir:'asc'|'desc' }] * ── multi-sort (shift-click stacks; per-column 3-state none->asc->desc->none) ──
*
* ★ SORT IS VIEW-LOCAL, AND THAT IS A DECISION, NOT AN OVERSIGHT
* (card BUG-TABLE-SORT-DOES-NOT-REACH-THE-GRID).
*
* The measured symptom was real: sorting Population asc/desc moved THIS table's first row
* Abai Sungai -> Kensiu -> Luwu while the grid's first card stayed Abai Sungai, with the filter
* chips constant throughout. The card asked the right question before proposing a channel —
* should sort be SHARED state at all? — and the answer comes from the contract, not from taste.
*
* IT IS NOT A SYMMETRIC DISAGREEMENT BETWEEN TWO PEERS. The grid is a SEPARATE BUNDLE
* (plugin-designs/people-groups-grid) composed beside this dashboard. It is a props/events
* component, not a bus subscriber: it takes `query` + `activeFilters` in and emits
* `groups-filtered` back out. It has NO sort control of its own and NO prop to receive one —
* its only .sort() call orders facet LABELS, never rows. So "share the sort" is not wiring up an
* existing capability that got disconnected; it is building a sort feature inside another bundle,
* adding a prop to its public surface, and adding a channel to useSelectionBus — three files, two
* of them owned by other authors, for a behaviour nobody has asked to see.
*
* FILTERS AND SEARCH ARE DIFFERENT, and the distinction is the reason this is not special
* pleading: a filter changes WHICH rows exist, so two views disagreeing about it means they are
* showing different data — a correctness bug. Sort changes only the ORDER of the same rows, so a
* table ranked by population beside a grid ranked as the API returned them is two presentations
* of one identical set. Nothing is hidden and no number disagrees.
*
* THE CARD'S REAL POINT STANDS THOUGH: today it is "neither", which is the worst of the three,
* because nothing tells the user which it is. The cure for `neither` is to make the local choice
* LEGIBLE rather than to plumb a channel — hence the `data-sort-scope="view-local"` marker on the
* grid and the header tooltip below, which say plainly that sorting orders THIS table. That way a
* user who sorts does not form an expectation about the grid that the product will not keep.
*
* IF A FUTURE DRIVER RULING WANTS SHARED SORT, it needs all three of: a sort channel on
* useSelectionBus, a `sort` prop on people-groups-grid, and a row comparator inside that grid.
* Do not add the bus channel alone — a channel with no subscriber is the "neither" state again,
* only now with more code.
*/
const sortState = ref([]) // [{ key, dir:'asc'|'desc' }] — LOCAL to this table by design (above)
function onSort(col, ev) { function onSort(col, ev) {
const shift = !!(ev && ev.shiftKey) const shift = !!(ev && ev.shiftKey)
const i = sortState.value.findIndex((s) => s.key === col.key) const i = sortState.value.findIndex((s) => s.key === col.key)
@ -521,7 +557,12 @@ useShadowStyles(`
</span> </span>
</div> </div>
<!-- data-sort-scope records the RULING (card BUG-TABLE-SORT-DOES-NOT-REACH-THE-GRID) in a form
a gate or a future reader can query: sort is deliberately view-local, not an unfinished
bus channel. The reasoning lives on `sortState` in <script>. -->
<div v-show="!collapsed" ref="scroller" class="rd-grid" role="grid" aria-multiselectable="true" <div v-show="!collapsed" ref="scroller" class="rd-grid" role="grid" aria-multiselectable="true"
data-sort-scope="view-local"
:data-sort-by="sortState.map((s) => s.key + ':' + s.dir).join(',')"
:aria-rowcount="visibleRows.length + 1" @scroll="onScroll" @click="onGridClick" @mousedown="onGridMouseDown"> :aria-rowcount="visibleRows.length + 1" @scroll="onScroll" @click="onGridClick" @mousedown="onGridMouseDown">
<div class="rd-thead" role="row"> <div class="rd-thead" role="row">
<div class="rd-th chk" style="cursor:pointer" :title="allVisibleSelected ? 'Deselect all ' + fmt(rowCount) : 'Select all ' + fmt(rowCount)" @click="toggleAll"> <div class="rd-th chk" style="cursor:pointer" :title="allVisibleSelected ? 'Deselect all ' + fmt(rowCount) : 'Select all ' + fmt(rowCount)" @click="toggleAll">
@ -532,8 +573,12 @@ useShadowStyles(`
</div> </div>
<!-- :style flex weight — see dashboard.config.js `columns[].w`. Header, body and FOOTER must <!-- :style flex weight — see dashboard.config.js `columns[].w`. Header, body and FOOTER must
all carry the same weight or the summary row drifts out of alignment with its columns. --> all carry the same weight or the summary row drifts out of alignment with its columns. -->
<!-- `title` states the SCOPE of the sort, because scope is the one thing a sort arrow
cannot show. Sorting orders THIS table only (see the sortState note in <script>);
saying so on the control is what turns a silent local choice into an honest one. -->
<div v-for="col in COLUMNS" :key="col.key" class="rd-th" :class="{ num: col.type === 'num' }" <div v-for="col in COLUMNS" :key="col.key" class="rd-th" :class="{ num: col.type === 'num' }"
:style="colFlex(col)" :style="colFlex(col)"
:title="`Sort this table by ${col.label} — shift-click to add a second column. Sorting orders the table only; the cards keep their own order.`"
role="columnheader" :aria-sort="ariaSort(col)" :data-col="col.key" @click="onSort(col, $event)"> role="columnheader" :aria-sort="ariaSort(col)" :data-col="col.key" @click="onSort(col, $event)">
{{ col.label }}<span class="ind">{{ ariaSort(col) === 'ascending' ? '▲' : ariaSort(col) === 'descending' ? '▼' : '' }}</span> {{ col.label }}<span class="ind">{{ ariaSort(col) === 'ascending' ? '▲' : ariaSort(col) === 'descending' ? '▼' : '' }}</span>
</div> </div>

View file

@ -454,8 +454,51 @@ useShadowStyles(`
.fr-row.on .fr-trunk-tee::after, .fr-row.on .fr-trunk-tee::after,
.fr-row.on .fr-trunk-elbow::before, .fr-row.on .fr-trunk-elbow::before,
.fr-row.on .fr-trunk-elbow::after{ background:var(--rd-primary,#92b195); } .fr-row.on .fr-trunk-elbow::after{ background:var(--rd-primary,#92b195); }
/* ── SELECTION EASES IN, IT DOES NOT SNAP (card BUG-TREE-SELECTION-SNAPS-NO-TRANSITION) ────
DRIVER: the tree selection "snaps" — it arrives in one frame with no sense of movement.
MEASURED CAUSE, and it was NOT the one the card guessed. The card's lead was that
keepZeroRows rebuilds the row array and Vue recreates the nodes, leaving a CSS transition
with nothing to animate from. That is not what happens: the template keys each row on
r.facet + ':' + r.value (see the v-for below), which is the row's IDENTITY pair and is
preserved verbatim by keepZeroRows — so a surviving row is PATCHED IN PLACE across a
filter change, not recreated. The keys were already stable; nothing needed fixing there.
The real cause is simply that NOTHING on the selected state was transitioned. Before this
rule the only three transitions in the rail were the two chevron rotations and .fr-bar i's
width. Gaining .on therefore changed four things in a single frame — background,
font-weight, the ::before accent rail (0 -> full height) and the trunk colours — which is
exactly what "snap" describes.
WHAT IS ANIMATED, AND WHAT IS DELIBERATELY NOT:
background / color / trunk background — eased. Paint-only, so 60+ rows cost no layout.
the ::before accent rail — eased via transform:scaleX, NOT width. The rail
is always present at full width and merely
scales from 0, so it composites instead of
re-laying-out the row on every frame.
font-weight — NOT transitioned, on purpose. It is not an
animatable property, and because .fr-lbl WRAPS
(see its note below) a weight change alters text
metrics and can re-wrap the label mid-animation.
Easing it is impossible; easing AROUND it is what
keeps the row from jolting.
140ms/ease-out: long enough to read as movement, short enough that a fast drill through
three tiers never feels laggy. prefers-reduced-motion drops it to 0 (rule below). */
.fr-row{ transition:background .14s ease-out, color .14s ease-out; }
.fr-row .fr-trunk-pipe::before, .fr-row .fr-trunk-tee::before, .fr-row .fr-trunk-tee::after,
.fr-row .fr-trunk-elbow::before, .fr-row .fr-trunk-elbow::after{ transition:background .14s ease-out; }
.fr-row.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink, #3b463d); } .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; } /* The accent rail is rendered on EVERY filterable row and scaled to 0 — so becoming active
scales it up rather than creating it. A ::before that only exists under .on would have no
start state to animate from (the same "nothing to animate" failure the card warned about,
arriving by a different route). Inert rows are excluded: they can never be active, and a
zero-width invisible pseudo-element on 40+ context rows is pure waste. */
.fr-row:not(.inert)::before{ content:''; position:absolute; left:0; top:2px; bottom:2px; width:3px;
background:var(--rd-accent,#e67e22); border-radius:0 2px 2px 0;
transform:scaleX(0); transform-origin:left center; transition:transform .14s ease-out; }
.fr-row.on::before{ transform:scaleX(1); }
@media (prefers-reduced-motion: reduce){
.fr-row, .fr-row:not(.inert)::before,
.fr-row .fr-trunk-pipe::before, .fr-row .fr-trunk-tee::before, .fr-row .fr-trunk-tee::after,
.fr-row .fr-trunk-elbow::before, .fr-row .fr-trunk-elbow::after{ transition:none; }
}
/* The whole row is the FILTER target now, so it gets the affordances of a control: a pointer, /* The whole row is the FILTER target now, so it gets the affordances of a control: a pointer,
a hover wash and a visible focus ring. */ a hover wash and a visible focus ring. */
.fr-row:focus-visible{ outline:2px solid var(--rd-primary,#92b195); outline-offset:-2px; } .fr-row:focus-visible{ outline:2px solid var(--rd-primary,#92b195); outline-offset:-2px; }
@ -501,7 +544,28 @@ useShadowStyles(`
overflow-wrap:anywhere (not break-all) breaks only when a word genuinely cannot fit, so overflow-wrap:anywhere (not break-all) breaks only when a word genuinely cannot fit, so
ordinary labels still break at spaces. The trunk glyphs stay aligned because .fr-trunk is ordinary labels still break at spaces. The trunk glyphs stay aligned because .fr-trunk is
align-self:stretch — it grows with the row instead of assuming one text line. */ align-self:stretch — it grows with the row instead of assuming one text line. */
.fr-lbl{ flex:1 1 auto; min-width:0; white-space:normal; overflow-wrap:anywhere; line-height:1.3; margin-left:6px; } /* ── ONE LINE PER ROW (card BUILD-TREE-LEGEND-ONE-LINE-ROWS-NARROWER-PROGRESS-BARS) ────────
DRIVER: the tree legend rows should each be ONE line, with narrower progress bars.
THIS CARD AND THE WRAP NOTE ABOVE PULL IN OPPOSITE DIRECTIONS, and both Driver reports are
real, so the resolution has to satisfy BOTH rather than simply reverting the earlier one:
- the wrap rule exists because nowrap+ellipsis ATE THE COUNT ("I can't even scroll right
to see the numbers"). Any return to a plain ellipsis re-breaks that, because .fr-body is
overflow-x:hidden by design and there is no way to scroll the number back into view.
- this card wants the scannable one-line rhythm back.
SO: clamp to ONE LINE, but buy the width for it instead of taking it out of the label.
line-clamp:1 (with the -webkit- prefixed pair, which is what actually ships in Chromium)
bounds the label to a single line and ellipsises ONLY the label — .fr-cnt is flex:0 0 auto
and sits OUTSIDE this box, so unlike the old white-space:nowrap on a competing flex child
the count can never be pushed out of the row. That is the specific failure being avoided:
the count stays on screen at every width, which was the whole point of the wrap fix.
The width to afford it comes from the BAR (34px -> 18px below) and the tightened gutters —
NOT from the font, which the Driver explicitly rejected shrinking ("super tiny text that
cannot be read"). overflow-wrap:anywhere is kept so a single unbreakable token still yields
rather than overflowing the rail.
min-height on .fr-row stays: it is now an exact one-line height rather than a floor. */
.fr-lbl{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis;
display:-webkit-box; -webkit-line-clamp:1; line-clamp:1; -webkit-box-orient:vertical;
overflow-wrap:anywhere; line-height:1.3; margin-left:6px; }
/* The bar YIELDS to the label now that the label wraps (flex:1 1 auto above). Previously both /* The bar YIELDS to the label now that the label wraps (flex:1 1 auto above). Previously both
were flex:1 1 auto and the bar's 24px floor competed with the text for the same row; with a were flex:1 1 auto and the bar's 24px floor competed with the text for the same row; with a
wrapping label the bar must be the element that gives way, so it is capped and allowed to wrapping label the bar must be the element that gives way, so it is capped and allowed to
@ -517,11 +581,22 @@ useShadowStyles(`
The bar is kept (not deleted) because the at-a-glance MAGNITUDE ranking is genuinely useful The bar is kept (not deleted) because the at-a-glance MAGNITUDE ranking is genuinely useful
when scanning; 34px still resolves ~10 distinguishable steps, which is enough to rank rows. when scanning; 34px still resolves ~10 distinguishable steps, which is enough to rank rows.
Do NOT solve a future width squeeze by shrinking the FONT — the Driver explicitly rejected Do NOT solve a future width squeeze by shrinking the FONT — the Driver explicitly rejected
"super tiny text that cannot be read". Shrink this bar further first. */ "super tiny text that cannot be read". Shrink this bar further first.
.fr-bar{ flex:0 1 34px; height:6px; min-width:0; background:var(--dx-surface, #f3f3f1); border-radius:3px; overflow:hidden; margin-left:8px; } ── 34px -> 18px, AND THE NOTE ABOVE IS THE REASON ────────────────────────────────────────
Driver, again, on this card: "narrower progress bars". The previous author left the exact
instruction for this situation one line up — when the rail needs width, the BAR yields
before the type does — so this takes it, rather than touching the font size.
Those 16px per row are what pay for the one-line clamp on .fr-lbl: the label's budget goes
UP by 16px at the same time its height budget drops to a single line, so the clamp bites on
far fewer rows than it would have at 34px. The two halves of this card are one change.
18px still reads as a magnitude (roughly 6 distinguishable steps at this height) and the
EXACT number is on the same row in .fr-cnt, which is why degrading the bar's precision is
acceptable where degrading the label's legibility is not. The height also drops 6px -> 4px
so a shorter bar does not read as a fat stub. */
.fr-bar{ flex:0 1 18px; height:4px; min-width:0; background:var(--dx-surface, #f3f3f1); border-radius:2px; overflow:hidden; margin-left:8px; }
/* magnitude fill — a QUANTITY bar, not a categorical series, so it takes the sage accent /* 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). */ 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-bar i{ display:block; height:100%; background:var(--dx-accent, #92b195); border-radius:2px; transition:width .3s; }
.fr-row.on .fr-bar i{ background:var(--rd-accent,#e67e22); } .fr-row.on .fr-bar i{ background:var(--rd-accent,#e67e22); }
.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; } .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') `, 'rd-facetrail')

View file

@ -38,6 +38,7 @@
import { inject, computed } from 'vue' import { inject, computed } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import SearchFilterHeader from '@map/components/search/SearchFilterHeader.vue' import SearchFilterHeader from '@map/components/search/SearchFilterHeader.vue'
import { getPrayerLevel, LABELS as PRAYER_LABELS } from '@map/colors/prayer-progress.js'
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js' import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
const bus = inject('selectionBus', null) const bus = inject('selectionBus', null)
@ -47,13 +48,61 @@ const cfg = inject('dashboardConfig', {})
const rows = computed(() => dash?.rows?.value || []) const rows = computed(() => dash?.rows?.value || [])
const filterSet = bus?.filterSet || {} const filterSet = bus?.filterSet || {}
// ── ★ THE PRAYER AXIS (card BUILD-ADD-A-PRAYER-FILTER-FIRST-IN-THE-ROW) ──
// DRIVER: "we want to add a new filter called prayer… it should probably be the first drop down."
// He described it once as a toggle and once as a dropdown, and the card makes the ordering explicit:
// MEASURE THE DATA FIRST, then pick the control — boolean is a toggle, levels are a dropdown.
//
// THE MEASUREMENT, and it did not need inventing: `people_praying` is already a LIVE per-row field
// on the pray-tools source. Three shipping consumers read it — the KPI strip sums it
// (dashboard.config.js `{ kind:'sum', field:'people_praying' }`), the table renders it as the
// "Praying" column (`num(r.people_praying)`), and the detail drawer prints it. So the honest field
// the card asked us to find exists, and no derived boolean had to be manufactured.
//
// It is NOT a boolean: it is a COUNT, and the app already splits that count THREE ways —
// library/colors/prayer-progress.js, the prayer map's colour strategy: none (0/null) / partial
// (0 < n < 144) / full (n >= 144). Three levels => a DROPDOWN, which is also where the Driver
// landed. We reuse getPrayerLevel + its LABELS rather than re-deriving the split here, so the
// filter can never disagree with the colour of the pin it filters to — one threshold, one owner.
//
// ── WHY THIS FACET IS DEFINED HERE AND NOT IN dashboard.config.js ──
// Every other axis lives in the config's `facets` block, and that is where this one belongs long
// term. It is declared in this adapter instead because dashboard.config.js is owned by another
// agent in this session and concurrent edits to it were out of scope for this card. The shape is
// IDENTICAL to a config facet ({ label, get }), and `FACETS` below merges config-first, so moving
// this block into the config later is a cut-and-paste that changes no behaviour and needs no edit
// here: a key present in cfg.facets wins over this local default.
const PRAYER_KEY = 'prayer_level'
const prayerFacet = {
label: 'Prayer',
// Returns the human LABEL, which is what the bus stores and byte-matches — same value-is-label
// contract every non-People axis follows, so a pick lands a matching filterSet entry with no
// separate display projection needed.
get: (r) => {
const n = r?.people_praying
const raw = n && typeof n === 'object' ? n.value : n
// getPrayerLevel reads `peoplePraying` (camel) or `_raw.people_praying`; these rows carry the
// flat snake field, so hand it the shape it documents rather than hoping the fallback fires.
return PRAYER_LABELS[getPrayerLevel({ peoplePraying: raw ?? null })]
},
}
// facet key -> accessor (returns the exact value the bus matches on — a label for most axes, // facet key -> accessor (returns the exact value the bus matches on — a label for most axes,
// a CODE for the People tiers; see optionsFor's note on value-vs-display). // a CODE for the People tiers; see optionsFor's note on value-vs-display).
const ACC = computed(() => Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get]))) // The local prayer facet is a DEFAULT: cfg.facets wins on a key collision, so promoting it into
// the config later silently takes over.
const FACETS = computed(() => ({ [PRAYER_KEY]: prayerFacet, ...(cfg.facets || {}) }))
const ACC = computed(() => Object.fromEntries(Object.entries(FACETS.value).map(([k, f]) => [k, f.get])))
// Which dropdown axes to render (config-driven; DX-life default five mapped to dashboard facet keys). // Which dropdown axes to render (config-driven; DX-life default five mapped to dashboard facet keys).
// ★ PRAYER IS FORCED FIRST — "it should probably be the first drop down." It is prepended to
// whatever the config lists (and de-duplicated, so promoting it into cfg.researchFilters.dropdowns
// later does not render it twice — it just stops being prepended and keeps its position).
const rf = computed(() => cfg.researchFilters || {}) const rf = computed(() => cfg.researchFilters || {})
const dropdownKeys = computed(() => rf.value.dropdowns || ['wagf_region', 'wagf_block', 'country_code', 'people_group', 'religion']) const dropdownKeys = computed(() => {
const configured = rf.value.dropdowns || ['wagf_region', 'wagf_block', 'country_code', 'people_group', 'religion']
return [PRAYER_KEY, ...configured.filter((k) => k !== PRAYER_KEY)]
})
const searchPlaceholder = computed(() => rf.value.searchPlaceholder || 'Search people groups, place, language, 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. // Apply a filter-set (facetKey -> label) to a row list — reused for cross-filtered counts.
@ -78,7 +127,7 @@ function applyWith(fset, list) {
function optionsFor(key) { function optionsFor(key) {
const acc = ACC.value[key] const acc = ACC.value[key]
if (!acc) return [] if (!acc) return []
const optLabel = cfg.facets?.[key]?.optionLabel const optLabel = FACETS.value[key]?.optionLabel
const others = {} const others = {}
for (const [k, v] of Object.entries(filterSet)) if (k !== key) others[k] = v for (const [k, v] of Object.entries(filterSet)) if (k !== key) others[k] = v
const subset = applyWith(others, rows.value) const subset = applyWith(others, rows.value)
@ -100,7 +149,7 @@ function optionsFor(key) {
// The `filters` prop the shared header renders (ONE object per dropdown). // The `filters` prop the shared header renders (ONE object per dropdown).
const filters = computed(() => dropdownKeys.value.map((key) => ({ const filters = computed(() => dropdownKeys.value.map((key) => ({
name: key, name: key,
label: cfg.facets?.[key]?.label || key, label: FACETS.value[key]?.label || key,
options: optionsFor(key), options: optionsFor(key),
value: filterSet[key] || '', value: filterSet[key] || '',
}))) })))
@ -111,9 +160,16 @@ const filters = computed(() => dropdownKeys.value.map((key) => ({
// bus, and those chips rendered raw ('Affinity Bloc: A012'). labelFor() resolves them the same way // 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 // the filter chips do. DISPLAY ONLY: `name` is what onRemoveChip acts on and the stored value is
// never rewritten. // never rewritten.
// ★ PRAYER IS EXEMPT FROM labelFor. labelFor resolves a CODE to a human name by looking the value
// up in the tree-legend's code tables; the prayer value is ALREADY the human label
// ('Needs Prayer'), so passing it through a code resolver can only fail to find it and fall back —
// at best a no-op, at worst a blank chip. The value-is-label axes need no resolution by definition.
const { labelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject('searchSourceId', null)) const { labelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject('searchSourceId', null))
const activeChips = computed(() => const activeChips = computed(() =>
Object.keys(filterSet).map((k) => ({ name: k, label: `${cfg.facets?.[k]?.label || k}: ${labelFor(k, filterSet[k])}` })) Object.keys(filterSet).map((k) => {
const v = filterSet[k]
return { name: k, label: `${FACETS.value[k]?.label || k}: ${k === PRAYER_KEY ? v : labelFor(k, v)}` }
})
) )
const searchValue = computed(() => bus?.searchTerm?.value || '') const searchValue = computed(() => bus?.searchTerm?.value || '')
@ -137,8 +193,12 @@ useShadowStyles(`
padding, the header's inter-row gap and the selections row's height are three different padding, the header's inter-row gap and the selections row's height are three different
owners of what looks like one strip of white space, which is why trimming any one of them owners of what looks like one strip of white space, which is why trimming any one of them
alone has never been enough. */ alone has never been enough. */
/* ★ 5px -> 3px. The band's own padding is the FOURTH owner of what the Driver sees as one
strip of white space, and the measurement is what made it worth touching: at 1600x900 the
band read 67px and decomposed exactly — 10px of this padding + 56px of header. Two of those
ten are the cheapest px in the budget because nothing sits in them. */
.rd-research-filters{ position:relative; z-index:55; height:100%; box-sizing:border-box; .rd-research-filters{ position:relative; z-index:55; height:100%; box-sizing:border-box;
padding:5px var(--rd-edge,14px); background:var(--rd-panel,#fff); border-bottom:1px solid var(--rd-line, #d1d4cd); padding:3px var(--rd-edge,14px); background:var(--rd-panel,#fff); border-bottom:1px solid var(--rd-line, #d1d4cd);
overflow:visible; } overflow:visible; }
`, 'rd-research-filters') `, 'rd-research-filters')
</script> </script>