From 762953a054935a7db64e1e991f2426b2509131bd Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:57:39 +0000 Subject: [PATCH] Land the swarm's dashboard work: colour, rails, cascade, scroll Sixteen source files from five agents, all verified against source gates before commit: template-literal, sfc-parse, kpi-unit, composite-reg, facet-cascade, one-palette, script-flags, no-public-binds, no-orphan-gates and quoted-scripts all pass. public-hygiene reports 0 findings. Colour. The choropleth ramp is now derived by interpolating the product's own tokens (surface #f3f3f1 to accent #92b195) rather than adding a sixth grey, and that fixes a pincer the earlier research had not named: on the old grey-to-black ramp the navy dot and the white ring failed at OPPOSITE ends, so no fixed pin colour could win. Worst-case dot contrast moves 1.13 to 7.29. Second-order breaks fixed with it -- the country outline would have measured 1.08-1.82 as white on the pale fill, so it is #6d766e at 2.59-4.34, and the legend swatch border. Religion colours no longer key by array index. colorFor takes no index at all; colour comes from the registry by facet plus value. All 31 distinct live religion labels now match the map, 0 mismatches. Tree-rail grey rows 142 to 0 against the live 2,107-row API. Cascade. Clearing a parent facet now clears its descendants, in the selection bus rather than in any component, because every clear path already funnels through setFilter/clearFilters -- a component fix would have fixed one button and left four. The hierarchy is derived from config.facetRail[].drill, so it cannot drift from the tiers the rail renders. Place turned out to be three deep (region, block, country), not two. Rails. Tree rows wrapping 18/66 to 3/66; the tree rail is now the wider of the two at 296 vs 264, with the map cell unchanged because both pairs sum to 560. Also lands the shell scroll fix and the facet-cascade gate with its committed fixture -- 73 sampled rows, no network, so the assertion that reproduces the empty map runs on a fresh clone instead of silently skipping. --- internal/check-facet-cascade.mjs | 437 ++++++++++++++++++ internal/fixtures/facet-cascade-rows.json | 1 + internal/verify-shell-scroll.mjs | 34 +- library/ProfileLoader.vue | 17 +- library/colors/language-family.js | 32 +- library/components/search/FilterDropdown.vue | 47 +- .../components/search/SearchFilterHeader.vue | 194 +++++--- library/layouts/VerticalTabsLayout.vue | 86 ++++ plugin-designs/dashboard-plus-grid/index.js | 27 ++ .../src/colors/affinity-block.js | 23 +- .../plugin-studio/plugin-studio.vue | 58 +++ .../research-dashboard/research-dashboard.vue | 23 +- .../dashboards/DashboardChartsRail.vue | 41 +- .../dashboards/DashboardResearchFilters.vue | 11 +- .../components/dashboards/DashboardTopBar.vue | 5 +- .../composables/useDashboardAggregations.js | 232 +++++++++- .../src/composables/useDashboardShell.js | 7 + .../src/composables/useSelectionBus.js | 76 ++- 18 files changed, 1271 insertions(+), 80 deletions(-) create mode 100644 internal/check-facet-cascade.mjs create mode 100644 internal/fixtures/facet-cascade-rows.json diff --git a/internal/check-facet-cascade.mjs b/internal/check-facet-cascade.mjs new file mode 100644 index 000000000..a76f4654f --- /dev/null +++ b/internal/check-facet-cascade.mjs @@ -0,0 +1,437 @@ +#!/usr/bin/env node +/** + * check-facet-cascade.mjs — A CHILD FACET SELECTION MUST NOT OUTLIVE ITS PARENT. + * + * THE DEFECT THIS CATCHES (Driver-reported 2026-09-22): "if you have a language selected and then + * you deselect the language family, it does not deselect the language of that language family that + * was selected... because of that I tried to go and select a new language family and it does not + * show up on the map. And I cannot see the reason why unless I look at the selected section header." + * + * ★ WHY THIS IS A DATA-TRUST BUG, NOT A NIT. filterSet is AND-stacked — filteredRows keeps a row + * only when EVERY active key matches. An orphaned child keeps filtering, so after the parent is + * gone, picking a DIFFERENT parent can never satisfy both predicates and the map returns EMPTY. + * Nothing errors. Nothing looks broken. The product quietly reports "there are no people groups + * here" when the truth is "your filter state is inconsistent". A silent wrong result about the + * world is worse than a crash, because the user believes it. + * + * ★ WHAT THIS GATE ASSERTS THAT A DOM CHECK CANNOT. This is a STATE bug. The UI is perfectly + * well-behaved throughout: the chips render, the buttons respond, the map paints. Only the + * RESULT COUNT is wrong. So this gate drives the real bus over the real config and asserts on + * row counts — never on appearance. + * + * ASSERTIONS + * A. HIERARCHY DERIVATION — every parent's descendant list is the TRANSITIVE closure of its + * config drill, nearest-first. A one-level cascade looks fixed from the top tier while + * leaving the identical orphan one rung down, so depth is asserted explicitly. + * B. NO ORPHANS — for each hierarchy, set every tier, clear the TOP, and assert no descendant + * key survives in filterSet. + * C. THE DRIVER'S ACTUAL SEQUENCE, asserted on counts (the step that reproduces what he saw): + * 1. select parent P, then child C within P -> record N1 (must be > 0) + * 2. clear P -> assert C is gone from filterSet + * 3. select a DIFFERENT parent Q -> assert count > 0 AND == Q-alone count + * Steps 1-2 can pass while the user-visible failure survives; step 3 is the one that fails + * on the un-fixed code (it returns 0). + * D. EVERY CLEAR PATH — the tree rail, the dropdowns, the per-chip X and Clear All / breadcrumb + * all route through setFilter(k,null) / clearFilters(). Each is driven here, because a fix + * in one component would leave the bug reachable through the other three buttons. + * E. SETTING IS NOT CASCADING — a child selected with no parent set must STAND (it is strictly + * narrower, never wrong) and restoring a shared #f= hash with parent+child intact must keep + * BOTH. This pins the invariant as one-directional so a future "fix" cannot over-reach and + * break share links. + * + * NEGATIVE-TESTED, and the negative test is part of this file: `--self-test` copies the bus, strips + * the cascade line back out, re-runs every assertion against the reverted copy and requires the + * gate to FAIL. Measured: 13 assertions fail, including "after switching to Q=… the map is EMPTY + * (Showing 0)" for all three hierarchies — i.e. it reproduces the Driver's report exactly. A gate + * nobody has broken is a gate nobody knows works. + * + * ── HERMETIC BY CONSTRUCTION (it runs inside build:unlocked) ────────────────────────────────── + * No network, no browser, no server, no vite. ~0.7s. Two things make that true: + * - rows come from internal/fixtures/facet-cascade-rows.json, a committed 73-row sample of the + * live pray-tools data (regenerate: `node internal/check-facet-cascade.mjs --refresh-fixture`); + * - dashboard.config.js is read as TEXT, never imported, because it transitively reaches + * `import.meta.glob()` — a VITE macro that throws in plain Node (see readFacetRail). + * `LIVE=1` opts into the full live dataset for production-scale numbers; the default never does. + * + * A MISSING FIXTURE IS A FAILURE, NOT A SKIP. An earlier draft fetched live rows and skipped the + * count assertions when offline — so on a fresh clone with no network, C3 (the assertion that + * reproduces the Driver's empty map) silently did not run and the build still went green. That is + * a gate that cannot fail, which is worse than no gate. Now every assertion runs everywhere. + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, cpSync } from 'node:fs' +import { resolve, dirname, join } 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 OFFLINE = process.argv.includes('--offline') +const REFRESH = process.argv.includes('--refresh-fixture') + +const fails = [] +const notes = [] +const ok = (label) => notes.push(` ok ${label}`) +const bad = (label, detail) => { fails.push(`${label}${detail ? ` — ${detail}` : ''}`); notes.push(` FAIL ${label}${detail ? ` — ${detail}` : ''}`) } +const eq = (label, got, want) => (String(got) === String(want) ? ok(`${label} = ${got}`) : bad(label, `got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)) + +// ── load the REAL modules under test ────────────────────────────────────────────────────────── +// The BUS is imported for real — it is plain JS over vue's `reactive`/`ref`, so Node runs it as-is, +// and importing it (rather than reimplementing it) is the whole point: a gate that restates the +// logic it checks cannot catch a drift between the gate and the product. +// +// ★ THE CONFIG IS READ AS TEXT, NOT IMPORTED — and that is deliberate, not a shortcut. +// `dashboard.config.js` transitively reaches `@map/colors/_registry.js`, which calls +// `import.meta.glob()` — a VITE COMPILE-TIME MACRO that does not exist in Node ("glob is not a +// function"). Importing the config therefore cannot work outside a vite build, and shimming the +// macro would mean this gate exercises a hand-written fake of vite's resolution whose semantics +// could drift from the real thing. +// +// The data this gate needs — `facetRail[].drill` and the facet accessors — are plain literals in +// that file, so they are extracted from its SOURCE. The config file remains the single source of +// truth (edit a drill array and this gate sees it on the next run); only the evaluation strategy +// changed. The accessors are re-derived here from the same row fields the config declares, and +// assertion A cross-checks the extracted drill arrays against EXPECT, so a silent extraction +// failure shows up as a named failure rather than a skipped check. +const BUS_SRC = join(ROOT, 'plugin-designs/research-dashboard/src/composables/useSelectionBus.js') +const CFG_SRC = join(ROOT, 'plugin-designs/research-dashboard/dashboard.config.js') + +const pathToUrl = (p) => 'file://' + p + '?t=' + Date.now() + +/** + * Extract `facetRail: [ { drill: [...] }, … ]` from the config SOURCE. + * Only the fields the cascade depends on; everything else in the config is irrelevant here. + */ +function readFacetRail(src) { + const start = src.indexOf('facetRail:') + if (start < 0) throw new Error('facetRail not found in dashboard.config.js') + // walk to the matching close bracket of the array literal + const open = src.indexOf('[', start) + let depth = 0, end = -1 + for (let i = open; i < src.length; i++) { + const c = src[i] + if (c === '[') depth++ + else if (c === ']') { depth--; if (depth === 0) { end = i; break } } + } + if (end < 0) throw new Error('facetRail array is unterminated') + const body = src.slice(open, end + 1) + const sections = [] + // one entry per `{ … }` object at the top level of that array + const objRe = /\{[^{}]*\}/g + let m + while ((m = objRe.exec(body))) { + const obj = m[0] + const id = /id:\s*'([^']+)'/.exec(obj)?.[1] + const drillRaw = /drill:\s*\[([^\]]*)\]/.exec(obj)?.[1] + const drill = drillRaw ? [...drillRaw.matchAll(/'([^']+)'/g)].map((x) => x[1]) : undefined + const facet = /facet:\s*'([^']+)'/.exec(obj)?.[1] + sections.push({ id, drill, facet }) + } + if (!sections.length) throw new Error('no facetRail sections parsed') + return sections +} + +async function loadModules(busPath) { + const { createSelectionBus, deriveHierarchy } = await import(pathToUrl(busPath)) + const config = { facetRail: readFacetRail(readFileSync(CFG_SRC, 'utf8')) } + return { createSelectionBus, deriveHierarchy, config } +} + +// ── hierarchies under test, named from the config's own facetRail drill arrays ──────────────── +// Declared here ONLY as the expectation to compare the derivation against. +const EXPECT = { + affinity_bloc: ['cluster', 'people_group'], // People — THREE deep + cluster: ['people_group'], + wagf_region: ['wagf_block', 'country_code'], // Place — THREE deep (bloc sits between) + wagf_block: ['country_code'], + language_family: ['primary_language'], // Language — two deep + religion_family: ['religion'], +} + +// ── rows for the count assertions ───────────────────────────────────────────────────────────── +// ★ A COMMITTED FIXTURE, NOT THE NETWORK. This gate runs inside build:unlocked, so it must be +// hermetic: no network, no browser, no server. It reads internal/fixtures/facet-cascade-rows.json, +// a 73-row sample of the live pray-tools data (regenerate with --refresh-fixture). +// +// WHY A FIXTURE AND NOT "fetch, else skip". The earlier version fetched live rows and SKIPPED the +// count assertions when offline — which meant that on a fresh clone with no network, C3 (the +// assertion that actually reproduces the Driver's empty map) silently did not run and the build +// still went green. That is the "a gate that cannot fail is worse than no gate" shape: decorative +// in exactly the situation where it matters. With the fixture committed, every assertion runs +// everywhere, always, in ~1s. +// +// The sample keeps the top-2 parents of each chain and a spread of their distinct children, so +// P+C, P-alone and Q-alone all have real non-zero counts and P+C < P-alone. The ABSOLUTE numbers +// are smaller than production's, which is fine: the invariant under test is relational +// ("switching to Q returns Q-alone, not 0"), never a specific total. +const FIXTURE = resolve(ROOT, 'internal/fixtures/facet-cascade-rows.json') +const CACHE = resolve(ROOT, '../_qa-cycle/.cache/pray-tools-rows.json') +const FIELDS = 'slug,name,wagf_region,wagf_block,country_code,rop1,imb_reg_of_people_2,imb_reg_of_people_25,primary_language,imb_language_family,religion,engagement_status' + +async function loadRows() { + // LIVE=1 opts into the full live dataset (or its scratch cache) — useful when you want the + // production-scale numbers. The default path touches neither the network nor _qa-cycle. + if (!process.env.LIVE) { + if (existsSync(FIXTURE)) return JSON.parse(readFileSync(FIXTURE, 'utf8')) + return null + } + if (existsSync(CACHE)) { + try { return JSON.parse(readFileSync(CACHE, 'utf8')) } catch (_) { /* refetch */ } + } + if (OFFLINE) return null + const base = process.env.VITE_API_BASE_URL || 'https://pray.doxa.life' + try { + const res = await fetch(`${base}/api/people-groups/list?fields=${FIELDS}&lang=en`, { + headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64)' }, + }) + if (!res.ok) return null + const json = await res.json() + const rows = Array.isArray(json) ? json : (json.posts || json.data || json.results || []) + if (!rows.length) return null + try { mkdirSync(dirname(CACHE), { recursive: true }); writeFileSync(CACHE, JSON.stringify(rows)) } catch (_) { /* scratch optional */ } + return rows + } catch (_) { return null } +} + +// ── the facet accessors ───────────────────────────────────────────────────────────────────── +// Mirrors dashboard.config.js `facets[k].get` for the keys in the three hierarchies. Written +// out here because the config cannot be imported in Node (see the note on readFacetRail); the +// field names and the {value,label} projections are copied from it verbatim. +// - most axes return the LABEL (lbl) +// - the People tiers return the CODE, because that is the value the tree filters on +// - language_family is API-value-first with a langFamilyByIso FALLBACK (the raw field is +// populated on only 34/2107 rows; the fallback lifts it to ~2057, and without it the +// Language chain would have too few rows to test meaningfully) +const lbl = (v) => (v && typeof v === 'object' ? (v.label ?? v.value ?? '') : (v ?? '')) +const codeOf = (v) => (v && typeof v === 'object' ? (v.value ?? v.label ?? '') : (v ?? '')) +const isoMap = JSON.parse(readFileSync(join(ROOT, 'library/data/langFamilyByIso.json'), 'utf8')) +const { canonicalFamilyName } = await import(pathToUrl(join(ROOT, 'library/colors/language-family.js'))) +const ACC = { + affinity_bloc: (r) => codeOf(r.rop1), + cluster: (r) => codeOf(r.imb_reg_of_people_2), + people_group: (r) => codeOf(r.imb_reg_of_people_25), + wagf_region: (r) => lbl(r.wagf_region), + wagf_block: (r) => lbl(r.wagf_block), + country_code: (r) => lbl(r.country_code), + religion: (r) => lbl(r.religion), + engagement_status: (r) => lbl(r.engagement_status), + primary_language: (r) => lbl(r.primary_language), + language_family: (r) => { + const api = lbl(r.imb_language_family) + if (api) return canonicalFamilyName(api) + const iso = r.primary_language && typeof r.primary_language === 'object' ? r.primary_language.value : null + const hit = iso && isoMap[iso] + return hit ? canonicalFamilyName(hit) : '' + }, +} + +/** + * --refresh-fixture: rebuild internal/fixtures/facet-cascade-rows.json from the live API. + * Keeps the top-2 parents of each chain (ranked by distinct-child count, matching the ranking + * `fixture()` uses below) and up to CAP rows spread across their distinct children, so every + * count assertion has real non-zero values. Recorded as a command rather than a one-off script so + * the fixture is REGENERABLE and not an unexplained blob. + */ +async function refreshFixture() { + const base = process.env.VITE_API_BASE_URL || 'https://pray.doxa.life' + const res = await fetch(`${base}/api/people-groups/list?fields=${FIELDS}&lang=en`, { + headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64)' }, + }) + if (!res.ok) throw new Error(`list HTTP ${res.status}`) + const json = await res.json() + const rows = Array.isArray(json) ? json : (json.posts || json.data || json.results || []) + const CAP = 14 + const keep = new Set() + for (const [pk, ck] of [['affinity_bloc', 'cluster'], ['wagf_region', 'wagf_block'], ['language_family', 'primary_language']]) { + const byP = new Map() + for (const r of rows) { + const p = ACC[pk](r), c = ACC[ck](r) + if (!p) continue + if (!byP.has(p)) byP.set(p, { rows: [], kids: new Set() }) + byP.get(p).rows.push(r); if (c) byP.get(p).kids.add(c) + } + const ranked = [...byP].filter(([, e]) => e.kids.size > 0).sort((a, b) => b[1].kids.size - a[1].kids.size) + for (const [, e] of ranked.slice(0, 2)) { + const byKid = new Map() + for (const r of e.rows) { const c = ACC[ck](r) || '∅'; if (!byKid.has(c)) byKid.set(c, []); byKid.get(c).push(r) } + let n = 0 + for (const [, rs] of byKid) { for (const r of rs.slice(0, 3)) { if (n++ >= CAP) break; keep.add(r) } if (n >= CAP) break } + } + } + const F = ['slug', 'wagf_region', 'wagf_block', 'country_code', 'rop1', 'imb_reg_of_people_2', 'imb_reg_of_people_25', 'primary_language', 'imb_language_family'] + const slim = [...keep].map((r) => { const o = {}; for (const f of F) if (r[f]) o[f] = r[f]; return o }) + mkdirSync(dirname(FIXTURE), { recursive: true }) + writeFileSync(FIXTURE, JSON.stringify(slim)) + console.log(`fixture refreshed: ${slim.length} rows -> ${FIXTURE}`) +} + +async function run(busPath) { + fails.length = 0; notes.length = 0 + const { createSelectionBus, deriveHierarchy, config } = await loadModules(busPath) + + // ── A. hierarchy derivation is the TRANSITIVE closure of config.facetRail[].drill ─────────── + const derived = deriveHierarchy(config) + for (const [parent, want] of Object.entries(EXPECT)) { + const got = derived[parent] || [] + eq(`A hierarchy ${parent}`, JSON.stringify(got), JSON.stringify(want)) + } + // depth is load-bearing: assert the 3-deep chains explicitly, by name + eq('A People chain is THREE deep (bloc clears cluster AND people_group)', (derived.affinity_bloc || []).length, 2) + eq('A Place chain is THREE deep (region clears bloc AND country)', (derived.wagf_region || []).length, 2) + // a flat section must contribute nothing + eq('A flat facet (engagement_status) has no descendants', (derived.engagement_status || []).length, 0) + + // accessors are module-level (see ACC above) — shared with --refresh-fixture + const rows = await loadRows() + const countWith = (fset) => { + const keys = Object.keys(fset) + return (rows || []).filter((r) => keys.every((k) => (ACC[k] ? ACC[k](r) === fset[k] : true))).length + } + + const newBus = () => { const b = createSelectionBus(); b.configureHierarchy(config); return b } + + // ── B. no orphans: set every tier, clear the top, assert nothing below survives ───────────── + const CHAINS = [ + { name: 'People (affinity_bloc -> cluster -> people_group)', tiers: ['affinity_bloc', 'cluster', 'people_group'] }, + { name: 'Place (wagf_region -> wagf_block -> country_code)', tiers: ['wagf_region', 'wagf_block', 'country_code'] }, + { name: 'Language(language_family -> primary_language)', tiers: ['language_family', 'primary_language'] }, + ] + for (const { name, tiers } of CHAINS) { + const bus = newBus() + tiers.forEach((t, i) => bus.setFilter(t, `V${i}`)) + bus.setFilter(tiers[0], null) + const survivors = tiers.filter((t) => t in bus.filterSet) + if (survivors.length) bad(`B no orphan after clearing top of ${name}`, `survived: ${survivors.join(', ')}`) + else ok(`B no orphan after clearing top of ${name}`) + + // and from the MIDDLE tier, for the 3-deep chains + if (tiers.length === 3) { + const b2 = newBus() + tiers.forEach((t, i) => b2.setFilter(t, `V${i}`)) + b2.setFilter(tiers[1], null) + const s2 = tiers.slice(1).filter((t) => t in b2.filterSet) + if (s2.length) bad(`B no orphan after clearing MIDDLE of ${name}`, `survived: ${s2.join(', ')}`) + else ok(`B no orphan after clearing MIDDLE of ${name}`) + if (!(tiers[0] in b2.filterSet)) bad(`B clearing a child must NOT clear its parent (${name})`) + else ok(`B clearing a child leaves the parent intact (${name})`) + } + } + + // ── C. the Driver's sequence, asserted on RESULT COUNTS ───────────────────────────────────── + if (!rows) { + // NOT a skip. The committed fixture is what makes C/D run everywhere, so its absence is a + // BROKEN GATE, not an excusable environment gap — silently skipping the assertions that + // reproduce the Driver's bug is how a gate becomes decorative. Only --offline with LIVE=1 + // (an explicit opt-in to the live dataset with no cache) is allowed to degrade. + if (process.env.LIVE && OFFLINE) notes.push(' SKIP C/D — LIVE=1 --offline with no cache (explicitly opted out of the fixture)') + else bad('C/D count assertions could not run', `missing fixture ${FIXTURE} — regenerate with --refresh-fixture`) + } else { + // pick real fixtures from the data: a parent with >=1 child, and a DIFFERENT non-empty parent + const fixture = (parentKey, childKey) => { + const byParent = new Map() + for (const r of rows) { + const p = ACC[parentKey](r), c = ACC[childKey](r) + if (!p) continue + if (!byParent.has(p)) byParent.set(p, new Set()) + if (c) byParent.get(p).add(c) + } + const ranked = [...byParent].filter(([, cs]) => cs.size > 0).sort((a, b) => b[1].size - a[1].size) + if (ranked.length < 2) return null + return { P: ranked[0][0], C: [...ranked[0][1]][0], Q: ranked[1][0] } + } + for (const { name, tiers } of CHAINS) { + const [pk, ck] = tiers + const f = fixture(pk, ck) + if (!f) { notes.push(` SKIP C ${name} — not enough distinct parents in the data`); continue } + const bus = newBus() + // 1. select parent P, then child C within P + bus.setFilter(pk, f.P); bus.setFilter(ck, f.C) + const n1 = countWith(bus.filterSet) + if (n1 > 0) ok(`C1 ${name}: P=${f.P} + C=${f.C} -> Showing ${n1}`) + else bad(`C1 ${name}`, `expected >0 rows for P+C, got ${n1}`) + // 2. clear P -> C must be gone + bus.setFilter(pk, null) + if (ck in bus.filterSet) bad(`C2 ${name}`, `child ${ck} ORPHANED (=${bus.filterSet[ck]}) after clearing ${pk}`) + else ok(`C2 ${name}: clearing ${pk} removed ${ck}`) + // 3. select a DIFFERENT parent Q -> must be >0 and == Q alone ← the step that reproduces the bug + bus.setFilter(pk, f.Q) + const n3 = countWith(bus.filterSet) + const nQ = countWith({ [pk]: f.Q }) + if (n3 === 0) bad(`C3 ${name}`, `after switching to Q=${f.Q} the map is EMPTY (Showing 0) — the orphan is still filtering`) + else if (n3 !== nQ) bad(`C3 ${name}`, `Showing ${n3} but Q alone is ${nQ} — a stale predicate is still ANDed`) + else ok(`C3 ${name}: Q=${f.Q} -> Showing ${n3} (== Q alone)`) + } + + // ── D. every clear path obeys the cascade ───────────────────────────────────────────────── + // tree rail + dropdown + chip X all call setFilter(k, null); Clear All calls clearFilters(); + // the breadcrumb popTo() calls setFilter(k, null) over a key slice. + const seed = (b) => { b.setFilter('affinity_bloc', 'A012'); b.setFilter('cluster', 'C1'); b.setFilter('people_group', 'P1') } + + const bTree = newBus(); seed(bTree); bTree.setFilter('affinity_bloc', null) + eq('D tree-rail / dropdown / chip-X path leaves keys', Object.keys(bTree.filterSet).length, 0) + + const bAll = newBus(); seed(bAll); bAll.clearFilters() + eq('D Clear All leaves keys', Object.keys(bAll.filterSet).length, 0) + + // breadcrumb popTo(k): clears k and every key AFTER it in insertion order. With the cascade, + // clearing the bloc crumb must not leave cluster/people_group behind even though popTo would + // have cleared them anyway — assert the bloc crumb ALONE is sufficient. + const bCrumb = newBus(); seed(bCrumb) + bCrumb.setFilter('affinity_bloc', null) + eq('D breadcrumb pop to a parent crumb leaves keys', Object.keys(bCrumb.filterSet).length, 0) + + // a SIBLING axis must be untouched by another axis's cascade + const bSib = newBus(); seed(bSib); bSib.setFilter('wagf_region', 'Asia') + bSib.setFilter('affinity_bloc', null) + eq('D sibling axis survives an unrelated cascade', bSib.filterSet.wagf_region, 'Asia') + } + + // ── E. setting is NOT cascading (one-directional invariant) ───────────────────────────────── + const bChild = newBus() + bChild.setFilter('primary_language', 'Marathi') + eq('E a child with no parent set STANDS ALONE', bChild.filterSet.primary_language, 'Marathi') + eq('E setting a child does not invent a parent', 'language_family' in bChild.filterSet, 'false') + + // restoring a shared #f= hash replays setFilter per key — parent AND child must both survive + const bHash = newBus() + Object.entries({ language_family: 'Indo-European', primary_language: 'Marathi' }).forEach(([k, v]) => bHash.setFilter(k, v)) + eq('E share-link restore keeps parent', bHash.filterSet.language_family, 'Indo-European') + eq('E share-link restore keeps child', bHash.filterSet.primary_language, 'Marathi') + + return fails.length === 0 +} + +// ── --self-test: the NEGATIVE test. Patch the cascade back OUT and require this gate to FAIL. ── +// A gate you have not broken is a gate you do not know works. +async function selfTest() { + // The broken copy MUST live beside the original, not in /tmp: package.json's `type: module` + // scope stops at the repo root, so a /tmp copy loads as CJS and dies on `import` before a + // single assertion runs — which exits 0 and reads as a pass. (First attempt did exactly that.) + // Same directory also keeps `vue` resolvable from the repo's node_modules. + const broken = join(dirname(BUS_SRC), '.useSelectionBus.negative-test.js') + cpSync(BUS_SRC, broken) + const src = readFileSync(broken, 'utf8') + const needle = ' for (const child of childFacets[key] || []) delete filterSet[child]\n' + if (!src.includes(needle)) { + console.error('SELF-TEST INCONCLUSIVE: the cascade line moved; update the needle in check-facet-cascade.mjs') + rmSync(broken, { force: true }) + process.exit(2) + } + writeFileSync(broken, src.replace(needle, '')) // ← revert the fix + const passed = await run(broken) + const observed = notes.slice() + rmSync(broken, { force: true }) + console.log('── NEGATIVE TEST (cascade reverted) ──') + observed.filter((l) => l.includes('FAIL')).forEach((l) => console.log(l)) + if (passed) { console.error('\nSELF-TEST FAILED: the gate PASSED against reverted code — it cannot detect the bug.'); process.exit(1) } + console.log(`\nSELF-TEST OK: gate caught the reverted cascade (${fails.length} assertions failed).`) + process.exit(0) +} + +if (REFRESH) { await refreshFixture(); process.exit(0) } + +if (SELF_TEST) { await selfTest() } else { + const passed = await run(BUS_SRC) + console.log('── facet cascade: a child selection must not outlive its parent ──') + notes.forEach((l) => console.log(l)) + if (!passed) { console.error(`\nFAIL check-facet-cascade: ${fails.length} assertion(s)\n` + fails.map((f) => ' - ' + f).join('\n')); process.exit(1) } + console.log('\nOK check-facet-cascade: all hierarchies cascade-clear on every clear path.') +} diff --git a/internal/fixtures/facet-cascade-rows.json b/internal/fixtures/facet-cascade-rows.json new file mode 100644 index 000000000..49f505a8d --- /dev/null +++ b/internal/fixtures/facet-cascade-rows.json @@ -0,0 +1 @@ +[{"slug":"accukattavandlu","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0214","label":"Telugu"},"imb_reg_of_people_25":{"value":"300027","label":"Accukattavandlu"},"primary_language":{"value":"tel","label":"Telugu"}},{"slug":"aiyarakulu","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0214","label":"Telugu"},"imb_reg_of_people_25":{"value":"300152","label":"Aiyarakulu"},"primary_language":{"value":"tel","label":"Telugu"}},{"slug":"arakala","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0214","label":"Telugu"},"imb_reg_of_people_25":{"value":"300454","label":"Arakala"},"primary_language":{"value":"tel","label":"Telugu"}},{"slug":"adiyan","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0333","label":"South Asian Tribal"},"imb_reg_of_people_25":{"value":"300053","label":"Adiyan"},"primary_language":{"value":"yea","label":"Ravula"}},{"slug":"andamanese","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0333","label":"South Asian Tribal"},"imb_reg_of_people_25":{"value":"300300","label":"Andamanese"},"primary_language":{"value":"hca","label":"Andaman Hindi Creole"}},{"slug":"aranadan","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0333","label":"South Asian Tribal"},"imb_reg_of_people_25":{"value":"300463","label":"Arandan (Aranadan)"},"primary_language":{"value":"tam","label":"Tamil"}},{"slug":"agaria","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0148","label":"Munda-Santal"},"imb_reg_of_people_25":{"value":"309205","label":"Agariya / Agaria"},"primary_language":{"value":"kfr","label":"Kacchi"}},{"slug":"didayi","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0148","label":"Munda-Santal"},"imb_reg_of_people_25":{"value":"302095","label":"Didayi"},"primary_language":{"value":"ory","label":"Odia"}},{"slug":"koda","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"BGD","label":"Bangladesh"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0148","label":"Munda-Santal"},"imb_reg_of_people_25":{"value":"309190","label":"Kuda (India)"},"primary_language":{"value":"cdz","label":"Koda"}},{"slug":"agasa","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0172","label":"Other South Asian"},"imb_reg_of_people_25":{"value":"300097","label":"Agasa"},"primary_language":{"value":"tcy","label":"Tulu"}},{"slug":"allar","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0172","label":"Other South Asian"},"imb_reg_of_people_25":{"value":"300216","label":"Allar"},"primary_language":{"value":"mal","label":"Malayalam"}},{"slug":"ayri","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0172","label":"Other South Asian"},"imb_reg_of_people_25":{"value":"300620","label":"Ayri"},"primary_language":{"value":"kfa","label":"Kodava"}},{"slug":"agri","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0135","label":"Marathi-Konkani"},"imb_reg_of_people_25":{"value":"300112","label":"Agri"},"primary_language":{"value":"mar","label":"Marathi"}},{"slug":"are-katika","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0135","label":"Marathi-Konkani"},"imb_reg_of_people_25":{"value":"300483","label":"Are Katika"},"primary_language":{"value":"mar","label":"Marathi"}},{"slug":"abu-junuk","wagf_region":{"value":"middle_east","label":"Middle East"},"wagf_block":{"value":"middle_east","label":"Middle East"},"country_code":{"value":"SDN","label":"Sudan"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0156","label":"Nuba Mountains"},"imb_reg_of_people_25":{"value":"309368","label":"Abu-Junuk"},"primary_language":{"value":"apd","label":"Arabic, Sudanese"}},{"slug":"afitti","wagf_region":{"value":"middle_east","label":"Middle East"},"wagf_block":{"value":"middle_east","label":"Middle East"},"country_code":{"value":"SDN","label":"Sudan"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0156","label":"Nuba Mountains"},"imb_reg_of_people_25":{"value":"300067","label":"Afitti"},"primary_language":{"value":"aft","label":"Afitti"}},{"slug":"alehemir","wagf_region":{"value":"middle_east","label":"Middle East"},"wagf_block":{"value":"middle_east","label":"Middle East"},"country_code":{"value":"SDN","label":"Sudan"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0156","label":"Nuba Mountains"},"imb_reg_of_people_25":{"value":"309301","label":"Alehemir"},"primary_language":{"value":"apd","label":"Arabic, Sudanese"}},{"slug":"ajja","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"east_africa","label":"East Africa"},"country_code":{"value":"SSD","label":"South Sudan"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0203","label":"Sudanic"},"imb_reg_of_people_25":{"value":"300157","label":"Ajja / Aja (South Sudan)"},"primary_language":{"value":"krs","label":"Gbaya"}},{"slug":"beli","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"east_africa","label":"East Africa"},"country_code":{"value":"SSD","label":"South Sudan"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0203","label":"Sudanic"},"imb_reg_of_people_25":{"value":"301050","label":"Beli (Jur Beli)"},"primary_language":{"value":"blm","label":"Beli (South Sudan)"}},{"slug":"binga","wagf_region":{"value":"middle_east","label":"Middle East"},"wagf_block":{"value":"middle_east","label":"Middle East"},"country_code":{"value":"SDN","label":"Sudan"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0203","label":"Sudanic"},"imb_reg_of_people_25":{"value":"301237","label":"Binga"},"primary_language":{"value":"yul","label":"Yulu"}},{"slug":"akaselem","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"west_africa","label":"West Africa"},"country_code":{"value":"TGO","label":"Togo"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0087","label":"Gur"},"imb_reg_of_people_25":{"value":"300162","label":"Akaselem"},"primary_language":{"value":"aks","label":"Akaselem"}},{"slug":"bago","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"west_africa","label":"West Africa"},"country_code":{"value":"TGO","label":"Togo"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0087","label":"Gur"},"imb_reg_of_people_25":{"value":"300686","label":"Bago (Koussountou)"},"primary_language":{"value":"bqg","label":"Bago-Kusuntu"}},{"slug":"bangi-me","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"west_africa","label":"West Africa"},"country_code":{"value":"MLI","label":"Mali"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0087","label":"Gur"},"imb_reg_of_people_25":{"value":"300826","label":"Bangi Me"},"primary_language":{"value":"dba","label":"Bangime"}},{"slug":"amba","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"east_africa","label":"East Africa"},"country_code":{"value":"UGA","label":"Uganda"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0258","label":"Bantu, Central-Congo"},"imb_reg_of_people_25":{"value":"300249","label":"Amba"},"primary_language":{"value":"rwm","label":"Amba (Uganda)"}},{"slug":"kango-dikango","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"central_africa","label":"Central Africa"},"country_code":{"value":"COD","label":"Democratic Republic of the Congo"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0258","label":"Bantu, Central-Congo"},"imb_reg_of_people_25":{"value":"303649","label":"Kango"},"primary_language":{"value":"kty","label":"Kango (DRC, , Bas-Uélé district)"}},{"slug":"kango-likango","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"central_africa","label":"Central Africa"},"country_code":{"value":"COD","label":"Democratic Republic of the Congo"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0258","label":"Bantu, Central-Congo"},"imb_reg_of_people_25":{"value":"303649","label":"Kango"},"primary_language":{"value":"kty","label":"Kango (DRC, , Bas-Uélé district)"}},{"slug":"baangi","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"central_africa","label":"Central Africa"},"country_code":{"value":"NGA","label":"Nigeria"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0044","label":"Benue"},"imb_reg_of_people_25":{"value":"300633","label":"Baangi"},"primary_language":{"value":"bqx","label":"Baangi"}},{"slug":"cung","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"central_africa","label":"Central Africa"},"country_code":{"value":"CMR","label":"Cameroon"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0044","label":"Benue"},"imb_reg_of_people_25":{"value":"301860","label":"Cung"},"primary_language":{"value":"cnq","label":"Chung (Cameroon)"}},{"slug":"agul","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"east_europe","label":"East Europe"},"country_code":{"value":"RUS","label":"Russian Federation"},"rop1":{"value":"A003","label":"Eurasian Peoples"},"imb_reg_of_people_2":{"value":"C0061","label":"Caucasus"},"imb_reg_of_people_25":{"value":"300118","label":"Agul (Aghul)"},"primary_language":{"value":"agx","label":"Aghul"}},{"slug":"akhwakh","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"east_europe","label":"East Europe"},"country_code":{"value":"RUS","label":"Russian Federation"},"rop1":{"value":"A003","label":"Eurasian Peoples"},"imb_reg_of_people_2":{"value":"C0061","label":"Caucasus"},"imb_reg_of_people_25":{"value":"300172","label":"Akhvakh"},"primary_language":{"value":"akv","label":"Akhvakh"}},{"slug":"akkin","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"east_europe","label":"East Europe"},"country_code":{"value":"RUS","label":"Russian Federation"},"rop1":{"value":"A003","label":"Eurasian Peoples"},"imb_reg_of_people_2":{"value":"C0061","label":"Caucasus"},"imb_reg_of_people_25":{"value":"300173","label":"Akkin"},"primary_language":{"value":"che","label":"Chechen"}},{"slug":"alawites-turkiye","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"south_europe","label":"South Europe"},"country_code":{"value":"TUR","label":"Türkiye"},"rop1":{"value":"A001","label":"Arab World"},"imb_reg_of_people_2":{"value":"C0015","label":"Arab, Levant"},"imb_reg_of_people_25":{"value":"300206","label":"Alawite"},"primary_language":{"value":"apc","label":"Arabic, Levantine"}},{"slug":"deaf-cypriots","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"south_europe","label":"South Europe"},"country_code":{"value":"CYP","label":"Cyprus"},"rop1":{"value":"A017","label":"Deaf Peoples"},"imb_reg_of_people_2":{"value":"C0252","label":"Deaf"},"imb_reg_of_people_25":{"value":"301989","label":"Deaf"},"primary_language":{"value":"qcy","label":"Cypriot Sign Language"}},{"slug":"deaf-greeks","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"south_europe","label":"South Europe"},"country_code":{"value":"GRC","label":"Greece"},"rop1":{"value":"A017","label":"Deaf Peoples"},"imb_reg_of_people_2":{"value":"C0252","label":"Deaf"},"imb_reg_of_people_25":{"value":"301989","label":"Deaf"},"primary_language":{"value":"gss","label":"Greek Sign Language"}},{"slug":"andorrans","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"na","label":"N/A"},"country_code":{"value":"AND","label":"Andorra"},"rop1":{"value":"A003","label":"Eurasian Peoples"},"imb_reg_of_people_2":{"value":"C0202","label":"Spanish"},"imb_reg_of_people_25":{"value":"301602","label":"Catalan"},"primary_language":{"value":"cat","label":"Catalan"}},{"slug":"deaf-monacans","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"na","label":"N/A"},"country_code":{"value":"MCO","label":"Monaco"},"rop1":{"value":"A017","label":"Deaf Peoples"},"imb_reg_of_people_2":{"value":"C0252","label":"Deaf"},"imb_reg_of_people_25":{"value":"301989","label":"Deaf"},"primary_language":{"value":"fsl","label":"French Sign Language"}},{"slug":"deaf-sammarinese","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"na","label":"N/A"},"country_code":{"value":"SMR","label":"San Marino"},"rop1":{"value":"A017","label":"Deaf Peoples"},"imb_reg_of_people_2":{"value":"C0252","label":"Deaf"},"imb_reg_of_people_25":{"value":"301989","label":"Deaf"},"primary_language":{"value":"ise","label":"Italian Sign Language"}},{"slug":"bosniaks","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"the_balkans","label":"The Balkans"},"country_code":{"value":"MNE","label":"Montenegro"},"rop1":{"value":"A003","label":"Eurasian Peoples"},"imb_reg_of_people_2":{"value":"C0195","label":"Slav, Southern"},"imb_reg_of_people_25":{"value":"301389","label":"Bosniak"},"primary_language":{"value":"bos","label":"Bosnian"}},{"slug":"deaf-bosnians","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"the_balkans","label":"The Balkans"},"country_code":{"value":"BIH","label":"Bosnia and Herzegovina"},"rop1":{"value":"A017","label":"Deaf Peoples"},"imb_reg_of_people_2":{"value":"C0252","label":"Deaf"},"imb_reg_of_people_25":{"value":"301989","label":"Deaf"},"primary_language":{"value":"ysl","label":"Slovenian Sign Language"}},{"slug":"deaf-bulgarians","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"the_balkans","label":"The Balkans"},"country_code":{"value":"BGR","label":"Bulgaria"},"rop1":{"value":"A017","label":"Deaf Peoples"},"imb_reg_of_people_2":{"value":"C0252","label":"Deaf"},"imb_reg_of_people_25":{"value":"301989","label":"Deaf"},"primary_language":{"value":"bqn","label":"Bulgarian Sign Language"}},{"slug":"deaf-faroese","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"north_europe","label":"North Europe"},"country_code":{"value":"FRO","label":"Faroe Islands"},"rop1":{"value":"A017","label":"Deaf Peoples"},"imb_reg_of_people_2":{"value":"C0252","label":"Deaf"},"imb_reg_of_people_25":{"value":"301989","label":"Deaf"},"primary_language":{"value":"dsl","label":"Danish Sign Language"}},{"slug":"deaf-icelanders","wagf_region":{"value":"europe","label":"Europe"},"wagf_block":{"value":"north_europe","label":"North Europe"},"country_code":{"value":"ISL","label":"Iceland"},"rop1":{"value":"A017","label":"Deaf Peoples"},"imb_reg_of_people_2":{"value":"C0252","label":"Deaf"},"imb_reg_of_people_25":{"value":"301989","label":"Deaf"},"primary_language":{"value":"icl","label":"Icelandic Sign Language"}},{"slug":"abai-sungai","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_east_asia","label":"South East Asia"},"country_code":{"value":"MYS","label":"Malaysia"},"rop1":{"value":"A008","label":"Malay Peoples"},"imb_reg_of_people_2":{"value":"C0059","label":"Borneo-Kalimantan"},"imb_reg_of_people_25":{"value":"300005","label":"Abai Sungai"},"primary_language":{"value":"abf","label":"Abai Sungai"}},{"slug":"aheu","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_east_asia","label":"South East Asia"},"country_code":{"value":"LAO","label":"Lao People's Democratic Republic"},"rop1":{"value":"A011","label":"Southeast Asian Peoples"},"imb_reg_of_people_2":{"value":"C0147","label":"Mon-Khmer"},"imb_reg_of_people_25":{"value":"300130","label":"Aheu"},"primary_language":{"value":"thm","label":"Aheu"}},{"slug":"akha","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_east_asia","label":"South East Asia"},"country_code":{"value":"LAO","label":"Lao People's Democratic Republic"},"rop1":{"value":"A019","label":"Himalayan Peoples"},"imb_reg_of_people_2":{"value":"C0089","label":"Hani-Akha"},"imb_reg_of_people_25":{"value":"300169","label":"Akha"},"primary_language":{"value":"ahk","label":"Akha"}},{"slug":"adi","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"north_asia","label":"North Asia"},"country_code":{"value":"CHN","label":"People's Republic of China"},"rop1":{"value":"A019","label":"Himalayan Peoples"},"imb_reg_of_people_2":{"value":"C0004","label":"Adi"},"imb_reg_of_people_25":{"value":"300047","label":"Adi"},"primary_language":{"value":"adi","label":"Adi"}},{"slug":"ai-cham","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"north_asia","label":"North Asia"},"country_code":{"value":"CHN","label":"People's Republic of China"},"rop1":{"value":"A011","label":"Southeast Asian Peoples"},"imb_reg_of_people_2":{"value":"C0054","label":"Bouyei"},"imb_reg_of_people_25":{"value":"300138","label":"Ai-Cham"},"primary_language":{"value":"aih","label":"Ai-Cham"}},{"slug":"ainu-china","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"north_asia","label":"North Asia"},"country_code":{"value":"CHN","label":"People's Republic of China"},"rop1":{"value":"A015","label":"Turkic Peoples"},"imb_reg_of_people_2":{"value":"C0223","label":"Ural-Siberian"},"imb_reg_of_people_25":{"value":"300618","label":"Aynu (Ainu)"},"primary_language":{"value":"aib","label":"Ainu (China)"}},{"slug":"ainu-japan","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"north_east_asia","label":"North East Asia"},"country_code":{"value":"JPN","label":"Japan"},"rop1":{"value":"A015","label":"Turkic Peoples"},"imb_reg_of_people_2":{"value":"C0223","label":"Ural-Siberian"},"imb_reg_of_people_25":{"value":"300146","label":"Ainu"},"primary_language":{"value":"jpn","label":"Japanese"}},{"slug":"deaf-koreans","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"north_east_asia","label":"North East Asia"},"country_code":{"value":"PRK","label":"North Korea"},"rop1":{"value":"A017","label":"Deaf Peoples"},"imb_reg_of_people_2":{"value":"C0252","label":"Deaf"},"imb_reg_of_people_25":{"value":"301989","label":"Deaf"},"primary_language":{"value":"qkp","label":"North Korean Sign Language"}},{"slug":"oki-no-erabu","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"north_east_asia","label":"North East Asia"},"country_code":{"value":"JPN","label":"Japan"},"rop1":{"value":"A002","label":"East Asian Peoples"},"imb_reg_of_people_2":{"value":"C0099","label":"Japanese"},"imb_reg_of_people_25":{"value":"306295","label":"Oki-No-Erabu"},"primary_language":{"value":"okn","label":"Oki-No-Erabu"}},{"slug":"avar-azerbaijan","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"central_asia","label":"Central Asia"},"country_code":{"value":"AZE","label":"Azerbaijan"},"rop1":{"value":"A003","label":"Eurasian Peoples"},"imb_reg_of_people_2":{"value":"C0061","label":"Caucasus"},"imb_reg_of_people_25":{"value":"300579","label":"Avar"},"primary_language":{"value":"ava","label":"Avaric"}},{"slug":"deaf-azerbaijanis","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"central_asia","label":"Central Asia"},"country_code":{"value":"AZE","label":"Azerbaijan"},"rop1":{"value":"A017","label":"Deaf Peoples"},"imb_reg_of_people_2":{"value":"C0252","label":"Deaf"},"imb_reg_of_people_25":{"value":"301989","label":"Deaf"},"primary_language":{"value":"tsm","label":"Turkish Sign Language"}},{"slug":"arra","wagf_region":{"value":"middle_east","label":"Middle East"},"wagf_block":{"value":"middle_east","label":"Middle East"},"country_code":{"value":"SDN","label":"Sudan"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0156","label":"Nuba Mountains"},"imb_reg_of_people_25":{"value":"309347","label":"Arra"},"primary_language":{"value":"jle","label":"Ngile"}},{"slug":"doloka","wagf_region":{"value":"middle_east","label":"Middle East"},"wagf_block":{"value":"middle_east","label":"Middle East"},"country_code":{"value":"SDN","label":"Sudan"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0156","label":"Nuba Mountains"},"imb_reg_of_people_25":{"value":"309350","label":"Doloka"},"primary_language":{"value":"jle","label":"Ngile"}},{"slug":"ngile","wagf_region":{"value":"middle_east","label":"Middle East"},"wagf_block":{"value":"middle_east","label":"Middle East"},"country_code":{"value":"SDN","label":"Sudan"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0156","label":"Nuba Mountains"},"imb_reg_of_people_25":{"value":"306046","label":"Ngile"},"primary_language":{"value":"jle","label":"Ngile"}},{"slug":"badyara","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"west_africa","label":"West Africa"},"country_code":{"value":"GIN","label":"Guinea"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0024","label":"Atlantic"},"imb_reg_of_people_25":{"value":"300664","label":"Badyara"},"primary_language":{"value":"pbp","label":"Badyara"}},{"slug":"bainouk","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"west_africa","label":"West Africa"},"country_code":{"value":"GMB","label":"Republic of The Gambia"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0024","label":"Atlantic"},"imb_reg_of_people_25":{"value":"300720","label":"Bainouk"},"primary_language":{"value":"bcz","label":"Bainouk-Gunyaamolo"}},{"slug":"banda-mbres","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"east_africa","label":"East Africa"},"country_code":{"value":"SSD","label":"South Sudan"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0003","label":"Adamawa-Ubangi"},"imb_reg_of_people_25":{"value":"308652","label":"Wada"},"primary_language":{"value":"bqk","label":"Banda-Mbrès"}},{"slug":"bandial","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"west_africa","label":"West Africa"},"country_code":{"value":"SEN","label":"Senegal"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0025","label":"Atlantic-Jola"},"imb_reg_of_people_25":{"value":"300812","label":"Bandial"},"primary_language":{"value":"bqj","label":"Bandial"}},{"slug":"banka-samogho","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"west_africa","label":"West Africa"},"country_code":{"value":"MLI","label":"Mali"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0126","label":"Malinke"},"imb_reg_of_people_25":{"value":"300843","label":"Banka (Mande)"},"primary_language":{"value":"bxw","label":"Bankagooma"}},{"slug":"bantu-swahili","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"east_africa","label":"East Africa"},"country_code":{"value":"SOM","label":"Somalia"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0275","label":"Bantu, Swahili"},"imb_reg_of_people_25":{"value":"307782","label":"Swahili"},"primary_language":{"value":"swh","label":"Swahili"}},{"slug":"central-swahili","wagf_region":{"value":"africa","label":"Africa"},"wagf_block":{"value":"central_africa","label":"Central Africa"},"country_code":{"value":"COD","label":"Democratic Republic of the Congo"},"rop1":{"value":"A013","label":"Sub-Saharan African"},"imb_reg_of_people_2":{"value":"C0275","label":"Bantu, Swahili"},"imb_reg_of_people_25":{"value":"307782","label":"Swahili"},"primary_language":{"value":"swh","label":"Swahili"}},{"slug":"bafan","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0192","label":"Sindhi"},"imb_reg_of_people_25":{"value":"300670","label":"Bafan"},"primary_language":{"value":"kfr","label":"Kacchi"}},{"slug":"bhodala","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0086","label":"Gujarati"},"imb_reg_of_people_25":{"value":"301159","label":"Bhodala"},"primary_language":{"value":"kfr","label":"Kacchi"}},{"slug":"are-kapu","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0302","label":"Kapu"},"imb_reg_of_people_25":{"value":"300482","label":"Are Kapu"},"primary_language":{"value":"mar","label":"Marathi"}},{"slug":"ahmadiya","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0183","label":"Punjabi"},"imb_reg_of_people_25":{"value":"300134","label":"Ahmadi"},"primary_language":{"value":"urd","label":"Urdu"}},{"slug":"attar-saibulu","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0224","label":"Urdu"},"imb_reg_of_people_25":{"value":"300561","label":"Attar Saibulu"},"primary_language":{"value":"urd","label":"Urdu"}},{"slug":"baig","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0224","label":"Urdu"},"imb_reg_of_people_25":{"value":"300714","label":"Baig"},"primary_language":{"value":"urd","label":"Urdu"}},{"slug":"amanaye","wagf_region":{"value":"latin_america_&_caribbean","label":"Latin America & Caribbean"},"wagf_block":{"value":"brazil","label":"Brazil"},"country_code":{"value":"BRA","label":"Brazil"},"rop1":{"value":"A007","label":"Latin-Caribbean Americans"},"imb_reg_of_people_2":{"value":"C0083","label":"Guarani"},"imb_reg_of_people_25":{"value":"300241","label":"Amanaye"},"primary_language":{"value":"por","label":"Portuguese"}},{"slug":"anambe","wagf_region":{"value":"latin_america_&_caribbean","label":"Latin America & Caribbean"},"wagf_block":{"value":"brazil","label":"Brazil"},"country_code":{"value":"BRA","label":"Brazil"},"rop1":{"value":"A007","label":"Latin-Caribbean Americans"},"imb_reg_of_people_2":{"value":"C0083","label":"Guarani"},"imb_reg_of_people_25":{"value":"300295","label":"Anambe"},"primary_language":{"value":"por","label":"Portuguese"}},{"slug":"apama","wagf_region":{"value":"latin_america_&_caribbean","label":"Latin America & Caribbean"},"wagf_block":{"value":"brazil","label":"Brazil"},"country_code":{"value":"BRA","label":"Brazil"},"rop1":{"value":"A007","label":"Latin-Caribbean Americans"},"imb_reg_of_people_2":{"value":"C0201","label":"South American Indigenous"},"imb_reg_of_people_25":{"value":"303126","label":"Iapama, Apama"},"primary_language":{"value":"por","label":"Portuguese"}},{"slug":"angarok","wagf_region":{"value":"asia","label":"Asia"},"wagf_block":{"value":"south_asia","label":"South Asia"},"country_code":{"value":"IND","label":"India"},"rop1":{"value":"A012","label":"South Asian Peoples"},"imb_reg_of_people_2":{"value":"C0053","label":"Bihari"},"imb_reg_of_people_25":{"value":"300319","label":"Angarok"},"primary_language":{"value":"ben","label":"Bengali"}}] \ No newline at end of file diff --git a/internal/verify-shell-scroll.mjs b/internal/verify-shell-scroll.mjs index 52314267c..f35051373 100644 --- a/internal/verify-shell-scroll.mjs +++ b/internal/verify-shell-scroll.mjs @@ -120,11 +120,41 @@ const run = async () => { console.log(' wheel BELOW map : grid moved ' + movedTotal + 'px total (top -> ' + afterLow.top + ')') console.log(' grid on screen : ' + onScreen) - await browser.close() - if (movedTotal < 200) fail('grid did not move — a real wheel gesture in the SHELL scrolled ' + movedTotal + 'px. The grid is mounted at h=' + before.h + 'px and is UNREACHABLE.') if (!onScreen) fail('grid moved ' + movedTotal + 'px but its top is still at ' + afterLow.top + ' (below the 900px fold) — still not reachable.') console.log('SHELL-SCROLL: PASS — the grid is reachable by wheel in the cap-5 shell.') + + // ── 5. THE OTHER SIDE OF THE FIX: a MAP tab must NOT gain a scrollbar. ───────────────────── + // Making the pane scrollable is only correct if it stays a NO-OP for the ~19 fixed-viewport map + // tabs. It did not, at first: a hosted map ships min-height:100dvh (resolved against the 900px + // VIEWPORT) beside height:100% (resolved against the 769px PANE), so the floor made it 131px + // taller than its container and the new overflow:auto faithfully grew a scrollbar on it. That + // regression was invisible to every assertion above — the grid test passed while a map tab broke — + // so it is asserted here, in the same gate, rather than left to be re-found by hand. + const page2 = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + await page2.goto(BASE + SHELL, { waitUntil: 'load' }) + await page2.waitForTimeout(6000) + const mapTab = await page2.evaluate(`(() => { ${WALK} + for (const t of __walk('.vtl-tab')) { + if ((t.textContent || '').toLowerCase().includes('doxa-research-map')) { t.click(); return t.textContent.trim() } + } + return null + })()`) + if (mapTab) { + await page2.waitForTimeout(9000) + const panes = await page2.evaluate(`(() => { ${WALK} + return __walk('.ps-pane, .vtl-host').filter(e => e.getBoundingClientRect().height > 0) + .map(e => ({ sh: e.scrollHeight, ch: e.clientHeight, over: e.scrollHeight - e.clientHeight })) + })()`) + const leaking = panes.filter(p => p.over > 4) + console.log(' map tab "' + mapTab + '" pane overflow: ' + JSON.stringify(panes)) + await browser.close() + if (leaking.length) fail('a MAP tab grew a scrollbar (' + leaking[0].over + 'px overflow: ' + leaking[0].sh + '/' + leaking[0].ch + '). The pane scroll must be a NO-OP for fixed-viewport elements — check the dvh floor neutraliser.') + console.log('MAP-TAB-NO-SCROLLBAR: PASS — fixed-viewport map tabs gained no scrollbar.') + } else { + await browser.close() + console.log('MAP-TAB-NO-SCROLLBAR: SKIPPED — no doxa-research-map tab in this shell.') + } } run().catch(e => { console.error('SHELL-SCROLL: FAIL — ' + e.message); process.exit(1) }) diff --git a/library/ProfileLoader.vue b/library/ProfileLoader.vue index a3fb646f0..4d7bef228 100644 --- a/library/ProfileLoader.vue +++ b/library/ProfileLoader.vue @@ -265,8 +265,21 @@ provide('dataStore', dataStore) percentage of auto collapses to 0. This wrapper sits between the host and EVERY profile, so that one collapse zeroed every profile whose chrome is position:absolute/inset:0 (upg-100-mfe -> .pane-col -> .pane all measured 0 while the header still painted). A dvh floor depends on no - ancestor, so the wrapper always has height; height:100% still wins whenever the host IS sized. */ - min-height: 100dvh; + ancestor, so the wrapper always has height; height:100% still wins whenever the host IS sized. + + ★ EXCEPT IT DOES NOT WIN, AND THAT IS WHY THIS IS NOW A VARIABLE (measured 2026-09-22, + BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID). height:100% and min-height:100dvh resolve against + DIFFERENT bases: the former against the host, the latter against the VIEWPORT. So whenever the + host is sized SHORTER than the viewport — any pane with chrome above it, e.g. the plugin-studio + tab pane at 769px under a 900px viewport — the floor wins over the height, the wrapper is 131px + taller than its host, and it overflows. A host that then turns on overflow-y:auto to let a tall + composite scroll gets a spurious 131px scrollbar on every fixed-viewport map. + It cannot be fixed from the host side: this wrapper is inside the element's SHADOW ROOT, where + no host-page selector reaches. Inherited properties DO cross that boundary, so a custom property + is the one lever a host has. Default is unchanged (100dvh) — every standalone leaf keeps the + floor that BUG-UPG100 needs. A host that has ALREADY given this element a definite height sets + --profile-min-h:0 and opts out. */ + min-height: var(--profile-min-h, 100dvh); position: relative; } diff --git a/library/colors/language-family.js b/library/colors/language-family.js index d95525ae0..13389b2a6 100644 --- a/library/colors/language-family.js +++ b/library/colors/language-family.js @@ -79,6 +79,19 @@ export const PALETTE = { 'Trans-New Guinea': '#ba68c8', 'Andamanese': '#4dd0e1', + // ── Live-but-unmapped families (MEASURED 2026-09-22, pray-tools 2,107 rows) ── + // These resolved on live data but had no palette entry, so they fell to the grey + // NEUTRAL in the semantic tree legend. Small but real: 8 rows total. + // Contrast vs the Material Light surface #f3f3f1 (3.0:1 non-text floor): + // Atlantic-Congo 6.36 · Mande 3.31 + // Both are African families, so both sit in the Niger-Congo purple / warm-earth + // neighbourhood their relatives already occupy rather than taking an unrelated hue. + // NOTE 'Tai-Kadai' is NOT listed here: it is the modern name for the family this + // palette already calls 'Kra-Dai' (#8e44ad). It is handled as an ALIAS in + // canonicalFamilyName() — giving it its own hex would paint one family two colours. + 'Atlantic-Congo': '#7D3C98', + 'Mande': '#B9770E', + // ── Special categories ─────────────────────────────────────────────────── 'Sign Language': '#212121', 'Creole': '#9575cd', @@ -107,10 +120,27 @@ const _LANG_FAMILY_ALIAS = (() => { return map })() +/** + * SYNONYMS — upstream spellings that name a family the PALETTE already holds under a + * different name. These are NOT new colours: giving a synonym its own hex would paint + * one family two colours, which is the same class of defect as colouring by array index. + * + * MEASURED on live pray-tools data (2,107 rows, 2026-09-22): + * 'Tai-Kadai' — the modern linguistic name for 'Kra-Dai' (already #8e44ad). 1 row. + * 'None' — the API's literal no-family value; belongs in the existing 'Unknown' + * bucket, which is the palette's designated null family. 1 row. + * Keyed lower-case; resolved before the case-fold lookup so a synonym wins. + */ +const _LANG_FAMILY_SYNONYM = { + 'tai-kadai': 'Kra-Dai', + 'none': 'Unknown', +} + export function canonicalFamilyName(raw) { if (!raw) return 'Unknown' const trimmed = String(raw).trim() - return _LANG_FAMILY_ALIAS[trimmed.toLowerCase()] || trimmed + const lower = trimmed.toLowerCase() + return _LANG_FAMILY_SYNONYM[lower] || _LANG_FAMILY_ALIAS[lower] || trimmed } export function getColor(properties) { diff --git a/library/components/search/FilterDropdown.vue b/library/components/search/FilterDropdown.vue index 410f532ba..99ed1fd2d 100644 --- a/library/components/search/FilterDropdown.vue +++ b/library/components/search/FilterDropdown.vue @@ -335,8 +335,42 @@ onBeforeUnmount(() => { :host block. Do not re-resolve these to literals — that is what made them disagree. Selected/active states tint with the accent but keep --dx-text for the LABEL: sage on white is ~1.9:1, so the accent carries the STATE and the ink carries the WORD. */ -.filter-dropdown { position: relative; min-width: 160px; max-width: 240px; flex: 1 1 160px; } -@media (max-width: 576px) { .filter-dropdown { flex: 1 1 100%; max-width: 90vw; } } +/* ── ★ THE TRIGGER IS A FIXED BOX — IT MUST NOT GROW ON SELECTION ── + (card BUILD-FILTERS-FIXED-WIDTH-ELLIPSIS-NOT-GROWING) + + DRIVER: "when you select something from the drop down it should not make the filter wider. + The filter should stay the same width. They should just use dot dot dot instead." + + MEASURED BEFORE, in BOTH hosts (standalone leaf + cap-5 studio shell), 1600x900: + empty : [160, 160, 160, 160, 160, 160] total 960 + full : [240, 240, 240, 184, 171, 219] total 1294 + i.e. every trigger resized to its own content, and the row grew by 334px. The old rule was + `min-width:160px; max-width:240px; flex:1 1 160px` — a RANGE, not a width. Three separate + things let the box track its content: + - flex-basis:160px is only a STARTING size; `flex-grow:1` then hands out free space, + - max-width:240px caps but does not FIX (that is the 240,240,240 clamping above), + - and the label `Region: North America & …` changes the content width the + moment an option is picked, so the negotiation restarts with a different input. + A width that is negotiated from content can only ever be content-shaped. So the fix is not a + tighter range — it is to stop negotiating: ONE fixed width, and the label ellipsises inside it. + + WHY THIS IS NOT COSMETIC. The selections row sits UNDERNEATH this one. A trigger that grows + reflows the row below it, so the centred stack the Driver asked for could not hold still even + once it was centred. Fixing the width here is what makes the alignment above it STRUCTURAL. + + --dx-filter-w is a TOKEN, not a literal, for the same reason every colour here is: this + component is shared by people-groups-grid, big-picture AND the research-dashboard, so a + consumer that needs a different trigger size sets the token at :host instead of forking the + component. 168px is sized to the Driver's brief — "just big enough to express the first or + second word" — measured against the real labels: it shows "Region: North Am…", i.e. the axis + name plus the first word or two, which is what makes the row scannable without reading it. */ +.filter-dropdown { position: relative; flex: 0 0 auto; + width: var(--dx-filter-w, 168px); min-width: var(--dx-filter-w, 168px); max-width: var(--dx-filter-w, 168px); } +/* Mobile keeps the SAME fixed-box contract — only the number shrinks. The old rule here was + `flex:1 1 100%`, which full-widthed the trigger and wrapped the row; that is the growth bug in + its most extreme form, so it cannot come back as a "mobile exception". */ +@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); } } .filter-dropdown__trigger { width: 100%; padding: 6px 12px; cursor: pointer; display: flex; justify-content: space-between; /* Pairs its own foreground with its own background for the same reason as the menu: @@ -347,7 +381,14 @@ onBeforeUnmount(() => { } .filter-dropdown__trigger:hover { border-color: var(--dx-accent, #92b195); } .filter-dropdown__trigger[data-active] { background-color: var(--dx-accent-weak, rgba(146, 177, 149, .18)); border-color: var(--dx-accent, #92b195); } -.filter-dropdown__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* ★ min-width:0 IS WHAT MAKES THE ELLIPSIS FIRE. text-overflow only takes effect once the box is + actually narrower than its text, and a flex item defaults to min-width:auto — floored at its + MIN-CONTENT width — so without this the label refuses to shrink and pushes the (now fixed) + trigger's content past its box instead of truncating. The ellipsis rules were already here and + were doing nothing for exactly this reason; the growth was happening one level up. + flex:1 1 auto lets the label take the room the chevron does not, so the truncation point sits + right before the chevron rather than at an arbitrary width. */ +.filter-dropdown__label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .filter-dropdown__chevron { flex-shrink: 0; transition: transform 200ms ease; } .filter-dropdown.is-open .filter-dropdown__chevron { transform: rotate(180deg); } .filter-dropdown__menu { diff --git a/library/components/search/SearchFilterHeader.vue b/library/components/search/SearchFilterHeader.vue index ea7e96c7b..c94ae19d5 100644 --- a/library/components/search/SearchFilterHeader.vue +++ b/library/components/search/SearchFilterHeader.vue @@ -186,24 +186,34 @@ function onClearAll() { emit('clear-all') } The empty state is "World" — the Driver's word, and it reads correctly too: no filter really does mean the whole world is in scope. Clear-all is omitted rather than the row collapsing, because there is nothing to clear; the row keeps its height either way. --> - +