diff --git a/internal/check-verification-tags.mjs b/internal/check-verification-tags.mjs
new file mode 100644
index 000000000..f8bb21998
--- /dev/null
+++ b/internal/check-verification-tags.mjs
@@ -0,0 +1,110 @@
+#!/usr/bin/env node
+/**
+ * check-verification-tags.mjs — enforce POL-two-kinds-of-verification on the builder backlog.
+ *
+ * THE DEFECT THIS CATCHES. A card was closed `status: "done"` on an agent measurement, the Driver saw
+ * the bug still happening, and the card had NO FIELD in which that disagreement could be recorded. So
+ * the board said "done" and the product said "broken" and nothing in the data knew. It happened at
+ * least twice before the policy existed:
+ * - duplicate badges: closed on LEAF-page evidence while the Driver was looking at the COMPOSITE;
+ * - filter dropdown: closed on the dashboard header while five more dropdowns live in the grid.
+ *
+ * ★ THE POINT IS NOT TO REQUIRE HUMAN SIGN-OFF. The Driver explicitly rejected that — "the backlog
+ * would be entirely gated by humans and things would be super slow." An agent MAY close a card alone.
+ * What this gate forbids is closing one ANONYMOUSLY, so that nobody can later ask "which of these did
+ * a human actually look at?"
+ *
+ * ★ THE ASYMMETRY THIS ENCODES (Driver's own words): human verification is more reliable on VISUAL
+ * bugs; agent verification is more reliable on NON-VISUAL bugs. A headless probe reads geometry, not
+ * perception — it can measure that a menu is at dTop=4 and still be blind to it feeling wrong. So a
+ * VISUAL card closed on agent evidence is a HYPOTHESIS; a STATE card closed on agent evidence is a
+ * RESULT. The gate reports them separately rather than lumping them into one "done" number.
+ *
+ * WHAT IT ASSERTS:
+ * 1. every done card carries fields.verification in {agent,human,both};
+ * 2. no card claims human_verified without human_evidence — an agent must never grant human
+ * verification to itself, and a quote is what makes that auditable;
+ * 3. a card with verification_conflict is NOT in status done (a conflict means reopen);
+ * 4. (report-only) how many done cards are agent-only VISUAL — the unpaid-attention ledger.
+ *
+ * Assertion 4 never fails the build. It is a NUMBER TO WATCH, not a rule to obey; failing on it would
+ * gate the board on human review, which is the exact thing the policy rejects.
+ *
+ * NEGATIVE-TESTED: deleting fields.verification from one done card fails assertion 1 by name; setting
+ * human_verified with no human_evidence fails assertion 2; marking a conflict card done fails 3.
+ */
+import { readFileSync, existsSync } from 'node:fs'
+import { resolve, dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const BACKLOG = join(ROOT, '../_qa-cycle/02-builder-cycle/backlog.json')
+
+if (!existsSync(BACKLOG)) {
+ // The _qa-cycle lives outside every repo the CI clones, so absence is EXPECTED there and must not
+ // fail the build. Say so loudly rather than passing silently, or this gate becomes one that cannot
+ // fail — the failure mode the repo has already been bitten by once.
+ console.log('VERIFY-TAGS: SKIP — backlog.json not present (expected in CI; _qa-cycle is not cloned).')
+ process.exit(0)
+}
+
+const b = JSON.parse(readFileSync(BACKLOG, 'utf8'))
+const cards = b.cards || []
+const done = cards.filter((c) => c.status === 'done')
+const errs = []
+
+// 1 — every done card names its verification kind
+const untagged = done.filter((c) => !(c.fields && c.fields.verification))
+if (untagged.length) {
+ errs.push(
+ `${untagged.length} done card(s) carry no fields.verification.\n` +
+ ' A bare status:"done" cannot answer "did a human ever look at this?". Tag each one "agent",\n' +
+ ' "human" or "both", and set verification_kind to "visual" or "state".\n' +
+ untagged.slice(0, 8).map((c) => ' - ' + c.id).join('\n') +
+ (untagged.length > 8 ? `\n …and ${untagged.length - 8} more` : '')
+ )
+}
+
+// 2 — human verification requires a quote; an agent must not self-grant it
+const unquoted = cards.filter(
+ (c) => c.fields && c.fields.human_verified && !String(c.fields.human_evidence || '').trim()
+)
+if (unquoted.length) {
+ errs.push(
+ `${unquoted.length} card(s) claim human_verified with no human_evidence.\n` +
+ ' Only the Driver grants human verification, and the quote is what makes that checkable. Without\n' +
+ ' it, an agent can quietly promote its own agent-verification into a human one — which would erase\n' +
+ ' the single distinction this whole policy exists to preserve.\n' +
+ unquoted.map((c) => ' - ' + c.id).join('\n')
+ )
+}
+
+// 3 — a conflict is an open card, by definition
+const wrongly = cards.filter((c) => c.fields && c.fields.verification_conflict && c.status === 'done')
+if (wrongly.length) {
+ errs.push(
+ `${wrongly.length} card(s) are marked verification_conflict AND done.\n` +
+ ' A conflict means a tool says fixed while the Driver says broken. That is not a done card; it is\n' +
+ ' the strongest available signal that the probe measured the wrong surface. Reopen it.\n' +
+ wrongly.map((c) => ' - ' + c.id).join('\n')
+ )
+}
+
+if (errs.length) {
+ console.error('VERIFY-TAGS: FAIL')
+ errs.forEach((e) => console.error(' ' + e))
+ process.exit(1)
+}
+
+// 4 — the ledger (report-only, never fails)
+const agentVisual = done.filter((c) => c.fields.verification === 'agent' && c.fields.verification_kind === 'visual')
+const agentState = done.filter((c) => c.fields.verification === 'agent' && c.fields.verification_kind === 'state')
+const humanSeen = done.filter((c) => c.fields.human_verified)
+
+console.log(`VERIFY-TAGS: PASS — ${done.length} done card(s), all tagged.`)
+console.log(` human-confirmed .............. ${humanSeen.length}`)
+console.log(` agent-only, STATE (strong) .. ${agentState.length} — counts, routing, file shapes; no human needed`)
+console.log(` agent-only, VISUAL (weak) .... ${agentVisual.length} — ★ closed on geometry, never on perception`)
+console.log(' The last number is the unpaid-attention ledger: cards a tool called fixed on a question')
+console.log(' only an eye can settle. It is not an error — it is the list to hand the Driver when he')
+console.log(' next says a bug is still there, before investigating anything from scratch.')
diff --git a/library/components/search/SearchFilterHeader.vue b/library/components/search/SearchFilterHeader.vue
index 8c701b048..ea7e96c7b 100644
--- a/library/components/search/SearchFilterHeader.vue
+++ b/library/components/search/SearchFilterHeader.vue
@@ -186,16 +186,24 @@ 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. -->
+
@@ -258,23 +266,65 @@ function onClearAll() { emit('clear-all') }
/* ★ FIXED HEIGHT IS THE WHOLE POINT — this row must be the same size empty or full, or everything
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).
- 28px is the Driver's "a bit slimmer": tall enough for a chip and its remove button, short enough
- that it reads as a status strip rather than a tray. overflow-x:auto lets a long selection scroll
- sideways instead of ever adding a row. */
+ ★ 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: 28px; overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; }
+ min-height: 24px; }
+/* ★ 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.
+ 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;
+ 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.75rem; font-weight: 700; letter-spacing: .4px;
+.sfh__sel-label { flex: 0 0 auto; font-size: 0.8125rem; font-weight: 700; letter-spacing: .4px;
text-transform: uppercase; color: var(--mut, #6d766e); }
/* 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.875rem; color: var(--mut, #6d766e); }
+.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).
+ 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. */
.sfh__chip {
- display: inline-flex; gap: 6px; 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: 4px 12px; font-size: 0.85rem;
+ flex: 0 0 auto; display: inline-flex; gap: 6px; 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;
}
.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:hover { opacity: 0.7; }
-.sfh__clear { background: none; border: none; cursor: pointer; font-size: 0.875rem; color: var(--ink); text-decoration: underline; text-decoration-color: var(--brand); text-underline-offset: 4px; }
+/* ★ 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. */
+.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);
+}
diff --git a/package.json b/package.json
index 92b898177..eef829520 100644
--- a/package.json
+++ b/package.json
@@ -84,7 +84,8 @@
"check:url-resolver": "node internal/check-url-resolver.mjs # cap-5 resolve() must not prefix a rooted URL (the dev-surface 404 class)",
"smoke:studio": "node internal/smoke-studio.mjs # 5s DEV-surface check: / mounts the studio, tabs>0, 0 4xx (exit 2 = dev server down)",
"check:manifest-covers": "node internal/check-manifest-covers-discovery.mjs # every DISCOVERED (non-gated) bundle must be in the built manifest",
- "check:csv-shape": "node internal/check-csv-export-shape.mjs # the export must OPEN as a table: header on line 0, every row matching"
+ "check:csv-shape": "node internal/check-csv-export-shape.mjs # the export must OPEN as a table: header on line 0, every row matching",
+ "check:verification-tags": "node internal/check-verification-tags.mjs"
},
"devDependencies": {
"@capacitor/android": "^7.6.9",
diff --git a/plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue b/plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue
index 77d476b35..df6d251c5 100644
--- a/plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue
+++ b/plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue
@@ -32,10 +32,10 @@ import PeopleGroupsGrid from '../people-groups-grid/people-groups-grid.vue'
// the two speak different vocabularies: the dashboard's bus keys are snake_case facet names
// from dashboard.config.js, the grid's props are camelCase.
//
-// ONLY FIVE OF THE DASHBOARD'S TWELVE FACETS MAP. The config defines religion,
-// religion_family, wagf_region, wagf_block, country_code, engagement_status, language_family,
-// primary_language, affinity_bloc, cluster, people_group and rop1 — but the grid accepts only
-// query/wagfRegion/wagfBlock/rop1/religion/countryCode. The unmapped seven are DROPPED rather
+// NOT EVERY DASHBOARD FACET MAPS. The config defines religion, religion_family, wagf_region,
+// wagf_block, country_code, engagement_status, language_family, primary_language, affinity_bloc,
+// cluster and people_group (plus rop1_name, a search-only projection that is not an axis at all)
+// — but the grid accepts only its declared props. The unmapped ones are DROPPED rather
// than guessed at: pushing a value into a prop the grid filters differently would silently
// show the WRONG rows, which is worse than the honest current behaviour of not narrowing on
// that axis. Widening this map means adding real props to the grid first.
@@ -75,13 +75,14 @@ import PeopleGroupsGrid from '../people-groups-grid/people-groups-grid.vue'
//
// affinity_bloc -> rop1 SAFE, and it needs NO new prop. dashboard.config.js reads it
// as code(r.rop1) — the CODE ('A008') of the very field the
-// grid's existing rop1 filter indexes (u.rop1), whose sibling
-// facet `rop1` reads the LABEL ('Malay Peoples') of the same
-// object. valueMatches() compares the selection against value
-// AND label, so both spellings select the same rows. Measured:
-// 16 distinct blocs, 0 normalisation collisions on either side.
-// This is the country_code case exactly — same field, only the
-// spelling differs.
+// grid's existing rop1 filter indexes (u.rop1). valueMatches()
+// compares the selection against value AND label, so either
+// spelling selects the same rows. Measured: 16 distinct blocs,
+// 0 normalisation collisions on either side. This is the
+// country_code case exactly — same field, only the spelling
+// differs. (It once had a sibling facet `rop1` holding the
+// LABEL of the same object; that duplicate axis is gone — see
+// the FACET_TO_PROP note below.)
// engagement_status -> engagement SAFE. `get: (r) => lbl(r.engagement_status)` is the label of
// u.engagement_status, already in the grid's fetch, a
// {value,label} pair on 100% of rows. 2 distinct values, 0
@@ -118,10 +119,14 @@ import PeopleGroupsGrid from '../people-groups-grid/people-groups-grid.vue'
const FACET_TO_PROP = {
wagf_region: 'wagfRegion',
wagf_block: 'wagfBlock',
- rop1: 'rop1',
- // The People tree's TOP tier and the People DROPDOWN are two projections (code vs label) of the
- // one rop1 field, so they share one prop. Whichever the user touches last wins, which is the
- // correct behaviour for two controls over the same axis.
+ // THE People axis. There used to be a second entry here — facet key `rop1` — also pointing at
+ // this prop, because the dashboard registered the People axis TWICE (a code facet for the tree,
+ // a label facet for the dropdown). Collapsing both onto one prop is what made the GRID behave
+ // correctly while the dashboard's own header still showed two rival controls, which is how the
+ // defect survived: a downstream adapter masked half of it. The duplicate facet is now gone at
+ // the source (dashboard.config.js), so ONE entry is both sufficient and honest.
+ // Value is the bloc CODE ('A012'); the grid's valueMatches() compares against value AND label,
+ // so it selects the same rows either spelling arrives in.
affinity_bloc: 'rop1',
religion: 'religion',
country_code: 'countryCode',
diff --git a/plugin-designs/research-dashboard/dashboard.config.js b/plugin-designs/research-dashboard/dashboard.config.js
index fa9a5d3e6..79539b1fa 100644
--- a/plugin-designs/research-dashboard/dashboard.config.js
+++ b/plugin-designs/research-dashboard/dashboard.config.js
@@ -108,17 +108,43 @@ export default {
primary_language: { label: 'Language', get: (r) => lbl(r.primary_language) },
// People tier accessors — key by CODE (DashboardFacetRail resolves code→human name via
// useAffinityBlockLegendData). Mirrors research-map's affinity-block → cluster → PG hierarchy.
- affinity_bloc: { label: 'Affinity Bloc', get: (r) => code(r.rop1) },
+ //
+ // ── ONE AXIS, ONE FACET (card BUG-PEOPLE-AND-AFFINITY-BLOC-ARE-THE-SAME-AXIS-TWICE) ──
+ // `affinity_bloc` is THE People axis. It used to have a twin: a second facet keyed `rop1`
+ // reading lbl(r.rop1) — the LABEL of the very same field this reads the CODE of. Two facet
+ // keys over ONE row field meant two controls for one axis (a "People" dropdown beside an
+ // "Affinity Bloc" tree tier) that could never recognise each other: the tree stored
+ // affinity_bloc='A012' while the dropdown stored rop1='South Asian Peoples', and
+ // filteredRows byte-matches each key against its OWN accessor, so neither ever saw the
+ // other's selection. Driver, from behaviour: "there are duplicate affinity blocks, one
+ // called People, one called Affinity Bloc… the People filter IS by affinity block. So they
+ // are the same thing." He is right — the People filter always WAS the affinity-bloc filter.
+ //
+ // THE RULE THIS RESTORES: one axis = one filterable facet key. The CODE won as canonical
+ // because every other People tier (cluster / people_group) already keys by code, and
+ // FACET_TO_PROP in dashboard-plus-grid.vue already mapped BOTH keys onto the one grid prop
+ // `rop1` — the grid had already been treating them as a single thing.
+ //
+ // `optionLabel` is how the dropdown keeps showing HUMAN NAMES while STORING codes ("codes
+ // must never reach a user-facing menu" — still true). It is a DISPLAY projection of the
+ // same row, so option.value=code('A012') byte-matches the accessor while option.label
+ // reads 'South Asian Peoples'. Both come off the SAME rop1 object on the SAME record, so
+ // they cannot drift the way a cross-source name lookup could. SearchFilterHeader already
+ // supports value≠label (it resolves a chip's text via `o.value === f.value`).
+ affinity_bloc: {
+ label: 'People',
+ get: (r) => code(r.rop1),
+ optionLabel: (r) => lbl(r.rop1),
+ },
cluster: { label: 'Cluster', get: (r) => code(r.imb_reg_of_people_2) },
people_group: { label: 'People Group', get: (r) => code(r.imb_reg_of_people_25) },
- // ── the PEOPLE axis for the research filter DROPDOWNS (card BUILD-FILTER-PEOPLE-AXIS-MISSING) ──
- // DX-life's research page filters people on `rop1` (UupgsList.vue: name="rop1",
- // label "People Group"), showing rop1.LABEL in the menu. The facets above key People by
- // CODE so the rail can cross-reference the map's semantic tree — codes must never reach a
- // user-facing menu. This is the LABEL projection of the SAME rop1 field: a separate facet
- // key (not a second accessor on affinity_bloc) because the selection bus matches on the
- // accessor's exact output, so one key = one projection keeps the byte-match contract intact.
- rop1: { label: 'People', get: (r) => lbl(r.rop1) },
+ // SEARCH-ONLY projection of the People name. NOT an axis: no control list names it
+ // (`facetRail.drill`, `charts[].facet` and `researchFilters.dropdowns` all name
+ // `affinity_bloc`), and nothing enumerates `facets` to render a control — every consumer
+ // looks a key UP. It exists solely so free-text search keeps matching "South Asian" rather
+ // than the code 'A012' (buildSearchAccessors resolves searchKeys through facets first).
+ // Never add this key to a control list: that would reintroduce the duplicate axis.
+ rop1_name: { label: 'People (name)', get: (r) => lbl(r.rop1) },
},
// ── facet rail sections ── (DashboardFacetRail) — ONE centralized semantic tree mirroring the
@@ -221,12 +247,17 @@ export default {
// always there (it is a defaultField on the pray-tools source and DX-life filtered on it), only
// the dropdown was dropped when this list was first written.
researchFilters: {
- dropdowns: ['rop1', 'wagf_region', 'wagf_block', 'country_code', 'primary_language', 'religion'],
+ // People = `affinity_bloc`, the SAME key the tree's top tier filters on, so a pick here and
+ // a click there are one selection (card BUG-PEOPLE-AND-AFFINITY-BLOC-ARE-THE-SAME-AXIS-TWICE).
+ // It stores the code and displays the name via the facet's `optionLabel`.
+ dropdowns: ['affinity_bloc', 'wagf_region', 'wagf_block', 'country_code', 'primary_language', 'religion'],
searchPlaceholder: 'Search people groups, place, language, religion…',
},
- // `rop1` included so free-text search matches the People axis too (DX-life's Fuse key set
- // includes 'rop1.label' — UupgsList.vue:299).
- searchKeys: ['name', 'rop1', 'wagf_region', 'wagf_block', 'country_code', 'primary_language', 'religion', 'engagement_status'],
+ // `rop1_name` (not `affinity_bloc`) so free-text search matches the People NAME — DX-life's
+ // Fuse key set includes 'rop1.label' (UupgsList.vue:299), and searching the axis's canonical
+ // CODE would mean typing 'A012' to find South Asian Peoples. This is the one place the label
+ // projection is still needed, and it is search-only — see the `rop1_name` facet note above.
+ searchKeys: ['name', 'rop1_name', 'wagf_region', 'wagf_block', 'country_code', 'primary_language', 'religion', 'engagement_status'],
// ── search header ── (DOXA-LIB-3) the 4 facet search bars (DashboardSearchHeader).
// Each bar = one standalone GeocoderComponent consuming useGeocoderSearch via a
diff --git a/plugin-designs/research-dashboard/research-dashboard.vue b/plugin-designs/research-dashboard/research-dashboard.vue
index ac1bc8d0e..5265ac384 100644
--- a/plugin-designs/research-dashboard/research-dashboard.vue
+++ b/plugin-designs/research-dashboard/research-dashboard.vue
@@ -105,7 +105,9 @@ const { startFacetResize, startRailResize, startTableResize } = useDashboardLayo
bus,
isNarrow,
storageKey: 'rd-dashboard-layout',
- defaults: { facetW: 248, railW: 312, tableH: '34%' },
+ // MUST mirror the `:host` --facet-w/--rail-w defaults, or reset() would snap the rails to a
+ // different balance than a fresh load shows. Tree wider than charts — see the rail-balance note.
+ defaults: { facetW: 296, railW: 264, tableH: '34%' },
})
// KPI strip — generic formatting per kind (mirrors the original inline strip).
@@ -157,12 +159,20 @@ useShadowStyles(`
--rd-line:var(--dx-line); --rd-bg:var(--dx-surface);
--rd-ink:var(--dx-text); --rd-muted:var(--dx-muted); --rd-panel:var(--dx-elevated);
--rd-primary:var(--dx-accent);
- --facet-w:248px; --rail-w:312px;
+ /* ── RAIL BALANCE (card BUILD-REBALANCE-THE-TWO-SIDEBARS) ────────────────────────────
+ The charts rail used to be 312px against the tree rail's 248px — the CHARTS rail was
+ 64px WIDER than the tree, which is exactly what the Driver saw ("it looks like the
+ CHART sidebar is LARGER than the TREE sidebar"). That was backwards for the content:
+ the tree carries long multi-word labels (bloc/cluster/region names) that must read on
+ ONE line, while the charts carry a donut plus a legend that can wrap.
+ 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;
--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%; }
.rd-shell{ position:relative; display:grid; width:100%; height:100%; min-height:560px; box-sizing:border-box;
- grid-template-columns: var(--facet-w,248px) minmax(0,1fr) var(--rail-w,312px);
+ grid-template-columns: var(--facet-w,296px) minmax(0,1fr) var(--rail-w,264px);
grid-template-rows: 52px auto 42px 1fr var(--table-h,34%);
grid-template-areas: "topbar topbar topbar" "filters filters filters" "stats stats stats" "facets map rail" "facets table rail";
gap:1px; background:var(--rd-line, #d1d4cd); container-type:inline-size;
@@ -185,8 +195,8 @@ useShadowStyles(`
/* Facet/rail collapse — override the COLUMN template (not the --*-w vars) so collapse wins
over a dragged inline width, exactly like the table row-template above. Map is minmax(0,1fr)
and self-resizes via DashboardMapPanel's ResizeObserver → map.resize(). */
- .rd-shell.rd-facet-collapsed{ grid-template-columns:0 minmax(0,1fr) var(--rail-w,312px); }
- .rd-shell.rd-rail-collapsed{ grid-template-columns:var(--facet-w,248px) minmax(0,1fr) 0; }
+ .rd-shell.rd-facet-collapsed{ grid-template-columns:0 minmax(0,1fr) var(--rail-w,264px); }
+ .rd-shell.rd-rail-collapsed{ grid-template-columns:var(--facet-w,296px) minmax(0,1fr) 0; }
.rd-shell.rd-facet-collapsed.rd-rail-collapsed{ grid-template-columns:0 minmax(0,1fr) 0; }
/* Always-reachable edge carets pinned to the map, toggle each rail (desktop only). */
.rd-edge-caret{ position:absolute; top:50%; transform:translateY(-50%); z-index:6; width:16px;
@@ -198,8 +208,12 @@ useShadowStyles(`
.rd-edge-facet{ left:5px; } .rd-edge-rail{ right:5px; }
/* (the floating .rd-table-restore pill is retired — the table's own always-docked header bar
carries the chevron, the live count and Export CSV while collapsed) */
- .rd-shell.rd-slim-rail{ --rail-w:240px; }
- .rd-shell.rd-slim-facet{ --facet-w:196px; }
+ /* Medium-width niceties. These keep the SAME rail balance as the defaults above (tree wider
+ than charts) — slim-facet used to drop the tree to 196px against a 240px charts rail, which
+ re-created the inverted proportion this card fixed, just at a smaller screen size. The tree
+ gives up less because it is the rail whose labels must not wrap. */
+ .rd-shell.rd-slim-rail{ --rail-w:228px; }
+ .rd-shell.rd-slim-facet{ --facet-w:250px; }
/* Desktop drag-resize handles (useDashboardLayout). Thin, transparent until hover/active; each is
a grid item pinned to the edge of its cell so no pixel offsets are needed. Straddle the 1px grid
gap via a small negative margin for a comfortable hit target. Never rendered on narrow/mobile. */
diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue
index edf7d594b..1f7c43bbd 100644
--- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue
+++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue
@@ -72,8 +72,14 @@ useShadowStyles(`
/* 10px -> 7px 6px: reclaims ~8px of width per card for the plot area itself. */
.rd-chart{ border:1px solid var(--rd-line, #d1d4cd); border-radius:var(--r-lg,10px); background:var(--rd-bg, #f3f3f1); padding:7px 6px; }
.rd-chart .rd-ct{ font-weight:700; font-size:var(--fs-sm,11px); color:var(--rd-ink, #3b463d); margin-bottom:6px; }
- .rd-donutwrap{ display:flex; align-items:center; gap:10px; }
- .rd-donut{ width:108px; height:108px; flex:0 0 auto; }
+ .rd-donutwrap{ display:flex; align-items:flex-start; gap:8px; }
+ /* 108px -> 84px (card BUILD-REBALANCE-THE-TWO-SIDEBARS). The rail narrowed to 264px to give
+ the tree rail room, and the donut was consuming 108px + a 10px gap of it — leaving the
+ legend labels only ~82px, narrow enough that "North America & Non-Spanish Caribbean" still
+ needed THREE lines and was truncated even after wrapping was enabled. The donut is the
+ DECORATIVE half of this card (the legend beside it carries the exact numbers), so it is the
+ side that gives way. 24px of donut + 2px of gap becomes 26px of label. */
+ .rd-donut{ width:84px; height:84px; flex:0 0 auto; }
.rd-slice{ cursor:pointer; transition:opacity .18s, transform .18s; transform-box:view-box; transform-origin:center; }
.rd-slice:hover{ opacity:.82; }
/* Active slice pops out (scale) with a clear ink stroke; when a slice is selected the rest dim. */
@@ -83,27 +89,74 @@ useShadowStyles(`
/* The donut legend is the WORST offender the Driver reported: a short, always-scrolling list
whose default scrollbar ate a large share of an already narrow column. Thin overlay bar,
transparent track, and no right padding so it hugs the card edge. */
- .rd-legend{ display:flex; flex-direction:column; gap:2px; font-size:10.5px; color:var(--dx-text, #3b463d); max-height:108px;
+ /* ── MAX-HEIGHT 108px -> 168px (card BUILD-REBALANCE-THE-TWO-SIDEBARS) ───────────────────
+ THE TRADE, STATED SO THE NEXT AUTHOR DOES NOT FLIP IT BACK A THIRD TIME:
+ The 108px cap existed to match the donut's height, so the legend never made the card
+ taller than the chart beside it. That cap is what forced .rd-lt to ELLIPSISE rather than
+ wrap — a wrapping row is ~2x tall, and in a 108px box that pushes items out of view.
+ The Driver has now ruled READABILITY OVER COMPACTNESS (2026-09-22): "on the religion and
+ region charts I cannot scroll right to read the rest of the text." He is right that there
+ was nothing to scroll to — .rd-legend is overflow-x:hidden by design, so an ellipsised
+ label was simply unrecoverable text.
+ So the legend is allowed to grow PAST the donut (176px, vs the donut's 84px) and .rd-lt
+ wraps to at most 3 lines. The card gets taller; that is the accepted cost. If you need to
+ reclaim vertical space, lower -webkit-line-clamp or trim the LABEL TEXT upstream — do NOT
+ restore white-space:nowrap + ellipsis here, and do not re-cap this to the donut's height:
+ that cap is precisely what forced the unreadable ellipsis the Driver rejected. */
+ .rd-legend{ display:flex; flex-direction:column; gap:2px; font-size:10.5px; color:var(--dx-text, #3b463d); max-height:176px;
overflow-y:auto; overflow-x:hidden; flex:1 1 auto; min-width:0;
scrollbar-width:thin; scrollbar-color:var(--dx-scroll-thumb, #c4c8c0) transparent; }
.rd-legend::-webkit-scrollbar{ width:6px; height:6px; }
.rd-legend::-webkit-scrollbar-track{ background:transparent; }
.rd-legend::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:6px; }
.rd-legend::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
- .rd-li{ display:flex; align-items:center; gap:5px; cursor:pointer; white-space:nowrap; padding:1px 2px; border-radius:3px; }
+ /* align-items FLEX-START (not center) and no row-level nowrap: with a wrapping label the row is
+ now two lines tall, and centring would float the swatch and the count in the middle of that
+ block, detached from the first line of text they label. The count is top-aligned with a 1px
+ nudge (.rd-li b) so it reads as belonging to the row's first line. */
+ .rd-li{ display:flex; align-items:flex-start; gap:5px; cursor:pointer; padding:1px 2px; border-radius:3px; }
.rd-li:hover{ background:var(--dx-surface, #f3f3f1); } .rd-li.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink, #3b463d); }
- .rd-li i{ width:9px; height:9px; border-radius:2px; flex:0 0 auto; } .rd-li b{ margin-left:auto; color:var(--rd-muted, #6d766e); font-variant-numeric:tabular-nums; flex:0 0 auto; padding-left:4px; }
+ /* margin-top nudges keep the swatch and the count optically on the label's FIRST line now that
+ a row can be two lines tall. */
+ .rd-li i{ width:9px; height:9px; border-radius:2px; flex:0 0 auto; margin-top:3px; } .rd-li b{ margin-left:auto; color:var(--rd-muted, #6d766e); font-variant-numeric:tabular-nums; flex:0 0 auto; padding-left:4px; margin-top:1px; }
/* The label is the ONLY item allowed to shrink. It used to be a bare text node, which in a
nowrap flex row cannot shrink at all: the row grew past .rd-legend (overflow-x:hidden) and
carried the VALUE out of sight — "Ethnoreligion - Chinese Folk Religion" rendered with no
number at all. Wrapping it in .rd-lt with min-width:0 makes it yield instead, so the count
- (flex:0 0 auto) is always the last thing on screen. Ellipsis, not wrap: the legend sits in a
- max-height:108px scroller beside a donut, so growing rows would push items out of view. */
- .rd-lt{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+ (flex:0 0 auto) is always the last thing on screen.
+ ── WRAP, NOT ELLIPSIS (card BUILD-REBALANCE-THE-TWO-SIDEBARS) ───────────────────────────
+ This previously read overflow:hidden + text-overflow:ellipsis + white-space:nowrap, a
+ DELIBERATE earlier choice to protect the 108px scroller (see the .rd-legend note above).
+ It is reversed on the Driver's explicit ruling: measured before this change, 4 of 16 legend
+ labels were ellipsised, including the two he named — "Ethnoreligion - Chinese Folk Religion"
+ (191px of text in a 130px box) and "North America & Non-Spanish Caribbean" (214px in 130px).
+ Because the scroller is overflow-x:hidden, that hidden text could not be scrolled to; it was
+ simply GONE. Two lines at this rail width fit both in full.
+ THE CLAMP IS 3, AND THAT NUMBER IS MEASURED, NOT GUESSED. A 2-line clamp was tried first and
+ still truncated the two labels above: at this rail width they need 40px of text height and a
+ 2-line box gives 27px. BEWARE THE INSTRUMENT — a clamped element reports
+ scrollWidth === clientWidth even while it truncates, because the clamp cuts VERTICALLY; the
+ horizontal overflow test says "fine" and is wrong. To re-verify, clone the label with the
+ clamp removed, measure its natural height at the same width, and require naturalH <= renderedH.
+ The clamp is kept (not dropped) so one pathological label can never own the whole legend; it
+ degrades to a plain wrap where -webkit-line-clamp is unsupported, which is still readable.
+ overflow-wrap:anywhere breaks only words that genuinely cannot fit, so ordinary labels still
+ break at spaces. Keep .rd-li's count at flex:0 0 auto — the number must never be the thing
+ that yields. */
+ .rd-lt{ flex:1 1 auto; min-width:0; white-space:normal; overflow-wrap:anywhere; line-height:1.28;
+ display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }
.rd-bars{ display:flex; flex-direction:column; gap:3px; }
.rd-bar{ display:flex; align-items:center; gap:6px; cursor:pointer; font-size:10.5px; padding:1px 2px; border-radius:3px; }
.rd-bar:hover{ background:var(--dx-surface, #f3f3f1); } .rd-bar.on{ background:#fff4ea; font-weight:700; color:var(--rd-ink, #3b463d); }
- .rd-bl{ flex:0 0 84px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--dx-text, #3b463d); }
+ /* Same wrap ruling as .rd-lt, applied to the bar chart's country labels: measured before this
+ change, 2 of 13 clipped — "People's Republic of China" (138px of text in 84px) and "Lao
+ People's Democratic Republic" (177px in 84px) — and like the donut legend there was no way
+ to scroll to the hidden half. Clamped at 3 lines — same measured reason as .rd-lt above, and
+ the same instrument warning: scrollWidth does NOT detect a vertical clamp. The column also
+ widens 84px -> 96px, because at 84px "Lao People's Democratic Republic" needed a 4th line;
+ the bar track (.rd-bt) is flex:1 1 auto so it absorbs the 12px. Value (.rd-bv) unchanged. */
+ .rd-bl{ flex:0 0 96px; white-space:normal; overflow-wrap:anywhere; line-height:1.28; color:var(--dx-text, #3b463d);
+ display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }
.rd-bt{ flex:1 1 auto; height:11px; background:var(--dx-surface, #f3f3f1); border-radius:3px; overflow:hidden; }
.rd-bf{ display:block; height:100%; border-radius:3px; transition:width .35s cubic-bezier(.4,0,.2,1); }
.rd-bv{ flex:0 0 auto; color:var(--rd-muted, #6d766e); font-variant-numeric:tabular-nums; min-width:30px; text-align:right; }
diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue
index 9fb115fcd..386bbd2a5 100644
--- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue
+++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue
@@ -506,8 +506,19 @@ useShadowStyles(`
were flex:1 1 auto and the bar's 24px floor competed with the text for the same row; with a
wrapping label the bar must be the element that gives way, so it is capped and allowed to
shrink to zero-ish on a narrow rail. The COUNT (.fr-cnt, flex:0 0 auto) never shrinks — it is
- the number the Driver could not previously see. */
- .fr-bar{ flex:0 1 56px; height:6px; min-width:0; background:var(--dx-surface, #f3f3f1); border-radius:3px; overflow:hidden; margin-left:8px; }
+ the number the Driver could not previously see.
+ ── 56px -> 34px (card BUILD-REBALANCE-THE-TWO-SIDEBARS) ──────────────────────────────────
+ Driver: "the little progress bars need to be less wide... I am fine with smaller progress
+ bars." Measured on the live composite before this change: the MEDIAN tree label had 85px
+ while the bar next to it had 56px — the bar was taking 40% of the label's budget to convey
+ a magnitude that .fr-cnt already states EXACTLY, in numbers, on the same row. The bar is
+ redundant precision; the label is the information. Combined with the wider rail this is
+ ~50px handed back to every row's text, which is what drives the wrap count down.
+ The bar is kept (not deleted) because the at-a-glance MAGNITUDE ranking is genuinely useful
+ when scanning; 34px still resolves ~10 distinguishable steps, which is enough to rank rows.
+ Do NOT solve a future width squeeze by shrinking the FONT — the Driver explicitly rejected
+ "super tiny text that cannot be read". Shrink this bar further first. */
+ .fr-bar{ flex:0 1 34px; height:6px; min-width:0; background:var(--dx-surface, #f3f3f1); border-radius:3px; overflow:hidden; margin-left:8px; }
/* magnitude fill — a QUANTITY bar, not a categorical series, so it takes the sage accent
rather than a hue of its own (the pale blue here was the last of the drift). */
.fr-bar i{ display:block; height:100%; background:var(--dx-accent, #92b195); border-radius:3px; transition:width .3s; }
diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardResearchFilters.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardResearchFilters.vue
index 2ba78cf54..a18a5773a 100644
--- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardResearchFilters.vue
+++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardResearchFilters.vue
@@ -47,7 +47,8 @@ const cfg = inject('dashboardConfig', {})
const rows = computed(() => dash?.rows?.value || [])
const filterSet = bus?.filterSet || {}
-// facet key -> accessor (returns the LABEL the bus matches on).
+// facet key -> accessor (returns the exact value the bus matches on — a label for most axes,
+// a CODE for the People tiers; see optionsFor's note on value-vs-display).
const ACC = computed(() => Object.fromEntries(Object.entries(cfg.facets || {}).map(([k, f]) => [k, f.get])))
// Which dropdown axes to render (config-driven; DX-life default five mapped to dashboard facet keys).
@@ -63,22 +64,36 @@ function applyWith(fset, list) {
}
// Build one axis's options with cross-filtered counts (this axis counted under the OTHER active
-// filters — DX-life updateFilterOptionCounts). option.value === option.label === accessor output,
-// so a pick lands a byte-match-safe value on the bus.
+// filters — DX-life updateFilterOptionCounts). option.value is ALWAYS the accessor's output, so
+// a pick lands a byte-match-safe value on the bus.
+//
+// ── VALUE AND DISPLAY ARE SEPARATE (card BUG-PEOPLE-AND-AFFINITY-BLOC-ARE-THE-SAME-AXIS-TWICE) ──
+// For most axes the accessor already returns a human label, so value === label as before. The
+// People axis (`affinity_bloc`) is the exception: its canonical value is a CODE ('A012'), because
+// it is the SAME key the semantic tree filters on — that shared key is what makes a tree click and
+// a dropdown pick one selection instead of two rival ones. A code must never be what the user
+// READS, so the facet may declare `optionLabel(row)` — a display projection of the SAME row — and
+// we key the tally by VALUE while carrying the name alongside. Both projections come off one
+// field on one record, so the pair can never drift.
function optionsFor(key) {
const acc = ACC.value[key]
if (!acc) return []
+ const optLabel = cfg.facets?.[key]?.optionLabel
const others = {}
for (const [k, v] of Object.entries(filterSet)) if (k !== key) others[k] = v
const subset = applyWith(others, rows.value)
const counts = new Map()
for (const r of subset) {
- const label = acc(r)
- if (label == null || label === '') continue
- counts.set(label, (counts.get(label) || 0) + 1)
+ const value = acc(r)
+ if (value == null || value === '') continue
+ const prev = counts.get(value)
+ if (prev) prev.count += 1
+ // Fall back to the value when there is no display projection (or it is empty on this row):
+ // an honest code beats a blank menu entry, matching labelFor()'s rule for the chips.
+ else counts.set(value, { label: (optLabel && optLabel(r)) || value, count: 1 })
}
return Array.from(counts.entries())
- .map(([label, count]) => ({ value: label, label, count }))
+ .map(([value, { label, count }]) => ({ value, label, count }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
}
diff --git a/plugin-designs/research-dashboard/src/composables/useDashboardLayout.js b/plugin-designs/research-dashboard/src/composables/useDashboardLayout.js
index 153710781..afeae6c84 100644
--- a/plugin-designs/research-dashboard/src/composables/useDashboardLayout.js
+++ b/plugin-designs/research-dashboard/src/composables/useDashboardLayout.js
@@ -3,8 +3,8 @@
*
* A dashboard shell (see plugin-designs/doxa-research-dashboards/research-dashboards.vue) is a CSS
* grid whose three flexible tracks are driven by CSS custom properties:
- * --facet-w (left facet-rail column width) default 248px
- * --rail-w (right charts-rail column width) default 312px
+ * --facet-w (left facet-rail column width) default 296px
+ * --rail-w (right charts-rail column width) default 264px
* --table-h (bottom table row height) default 34% (map above it is the 1fr hero)
*
* This composable OWNS those three vars: it exposes pointer-based drag handlers (one per edge),
@@ -21,7 +21,7 @@
* self-resizes via DashboardMapPanel's ResizeObserver → map.resize(); we add no second resize.
*
* NON-REGRESSION with the responsive slim rails:
- * `.rd-shell.rd-slim-rail{ --rail-w:240px }` / `.rd-shell.rd-slim-facet{ --facet-w:196px }` are
+ * `.rd-shell.rd-slim-rail{ --rail-w:228px }` / `.rd-shell.rd-slim-facet{ --facet-w:250px }` are
* medium-width niceties set via CLASS. Inline styles always beat class rules, so we apply inline
* vars ONLY once the layout is customized (restored-from-storage or dragged). An untouched
* dashboard writes no inline vars → the slim classes and the exact CSS defaults are preserved.
@@ -49,7 +49,9 @@
*/
import { ref, unref, onMounted, onBeforeUnmount } from 'vue'
-const DEFAULTS = { facetW: 248, railW: 312, tableH: '34%' }
+// Tree rail WIDER than the charts rail — see the rail-balance note in research-dashboard.vue.
+// These must stay in lockstep with the CSS `:host` defaults, since reset() snaps back to them.
+const DEFAULTS = { facetW: 296, railW: 264, tableH: '34%' }
const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70] }
const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v))