The gospel map rendered identical English under every locale — measured, 3,018
words in en and in ar alike and not one non-Latin character. Its short UI labels
now come from library/i18n/locales/<lc>/gospel.json: the two mode buttons, the
search placeholders, the legend title and its eight colour bands, the four tier
filters, the five gap tabs, the twelve country and group stat rows, the sheet
controls and the dot tally. Key parity across all eleven.
The explanatory prose stays English on purpose and is marked dir=ltr where it
sits. It carries doctrinal and statistical precision — which pools were
searched, what "nothing found" does and does not claim — and a machine
translation there would read as authoritative while being wrong. One translated
line says so in the reader's own language, and it is absent in English, where
saying it would be noise.
Two things Arabic taught. Numeric ranges needed explicit LTR isolates: 30-59%
rendered as 59%-30 inside RTL text. And the dot-tally sentence was pinned LTR
first, which pushed it out of the panel and clipped "with a link" against the
left edge — it is a count, not doctrine, so it went into the catalogue and the
bidi problem stopped existing.
Choosing a language in the studio translated the plugins it mounts but
left the studio itself in English: the title, the count, the filter box,
Collapse all, the section names, Copy embed, the loading and error
messages, and the rail's own Collapse button and aria labels.
The studio's strings now live in library/i18n/locales/<lc>/studio.json
for all 11 locales, held to key parity by check:i18n-parity like every
other catalog. The studio has no vue-i18n instance, so it looks strings
up directly (locale, then English, per key). The Explorer tree takes
them through a `strings` prop. VerticalTabsLayout gains an optional
`labels` prop for its five words; hosts that do not pass it keep the
English defaults, so doxa-maps-spa is unchanged until it opts in.
Verified by switching the studio through every language its switcher
offers (en, es, fr, pt, ar, ru): no English left in the studio chrome.
Technical names (CAP n, element tags, tier machine names in tooltips)
stay as they are.
With no assetsBase in the profile-config, the map fell back to '/assets' and
asked the host site for people-groups-slim.json. That is a 404 on any partner
page, so a script embed drew a basemap with no groups on it. It now resolves the
base through resolveAssetsBase(), the same path the other bundles use, which
finds the asset pool inside the tree the script came from.
Measured with the tree served under /plugin-assets/doxa-maps/ and a partner
page on the same host: main requested /assets/... and got 404; this build
requested /plugin-assets/doxa-maps/assets/... and got 200, drawing all 12,379
locations. The leaf page and the Research Gallery tab load on both builds.
createAppI18n sets document.documentElement.dir=rtl for Arabic, which is right
for a translated app. Every string in these five profiles is hard-coded English
— measured at ?lang=ar: identical word count to ?lang=en and zero non-Latin
characters — so the flip gained nothing and cost a great deal. On the built
tree it clipped the legend title's first letter against the left edge, turned
30-59% into 59%-30, moved every colour swatch to the far side of its label,
threw sentence-ending periods to the START of lines, and dropped the zoom
controls on top of the search box. Not untranslated: scrambled.
The five profile roots declare dir=ltr until their strings are translated, and
the comment says to take it off on that day. The host page's own dir is left
alone, as createAppI18n already intends.
Arabic now renders exactly as English does: 12,379 of 12,379 pins, full
choropleth, nothing clipped.
On mount the map centred its active tab with
btn.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' })
and 'nearest' does not mean "do not scroll the page". It means "scroll every
scrollable ancestor by the least amount needed", and on a partner page the HOST
is an ancestor. Measured by the embed sweep: a page carrying this map below the
fold jumps to y≈640 the moment it mounts — on a phone, where Simple Research is
the default tab. The reader asked for nothing and the page moved under them.
The tab strip now scrolls ITSELF: compute the offset that centres the active
button inside its own box, clamp it to the strip's own scroll range, and write
scrollLeft. Same shape as SemanticTreeLegend's bringRowToTop, which scrolls the
container it found rather than asking the platform to walk ancestors. An element
that only ever writes its own scrollLeft cannot move anybody's page.
The exemption goes in the SAME commit, per that gate's contract — main never
carries a stale exemption or a red gate on someone else's bug. Both
NEIGHBOUR_KNOWN entries (research-map, research-map-plus-grid) are deleted here.
MEASURED after, with the exemption gone: check:gallery-embed PASS, and the
neighbour phase now reports "research-map leaves the host page alone" and
"research-map-plus-grid leaves the host page alone" on its own evidence — js 13
tabs, iframe 13 tabs, neighbour 13 tabs. The two KNOWN lines still printed belong
to regions-dashboard and are theirs to clear.
gospel-dashboard.vue declared @element gospel-resources-dashboard. index.js
registers exactly one custom element, gospel-resources-map, and every profile is
selected through its profile-config — which is why all five generated leaves
embed gospel-resources-map and work. The manifest reads that JSDoc line, so the
catalogue offered consumers a tag that resolves to nothing: copy the snippet
into a page and get an empty box. Invisible in dev, where the leaf HTML is what
loads, and only findable on a built tree.
Also retires the numbers shipped alongside it, which described a different
dataset: 3,457 groups with nothing became 1,613 and 347 million people became
208 million when the GRN sweep finished, and the tables profile still called
itself Eight tables over 3,550 resources when it carries eleven over 8,437
across 4,231 languages. The manifest is what a consumer of this map reads first.
Five codes still carried the retired ?languageId= URL, which nothing can verify.
Asking Arclight what each language actually has:
ars Arabic, Najdi The Savior (8 groups; film was their ONLY resource)
gjk Kachi Koli Magdalena - Director's Cut
mzn Mazanderani Magdalena - Director's Cut
lia Limba, West-Central no feature at all
jos Jordanian Sign Lang left alone, see below
counts.featureFilm >= 1 says a feature exists, not that it is JESUS. Three of
these had a real JESUS Film Project film in their own language under a different
title, and the map called all of them the JESUS Film — the same class of lie as
calling Open Bible Stories Scripture. They now ship under their own names, with
verified deep links; lia's film is dropped, because no component carries it and
a link nobody can check is worse than none.
jos is left exactly as it was. Arclight files iso3 jos under a row whose own
name reads Lebanese Sign Language, while the JESUS Film sits at a different id
named Jordanian Sign Language. The ISO join and the publisher's label disagree,
and resolving that is a data question, not a guess to make inside a fetcher.
Two gaps found by opening every plugin in the studio under the dev
server:
- key-cities is gated (vite.doxa-maps-build-config.json gatedProfiles)
out of every public surface the build emits, but the dev manifest
listed it anyway, so the dev studio and the Research Gallery's
Experimental shelf showed a plugin the built tree deliberately hides.
The dev model now applies the same gate: 21 bundles, 33 profiles, as
in the built manifest. readBuildConfig() does not carry gatedProfiles,
so the gate is read from the config file, as generate-tree does.
- golden-path-check takes its tree base from a tree-base attribute, not
from profile-config. The studio only passed the latter, so inside the
studio the check guessed the tree from the page URL, fetched
/manifest.json, got a 404 and showed an empty self-test. The studio
now sets tree-base on every element it mounts; elements that do not
read it ignore it.
Dev sweep after: all 31 listed plugins mount with content, no page
errors, no failed requests. Rebuilt the plugin-studio bundle and
re-baked its SRI.
Embedded below the fold on a phone-width host page, the gallery disturbed
the page it was a guest on:
- The host's inherited typography flowed into the gallery. Shadow DOM
isolates selectors, not inheritance, so a host with body{font:20px/3;
letter-spacing:2px} re-flowed every rail row and legend (root measured at
20px / 60px / 2px). :host now resets the inherited text properties to the
gallery's own defaults (16px / normal / normal). Direction still inherits,
so RTL hosts stay RTL.
- Nested maps wrote the host's URL and title. The gallery now tells every
child urlSync:false. Regions needs to honour it (routed).
check:gallery-embed gains a good-neighbour phase: phone width, gallery
below the fold, hostile host CSS, the host's own #anchor and <title>. After
load and after every tab it asserts no host scroll, an unchanged URL, an
unchanged title, and no inherited typography. Two nested-plugin violations
are listed, owned and printed in NEIGHBOUR_KNOWN until their owners fix
them (same contract as MAPBOX_KNOWN):
- research-map.vue:647's scrollIntoView jumps the host to y=640 on load on
a phone.
- Regions' unconditional URL/title sync.
A boot timeout is now a named FAIL instead of an uncaught crash. The gate
caught a real one on the way: a rebuilt bundle whose leaf page still
pinned the old SRI hash.
It crashed on ENOENT — app/doxa-maps/gospel-resources/assets/tables/, a tree
deleted when data moved out of public/. Nothing noticed, because nothing ran it:
it had no npm script, so it was neither discoverable nor wired anywhere.
The dead path was the smaller half. Its other input was a private by-country
copy under plugin-designs/, and its people-group table was the curated 2,106-row
subset that resources-map.vue stopped fetching some time ago — it says so in its
own comments; the country sheet is built from the 12,379-pin index now. Repaired
in place it would have gone green about a surface it was no longer looking at.
Both inputs are the live tables. The country join is ISO3 instead of a substring
test against a comma-joined name blob, which the file's own comment admitted was
under-counting because country names contain commas. It reads 242 of 242
countries resolving to a pin and 8,435 sheet links, through the same flattener
the sheet renders from, and its self-test now requires four corruptions to go
red rather than two.
Also gives it and the stale-sweep gate an npm script, and corrects the link-rot
entry, which still told readers that jesusfilm.org and fivefish.org can never be
status-checked — the thing that gate stopped believing two commits ago.
Click a country on the map, follow through to the table, export: you get 264 of
8,438 rows, correctly filtered, in a file called
gospel-resources-every-link-and-who-it-reaches.csv. Do it for a second country
and you get the same filename again, so the second download overwrites the first
or lands as "(1)". The rows were right and the file was unidentifiable, which
is the whole failure for the person this export is for — someone pulling a list
per country to hand to a team.
The filter now travels in meta.title, which is what the download is named from,
so China arrives as -people-s-republic-of-china.csv and German as
-german-standard.csv. Done here rather than by teaching the shared DataTable a
new prop, keeping the decision this file already made about a component with
other consumers, and it fixes the heading in the same move: it read "every
link, and who it reaches" over a table showing one language's six.
base/_reset.scss's `img, picture { max-width: 100%; display: block }` is a
document-level element rule, so it never reached our shadow root. Measured on a
COUNTRY page, where the uupg photos actually render: their img.uupg__image has
max-width 100%, ours had NONE, so an image wider than its grid cell overflows
our card where theirs is constrained. display already matched, and
height/border-radius/object-fit already match because our .uupg__image class
sets them (100px, 7.5px, cover), so max-width was the whole gap.
_globals.scss:80's `img { border-radius: var(--border-radius-lg) }` is
deliberately NOT ported. On their site it rounds every image because every image
there is theirs; inside our root it would also round the poster-wall previews,
part of a sanctioned addition their page does not have. The elements that ARE
clones already carry the right radius from their own class.
An earlier run reported img as 'absent from BOTH pages' -- `.uupg__image` IS the
img, not a wrapper, so a descendant selector matched nothing. That is the third
time a mis-selector has masqueraded as agreement here, so the probe now fails
loudly on anything it did not actually exercise instead of counting it as a pass.
The two curated figures — growing & unreached, deaths a day — sit under a
footnote saying how much narrower their basis is than the pins. It read "covers
391 of these 391 groups — a narrower set than the pins" for every one of the 242
countries, because the script that recomputes country rows from the pins
overwrote the curated group count with the pin count and the footnote went on
reading it. Always false, and false about the one thing it existed to qualify.
The real numbers were still in the commit that introduced the overwrite and are
restored on the rows: China 158 of 391, India 856 of 2,146. They are recorded
once now and never rewritten, because the pins change every build and a curated
analysis does not.
The same block also drew both figures for the 72 countries that have no curated
row at all — "Est. deaths/day 0", which is not a measurement. It is hidden for
them instead. A first pass at the fix handed those 72 a basis equal to their pin
count, inventing the number the footnote exists to qualify; null now means there
is no curated set here.
Open a group in the gap and the sheet listed five pools that found nothing —
Scripture, film, FCBH audio, Bible Stories, Zume — and said nothing at all about
Global Recordings Network or Scripture Earth. For 1,220 groups GRN is the only
resource there is, so silence about it at the moment of decision does not read
as "searched and empty", it reads as "nobody asked". Both now have an absent
case, with the pool named.
The GRN chip asks the data whether the pool was really searched, and that is the
part worth keeping. The sweep ran against the gap first and stopped there, so
3,202 codes that already carry resources have never been asked — the first
version of this chip told German, which certainly does have Words of Life, that
none was found. The build had recorded the truth per code all along: a swept
code lists grn under checked, an unswept one carries "grn (not yet swept)".
The chip reads that instead of assuming the sweep was universal.
The legend's caveat is the paragraph a reader uses to decide what "nothing
found" is worth. It said GRN was being swept a batch at a time and part of the
pool was still unread. That was true while the crawl ran and false the moment it
ended, and it stayed on the page through four commits that knew better. Found by
reading a screenshot, which is the only thing that could have found it.
Corrected on all three surfaces, and scoped honestly rather than upgraded: the
sweep finished for every one of the 1,192 languages in the gap, so "nothing
found" no longer rests on an unread pool — but codes that already carry a
resource were not all asked, because the sweep was spent on the gap first. Both
halves are now on the page.
A gate holds it. The DATA decides whether the claim is allowed: while codes are
genuinely undecided it passes, and it starts failing the moment they are all
decided, so it re-arms itself for the next slow pool. It does not flag a claim
rendered under a v-if on the pending count, because that form expires on its own
and is the one to copy. Its first version asked about every code on the map
instead of the gap, measured 3,202 undecided and would have let the stale
sentence stand.
Their base/_reset.scss zeroes margins with
`body,h1,h2,h3,h4,p,figure,blockquote,dl,dd { margin: 0 }` -- a bare ELEMENT
selector, specificity 0-0-1. Their `.stack > * + *` is 0-1-0, so the stack wins
and their heading gets its 10px.
We recreated that reset as class rules, `.rd-title{margin:0}` and
`.rd-summary{margin:0}`. Those are ALSO 0-1-0, because `*` contributes nothing
to specificity, so `.rd-head > * + *` and `.rd-title` tied and source order
decided it -- the later class rule won and the stack margin was discarded.
Measured on live /regions/africa, their head is a(0px), h1(10px), p(10px).
Ours was h1(0px), p(0px): the title and strapline sat flush against the back
link, 10px tight, on every region and country page. Porting the reset as the
element selector it actually is, and deleting the two class-level margin:0
declarations, restores their cascade exactly -- both now read 10px.
Found by sweeping for the defect CLASS rather than the instance: for every
`> * + *` container in our sheets, any child that is not the first and still
computes margin-top 0 has lost the same race. That turned up three candidates,
and measuring THEIR page settled the third -- their .region-goal is 0px too, so
our .rd-goal matching at 0px was never a defect. A heuristic found the
suspects; only the live measurement could tell a defect from a faithful copy.
This is the fourth of their global rules that the shadow boundary changed the
behaviour of, after box-sizing, the list bullet and prefers-reduced-motion --
but the first where the rule DID reach us and the port altered its specificity.
Copying a global element rule as a scoped class rule is not a faithful port even
when the declaration is identical.
reset-vs-live now reports 0 differences; region-vs-live 4/4, list-vs-live 21/21,
table rows 4/4, SRI passes.
The rot sampler now covers 5fish by where its redirect LANDS — a published code
goes to its numbered language page, one they do not carry is swept to /regions —
the same rule the verifier uses, with both arms proved in band. Six links a run,
ten seconds apart, because their robots.txt asks for it. That leaves 94 of 8,510
shipped links outside every re-check, down from 5,760 this morning.
YouVersion is the largest catalogue the brief names and it is now recorded as
rejected, with the measurement: nodejs.bible.com serves it without a key but its
robots.txt is Disallow: / , www.bible.com answers even robots.txt with a bot
wall, and the unauthenticated slice returns 2 English versions against the dozens
that exist — so taking it would be both discourteous and wrong. It is named in
the unchecked list on 5,413 codes instead of being silently absent, because a
reader deciding that a group has nothing is entitled to know which pools nobody
searched.
Batch fourteen closed it — 2,347 language codes decided, 1,022 carrying Words
of Life. Forty groups leave the gap: 1,653 with nothing becomes 1,613, and the
gap breakdown drops from four situations to three, because "GRN not yet swept"
no longer describes anybody. What is left is 1,429 groups where every pool this
build can reach has been searched, 183 sign languages behind key-gated sources,
and one group with no language code at all.
Two things the sweep exposed. 5fish answers 200 for any path, so its 982 links
were being verified by a status that means nothing there; they are now judged by
where the redirect LANDS — a real code goes to its numbered language page, one
they do not carry is swept to /regions — with both arms proved in band before
any link is classified. And country-gap reads a file that another script
refreshes from the pins, so running them out of order publishes the previous
build's numbers: it said 1,653 while the map said 1,613. It now refuses to write
a table that disagrees with the index the map draws.
base/_reset.scss:66-79 suppresses motion site-wide with
`@media (prefers-reduced-motion: reduce) { *, *::before, *::after {
animation-duration:.01ms!important; transition-duration:.01ms!important; ... } }`
-- a DOCUMENT-level universal selector, so it styles nothing inside a shadow
root. The same boundary that swallowed their box-sizing reset.
It matters because we had ported their animation faithfully: their
RegionDetail.vue:209 and our .rd-seg / .rd-bar__slider both carry
`transition: width 1s ease-out`. Measured with emulateMedia on the segment that
actually carries the transition, in both states, because a value that is 1s in
BOTH states is the whole claim:
theirs 1s -> 1e-05s under reduce
ours 1s -> 1s
So a vestibular visitor got a full one-second sliding bar from us that their own
site suppresses -- a clone difference and an accessibility regression in one.
After porting the block into .rd-root and .rl-root, ours reads 1s -> 1e-05s,
matching theirs, and the unreduced case is still 1s.
Their `html:focus-within { scroll-behavior: auto }` half is deliberately NOT
ported: a shadow root cannot style the host document's html element, and the
leaf page owns that.
Found by finishing an audit I had already called complete. The previous pass
enumerated 21 conditional rules but only actually checked the [lang] and [dir]
ones -- the four :has() rules and the @media set were reasoned about, not
measured. Of those, @media print turns out to be moot for us (it hides header,
footer and nav, which an embed does not have, plus orphans/widows on p, blocked
by the same boundary and marginal), and this one was a real defect.
list-vs-live still 21 of 21, chooser 15 of 15, parity and SRI pass.
The breakdown hardcoded "Four different situations share it" and gave
every line an unconditional row. The GRN sweep has 302 codes left, so
within an hour or two that bucket empties — and the sheet would have
rendered "· 0 (0) — Global Recordings Network has not been swept for
them yet" beside a sentence insisting there were four. Two falsehoods
at once: the count, and the implication that some remain unswept.
Each line is now conditional and the opening counts the situations
that actually have rows.
Tested by simulating completion in the data the page really loads:
with every code marked swept it renders "Three different situations",
the GRN line is gone, and the real translation gap absorbs them,
1,220 → 1,531. Restored afterwards and both asset copies verified
identical and back to 311.
Worth recording from that test: the page reads public/assets/tables/,
NOT public/doxa-maps/assets/tables/. Editing the second one changes
nothing, which cost two browser runs to discover — the first
"simulation" verified the file it edited and still watched the old
number render, which looked like a broken guard rather than a wrong
path.
Two defects, both found by looking at the rendered page rather than
the data behind it.
The footer read "Pools CHECKED: … grn. Pools NOT checked: …, grn (not
yet swept)." GRN is swept a batch at a time, so some codes carry it as
checked and the rest as unchecked, and unioning across the corpus
printed it on both sides. To a reader that is a flat contradiction,
and it is the kind that quietly discredits every other number on the
page. It is neither fully checked nor untouched, so it now says so
once: "grn (partly — the sweep is still running)", on the checked side
only.
And three of the eleven bars — Audio, Gospel presentation,
Discipleship training — are drawn in a green the legend never named,
so a reader had no key for them at all. The legend now carries it.
Neither was visible in the markup or the numbers. The pool strings are
assembled from per-code provenance, and the legend was three entries
that all looked reasonable until you counted the colours actually on
screen against the colours explained.
The Driver said "it doesn't need to have a People Group tab inside the legend".
The reason is stronger than preference: clicking it showed nothing at all.
SemanticTreeLegend renders tab N as collectAtDepth(nodes, N) -- a tab's INDEX
IS a tree depth. This legend's tree is exactly TWO levels: 9 regions -> 28
lists. MEASURED by expanding every caret on the Region tab: 37 rows, 9 at
indent 0 and 28 at indent 1, deepest level 1. There is no level 2, so tab index
2 resolved to an empty set and the panel rendered its "No data at this level"
empty state with the tab ACTIVE and selected.
★ THIS IS WHY PART (c) WAS GATED AND NO LONGER IS. The card asks "what is the
legend's intended tree once Region/Zone/People Group are removed" -- a real
question, still the Driver's. But removing a tab that is STRUCTURALLY INCAPABLE
of content needs no ruling about intent. It is a defect, not a design choice.
AFTER: two tabs, both with content -- Region 9 rows (expandable to the 28) and
UPG-100 Lists 28 rows. Clicking every remaining tab and info button, 6 elements
in total, produces ZERO empty states.
⚠ THE i18n KEYS ARE DELIBERATELY LEFT IN PLACE. 22 locale files carry
upg100Group; deleting them costs 11 languages of re-translation if a third
level ever arrives, while an unused key costs nothing at runtime and the parity
gates assert presence rather than usage. Removing them would be the riskier
edit, not the tidier one.
⚠ AND A THIRD TAB IS NOT THE MECHANISM if people-group rows are ever wanted.
The tree would need a third level (list -> group); re-adding the tab without
deepening the data reproduces exactly this empty state.
Verified: check:legend-export-reachable PASS, check:legend-matches-source PASS,
check:a11y-button-names PASS, and check:legend-info-localized PASS on this leaf
-- 4 info popups, all differing from English in es/ar/zh including RTL Arabic,
0 blank.
A footnote under every country sheet still said "No resource found
means not present in eBible or the JESUS Film catalogue; audio pools
are key-blocked and unchecked". Both halves stopped being true long
ago: eight pools are checked, not two, and audio is searched at Faith
Comes By Hearing and at GRN — which needed no key at all.
That is not a harmless leftover. It understates what the map knows, to
a reader deciding whether "nothing found" means anything, and it is
the single caveat most likely to be quoted back.
The file's own header was wrong in three ways and is corrected with
it: the band is not "no scripture and no film" but no resource of ANY
kind, since it is computed from the tier; the pools are named in full
rather than as "eBible + Arclight"; and GRN is not key-blocked.
Verified in the browser rather than in the markup — the footnote is
inside the country sheet, which only appears after a pin and a back
button, so a grep would not have told me it renders.
Diffing Arabic against live for the first time. dx-life serves /ar/regions under
strategy 'prefix_except_default', measured live as lang="ar" dir="rtl", so this
axis was comparable all along and never had been compared. Our own RTL checks
only ever asserted our own invariants -- that a dir attribute exists, that an
arrow points the right way -- which cannot tell us whether we flip the things
THEY flip.
The defect: .region-card__link and .country-link both set text-align:left.
Theirs is a NuxtLink, an inline <a> that shrink-wraps to its text; ours has to
be a <button> because the control changes the embed's subject in place instead
of navigating a route, and a button's UA default is text-align:center -- so the
port overrode it with the PHYSICAL `left`. In English `left` and `start` are the
same value, which is why this survived a 37-property region-card diff and an
11-property bullet diff. In Arabic they are opposites.
Measured on live /ar/regions against ours at dir=rtl, same 246.9px row and the
same 102.7px of text on both sides: their label sits flush to the row's RIGHT
edge, 0px from the RTL reading start, and ours sat flush LEFT -- every country
label in the list stranded 144.2px on the wrong side of its column. After the
change both sides read 0px from the right and 144.2px from the left, identical.
text-align:start is correct in both directions, and English is unregressed:
the list-vs-live diff still passes 21 of 21, and every measured RTL property now
matches live with 0 differences.
Three defects in the new probe, all of which blamed the product first, are fixed
in it rather than worked around:
- it read dir/lang off the custom element <regions-dashboard>, which carries
neither. They live on .rl-root, which is the only place they can usefully sit
because a [lang]/[dir] selector cannot cross a shadow boundary. It had duly
reported dir=ltr lang=null against their dir=rtl lang=ar.
- it waited only for Arabic text before reading. The chrome translates instantly
from the client-side catalogue while the same locale change refetches the data,
and that in-flight state briefly unmounts every .region-card -- so it read a
page with no cards and called four elements "MISSING on ours" when the product
was fine (measured: 7 cards before the switch, 7 at +1.5s, +5s and +12s after).
- it read li::before on both sides. The two sides attach the same bullet to
different elements: theirs styles li::before, ours .country-link::before. The
bullet's own properties are byte-identical wherever it hangs -- content "",
width 29.2969px, position absolute, margin-inline-start -39.0625px, left
256.703px -- and the first country sits at 834.7px on both. That is a
structural difference to report, and the probe now reports it as a note instead
of failing four style assertions over it.
The map explains that "1,845 groups have nothing" is four situations
wearing one number. An exported copy of the gap table could not say
that at all, which is the copy a translation organisation actually
works from. Each row now carries its kind:
1,001 every reachable pool checked — the real translation gap
660 GRN not yet swept — the crawl is still running
183 sign language — pools are key-gated, an access problem
1 no language code — no pool can index it
Adding it exposed that the two surfaces bucketed differently. The
table tests sign language first; the map tested private-use first, so
47 Deaf groups were filed under "unindexable code" and the Deaf share
read 136 instead of 183. Both were internally honest and they told
different stories, which is worse than either being wrong on its own.
The map now uses the table's order, because being Deaf is the more
actionable fact about those rows than the shape of their code, and it
still says 47 of them are doubly stuck. Both surfaces report 1,001 /
660 / 183 / 1, verified against each other rather than separately.
The meta warns that filtering to sign_language alone is now the whole
Deaf figure and not a subset, since the private-use rows are the same
people.
The breakdown I shipped last tick implied two separate populations:
136 sign languages whose pools are key-gated, and 48 groups with a
private-use code that no pool can index. Measured: ALL 47 of the
private-use groups are sign languages too. Read as written, a reader
would put the Deaf share of this gap at 136 groups and 50.6 million
people, when it is 183 groups and 55.2 million — the largest blocked
population on this map, and one whose gap is an agreement away rather
than a translation away.
Both lines stay, because the reasons they are stuck genuinely differ,
but the sheet now says they overlap and gives the combined figure.
Found while researching whether anything open exists for them, which
is recorded next to the other rejected pools so nobody re-probes it:
deafbible.com serves the same 3,894-byte app shell for every URL,
door.international returns 114 bytes, and Deaf Missions is a real
ministry with real Scripture but ASL only — and not one of these 183
groups speaks ASL. Watchtower publish a great deal of sign-language
material and are excluded on doctrine, without exception.
"1,913 people groups have nothing" is four different situations
wearing one number, and they call for four different responses. The
sheet now breaks it down, computed from the pins and each code's own
"checked" line rather than typed:
887 groups, 149.7M people — every pool this build can reach was
checked and found nothing. This is the real translation gap, and
the only quarter a translator rather than an administrator can
close.
842 groups, 1.1M — Global Recordings Network has not been swept for
them yet. The crawl runs a batch at a time at the delay they ask
for, so these resolve themselves.
136 groups, 50.6M — sign languages, whose pools are key-gated. Fifty
million people whose gap is an ACCESS problem, not a translation
one, and reading them as untranslated would send help to the
wrong place entirely.
48 groups, 6.9M — a private-use code or none at all, which no pool
can index. Searching harder cannot move these.
The four sum to 1,913 and the rendered figures were checked against
the same count made offline. Also fixes a missing space that ran two
sentences together — "people.These are the 40 largest" — which the
screenshot caught and the markup did not.
The chooser's zoom came from log2(width / 512), and the comment above it said why:
"Mercator's world is 512*2^z wide". But the chooser renders under the globe
projection, so that is the same mistake as the region pages framing too wide --
a mercator answer applied to a sphere -- only here it made the frame too SMALL.
Found by looking at the screenshot, not by any assertion: a sphere adrift in
white space.
Measured at 1200x780: the mercator answer of 1.229 put the sphere at 390.61px,
50.1% of the frame's short side, so half the frame was dead space. And the
number was inert -- zoom 0.5, 1.0 and 1.229 all render an IDENTICAL 390.61px
sphere, because Mapbox clamps the globe's apparent size at low zoom, so the
formula's output sat inside the clamp and could have been 0.5 with no visible
difference.
There is no closed form to swap in. Diameter against a clean doubling is 3.6 to
10.5% off above the clamp, because the globe-to-mercator transition is nonlinear
there. What the measurement does establish is monotonicity, which is the only
property a solver needs, so the zoom is now SOLVED against the engine: bisect
the zoom whose limb projection fills 92% of the frame's short side. project() is
pure transform math, every setZoom lands in one synchronous block before a frame
renders, the original zoom is restored in a finally, and the old mercator
formula remains the fallback because a small globe beats a broken chooser.
The first version of the solver measured pole-to-pole and called it the
diameter, which holds only when the centre is on the equator. The chooser
centres on the mean of every people group, latitude 28, so that was a CHORD:
717.6px where the sphere was really 792-835px across, understating it by 1.104x.
The solver over-zoomed and clipped the globe top and bottom -- again visible in
the screenshot while every number still looked right. It now samples the limb,
points at 90 degrees angular distance, and takes the MAX across twelve bearings,
because Mapbox's globe is a perspective camera rather than a true orthographic
one and the silhouette is not a perfect screen-space circle: 396px along the
meridian against 417.3px east and west at one zoom.
Result: the widest axis is 750.6px inside a 780px frame, the whole limb sits in
frame, and dead space falls from 49.9% to under 4%. Pins are correspondingly
bigger click targets. On a phone the ported 1/2 aspect ratio means the globe
fills the WIDTH, which is the right trade -- filling the height would crop it
horizontally and lose pins off both edges -- and that leaves half the height
empty by geometry, which is carded for a Driver decision rather than resolved by
deviating from a clone-faithful rule.
The chooser's behaviour is unchanged and re-proven after the camera moved:
_probe-chooser 15/15 and the new _probe-chooser-pin-opens-country 8/8.
At 390px the placeholder read "Find a people group, language or ISO
cod" — losing the one word that tells a reader an ISO code is accepted
at all, on the control most likely to be used first. Measured: 40
characters of text in 279px of room.
The short form keeps all three things the box accepts and fits with
room to spare, 185px of 279. The full phrase still shows at desktop
width, and the aria-label stays long either way, because a screen
reader has no width to run out of.
Found by checking phone layout after a flight of additions to this
sheet — five gap tabs, a filter, new chips and the bridge panel all
arrived without anyone looking at 390px. The rest held up: no
horizontal overflow, the chips wrap, the sheet scrolls, and the bridge
panel renders at 358px.
For 230 people groups the map said "nothing found" and stopped, when
that was not the whole truth: a language already spoken widely in
their country does have Scripture or the film. Kimr in Sudan has
nothing in Assangori; Sudanese Arabic, spoken by 65 groups around
them, has both. That is the difference between no way in and a way in
that is not their heart language.
WORDED SO IT CANNOT BE SKIMMED AS "THEY HAVE SCRIPTURE". The heading
says "Not their language", the body names whose language it is and how
widely it is spoken, the strength is quoted from the source, and the
last line says a second language is a way in and not a translation.
The chips above are untouched and still read "none found" five times,
and the tier is unchanged: 2,001 groups still have nothing of their
own, exactly as before.
Two exclusions, both measured rather than assumed. Fourteen of these
rows offer a DEAF people group a SPOKEN bridge — Gawri "has a film" is
no use to Deaf Pakistanis, the same error as offering them recorded
audio — so sign languages are out. Sixty-five carry the curated
strength "weak — may not be a real lingua franca"; a bridge the source
itself doubts is not an answer, so those are out too.
Placed below the whole chip row after a first pass split it, leaving
the Zume chip stranded under the panel.
Looking at the side-by-side Africa crops after the framing fix showed the
legend rows sitting a few px off and the panel reading differently. Diffing
our block against CountryMap.vue's found six differences that no computed-CSS
probe was covering: font-size 12.5px against their 12px (a rounded-token
artifact of mine, not their scale), colour #2a332a against their #1f1f1f, and
four properties simply missing -- the panel's box-shadow, pointer-events:none,
and the swatch's display, border and ring. The swatch border is the one with
layout consequences: a 10px dot inside a 1px border is 12px tall, so dropping
it shortened every row and moved the whole stack, which is the drift the crop
showed. An old comment here called this legend "one of the sanctioned
additions"; that was wrong and is why it drifted -- their CountryMap.vue
carries this legend with these same four entries, so it is a clone and every
property is theirs to dictate. The one deviation stays and stays documented:
inset-inline-start instead of their physical left, so the legend sits in the
reading-start corner in Arabic.
That left the panel 2px wider than theirs on the right edge alone. The text was
ruled out first: the same string measures 129px on both sides, in the same
Poppins at the same weight. The cause is their GLOBAL reset, _reset.scss lines
3-8, which sets box-sizing:border-box on every element and every ::before and
::after. No scoped style could ever have shown it. Our leaf document already
carries the identical rule, but this component renders in a shadow root and a
document-level universal selector does not cross that boundary, so our swatch
was content-box at 10px+2px while theirs was border-box at 10px. Two swatch
borders, exactly 2px.
Porting the reset into .rcm closes it: the panel now measures 165px against
their 165px, the panel bounding box lands on the same pixel on all four edges,
and the legend corner diffs to a mean 1.14/255 per channel, which is
antialiasing. The map frame is unchanged at 1200x780.
This is the seventh global rule of theirs found missing, and it explains the
seven per-element box-sizing patches scattered through this bundle -- each one a
local workaround for the reset that never arrived. Porting it bundle-wide and
deleting those seven is carded rather than swept in here, because border-box
changes geometry in components whose match against live has to be re-measured.
Two claims in shipped metadata had gone false, and both were the kind
a reader trusts precisely because it looks specific.
people-group-resources listed Global Recordings Network under "not
included: requires an API key". Wrong on both halves: GRN is included,
583 codes of it, and it needed no key — 5fish resolves by ISO 639-3
and asks only for a crawl delay. A caveat naming a pool as missing
misleads exactly as much as a stale number when the pool is there. The
refresh now moves it out of that list and records how it was reached,
so the list cannot drift again.
The links table said "the 506 zero-reach rows" and "51 of the 168
codes". The true figures are 1,038 and 492: every source added rows
for languages no pin on this map speaks, and a sentence that counts
something has to be recounted when the something changes. Both are
computed now rather than typed.
Found by sweeping every shipped table's metadata for baked numbers
instead of waiting to trip over one. The surfaces came back clean —
their prose is already computed — so the stale claims were all in the
table meta, which is the part nobody looks at and a CSV carries away.