Commit graph

54 commits

Author SHA1 Message Date
Doxa Maps
3548c73c09 On a phone the grid's pill search keeps its own 15.2px text, so the placeholder fits beside the round ☰
With the ☰ resting in its corner the row pads its footprint on both sides, leaving the field about
230px wide at 390, where the live list's 19.5px text doesn't fit the placeholder. Above 576px the field
keeps the live list's size.
2026-09-30 10:10:00 +00:00
Doxa Maps
56c6eacca5 The grid's search row keeps clear of the round ☰ only while the ☰ rests on it, and spans the row once the ☰ rides above the legend
The ☰ rests at the bottom of a phone's first screen until the map's legend sheet announces itself, so
while the map loads, or for good if it fails, it covered the start of the full-width field. The row now
pads by the ☰'s footprint on both sides while the layout's --vtl-fab-lift is 0, and not at all once the
☰ lifts, easing with the ☰'s own .2s move (none under reduced motion).
2026-09-30 09:50:10 +00:00
Doxa Maps
a66b120185 The grid's search and filters look like the live DX Life list: a pill search field, Show filters as an uppercase link, and 37px dropdowns
The values are the live /research list's own computed styles, measured at 390 and 1440. The search
field spans its row (up to 600px), since the round ☰ now rides above the map's legend sheet and no
longer rests on it. Every new rule is under .pgg-root, so Big Picture's header, which shares SearchBar
and FilterDropdown, is untouched; there the grid's search and filters are off. The filters still open
by default.
2026-09-30 09:36:45 +00:00
Doxa Maps
3b12fec79a The grid's search field is centred on a phone, clear of the round ☰ on both sides, and the unclear Exact chip is hidden
The ☰ clearance padded only the circle's side, which pushed the field off centre. It now pads both
sides, and the row centres the field. Exact is hidden with display:none; the search stays fuzzy and
the filter code behind the chip is unchanged. Big Picture and dashboard-plus-grid run the grid with
its search off, so neither renders differently.
2026-09-30 08:26:09 +00:00
Doxa Maps
7d2a5f7778 The grid's search row starts beside the gallery's round ☰ on a phone
At rest on a phone the search row sits where the gallery's 44px ☰ circle lands (bottom-left, or
bottom-right in Arabic), so the circle covered the start of the search box. The row now starts at
--map-fab-beside, which the layout publishes only while the circle shows. Everywhere else the
value is 0, so the change does nothing until the circle comes back.
2026-09-30 06:15:30 +00:00
Doxa Maps
8761483879 The grid's "Hide filters" icon no longer turns upside down
The filters toggle turned every svg in the button over when the filters were open. So the filter
icon beside the label flipped as well, with its longest line at the bottom, which read as a broken
icon. That shows on a phone, where the grid opens with its filters expanded. Only the chevron turns
over now.
2026-09-30 05:37:07 +00:00
Doxa Maps
1fd562c28e Read the page's own direction at mount, not at our first write
The clear-only-ours fix recorded the host's dir lazily, inside the rtl branch,
the first time it wrote. That is correct only while nothing else has written a
direction first. If anything set dir="rtl" on the host before that moment, the
element recorded rtl as "the page's own declaration" and then faithfully
restored it on every switch away from Arabic -- so the direction never cleared,
which is the defect this whole change exists to remove.

Measured on the Big Picture leaf: ar left rtl, and zh put rtl straight back,
while its three siblings with the same code passed. The difference was not the
code; it was that something writes rtl on that host before the element does.

Reading the attribute on the FIRST call instead, before any write of ours, is
the only moment it is certainly the page's and not ours. A page that genuinely
declares dir still owns that decision and still gets it back.

Five cases driven against the real function in all four files: no dir at boot,
a page declaring ltr, a page declaring rtl, an element mounting while already
Arabic, and -- the case that reproduces the failure -- a FOREIGN writer setting
rtl after mount and before ours. Restoring the lazy capture to one file fails
that fifth case alone and leaves the other four passing.
2026-09-29 20:31:04 +00:00
Doxa Maps
77415cf361 Switching from Arabic to Chinese left the page right-to-left
Every element that sets a reading direction did it one way: if the locale was
RTL it wrote dir="rtl", and there was no else. So a reader who moved from an
RTL locale to an LTR one kept the right-to-left layout for the rest of the
session -- Chinese laid out backwards, which reads as a broken page rather than
a stale setting.

The reason the clear was missing is sound and is kept: a host page that
declares its own dir owns that decision, and blindly removing the attribute
would take that with it. So each element now remembers what it found before it
first wrote, restores that value verbatim when the locale stops being RTL, and
removes the attribute only when there was none to begin with. It clears its
own, never the host's.

FOUR elements, not the three the report named: big-picture.vue,
research-map-plus-grid.vue and people-groups-grid.vue all carry it, and so does
dashboard-plus-grid.vue -- the Big Picture composite, same shape, found by
checking the neighbours of the named lines rather than only the lines
themselves.

The comment above each one said "Only ever SET, never cleared". Left alone it
would now describe the opposite of the code, so it says what the code does.

Verified two-sided against all four shipped files, driving the real extracted
function: it clears what it set, preserves a host's own dir, and still sets rtl.
Restoring the one-way body to one file fails exactly the first two and leaves
the third passing, with the other three files unaffected.
2026-09-29 20:31:04 +00:00
Doxa Maps
7d816f5eab Four more gates:post reds, hidden until a failed gate printed why
- research-map.vue's openPacket read and wrote _packetBusy, a name 8ab3dc0f5 replaced with the
  reactive packetBusy ref. Reading an undeclared name throws, so the packet export threw a
  ReferenceError on every click, in doxa-research-map and in research-map-plus-grid, the production
  map that compiles it. It uses packetBusy.value now (check:no-free-identifiers).
- check:list-prop-unit and check:optional-t-equivalence read every entry of library/i18n/locales as
  a locale, and the folder has held a README.md since bba33e783. They read locale FOLDERS now.
- people-groups-grid's regions-look instance previewed two props (filters-toggle, backdrop) of a grid
  merge that is not on development, so the page showed nothing different and
  check:instance-config-keys failed. It returns with that merge.
2026-09-29 18:40:27 +00:00
Doxa Maps
57671ca465 Merge remote-tracking branch 'origin/development' into regions/handoff-integration
# Conflicts:
#	public/dx-maps/cities-internet/cities-internet.js
#	public/dx-maps/cities-internet/connectivity-list/index.html
#	public/dx-maps/cities-internet/connectivity-map/index.html
#	public/dx-maps/cities-internet/data-tables/index.html
#	public/dx-maps/country-map/country-map.js
#	public/dx-maps/country-map/country-map/index.html
#	public/dx-maps/country-posters/countries-map/index.html
#	public/dx-maps/country-posters/country-poster-wall/index.html
#	public/dx-maps/country-posters/country-posters-mfe/index.html
#	public/dx-maps/country-posters/country-posters.js
#	public/dx-maps/demo-map/demo-map.js
#	public/dx-maps/demo-map/demo-map/index.html
#	public/dx-maps/demo-map/demo-map/maplibre/index.html
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/doxa-research-map/doxa-research-map.js
#	public/dx-maps/doxa-research-map/research-map/index.html
#	public/dx-maps/doxa-research-map/research-map/maplibre/index.html
#	public/dx-maps/doxa-simple-map/doxa-simple-map.js
#	public/dx-maps/doxa-simple-map/doxa-simple-map/adoption/index.html
#	public/dx-maps/doxa-simple-map/doxa-simple-map/engagement/index.html
#	public/dx-maps/doxa-simple-map/doxa-simple-map/index.html
#	public/dx-maps/doxa-simple-map/doxa-simple-map/prayer/index.html
#	public/dx-maps/gospel-internet/board/board/index.html
#	public/dx-maps/gospel-internet/board/index.html
#	public/dx-maps/gospel-internet/board/india-china/index.html
#	public/dx-maps/gospel-internet/board/language-tree/index.html
#	public/dx-maps/gospel-internet/board/platforms/index.html
#	public/dx-maps/gospel-internet/board/project-700/index.html
#	public/dx-maps/gospel-internet/board/zero/index.html
#	public/dx-maps/gospel-internet/gospel-internet.js
#	public/dx-maps/manifest.json
#	public/dx-maps/regions-dashboard/region-dashboards/index.html
#	public/dx-maps/regions-dashboard/regions-dashboard.js
#	public/dx-maps/regions-dashboard/regions-list/index.html
#	public/dx-maps/regions-dashboard/regions-mfe/index.html
2026-09-29 16:33:55 +00:00
Doxa Maps
a8bbc6131e Keep the reason, drop the speaker, in three merged-in comments
The handoff merges brought in three references that name the Driver in shipped
text: two comments in CenteredSetTarget.vue and one _why in the people-groups-grid
instances. Reworded to state the engineering reason without the speaker -- an
open decision now reads as pending a product decision, which is what the rule
asks for. The instances file still parses as JSON.
2026-09-29 14:59:23 +00:00
Doxa Maps
7869f28ebc Merge branch 'regions/grid-merge' into regions/handoff-integration
# Conflicts:
#	package.json
#	plugin-designs/people-groups-grid/people-groups-grid.vue
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
#	public/dx-maps/people-groups-grid/people-groups-grid.js
#	public/dx-maps/people-groups-grid/people-groups-grid/driven/index.html
#	public/dx-maps/people-groups-grid/people-groups-grid/index.html
#	public/dx-maps/people-groups-grid/people-groups-grid/people-groups-grid/index.html
2026-09-29 14:53:55 +00:00
Doxa Maps
4571596cf1 Merge branch 'bundler-2/grid-lazy-images' into bundler-2/handoff 2026-09-29 14:39:13 +00:00
Doxa Maps
2cde9c0918 people-groups-grid: keep the reasons, drop the speaker
Fourteen references, each rewritten to state the requirement directly. Every
date, measurement and pixel coordinate is kept — the y=1827/y=1865 redundancy
that prompted the count line, the 2026-09-22 interoperability report behind
the Language and Status props, the 220x130 letterbox the square image
replaced, the dev-server path that showed "view more information" reaching
the wrong page, and the India click that left the grid unchanged.

Thirteen were found by a case-sensitive search and a fourteenth was not: a
line in capitals, carrying a verbatim quote, which `grep -w Driver` cannot
see. The check that catches both is `grep -wi driver`, and it now reports 0.

Two sentences broke in the first pass and were found by re-reading every
changed line rather than by the reference count: one left "the control the"
with its object removed, and one left a quotation with an unclosed quote when
the surrounding attribution went. The count was correct while the prose was
wrong, which is why the count is not the check.

check:no-new-driver-references passes. research-map-plus-grid and
dashboard-plus-grid both rebuild byte-identical.
2026-09-29 10:30:59 +00:00
Doxa Maps
07eb74c33c Move four identical bodies into the library
From the fleet census: 620 function bodies indexed across 29 plugin trees,
16 identical in two or more. These are the four cheapest — none closes over
anything beyond its own parameters, so each is a file move plus an import
rather than a new dependency to thread.

hostElement resolves the custom element a component is mounted inside, one
hop out through the shadow root. It was identical in three plugins, and it
is the piece worth sharing for a reason other than line count: every plugin
ships through defineCustomElement, a composite nests two shadow roots, and
code that forgets the hop operates on the wrong node while looking correct.
A document-level query for a node inside a shadow root returns null on a
working build, which reads as a broken feature rather than a wrong query.
Three copies were three chances to write that.

findScrollportAncestor walks up to the nearest ancestor that actually
scrolls, crossing shadow boundaries on the way — parentElement stops at a
root, so a plugin in a custom element would otherwise find no scrollport and
conclude the page does not scroll.

findNodeInTree and buildFlatLegendMatchExpr join useLegendMapBridge, which
already holds four functions from the same seam. They sit at module level
rather than inside the factory because they close over nothing: a pure
function hidden in a closure can only be reached by constructing the whole
bridge.

Call sites keep their old names through a one-line local binding, so no
call changed and the diff is the declaration plus an import.

Rebuild-and-compare, all five affected bundles, before -> after:
  regions-dashboard   1,098,469 -> 1,098,479   +10
  country-posters     1,484,053 -> 1,484,028   -25
  doxa-research-map   1,354,051 -> 1,354,061   +10
  dashboard-plus-grid   915,660 ->   915,644   -16
  big-picture           366,907 ->   366,719  -188
  net -209, and country-posters gains 25 bytes against the 1.5 MB ceiling.
Every behaviour-bearing token counted, not set-compared, and unchanged in
all three map bundles: regions-fill, peoplePraying, engagementStatus,
adoptionStatus, overflowY, scrollHeight, getRootNode, _flat_filter, religion.

Owners cleared their own files first: regions for regions-list.vue, upg100
for countries-map.vue. check:selection-kinds, check:legend-matches-source,
check:legend-data-contract, check:packet-variants, check:no-dead-library and
check:no-cross-plugin-imports all pass.
2026-09-29 09:19:04 +00:00
Doxa Maps
34010ce6d8 The grid's "controls off" preset did nothing, and nothing said so
rega RQ20: the driven page asks for show-search:false and show-filters:false and
renders the search box and the filters bar anyway, with 0 console errors. There
were no errors because nothing failed. The key never arrived:

  · the preset writes KEBAB into profile-config — "show-search": "false"
  · ProfileLoader PROVIDES that object; it does not bind its keys as props
  · the profile reads props.showSearch (CAMEL), which keeps its default of true

Three correct-looking layers and no join between them. A preset key that does
nothing looks exactly like a preset key that works, which is why the fix ships
with a gate rather than a comment.

The grid now reads these flags off the config as well as the prop, in the same
prop -> cfg ladder researchUrl beside them already used, and accepts BOTH
spellings: someone writing profile-config by hand reaches for the kebab spelling
because that is what the docs show on the tag, while the prop is camel.
Accepting one silently is what made this inert. An explicit prop still wins.
Proved on five cases including the exact preset, an explicit prop overriding the
config, and config:true keeping the control on.

check:instance-config-keys asserts every preset key reaches the profile that
renders it. Writing it was the interesting part, and it was WRONG TWICE:

  1. First version matched only `cfg.` and reported NINE FALSE POSITIVES across
     four plugins — big-picture reads config.value.accent, doxa-simple-map reads
     profileConfig?.value?.tabs. The config object's variable name is each
     profile's own business; matching on it measured my naming habit, not the
     contract.
  2. Widening it to "any accessor" then matched `props.showSearch` — the exact
     line that does NOT work — and the gate went GREEN on the defect it exists
     for. props is the one object that cannot carry a profile-config key, so it
     is now masked out before the match.

Mutation-tested after that: reverting the fix to props-only makes it fail again,
and the grid file is restored byte-identical. A gate I have not watched fail is
not evidence, and this one passed for the wrong reason once already.

The four bundles that embed the grid are rebuilt, so the fix is in the shipped
.js and not only in the source rega cannot run.
2026-09-28 22:25:30 +00:00
Doxa Maps
8350617853 The country link went to the people group; four links broke on a missing slash
Two interop defects on production plugins, found by reading the links rather
than the code around them, and corrected against DX-life's own component once
dx-shepherd pointed me at it.

THE COUNTRY LINK POINTED AT THE PEOPLE GROUP. On the regions dashboard's card,
the country name under the title linked to that people group's own profile —
the same destination as the "Full Profile" button directly beneath it. Two
links on one card, one labelled with a country, both landing in the same place.

Theirs, in BOTH card variants (app/components/public/UupgsList.vue:610, :638):

    localePath(`/regions/${countrySlug(country_code.value, country_code.label)}`)

A page on the HOST site — not a research profile, and not the ../countries/
path my first pass reached for. So the base is a PROP, not a constant: only the
host knows where its country pages live, and DX-life's are under /regions/.
Prop, then profile-config, then the site base, so an embedder can point country
names anywhere without touching the research base.

people-groups-grid is ported from that same component and had the same defect
in a different disguise: `${researchUrl}../countries/<slug>`, climbing out of
the research base to a section DX-life does not use for this. Both now build
the same URL from the same countrySlug.

Our countrySlug is theirs: identical body, identical slugify (every transform,
in order), and the tables behind it match exactly — 242 codes each, zero
missing, zero differing labels. My first comparison of those tables parsed
their file into ZERO entries and reported "no differences", which is the shape
of a check that cannot fail; the number above is from the run that actually
parsed 242.

FOUR LINKS DEPENDED ON A TRAILING SLASH NOBODY WAS TOLD ABOUT. researchUrl and
prayBaseUrl are set by whoever embeds the element, in someone else's HTML. The
joins were bare concatenation, and RegionCountryMap's own prop comment records
the contract without ever telling the embedder:

    Pray -> prayBaseUrl/<slug>,  Full Profile -> researchUrl<slug>

One needs a trailing slash, the other must not have one. An embedder writing
research-url="https://doxa.life/research" — the spelling a human would choose —
got "https://doxa.life/researchbantu-tswana" on every profile link the plugin
draws: a 404, with nothing logged anywhere.

We cannot test what an embedder passes, so joinBase() encodes the rule instead
of documenting it: exactly one slash, whatever we are handed. Eight shapes
checked, including a doubled slash, a leading slash on the path, an empty path
and a null base.
2026-09-28 21:59:29 +00:00
Doxa Maps
182611c94b Regions batch: perRow boot, control inset, stats grid, print, two leaks, smaller watermark
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Seven changes to two plugins, re-applied onto current main as ONE source patch
rather than rebased, because the rebase conflicted in the generated bundles —
where a conflict is never resolvable by hand, since neither side matches what
either source builds. Source re-applied clean, everything rebuilt from it.

people-groups-grid
  · the saved column count is restored before first paint, not after, so a
    returning reader no longer watches the grid snap from auto to their choice.
    It re-reads if the profile-config key arrives late, and never over a choice
    the reader has since made by hand.
  · the cards-per-row control is inset from the card edge it was sitting level
    with (Driver round 2, #5).
  · the window vocabulary handle is released on unmount, identity-checked so the
    first of two grids on a page cannot strip the second's live handle.

regions-dashboard
  · the four stat cards no longer let Population stretch across a tablet row.
    auto-fit collapsed the empty tracks; auto-fill with the track minimum capped
    at a quarter of the row gives at most four columns and never a stretched
    orphan. Measured at nine widths.
  · the dashboard is printable at all. It had no @media print rule, so the app
    shell's absolute root and inner scroller meant the browser printed one
    screenful; the legend stayed on top of the map; interactive chrome printed.
  · both window globals are released on unmount, not just the remembered one,
    each identity-checked and on its own line.

shared asset
  · the worldmap watermark is 242 KB rather than 311 KB — same 394 paths, same
    drawing, coordinate precision trimmed to what the render resolves.
2026-09-28 20:20:50 +00:00
Doxa Maps
4d4e12ef09 Twenty-four photos were competing with the map for the first three seconds
rega measured a cold load of Simple Research pulling 24 people-group photos, 5.3 MB,
DURING BOOT — alongside the map engine and the API data, on one connection. Nothing was
wrong with any single image. There was no instruction about WHEN to fetch them, so the
browser fetched them all at once, which is the correct behaviour for markup that says
nothing.

Past the first row the cards now load lazily, at low fetch priority, decoded off the main
thread.

★ THE FIRST ROW STAYS EAGER, and that is the half a blanket loading="lazy" gets wrong.
Lazy-loading what the reader is already looking at trades a visible flash of empty cards
for bytes nobody saves. EAGER_IMAGES is a COLUMN COUNT, not a taste: the grid is
repeat(auto-fill, minmax(240px, 1fr)) unless a host overrides cards-per-row, so the first
row is never more than five cards at any width. It is a constant rather than a read of
perRow because the override is the host's state and this only needs a safe CEILING —
generous by a card costs a few KB, short by one lazy-loads something in view.

The size is stated on the element as well as in CSS. .uupg__image already carries
aspect-ratio 1/1, which is what actually reserves the box; width/height repeat the same
1:1 as an intrinsic ratio for the moment before the stylesheet applies. The same truth
twice on purpose, not a second different one.

check:grid-image-loading exists because this component is being MERGED with the regions
list right now, and a merge rewrites markup: four attributes on one <img> survive a review
and not a rewrite, and losing them is invisible — the page still works and costs 5.3 MB
again. It also fails if it cannot find the element, and asserts the first row is eager,
which a bare loading="lazy" would otherwise pass. Negative-tested four ways.

Coordinated with regions-plugin before touching a file they are actively reshaping: their
six hunks bracket this element without touching it, so neither of us collides.
2026-09-28 18:51:21 +00:00
Doxa Maps
741783b4a5 A preview the Driver can open, and the reason it would have shown him nothing
Added a regions-look instance so the merge can be compared side by side against
the standalone one: same component, same cards, only the two props he chose
differing. If anything else differs between those two pages, the merge changed
something it was not asked to.

Then the preview would have proved nothing, and finding that was the point of
building it. These generated pages set exactly ONE attribute —
el.setAttribute('profile-config', JSON.stringify(cfg)) — and never per-prop
attributes. A prop read only from `props` is therefore invisible to every
preconfigured instance: the page would have rendered the default look while its
title announced the variant. A demo that cannot fail.

So the merge props read profile-config as well, with the prop still winning when
a host passes one directly. Measured on the generated page rather than assumed:
BASE_CONFIG carries "filters-toggle":"link" and the element receives it inside
profile-config, never as an attribute.

Bundle rebuilt before the tree, and the props are in the artifact.

⚠ THE SAME GAP LOOKS LIVE FOR show-search / show-filters, and it is not mine to
fix blind: the shipped `driven` instance sets "show-search":"false" in
BASE_CONFIG, and useSearch reads only props.showSearch. If that never arrives,
the driven preview has been rendering its own controls all along while
advertising that they are off. I have not touched it — it needs one look to
confirm before anyone changes behaviour that three hosts embed.
2026-09-28 18:23:06 +00:00
Doxa Maps
53505aa03d Separate the backdrop's layout floor from its styling, and assert the defaults
sr caught a layout change hiding inside a styling one. dx-life's root treatment
carries min-height:400px, and shipping that as part of backdrop:'root' was
tempting because it came with it — but in a scroll stack a min-height is a FLOOR
on the grid band, not a backdrop rule. An empty or heavily-filtered grid that
collapses to a short band today would have become 400px tall with a world map
behind it. That is the shape of thing a reader finds before we do. The floor is
now its own opt-in prop, named for what it does.

And the gate sr asked for. "Every default reproduces today's grid exactly" is
the entire safety argument for putting three consumers on one component, and it
is exactly the kind of claim that is true when written and false three props
later — the grid ships in Big Picture, Simple Research and the studio preview,
so a drifted default moves all three at once, silently, because none of them
passes the prop that changed.

It reads the defaults through the SFC and JS parsers rather than by grep, since
a default is an AST node and a regex cannot tokenise JavaScript. It also asserts
the 400px is not hardcoded anywhere in the backdrop, which is the specific
defect above rather than a general rule. Falsified: flipping the backdrop
default to 'root' turns it red. A negative control pins that the reader really
sees defaults, so the arms are not vacuous.

Simple Research's own answer is recorded in the shape of the code: their filters
stay non-collapsible by default, because a collapsed panel on a map-plus-grid
hides the control a reader came to use.
2026-09-28 18:17:55 +00:00
Doxa Maps
b1e839a33f Parameterize the grid for the merge, and correct what the merge actually is
Measuring the two grids changed the shape of this. The ruling reads as porting
the regions dashboard's filters control into this one; the control is already
here. Same class, same show/hide labels, same aria-expanded, same caret. What
differs is the styling: this one is a bordered pill with a hover fill and a
COUNT BADGE of active filters, the regions one is borderless text whose caret
turns over on open.

So the prop is filtersToggle — which toggle, not whether — and 'link' keeps the
count badge. The Driver asked for the look of the control; removing a feature
along with the border would not be a restyle.

The backdrop is the same story. Both sides already use assets/images/worldmap.svg;
this one scopes it to the filters block, the regions dashboard puts it on the
list root at 60% / top / 400px floor, measured off their live page. So backdrop
is a placement prop, and 'root' switches the filters copy OFF — the same artwork
painted twice at two sizes reads as a rendering fault rather than a design.

Both defaults reproduce today's grid exactly, so the three hosts that already
ship it are unchanged until each opts in.

C3 measured before building, as asked: nil. This grid declares no scroll
container and no viewport-height unit, and neither does the regions list it
replaces, so embedding it inside .rd-scroll cannot produce a scroller inside a
scroller.

One defect caught in passing: my first attempt redeclared filtersExpanded, which
already exists. The parse gate named the line, and the duplicate was the thing
that revealed the control was already there.
2026-09-28 18:14:57 +00:00
Doxa Maps
07e086ff23 check:legend-top-offset runs in gates:pre; a grid comment states its rule instead of quoting it 2026-09-28 17:54:33 +00:00
Doxa Maps
5880e84616 Cards per row on the shared grid, with Auto as the absence of a value
The Driver asked for a top-right dropdown with Auto plus fixed overrides,
remembered, and said plainly that the CSS already working must not break.

So Auto writes NOTHING. The grid is repeat(auto-fill, minmax(240px, 1fr)),
which already gives one column on a phone and three to five on a desktop by
measuring its container; Auto leaves the stylesheet alone and only an explicit
override sets grid-template-columns. An Auto that computed a number and wrote
it back would look identical until the container resized, and then be silently
wrong — the same picture, with the responsiveness removed.

An override is honoured even when it is a poor idea: five columns on a 390px
phone is a 62px card. That is deliberate. The reader asked for it, Auto is the
default and one click away, and the control shows which is active. Clamping
someone's explicit choice to what we think is sensible is how a control stops
being trusted.

It is remembered per PROFILE, not per bundle. Two grids on one page are two
different lists, and useDashboardLayout already paid for that lesson when a
shared key made one caller inherit another's saved layout. Blocked storage is
caught and ignored: in private mode the default is the correct outcome, not an
error to report.

It sits in its own row rather than in #filters, which is gated on
useSearch || useFilters — a host turning both off would otherwise lose a control
that has nothing to do with either. It modifies the grid, so it lives on the
grid.

Both strings are authored in all eleven catalogues, in the snake_case this
catalogue uses: a camelCase key here would have resolved to nothing and rendered
its own name, which is the defect I shipped in a legend earlier today.
2026-09-28 17:53:39 +00:00
Doxa Maps
39b3285f55 The composed grid's locale depends on a spelling neither file mentions
Auditing Big Picture for goal (d) I read this as a live defect: big-picture threads the host's locale into
each child's profile-config as `lang`, while the grid's prop is `languageCode`. On a profile-config embed
that would leave a composed grid in the wrong language — exactly the failure 8b40f6c58 was written to fix.

It is not a defect. people-groups-grid.vue accepts three spellings — languageCode || locale || lang — and
`lang` is the third. Two greps settled it, and the near-miss is the point: nothing at either end said so.

Both ends now do. The writer records that the spelling is load-bearing and which fallback it lands on; the
reader records that `lang` has a caller and why that caller cannot reach it any other way — getRuntimeLocale
is module state, and a separately-built child bundle gets its own copy, so threading the config is the only
channel big-picture has.

★ WHY A COMMENT AND NOT A GATE. The failure is invisible in the obvious place: ?lang= embeds keep working,
because the grid falls through to localeFromUrl(). Only a host passing profile-config sees it, which is the
smaller and quieter half of the traffic. A gate for that would have to stand up a composed embed with a
profile-config locale and read rendered text — which is rega's browser run, not a headless check — so the
honest guard is to make the coupling legible to whoever next tidies either list.

No behaviour change. Verified: check:sfc-parse PASS.
2026-09-28 10:26:43 +00:00
Doxa Maps
6cc5542b70 One place decides how a count is punctuated, and it still says en-US
The numerals policy is HELD — whether counts follow the reader's locale is the Driver's decision — so
this changes nothing anyone can see. Every caller renders exactly the digits and separators it
rendered before. What changes is how many places that decision is written down: thirteen call sites
across four bundles becomes one, library/utils/formatCount.js, and eight of the thirteen (mine) now
read it.

That is the whole value while the policy is held. If the answer becomes "follow the locale" it is a
one-line edit in formatCount.js instead of thirteen edits by four owners, each free to pick a
different locale source — and the first divergence in that world would be invisible: two panels in
one dashboard, same number, different separators.

★ IT FORMATS. IT DOES NOT DECIDE WHAT EMPTY MEANS, and that is the bug this fold would otherwise have
shipped. I told dx-shepherd these three helpers were "byte-identical". They are not:
  DashboardDataTable     null/undefined -> ''   and '' falls through to Number('') === 0, so "0"
  DashboardDetailDrawer  null/undefined/'' -> '—'
  useDashboardShell      null/undefined -> '—'  and '' likewise becomes "0"
Three different answers to "what does a missing count look like", inside one dashboard. Folding them
onto one empty policy would have silently changed two of the three — a table cell going from blank to
an em dash, or an em dash where a zero used to be. So each caller KEEPS its own guard and hands
formatCount a value it has already established is real. The locale decision folds; the empty policy
does not, and the difference is written into the helper's header so the next person does not re-merge
them.

Folded: DashboardDataTable, DashboardDetailDrawer, useDashboardShell (its exported fmt keeps working
for the KPI strip), DashboardFacetRail's three inline template calls, people-groups-grid's two.
NOT folded, because they are other owners': regions-dashboard (3) and pplr-data-maps (2). The helper
is theirs to import when they want it; nothing forces them, and no gate demands it — a gate requiring
the helper everywhere would fail on their files today, which is not a thing to land on someone else.

Source only. research-dashboard, dashboard-plus-grid and people-groups-grid need a rebuild.
2026-09-27 06:04:08 +00:00
Doxa Maps
02b2e9d59a The second consumer drops its copy of the highlighter
people-groups-grid is the file whose duplicate earned useTextHighlight its place in
library/ under RULE 2, and studio agreed to treat it as unowned and asked for it to
be cut. Its hand-rolled range walk is replaced by highlightRanges(value,
match.indices), and its local escapeHtml goes with it: once the loop moved, that
function had ZERO callers left.

★ ONE CORRECTION TO THE STATED REASON. studio approved this with "removing the
null-throw is reason enough". Measured before accepting it: that escaper has exactly
three callers, all inside `if (value && typeof value === 'string')`, so the throw
was NOT REACHABLE here. The library escaper's null guard is still an improvement in
principle, but it fixes nothing that could fire in this file, and I would rather
carry the smaller true reason than the larger convenient one.

The reason that does hold is the original one: the same construction existed twice,
character for character apart from variable names. One fact, one copy.

The gate flips with it — check:text-highlight now asserts this file routes through
the helper, that its escapeHtml is gone, and that the hand-rolled range walk is
really removed rather than merely shadowed by the new import.
2026-09-27 01:16:50 +00:00
Doxa Maps
c58db47ac3 "Arabic" is 23 ISO codes, not 22
rega counted 23 while measuring the language link, and was right. My 22 came
from a cruder comma-split grouping of the labels; the language tier's actual
rule has three arms — exact, "base," prefix, " base" suffix — and selects 23.

Only the prose was ever wrong. Every computed number in the gate and in the
product came from the real rule and has always been 23; these are two comments
explaining it, in two files, one word each. I did not correct them at the time
because the branch carrying them was already in studio's queue, and rewriting a
branch somebody is landing is a worse habit than a stale comment.

A comment that states a measurement is a claim like any other, and this is one a
reader cannot check without redoing the measurement.
2026-09-26 18:38:20 +00:00
Doxa Maps
f79d8f48e2 D1: a filter may be several values, and one function decides what matches
SPEC-DASHBOARD-KIT D1. filterSet[key] may hold an array meaning "any of these";
keys still AND together. The array support is four lines. The reason this is a
landing rather than a patch is the other half.

`a(r) === filterSet[k]` WAS WRITTEN OUT SEVEN TIMES — the shell's filteredRows,
the facet rail twice, the map panel, the table, the charts rail and the CSV
exporter. Seven copies of a one-character rule are survivable while the rule
stays one character. The moment it grows an array case, six of the seven keep
comparing an array with ===, every one of them quietly matches NOTHING, and the
panels disagree with each other about what is on screen. That is not abstract:
the facet rail computes the COUNTS a reader acts on before clicking, so a rail
still using === would print counts for a scope the map and table no longer show.
So the predicate now lives in the bus and every panel calls it. The
de-duplication is what makes the four lines safe.

WHAT A CLICK DOES IS UNCHANGED, deliberately. A plain click still REPLACES. D1
gives the bus the capability; making a rail row additive on an unmodified click
would change what every existing click does to the flagship, which is a product
decision and not this one. The array path is reached programmatically today.

AND THAT PATH IS THE PAYOFF: "Arabic" now narrows the grid. The language tier
covers 22 ISO codes, so no single value expressed it, and the composite had to
send nothing and leave the axis unfiltered — honest, and visibly incomplete,
because the map narrowed and the grid did not. The whole code set now crosses and
the grid ORs it. Verified per tier on the committed fixture rather than on one
example: for all 805 tiers the joined set selects exactly the rows the map
selected.

★ THE SEPARATOR IS `|`, AND MY FIRST DRAFT GOT THAT WRONG IN A WAY WORTH
RECORDING. I joined on `,` and broke label matching outright: 120 of the 864 live
language labels are comma-inverted ("Arabic, Mesopotamian"), so a selection made
BY LABEL was chopped in half and matched nothing. A separator that can also occur
inside a value is not a separator — the rule the URL hash states one file over,
which I had just written and then violated. check:filter-multivalue caught it
before it left this machine, and it caught it because it asserts a behaviour I
was KEEPING (label matching) and not only the one I was adding. Asserting only
the new behaviour would have shipped it.

EMPTY IS NOT A FILTER. An empty array clears the key rather than matching
nothing — a UI deselecting its last chip produces exactly that array, and
treating it as "narrow to zero" is the silent-empty-screen failure the hierarchy
contract already exists to prevent. A one-element array collapses to the scalar
it means, so the hash, the chips and every adapter keep their existing shape
until somebody actually picks two.

THE SURFACES THAT CANNOT SHOW A LIST SAY SO HONESTLY. labelFor resolves each
value and joins them, at the one owner, so the chip bar, the research-filter
chips and the CSV provenance line cannot drift apart. A dropdown shows NO
selection when the key holds several: ticking the first of three would state
something false about the board, which is worse than stating nothing, and the
chip still names every value so the selection is never invisible. The URL hash
carries a list and drops the display slug for it — the slug is one human name for
one code, and there is no honest slug for twenty-two.

check:filter-multivalue runs the real bus, greps the panels for surviving copies
of the predicate, round-trips the hash including a value containing a comma, and
lifts the grid's valueMatches to prove both ends OR the same way. No browser.

Green: check:filter-multivalue, check:language-code-link, check:facet-cascade and
its self-test, check:kpi-unit, check:csv-shape, check:no-free-identifiers,
check:sfc-parse, check:mjs-parse, check:descriptions, check:no-orphan-gates,
check:doc-scripts, check:script-flags, check:quoted-scripts.

SOURCE ONLY. Stacked on kit landing 1, which moved the bus into library/.
STUDIO: needs a rebuild of research-dashboard, dashboard-plus-grid,
doxa-research-map, research-map-plus-grid and people-groups-grid. package.json is
main's copy plus two lines beside check:fits-screen.
2026-09-26 17:28:46 +00:00
Doxa Maps
ae5e27c389 The grid's DATA follows the URL's language, and the grid turns round in Arabic
Two defects rega found at ?lang=ar and ?lang=es (row 30).

★ THE CHROME TRANSLATED AND THE DATA DID NOT. The `&lang=` on the people-groups
fetch was built from props and profile-config only, never from the URL — so a
reader who asked for Arabic got Arabic buttons around English country, affinity
and religion names. That reads as a half-finished translation rather than the
wiring bug it is. It now uses the library's own localeFromUrl() — imported, not
re-implemented — so the fetch and the chrome cannot disagree about what the URL
asked for. Same ladder regions uses, and a host passing languageCode still wins.

★ AND THERE WAS NO RTL AT ALL. In ar the cards, the total and the filter bar laid
out left-to-right around Arabic words. createAppI18n reflects an RTL locale onto
document.documentElement, which is right for a page it owns and useless for an
element dropped into somebody else's — which is this grid's whole job. `dir` is
set on the HOST now, like both composites: it takes effect through the inherited
CSS `direction`, and inheritance crosses shadow boundaries, so one attribute
turns the element and everything it renders. Reflected on mount as well as on
change, because the common case is a page that loads in Arabic and never changes.

⚠ ONE CONSEQUENCE I AM NOT HIDING. Localizing the DATA introduces a coupling the
linking did not have: the composite's language facet travels as a DISPLAY NAME,
so the map and the grid must now fetch in the SAME locale for that link to match.
Region links on a slug and religion on a family letter, so both are unaffected;
language is the one that depends on it. check:linked-vocabulary compares the two
halves' vocabularies and would catch a mismatch, but it runs in English today —
extending it to a non-English locale is the honest next step, and it is not done.
2026-09-26 07:56:38 +00:00
Doxa Maps
e3d25ff6db The people-group list is a universal prop: the same maps can show any project's list
Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
  profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').

- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
  in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
  to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
  counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
  (71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
  The default list returns the same array, unfetched. A list whose membership cannot load shows no
  groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
  their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
  Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
  are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
  the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
  thread `list` to their children like `provider`.

Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.

Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
2026-09-26 05:16:02 +00:00
doxa-maps-88655c70
93ace277df Match the live page's wording where we differed only by case
The census against doxa.life/research found our grid saying "Full profile" and
"Load more" where the page we clone says "Full Profile" and "Load More". Case,
nothing else — the kind of difference that is invisible in review and obvious
side by side. Fixed in the English catalogue and in the fallback literals; the
other ten locales are untouched, since none of them has a capital to drift.

The filter toggle's "Hide Filters" is now SANCTIONED in the preset rather than
fixed, with the Driver's words, the date and the card: our filter panel is open
on load because the Driver reported not being able to find the dropdowns, so our
toggle offers to hide where theirs offers to show. Same control, other state.

Everything else the census found went to the Driver as
DECIDE-RESEARCH-CENSUS-DIFFERENCES rather than into the allowlist — the legend's
prayer-band wording, our four additions, and their page title.

★ AND THE CENSUS TURNED UP A REAL DEFECT WHILE CONFIRMING THE "DATA DRIFT".
The drift is OURS. Measured against pray.doxa.life/api/people-groups/list with
this repo's own thresholds (prayer-progress.js: 0 or null = Needs Prayer):

    API         Needs Prayer 1,162 (58.8M)    Has Prayer 945 (147.5M)
    doxa.life   Needs Prayer 1,162 (58.8M)    — matches the API exactly
    our legend  Needs Prayer 1,417 (69.1M)    Has Prayer 690 (137.1M)

254 people groups the API says have someone praying are drawn and counted by our
map as having nobody. Their snapshot is right and we are wrong. Recorded on the
card with a candidate cause (useMapLayers.js:226 reads only the camelCase
peoplePraying and defaults to 0, where getPrayerLevel also accepts the snake
_raw.people_praying) and passed to the shepherd for its own card rather than
fixed inside a wording commit.
2026-09-25 17:08:16 +00:00
doxa-maps-88655c70
16629f4906 Link religion and language between the map and the grid, by the one table they already share
A reader who picks a religion family on the map now sees the grid narrow to the
same peoples, and a language selection travels the same way. Region already
linked; these two did not, because the two halves do not name a religion the
same way and nothing yet proved what the honest translation was.

The map filters religion through its legend's flat tab, keyed by the FIRST
LETTER of the religion code ('B'); the grid filters through a derived axis that
runs library/colors/religion.js forward per row ('BLA' -> 'Buddhism'). One
shared table, read in opposite directions, copied by neither half — so the wire
carries the family NAME and each side resolves it against its own data.

Two measurements changed the design on the way:

  - mapStore.filters.religions, which the map's filter panel writes, is read by
    NOTHING: the mapLayers.applyFilters() it was meant to reach is called and
    never implemented anywhere in the tree. A link routed through it would have
    set state, passed a smoke test and dimmed no pin.
  - the map's legend selections are mutually exclusive by design, so it can
    light exactly one of region/religion/language. The composite mirrors that
    rather than letting the grid hold three facets the map cannot show.

Religion links at the FAMILY and not at the code, deliberately: a map cannot
distinguish 'BLA' from 'BMA' — both dim as Buddhism — so linking at the code
would promise a precision one half cannot keep.

check:linked-vocabulary proves it by measurement rather than by reading: it
compares the total reached over the composite's wire against the total the grid
gives for the same facet on its own (83 of 2,107 both ways), and runs the MUTANT
first — the legend key 'B' instead of 'Buddhism' — failing as NO-TEETH if the
wrong mapping scores the same as the right one. Measured: mutant 0, real 83.

The CLI doc's apk:debug quote was realigned in passing; package.json had gained
app:start-page and the doc still described the older chain.
2026-09-25 10:20:27 +00:00
doxa-maps-88655c70
7f700935c5 Simple Research + Big Picture: the host's style stops at the box, and the last English goes
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.
2026-09-25 08:19:56 +00:00
doxa-maps-88655c70
03b838e16d Simple Research: one filter state, so the two halves move together
The composite showed a map and a grid that ignored each other: filtering the grid
to Africa moved its own count 2,107 → 315 while the map beside it kept every pin.
The composite now owns ONE filter state; the children report theirs upward as DOM
events and accept ours downward as attributes, so each stays independently
embeddable — a child mounted alone hears nothing and is handed nothing.

MEASURED on a served built tree, region facet:
  grid → map   2,107 → 315, and the map's own state follows (externalRegion and
               selectedRegion both 'africa'); clearing returns both
  map → grid   the map's map-filtered contract → the grid's list 2,107 → 315;
               clearing returns it
  standalone   grid alone 24 rows / Total 2,107 / 0 errors; map alone canvas 1 /
               0 errors — unchanged
  MUTATION     disabling the composite's push leaves the grid filtering itself
               (315) while the map stays empty, and kills the reverse direction:
               the check fails when the link is broken

Four things this cost, each of which first looked like it worked:

1. StackLayout applies `attrs` ONCE at create — its documented contract. The
   first version recomputed `sections`; the composite adopted the filter and the
   map's attribute in the DOM never changed. A computed nobody re-reads is not a
   channel, so the state is now PUSHED onto the mounted children.
2. The echo cancelled itself. Composite pushes → the grid re-filters → it emits
   groups-filtered with activeFilters still EMPTY → the composite adopts that as
   "the reader cleared it" and wipes what it just applied. Both handlers were
   correct; the round trip undid itself in under a second. A push is not a user
   action, so the push window ignores children.
3. The grid ignored host filters BY DESIGN when its own dropdowns are visible:
   activeFilters picks local OR props, never both. Right for the desktop
   composite, which hides them; silently wrong here. A host facet now seeds the
   LOCAL state, so the map's selection appears as a chip in the grid's own bar
   and the reader can clear it there.
4. That seeding called lbl(), which is local to another computed — "lbl is not
   defined", watcher dead, attribute set, list unchanged. Visible only after the
   probe was widened to console.error. A silent watcher failure looks exactly
   like a feature that was never wired.

SCOPE, DELIBERATE: region only. The map filters religion and language by the
canonical strings its own data carries; the grid filters them by slugs from a
different table. Linking those means inventing a mapping, and a wrong link is
worse than an honest gap.

The map also gains a verification handle (window.__researchMapFilterState), the
same contract as __rdashMap and __grMap: a linked filter is a STATE claim, and
every DOM proxy for it was either tab-specific or a selector guess.
2026-09-25 06:10:43 +00:00
doxa-maps-88655c70
344990d186 Research map: centre the active tab without scrolling the host page
On mount the map centred its active tab with

    btn.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' })

and 'nearest' does not mean "do not scroll the page". It means "scroll every
scrollable ancestor by the least amount needed", and on a partner page the HOST
is an ancestor. Measured by the embed sweep: a page carrying this map below the
fold jumps to y≈640 the moment it mounts — on a phone, where Simple Research is
the default tab. The reader asked for nothing and the page moved under them.

The tab strip now scrolls ITSELF: compute the offset that centres the active
button inside its own box, clamp it to the strip's own scroll range, and write
scrollLeft. Same shape as SemanticTreeLegend's bringRowToTop, which scrolls the
container it found rather than asking the platform to walk ancestors. An element
that only ever writes its own scrollLeft cannot move anybody's page.

The exemption goes in the SAME commit, per that gate's contract — main never
carries a stale exemption or a red gate on someone else's bug. Both
NEIGHBOUR_KNOWN entries (research-map, research-map-plus-grid) are deleted here.

MEASURED after, with the exemption gone: check:gallery-embed PASS, and the
neighbour phase now reports "research-map leaves the host page alone" and
"research-map-plus-grid leaves the host page alone" on its own evidence — js 13
tabs, iframe 13 tabs, neighbour 13 tabs. The two KNOWN lines still printed belong
to regions-dashboard and are theirs to clear.
2026-09-25 03:57:20 +00:00
doxa-maps-88655c70
f824329466 One worldmap.svg, at the path that carries its provenance
Some checks are pending
build-gate / build (push) Waiting to run
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.
2026-09-24 09:37:45 +00:00
doxa-maps-88655c70
9f0df8b579 Exact sits in the search row, where DX-life puts it
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.
2026-09-24 03:52:44 +00:00
doxa-maps-88655c70
9202469197 The grid's filter block gets DX-life's world-map backdrop
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.
2026-09-24 03:40:35 +00:00
doxa-maps-88655c70
446ec38c5b Religion/Language tree TOP TIERS now filter the grid (derived-axis interop)
Some checks are pending
build-gate / build (push) Waiting to run
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.
2026-09-23 23:00:00 +00:00
doxa-maps-88655c70
80d5981229 People-group links go to doxa.life from any host; stale layouts retire
Some checks are pending
build-gate / build (push) Waiting to run
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.
2026-09-23 22:10:18 +00:00
doxa-maps-88655c70
1449d47573 Add the Gospel Access filter, the ladder the binary flag was flattening
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.
2026-09-23 13:19:22 +00:00
doxa-maps-88655c70
94b356d2b3 Tell the user when the data source is down, not that their filter matched nothing
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.
2026-09-23 09:31:10 +00:00
doxa-maps-88655c70
6b29e146df Let the deepest two tree tiers reach the grid
Some checks are pending
build-gate / build (push) Waiting to run
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.
2026-09-22 16:22:21 +00:00
doxa-maps-88655c70
38a7dbb939 Land the swarm's work: no iframes anywhere, a tree that keeps its shape, deeper facets
Some checks are pending
build-gate / build (push) Waiting to run
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.
2026-09-22 14:51:52 +00:00
doxa-maps-88655c70
012c79328a Free the filter menu from its clipping ancestor; square the people-group images
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.
2026-09-22 13:31:11 +00:00
doxa-maps-88655c70
44dd2d01f3 Make dashboard selections actually filter the grid, and fit the camera to the data
Some checks are pending
build-gate / build (push) Waiting to run
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.
2026-09-22 12:50:37 +00:00
doxa-maps
344c4dc831 Describe every plugin: 15 of 26 profiles shipped with no description
Some checks are pending
build-gate / build (push) Waiting to run
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.
2026-09-22 03:11:07 +00:00
doxa-maps-88655c70
4c9aca1321 show the grid filters by default and make the toggle a real control 2026-09-21 05:50:19 +00:00
doxa-maps-88655c70
37b9a550f7 Add show-total prop so an embedded grid defers its row count to the host 2026-09-20 05:57:47 +00:00