The merge touched library/, so both owned bundles were rebuilt before fix:sri
rather than letting the regenerated tree ship a stale .js. The conflicted
public/doxa-maps/index.html is generated, so it is resolved by regeneration.
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.
Last tick the map and the exported gap table bucketed the same 1,845
groups differently — the table tested sign language first, the map
tested private-use first, so 47 Deaf groups landed in different
buckets and the Deaf share read 136 in one place and 183 in the other.
Both were internally consistent and told different stories, which is
worse than one being plainly wrong: a reader comparing them cannot
tell which to believe. I fixed it by hand, which fixes today only.
Clause O holds three things a reader depends on: every tier-0 group
appears in the table exactly once, every row carries a kind, and a
sign language is ALWAYS labelled as one rather than as an unindexable
code — because being Deaf is the actionable fact, and every
private-use code in this gap belongs to a sign language anyway.
Checked against the real defect, not a synthetic one: putting the
precedence back the wrong way round makes it fail with "47
sign-language row(s) are not labelled as sign languages (e.g. qcd,
qiq, qns) — the Deaf share of the gap will read low". Poison is 15/15.
The consumer list was derived with a plain substring test, and a substring
cannot tell an import from a comment ABOUT the component.
MEASURED: it reported research-dashboard as a stale consumer of
SemanticTreeLegend for three ticks, and I routed that "drift" to its owner as
work to do. It was a FALSE POSITIVE. research-dashboard imports the component
ZERO times and mentions it four times in prose -- DashboardFacetRail.vue:3 says
"the floating @map SemanticTreeLegend is DROPPED", followed by the root cause it
was dropped for. A bundle that deliberately removed a component is CORRECT to
lack its translation keys. The gate was reading the comment explaining the
removal as evidence of use, and the more carefully that removal was documented,
the more certain the false positive became.
★ A GATE THAT MANUFACTURES WORK FOR ANOTHER AGENT IS WORSE THAN ONE THAT MISSES
DRIFT. A missed regression is found by the next reader; an invented one costs
someone a tick discovering there was nothing to fix, and it discredits the gate
that raised it.
Now requires real import syntax -- `import ... from '<path>'` or a dynamic
`import('<path>')` -- matching either the @map path or any path ending in
/<Name>.vue.
MUTATION-TESTED THREE WAYS, because narrowing a detector can quietly blind it:
false positive gone -- research-dashboard no longer listed; legend-a11y goes
4 of 5 to 4 of 4, and every genuine consumer is
unchanged (PosterWall still finds all four)
real drift caught -- stripping the marker from doxa-simple-map's built
bundle fails it, exit 1
new consumer found -- injecting a real import into big-picture lists it
immediately as missing the keys
The second and third are the ones that matter: it would have been easy to fix
the false positive by narrowing until the gate saw nothing at all.
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.
A publish was ALREADY BLOCKED once at a 74MB asset tree -- Forgejo rejected the
pack. Nothing has measured it since. public/assets is now 132MB, against the
94MB recorded when BUILD-POSTERS-GENERATE-ON-DEMAND-DROP-52MB was written. It
grew ~40MB and no one noticed, because size only becomes visible when something
refuses to accept it.
★ IT REPORTS THE SPLIT, NOT A TOTAL, because a total invites deleting the wrong
half:
city-*.webp 176 files 22.0MB NOT reproducible -- needs OSM tiles
country-*.webp 174 files 13.2MB
zone *.webp 28 files 5.7MB reproducible
zone *.html 28 files 27.9MB reproducible
The city posters are simultaneously the heaviest single group and the one that
cannot be regenerated offline, which is why publish-html.mjs deliberately skips
them. "132MB" on its own points an optimiser straight at them.
⚠ AND RAW SIZE IS NOT THE SHIPPING COST -- the gate says so in its own output.
publish-html.mjs already measured this with real git packs: swapping the 28 zone
rasters (5.0MB raw, ~5.0MB packed; webp is already compressed and git cannot
pack it further) for their HTML sources (21.4MB raw, 5.78MB packed; repetitive
text) is about +0.8MB PACKED. A net LOSS. Its comment says "do not ship that
swap and call it a saving", and a gate reporting raw bytes without that caveat
would have re-argued for exactly the swap that file talks you out of.
The ceiling is 160MB -- roughly 20% headroom over today, far above the current
tree and far below a surprise repeat of the 74MB rejection. Deliberately not
pinned to today's value: a gate that fails on the next legitimate asset teaches
people to raise the number instead of reading it.
MUTATION-TESTED BOTH WAYS: a ceiling below the current tree fails it (exit 1)
with the publish-rejection history in the message; a generous ceiling passes
(exit 0). The poster buckets were checked exhaustive -- 406 of 406 files
categorised, so nothing can hide between them.
170 more codes, 62 with gospel audio. 797 codes carry a Words of Life
link and 1,028 people groups whose ONLY resource in any pool is that
recording — 79.2 million people — can hear the gospel in their own
language.
Tier 0 is 1,845, from 3,457 when this began: 1,612 groups moved out of
"nothing found" on verified links. 1,707 of the gap's 2,456 codes are
decided, 70% of the sweep.
The hit rate is falling, and that is the sweep working rather than
failing: 36% this batch against 43% over the first nine. The codes are
probed largest-population first, so the tail is the small and rare
languages where GRN is least likely to have reached — which is exactly
where the remaining gap lives.
Provenance exact — 1,578 name GRN as checked, 1,578 decided, none
decided goes unnamed — and all 62 new URLs verified before shipping.
Every assertion in this gate measured cards that are ALREADY laid out -- which
is satisfied perfectly by a wall whose bottom half is unreachable. The
Kingdom-agent ruled UPG-100 the one mobile-app showcase, promising an
"Instagram-style scroll", and nothing asserted that the scroll works.
MUTATION-TESTED, and the result is the argument. Capping the wall's height with
overflow:hidden leaves every existing number perfect -- overflow 0, neighbour 0,
over-preview 0, 108 controls, 0 too small, 0 clipped, at all five widths -- while
20 of 27 posters become unreachable. Only the new assertion fires:
"scrolling to the bottom does NOT bring the last poster into view (scrolled 0 of
0)". A reader on the showcase would see the top of the wall and never learn the
rest existed, with the whole suite green.
⚠ DO NOT ASK document.documentElement.scrollHeight. MEASURED at 390x844 on
upg-100-mfe: it reports 844 -- exactly one viewport -- while the wall holds
13,032px of cards across two nested scrollers. The composite scrolls an INNER
element, so the document is not the thing that moves, and a document-level check
would call a healthy wall empty. It finds the element that genuinely overflows,
picking the largest so a small inner scroller (the legend's own list) cannot be
mistaken for the wall.
It SCROLLS rather than computing. "The last card is at 13001px" is not the same
claim as "scrolling brings it into view with working controls", and only the
second one is what a reader gets. Having arrived, it also checks that card's
controls are present, unclipped and pressable -- the far end of a long list is
where a layout gives out, and every previous sample was taken near the top.
Side effect worth noting: scrolling to the bottom triggers the lazy previews, so
the 390px run now reports 17 painted rather than 8. The images were always fine;
nothing had ever scrolled far enough to load them.
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.
check:packets-all reported "every generated packet renders, in every locale
present" and passed on 11 packets. All 11 were the SAME ZONE (laos) in 11
locales. It proved one zone's layout eleven times.
The answer is not more zones -- 28 zones x 11 locales is a network fetch per
zone and ~100MB of artefacts. It is more SHAPES, chosen where the layout can
actually break, built from committed records with no network.
★ THE SHAPE THAT MATTERS IS THE PAGE BOUNDARY. packet-render.js:76 sets
PER_PAGE = 20 and paginates `for (start = 0; start < groups.length; start +=
PER_PAGE)`. laos has 95 groups and tibet 43 -- both mid-range remainder cases,
so the boundary ITSELF had never been rendered. Three fixtures, each a slice of
the committed tibet records, so they are byte-deterministic and need no API:
exactly 20 -- one full page; an off-by-one yields a second, empty one
21 -- two pages, the second holding a single card
1 -- the minimum a zone can have
MUTATION-TESTED, and this is the argument for the whole change. Flipping the
page loop to `start <= groups.length` -- an off-by-one that only shows when the
count is a multiple of PER_PAGE:
fixture-edge-exactly-20 FAIL <- the only one of 15
fixture-edge-21-groups pass
fixture-edge-one-group pass
fixture-tibet pass
laos x 11 locales pass
A real pagination bug would have shipped completely green under the old suite.
The failure is specific too: "20 people cards should lay out as 4 sheets but the
packet has 5" and "list pages disagree on the zone total: 20 vs 21".
⚠ EACH BUILD OVERWROTE THE LAST, which is why this is a script and not a line of
shell. packet.mjs names its output for the ZONE, not the fixture, so four
fixture builds against one zone leave ONE file -- and check:packets-all would go
on seeing a single shape while reporting success. Each output is renamed to its
fixture's own name so all of them coexist and every one is evaluated.
packet:ci now builds all four. check:packets-all goes from 11 packets in 1 zone
to 15 across 5 shapes; it still reports "5 of 28" and names what it does not
cover.
"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 pipe rule was only half of it. $? is a single slot that every
command overwrites, including the harmless one somebody adds months
later — a sed that redacts a token out of a log, a rotate, an echoed
heading. The command fails, the sed succeeds, and the check reads the
sed.
Measured both forms with a deliberately failing command and a sed
between: the bare $? reported SUCCESS on a command that had failed,
while a status captured into rc on the very next line still read 1.
So the durable form names it immediately and branches on the name,
which survives anyone later inserting a redaction step. That matters
most in exactly the scripts that redact a credential before printing
a log, because that redaction is the change that looks safest.
Same family as the pipe above: a status read one step too late is a
status about something else.
170 more codes, 78 with gospel audio. 735 codes carry a Words of Life
link, and 960 people groups whose ONLY resource in any pool is that
recording — 79.0 million people — can hear the gospel in their own
language.
Tier 0 is 1,913. It was 3,457 when this work began, so 1,544 people
groups have moved out of "nothing found" on real, verified links.
1,537 of the gap's 2,456 codes are decided, 63% of the sweep, and the
hit rate holds at 43% across nine batches.
Provenance exact — 1,412 name GRN as checked, 1,412 decided, none
decided goes unnamed — and all 78 new URLs verified before shipping.
The Driver's requirement was "full words visible AND support localization".
This gate only ever ran in English, which is the shortest of the eleven -- so
the half of the requirement about translation had never been measured.
MEASURED at 1440px, the three print labels:
en Prayer List 76px · Card Deck 72px · Poster Map 79px
ro Listă de rugăciune 122px · Pachet de cartonașe 133px · Harta afișului 92px
zh 禱告名單 56px · 禱告卡組 56px · 海报地图 56px
★ THE WORST CASE IS RENDERED WIDTH, NOT STRING LENGTH, and picking by the wrong
one would have picked the wrong locale. Chinese has the FEWEST characters of the
three and is nowhere near the widest, because each glyph is near double-width;
Romanian is +75% on English. The gate asserts scrollWidth > clientWidth, which
is pixels, so the sweep is pixels too. en is the control, ro and zh the measured
extremes; CARD_FIT_LANG overrides.
MUTATION-TESTED, and the result is the whole argument for this change.
Restoring the ellipsis the Driver rejected, at 1440px:
en 0 clipped <- passes
ro 54 clipped <- fails
zh 0 clipped <- passes
A regression that clips half the controls for Romanian readers would have
shipped completely green under the English-only gate. Restore the wrap and all
15 combinations pass.
Failures now name the locale ("ro 768px: 54 of 108 clipped"), because "768px"
alone is a report nobody can reproduce.
The PASS line reports widths x locales rather than their product -- it used to
say "at 15 width(s)" for 5 widths in 3 languages, and a summary that overstates
its own coverage is how a green line stops meaning anything.
Also verified, separately from the sweep: a PARTIAL translation degrades per key.
German renders translated (Gebetsliste, Kartenset, Posterkarte); an unknown
locale falls back to English; both with 0 blank labels, 0 raw i18n keys visible
and 0 clipped.
The merge brought library/components/poster/PosterWall.vue, which both owned
bundles compile their own copy of, so regenerating the tree alone would have
shipped a stale .js for each. Rebuilt regions-dashboard and country-posters
before fix:sri; check:sri passes across 56 script references and 57 leaves, and
the staleness check finds every sampled source literal present in both bundles.
public/doxa-maps/index.html conflicted and is generated, so it is resolved by
regeneration rather than by hand.
check:shared-reached confirms the poster hover outline reached all four of its
built consumers, this bundle included.
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.
library/ is the single source of truth, but EVERY BUNDLE SHIPS ITS OWN COMPILED
COPY of the components it imports. So a one-line fix in a shared component is
live in whichever bundles happened to be rebuilt afterwards and silently absent
from the rest -- and nothing detected it, because every other gate measures the
RENDERED BEHAVIOUR OF ONE LEAF, and a leaf whose bundle is stale renders the old
behaviour perfectly.
Three independent instances in one week, which is what makes it a gate rather
than a note:
- the PosterWall hover outline (a7f57c1) reached my-upg-100-list and none of
the other three consumers;
- the SemanticTreeLegend legend.a11y keys reached four bundles and NOT
research-dashboard, so a legend there still ships nameless carets while
check:a11y-button-names passes 791/791 -- that gate only sees the caret on
leaves where an expandable branch happens to render. Nobody was working on
it. It had just been sitting there;
- commit 3cfe93c6, "Rebuild the fleet so the translated hint actually reaches
it" -- someone already hit this and fixed it by hand. The remedy was known;
the detection was not.
★ IT ASSERTS A MARKER STRING, NOT A TIMESTAMP OR A HASH. Comparing mtimes says
"older" and goes noisy on every unrelated rebuild. Comparing hashes cannot work
at all: a bundle legitimately differs from the library source it compiled. The
answerable question is narrower -- does the BUILT bundle contain THIS change? --
and a distinctive literal answers exactly that.
Consumers are DERIVED from the imports, never listed. A hardcoded list goes
stale the moment someone adopts a shared component, and a gate that silently
stops covering a new consumer is worse than none.
A marker that vanishes from SOURCE is reported as a stale ENTRY, not as failing
bundles -- otherwise reverting a change would blame every consumer for it.
MUTATION-TESTED IN BOTH DIRECTIONS, because a gate that has only ever failed is
indistinguishable from a broken one: rebuilding key-cities flipped its line from
✗ to ✓ and the count from 1 to 2 of 4, live; a positive control over a
long-standing marker reaches the PASS branch with all four consumers green; and
pointing an entry at a marker absent from source produces the stale-entry notice
rather than four false failures.
key-cities is rebuilt here because it was the mutation subject and the rebuild
is the fix the gate prescribes. The two bundles still red -- country-posters and
regions-dashboard -- belong to regions, and research-dashboard's legend drift is
older than this work; all three are reported, not rebuilt for them.
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.
The card asks for the Pinterest/Instagram behaviour: hovering a poster outlines
it in blue so a reader knows which one their click is about to act on.
★ THIS IS NOT A RESTORED BORDER. Several reports asked to bring back "the blue
outline around the poster", and MEASURED 2026-09-24 there has never been one:
all 136 elements painting a frame on this wall compute outline:none, and every
border is grey (.card #e3e6ea, .main #dfe3e7). Nothing regressed -- the
affordance was never built. The reports kept recurring because a hover state is
invisible to a screenshot, so each check confirmed "no outline" and concluded
the sighting was mistaken.
outline, NOT border, and that is load-bearing. An outline paints OUTSIDE the
box and takes no space, so it cannot move anything. The rest of this wall's gate
asserts where the print row sits at five widths; a hover that grew a border
would re-lay-out the card and push that row, so a cosmetic state could break a
gate about print controls.
:focus-within carries it to the keyboard, and @media (hover:none) drops the
whole state on touch -- with no pointer it would either never appear or stick
after a tap, leaving a card looking permanently selected.
THE GATE NOW MOVES THE POINTER. Everything it measured before was of a loaded,
untouched page, which is precisely why this affordance could go unbuilt while
being repeatedly requested.
⚠ AND ONE OF MY OWN ASSERTIONS WAS DECORATION UNTIL I MUTATION-TESTED IT. I
first asserted the card's OUTER width is unchanged by hover. It can't change:
the card is a grid item, so the track fixes its width and a border grown on
hover eats inward. MEASURED with border-width:4px on hover -- outer 224->224px,
unfalsifiable, while the CONTENT box went 222->216px. It now asserts clientWidth,
which is what actually repositions the print row.
MUTATION-TESTED: remove the outline -> FAIL at all four pointer widths (exit 1);
grow a border alongside it -> FAIL naming the content-box shift (exit 1);
restore -> PASS (exit 0), reporting "hover 2px rgb(47, 111, 219)" at 768-1920
and "no outline (touch, correct)" at 390.
The print/open controls the card also asks for are already there and were
verified clickable in the same pass: Prayer List, Card Deck, the stock select
and Poster Map, all four with full labels and real handlers, 0 clipped.
170 more codes, 74 with gospel audio. 657 codes carry a Words of Life
link and 872 people groups whose ONLY resource in any pool is that
recording — 78.7 million people — can hear the gospel in their own
language.
Tier 0 is 2,001, from 3,457 when this started: 1,456 groups have been
moved out of "nothing found" by real, verified links. 1,367 of the
gap's 2,456 codes are decided, 56% of the sweep, and the hit rate is
steady at 43% over eight batches.
Provenance exact — 1,246 name GRN as checked, 1,246 decided, none
decided goes unnamed — and all 74 new URLs verified before shipping.
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.
Every gate in this repo measures the LOCAL build. Nothing measured the
PUBLISHED one. So the whole board stays green while readers are served a
months-old bundle, and the only detector is a human noticing something looks
wrong.
Running it today: 17 of 22 published bundles are NOT what we built, 3 more
return 404 and are not published at all, and 2 match. my-upg-100-list: readers
get 474KB, we built 663KB.
★ THE EXPENSIVE PART IS NOT THE STALENESS, IT IS THE PHANTOM BUGS. A stale
publish manufactures reports that cannot be reproduced. Three this week --
print buttons truncated to "Pr...", the legend reading "Generation 1/2/3"
instead of the real tab names, the pin hint saying "red dot" instead of
"coloured dot" -- were ALL fixed in main and ALL still visible live. Each one
read as a regression against a card marked done. Two cost most of a tick to
diagnose, and the diagnosis was the same sentence every time.
It compares the sha384 the manifest already carries, not the byte size: two
different builds can coincide in length, and a size check would call that a
match. Size is reported only to make a mismatch legible.
One finding the hash surfaced that a size check would have blurred:
gospel-resources is published 331KB LARGER than we build it. That is not
"behind", it is a different lineage -- published from a tree that has since
dropped code. And doxa-vision-card and golden-path-check DO match, so this is
not "the host is stale", it is a partial publish, which is a more specific
thing to go fix.
MUTATION-TESTED THREE WAYS: a matching bundle passes (exit 0); appending five
bytes to a matching local file fails it (exit 1); an unreachable host SKIPs
rather than failing, because a network gate that goes red on flaky wifi is a
gate people learn to ignore -- and the SKIP says it is not a pass.
Twelve gates here spawn serve-strict and eleven reap it only on the
happy path — srv.kill() at the end of the script. That holds until the
end is not reached. When the box ran low on memory it killed gate
processes mid-run, and every server those gates had spawned was
orphaned: ten in one worktree, four in another. Each orphan holds
memory, which makes the next kill likelier, which orphans more. The
leak feeds the condition that causes it.
Both halves measured rather than argued. A script using the old spawn
was sent SIGTERM and its server was still answering afterwards —
orphan reproduced on demand. The same script using this helper left
nothing behind.
The helper registers the child against SIGTERM, SIGINT, SIGHUP and
normal exit, and unregisters when stopped deliberately, so it is safe
in a finally block. Nothing survives SIGKILL and nothing can, which is
why the port is returned too — a sweep needs something to look for.
This gate adopts it; it still passes, and its negative control still
discriminates. The other ten are other agents' files, so they get the
one-line change offered rather than applied.
"PACKETS-ALL: PASS — every generated packet renders, in every locale present"
is true and reads like "every packet is fine". It counts FILES. Only the zones
someone generated are ever in that directory.
MEASURED 2026-09-24: 11 packets, all of them the SAME zone (laos) in 11
locales, against 28 poster zones in out/posters. The suite proved one zone's
layout eleven times and had never rendered the other 27 -- any of which can
carry a different data shape: fewer groups, a missing photo, a map that does
not resolve. The packet gate itself is strong; the claim about its reach was
not.
It now prints "zones covered : N of M" with the zone names, and names the
shortfall. Running it today reports 2 of 28.
NOT a failure, deliberately. Generating 28 zones x 11 locales means a network
fetch per zone and ~100MB of artefacts; checking only what exists is a real
trade. The problem was never the cap, it was that the cap was silent -- a
summary line that overclaims is how a green board stops meaning anything. The
PASS text now ends with what it does NOT cover, and points at packet:ci, which
builds a second zone from a committed fixture with no network.
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.
The merge auto-merged library/composables/useMapInstance.js, which is bundle
source, so both owned bundles were rebuilt before fix:sri rather than letting
the regenerated tree ship a stale .js. public/doxa-maps/index.html conflicted
and is generated, so it is resolved by regeneration rather than by hand.
170 more codes probed, 72 with gospel audio. 583 codes carry a Words
of Life link and 785 people groups whose ONLY resource in any pool is
that recording — 78.3 million people — can hear the gospel in their
own language.
Tier 0 falls 2,172 to 2,088, from 3,457 when this began: the gap has
shrunk by 40%. 1,197 of the gap's 2,456 codes are decided, so the
sweep is now about half done, and the hit rate has settled around 42%
across seven batches.
Provenance exact through the change — 1,078 codes name GRN as checked,
1,078 decided, none decided goes unnamed — and all 72 new URLs
verified before shipping.
The Driver reported "the polygons for the map and map colors and pins are not
showing up" and no gate could answer it. verify-map-paint asserts a live GL
context, zero console errors and that Mapbox emitted attribution -- every one
of which is true of a perfectly-painted basemap with none of our data on it.
"The engine booted and tiles are loading" and "my data is on it" are different
properties, and only the first was measured.
The profile already counts this, at the moment it adds the layers, and prints
it: upg-100-list.vue:1849 writes "N polys · R/T zones · P pins" into
.upg-status. Three numbers printed by the product and asserted on by nothing.
This gate reads them.
★ THE TWO OBVIOUS APPROACHES ARE BOTH WRONG, and I shipped one before measuring.
Counting DOM .mapboxgl-marker elements returns 0 on a HEALTHY map -- mapbox
draws these pins as GL geometry inside the canvas, so there is no element to
count; I carried "markers: 0" across three probes as proof of breakage, and it
was proof of a wrong selector. Sampling the canvas returns an empty buffer,
because a WebGL drawing buffer is cleared after compositing: 0 of 13362 sampled
pixels had any alpha, on a map visibly full of colour. The product's own count
is authoritative in a way a proxy is not.
It asserts the zone RATIO, not just presence. "27/27 zones" and "1/27 zones"
both contain polygons and only one of them is the product, so the threshold is
what separates "some data arrived" from "the data arrived". Floors rather than
exact counts, well below the real values, so a data refresh does not fail the
gate and teach people to edit the expectation instead of reading it.
MUTATION-TESTED BOTH WAYS: raise the three thresholds above the real values and
each assertion fires independently with its own message (exit 1); restore them
and it passes (exit 0). Blocking the boundary geojson also fails it, via the
unparsed-status branch.
MEASURED, local AND live, both leaves: 195 polys · 27/27 zones · 2107 pins.
The Driver's symptom does not reproduce in that form on either tree.
The Africa crops showed theirs tighter than ours, and 107 identical CSS
properties could never have said why. Everything I could think to compare
matched: mapbox-gl 3.24.0 on both sides (their bun.lock, our CDN pin), the
style, the 1200x780 container, the padding of 40, the maxZoom of 6, the box
itself (their viewFor() pin extent is byte-identical to our rollup), and
NEITHER page sets a projection.
The asymmetry was when the fit runs. They frame the box in the constructor
(CountryMap.vue:179); Mapbox v3 resolves a constructor `bounds` while
getProjection() still reports 'mercator', giving zoom 3.210. We had recreated
that as a post-style-load fitBounds, resolved with the globe already active,
giving 2.777 -- a sphere shows more planet at the same zoom, so it pulls back.
0.433 zoom is 1.35x linear scale, which is exactly the tightness in the crops.
Copy don't recreate: I recreated their fit instead of copying their construction.
useMapInstance gains a bounds/fitBoundsOptions pass-through, inert unless a
caller passes it, for the deep-link case -- the only case Mapbox can honour,
since the dashboard mounts its one map before the fetch resolves. The normal
case goes through fitTheirWay(), which asks the engine the same question their
constructor asks: setProjection('mercator'), cameraForBounds, restore the globe
in a `finally` so a throw cannot leave the map flat, then easeTo. Asking the
engine beats reimplementing a Web Mercator fit that would drift from Mapbox's
own padding and worldSize semantics at the next upgrade.
Proof, three independent oracles: the camera moved 2.777 -> 3.210 with the
projection still 'globe' afterwards; the like-for-like tile oracle now reads
ours z3 / 8 tiles / [[-45,-66.51],[90,40.98]] against theirs z3 / 8 tiles /
[[-45,-66.51],[90,40.98]], the same tiles at 1.00x; and the regenerated crops,
looked at, put the same labels and pins on the same pixels.
Two oracles of mine were wrong and are fixed here too. The tile-extent probe
compared a z2-quantized extent against a z3 one and reported a "1.33x framing
gap" twice -- 180/135 is four tiles at z2 against three at z3 of the same box,
and our camera's own visible box was 135.44 deg against their 135 deg of tiles.
It now refuses to compare extents across levels and reports the level instead,
which is the real signal. The framing probe asserted our zoom equalled the
GLOBE's cameraForBounds answer, so it would have failed the correct clone; it
now compares against the mercator-resolved camera and checks that asking the
mercator question left the map on the globe.
"Can hear it, no text" is a list of 2,556 groups. A team is not sent to
a group, it is sent to a country, and nothing aggregated that set —
the existing country table counts the groups with NOTHING, which is a
different population and does not contain one of these rows, because
none of them is tier 0.
154 countries, with two columns that disagree on purpose. Nigeria and
the DRC lead on LANGUAGES, 221 and 103, which is the translation
workload. China and Pakistan lead on PEOPLE, 218 and 195 million,
across far fewer languages. Which number matters depends on whether
you are funding translators or broadcasters, so both are shown and
neither is ranked for the reader.
The aggregate reconciles with its source exactly — 2,556 groups and
1,329,044,699 people both ways — and the basis travels as a column, so
a CSV that outlives this page still says "no text found in the pools
checked" rather than "no text exists".
Built without a browser: the box was low on memory this tick and a
gate that needs chromium was the wrong thing to add to it.
A map inside a SHADOW ROOT has to inject its own copy of mapbox-gl.css, because
a document stylesheet does not cross a shadow boundary. This profile did that
by writing the URL out a second time, and the second copy drifted: it pinned
v3.3.0 while every production loader -- generate-tree's baked <script>,
useMapInstance, generate-gallery -- had moved to v3.24.0. So the map ran new JS
against old CSS.
MEASURED on the leaf: mapboxgl.version 3.24.0, the only mapbox stylesheet
reaching the shadow root v3.3.0, and mapbox-gl emitting its own "missing CSS
declarations" warning. The file's own header already says the engine loader is
imported rather than re-declared "because a second copy of one fact drifts".
The stylesheet URL was that second copy. It now comes from the same registry
via engineStylesheetUrl().
★ THE GATE COULD NOT SEE ANY OF IT, which is the more useful half of this
commit. verify-map-paint asserts a live GL context, zero console ERRORS, and
that mapbox emitted attribution -- all true of a map whose stylesheet never
applied, since the basemap paints fine while the chrome around it does not.
Mapbox itself says so, but as a WARNING, and the listener filtered to
type()==='error'. It now listens for _detectMissingCSS's verdict and fails on
it.
MUTATION-TESTED BOTH WAYS on the same leaf: restore v3.3.0 -> FAIL, exit 1;
restore the registry lookup -> PASS, exit 0.
Two things I had wrong on the way, recorded so the next reader does not repeat
them. I first asserted position:absolute on .mapboxgl-canvas-container -- but
v3.24.0 declares only cursor and touch-action there, so static is its CORRECT
value and that assertion would have failed every healthy map. I also read
"0 .mapboxgl-marker elements" as missing pins; mapbox draws pins as GL geometry
in the canvas, so zero is expected. A screenshot showed the polygons, the
region colours and hundreds of pins all rendering. Both were properties I
assumed rather than verified, and the canary's background-color is the one
mapbox actually defines -- it is the test _detectMissingCSS uses.
The canary alone would still have been a false green here: that rule is
identical in v3.3.0 and v3.24.0, so it is salmon under both. It catches an
ABSENT stylesheet; the warning catches a MISMATCHED one. The gate asserts both.
The same drift has now appeared six times, each spelled differently:
negated fields on a line naming "none", a positive hasAny over a
mapped row, a predicate inside a build script, and — found this tick —
a pair of columns no pass had ever touched. A source-level pattern has
to guess how the next one will be written.
Clause N does not guess. It compares the shipped ANSWER against the
pin tier, so it catches the seventh however it is phrased. Measured
when written: people-group-resources.any_resource_found disagreed with
the tier on 194 rows, and its no_resource_found pair contradicted
ITSELF on 7 more, because neither column was in the refresh at all.
Both now move together, 195 and 202 corrected, and the table agrees
with the tier on all 2,103 judgeable rows.
A code with no pin is SKIPPED, not guessed. The tier lives on pins, so
an unpinned code cannot be judged this way — judging it anyway
reported ajp as a defect on the first run, when the row was right and
the check was wrong. That is the failure this repo keeps meeting, so
it is written into the clause rather than left as a comment.
Also corrected there: the table's own note still told readers GRN
answers 403 to every request and had never been searched.
The legacy reconciler reported "nothing to change" and was confidently
wrong: its hasAny predicate never learned the two newest pools, so 122
curated rows — 8.9 million people — still carried heart_has_any=false
for groups the pin index can now reach. A reconciler that cannot see a
source is not reconciling, it is confirming.
Corrected exactly the 122 predicted, and the derived columns moved
with them: status 122, opportunity 93, lbc_carries_resource 69,
aligned 59, gap 29. Gap moves least because it means "nothing in the
heart language AND no in-country bridge either" — most of these have a
bridge, so they were already opportunities rather than gaps, and that
documented meaning is intact.
This was the fifth occurrence of one bug, and the gate written for it
had never looked at scripts — only .vue. Extending it to the build
scripts immediately found a sixth, in the Deaf gap table, which is
latent today because no sign language carries either field: GRN is
deliberately skipped for them and Scripture Earth has none. Fixed
while it is still cheap.
The rule now differs by file because the truth does: a surface must
READ the tier, while a build script COMPUTES it and cannot, so a
script must instead name every pool. Both halves verified against the
real pre-fix lines rather than synthetic ones.
The country sheet split a country's groups into "with resources" and
"no resource found" using its own hand-listed field set, which never
learned about Scripture Earth or GRN. Measured: 729 people groups —
77.9 million people — were filed under "no resource found" while this
same map's pin sheet offered them a working link. Chad alone had 107
groups with something and showed fewer.
It now reads the tier the pin index computed, carried onto the mapped
row. Chad reports 150 groups, 107 with a resource, 43 without, which
is what the tier says.
This is the FOURTH time this bundle has re-derived that decision
somewhere, and the gate written for it missed this one: the gate
looked for negated c.* fields on a line naming "none", and this was a
positive predicate named hasAny over the mapped row.
Widening it took three attempts and the first two were worse than the
bug. "Three OR-ed resource fields" flags canHear(), which ORs media
precisely because it must exclude text. "Spans text and media" flags
`!!c.s || !!(c.au && /text/…)`, because c.au is text or audio
depending on a qualifier and a `|| ''` default inflates the operator
count. Both were confident false findings — the failure this repo has
now met in a forced token, a swallowed flag and a class extractor, and
a gate that cries wolf gets switched off.
So it asks one question it can actually answer: a predicate NAMED for
"has anything" is deciding the tier by definition and must read it.
Verified against the real pre-fix line, not a synthetic one.
170 more gap codes probed, 84 with gospel audio. 511 codes now carry a
Words of Life link, and 701 people groups whose ONLY resource in any
pool is that recording — 77.6 million people — can hear the gospel in
their own language.
Tier 0 falls 2,270 to 2,172. It was 3,457 when this work began, so the
gap is down by more than a third, and the translation-priority table
grows in step: 2,556 groups now, because every code GRN reaches moves
a group out of "nothing found" and into "can hear it, no text".
1,027 of the gap's codes are decided. Provenance stays exact through
the change — 915 codes name GRN as checked, 915 have been decided,
none decided goes unnamed — and all new URLs were verified before
shipping.
The Driver, verbatim: "i need two posters per row on the regiosn posters and
witha setitn got icnreat eh number of poswers per row".
The wall's own grid is repeat(auto-fill, minmax(210px, 1fr)), which lays five
across a region card. It now takes a `columns` count and an optional
`columnsControl`, both opt-in and both defaulting to the behaviour that shipped
— so country-posters and my-upg-100-list, which did not ask for this, keep the
auto-fill they have always had. Measured after the change: they still lay out
five across and neither grew a control.
The control is a <select>, not a stepper or a pair of chevrons. It is what
every reader already knows for "choose one of a few", it is keyboard- and
screen-reader-operable with no work, and it shows its current value without a
second element. Imitating the solved pattern rather than inventing one. Its
label is catalogued in eleven locales like everything else on this surface.
The probe measures GEOMETRY, not CSS. Asserting grid-template-columns would
pass on a rule that says repeat(2, …) while the cards actually wrapped three
across because one had a min-width the track could not honour — and a computed
style is exactly the kind of proxy that has fooled this suite five times today.
It counts how many cards share a rendered top coordinate, which is what a
reader sees: [2,2,2,2,2].
And it proves the control by USING it — set it to 3 and the rows become
[3,3,3,3,3]. A default that happens to be right would pass a one-shot check
with an inert knob.
One thing worth recording for the next person: the first attempt put the
explanatory comment inside the PosterWall tag's attribute list, and the Vue
compiler rejected it. An HTML comment belongs above the tag. This file had
already learned that once.
Deployment method 2 now shows the UPG-100 prayer lists and posters at phone
size: one poster per row, print options wrapping in full. Shot from the
rebuilt bundle at 88a566f, not from source. The capability-4 shots are
retaken from the same build, so they show the print-option fix too.
THE LEGEND OVERFLOWED THE BOTTOM OF THE MAP because two numbers that had to
agree were written out twice.
.stl-panel is capped at calc(100% - var(--stl-top) - 12px), but this profile
set `top` DIRECTLY with !important instead of setting --stl-top. So the panel
moved down to 58px while the cap kept subtracting 8px, and the two silently
disagreed by exactly the difference.
MEASURED at 390px in a 420px-tall host: top 58px, max-height calc(100% - 20px),
panel bottom 458 in a 420px map -- 38px PAST the map. Same 38px at 560px,
because the error is the mismatch, not the map's size. After: -12px, the
intended bottom margin, at 390/420, 390/560 and 1440/500 alike.
Two more copies of the same mistake went with it. The mobile rule paired
top:64px with max-height:calc(100vh - 72px) -- a constant unrelated to the
offset, AND the wrong box: 100vh measures the VIEWPORT while the panel is
positioned inside the MAP. Those agree only when the map fills the screen,
which is true of every standalone leaf and false the moment a host puts a
header above it. .fit-content had the same fixed 72px. Both now derive from
--stl-top, so they cannot drift apart again.
★ THIS IS INVISIBLE ON EVERY PUBLISHED LEAF. A standalone leaf gives the map
the whole viewport, and a panel that overflows a full-height map is still on
screen. It only appears when the host makes the map SHORTER than the viewport
-- which is how the element is actually embedded. Verify in a constrained
host, never on the bare leaf.
The a11y half of this work is NOT here. I fixed the same two nameless legend
controls locally while the remote was unreachable, then found on rebase that
they were already fixed on main -- better, with the row's own label
interpolated ("Expand Africa" rather than a generic string), and in one
key namespace instead of the second one I had introduced. Took main's version
whole and dropped my duplicate locale keys; only the CSS above is mine.