Commit graph

503 commits

Author SHA1 Message Date
doxa-maps-88655c70
2ed595c0f8 Zume Training: 52 languages of discipleship course, joined to 3,479 pins
The brief names zume.training every tick and an earlier pass recorded the list as
unobtainable. That was wrong. robots.txt Disallows /api/ and the homepage switcher
is client-rendered, both true, but robots.txt also DECLARES a sitemap, and the
publisher maintains /articles/zume-languages for exactly this purpose. Neither is
behind a Disallow. The real figure is 62 locales, not the ~40 estimated: 54 serve a
landing page that answers 200.

The two sources disagree in both directions - Dari and Pashto ship sites but are
missing from the article's table, and eight table rows serve no page yet - so
fetch-zume-index.mjs takes the union and then fetches every landing URL. A locale
ships a link only if its own page answered. Naming and linking stay separate facts.

Pashto is deliberately left unjoined: pus is a macrolanguage, the people-group data
carries pbu, pbt and pst, and nothing the publisher gives selects one.

Training is not Scripture and not film, so it sits in its own field and its own type
and moves NO tier. Measured: the 52 joined languages reach 3,479 pins and 4.78bn
people, and none of them are tier 0 - every Zume language already has Scripture or
film. Folding training into the tiers would have cut the gap count without anyone
having translated anything. The gap filter still cuts 12,379 features to 3,457.

Both gates had a hole this exposed. The coverage gate harvested URLs from s, f and d
only, so the 52 new URLs existed in no pin record it could see; and clause I checked
Film, Scripture and Bible Stories by host but let any other type fall through
unchecked. Both now cover training, and a poisoned row typed Scripture on
zume.training is caught. The browser gate screened 3,550 links and silently ignored
the new ones; it now screens all 3,602.

Verified in a browser with a negative control: deu opens with a chip linking
https://zume.training/de, kri opens with "not in Zume's 54" and no href, no page
errors.
2026-09-24 09:31:13 +00:00
doxa-maps-88655c70
22a216a207 Put the list on the same index as everything else, and shrink the bundle 60%
resources-list was the last surface still reading the old 862-language file, and the only
one that BAKED its data in: `import data from './data/resources.json'`, 695KB of JSON
compiled into the shipped JS. Every other profile in this bundle carried that weight,
because they all share one .js — the map paid for the list's dataset on every load.

Fetched at runtime from the pin index instead: 5,067 languages rather than 862, and the
bundle drops 798KB to 317KB, gzip 148KB to 106KB.

★ AND IT RENDERED NOTHING, SILENTLY. I emitted `countries` as a COUNT on the strength of a
comment I wrote in the same edit — "rendered as a count elsewhere in this file" — without
reading the template, which does `l.countries.slice(0, 3).join(', ')`. Result:
"x.countries.slice is not a function", a render-time TypeError Vue logs to the CONSOLE and
does not raise as a pageerror. My probe reported an empty page AND "no page errors", which
is the most misleading pair of signals available here. Only listening for console errors
found it. countries is an array of country NAMES again, resolved through
gospel-by-country with an ISO3 fallback.

★ THE HEADLINE NUMBER MOVED TO 2.99 BILLION and it is true, verified against an
independent count — languages 4,031, groups 7,105, people 2,989,037,834, all three
matching exactly. It is also dangerously misreadable. The largest entries are Javanese
113M, Western Panjabi 101M, Shanghainese 92M, Egyptian Arabic 81M, Cantonese 77M: major
world languages that plainly DO have Scripture, which eBible does not host. Read as "3
billion people have no Bible" it is false; read as "not found in the pools we can reach"
it is exact. The page now names those languages directly under the number rather than
leaving the reader to find the caveat further down.

That figure is also the clearest argument yet for the credentials this build keeps asking
for: the pools that DO carry Javanese and Cantonese Scripture are the ones behind a key.

Verified: no console errors, 1,365 real anchors on ebible/jesusfilm/door43/arc.gt hosts,
and the coverage gate, map gate, dashboard and charts probes all pass.
2026-09-24 09:10:06 +00:00
doxa-maps-88655c70
48bc645c67 Chart the corpus the map draws, not a sixth of it
charts.vue aggregated people-group-resources — 862 languages — while the map beside it
draws 5,067. The charts described a sixth of the corpus and looked authoritative doing it.
Same defect the country sheet had at tick 13, listing 36 groups for Indonesia against 669
pins, and found the same way: by asking which table a surface actually reads.

Now built from the pin index, and every number cross-checked against an independent count
of the same data — 5,067 languages, Scripture 1,036, Full Bible 121, New Testament 1,025,
Film 1,852, Open Bible Stories 101, nothing found 2,758. Six of seven matched exactly.

★ THE SEVENTH DIFFERED BY 2,330,000 AND THAT IS THE INTERESTING ONE. My count said
347,030,459 people behind the gap; the chart said 344,700,459. The difference is exactly
"Arab, Gazan" — the single group with NO ISO 639-3 code. A per-LANGUAGE chart cannot count
a group that has no language, which is correct, but it left the charts and the gaps table
quietly disagreeing by 2.33M. The hero line now says so and points at the table that does
include them. A discrepancy named is a caveat; unnamed it is a contradiction someone finds
later and cannot explain.

THE FOOTER SAID "Pools CHECKED: unknown." over a corpus where we know exactly what was
searched — it was reading meta.sources from the old table. Derived from the codes instead:
"Pools CHECKED: ebible, arclight, door43. Pools NOT checked: grn, api_bible, dbl,
progress_bible." The same understatement the gaps table carried until tick 30.

Audio still renders as not-measured rather than as a zero bar, which is the honest reading
of a pool nobody has looked in — the guard that protects it was rewritten for the new
shape rather than dropped with the old one.

YOUVERSION / BIBLE.COM IS BLOCKED, tried because the brief names it: /languages, /versions
and /api/bible/configuration all return an identical 3,038-byte "Client Challenge" page,
and robots.txt returns that challenge instead of a robots file. Recorded beside GRN as
needing an agreement rather than a scraper.

One self-inflicted note: my edit left an orphaned continuation line and check:sfc-parse
caught it immediately with the right file and line. The build would have failed far from
the edit, which is the failure that gate exists to replace.

Verified: coverage gate, map gate, dashboard and table probes all pass, and the rendered
pools line names all seven pools rather than "unknown".
2026-09-24 09:01:37 +00:00
doxa-maps-88655c70
e9338a8757 Fold the legend on a phone: 69% of the map was its own explanation
Checked the new dashboard at phone width before doing anything else, and found a defect I
had been making worse all flight. MEASURED at 390x844: the legend rendered 469px tall over
a 675px map — 69% of the map hidden behind the text describing it — and 484px of a 740px
viewport at 360 wide. The search box added two ticks ago and the pin key added at tick 2
each pushed it further down the screen.

Everything in that legend earns its place on a desktop, where it is 395px of a 900px map
and the map is still legible around it. On a phone the prose has to fold.

Search and the Gap/Resources toggle stay visible, because they are the two controls a
reader actually touches. The key and the caveats fold behind one button — FOLDED, NOT
DELETED. "A red dot can sit on a green country — that is the case worth finding, not a
rendering fault" is the sentence that stops a correct map reading as a bug, so it is one
tap away rather than dropped on small screens.

390x844: legend 469px -> 124px, about 18% of the map, expanding to 507px on demand.
1280x900: no fold button at all and the legend unchanged, so the check discriminates by
viewport rather than always firing.

Also confirmed while there, since a stacked composite is exactly where this goes wrong:
NO horizontal overflow at either 390 or 360 — scrollWidth equals clientWidth at both — and
the dashboard still scrolls to its tables on a phone, 1,999px of document at 390 wide with
8 tabs and 20 rows reachable.

The resize listener is removed on unmount. An element that keeps a window listener after it
is gone is the kind of leak that only shows up in the SPA, where these get mounted and
unmounted as tabs change.

Verified: map gate, coverage gate, and the dashboard, search, filter and scope probes all
pass.
2026-09-24 08:51:40 +00:00
doxa-maps-88655c70
b9343e9000 The gospel resources dashboard: the map above its own tables, in one element
The Driver asked for "a gospel resources dashboard if possible". Both halves already
existed as separate profiles a reader had to know to visit separately — the map answers
WHERE, the tables answer WHAT EXACTLY and give you the list. Neither is improved by being
rebuilt; they are improved by being in one place.

Composed the way this repo already composes: children imported directly and stacked with
StackLayout, as dashboard-plus-grid does, not injected at runtime like stack-demo. Both
children live in THIS bundle, so there is no second script to load and no cross-bundle
version skew — the tables you scroll to are built from the same pin index the map above
them is drawing.

★ IT RENDERED, AND IT WAS UNREACHABLE. Both children are .gt/.gm {position:absolute;
inset:0} — correct when each IS the whole page, and fatal when stacked, because neither
has intrinsic height. The tables section collapsed and the host clamped to exactly one
viewport: MEASURED documentElement.scrollHeight 900 against clientHeight 900, with
scrollTo AND a real wheel gesture both moving 0px. A probe counted 50 table rows and 8
tabs the whole time. Rendered, counted, and invisible to a person is the worst of the
three, and only measuring the scroll geometry caught it.

The tables are made static inside the composite so the section takes its natural height
and the PAGE scrolls — 1,638px of document against a 900px viewport, wheel and scrollTo
both reaching 738. ONE scroll container, not a scroller inside a scroller; this repo has
already fixed the nested-scroll version of this twice. The map keeps a real height,
because a map with no height paints a zero-pixel canvas that reads as "did not load".

TWO FURTHER THINGS THE BUILD TAUGHT. The profile first came out isMap:false, so its leaf
page shipped without the Mapbox script and the map failed with "mapbox-gl not loaded" —
fixed by declaring what it composes in gospel-dashboard.meta.json, the same mechanism
dashboard-plus-grid uses. And the probe's first assertion demanded every pin be rendered;
in a 720px map three sit off-screen, so it now asserts substantially all of them —
queryRenderedFeatures reports the VIEWPORT, not the layer.

Verified: 12,379 pins loaded with 12,376 rendered at 720px, a live canvas, 8 tabs and 50
table rows below the fold and reachable, and the map gate, coverage gate, descriptions,
composite-registration and orphan-gate checks all pass.
2026-09-24 08:42:17 +00:00
doxa-maps-88655c70
f0b41ae3be Describe the plugin that exists now, in one line that survives extraction
The gallery and the plugin-studio — the surfaces a new developer or an inheritor opens to
learn what is here — described a plugin that stopped existing twenty ticks ago.
resources-map read "Every country shaded by how much of its unengaged population has NO
gospel resource": the choropleth, and nothing about the 12,379 people-group pins, the real
per-group links, the search or the gap filter. data-tables read "Two validated tables"
when there are eight. Someone browsing for a tool that maps gospel resources per people
group would not have learned that this is one.

★ AND A LONGER DESCRIPTION WOULD HAVE MADE IT WORSE. generate-tree extracts with
/^\s*\*\s*@([\w-]+)\s+(.+?)\s*$/gm — ONE LINE. A wrapped @description silently ships only
its first line, so my first rewrite ended mid-sentence on the word "and", which is worse
than the stale text it replaced. All four profiles are now a single complete sentence.

THIS IS NOT LOCAL. 27 profiles across the repo ship a description that ends mid-sentence,
including doxa-maps-spa — the flagship SPA — and big-picture, cities-internet and
all-people-groups. check:profile-descriptions asserts a description EXISTS, not that it is
a whole sentence, so all 27 pass. Fixing this bundle takes it to 23. The rest are other
bundles' and are left alone rather than failing a shared gate on work that is not mine;
the measurement is here for whoever owns them.

The gallery also had to be regenerated: it is written by `bun run gallery` from the
manifest, so updating the source docblocks moved the manifest and left the gallery HTML
stale. Both now carry the new text; verified by searching the built gallery for it.

Verified: 4 of 4 profiles in this bundle end in a full stop, the manifest carries the
complete text, the built gallery contains it, and the coverage gate, descriptions gate,
SRI check and map gate all pass.
2026-09-24 08:26:50 +00:00
doxa-maps-88655c70
f35063b0ca Say we looked, when we looked: 2,608 rows understated their own evidence
The performance worry that started this tick was unfounded and measured as such: the new
gaps table reaches first rows in 1,021ms, second-fastest of the four large tabs and ahead
of Gaps & Opportunities at 1,910ms. 901KB raw but 106KB gzipped, and smaller than the
1.8MB tables already shipping. Nothing to fix.

Reading that table found something that did need fixing. 2,608 of its 3,457 rows — 75% —
carried an EMPTY "pools checked" and listed eBible, Arclight and Door43 as NOT checked.
That is backwards. This build scans all three catalogues in full; a code reaching the
naming pass is absent from every one of them, verified — 0 of the 2,248 such codes appear
in any catalogue. "Not checked" reads as "we do not know". The truth is the stronger and
far more useful "we searched three catalogues and it is not there", which is exactly the
distinction a mobiliser needs when deciding where to work.

A FURTHER 849 ROWS had the same understatement in milder form, claiming only "ebible,
arclight" because their records predate the Door43 pass — though Door43 had in fact been
searched for them too. Provenance is now stamped once, uniformly, over every code: the
three catalogues this build actually scans are named as checked, and the four that
genuinely were not — GRN, api.bible, DBL, Progress Bible, all credential-gated — are the
only things the unchecked list ever names. 3,456 of 3,457 rows now state it correctly.

THE ONE REMAINING ROW IS HONESTLY EMPTY and now says why. "Arab, Gazan", 2,330,000 people,
has no ISO 639-3 code in the source at all, so nothing could be looked up — there was
nothing to look up WITH. Two blank cells read as an oversight; the row now reads "no
language code in the source — no lookup possible".

This moves the map's claims in the direction of knowing MORE, not less, which is the rarer
correction here — the previous eleven went the other way.

Verified: 0 rows left with blank provenance, the coverage gate and map gate pass, the gaps
tab still renders with both export buttons, and the country filter still isolates Chad at
94 and Nepal at 54.
2026-09-24 08:16:54 +00:00
doxa-maps-88655c70
29e4b153a4 List what is missing, in a form you can take away
Every table in this bundle lists what EXISTS. The map can filter to the gap and rank its
40 largest, but 40 is a preview and a map is not something you hand to a prayer group or
work through in a spreadsheet. There was no exportable list of what is missing.

"Where Nothing Was Found" is that list: 3,457 people groups, 347,030,459 people, 183 of
them speaking a sign language. Largest first, with CSV and JSON export already provided by
the shared table.

IT IS NOT THE EXISTING "GAPS & OPPORTUNITIES" TABLE and could not have been folded into
it. That one is one row per group across the curated 2,106-record set, so it structurally
cannot see these 3,457. It keeps its own job — bridge-language analysis for the groups it
does cover — and this sits beside it.

EVERY ROW CARRIES ITS OWN PROVENANCE: checked_pools and unchecked_pools travel with the
data, so an exported CSV cannot be read as a finding about the world. Rows with an EMPTY
"pools checked" are groups whose language was in no resource table at all, and they say so
rather than looking identical to a group that was searched and came back empty. The meta
states outright that audio has never been searched for any group here.

THE COUNTRY CLICK NOW REACHES IT. The deep-link filter applied only to the resource table,
so a country click could show what a country HAS and never what it lacks — the half a
mobiliser came for. Chad: 94 of 3,457. Nepal: 54. Both match the map's own country sheet
exactly, which is the check that the two surfaces are describing the same groups.

180 ROWS PRINTED A RAW ISO3 IN THE COUNTRY COLUMN — "CHE", plus Canada, Taiwan,
Timor-Leste, Mexico, Argentina. gospel-by-country knows 170 countries and the boundaries
file 162, while the pins span 242. Names now come from people-groups-slim, the source the
pins were built from, so coverage matches by construction rather than by luck; the curated
spellings still win where they exist. 180 -> 1. The one left is Kosovo, whose ISO coding is
genuinely contested across datasets, and its row is named "Deaf of Kosovo" so no reader is
misled — one honest code beats starting a hand-written country table.

Verified: the tab renders 3,457 rows with all eight columns and both export buttons, the
country filter isolates Chad and Nepal correctly, and the coverage gate plus the table and
deep-link probes all pass.
2026-09-24 08:08:59 +00:00
doxa-maps-88655c70
b771fa622d Make a people group sendable, not just findable
Search made a group findable last tick; nothing made it sendable. A colleague could be
pointed at the map but not at the Fulani group you had just spent five minutes locating —
the Share button emitted the same URL no matter what was open.

It now carries the selection: ?code=<iso639-3>&country=<iso3>&name=<people group>, and the
map reopens that group on load, flying to it.

★ KEYED ON code+country+name, NEVER ON THE PIN INDEX. The index is a position in a
generated table and shifts every time the pins are rebuilt — which has happened eight
times this flight. An index-based link would keep working and silently open a DIFFERENT
people group after the next data refresh. A link that rots into a wrong answer is far
worse than one that breaks, and this map's whole discipline is not handing someone the
resources for the wrong people.

The query rides on ShareButton's embedPath rather than teaching that component about
parameters: it has four other consumers and none of them need this. Zero blast radius.

Verified end to end on the served tree, in a FRESH page rather than the one that made the
link: opening German emits
?code=deu&country=BEL&name=German, and loading that URL reopens German (deu) at zoom 4.2.
NEGATIVE CONTROL: ?code=zzq&country=ZZZ&name=Nobody opens no group at all, so the resolver
discriminates rather than falling back to whatever is first.

The repo's own smoke:share-all passes this bundle — share → reopened <gospel-resources-map>,
canvas:y. It reports one failure elsewhere, my-upg-100-list/upg-100-list "no Share button
found", which predates this flight: commit ca4343c "UPG-100 drops its duplicate share" is
an ancestor of my first commit. Not mine, and left alone.
2026-09-24 08:01:05 +00:00
doxa-maps-88655c70
4034fa9d6a Let someone find the group they came for
12,379 dots and no way to reach a named one. Everything built so far answers "show me the
gap" — the filter, the ranked list, the country view — and none of it answers the question
a person actually arrives with: where is Fulani, where is Uzbek. Finding one meant hunting
the map by eye.

The legend now opens with a search that matches a people-group NAME, its LANGUAGE, or its
ISO 639-3 code, because a user turns up knowing whichever of the three they happen to
know. Results carry the tier dot, so you can see before clicking whether that group has
anything. Tapping one flies there and opens it.

SUBSTRING, NOT FUZZY, and that is the load-bearing choice. A fuzzy match over 12,379 rows
returns plausible WRONG groups, and handing someone the resources for a similarly-named
people is precisely the failure this map is careful about everywhere else — it is why the
language join is an exact ISO code and never a name. A search that quietly suggests the
wrong Fulani is worse than one that finds nothing.

Sorted by population, because "Fulani" matches dozens of groups and the largest is the one
most likely meant. Capped at 8 with a count of the rest, so a broad word does not bury the
legend.

Built inline rather than importing library SearchBar: the shared component injects its own
styles, and whether that lands inside THIS shadow root was unverified — an unstyled search
box would have been a worse outcome than eleven lines of CSS in the palette already here.

Verified on the served tree: "fulani" returns 8 rows led by Adamawa and Sokoto Fulani,
the ISO code "deu" returns German and Austrian, a nonsense string returns 0 — so the
search discriminates rather than always matching — and clicking Uzbek moves the map from
zoom 1.3 to 4.2 and opens Uzbek, 28,470,000 people. Coverage gate, the map gate, and the
pins, filter, gap-list and scope probes all pass.
2026-09-24 07:51:37 +00:00
doxa-maps-88655c70
ff8a318e51 Let the map gate start its own server, so it will actually run
Registering a gate is not enough to make it run. verify:browser-all is invoked NOWHERE in
this repo; build:unlocked runs only `bun run verify`, and verify-all.mjs skips anything
marked browser:true unless --full is passed. That is precisely how two gates rotted here
before — "both had rotted unnoticed precisely because the build chain has never run them".
A gate whose precondition is "remember to start a server first" has a precondition nobody
remembers.

So the precondition is gone. If nothing answers, the gate spawns serve-strict on an
ephemeral port — ephemeral so two gates running at once cannot collide on 4173 — and shuts
it down afterwards. `bun run verify:gospel-map` now works from a clean shell with no setup.

An explicitly named BASE_URL or argv is honoured and never auto-served: a caller naming a
host means it, and quietly pointing at a different tree would be worse than skipping.
Verified — a dead explicit URL SKIPs and starts nothing.

★ THE FAILURE PATH LEAKED A PROCESS AND THE PASS PATH DID NOT. process.exit(1) inside the
try terminates before the finally block's cleanup runs, so a failing run left its spawned
server alive: measured 0 strays after a pass, 1 after a fail. A gate that leaks only when
it fails leaks exactly when someone is re-running it to see what broke. The exit code is
now set, not thrown, and the process exits after cleanup. Re-measured from a clean
baseline: 0 strays after pass, after fail, and after restore.

One scare worth recording: a stray server DID show up mid-test and it was the orphan from
the pre-fix run, six minutes older than the code being tested. Checking its start time
rather than its existence is what told them apart.
2026-09-24 07:44:26 +00:00
doxa-maps-88655c70
3d3586b5f9 Commit the map check, so the browser path survives this flight
Every browser check written for this bundle lived in _qa-cycle/, which is gitignored: a
dozen probes proving the pins render, the sheet opens with real links and the deep link
filters the table — all of which disappear with the working tree. check:coverage-consistent
guards the DATA and cannot see the map at all. The repo has carded orphaned gates twice;
this is the same loss one step earlier, a gate that was never committed.

It asserts, against the SHIPPED tree through the production-parity server: the element
mounts and exposes its state handle (a stale-SRI leaf silently refuses to execute, which
looks identical to a mount failure); every pin in the source is RENDERED, not merely
loaded; a tap opens a group with a NAMED language; the table deep-link carries that
group's own ISO code; and the gap filter leaves only tier 0.

★ IT TOOK THREE TRIES TO MAKE IT WORTH TRUSTING, and each failure is the point.

FIRST it was FLAKY — 1 run in 3. Clause 4 waited a fixed 900ms after clicking the filter,
but setFilter is applied asynchronously, so it queried mid-transition and still saw tier 1
and 2. It now waits for the map's own idle event. 5 of 5 runs clean. A gate that fails a
third of the time on correct code is a gate that gets switched off.

SECOND its doctrine check had a SAMPLE SIZE OF 1. It validated the two URLs of the one
group it happened to open — 1 of 2,685 codes — so a jw.org link planted anywhere else
passed cleanly. MEASURED: the injection did not fail the gate until the scan covered the
whole index. It now checks all 3,550 links, and fails if the index carries none at all,
because a scan over nothing passes vacuously.

THIRD, adversarially tested end to end: with jw.org injected on code `tel` the gate exits
1 naming the publisher; restored, it exits 0; the file's md5 is unchanged either side.

Registered in package.json and in verify-browser-all's GATES array — check:no-orphan-gates
confirms it, and check:quoted-scripts passes. Without a server it SKIPS loudly with the
command to start one, rather than passing quietly.
2026-09-24 07:31:01 +00:00
doxa-maps-88655c70
02351725ba Gate the shape of last tick's bug, not just the instance
70 story sets were typed as Scripture for seven ticks because NOTHING compared a row's
label to where it points. Clause H, added last tick, catches that exact mislabel. It would
not have caught the same mistake made anywhere else.

Clause I asserts the general shape: Film only on jesusfilm.org or arc.gt, Bible Stories
only on git.door43.org and only under that name, Scripture never on a film host. Audited
across all 3,550 rows first — 0 violations, plus 0 duplicate (language, url) pairs — so
this ships as a guard rather than a fix. The 8 languages carrying more than one Scripture
URL were checked and are correct: Cebuano really does have an eBible ULB alongside Door43's
ULB and UDB, which is three translations, not three copies.

THE PASS LINE NOW NAMES EVERY CLAUSE. It listed four of nine, which reads as a narrower
check than it is, and the next reader calibrates their trust on that line rather than on
the source. Nine clauses, nine names, and --poison still fails 9/9 on demand.

The table also states its scope breakdown where a reader meets it — 855 New Testament, 254
Portions of both Testaments, 136 Full Bible, 14 Old Testament, 13 Portions — with the
reason it is counted from each publication's own book list rather than its subject label.
"How much Scripture exists" is the question under every other number in that table and it
was previously visible only by sorting a column.

Verified: gate passes, self-test 9/9, the table renders its new Scope column, and pins,
scope and deep-link probes all pass.
2026-09-24 07:06:21 +00:00
doxa-maps-88655c70
25405d571a Scope the Door43 Scripture, and stop 70 story sets being typed as Scripture
Door43 entries carry their own book list. Each ingredient is categorised bible-ot or
bible-nt, so a Door43 Scripture entry can say how much Scripture it is, exactly as an
eBible one does — and it is already in the response we fetch, so this costs no extra
requests. 35 entries scoped: 15 New Testament, 13 Portions, 4 Full Bible, 3 Portions of
both Testaments.

THE SUBJECT LABEL IS NOT THE SCOPE, which is why it is counted from the data. An entry
filed under "Bible" can be 27 NT books and nothing else. Calling that a Bible because of
its folder name is the same error as calling Open Bible Stories Scripture.

★ AND THAT ERROR WAS ALREADY SHIPPING, 70 ROWS OF IT, BY MY OWN HAND. Tick 15 added owner
disambiguation so two same-named entries could be told apart — "Open Bible Stories
(OBS-TLF)". The type test was `d.s === 'Open Bible Stories'`, an exact match, so every
disambiguated label fell through to Scripture. 70 story sets have been typed as Scripture
for seven ticks: Bhojpuri, Maithili and 68 more told a reader they had a translation when
they have 50 illustrated narratives. Found only because the type counts moved when this
tick changed the test to a prefix.

An exact match against a label that can grow a suffix is the whole defect, and the fix is
not enough on its own — so clause H now asserts that nothing named "Open Bible Stories" is
typed as Scripture, poisoned with the rest. 8/8 clauses fail on demand.

The Door43 chip in the sheet had the same exact-match and now reads "Bible Stories
(OBS-TLF) · Door43 · <scope>". The one `===` left in the fetcher is correct and was
checked rather than blanket-replaced: it compares the RAW subject from the API, which
never carries an owner suffix.

Every Scripture row on the map now has a scope. The 2,278 blanks reconcile exactly to
2,087 film plus 191 Bible Stories — none of which is a portion of the canon.

Verified: gate passes, its self-test fails 8/8 on demand, and the Door43, pins, table,
scope and deep-link probes all pass.
2026-09-24 06:58:12 +00:00
doxa-maps-88655c70
91dc9e6b33 Name and scope every Scripture link, including the 24 on an older edition
Last tick left 547 Scripture links with no scope and 24 with no name. Both had a cause
worth finding rather than papering over.

★ THE 547 WERE A PATCH THAT SILENTLY DID NOTHING. Two code paths create an eBible entry.
The edit that added `ss` matched a string containing httpsify(ee.u); the second path reads
`s: ee.u` without it, so the replacement found no match, reported success, and changed
nothing. 523 entries kept a title and a full-Bible flag while losing their scope — a
half-applied change is harder to see than a failed one, because every field you happen to
check is present. That branch now sets every field the other branch sets.
Scope coverage 690 -> 1,213.

THE 24 POINT AT AN OLDER EDITION than the catalogue's current pick — tel2010 where the
catalogue says tel2017, ben2006 where it says benobcv. BOTH RESOLVE, checked 8 of 8 at
200, so the curated choice is a choice and fill-never-overwrite keeps it. What was missing
was only the description, and the language index cannot supply it: it answers "the best
translation for this code", not "what is THIS link". The catalogue is now also indexed BY
URL, 1,545 entries, so a link is described by the edition it actually points to. Telugu
keeps ebible.org/tel2010/ and is now named "Telugu Easy Reading Version — Full Bible".

RESULT: 1,237 of 1,237 Scripture links carry a real translation name and a scope. Zero
rows still read "Scripture in this language". 840 New Testament, 251 Portions of both
Testaments, 132 Full Bible, 14 Old Testament.

The 2,311 blank scopes in the table reconcile exactly — 2,087 film, 121 Bible Stories and
103 Door43 Scripture entries, which are not eBible publications and have no book counts to
scope. Blank there is the honest answer, not a gap.

Verified: the scope line renders on a real group and is ABSENT on a group with no
Scripture, so it still discriminates; coverage gate passes; pins, table and deep-link
probes pass.
2026-09-24 06:47:23 +00:00
doxa-maps-88655c70
cc350514f3 Say which Scripture it is, and how much of it
Every Scripture row on this map said "Scripture". The catalogue knows the translation's
actual name for 1,213 of the 1,237 languages that have one, and the difference it hides is
the one a mobiliser most needs: "Ahirani Matthew and Mark" is two Gospels, "Bhadrawahi
Bible" is a Bible. The table said "Scripture in this language" on all 1,237 rows and the
sheet showed a bare chip.

The sheet now names the translation and its scope — "German ELBBK Bible — Full Bible" —
and the table carries the real name in its Resource column plus a new Scope column: 101
Full Bible, 461 New Testament, 122 Portions of both Testaments, 6 Old Testament. Scope is
its own column rather than folded into Type, so Type stays a clean three-value filter.

SCOPE DESCRIBES THE TRANSLATION WE ACTUALLY LINK TO. A curated scripture_url pointing at
the same eBible page inherits it; one pointing elsewhere does NOT, because the catalogue's
book counts would then be describing a different publication. That distinction recovered
96 entries and correctly leaves 547 blank rather than guessing.

EBIBLE LINKS WERE THE TICK'S ACTUAL QUESTION and they came back clean: ebible.org/cmn2006/
renders the Mandarin Bible with its full book list in-script, png.bible/aai/ renders
Minaifia NT from Matthew to Revelation. They land where a person can read, which is what
the film links did not do. The scope work came out of reading those pages.

★ THE DEEP-LINK PROBE FAILED ON THIS COMMIT AND THE PRODUCT WAS FINE. It read the ISO code
from cell index 4; inserting the Scope column moved it to 5, so it reported a filter
failure while the filter was returning exactly the right 2 rows. It now finds the column
by its HEADER. A column index is a position, a header is a contract — the same mistake the
tooltip probe made two ticks ago by asserting wording instead of the rule.

Verified: the scope line renders "German ELBBK Bible — Full Bible" on a real group and is
ABSENT on a group with no scope, so it discriminates. Coverage gate passes, its self-test
still fails 7/7 on demand, and pins, table, deep-link, tooltip and sign probes all pass.
2026-09-24 06:40:37 +00:00
doxa-maps-88655c70
9dfcf3551b Correct a wrong finding: Scripture Earth's links are not dead, its shelves are empty
Two ticks ago this repo recorded that Scripture Earth was rejected because its JESUS Film
links are dead, on the evidence that the /watch/jesus.html/<slug>.html scheme 404s —
english.html included, so not merely rare languages. That was wrong, and it was published
where the next agent would inherit it.

It 404s TO A SERVER-SIDE FETCH ONLY. Loaded in a browser, jesus.html/english.html and the
Ghotuo link both render "FEATURE FILM JESUS · 128 min FHD", and a bogus slug renders
"404 PAGE NOT FOUND". The scheme is alive. The lesson was already in this file for
?languageId= and I applied it inconsistently: no jesusfilm.org URL can be graded by
fetching, because the site is client-routed — a fetch 404s pages that work and returns a
shell for pages that do not.

THE REJECTION STANDS, for the reason that actually holds. MEASURED on a random sample of
20 of the 730 tier-0 languages with a Scripture Earth directory: 19 are EMPTY — no video
files, mostly no subdirectories at all. A code directory is a roster row, not a resource.
Perhaps ~5% hold something real, recoverable only by crawling all 730, which is now
recorded as a small-return future option rather than a dead end.

This is the difference between rejecting a source and rejecting it for a reason that is
true. The first would have been re-litigated the moment someone opened one of those links
and watched it play.

ALSO VERIFIED, since the tick started there: all 33 arc.gt links we ship resolve to a real
JESUS Film page — every one, not a sample. Arclight returns those shortlinks as the
shareUrl for some languages, notably American, Russian and Iranian Sign Language, which
matters because sign languages are the emptiest part of this map. A bogus keyword answers
with JSON, {"error":"Keyword not found or invalid"}, NOT a 404 page — so the probe's
control had been grading the bogus case as alive and failing for the wrong reason. Fixed,
and the string recorded so the next check knows what a dead arc.gt looks like.
2026-09-24 06:29:52 +00:00
doxa-maps-88655c70
7b27bf1b83 Every link https, and a gate so it stays that way
1,237 of the URLs we ship were plain http — all of eBible.org, png.bible and alkitab.pw,
with the host spelled `eBible.org` on six of them. Every one of those hosts serves https
directly (sampled 12/12 at 200) and redirects http there anyway, so the plain link bought
nothing but a redirect hop and a downgraded scheme in a field worker's browser, on a page
that may itself be embedded over https.

NORMALISED WHERE A URL ENTERS THE INDEX, not per fetcher. Doing it in the eBible fetcher
first left 120 links behind — the curated scripture_url values, which fill-never-overwrite
preserves and which therefore never pass through a fetcher at all. One httpsify at every
emit point takes it to 3,548 https and 0 http.

Scheme and HOST only. The path is left exactly as published, because a path is
case-sensitive and "tidying" it would break the link rather than improve it.

★ THE GATE CAUGHT MY OWN DRIFT, ten seconds after I added the clause. Clause G failed —
but as clause E, "1237 table URL(s) exist in no pin record": I had rebuilt the pins and
not the table, so the two surfaces disagreed about every eBible link in the corpus. That
is precisely the class this gate was written for last tick, and it found it on its first
real use rather than three ticks later by hand.

Clause G is poisoned in the self-test alongside the rest: 7/7 clauses now fail on demand.

Verified: 0 plain-http URLs remain, six upgraded links sampled live all answer 200 over
https, the coverage gate passes, and pins, table, deep-link, tooltip and list probes all
still pass.
2026-09-24 06:15:58 +00:00
doxa-maps-88655c70
799345cba2 Give the film links a destination: 2,049 real per-language deep links
Last tick established that ?languageId= is ignored by the site — a real id and a bogus id
render the same generic library — so 2,087 groups had a film link that did not take them
to the film. Arclight publishes the missing piece: media-components/1_jf-0-0/languages/<id>
returns a shareUrl, and that IS a deep link — jesusfilm.org/watch/jesus/<language-slug>.

2,082 of 2,087 languages now carry one; 304 existing links were upgraded in place and the
rest were written as deep links from the start. The 5 with no shareUrl keep the old URL
and their tooltip says so, rather than every group getting the same hedge.

FETCHED, NOT DERIVED, and that was the judgement call. The slug looks derivable — "Pashto,
Southern" -> pashto-southern, and 8 of 8 sampled names transformed correctly. Eight is not
2,087, and a wrong slug is not a broken link but a link to SOMEONE ELSE'S language, which
is the worst failure available here. So the API was asked 2,087 times, rate-limited at
250ms, cached and resumable so it is asked once. 5 missing, 0 wrong.

★ AND THIS CHECK CAN FAIL, which none of the previous jesusfilm checks could. A real slug
returns a page carrying the media component id `1_jf-0-0`; a bogus slug returns 0 of them.
Sampled 8 deep links: 8 real, 0 bad, and the bogus control returns 0 hits. In a browser
the Bhilali link renders "FEATURE FILM JESUS · Watch now · 128 min FHD" while the bogus
slug renders "404 PAGE NOT FOUND". That is the first time a jesusfilm URL has been
verified rather than assumed in either direction.

THE TOOLTIP PROBE FAILED ON THIS COMMIT, correctly: it asserted the old caveat wording, so
fixing the links broke the test that described the defect. It now asserts the CONTRACT —
a deep link must NOT carry the caveat and a legacy link MUST — which survives the next
change to the words.

SOURCES-CHECKED-AND-REJECTED.md records the shareUrl endpoint and the `1_jf-0-0`
discriminator, so the next agent inherits the answer instead of the dead end.

Verified: coverage gate, pins, table and deep-link probes all pass; the film chip on
German renders the real deep link with the accurate tooltip.
2026-09-24 06:09:11 +00:00
doxa-maps-88655c70
984f86d10b Say that the film link is not language-specific, because it is not
A tick spent on Scripture Earth ended by turning the check on our own links, and they do
not do what the chip implies.

MEASURED IN A BROWSER: https://www.jesusfilm.org/watch/?languageId=<id> renders the same
generic library page for id 529, for id 1304, and for id 999999999. languageId is a query
parameter on a client-rendered page and the current site ignores it. The film is real —
the JESUS Film Project's own API confirms a feature film in that language — but the URL
does not carry you to it, and the tooltip said nothing about that. A field worker follows
the link and has to find the language themselves. The chip now says so.

★ THE URL CANNOT BE VERIFIED BY FETCHING, IN EITHER DIRECTION, and both wrong answers are
available. The server returns a byte-identical 867,227-byte shell for every id: grading by
STATUS reports ALIVE for everything, including a bogus id; grading by CONTENT reports DEAD
for everything, because "WatchNotFound" lives in an i18n bundle present on every page. I
ran the content check first and it returned 10 of 10 of our own links DEAD — Fon and
Southern Pashto among them, which is not believable, and that implausibility is the only
thing that caught it. Reported as-is it would have ripped out 2,087 correct links.

No constructible scheme works either: the legacy /watch/jesus.html/<slug>.html form is
retired (english.html 404s, not just rare languages) and /watch/<slug>.html/videos — a
shape the live page itself renders — also 404s to a fetch, resolving client-side. The
Arclight API is the only thing that discriminates, and it stays the basis for the claim.

SCRIPTURE EARTH IS REJECTED, with the reasoning kept next to the code so the next agent
does not re-walk it. Its /data/ index looks like the largest win available — 3,477 ISO
639-3 directories, 730 of them languages we currently show as "nothing found" — and it is
not coverage: aab/video and aac/video are EMPTY, its JESUS Film links use the retired
scheme, and it files Ghotuo under `aaa` where the publisher files it under `ikh`. Shipping
from it would have added ~730 dead links to the groups that most need real ones.

SOURCES-CHECKED-AND-REJECTED.md records that, plus what is credential-blocked and why,
plus that sign.bible is a parked domain answering 200. It sits beside the bundle rather
than in _qa-cycle, which is gitignored — a note the next agent cannot read is not a note.

Verified: the film chip's title renders the honest text on a real group (German), and the
pins, country and table probes still pass.
2026-09-24 05:44:55 +00:00
doxa-maps-88655c70
c2046be7ec Gate the agreement between the surfaces, after finding the same drift three times
Three defects this flight were the same defect, and none of them threw. Every page
rendered; only comparing two surfaces caught them:

  the country sheet listed 36 groups for Indonesia while the map drew 669 pins, because
  the sheet read a curated subset and the map read the pins;

  the resource table silently dropped 506 real resources across 377 languages, because
  its generator skipped any code no pin speaks — in a table titled "every link";

  resources attached to `und` told 14 people groups there was Scripture in a language
  the record explicitly does not identify.

A fourth is a matter of time, so the comparison becomes a gate rather than a habit.

SIX CLAUSES, all static, 60ms, no browser and no server: no ISO reserved code carries a
resource; every resourced code has a language name; the table's reach columns match an
independent recount off the pins; every resourced code appears in the table; no table URL
exists that the pin index does not hold; and each pin's tier agrees with its code's URLs.
Each one is a defect that was actually shipped, not a hypothetical.

★ IT REFUSES TO PASS VACUOUSLY. Five of six clauses assert a NEGATIVE, which is exactly
the shape that reads green against empty or mis-keyed input, so an empty corpus is a FAIL
rather than a clean run, and a missing input SKIPS loudly with the command to fix it.

★ AND IT SELF-TESTS. `--poison` injects one defect per clause in memory and requires all
six to fail. Clause D is included deliberately: it is the one that would have caught the
506 dropped resources, and the first version of this self-test left it unproven — the
most valuable clause was the one clause never shown to fail. 6/6 now.

Registered in package.json and in build:unlocked, because a gate nothing runs is not a
gate — this repo has carded that twice. check:no-orphan-gates went from FAIL to PASS on
registration, which is the check confirming itself. The CLI doc quotes the build chain
verbatim, so it was updated to match: package.json is what runs, the doc is the copy.

No product file changed, so nothing is rebuilt. check:quoted-scripts, check:doc-scripts,
check:no-orphan-gates and check:mjs-parse all pass.
2026-09-24 05:26:34 +00:00
doxa-maps-88655c70
117467f7e7 Stop claiming Scripture in a language recorded as "Undetermined"
Reading the list profile's own screenshot turned up a row for "Undetermined (und)" —
14 people groups — offering two Open Bible Stories links. ISO 639-3 reserves und, mul,
mis, zxx and qaa-qtz for exactly the case where the language is NOT known, so a resource
attached to one is attached to nothing. Telling 14 groups there is Scripture in their
language when the record says the language is undetermined is the single worst thing this
map can do, and it took looking at the page to see it. Door43 publishes 4 entries under
und; 16 pins sit on a special code. All are now excluded at the index.

COVERAGE GOES DOWN, 8,935 -> 8,921 pins with a real URL. That is the right direction: the
14 were false.

The same screenshot showed Dogri rendering "Open Bible Stories  Open Bible Stories" —
two links a reader cannot tell apart. 40 languages carry more than one entry under the
same subject, because a language can have more than one independent translation effort.
The owner now disambiguates, and ONLY where it is needed: Dogri reads "Open Bible Stories
(translationCore-Create-BCS)" and "(OBS-TLF)", while a language with one entry is
unchanged.

The list profile also gained the pool it predated. It bundles its own resources.json at
build time, so Door43 never reached it: 49 languages, 63 links. Checked before writing —
120 of its 862 languages already carry a scripture link and those are exactly the 120 the
enriched index has, so nothing there needed correcting, and its curated 862-language scope
is this profile's subject rather than staleness. The enrichment is idempotent: it drops
what a previous run added before re-adding, or a stale title would survive the index
learning to disambiguate.

NOT DONE, DELIBERATELY: the 506 zero-reach rows still never match a COUNTRY filter.
Arclight carries a primaryCountryId that could attribute them, but the only alpha-2 to
alpha-3 mapping in this repo disagrees with itself on 18 countries and cannot resolve 51
of the 168 codes. A wrong attribution would point a real link at the wrong field, so the
behaviour is documented in the table meta instead of guessed at.

Verified: und/mul/mis/zxx absent from the index, the list renders 49 Door43 anchors where
it rendered none, three sampled new links answer 200, and pins, table, deep-link, country
and sign-language probes all still pass.
2026-09-24 05:22:59 +00:00
doxa-maps-88655c70
694f3859d6 List every link, including the 377 languages no pin speaks
The table is titled "every link" and was silently dropping 506 real resources across 377
languages, because the generator skipped any code with no people group on this map. They
are not edge cases: Ahirani has Scripture, a film and Open Bible Stories; Pahari Mahasui
has Scripture, a film and five Door43 resources. Someone searching for that language
still needs to find it, and the reach columns reading 0 is the true answer to "how many
groups on THIS map speak it" rather than a reason to hide the resource.

3,043 -> 3,549 rows, 2,307 -> 2,684 languages. The meta says what a zero-reach row means
so the column is not read as "this resource reaches nobody".

THE INCLUSION EXPOSED A BLANK. Those codes are named nowhere in the people-group dataset
— it only names languages some group speaks — so their rows rendered a bare ISO code with
an empty language column, which tells a reader looking for a language precisely nothing.
The eBible index now carries languageNameInEnglish alongside the translation title (two
different things), and a final pass names any remaining code from whichever catalogue
knows it. Codes carrying a resource with no language name: 377 -> 0.

An audit of the reach columns against an independent recount off the pins found 0
disagreements on people_groups and 0 on speakers, so the table and the map agree about
who speaks what.

Verified: a seeded sample of 12 newly-exposed rows — 9 resolved 200, 3 skipped as
unverifiable, because jesusfilm.org/watch answers 200 for any id and a status check there
proves nothing. Three Door43 links first returned 000, which is curl failing to CONNECT
rather than an HTTP status; retried with spacing they all answer 200, so the failure was
my own request rate and not a dead link. Table, deep-link, pins, gap-list and country
probes all still pass, and the country sheet still never fetches the retired 1.4MB file.
2026-09-24 05:13:57 +00:00
doxa-maps-88655c70
a1ccc683a6 Make a country describe the same people groups the map draws
The country sheet was built from people-group-resources.json, a curated 2,106-record
subset, while the map draws 12,379 pins. The two described different universes and the
sheet silently undercounted. MEASURED before this change: Indonesia listed 36 groups with
669 pins under your finger, Nigeria 38 against 433, India 899 against 2,146. A map that
contradicts itself on tap is worse than one that says less.

Matching was also done by PARSING a comma-separated country-name string against a
longest-first vocabulary — a second way to lose rows, since a name that does not match
the vocabulary exactly drops out with no error. Pins carry ISO3, so the index is now an
exact key rather than a text search.

ONE SHAPE, BUILT IN ONE PLACE. openPin used to assemble its own group object while the
country list built another, so a field added to one path silently did not exist on the
other — which is how is_sign and the Door43 list would have gone missing from a group
opened out of a country. groupAt(i) is now the only constructor.

THE HEADER HAD TO FOLLOW, and reading the screenshot is what caught it. Moving the list
to the pins left the stat block still reading the curated table, so the card contradicted
itself inside one view: "People groups 36" sitting directly above "Gospel resources found
(282)". The stats are now derived from the same groupsForCountry the list renders.
Growing-and-unreached and deaths/day are NOT derivable from pins — they come from the
curated analysis — so they are marked and footnoted with how many groups that analysis
actually covers, rather than sitting unlabelled among numbers with a different basis.

RETIRES A 1.4MB FETCH. people-group-resources.json is no longer requested at all: the
pins are already in memory and carry richer resource data than that file ever did. Only
the 136KB link-verification fetch remains, and it is still allowed to fail.

Verified on the served tree: for IDN, NGA, IND, NPL and TCD the sheet's group count now
equals the layer's pin count exactly (669/669, 433/433, 2146/2146, 171/171, 150/150), and
with + without partitions the total in every case. The network log confirms
people-group-resources.json is never fetched. Indonesia reads 669 groups, 387 with
nothing, 282 with a resource — and 669 - 387 = 282. All seven browser probes still pass.
2026-09-24 05:07:31 +00:00
doxa-maps-88655c70
5e54e601aa Say that audio was never searched, on the groups whose need it is
MEASURED: has_audio is 'unknown' for all 2,106 source records and therefore for all
12,379 pins. Not one group on this map has had its audio checked, because GRN — the main
audio-gospel pool — is named as unchecked on every record. The map was nonetheless
showing 3,444 groups as "nothing found", covering 347,125,659 people, and for an oral
culture audio is not one resource among several, it is the one that matters.

So the claim was overstated in the place it is least affordable. Three changes, all copy,
no data:

The audio chip said "unknown", which did no work — it is unknown for EVERY group, so it
read as a property of that group rather than of our search. It now says "not searched
(GRN)" and names the pool in its tooltip. A shrug becomes something a reader can act on.

The gap list now leads with it: 3,444 groups, 347,125,659 people, and "Audio has never
been searched for any of them" before the ranked rows rather than after.

The legend caveat names the pool and what its absence costs, instead of the passing
"GRN audio is unchecked".

NOT ADDED, DELIBERATELY: Arclight publishes an audioPreview for 2,074 of these codes and
it is a real, resolving URL. It is also ~24.4KB of AAC — MEASURED at a uniform 11.8
seconds — a language-identification sample, not Scripture. Shipping it under "Audio ·
found" would be the same error as calling Open Bible Stories a Bible, so it is left out.
Real audio still needs GRN or FCBH credentials.

Verified on the served tree: the legend carries the warning, a group sheet reads "Audio ·
not searched (GRN)" with no bare "unknown" anywhere in it, the gap panel carries the
sentence, and the people-count renders 347,125,659 — the same number the build measures.
Pins, filter, gap-list and sign-language probes all still pass.
2026-09-24 04:48:45 +00:00
doxa-maps-88655c70
45354c95ec Say what "none found" means for a Deaf group, because it does not mean the same thing
A sign language cannot be served by a text translation at all. eBible is a text pool, so
its silence for a Deaf group describes OUR SEARCH, not their need — and the sheet was
reporting it as "Scripture · none found" exactly as it does for a spoken language. That
is the map telling a true-sounding thing that misleads.

Sign languages are now flagged in the pin build and the sheet says so: what counts for
them is Scripture FILMED in that sign language, and the text pool was never the right
place to look. Counts are computed, not typed — 150 sign languages on the map covering
84,159,050 people, and the copy reads "none yet has both" only while that is true, since
it is generated from the same pins the layer draws.

THE SCALE: 234 pins on 150 sign languages, 183 of them with nothing found, 0 with both a
film and a text Scripture. 13 have a JESUS Film through Arclight and 1 has Open Bible
Stories through Door43; everything else is empty.

★ A NOTE LEFT IN THE BUILD FOR WHOEVER FILLS THIS GAP. The largest producer of
sign-language Bible content by a wide margin is the Watchtower/JW organisation, which is
excluded by standing rule — added canon. Anyone researching this will meet jw.org first
and it will look like the obvious answer. The legitimate partners are the Deaf Bible
Society and DOOR International, and both need credentials this build does not have:
deafbible.com is a key-gated app, doorinternational.org answers 403. sign.bible is NOT a
source — it is a parked domain listed for sale, which a status-code check would have
happily accepted as live.

No sign-language resource is added here, because none could be verified. What changed is
that the map stops implying the absence was measured against the right pool.

Verified on the served tree with a negative control: the note renders for Deaf Nicaraguan
(Nicaraguan Sign Language, ncs) and does NOT render for German, so it discriminates
rather than always firing. The rendered counts match the build's own tally exactly. Pins,
deep-link, filter, gap-list and language-naming probes all still pass.
2026-09-24 04:39:10 +00:00
doxa-maps-88655c70
f68ffc33ba Rank the gap, and name the languages that have nothing
The filter shows WHERE the gap is; it could not say which part of it is biggest. The
3,444 groups with nothing were reachable only by tapping dots until a large one turned
up, which is not how anyone chooses a field.

"See the 40 largest" now opens a ranked list in the sheet — the slot the country and
group views already use, so no new geometry and no new collision with the control rail.
Sorted by population, because choosing where to go is choosing how many people are
behind the decision. Tapping a row flies there and opens that group. Fly first, THEN
open: opening sets `selected`, which swaps the sheet away from the list, so the other
order loses the map movement behind a re-render.

THE LIST EXPOSED A REAL DEFECT. The largest gaps are exactly the groups with no entry
in any pool, so they had no record to carry a language name and the sheet rendered
"Language: (blank) cjy" for a 60-million-speaker group. Every language is now named
from the people-group dataset, creating the entry where no pool has one: 2,251 codes
are named-only — the language is known, nothing was found for it anywhere.

That makes "has a code entry" ~100% by construction, so it is relabelled in the build
output as naming and NOT coverage. Coverage is the line under it and still reads 8,935
of 12,379, 72.2%. A metric that reaches 100% because its meaning changed is worse than
no metric.

Verified on the served tree: 40 rows, populations strictly descending 60,310,000 ->
974,000 so "largest" is not a claim taken on trust; tapping row 1 moves zoom 1.3 -> 4.2
and opens Han Chinese Jinyu, whose scripture_url and film_url are empty and whose
Door43 list is empty — so the list is not mislabelling groups that have something. 200
sampled zero-resource groups all name their language, where the screenshot before this
change shows that field blank. Pins, table, deep-link and filter probes all still pass,
and the full a11y sweep passes 810 of 810 buttons across 21 leaf pages.
2026-09-24 04:30:11 +00:00
doxa-maps-88655c70
fc04f14a52 Let the key filter the map, so the gap can be looked at on its own
The map could say what one group has, on tap. It could not answer the question it
exists for — show me only the ones with nothing — so the 3,444 groups that still have
no resource were spread through 12,379 dots and could not be seen as a pattern.

THE KEY IS THE FILTER. A reader who has just worked out what a colour means is exactly
the reader who wants to see only that colour, so the legend entries became the controls
rather than adding a second row beneath them. Each carries its live count: 4,711 with
Scripture and film, 4,224 with some resource, 3,444 with nothing — summing to 12,379,
which is the check that the three buckets partition the set rather than overlap it.

FILTERED ON THE LAYER, NOT THE SOURCE. setFilter is a GL-side predicate, so all 12,379
features stay on the GPU and nothing is re-parsed or re-uploaded on a toggle. Rebuilding
the FeatureCollection per click would re-upload a megabyte to redraw a subset it already
holds.

Clicking an active filter clears it, and the state line names what is hidden with a
"Show all 12,379" out — a filtered map that does not say it is filtered is how a reader
concludes the data is missing.

Verified on the served tree: rendered features 12,379 -> 3,444 on the "nothing found"
filter, getFilter returns ["==",["get","tier"],0], and the only tier visible afterwards
is 0. All three buttons expose an accessible name and aria-pressed. The filtered view
shows the remaining gap concentrated in the Sahel, the Horn, north India and Nepal,
Myanmar, Indonesia and the Philippines.
2026-09-24 04:22:40 +00:00
doxa-maps-88655c70
f9808d0c8b Harvest the whole Arclight catalogue: film coverage 306 -> 2,087 languages
Same shape as the eBible finding, same cause. Arclight was ALREADY a checked pool and
only 306 language codes carried a film link, while the catalogue lists 2,330 media
languages with 2,087 carrying a feature film. Being inside a pool is not the same as
having been read.

COVERAGE: pins with a real URL 6,985 -> 8,935, 56.4% -> 72.2%. Language codes 2,068 ->
3,196. 1,950 pins lift off "nothing found" and 1,214 move to Scripture AND film. The
resource table grows 1,497 -> 3,043 rows across 2,307 languages.

★ THE VERIFICATION HAD TO BE REDONE, and that is the part worth keeping. The obvious
check is to sample watch URLs and confirm 200. Ten of ten passed. The check was
worthless: https://www.jesusfilm.org/watch/?languageId=<id> is a single-page app and
answers 200 for EVERY id — measured, `0` and `abcdef` both return 200. A check that
cannot fail proves nothing, and that one would have certified 1,781 links on a status
code that is always 200.

The API discriminates where the page does not: media-languages/<id> returns a real
counts.featureFilm for a genuine language and 404 for a bogus one. So existence is
established from the API and the watch URL is the deep link for a language already
confirmed. Re-verified that way: 10/10 sampled languages carry featureFilm >= 1, and
the negative control — a bogus id — returns featureFilm 0, so the check can fail and
does, on the case that should fail.

featureFilm >= 1 IS THE COVERAGE TEST, not roster membership: 21 of the 2,330 listed
media languages have no feature film and are dropped. Only featureFilm is taken;
Arclight also carries short films and other media that were not screened.

FILL, NEVER OVERWRITE, as with eBible — a curated film_url stands, and the catalogue
only supplies a link where there was none. 1,695 of the codes had no row in the
resources table at all.

Verified: all browser probes pass — pins, table, deep link — 12,379 pins rendered, and
the map now reads green across Papua New Guinea, Indonesia, Brazil and Central Africa
where it was red, leaving the remaining red concentrated in the Sahel, the Horn, north
India and Central Asia. The pin table is 1.1MB but 281KB gzipped; the four repeated
pool strings compress 4:1, so interning them would buy nothing on the wire.
2026-09-24 04:08:30 +00:00
doxa-maps-88655c70
4716e62ec5 Harvest the whole eBible catalogue, not a tenth of it
eBible was ALREADY one of the two pools this repo checked, and that is what made the
gap invisible: 120 language codes carried an eBible link while the catalogue lists
1,550 translations across 1,239 codes with canonical books. The pool had been read at
roughly a tenth of its content, and ~1,600 people groups were being told "nothing
found" about a published translation in their own language.

COVERAGE: pins carrying a real URL 5,349 -> 6,985, 43.2% -> 56.4%. Language codes
978 -> 2,068. The resource table grows 581 -> 1,497 rows across 1,314 languages. 1,090
of the new codes had no row in the resources table at all, which is why filling gaps
in the existing 862 would have missed most of it.

FILL, NEVER OVERWRITE. An existing scripture_url was curated and verified by an earlier
pass; the catalogue is broad but not hand-checked, so it only supplies a link where
there was none. Where the two disagree, curation wins.

CANON TEST: OTbooks + NTbooks > 0. A deuterocanonical-only publication is not the
66-book canon and is dropped; DC books ALONGSIDE OT/NT are not a reason to drop, since
the 66 are present either way. Measured on this snapshot: 3 rows carry no books at all.

DOMAINS ARE ALLOW-LISTED AFTER A SCREEN, because publicationURL does not always point
at ebible.org. Four domains appear and each was fetched and read: ebible.org (1,242),
png.bible (302, "Papua New Guinea Scriptures"), alkitab.pw (3, an eBible mirror), and
inscript.org (1) — EXCLUDED, because its page establishes no publisher or doctrinal
provenance and an unclear source is left out. A domain not on the list is dropped, so a
future catalogue update cannot introduce a publisher nobody screened.

TWO BROKEN LINKS CAUGHT STRUCTURALLY: nko and nlx publish 'http:///nkoNT/' — a
malformed URL with an EMPTY host, broken in eBible's own data. Requiring a non-empty
host rejects them; a looser check for the 'http://' prefix passes them happily, which
is how a "real" link that resolves nowhere gets shipped. badUrl and badDomain are
counted separately for the same reason — folded together, this hid.

Verified: the CSV parser was diffed against a reference parser across all 1,550 rows,
0 mismatches, so no URL is attached to the wrong language. A seeded random sample of 20
catalogue URLs resolved 20/20 at 200. All five browser probes still pass — pins, table,
deep link, Door43 lift, and 12,379 pins rendered — and the map now shows Papua New
Guinea, Indonesia and South America carrying resource colour where they were solid red.
2026-09-24 04:01:04 +00:00
doxa-maps-88655c70
ed27b40e66 Put the tables in the house palette instead of Tabulator's stock theme
Every table in the product was shipping Tabulator's DEFAULT theme — #333 header,
#666 rows, #444 filter boxes — against a house style that is Material LIGHT and has
no dark mode. Measured identically in both served consumers before the change:
row rgb(102,102,102), header rgb(51,51,51).

OVERRIDDEN BY SPECIFICITY, NOT BY ORDER, and that is the load-bearing part. The vendor
sheet is fetched and appended AFTER these rules, so any tie goes to the vendor. Each
override is written one class deeper than the rule it replaces: four for the column
header, five for the filter inputs, four for the paginator. Re-ordering the injection
would have been the fragile fix — it breaks the first time injectCss is made sync.

Getting that wrong is visible in stages, which is why it took three passes: at three
classes the columns stayed dark while the header went light; at four the filter inputs
were still #444 and the "Page Size" label was rendering white on a now-light footer,
invisible. The computed-style probe reported the header and row fixed and said nothing
about either — both were caught by looking at the screenshot.

Dark mode is left alone. .dt.is-dark is three classes and still beats these, so callers
using it keep it; this repo ships light only, but silently removing another consumer's
dark mode is not this change's business.

Blast radius named and checked: three consumers import DataTable — gospel-resources,
cities-internet and key-cities. The first two are the ones with a served table page and
both were re-checked, by computed style (row #fff, header #f3f3f1 in both) and by
reading both screenshots. key-cities ships no data-tables page, so it could not be
exercised live. The gospel-resources link and deep-link probes both still pass, so the
restyle did not disturb the anchors the table exists for.
2026-09-24 03:51:06 +00:00
doxa-maps-88655c70
dfb4f755be Carry the map selection into the resource table
The table listed every link but had no idea what you had just tapped, so answering
"what exists for THIS group" meant reading a language name off the map and typing it
into a filter box. The map and the table are separate PAGES, so the selection travels
as a URL: ?tab=links&iso=<code> from a people group, &country=<ISO3> from a country.

FILTERED IN THE PROFILE, NOT IN THE SHARED COMPONENT. DataTable has other consumers
and none of them need a filter prop; teaching it one would put a change with repo-wide
blast radius behind a feature only this bundle uses. The rows are filtered before they
are handed over, which is invisible to every other caller.

COUNTRY FILTERING WITHOUT A SECOND FETCH. Rows carry country_codes, the ISO3 set the
language is spoken in, so a country filter is a membership test on data already loaded
rather than pulling the 679KB pin table just to resolve which languages a country has.
+23KB for the column, against 679KB avoided.

THE EMPTY CASE SAYS WHY. A selection with no match renders "nothing found for this
selection in the pools checked, which is not proof that nothing exists" rather than an
empty grid, which would read as a broken filter or, worse, as proof of absence.
Switching to a tab that has no language key says so too instead of showing zero rows.

Verified end to end on the served tree: clicked a real pin, followed the link THE SHEET
RENDERED rather than one built in the test, and landed on 2 rows both carrying cmn with
"Showing 2 of 581 resources for Chinese, Mandarin". Country path checked the same way —
Nepal 51, Chad 22, unfiltered 581 with no banner, and an unmatched code showing the
honest empty state.
2026-09-24 03:35:35 +00:00
doxa-maps-88655c70
410f101846 A table of every resource and its link, keyed by the resource not the group
Every table in this bundle is keyed by PEOPLE GROUP and answers "who has nothing".
None of them answered the question a mobiliser asks first: what exists, what is it
called, and where do I open it. Answering it from a group-grained table means reading
the same JESUS Film link off forty rows.

581 rows, one per language x resource, across 413 languages, together reaching 5,349
people groups — the same 5,349 the pin build counts as carrying a real URL, so the two
tables cannot drift without one of them being wrong.

Reach is MEASURED, not assumed: people_groups, speakers and countries are aggregated
across the pins that actually speak that language, so a row says how many people that
one link serves. Sorted by reach, because the link serving the most people is the one
to open first.

The column is named resource_url because DataTable keys on /_url$/ to render a real
anchor instead of printed text. That is a contract with the shared component, not a
naming preference — rename it and every link silently becomes a string.

TYPES STAY HONEST. 146 Scripture, 306 Film, 129 Bible Stories. Open Bible Stories is
typed as Bible Stories and never as Scripture, and the meta says why. The pools caveat
and the doctrine boundary both render under the table rather than living in a file
nobody opens: only 66-book-canon publishers are included, and the excluded ones are
named so their absence reads as a rule rather than an oversight.

Verified at 1280x900 on the served tree: the tab loads first, renders all ten columns
and 50 rows a page, and 50 rows carry real anchors to ebible.org, jesusfilm.org and
door43 — clicked through from the page, not read out of the JSON.
2026-09-24 03:25:13 +00:00
doxa-maps-88655c70
af10d727c4 Add Door43 as a third pool, so 240 groups stop reading "nothing found"
Two pools had ever been checked — eBible and JESUS Film — and the map said "nothing
found" for everyone outside them. For 240 people groups that claim was simply untrue:
unfoldingWord publishes a released resource in their language and we were not looking.

WHAT WAS ADDED. A dated snapshot of the Door43 catalog (stage=prod only) keyed by ISO
639-3, 165 codes across Open Bible Stories, Bible and Aligned Bible. Cached rather than
fetched live so the pin table is reproducible offline and does not depend on someone
else's uptime; the source URLs travel in the file.

stage=prod IS THE WHOLE CARE. Door43 LISTS more languages than it HOSTS, and this repo
already recorded that trap — "roster membership is not coverage". Released entries only,
and the result reconciles cleanly against what was already known: of 38 languages
previously flagged as having Open Bible Stories, 38 are confirmed and 0 contradicted.
It also found 11 flagged false that do have a published resource.

THE BIGGER HALF WAS INVISIBLE. 116 of the 165 codes have no row in
people-group-resources.json at all, so there was no record to hang a link on and their
pins stayed "nothing found" no matter what Door43 published. Those codes are now created
from the catalog, which is where 213 of the 240 lifted pins come from.

COVERAGE: pins carrying a real URL 5,109 -> 5,349 (41.3% -> 43.2%); codes 862 -> 978.
Tier 1 now means SOMETHING was found rather than "one of scripture/film", because that
is what the colour has to mean once a third pool exists. Tier 2 still means Scripture
AND film — a story set does not get promoted into a Bible.

OBS IS LABELLED AS STORIES. The chip reads "Bible Stories · Door43", never Scripture:
Open Bible Stories is 50 illustrated narratives drawn from the canon, and telling a
reader they have a translation when they have a story set is the one lie this map
cannot afford. 2-letter and region-suffixed tags are DROPPED rather than guessed into
639-3 — a wrong language match ships a real link to the wrong people.

Verified on the served tree: the Kir people group (Mandeali, mjl) previously showed
nothing and now renders a real anchor to git.door43.org/OBS-TLF/mjl_obs, which answers
200. The existing pin probe still passes 12,379 rendered with both Han Chinese links
at 200, so the new pool added coverage without disturbing the old.
2026-09-24 03:18:27 +00:00
doxa-maps-88655c70
0ebde03046 Say what a dot means, and that it is not what the country means
The pins landed with no key, so the map shipped two colour systems using the SAME
four hexes for two different claims and nothing telling them apart. A country is an
AVERAGE across its groups; a dot is one group's own language. Red-on-green is the
most interesting case on the map — a group with nothing found inside a well-supplied
country — and with no key it reads as a rendering fault instead of a finding.

The key says so in those words, and the SHAPE now carries the level: squares for the
country average, circles for a single group. Shape is the honest separator here,
because the hexes are deliberately shared — a dot and its country agreeing on what
"nothing found" looks like is the property worth keeping.

Counts are computed from the layer rather than written down: 12,379 dots, 5,109 with
a link. A number typed into a legend rots the first time the data moves.

Verified at 1280x800 on the served tree: the legend renders both key blocks, quotes
12,379/5,109 matching the generator's own tally, and the pin probe still passes
12,379 rendered with both Han Chinese links answering 200.
2026-09-24 03:05:22 +00:00
doxa-maps-88655c70
b05d1f13b4 Pin every people group, and link what exists in their own language
The map shaded COUNTRIES and stopped there, so the one question it could not answer
was the only one that matters in the field: for THIS group, what exists right now?
12,379 people-group pins now sit on it, each one clickable into the resources for
its language.

THE JOIN. people-groups-slim.json carries Lat/Long per people-group-in-country;
people-group-resources.json carries the real scripture_url/film_url per LANGUAGE.
The key is ISO 639-3 (slim.LanguageCode == resources.iso639_3), never the group
name — a group is reached in its language, not its nationality, and names collide
across unrelated languages.

MEASURED at build time: 5,981 of 12,379 pins (48.3%) join a resource record and
5,109 (41.3%) join one carrying a real URL, across 862 language codes. The other
59% is not a gap in the data to be hidden — it IS the subject. A pin with nothing
found sorts ABOVE a covered one, so the gap wins the z-fight at world zoom rather
than disappearing under a green dot.

NO URL IS INVENTED. Every link is copied from the resources table, which records
its own checked_pools/unchecked_pools, and the sheet keeps saying "none found
means absent from the pools checked, never proof that nothing exists". has_audio
keeps its third state: absent means the pool was never queried, not a no.

PIN COLOURS READ OFF BANDS rather than declaring a fourth palette, so a pin and
the country under it can never disagree about what "nothing found" looks like.

The table is generated into data/tables/ — the REPO ROOT copy, which is what
generate-tree publishes. The plugin-local data/tables/ is a stale divergent
duplicate that nothing serves; building into it ships a file no page fetches.

Verified on the served tree at 1280x800: 12,379 pins loaded AND rendered, layer
and source present, and a click on Han Chinese returns ebible.org/cmn2006/ plus
jesusfilm.org languageId=53352, both rendered as real anchors in the sheet and
both answering 200. window.__grState() exposes those counts so the next check does
not have to guess at screen coordinates to hit a 3px dot.
2026-09-24 03:02:06 +00:00
doxa-maps-88655c70
1d4fe185e3 Full table headers via horizontal scroll, and a full-screen table
Some checks are pending
build-gate / build (push) Waiting to run
Driver, on the people-groups table under the map: "I would like to add a
horizontal scroll bar so that instead of ABBREVIATING the headers, we can have
the FULL headers in the table", and "I would also like to add a FULL SCREEN
button to the table, so that if you want you can open up the table to be full
screen."

These ship together on purpose. The table now shows seven data rows by default
because we are reclaiming vertical space; a deliberately small table is only an
honest trade if the reader can get a big one on demand. The cap and its release
are one change.

HEADERS ARE NO LONGER ABBREVIATED. text-overflow:ellipsis is gone from .rd-th.
Ellipsis destroys information with no way to recover it -- "Engageme..." is not
a column name, and unlike a truncated VALUE it has no menu behind it. Horizontal
scroll preserves it: the full text always exists and is always reachable. This
is the same mechanism already applied to the chart legends (nowrap +
overflow-x:auto), deliberately reused rather than a second invention.

THE FLOOR IS MEASURED, NOT ESTIMATED. Columns are flex:<w> 1 0, which can never
overflow -- flex-basis 0 plus shrink means every column collapses to whatever
the container offers, so scrollWidth always equalled clientWidth and overflow-x
had nothing to scroll. Each column now carries a min-width derived from its own
header. A first pass guessed 7.1px/char, which put the summed floor at 675px
against an 878px scrollport, so it never bound and no scrollbar could appear at
any width. Measured live by cloning each header out of flex flow: worst case is
9.75px/char (Language 78px/8ch), so 9.9 covers every label, +12px reserves the
sort indicator. Summed floor 742px + 30px checkbox.

That floor sits BELOW the 1440px scrollport on purpose. At 1440 the eight
columns genuinely fit and there is nothing to scroll; forcing a scrollbar there
would invent a defect. It binds at 1280 and below, which is exactly where the
truncation being complained about was happening.

ROW WIDTH IS SHARED, NEVER INTRINSIC. An intermediate pass used width:max-content
to create the overflow. It did -- and sized every row to ITS OWN content, so a
row holding "Lao People's Democratic Republic" laid out wider than the row above
it. That is not a table but a stack of independent flex lines: screenshot-
verified ragged, with Country/Region/Language stepping left and right on
consecutive rows while the header, correctly aligned, disagreed with all of
them. The measurement said so too and was read past: scrollWidth was 969 at the
top of the list and 1051 after scrolling, i.e. the table's width depended on
which rows happened to be rendered. Head, rows and footer now share one explicit
width (summed floor as min-width, width:100%), so nothing is intrinsic and the
column grid holds at every scroll offset.

The sticky header stays pinned on the vertical axis only, which is correct: it
must travel sideways with the body or the labels detach from the columns they
name. The footer is a SIBLING of the scrollport, so it is translated in lockstep
from the scroll handler -- otherwise the population total slides out from under
its own column.

FULL SCREEN is CSS pseudo-fullscreen, not requestFullscreen(). The native API
promotes the element to the browser's top layer, detaching it from the cascade
this table is built on, and needs allow="fullscreen" to work in the iframe embed
this dashboard actually ships in. Pinning the wrapper with position:fixed keeps
the element where it is in the tree, so inherited tokens and ancestor rules keep
applying and only the two rules we mean to override are overridden. Esc exits.

The seven-row cap is released with max-height:none !important, and !important is
the honest tool rather than a specificity war: the first attempt lost 0-2-0 to
the shell's 0-3-0 selector and rendered a 900px box containing a 182px table --
the exact failure its own comment warned about. That shell rule is also being
rewritten concurrently, so matching its selector would break on the next edit.

library/components/map-controls/FullscreenButton.vue was read first and does not
fit: it resolves its target as the SHADOW HOST, so pointed here it would
fullscreen the whole dashboard rather than the table; it renders MapControlButton,
a circular control built to float over a canvas; and it depends on vue-i18n,
which this bundle does not use. Making it fit means adding a target prop and a
chromeless variant to a component with seven map consumers to serve one non-map
caller, so this stays local until a second table wants it.

Verified at 1280 and 1440, real browser, live data: headers complete with
text-overflow clip and no cell clipped; scrollWidth 867 vs clientWidth 718 with
the last column's left edge moving 1045 -> 896; rows recycle (abai-sungai ->
deaf-comorans) with the rendered count bounded at 14; zero ragged columns and
zero head-vs-row drift at every scroll offset; fullscreen 244 -> 900px with the
keyhole 182 -> 838 and 45 rows rendered, restoring exactly on exit.
check:fits-screen 17/17.
2026-09-23 20:25:08 +00:00
doxa-maps-88655c70
afe6abe556 Plugin studio: show what a composite is made of, in one panel
Some checks are pending
build-gate / build (push) Waiting to run
A "Composed of" panel now sits under the plugin title, above the live
preview. For a composite it lists one row per directly-nested child:
name, element tag, and a capability badge, each row a link to that
child's own entry. A leaf plugin shows no panel.

Composition is no longer navigation. The rail had carried a collapsible
folder per composite, which re-listed children that already had their
own home — people-groups-grid held six nav nodes, so there was no single
name to call it by when reporting a problem. The rail is back to one
node per plugin in its own capability tier (31 nodes, 31 manifest rows,
none repeated) and the relationship lives on the plugin's own page.

Depth is one. A nested composite shows as a single row and does not
expand its own children; its breakdown is one click away, on its page.

The badge reads the manifest's declared profile type, so retyping a
profile moves its badge with no code change here. A declared child that
is published nowhere is counted as "not published" beside the heading
rather than dropped in silence — big-picture names bp-research-header,
which is an internal part, not a plugin. The list caps its own height:
the layout's header row is auto-sized, so ten uncapped rows would have
taken height from the preview instead of overflowing.

Rows are buttons with descriptive aria-labels, reachable and activatable
by keyboard. No emoji glyph on a row: this box has no emoji font, so
every one rendered as an empty box, and the badge says it better.
2026-09-23 20:21:48 +00:00
doxa-maps-88655c70
a69386582c Reclaim height: seven data rows, slimmer headers, charts scroll sideways
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "we are trying to reclaim height."

TABLE = SEVEN DATA ROWS, stated in ROWS rather than pixels. The old formula
derived the keyhole by subtraction (shell - map - bar - foot), so the row
count was whatever fell out -- measured 300px = 11 rows. Now
calc(var(--rd-table-rows,7) * var(--rd-table-row-h,26px)), so the promise is
the declaration: change the row height and seven rows stays seven rows.
His own arithmetic settles an earlier ambiguity (he had said both 77 and 7):
seven is DATA rows, excluding field, footer and table header.

HEADERS 137px -> 125px: topbar 52 -> 44, stats 30 -> 26, both now vars so a
host tunes them in one place instead of three grid-template-rows that must
agree.

CHARTS scroll sideways instead of wrapping. This is the third pass on that
trade and it resolves rather than reverses it: pass 1 was nowrap + ellipsis +
overflow-x HIDDEN, so labels were truncated and unrecoverable -- the defect
he rejected. This is nowrap + overflow-x AUTO, so the full label still exists
and is reachable. Legend cap 176 -> 120.

Measured at 1440x900: grid-template-rows 44/55/26/476/244, keyhole 182px,
exactly 7 data rows, shell 863 -> 849 with the map gaining 116px.
2026-09-23 20:03:52 +00:00
doxa-maps-88655c70
e5c66272c2 Tighten the vertical legend's row spacing without touching the type
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "the spacing between the rows is too thick, they take up way too much
height -- the padding should be decreased per row."

Row vertical padding 3px -> 1px, min-height 22px -> 18px, section header
padding 8px -> 5px.

Font-size is deliberately untouched at 12px. It is not a lever here: the
Driver previously rejected shrinking it ("super tiny text that cannot be
read"), and the note beside .fr-lbl records that the width for the count came
from the bar and the gutters for the same reason. Only whitespace moved.
min-height 18px still clears a 12px glyph at line-height 1.3, so no descender
is clipped.

Measured live at 1440x900 with four sections open, 69 rows rendered:
  row height 24px -> 20px
  computed padding 1px/1px, font-size 12px unchanged
  rail content height 1966px -> 1424px (542px saved)
2026-09-23 19:58:39 +00:00
doxa-maps-88655c70
02baa2d42b Switch the choropleth from heartbeat-red to the greyscale ramp
Some checks are pending
build-gate / build (push) Waiting to run
Driver ruling: "instead of the red, we need to use the grey scale from grey
to black." The greyscale preset was built earlier at his request as a
swappable alternative; this is him taking the swap.

One config key moves both the choropleth and its legend together, which is
why it was built as config rather than scattered hex. Ramp is
#e8e8e8 / #a9a9a9 / #6f6f6f / #3a3a3a / #060606.

Verified in the shipped bundle: colorRamp "greyscale", the five stops
present, sfc-parse passes.

Worth knowing for the follow-up: the colour research measured the ramp
change as roughly neutral for pin visibility (worst case 1.96 greyscale vs
1.84 red). The real fix for pin contrast is the lightness-conditional stroke
-- dark ring under a light pin, white under a dark one -- which lifts the
worst case to 4.51 and works on either ramp. Not done here; that is its own
change.
2026-09-23 19:57:08 +00:00
doxa-maps-88655c70
d01951bdd6 dev server: answer the production embed URL instead of silently serving the studio
Some checks are pending
build-gate / build (push) Waiting to run
A copied embed URL — <bundle>/<profile>[/<instance>]/index.html, the shape the
manifest advertises and a person actually pastes — had no route on the dev
server, which served its leaf stand-in only at /__leaf/<bundle>/<profile>.

It failed silently, which is the expensive part: with no route, Vite's SPA
fallback answers an unknown path with the studio's HTML at status 200. The
reader asks for one map and receives a different, working page, with no 404 and
nothing in the console.

leafRewrite() maps the embed shape onto the existing /__leaf/ renderer before
any branch runs, so there is one renderer, not two that can drift — the point of
the route is that one URL renders the same thing in dev and in the built tree.
Both the bare tree-relative form and the /<treeName>/-prefixed form are accepted;
the tree name is read from the build config rather than hardcoded. Traversal,
encoded traversal and malformed escapes fall through untouched, and an unknown
bundle keeps the leaf route's 404 JSON rather than becoming HTML at 200.

Verified: /doxa-simple-map/doxa-simple-map/index.html and
/__leaf/doxa-simple-map/doxa-simple-map return byte-identical bodies (742 B);
the prayer URL mounts doxa-map at 1280x800 with a live canvas in a real browser.
2026-09-23 19:53:56 +00:00
doxa-maps-88655c70
ca4343c25d Regions mounts its cap-4 composite; UPG-100 drops its duplicate share and shrinks its dots
Some checks are pending
build-gate / build (push) Waiting to run
REGIONS WAS RENDERING AS A CAP-1. The regions-mfe composite (nested-elements-mfe
over regions-list + region-dashboards, with the shared Select View strip) had
shipped, but the shell entry still named the bare `regions-list` leaf -- the
cap-1 half -- so one flat list rendered and the composite sat unused. The cause
was in the shell, not the regions bundle. Compare the UPG-100 entry, which names
`upg-100-mfe` and is why that tab has tabs.
Measured in the shell after: Select View present, options "Regions List" /
"Region Dashboards".

DUPLICATE SHARE BUTTON. The map carried its own ShareButton while the composite
header already mounts one. Removed the map-level control here rather than in the
shared component, because sharing is per-map policy: Prayer Progress is the map
meant for embedding elsewhere and keeps its own button; a map only ever seen
inside a composite does not need one. Unused import dropped.

DOTS WERE OVERCORRECTED. An earlier pass raised z=1 radius 4 -> 6 to fix "hard to
see". Sound reasoning, wrong amount: it made this the largest-dotted map in the
set (all-people-groups is 2.2 at z=0). Now 5 at z=1, keeping a deliberate step
above the dense map without leaving the family; the halo carries visibility.

Gates: template-literal, sfc-parse, sri all pass.
2026-09-23 19:37:55 +00:00
doxa-maps-88655c70
1cf4e89dab Resolve a composite's child bundles against the tree root, not a guessed depth
Some checks are pending
build-gate / build (push) Waiting to run
Driver-reported console text:
  Could not load <doxa-research-map> from ../../doxa-research-map/doxa-research-map.js

A composite declares children as src: '../../<bundle>/<bundle>.js'. That literal
is right from a one-deep leaf and wrong from a two-deep one, and the generator
emits both:
  .../research-map-plus-grid/index.html          ../../ -> /doxa-maps/          200
  .../research-map-plus-grid/default/index.html  ../../ -> /doxa-maps/rmpg/     404

So the path encoded an assumption about the host's depth that the host does not
control and cannot see. It survived because every gate exercised the shallow
leaf, which is genuinely fine.

StackLayout now derives the tree root from the document's own pathname (up to
and including the /doxa-maps/ segment) and rebuilds the path from there --
depth-independent by construction, so a third leaf depth cannot reintroduce it.
Absolute, protocol-relative and root-relative srcs pass through untouched, and
a missing /doxa-maps/ segment returns the original string, so this can only fix
a path, never break a working one.

Measured, both leaves, real browser with live data:
  one-deep  mapEl=true mapH=792 canvasH=856 0 4xx
  two-deep  mapEl=true mapH=792 canvasH=856 0 4xx   (was 404, map absent)

Also carries source that had been left behind by its own built output:
the cities/gospel legend bounding (shipped in 5008a6f as output only) and the
per-profile config carrier in generate-tree.mjs, plus a new fits-screen clause
that wheels inside every column rather than one.
2026-09-23 19:34:28 +00:00
doxa-maps-88655c70
d97cf9eab5 Say why a tab could not load instead of painting an empty grey pane
Some checks are pending
build-gate / build (push) Waiting to run
The Simple Research tab rendered as a featureless grey rectangle with no
message, no overlay and nothing in the console. It reads as a broken app,
and it hid a plain missing-file condition through four separate reports.

Two silent failure paths produced it, one layer apart:

- doxa-maps-spa swallowed a failed manifest.json fetch. Every tab except
  Big Picture is composed from that manifest (Big Picture alone is baked),
  so losing it leaves each tab with no scriptUrl. mountTab then returns
  early and the pane stays in state 'idle' -- which renders neither the
  loading hint nor the error hint. The manifest fetch now tries the built
  tree location as a second candidate and, if every candidate fails, sets a
  message the pane displays.

- StackLayout's per-section catch deliberately left a failed section's host
  empty so the rest of the stack still renders. Keeping the stack alive was
  right; leaving the band blank and silent was not. A failed section now
  shows which element failed and which URL it came from, and still does not
  abort its siblings.

Tree-relative scriptUrls are also resolved against the manifest they came
from rather than the current document. The two roots are identical in the
built tree and differ elsewhere, which turned every composed tab's bundle
into a 404 against a path that exists nowhere.

Measured, built tree: research-map-plus-grid mounts both children, 4778px
of content, live canvas, zero 4xx -- unchanged. dashboard-plus-grid,
stack-demo and research-map-plus-grid all render with a painted canvas and
no failure cards. Where the bundles genuinely cannot be reached, the pane
now names the element and the URL instead of showing grey.
2026-09-23 19:14:27 +00:00
doxa-maps-88655c70
bca3d58c3e Regions element gets the Select View tabs, shared with UPG-100
Some checks are pending
build-gate / build (push) Waiting to run
The Regions bundle shipped only tab 1, so the Select View switcher the
UPG-100 list shows had nowhere to live. Adds the composite that carries
it over two panes -- Regions List and Region Dashboards -- plus the
Region Dashboards tab itself, which summarises a region or a country
from the same live endpoint and the same rollup tab 1 uses.

Clicking a region heading or a country row on tab 1 now forwards that
selection and swaps to tab 2 with it applied.

The switcher itself is extracted into library/components/ViewSelect.vue
rather than copied a third time: it had two implementations already, in
the app shell and hand-copied into upg-100-mfe. It takes the views and
the current id as props and knows nothing about maps or panes, so the
placement stays with each header. UPG-100 now renders from the shared
component and is unchanged on screen -- same 45px height, 13px labels,
8px radius.
2026-09-23 19:08:00 +00:00
doxa-maps-88655c70
3e95c6bbee Dashboard height handle follows the cursor instead of snapping 144px
The code for this landed inside the preceding commit -- a concurrent worktree
staged it before this commit could run -- so this records what changed and how
it was measured.

Dragging the bar at the dashboard's bottom edge added about three inches of
height on the first pixel of movement, then tracked correctly afterwards.

--rd-shell-h is not the element's height. The shell is height:auto when it is
page-scrolling, and the variable's only consumer is the table grid's
max-height: calc(shell-h - map-h - bar - foot), so it budgets the map and the
table and excludes the 141px of chrome stacked above them. The drag treated the
cursor's Y position as the new height, which published a budget with the chrome
counted twice: the grid's ceiling jumped 300px to 444px on the first frame and
the element grew 144px with it.

The drag now anchors on the budget the stylesheet is actually resolving and
adds the pointer's delta, so the first frame re-publishes the value already in
effect and every later frame moves 1:1 with the cursor. That anchor reads the
computed variable when one is set, because the budget is a ceiling rather than
a floor and the element can render smaller than it -- a restored 983px budget
measured 743px on screen, which had the first frame snapping 239px the other
way. It falls back to measuring map + table only when no variable is set, since
the stylesheet's 80svh default cannot be read off the stored number at any
viewport other than 900px tall.

Measured at 1440x900 with dispatched mouse events, with and without a stored
layout: 1px moves the edge 1-3px, 120px moves it 120-122px, dragging back up
100px shrinks it 100px, and the height holds after the button is released.
2026-09-23 19:07:55 +00:00
doxa-maps-88655c70
5008a6ff5f Bound the cities and gospel legends so the map controls stay clear
Some checks are pending
build-gate / build (push) Waiting to run
Both maps drew their legend as a full-width strip (left:8px right:8px --
measured 1264px of a 1280px map, 98.75%), which ran underneath the floating
control rail. MapToolbar sits at inset-inline-end:3px with 32px buttons, so
any box reaching right:8px intersects it at every viewport; the rail's higher
z-index kept the buttons clickable, which is why the defect survived three
reports as "looks broken but works". Gospel ran under four of five buttons,
cities under two, the difference being only legend height.

The legend is now a bounded, left-anchored panel imitating the shared
SemanticTreeLegend: left:8px with width var(--map-legend-width,380px). Being
bounded on the left, it cannot reach the right rail at all -- a geometric
guarantee rather than a tuned z-index. Below 480px it keeps a 46px gap so the
rail still clears. Measured after: legend 380px of 1280 (29.7%), zero
intersection with all five buttons, 857px clearance, and a dispatched click on
the top control landing on button.mcb via composedPath. Mobile at 390px: 336px
wide, 11px clearance, zero intersections. Both files kept byte-identical.

Both maps also showed the shared "Tap a coloured dot" hint while shading
country polygons and drawing no dots at all -- a hint pointing at something the
reader cannot see. PinHint documents a pinHint:false opt-out, but the
standalone leaf page built its config from a hardcoded object, so no profile
could reach that key. <profile>.meta.json now carries an optional `config` map
merged into the leaf's BASE_CONFIG, with generator-owned keys reserved and the
carrier stripped before the manifest is written so its published shape is
unchanged. Both maps opt out; the hint is gone from both.
2026-09-23 19:07:13 +00:00
doxa-maps-88655c70
988b1cef87 Share menu shows the whole embed snippet, with no scrollbar
Some checks are pending
build-gate / build (push) Waiting to run
The code box was max-height:90px + overflow-y:auto, which clipped a ~4-line
embed snippet to about two and a half lines and put a scrollport inside a
popover. Wrong trade for this content: the box exists to be read and copied
in one glance, and user-select:all means a single click selects the whole
snippet -- a scrollport fights that gesture, because the selection extends
into content the reader never saw.

height:auto lets the snippet size itself. The popover is short-lived and
anchored, so the extra lines cost nothing.

Measured on the demo-map leaf after opening the "Add to Your Website" tab:
max-height none, scrollHeight 110 == clientHeight 110, scrolls false.

Library change, so it reaches every bundle that mounts the share control on
the next full build.
2026-09-23 19:01:14 +00:00
doxa-maps-88655c70
5520b32173 Size the UPG-100 dots for a hundred marks, not twelve thousand
Some checks are pending
build-gate / build (push) Waiting to run
At world zoom the dots were radius 4 with a 1.2px halo -- the same scale
all-people-groups uses for ~12,379 marks, where density itself carries the
signal. This map draws ONE HUNDRED, alone on a whole planet, so the same
radius reads as almost nothing.

Radius 4 -> 6 at z=1 (+50%), tapering back to the original 9 by z=8 where
geography supplies the context. Halo widened 1.2 -> 1.8 in step, so it stays
a frame rather than a smudge: a 1.2px ring around a 6px dot is
proportionally thinner than around a 4px one, which would have spent the
size increase and lost the contrast that made small dots findable at all.

Verified in the shipped bundle: new interpolation present, old values gone,
0 data 404s, legend mounts, canvas at real height.
2026-09-23 18:47:36 +00:00