c43aa39 landed the same 311KB asset at public/assets/images/worldmap.svg --
DX-life's own path -- together with a PROVENANCE record: a full-file scan
for copyright, licence, attribution, author and the usual stock-library
names returns nothing, the ids are design-tool export names, and DX-life
keeps no licence file beside that folder either, so this is internal reuse
of first-party artwork rather than redistribution of a third-party asset.
The contrast it draws is the right one: the vendored typefaces ARE
third-party and do carry their OFL licence in the folder that serves them.
I had ported the same file to assets/brand/ earlier in this flight. MEASURED
identical -- md5 b7af01b31096c265e221a1d4e3f88c8d on both -- so the tree was
shipping 311KB twice, and, worse, keeping the provenance record in one
folder and a copy of the asset in another, where the two can drift apart
without anything noticing.
So the grid now points at the documented copy and the brand/ one is deleted.
Nothing about the rendering changes; only which of two identical files it
asks for.
VERIFIED on the built tree: /assets/brand/worldmap.svg now 404,
/assets/images/worldmap.svg 200, the #filters backdrop still computes
background-size 60% and position 50% 0% with 0 failed requests, check:sri 0,
gates:pre 0, gates:post 0.
Driver: "my filter underneath the simple research map does not look like the
same CSS styling of the one on dx-life."
Read the reference: UupgsList.vue puts the input and the Exact button in ONE
`.repel` row inside `.search-box`, so the modifier reads as part of the
field it changes. Ours had Exact in a SECOND filters__panel below the four
dropdowns, where it looked like a fifth filter -- and, because that panel
only exists while filters are expanded, a user searching with the panel
collapsed could not reach it at all.
★ COPYING THE REPEL WITHOUT THE WIDTH CAP MADE IT WORSE, and the screenshot
was the only thing that said so. dx-life's markup is
`.search-box | center | max-width-md` wrapping the repel: the repel only
reads as a PAIR because the box is narrow. Dropped into our full-width row
it pushed Exact against the far edge -- input ending at x=843, button at
x=1231, 390px apart -- which reads as an unrelated control. The row is now
capped at 600px and centred, and repels inside that. Wraps on a phone so the
button drops under the input rather than squeezing it.
NOT DONE, and deliberately left for the Driver: he listed Country, Adopted
and Engaged among dx-life's controls. This element ships FOUR dropdowns by
an explicit earlier decision of his, recorded twice in this file -- "COUNTRY
IS PROP-ONLY, NOT A FIFTH DROPDOWN ... the Driver's exclusion of Country was
about this element's OWN CONTROLS". Describing dx-life's controls is not the
same as asking for Country back, and adding it would silently reverse that.
Written up in the rollout ledger as a question instead.
gates:pre 0. gates:post 17/18, the single failure being the pre-existing
verify/tables-complete break from the visa-leak fix.
Driver: "there is an SVG of a map that shows up under the search bar, where
the toggles are... my filter underneath the simple research map does not
look like the same CSS styling of the one on dx-life."
Read the reference rather than guessing at it:
dx-life/app/components/public/UupgsList.vue puts
background-image: url(<images>/worldmap.svg); background-size: 60%;
background-position: top
on the container wrapping the search box AND the toggles. So it is a
BACKGROUND behind the whole block, not an icon beside the input -- which is
the detail that makes it read as a map tool instead of a form. Ported
faithfully, per the house rule to imitate DX-life's decisions rather than
substitute our own tokens.
The 311KB file is served as a SHARED ASSET, not inlined: a data: URI would
put it in every bundle that imports this grid, and it is decorative.
resolveAssetsBase() is the same helper the boundary geojson already uses, so
it resolves wherever the tree is dropped -- CDN, sub-path, or the studio --
instead of assuming a root-relative path. It lives in public/assets/, the
shared pool, which `clean` does not wipe, so no change to the tree verb.
It is drawn ONLY when the block exists. With show-search and show-filters
both off there is nothing to sit behind, and a backdrop with no controls on
it is noise.
MEASURED on the built tree:
/assets/brand/worldmap.svg HTTP 200, 0 failed requests
#filters computed backgroundSize 60%
#filters computed backgroundPosition 50% 0% (top centre)
grid leaf (defaults on) backdrop ON
research-map-plus-grid (Simple Research) backdrop ON, behind search +
Hide Filters + 4 dropdowns + Exact
dashboard-plus-grid (both props false) no #filters element at all
Screenshot read, not just counted.
gates:pre 0, check:public-hygiene 0. gates:post 17/18 -- the single failure
is the pre-existing verify/tables-complete break from the visa-leak fix,
filed and not ours.
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 LINK (Driver pasted localhost:5174/research/pitaguari/): the card's
"Full profile" base defaulted to root-relative /research/, which resolves
against whatever origin hosts the element -- right on doxa.life, wrong on
the dev server, an iframe, or a partner site. Same class as the ../../
child-src bug. Adopted the pattern the shared PeopleGroupDetail already
uses: window.DOXA_SITE_URL override, else the absolute canonical origin.
TWO owners had to move, and the first fix alone measured as a no-op: the
computed's fallback AND the prop's own default -- a non-empty prop default
is always truthy, so the canonical branch was unreachable until the prop
default became ''. Unset must mean unset.
Playwright on a non-doxa origin: all 24 rendered links now
https://doxa.life/research/<slug>, zero relative.
LAYOUT KEY v2: the Driver sees unequal sidebars because his browser
restores a layout saved under the OLD defaults (rail 264), and a restored
value outranks any default by design. Defaults changed materially today, so
the storage key is versioned: everyone starts once from the new symmetric
defaults; a fresh drag re-personalises from there.
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.
people-groups-grid caught a failed fetch, logged to the console and cleared its
loading flag. The template's `v-else-if="!displayedUUPGs.length"` branch then
rendered "No people groups match this filter." So when pray.doxa.life was
unreachable, the element told the reader their FILTER was wrong.
Measured by blocking that host in a browser: two console errors logged, and the
only thing on screen was the ordinary empty state. A user adjusts filters that
were never the problem; an operator glancing at the page sees normal operation.
The console held the truth and nobody opens it — least of all on a partner site
or inside the cap-4/cap-5 composites, which is exactly what this element is for.
Now distinguishes the two. A `loadFailed` ref drives its own branch: "People-group
data could not be loaded. This is a connection problem, not your filter."
Also checks response.ok, which was a second silent path. fetch() only rejects on
a NETWORK failure, so a 404, a 500 or an HTML error page all resolve — and
response.json() on them either throws into the same silent catch or yields an
object with no `posts`, which sets an empty grid and reports success. Both were
indistinguishable from "nothing matched".
Styling adds no colour: the palette is material-light only and --mut is what
this component already uses, so the failure line is separated by weight and a
hairline rather than an alarm state the design language does not have.
Verified both directions. Host blocked: the new message renders, no cards.
Host reachable: no message, the grid renders. And `bun run verify:gate1` still
passes 13/13 against 2,107 live records, including its "nonsense query -> 0
(empty-state path)" case — so genuine zero-match still works and is now a
different thing from a failed load.
Found by reading EXPLORE-dxlife-people-groups-refactor.md (2026-09-18) in the
staleness scan. Its PLAN is superseded — steps 1-5 describe editing DX-life's
own UupgsList.vue, which the standing law forbids and which this flight solved
by cloning instead. But its RISK register was still testable, and risk 3 named
this exactly: "external data dep on pray.doxa.life/api/people-groups/list". Half
of that was already mitigated — prayBaseUrl is injected three ways — and this
was the unmitigated half. Risk 6 ("FIVE dropdowns not four, confirm with the
Driver") is also settled: four are rendered.
A doc can be stale as a plan and still carry a live finding.
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.
FILTER DROPDOWNS WERE INVISIBLE. Opening a filter rendered its options underneath
the surrounding content, making the dashboard's primary control unusable.
The cause was CLIPPING, not stacking order, and that distinction is why an earlier
attempt failed. The filter band is a one-row horizontal scroller authored as
overflow-x:auto with overflow-y:visible — but CSS Overflow 3 coerces a visible axis
to auto when the other axis is not visible, so it computes to auto/auto and clips
vertically. Clipping happens during painting, before stacking order is consulted, so
raising the band to z-index 55 changed nothing: it won a stacking fight that was never
the problem. You cannot re-order pixels that were never generated.
The menu is now position:fixed, anchored to the trigger's viewport rect, which takes it
out of the clip chain entirely. Fixed coordinates go stale whenever anything scrolls or
resizes, so they are recomputed on open and while open, listening in the capture phase
so the dashboard's inner scroll regions are seen too.
PEOPLE-GROUP IMAGES WERE LETTERBOXED. A fixed 130px height against a 220px column
cropped each person to a band. The image is now a square via aspect-ratio, and the
column floor rises to 240px, roughly doubling the visible area. object-fit:cover is
kept deliberately — stretching to a square would distort faces, which is worse than
the letterbox it replaces.
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.
More than half the catalog had an empty description in manifest.json, so the
plugin-studio — the surface a Doxa inheritor browses to learn what exists —
showed flagship plugins as a bare "bundle - <tag>" with no explanation, while
a demo bundle explained itself fully.
Cause: the generator reads a JSDoc @description TAG, and these files carried
only prose. Every description added here is a summary of what that file's own
header already documented, not new invention. Claims were checked against the
artifacts: doxa-simple-map's "three presets" was verified against
simple-map.instances.json (prayer, engagement, adoption).
The two cap-5 shells needed a second pass. Their headers are HTML comments
with no leading asterisk, and the real TAG_RE requires `* @tag value`, so the
first attempt extracted nothing.
Result: 26/26 profiles described, 0 empty; the built gallery went from 15
empty / 11 populated to 0 / 26. Verified in a browser, not just in the
manifest. Embed suite 40/40.