Commit graph

531 commits

Author SHA1 Message Date
doxa-maps-88655c70
b5bebbe1e4 Merge remote-tracking branch 'origin/main' into exp-shepherd/apg-assets 2026-09-25 04:18:49 +00:00
doxa-maps-88655c70
34ec9eba78 Merge remote-tracking branch 'origin/main' into exp-shepherd/apg-assets
# Conflicts:
#	public/doxa-maps/index.html
2026-09-25 04:13:21 +00:00
doxa-maps-88655c70
814124b3f6 Plugin studio: its own words in every locale
Some checks are pending
build-gate / build (push) Waiting to run
Choosing a language in the studio translated the plugins it mounts but
left the studio itself in English: the title, the count, the filter box,
Collapse all, the section names, Copy embed, the loading and error
messages, and the rail's own Collapse button and aria labels.

The studio's strings now live in library/i18n/locales/<lc>/studio.json
for all 11 locales, held to key parity by check:i18n-parity like every
other catalog. The studio has no vue-i18n instance, so it looks strings
up directly (locale, then English, per key). The Explorer tree takes
them through a `strings` prop. VerticalTabsLayout gains an optional
`labels` prop for its five words; hosts that do not pass it keep the
English defaults, so doxa-maps-spa is unchanged until it opts in.

Verified by switching the studio through every language its switcher
offers (en, es, fr, pt, ar, ru): no English left in the studio chrome.
Technical names (CAP n, element tags, tier machine names in tooltips)
stay as they are.
2026-09-25 04:10:49 +00:00
doxa-maps-88655c70
6d09e4f8d6 All People Groups: find its data when a partner page embeds the script
With no assetsBase in the profile-config, the map fell back to '/assets' and
asked the host site for people-groups-slim.json. That is a 404 on any partner
page, so a script embed drew a basemap with no groups on it. It now resolves the
base through resolveAssetsBase(), the same path the other bundles use, which
finds the asset pool inside the tree the script came from.

Measured with the tree served under /plugin-assets/doxa-maps/ and a partner
page on the same host: main requested /assets/... and got 404; this build
requested /plugin-assets/doxa-maps/assets/... and got 200, drawing all 12,379
locations. The leaf page and the Research Gallery tab load on both builds.
2026-09-25 04:08:27 +00:00
doxa-maps-88655c70
1e3a0b0b27 Take main
# Conflicts:
#	public/doxa-maps/index.html
2026-09-25 03:59:04 +00:00
doxa-maps-88655c70
d77ca860b3 Arabic did not get a translation, it got a scrambled English one
createAppI18n sets document.documentElement.dir=rtl for Arabic, which is right
for a translated app. Every string in these five profiles is hard-coded English
— measured at ?lang=ar: identical word count to ?lang=en and zero non-Latin
characters — so the flip gained nothing and cost a great deal. On the built
tree it clipped the legend title's first letter against the left edge, turned
30-59% into 59%-30, moved every colour swatch to the far side of its label,
threw sentence-ending periods to the START of lines, and dropped the zoom
controls on top of the search box. Not untranslated: scrambled.

The five profile roots declare dir=ltr until their strings are translated, and
the comment says to take it off on that day. The host page's own dir is left
alone, as createAppI18n already intends.

Arabic now renders exactly as English does: 12,379 of 12,379 pins, full
choropleth, nothing clipped.
2026-09-25 03:58:39 +00:00
doxa-maps-88655c70
14f8388172 Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-25 03:57:29 +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
5418a79cd8 Take main
Some checks are pending
build-gate / build (push) Waiting to run
2026-09-25 03:50:20 +00:00
doxa-maps-88655c70
851aa93e16 The manifest published an element name nothing defines
gospel-dashboard.vue declared @element gospel-resources-dashboard. index.js
registers exactly one custom element, gospel-resources-map, and every profile is
selected through its profile-config — which is why all five generated leaves
embed gospel-resources-map and work. The manifest reads that JSDoc line, so the
catalogue offered consumers a tag that resolves to nothing: copy the snippet
into a page and get an empty box. Invisible in dev, where the leaf HTML is what
loads, and only findable on a built tree.

Also retires the numbers shipped alongside it, which described a different
dataset: 3,457 groups with nothing became 1,613 and 347 million people became
208 million when the GRN sweep finished, and the tables profile still called
itself Eight tables over 3,550 resources when it carries eleven over 8,437
across 4,231 languages. The manifest is what a consumer of this map reads first.
2026-09-25 03:50:02 +00:00
doxa-maps-88655c70
879f08ba08 upg-100 legend: the title was hardcoded English in every locale
Some checks are pending
build-gate / build (push) Waiting to run
MEASURED on the live Chinese render: the legend header read "My UPG 100 List —
Legend" in full English, inside an otherwise localized panel — beside translated
tabs (区域 · UPG-100 名单) and a translated column header (人口). It was a plain
string literal, and SemanticTreeLegend passes `title` through RAW, so the caller
owns localizing it.

The Driver's gift policy names this exactly: "BEAUTIFUL: no clipped text, no
placeholders, no English leaking." An English title on the plugin chosen as the
mobile showcase is that.

★ FOUND BY SEPARATING CHROME FROM DATA, which is the part worth keeping. A naive
Latin-script sweep of the Chinese render returned 31 hits and would have been a
wildly overstated report. Rendering the same elements in en and zh and keeping
only those that are IDENTICAL narrows it to 24, and those fall in three classes:
  · Mapbox attribution (© Mapbox, Improve this map) — third-party, a LICENCE
    obligation, must not be touched
  · zone and region names (Middle East Core, Sudan) — API DATA; translating
    place names is a domain decision, not a bug
  · the legend title and "UUPGs" — product UI strings, and the only real leaks
So 31 hits reduce to 2, one of which is already carded.

★ AND THE HOUSE STYLE ALREADY EXISTED, so no terminology was invented. This
catalog renders "Doxa Regions" as "Doxa 区域" / "مناطق Doxa" / "Регионы Doxa" —
the proper noun stays Latin, the frame translates. "UPG 100" is treated the same
way across all 11 locales, following a convention the catalog demonstrates
rather than coining acronyms (the trap I stopped at on the UUPGs header).

COMPUTED, not a constant: a locale change must move it. A const captured at
setup keeps the mounting language forever, which is the subtler half of the same
bug. That also forced the definition below useI18n(), since a computed
referencing t() above it throws at setup.

VERIFIED at the rendered layer: en "My UPG 100 List", es "Mi lista UPG 100",
zh "我的 UPG 100 名单", ar "قائمة UPG 100 الخاصة بي" (RTL), ru "Мой список UPG 100"
— 0 clipped in all five, including the longest Russian form.
check:legend-info-localized PASS, check:poster-card-fits PASS, SRI consistent.
2026-09-25 03:46:06 +00:00
doxa-maps-88655c70
6433d7f92e Take main before landing
# Conflicts:
#	public/doxa-maps/index.html
2026-09-25 03:45:03 +00:00
doxa-maps-88655c70
4fc261f8df Say which film it is: three languages were labelled JESUS Film without one
Five codes still carried the retired ?languageId= URL, which nothing can verify.
Asking Arclight what each language actually has:

  ars  Arabic, Najdi        The Savior                 (8 groups; film was their ONLY resource)
  gjk  Kachi Koli           Magdalena - Director's Cut
  mzn  Mazanderani          Magdalena - Director's Cut
  lia  Limba, West-Central  no feature at all
  jos  Jordanian Sign Lang  left alone, see below

counts.featureFilm >= 1 says a feature exists, not that it is JESUS. Three of
these had a real JESUS Film Project film in their own language under a different
title, and the map called all of them the JESUS Film — the same class of lie as
calling Open Bible Stories Scripture. They now ship under their own names, with
verified deep links; lia's film is dropped, because no component carries it and
a link nobody can check is worse than none.

jos is left exactly as it was. Arclight files iso3 jos under a row whose own
name reads Lebanese Sign Language, while the JESUS Film sits at a different id
named Jordanian Sign Language. The ISO join and the publisher's label disagree,
and resolving that is a data question, not a guess to make inside a fetcher.
2026-09-25 03:44:39 +00:00
doxa-maps-88655c70
7f71ed0ffc Merge commit '580dff6369ae6dce621131a6b112ae2f095af94a' into bundler-1/work
# Conflicts:
#	public/doxa-maps/index.html
2026-09-25 03:44:13 +00:00
doxa-maps-88655c70
580dff6369 Studio first run: dev lists what the build lists, and the self-test finds its manifest
Some checks are pending
build-gate / build (push) Waiting to run
Two gaps found by opening every plugin in the studio under the dev
server:

- key-cities is gated (vite.doxa-maps-build-config.json gatedProfiles)
  out of every public surface the build emits, but the dev manifest
  listed it anyway, so the dev studio and the Research Gallery's
  Experimental shelf showed a plugin the built tree deliberately hides.
  The dev model now applies the same gate: 21 bundles, 33 profiles, as
  in the built manifest. readBuildConfig() does not carry gatedProfiles,
  so the gate is read from the config file, as generate-tree does.
- golden-path-check takes its tree base from a tree-base attribute, not
  from profile-config. The studio only passed the latter, so inside the
  studio the check guessed the tree from the page URL, fetched
  /manifest.json, got a 404 and showed an empty self-test. The studio
  now sets tree-base on every element it mounts; elements that do not
  read it ignore it.

Dev sweep after: all 31 listed plugins mount with content, no page
errors, no failed requests. Rebuilt the plugin-studio bundle and
re-baked its SRI.
2026-09-25 03:43:38 +00:00
doxa-maps-88655c70
2dce74ce67 Merge remote-tracking branch 'origin/main' into rega/research-gallery-embed
# Conflicts:
#	public/doxa-maps/index.html
2026-09-25 03:37:50 +00:00
doxa-maps-88655c70
86fb6e750b Research Gallery: a good guest on the partner's page
Embedded below the fold on a phone-width host page, the gallery disturbed
the page it was a guest on:

- The host's inherited typography flowed into the gallery. Shadow DOM
  isolates selectors, not inheritance, so a host with body{font:20px/3;
  letter-spacing:2px} re-flowed every rail row and legend (root measured at
  20px / 60px / 2px). :host now resets the inherited text properties to the
  gallery's own defaults (16px / normal / normal). Direction still inherits,
  so RTL hosts stay RTL.
- Nested maps wrote the host's URL and title. The gallery now tells every
  child urlSync:false. Regions needs to honour it (routed).

check:gallery-embed gains a good-neighbour phase: phone width, gallery
below the fold, hostile host CSS, the host's own #anchor and <title>. After
load and after every tab it asserts no host scroll, an unchanged URL, an
unchanged title, and no inherited typography. Two nested-plugin violations
are listed, owned and printed in NEIGHBOUR_KNOWN until their owners fix
them (same contract as MAPBOX_KNOWN):
- research-map.vue:647's scrollIntoView jumps the host to y=640 on load on
  a phone.
- Regions' unconditional URL/title sync.

A boot timeout is now a named FAIL instead of an uncaught crash. The gate
caught a real one on the way: a rebuilt bundle whose leaf page still
pinned the old SRI hash.
2026-09-25 03:37:26 +00:00
doxa-maps-88655c70
c3d1df30dc The click-sheet verifier had not run in months, and was aimed at the wrong files
It crashed on ENOENT — app/doxa-maps/gospel-resources/assets/tables/, a tree
deleted when data moved out of public/. Nothing noticed, because nothing ran it:
it had no npm script, so it was neither discoverable nor wired anywhere.

The dead path was the smaller half. Its other input was a private by-country
copy under plugin-designs/, and its people-group table was the curated 2,106-row
subset that resources-map.vue stopped fetching some time ago — it says so in its
own comments; the country sheet is built from the 12,379-pin index now. Repaired
in place it would have gone green about a surface it was no longer looking at.

Both inputs are the live tables. The country join is ISO3 instead of a substring
test against a comma-joined name blob, which the file's own comment admitted was
under-counting because country names contain commas. It reads 242 of 242
countries resolving to a pin and 8,435 sheet links, through the same flattener
the sheet renders from, and its self-test now requires four corruptions to go
red rather than two.

Also gives it and the stale-sweep gate an npm script, and corrects the link-rot
entry, which still told readers that jesusfilm.org and fivefish.org can never be
status-checked — the thing that gate stopped believing two commits ago.
2026-09-25 03:33:31 +00:00
doxa-maps-88655c70
7f8ff4c4f8 A filtered export should arrive under its own name
Click a country on the map, follow through to the table, export: you get 264 of
8,438 rows, correctly filtered, in a file called
gospel-resources-every-link-and-who-it-reaches.csv. Do it for a second country
and you get the same filename again, so the second download overwrites the first
or lands as "(1)". The rows were right and the file was unidentifiable, which
is the whole failure for the person this export is for — someone pulling a list
per country to hand to a team.

The filter now travels in meta.title, which is what the download is named from,
so China arrives as -people-s-republic-of-china.csv and German as
-german-standard.csv. Done here rather than by teaching the shared DataTable a
new prop, keeping the decision this file already made about a component with
other consumers, and it fixes the heading in the same move: it read "every
link, and who it reaches" over a table showing one language's six.
2026-09-25 03:29:09 +00:00
doxa-maps-88655c70
0b2e07c830 Merge commit 'bf7e0e64640a633a4bdb132867861d65e694c19d' into bundler-1/work
# Conflicts:
#	public/doxa-maps/index.html
2026-09-25 03:28:15 +00:00
doxa-maps-88655c70
4f7e2342f1 Port their img reset: our photos had max-width none where theirs is 100%
base/_reset.scss's `img, picture { max-width: 100%; display: block }` is a
document-level element rule, so it never reached our shadow root. Measured on a
COUNTRY page, where the uupg photos actually render: their img.uupg__image has
max-width 100%, ours had NONE, so an image wider than its grid cell overflows
our card where theirs is constrained. display already matched, and
height/border-radius/object-fit already match because our .uupg__image class
sets them (100px, 7.5px, cover), so max-width was the whole gap.

_globals.scss:80's `img { border-radius: var(--border-radius-lg) }` is
deliberately NOT ported. On their site it rounds every image because every image
there is theirs; inside our root it would also round the poster-wall previews,
part of a sanctioned addition their page does not have. The elements that ARE
clones already carry the right radius from their own class.

An earlier run reported img as 'absent from BOTH pages' -- `.uupg__image` IS the
img, not a wrapper, so a descendant selector matched nothing. That is the third
time a mis-selector has masqueraded as agreement here, so the probe now fails
loudly on anything it did not actually exercise instead of counting it as a pass.
2026-09-25 03:28:12 +00:00
doxa-maps-88655c70
a71f676fc3 Give the country sheet back the number its own footnote depends on
The two curated figures — growing & unreached, deaths a day — sit under a
footnote saying how much narrower their basis is than the pins. It read "covers
391 of these 391 groups — a narrower set than the pins" for every one of the 242
countries, because the script that recomputes country rows from the pins
overwrote the curated group count with the pin count and the footnote went on
reading it. Always false, and false about the one thing it existed to qualify.

The real numbers were still in the commit that introduced the overwrite and are
restored on the rows: China 158 of 391, India 856 of 2,146. They are recorded
once now and never rewritten, because the pins change every build and a curated
analysis does not.

The same block also drew both figures for the 72 countries that have no curated
row at all — "Est. deaths/day 0", which is not a measurement. It is hidden for
them instead. A first pass at the fix handed those 72 a basis equal to their pin
count, inventing the number the footnote exists to qualify; null now means there
is no curated set here.
2026-09-25 03:19:00 +00:00
doxa-maps-88655c70
320b75cb74 Name the two pools the group sheet was searching in silence
Open a group in the gap and the sheet listed five pools that found nothing —
Scripture, film, FCBH audio, Bible Stories, Zume — and said nothing at all about
Global Recordings Network or Scripture Earth. For 1,220 groups GRN is the only
resource there is, so silence about it at the moment of decision does not read
as "searched and empty", it reads as "nobody asked". Both now have an absent
case, with the pool named.

The GRN chip asks the data whether the pool was really searched, and that is the
part worth keeping. The sweep ran against the gap first and stopped there, so
3,202 codes that already carry resources have never been asked — the first
version of this chip told German, which certainly does have Words of Life, that
none was found. The build had recorded the truth per code all along: a swept
code lists grn under checked, an unswept one carries "grn (not yet swept)".
The chip reads that instead of assuming the sweep was universal.
2026-09-25 03:10:11 +00:00
doxa-maps-88655c70
2087074e9d Stop telling readers the audio sweep is unfinished — it finished
Some checks are pending
build-gate / build (push) Waiting to run
The legend's caveat is the paragraph a reader uses to decide what "nothing
found" is worth. It said GRN was being swept a batch at a time and part of the
pool was still unread. That was true while the crawl ran and false the moment it
ended, and it stayed on the page through four commits that knew better. Found by
reading a screenshot, which is the only thing that could have found it.

Corrected on all three surfaces, and scoped honestly rather than upgraded: the
sweep finished for every one of the 1,192 languages in the gap, so "nothing
found" no longer rests on an unread pool — but codes that already carry a
resource were not all asked, because the sweep was spent on the gap first. Both
halves are now on the page.

A gate holds it. The DATA decides whether the claim is allowed: while codes are
genuinely undecided it passes, and it starts failing the moment they are all
decided, so it re-arms itself for the next slow pool. It does not flag a claim
rendered under a v-if on the pending count, because that form expires on its own
and is the one to copy. Its first version asked about every code on the map
instead of the gap, measured 3,202 undecided and would have let the stale
sentence stand.
2026-09-25 02:57:50 +00:00
doxa-maps-88655c70
c7942ae7f7 Merge commit '28b0e505b788e47e028287144bb8039c3a68fc25' into bundler-1/work
# Conflicts:
#	public/doxa-maps/index.html
2026-09-25 02:50:22 +00:00
doxa-maps-88655c70
baa5e72e5c The title and strapline sat 10px tight, because we recreated a reset as a class
Their base/_reset.scss zeroes margins with
`body,h1,h2,h3,h4,p,figure,blockquote,dl,dd { margin: 0 }` -- a bare ELEMENT
selector, specificity 0-0-1. Their `.stack > * + *` is 0-1-0, so the stack wins
and their heading gets its 10px.

We recreated that reset as class rules, `.rd-title{margin:0}` and
`.rd-summary{margin:0}`. Those are ALSO 0-1-0, because `*` contributes nothing
to specificity, so `.rd-head > * + *` and `.rd-title` tied and source order
decided it -- the later class rule won and the stack margin was discarded.

Measured on live /regions/africa, their head is a(0px), h1(10px), p(10px).
Ours was h1(0px), p(0px): the title and strapline sat flush against the back
link, 10px tight, on every region and country page. Porting the reset as the
element selector it actually is, and deleting the two class-level margin:0
declarations, restores their cascade exactly -- both now read 10px.

Found by sweeping for the defect CLASS rather than the instance: for every
`> * + *` container in our sheets, any child that is not the first and still
computes margin-top 0 has lost the same race. That turned up three candidates,
and measuring THEIR page settled the third -- their .region-goal is 0px too, so
our .rd-goal matching at 0px was never a defect. A heuristic found the
suspects; only the live measurement could tell a defect from a faithful copy.

This is the fourth of their global rules that the shadow boundary changed the
behaviour of, after box-sizing, the list bullet and prefers-reduced-motion --
but the first where the rule DID reach us and the port altered its specificity.
Copying a global element rule as a scoped class rule is not a faithful port even
when the declaration is identical.

reset-vs-live now reports 0 differences; region-vs-live 4/4, list-vs-live 21/21,
table rows 4/4, SRI passes.
2026-09-25 02:50:05 +00:00
doxa-maps-88655c70
43e3102f47 Check 5fish links too, and say why YouVersion stays out
The rot sampler now covers 5fish by where its redirect LANDS — a published code
goes to its numbered language page, one they do not carry is swept to /regions —
the same rule the verifier uses, with both arms proved in band. Six links a run,
ten seconds apart, because their robots.txt asks for it. That leaves 94 of 8,510
shipped links outside every re-check, down from 5,760 this morning.

YouVersion is the largest catalogue the brief names and it is now recorded as
rejected, with the measurement: nodejs.bible.com serves it without a key but its
robots.txt is Disallow: / , www.bible.com answers even robots.txt with a bot
wall, and the unauthenticated slice returns 2 English versions against the dozens
that exist — so taking it would be both discourteous and wrong. It is named in
the unchecked list on 5,413 codes instead of being silently absent, because a
reader deciding that a group has nothing is entitled to know which pools nobody
searched.
2026-09-25 02:44:37 +00:00
doxa-maps-88655c70
2253d893b7 Finish the GRN sweep: every reachable pool has now been checked
Batch fourteen closed it — 2,347 language codes decided, 1,022 carrying Words
of Life. Forty groups leave the gap: 1,653 with nothing becomes 1,613, and the
gap breakdown drops from four situations to three, because "GRN not yet swept"
no longer describes anybody. What is left is 1,429 groups where every pool this
build can reach has been searched, 183 sign languages behind key-gated sources,
and one group with no language code at all.

Two things the sweep exposed. 5fish answers 200 for any path, so its 982 links
were being verified by a status that means nothing there; they are now judged by
where the redirect LANDS — a real code goes to its numbered language page, one
they do not carry is swept to /regions — with both arms proved in band before
any link is classified. And country-gap reads a file that another script
refreshes from the pins, so running them out of order publishes the previous
build's numbers: it said 1,653 while the map said 1,613. It now refuses to write
a table that disagrees with the index the map draws.
2026-09-25 02:44:37 +00:00
doxa-maps-88655c70
d0ae01e0c6 Honour prefers-reduced-motion: their reset never reached our shadow roots
base/_reset.scss:66-79 suppresses motion site-wide with
`@media (prefers-reduced-motion: reduce) { *, *::before, *::after {
animation-duration:.01ms!important; transition-duration:.01ms!important; ... } }`
-- a DOCUMENT-level universal selector, so it styles nothing inside a shadow
root. The same boundary that swallowed their box-sizing reset.

It matters because we had ported their animation faithfully: their
RegionDetail.vue:209 and our .rd-seg / .rd-bar__slider both carry
`transition: width 1s ease-out`. Measured with emulateMedia on the segment that
actually carries the transition, in both states, because a value that is 1s in
BOTH states is the whole claim:

  theirs  1s -> 1e-05s under reduce
  ours    1s -> 1s

So a vestibular visitor got a full one-second sliding bar from us that their own
site suppresses -- a clone difference and an accessibility regression in one.
After porting the block into .rd-root and .rl-root, ours reads 1s -> 1e-05s,
matching theirs, and the unreduced case is still 1s.

Their `html:focus-within { scroll-behavior: auto }` half is deliberately NOT
ported: a shadow root cannot style the host document's html element, and the
leaf page owns that.

Found by finishing an audit I had already called complete. The previous pass
enumerated 21 conditional rules but only actually checked the [lang] and [dir]
ones -- the four :has() rules and the @media set were reasoned about, not
measured. Of those, @media print turns out to be moot for us (it hides header,
footer and nav, which an embed does not have, plus orphans/widows on p, blocked
by the same boundary and marginal), and this one was a real defect.

list-vs-live still 21 of 21, chooser 15 of 15, parity and SRI pass.
2026-09-25 02:37:08 +00:00
doxa-maps-88655c70
2fa09852ab Let the gap breakdown survive the sweep finishing
The breakdown hardcoded "Four different situations share it" and gave
every line an unconditional row. The GRN sweep has 302 codes left, so
within an hour or two that bucket empties — and the sheet would have
rendered "· 0 (0) — Global Recordings Network has not been swept for
them yet" beside a sentence insisting there were four. Two falsehoods
at once: the count, and the implication that some remain unswept.

Each line is now conditional and the opening counts the situations
that actually have rows.

Tested by simulating completion in the data the page really loads:
with every code marked swept it renders "Three different situations",
the GRN line is gone, and the real translation gap absorbs them,
1,220 → 1,531. Restored afterwards and both asset copies verified
identical and back to 311.

Worth recording from that test: the page reads public/assets/tables/,
NOT public/doxa-maps/assets/tables/. Editing the second one changes
nothing, which cost two browser runs to discover — the first
"simulation" verified the file it edited and still watched the old
number render, which looked like a broken guard rather than a wrong
path.
2026-09-25 01:37:30 +00:00
doxa-maps-88655c70
f4595325fa Stop the charts listing GRN as both checked and unchecked
Two defects, both found by looking at the rendered page rather than
the data behind it.

The footer read "Pools CHECKED: … grn. Pools NOT checked: …, grn (not
yet swept)." GRN is swept a batch at a time, so some codes carry it as
checked and the rest as unchecked, and unioning across the corpus
printed it on both sides. To a reader that is a flat contradiction,
and it is the kind that quietly discredits every other number on the
page. It is neither fully checked nor untouched, so it now says so
once: "grn (partly — the sweep is still running)", on the checked side
only.

And three of the eleven bars — Audio, Gospel presentation,
Discipleship training — are drawn in a green the legend never named,
so a reader had no key for them at all. The legend now carries it.

Neither was visible in the markup or the numbers. The pool strings are
assembled from per-code provenance, and the legend was three entries
that all looked reasonable until you counted the colours actually on
screen against the colours explained.
2026-09-25 01:14:48 +00:00
doxa-maps-88655c70
1465e3a7bb Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-25 01:02:29 +00:00
doxa-maps-88655c70
a87c80c9ad upg-100 legend: drop the People Group tab, which could never render anything
Some checks are pending
build-gate / build (push) Waiting to run
The Driver said "it doesn't need to have a People Group tab inside the legend".
The reason is stronger than preference: clicking it showed nothing at all.

SemanticTreeLegend renders tab N as collectAtDepth(nodes, N) -- a tab's INDEX
IS a tree depth. This legend's tree is exactly TWO levels: 9 regions -> 28
lists. MEASURED by expanding every caret on the Region tab: 37 rows, 9 at
indent 0 and 28 at indent 1, deepest level 1. There is no level 2, so tab index
2 resolved to an empty set and the panel rendered its "No data at this level"
empty state with the tab ACTIVE and selected.

★ THIS IS WHY PART (c) WAS GATED AND NO LONGER IS. The card asks "what is the
legend's intended tree once Region/Zone/People Group are removed" -- a real
question, still the Driver's. But removing a tab that is STRUCTURALLY INCAPABLE
of content needs no ruling about intent. It is a defect, not a design choice.

AFTER: two tabs, both with content -- Region 9 rows (expandable to the 28) and
UPG-100 Lists 28 rows. Clicking every remaining tab and info button, 6 elements
in total, produces ZERO empty states.

⚠ THE i18n KEYS ARE DELIBERATELY LEFT IN PLACE. 22 locale files carry
upg100Group; deleting them costs 11 languages of re-translation if a third
level ever arrives, while an unused key costs nothing at runtime and the parity
gates assert presence rather than usage. Removing them would be the riskier
edit, not the tidier one.

⚠ AND A THIRD TAB IS NOT THE MECHANISM if people-group rows are ever wanted.
The tree would need a third level (list -> group); re-adding the tab without
deepening the data reproduces exactly this empty state.

Verified: check:legend-export-reachable PASS, check:legend-matches-source PASS,
check:a11y-button-names PASS, and check:legend-info-localized PASS on this leaf
-- 4 info popups, all differing from English in es/ar/zh including RTL Arabic,
0 blank.
2026-09-25 00:59:50 +00:00
doxa-maps-88655c70
b18679e2e9 Stop telling readers the audio pools were never searched
A footnote under every country sheet still said "No resource found
means not present in eBible or the JESUS Film catalogue; audio pools
are key-blocked and unchecked". Both halves stopped being true long
ago: eight pools are checked, not two, and audio is searched at Faith
Comes By Hearing and at GRN — which needed no key at all.

That is not a harmless leftover. It understates what the map knows, to
a reader deciding whether "nothing found" means anything, and it is
the single caveat most likely to be quoted back.

The file's own header was wrong in three ways and is corrected with
it: the band is not "no scripture and no film" but no resource of ANY
kind, since it is computed from the tier; the pools are named in full
rather than as "eBible + Arclight"; and GRN is not key-blocked.

Verified in the browser rather than in the markup — the footnote is
inside the country sheet, which only appears after a pin and a back
button, so a grep would not have told me it renders.
2026-09-25 00:54:09 +00:00
doxa-maps-88655c70
59a3295a01 Merge commit '6090e8e7daea9d1275b6ce3d6346f5940802b9c9' into bundler-1/work
# Conflicts:
#	public/doxa-maps/index.html
2026-09-25 00:38:09 +00:00
doxa-maps-88655c70
8da0e9f256 Arabic: align the country and region buttons to the reading start, not the left
Diffing Arabic against live for the first time. dx-life serves /ar/regions under
strategy 'prefix_except_default', measured live as lang="ar" dir="rtl", so this
axis was comparable all along and never had been compared. Our own RTL checks
only ever asserted our own invariants -- that a dir attribute exists, that an
arrow points the right way -- which cannot tell us whether we flip the things
THEY flip.

The defect: .region-card__link and .country-link both set text-align:left.
Theirs is a NuxtLink, an inline <a> that shrink-wraps to its text; ours has to
be a <button> because the control changes the embed's subject in place instead
of navigating a route, and a button's UA default is text-align:center -- so the
port overrode it with the PHYSICAL `left`. In English `left` and `start` are the
same value, which is why this survived a 37-property region-card diff and an
11-property bullet diff. In Arabic they are opposites.

Measured on live /ar/regions against ours at dir=rtl, same 246.9px row and the
same 102.7px of text on both sides: their label sits flush to the row's RIGHT
edge, 0px from the RTL reading start, and ours sat flush LEFT -- every country
label in the list stranded 144.2px on the wrong side of its column. After the
change both sides read 0px from the right and 144.2px from the left, identical.

text-align:start is correct in both directions, and English is unregressed:
the list-vs-live diff still passes 21 of 21, and every measured RTL property now
matches live with 0 differences.

Three defects in the new probe, all of which blamed the product first, are fixed
in it rather than worked around:
- it read dir/lang off the custom element <regions-dashboard>, which carries
  neither. They live on .rl-root, which is the only place they can usefully sit
  because a [lang]/[dir] selector cannot cross a shadow boundary. It had duly
  reported dir=ltr lang=null against their dir=rtl lang=ar.
- it waited only for Arabic text before reading. The chrome translates instantly
  from the client-side catalogue while the same locale change refetches the data,
  and that in-flight state briefly unmounts every .region-card -- so it read a
  page with no cards and called four elements "MISSING on ours" when the product
  was fine (measured: 7 cards before the switch, 7 at +1.5s, +5s and +12s after).
- it read li::before on both sides. The two sides attach the same bullet to
  different elements: theirs styles li::before, ours .country-link::before. The
  bullet's own properties are byte-identical wherever it hangs -- content "",
  width 29.2969px, position absolute, margin-inline-start -39.0625px, left
  256.703px -- and the first country sits at 834.7px on both. That is a
  structural difference to report, and the probe now reports it as a note instead
  of failing four style assertions over it.
2026-09-25 00:37:35 +00:00
doxa-maps-88655c70
2046f2febd Export which kind of gap each row is, and make both surfaces agree
The map explains that "1,845 groups have nothing" is four situations
wearing one number. An exported copy of the gap table could not say
that at all, which is the copy a translation organisation actually
works from. Each row now carries its kind:

  1,001  every reachable pool checked — the real translation gap
    660  GRN not yet swept — the crawl is still running
    183  sign language — pools are key-gated, an access problem
      1  no language code — no pool can index it

Adding it exposed that the two surfaces bucketed differently. The
table tests sign language first; the map tested private-use first, so
47 Deaf groups were filed under "unindexable code" and the Deaf share
read 136 instead of 183. Both were internally honest and they told
different stories, which is worse than either being wrong on its own.

The map now uses the table's order, because being Deaf is the more
actionable fact about those rows than the shape of their code, and it
still says 47 of them are doubly stuck. Both surfaces report 1,001 /
660 / 183 / 1, verified against each other rather than separately.

The meta warns that filtering to sign_language alone is now the whole
Deaf figure and not a subset, since the private-use rows are the same
people.
2026-09-25 00:22:11 +00:00
doxa-maps-88655c70
1c7384ee39 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-25 00:02:18 +00:00
doxa-maps-88655c70
5e4c76e7b8 Say that 183 groups in the gap are Deaf, not 136
The breakdown I shipped last tick implied two separate populations:
136 sign languages whose pools are key-gated, and 48 groups with a
private-use code that no pool can index. Measured: ALL 47 of the
private-use groups are sign languages too. Read as written, a reader
would put the Deaf share of this gap at 136 groups and 50.6 million
people, when it is 183 groups and 55.2 million — the largest blocked
population on this map, and one whose gap is an agreement away rather
than a translation away.

Both lines stay, because the reasons they are stuck genuinely differ,
but the sheet now says they overlap and gives the combined figure.

Found while researching whether anything open exists for them, which
is recorded next to the other rejected pools so nobody re-probes it:
deafbible.com serves the same 3,894-byte app shell for every URL,
door.international returns 114 bytes, and Deaf Missions is a real
ministry with real Scripture but ASL only — and not one of these 183
groups speaks ASL. Watchtower publish a great deal of sign-language
material and are excluded on doctrine, without exception.
2026-09-24 23:56:07 +00:00
doxa-maps-88655c70
45a7e1a1f5 Say what the gap number is actually made of
"1,913 people groups have nothing" is four different situations
wearing one number, and they call for four different responses. The
sheet now breaks it down, computed from the pins and each code's own
"checked" line rather than typed:

  887 groups, 149.7M people — every pool this build can reach was
      checked and found nothing. This is the real translation gap, and
      the only quarter a translator rather than an administrator can
      close.
  842 groups, 1.1M — Global Recordings Network has not been swept for
      them yet. The crawl runs a batch at a time at the delay they ask
      for, so these resolve themselves.
  136 groups, 50.6M — sign languages, whose pools are key-gated. Fifty
      million people whose gap is an ACCESS problem, not a translation
      one, and reading them as untranslated would send help to the
      wrong place entirely.
   48 groups, 6.9M — a private-use code or none at all, which no pool
      can index. Searching harder cannot move these.

The four sum to 1,913 and the rendered figures were checked against
the same count made offline. Also fixes a missing space that ran two
sentences together — "people.These are the 40 largest" — which the
screenshot caught and the markup did not.
2026-09-24 23:51:46 +00:00
doxa-maps-88655c70
dac7b1ab57 Merge commit 'b6254567e1b32df2c0c5ceb0ee5d0576c47cb159' into bundler-1/work
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 23:38:21 +00:00
doxa-maps-88655c70
78a7baaa96 Fill the chooser's frame: solve the globe's zoom instead of reusing a mercator formula
The chooser's zoom came from log2(width / 512), and the comment above it said why:
"Mercator's world is 512*2^z wide". But the chooser renders under the globe
projection, so that is the same mistake as the region pages framing too wide --
a mercator answer applied to a sphere -- only here it made the frame too SMALL.
Found by looking at the screenshot, not by any assertion: a sphere adrift in
white space.

Measured at 1200x780: the mercator answer of 1.229 put the sphere at 390.61px,
50.1% of the frame's short side, so half the frame was dead space. And the
number was inert -- zoom 0.5, 1.0 and 1.229 all render an IDENTICAL 390.61px
sphere, because Mapbox clamps the globe's apparent size at low zoom, so the
formula's output sat inside the clamp and could have been 0.5 with no visible
difference.

There is no closed form to swap in. Diameter against a clean doubling is 3.6 to
10.5% off above the clamp, because the globe-to-mercator transition is nonlinear
there. What the measurement does establish is monotonicity, which is the only
property a solver needs, so the zoom is now SOLVED against the engine: bisect
the zoom whose limb projection fills 92% of the frame's short side. project() is
pure transform math, every setZoom lands in one synchronous block before a frame
renders, the original zoom is restored in a finally, and the old mercator
formula remains the fallback because a small globe beats a broken chooser.

The first version of the solver measured pole-to-pole and called it the
diameter, which holds only when the centre is on the equator. The chooser
centres on the mean of every people group, latitude 28, so that was a CHORD:
717.6px where the sphere was really 792-835px across, understating it by 1.104x.
The solver over-zoomed and clipped the globe top and bottom -- again visible in
the screenshot while every number still looked right. It now samples the limb,
points at 90 degrees angular distance, and takes the MAX across twelve bearings,
because Mapbox's globe is a perspective camera rather than a true orthographic
one and the silhouette is not a perfect screen-space circle: 396px along the
meridian against 417.3px east and west at one zoom.

Result: the widest axis is 750.6px inside a 780px frame, the whole limb sits in
frame, and dead space falls from 49.9% to under 4%. Pins are correspondingly
bigger click targets. On a phone the ported 1/2 aspect ratio means the globe
fills the WIDTH, which is the right trade -- filling the height would crop it
horizontally and lose pins off both edges -- and that leaves half the height
empty by geometry, which is carded for a Driver decision rather than resolved by
deviating from a clone-faithful rule.

The chooser's behaviour is unchanged and re-proven after the camera moved:
_probe-chooser 15/15 and the new _probe-chooser-pin-opens-country 8/8.
2026-09-24 23:37:47 +00:00
doxa-maps-88655c70
2f77da9d5b Stop the search box clipping the word "code" on a phone
At 390px the placeholder read "Find a people group, language or ISO
cod" — losing the one word that tells a reader an ISO code is accepted
at all, on the control most likely to be used first. Measured: 40
characters of text in 279px of room.

The short form keeps all three things the box accepts and fits with
room to spare, 185px of 279. The full phrase still shows at desktop
width, and the aria-label stays long either way, because a screen
reader has no width to run out of.

Found by checking phone layout after a flight of additions to this
sheet — five gap tabs, a filter, new chips and the bridge panel all
arrived without anyone looking at 390px. The rest held up: no
horizontal overflow, the chips wrap, the sheet scrolls, and the bridge
panel renders at 358px.
2026-09-24 23:25:28 +00:00
doxa-maps-88655c70
d55285eafe Name the language spoken around a group that has nothing
For 230 people groups the map said "nothing found" and stopped, when
that was not the whole truth: a language already spoken widely in
their country does have Scripture or the film. Kimr in Sudan has
nothing in Assangori; Sudanese Arabic, spoken by 65 groups around
them, has both. That is the difference between no way in and a way in
that is not their heart language.

WORDED SO IT CANNOT BE SKIMMED AS "THEY HAVE SCRIPTURE". The heading
says "Not their language", the body names whose language it is and how
widely it is spoken, the strength is quoted from the source, and the
last line says a second language is a way in and not a translation.
The chips above are untouched and still read "none found" five times,
and the tier is unchanged: 2,001 groups still have nothing of their
own, exactly as before.

Two exclusions, both measured rather than assumed. Fourteen of these
rows offer a DEAF people group a SPOKEN bridge — Gawri "has a film" is
no use to Deaf Pakistanis, the same error as offering them recorded
audio — so sign languages are out. Sixty-five carry the curated
strength "weak — may not be a real lingua franca"; a bridge the source
itself doubts is not an answer, so those are out too.

Placed below the whole chip row after a first pass split it, leaving
the Zume chip stranded under the panel.
2026-09-24 23:20:30 +00:00
doxa-maps-88655c70
31b934bdf5 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
build-gate / build (push) Waiting to run
2026-09-24 23:01:56 +00:00
doxa-maps-88655c70
f7e39d5d5c Clone their map legend exactly, reset and all
Looking at the side-by-side Africa crops after the framing fix showed the
legend rows sitting a few px off and the panel reading differently. Diffing
our block against CountryMap.vue's found six differences that no computed-CSS
probe was covering: font-size 12.5px against their 12px (a rounded-token
artifact of mine, not their scale), colour #2a332a against their #1f1f1f, and
four properties simply missing -- the panel's box-shadow, pointer-events:none,
and the swatch's display, border and ring. The swatch border is the one with
layout consequences: a 10px dot inside a 1px border is 12px tall, so dropping
it shortened every row and moved the whole stack, which is the drift the crop
showed. An old comment here called this legend "one of the sanctioned
additions"; that was wrong and is why it drifted -- their CountryMap.vue
carries this legend with these same four entries, so it is a clone and every
property is theirs to dictate. The one deviation stays and stays documented:
inset-inline-start instead of their physical left, so the legend sits in the
reading-start corner in Arabic.

That left the panel 2px wider than theirs on the right edge alone. The text was
ruled out first: the same string measures 129px on both sides, in the same
Poppins at the same weight. The cause is their GLOBAL reset, _reset.scss lines
3-8, which sets box-sizing:border-box on every element and every ::before and
::after. No scoped style could ever have shown it. Our leaf document already
carries the identical rule, but this component renders in a shadow root and a
document-level universal selector does not cross that boundary, so our swatch
was content-box at 10px+2px while theirs was border-box at 10px. Two swatch
borders, exactly 2px.

Porting the reset into .rcm closes it: the panel now measures 165px against
their 165px, the panel bounding box lands on the same pixel on all four edges,
and the legend corner diffs to a mean 1.14/255 per channel, which is
antialiasing. The map frame is unchanged at 1200x780.

This is the seventh global rule of theirs found missing, and it explains the
seven per-element box-sizing patches scattered through this bundle -- each one a
local workaround for the reset that never arrived. Porting it bundle-wide and
deleting those seven is carded rather than swept in here, because border-box
changes geometry in components whose match against live has to be re-measured.
2026-09-24 22:58:16 +00:00
doxa-maps-88655c70
064ee63a6f Stop the tables saying GRN is missing and miscounting their own rows
Two claims in shipped metadata had gone false, and both were the kind
a reader trusts precisely because it looks specific.

people-group-resources listed Global Recordings Network under "not
included: requires an API key". Wrong on both halves: GRN is included,
583 codes of it, and it needed no key — 5fish resolves by ISO 639-3
and asks only for a crawl delay. A caveat naming a pool as missing
misleads exactly as much as a stale number when the pool is there. The
refresh now moves it out of that list and records how it was reached,
so the list cannot drift again.

The links table said "the 506 zero-reach rows" and "51 of the 168
codes". The true figures are 1,038 and 492: every source added rows
for languages no pin on this map speaks, and a sentence that counts
something has to be recounted when the something changes. Both are
computed now rather than typed.

Found by sweeping every shipped table's metadata for baked numbers
instead of waiting to trip over one. The surfaces came back clean —
their prose is already computed — so the stale claims were all in the
table meta, which is the part nobody looks at and a CSV carries away.
2026-09-24 22:46:16 +00:00
doxa-maps-88655c70
4d9b24a82d Merge commit '840830ebc47250b642d6d58ba1e584a30e74d47d' into bundler-1/work
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 22:45:25 +00:00
doxa-maps-88655c70
840830ebc4 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 22:41:49 +00:00
doxa-maps-88655c70
770d29fd25 Frame the region map the way their constructor does, not the way fitBounds does
The Africa crops showed theirs tighter than ours, and 107 identical CSS
properties could never have said why. Everything I could think to compare
matched: mapbox-gl 3.24.0 on both sides (their bun.lock, our CDN pin), the
style, the 1200x780 container, the padding of 40, the maxZoom of 6, the box
itself (their viewFor() pin extent is byte-identical to our rollup), and
NEITHER page sets a projection.

The asymmetry was when the fit runs. They frame the box in the constructor
(CountryMap.vue:179); Mapbox v3 resolves a constructor `bounds` while
getProjection() still reports 'mercator', giving zoom 3.210. We had recreated
that as a post-style-load fitBounds, resolved with the globe already active,
giving 2.777 -- a sphere shows more planet at the same zoom, so it pulls back.
0.433 zoom is 1.35x linear scale, which is exactly the tightness in the crops.
Copy don't recreate: I recreated their fit instead of copying their construction.

useMapInstance gains a bounds/fitBoundsOptions pass-through, inert unless a
caller passes it, for the deep-link case -- the only case Mapbox can honour,
since the dashboard mounts its one map before the fetch resolves. The normal
case goes through fitTheirWay(), which asks the engine the same question their
constructor asks: setProjection('mercator'), cameraForBounds, restore the globe
in a `finally` so a throw cannot leave the map flat, then easeTo. Asking the
engine beats reimplementing a Web Mercator fit that would drift from Mapbox's
own padding and worldSize semantics at the next upgrade.

Proof, three independent oracles: the camera moved 2.777 -> 3.210 with the
projection still 'globe' afterwards; the like-for-like tile oracle now reads
ours z3 / 8 tiles / [[-45,-66.51],[90,40.98]] against theirs z3 / 8 tiles /
[[-45,-66.51],[90,40.98]], the same tiles at 1.00x; and the regenerated crops,
looked at, put the same labels and pins on the same pixels.

Two oracles of mine were wrong and are fixed here too. The tile-extent probe
compared a z2-quantized extent against a z3 one and reported a "1.33x framing
gap" twice -- 180/135 is four tiles at z2 against three at z3 of the same box,
and our camera's own visible box was 135.44 deg against their 135 deg of tiles.
It now refuses to compare extents across levels and reports the level instead,
which is the real signal. The framing probe asserted our zoom equalled the
GLOBE's cameraForBounds answer, so it would have failed the correct clone; it
now compares against the mercator-resolved camera and checks that asking the
mercator question left the map on the globe.
2026-09-24 22:31:20 +00:00
doxa-maps-88655c70
4b4cb30eb8 Rank the translation priority by the place a team is sent
"Can hear it, no text" is a list of 2,556 groups. A team is not sent to
a group, it is sent to a country, and nothing aggregated that set —
the existing country table counts the groups with NOTHING, which is a
different population and does not contain one of these rows, because
none of them is tier 0.

154 countries, with two columns that disagree on purpose. Nigeria and
the DRC lead on LANGUAGES, 221 and 103, which is the translation
workload. China and Pakistan lead on PEOPLE, 218 and 195 million,
across far fewer languages. Which number matters depends on whether
you are funding translators or broadcasters, so both are shown and
neither is ranked for the reader.

The aggregate reconciles with its source exactly — 2,556 groups and
1,329,044,699 people both ways — and the basis travels as a column, so
a CSV that outlives this page still says "no text found in the pools
checked" rather than "no text exists".

Built without a browser: the box was low on memory this tick and a
gate that needs chromium was the wrong thing to add to it.
2026-09-24 22:29:38 +00:00
doxa-maps-88655c70
d5fff78ccd upg-100: ask the registry for the stylesheet URL, and let the gate hear mapbox
Some checks are pending
build-gate / build (push) Waiting to run
A map inside a SHADOW ROOT has to inject its own copy of mapbox-gl.css, because
a document stylesheet does not cross a shadow boundary. This profile did that
by writing the URL out a second time, and the second copy drifted: it pinned
v3.3.0 while every production loader -- generate-tree's baked <script>,
useMapInstance, generate-gallery -- had moved to v3.24.0. So the map ran new JS
against old CSS.

MEASURED on the leaf: mapboxgl.version 3.24.0, the only mapbox stylesheet
reaching the shadow root v3.3.0, and mapbox-gl emitting its own "missing CSS
declarations" warning. The file's own header already says the engine loader is
imported rather than re-declared "because a second copy of one fact drifts".
The stylesheet URL was that second copy. It now comes from the same registry
via engineStylesheetUrl().

★ THE GATE COULD NOT SEE ANY OF IT, which is the more useful half of this
commit. verify-map-paint asserts a live GL context, zero console ERRORS, and
that mapbox emitted attribution -- all true of a map whose stylesheet never
applied, since the basemap paints fine while the chrome around it does not.
Mapbox itself says so, but as a WARNING, and the listener filtered to
type()==='error'. It now listens for _detectMissingCSS's verdict and fails on
it.

MUTATION-TESTED BOTH WAYS on the same leaf: restore v3.3.0 -> FAIL, exit 1;
restore the registry lookup -> PASS, exit 0.

Two things I had wrong on the way, recorded so the next reader does not repeat
them. I first asserted position:absolute on .mapboxgl-canvas-container -- but
v3.24.0 declares only cursor and touch-action there, so static is its CORRECT
value and that assertion would have failed every healthy map. I also read
"0 .mapboxgl-marker elements" as missing pins; mapbox draws pins as GL geometry
in the canvas, so zero is expected. A screenshot showed the polygons, the
region colours and hundreds of pins all rendering. Both were properties I
assumed rather than verified, and the canary's background-color is the one
mapbox actually defines -- it is the test _detectMissingCSS uses.

The canary alone would still have been a false green here: that rule is
identical in v3.3.0 and v3.24.0, so it is salmon under both. It catches an
ABSENT stylesheet; the warning catches a MISMATCHED one. The gate asserts both.
2026-09-24 22:27:52 +00:00
doxa-maps-88655c70
cc3ab8b822 Check the shipped answer, not the code that wrote it
The same drift has now appeared six times, each spelled differently:
negated fields on a line naming "none", a positive hasAny over a
mapped row, a predicate inside a build script, and — found this tick —
a pair of columns no pass had ever touched. A source-level pattern has
to guess how the next one will be written.

Clause N does not guess. It compares the shipped ANSWER against the
pin tier, so it catches the seventh however it is phrased. Measured
when written: people-group-resources.any_resource_found disagreed with
the tier on 194 rows, and its no_resource_found pair contradicted
ITSELF on 7 more, because neither column was in the refresh at all.
Both now move together, 195 and 202 corrected, and the table agrees
with the tier on all 2,103 judgeable rows.

A code with no pin is SKIPPED, not guessed. The tier lives on pins, so
an unpinned code cannot be judged this way — judging it anyway
reported ajp as a defect on the first run, when the row was right and
the check was wrong. That is the failure this repo keeps meeting, so
it is written into the clause rather than left as a comment.

Also corrected there: the table's own note still told readers GRN
answers 403 to every request and had never been searched.
2026-09-24 22:24:24 +00:00
doxa-maps-88655c70
337a1a863b Tell the curated tables that GRN and Scripture Earth exist
The legacy reconciler reported "nothing to change" and was confidently
wrong: its hasAny predicate never learned the two newest pools, so 122
curated rows — 8.9 million people — still carried heart_has_any=false
for groups the pin index can now reach. A reconciler that cannot see a
source is not reconciling, it is confirming.

Corrected exactly the 122 predicted, and the derived columns moved
with them: status 122, opportunity 93, lbc_carries_resource 69,
aligned 59, gap 29. Gap moves least because it means "nothing in the
heart language AND no in-country bridge either" — most of these have a
bridge, so they were already opportunities rather than gaps, and that
documented meaning is intact.

This was the fifth occurrence of one bug, and the gate written for it
had never looked at scripts — only .vue. Extending it to the build
scripts immediately found a sixth, in the Deaf gap table, which is
latent today because no sign language carries either field: GRN is
deliberately skipped for them and Scripture Earth has none. Fixed
while it is still cheap.

The rule now differs by file because the truth does: a surface must
READ the tier, while a build script COMPUTES it and cannot, so a
script must instead name every pool. Both halves verified against the
real pre-fix lines rather than synthetic ones.
2026-09-24 22:15:53 +00:00
doxa-maps-88655c70
90f0c37ddf Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 22:12:10 +00:00
doxa-maps-88655c70
de3b34beb5 Stop the country sheet calling 729 reached groups unreached
The country sheet split a country's groups into "with resources" and
"no resource found" using its own hand-listed field set, which never
learned about Scripture Earth or GRN. Measured: 729 people groups —
77.9 million people — were filed under "no resource found" while this
same map's pin sheet offered them a working link. Chad alone had 107
groups with something and showed fewer.

It now reads the tier the pin index computed, carried onto the mapped
row. Chad reports 150 groups, 107 with a resource, 43 without, which
is what the tier says.

This is the FOURTH time this bundle has re-derived that decision
somewhere, and the gate written for it missed this one: the gate
looked for negated c.* fields on a line naming "none", and this was a
positive predicate named hasAny over the mapped row.

Widening it took three attempts and the first two were worse than the
bug. "Three OR-ed resource fields" flags canHear(), which ORs media
precisely because it must exclude text. "Spans text and media" flags
`!!c.s || !!(c.au && /text/…)`, because c.au is text or audio
depending on a qualifier and a `|| ''` default inflates the operator
count. Both were confident false findings — the failure this repo has
now met in a forced token, a swallowed flag and a class extractor, and
a gate that cries wolf gets switched off.

So it asks one question it can actually answer: a predicate NAMED for
"has anything" is deciding the tier by definition and must read it.
Verified against the real pre-fix line, not a synthetic one.
2026-09-24 22:10:03 +00:00
doxa-maps-88655c70
77989089e0 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 22:05:31 +00:00
doxa-maps-88655c70
5a959d6b94 Two posters per row on the regions wall, with a control to change it
The Driver, verbatim: "i need two posters per row on the regiosn posters and
witha setitn got icnreat eh number of poswers per row".

The wall's own grid is repeat(auto-fill, minmax(210px, 1fr)), which lays five
across a region card. It now takes a `columns` count and an optional
`columnsControl`, both opt-in and both defaulting to the behaviour that shipped
— so country-posters and my-upg-100-list, which did not ask for this, keep the
auto-fill they have always had. Measured after the change: they still lay out
five across and neither grew a control.

The control is a <select>, not a stepper or a pair of chevrons. It is what
every reader already knows for "choose one of a few", it is keyboard- and
screen-reader-operable with no work, and it shows its current value without a
second element. Imitating the solved pattern rather than inventing one. Its
label is catalogued in eleven locales like everything else on this surface.

The probe measures GEOMETRY, not CSS. Asserting grid-template-columns would
pass on a rule that says repeat(2, …) while the cards actually wrapped three
across because one had a min-width the track could not honour — and a computed
style is exactly the kind of proxy that has fooled this suite five times today.
It counts how many cards share a rendered top coordinate, which is what a
reader sees: [2,2,2,2,2].

And it proves the control by USING it — set it to 3 and the rows become
[3,3,3,3,3]. A default that happens to be right would pass a one-shot check
with an inert knob.

One thing worth recording for the next person: the first attempt put the
explanatory comment inside the PosterWall tag's attribute list, and the Vue
compiler rejected it. An HTML comment belongs above the tag. This file had
already learned that once.
2026-09-24 22:04:28 +00:00
doxa-maps-88655c70
88a566f49d legend: the height cap now derives from the offset instead of repeating it
Some checks are pending
build-gate / build (push) Waiting to run
THE LEGEND OVERFLOWED THE BOTTOM OF THE MAP because two numbers that had to
agree were written out twice.

.stl-panel is capped at calc(100% - var(--stl-top) - 12px), but this profile
set `top` DIRECTLY with !important instead of setting --stl-top. So the panel
moved down to 58px while the cap kept subtracting 8px, and the two silently
disagreed by exactly the difference.

  MEASURED at 390px in a 420px-tall host: top 58px, max-height calc(100% - 20px),
  panel bottom 458 in a 420px map -- 38px PAST the map. Same 38px at 560px,
  because the error is the mismatch, not the map's size. After: -12px, the
  intended bottom margin, at 390/420, 390/560 and 1440/500 alike.

Two more copies of the same mistake went with it. The mobile rule paired
top:64px with max-height:calc(100vh - 72px) -- a constant unrelated to the
offset, AND the wrong box: 100vh measures the VIEWPORT while the panel is
positioned inside the MAP. Those agree only when the map fills the screen,
which is true of every standalone leaf and false the moment a host puts a
header above it. .fit-content had the same fixed 72px. Both now derive from
--stl-top, so they cannot drift apart again.

★ THIS IS INVISIBLE ON EVERY PUBLISHED LEAF. A standalone leaf gives the map
the whole viewport, and a panel that overflows a full-height map is still on
screen. It only appears when the host makes the map SHORTER than the viewport
-- which is how the element is actually embedded. Verify in a constrained
host, never on the bare leaf.

The a11y half of this work is NOT here. I fixed the same two nameless legend
controls locally while the remote was unreachable, then found on rebase that
they were already fixed on main -- better, with the row's own label
interpolated ("Expand Africa" rather than a generic string), and in one
key namespace instead of the second one I had introduced. Took main's version
whole and dropped my duplicate locale keys; only the CSS above is mine.
2026-09-24 22:00:52 +00:00
doxa-maps-88655c70
661133dd5c Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 21:57:12 +00:00
doxa-maps-88655c70
6958d0a187 Expose the fitted bounds, so a probe can ask the engine what it would choose
Two lines that assign the box we pass to fitBounds onto window, beside the
map instance that has been exposed there for the same reason since this
element was built. It lets a probe call cameraForBounds on the SAME box and
compare the answer with where the map actually sits — which is what separates
"the fit is wrong" from "something overrode the fit".

It earned itself immediately. Looking at our Africa map beside theirs, the
framing differs: theirs cuts the Sahel off the top and shows city labels, ours
shows the whole continent with margin. The engine's own answer for our box at
our frame is zoom 2.777 and the map sits at 2.777, so fitBounds is correct; the
dynamic world-fill minZoom — the best suspect, whose own comment records it
pinning this dashboard at ~1.45 once — measures 1.229, well below the actual
zoom, so it is not overriding anything either. Both cleared by measurement
rather than by argument, which leaves their bounding box, and their map is a
bundled ESM import with no global to read it from.

Recorded as an open, measured difference. Everything about how the map is drawn
matches — projection, style, highlight, pin colours, legend, controls,
attribution, and Botswana and Lesotho unhighlighted on both sides because
neither is among the 41 countries the Africa rollup returns. What it is pointed
at does not.
2026-09-24 21:56:43 +00:00
doxa-maps-88655c70
13a1225182 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
build-gate / build (push) Waiting to run
2026-09-24 21:51:30 +00:00
doxa-maps-88655c70
f63d75ed80 Plugin studio: an Explorer tree for the rail, imitating VS Code
Some checks are pending
build-gate / build (push) Waiting to run
The studio's rail was hand-rolled vertical tabs stretched into a tree.
It is now an Explorer tree modelled on the VS Code Explorer, keyboarded
per the WAI-ARIA tree pattern:

- CAP 5 -> CAP 1 sections with sticky headers; one-line rows with the
  name and a muted element tag; twisty on the left; indent guides.
- Composites fold; their parts hang beneath, one level at a time, each
  part opening the plugin's one canonical entry.
- A filter box on top shows matches together with the path to them.
- One view action, Collapse all.
- Auto-reveal: opening a plugin by click, URL hash or remembered tab
  scrolls its row into view and opens its section.
- Arrow keys walk the tree, Right/Left open, close and move between
  parent and child, Enter opens, a letter jumps to the next name.
- The slim rail becomes an activity bar with one icon per section.
- Fold state is remembered per page.

The tree lives in plugin-designs/plugin-studio/src/StudioTree.vue and
fills VerticalTabsLayout's new #rail slot, so the layout still owns the
collapse toggle, mobile drawer and slim strip. The alias, depth and
branch row fields added to the layout earlier today are removed again:
nothing else used them.

The studio host now has a default height (85vh, min 480px), as
doxa-maps-spa does. Pasting the published embed snippet into a plain
page mounted a complete studio 0px tall. A page's own height or
--plugin-studio-height still wins.

The studio gates that click tabs by text now also match tree rows.
Rebuilt the plugin-studio bundle and re-baked its SRI.
2026-09-24 21:40:48 +00:00
doxa-maps-88655c70
16de564fb4 Record the pools that were checked and rejected
Three audio-Scripture sources were investigated this tick and none is
shippable. Writing down WHY, beside the unchecked-pool list, so the
next agent does not spend a tick rediscovering it — each was reachable
and doctrinally sound, and the reason is always coverage or the join
key, never suspicion.

AudioTreasure publishes 9 languages and every one is major; zero
overlap with the gap. Davar is a ministry site rather than a
catalogue, with no per-language page and so no URL a worker could be
sent to. Talking Bibles is the interesting one: 177 real audio Bibles,
open robots.txt, but the player keys languages by an internal numeric
id and publishes no ISO code, so a join could only be by NAME — which
this map refuses, because names collide across unrelated languages. It
turned out not to matter: of the 81 whose names match a language we
carry, ZERO are in the gap, so even a perfect ISO join would have
added no coverage.

GRN is the contrast worth keeping next to them — equally name-shy at
first glance, but 5fish resolves by ISO 639-3 directly, which is why
it is in and these are not.
2026-09-24 21:40:28 +00:00
doxa-maps-88655c70
dfe49b4685 Correct two headers that still called the shared rollup a copy
Some checks are pending
build-gate / build (push) Waiting to run
Both tab components' file headers said the region grouping was a COPY of
DX-life's countries-meta.ts "not an import", one of them attributing it to the
other tab. That was true when it was written — their file is a .ts module with
a Nuxt-only COUNTRIES import that cannot resolve outside that app — and it
stopped being true when the file was ported to library/utils/countries-meta.js.
Both tabs import summarizeRegions and PEOPLE_GROUP_LIST_FIELDS from there now,
and grep finds exactly one definition in the repo.

Worth the edit because a comment that still describes the old shape is worse
than no comment: the next reader would have duplicated the rollup a third time
on its authority, and one of the headers told them to copy it from the other
tab. "Kept field-for-field identical so both tabs roll up the same way" is also
no longer a discipline anyone has to maintain — it is the same function.
2026-09-24 21:30:27 +00:00
doxa-maps-88655c70
558786ddf7 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 21:26:47 +00:00
doxa-maps-88655c70
c4c60a4fd1 The font base must be absolute: @font-face is document-scoped
Some checks are pending
build-gate / build (push) Waiting to run
On any third-party page the whole clone rendered in a fallback face, and this
is why. useDocumentFont injects a <style> into the HOST document, so a RELATIVE
src resolves against the HOST PAGE rather than against this bundle. The leaf's
generated config says "../../assets" and the font loader was handed that value
raw. On our own leaf it is invisible — the leaf really is two directories deep,
so it resolves to /doxa-maps/assets and works. On a partner page it resolves
against the partner's URL, the file is not there, and every face fails.

resolveAssetsBase exists for exactly this. Its own header documents this bug
class in these words: "'./assets' or '../../assets' is the ambiguous input whose
meaning depends on the host, which is the whole problem." The poster previews
next door have used it all along. The font loader was the last consumer still
taking the raw value, and fonts are the one consumer where it cannot be merely
safer, because the style lands in a document this element does not own.

TWO WRONG HYPOTHESES DIED FIRST, and both are worth keeping.

A missing Access-Control-Allow-Origin. Plausible: @font-face is
document-scoped, the woff2 is cross-origin, and a font fetch is a CORS fetch. I
built a proxy that forwards the same bytes and adds only that header so the
claim would stand or fall on a pair — and with the header it still failed.
Then I checked what the asset host actually sends, which I should have done
first: serve-strict already answers
    content-type: font/woff2
    access-control-allow-origin: *
So the header was never missing, and the proxy was proving nothing about a
condition that was already satisfied.

A check made too early. document.fonts.check answers "can I render with this
right now", and fonts load lazily, so the probe had to await
document.fonts.ready — the suite's older diff has done that since the day it
was written. Fixed, and still failing.

What settled it was asking the document what it actually held instead of
proposing a third cause: every FontFace on the partner page read status
"error" while the responses were 200. A 200 that is then discarded is not a
network problem and not a timing problem — it is the wrong URL, fetched
successfully from somewhere that has no font at it.

The embed probe now records each font response's ORIGIN and its
access-control-allow-origin header, because "200 bebas-neue-latin.woff2" does
not say which host served it, and in a proxy phase that is the entire question.
A log that cannot distinguish the proxy from the bare host cannot test a claim
about the proxy.
2026-09-24 21:20:34 +00:00
doxa-maps-88655c70
9512c54239 Make the translation-priority list a table you can export
The map could select the groups that can already hear the gospel but
have no text Scripture. A filter cannot be sorted, exported, or handed
to a committee, and this is precisely the list that gets handed to a
committee. None of these groups appears in "Where Nothing Was Found"
either, because none of them is tier 0 — it is the opposite question.

2,355 groups across 1,224 languages, largest first, with CSV and JSON
export. Each row names what the group DOES have and links to it, so it
is actionable without the map: Western Punjabi, 97 million, "audio
Scripture, the JESUS Film". The pools checked and the pools NOT checked
are columns on every row, because an exported spreadsheet strips the
caveat first and the caveat is the part that keeps this honest.

Fixing the map to agree exposed a real defect in the filter shipped
last tick: it counted 49 SIGN languages among its 2,289. "Can hear the
gospel" is the wrong sentence about a Deaf people group, and recorded
audio is neither what they lack nor what they need — this map already
suppresses the audio chip and the GRN attach for them for that reason.
Both surfaces now exclude them and both report 2,355.
2026-09-24 21:14:21 +00:00
doxa-maps-88655c70
bb589d584e Vendor Oswald, because Bebas Neue cannot render Russian at all
Some checks are pending
build-gate / build (push) Waiting to run
Their heading face has no Cyrillic glyphs. That is why base/_globals.scss:35-44
switches every h1/h2/h3 to --font-family-cyrillic under [lang="ru-RU"], with
letter-spacing -0.02em — without it a Russian heading leaves the family
entirely and lands on whatever condensed system face the browser picks. We ship
a `ru` locale and had neither the rule nor the font, so that is exactly what
every Russian heading in this clone was doing.

Four subsets vendored, Google's own unicode-range values carried verbatim so
the browser fetches only what a heading needs: cyrillic 6700B and cyrillic-ext
7688B are the point, latin 12248B and latin-ext 11624B come too because a
Russian heading routinely contains Latin characters — a country name, a figure,
an ampersand — and a Cyrillic-only face drops those back to the fallback
mid-word, which looks worse than not switching. Each file verified to begin
with the wOF2 magic after download, because a fetch that returns an HTML error
page also "succeeds" and a font that is really a 404 fails at render time with
no network error left to find.

Two things the rule needed beyond the font. [lang] cannot cross a shadow
boundary, so the attribute has to be INSIDE the root for a selector in the
root's own stylesheet to see it — our roots now carry :lang for the same reason
they already carried :dir, and it is correct HTML besides. And the selector
accepts both `ru` and `ru-RU`: their site tags the document ru-RU while our
i18n locale is `ru`, and a host page embedding this element may set either, so
a selector matching only one would work on their site and fail on ours.

Only the headings, as theirs. Their .region-stat__value is a <p>, so their
ru-RU rule does not touch it and it keeps the heading family even in Russian —
switching everything would be wrong in a way no Russian reader would report and
no English reader could see.

_probe-cyrillic-headings.mjs checks the mechanism and the result separately, so
a rule that is present but inert cannot pass: the attribute is on the root, the
Oswald subset returned 200, the face is usable once document.fonts.ready
resolves, the h1 and h2 switched, the stat value did not, and English is
untouched in both family and letter-spacing. It awaits fonts.ready before
asking about a font — not doing so is what made a sibling probe report a
cross-origin font failure that did not exist.
2026-09-24 21:05:12 +00:00
doxa-maps-88655c70
510b6edbf8 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 20:55:42 +00:00
doxa-maps-88655c70
5869fed6a5 Hold the total row's height, and translate the three strings my own scan's bound hid
Two fixes and a correction, all three found by turning other agents' lessons on
my own already-green work.

THE TOTAL ROW DISAPPEARED WHILE LOADING. Theirs never removes it: it swaps the
count for a visibility:hidden placeholder so the row holds its line and the
results below do not jump down when the number arrives. Ours v-if-ed the whole
row away, so the layout shifted on every load. It surfaced only after the
class-coverage sweep's extractor was widened to read the whole file instead of
just the template — the class is in markup my template scan reached, but the
sweep had never seen the NAME, so nobody had asked what it was for. Widening it
took that sweep from 22 to 58 classes seen on their UupgsList and from 103 to
134 checked overall, inside a run I had already reported as complete. Of the
ten new names, eight are their useSelectCard variant this clone does not render
and each is recorded with that reason and its line number; the ninth,
line-height-tight, was already correct because we ported the resolved value
rather than the class.

AND A CORRECTION TO MY OWN LOCALISATION CLAIM. I reported zero hardcoded
English in the regions components. That was wrong, and my own instrument is
why: the scan bounded matches at sixty characters, and three failure messages
are longer than that —

    Region data could not be loaded. This is a connection problem, not a filter.
    People groups could not be loaded. This is a connection problem, not a filter.
    The map could not start. This is a map-token or connection problem, not a
    filter — the figures above are unaffected.

Re-scanned with no upper bound and across line breaks, translated into all
eleven locales, wired through t(). A scan with a bound can only find what fits
the bound, and I quoted its zero as though it covered everything.

Verified against live after all of it: the region card diff is now 37 of 37
identical (it grew by the icon's own margin, which nothing had read), the
bullet 11 of 11 exact, the region page 107 of 107, the country page 32 of 32.
2026-09-24 20:55:19 +00:00
doxa-maps-88655c70
795b357ab8 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/gospel-resources/charts/index.html
#	public/doxa-maps/gospel-resources/data-tables/index.html
#	public/doxa-maps/gospel-resources/gospel-dashboard/index.html
#	public/doxa-maps/gospel-resources/gospel-resources.js
#	public/doxa-maps/gospel-resources/resources-list/index.html
#	public/doxa-maps/gospel-resources/resources-map/index.html
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
2026-09-24 20:52:29 +00:00
doxa-maps-88655c70
938f58c5f7 Rank the translation-priority set, and sweep GRN batch four
The map could select the 2,289 groups that can hear the gospel but
have no text Scripture; it could not say which of them to weigh first,
and 2,289 dots cannot be read top-down. The gap sheet gains a fifth
tab that ranks them by population and, on each row, names what the
group DOES have — "has audio Scripture, the JESUS Film — no text
found" — so a reader can see whether they are being sent to a
recording or a film before they go.

The tab describes a different population from the other four, so it
does not inherit their sentences: the nothing-found opener, the audio
caveat and the macrolanguage note are all scoped away from it. Its own
paragraph states the bound twice, because the top rows are Western
Punjabi at 97 million and Wu Chinese at 92.5 million, and a reader
skimming those numbers must not come away thinking no text exists in
any variety they speak.

GRN batch four: 170 more codes, 90 with gospel audio. 343 codes now
carry a Words of Life link and tier 0 falls 2,488 to 2,373 — from
3,457 when this work began. 689 of the gap's codes are decided, and
provenance stays exact at 588 claiming GRN, 588 decided, none
decided-but-unnamed.
2026-09-24 20:50:27 +00:00
doxa-maps-88655c70
71ff1d3b02 Plugin studio: call the cap-5 shell "Research Gallery"
Some checks are pending
build-gate / build (push) Waiting to run
The studio listed the app shell by its profile id, doxa-maps-spa. It now
shows the Driver's name for it, Research Gallery, in the rail, in every
composition tree and in the title bar. Display only: the row key, the URL
hash, the remembered tab and the <doxa-maps-spa> tag under the name are
unchanged.

Rows now carry the real profile id separately from the label, because the
mount handed the label to the element as its profile; a display name
there would have mounted the shell with a profile it does not have.
2026-09-24 20:49:42 +00:00
doxa-maps-88655c70
8bb7f47e53 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
2026-09-24 20:48:39 +00:00
doxa-maps-88655c70
fac00c00bf Adopt the research map's region colours, and pin the one unit that wasn't px
THE DRIVER RULED ON THE PALETTE. He compared the two screens himself: "when i
look on teh color ffor th regiosn tab of the simpel research map i don tsee the
you using htes colors on the reigosn dashbarod hwen you shoudl". Two surfaces
disagreeing about what Africa's colour means is a defect whichever palette is
prettier, and he chose which one wins. library/colors/wagf-region.js now
carries doxa-research-map/src/colors/doxa-region.js's values.

The blast radius turned out to be nil, and my earlier escalation overstated it:
wagf-region.js is imported by exactly two files, both mine. I had reported it as
needing a cross-agent decision on the grounds that seven surfaces would be
repainted. They would not have been — nobody else imports it.

WHAT THE CHANGE COST, measured with the same validator and surface the previous
palette was stepped against:

    lightness band   FAIL   #1ee05f .793 · #FFEB3B .928 · #00e0bd .810 sit
                            above the .43–.77 band
    chroma floor     PASS   all 7 >= .1
    CVD separation   PASS   worst adjacent #FFEB3B/#1ee05f dE 8.7 protan
    normal vision    PASS   worst adjacent dE 21.0
    contrast         WARN   under 3:1 vs surface: #1ee05f 1.72 · #FFEB3B 1.19
                            · #00e0bd 1.65 · #ff7f11 2.47

The palette this replaced passed all five. The contrast warning is survivable
here for a reason and not by dismissal: it obligates relief through a secondary
encoding, and both surfaces already carry one — the chooser renders a legend of
labelled chips in these colours, the dashboard renders the same seven as a
table. Identity never rests on hue alone. The lightness failure has no such
relief: three of these read faintly as pins on a light basemap. Darkening those
three by about 10% L would enter the band with the hue still recognisable, and
that is the Driver's call, so it is asked rather than done quietly.

_chk-region-palette-agrees.mjs is what stops the two drifting apart again. It
compares them value by value and names both files on a disagreement. Ours is
imported; theirs is parsed, because doxa-region.js imports a Vite alias and node
refuses the module — so the parse asserts its own shape first, requiring the
eight entries that file declares, and fails rather than comparing against an
empty object if anyone refactors it to computed values. Negative-tested: flip
one hex and it names that region and both paths.

AND ONE UNIT IN THIS PORT WAS NOT PX. The chevron's gap was a literal 1rem,
ported from their `.with-icon .icon.right { margin-inline-start: 1rem }` — 10px
at their 10px root. `rem` resolves against the document root wherever the
element lives, which is the whole reason dxlife-tokens.js resolves their scale
to px, so ours rendered 16px on our own leaf and would render 10px by accident
on a host with the common 62.5% reset. Pinned to 10px. No diff caught it: the
list diff's link spec reads colour, decoration, weight, display and alignItems,
and never a margin. It reads the icon's margin now.
2026-09-24 20:48:27 +00:00
doxa-maps-88655c70
e3df4677d3 Plugin studio: fold every composite, so the rail starts as a plain list
Some checks are pending
build-gate / build (push) Waiting to run
With every composition tree open, the rail was too long to find your way
around. Each composite now carries a chevron and starts folded, so the
rail first reads as the 35 plugins by capability, each composite saying
how many parts it has. Open one and you see its direct parts; a part that
is itself a composite folds again, so doxa-maps-spa opens to its tabs and
each tab opens to what it is made of, one level at a time. Choosing a
composite also opens it; only the chevron closes it. The same child folds
independently wherever it appears.

VerticalTabsLayout gains two optional row fields: `branch` makes a row
foldable, `ancestors` hides a row until every branch above it is open.
Rows are wrapped so the chevron can sit beside the tab rather than
inside it. doxa-maps-spa sets neither field and renders unchanged.
2026-09-24 20:45:19 +00:00
doxa-maps-88655c70
edccc8ee05 Filter the map to the groups a translator would go to next
The pin filter could separate nothing / something / both, which are
tiers. It could not ask the question a translation team actually
starts from: who can already HEAR the gospel — a recording, the JESUS
Film, a Bible video, Words of Life — but has no text Scripture in
their language? That set cuts across all three tiers, so no tier
button could select it, and it is the largest actionable group on this
map: 2,289 people groups across 1,147 languages.

The claim is bounded in the button, the caption and the tooltip: no
text Scripture FOUND IN THE POOLS CHECKED, never a claim that none
exists. It matters here more than usual, because the largest entries
are Western Punjabi, Shanghainese and Egyptian Arabic — varieties
where a recording plainly exists and a text in that same variety is
genuinely contested.

The flag is baked into the pin features so the filter stays a GL-side
predicate like the others; recomputing it per toggle would re-upload
12,379 features to answer a yes/no already known at build time. The
button carries no colour swatch, because it names a question rather
than one of the three colours.

Measured in the browser: 12,379 pins rendered, 2,289 after the filter,
matching the offline count exactly.
2026-09-24 20:41:49 +00:00
doxa-maps-88655c70
1257ab575b Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
#	public/doxa-maps/regions-dashboard/region-dashboards/index.html
#	public/doxa-maps/regions-dashboard/regions-dashboard.js
#	public/doxa-maps/regions-dashboard/regions-list/index.html
#	public/doxa-maps/regions-dashboard/regions-mfe/index.html
2026-09-24 20:33:58 +00:00
doxa-maps-88655c70
e944132933 Fix an illegible active filter button, and the comment that defended it
Apply a filter on the country page and the button you just pressed went white
text on 5%-alpha blue over a white ground — an invisible label. Theirs is white
on #6d766e. We had copied their DEAD FALLBACK:

    background-color: var(--color-surface-brand-lighter, rgba(0,80,160,0.05));
    color: var(--color-white);

and --color-surface-brand-lighter is defined, at tokens/semantic/_colors.scss
:64, as $color-green-300. The fallback never applies on their site.

What kept it alive was a comment of mine sitting right above the rule. It said
their active state is white-on-white on purpose, that the token "is NOT DEFINED
in their token files", and that the white-on-white had been "MEASURED on their
live page". The first two are contradicted by a file in the read-only clone.
The third could not have been what it claimed: getComputedStyle returns the
specified colour and never the composite, so neither candidate can serialise as
white — the token is #6d766e, the fallback is rgba(0,80,160,0.05). The only
rule on that button that yields white is .input's var(--color-surface-white),
which is the inactive state. The measurement had read a button without
data-active set.

Measured properly, by clicking the Adopted filter on both sides: theirs is
background rgb(109,118,110), colour rgb(255,255,255). Ours now matches, and so
does Clear All — theirs is `.button compact link`, which blocks/_buttons.scss
:64-69 gives --button-text-color: var(--color-brand) = #3b463d, where ours had
used the body ink #2a332a. 30 of 30 properties identical to live, 0 differ.

That second one was found twice over, independently: by this diff, and by a
static sweep of their markup's classes that flagged `.link` as a rule our CSS
had no equivalent for at all. The sweep is worth reusing — list every class and
tag a component's markup uses, resolve each against their scoped block and
their global sheet, then check ours has something for it.

The probe exists because _probe-country-vs-live.mjs printed one line everybody
had read past: "NOT MEASURED (absent on both sides, so neither identical nor
different): chip". A filter chip only exists once a filter is applied. Absent
on both sides is not a pass. It is the second diff here that clicks something
before measuring, after the pin popup, and its own first run was a probe bug
worth recording: it waited thirty seconds for the three filter buttons and then
reported that Adopted could not be found. The panel is collapsed by default on
both sides and the buttons sit behind a v-if, so they did not exist yet — a
probe failure dressed as a product finding.
2026-09-24 20:33:03 +00:00
doxa-maps-88655c70
25e3edec8f upg-100: no mapbox stylesheet under maplibre, and assert the preview exists
Some checks are pending
build-gate / build (push) Waiting to run
Two things, both found by other agents' reports rather than by my own gates.

rega found upg-100-list.vue:1464 injecting mapbox-gl.css v3.3.0 into the shadow
root unconditionally, so a keyless maplibre embed still pulled a stylesheet from
api.mapbox.com. Token-free and harmless to render, but wrong-engine leakage on a
page whose point is having no Mapbox dependency. Gated on isMaplibre, which
already existed at line 119.

This commit is ATOMIC by design: the fix, the rebuilt bundle, and the deletion
of rega's MAPBOX_KNOWN exemption land together. Either half alone leaves a
window — delete first and main red-gates on its own known bug; fix first and a
stale exemption sits there hiding the next one.

Proven before deleting, because the exemption is keyed on a URL STRING while the
defect is a network REQUEST: the built bundle still contains the v3.3.0 literal
(the constant remains; the guard stops the fetch), so a source-grep gate would
have been misled. rega's gate measures requests, and it reported
"provider=maplibre made 0 request(s) to api.mapbox.com" in BOTH js and iframe
modes with no KNOWN line printed at all — i.e. the exemption was already dead
code. Re-proven after deletion: still 0 requests, still PASS.

The leaf-head preloads are explicitly NOT covered and the reason is recorded in
the gate: mapbox-gl.css v3.24.0 and mapbox-gl-geocoder v5.0.0 are a different
version and a different owner (generate-tree, bundler-1's lane), already tracked
separately.

Second: the Driver's report had two halves — "it is supposed to show the
previews" and the print options overlapping. I fixed and gated the overlap and
left the first unasserted. The over-preview check was written as `if (img)`,
which means a card with NO preview satisfies it perfectly: nothing can overlap
an image that is not there.

The gate now counts previews. Mutation-tested by aborting every previews/**
request: the product degrades well — all 27 cards swap to a "poster not
generated yet" placeholder with no broken-image icons and no <img> at all, which
is precisely why an image-health check cannot see it. So the fallback itself is
asserted, and all-cards-placeholder now fails.

A first version of that assertion was wrong and the reason is recorded in the
code: it called 18 lazy-loaded previews broken. Every img carries loading=lazy,
the first unpainted one sat 3926px down an 844px viewport, and no HTTP response
failed. Deferred is not broken; it now judges only in-viewport images.
2026-09-24 20:28:45 +00:00
doxa-maps-88655c70
460ccb564b Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
#	public/doxa-maps/regions-dashboard/region-dashboards/index.html
#	public/doxa-maps/regions-dashboard/regions-dashboard.js
#	public/doxa-maps/regions-dashboard/regions-list/index.html
#	public/doxa-maps/regions-dashboard/regions-mfe/index.html
2026-09-24 20:26:59 +00:00
doxa-maps-88655c70
728d3cbaef Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 20:12:15 +00:00
doxa-maps-88655c70
29eaf3a62a Port their pin popup, and close the last English out of the regions clone
THE POPUP WAS A STUB. Click a pin on their region page and you get a card: the
people group's photo, the name, "country · primary ethnolinguistic group", a
definition list of Population / Religion / Intercessors-out-of-100, up to three
clamped lines of location description, and two buttons — Pray, which is the
point of the entire site, and Full Profile. Click one on ours and you got four
lines of hardcoded English with en-US number grouping in all eleven locales.

No diff here could ever have found it. Every vs-live probe reads the page at
rest, and a popup only exists after a click. So there is a new one, and the
popup now matches theirs on 37 of 37 measured properties with the same stat
labels, the same two actions, and their ?source=doxalife attribution on the
Pray link — which is invisible on screen and exactly what a re-implementation
drops silently.

Noticing it was not enough to fix it: the DATA was not on the page. Their
PIN_FIELDS asks for rop1, religion, location_description, image_url and
has_photo; ours asked for none of them, so a faithful popup would have rendered
empty rows. Five more columns on the same single request. Their row mapping
comes with it, including the has_photo gate on the image — a row can carry a
placeholder image_url and they only show it when the flag says there is a real
photo — as does their fuller escape, which also escapes both quote characters,
and that matters because these strings are interpolated into href and alt.

AND THAT FILE HAD NO t() AT ALL. Its prayer legend rendered four hardcoded
English strings on a page whose every other word is translated into eleven
languages — and three of those four were already in the catalogue, translated
for the Prayer Coverage card and hardcoded here anyway.

LOCALISATION, re-measured honestly. The old 97.4% was counting strings
identical to English as untranslated, which is wrong: es/fr/pt/ro share
"Total", and French shares "Population" and "Religion". Those are cognates. The
real question is coverage of their surfaces, so: every t() argument in
RegionDetail.vue, pages/regions/index.vue, CountryDetail.vue and CountryMap.vue
— 35 strings — checked against our catalogue. Two were missing, both
CountryMap's: "Outside this region" and "Pray". Added in all eleven, the eight
DX-life translates copied from their own common.json and it/ro/zh composed from
words already attested in those locales' own files rather than invented. Then
the reverse direction: a scan for capitalised text nodes outside {{ }} found
two of our own strings left in English — the dashboard's loading state and the
list's empty state, neither of which DX-life has an equivalent for because
their page is server-rendered. Both translated. The scan now returns zero in
all three components, and coverage of their surfaces is 35/35.

Verified alongside: all seven regions at 107 properties identical to live each
(not just Africa), mobile parity still 44/0 at 390 and 768 after the CSS
surgery, and first paint of the list measured at 3766ms with 1863KB of it a map
engine that leaf never calls — carded to the build machine, not worked around.
2026-09-24 20:11:44 +00:00
doxa-maps-88655c70
6fc39f525a Merge main into bundler-2/work
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 20:10:09 +00:00
doxa-maps-88655c70
fd95518152 Merge remote-tracking branch 'origin/main' into rega/research-gallery-embed
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 20:00:50 +00:00
doxa-maps-88655c70
706ec3fab8 Merge main into bundler-2/work
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 19:59:57 +00:00
doxa-maps-88655c70
8ade2f3062 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 19:58:09 +00:00
doxa-maps-88655c70
dd2443b7de Merge remote-tracking branch 'origin/main' into rega/research-gallery-embed
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 19:57:53 +00:00
doxa-maps-88655c70
3a4f065ec5 Research Gallery: a host that asks for maplibre gets maplibre in every tab
The shell forwarded tk, tokenUrl, locale and treeBase to each nested map,
but not the engine. So <doxa-maps-spa provider="maplibre"> still loaded
mapbox-gl and Mapbox styles. On a keyless host, that is the embed failing.

The shell now forwards `provider` in each child's profile-config, validated
to the two known engines. That fixed the simple map tabs, but not the
composites: they mount their inner maps without passing the provider on.
Measured with one tab per clean page: Big Picture made 19 api.mapbox.com
requests, Regions 17, UPG-100 15, Simple Research 14. So when a host asks
for an engine, the shell also sets window.MAP_PROVIDER (only if the page
hasn't). That is rung 3 of every map's own ladder, so it reaches maps
nested at any depth. After it, all four composites make 0 Mapbox requests.

check:gallery-embed:maplibre runs the cross-origin gate with a keyless
provider and fails on any api.mapbox.com request, attributed to the tab
that was open. Before this change, js mode failed with 20 URLs. It has one
named, printed exemption: upg-100-list.vue pins a token-free mapbox-gl.css.
That belongs to the upg100 plugin and is routed there.
2026-09-24 19:57:23 +00:00
doxa-maps-88655c70
c57b51044f Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 19:56:38 +00:00
doxa-maps-88655c70
a5a3c6b9d1 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 19:56:32 +00:00
doxa-maps-88655c70
65e959a08f Say which pools were really searched for THIS language
The sheet's "checked" line is what tells a reader whether "nothing
found" means anything, and it was wrong in both directions. 5,131
codes omitted Scripture Earth, which had in fact been swept for every
one of them. And 110 codes named GRN as checked when the sweep has
never looked at them — GRN is crawled a batch at a time at the delay
their robots.txt asks for, so 2,100 codes are still unread.

The cause was ordering, the same lesson this file already carries for
the dead-link drop: the pass that normalises provenance ran before the
Scripture Earth and GRN fetchers, so the codes those fetchers create
kept the raw constant. A tidying pass that runs before the thing it
tidies reports a confident answer about a room still filling up. It
now runs after every source attaches, and the count is exact — 282
codes name GRN, 282 have been decided, and none that was decided goes
unnamed.

Scripture Earth is claimed unconditionally because that sweep really
did cover every code this map uses: a code absent from their index is
a checked negative. GRN is claimed per code, and the rest carry
"grn (not yet swept)" in the unchecked list instead.

Clause M of the coverage gate now fails if any code names a pool the
sweep never decided for it. Poison is 13/13.
2026-09-24 19:55:12 +00:00
doxa-maps-88655c70
d5effe7c66 Copy their list bullet instead of my own, and stop shipping rounded tokens
Two findings on the regions LIST tab, both from extending a diff that had been
reporting a clean pass.

THE BULLET WAS MINE, NOT THEIRS. RULE 1 says copy, don't recreate, and I had
recreated their list marker as a 6px border-radius:50% dot. base/_globals.scss
:117-131 paints every list marker on their site with a 16x16 data-URI SVG
circle in #92b195 sized to 1.5em, hung off the text's starting edge by
margin-inline-start:-1.3em (-2em in RTL), top .3em, line-height .9 — and their
.countries-grid carries a comment saying keeping it is deliberate. Against live
mine was wrong in 9 of 11 properties: 6px against 29.2969px, a solid fill
against a transparent box carrying the image, top 11.72px against 5.86px,
positioned by inset-inline-start:-16px instead of their negative margin. Two
matched. Not a near-miss — a different bullet. Theirs is now copied, and
.countries-grid's physical padding-left:35.2px is replaced by their logical
padding-inline-start:1.8em, because the physical one put the indent on the
wrong side in Arabic, where the bullets hang off the right edge.

THE TOKENS WERE ROUNDED, AND EM MULTIPLIES THE ERROR. With their bullet ported
it still measured 29.2812px against their 29.2969px. 0.0157px is nothing to
look at, but it is the rounding of --font-size-md (19.5312 for their 19.53125)
times 1.5, and every em- and percentage-derived length on the page inherits the
same error. dxlife-tokens.js said re-deriving to live's precision would be "a
separate, separately measured step"; this is that step. Both scales are exact
powers of their two ratios on a 10px root — type 1.25, spacing 1.33 — so full
precision costs nothing. The bullet now matches on all 11 properties exactly,
and the region card on all 32.

The diff could not have found either before. getComputedStyle(el) does not see
::before and no spec named one, so a pseudo-element that was wrong in nine ways
sat next to "32 of 32 identical". The probe now reads the bullet as a pair on
both sides — and compares its backgroundImage after normalisation, because
their live CSS serialises the SVG with xmlns first while the checked-out SCSS
writes it last: their deployed stylesheet is not byte-for-byte the source, so a
raw compare would report a permanent difference for an identical bullet while
blanket-accepting it would hide a real change of image. The comparison
percent-decodes, drops the charset Chrome adds and sorts the attribute tokens —
sensitive to the circle, its radius and its colour, blind only to their order.

Also here: the maps render the globe projection, measured rather than argued —
projection=globe on both the chooser and a region page, via a constructor trap
installed before any page script. And the probe's own two defects fixed: it
asserted "our style JSON does not override the projection" and failed, which
was my guess about where the value comes from rather than a requirement
(Mapbox Standard declares globe itself, which is why the default is globe); and
it read the map list from the main frame only. The remaining honest gap is
named in the file and on the card: their CountryMap imports mapbox-gl as a
bundled ESM module, so no global exists to trap and the ours-vs-theirs half is
unproven until a pixel oracle with a forced-mercator negative control exists.
2026-09-24 19:55:03 +00:00
doxa-maps-88655c70
e888a2e5ba Show every resource a language has in the list, not two of them
The list built its links from c.s and c.d, then appended film, audio
and Bible video by hand. Scripture Earth, GRN, gospel presentations
and Zume were never added, so 190 languages rendered with no link at
all while the map offered a working one for the same language — Naba
in Chad, 753,000 people, had a Words of Life recording and the list
showed nothing.

Both surfaces now derive their rows from one helper in this plugin's
own folder. It is private on purpose: nothing outside gospel-resources
has a codes[] entry, so publishing it to library/ would advertise a
signature only one bundle can call.

Two things this nearly got wrong. The link kind was hardcoded to
"scripture" for the first rows, which was harmless while the list held
only text Scripture and became a doctrinal error the moment it held
gospel audio — Words of Life is gospel messages with songs, and
styling it as Scripture tells a reader they have a translation they do
not have. The kind now comes from the row's own type. And the eBible
translation count on the page reduced over the same array, so widening
it would have overstated how many translations exist under a sentence
that names eBible.org; it counts text Scripture rows only.

The gap pill is untouched and still reads "no text" for Naba, because
that remains true.
2026-09-24 19:49:00 +00:00
doxa-maps-88655c70
f22609fb3c Clone the six global rules the component's own stylesheet never showed
The Driver looked at the two sides and named the components: "the tbabel
compoent is not the same", and of Prayer Coverage / Adoption Progress /
Engagement Progress, "thes three are not th esame css of the dx lfie site, it
properl spces and lcolros thigns while ours doe snot". He was right about both,
and for one reason: I ported RegionDetail.vue and its <style scoped>, and the
rules that differ are not in either. They are in the global sheet every page on
their site loads.

  base/_globals.scss:99-105  every <table> is a 25px-rounded, #666-ringed WHITE
                             card; :205-207 gives every <td> a 1px #666
                             gridline. Ours was bare and borderless.
  composition/_stack.scss    .stack spaces children with margin-top, not gap —
                             and .region-goal{margin-top:auto} OVERRIDES it.
                             Ported as gap, which no margin can override, so
                             both applied and every goal card carried an extra
                             23.526px. That is his "spacing is not the same".
  base/_globals.scss:19-23   every <section> is padded
                             clamp(--spacing-2xl,6vw,--spacing-4xl) top and
                             --spacing-4xl bottom: 73.614px on each side of the
                             countries table, where we had none.
  base/_globals.scss:132-141 every <a> is --color-brand-light at weight 500
                             with a 2px/4px underline. Their country cell is a
                             NuxtLink; ours is a <button>, which inherits none
                             of it and read as plain body text.
  base/_globals.scss:200-202 <strong> is 500, not the UA's bold. The "Goal:"
                             label wears it three times.

All five ported, and the remaining .stack ports converted from gap to their
margin-top so the next margin-overriding child behaves as it does on their
page. Measured against live doxa.life/regions/africa: 107 properties
identical, 0 CSS differences, 0 structural-text differences.

The measurement gap matters more than the fix. _probe-region-vs-live.mjs
reported 79/79 IDENTICAL through every one of these and could not have failed:
goalCard's spec was padding/radius/background/colour/align with no gap, so the
property that spaces the children was never read; table's was minWidth and
width; td had no border; and the country link had no entry in the selector map
at all. The spec now also reads gap and flexDirection on the card, marginTop on
its children, the td border, the table's radius/background/box-shadow/overflow,
section padding, strong weight, rowGap on all five stacks, and a new tdLink
pair — 107 properties.

Also, the Driver's other report on that screen: "poster not generated yet ...
it is not supposed to say not generated yet it is supposed to show the
previews". Ethiopia's country-eth.webp exists — 174 of the 175 records have
theirs, India being the one that legitimately does not and carrying its own
reason. Measured on the built tree and on the dev server: every preview request
200, zero cards in that branch, and the lazy contract holds (nothing fetched
until the dropdown is opened). So the file is not missing; a host layout is.
PosterWall now tries the self-contained copy generate-tree stages inside the
tree before giving up, and when it does give up it no longer claims the poster
was never generated — it says the preview is unavailable, which is the only
thing a failed image request establishes.
2026-09-24 19:46:36 +00:00
doxa-maps-88655c70
3921c605d6 Merge main into bundler-2/work
# Conflicts:
#	package.json
#	public/doxa-maps/index.html
2026-09-24 19:42:35 +00:00
doxa-maps-88655c70
4727005aba Studio and stack-demo: forward the host's engine and token too
Extending the composite embed fix to the two remaining composites that a partner
can mount, plus two red gates that arrived on main.

plugin-studio already threaded tk, tokenUrl, locale and treeBase into every pane
it mounts — but not `provider`, so a studio embedded with provider=maplibre
showed mapbox panes and, on a keyless host, panes asking for a token. It now
forwards the engine the same way, validated to the two known values, and accepts
it as an attribute as well (a host showing plugins to partners wants to flip both
at once).

stack-demo declared no props at all, the same hole the two shipped composites
had: <stack-demo profile-config='{"tk":"…"}'> had its token read by nobody, and
on a page with no baked token the nested dashboard reported "No Mapbox access
token provided". It now parses the host config, publishes the token page-wide and
threads tk + provider into the child it mounts by tag.

MEASURED on a foreign origin, minimal config: stack-demo [tk] canvas 1 with 13
api.mapbox.com requests; stack-demo [provider] canvas 1 with ZERO. plugin-studio
[tk] canvas 1 with 19.

★ ONE LEVEL DEEPER, NOT MINE, MEASURED AND REPORTED: plugin-studio still shows
mapbox tiles under provider=maplibre, and the pane chain says why — the studio
hands provider=maplibre to <doxa-maps-spa> correctly, and the SPA then mounts
<dashboard-plus-grid> with a config carrying tk and NO provider. The cap-5 shell
drops it. Same three lines fix it there; routed to its owner rather than reached
into.

Also fixed two gates that were red on main and blocking every builder:
  check:script-flags       check:gallery-embed advertised --mode/--only in an
                           inline comment its command ends with, so the flags
                           could never arrive and the gate would run its default
                           and exit 0 looking like success. Gave the modes their
                           own entries and stopped the parent advertising what it
                           cannot receive.
  check:capability-exemplars  the generated table had drifted from the manifest
                           (a legacy bundle arrived); regenerated, 35 rows match.
2026-09-24 19:39:59 +00:00
doxa-maps-88655c70
2693c6ffa3 Plugin studio: one rail, highest capability to lowest, trees under their plugin
Some checks are pending
build-gate / build (push) Waiting to run
The separate "What each composite is made of" part is gone. It started
over at CAP 5 below the CAP 1 singles, so reading down the rail jumped
back up the ladder. Each composite's composition tree now sits directly
under that composite's own row, inside its capability section, so the
whole rail reads CAP 5 -> CAP 1 once: doxa-maps-spa with its six tabs and
what they are made of, then each CAP 4 composite with its parts, then
the multi-view, preset and single elements.

Tree rows are still aliases of each plugin's one canonical row, arrow
keys still visit each plugin once, and section counts count plugins, not
repeats. An unpublished part now sorts after every published child.
2026-09-24 19:31:40 +00:00
doxa-maps-88655c70
6ecd845e05 Plugin studio: give the plugin the whole stage, and stop showing the grid twice
Some checks are pending
build-gate / build (push) Waiting to run
The "Composed of" panel above the preview is gone. It sat between the
title and the plugin and took the plugin's space; composition already
lives in the rail's "Inside <composite>" trees. The toolbar is back to
one line: name, element tag, Copy embed.

big-picture is no longer listed. It is a dark header of search plus four
filters over the people-groups grid, with the grid's own identical
controls switched off: in the studio it read as the grid a second time
under a misleading name. The real Big Picture is dashboard-plus-grid,
and the app shell already hides big-picture for the same reason. The
bundle is still built and embeddable. The studio's own composes list now
names dashboard-plus-grid instead of bp-big-picture.
2026-09-24 19:25:50 +00:00
doxa-maps-88655c70
f0c46022ee Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 19:15:28 +00:00
doxa-maps-88655c70
e565c58ca0 Merge main into bundler-2/work
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 19:13:31 +00:00
doxa-maps-88655c70
b105fcebcf Merge main and regenerate the served tree
Some checks are pending
build-gate / build (push) Waiting to run
Generated index.html conflicted; resolved by regenerating rather than hand-merging.
2026-09-24 19:07:41 +00:00
doxa-maps-88655c70
f6e5466019 Restore their zoom and fullscreen controls, and stop overriding the projection
Two clone gaps the Driver caught by looking at the pages side by side.

Their CountryMap adds NavigationControl({showCompass:false}) and
FullscreenControl() at top-right. Ours added neither, and no probe could
have noticed: every vs-live diff compares computed CSS and text on selected
elements, so two whole controls were absent while the page still reported
79 of 79 properties identical. Ported with their exact options and corner,
each guarded so a MapLibre provider cannot take the map down with it.

The mercator override is deleted. I had forced it in the chooser because
Mapbox's globe below zoom 6 left a world view as a small sphere in a large
frame. The Driver has ruled the maps are a clone with no change beyond
colour and the scroll strips, the globe included, so the override goes
rather than gets argued — and deleting is the right shape here, since their
page sets no projection at all.

Also localises the last nine strings of our own two additions across all
eleven locales, composed from their attested vocabulary rather than invented:
their own translations of Regions, Select, View, Choose, and the dashboard
word from "See %region% dashboard". zh goes 93.9% to 97.4%; what remains is
Mapbox's own canvas chrome and one API code.
2026-09-24 19:06:59 +00:00
doxa-maps-88655c70
6ab560e42f Merge remote-tracking branch 'origin/main' into rega/research-gallery-embed
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 19:06:54 +00:00
doxa-maps-88655c70
7609cd36f0 Composites: pass the provider prop down, so keyless embeds are keyless
The token fix was half the contract. resolveMapProvider's ladder starts at
profile-config.provider, and a child reads the config its PARENT writes — so a
partner asking for

    <research-map-plus-grid profile-config='{"provider":"maplibre"}'>

got a child still on mapbox, which then demanded a token. MEASURED on a
third-party origin: "No Mapbox access token provided", 0 tiles, no canvas — for
the exact configuration whose entire purpose is needing no token. Keyless at
volume is why MapLibre support exists; a composite that drops the prop cancels
it, and the failure reads as a token problem rather than a plumbing one.

Both composites now thread `provider` beside `tk`, validated to the two known
values so a typo cannot silently select a third engine.

MEASURED AFTER, partner page, NO token anywhere in the config:
  research-map-plus-grid  canvas 1280x906, ZERO requests to api.mapbox.com,
                          basemap painted, 12,379 pins, legend, grid
  dashboard-plus-grid     canvas present, ZERO requests to api.mapbox.com
Screenshot read: the keyless map also shows the deferred search-bar rule holding
on a partner page — no search bar, and the legend risen into its place with no
gap.
2026-09-24 19:05:19 +00:00
doxa-maps-88655c70
3a2cf0e8f2 Research Gallery: embed on someone else's page, in both modes
The gallery is going onto the DX-life research page, and nothing had tried
it on a page it didn't generate. From a second origin, a bare
<script> + <doxa-maps-spa> drew over the host's header, and 8 of 13 tabs
showed an error card.

Three causes, each fixed in the shell:

- No :host box. The layout root is position:absolute, so it anchored to the
  viewport instead of the element. :host is now a positioned block with a
  default height the host can override.
- A foreign page sets none of the globals a leaf page bakes. Every data call
  hit the host's own origin and got HTML back. The manifest now publishes a
  `runtime` block (apiBaseUrl + the public token every leaf already carries).
  The shell fills window.MAP_APP_API_URL / window.MAP_TOKEN only when the page
  hasn't set them, and forwards tk in profile-config.
- Nested elements resolved assets to <tree>/../assets, a sibling pool the
  publisher doesn't ship (404 live). The shell now forwards an absolute
  <tree>/assets. This also fixes the iframe mode, where Cities with Internet
  and Gospel Resources were failing the same way.

Also adds a `tab` attribute (plus ?tab=) to deep-link into one map, and a
`tab-change` event for the host.

check:gallery-embed drives every tab in both modes from a second origin. It
passes here and fails on the previous tree (7 findings).

EMBED.md documents the contract. The Traefik template now sends CORS for the
whole tree: live manifest.json answers without Access-Control-Allow-Origin.
2026-09-24 19:03:37 +00:00
doxa-maps-88655c70
dc252bb304 Composites: give a partner's embed the token it passes in
A JS embed of either composite mounted a map that could not draw. MEASURED the
way a partner actually embeds — one origin serving the tree with CORS, a
SEPARATE origin serving the page:

  research-map-plus-grid   "No Mapbox access token provided", style 0, tiles 0, no canvas
  dashboard-plus-grid      the same
  (iframe embeds were fine throughout: style 2, tiles 9, 0 failures)

Both composites declared NO PROPS, so the host's profile-config — token included
— was read by nobody. Our own leaves hide this completely: a leaf carries a
BAKED token and assigns window.MAP_TOKEN, which the shared-token bridge hands to
the nested map. The failure therefore exists ONLY where there is no leaf, which
is precisely the partner page the embed snippet is written for. It is also the
snippet we publish: profile-config='{"tk":"YOUR_MAPBOX_TOKEN"}' is the documented
way to supply a key, and it did nothing.

Both now parse their host config, publish the token page-wide through the shared
composable, and hand it to their children. The two differ in how children are
mounted, so the last step differs:

  research-map-plus-grid composes by TAG + BUNDLE URL, so the token is threaded
  into the child element's profile-config, where its own ProfileLoader reads it.

  dashboard-plus-grid bakes its children in and bypasses ProfileLoader entirely
  — and ProfileLoader is the only provider of `mapboxToken` in the repo, so
  DashboardMapPanel's inject fell through to its empty default. It now provides
  `mapboxToken` itself, from the host's tk with the shared page token behind it.

Spread last and only when present, so a host that supplies none leaves the key
absent and the child asks for exactly what its standalone leaf asks for. A
malformed attribute is caught and ignored: the map half is more useful
token-less than the page is with a thrown setup().

This went in the PLUGINS, not the library. A shared change reaches every map —
that is the lesson SHOW_EXPORT_BUTTON taught this week, and nothing here needs
to be shared to work.

MEASURED AFTER, same harness: research-map-plus-grid canvas 1280x906, style 2,
tiles 9, 0 failures; dashboard-plus-grid canvas present, style 2, tiles 10, 0
failures — basemap, pins, legend, table and charts all drawn. Screenshots read.
Both LEAVES re-checked for regression: canvas 1, 0 failed requests, each still
painting its own map.
2026-09-24 19:00:24 +00:00
doxa-maps-88655c70
b089f9effb Reach Global Recordings Network, and lift 147 more groups
GRN records the gospel as AUDIO for languages with no writing, which is
most of what is left in the gap — and every earlier attempt recorded it
as unreachable. globalrecordings.net sits behind a proof-of-work bot
shield that challenges even robots.txt; that is an access control and
is not worked around. Their own delivery site, 5fish.org, is not
shielded, allows what this reads, and resolves by ISO 639-3 directly.

First 180 gap codes swept: 74 hold gospel audio, a 41% hit rate, and
147 people groups that had nothing now have the gospel in a form they
can receive. Tier 0 falls 2,873 to 2,746.

The host NEVER 404s. A nonsense path returns HTTP 200 and a plausible
geo-located page, so reading 200 as existence would have marked every
code on earth as covered. Existence is read from the page title, and a
negative and a positive control run in band on every run — the fetcher
aborts if a nonsense code ever reads as a real language. Their
robots.txt asks for a ten-second crawl delay, so the sweep is
sequential, capped per run and resumable. Do not add concurrency.

Three sign languages are deliberately left out. GRN's catalogue is
audio; 5fish holding a page titled "Pakistan Sign Language" says they
index the language, not that they have signed video. Attaching it told
20 Deaf groups — 11.5 million people — that gospel audio was available
to them. Unverified, and for that audience useless.

Six places across three surfaces still told the reader GRN answers 403
to everything and had never been searched. That is now false, and a
stale caveat misleads exactly as much as a stale number.

Also here, because adding a source is what exposed it: the charts
profile decided "this group has nothing" a second time, by listing the
resource fields by hand. That list went stale every time a source was
added — 7 languages once, 23 when Scripture Earth landed — so the chart
drew groups as having nothing while the map offered them a working
link. It now reads the tier the pin index already computed, and a new
gate refuses any surface that re-derives it. The gate was checked
against the real pre-fix file, not only a synthetic one.
2026-09-24 18:55:28 +00:00
doxa-maps-88655c70
0cdf079fc4 All people groups: count the DOXA groups again
Some checks are pending
build-gate / build (push) Waiting to run
The legend read "DOXA people groups 0". The prayer API source maps its
ROP3 (imb_reg_of_people_3) to peopleGroupCountry and maps no
peopleGroupId, which is the only field the DOXA set read, so the set was
empty and every group fell through to its GSEC colour. Read
peopleGroupCountry too: the legend now shows 2,622 DOXA rows, the count
the raw API and peoplegroups.org data predict, and the four categories
still sum to all 12,379 rows.
2026-09-24 18:52:33 +00:00
doxa-maps-88655c70
f9146d5105 Plugin studio: every plugin by capability, and a composition tree per composite
Some checks are pending
build-gate / build (push) Waiting to run
The rail now has two labelled parts. "Every plugin, by capability" keeps
one node per plugin in collapsible CAP 5 -> CAP 1 sections. "What each
composite is made of" adds one collapsible section per composite, headed
by the composite, with every element nested into it drawn as an indented
tree at full depth, ordered by capability at each level. A child repeats
in every composite that nests it, as specified.

Repeats are aliases, not new nodes: clicking people-groups-grid under
big-picture selects the same canonical entry as clicking it in CAP 2 and
highlights every copy, so each plugin still has exactly one name. Arrow
keys walk only the canonical rows. A declared child that is published
nowhere (big-picture's bp-research-header) shows as a "part" row, so the
header/grid split inside Big Picture can be named.

The dev server's manifest never carried `composes` edges, so under
`bun x vite` the Composed-of panel was empty for every composite. It now
publishes them from the same meta.json source as the built manifest.

Also: bare sibling profile refs (gospel-resources composes "data-tables")
now resolve, and rail rows use local SVG icons per capability instead of
emoji that rendered as empty boxes without an emoji font.

VerticalTabsLayout gains two optional row fields (`selects`, `depth`) and
shows badge/count on folder headings; existing consumers set neither.
2026-09-24 18:49:20 +00:00
doxa-maps-88655c70
42c2279ee3 UPG Neighbor: build the five neighbor maps from source, not from a frozen IIFE in an iframe
Send Map, the Bloom cockpit, Flower Mesh, Lang Cluster and Flowers ran in
the Experimental shelf as a 976 KB prebuilt legacy bundle inside a sandboxed
iframe. Their original source survived in the old app-profiles/doxa-upg-neighbor
bundle, and it only imports library pieces that still exist, so it moves here
as-is: the unified hub becomes upg-neighbor.vue and the lenses go in src/.

The hub now picks its opening lens at setup rather than on mount. Switching
on mount tore Send Map down mid-initialisation and threw on every other lens.
The five instance ids stay the same, so existing embed URLs still work, and
the old legacyElement key still maps to a lens.

The legacy sandbox (data/legacy/upg-neighbor and its staging rows) is gone
because nothing loads it any more.
2026-09-24 18:47:14 +00:00
doxa-maps-88655c70
29dfef1fc4 Agent brief lands through the merge queue; the gallery meta says what ships
- AGENT-BRIEF.md told agents to push with an inline token, to a retired
  branch, and to main without the lock the server now requires. It now
  gives the askpass setup, the capture/scan/push/release order, and says to
  grade the push by exit code.
- README: the full build needs BUILD_ALLOW=1 (it refuses with exit 75
  otherwise); say so, and why, where the build is documented.
- doxa-maps-spa.meta.json described the retired iframe mode and listed six
  old children. `composes` now names the seven tabs the shell mounts, so the
  studio's Composed-of panel and check:composite see the real tree.
2026-09-24 18:44:51 +00:00
doxa-maps-88655c70
3fd1fbcaea Move the private field-access dataset out of the repo
data-private/ was a TRACKED folder, so field-access-visas.json would ship
with the repo the moment it goes public. A tracked folder is not private.

The table generator no longer writes inside the checkout: private tables go
to DOXA_PRIVATE_DIR (a folder outside the repo) or are skipped. data-private/
is gitignored so it cannot be re-created by accident.

The file is removed from HEAD only; it is still in history (5 commits).
Publish from fresh history or scrub it before the repo goes public.
2026-09-24 18:34:01 +00:00
doxa-maps-88655c70
e7f84bb84d country-posters: a phone can export too — and the gate was shutting the panel
Some checks are pending
build-gate / build (push) Waiting to run
Same gap as my-upg-100-list. countries-map.vue provided onSemanticTreeSelect to
the mobile sheet but not onLegendExport, so its embedded SemanticTreeLegend
rendered with exportEnabled defaulting to false. Fixed the same way, gated on
the profile's own EXPORT_FEATURE_ENABLED so the phone never gets an affordance
the desktop deliberately hides.

Mutation-tested, which is what shows the provide is load-bearing: without it
mobile reports 3 .stl-export elements with 0 having a real box, and fails. With
it, 6 present and 3 boxed, and passes.

I deferred this last tick as "another agent's profile". That was my assumption,
not a recorded rule. The swarm lane assignment maps FILES, and
countries-map.vue appears in no lane — while SemanticTreeLegend.vue, which I
have been editing throughout, is in one. Treating profile directories as
ownership boundaries is a reasonable default, but it is not what the swarm
recorded, and it cost a known two-line fix a full tick.

A second defect turned up in my own gate. check:legend-export-reachable clicked
the legend toggle unconditionally. That is right for my-upg-100-list, whose
sheet starts closed, and exactly wrong for country-posters, whose sheet opens
expanded: 8 rows visible before the click, 0 after. The gate was closing the
panel it came to inspect and then reporting "no selectable row" — which reads as
a product defect and was purely the probe. It now toggles only when nothing is
selectable yet, and both profiles pass.

Also re-verified two cards already marked done, after the previous tick found
part (a) of the legend card shipped-but-broken. Legend part (b), "remove or
demote Zone", holds: zero occurrences of "zone" in the rendered legend. The
per-map share policy holds at the rendered layer — upg-100 has no share button,
prayer-progress has exactly one — with smoke:share-all green across all twelve
published bundles and its coverage set checked against the policy's own list.
2026-09-24 18:21:35 +00:00
doxa-maps-88655c70
795382fabc Merge main and regenerate the served tree
Some checks are pending
build-gate / build (push) Waiting to run
public/doxa-maps/index.html conflicted; generated, so resolved by regenerating. fix:sri reports all 113 hashes already in agreement.
2026-09-24 18:12:53 +00:00
doxa-maps-88655c70
3e0a8b49a3 Print the country posters in place, instead of saving them to the device
The regions poster wall was downloading a .webp. UPG-100 opens a print dialog
over the page, and the Driver asked for the same UX with no difference.

This is one prop, not a second implementation: PosterWall's posterAction
defaults to 'download', UPG-100 passes "print", and regions passed nothing.
deliverPoster() branches on it and routes both the shipped-.webp and the
browser-rasterised path through the shared openPrintFrame, so the two walls
now print through exactly the same code.

The probe that covered this had been asserting the wrong thing. Its own
header said the Driver asked for print, but the assertion accepted "delivers
a file or says why" and then checked the file was a .webp — which a download
satisfies. It now asserts nothing is downloaded and that the shared print
frame is created.

Catching that took two attempts, because the frame is transient by design:
print() returns immediately in headless, afterprint fires, and the frame
removes itself within milliseconds. Polling the DOM afterwards found nothing
and looked like a broken print path. Subscribing to frameattached before the
click observes it without racing the cleanup.
2026-09-24 18:11:23 +00:00
doxa-maps-88655c70
6e958bc22f legend: open on the UPG-100 list, which is what the Driver asked for first
Some checks are pending
build-gate / build (push) Waiting to run
Measured at the rendered layer: the legend opened on REGION at desktop and
mobile. That is the Driver's first complaint about this legend word for word —
"it shouldn't have me sitting there on Regions, I don't know what to do, it's
confusing" — and the card recorded that part as shipped.

Every source signal said it worked. LEGEND_DEFAULT_TAB = 1 existed, defaultTab
was a declared prop, it seeded a local ref, and it was named in a watch fallback.

The root cause is one layer down. SemanticTreeLegend resolves its tab as
`instance ? instance.activeTab.value : localActiveTab.value`, and
usePplrInstance initialises activeTab to ref(0). Zero is indistinguishable from
"the user chose tab 0", so for every profile sharing an instance store the
defaultTab prop was dead — read on every render, never able to win. An
activeTabTouched sentinel fixes it: the store only wins once something actually
chose a tab, meaning a tab click or a node selection that jumps to that node's
generation. A sentinel rather than a new default, so no other profile's landing
tab moves as a side effect.

And a regression of my own, from the previous commit. There was only one
:default-tab left in the profile and it was on <LegendMobile>, where defaultTab
is not a prop. Last tick I "dropped the inert attribute" with a string replace
that matched the DESKTOP block first, deleting the working one and leaving the
inert copy. A dropped attribute is silent, so the build stayed green. Restored
to SemanticTreeLegend, and LegendMobile now has a real defaultTab prop so the
phone honours it too.

Both widths open on "UPG-100 Lists", and on ?lang=es both open on "Listas
UPG-100" — locale-independent. A user's own choice still sticks: clicking Region
holds at 0, and selecting a row does not snap back, so the sentinel is not a
trap.

check:legend-export-reachable still passes and now selects a real zone rather
than a region, because the legend opens on the Lists tab. The two fixes compound:
a phone user lands on the lists and can export the one they pick.
2026-09-24 17:59:57 +00:00
doxa-maps-88655c70
4d79c08fd4 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 17:48:14 +00:00
doxa-maps-88655c70
cc6a5ea901 legend: a phone can now export the packet — it was a prop, not a design call
Some checks are pending
build-gate / build (push) Waiting to run
Last tick I found the export unreachable on mobile and recorded it as
Driver-gated: "WHERE the control goes on a phone is a design call, the sheet has
no titlebar". Both halves of that were wrong, and checking them was this tick.

LegendMobile RENDERS SemanticTreeLegend itself — titlebar and all — and the 0x0
elements my measurement found belonged to the OTHER, hidden desktop instance
mounted below the breakpoint. That geometry made it look as though the sheet had
nowhere to put the control. A Driver decision was never needed.

Two gaps, not one.

The sheet passed neither :export-enabled nor @export, and exportEnabled defaults
to false, so the control's v-if was never satisfied. Fixed via provide/inject,
matching onSemanticTreeSelect immediately above it, because the sheet renders
several levels down where an emit would not reach. Opt-in by construction:
enabled only when a profile provides a handler. The other three LegendMobile
consumers provide none and are verified untouched.

Even then the export did nothing, and that second gap is the one that mattered.
The profile passed :nodes but no :tabs, so hideTabs was true and the sheet showed
a single flat Region tree with no tab strip — no UPG-100 Lists tab, no selectable
zone, and onLegendExport bails on `if (!zoneId) return`. Passing :tabs fixed it.
A :default-tab attribute was added and then removed: defaultTab is not a prop on
LegendMobile, so it would have been inert.

Verified end to end at 390x844: the tab strip appears, a real zone selects,
Export opens, Packet clicks, and one print frame is produced. A packet on a
phone. The gate written last tick — which failed on mobile by design — now
passes at both widths. The newly visible mobile tabs are fully localized in
en/es/zh, so catalog work from earlier ticks now reaches a surface that never
showed tabs at all.

Four of my own probe errors were caught by measuring rather than concluding.
Clicking a REGION row left selectedKey empty, so the handler correctly bailed
and it read as "export does nothing" — the same zero on DESKTOP is what proved
it was the probe and not a regression.

country-posters shows the same mobile gap on its own surface. Not taken; its
owner's call.
2026-09-24 17:47:26 +00:00
doxa-maps-88655c70
bea4a4ff2d Rename the app-shell to Research Gallery
"DX-Maps SPA" named the technology; "Research Gallery" names what the
thing is to someone opening it — the Big Picture plus every research
map behind one vertical tab rail.

Display labels only: the profile folder, bundle name and custom-element
tag are untouched, since changing those breaks every existing embed.
The in-shell header moves with the other two — it is the most visible
label of the three, and leaving it reading "DX-Maps" would have made
the rename look half-finished to the only people who see it.
2026-09-24 17:46:35 +00:00
doxa-maps-88655c70
e1df0d5a7c Add Scripture Earth, and lift 28 groups off "nothing found"
SIL and its translation partners publish Scripture for small languages
the big catalogues skip, which is exactly the population left in the
gap: 2,901 groups had nothing in eBible, Arclight, Door43 or FCBH.
921 codes now carry a Scripture Earth link and 28 groups that had
nothing at all — 339,550 people — have Scripture in their own language
for the first time on this map. Tier 0 falls 2,901 to 2,873.

Most of this source is not what it appears to be, and the fetcher
throws away both false positives rather than shipping them. Their open
index lists 3,477 language directories, but 764 of the ones we can join
are empty stubs: trusting the listing alone would have handed 716
people groups a link to an empty folder. And a "video" directory holds
JESUSFilm-<iso>.txt metadata, not playable film — 1,588 codes have
nothing else, so video alone never qualifies a code. Only PDF, audio,
Scripture App Builder, study notes and SFM text count.

All 921 URLs verified HTTP 200 before shipping, and the artefacts were
opened by hand first: Baka's is 14.4 MB of application/pdf, Foodo's
40.7 MB, and a nonexistent sibling 404s, so a 200 here means a file.

The coverage gate, the verifier and the links table each had to be
taught the new field. Leaving any one of them ignorant would have been
worse than not adding the source: the gate caught all 28 tier changes
as inconsistent, and the links table filed 921 rows under the "other"
publisher — the fallback that exists to make an unscreened domain
impossible to hide. The sheet's "checked" line now names the pool too,
since it is searched.
2026-09-24 17:46:27 +00:00
doxa-maps-88655c70
1cfb0578d9 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/gospel-resources/charts/index.html
#	public/doxa-maps/gospel-resources/data-tables/index.html
#	public/doxa-maps/gospel-resources/gospel-dashboard/index.html
#	public/doxa-maps/gospel-resources/gospel-resources.js
#	public/doxa-maps/gospel-resources/resources-list/index.html
#	public/doxa-maps/gospel-resources/resources-map/index.html
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
2026-09-24 17:04:21 +00:00
doxa-maps-88655c70
4f5136211c Show the groups with nothing found and nobody praying
213 people groups have no Scripture, audio, film, Bible video or Door43
resource in any pool checked AND zero people praying on doxa.life's
counter — 4.67M people, largest Songhai (BFA, 259,000), Kuka (TCD,
250,000), Kimr (SDN, 221,000). That intersection is the sharpest thing
in the data and nothing surfaced it.

The gap sheet gains a fourth tab whose rows are links straight to each
group's prayer page, so the answer to "nobody is praying" is one tap,
not a search. The sheet says plainly that "nobody praying" means zero
on doxa.life's counter, not a claim nobody in the world prays for them.

Two fixes found while proving it: the tab is built from the lazily
fetched prayer index, so opening the gap list now loads that index —
without it the tab only ever appeared if you had clicked a pin first.
And the new branch opened its own v-if chain, which left the old
"largest groups" list rendering underneath it, 80 rows where 40 belong.
2026-09-24 16:54:14 +00:00
doxa-maps-88655c70
a53953423b Merge main and regenerate the served tree
Some checks are pending
build-gate / build (push) Waiting to run
public/doxa-maps/index.html conflicted; generated, so resolved by regenerating rather than hand-merging. fix:sri reports all 113 hashes already in agreement.
2026-09-24 16:46:46 +00:00
doxa-maps-88655c70
be33bac508 Carry the region colours onto the landmasses, in the chooser only
The pins already paint by region; the polygons underneath were flat brand
grey. They now take the same seven hues, so the chooser reads as seven
regions at a glance instead of one grey world with coloured dots.

The shared expression could not be reused as-is: it matches on a region
property that belongs to our own geojson, while the boundary tileset's
features carry iso_3166_1_alpha_3 and know nothing about WAGF regions. So
this bridges country code to region to the same palette — no second palette,
which is what the one-palette gate exists to prevent. Their fill is 0.12,
near-invisible, which is right for a single highlighted country; the chooser
is ours and carries 0.42 so the colours read. Cloned pages keep 0.12 and the
flat fill exactly, asserted both ways.
2026-09-24 16:44:45 +00:00
doxa-maps-88655c70
ee4b7ed4e9 Vendor Figtree in place of Brandon Grotesque, chosen by measurement
Brandon Grotesque is commercial and must not ship in a public giveaway, so
the two rules that named it now ask for Figtree. It is vendored the way the
other three families are — woff2 per subset under public/assets/fonts, an
OFL licence beside it, registered at document level because @font-face is
document-scoped.

The choice is measured, not a matter of taste. Their live page really does
load Brandon_Grotesque_medium.woff2, and Brandon sets "FULL PROFILE" at
128.00px at 500/19.5312px. Nineteen libre grotesques were measured the same
way: Figtree is 126, Nunito Sans 130, Barlow 125, Inter 134, Archivo 135,
Poppins 119, Montserrat 148. Figtree is the closest at two pixels.

Worth recording because the old scorecard has it backwards: 134.33 is the
generic sans fallback, not Brandon. The number to match was 128 all along.
2026-09-24 16:44:44 +00:00
doxa-maps-88655c70
7fd8e4545c Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 16:33:20 +00:00
doxa-maps-88655c70
9bc2b5ea48 Gospel resources: MapLibre was dropping every pin on the map
check:maplibre-drops went red on gospel-resources/resources-map and the new
gospel-dashboard leaf: layers.gr-pins.paint.circle-sort-key: unknown property.

circle-sort-key is a LAYOUT property. Mapbox tolerates it in `paint`; MapLibre
rejects the whole layer for one misplaced property and does NOT throw — so under
the keyless engine all 12,379 dots were absent while the page still rendered its
basemap, its choropleth and its legend, and looked fine. That is the same defect
that cost all-people-groups its pins, so the fix is the same: move the key into
`layout` and leave the z-order comment with it, since the reason (a group with
nothing found must win the z-fight) is about the sort, not about paint.

MEASURED after, both engines: gr-pins present, circle-sort-key in layout, pins
drawn. Screenshot read on maplibre — dots and choropleth both there.
check:maplibre-drops PASS 17/17 (it was 15 ok / 2 dropping).

The gate earned its keep here: this arrived with incoming work, not with mine,
and nothing in the page's own appearance would have told anyone.
2026-09-24 16:33:11 +00:00
doxa-maps-88655c70
a9b717c246 Merge main and regenerate the served tree
Some checks are pending
build-gate / build (push) Waiting to run
public/doxa-maps/index.html conflicted again; it is generated, so it was resolved by regenerating the pipeline rather than hand-merging. fix:sri reports all 113 hashes already in agreement.
2026-09-24 16:24:42 +00:00
doxa-maps-88655c70
487e3c9aa2 Give the regions map the shared scroll-past edge strips
The Driver asked for the affordance every other map here already has. The
flag alone was not enough: useEdgeScrollAffordance does not create the strips,
it scans the wrapper for them and returns a no-op when it finds none, so
flipping showEdgeBorders left the page with zero strips. The markup and its
CSS are per-consumer in all five existing users; only the behaviour is
shared. Both are ported from upg-100-list rather than written fresh —
including touch-action:pan-y, which is the mechanism that lets a vertical
drag scroll the page while Mapbox keeps the centre.

The reason the flag was originally false is kept in the file rather than
deleted, because it is the thing to re-examine if the result looks wrong:
this map sits inside a scrolling dashboard, where the gutters can read as
page chrome rather than map chrome. The reference maps own their viewport.
2026-09-24 16:23:11 +00:00
doxa-maps-88655c70
22712eb43a The map said "someone still needs prayer" and gave you no way to pray
Its toast has always read "Tap a coloured dot — someone still needs prayer". Tapping one
showed Scripture, film, audio, video, stories, tracts and training, and no way to pray.
doxa.life publishes a page per people group and sibling plugins in this repo already link
to it; this map simply never did. 1,723 groups now carry a "Pray for <group>" action with
the number already praying beside it.

THIS IS THE ONE JOIN HERE THAT IS NOT BY LANGUAGE, and it needed justifying rather than
assuming. Every other source keys on ISO 639-3 because a translation serves everyone who
speaks the language; prayer does not work that way — you pray for a people, in a place. So
this keys on (group name, country), which is a NAME join, the thing this map refuses
everywhere else because names collide.

Allowed only because the key was tested first: on the API side (name, country) is
perfectly unique — 2,107 entries, 2,107 distinct keys, 0 collisions — and on the pin side
12,379 pins give 12,369 distinct keys. The ambiguous ones are DROPPED, not guessed. One
entry was lost that way, and 383 more have no pin here at all.

pray.doxa.life is the fourth host in this bundle that answers 200 with identical bytes for
any URL: /abai-sungai and /zzz-not-a-real-group are both 3,903 bytes. Rendered it does
discriminate — a real slug titles the page "Abai Sungai - App" with 1,863 characters, a
bogus one titles it "Doxa.Life" with zero — but existence comes from the API listing,
which is the publisher's own statement and costs one request rather than 1,723 renders.

The lookup is COMPUTED, not baked into groupAt. groupAt runs the instant a pin is clicked,
so a value read there is whatever had arrived by then and never updates when the lazy
fetch lands — the same shape as the link-verification state that used to hang off
openPin's country branch.

Verified with a negative control: a group in the index renders an anchor whose href
matches the expected slug URL exactly, and German, which is not in it, renders nothing.
My probe reported FAIL twice against correct code by waiting a guessed 900ms; it now waits
for the data. Screenshot read — the Lati, 2,900 people with nothing found in any pool, can
now be prayed for.
2026-09-24 16:06:01 +00:00
doxa-maps-88655c70
f89e5eb370 The bridge table was recommending bridges on stale resource flags
languages-by-country is the bridge view: one row per country and language, ranked by
prevalence, flagging which languages could carry a resource to groups that lack one. Its
curated work — prevalence_rank, bridge_candidate, groups_speaking — is the reason it
exists and is untouched.

Its resource flags were not curated, and they had drifted like the other two legacy
tables: 75 rows wrong about Scripture, 39 wrong about whether the language carries
anything at all, 1 about film. That matters more here than elsewhere, because this table
is consulted to CHOOSE a bridge: a language flagged as carrying nothing when it carries
audio Scripture is a bridge somebody was told not to use.

Refreshed from the pin index, which is the same source the map draws. 0 of 613 rows now
disagree, and all 613 still carry their curated columns.

full_bible is deliberately NOT refreshed. The pin index knows a scope only for eBible
rows, so downgrading a curated "full Bible" on that basis would lose information rather
than correct it.

Also recorded rather than acted on: this table covers 175 countries while the map has
242 with pins, so 67 countries and 1,537 people groups have no bridge analysis at all.
That is a gap in curated coverage, not a contradiction — the rows that exist are now
correct — and filling it means doing the prevalence work, not copying numbers across.
2026-09-24 15:55:58 +00:00
doxa-maps-88655c70
ffa49709ac The gap by country is now a table you can export, not just a sheet you can read
gospel-by-country carries 242 countries with real per-country counts, and the map uses
them for the choropleth and the "where the gap concentrates" sheet. The tables profile
had no country table at all, so those numbers lived only inside the map — readable, not
exportable — while every other tab there offers CSV and JSON. A mobiliser deciding where
to send a team cannot work from a sheet they have to screenshot.

Derived, not a second source: every row is read out of gospel-by-country, which is itself
refreshed from the pin index, so the table and the map cannot disagree.

Three columns because they rank differently and each is somebody's question — groups with
nothing, people with nothing, and the share of the country each represents. The default
sort is by groups, and the share column is what makes rows like Vanuatu legible: 79 groups
with nothing out of 114, 69.3%, which ranks ninth by count and first by share. Region is
carried through so the table can be grouped the same way the map's region tab ranks.

Curated columns come through as-is and stay null where the curated analysis does not cover
the country — null, never zero, because zero is a claim this build cannot make.

Verified against the PIN INDEX rather than against the table being tested: 242 countries
with pins, 2,901 groups with nothing, worst is PNG at 469. The rendered tab reports 242
rows and its first row reads Papua New Guinea / PNG / Oceania / 831 / 469 / 56.4, with
650 real cells. No page errors. Screenshot read.
2026-09-24 15:43:13 +00:00
doxa-maps-88655c70
04c8a816d0 Oceania is 51% unreached by group, and nothing on this map said so
region was empty on all 242 country rows, so the map had no way to answer "what share of
this part of the world is unreached" — only how many groups a country holds and how many
people live in them.

The source carries UNm49Region, and it was verified before being trusted: only 2, 9, 19,
142 and 150 appear — exactly the five UN M49 top-level regions — every country maps to
exactly ONE of them with 0 conflicts, and the five sum to 12,379, the whole corpus. So
the code resolves to a real name. It is backfilled on every row, not just the new ones,
and all 242 now carry a region with none blank.

The rate is a genuinely different answer from the two already on offer:

  Oceania   51.2% of its 1,325 groups have nothing —   679 groups —   3.1M people
  Africa    30.0% of 3,074                         —   923         —  68.2M
  Asia      19.3% of 5,073                         —   978         — 198.7M
  Americas  11.4% of 2,117                         —   241         —   6.1M
  Europe    10.1% of   790                         —    80         —  11.6M

Oceania is half unreached by group and the smallest population of the five; Asia is the
reverse on both. Ranked by rate, because count and population are what the other two tabs
already say and a third copy of either would be noise. All three are offered rather than
one picked for the reader.

The rows are plain divs, not buttons: a region is not somewhere this map can fly to, and
a control that looks clickable and does nothing is worse than a row that does not pretend.

Verified against an offline computation over the same index: all five regions match
exactly on rate, group count and population. The sheet header is now three-way rather
than showing the country prose in region mode. All five profiles still mount clean.
2026-09-24 15:39:50 +00:00
doxa-maps-88655c70
6062b080f7 60 countries carrying pins had no country row at all
Set out to verify the map's second mode, "Resources found", which had never been tested.
It works: the legend switches, the arithmetic is an exact complement of the gap view — 0
of 170 countries disagreed — and the two modes genuinely repaint, 98/46/12/6 polygons by
band in gap against 85/66/9/2 in coverage.

Proving that took three attempts and the third one found the real defect. Comparing the
fill expression proved nothing, because it is ["get","color"] in BOTH modes — the colour
is baked onto each feature and switching modes rewrites the source data. Comparing the
features instead showed 157 coloured and 5 transparent against 170 rows in the table,
and that mismatch was the thread worth pulling.

The previous refresh of this table only UPDATED rows that already existed, and the table
came from the curated 2,106-record set. So 60 countries carrying pins had no row at all —
150 people groups with nothing found, invisible to every country-level surface. Canada
20, Ghana 15, Timor-Leste 13, Australia 7. 72 countries are now added from the pins, and
0 countries with bare groups are missing; all 242 rows agree with the pins on groups and
no_resource.

Curated columns on the new rows are null, never zero: this build does not know a growth
rate for Canada, and zero is a claim. They are flagged from_pins_only so the different
basis is visible rather than implied. Names come from people-groups-slim's CountryName,
because a row labelled "CAN" renders "CAN" as the sheet's title. UNm49Region was
deliberately NOT used for region — it is a numeric code, and "19" is worse than blank.
One row stays a bare code: Kosovo, whose non-standard KOS has no name in the source, and
inventing one for a single row is not worth it.

The boundary file turns out to hold only 162 polygons and has no Canada, Australia,
Timor-Leste or Taiwan, so this paints five previously-transparent countries and fixes the
rest where it actually shows: clicking a pin in Canada now opens a sheet titled "Canada"
with 304 groups, matching the pins exactly, where before it fell through to a bare ISO
code with no statistics.
2026-09-24 15:39:49 +00:00
doxa-maps-88655c70
a3780fc06a Three buttons said "Show all" in the same composite and meant different things
Checked whether a reader can undo the dashboard filter and tell why the table changed.
They can: the banner names the selection and offers an escape, clearing works, and
re-selecting the same country refilters rather than sitting cleared — the filter object
carries a timestamp precisely so an identical selection is still a change.

The ambiguity is in the labels. Stacked under the map there were THREE controls reading
exactly "Show all": two in the map's group lists, which reveal the rest of a country's
people groups, and one under the table, which clears the resource filter. Nothing on
screen distinguished them.

That is not a theoretical complaint. The first probe written to verify this clicked one
of the map's, saw the filter unchanged, and reported "Show all does not clear the filter
in composed mode" — a defect that did not exist. If the ambiguity can mislead a probe
reading the DOM, it can mislead a reader looking at the screen.

The map's now read "Show all groups", the table's "Show all resources". One word each.

Verified after the change: the three candidates are distinguishable by text alone, and
the full cycle passes — filter to 400 of 6,498 for Indonesia, clear back to no banner,
re-select the same country and return to 400, no page errors. Screenshot read.

Fourth probe in this flight to produce a wrong verdict from an assumption about the DOM,
and the second where a composite made a pattern ambiguous that is unambiguous on a
single page.
2026-09-24 15:39:49 +00:00
doxa-maps-88655c70
1a410ff8ef Country click now filters the dashboard's tables too, instead of leaving the page
The language link was fixed last change; the country link had the identical defect and
answers the brief's SECOND requirement. "Every resource used in Indonesia" was an
<a href="../data-tables/index.html?tab=links&country=IDN">, so inside the composite,
clicking a country to see its resources navigated away from the composite to do it.

Both controls now split the same way: a button that filters the tables below when they
are on screen, an anchor when they are not.

Verified against an offline expectation rather than read off the page. Counting the
links table for rows whose country_codes contain IDN gives 400. In the dashboard the
control is a BUTTON, the URL stays on gospel-dashboard/index.html, and the banner reads
"Showing 400 of 6,498 resources for Indonesia" — the same 400. Standalone it is still an
<a> to data-tables/index.html?tab=links&country=IDN&label=Indonesia, so nothing changed
for the map on its own page. No page errors on either.

Screenshot read: the filtered rows are real Indonesian-language resources across five
sources — JESUS Film, Faith Comes By Hearing audio and Bible video, Cru's Four Spiritual
Laws, eBible and Zume — with a "Show all" escape beside the count.
2026-09-24 15:39:49 +00:00
doxa-maps-88655c70
c63d92542c In the dashboard, "every resource in this language" now filters the tables below it
The composite puts the map above its own tables, and the brief's third requirement is
that clicking a pin filters that table. It did not. "Every resource in German, Standard"
was an <a href="../data-tables/index.html?...">, which is exactly right when the map is
alone — and inside the dashboard it navigated AWAY, to the standalone tables page. The
filter applied correctly, 6 of 6,498, and the map was gone. Measured: the URL changed
from gospel-dashboard/index.html to data-tables/index.html.

The two halves now talk to each other. The dashboard provides a shared filter; the map
injects it and, when present, turns that link into a button that sets the filter and
scrolls the tables into view; the tables inject it and apply it in place, switching to
the links tab because a language filter means nothing on the others.

PROVIDED, NOT PROPPED, because both children must still work standalone. A child that
finds nothing injected keeps its old behaviour exactly — verified rather than assumed:
on resources-map/index.html the control is still an <a> pointing at
data-tables/index.html?tab=links&iso=deu.

Composed: the control is a BUTTON, the URL stays on gospel-dashboard/index.html, the
table banner reads "Showing 6 of 6,498 resources for German, Standard", and 6 rows with
66 cells render. Arrow changed from → to ↓ because the destination is now down the page
rather than away from it.

My first probe read "12 of 25" here and was wrong. Both halves share ONE shadow root in
the composite, so the map's own "Showing 8 of 25" matched the same pattern as the
table's banner. The probe now matches the table's specific wording and asserts rendered
rows and cells beside it. Same class of mistake as the three already recorded in
AGENT-BRIEF — a pattern that is unambiguous on one page can be ambiguous in a composite.

All five profiles still mount clean; gospel-dashboard 68,459 chars, 54 links, 0 errors.
2026-09-24 15:39:48 +00:00
doxa-maps-88655c70
0c64fdd856 A caveat naming three pools sat directly under rows from eight
The dashboard is the one profile never opened on a phone. Structurally it holds up at
390x844 — no horizontal overflow anywhere in either shadow tree, canvas 390x675, the
table half renders 20 real rows and 60 cells, no page errors. What does not hold up is
what it says.

Under the table, the Pools Caveat read "Pools checked: eBible.org, JESUS Film (Arclight),
Door43/unfoldingWord". On the same screen, visible immediately above it, were rows typed
Audio Scripture, Bible video, Zume Training and Gospel presentation — four pools the
caveat denied having checked. It is the most direct version of a mistake this bundle has
now hit repeatedly: a sentence written by hand goes stale the moment a source is added,
and this one had gone stale four times over.

It is no longer written by hand. The checked list is now counted off the rows themselves,
so it cannot disagree with the table it sits under: "Cru (GodTools), Door43
(unfoldingWord), Faith Comes By Hearing, JESUS Film, PNG.Bible, Zume Training,
eBible.org, eBible.org (mirror)". The NOT-checked half stays prose, because it names
things that are absent and no row can testify to those — GRN, api.bible, the Digital
Bible Library and Progress Bible, each needing a credential or an agreement.

The page subtitle had the same shape of error, offering "scripture, film or audio" over
tables carrying seven media. It now names the question rather than a list that will go
stale again.

Verified on the phone viewport after the change: table half still 20 rows / 60 cells, no
overflow, no page errors, and the rendered caveat lists all eight sources. Screenshot
read.
2026-09-24 15:39:48 +00:00
doxa-maps-88655c70
4f202cf8fd Merge main and regenerate the served tree
public/doxa-maps/index.html conflicted again. It is generated, so it was
resolved by regenerating rather than by hand-merging two machine-written
files: build the bundle, rebuild the tree, re-hash. fix:sri then reports all
113 hashes already in agreement, which is the evidence the tree is coherent
rather than merely conflict-free.
2026-09-24 14:37:48 +00:00
doxa-maps-88655c70
9a05d5218e Make Back retrace the whole chain, and Forward work at all
Drilling All Regions to Africa to Angola and then pressing Back: the first
press returned to Africa, the second stayed on Africa, and Forward never did
anything. history.length sat at 4 the whole time, which is the tell — every
Back was pushing a replacement entry, discarding the forward stack and
pinning the position.

The guard against that could never have worked. onPopState set a flag,
assigned the selection and cleared the flag synchronously, while the watcher
it guards is flush:'post' and runs after the DOM update — by which time the
flag was already false. So the flag is gone rather than patched: writeUrl now
compares the URL it would write against the one the address bar already
holds and returns early when they match. After a popstate there is nothing to
write, so no entry is created.

A one-step check could not have caught this. The existing assertion picks a
region, clears it, and presses Back once — and one step is exactly the depth
at which the bug still looks correct.
2026-09-24 14:34:10 +00:00
doxa-maps-88655c70
9484a98bbd On a phone the gap workflow was hiding behind a label that said "notes"
Some checks are pending
build-gate / build (push) Waiting to run
The pin-hint fix covered the group sheet; the other two full-height sheets had not been
exercised. They are fine — the country sheet and both gap views hide the hint, fit 390px
with no horizontal overflow, and throw no page errors.

Getting that answer took two attempts, and the first attempt is the finding. The probe
clicked the pin filter and the "see the largest" button, got three states back, and
passed. All three were the SAME state: the country sheet, three times. Nothing had been
clicked, because at phone width those controls are inside the collapsed legend and are
not reachable until it is opened.

Which is the real defect. The panel is labelled "Key & notes", but it also holds the pin
FILTERS — deliberately, because the key doubles as the filter, and the component says so:
"a reader who has just understood what a colour means is exactly the reader who wants to
see only that colour". On a phone that design puts the entire gap workflow behind a
label advertising documentation. Reaching the ranked gap list means opening "notes"
first, and a reader with no reason to open notes never finds it.

It now reads "Key & filters" / "Hide key & filters".

Verified after the fix with the legend actually opened: the three sheets are now three
distinct states — "Indonesia...", "Largest groups with nothing found...", "Where the gap
concentrates..." — each with the hint hidden, docWidth equal to viewport width, no
overflowing child and no page errors. Screenshot read at 390px.
2026-09-24 14:28:39 +00:00
doxa-maps-88655c70
4565ecfec1 Merge bundler-3's newest two commits; regenerate the touched bundle and tree 2026-09-24 14:24:20 +00:00
doxa-maps-88655c70
9dea551586 On a phone, the pin hint was sitting on top of the sheet it invited you to open
Seven chip types and two long notes have been added to the group sheet this flight and
none of it had been looked at on a phone. At 390x844 the layout itself holds up — no
horizontal overflow, chips wrap, the sheet scrolls its 1,338px of content in a 559px
window. What does not hold up is the shared pin hint.

"Tap a coloured dot — someone still needs prayer" is pinned near the bottom of the map,
which on a phone is exactly where the bottom sheet is. Measured: it covered "Also speak
German, Standard (25)" and the first group row under it. Once a sheet is open the
invitation is also redundant — the reader has already tapped a dot.

Hidden while a sheet is open, at phone widths only, and scoped to this bundle. NOT
out-stacked: the hint is a library component with z-index 2147482000 and a deliberate
"only the reader closes it" rule, which is the right behaviour for a bare map, and
raising this sheet above that number would be a z-index arms race with shared code that
every other map would inherit. The hint renders inside this component's own shadow root,
so a media-query rule here costs nothing and changes nothing for anyone else. Desktop is
untouched — there the sheet is a 330px corner card and the two never collide.

Verified in both directions rather than one: with the sheet open the hint computes to
hidden, and after pressing Close it is visible again. Screenshot read at 390px — the
"Also speak" heading and its rows are now legible.
2026-09-24 14:20:10 +00:00
doxa-maps-88655c70
445131cf94 Merge main into bundler-3/work — settle the 21-file conflict, keep both campaigns
Only 2 files conflicted in source: package.json and its CLI doc, both taken to
main's refactored gate pipeline (gates:pre/gates:post/build:bundles) after
verifying every check the old inline chain named still runs there. The other 19
were generated: public/assets/tables mirrors keep main's deliberate deletion,
and public/doxa-maps is regenerated by the build that follows.
2026-09-24 14:16:47 +00:00
doxa-maps-88655c70
38f8d8b1d6 The charts were drawing four of the seven resource types
A health check across all five profiles found them all mounting cleanly, and then found
the charts telling a much smaller story than the data holds.

Bible video reaches 1,449 of the 5,067 languages here — it would be the third-largest
bar on the page — and was not drawn at all. Neither were gospel presentations (85) nor
discipleship training (51). A reader taking away "what exists, by resource type" was
missing three of the seven types, including one larger than Open Bible Stories, audio
and Full Bible combined.

"Scripture (any)" also read only eBible's field, counting 1,237 languages where 1,729
have a text Scripture. Faith Comes By Hearing publishes Bible text, and the list profile
had already been corrected for exactly this undercount; the chart had not.

Two caveats had gone stale in the way this bundle keeps finding. The hero said "four
resource pools, including all audio, were never queried" — audio has been searched at
FCBH since. And the pools footer unioned two different provenance vocabularies into
nonsense: "arclight (14 sign languages, all captured), fcbh (none), ebible, arclight,
door43, fcbh", the sign-language sentence spliced into the general list with arclight and
fcbh appearing twice. Sign languages carry a different provenance because text and audio
do not reach a Deaf group, so the two are now reported as the separate claims they are.

MY FIRST PROBE FAILED AGAINST A CORRECT CHART. It compared the bars to counts over all
5,563 catalogue codes, but the chart counts languages SPOKEN BY A PIN — 5,067 of them.
The chart was right and the expectation was wrong. Corrected, all four bars match
exactly: Scripture 1,729, Bible video 1,449, gospel presentation 85, training 51. The
denominator is part of the claim, and a probe that gets it wrong invents a defect.

All five profiles verified mounting with no page errors. Screenshot read.
2026-09-24 14:09:28 +00:00
doxa-maps-88655c70
a4d8363179 Where the gap concentrates: the same 2,901 groups, ranked by country
The ranked gap list answered "which single group is largest". A mobiliser also asks
"where would a team go", and that answer concentrates hard: the top 10 countries hold
56.6% of all 2,901 groups with nothing, and Papua New Guinea alone holds 469 of them.

The gap sheet now switches between the two. Country rows show groups AND people, because
the two rankings disagree and the disagreement is the useful part — Papua New Guinea has
469 groups with nothing and 2.2M people; China has 125 groups and 150.3M. Which number
matters depends on whether you are sending translators or broadcasters, and the sheet
says so rather than picking for the reader.

Verified against an offline count from the same index: the rendered first row reads
"Papua New Guinea 469 groups with nothing · 2,187,150 people", matching exactly, with the
By country tab active.

Two sources researched this tick and both recorded as rejected rather than shipped:

WordProject is real and permitted — robots.txt allows everything except /database/ and
/scripts/, and it lists 78 Bible languages. But only 7 of its directory codes are
literally an ISO 639-3 this map carries, ZERO of those reach a group with nothing, and
matching the other 71 would mean joining on the English language name, which this map
refuses everywhere else because names collide across unrelated languages. Taking that
risk to duplicate Scripture links that already exist is a bad trade.

GodTools, added last change, reaches 0 tier-0 groups of its 105 ISO-joined languages.

Both are depth for languages already served. The remaining 2,901 groups are not short of
a source nobody has looked at — GRN, YouVersion, api.bible, DBL, Deaf Bible Society and
DOOR all need a credential or an agreement, and everything open has now been mined.
2026-09-24 13:58:42 +00:00
doxa-maps-88655c70
e0fd61eb3e Gospel presentations in 94 languages, from Cru's API rather than its parked domain
Every pool here so far is Scripture, film, Bible video or a course. None is a GOSPEL
PRESENTATION — the short translated thing a person actually opens with someone. Cru
publishes those, in the same organisation that makes the JESUS Film, so the doctrine
question is settled the same way the brief settles it for the film. Only their gospel
tracts are included, from a fixed allow-list; the lessons and survey tools are not.

IT CLOSES NO GAP AND SAYS SO. All 94 of these languages already have something, so this
is depth rather than coverage and moves no tier. Tiers are byte-identical at
2901/4767/4711. It reaches 3,270 pins and 3.97bn people.

godtools.org, the obvious host, IS A PARKED DOMAIN. It answers 200 to everything with a
114-byte body, and rendered, every URL — including /zz/nosuchtool-xyzzy — reads
"godtools.org is parked free, courtesy of GoDaddy.com". Shipping links there would have
sent people to a GoDaddy ad.

The real front-end is knowgod.com, and it does serve per-language tracts: /en/kgp reads
"Knowing God Personally", /sw/kgp reads "Kumjua Mungu Kibinafsi". But ONLY once
rendered. By fetch it is a uniform 3,864-byte shell for every URL valid or not — the
third host in this bundle with that shape, after jesusfilm's old ?languageId= and
live.bible.is.

So existence is proved from Cru's own API and never from the page: a (language, tool)
pair exists exactly when the resource's latest-translations intersect that language's
translations. Checked against the browser before being trusted — en/kgp and sw/kgp both
intersect and both render real localised text, and the bogus pair does not intersect. In
band, the verifier's controls are now 7 negative all non-verified and 7 positive all
verified, with a bogus knowgod pair among the negatives. All 94 URLs verified.

Clause I keeps its closed set: Gospel presentation only on knowgod.com, and knowgod.com
only that type. Poison self-test still 12/12. Screenshot read: German shows six distinct
resource types, including "Gospel presentation - Knowing God Personally".
2026-09-24 13:51:56 +00:00
doxa-maps-88655c70
5000355237 Reference the chevron once, the way their markup references it
Their chevron is a single <use href="/assets/icons/arrow-chevron.svg#chevron-up">.
We had copied its path into three call sites and never vendored the file.

I assumed the platform forced that, and it does not: an external
<use href="file.svg#id"> renders in Chromium, renders inside a shadow root,
and a local <symbol> plus <use href="#id"> renders too — 410x234 in all
three. What is forced is the external href, not the mechanism. A dropped
bundle resolves assets against whatever tree it sits in and the tree
generator stages an allowlist with no icons directory, so an external
reference would 404 in silence and the chevron would simply be absent. That
is the same reason worldmap.svg arrives through a prop. So: their path,
verbatim, as one <symbol>, referenced the way they reference it.

The first attempt blanked all 21 chevrons — the regex rewrote the path inside
the sprite it had just inserted, so the symbol referenced itself. Worth
recording how that was caught, because the CSS fingerprint called the broken
page identical: a <use> resolving nothing keeps exactly the same computed
styles. Geometry caught it, and so did looking at the page. The fingerprint
now says so in its own header, along with the fact that its keys are
ordinal-sensitive for unclassed elements.
2026-09-24 13:48:54 +00:00