check:maplibre-drops went red on gospel-resources/resources-map and the new
gospel-dashboard leaf: layers.gr-pins.paint.circle-sort-key: unknown property.
circle-sort-key is a LAYOUT property. Mapbox tolerates it in `paint`; MapLibre
rejects the whole layer for one misplaced property and does NOT throw — so under
the keyless engine all 12,379 dots were absent while the page still rendered its
basemap, its choropleth and its legend, and looked fine. That is the same defect
that cost all-people-groups its pins, so the fix is the same: move the key into
`layout` and leave the z-order comment with it, since the reason (a group with
nothing found must win the z-fight) is about the sort, not about paint.
MEASURED after, both engines: gr-pins present, circle-sort-key in layout, pins
drawn. Screenshot read on maplibre — dots and choropleth both there.
check:maplibre-drops PASS 17/17 (it was 15 ok / 2 dropping).
The gate earned its keep here: this arrived with incoming work, not with mine,
and nothing in the page's own appearance would have told anyone.
Merged rather than rebased: this branch already carries an ours-merge recording an
earlier pre-rebase tip, so replaying it onto main would duplicate that ancestry. A merge
takes main's one new commit cleanly and leaves both histories intact.
Conflicts, if any, are generated public/ output and are resolved by taking main's and
regenerating from source.
Typing into the research-map's search bar inside a composite (the Simple
Research dashboard) wiped the box instantly and never queried. Standalone, the
same map searched fine — which is the shape of the bug, not a coincidence.
mapbox-gl-geocoder unwraps exactly ONE shadow level to find the input:
var e = t.target && t.target.shadowRoot ? t.target.shadowRoot.activeElement : t.target
if (!(e ? e.value : "")) { this.fresh = !0, 9 !== t.keyCode && this.clear(t), ... }
Its handler is debounced, so it reads event.target after dispatch has ended, by
which time the target is the OUTERMOST host. One hop is therefore exactly right
for a map one custom element deep and exactly wrong for a map inside another
one. Measured, same build, same keystroke:
doxa-research-map (1 deep) target=DOXA-RESEARCH-MAP -> unwrap = the input -> _geocode("Bhu")
research-map-plus-grid (2) target=RESEARCH-MAP-PLUS-GRID -> unwrap = <doxa-research-map> -> clear()
An element with no .value reads as an empty box, so the vendor cleared what you
had just typed. Walk the activeElement chain to the bottom instead of one step
and hand the vendor an event whose target is that element; nesting deeper cannot
break it again, and at one level it resolves to the same input as before.
Two things worth recording. The receiver is passed explicitly: the Mapbox build
binds this method in its constructor and the MapLibre build does not, and an
unbound call landed on `this === undefined`, which swallowed the keystroke AND
the query ("Cannot read properties of undefined (reading
'_hideGeolocateButton')") — green-looking text with no search behind it. And
neither suspect on the card was the cause: the clear came from inside the vendor
bundle, not from _geoBeingCleared, and SearchBar is not involved.
MEASURED on the built tree, both engines' mapbox mode, typing "Bhutan":
fix off leaf ok (1 geocoding request) / composite value="" and 0 requests
fix on both keep "Bhutan", both fire the query, both list 19 results
including People Cluster, People Group + Country, Country and Place
Screenshot read, not just the log. gates:pre 7/7, gates:post 34/34.
Its toast has always read "Tap a coloured dot — someone still needs prayer". Tapping one
showed Scripture, film, audio, video, stories, tracts and training, and no way to pray.
doxa.life publishes a page per people group and sibling plugins in this repo already link
to it; this map simply never did. 1,723 groups now carry a "Pray for <group>" action with
the number already praying beside it.
THIS IS THE ONE JOIN HERE THAT IS NOT BY LANGUAGE, and it needed justifying rather than
assuming. Every other source keys on ISO 639-3 because a translation serves everyone who
speaks the language; prayer does not work that way — you pray for a people, in a place. So
this keys on (group name, country), which is a NAME join, the thing this map refuses
everywhere else because names collide.
Allowed only because the key was tested first: on the API side (name, country) is
perfectly unique — 2,107 entries, 2,107 distinct keys, 0 collisions — and on the pin side
12,379 pins give 12,369 distinct keys. The ambiguous ones are DROPPED, not guessed. One
entry was lost that way, and 383 more have no pin here at all.
pray.doxa.life is the fourth host in this bundle that answers 200 with identical bytes for
any URL: /abai-sungai and /zzz-not-a-real-group are both 3,903 bytes. Rendered it does
discriminate — a real slug titles the page "Abai Sungai - App" with 1,863 characters, a
bogus one titles it "Doxa.Life" with zero — but existence comes from the API listing,
which is the publisher's own statement and costs one request rather than 1,723 renders.
The lookup is COMPUTED, not baked into groupAt. groupAt runs the instant a pin is clicked,
so a value read there is whatever had arrived by then and never updates when the lazy
fetch lands — the same shape as the link-verification state that used to hang off
openPin's country branch.
Verified with a negative control: a group in the index renders an anchor whose href
matches the expected slug URL exactly, and German, which is not in it, renders nothing.
My probe reported FAIL twice against correct code by waiting a guessed 900ms; it now waits
for the data. Screenshot read — the Lati, 2,900 people with nothing found in any pool, can
now be prayed for.
languages-by-country is the bridge view: one row per country and language, ranked by
prevalence, flagging which languages could carry a resource to groups that lack one. Its
curated work — prevalence_rank, bridge_candidate, groups_speaking — is the reason it
exists and is untouched.
Its resource flags were not curated, and they had drifted like the other two legacy
tables: 75 rows wrong about Scripture, 39 wrong about whether the language carries
anything at all, 1 about film. That matters more here than elsewhere, because this table
is consulted to CHOOSE a bridge: a language flagged as carrying nothing when it carries
audio Scripture is a bridge somebody was told not to use.
Refreshed from the pin index, which is the same source the map draws. 0 of 613 rows now
disagree, and all 613 still carry their curated columns.
full_bible is deliberately NOT refreshed. The pin index knows a scope only for eBible
rows, so downgrading a curated "full Bible" on that basis would lose information rather
than correct it.
Also recorded rather than acted on: this table covers 175 countries while the map has
242 with pins, so 67 countries and 1,537 people groups have no bridge analysis at all.
That is a gap in curated coverage, not a contradiction — the rows that exist are now
correct — and filling it means doing the prevalence work, not copying numbers across.
Censused the 67 gate scripts that are in no wave. The framing I had been using —
"coverage we are not getting" — was wrong in the more serious direction. Two of
them were FAILING on main right now. The gates exist, work, and had found
something; because nothing ran them the findings sat silently.
check:legend-source, a real product finding: the live corpus moved 2108 -> 2107
and one group crossed Unengaged -> Engaged, so the legend baseline was
describing a dataset that no longer exists. Confirmed 2107 independently against
the live endpoint rather than trusting the gate's own reading.
Updated the baseline following the procedure its header requires, repeated
rather than assumed: grepping the six figures across plugin-designs/ and
library/ finds them only in explanatory comments, so no shipping code hardcodes
a corpus total and only the baseline lagged. Mutation-tested — an off-by-8
baseline fails. Recorded that this is the third reading, that it drifts in both
directions, and that it must NOT be widened into a range: the noise is the
signal that the numbers on the page are live rather than asserted.
check:verification-tags asks whether a human ever looked at a done card. 98
could not answer. 24 of those were mine, and they are now tagged agent/state —
honestly agent rather than human, and state rather than visual, because every
one was verified by measuring rendered DOM, PDF bytes and request tallies, never
by a human looking at pixels. The other 74 belong to other agents and the
Driver; guessing their provenance would defeat the gate, so it stays unwired.
Six further gates were green and self-contained and are now wired: locale
reachability, url resolution, manifest coverage, the palette registry, and the
legend/source comparison. gates:post 37 -> 42; wave membership 43 -> 50 of 106.
One I nearly wired wrong. check:dxlife passes standalone but exits 2 by
documented contract — 0 unchanged, 1 drift, 2 clone not found — and the wave
runner grades any non-zero as failure, so a checkout without the sibling clone
would go red on a precondition. Caught in the fresh-checkout run, unwired, with
the reason recorded on the script so the next census does not re-add it. "Can it
run" and "is it wave-safe" are different questions.
42/42 with servers up; 31/42 passed and 7 SKIPPED on a fresh checkout, every
skip named.
The seven gospel-resources commits were rebased onto main after other agents landed, so
the branch tip and its rebased copies have different hashes with identical content. This
merge records the old tip as an ancestor so the branch advances by fast-forward instead
of a force push, which this repo forbids.
Verified before recording: the source trees at af6f343 and at the rebased HEAD are
identical under plugin-designs/, internal/ and data/.
gospel-by-country carries 242 countries with real per-country counts, and the map uses
them for the choropleth and the "where the gap concentrates" sheet. The tables profile
had no country table at all, so those numbers lived only inside the map — readable, not
exportable — while every other tab there offers CSV and JSON. A mobiliser deciding where
to send a team cannot work from a sheet they have to screenshot.
Derived, not a second source: every row is read out of gospel-by-country, which is itself
refreshed from the pin index, so the table and the map cannot disagree.
Three columns because they rank differently and each is somebody's question — groups with
nothing, people with nothing, and the share of the country each represents. The default
sort is by groups, and the share column is what makes rows like Vanuatu legible: 79 groups
with nothing out of 114, 69.3%, which ranks ninth by count and first by share. Region is
carried through so the table can be grouped the same way the map's region tab ranks.
Curated columns come through as-is and stay null where the curated analysis does not cover
the country — null, never zero, because zero is a claim this build cannot make.
Verified against the PIN INDEX rather than against the table being tested: 242 countries
with pins, 2,901 groups with nothing, worst is PNG at 469. The rendered tab reports 242
rows and its first row reads Papua New Guinea / PNG / Oceania / 831 / 469 / 56.4, with
650 real cells. No page errors. Screenshot read.
region was empty on all 242 country rows, so the map had no way to answer "what share of
this part of the world is unreached" — only how many groups a country holds and how many
people live in them.
The source carries UNm49Region, and it was verified before being trusted: only 2, 9, 19,
142 and 150 appear — exactly the five UN M49 top-level regions — every country maps to
exactly ONE of them with 0 conflicts, and the five sum to 12,379, the whole corpus. So
the code resolves to a real name. It is backfilled on every row, not just the new ones,
and all 242 now carry a region with none blank.
The rate is a genuinely different answer from the two already on offer:
Oceania 51.2% of its 1,325 groups have nothing — 679 groups — 3.1M people
Africa 30.0% of 3,074 — 923 — 68.2M
Asia 19.3% of 5,073 — 978 — 198.7M
Americas 11.4% of 2,117 — 241 — 6.1M
Europe 10.1% of 790 — 80 — 11.6M
Oceania is half unreached by group and the smallest population of the five; Asia is the
reverse on both. Ranked by rate, because count and population are what the other two tabs
already say and a third copy of either would be noise. All three are offered rather than
one picked for the reader.
The rows are plain divs, not buttons: a region is not somewhere this map can fly to, and
a control that looks clickable and does nothing is worse than a row that does not pretend.
Verified against an offline computation over the same index: all five regions match
exactly on rate, group count and population. The sheet header is now three-way rather
than showing the country prose in region mode. All five profiles still mount clean.
Set out to verify the map's second mode, "Resources found", which had never been tested.
It works: the legend switches, the arithmetic is an exact complement of the gap view — 0
of 170 countries disagreed — and the two modes genuinely repaint, 98/46/12/6 polygons by
band in gap against 85/66/9/2 in coverage.
Proving that took three attempts and the third one found the real defect. Comparing the
fill expression proved nothing, because it is ["get","color"] in BOTH modes — the colour
is baked onto each feature and switching modes rewrites the source data. Comparing the
features instead showed 157 coloured and 5 transparent against 170 rows in the table,
and that mismatch was the thread worth pulling.
The previous refresh of this table only UPDATED rows that already existed, and the table
came from the curated 2,106-record set. So 60 countries carrying pins had no row at all —
150 people groups with nothing found, invisible to every country-level surface. Canada
20, Ghana 15, Timor-Leste 13, Australia 7. 72 countries are now added from the pins, and
0 countries with bare groups are missing; all 242 rows agree with the pins on groups and
no_resource.
Curated columns on the new rows are null, never zero: this build does not know a growth
rate for Canada, and zero is a claim. They are flagged from_pins_only so the different
basis is visible rather than implied. Names come from people-groups-slim's CountryName,
because a row labelled "CAN" renders "CAN" as the sheet's title. UNm49Region was
deliberately NOT used for region — it is a numeric code, and "19" is worse than blank.
One row stays a bare code: Kosovo, whose non-standard KOS has no name in the source, and
inventing one for a single row is not worth it.
The boundary file turns out to hold only 162 polygons and has no Canada, Australia,
Timor-Leste or Taiwan, so this paints five previously-transparent countries and fixes the
rest where it actually shows: clicking a pin in Canada now opens a sheet titled "Canada"
with 304 groups, matching the pins exactly, where before it fell through to a bare ISO
code with no statistics.
Checked whether a reader can undo the dashboard filter and tell why the table changed.
They can: the banner names the selection and offers an escape, clearing works, and
re-selecting the same country refilters rather than sitting cleared — the filter object
carries a timestamp precisely so an identical selection is still a change.
The ambiguity is in the labels. Stacked under the map there were THREE controls reading
exactly "Show all": two in the map's group lists, which reveal the rest of a country's
people groups, and one under the table, which clears the resource filter. Nothing on
screen distinguished them.
That is not a theoretical complaint. The first probe written to verify this clicked one
of the map's, saw the filter unchanged, and reported "Show all does not clear the filter
in composed mode" — a defect that did not exist. If the ambiguity can mislead a probe
reading the DOM, it can mislead a reader looking at the screen.
The map's now read "Show all groups", the table's "Show all resources". One word each.
Verified after the change: the three candidates are distinguishable by text alone, and
the full cycle passes — filter to 400 of 6,498 for Indonesia, clear back to no banner,
re-select the same country and return to 400, no page errors. Screenshot read.
Fourth probe in this flight to produce a wrong verdict from an assumption about the DOM,
and the second where a composite made a pattern ambiguous that is unambiguous on a
single page.
The language link was fixed last change; the country link had the identical defect and
answers the brief's SECOND requirement. "Every resource used in Indonesia" was an
<a href="../data-tables/index.html?tab=links&country=IDN">, so inside the composite,
clicking a country to see its resources navigated away from the composite to do it.
Both controls now split the same way: a button that filters the tables below when they
are on screen, an anchor when they are not.
Verified against an offline expectation rather than read off the page. Counting the
links table for rows whose country_codes contain IDN gives 400. In the dashboard the
control is a BUTTON, the URL stays on gospel-dashboard/index.html, and the banner reads
"Showing 400 of 6,498 resources for Indonesia" — the same 400. Standalone it is still an
<a> to data-tables/index.html?tab=links&country=IDN&label=Indonesia, so nothing changed
for the map on its own page. No page errors on either.
Screenshot read: the filtered rows are real Indonesian-language resources across five
sources — JESUS Film, Faith Comes By Hearing audio and Bible video, Cru's Four Spiritual
Laws, eBible and Zume — with a "Show all" escape beside the count.
The composite puts the map above its own tables, and the brief's third requirement is
that clicking a pin filters that table. It did not. "Every resource in German, Standard"
was an <a href="../data-tables/index.html?...">, which is exactly right when the map is
alone — and inside the dashboard it navigated AWAY, to the standalone tables page. The
filter applied correctly, 6 of 6,498, and the map was gone. Measured: the URL changed
from gospel-dashboard/index.html to data-tables/index.html.
The two halves now talk to each other. The dashboard provides a shared filter; the map
injects it and, when present, turns that link into a button that sets the filter and
scrolls the tables into view; the tables inject it and apply it in place, switching to
the links tab because a language filter means nothing on the others.
PROVIDED, NOT PROPPED, because both children must still work standalone. A child that
finds nothing injected keeps its old behaviour exactly — verified rather than assumed:
on resources-map/index.html the control is still an <a> pointing at
data-tables/index.html?tab=links&iso=deu.
Composed: the control is a BUTTON, the URL stays on gospel-dashboard/index.html, the
table banner reads "Showing 6 of 6,498 resources for German, Standard", and 6 rows with
66 cells render. Arrow changed from → to ↓ because the destination is now down the page
rather than away from it.
My first probe read "12 of 25" here and was wrong. Both halves share ONE shadow root in
the composite, so the map's own "Showing 8 of 25" matched the same pattern as the
table's banner. The probe now matches the table's specific wording and asserts rendered
rows and cells beside it. Same class of mistake as the three already recorded in
AGENT-BRIEF — a pattern that is unambiguous on one page can be ambiguous in a composite.
All five profiles still mount clean; gospel-dashboard 68,459 chars, 54 links, 0 errors.
The dashboard is the one profile never opened on a phone. Structurally it holds up at
390x844 — no horizontal overflow anywhere in either shadow tree, canvas 390x675, the
table half renders 20 real rows and 60 cells, no page errors. What does not hold up is
what it says.
Under the table, the Pools Caveat read "Pools checked: eBible.org, JESUS Film (Arclight),
Door43/unfoldingWord". On the same screen, visible immediately above it, were rows typed
Audio Scripture, Bible video, Zume Training and Gospel presentation — four pools the
caveat denied having checked. It is the most direct version of a mistake this bundle has
now hit repeatedly: a sentence written by hand goes stale the moment a source is added,
and this one had gone stale four times over.
It is no longer written by hand. The checked list is now counted off the rows themselves,
so it cannot disagree with the table it sits under: "Cru (GodTools), Door43
(unfoldingWord), Faith Comes By Hearing, JESUS Film, PNG.Bible, Zume Training,
eBible.org, eBible.org (mirror)". The NOT-checked half stays prose, because it names
things that are absent and no row can testify to those — GRN, api.bible, the Digital
Bible Library and Progress Bible, each needing a credential or an agreement.
The page subtitle had the same shape of error, offering "scripture, film or audio" over
tables carrying seven media. It now names the question rather than a list that will go
stale again.
Verified on the phone viewport after the change: table half still 20 rows / 60 cells, no
overflow, no page errors, and the rendered caveat lists all eight sources. Screenshot
read.
gospel-by-country carries 242 countries with real per-country counts, and the map uses
them for the choropleth and the "where the gap concentrates" sheet. The tables profile
had no country table at all, so those numbers lived only inside the map — readable, not
exportable — while every other tab there offers CSV and JSON. A mobiliser deciding where
to send a team cannot work from a sheet they have to screenshot.
Derived, not a second source: every row is read out of gospel-by-country, which is itself
refreshed from the pin index, so the table and the map cannot disagree.
Three columns because they rank differently and each is somebody's question — groups with
nothing, people with nothing, and the share of the country each represents. The default
sort is by groups, and the share column is what makes rows like Vanuatu legible: 79 groups
with nothing out of 114, 69.3%, which ranks ninth by count and first by share. Region is
carried through so the table can be grouped the same way the map's region tab ranks.
Curated columns come through as-is and stay null where the curated analysis does not cover
the country — null, never zero, because zero is a claim this build cannot make.
Verified against the PIN INDEX rather than against the table being tested: 242 countries
with pins, 2,901 groups with nothing, worst is PNG at 469. The rendered tab reports 242
rows and its first row reads Papua New Guinea / PNG / Oceania / 831 / 469 / 56.4, with
650 real cells. No page errors. Screenshot read.
check:provider-switch is a good gate — it counts requests BY HOST rather than
reading window globals (which stay defined after a swap), and it allows exactly
the api.mapbox.com URLs the generated leaf declares in its own head, so a
half-fix at 18 requests cannot pass.
It had a package.json entry and was in no wave, because it REQUIRED a URL
argument and exited 2 without one. So it only ever ran when somebody typed a
URL — the same present-but-never-executed shape check:packets-all had before it
was given a fixture.
Wiring it as-is would have turned every server-less build red rather than
silently green, which is the less common half of that failure. It now defaults
to the upg-100 leaf and SKIPs in the form the wave runner recognises, so the
skip is counted and named instead of grading as a pass or an error.
gates:post 36 -> 37, README re-synced. Verified both ways: 37/37 with a server,
and 27/37 passed / 6 SKIPPED on a fresh checkout with none.
Found while re-verifying BUG-UPG100-IGNORES-THE-PROVIDER-SWITCH from scratch
rather than trusting its done status. The card holds: ?provider=maplibre drops
Mapbox traffic 19 -> 5 on the upg-100 leaf, and all 5 are the page's own
declared preloads, so zero runtime calls remain. The same run independently
reproduced the four residual URLs in BUG-LEAF-PAGES-PRELOAD-MAPBOX-EVEN-UNDER-
MAPLIBRE, which stays untaken — its fix is in generate-tree, the tree verb
bundler-1 owns. When those preloads go, this gate's allowance drops to 0 and it
holds the new floor with no edit.
region was empty on all 242 country rows, so the map had no way to answer "what share of
this part of the world is unreached" — only how many groups a country holds and how many
people live in them.
The source carries UNm49Region, and it was verified before being trusted: only 2, 9, 19,
142 and 150 appear — exactly the five UN M49 top-level regions — every country maps to
exactly ONE of them with 0 conflicts, and the five sum to 12,379, the whole corpus. So
the code resolves to a real name. It is backfilled on every row, not just the new ones,
and all 242 now carry a region with none blank.
The rate is a genuinely different answer from the two already on offer:
Oceania 51.2% of its 1,325 groups have nothing — 679 groups — 3.1M people
Africa 30.0% of 3,074 — 923 — 68.2M
Asia 19.3% of 5,073 — 978 — 198.7M
Americas 11.4% of 2,117 — 241 — 6.1M
Europe 10.1% of 790 — 80 — 11.6M
Oceania is half unreached by group and the smallest population of the five; Asia is the
reverse on both. Ranked by rate, because count and population are what the other two tabs
already say and a third copy of either would be noise. All three are offered rather than
one picked for the reader.
The rows are plain divs, not buttons: a region is not somewhere this map can fly to, and
a control that looks clickable and does nothing is worse than a row that does not pretend.
Verified against an offline computation over the same index: all five regions match
exactly on rate, group count and population. The sheet header is now three-way rather
than showing the country prose in region mode. All five profiles still mount clean.
This header said the actions/runners endpoints 404 on this Forgejo version, so
runner state "was not directly measurable". Only the LISTING endpoint 404s. Both
registration-token endpoints return 200 and issue a 40-char token on demand:
/api/v1/repos/doxa-life/doxa-maps/actions/runners/registration-token
/api/v1/admin/runners/registration-token
That distinction matters more than it looks. "Not measurable" reads as "nothing
to try", and this workflow has sat designed-but-never-run for weeks while the
one action that would activate it was API-reachable the whole time.
The header's load-bearing claim is unaffected and re-verified today: still
total_count 0, still never run.
It also cited a handoff doc twice — in this header, and beside "the ONE Driver
action that registers it" — and that file did not exist. It does now, at the
path cited, written from measured server facts rather than generic Forgejo
advice: version 9.0.3+gitea-1.22.0, has_actions true, the label the runner must
carry (ubuntu-latest, or every job queues forever looking exactly like no
runner), the github.com reachability the setup-bun action needs, and the
confirmation step that distinguishes "registered" from "actually ran".
Note this commit touches only .forgejo/, which paths-ignore excludes — so it
will not trigger a build even once a runner exists. That is intended.
Set out to verify the map's second mode, "Resources found", which had never been tested.
It works: the legend switches, the arithmetic is an exact complement of the gap view — 0
of 170 countries disagreed — and the two modes genuinely repaint, 98/46/12/6 polygons by
band in gap against 85/66/9/2 in coverage.
Proving that took three attempts and the third one found the real defect. Comparing the
fill expression proved nothing, because it is ["get","color"] in BOTH modes — the colour
is baked onto each feature and switching modes rewrites the source data. Comparing the
features instead showed 157 coloured and 5 transparent against 170 rows in the table,
and that mismatch was the thread worth pulling.
The previous refresh of this table only UPDATED rows that already existed, and the table
came from the curated 2,106-record set. So 60 countries carrying pins had no row at all —
150 people groups with nothing found, invisible to every country-level surface. Canada
20, Ghana 15, Timor-Leste 13, Australia 7. 72 countries are now added from the pins, and
0 countries with bare groups are missing; all 242 rows agree with the pins on groups and
no_resource.
Curated columns on the new rows are null, never zero: this build does not know a growth
rate for Canada, and zero is a claim. They are flagged from_pins_only so the different
basis is visible rather than implied. Names come from people-groups-slim's CountryName,
because a row labelled "CAN" renders "CAN" as the sheet's title. UNm49Region was
deliberately NOT used for region — it is a numeric code, and "19" is worse than blank.
One row stays a bare code: Kosovo, whose non-standard KOS has no name in the source, and
inventing one for a single row is not worth it.
The boundary file turns out to hold only 162 polygons and has no Canada, Australia,
Timor-Leste or Taiwan, so this paints five previously-transparent countries and fixes the
rest where it actually shows: clicking a pin in Canada now opens a sheet titled "Canada"
with 304 groups, matching the pins exactly, where before it fell through to a bare ISO
code with no statistics.
Checked whether a reader can undo the dashboard filter and tell why the table changed.
They can: the banner names the selection and offers an escape, clearing works, and
re-selecting the same country refilters rather than sitting cleared — the filter object
carries a timestamp precisely so an identical selection is still a change.
The ambiguity is in the labels. Stacked under the map there were THREE controls reading
exactly "Show all": two in the map's group lists, which reveal the rest of a country's
people groups, and one under the table, which clears the resource filter. Nothing on
screen distinguished them.
That is not a theoretical complaint. The first probe written to verify this clicked one
of the map's, saw the filter unchanged, and reported "Show all does not clear the filter
in composed mode" — a defect that did not exist. If the ambiguity can mislead a probe
reading the DOM, it can mislead a reader looking at the screen.
The map's now read "Show all groups", the table's "Show all resources". One word each.
Verified after the change: the three candidates are distinguishable by text alone, and
the full cycle passes — filter to 400 of 6,498 for Indonesia, clear back to no banner,
re-select the same country and return to 400, no page errors. Screenshot read.
Fourth probe in this flight to produce a wrong verdict from an assumption about the DOM,
and the second where a composite made a pattern ambiguous that is unambiguous on a
single page.
The legend's Export > Prayer packet action is the path a reader clicks. The
build-time file is the one nobody clicks. The reader's packet was the less
verified of the two.
check:packet-render gained six assertion families over recent ticks — the 12
prayer points, the wheel's 12 LABELS rather than just its 12 segments, page 1's
geography, the print layer, the image census, the build stamp. Each was found by
mutation and each catches a real "looks fine, is empty" shape. None existed
here.
It also printed "Mark 5 in full : undefined" beside a PASS for as long as it has
existed: the measurement is r.mark5Len and the print read r.mark5, which is not
a key on r. The assertion always read the right key, so the requirement was
protected — but undefined next to a PASS reads as a gate that does not know what
it is doing. That is the inverse of this tree's usual failure, where a number is
printed and never asserted on.
A mistake caught before it became a false report. The new geography assertion
went red immediately with "no map svg on page 1", which would have read as a
serious product defect. It was the harness: this gate called buildPacket with an
EMPTY mapSvg, while the real export path fetches posters/<zone>.html and lifts
its <svg>. The shortcut was not just skipping an assertion — it meant the gate
never exercised mapCss() in a browser at all, which is the one thing it exists
to prove. It now fetches the map the way the product does and reports 13
geography paths, the same number check:packet-render gives for laos. ISO3 and
ZONE are also different strings ("lao" vs "laos.html"), so even a naive fetch
would have missed; PACKET_ZONE names it.
Mutation-tested: blanking the wheel labels gives 0/12 labelled and fails; hiding
content at print time gives 0 embedded fonts and fails.
i18n verified on the browser path for the first time. A partial locale with 3 of
12 points renders 12/12 points — the per-key merge holds here too — with the
points heading translated, the wheel heading falling back to English, Mark 5 at
2220 chars, and nothing blank.
Also made wave-safe: without a server it threw an unhandled navigation error and
dumped a stack trace, which is why it could never join a wave. It now SKIPs
cleanly naming its precondition. gates:post 35 -> 36, README re-synced. Verified
both ways — 36/36 with a server, and "0/1 passed, 1 SKIPPED" without one, which
is visible only because of the runner change in the previous commit.
The language link was fixed last change; the country link had the identical defect and
answers the brief's SECOND requirement. "Every resource used in Indonesia" was an
<a href="../data-tables/index.html?tab=links&country=IDN">, so inside the composite,
clicking a country to see its resources navigated away from the composite to do it.
Both controls now split the same way: a button that filters the tables below when they
are on screen, an anchor when they are not.
Verified against an offline expectation rather than read off the page. Counting the
links table for rows whose country_codes contain IDN gives 400. In the dashboard the
control is a BUTTON, the URL stays on gospel-dashboard/index.html, and the banner reads
"Showing 400 of 6,498 resources for Indonesia" — the same 400. Standalone it is still an
<a> to data-tables/index.html?tab=links&country=IDN&label=Indonesia, so nothing changed
for the map on its own page. No page errors on either.
Screenshot read: the filtered rows are real Indonesian-language resources across five
sources — JESUS Film, Faith Comes By Hearing audio and Bible video, Cru's Four Spiritual
Laws, eBible and Zume — with a "Show all" escape beside the count.
run-parallel grades on the exit code, and a gate that declines to evaluate
exits 0. So "35/35 passed" read the same whether 35 gates graded something or
31 did, and the summary line — the one people quote — was the most misleading
part of the output.
This is the generalisation of the packet-sweep fix two commits ago. That closed
ONE always-skipping gate; the reason nobody could see it for months was that the
runner reported it green. 18 of the 35 steps in gates:post CAN skip, every one
on a precondition a fresh checkout does not have, so the gap is structural
rather than specific to packets.
Measured on a genuinely fresh checkout — `git archive HEAD | tar -x`, which is
what CI sees. Before: "31/35 passed". After: "28/35 passed, 4 SKIPPED", naming
check:packets-all, check:zone-census, check:done-evidence, check:backlog-status.
All four are legitimate, and I checked rather than assumed. packets-all skips
until packet:ci runs, which CI does first. zone-census needs generated posters.
The two backlog gates read _qa-cycle, which lives outside any repo by the
deliberate decision that PM markdown never sits inside a code repo — and both
already say so in their own output. So the finding is the REPORTING, not a
broken gate.
Exit semantics are deliberately unchanged, and verified: a wave whose only step
skips still exits 0. Failing on a SKIP would fail every CI build on the backlog
gates and teach people to disable the reporting.
The detector is mutation-tested against seven real output shapes. It matches
"ZONE-CENSUS: SKIP — …" and the indented variant, and correctly does not match
prose mentioning skip, nor "SHARE-POLICY: PASS — 12 buttons, 0 skipped".
A correction to my own method along the way: I first censused skips in
doxa-maps-plugin-studio as a proxy for a fresh checkout. It is not one — it sits
many commits behind main with a 21-step gates:post that lacks 14 of the steps
main has, so the two censuses were of different waves. The fresh-archive test
replaced it.
Also documents check:zone-census's precondition, the one conditional gate in the
wave that had none — including why it cannot be fixtured the way packets-all
was: it asserts coverage of real generated output, so a fixture would only
assert that the fixture is complete.
The composite puts the map above its own tables, and the brief's third requirement is
that clicking a pin filters that table. It did not. "Every resource in German, Standard"
was an <a href="../data-tables/index.html?...">, which is exactly right when the map is
alone — and inside the dashboard it navigated AWAY, to the standalone tables page. The
filter applied correctly, 6 of 6,498, and the map was gone. Measured: the URL changed
from gospel-dashboard/index.html to data-tables/index.html.
The two halves now talk to each other. The dashboard provides a shared filter; the map
injects it and, when present, turns that link into a button that sets the filter and
scrolls the tables into view; the tables inject it and apply it in place, switching to
the links tab because a language filter means nothing on the others.
PROVIDED, NOT PROPPED, because both children must still work standalone. A child that
finds nothing injected keeps its old behaviour exactly — verified rather than assumed:
on resources-map/index.html the control is still an <a> pointing at
data-tables/index.html?tab=links&iso=deu.
Composed: the control is a BUTTON, the URL stays on gospel-dashboard/index.html, the
table banner reads "Showing 6 of 6,498 resources for German, Standard", and 6 rows with
66 cells render. Arrow changed from → to ↓ because the destination is now down the page
rather than away from it.
My first probe read "12 of 25" here and was wrong. Both halves share ONE shadow root in
the composite, so the map's own "Showing 8 of 25" matched the same pattern as the
table's banner. The probe now matches the table's specific wording and asserts rendered
rows and cells beside it. Same class of mistake as the three already recorded in
AGENT-BRIEF — a pattern that is unambiguous on one page can be ambiguous in a composite.
All five profiles still mount clean; gospel-dashboard 68,459 chars, 54 links, 0 errors.
public/doxa-maps/index.html conflicted again. It is generated, so it was
resolved by regenerating rather than by hand-merging two machine-written
files: build the bundle, rebuild the tree, re-hash. fix:sri then reports all
113 hashes already in agreement, which is the evidence the tree is coherent
rather than merely conflict-free.
The dashboard is the one profile never opened on a phone. Structurally it holds up at
390x844 — no horizontal overflow anywhere in either shadow tree, canvas 390x675, the
table half renders 20 real rows and 60 cells, no page errors. What does not hold up is
what it says.
Under the table, the Pools Caveat read "Pools checked: eBible.org, JESUS Film (Arclight),
Door43/unfoldingWord". On the same screen, visible immediately above it, were rows typed
Audio Scripture, Bible video, Zume Training and Gospel presentation — four pools the
caveat denied having checked. It is the most direct version of a mistake this bundle has
now hit repeatedly: a sentence written by hand goes stale the moment a source is added,
and this one had gone stale four times over.
It is no longer written by hand. The checked list is now counted off the rows themselves,
so it cannot disagree with the table it sits under: "Cru (GodTools), Door43
(unfoldingWord), Faith Comes By Hearing, JESUS Film, PNG.Bible, Zume Training,
eBible.org, eBible.org (mirror)". The NOT-checked half stays prose, because it names
things that are absent and no row can testify to those — GRN, api.bible, the Digital
Bible Library and Progress Bible, each needing a credential or an agreement.
The page subtitle had the same shape of error, offering "scripture, film or audio" over
tables carrying seven media. It now names the question rather than a list that will go
stale again.
Verified on the phone viewport after the change: table half still 20 rows / 60 cells, no
overflow, no page errors, and the rendered caveat lists all eight sources. Screenshot
read.
Drilling All Regions to Africa to Angola and then pressing Back: the first
press returned to Africa, the second stayed on Africa, and Forward never did
anything. history.length sat at 4 the whole time, which is the tell — every
Back was pushing a replacement entry, discarding the forward stack and
pinning the position.
The guard against that could never have worked. onPopState set a flag,
assigned the selection and cleared the flag synchronously, while the watcher
it guards is flush:'post' and runs after the DOM update — by which time the
flag was already false. So the flag is gone rather than patched: writeUrl now
compares the URL it would write against the one the address bar already
holds and returns early when they match. After a popstate there is nothing to
write, so no entry is created.
A one-step check could not have caught this. The existing assertion picks a
region, clears it, and presses Back once — and one step is exactly the depth
at which the bug still looks correct.
check:packets-all is in gates:post. CI runs it on every build. It has SKIPped
every single time, because library/scripts/posters/out/ is gitignored — both the
records cache and every generated packet — so a clean checkout has none.
Every packet assertion in this tree has therefore never once been evaluated
there: the 12 prayer points, the wheel's 12 labels, page 1's geography paths,
the print layer, the build stamp. Several ticks of work accumulating on a gate
that had only ever graded files in my own working tree.
A SKIP that is always taken is indistinguishable from an unwired gate — and the
wave-membership census cannot tell them apart, because membership measures
invocation, not evaluation. That census is how check:packets-all was created in
the first place, which makes this the same class recurring one level up.
I raised this at the end of the last tick and deferred it on the premise that
closing it "adds a live-API call to every build". The premise was wrong.
packet.mjs already had an OFFLINE mode, and a fixture needs no network at all.
The fixture is the smallest zone — Tibet, 43 slugs, 20KB — with image_url
stripped so CI can never fetch 43 photos from someone else's CDN. It is DERIVED,
not hand-maintained: membership comes from the committed clusters.json, so
check:packet-fixture regenerates and content-compares it. Re-adding an image_url
fails; dropping a record fails naming the slug.
A fixture packet stamps itself "fixture", and "fixture nomap" in CI, which has
no poster sources either. `fixture` implies nophotos in the gate — without that
it reported "every source record carries an image_url", a true sentence about
real records and a false one about this packet.
A real bug in my own change, caught only by running the script through its REAL
invocation path: packet.mjs fails closed on a missing VITE_API_BASE_URL BEFORE
loadRecords() ever looks at PACKET_RECORDS, so `bun run packet:ci` exited 1 on a
tree that had everything it needed. Every hand-test had passed because I was
supplying a dummy host. The guard now exempts fixture builds and still fails
closed otherwise.
From a genuinely clean state — the whole out/ directory moved away — the sweep
now reports "1 packet(s) checked, 1 passed". i18n verified through that same
path: a partial block (1 string, 5/12 points, 3/12 rhythms) renders 12 points,
12/12 wheel labels, wheel 3 translated and 9 English, 0 blanks.
check:packet-fixture joins gates:post (34 -> 35), README quote re-synced in the
same commit. Wave 35/35, packets-all 11/11 here and 2/2 in the studio.
The pin-hint fix covered the group sheet; the other two full-height sheets had not been
exercised. They are fine — the country sheet and both gap views hide the hint, fit 390px
with no horizontal overflow, and throw no page errors.
Getting that answer took two attempts, and the first attempt is the finding. The probe
clicked the pin filter and the "see the largest" button, got three states back, and
passed. All three were the SAME state: the country sheet, three times. Nothing had been
clicked, because at phone width those controls are inside the collapsed legend and are
not reachable until it is opened.
Which is the real defect. The panel is labelled "Key & notes", but it also holds the pin
FILTERS — deliberately, because the key doubles as the filter, and the component says so:
"a reader who has just understood what a colour means is exactly the reader who wants to
see only that colour". On a phone that design puts the entire gap workflow behind a
label advertising documentation. Reaching the ranked gap list means opening "notes"
first, and a reader with no reason to open notes never finds it.
It now reads "Key & filters" / "Hide key & filters".
Verified after the fix with the legend actually opened: the three sheets are now three
distinct states — "Indonesia...", "Largest groups with nothing found...", "Where the gap
concentrates..." — each with the hint hidden, docWidth equal to viewport width, no
overflowing child and no page errors. Screenshot read at 390px.
The merge surfaced what main's new script-flags gate was written for: two
gospel-resources entries advertised --poison inside an inline # comment, where
the shell drops it — a self-test that never runs is a gate certifying itself.
Each flag now has a runnable sibling entry (check:snapshot-guard:poison,
check:coverage-consistent:poison) and the prose points at the entry instead of
the raw flag. Both siblings executed: the injected defects are caught (12/12
clauses). capability-exemplars regenerated; full build green, 34/34 post gates.
out/packets/ is gitignored, so check:packets-all grades whatever .html files
happen to be sitting there — an accidental set, not a current one.
On this machine that was 11 freshly generated packets. In
doxa-maps-plugin-studio, which shares main with this worktree, it was two
NOPHOTOS=1 layout probes of mine from earlier ticks, and the sweep was RED there
for anyone who ran the wave. The gate was honest about each file it checked; the
set it checked was accidental.
The underlying problem is that a fast layout-iteration build and a shippable one
were byte-indistinguishable. The packet carried lang and dir and nothing about
its own provenance, so the only way to tell them apart was for the person
running the gate to remember and pass PACKET_EXPECT_PHOTOS=0.
packet.mjs now stamps data-packet-build with the flags it generated under, and
the gate reads it. The GENERATOR writes it rather than the gate inferring it:
had the gate guessed "no photos present, so probably a probe", it would have
excused genuine photo-fetch failures. Tested explicitly — a packet stamped
"full" whose map svg is then stripped STILL FAILS. The stamp records intent and
cannot excuse a defect the build did not declare.
Deliberately not a timestamp. An mtime staleness rule was considered and
rejected on evidence from this same session: packet-strings.json was 303s newer
than the packets while byte-identical to HEAD, because a probe had restored it
from backup, so an mtime rule would have failed a correct tree — the exact
mistake fixed two commits ago in the library-index gate. Content comparison is
not available here (regenerating hits a live API and takes minutes), so the
build records its intent instead of the clock.
The studio sweep goes from "2 checked, 0 passed" to "1 checked, 1 passed", with
the packet self-declaring build="nophotos nomap". Env vars stay as an override
for pre-stamp artefacts, and the declared-deliberate note now names whichever
source actually declared it rather than always crediting the env var.
All 11 locales stamp "full". i18n re-verified with a partial block (1 string,
3/12 points, 6/12 rhythms): wheel 6 translated and 6 English, 12 point rows with
6 translated, 0 blanks. Wave 34/34, packets-all 11/11 here and 1/1 in the studio.
Seven chip types and two long notes have been added to the group sheet this flight and
none of it had been looked at on a phone. At 390x844 the layout itself holds up — no
horizontal overflow, chips wrap, the sheet scrolls its 1,338px of content in a 559px
window. What does not hold up is the shared pin hint.
"Tap a coloured dot — someone still needs prayer" is pinned near the bottom of the map,
which on a phone is exactly where the bottom sheet is. Measured: it covered "Also speak
German, Standard (25)" and the first group row under it. Once a sheet is open the
invitation is also redundant — the reader has already tapped a dot.
Hidden while a sheet is open, at phone widths only, and scoped to this bundle. NOT
out-stacked: the hint is a library component with z-index 2147482000 and a deliberate
"only the reader closes it" rule, which is the right behaviour for a bare map, and
raising this sheet above that number would be a z-index arms race with shared code that
every other map would inherit. The hint renders inside this component's own shadow root,
so a media-query rule here costs nothing and changes nothing for anyone else. Desktop is
untouched — there the sheet is a 330px corner card and the two never collide.
Verified in both directions rather than one: with the sheet open the hint computes to
hidden, and after pressing Close it is visible again. Screenshot read at 390px — the
"Also speak" heading and its rows are now legible.
Only 2 files conflicted in source: package.json and its CLI doc, both taken to
main's refactored gate pipeline (gates:pre/gates:post/build:bundles) after
verifying every check the old inline chain named still runs there. The other 19
were generated: public/assets/tables mirrors keep main's deliberate deletion,
and public/doxa-maps is regenerated by the build that follows.
The search box was never tested this flight, so it was checked: it finds groups by
people-group name, by language name and by ISO code, and shows its empty state for a
query that matches nothing. "Pashtun" returns 8 of 13 matches, "deu" 8 of 27, "Uab Meto"
2, and a nonsense string gives "Nothing matches that name, language or code." No defect,
so nothing in the product changed.
Finding it healthy took two attempts, and the first attempt is the point. It queried
invented selectors — .gm-sug, .gm-hit — got 0 results for every query including valid
ones, and would have reported a working search as broken. The real class is .gm-qrow,
and it is in the component.
That is the third time in this flight one probe produced a confident wrong verdict from
a guessed selector or a guessed denominator, and it has gone wrong in both directions:
- a table probe read the page's own "234 rows" caption and PASSED an entirely empty
body, because the document was missing its columns block;
- a charts probe FAILED a correct chart by counting all 5,563 catalogue codes when the
chart counts the 5,067 languages spoken by a pin;
- this one.
The existing selector row in the pitfalls table is about Shadow DOM not piercing, which
is a different failure. This one is added beside it: read the selector and the
denominator out of the source, assert on rendered content rather than on a number the
page prints about itself, and grep the component for the real class names first.
check-packet-render has called page.pdf() for a long time and then read only
MediaBox and /Type /Page. Eight blank sheets of the right size would have passed
every assertion in it.
That matters because the packet exists to be PRINTED — it is a standalone file
with photos and fonts inlined precisely so it prints offline — and until now
every check was on-screen. A print-only CSS rule can strip content that the
on-screen checks still see.
The first mutation is the argument for the whole change. Adding
`@media print{.card img{display:none}}` left the on-screen line reading
"95 photos, 0 typographic" — every pre-existing check green — while the PDF
carried 2 images instead of 85. The second, hiding sheet contents in print,
fires all three new assertions at once on a packet that is still 8 correctly
sized pages.
Images are compared against DISTINCT srcs, not cards. A PDF de-duplicates
identical images, and laos has 95 cards and 95 <img> but only 85 distinct
sources, because ten groups share a portrait — a fact about the upstream photo
set, not a rendering fault. Comparing against 95 would have reported "8 photos
dropped at print time" on a correct packet. I nearly did exactly that.
A scare investigated and cleared rather than reported: the PDF names only
DejaVuSerif, which looked like the vendored Devanagari face failing to reach
print. It is not. Under print media emulation the stack still resolves to
"Noto Serif Devanagari", and the same string measures 210px in it against 420px
in plain serif, so the face is genuinely in use and the PDF names are Chromium
subset aliases. Corroborated independently: hi is the largest packet at 3.2MB
with the fewest faces at 7, which is one big vendored face, not a fallback. A
ToUnicode-based check was discarded after calibration found only 30 Latin
codepoints in a known-good English PDF — an incomplete parser whose
zero-Devanagari result proved nothing.
All 11 locales embed fonts and carry every photo. i18n re-verified through the
new assertions with a partial block (2 strings, 4/12 points, 5/12 rhythms):
2.8MB, 11 fonts, 87 images, wheel 5 translated and 7 English, 12 point rows,
0 blanks. The studio's declared map-less photo-less packet still passes — the
image assertion skips at 0 distinct sources, so the opt-outs compose. Wave 34/34.
A health check across all five profiles found them all mounting cleanly, and then found
the charts telling a much smaller story than the data holds.
Bible video reaches 1,449 of the 5,067 languages here — it would be the third-largest
bar on the page — and was not drawn at all. Neither were gospel presentations (85) nor
discipleship training (51). A reader taking away "what exists, by resource type" was
missing three of the seven types, including one larger than Open Bible Stories, audio
and Full Bible combined.
"Scripture (any)" also read only eBible's field, counting 1,237 languages where 1,729
have a text Scripture. Faith Comes By Hearing publishes Bible text, and the list profile
had already been corrected for exactly this undercount; the chart had not.
Two caveats had gone stale in the way this bundle keeps finding. The hero said "four
resource pools, including all audio, were never queried" — audio has been searched at
FCBH since. And the pools footer unioned two different provenance vocabularies into
nonsense: "arclight (14 sign languages, all captured), fcbh (none), ebible, arclight,
door43, fcbh", the sign-language sentence spliced into the general list with arclight and
fcbh appearing twice. Sign languages carry a different provenance because text and audio
do not reach a Deaf group, so the two are now reported as the separate claims they are.
MY FIRST PROBE FAILED AGAINST A CORRECT CHART. It compared the bars to counts over all
5,563 catalogue codes, but the chart counts languages SPOKEN BY A PIN — 5,067 of them.
The chart was right and the expectation was wrong. Corrected, all four bars match
exactly: Scripture 1,729, Bible video 1,449, gospel presentation 85, training 51. The
denominator is part of the claim, and a probe that gets it wrong invents a defect.
All five profiles verified mounting with no page errors. Screenshot read.
The ranked gap list answered "which single group is largest". A mobiliser also asks
"where would a team go", and that answer concentrates hard: the top 10 countries hold
56.6% of all 2,901 groups with nothing, and Papua New Guinea alone holds 469 of them.
The gap sheet now switches between the two. Country rows show groups AND people, because
the two rankings disagree and the disagreement is the useful part — Papua New Guinea has
469 groups with nothing and 2.2M people; China has 125 groups and 150.3M. Which number
matters depends on whether you are sending translators or broadcasters, and the sheet
says so rather than picking for the reader.
Verified against an offline count from the same index: the rendered first row reads
"Papua New Guinea 469 groups with nothing · 2,187,150 people", matching exactly, with the
By country tab active.
Two sources researched this tick and both recorded as rejected rather than shipped:
WordProject is real and permitted — robots.txt allows everything except /database/ and
/scripts/, and it lists 78 Bible languages. But only 7 of its directory codes are
literally an ISO 639-3 this map carries, ZERO of those reach a group with nothing, and
matching the other 71 would mean joining on the English language name, which this map
refuses everywhere else because names collide across unrelated languages. Taking that
risk to duplicate Scripture links that already exist is a bad trade.
GodTools, added last change, reaches 0 tier-0 groups of its 105 ISO-joined languages.
Both are depth for languages already served. The remaining 2,901 groups are not short of
a source nobody has looked at — GRN, YouVersion, api.bible, DBL, Deaf Bible Society and
DOOR all need a credential or an agreement, and everything open has now been mined.
`✓ laos 95 groups 8 pages 0 photos` — for a packet whose first page is blank.
The page count stayed 8 because a blank sheet is still a sheet.
The map one-pager is lifted from out/posters/<zone>.html, which a separate
manual step writes. When it is absent packet.mjs deliberately still builds, and
that fallback is right — but it was silent. The empty-groups branch three lines
above already warns and skips; the map branch had the same shape and no warning.
A gate catching this is not the same as the tool reporting it. The gate added
last tick runs later, optionally, and only when someone invokes it. The
generator prints its tick at the moment of production, which is the one output
an operator is guaranteed to see.
Now it prints ⚠ with the reason inline and repeats it in an end-of-run summary
naming every affected zone and the fix — a per-line mark scrolls off in a
27-zone run, and this is the one fault that cannot be seen by counting pages.
Both silent branches are covered, distinctly: a missing poster reports "no
out/posters/laos.html"; a poster that exists but carries no <svg> — a truncated
generate run — reports "out/posters/laos.html has no <svg>". An operator can
tell "never generated" from "generation broke".
Also adds the `packets` script. Its absence is why the posters -> packets
dependency was never expressible anywhere: packet generation was only ever a
hand-typed node invocation, so nothing could state what it reads.
Measured in both repos. doxa-maps-plugin-studio has 0 generated posters against
wt-packet's 28 — out/ is gitignored in both, so posters are build artifacts and
this is a missing build step, not a repo defect. A fresh studio run now warns;
wt-packet prints a clean tick. The size difference corroborates: 0.4 MB with the
map, 0.1 MB without.
i18n re-verified end to end since this touches the generator: a partial locale
block (2 strings, 3/12 points, 4/12 rhythms) renders the wheel 4 translated and
8 English, 12 point rows with 4 translated, 0 blanks, clean tick. All 11 locales
regenerated with photos — packets-all 11/11, each 12/12 wheel labels, 12/12
points, 13 geography paths. Wave 34/34.
Every pool here so far is Scripture, film, Bible video or a course. None is a GOSPEL
PRESENTATION — the short translated thing a person actually opens with someone. Cru
publishes those, in the same organisation that makes the JESUS Film, so the doctrine
question is settled the same way the brief settles it for the film. Only their gospel
tracts are included, from a fixed allow-list; the lessons and survey tools are not.
IT CLOSES NO GAP AND SAYS SO. All 94 of these languages already have something, so this
is depth rather than coverage and moves no tier. Tiers are byte-identical at
2901/4767/4711. It reaches 3,270 pins and 3.97bn people.
godtools.org, the obvious host, IS A PARKED DOMAIN. It answers 200 to everything with a
114-byte body, and rendered, every URL — including /zz/nosuchtool-xyzzy — reads
"godtools.org is parked free, courtesy of GoDaddy.com". Shipping links there would have
sent people to a GoDaddy ad.
The real front-end is knowgod.com, and it does serve per-language tracts: /en/kgp reads
"Knowing God Personally", /sw/kgp reads "Kumjua Mungu Kibinafsi". But ONLY once
rendered. By fetch it is a uniform 3,864-byte shell for every URL valid or not — the
third host in this bundle with that shape, after jesusfilm's old ?languageId= and
live.bible.is.
So existence is proved from Cru's own API and never from the page: a (language, tool)
pair exists exactly when the resource's latest-translations intersect that language's
translations. Checked against the browser before being trusted — en/kgp and sw/kgp both
intersect and both render real localised text, and the bogus pair does not intersect. In
band, the verifier's controls are now 7 negative all non-verified and 7 positive all
verified, with a bogus knowgod pair among the negatives. All 94 URLs verified.
Clause I keeps its closed set: Gospel presentation only on knowgod.com, and knowgod.com
only that type. Poison self-test still 12/12. Screenshot read: German shows six distinct
resource types, including "Gospel presentation - Knowing God Personally".
Their chevron is a single <use href="/assets/icons/arrow-chevron.svg#chevron-up">.
We had copied its path into three call sites and never vendored the file.
I assumed the platform forced that, and it does not: an external
<use href="file.svg#id"> renders in Chromium, renders inside a shadow root,
and a local <symbol> plus <use href="#id"> renders too — 410x234 in all
three. What is forced is the external href, not the mechanism. A dropped
bundle resolves assets against whatever tree it sits in and the tree
generator stages an allowlist with no icons directory, so an external
reference would 404 in silence and the chevron would simply be absent. That
is the same reason worldmap.svg arrives through a prop. So: their path,
verbatim, as one <symbol>, referenced the way they reference it.
The first attempt blanked all 21 chevrons — the regex rewrote the path inside
the sprite it had just inserted, so the symbol referenced itself. Worth
recording how that was caught, because the CSS fingerprint called the broken
page identical: a <use> resolving nothing keeps exactly the same computed
styles. Geometry caught it, and so did looking at the page. The fingerprint
now says so in its own header, along with the fact that its keys are
ordinal-sensitive for unclassed elements.
Deleting the map svg from page 1 entirely left this gate reporting
"map: palette resolves, not black — PASS, map painted".
mapFaults queried `document.querySelector('.sheet svg')` — the first svg
ANYWHERE in the document. With page 1 empty the query silently retargeted to the
PRAYER WHEEL's svg two sheets later, and the wheel has no CSS variables to leave
unresolved and no black fills, so both checks passed vacuously. The sheet count
stayed 8 because the empty sheet still exists.
A fallback selector turns an absence into a pass: when the thing being asserted
about disappears, the query finds a different object that happens to satisfy the
checks — and here the gate printed a stronger claim than it had evidence for.
"page 1 = map one-pager" is the first line of the packet spec.
Now scoped to sheet 0, which the page order fixes as the map page. And it
asserts the map paints GEOGRAPHY, because palette-resolves and not-black are
both properties a map with zero country shapes satisfies perfectly. Geography is
paths whose `d` exceeds 200 chars — a coastline, not an icon. The floor of 5 is
set from a measured spread rather than guessed: laos 13, brazil 18,
middle-east-core 55, tibet 69, uncovered-scatter 74, varying with how many
countries a zone spans.
Mutation-tested in three shapes: no svg fails naming page 1; an svg present but
drawing nothing fails at "only 0 geography path(s)"; the good packet passes at
13. All 11 locales report 13 — the map is data, not text.
Syncing the gate surfaced a second thing. doxa-maps-plugin-studio ships 0 poster
html files against wt-packet's 28, and packet.mjs deliberately "still builds
(cover page without the map) rather than failing" when out/posters/<zone>.html is
absent. That soft-fail is intentional but it was invisible: the degraded packet
has 8 sheets, an <svg> present (the wheel's), and no marker at all, so it is
structurally indistinguishable from a good one while page 1 promises a map and
delivers an empty sheet. PACKET_EXPECT_MAP=0 follows the PACKET_EXPECT_PHOTOS
precedent — fail by default with the remedy named, pass when the degradation is
declared.
Also fixed the summary line, which read "map painted" unconditionally and so
described a declared map-less packet as having a painted map. It now reads
"NO map (declared)". The summary is the part a reader quotes.
i18n verified with a 2-key partial block: page 1 renders the translated keys,
falls back to English elsewhere, 0 blank runs. Wave 34/34, packets-all 11/11.
gospel-by-country supplies band and no_resource, which fill every country polygon on the
map. Those were computed on 2026-08-01 over the curated 2,106-record set. The pins drawn
ON TOP of that fill come from the 12,379-pin index. The two disagreed for 135 of 170
countries, and the worst cases are not close:
PNG coloured "critical" from ONE curated group, under 831 pins (56.4% bare -> high)
USA coloured "covered" from three groups, under 623 pins (7.1% bare -> partial)
PHL coloured "covered" from one group, under 156 pins (17.9% bare -> partial)
IDN coloured "critical" from 36 groups, under 669 pins (48.0% -> high)
A reader sees the colour first and the dots second. Telling them a country is covered
while plotting bare groups across it, or critical on the evidence of a single group, is
the map arguing with itself - and the fill is the louder half.
groups, no_resource, pop_no_resource, scripture, film, pct_no_resource and band are now
counted from the pins, under the same band rule as before (critical >=60%, high >=30%,
partial >0, covered 0). growing_unreached, deaths_day and growth are NOT derivable from
the pins - they come from the curated country analysis over a narrower set, the country
sheet already labels them as a different basis, and they are left exactly as they were.
Verified: 0 of 170 countries still disagree with the pins on groups, no_resource or
band. Bands are now 8 critical / 52 high / 100 partial / 10 covered.
The legend was making a claim that stopped being true two changes ago: "Audio is
unchecked for every group here". Faith Comes By Hearing has been searched since. It now
names all five pools that were checked and isolates GRN as the one that cannot be -
403 on every request including its own robots.txt - which is the honest version of the
same warning.
main's legend work added library/components/legends/SemanticTreeLegend.vue
without re-running the derived index, so check:library-index was failing on a
file none of this branch's work touched. Regenerated: 94 items catalogued.
public/doxa-maps/index.html conflicted. It is generated, so it was resolved by
regenerating the pipeline rather than by hand-merging two machine-written
files: build the bundle, rebuild the tree, re-hash. fix:sri then reports all
111 hashes already in agreement, which is the evidence the tree is coherent
rather than merely conflict-free.