Embedded with profile-config {"locale":"ar"}, these dashboards came back half
translated: the map's six tabs in Arabic and the grid's four dropdown labels in
English, on the same screen. That reads as "some strings were never translated",
and it sends whoever investigates to the catalogues — where every one of those
strings is already present and correct.
Two readers, one locale, and only one of them was being told.
t() — vue-i18n, follows the plugin's locale, which ProfileLoader already
drove from the host's config.
tStatic — the plugin-free reader that components mounted as their own custom
element must use (useI18n() throws at a custom-element root and
takes the subtree with it). It resolved the locale from the URL and
the document, which are the only two things a STATIC page can say.
So a locale set at RUNTIME moved every t() string and no tStatic string. Now the
i18n instance publishes its locale whenever it changes — one choke point, so
ProfileLoader, the SPA's picker and the studio's picker all work without any of
them remembering to tell the static reader as well.
★ ON THE WINDOW, NOT IN THIS MODULE, AND THE FIRST VERSION GOT THIS WRONG.
A composite loads SEVERAL bundles — research-map-plus-grid loads its own,
doxa-research-map's and people-groups-grid's — and each carries its own copy of
library/i18n. A module-level variable set inside the map's copy is invisible to
the grid's copy, so the first attempt translated the map and left the grid in
English: the very defect it was written to remove, reproduced one layer down.
Measured, then moved to a window-level store, the way the shared map token has
always crossed bundles. Module state is per-BUNDLE; a locale is per-PAGE.
Both composites also publish the locale themselves, because a composite sits
ABOVE every ProfileLoader on its page rather than under one.
MEASURED with check:host-locale-contract, on c7c2050d and on this commit:
dashboard-plus-grid/dashboard-plus-grid 32 of 2121 -> 2121 of 2121
research-dashboard/research-dashboard 75 of 1891 -> 1891 of 1891
research-map-plus-grid 489 of 1201 -> 1201 of 1201
whole fleet 18 failing -> 1 failing
The one left is big-picture/big-picture, which installs no i18n and mounts no
ProfileLoader at all — a different shape of gap, and not this lane's bundle.
Every bundle was rebuilt; composite-freshness and SRI are clean.
A host embedding this dashboard with profile-config {"locale":"ar"} got an
entirely English page. The config was threaded correctly and reached the
children; nothing ever turned it into an i18n locale.
WHY THIS COMPOSITE AND NOT ITS SIBLING, which passes the same gate untouched.
Simple Research mounts its children BY TAG, so every child is its own custom
element with its own ProfileLoader — and ProfileLoader is the only thing in the
tree that watches `config.locale || config.lang` and drives the i18n locale from
it. Big Picture BAKES its children in (it imports the .vue files), so there is
no loader anywhere beneath it and nobody was doing that job. The entry does
provide appI18n; it was simply never told which language to use.
So this composite now does what ProfileLoader does for the other one: inject the
i18n instance and drive its locale from the host's lang/locale.
MEASURED with check:host-locale-contract, before and after:
dashboard-plus-grid host 0 of 2121 -> host 32 of 2121
32, not 2121, and the remaining gap has a single known cause: most of this
dashboard's chrome is read through tStatic, which resolves the locale from the
URL and the document and cannot see a locale set at runtime. That fix lives in
library/i18n/index.js, is measured, and is parked behind another agent's landing
in the same file — the measurement also showed it must be backed by a
window-level store rather than module state, because a composite loads several
bundles and each carries its own copy of that module.
A reader on an Arabic page saw a handful of English words among the Arabic:
the table's "People Groups" title, its count line, the Engagement column, the
Gospel Access chart title, and the side drawer's close button. Every one of them
had a correct Arabic entry sitting in the catalogue; nothing looked it up.
THE CHECK CAME FIRST, AND IT REPLACES A SWEEP THAT PASSED A BROKEN TREE.
The old method sorted every rendered string into third-party / data / chrome and
counted only chrome. That split is a guess, and it guessed wrong exactly where
the defect lived: single words like "Region", "Prayer" and "Country" are as
plausible as values as they are as labels, so they scored as data and the sweep
reported zero on a page that was visibly leaking.
check:composite-locale-leaks asks a question with an answer instead:
the English catalogue holds this exact string as a value, AND
this locale holds a DIFFERENT value for that key, AND
the page still renders the English one.
No judgement about what kind of word it is — if the translation exists and the
reader is not getting it, that is the defect. The two surfaces a lookup cannot
judge (vendor map chrome, and table/facet cells carrying API values) are muted
by NAME with a stated reason, never by word, so muting one can never hide the
next real leak that happens to reuse it. The gate failed on this tree naming 9
strings before any of them were fixed.
Also threaded here: both composites now accept `lang` / `locale` in
profile-config and pass it to the children they mount, alongside `tk` and
`provider`. A partner writing {"lang":"ar"} reached nothing before — the knob
was documented and read by nobody.
The prayer facet's label in DashboardResearchFilters is now a catalogue getter
too. Measured before touching it: dashboard.config.js does define prayer_level
and FACETS spreads the config last, so that literal never rendered for this
config — it is a default for a config that omits the facet, and it stays, with
the merge order written down so the next reader need not re-derive it.
Four strings remain English with no catalogue entry at all — "Top countries",
"Select all {n}", the table dock's title, and the singular "group". They need
wording in ten languages that cannot be composed from existing entries, so they
are a DECIDE card for the Driver rather than translations invented here.
TWO FIXES THAT BOTH SHOW UP ONLY WHEN THESE PLUGINS ARE SOMEBODY ELSE'S GUEST.
1. ATTRIBUTE FALLTHROUGH. These SFCs mount AS custom elements, so a style the
host puts on the tag lands on the component's own root element. MEASURED with
a host style of "display:flex; position:relative; height:100%": that exact
string appeared verbatim on the inner root div of people-groups-grid,
stack-demo, research-map-plus-grid and dashboard-plus-grid. The gallery sets
one on every tab, and the same fallthrough collapsed the Prayer Walk Planner
to 76px. inheritAttrs:false on each root — a host styling the TAG is styling
the box, not our internals.
AFTER: the inner root carries no inline style on any of the four, and each
still renders full height (2997-4010px in the harness), so the guard did not
become the collapse it prevents.
Worth knowing for the next probe: shadowRoot.firstElementChild is a <style>
node — Vue injects styles first — so a probe that trusts it measures nothing
and reports "no fallthrough, nothing to fix". Take the first non-style child.
2. THE RESIDUAL CHROME the in-gallery re-measure found: the dashboard title,
Align Dashboard and its tooltip, World, Vertical Legend, groups in scope, the
per-row aria templates (Filter by / Remove filter / groups), and the map
drawer's aria label. Catalogued in all 11 locales and read through tStatic,
reusing existing wording where the catalogue already had the word.
The aria TEMPLATES matter more than their size suggests: they repeat per row, so
one untranslated template is dozens of English strings in a screen-reader's ear.
The token fix was half the contract. resolveMapProvider's ladder starts at
profile-config.provider, and a child reads the config its PARENT writes — so a
partner asking for
<research-map-plus-grid profile-config='{"provider":"maplibre"}'>
got a child still on mapbox, which then demanded a token. MEASURED on a
third-party origin: "No Mapbox access token provided", 0 tiles, no canvas — for
the exact configuration whose entire purpose is needing no token. Keyless at
volume is why MapLibre support exists; a composite that drops the prop cancels
it, and the failure reads as a token problem rather than a plumbing one.
Both composites now thread `provider` beside `tk`, validated to the two known
values so a typo cannot silently select a third engine.
MEASURED AFTER, partner page, NO token anywhere in the config:
research-map-plus-grid canvas 1280x906, ZERO requests to api.mapbox.com,
basemap painted, 12,379 pins, legend, grid
dashboard-plus-grid canvas present, ZERO requests to api.mapbox.com
Screenshot read: the keyless map also shows the deferred search-bar rule holding
on a partner page — no search bar, and the legend risen into its place with no
gap.
A JS embed of either composite mounted a map that could not draw. MEASURED the
way a partner actually embeds — one origin serving the tree with CORS, a
SEPARATE origin serving the page:
research-map-plus-grid "No Mapbox access token provided", style 0, tiles 0, no canvas
dashboard-plus-grid the same
(iframe embeds were fine throughout: style 2, tiles 9, 0 failures)
Both composites declared NO PROPS, so the host's profile-config — token included
— was read by nobody. Our own leaves hide this completely: a leaf carries a
BAKED token and assigns window.MAP_TOKEN, which the shared-token bridge hands to
the nested map. The failure therefore exists ONLY where there is no leaf, which
is precisely the partner page the embed snippet is written for. It is also the
snippet we publish: profile-config='{"tk":"YOUR_MAPBOX_TOKEN"}' is the documented
way to supply a key, and it did nothing.
Both now parse their host config, publish the token page-wide through the shared
composable, and hand it to their children. The two differ in how children are
mounted, so the last step differs:
research-map-plus-grid composes by TAG + BUNDLE URL, so the token is threaded
into the child element's profile-config, where its own ProfileLoader reads it.
dashboard-plus-grid bakes its children in and bypasses ProfileLoader entirely
— and ProfileLoader is the only provider of `mapboxToken` in the repo, so
DashboardMapPanel's inject fell through to its empty default. It now provides
`mapboxToken` itself, from the host's tk with the shared page token behind it.
Spread last and only when present, so a host that supplies none leaves the key
absent and the child asks for exactly what its standalone leaf asks for. A
malformed attribute is caught and ignored: the map half is more useful
token-less than the page is with a thrown setup().
This went in the PLUGINS, not the library. A shared change reaches every map —
that is the lesson SHOW_EXPORT_BUTTON taught this week, and nothing here needs
to be shared to work.
MEASURED AFTER, same harness: research-map-plus-grid canvas 1280x906, style 2,
tiles 9, 0 failures; dashboard-plus-grid canvas present, style 2, tiles 10, 0
failures — basemap, pins, legend, table and charts all drawn. Screenshots read.
Both LEAVES re-checked for regression: canvas 1, 0 failed requests, each still
painting its own map.
The vertical legend's Religion and Language top tiers emit DERIVED
family names ('Hinduism', 'Indo-European') that exist on no row field,
so they narrowed the dashboard but never the people-groups grid below
-- the last two dead axes of the tree-sidebar interop bug. The grid
now runs the SAME derivations (DERIVED_AXES) from the SAME library
modules the dashboard config reads (RELIGION_FAMILIES,
canonicalFamilyName, langFamilyByIso), routed through the shared
valueMatches() by a one-line rowAxisValue() resolver in BOTH filter
loops. imb_language_family added to the grid fetch; composite maps
religion_family/language_family onto the new props; the dashboardOnly
stopgap flags are retired.
Root cause of the first failed attempt: the re-filter watcher
hand-enumerated every filter prop -- the THIRD copy of the axis list
-- so the new props changed propFilters but never triggered a
re-filter. The watcher now watches propFilters itself: one owner,
future axes cannot miss it.
Measured round-trip: Hinduism -> dashboard 693 / grid 'Showing 693';
Indo-European -> 677/677; clear -> 2,107 both.
The Driver asked for "the phases of engagement, measured in the live data."
engagement_status cannot express them: over 2,108 live rows it is 2,081
Unengaged / 27 Engaged, so filtering on it separates 1.3% of the corpus and
tells a researcher almost nothing about where a group actually sits.
imb_gsec is the ordinal ladder underneath that flag, present on 2,106/2,108:
0 No Evangelicals, Churches or Resources ........... 225
1 Less than 2% Evangelical, No Active CP Activity . 1,853
2 Less than 2% Evangelical, Concentrated CP ......... 23
4 2% or Greater but Less than 5% Evangelical ......... 4
6 10% or Greater Evangelical ......................... 1
Cross-tabbed against the flag, ALL 225 groups with no evangelicals, churches or
resources -- the most lost category in the data -- are merely "Unengaged",
indistinguishable from the 1,841 groups that have some evangelical presence.
That distinction is the one the map exists to show.
Verified safe to filter on before wiring, the same way cluster/people_group
were: 5 distinct {value,label} pairs and NO value-vs-label cross-collision, so
valueMatches() selects the same rows whichever vocabulary arrives. It is a real
row field, unlike the derived language_family/religion_family facets, so it is
mapped in FACET_TO_PROP from the day it lands rather than joining the
dashboard-only class.
Wired through every layer that would otherwise silently drop it: the shared
sources.json defaultFields (the dashboard reads rows through that seam), the
grid's own fetch field list, the prop, the API-field-name filter key (imb_gsec,
not gospelAccess), the re-filter watch list, and the composite's prop binding.
Verified in a browser on the built tree with live data: the section renders all
five rungs with counts matching the API exactly; clicking "No Evangelicals"
takes dashboard scope 2,108 -> 225 and the grid to "Showing 225"; clicking
"Concentrated CP" gives 23 and "Showing 23"; deselecting returns both to
baseline. 0 failed requests.
Gates: facet-one-field, facet-cascade, facet-changes-map, csv-shape,
label-joins, one-palette, sfc-parse, parse all pass. verify:gate1 still 13/13
against 2,108 live records.
Sharing from dashboard-plus-grid produced a link to
research-dashboard/research-dashboard/index.html — its own nested child. A
recipient opened the inner dashboard alone and silently lost the grid half of
the composite. The gate said it precisely: "share URL reopened
<research-dashboard-map>, not <dashboard-plus-grid>".
The nested dashboard's DashboardTopBar owns the Share button, and inside the
composite that button had no idea it was a passenger, so it shared its own
address.
Two attempts, and the first one is worth keeping in the record because it built
and shipped and changed nothing. I set profileConfig.shareEmbedPath — tier 2 of
ShareButton's documented ladder, and the natural-looking channel. The ladder is
`embedPath || cfg?.shareEmbedPath`, and DashboardTopBar passes an explicit
prop (`<ShareButton :embed-path="SHARE_EMBED_PATH" />`), so a child that has
already made up its mind always wins. The value was baked into the bundle,
resolveShareUrl reads that key, and the rebuilt bundle failed the gate
identically — a fix that is present in the artifact and unreachable at runtime.
Tier 1, `shareUrl`, is the only rung above a prop, and its docs say exactly what
it is for: "a WHOLE url the HOST supplies wins".
Tier 1 takes a whole url, so it is computed rather than hardcoded, via the same
resolveEmbedBase() helper ShareButton itself uses: profileConfig.embedBaseUrl →
window.MAP_EMBED_BASE_URL → the root the built bundle derives from its own
script url. The link therefore stays correct on doxa.life, on nuxtbase.app and
in a dropped tree with no configuration. An absolute url would have pinned every
share to whichever host happened to build it.
Also adds the two uncovered bundles to smoke:share-all. Nine bundles ship a
Share affordance and the hardcoded --url list covered seven; research-dashboard
and dashboard-plus-grid were absent, which is why this survived. The list is now
ten targets over nine bundles. Note the structural difference from
smoke:embeds, which enumerates from manifest.json and therefore cannot omit a
bundle that ships later — a hardcoded list silently omits whatever arrives after
it was written, and that is what happened here.
This is the two-host defect class exactly: research-dashboard standalone PASSES
this gate, and the identical component inside the composite FAILED. No
single-host gate can tell those apart.
Verified: the composite now reopens <dashboard-plus-grid> with a canvas, and the
full suite is 10/10 pass · 0 fail, so nothing regressed. Eleven gates pass,
including sri, composite, composite-srcs, synced-css and one-palette. The share
gate aborts rather than reporting a false failure when handed Firefox, which has
no WebGL here — it told me to re-run with chromium, and it was right.
The Driver could not scroll down to the people-groups grid. The cause was a
layout mismatch rather than any one property: research-dashboard is a
fixed-viewport app whose table scrolls internally, and the cap-4 composite
stacked it above a grid and expected the page to scroll, pinning the band to one
screen so height:100% would resolve. At a 900px viewport that left the table a
269px window over 54834px of rows, so a wheel anywhere in the lower third landed
on the table and never reached the page.
research-dashboard now accepts layout:page-scroll through profileConfig, the
channel ProfileLoader already exposes as the profile-config attribute. A prop
would not work here: the registered custom element is ProfileLoader, which
renders the profile with no props bound, so a declared prop type-checks, builds
clean and never fires. Default is unset, so every existing embed is unchanged.
In that mode the shell height is auto, the map row carries an explicit height so
it cannot collapse, and the table keeps an internal scroll bounded at 62svh.
Both extremes were measured and both are wrong: at 34% of a screen the table is
a keyhole that captures every wheel event, and released to overflow:visible it
renders all 54834px and puts the grid at y=57777 -- reachable in principle,
useless in practice. A tall bounded panel is neither.
Verified in the cap-5 shell on a settled tree: wheeling over the map and
wheeling below it now both move the page 1500px, which is its entire scroll
range, and the grid lands on screen at top 851. The two gestures agreeing is the
real proof -- the broken state read 300 against 2311, the signature of something
consuming the wheel. The standalone leaf is unchanged: no page-scroll class,
900px shell, internal table scroll intact.
The cap-4 composite bakes research-dashboard's .vue directly, so it never runs
that bundle's entry file and has to repeat everything the entry does. It
repeated Pinia, i18n and the colour strategies. It did not repeat this one.
Measured in the shipped bundles:
research-dashboard.js ..... 1 occurrence of mapbox-gl-rtl-text
doxa-research-map.js ...... 1
dashboard-plus-grid.js .... 0
So Arabic, Hebrew and Persian labels rendered reversed inside the cap-5 shell
and correctly standalone. Not cosmetic here: Arab World has 99 rows and
Persian-Median 49, and a reversed label is a wrong NAME for exactly the groups
this map is about. It failed silently -- no error, no warning, just mirrored
text that reads as a font problem to anyone who does not read the script.
Fixed as a shared library function rather than a fourth hand-copy, which is
what the composite-registration card asks for: one definition, two callers,
nothing to drift. Idempotent by construction, since Mapbox throws on a second
setRTLTextPlugin in a session and that throw is swallowed.
Four of the five entry side effects fail silently; only provide('profileModules')
throws, and that is the one safe to omit because a baked composite does not use
ProfileLoader. So "it renders" is no evidence the entry's work was done.
Found by the map-beauty agent, which I verified independently before acting
rather than taking the bundle counts on report.
Sixteen source files from five agents, all verified against source gates
before commit: template-literal, sfc-parse, kpi-unit, composite-reg,
facet-cascade, one-palette, script-flags, no-public-binds, no-orphan-gates and
quoted-scripts all pass. public-hygiene reports 0 findings.
Colour. The choropleth ramp is now derived by interpolating the product's own
tokens (surface #f3f3f1 to accent #92b195) rather than adding a sixth grey, and
that fixes a pincer the earlier research had not named: on the old grey-to-black
ramp the navy dot and the white ring failed at OPPOSITE ends, so no fixed pin
colour could win. Worst-case dot contrast moves 1.13 to 7.29. Second-order
breaks fixed with it -- the country outline would have measured 1.08-1.82 as
white on the pale fill, so it is #6d766e at 2.59-4.34, and the legend swatch
border.
Religion colours no longer key by array index. colorFor takes no index at all;
colour comes from the registry by facet plus value. All 31 distinct live
religion labels now match the map, 0 mismatches. Tree-rail grey rows 142 to 0
against the live 2,107-row API.
Cascade. Clearing a parent facet now clears its descendants, in the selection
bus rather than in any component, because every clear path already funnels
through setFilter/clearFilters -- a component fix would have fixed one button
and left four. The hierarchy is derived from config.facetRail[].drill, so it
cannot drift from the tiers the rail renders. Place turned out to be three deep
(region, block, country), not two.
Rails. Tree rows wrapping 18/66 to 3/66; the tree rail is now the wider of the
two at 296 vs 264, with the map cell unchanged because both pairs sum to 560.
Also lands the shell scroll fix and the facet-cascade gate with its committed
fixture -- 73 sampled rows, no network, so the assertion that reproduces the
empty map runs on a fresh clone instead of silently skipping.
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.
Cluster and People Group changed the dashboard correctly and could never move the grid,
because the fields behind them were not in the grid's fetch. They are real fields, unlike
the two family axes, which are derived and have no row field to filter against at all —
those stay unmapped rather than being aimed at a lookalike and quietly showing the wrong
rows.
Verified against the live data before mapping, since a wrong mapping is worse than no
mapping: both fields are present on 2,106 of 2,107 rows in the same value-and-label shape
as the axes already wired, with 194 and 1,736 distinct values, no collisions once
normalised, and no case where one entry's label matches another's code. Codes and names
never overlap, so either vocabulary can be accepted safely.
Measured end to end: selecting the Telugu cluster narrows the grid to 93, which is
exactly the number of rows the API holds for it.
Checkpointing thirteen files of agent work that were sitting uncommitted while three
agents wrote to a live tree the Driver was watching through hot reload. Uncommitted work
is work at risk, and a tree being edited under an observer oscillates between states that
look like regressions and are really just partial saves.
The second capability-5 shell now mounts its tabs as real elements too, so no iframe
remains in either shell. It was the last holdout: eight tabs, all iframe mode, each
pointing at a hardcoded built-tree leaf that does not exist under the dev server.
The facet rail keeps its shape when a filter narrows it. Selecting one place took the
rail from sixty-six rows to twenty-one — forty-six rows deleted, none dimmed — so the row
a user was about to click vanished under the cursor, which is what "the buttons went
dead" actually was. Rows whose count falls to zero are now re-admitted at their original
slot and depth, dimmed and inert, which also restores the component's own promise to lose
nothing against the tree it replaced.
Expanding a branch and filtering by it are now separate, visible affordances: the whole
row filters, and expand is a real button with its own hit area and label. Rows that were
never clickable are unmistakably inert rather than silently ignored.
Three more facet axes reach the grid — affinity bloc, engagement status and primary
language — each verified to share a field with an existing prop rather than being mapped
on name resemblance. Two derived facets remain unmapped on purpose: they have no matching
row field, and mapping them would filter confidently to the wrong rows.
Four fixes to the research dashboard and the cap-4 composite, all Driver-reported.
GRID SHOWED ZERO ON EVERY SELECTION. Picking a region showed "1,309" in the chart
above an empty grid below. The two halves spell the same value differently: the
dashboard emits the human LABEL ("Asia", "North America & non-Spanish Caribbean")
while the API stores the machine VALUE ("asia", "north_america_&_non-spanish_caribbean"),
and the comparison was a strict ===, so it never matched. Measured over the 2,107
live records. Adds a shared valueMatches() normaliser used by BOTH applyDropdownFilters
and applyWith, so facet counts and shown rows cannot disagree. Compares value OR label,
case- and separator-insensitively, chosen over a label-to-value lookup table that would
need maintaining per axis and would rot when the API adds a region.
The grid's own dropdowns were never affected, because they build their options from the
data itself; only the host-prop path could inject a label into the comparison. That is
why the standalone grid is fine and the gate passes 13/13 on the same records.
COUNTRY NOW REACHES THE GRID. Clicking India in Top Countries did nothing because
country_code had no matching prop and was deliberately dropped rather than guessed at.
Adds a real countryCode prop and maps it. Country is prop-only, not a fifth dropdown.
LONG FACET LABELS HID THE COUNTS. white-space:nowrap let a long label eat the row, and
the rail scrolls vertically only, so there was no way to recover the number. Labels now
wrap; the bar yields; the count never shrinks.
CAMERA FLEW TO THE NORTH POLE on globally dispersed selections. Fits now trim outliers,
reject invalid and null-island coordinates, and unwrap longitudes across the antimeridian,
so the view lands on the data instead of a pole.
Two Driver-reported defects.
1. No more "Basemap tiles blocked" popup on the map. The banner reported an
operator problem (one Mapbox account setting on one deployment origin) to
the end user, who can do nothing about it, on every map and every load. The
console diagnosis stays, because it reaches the person who can act.
2. Selections in the dashboard now filter the grid beneath it. The two halves
were never connected: research-dashboard had no defineEmits and its bus is a
per-instance factory that is never provided, while dashboard-plus-grid passed
the grid no filter props. The grid's receiving end already worked. Added the
emit, plus a translation layer between the dashboard's snake_case facet keys
and the grid's camelCase props, mirroring big-picture.
Proven live: typing "Turk" takes the grid from 24 cards to 22 with wholly
different rows. Only 4 of 12 facets map; the rest are dropped rather than
guessed, because religion_family carries a family name while the grid's
religion prop matches a code, and mapping them would filter to zero rows.