Record which kind of verification closed each card
Some checks are pending
build-gate / build (push) Waiting to run

A card was closed on an agent measurement while the bug was still visible to
the Driver, and the card had no field in which that disagreement could live.
The board said done and the product said broken, and nothing in the data knew.

Adds fields.verification (agent|human|both) plus verification_kind
(visual|state) and a conflict flag, back-filled across 179 closed cards, and
a gate that refuses an anonymous close.

The gate does not require human sign-off. Gating the board on human review was
tried and it stalled. What it forbids is losing the distinction, so the
question "which of these did a human actually look at?" stays answerable.

The visual/state split encodes the asymmetry the Driver named: human
verification is more reliable on visual bugs, agent verification on non-visual
ones. A headless probe reads geometry, not perception -- it can measure a menu
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 counts are reported separately.

Negative-tested three ways: stripping a tag, claiming human verification with
no quote, and marking a conflict card done each exit 1 by name.
This commit is contained in:
doxa-maps-88655c70 2026-09-22 17:00:26 +00:00
parent 7a648f1e1c
commit e1d27cb476
10 changed files with 367 additions and 75 deletions

View file

@ -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.')

View file

@ -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. -->
<!-- ★ 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. -->
<div v-if="useFilters" class="sfh__chips" :class="{ 'is-empty': !chips.length }">
<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)">&times;</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 class="sfh__chip-strip">
<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)">&times;</button>
</span>
</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>
@ -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);
}
</style>

View file

@ -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",

View file

@ -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',

View file

@ -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

View file

@ -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. */

View file

@ -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; }

View file

@ -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; }

View file

@ -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)))
}

View file

@ -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))