From ad6da5467459c091478e3f92b974899d41b04837 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:42:18 +0000 Subject: [PATCH] Checkpoint the swarm's in-flight work 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. --- .../README-SECTION-3-cli-commands.md | 2 +- documentation/README-longform.md | 8 +- documentation/reference.md | 11 +- internal/check-csv-export-shape.mjs | 29 +- internal/check-facet-one-field.mjs | 277 ++++++++++++++++++ internal/generate-gallery.mjs | 32 +- internal/plugin-gallery/dev-plugin.mjs | 12 +- library/components/legends/LegendTools.vue | 8 +- .../components/legends/SemanticTreeLegend.vue | 8 +- .../components/map-controls/ShareButton.vue | 80 ++++- library/components/poster/PosterDialog.vue | 7 +- library/components/poster/PosterWall.vue | 7 +- library/components/search/FilterDropdown.vue | 19 +- .../components/search/SearchFilterHeader.vue | 51 +++- library/components/tables/DataTable.vue | 13 +- library/utils/viewportScrim.js | 140 +++++++++ package.json | 4 +- .../my-upg-100-list/poster-wall.vue | 19 +- .../dashboards/DashboardDataTable.vue | 49 +++- .../dashboards/DashboardFacetRail.vue | 85 +++++- .../dashboards/DashboardResearchFilters.vue | 72 ++++- 21 files changed, 885 insertions(+), 48 deletions(-) create mode 100644 internal/check-facet-one-field.mjs create mode 100644 library/utils/viewportScrim.js diff --git a/documentation/README-SECTION-3-cli-commands.md b/documentation/README-SECTION-3-cli-commands.md index b88685618..0e8fb5351 100644 --- a/documentation/README-SECTION-3-cli-commands.md +++ b/documentation/README-SECTION-3-cli-commands.md @@ -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. "dev": "bun x vite", "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", "serve": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public", "cap:sync": "./node_modules/.bin/cap sync android", diff --git a/documentation/README-longform.md b/documentation/README-longform.md index 5a10149e0..5e77913bf 100644 --- a/documentation/README-longform.md +++ b/documentation/README-longform.md @@ -212,9 +212,11 @@ You need [**bun**](https://bun.sh) installed (`bun.lock` is committed, so instal ```bash bun install # download node_modules (one time) -bun run dev # the plugin gallery: http://localhost:5173 (or the next free port — read the terminal) - # an auto-generated SPA of every map, grouped by bundle and composition tier, - # each deep-linkable; `bun run smoke:gallery` proves every one of them boots +bun run dev # the plugin STUDIO: http://localhost:5173 (or the next free port — read the terminal) + # `/` mounts the studio; the ARCHIVED bundle index is at /__gallery — an + # 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// becomes # public/doxa-maps//.js + a standalone index.html per map diff --git a/documentation/reference.md b/documentation/reference.md index 102a2e45e..57385b5c9 100644 --- a/documentation/reference.md +++ b/documentation/reference.md @@ -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` (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 `#//[/]`), and -`bun run smoke:gallery` proves every element actually boots inside it. **`/` itself serves the plugin studio** -in both trees, not the gallery. +`bun run smoke:gallery` proves every element actually boots **inside that archived gallery page** — not in the +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 | |---|---| | `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 ` 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 ` 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 --tag ` = 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 `; exit 1 on any failure — proves REQ-1 ("links work wherever the tree is hosted"). | | `bun run check:public-hygiene [] [--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 [--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 ``), `__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 | |---|---|---| -| `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 ` 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 @@ -350,7 +351,7 @@ The rest, grouped by what they guard — same rule (outside the build, run befor | 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: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 | diff --git a/internal/check-csv-export-shape.mjs b/internal/check-csv-export-shape.mjs index 7a08a367e..f9cf169a7 100644 --- a/internal/check-csv-export-shape.mjs +++ b/internal/check-csv-export-shape.mjs @@ -31,7 +31,13 @@ * 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. * - * 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 { resolve, dirname, join } from 'node:path' @@ -65,7 +71,10 @@ if (!linesMatch) { process.exit(1) } 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 rows = [ @@ -96,6 +105,13 @@ function parse(t) { const parsed = parse(csv) 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) if (detail) console.error(detail) 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.') } +// 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(' 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') diff --git a/internal/check-facet-one-field.mjs b/internal/check-facet-one-field.mjs new file mode 100644 index 000000000..775cc34ed --- /dev/null +++ b/internal/check-facet-one-field.mjs @@ -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.". 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.') diff --git a/internal/generate-gallery.mjs b/internal/generate-gallery.mjs index 9177f44dd..1c8f0b49a 100644 --- a/internal/generate-gallery.mjs +++ b/internal/generate-gallery.mjs @@ -381,6 +381,22 @@ ${dev ? LADDER_CSS : ''} // 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. 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 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 } @@ -422,6 +438,17 @@ ${dev ? LADDER_CSS : ''} 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; 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 /// 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; var provider = cfg.provider === 'maplibre' ? 'maplibre' : 'mapbox', my = ++seq; hint(provider === 'maplibre' ? 'loading map…' : 'loading token…'); @@ -432,6 +459,7 @@ ${dev ? LADDER_CSS : ''} try { var c = token ? assign({}, cfg, { tk: token }) : cfg; 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); } catch (e) { hint(e && e.message ? e.message : String(e), true) } }); @@ -542,7 +570,7 @@ ${dev ? LADDER_CSS : ''} 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. 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() }); } else { self.route(); } } @@ -590,7 +618,7 @@ ${dev ? LADDER_CSS : ''} } window.__spa = { slot: $('slot') }; 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(); }); } else { paint(); route(); } window.addEventListener('hashchange', route); diff --git a/internal/plugin-gallery/dev-plugin.mjs b/internal/plugin-gallery/dev-plugin.mjs index 7746e1860..d0cf967e5 100644 --- a/internal/plugin-gallery/dev-plugin.mjs +++ b/internal/plugin-gallery/dev-plugin.mjs @@ -117,7 +117,17 @@ export function autoSpaDevPlugin(token = '') { const page = () => { const cfg = readBuildConfig() 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 '../' 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. // 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 diff --git a/library/components/legends/LegendTools.vue b/library/components/legends/LegendTools.vue index b2146aa06..338d145f1 100644 --- a/library/components/legends/LegendTools.vue +++ b/library/components/legends/LegendTools.vue @@ -29,6 +29,11 @@ import { useI18n } from 'vue-i18n' import { useShadowStyles } from '../../composables/useShadowStyles.js' import { useLegendData } from '../../composables/legends/useLegendData.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 // 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. const showSpeedSelector = ref(false) const showClusterSettings = ref(false) +const { scrimRef, scrimStyle } = useViewportScrim(showClusterSettings) const autoFlyIndex = ref(0) const autoFlyTimer = ref(null) @@ -240,7 +246,7 @@ onBeforeUnmount(() => { Minimum Spanning Tree
click a legend row to cluster only that group
-
+
diff --git a/library/components/legends/SemanticTreeLegend.vue b/library/components/legends/SemanticTreeLegend.vue index d9af0a6e9..698166e84 100644 --- a/library/components/legends/SemanticTreeLegend.vue +++ b/library/components/legends/SemanticTreeLegend.vue @@ -18,6 +18,11 @@ import { useI18n } from 'vue-i18n' import { useShadowStyles } from '../../composables/useShadowStyles.js' import { useInstance } from '../../composables/usePplrInstance.js' 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() @@ -157,6 +162,7 @@ const localSelected = ref(null) const panelOpen = ref(props.open !== null ? props.open : true) const showInfoForTab = ref(null) 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 // 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 @@ -1069,7 +1075,7 @@ useShadowStyles(`