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.
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.
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.
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.
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.
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.
- 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.
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.
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.
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.
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.
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.
"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.
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.
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.
public/doxa-maps/index.html conflicted; generated, so resolved by regenerating rather than hand-merging. fix:sri reports all 113 hashes already in agreement.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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".
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.
gospel-by-country supplies band and no_resource, which fill every country polygon on the
map. Those were computed on 2026-08-01 over the curated 2,106-record set. The pins drawn
ON TOP of that fill come from the 12,379-pin index. The two disagreed for 135 of 170
countries, and the worst cases are not close:
PNG coloured "critical" from ONE curated group, under 831 pins (56.4% bare -> high)
USA coloured "covered" from three groups, under 623 pins (7.1% bare -> partial)
PHL coloured "covered" from one group, under 156 pins (17.9% bare -> partial)
IDN coloured "critical" from 36 groups, under 669 pins (48.0% -> high)
A reader sees the colour first and the dots second. Telling them a country is covered
while plotting bare groups across it, or critical on the evidence of a single group, is
the map arguing with itself - and the fill is the louder half.
groups, no_resource, pop_no_resource, scripture, film, pct_no_resource and band are now
counted from the pins, under the same band rule as before (critical >=60%, high >=30%,
partial >0, covered 0). growing_unreached, deaths_day and growth are NOT derivable from
the pins - they come from the curated country analysis over a narrower set, the country
sheet already labels them as a different basis, and they are left exactly as they were.
Verified: 0 of 170 countries still disagree with the pins on groups, no_resource or
band. Bands are now 8 critical / 52 high / 100 partial / 10 covered.
The legend was making a claim that stopped being true two changes ago: "Audio is
unchecked for every group here". Faith Comes By Hearing has been searched since. It now
names all five pools that were checked and isolates GRN as the one that cannot be -
403 on every request including its own robots.txt - which is the honest version of the
same warning.
gaps-and-opportunities and people-group-resources predate everything added this flight.
Their curated columns — the bridge-language analysis, internet access, messaging
channels — are the reason they exist and are untouched. Their resource booleans had
drifted into saying things the map disproves:
80 rows heart_has_any=false while the index has a resource (6M people)
317 rows "no scripture" while the index has TEXT Scripture
277 rows the same, for the bridge language
5 rows "no film" while the index has one
2,106 rows has_audio = the string "unknown", written when no audio pool had been
searched. Faith Comes By Hearing has been searched since.
Only the facts the pin index is authoritative for are rewritten. Rebuilding these tables
would have destroyed curated work to fix a few columns.
I GOT THE DERIVED FIELDS WRONG FIRST AND CAUGHT IT BEFORE COMMITTING. `gap` reads
naturally as "no resource in the heart language", and defining it that way changed 416
rows. The table's own meta defines it as "nothing found in the heart language AND no
in-country bridge language carries anything" — my version would have silently discarded
half its meaning while looking like a correction. Every formula is now taken from the
table's documented meaning and was verified to reproduce the existing column on all
2,106 rows BEFORE anything was rewritten: gap, opportunity (which also requires the
bridge to be spoken by more than one group in the country), aligned, and status. With
the right formula, gap moves 12 rows, not 416.
has_audio stays THREE-STATE and never false: true where FCBH publishes audio, "unknown"
otherwise. GRN is the main audio pool and answers 403 to everything, so "no audio
exists" is a claim this build cannot make. 1,297 true, 809 unknown.
Verified after the fact, not assumed: zero remaining contradictions between either
table and the pin index, and all four derived formulas still reproduce their columns
2,106/2,106. Both tables written through the snapshot guard. Tables tab still renders
real cells.
The three regions components carried 119 inlined values — 23.5264px,
19.5312px and sixteen others — with nothing to tell a reader that those two
are --spacing-lg and --font-size-md on the site being cloned. They now say so.
The values are unchanged. Live declares them in rem against a 10px root, but
rem resolves against the document root even inside a shadow root, and these
components render inside whatever page embeds them, so rem would scale every
space by 1.6 against a default 16px host. That is why they were resolved to
px originally, and the tokens stay px. Nor are they re-derived from live's
fuller precision: naming numbers and changing numbers are separate changes,
and keeping them separate is what makes this one provably inert.
They live in the plugin folder rather than library/styles because the count
says so — 121 occurrences here, none in any other plugin, none in library —
and shared placement wants a second consumer. The file says to move it when
one arrives.
Verified by fingerprinting every element the bundle renders: four pages at two
widths, 2,650 elements, 27 computed properties each, baseline taken from the
previous source. Zero differences, nothing gone, nothing new.
Two tables behind the brief's resources view were describing a different dataset from
the map beside them.
"Where Nothing Was Found" still listed 3,457 groups. The map draws 2,901. Its builder
already used the current tier; it simply had not been re-run since audio Scripture and
Bible video started lifting groups out of tier 0. A mobiliser exporting that tab would
have worked through 556 groups who now have a resource. Regenerated, and it matches the
map exactly.
"The Deaf Gap" was worse, and structurally so. It came from the curated 2,106-record
set and reported 73 sign languages, 120 people groups, 19.35M people. The map draws
150 sign languages, 234 groups, 84.2M people. The tab named for the Deaf gap was
missing more than half of it. Rebuilt from the pins - the same index the map draws, so
the two cannot disagree - and it now measures 150 languages, 234 groups, 84,159,050
people, of which 136 languages / 183 groups / 55,199,840 people have nothing found.
Kept deliberately: the bridge rule, verbatim, because it is the most important sentence
in the table - a sign language is bridged ONLY by another sign language, and text or
audio in a spoken language does not reach a Deaf community. That is also why the
related-variety reasoning added for spoken macrolanguages must never be applied to
these rows. Private-use codes are counted rather than hidden: 40 of these languages
carry qaa-qtz codes that no resource pool indexes, so "nothing found" for them is a
statement about the join as much as about the world, and each such row says so.
MY FIRST VERSION OF THIS SHIPPED AN EMPTY TABLE AND MY PROBE PASSED IT. DataTable
renders a { meta, columns, rows } document and the rebuild omitted `columns`, so the
tab drew its header, its "234 rows" caption and five pages of pagination over a
completely empty body. The probe had asserted the caption - a number the page prints
about itself, which is not evidence it drew anything. It now counts rendered rows AND
cells, and is proven against the defect: with `columns` removed it reports 50 row
shells and 0 cells and FAILS; restored, it passes. The row count alone would still have
missed it.
Verified: both tabs' captions match offline counts from the same index (2,901 and 234),
both render real cells, no page errors. Screenshot read.