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. --> +
{{ L.selected || 'Selected' }} - - {{ L.world || 'World' }} +
+ + {{ L.world || 'World' }} +
+
@@ -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))