The three map views ask for projection:'mercator' at construction, but on
Mapbox the style's own projection (globe) wins, so the map booted as a globe
while the view's state and button said Mercator, and the first "Switch to
Globe" click did nothing (rega, C2 at 0f136d25: pre/post-mapbox logs, "proj
globe -> globe"). The existing setProjectionFor ran only after a basemap
switch, never at boot.
Each view's 'load' handler (religion, rop-tree, language-families) now applies
the state's projection first; the style is ready there on both engines.
Headless, against a map whose style projection is globe: before, the map is
globe while the state is mercator; after, both are mercator.
Four lenses (send-map, lang-cluster, the cockpit's MapCell and FlowerClusters)
called setProjectionOn(m, 'mercator') straight after initializeMap(). MapLibre
throws "Style is not done loading" from setProjection before the style loads;
setProjectionOn swallows it and returns false, so on MapLibre the projection was
never set (rega, C2 at 0f136d25). Mapbox accepts it early, which is why only
one engine showed it.
A small plugin helper, src/whenStyleReady.js, runs the call at once if the style
has loaded, else on 'style.load'. Headless, against a MapLibre-shaped map that
throws before load, with the real setProjectionOn: before, it returns false and
the projection stays null; after, it is mercator, both when the style loads
later and when it is already loaded.
Every number on the country sheet is work for somebody else to fund, staff or
translate. The one thing the reader can do before closing the tab was reachable
only by guessing which pin to tap, so the sheet now names it: the largest group in
this country with nothing and nobody praying, one tap from its prayer page.
Need first, then size, and India shows the cost of that order — of its 601 groups
with nobody praying only five have nothing at all, so it offers Malai Pandaram at
8,700 people ahead of Badhai at 851,000, who already have Scripture and film. The
sub-label says which rung the reader was handed.
The count above it reads "601 / 779", never a bare 601: doxa.life publishes a page
for 1,723 of the 12,379 groups here, so nobody-praying can be said of the groups
that HAVE a page and never of the country. Zero still means zero on doxa.life's
counter, not a claim about the world.
Costs 1.05 kB: the prayer pages and the tabs catalogue were already shipped.
The cleanup I promised when the fallback went in, done now that both halves are on
main (verified BY CONTENT at ccc2b1bb, not by sha).
onLegendSelect returns an EXPLICIT boolean on all four of its paths — false when the
tapestry layer is not up, true for batch mode, true for the un-isolate, true on
success. No path yields undefined, so the `typeof applied === 'boolean'` capability
check can never fall through and the inference behind it is unreachable. Three lines
become one.
WHY THE CHECK WAS WRITTEN THAT WAY, since the reasoning outlives the code: upg100-plugin
warned that a function with no explicit return yields undefined, which is FALSY — so
adopting their boolean before it landed would have read every success as a failure and
retried it. Testing the CAPABILITY rather than the version meant this worked before
their change, after it, and in any bundle built from either, with no flag day.
AND WHY NOT TO REINTRODUCE THE INFERENCE, which is the part worth keeping: I had read
!selectedKey.value as "it failed". That is a proxy for "did it ISOLATE", not for "did
it work", and the two come apart in exactly two paths — batch mode feeds the selection
union and the un-isolate clears it, and both leave selectedKey unset while having
succeeded. Retaining on that would have re-applied the zone and toggled it back OUT of
the union. upg100-plugin found it prospectively and added the boolean so the applier
could be asked instead of guessed at.
Source only; my-upg-100-list needs the rebuild.
Completes C1. Source only; studio rebuilds (rule 5B). Cut on top of C1a because it
depends on that move. Rebuild scope: country-posters, doxa-research-map,
research-dashboard, dashboard-plus-grid, gospel-resources.
resource.js existed as TWO identical plugin-local copies with no library original,
while library composables resolved it through the registry — so the library depended
on a plugin registering a module the library itself documented. Now it lives in
library/colors/ like the other eight, both copies are gone, and src/colors/ holds no
.js in either plugin. The local `registerStrategies(import.meta.glob('./src/colors/*.js'))`
in both index.js files is therefore DELETED: it matched nothing after the move, and a
glob that matches nothing reads as a deliberate override. Their now-unused
registerStrategies imports went with them. That also completes the shepherd's original
C1 instruction, which C1a had to defer for exactly this reason.
★ MY OWN PREDICTION HERE WAS WRONG, and the measurement is the only reason this is
clean. I predicted check:one-palette would FAIL after the move, reasoning that the
dashboards carry 6 of resource's 8 hexes (because 6 are SHARED with affinity-block and
language-family, which they legitimately ship) while the 2 unique ones — #20c997
jesusFilm and #2c3e50 noResources — are absent, and the gate fails a PARTIAL palette.
The gate PASSES: 9 strategies, 18 pairs. Once the module is in library/colors/,
_registry.js bundles it into every consumer, so all 8 hexes are present BY CONSTRUCTION.
I had reasoned about the pre-move bundle and assumed the rebuild would not change it.
That also corrects the diagnosis I gave earlier. simpleresearch was right that there
was no disagreement, but not that the 6 were coincidental substrings in minified
output: they are real hexes, genuinely present, shared across three strategies. The
gate cannot attribute a hex to a strategy — which is a real limitation worth knowing,
just not one that bites here.
Stale comments corrected rather than left to rot: five references across
useLegendData, researchLegendOptions, useWagfRegionsLegendData and my own
wagf-region.js header all claimed these palettes live in a plugin folder. One
parenthetical keeps the old path deliberately, as history.
GATES: check:one-palette PASS 9/18 (baselined 6/12 before C1a). check:library-index
PASS after regenerating the derived menu. check:no-free-identifiers PASS. All six
affected bundles build; their artifacts reverted.
I PUT A CONFIDENT WRONG NUMBER ON THE PAGE AND AM TAKING IT OFF. The footnote
read "21 of the 2,107 are not yet in any list". That 21 is the total minus the sum
of the DECLARED `count` fields in clusters.json — a claim about that file's
bookkeeping, not about membership. I wrote it, asserted nothing about it, and
shipped it twice.
TRYING TO VERIFY IT PRODUCED A SECOND WRONG NUMBER, which is what settled the
question. Resolving every selector against the packet records gives 418 in no
geographic list, not 21. But that cache carries only TWELVE fields — no
wagf_region, no wagf_block — so 13 of the 27 selectors cannot resolve against it
at all, and deaf_predicate matches 0 of the 123 deaf records that ARE in it. Both
figures describe the FIXTURE, not the product. Nothing on disk can answer this.
AND THE QUESTION IS ILL-POSED BESIDES. clusters.json has FOUR buckets, and
allLists() reads three: the two DEAF lists (61 + 62 groups, with their own poster)
are excluded. Summing all 29 gives 2,209 against a source total of 2,106, so the
deaf lists OVERLAP the geographic ones — a deaf group in West Africa is in both.
They are a second, cross-cutting partition, organised by disability rather than
geography, which is very likely WHY allLists() leaves them out: including them in
a zone-to-list map would double-count. So "in no list" has no single answer until
someone says which partition is meant.
funnelPage already falls back to the provenance-only footnote when `unassigned` is
absent, so removing it is the whole fix: the page now says "Counts from IMB /
peoplegroups.org, captured 2026-07-16." and stops there. The share line is
untouched — "1 of 27 lists · 2% of the 2,107" is provable.
BOTH PATHS CHANGED TOGETHER. The Node generator computed the same figure; leaving
it would have made the two packets disagree about page 2.
CARDED FOR THE DRIVER, because two of these are product questions and not mine:
whether a packet counts 27 geographic lists or 29 including the deaf ones, and
whether "Central Asia + Pakistan" is a prayer list rather than a residual bucket.
It has 55 groups, contiguous=true with contiguityExempt=false, a country-code
selector, and the poster wall already files it under "Central Asia" — it is larger
than four accepted lists (Europe 55, Oceania 53, SE Asia 50, Southern Africa 39)
and reads exactly like them. Its neighbour uncovered-scatter is the genuine
catch-all: 8 groups, contiguous=FALSE with contiguityExempt=TRUE, and that
exemption flag is the tell. So "27 or 25" was a false binary and 26 is live.
Verified: all 18 packets in out/ pass sheets, rows==bars, zero-population and the
script check; the footnote no longer contains "not yet in any list"; the share
line is unchanged.
⚠ NO BUNDLE REBUILD IN THIS COMMIT — dx-shepherd's shot window forbids public/
churn while rega reshoots the README screenshots. This is source only; whoever
builds after the all-clear produces the matching artefact.
First palette migration from SPEC-LIBRARY-CURATION.md. Source only; studio rebuilds
(rule 5B — library/ plus four other owners' plugins).
WHY THIS ONE FIRST: the duplication was not idle. doxa-research-map's
affinity-block.js carried A018 'Tibetan Peoples' and A019 'Himalayan Peoples' —
live on the pray-tools API — with colours measured to WCAG 1.4.11's 3.0:1 non-text
floor (A013 5.28 unchanged, A018 7.69, A019 3.53; sibling separations 2.61/1.46/1.79).
country-posters' copy covered only A001–A017, so 134 rows (6.4% of 2,107) fell to the
grey fallback #95a5a6 — the "bunch of grey colours" report. exp-shepherd confirmed
that code path headlessly. So the correct copy wins with no judgement call, and the
move FIXES country-posters rather than changing it.
FOUR CONSUMERS, NOT THE TWO THE SPEC COUNTED, and finding that is the sharpest
argument for the migration: research-dashboard/index.js and dashboard-plus-grid/index.js
each imported `../doxa-research-map/src/colors/affinity-block.js` — a cross-plugin
RELATIVE import into another plugin's PRIVATE folder. Both now import @map/colors/.
Only the build found this; no gate names cross-plugin reach.
THE GLOB STAYS in both plugins' index.js, deliberately, and is documented there:
resource.js is still plugin-local pending C1b, so deleting the glob would unregister
it and colour those pins by the soft fallback — the same silent wrong-colour class
being fixed here. Deleting the two STALE FILES is what stops the stale copy winning.
THE REJECTED-CANDIDATE NOTE MOVED ABOVE `export const PALETTE`, and this is a real
constraint rather than tidying: check:one-palette slices from that export to END OF
FILE and matches every hex, despite its own comment claiming it reads "only the
PALETTE export". #AF7AC5 was rejected for A019 at 2.95 — under the accessibility
floor — and mentioned below the export it would have been DEMANDED of every consumer
bundle. The note keeps the reasoning without creating the obligation; the gate bug
itself is reported separately since internal/ is not mine to edit.
The legend needed no change and that is by design: useAffinityBlockLegendData resolves
via getColorStrategy(COLOR_MODES.AFFINITY_BLOCK) — the same strategy the pins use — and
reads labels from the data, so map and legend gain A018/A019 together. Its comments
claiming the strategy lives in a plugin folder were corrected; they now describe where
it actually is.
GATES: check:one-palette PASS — 8 strategies, 16 bundle/palette pairs (baselined at 6
and 12 before the move, so a red would have been mine). check:library-index PASS after
regenerating the derived menu. check:no-free-identifiers PASS. All four consumer
bundles build; their artifacts reverted for studio.
SPEC-LIBRARY-CURATION.md's first migration, in the order the shepherd approved.
Source only; studio rebuilds (rule 5B — library/ plus eight other owners' plugins).
THE ASYMMETRY, which is why 12 sites each solved it or failed at it separately:
Mapbox getProjection() -> { name } setProjection('mercator')
MapLibre getProjection() -> { type } | undefined setProjection({ type: 'mercator' })
Passing the Mapbox shape to MapLibre THROWS. Measured on regions-dashboard, where
the throw aborted the camera fly so a region dashboard never framed its region.
WHAT THE 12 SITES WERE DOING:
· NINE passed a bare string, so on MapLibre the projection silently never changed.
Eight swallowed the throw in a try/catch; doxa-simple-map.vue:1244 did NOT, so
there it threw uncaught.
· TWO had solved it independently and differently: pplr-data-maps'
setProjectionFor() (instance-tested — the correct one, and the shape adopted)
and regions-dashboard's local pair (config-derived, which is weaker: it trusts
what the page was configured with rather than what the map IS).
· ONE probed by exception, trying the object form and falling back on throw.
The helper reads the engine from the INSTANCE, never from config, because a page can
hold a map built by the other engine. Both functions return something observable —
setProjectionOn returns whether it applied, projectionOf returns null rather than
throwing — on upg100-plugin's argument that a precondition which bails without
recording it is indistinguishable from the request never arriving.
pplrData.setProjectionFor stays as a delegating named export so its three views need
no change.
GATE: _chk-c2-projection-helper-equivalence.mjs, 20 assertions. It runs all THREE old
patterns and the helper against the same stub maps and compares outcomes, rather than
testing the helper alone. Controls prove pattern A really does fail on MapLibre and
leave the projection untouched, and that the bare string really throws — so the
"equivalence" claim has something to be equivalent to. A NEG control catches a helper
that reports success it did not achieve.
★ I DESTROYED 984 LINES OF ANOTHER TEAM'S CODE GETTING HERE, and only the build caught
it. My import-inserting script did `s[:at] + IMPORT` and never appended `s[at:]`, so
every file where the import was ADDED rather than extended was truncated at its last
import line: MapCell.vue -458, FlowerClusters.vue -334, lang-cluster.vue -192.
check:no-free-identifiers PASSED on all three, because a truncated script block is
still valid script. Restored from HEAD and redone with an assertion that a migration
must change the line count by exactly +1 and never remove a line — which is the check
that would have caught it in the first second.
Verified after: all 9 affected bundles build, both gates green, and a per-file line
delta on all 13 changed files with the only negative being the 2 lines I deliberately
deleted from RegionCountryMap when its local pair became an import.
The province table and the map's province layer both read gb-IND-ADM1.geojson,
which is the UPG-100 list's curated 34-unit partition — not a country's states.
Eleven groups came out with no province because they were standing in states that
partition does not draw. Both now read the complete ADM1 that landed with the
internet work (36 states, geoBoundaries via DataMeet India): 998 assigned becomes
1,005, 44 province rows become 46, and the rows name Indian states.
Four groups are still unassigned and each one is legible: Great Andamanese and the
Sentinelese sit on islands whose simplified coastline their point falls just
outside, and Jing and Pana are on China's Vietnam and Laos borders, outside its
twelve-province subset. They are listed with their coordinates, not dropped.
The staged-assets gate now also asserts the map and the table read the SAME
geometry. Staging could never catch this: both files were staged.
A LIVE WRONG NUMBER, not a future risk. The universal `list` prop is already on
main (e3d25ff6) and this profile already honours it — applyPeopleGroupList()
filters the rows at all four fetch sites, packetRecords included — and the
membership files are on disk. So under ?list=frontier-500k the packet's rows were
the 71 network groups that are Project 700, while the funnel took its denominator
from the last step of vision-funnel.json and printed "of the 2,107". Off by a
factor of 30, with nothing on the page looking broken.
Measured from data/people-groups/lists/*.json (counts.doxa, what a Tier A map
shows): frontier-500k 71 · frontier 1,745 · unengaged 1,929 · unreached 2,082 ·
the default 2,107.
THREE NUMBERS WERE WRONG, NOT ONE, because all three descended from one total:
the share's denominator; the footnote's "N are in no list" (total minus assigned);
and "1 of 27 lists", which is a property of the UPG-100 ZONE CATALOGUE, not of the
active list — the zones partition the default list and nothing else.
A non-default list now gets its own step, labelled with the list's own label, so
the narrowing does not jump from one list's total to another list's packet.
AND EACH CLAUSE IS SUPPRESSED SEPARATELY, because they are provable separately.
A first pass dropped the whole share line whenever listCount was absent, throwing
away a true statement ("5.4% of the 1,745") to avoid an unprovable one. The
footnote splits the same way: provenance holds for every list, coverage does not.
⚠ The footnote is NOT split by parsing the translated sentence — hi ends in "।"
and zh in "。", and reading translated prose for structure is the trap
check-packet-render's own comment warns about. It is a second key.
⚠ And `funnel.unassigned ?? 0` would have printed "0 of the 1,745 are not yet in
any list" — a confident falsehood whose zero reads as a completeness claim.
── AND THREE FINDINGS FROM rega'S RENDER, none of which I could see ──────────
"0 people" PRINTED ON THE READER'S ROW OF EVERY PACKET. Number(null) is 0 (only
Number(undefined) is NaN), so `Number.isFinite(Number(x))` admitted the
deliberate `population: null` marker as the number zero. I asked "is this a
finite number?" when the question was "did anyone give me a population?" — the
same presence-vs-value confusion as inferring "did it work" from a field that
answers "did it isolate". Zero is suppressed too: a people group with no people
is missing data wearing a number's clothes.
THE SHARE LINE READ AS PART OF THE FOOTNOTE. Measured: 218px below the reader's
row, 42px above the footnote, because the last row kept the full inter-row gap.
Proximity is the only thing grouping them, so an even gap grouped the share with
the wrong element. The last row's margin is now 14px: share 14px under its row,
288px clear of the footnote.
THE GATE ASSERTED PAGE 2 AND PRINTED NOTHING ABOUT IT. rega: "a reader cannot see
WHAT it asserted." A silent pass is the mirror of a printed-but-unasserted number
— the check is real and the evidence invisible. It now reports rows, bars and the
share line, and gained two assertions: every row must have a BAR (the reader's
step had none, so the funnel never visibly narrowed to "yours"), and no row may
print a zero population.
Verified: all five list cases render their own true numbers — doxa keeps the full
claim, unreached/frontier/unengaged get their own step and a percentage with no
false "1 of K", frontier-500k reads "100% of the 71" with the duplicate row
suppressed (a step that repeats its neighbour is not a step). The four fixtures
are byte-for-byte unchanged on the default path, so the list-awareness is purely
additive. check:packet-strings-parity PASS with the three new keys. Both new gate
assertions mutation-tested in en and ar: removing a bar is DETECTED, injecting a
zero population is DETECTED. 5 rows = 5 bars, 0 zero-population rows, "0 people"
absent. Six-row layout recomputed: fits with 318px headroom, 206px if every label
wraps. BUNDLE=my-upg-100-list vite build clean.
NOT re-rendered in a browser: rega measured the previous state and these are
responses to it, so the new geometry wants one more look.
TWO CHANGES, both from other agents catching things I could not see from here.
1. NAMESPACED, on upg100-plugin's point that the host element is SHARED SURFACE.
data-select-zone-state / -applied become data-doxa-select-zone-state / -applied.
A partner page embedding this element could reasonably own a generic name like
the first pair, and a diagnostic has no business squatting one on someone else's
element. Renamed AFTER rega's re-shoot rather than before: their probe polls
these names, so renaming mid-flight would have burned a measurement to save a
few minutes.
2. THE RETAIN CHECK WAS WRONG IN BATCH MODE, caught prospectively by upg100-plugin
before it bit anyone. applySelectZone inferred failure from !selectedKey.value,
but onLegendSelect feeds the selection UNION in batch mode and returns without
ever setting selectedKey — isolating is not what it should do there. So correct
behaviour read as a failure, and retaining on it would have re-applied the zone
and toggled it back OUT of the union. Un-isolating (node == null) has the same
shape: the un-isolate IS the action, and selectedKey is null afterwards by design.
They have given onLegendSelect a boolean return for exactly this (fb076c8b on
packet/vertical), where false means one thing only: the map was not ready.
FORWARD-COMPATIBLE, deliberately. That change is not on main yet, and a function
with no explicit return yields undefined — which is FALSY. Depending on it today
would read every success as a failure and retry it, which is precisely the trap
they warned about. So the boolean is authoritative when it IS a boolean, and until
then the old inference stands with batch mode excluded.
Their design note is the part worth keeping: the early return stays and stays
side-effect free, because other code depends on it BAILING; a boolean makes the bail
OBSERVABLE without changing when it happens. That is the smallest change that fixes
the epistemic problem rather than the control flow.
The India layer drew on UPG-100's curated partition (34 of 36 units), so
Mizoram and Nagaland had figures but no geometry. studio staged the complete
ADM1 beside it (data/boundaries/gb-IND-ADM1-full.geojson: gbOpen
IND-ADM1-1811400, DataMeet / ECI, CC BY 2.5 IN, with its .meta.json). The
curated file is untouched.
- connectivity-map.vue fetches boundaries/gb-IND-ADM1-full.geojson.
- The crosswalk maps Mizoram -> "Mizoram" and Nagaland -> "Nāgāland" (the
boundary file's spelling) instead of null. The generator validates against the
full file, and the table's meta names it with its licence.
- The regenerated table has 36 areas drawn and none missing, so the legend's
"no boundary yet" line no longer shows (it renders only when that list is
non-empty).
Headless: build and tree OK; both boundary files are staged; the fill
expression (maplibre-gl-style-spec) colours 36 of 36 states (Mizoram women
67.6, Nagaland women 49.9).
A SILENT `return` ON A PRECONDITION IS INDISTINGUISHABLE AT THE RENDERED LAYER
FROM THE REQUEST NEVER ARRIVING. The "Show on map" interop bug had three layers
stacked under one symptom: an element event with no replay, an id resolved
against legendNodes that had not loaded, and this function no-op'ing on a missing
'tapestry-fill' layer. Each fix looked sufficient because all three produced the
same nothing. regions-plugin fixed the first two by retaining the request and
flagged the third as mine, since it has three call sites — correctly, and this is
it.
IT NOW RETURNS A BOOLEAN. true when the selection was applied, false when it
bailed on the readiness check.
THE EARLY RETURN STAYS, AND STAYS FREE OF SIDE EFFECTS. It is a documented
readiness gate — the comment at :630 calls it "THE REAL READINESS SIGNAL" — and
other code relies on it bailing rather than throwing or queueing. A boolean is
the smallest change that makes the bail OBSERVABLE without altering when it
happens.
TWO EXITS RETURN true THAT MIGHT LOOK LIKE FAILURES, and the distinction is
load-bearing rather than cosmetic:
- batch mode fed the selection UNION instead of isolating. That is the correct
behaviour there, not a failure to isolate. A caller retrying because it read
`false` would toggle the zone back OUT of the union.
- the un-isolate (node == null) IS the action, and it succeeded.
And the success path returns true EXPLICITLY: an implicit fall-through yields
undefined, which is falsy, so a caller checking the result would read success as
failure and retry — the opposite of what this value is for.
No caller reads the value yet, so the change is purely additive and cannot
regress: verified all four call sites (:945 un-isolate, :1539 paintAll's
re-apply, :1638 and :1641 in the select-zone path) ignore it today. regions'
applySelectZone is where it becomes useful — it already retains on no-effect by
checking whether selectedKey held, and can now ask the applier directly instead
of inferring.
BUNDLE=my-upg-100-list vite build clean.
Country-level internet data is not good enough for India. This adds the first
sub-national layer, from the one open, machine-readable source verified for it:
NFHS-5 (2019-21, IIPS / MoHFW), "women / men aged 15-49 who have ever used the
internet (%)", urban / rural / total, for every state and UT (CSV transcription
of the state fact sheets, CC BY 4.0; SPEC-GRANULAR-INTERNET.md).
- data/internet/: the two NFHS-5 internet rows (with provenance, licence and
upstream commit), plus a strict crosswalk from source names to the shipped
gb-IND-ADM1 shapeNames.
- plugin-designs/cities-internet/scripts/build-adm1-internet.mjs (`bun run
internet:adm1`) writes data/tables/internet-by-adm1.json. It is GENERIC, one
row per iso3 x adm1 x measure x sex x area, with meta naming measures,
sources, boundary files and the national figures, so go-int and other plugins
can reuse it. It fails on an unmatched name or a value outside 0-100: a name
is never guessed.
- The map draws India's states in a blue ramp (0-25-40-55-70%+), leading with
women, with a Women / Men toggle. A tapped state shows both halves, urban /
rural / total. Its own legend block names the measure, survey, years and ages,
and says it counts people, not subscriptions, so it is never read as the
World Bank country bands. India's national band is taken out under its states.
- The shipped boundary file is the UPG-100 partition (34 of 36 units): Mizoram
and Nagaland have data but no geometry. They stay in the table and the legend
names them.
- 8 new strings under connectivity.adm1, in all 11 locales (library change:
consumers need rebuilding).
Headless: build and tree OK, and the table and gb-IND-ADM1 are staged under
assets/. The fill expression validates with maplibre-gl-style-spec, and all 34
shipped states get a colour (e.g. Bihar women 20.6 -> 0-25, Delhi men 85.2 ->
70+). A crosswalk poisoned to a wrong shapeName fails the generator. Passing:
universal-props, no-free-identifiers, i18n-parity, i18n-never-translated,
profiles-mount, composites-mount, no-orphan-gates, script-flags, quoted-scripts.
Not rendered in a browser.
Localizing the grid's data (ae5e27c3) would have broken the language link
silently, and the shepherd asked me to prove the coupling with a test. Measuring
it first showed the coupling should not exist.
MEASURED against the live API, en vs ar, 2,107 records:
primary_language LABEL 864 names in en, 864 in ar, ZERO shared → localized
primary_language VALUE 864 codes, ALL 864 shared → stable
religion / wagf_region VALUE also identical across locales
So the fix is not "make both halves fetch the same locale and test that they do".
It is to link on the form that does not move. Region already links on a slug and
religion on a family letter; language was the one facet still travelling as a
display name.
The map now emits `languageCode` alongside `language`, the composite carries
{value, label} for language as it always has for region, the GRID is driven by
the code (valueMatches compares the normalized value as well as the label, so a
code matches in every locale) and the MAP is still driven by the name, because
its legend selects by name. Each half gets the form it can actually use, and
neither depends on the other's locale.
★ THIS TREE HAD ALREADY PAID FOR THIS ONCE. deriveLanguageFamily in useMapData
carries a comment about the 2026-06-22 locale colour bug: deriving a family from
the localized label collapsed every pin onto one bucket, and the fix was to use
primary_language.value. Same trap, one facet over, three months later.
check:linked-vocabulary now runs per LOCALE as well as per provider — en as the
control, ar as the case that would have broken in silence — and asserts the
language facet with a poison case (a language that cannot exist must NOT narrow
the grid). It is a browser gate, so rega runs it; I have not.
★ AND check:no-free-identifiers CAUGHT MY OWN BUG IN THIS COMMIT on its first
outing: I wrote `rows.find(...)` in the emitter where no `rows` was in scope. The
gate named the file and line before anything was built. That is the class of
mistake that broke both composites yesterday, found in seconds this time.
Two defects rega found at ?lang=ar and ?lang=es (row 30).
★ THE CHROME TRANSLATED AND THE DATA DID NOT. The `&lang=` on the people-groups
fetch was built from props and profile-config only, never from the URL — so a
reader who asked for Arabic got Arabic buttons around English country, affinity
and religion names. That reads as a half-finished translation rather than the
wiring bug it is. It now uses the library's own localeFromUrl() — imported, not
re-implemented — so the fetch and the chrome cannot disagree about what the URL
asked for. Same ladder regions uses, and a host passing languageCode still wins.
★ AND THERE WAS NO RTL AT ALL. In ar the cards, the total and the filter bar laid
out left-to-right around Arabic words. createAppI18n reflects an RTL locale onto
document.documentElement, which is right for a page it owns and useless for an
element dropped into somebody else's — which is this grid's whole job. `dir` is
set on the HOST now, like both composites: it takes effect through the inherited
CSS `direction`, and inheritance crosses shadow boundaries, so one attribute
turns the element and everything it renders. Reflected on mount as well as on
change, because the common case is a page that loads in Arabic and never changes.
⚠ ONE CONSEQUENCE I AM NOT HIDING. Localizing the DATA introduces a coupling the
linking did not have: the composite's language facet travels as a DISPLAY NAME,
so the map and the grid must now fetch in the SAME locale for that link to match.
Region links on a slug and religion on a family letter, so both are unaffected;
language is the one that depends on it. check:linked-vocabulary compares the two
halves' vocabularies and would catch a mismatch, but it runs in English today —
extending it to a non-English locale is the honest next step, and it is not done.
country-languages, translation-credits and the SUN index each wrote straight to
disk. Each had a floor of its own — 150 countries, "something joined", 3 verified
pages — and a floor only catches a collapse. The run that half-succeeds clears it
and overwrites good data with less of it, which is the exact failure the guard was
written for after a rate-limited FCBH run lost a third of its video scopes.
All 16 fetchers now write through it. Re-ran two of them against the live
catalogues: 254 countries and 929 credited codes, byte-identical but for the date,
and with the floor forced high the guard refuses and leaves the file alone.
The province data was readable only inside the map's own sheet. It is a tab now:
44 provinces of India and China, the same file the map reads, so the two cannot
disagree — and unlike the sheet it sorts and exports.
Every row still says placement is approximate. A people group is assigned to the
province its map point falls in, and one point stands for a people who may live
across several.
The credit line said who translated the Scripture and called them "the likeliest
people to ask", then stopped at a name. 838 of the 929 credited groups now reach
that organisation's own homepage from the sheet.
The URL does not come from eBible's homeDomain field, which is still dropped: it
comes from a hand-screened list of 16 organisations, every one fetched on the run
that wrote the table. Where a credit names two, the one it LEADS with wins, so
"Pioneer Bible Translators and The Word for the World International" reaches
Pioneer and not the other way round.
National Bible Societies hold 89+ credits and are deliberately linked to none of
them: several UBS members publish editions carrying the deuterocanonicals, so
whether a Society contact sits inside the 66-book filter is a ruling to be made,
not one to assume. They keep the name without the link, and the count says so.
A contact is screened as somewhere to write to, not as a publisher this map reads
text from — two separate lists, so wycliffe.org appearing as a scripture link is
still a failure. Four organisations answered a datacentre IP with a bot challenge
while serving browsers normally; those are proved by robots.txt and the table
records which method proved which.
pplr-data-maps and the Wall each carried the same ~40-line d3 loader (pinned, SRI, and a trap that keeps
the UMD from writing into a host page's own window.d3). It is now one library helper, fed from the
CDN registry. check:pinned-umd (headless, gates:post): the tag carries the pinned integrity, a host's d3
gains no keys, and a failed load rejects leaving the host's d3 in place. Both bundles build.
rega falsified my own fix. The button went 25.44 -> 17.63 against their 37.63 --
FURTHER from theirs than before I touched it.
I wrote padding:var(--spacing-xs) var(--spacing-2xl). --spacing-xs DOES NOT EXIST,
and dxlife-tokens.js:43-45 says so on purpose: "--spacing-xs (10px) and --spacing-sm
(13.3px) are deliberately NOT tokenised: '10px' is far too common a literal in this
CSS to substitute safely, and a wrong substitution here is worse than a magic number
because it reads as deliberate." I substituted anyway, into the one file whose
comment forbids it.
An undefined var() invalidates the WHOLE declaration at compute time, so the padding
fell to 0 on BOTH axes -- not just the vertical I was aiming at. Everything else on
that line landed: border 1px, radius 100px, line-height 15.625. Nothing errored, the
build passed, check:sri passed, and the failure was invisible from here.
Now the literal: padding:10px var(--spacing-2xl). Theirs computes "10px 41.6158px",
so 15.625 + 2x10 + 2x1 = 37.63 tall and ~210.7 wide against their 210.73.
GUARDED so this class cannot recur silently: the spacing check now resolves every
var(--spacing|--font-size|--line-height) reference against dxlife-tokens.js and
fails on any that is undefined, with a NEG control asserting --spacing-xs really is
absent so the check has something real to catch. rega found this by grepping every
var() by hand; that is now automatic. Currently 19 tokens defined, 0 undefined
references.
THE OVER-REACH WAS REAL AND rega QUANTIFIED IT: because the button shrank 7.81px,
all 121 leaves below it on ethiopia moved UP by the same amount at both 1440 and 768
-- the Total count and every UUPG card's name, country, tags and Full Profile. With
the padding resolving, the list sits 12.19px LOWER than before, which is correct
because it now matches theirs.
Confirmed in the same run, unchanged: all four stats at 47.6837px, identical to
theirs, with the only knock-on being the Population label moving down 9.55px as its
value grew -- 93 of 95 africa leaves untouched.
P1 rendering. Opening India or China now draws its provinces, coloured by the
same ladder as the country beneath them, and tapping one gives its groups, its
people, how many have nothing, the largest of those and the way in.
It arrives on intent rather than on load. India's ADM1 geometry is 1,628 KB —
nearly the size of the whole world ADM0 file — on a leaf that already loads about
4 MB and is meant to be embedded on thousands of partner sites. Loading it for
the 173 countries nobody is looking at would tax every reader for the benefit of
two. Opening one of the two IS the signal; China is 54 KB, and both are cached
after the first open.
The counts come from the measured table, never recomputed in the leaf. The
point-in-polygon belongs in the build, and a second implementation here is how
two surfaces come to disagree. Provinces repaint when the mode changes, because a
country saying one thing while the province on top of it says another is worse
than having no provinces at all.
The panel repeats that placement is approximate, and that the way in was chosen
for the COUNTRY — which is exactly the limitation going below the border exposed.
Also fixes the gate I added for this, twice. The province fetch interpolates its
filename, so the parse captured the literal prefix "boundaries/" — a real
directory of 4,096 bytes that always exists, proving nothing. The first fix
expanded that prefix against the filesystem, which was worse: unstaging a real
geojson simply shortened the list and the gate stayed green on nine assets
instead of ten, exit 0. The expectation now comes from the filenames the leaf
names, so a file the map asks for and the tree lacks fails. Poison-tested both
ways.
rega measured it on /regions/ethiopia: ours 25.44px tall against their 37.63.
Their height reconciles exactly once the right rules are in. _buttons.scss gives
.button { border: 1px solid var(--button-border-color, transparent); line-height:
var(--line-height-none); border-radius: 100px }, .button.compact { padding:
var(--spacing-xs) var(--spacing-2xl) }, and .button.link sets --button-border-color
to transparent:
line-height 15.625 + 2x10 padding + 2x1 border = 37.63
Ours declared NO padding, so the UA's 1px showed, plus border:0, and font:inherit
pulled the base line-height 1.5 through as 23.44. All four declarations are
portable, so all four are ported and the geometry now matches.
THE ONE SUBSTITUTION, documented rather than silently skipped: their .button also
sets font-family: var(--font-family-buttons), which resolves to Brandon Grotesque —
commercial, and blocked on the Driver. So this control keeps our stand-in face and
the family stays the single measured difference. That is the RULE 1 shape: port
everything portable, document the one thing that cannot be, with the reason.
Both of this pair came from rega measuring text leaves I had not thought to compare,
then re-measuring every hit against the PREVIOUS tree so "already wrong" could be
told apart from "my regression". Neither would have surfaced from my side: the stat
shrink looked like a deliberate rule with a comment justifying it, and this button
looked like a plain button because ours had made it one.
rega measured it on main 71858d71 against live: our "43,045,275" rendered at
38.15px where theirs is 47.68px, on a card of the same width, while the other three
stats matched exactly.
The cause was ours — .rd-stat__value[data-len="long"|"xlong"], stepping the figure
down a rung. Their .region-stat__value is font-family-heading + font-size-3xl +
line-height:1 and nothing else; no such attribute exists in their markup.
ITS OWN COMMENT CONTAINED BOTH REASONS TO DELETE IT.
· "our condensed fallback is wider" than Bebas — STALE. Bebas is vendored now
(public/dx-maps/assets/fonts/bebas-neue, and the glyph oracle reads 279 = 279
SAME), so there is no wider fallback left to compensate for. useDocumentFont.js
records that an uppercase hack and "two font-sizes" were knock-ons of not having
the real face; this rule is the third, missed when the others were fixed.
· "every value DX-life itself renders keeps their size" — FALSIFIED. "43,045,275"
is ten characters, so it hit the 'long' bucket, and DX-life renders exactly that
number at full size on /regions/africa. A rule whose own invariant is violated in
practice is not protecting anything.
Deleted with its mechanism, not just its symptom: both rules, the :data-len binding,
and lenBucket() itself. A dead helper left behind is the next reader's puzzle.
Found because rega re-measured every over-reach hit against the PREVIOUS tree, which
separated "my line-height change did this" from "this was already wrong". Without
that control the finding would have looked like my regression, and I would have
spent the tick defending the wrong thing.
rega's re-shoot on main 27eed341 answered it in one run, because the apply now
reports its own verdict. Timeline, ms from navigation:
t=2958 click 'Show Northwest India on the map'
t=3015 select-zone = "asia-india-rajasthan" written on the map pane
t=4887 data-select-zone-state = "applied-but-no-selection"
t=4900 the same pair AGAIN, 13 ms later, still no selection
t=5308 the doxa:select-zone listener first appears
So delivery was fine, the id was right, the node WAS found, onLegendSelect WAS
called — and the selection did not hold, twice.
THE CAUSE is onLegendSelect's own first line:
if (!map || !map.getLayer('tapestry-fill')) return
It returns silently when the map's tapestry layer is not up yet, so selectedKey is
never set. At t=4.9s that layer did not exist. Both applies came from
watch([legendNodes, mapRef]), which is why they landed before the listener did.
So the readiness signal was never legendNodes and never mapRef. It is THE LAYER,
and I had guessed at the other two in turn. This is the THIRD layer of one defect —
apply once against unready state and discard — and each layer hid the next because
every failure was silent.
Two changes, both from the measurement rather than a theory:
1. applySelectZone RETAINS the request when the effect did not take. It used to
clear _pendingZoneId right after calling onLegendSelect, treating "called" as
"done"; now a state of applied-but-no-selection keeps the id outstanding.
2. ensureLayers() drains it. That function adds the tapestry source and both
layers, so it is the exact moment onLegendSelect can hold a selection.
Coverage is complete either way round: if ensureLayers runs first the layer already
exists and the ordinary apply succeeds; if it runs later, the retained request
drains. A click after everything is up still goes through the attribute observer.
Branch was RESET onto main 27eed341 and carries this one commit only — the handoff
lesson from last time: a merged branch's tree holds my stale copies of everyone
else's files, so replaying it (the sane thing for a recipient) reverts their work.
Source only; my-upg-100-list needs a rebuild.
It measured the research-dashboard leaf, which mounts no search bar, so every run reported 'search bars: 0'
and passed without ever exercising search. It now defaults to doxa-research-map/research-map (which mounts
the library GeocoderComponent), takes --leaf, and fails when no bar renders.
maplibre-gl@5 and maplibre-gl-geocoder@1 were floating ranges, and no runtime loader set `integrity`,
so a CDN could serve any bytes to every partner's visitors unnoticed (CRITIC-5).
- library/constants/cdn.js is the one registry: mapbox-gl 3.24.0, maplibre-gl 5.24.0 (the release the
@5 range already resolved to), both geocoders (1.9.4 / 5.0.0) and d3 7.9.0, each with its sha384.
Every host answers Access-Control-Allow-Origin: *, so integrity + crossorigin work cross-origin.
- ensureEngineScript / ensureEngineCSS, the geocoder loaders (library and PPLR) and the generator's
static MapLibre tags set integrity + crossorigin from it; the embed snippet's engine preload matches.
- check:cdn-pins (gates:post): no floating CDN url in shipped code, and every one is in the registry;
--online re-fetches each and compares the hash (all 9 match today).
MapLibre stays on 5.x knowingly. GHSA-jrc7-96c5-q579 (DOM.sanitize, reached through a source's
attribution string) affects <= 6.4.0, and 6.x is ESM-only (no UMD global), so the upgrade is a loader
migration of its own. The attribution strings rendered today come from our configured basemap styles
and our own sources; the note in cdn.js says who is exposed until then.
utilities/_typography.scss gives .font-size-4xl a clamp:
font-size: clamp(var(--font-size-2xl), 6vw, var(--font-size-4xl))
and ours was a flat var(--font-size-4xl). Identical at 1024 and above, where both
sit on the ceiling, but below that ours never shrank. Their resolved values against
ours:
390px theirs 38.15 ours 59.60 ours 21.46 too large
768px theirs 46.08 ours 59.60 ours 13.52 too large
1024px+ theirs 59.60 ours 59.60 identical
★ I REPORTED THIS AS UNCHANGEABLE ONE COMMIT AGO, AND THAT WAS BACKWARDS. I said it
interacted with the documented nowrap decision — a figure must never split across
lines in a 372px card, which is why the flat value was chosen. But the clamp makes
the figure SMALLER exactly where the wrap was observed: in a 372px container 6cqw is
22.3px, below the 2xl floor, so it resolves to 38.15px against the 59.60px that was
breaking. The clamp helps the very problem the flat value was picked to avoid. I had
assumed a conflict instead of computing it, and one line of arithmetic settled it.
The nowrap stays as a belt.
cqw rather than vw, consistent with every other clamp in this element (the
container-vs-viewport substitution the Driver is still ruling on). At 768 that lands
42.4 against their 46.08 — 3.68 off instead of 13.52.
_chk-clamp-floors-at-phone-and-tablet.mjs picks the new clamp up on its own: 14
clamps now, and 12/12 token-bounded ones still pin to their floor at 390, so the
"exact parity at phone width, provable without a container measurement" result now
covers the big number too.
THE EIGHTH GLOBAL RULE OF THEIRS FOUND MISSING, and the same class as the
box-sizing reset: _globals.scss:14 is `body { line-height: var(--line-height-normal) }`
with $line-height-normal: 1.5, and a document-level rule does not cross a shadow
boundary. dxlife-tokens.js declared every spacing and font token and NO line-height
at all, so every text element in the clone inherited whatever the EMBEDDING PAGE
set, while theirs is deterministically 1.5.
.rd-big carried line-height:1.1 to compensate. At --font-size-4xl (59.6046px) that
is a 65.6px line box against their 89.4px — a 23.8px difference. rega measured the
consequence to the pixel: our goal cards were 24px shorter than theirs (571 vs 595),
their big number sitting ~12px lower and its label ~24px lower, the extra leading
splitting roughly half above and half below the glyphs. Theirs is
`<p class="font-size-4xl font-weight-medium">` and the .font-size-4xl utility sets
ONLY a font-size, so their number simply inherits body's 1.5.
So :host now carries their line-height and its tokens, and .rd-big's override is
gone. Their own tighter cases stay explicit and already agreed: .region-stat__value
is line-height:1 and so is our .rd-stat__value.
★ I APPLIED THIS HALF-WAY FIRST. The :host patch failed on a string mismatch while
the .rd-big patch succeeded, which left the number's line box inheriting from the
HOST PAGE — worse than the wrong-but-deterministic 1.1 it replaced. Caught because
the two patches reported separately instead of as one result.
CORRECTION TO 9009dd62, owed and measured against me: that commit claimed our goal
line "floated at three different heights" where theirs bottom-aligned. rega measured
both sides — the goal line sits 42px above the card's bottom edge on BOTH, in all
three cards, with identical gaps to the element above (0 / 73 / 120px). So margin:0
already reproduced their margin-top:auto at 1440, and the visible defect I described
did not exist. The change stands as a verbatim port of their declaration (RULE 1),
and it matters only where a card has free space — but I asserted a consequence I had
not measured, and the measurement says otherwise.
STILL DIFFERENT, reported not changed: their .font-size-4xl utility is
`clamp(var(--font-size-2xl), 6vw, var(--font-size-4xl))` where ours is a flat
var(--font-size-4xl). Identical at 1440, divergent below — at 768 theirs is 46.08px
and ours stays 59.6px. It interacts with the documented nowrap decision (a figure
must never split across lines in a 372px card), so it goes out for measurement
rather than into this commit.
Drawn verification is rega's: card heights 571 -> 595 and the two gaps around the
big number. A base line-height touches every text element in the element, and I
cannot measure layout headlessly.
Two rounds of diagnosis went on guessing which step failed, and both guesses were
wrong in the same direction: I assumed delivery.
rega's timeline settled delivery. On main 031b9b38: all three elements exist at
t=893, the map pane's legend rows are populated at t=1017, the click lands at
t=4054, the select-zone attribute is written at t=4106, and the listener first
appears at t=4763. So the attribute IS written, and main ALREADY reads it at
registration (upg-100-list.vue:2140). A headless test then ruled out the lookup:
buildLegendNodes over the real clusters.json yields 37 keys and
findLegendNode('asia-india-rajasthan') resolves to "Northwest India".
So the attribute is read, the node IS found, onLegendSelect IS called -- and
nothing appears. The failure is DOWNSTREAM of the apply, and it is invisible:
nothing throws, nothing logs, and "selected but not visibly selected" is
indistinguishable at the rendered layer from "never selected". That is the third
time this session that a defect has hidden in that gap.
So the apply now writes its own outcome onto the host, where rega's existing 50 ms
poll can read it with no CDP and no new instrument:
data-select-zone-state = applied | applied-but-no-selection | unresolved | cleared
data-select-zone-applied = <the zone id>
'applied-but-no-selection' is the discriminating case: the path was reached and the
EFFECT was lost. It reads selectedKey, the component's own record of the isolation.
★ AND THE DIAGNOSTIC ITSELF NEARLY LIED. I first wrote selectedZoneKey, which does
not exist in this file; the try/catch around it would have swallowed the
ReferenceError and reported 'applied' every time. A diagnostic that cannot report
failure is worse than none -- the exact fault it was added to expose. Caught by
reading :163 for the real name (selectedKey) instead of trusting the one I
expected.
This does not claim to fix upg-100. It makes the next re-shoot decisive instead of
another guess.
rega re-shot the first click at main 031b9b38. country-posters HELD (India fits in
~1 s, bounds cover its bbox). upg-100 FAILED: the view switches, every list paints,
and 12 s later it is still the world view. Not a stale build — freshness PASS and
8 'select-zone' references in the shipped bundle, so the retained request ships and
does not reach the apply path.
WHY THE TWO DIVERGED, which is the useful part. Both got the same pattern, but they
drain differently. country-posters drains on isMapReady, which flips AFTER the
request arrives. upg-100 drained on legendNodes and only from the REMEMBERED id --
so it silently assumed an order: the attribute was read, or the observer fired, and
legendNodes changed afterwards. If legendNodes was already populated when the click
came, that watcher never fires again and the request sits in _pendingZoneId forever.
Nothing throws. It is the same shape as the bug being fixed, one level up: state
recorded correctly and then never applied.
So the drain no longer consults its own bookkeeping. It watches BOTH readiness
signals (legendNodes and mapRef) and on either one RE-READS THE ATTRIBUTE off the
host. The attribute is the durable record; re-reading it is what makes the outcome
independent of whether any single hook fired at the right moment, which was the
entire reason for choosing state over an event.
THIS HALF IS A HYPOTHESIS, NOT A PROVEN FIX, and I would rather say so than imply
otherwise: I can show the old drain COULD strand a request, not that it did. rega
has offered to instrument the timeline and that is the measurement that will settle
it — element <my-upg-100-list> in the map pane, attribute select-zone.
Also fixed, and this one rega proved: DESELECT DID NOTHING. country-posters'
applySelectCountry(null) cleared the flag and returned, so a second press left the
camera on India. PosterWall's button IS a toggle — it tracks lastShown and sends
listId: null on the second press — so a control you can turn on but not off reads
as broken even when the first press worked. It now flies back to the configured
opening view, the same center/zoom useMapInstance booted with.
A real clone defect, found by reading their markup after rega's live spacing probe
landed NEAR it and measured the pair as EQUAL.
Theirs is RegionDetail.vue:24 `.region-goal { margin-top: auto }`, and the goal
paragraph is the last child of `<div class="stack stack--lg | card | text-center">`.
composition/_stack.scss makes .stack a flex COLUMN with
`> * + * { margin-top: var(--stack-spacing) }`, so that auto OVERRIDES the child
margin and pushes the goal line to the BOTTOM of the card. Ours was margin:0, which
left it sitting directly under the text above it.
The visible consequence is the whole point for a clone: the three goal cards hold
different amounts of copy, so theirs align their goal lines flush along the cards'
bottom edge while ours floated at three different heights. That is exactly the kind
of thing the Driver means by "proper css spacing" — nothing is off-scale, and it
still looks wrong.
WHY THE PROBE COULD NOT SEE IT, which is worth more than the fix. rega measured
.rd-goal against .region-goal as "same", and they were right: at 1440 the card has
no free space, so margin-top:auto resolves to 0 and the two computed values agree.
An equal number hid a real difference. Their probe was correct AND incomplete, and
only their markup settled it.
My own spacing check could not see it either, for a different reason: it verifies
every value we SHIP is on their scale and is structurally blind to a declaration we
OMIT. That is "a property diff cannot see an addition", inverted. It now carries
hand-written assertions for the two omissions found this way (this one and
.region-stat__label's margin-top), since there is no general way to enumerate what
is missing.
★ AND I APPENDED THOSE TWO ASSERTIONS BELOW process.exit() at first, so they were
dead code: they printed nothing while the check still reported success. A check you
can add assertions to without them running is the same defect as a check that
cannot fail. Moved above the exit.
Also corrected, for the record: two of the nine selector pairs I gave rega were
wrong. .rd-big/.rd-big-label are the GOAL CARD's number and label, not their
.region-stat__* pair — our real counterparts, .rd-stat__value and .rd-stat__label,
already matched theirs exactly. And .rd-switcher's 112.5px side margins are the
container rule on .rd-scroll > * (90%+16px capped at 1200, centred at 1440), not a
spacing difference; theirs lives on a separate .container ancestor. I built that
pair list from greps instead of from the provenance comments in the file, which
document the true counterparts.
Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').
- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
(71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
The default list returns the same array, unfetched. A list whose membership cannot load shows no
groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
thread `list` to their children like `provider`.
Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.
Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
The Wall's pinned-d3 loader had the flaw rega found in PPLR's: d3's UMD build
writes its exports INTO an existing window.d3, so restoring the host's object
afterwards was too late. Ours had already been written into it. Same fix as
pplr-data-maps (60faff1a): while our script executes (document.currentScript
is our element), window.d3 is a slot of ours; any other code always sees the
host's own. A non-configurable `var d3` global is emptied for the load instead.
Headless, the real d3@7.9.0 (sha384 matches the pinned SRI) in a vm realm:
before host owns window.d3: keys 2 -> 579, version -> 7.9.0
after host owns window.d3: keys 2 -> 2, version host-7.0.0; the Wall gets its own d3 7.9.0
after no host d3: window.d3 absent afterwards, as before
rega re-ran PPLR's identical loader in a browser in all three variants, including a real
non-configurable `var d3`: PASS.