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(`