Land the swarm's dashboard work: colour, rails, cascade, scroll
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
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.
This commit is contained in:
parent
ca39d27088
commit
762953a054
18 changed files with 1271 additions and 80 deletions
437
internal/check-facet-cascade.mjs
Normal file
437
internal/check-facet-cascade.mjs
Normal file
|
|
@ -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.')
|
||||
}
|
||||
1
internal/fixtures/facet-cascade-rows.json
Normal file
1
internal/fixtures/facet-cascade-rows.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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) })
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: <strong>North America & …</strong>` 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 {
|
||||
|
|
|
|||
|
|
@ -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. -->
|
||||
<!-- ★ THREE ZONES, ONLY THE MIDDLE ONE SCROLLS. Clear-all is a SIBLING of the scroll strip,
|
||||
never a child of it: "even if you have so many options selected that you need the scroll bar
|
||||
activated horizontally, you still need to be able to see the Clear All button — because the
|
||||
Clear All button is how somebody gets back to normal." (Driver, 2026-09-22)
|
||||
Inside the strip it was merely LAST, so overflow carried it off-screen exactly when a user
|
||||
most needs it. Pinned outside, it is reachable at any chip count. -->
|
||||
<!-- ★ CLEAR ALL TRAVELS WITH THE CHIPS — it is the LAST CHILD OF THE SCROLL STRIP
|
||||
(card BUILD-CLEAR-ALL-SITS-BESIDE-THE-SELECTIONS-NOT-FAR-RIGHT).
|
||||
DRIVER: "the clear all button is to look fancy, fancy Nancy. And it doesn't need to be at
|
||||
the far right. It needs to be right next to the selected stuff."
|
||||
|
||||
THIS DELIBERATELY REVERSES the previous instruction (kept below for the record, because
|
||||
the reasoning was sound and someone will be tempted to re-apply it): Clear All used to be
|
||||
a SIBLING pinned outside the strip so it stayed reachable while the chips scrolled —
|
||||
"even if you have so many options selected that you need the scroll bar activated
|
||||
horizontally, you still need to be able to see the Clear All button." That pinning is what
|
||||
MEASURED as gap(last chip -> Clear All) = -164.72px: the button sat at x=1512 while the
|
||||
chips ran on to 1676 UNDERNEATH it. Reachable, yes — but detached from the thing it acts
|
||||
on, and overlapping it.
|
||||
The newer instruction wins. Do not re-pin it; if the reachability concern returns, the
|
||||
answer is to keep the strip from overflowing (fewer/narrower chips), not to re-separate
|
||||
the button from its subject. -->
|
||||
<div v-if="useFilters" class="sfh__chips" :class="{ 'is-empty': !chips.length }">
|
||||
<span class="sfh__sel-label">{{ L.selected || 'Selected' }}</span>
|
||||
<div class="sfh__chip-strip">
|
||||
<span class="sfh__sel-label">{{ L.selected || 'Selected' }}</span>
|
||||
<template v-if="chips.length">
|
||||
<span v-for="c in chips" :key="c.name" class="sfh__chip">
|
||||
{{ c.label }}
|
||||
<button class="sfh__chip-x" type="button" :aria-label="`Remove ${c.label}`" @click="onRemoveChip(c.name)">×</button>
|
||||
</span>
|
||||
<button class="sfh__clear" type="button" @click="onClearAll">{{ L.clear_all }}</button>
|
||||
</template>
|
||||
<span v-else class="sfh__sel-world">{{ L.world || 'World' }}</span>
|
||||
</div>
|
||||
<button v-if="chips.length" class="sfh__clear" type="button" @click="onClearAll">{{ L.clear_all }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -218,10 +228,27 @@ function onClearAll() { emit('clear-all') }
|
|||
.sfh {
|
||||
--brand: var(--dx-accent, #92b195); --ink: var(--dx-text, #3b463d); --mut: var(--dx-muted, #6d766e);
|
||||
--line: var(--dx-line, #d1d4cd); --card: var(--dx-elevated, #fff);
|
||||
display: flex; flex-direction: column; gap: 10px; width: 100%; box-sizing: border-box;
|
||||
/* ★ SOURCE 1 OF THE THREE HEIGHT SOURCES — "decreasing the height of the white space
|
||||
underneath the filters" (card BUILD-SELECTED-ROW-MUCH-THINNER-AND-CENTRED). 10px -> 6px.
|
||||
This gap is what sits BETWEEN the filters row and the selections row, so it reads as the
|
||||
white space under the filters even though no element owns it. That is why it was easy to
|
||||
miss when only the row's own height was being hunted. */
|
||||
display: flex; flex-direction: column; gap: var(--dx-sfh-gap, 6px); width: 100%; box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; color: var(--ink);
|
||||
}
|
||||
.sfh__search-row { display: flex; align-items: center; gap: 12px; }
|
||||
/* ★ THE SEARCH BAR IS BAND 1 OF THE CENTRED STACK, so it centres like the rest
|
||||
(card BUILD-THE-FOUR-HEADERS-ARE-ONE-CENTRED-STACK: "the search input CENTRED in the page,
|
||||
not left-justified").
|
||||
The Show/Hide-Filters toggle is a SIBLING in this row, so a naive centring would centre the
|
||||
PAIR and leave the input itself off-centre by half the toggle's width. The input is centred
|
||||
instead by giving the row three parts: a flexible spacer, the box, and the toggle balanced by
|
||||
an equal spacer. Simpler and more robust than absolute positioning (which is what the
|
||||
dashboard's topbar has to do, because there the search shares a row with a title it cannot
|
||||
measure): here the header owns both children, so plain flow can do it.
|
||||
Consumers that hide the search (the research-dashboard passes :show-search="false" and hosts
|
||||
it in the topbar) are unaffected — this row is not rendered at all for them. */
|
||||
.sfh__search-row { display: flex; align-items: center; gap: 12px; justify-content: center; }
|
||||
.sfh__search-row > :first-child { flex: 0 1 auto; }
|
||||
.sfh__toggle {
|
||||
flex: 0 0 auto; background: none; border: none; cursor: pointer; font-size: 0.875rem;
|
||||
color: var(--ink); display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 2px;
|
||||
|
|
@ -240,15 +267,33 @@ function onClearAll() { emit('clear-all') }
|
|||
This row therefore still clips vertically ON PURPOSE (it must, to scroll horizontally) —
|
||||
which is why FilterDropdown's menu is position:FIXED and escapes the clip chain entirely
|
||||
rather than trying to out-z-index it. Do not "restore" an overflow-y here; it cannot work. */
|
||||
/* ★ CENTRED AS A GROUP, EVENLY SPACED (card BUILD-THE-FOUR-HEADERS-ARE-ONE-CENTRED-STACK).
|
||||
DRIVER: "evenly spaced out so that everything is centred, not justified left."
|
||||
|
||||
MEASURED BEFORE at 1600x900 standalone: the triggers started 14px from the left edge and ended
|
||||
242px from the right — a quarter-screen of dead space on one side only, which is precisely what
|
||||
"justified left" looks like in numbers.
|
||||
|
||||
justify-content:center is the whole fix, and it is SAFE HERE ONLY BECAUSE of the overflow rule
|
||||
below it. On an overflowing flex row `justify-content:center` is the classic trap: it centres
|
||||
the content by pushing the first item to a NEGATIVE offset, which is unreachable because a
|
||||
scroll container cannot scroll left of zero — the first dropdown would be permanently cut off.
|
||||
`justify-content: safe center` is the guard: it degrades to flex-start exactly when centring
|
||||
would overflow, so the row is centred while it fits and left-aligned (fully scrollable) once it
|
||||
does not. The BEFORE run measured the studio host already overflowing (right gutter -40), so
|
||||
this is a live case here, not a hypothetical.
|
||||
The unprefixed fallback is listed first for engines without `safe` — they get plain centring,
|
||||
which is the pre-existing behaviour for the fitting case and no worse for the other. */
|
||||
.sfh__filters-row {
|
||||
display: flex; flex-wrap: nowrap; align-items: center; gap: 10px;
|
||||
overflow-x: auto; padding-bottom: 4px;
|
||||
display: flex; flex-wrap: nowrap; align-items: center; gap: var(--dx-filter-gap, 10px);
|
||||
justify-content: center; justify-content: safe center;
|
||||
overflow-x: auto; padding-bottom: 2px;
|
||||
scrollbar-width: thin; -webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* keep each control at its width instead of collapsing into a 2nd line */
|
||||
/* keep each control at its width instead of collapsing into a 2nd line.
|
||||
The primitive now fixes its OWN width (--dx-filter-w), so this no longer has to fight it — it
|
||||
only has to stop the flex row from shrinking it, which flex-shrink:0 does. */
|
||||
.sfh__dd { flex: 0 0 auto; }
|
||||
/* override the primitive's mobile "flex:1 1 100%" so it never full-widths and wraps */
|
||||
@media (max-width: 576px) { .sfh__dd { flex: 0 0 auto; max-width: 240px; } }
|
||||
.sfh__filters-row::-webkit-scrollbar { height: 8px; }
|
||||
/* --dx-scroll-thumb is a DELIBERATE neutral, not a sage: it must read between line and
|
||||
muted on both white and surface, which a sage tint does not do (matches research-dashboard). */
|
||||
|
|
@ -267,64 +312,109 @@ function onClearAll() { emit('clear-all') }
|
|||
below it jumps on every click. min-height pins it; nowrap keeps a long selection on ONE line rather
|
||||
than growing to a second (flex-wrap was the other way this row could change height).
|
||||
★ min-height MUST be >= the tallest child, or it stops being a floor and the CHIP drives the
|
||||
height instead. Measured that failure here: at min-height:22px the empty row was 22px and the
|
||||
chipped row 24.25px, walking the map 180 -> 182.25px — the exact layout shift this row exists to
|
||||
prevent, reintroduced by a floor set too low. 24px clears the chip's 24.25px box once the chip's
|
||||
own border-box is trimmed to match, so the floor governs in EVERY state.
|
||||
24px is still well down from the old 28px — the Driver's "decrease the height of that row so it
|
||||
is not so large" — and the saving came from padding, not from the type, which went UP.
|
||||
★ THE ROW ITSELF NO LONGER SCROLLS. overflow-x moved down to .sfh__chip-strip. When it lived here
|
||||
the whole row was one scroll box, so Clear All — merely the last child — slid out of view under
|
||||
exactly the condition that makes it necessary. A non-scrolling rail with a scrolling middle child
|
||||
is what pins it. Do not put overflow back on this element.
|
||||
overflow: visible is also load-bearing: a clip here would re-eat the fixed FilterDropdown menu. */
|
||||
.sfh__chips { display: flex; flex-wrap: nowrap; gap: 8px; align-items: center;
|
||||
min-height: 24px; }
|
||||
height instead. Measured that failure here once already: at min-height:22px the empty row was 22px
|
||||
and the chipped row 24.25px, walking the map 180 -> 182.25px — the exact layout shift this row
|
||||
exists to prevent, reintroduced by a floor set too low. So the floor and the chip's own box are
|
||||
set TOGETHER, every time either moves.
|
||||
|
||||
★ 18px, AND THE OLD 24px MEASUREMENT IS NOT EVIDENCE IT WAS RIGHT
|
||||
(card BUILD-SELECTED-ROW-MUCH-THINNER-AND-CENTRED). DRIVER, twice in one message: "the selected
|
||||
row is way too tall, needs to be thinner, a lot thinner, a lot more subtle."
|
||||
A previous fix asserted rowH=24 identical across empty / one-chip / many-chip states and cited it
|
||||
as done. That measurement was TRUE and it proved the row does not RESIZE — it never said a word
|
||||
about whether 24 is the right number, which is a different question that only the Driver can
|
||||
answer. He has now answered it: still too tall. Do not re-cite a stability measurement as a
|
||||
sizing one.
|
||||
He named THREE height sources, so all three come down together — reducing only the row would
|
||||
have been the same partial fix again:
|
||||
1. the white space UNDER the filters -> .sfh gap 10 -> 6, .sfh__filters-row padding-bottom 4 -> 2
|
||||
2. the selected section itself -> this min-height 24 -> 18
|
||||
3. the CHIP BUTTONS -> .sfh__chip height 22 -> 18, font 0.9375rem -> 0.8125rem
|
||||
--dx-sel-row-h keeps the floor tokenized so a consumer can trade density back without forking.
|
||||
|
||||
★ CENTRED: justify-content:center, because the Driver puts the selections directly above the map
|
||||
— "that needs to be centred... right above the globe, centred, is going to be what is selected."
|
||||
★ THE ROW ITSELF STILL DOES NOT SCROLL. overflow-x lives on .sfh__chip-strip. That split is now
|
||||
doing a different job than when it was introduced (it was there to pin Clear All OUTSIDE the
|
||||
scroller; Clear All has since moved INSIDE, per its own card) — it is kept because a scroll box
|
||||
cannot also be the thing that centres its overflowing content, and because overflow here would
|
||||
re-eat the fixed FilterDropdown menu. Do not put overflow back on this element. */
|
||||
.sfh__chips { display: flex; flex-wrap: nowrap; gap: 6px; align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--dx-sel-row-h, 18px); }
|
||||
/* ★ THE ONLY SCROLLING ZONE. min-width:0 is REQUIRED, not decorative: a flex item defaults to
|
||||
min-width:auto, which floors it at its CONTENT width — the strip would then push Clear All out of
|
||||
the row instead of scrolling inside itself, reproducing the exact bug from the other direction.
|
||||
min-width:auto, which floors it at its CONTENT width, so the strip would refuse to shrink and
|
||||
would widen the row instead of scrolling inside itself.
|
||||
★ flex is now `0 1 auto`, not `1 1 auto`. Growing was only needed while Clear All sat OUTSIDE
|
||||
as a pinned sibling — the strip had to eat all the free space to shove it to the far right.
|
||||
That is the layout the Driver just reversed, so the growth term goes with it: at 0 1 auto the
|
||||
strip is only as wide as its chips (shrinking to fit when they overflow), which is what lets
|
||||
.sfh__chips centre it as a group rather than centring a full-width box that merely CONTAINS
|
||||
left-aligned chips. Keeping flex-grow here would have silently defeated the centring above.
|
||||
★ safe center for the same reason as the filters row: once the chips overflow, plain centring
|
||||
would push the first chip to a negative, unscrollable offset.
|
||||
overflow-y is omitted deliberately (see the .sfh__filters-row note): a visible y-axis computes
|
||||
to auto next to a non-visible x-axis, so naming it would clip nothing and mislead the reader. */
|
||||
.sfh__chip-strip {
|
||||
flex: 1 1 auto; min-width: 0; display: flex; flex-wrap: nowrap; gap: 8px; align-items: center;
|
||||
flex: 0 1 auto; min-width: 0; display: flex; flex-wrap: nowrap; gap: 6px; align-items: center;
|
||||
justify-content: center; justify-content: safe center;
|
||||
overflow-x: auto; scrollbar-width: thin; -webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.sfh__chip-strip::-webkit-scrollbar { height: 6px; }
|
||||
.sfh__chip-strip::-webkit-scrollbar-thumb { background: var(--dx-scroll-thumb, #c4c8c0); border-radius: 8px; }
|
||||
.sfh__chip-strip::-webkit-scrollbar-track { background: transparent; }
|
||||
/* The persistent prefix. flex:0 0 auto so it never shrinks away and the row always starts the same
|
||||
way, which is what makes the strip scannable at a glance. */
|
||||
.sfh__sel-label { flex: 0 0 auto; font-size: 0.8125rem; font-weight: 700; letter-spacing: .4px;
|
||||
text-transform: uppercase; color: var(--mut, #6d766e); }
|
||||
way, which is what makes the strip scannable at a glance.
|
||||
It moved INSIDE .sfh__chip-strip so it is part of the group being centred — left outside it, it
|
||||
would have been a fixed-width item that the centring had to work around, pushing the chips
|
||||
off-centre by its own width. It scrolls with the chips now, which is the same trade Clear All
|
||||
makes and for the same reason: the label describes the chips, so it belongs with them.
|
||||
0.6875rem + subtler colour: this is a LABEL, not content — the Driver asked for the row to read
|
||||
"a lot more subtle", and shrinking the all-caps prefix is the cheapest way to drop the row's
|
||||
visual weight without touching the chips' legibility. */
|
||||
.sfh__sel-label { flex: 0 0 auto; font-size: 0.6875rem; font-weight: 700; letter-spacing: .5px;
|
||||
text-transform: uppercase; color: var(--mut, #6d766e); opacity: .75; }
|
||||
/* The empty state. Muted rather than hidden: "World" is real information (nothing is filtered), and
|
||||
rendering it keeps the row's content box occupied so the height cannot collapse. */
|
||||
.sfh__sel-world { flex: 0 0 auto; font-size: 0.9375rem; color: var(--mut, #6d766e); }
|
||||
/* Slimmer but LARGER TEXT — the Driver asked for both: "increase the font slightly of the selected
|
||||
row and ... decrease the height of that row so it is not so large." The height came out of the
|
||||
VERTICAL PADDING (4px -> 0) and a tight line-height, not out of the type, so the row shrinks
|
||||
while the labels get easier to read (0.85rem -> 0.9375rem).
|
||||
rendering it keeps the row's content box occupied so the height cannot collapse. Tracks the chip
|
||||
type size so the empty and full states read at the same weight. */
|
||||
.sfh__sel-world { flex: 0 0 auto; font-size: 0.8125rem; color: var(--mut, #6d766e); }
|
||||
/* ★ SOURCE 3 OF THE THREE HEIGHT SOURCES — "the size of the buttons that show up whenever you
|
||||
select something." 22px -> 18px, and the type comes DOWN with it (0.9375rem -> 0.8125rem).
|
||||
NOTE THIS REVERSES AN EARLIER, CORRECTLY-FOLLOWED INSTRUCTION. A previous round was told
|
||||
"increase the font slightly of the selected row", and did: 0.85rem -> 0.9375rem. The Driver is
|
||||
now asking for the whole row to be "a lot more subtle", and at 18px a 0.9375rem glyph does not
|
||||
fit its box. The newer instruction wins; this is not a regression of the older one.
|
||||
white-space:nowrap keeps a two-word label on one line — a wrapped chip would grow the row and
|
||||
re-break the fixed-height invariant.
|
||||
★ height + border-box is what makes that invariant STRUCTURAL rather than lucky: without it the
|
||||
chip's height is a computed sum of font-size x line-height + padding + border, so a token change
|
||||
or a glyph with taller metrics could silently push past the row's 24px floor and reopen the
|
||||
layout shift. Pinning the box means the floor is provably the maximum. */
|
||||
or a glyph with taller metrics could silently push past the row's floor and reopen the layout
|
||||
shift. Pinning the box means the floor (18px, above) is provably the maximum. The two numbers
|
||||
are EQUAL on purpose and must be changed together. */
|
||||
.sfh__chip {
|
||||
flex: 0 0 auto; display: inline-flex; gap: 6px; align-items: center;
|
||||
flex: 0 0 auto; display: inline-flex; gap: 4px; align-items: center;
|
||||
background-color: var(--dx-accent-weak, rgba(146, 177, 149, .18));
|
||||
color: var(--ink); border: 1px solid var(--line); border-radius: 100px;
|
||||
padding: 0 10px; font-size: 0.9375rem; line-height: 1; white-space: nowrap;
|
||||
height: 22px; box-sizing: border-box;
|
||||
padding: 0 8px; font-size: 0.8125rem; line-height: 1; white-space: nowrap;
|
||||
height: var(--dx-sel-row-h, 18px); box-sizing: border-box;
|
||||
}
|
||||
.sfh__chip-x { background: none; border: none; cursor: pointer; font-size: 1.1em; line-height: 1; color: inherit; padding: 0; display: inline-flex; align-items: center; }
|
||||
.sfh__chip-x { background: none; border: none; cursor: pointer; font-size: 1.05em; line-height: 1; color: inherit; padding: 0; display: inline-flex; align-items: center; }
|
||||
.sfh__chip-x:hover { opacity: 0.7; }
|
||||
/* ★ PINNED, NEVER SCROLLED. flex:0 0 auto + nowrap keep it at full width outside the scroll strip,
|
||||
so it stays on screen at any chip count. The left border-and-padding separates it from a strip
|
||||
that may be mid-scroll underneath, so it reads as a fixed control rather than the last chip. */
|
||||
/* ★ THE LAST CHIP'S NEIGHBOUR, NOT A PINNED CONTROL
|
||||
(card BUILD-CLEAR-ALL-SITS-BESIDE-THE-SELECTIONS-NOT-FAR-RIGHT — "right next to the selected
|
||||
stuff", and "fancy Nancy", i.e. it should look like a light touch, not a button).
|
||||
It is now the strip's last child, so it inherits the strip's gap and simply follows the chips.
|
||||
THE LEFT BORDER IS GONE with the pinning that justified it: that rule read "separates it from a
|
||||
strip that may be mid-scroll UNDERNEATH" — there is no underneath any more, and a divider
|
||||
between a chip and the button that clears it would now draw a boundary where the relationship
|
||||
is the point. The extra left padding goes for the same reason.
|
||||
Type tracks the chips (0.8125rem) so it sits on the same baseline as its neighbours rather than
|
||||
out-shouting them. */
|
||||
.sfh__clear {
|
||||
flex: 0 0 auto; white-space: nowrap; background: none; border: none; cursor: pointer;
|
||||
font-size: 0.9375rem; color: var(--ink); text-decoration: underline;
|
||||
text-decoration-color: var(--brand); text-underline-offset: 4px;
|
||||
padding: 0 2px 0 10px; border-left: 1px solid var(--line);
|
||||
font-size: 0.8125rem; line-height: 1; color: var(--mut, #6d766e); text-decoration: underline;
|
||||
text-decoration-color: var(--brand); text-underline-offset: 3px;
|
||||
padding: 0 4px; margin-inline-start: 2px;
|
||||
}
|
||||
.sfh__clear:hover { color: var(--ink); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -362,9 +362,52 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl-tab-sub code { font-family:ui-monospace,Menlo,monospace; }
|
||||
.vtl-tab.active .vtl-tab-sub { color:var(--vtl-muted); }
|
||||
.vtl-main { position:absolute; top:0; bottom:0; right:0; left:256px; min-width:0; display:flex; flex-direction:column; }
|
||||
/* ★ contain:layout, NOT layout paint (BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID, measured
|
||||
2026-09-22). layout is the half that does the work the rail collapse needs — it isolates this
|
||||
subtree so sliding the rail never reflows a heavy mounted map. paint additionally CLIPS the
|
||||
subtree to this box, which silently swallowed every pixel a scroll-stack composite rendered below
|
||||
one viewport. The clipping now belongs to .vtl-host, which owns a real scrollbar, so the content
|
||||
is both contained AND reachable. Dropping paint costs the collapse nothing: paint never
|
||||
contributed reflow isolation, only clipping. */
|
||||
.vtl-content { flex:1 1 auto; position:relative; min-height:0; contain:layout paint; }
|
||||
/* ★ THE PANE OWNS THE SCROLL (BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID). Measured in the cap-5
|
||||
shell: the dashboard-plus-grid composite mounted here reported scrollHeight 3380 / clientHeight
|
||||
769 — 2611px of real, mounted, populated content (the people-groups grid, h=2586) that NO element
|
||||
could scroll to, because this box was position:absolute; inset:0 with the default
|
||||
overflow:visible. The document itself measured scrollHeight 900 === clientHeight 900, so a
|
||||
wheel gesture anywhere had nothing to move and the page simply sat still.
|
||||
THIS IS A HOST GAP, NOT A PLUGIN BUG. The same composite is PERFECT standalone (document
|
||||
2960/900, wheel scrolls to 2060, grid reachable) because its generated leaf sets
|
||||
body{overflow-y:auto} — the document owns the scroll there. In this shell the pane is pinned to
|
||||
viewport height and no ancestor scrolls, so the layout must own it. generate-tree.mjs keeps cap-5
|
||||
leaves fixed-viewport on the reasoning that "any scroll-stack it hosts is its OWN iframe leaf" —
|
||||
but the studio documents "there is NO iframe mode": every tab mounts its REAL custom element
|
||||
directly into this pane. So the scroll has to land here.
|
||||
WHY THIS IS SAFE FOR THE FIXED-VIEWPORT MAP TABS, which are the majority. A map element ships
|
||||
position:absolute; inset:0 (or height:100%) and therefore fills this box exactly:
|
||||
scrollHeight === clientHeight, so auto yields NO scrollbar and NOTHING changes for them. Only a
|
||||
pane whose content genuinely exceeds one viewport — i.e. a StackLayout scroll-stack, which is the
|
||||
composite that is meant to scroll — gets one. auto is load-bearing; scroll would paint a dead
|
||||
scrollbar on all 19 map tabs.
|
||||
overscroll-behavior:contain stops a wheel that bottoms out here from chaining to the document. */
|
||||
.vtl-host { position:absolute; inset:0; }
|
||||
/* The slot is the element's mount point. It must NOT be position:absolute; inset:0 any more: that
|
||||
pinned it to the pane's height and re-created the exact trap one level down — an absolutely
|
||||
positioned child cannot grow a scroll container. min-height:100% keeps a fixed-viewport map
|
||||
filling the pane (the behaviour absolute-inset gave it) while letting a taller scroll-stack
|
||||
composite push the pane's scrollHeight past its clientHeight, which is what produces the
|
||||
scrollbar. */
|
||||
.vtl-host-slot { position:absolute; inset:0; }
|
||||
/* ★ THE dvh FLOOR, NEUTRALISED FOR HOSTED ELEMENTS (measured 2026-09-22 in the studio, same layout
|
||||
shape). A hosted map ships .profile-loader { height:100%; min-height:100dvh }, and the two resolve
|
||||
against DIFFERENT bases: height:100% against this pane, min-height:100dvh against the VIEWPORT.
|
||||
Wherever the pane is shorter than the viewport (any header/toolbar chrome above it), the floor
|
||||
wins, the element overflows by exactly that chrome height, and the overflow-y:auto above then
|
||||
correctly paints a scrollbar on a fixed-viewport map that should never have one.
|
||||
The floor is DELIBERATE and must stay in the element (standalone it prevents a collapse to 0
|
||||
against an auto-height body). It is only wrong HERE, against a definite shorter pane. A genuine
|
||||
scroll-stack composite is unaffected: it exceeds the pane on its own content, not on this floor. */
|
||||
.vtl-host-slot > * { min-height:0; max-height:none; }
|
||||
.vtl-host-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:var(--dx-muted, #6d766e); font-size:13px; padding:20px; }
|
||||
.vtl-host-hint.err { color:#f87171; }
|
||||
.vtl-host-hint small { color:var(--dx-muted, #6d766e); }
|
||||
|
|
@ -741,9 +784,52 @@ export default defineComponent({
|
|||
.vtl-tab-sub code { font-family:ui-monospace,Menlo,monospace; }
|
||||
.vtl-tab.active .vtl-tab-sub { color:var(--vtl-muted); }
|
||||
.vtl-main { position:absolute; top:0; bottom:0; right:0; left:256px; min-width:0; display:flex; flex-direction:column; }
|
||||
/* ★ contain:layout, NOT layout paint (BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID, measured
|
||||
2026-09-22). layout is the half that does the work the rail collapse needs — it isolates this
|
||||
subtree so sliding the rail never reflows a heavy mounted map. paint additionally CLIPS the
|
||||
subtree to this box, which silently swallowed every pixel a scroll-stack composite rendered below
|
||||
one viewport. The clipping now belongs to .vtl-host, which owns a real scrollbar, so the content
|
||||
is both contained AND reachable. Dropping paint costs the collapse nothing: paint never
|
||||
contributed reflow isolation, only clipping. */
|
||||
.vtl-content { flex:1 1 auto; position:relative; min-height:0; contain:layout paint; }
|
||||
/* ★ THE PANE OWNS THE SCROLL (BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID). Measured in the cap-5
|
||||
shell: the dashboard-plus-grid composite mounted here reported scrollHeight 3380 / clientHeight
|
||||
769 — 2611px of real, mounted, populated content (the people-groups grid, h=2586) that NO element
|
||||
could scroll to, because this box was position:absolute; inset:0 with the default
|
||||
overflow:visible. The document itself measured scrollHeight 900 === clientHeight 900, so a
|
||||
wheel gesture anywhere had nothing to move and the page simply sat still.
|
||||
THIS IS A HOST GAP, NOT A PLUGIN BUG. The same composite is PERFECT standalone (document
|
||||
2960/900, wheel scrolls to 2060, grid reachable) because its generated leaf sets
|
||||
body{overflow-y:auto} — the document owns the scroll there. In this shell the pane is pinned to
|
||||
viewport height and no ancestor scrolls, so the layout must own it. generate-tree.mjs keeps cap-5
|
||||
leaves fixed-viewport on the reasoning that "any scroll-stack it hosts is its OWN iframe leaf" —
|
||||
but the studio documents "there is NO iframe mode": every tab mounts its REAL custom element
|
||||
directly into this pane. So the scroll has to land here.
|
||||
WHY THIS IS SAFE FOR THE FIXED-VIEWPORT MAP TABS, which are the majority. A map element ships
|
||||
position:absolute; inset:0 (or height:100%) and therefore fills this box exactly:
|
||||
scrollHeight === clientHeight, so auto yields NO scrollbar and NOTHING changes for them. Only a
|
||||
pane whose content genuinely exceeds one viewport — i.e. a StackLayout scroll-stack, which is the
|
||||
composite that is meant to scroll — gets one. auto is load-bearing; scroll would paint a dead
|
||||
scrollbar on all 19 map tabs.
|
||||
overscroll-behavior:contain stops a wheel that bottoms out here from chaining to the document. */
|
||||
.vtl-host { position:absolute; inset:0; }
|
||||
/* The slot is the element's mount point. It must NOT be position:absolute; inset:0 any more: that
|
||||
pinned it to the pane's height and re-created the exact trap one level down — an absolutely
|
||||
positioned child cannot grow a scroll container. min-height:100% keeps a fixed-viewport map
|
||||
filling the pane (the behaviour absolute-inset gave it) while letting a taller scroll-stack
|
||||
composite push the pane's scrollHeight past its clientHeight, which is what produces the
|
||||
scrollbar. */
|
||||
.vtl-host-slot { position:absolute; inset:0; }
|
||||
/* ★ THE dvh FLOOR, NEUTRALISED FOR HOSTED ELEMENTS (measured 2026-09-22 in the studio, same layout
|
||||
shape). A hosted map ships .profile-loader { height:100%; min-height:100dvh }, and the two resolve
|
||||
against DIFFERENT bases: height:100% against this pane, min-height:100dvh against the VIEWPORT.
|
||||
Wherever the pane is shorter than the viewport (any header/toolbar chrome above it), the floor
|
||||
wins, the element overflows by exactly that chrome height, and the overflow-y:auto above then
|
||||
correctly paints a scrollbar on a fixed-viewport map that should never have one.
|
||||
The floor is DELIBERATE and must stay in the element (standalone it prevents a collapse to 0
|
||||
against an auto-height body). It is only wrong HERE, against a definite shorter pane. A genuine
|
||||
scroll-stack composite is unaffected: it exceeds the pane on its own content, not on this floor. */
|
||||
.vtl-host-slot > * { min-height:0; max-height:none; }
|
||||
.vtl-host-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:var(--dx-muted, #6d766e); font-size:13px; padding:20px; }
|
||||
.vtl-host-hint.err { color:#f87171; }
|
||||
.vtl-host-hint small { color:var(--dx-muted, #6d766e); }
|
||||
|
|
|
|||
|
|
@ -13,8 +13,35 @@
|
|||
import { defineCustomElement } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createAppI18n } from '@map/i18n/index.js'
|
||||
import { registerStrategies } from '@map/colors/_registry.js'
|
||||
import affinityBlock from '../doxa-research-map/src/colors/affinity-block.js'
|
||||
import doxaRegion from '../doxa-research-map/src/colors/doxa-region.js'
|
||||
import DashboardPlusGrid from './dashboard-plus-grid.vue'
|
||||
|
||||
// ─── THE SAME BYPASS AS THE i18n BUG BELOW, SECOND LEG (card BUILD-MAP-STYLE-...) ────────────
|
||||
// The note below explains that importing research-dashboard.vue DIRECTLY bypasses
|
||||
// plugin-designs/research-dashboard/index.js. That entry installs i18n — which was fixed — AND
|
||||
// registers the two PROFILE-LOCAL colour strategies the dashboard needs (index.js:48). Only the
|
||||
// i18n leg was fixed; this is the colour leg of the identical root cause.
|
||||
//
|
||||
// `affinityBlock` and `doxaRegion` are bundle-private to doxa-research-map, so without this the
|
||||
// composite's registry simply does not contain them. MEASURED in the cap-5 shell 2026-09-22:
|
||||
// the Region donut, the Top-countries bars and the Place tree swatches all rendered GREY, while
|
||||
// the very same build rendered them correctly in the standalone host.
|
||||
//
|
||||
// THE FAILURE IS SILENT BY DESIGN, which is why it survived: getColorStrategy() falls back to
|
||||
// language-family for an unregistered mode, and every consumer guards on propertyKey and
|
||||
// returns a neutral instead of throwing. Nothing errors; the colour is just quietly absent.
|
||||
//
|
||||
// Imported EXPLICITLY, not via import.meta.glob — a cross-bundle relative glob resolves against
|
||||
// THIS file and fails SILENTLY if it stops matching (zero strategies, grey swatches), whereas a
|
||||
// named import fails loudly at build time. Registering the SAME modules means one palette, not
|
||||
// a copied one.
|
||||
registerStrategies({
|
||||
'./affinity-block.js': { default: affinityBlock },
|
||||
'./doxa-region.js': { default: doxaRegion },
|
||||
})
|
||||
|
||||
// ─── configureApp is NOT optional for a BAKED composite ──────────────────────
|
||||
// BUG-VUE-I18N-CODE27-SYNTAXERROR-ON-EVERY-MOUNT. dashboard-plus-grid.vue imports
|
||||
// research-dashboard.vue DIRECTLY, which bypasses plugin-designs/research-dashboard/index.js
|
||||
|
|
|
|||
|
|
@ -31,7 +31,14 @@ export const AFFINITY_BLOCK_NAMES = {
|
|||
'A013': 'Tibetan-Himalayan Peoples',
|
||||
'A014': 'Turkic Peoples',
|
||||
'A015': 'Latin-Caribbean Americans',
|
||||
'A017': 'Deaf'
|
||||
'A017': 'Deaf',
|
||||
// A018/A019 are LIVE on the pray-tools API but were absent here, so 134 rows (6.4% of
|
||||
// 2,107, measured 2026-09-22) fell through to the grey fallback and rendered as
|
||||
// uncoloured rows in the semantic tree legend — the "bunch of grey colours" reported.
|
||||
// Names are the API's OWN rop1 labels, not invented: A018 -> "Tibetan Peoples"
|
||||
// (102 rows), A019 -> "Himalayan Peoples" (32 rows).
|
||||
'A018': 'Tibetan Peoples',
|
||||
'A019': 'Himalayan Peoples'
|
||||
}
|
||||
|
||||
/** Primary mapping: ROP1 Code → Color */
|
||||
|
|
@ -51,7 +58,19 @@ export const PALETTE = {
|
|||
'A013': '#8E44AD', // Tibetan-Himalayan Peoples - Purple
|
||||
'A014': '#D35400', // Turkic Peoples - Burnt orange
|
||||
'A015': '#27AE60', // Latin-Caribbean Americans - Forest green
|
||||
'A017': '#000000' // Deaf - Black (universal accessibility)
|
||||
'A017': '#000000', // Deaf - Black (universal accessibility)
|
||||
// ── A018 / A019: the Tibetan-Himalayan SPLIT ────────────────────────────────
|
||||
// These two are not new peoples — they are the finer-grained split of A013
|
||||
// 'Tibetan-Himalayan Peoples'. They therefore keep A013's PURPLE identity and vary
|
||||
// only in value, so the three read as one family at a glance instead of as three
|
||||
// unrelated hues. Chosen by measurement against the Material Light surface #f3f3f1
|
||||
// (WCAG 2.1 SC 1.4.11 non-text minimum 3.0:1):
|
||||
// A013 #8E44AD 5.28 (unchanged) A018 #6C3483 7.69 A019 #A569BD 3.53
|
||||
// Sibling separation so the three stay tellable apart: A018-vs-A019 2.61,
|
||||
// A013-vs-A018 1.46, A013-vs-A019 1.79.
|
||||
// REJECTED #AF7AC5 for A019 — scored 2.95, just under the 3.0 floor.
|
||||
'A018': '#6C3483', // Tibetan Peoples - deep purple (A013 family, darker)
|
||||
'A019': '#A569BD' // Himalayan Peoples - light purple (A013 family, lighter)
|
||||
}
|
||||
|
||||
export const AFFINITY_BLOCK_COLORS = PALETTE
|
||||
|
|
|
|||
|
|
@ -179,8 +179,49 @@ const PS_SHADOW_CSS = `
|
|||
.ps-embed label { font-size:12px; color:#6d766e; display:flex; justify-content:space-between; align-items:center; }
|
||||
.ps-embed textarea { width:100%; height:58px; font:11px/1.35 ui-monospace,Menlo,monospace; border:1px solid #d1d4cd; border-radius:6px; background:#ffffff; color:#3b463d; padding:6px; resize:vertical; }
|
||||
.ps-stage { position:absolute; inset:10px 12px 12px; border:1px solid #d1d4cd; border-radius:10px; overflow:hidden; background:#f3f3f1; }
|
||||
/* ★ THE PANE OWNS THE SCROLL (BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID, measured 2026-09-22).
|
||||
A scroll-stack composite (dashboard-plus-grid: an 88svh dashboard band + the people-groups grid
|
||||
below it) mounted here measured scrollHeight 3380 / clientHeight 769 — 2611px of real, mounted,
|
||||
populated content that NO element could scroll to, because this pane was position:absolute;
|
||||
inset:0 with the default overflow:visible inside a .ps-stage that is overflow:hidden.
|
||||
Document scrollHeight was 900, exactly === clientHeight, so a wheel gesture had nothing to move.
|
||||
The identical composite is PERFECT standalone (document 2960/900, wheel reaches 2060) because its
|
||||
generated leaf gives the DOCUMENT the scroll — a host that pins the pane to viewport height has
|
||||
to own that scroll itself.
|
||||
auto, not scroll: only a pane whose content really exceeds its box gets a scrollbar, so the map
|
||||
tabs stay scrollbar-free (see the dvh floor note below, which is what makes that true). */
|
||||
.ps-pane { position:absolute; inset:0; }
|
||||
/* NOT absolute-inset: that pinned the mount point to the pane's height and re-created the same trap
|
||||
one level down (an absolutely positioned child cannot grow a scroll container). min-height:100%
|
||||
keeps a fixed-viewport map filling the pane while letting a taller composite grow it. */
|
||||
.ps-slot { position:absolute; inset:0; }
|
||||
/* ★ THE dvh FLOOR, NEUTRALISED FOR HOSTED ELEMENTS (measured 2026-09-22, caught by the regression
|
||||
check on this very fix — my first version of the rule above was WRONG about this).
|
||||
I claimed a fixed-viewport map "fills this box exactly, so auto is a no-op for it". It is not.
|
||||
doxa-research-map ships .profile-loader { height:100%; min-height:100dvh }, and those two resolve
|
||||
against DIFFERENT bases: height:100% against the pane (769px here, because the pane sits inside a
|
||||
.ps-stage inset by the studio chrome), min-height:100dvh against the VIEWPORT (900px). The floor
|
||||
wins, the element is 131px taller than its container, and overflow:auto then correctly reported a
|
||||
scrollbar on a map tab that should never have one — a real regression my reasoning had ruled out.
|
||||
The dvh floor is deliberate and must stay for the STANDALONE leaf, where it fixes the opposite
|
||||
bug (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT: min-height:100% against a body with no definite
|
||||
height collapses to 0). It is only wrong HERE, where the pane is a definite box shorter than the
|
||||
viewport. So neutralise it on the hosted subtree rather than editing the map: a direct child of a
|
||||
studio pane is bounded by the PANE. A taller-than-viewport scroll-stack composite is unaffected —
|
||||
it grows past this floor on its own content, which is what makes the grid reachable.
|
||||
|
||||
★ AND --profile-min-h:0 ON THE PANE, which is the half that actually does the work. Sizing the
|
||||
host is NOT enough, and measuring proved it: the host correctly became 769px while the pane still
|
||||
overflowed by exactly 131px, because .profile-loader lives INSIDE the map's SHADOW ROOT at
|
||||
min-height:100dvh and a host-page SELECTOR cannot reach into a shadow tree. Inherited properties
|
||||
CAN, so ProfileLoader.vue now reads min-height:var(--profile-min-h, 100dvh) and this pane sets it
|
||||
to 0 — the floor keeps its default everywhere else, including every standalone leaf where it
|
||||
still fixes BUG-UPG100.
|
||||
★ NOT overflow:clip, which I tried first and MEASURED as wrong: it bounded the map (good) but
|
||||
ALSO bounded the scroll-stack composite, whose host box is the pane height while its content is
|
||||
3380px — so the grid became unreachable again and the gate went straight back to 0px scrolled.
|
||||
One blanket rule cannot serve both; the variable targets the actual culprit instead. */
|
||||
.ps-slot > * { min-height:0; max-height:none; }
|
||||
.ps-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#6d766e; font-size:13px; padding:20px; }
|
||||
.ps-hint.err { color:#f87171; }
|
||||
.ps-hint small { color:#6d766e; }
|
||||
|
|
@ -584,8 +625,25 @@ export default defineComponent({
|
|||
.ps-embed label { font-size: 12px; color: #6d766e; display: flex; justify-content: space-between; align-items: center; }
|
||||
.ps-embed textarea { width: 100%; height: 58px; font: 11px/1.35 ui-monospace, Menlo, monospace; border: 1px solid #d1d4cd; border-radius: 6px; background: #ffffff; color: #3b463d; padding: 6px; resize: vertical; }
|
||||
.ps-stage { position: absolute; inset: 10px 12px 12px; border: 1px solid #d1d4cd; border-radius: 10px; overflow: hidden; background: #f3f3f1; }
|
||||
/* Keep in sync with the injected copy above (BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID). THE PANE
|
||||
OWNS THE SCROLL: a scroll-stack composite mounted here measured scrollHeight 3380 / clientHeight
|
||||
769 — 2611px of mounted, populated content no element could scroll to, because this pane was
|
||||
position:absolute; inset:0 with the default overflow:visible inside an overflow:hidden stage.
|
||||
auto, not scroll: a fixed-viewport map fills the box exactly and gets NO scrollbar, so the map
|
||||
tabs are untouched. */
|
||||
.ps-pane { position: absolute; inset: 0; }
|
||||
/* NOT absolute-inset: that pinned the mount point to the pane's height and re-created the trap one
|
||||
level down. min-height:100% keeps a map filling the pane while letting a taller composite grow it. */
|
||||
.ps-slot { position: absolute; inset: 0; }
|
||||
/* THE dvh FLOOR, NEUTRALISED FOR HOSTED ELEMENTS — keep in sync with the injected copy above.
|
||||
doxa-research-map ships .profile-loader { height:100%; min-height:100dvh }: height:100% resolves
|
||||
against the 769px pane, min-height:100dvh against the 900px VIEWPORT, so the floor made a map tab
|
||||
131px taller than its container and overflow:auto correctly grew a scrollbar on it. The floor is
|
||||
right for the standalone leaf and wrong only here, where the pane is a definite shorter box.
|
||||
--profile-min-h:0 on the pane is the half that does the work: the floor is inside the map's SHADOW
|
||||
ROOT, which no host-page selector can reach, but a custom property DOES inherit across that
|
||||
boundary. overflow:clip was tried and measured wrong — it also clipped the tall composite. */
|
||||
.ps-slot > * { min-height: 0; max-height: none; }
|
||||
.ps-hint { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: #6d766e; font-size: 13px; padding: 20px; }
|
||||
.ps-hint.err { color: #f87171; }
|
||||
.ps-hint small { color: #6d766e; }
|
||||
|
|
|
|||
|
|
@ -168,6 +168,16 @@ useShadowStyles(`
|
|||
So the widths SWAP ROLES: the tree takes the wider column, the charts the narrower.
|
||||
The pair still sums to 560px, so the centre map cell is unchanged. */
|
||||
--facet-w:296px; --rail-w:264px;
|
||||
/* ── ★ ONE EDGE GUTTER FOR THE WHOLE HEADER STACK ────────────────────────────────────
|
||||
(card BUILD-THE-FOUR-HEADERS-ARE-ONE-CENTRED-STACK: "on the edges there should be padding
|
||||
so that everything lines up perfectly.")
|
||||
MEASURED BEFORE at 1600x900: topbar padding 14px, filters band 14px, KPI strip 16px. The
|
||||
KPI band was inset 2px further than the two bands above it, so the stack's left edge was a
|
||||
2px staircase rather than a line. Nobody chose that — each band picked its own number
|
||||
(.rd-stats reached for the generic --s4 spacing step, the other two were hand-set to 14).
|
||||
--rd-edge is now the single source; all three bands resolve it, so they cannot drift again
|
||||
and a consumer can retune the whole stack's margin with one value. */
|
||||
--rd-edge:14px;
|
||||
--fs-xs:10px; --fs-sm:11px; --fs-md:12px; --fs-lg:13px; --fs-xl:16px;
|
||||
--r-lg:10px; --r-md:6px; --r-pill:999px; }
|
||||
.profile-loader{ width:100%; height:100%; }
|
||||
|
|
@ -227,7 +237,18 @@ useShadowStyles(`
|
|||
.rd-rz:hover::after{ background:var(--rd-accent,#e67e22); }
|
||||
.rd-rz:active{ background:color-mix(in srgb, var(--rd-accent,#e67e22) 18%, transparent); }
|
||||
.rd-rz:active::after{ background:var(--rd-accent,#e67e22); }
|
||||
.rd-stats{ display:flex; align-items:center; gap:var(--s5,24px); height:100%; padding:0 var(--s4,16px);
|
||||
/* ★ BAND 4 OF THE CENTRED STACK — the PROGRESS header (UPGs / scope / population / reach /
|
||||
countries / unreached / unadopted). Two changes, both from the one-centred-stack card:
|
||||
- padding now resolves --rd-edge instead of --s4, so its gutter matches the topbar and the
|
||||
filters band exactly (it was 16 against their 14 — measured).
|
||||
- justify-content:safe center, so the KPI tiles read as a centred group like the bands
|
||||
above rather than a left-justified run. MEASURED BEFORE: the 6 tiles spanned x=16..852
|
||||
inside a 1600px band — i.e. they occupied the left half and left ~750px of white space on
|
||||
the right, which is the "justified left" the Driver is describing.
|
||||
safe matters here as much as on the filter row: this band is overflow-x:auto, and plain
|
||||
centring on an overflowing scroller pushes the first tile to a negative, unreachable offset. */
|
||||
.rd-stats{ display:flex; align-items:center; gap:var(--s5,24px); height:100%; padding:0 var(--rd-edge,14px);
|
||||
justify-content:center; justify-content:safe center;
|
||||
background:var(--rd-panel,#fff); overflow-x:auto; box-sizing:border-box; }
|
||||
.rd-stats .kpi{ display:flex; align-items:baseline; gap:6px; white-space:nowrap; }
|
||||
.rd-stats .kpi .n{ font-weight:800; font-size:15px; color:var(--rd-ink, #3b463d); transition:color .2s; }
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
import { inject, computed } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { aggregate, buildDonut, buildBars, lbl } from '../../composables/useDashboardAggregations.js'
|
||||
import { aggregate, buildDonut, buildBars, lbl, buildCountryRegionColors } from '../../composables/useDashboardAggregations.js'
|
||||
import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDashboardShell.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
|
|
@ -28,16 +28,33 @@ function rowsExcluding(exclude) {
|
|||
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }) && matchSearch(r))
|
||||
}
|
||||
|
||||
// COUNTRY -> REGION hue (the Driver's "countries should match the colour of their regions").
|
||||
// A country has no palette of its own, so the Top-countries bar inherits the hue of its
|
||||
// MAJORITY region — the same hex the Region donut and the map legend use for that region.
|
||||
// Computed off the FULL row store (not the cross-filtered set) so a country keeps one stable
|
||||
// colour no matter what is filtered; a colour that moved with the filter is precisely the
|
||||
// defect this whole change removes.
|
||||
const countryRegionColors = computed(() => {
|
||||
const countryAcc = cfg.facets?.country_code?.get
|
||||
const regionAcc = cfg.facets?.wagf_region?.get
|
||||
if (!countryAcc || !regionAcc) return null
|
||||
return buildCountryRegionColors(dash?.rows?.value || [], countryAcc, regionAcc)
|
||||
})
|
||||
|
||||
const charts = computed(() => (cfg.charts || []).map((c) => {
|
||||
const acc = cfg.facets?.[c.facet]?.get || ((r) => lbl(r[c.facet]))
|
||||
// Exclude this chart's own facet so it keeps ALL its slices while highlighting the active one.
|
||||
const agg = aggregate(rowsExcluding([c.facet]), acc, c.topN)
|
||||
// Colour is now keyed by FACET + VALUE (registry palettes), never by slice index.
|
||||
const overrides = c.facet === 'country_code' ? countryRegionColors.value : null
|
||||
return {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
facet: c.facet,
|
||||
title: c.title,
|
||||
geometry: c.type === 'bar' ? buildBars(agg) : buildDonut(agg),
|
||||
geometry: c.type === 'bar'
|
||||
? buildBars(agg, c.facet, overrides)
|
||||
: buildDonut(agg, 132, 0.58, c.facet, overrides),
|
||||
}
|
||||
}))
|
||||
const isActive = (facet, key) => filterSet[facet] === key
|
||||
|
|
@ -54,6 +71,26 @@ function onChartClick(e) {
|
|||
}
|
||||
|
||||
useShadowStyles(`
|
||||
/* ══ READ THIS BEFORE YOU VERIFY A LABEL IN THIS FILE ══════════════════════════════════════
|
||||
Every text label here (.rd-lt, .rd-bl) is a -webkit-line-clamp box. THE OBVIOUS TEST FOR
|
||||
"is this text cut off" IS WRONG ON THEM:
|
||||
|
||||
el.scrollWidth > el.clientWidth // <-- DOES NOT DETECT A CLAMP
|
||||
|
||||
A clamp truncates VERTICALLY, so horizontal metrics stay clean while line 4 is being cut.
|
||||
Measured on the live composite 2026-09-22: that test reported 0 of 16 legend labels clipped
|
||||
while 4 were actually truncated, including the two the Driver had just complained about.
|
||||
A green result from it is not evidence.
|
||||
|
||||
USE A STRUCTURALLY DIFFERENT MEASUREMENT — clone the label, strip the clamp, let it lay out
|
||||
at the SAME width, and compare the height it WANTS to the height it GOT:
|
||||
|
||||
naturalH(el) <= el.getBoundingClientRect().height // true = every glyph is on screen
|
||||
|
||||
This is the fourth time in this repo that a verification instrument has returned a
|
||||
confident wrong answer. When a gate disagrees with what the screen plainly shows, suspect
|
||||
the gate, and cross-check with a measurement that fails for different reasons.
|
||||
═══════════════════════════════════════════════════════════════════════════════════════ */
|
||||
/* SPACE ECONOMY (BUILD-SPACE-ECONOMY-SCROLLBARS-CHARTS-MAP): the rail is a narrow column, so
|
||||
every horizontal pixel of padding is taken from the charts themselves. Padding is trimmed
|
||||
12px -> 6px, and the RIGHT padding is dropped to 0 so the thin overlay scrollbar sits flush
|
||||
|
|
|
|||
|
|
@ -128,8 +128,17 @@ function onClearAll() { bus?.clearFilters() }
|
|||
useShadowStyles(`
|
||||
/* header-2 host: the research-filters band sits in the dashboard's dedicated 'filters' grid row.
|
||||
z-index high so the dropdown menus render ABOVE the stats/map/table below. */
|
||||
/* ★ EDGE GUTTER COMES FROM --rd-edge, THE STACK'S ONE SOURCE (card
|
||||
BUILD-THE-FOUR-HEADERS-ARE-ONE-CENTRED-STACK). It was a hand-set 14px here and 16px on the
|
||||
KPI strip below, so the stack's edges formed a 2px staircase instead of a line — measured,
|
||||
not guessed. The horizontal value must never be re-literalised.
|
||||
★ The VERTICAL padding drops 8px -> 5px: it is part of "the white space underneath the
|
||||
filters" the Driver named (card BUILD-SELECTED-ROW-MUCH-THINNER-AND-CENTRED). The band's own
|
||||
padding, the header's inter-row gap and the selections row's height are three different
|
||||
owners of what looks like one strip of white space, which is why trimming any one of them
|
||||
alone has never been enough. */
|
||||
.rd-research-filters{ position:relative; z-index:55; height:100%; box-sizing:border-box;
|
||||
padding:8px 14px; background:var(--rd-panel,#fff); border-bottom:1px solid var(--rd-line, #d1d4cd);
|
||||
padding:5px var(--rd-edge,14px); background:var(--rd-panel,#fff); border-bottom:1px solid var(--rd-line, #d1d4cd);
|
||||
overflow:visible; }
|
||||
`, 'rd-research-filters')
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -168,7 +168,10 @@ useShadowStyles(`
|
|||
/* --rd-search-w is the ONE source of truth for the centred search's width; both the search box
|
||||
itself and the chip strip's right-hand bound are derived from it, so the two can never drift
|
||||
(a hardcoded second copy is what let the chips run under the search — see .rd-chips below). */
|
||||
.rd-topbar{ position:relative; display:flex; align-items:center; gap:12px; height:100%; padding:0 14px;
|
||||
/* padding resolves --rd-edge (the header stack's ONE edge gutter, defined on :host in
|
||||
research-dashboard.vue) so this bar, the filters band and the KPI strip share an edge. They
|
||||
were 14 / 14 / 16 — a measured 2px staircase. Do not re-literalise the horizontal value. */
|
||||
.rd-topbar{ position:relative; display:flex; align-items:center; gap:12px; height:100%; padding:0 var(--rd-edge,14px);
|
||||
--rd-search-w:min(360px,34%);
|
||||
background:var(--rd-header,#4e594f); color:var(--rd-header-ink,#f3f3f1); border-bottom:1px solid var(--rd-header-line,#3c453d);
|
||||
box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
|
||||
|
|
|
|||
|
|
@ -4,14 +4,136 @@
|
|||
* Hand-rolled (no ECharts: starter-kit node_modules is a symlink into frozen doxa-life — can't
|
||||
* install). SVG donut/bar geometry is pure math, renders in the shadow root, tweens via CSS.
|
||||
*/
|
||||
export const REGION_COLORS = {
|
||||
'Africa': '#e74c3c', 'Asia': '#3498db', 'Europe': '#2ecc71', 'Latin America': '#f39c12',
|
||||
'Middle East': '#9b59b6', 'North America': '#1abc9c', 'Oceania': '#e67e22', 'Other': '#95a5a6',
|
||||
}
|
||||
export const CATEGORICAL = ['#2456c7', '#e67e22', '#16a085', '#8e44ad', '#c0392b', '#27ae60', '#2c3e50', '#d35400', '#2980b9', '#7f8c8d', '#f39c12', '#9b59b6', '#1abc9c', '#34495e', '#e74c3c']
|
||||
import { getColorStrategy, COLOR_MODES } from '@map/colors/_registry.js'
|
||||
import { PALETTE as RELIGION_PALETTE, RELIGION_FAMILIES, getReligionFamily } from '@map/colors/religion.js'
|
||||
|
||||
/**
|
||||
* ── ONE PALETTE FOR MAP, LEGEND AND CHARTS (card BUILD-MAP-STYLE-MUST-BE-BEAUTIFUL) ──────────
|
||||
*
|
||||
* WHAT WAS WRONG. This module used to declare TWO private palettes — a label-keyed
|
||||
* REGION_COLORS and a 15-entry CATEGORICAL — that the map and legend had never seen. Every
|
||||
* chart colour came from one line:
|
||||
*
|
||||
* colorFor(key, i) => REGION_COLORS[key] || CATEGORICAL[i % CATEGORICAL.length]
|
||||
*
|
||||
* Two distinct defects came out of that, and the second is a correctness bug, not a taste one:
|
||||
*
|
||||
* 1) REGION near-misses. Both tables had an 'Africa' and they disagreed (#e74c3c chart vs
|
||||
* #ff2e2e map); Latin America was ORANGE #f39c12 in the chart and YELLOW #FFEB3B on the
|
||||
* map. A near-miss reads as a rendering artefact rather than a bug, which is worse.
|
||||
*
|
||||
* 2) RELIGION coloured BY ARRAY INDEX. REGION_COLORS held no religion keys at all, so every
|
||||
* religion fell to CATEGORICAL[i] — its position in a list sorted BY COUNT. The counts
|
||||
* move whenever you cross-filter, so a religion CHANGED COLOUR AS YOU FILTERED.
|
||||
* MEASURED 2026-09-22 by simulating a cross-filter that reorders four religions:
|
||||
* Islam #2456c7 -> #e67e22 Hinduism #e67e22 -> #2456c7
|
||||
* Buddhism #16a085 -> #8e44ad Christianity #8e44ad -> #16a085
|
||||
* All four swapped. That is not a palette mismatch — it is colour carrying no meaning.
|
||||
*
|
||||
* THE RULE NOW. A categorical colour is keyed by the VALUE, never by the index. Every hex is
|
||||
* read from the SAME colour-strategy modules the map's pins paint with, via the auto-discovering
|
||||
* registry at library/colors/_registry.js. This module declares NO palette of its own — the same
|
||||
* standard useTreeLegendColors.js already documents and obeys. It is a DELETION, not a new
|
||||
* abstraction: the registry already existed and was already load-bearing for map + legend.
|
||||
*
|
||||
* WHY A PER-FACET ADAPTER AND NOT ONE LOOKUP. Each palette is keyed by a different shape, and
|
||||
* the chart's aggregation key (the facet accessor's output) matches only one of them directly:
|
||||
*
|
||||
* chart facet agg key is palette wants -> adapter must
|
||||
* wagf_region 'Asia' (localized LABEL) slug 'asia' -> label->slug + alias
|
||||
* religion 'Islam - Sunni' (LABEL) family letter 'M' -> first letter of the CODE
|
||||
* country_code 'India' (no country palette) -> NEUTRAL, see below
|
||||
*
|
||||
* Religion is the sharp one: the aggregation key is the localized label, but religion.js colours
|
||||
* off the CODE's first letter. getReligionFamily() is the map's OWN resolver, reused here rather
|
||||
* than re-implemented, so chart and pin can never drift.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A deliberately NEUTRAL tone for a value this facet has no palette for — matching the tree
|
||||
* legend's NEUTRAL exactly. An uncoloured slice must read as "no colour strategy for this key",
|
||||
* never as a confident member of some other family. Critically it is STABLE: the same key gets
|
||||
* the same neutral every time, so nothing changes colour when a filter reorders the list.
|
||||
*/
|
||||
export const NEUTRAL = '#c4c8c0'
|
||||
|
||||
export const lbl = (v) => (v === null || v === undefined ? '' : (typeof v === 'object' ? (v.label ?? v.value ?? '') : v))
|
||||
|
||||
// Place: the facet emits the localized label but the authoritative palette is slug-keyed
|
||||
// (doxa-region.js documents that the label is localized per `lang` and the slug is stable).
|
||||
// Derive the slug the way the API does, and keep the label-keyed aliases as the fallback.
|
||||
const toSlug = (label) => String(label || '').trim().toLowerCase().replace(/\s+/g, '_')
|
||||
|
||||
/**
|
||||
* Read a registered strategy's palette, GUARDED on propertyKey.
|
||||
* getColorStrategy() silently falls back to language-family for an unregistered mode, so an
|
||||
* unguarded read would paint Region slices with LANGUAGE colours — plausible-looking and
|
||||
* entirely wrong. Same guard, same reason, as useTreeLegendColors.js.
|
||||
*/
|
||||
function guardedStrategy(mode, propertyKey) {
|
||||
const strat = getColorStrategy(mode)
|
||||
return (strat && strat.propertyKey === propertyKey) ? strat : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Religion LABEL -> family letter.
|
||||
*
|
||||
* ★ CAUGHT BY LOOKING AT A RENDERED MAP, NOT BY A CONTRAST NUMBER (2026-09-22).
|
||||
* The `religion` chart facet emits the DISPLAY LABEL ('Islam - Sunni', 'Buddhism - Tibetan'),
|
||||
* while religion.js keys its palette on the family letter taken from the CODE ('MSN' -> 'M').
|
||||
* A first-letter-of-the-label fallback looks right and is quietly wrong:
|
||||
* 'Hinduism' -> 'H' correct BY COINCIDENCE
|
||||
* 'Islam - Sunni'-> 'I' NOT a family letter -> fell through to 'U' = Unknown GREY,
|
||||
* while the map painted those same pins Islam RED.
|
||||
* 542 rows — the second-largest slice — would have shipped grey in the chart and red on the
|
||||
* map. The label is not the code and must never be treated as one.
|
||||
*
|
||||
* So resolve by the family NAME the label STARTS WITH ('Islam - Sunni' -> 'Islam' -> 'M'),
|
||||
* which is exactly how the API composes these labels ('<Family> - <Branch>'). Exact match
|
||||
* first, then longest-prefix so 'Ethnoreligion - Chinese Folk Religion' cannot be beaten by
|
||||
* a shorter unrelated family. Falls back to the code path for a genuine CODE input, and to
|
||||
* 'U' only when nothing matches — which is now a real Unknown, not a silent miss.
|
||||
*/
|
||||
function religionLetterFromLabel(key, families) {
|
||||
const s = String(key || '').trim()
|
||||
if (!s) return 'U'
|
||||
const letters = Object.keys(families)
|
||||
// 1) exact family name ('Islam') — what `religion_family` emits.
|
||||
const exact = letters.find((L) => families[L] === s)
|
||||
if (exact) return exact
|
||||
// 2) longest family name the label starts with ('Islam - Sunni' -> 'Islam').
|
||||
let best = null, bestLen = 0
|
||||
for (const L of letters) {
|
||||
const name = families[L]
|
||||
if (name && s.toLowerCase().startsWith(name.toLowerCase()) && name.length > bestLen) { best = L; bestLen = name.length }
|
||||
}
|
||||
if (best) return best
|
||||
// 3) a raw CODE ('MSN') — but ONLY if the input really IS a code.
|
||||
//
|
||||
// ★ WHY THIS IS GUARDED BY A SHAPE TEST (hardened 2026-09-22 after review).
|
||||
// This step used to run on ANY unmatched string, taking its FIRST LETTER. That is what put
|
||||
// 'Islam - Sunni' (542 rows) into Unknown grey: 'I' is not a family letter, so it fell to
|
||||
// 'U'. Steps 1-2 now catch every one of the 31 live labels (AUDITED: 990 rows exact,
|
||||
// 1,117 prefix, 0 reaching here, 0 disagreeing with the map), so step 3 is currently
|
||||
// unreachable by a label — but "unreachable today" is not "safe".
|
||||
//
|
||||
// AUDITED the latent failure: on an unguarded first letter, a NEW label would be coloured
|
||||
// CONFIDENTLY WRONG rather than obviously unknown —
|
||||
// 'Bahai' -> B -> Buddhism 'Jainism' -> J -> Judaism
|
||||
// 'Mandaeism' -> M -> Islam 'Shinto' -> S -> Sikhism
|
||||
// 'Confucianism' -> C -> Christianity (the one family that means "blue" here)
|
||||
// religion.js's own header lists Bahai/Druze/Jainism/Mandaeism/Zoroastrianism under family
|
||||
// 'O', so those are realistic future labels, not exotic ones. A plausible wrong answer is
|
||||
// worse than an obvious one: grey says "no strategy for this key" and gets reported, while
|
||||
// a confident hue silently lies and matches nothing on the map.
|
||||
//
|
||||
// The API's religion codes are 1-3 UPPERCASE letters ('M', 'MSN', 'CPR'). Requiring that
|
||||
// shape means a real code still resolves, and anything else returns 'U' = Unknown, which
|
||||
// renders as the neutral and is meant to be SEEN and fixed.
|
||||
if (/^[A-Z]{1,3}$/.test(s)) return getReligionFamily(s)
|
||||
return 'U'
|
||||
}
|
||||
|
||||
/** Count rows by a key accessor; collapse the long tail into 'Other'. */
|
||||
export function aggregate(rows, keyFn, topN) {
|
||||
const m = new Map()
|
||||
|
|
@ -26,8 +148,45 @@ export function aggregate(rows, keyFn, topN) {
|
|||
return arr
|
||||
}
|
||||
|
||||
/**
|
||||
* COUNTRY -> REGION HUE. The Driver's ask: "countries should match the colour of their regions."
|
||||
* A country has no palette of its own, so the Top-countries bar inherits its REGION's hue —
|
||||
* which makes the bar chart, the Region donut and the map legend agree at a glance.
|
||||
*
|
||||
* Pure client-side join over rows already fetched: group by the country label, read the
|
||||
* `wagf_region` of those rows, take the MAJORITY region (regions are NOT strictly partitioned
|
||||
* by country in this data, so "majority wins, ties -> NEUTRAL" is the honest rule rather than
|
||||
* assuming a clean join). No API change, no new field.
|
||||
*
|
||||
* @param {Array} rows the row store
|
||||
* @param {Function} countryAcc facet accessor for the country label
|
||||
* @param {Function} regionAcc facet accessor for the region label
|
||||
* @returns {Map<string,string>} country label -> hex
|
||||
*/
|
||||
export function buildCountryRegionColors(rows, countryAcc, regionAcc) {
|
||||
const tally = new Map() // country -> Map(region -> count)
|
||||
for (const r of rows || []) {
|
||||
const c = countryAcc && countryAcc(r)
|
||||
const g = regionAcc && regionAcc(r)
|
||||
if (!c || !g) continue
|
||||
if (!tally.has(c)) tally.set(c, new Map())
|
||||
const t = tally.get(c)
|
||||
t.set(g, (t.get(g) || 0) + 1)
|
||||
}
|
||||
const out = new Map()
|
||||
for (const [country, regions] of tally) {
|
||||
let best = null, bestN = 0, tied = false
|
||||
for (const [region, n] of regions) {
|
||||
if (n > bestN) { best = region; bestN = n; tied = false }
|
||||
else if (n === bestN) tied = true
|
||||
}
|
||||
out.set(country, (best && !tied) ? colorFor('wagf_region', best) : NEUTRAL)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Donut slice geometry (SVG path strings), starting at 12 o'clock, clockwise. */
|
||||
export function buildDonut(data, size = 132, innerRatio = 0.58) {
|
||||
export function buildDonut(data, size = 132, innerRatio = 0.58, facet = '', overrides = null) {
|
||||
const cx = size / 2, cy = size / 2, r = size / 2 - 3, ri = (size / 2 - 3) * innerRatio
|
||||
const total = data.reduce((s, d) => s + d.value, 0) || 1
|
||||
let a = -Math.PI / 2
|
||||
|
|
@ -36,14 +195,67 @@ export function buildDonut(data, size = 132, innerRatio = 0.58) {
|
|||
const big = (a1 - a0) > Math.PI ? 1 : 0
|
||||
const p = (ang, rad) => `${(cx + rad * Math.cos(ang)).toFixed(2)},${(cy + rad * Math.sin(ang)).toFixed(2)}`
|
||||
const path = `M${p(a0, r)} A${r},${r} 0 ${big} 1 ${p(a1, r)} L${p(a1, ri)} A${ri},${ri} 0 ${big} 0 ${p(a0, ri)} Z`
|
||||
return { ...d, path, pct: Math.round((d.value / total) * 100), color: colorFor(d.key, i) }
|
||||
return { ...d, path, pct: Math.round((d.value / total) * 100), color: resolveColor(facet, d.key, overrides) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Horizontal-bar geometry as fractions of the widest bar. */
|
||||
export function buildBars(data) {
|
||||
export function buildBars(data, facet = '', overrides = null) {
|
||||
const max = data.reduce((m, d) => Math.max(m, d.value), 0) || 1
|
||||
return data.map((d, i) => ({ ...d, frac: d.value / max, color: colorFor(d.key, i) }))
|
||||
return data.map((d) => ({ ...d, frac: d.value / max, color: resolveColor(facet, d.key, overrides) }))
|
||||
}
|
||||
|
||||
export function colorFor(key, i) { return REGION_COLORS[key] || CATEGORICAL[i % CATEGORICAL.length] }
|
||||
/**
|
||||
* One colour resolution point for both geometry builders: an explicit per-key override
|
||||
* (e.g. the country->region join) wins, otherwise the facet's registry palette.
|
||||
* Note both paths are keyed by the VALUE — no index is consulted anywhere.
|
||||
*/
|
||||
function resolveColor(facet, key, overrides) {
|
||||
if (overrides && key !== 'Other') { const hit = overrides.get ? overrides.get(key) : overrides[key]; if (hit) return hit }
|
||||
return colorFor(facet, key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ONE chart key -> hex, keyed by the VALUE and the FACET. Never by index.
|
||||
*
|
||||
* @param {string} facet the dashboard facet key this chart aggregates on
|
||||
* @param {string} key the aggregation key (the facet accessor's exact output)
|
||||
* @returns {string} hex from the map's own strategy module, or NEUTRAL when unmapped
|
||||
*/
|
||||
export function colorFor(facet, key) {
|
||||
if (key == null || key === '') return NEUTRAL
|
||||
// The long-tail bucket is a chart-side construct, not a data value — always neutral.
|
||||
if (key === 'Other') return NEUTRAL
|
||||
|
||||
if (facet === 'religion' || facet === 'religion_family') {
|
||||
const strat = guardedStrategy(COLOR_MODES.RELIGION, 'religion')
|
||||
const palette = (strat && strat.palette) || RELIGION_PALETTE
|
||||
const families = (strat && strat.families) || RELIGION_FAMILIES
|
||||
return palette[religionLetterFromLabel(key, families)] || NEUTRAL
|
||||
}
|
||||
|
||||
if (facet === 'wagf_region') {
|
||||
const strat = guardedStrategy(COLOR_MODES.DOXA_REGION, 'doxaRegion')
|
||||
if (!strat) return NEUTRAL
|
||||
const hit = strat.slugColors && strat.slugColors[toSlug(key)]
|
||||
if (hit) return hit
|
||||
const alias = strat.aliases && strat.aliases[key]
|
||||
return alias || NEUTRAL
|
||||
}
|
||||
|
||||
if (facet === 'affinity_bloc') {
|
||||
// The People facet emits the ROP1 CODE ('A008'), which is this palette's own key.
|
||||
const strat = guardedStrategy(COLOR_MODES.AFFINITY_BLOCK, 'affinityBlock')
|
||||
return (strat && strat.palette && strat.palette[key]) || NEUTRAL
|
||||
}
|
||||
|
||||
if (facet === 'language_family') {
|
||||
const strat = guardedStrategy(COLOR_MODES.LANGUAGE_FAMILY, 'languageFamily')
|
||||
return (strat && strat.palette && strat.palette[key]) || NEUTRAL
|
||||
}
|
||||
|
||||
// No colour strategy exists for this facet (e.g. country_code — a country's identity is
|
||||
// carried by the choropleth, not by a categorical hue). Returning NEUTRAL is deliberate
|
||||
// and STABLE; inventing an index-keyed colour is exactly the bug this replaced.
|
||||
return NEUTRAL
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,6 +175,13 @@ export function useDashboardShell(config) {
|
|||
// One brushing-and-linking bus, provided to every panel (publisher + subscriber).
|
||||
const bus = createSelectionBus()
|
||||
|
||||
// Teach the bus this profile's facet hierarchy, derived from config.facetRail[].drill, so that
|
||||
// clearing a parent facet cascade-clears its descendants on EVERY clear path (tree rail /
|
||||
// dropdowns / chip ✕ / Clear All all route through bus.setFilter). Without this, a child
|
||||
// predicate outlives its parent and silently ANDs an empty result — see the HIERARCHY CONTRACT
|
||||
// in useSelectionBus.js. Configured before any panel can emit a filter.
|
||||
bus.configureHierarchy(config)
|
||||
|
||||
// The single in-memory row store — one fetch on mount. (useDashboardData reads the shared
|
||||
// @map/config/sources.json activeSource + getApiBaseUrl; config.data may carry overrides.)
|
||||
const data = useDashboardData(config.data)
|
||||
|
|
|
|||
|
|
@ -27,9 +27,54 @@
|
|||
*
|
||||
* RULE: never loop `toggleSelect()` over many slugs. Use the bulk ops. They are the only
|
||||
* reason batch selection is interactive.
|
||||
*
|
||||
* ── HIERARCHY CONTRACT (BUG-DESELECTING-A-PARENT-FACET-ORPHANS-ITS-CHILD-SELECTION) ────────
|
||||
* A CHILD FACET SELECTION MUST NOT OUTLIVE ITS PARENT. Clearing a parent facet cascade-clears
|
||||
* every descendant, transitively, in one commit.
|
||||
*
|
||||
* WHY IT LIVES HERE AND NOWHERE ELSE. filterSet is AND-stacked: filteredRows keeps a row only
|
||||
* when EVERY active key matches. So an orphaned child keeps filtering after its parent is gone —
|
||||
* deselect "Indo-European" while "Marathi" is picked and Marathi survives; pick a DIFFERENT
|
||||
* family and the two predicates can never both hold, so the map goes empty with no error. The
|
||||
* user reads that as "there are no people groups here" rather than "my filter state is
|
||||
* inconsistent" — a SILENT WRONG RESULT, which is why this is a data-trust bug, not a nit.
|
||||
*
|
||||
* Four separate controls clear a filter — the tree rail (DashboardFacetRail), the dropdowns
|
||||
* (DashboardResearchFilters), the per-chip ✕ (SearchFilterHeader/DashboardFilterChips) and
|
||||
* Clear All / the breadcrumb — and ALL FOUR funnel through setFilter()/clearFilters() here.
|
||||
* Implementing the cascade in any one component would fix one button and leave the identical
|
||||
* bug reachable through the other three. One chokepoint, one rule.
|
||||
*
|
||||
* The hierarchy is NOT declared twice: `configureHierarchy()` derives it from the config's
|
||||
* existing `facetRail[].drill` arrays, which are already ordered parent→child (the rail renders
|
||||
* them as tiers). The config stays the single source of truth for what nests inside what.
|
||||
*
|
||||
* Transitivity is the whole point: People is THREE deep (affinity_bloc → cluster → people_group)
|
||||
* and Place is three deep (wagf_region → wagf_block → country_code). A one-level cascade would
|
||||
* look fixed from the top tier while leaving the same orphan one rung down.
|
||||
*/
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Derive facetKey → [descendant keys, nearest-first] from a dashboard config.
|
||||
* Reads `facetRail[].drill` (already ordered parent→child) so the hierarchy is declared ONCE,
|
||||
* in the config, and can never drift from the tiers the rail actually renders.
|
||||
* A section with no `drill` (e.g. the flat Status list) contributes nothing — a facet absent
|
||||
* from the map simply has no descendants and clears alone, exactly as before.
|
||||
*/
|
||||
export function deriveHierarchy(config) {
|
||||
const children = {}
|
||||
for (const section of config?.facetRail || []) {
|
||||
const drill = Array.isArray(section.drill) ? section.drill.filter(Boolean) : []
|
||||
// every tier below i is a descendant of tier i (transitive closure, nearest-first)
|
||||
for (let i = 0; i < drill.length - 1; i++) {
|
||||
const rest = drill.slice(i + 1)
|
||||
children[drill[i]] = children[drill[i]] ? children[drill[i]].concat(rest.filter((k) => !children[drill[i]].includes(k))) : rest
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
export function createSelectionBus() {
|
||||
const filterSet = reactive({}) // { facetKey: value | value[] } — AND-stacked facets
|
||||
// PLAIN Set (see the performance contract above) — pair every read with `selectionVersion`.
|
||||
|
|
@ -52,10 +97,35 @@ export function createSelectionBus() {
|
|||
subs.get(event)?.forEach((cb) => { try { cb(payload) } catch (_) { /* isolate subscribers */ } })
|
||||
}
|
||||
|
||||
// ── facet hierarchy (see the HIERARCHY CONTRACT above) ──
|
||||
// facetKey -> [descendant keys]. Empty until the shell calls configureHierarchy(config), so a
|
||||
// bus used without a config behaves exactly as it did before (every facet clears alone).
|
||||
let childFacets = {}
|
||||
function configureHierarchy(config) { childFacets = deriveHierarchy(config) }
|
||||
|
||||
// ── filterSet ops (scope) ──
|
||||
/**
|
||||
* Set or clear one facet. CLEARING CASCADES: removing a parent removes every descendant in the
|
||||
* same call, so a child predicate can never outlive the parent that scoped it.
|
||||
*
|
||||
* Setting is deliberately NOT cascading in either direction. Selecting a child with no parent
|
||||
* set is a legitimate, self-consistent state — the child alone is strictly narrower than its
|
||||
* parent, so the result is never wrong, merely more specific (the tree rail, the dropdowns and
|
||||
* the geocoder bars all allow it today, and it is how "search language → Marathi" works without
|
||||
* first picking a family). The invariant is one-directional by design: no ORPHANS, but
|
||||
* stand-alone children are fine.
|
||||
*
|
||||
* One 'filter' emit for the whole cascade — subscribers rebuild the map/table once, not once
|
||||
* per cleared tier (same batching discipline as commitSelection).
|
||||
*/
|
||||
function setFilter(key, value) {
|
||||
if (value === null || value === undefined) delete filterSet[key]
|
||||
else filterSet[key] = value
|
||||
if (value === null || value === undefined) {
|
||||
delete filterSet[key]
|
||||
// transitive: childFacets[key] already holds the full descendant closure
|
||||
for (const child of childFacets[key] || []) delete filterSet[child]
|
||||
} else {
|
||||
filterSet[key] = value
|
||||
}
|
||||
emit('filter', filterSet)
|
||||
}
|
||||
function clearFilters() {
|
||||
|
|
@ -129,7 +199,7 @@ export function createSelectionBus() {
|
|||
|
||||
return {
|
||||
filterSet, selectionSet, selectionVersion, searchTerm, on, emit,
|
||||
setFilter, clearFilters, setSearch,
|
||||
setFilter, clearFilters, setSearch, configureHierarchy,
|
||||
toggleSelect, clearSelection, selectMany, deselectMany, setSelection,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue