regions-mfe forwards page-scoped config to its nested panes by an explicit key
list, because a cap-4 composite mounts its children in THIS document and a pane
has no leaf page of its own to hand it anything. Two keys were missing.
dataSource is the one that was actually broken. PRODUCTION-MATRIX's own table for
embedders lists it -- "wrong/partial data ... Regions uses pray-tools" -- and
nothing pinned it in spec.config or either pane.config, so a partner setting it on
<regions-mfe> had it silently dropped and both panes fell back to sources.json's
activeSource. Unlike the token there is no page-wide publish to rescue it.
tokenUrl is latent rather than broken, and it is worth being exact about which.
exp-shepherd's universal-props re-measure named it absent here, and it is; but
this composite sits under a ProfileLoader that already watches tokenUrl, fetches
it, and publishes the answer page-wide, so the panes do hold a token. What they
lacked was the URL, so a pane could never re-ask the token service itself and was
stuck with whatever the page had published.
Of the four keys that table documents, assetsBase was already forwarded, tk needs
no forwarding (ProfileLoader publishes a token it is handed page-wide), and
profile must NOT be inherited -- the host's would overwrite each pane's own and
collapse both tabs into one view. The new gate asserts that exclusion too, so a
future well-meaning addition fails instead of quietly merging the tabs.
connectivity-map took its engine as engineGlobal(provider) || window.mapboxgl.
engineGlobal already answers for Mapbox, so the fallback fired only when
MapLibre was asked for and failed to load, and it handed back the token-gated
engine on a keyless page (regions' C3 read; upg100's ade12e4c is the same fix).
The fallback is deleted. A missing engine now warns in the console and shows the
map's existing error state, naming the engine, through
messages.engineNotLoadedDetail when that key lands with ade12e4c, and in English
until then. upg100's second defect (the token gated on the engine) does not
apply here: accessToken is passed in the constructor options on both engines.
Headless, the engine pick over both providers x loaded / failed with the other
engine present / failed with nothing: the only change is 'MapLibre failed,
Mapbox on the page', which went from MAPBOX to the error; the other five are
unchanged. Build OK; no-free-identifiers, universal-props and profiles-mount
pass.
countries-map typed in 'Research map loading…' and 'The map could not be
loaded.' (universal-props re-measure, main dee9f9c9). Both now reuse existing
keys through the component's own t(): messages.loadingNamed and
messages.couldNotLoadNamed, with the name tabs.map. That is 11/11 locales with
no new keys, so no library change. Headless: build OK, no-free-identifiers and
profiles-mount pass.
country-posters-mfe forwards the host's page keys to its two panes by an
explicit list, and dataSource and tokenUrl were not on it. The same gap regions
fixed in f0aa5ab3, found by dx-shepherd on this composite.
- dataSource was LIVE: countries-map reads it from its own profile-config
(countries-map.vue:392, :622), nothing pinned it, and nothing publishes it
page-wide, so <country-posters-mfe profile-config='{"dataSource":…}'> fell
back to sources.json's activeSource.
- tokenUrl was LATENT: the outer ProfileLoader already publishes the fetched
token page-wide, but the panes never got the URL itself.
Both join INHERITED_PAGE_KEYS. profile and instanceId are still never inherited
(pane.config is spread last).
Headless: replaying paneElement's forwarding on a partner config, main passes
{provider, lang} and drops dataSource and tokenUrl; after, the map pane gets
dataSource and tokenUrl too, and profile stays countries-map. Build OK;
no-free-identifiers, universal-props and composites-mount pass.
This map ships Doxa's roster, and the point of a public corpus is that other lists
can stand on it. A partner could already embed the map; their reader then had to
go hunting for their own people among 2,107 dots.
?codes=kfr,cmn,npi draw only those languages, framed on them
?country=IND open that country's sheet — this did NOTHING before unless
a ?code came with it, so the brief's second requirement had
no address, which is exactly what a partner embeds
?view=zero unchanged
The join is ISO 639-3, so a partner's list needs no agreement with ours — only the
codes their own data already carries. It filters and never invents: the notice
reads "74 / 2,107" and is itself the button back to the whole map, labelled with a
key that already exists in 11 locales. A link matching nothing shows an empty map
reading "0 / 2,107", because showing the full map instead would tell a partner
their broken list works.
check:embed-contract gates the promise. Written against the pin index first, it
demanded "12 / 12,379" of a map correctly saying "0 / 2,107" — the gate was wrong
and the map was right, so its numbers and its probe codes now come from the file
the leaf itself reads.
Cross-filtering is the one interaction whose whole point is that acting on ONE
control changes a number somewhere ELSE. Pick "Islam" in the left rail and 2,107
becomes 693 across the KPI strip, the map, the table and the charts. A sighted
user watches that happen. A screen-reader user heard nothing at all: this
element carried exactly one role="status" (the table's selection pill) and zero
aria-live regions. The filter worked; the feedback did not exist.
ONE polite live region in the shell, not one per panel. Six surfaces change on
every brush, and polite announcements QUEUE — six regions would make the user
wait through five repeats before reaching the next control. It is always
rendered and starts empty, because a live region inserted already holding its
text is routinely not announced; it is visually hidden rather than display:none,
which would take it out of the accessibility tree along with the pixels. It is
debounced at 700ms for the same reason the selection camera is debounced: typing
re-derives the row set per keystroke, and an un-debounced region would queue one
announcement per character. It stays silent on arrival and only ever reports a
change. It names the count, the total, the active facets and the search term,
formatted by the shell's own formatter so it can never read as a different
number from the KPI strip beside it.
ONE RESIZE HANDLE ANNOUNCED ITSELF IN ENGLISH in all 11 locales. Four of the five
read their title and aria-label through T(); the map/table one held raw string
literals. It survived review because each half looks correct on its own — the
ternary is right, the strings are right, only the missing T( is wrong. Both now
resolve through the catalogue. (The keys themselves are on
sr/dashboard-a11y-strings: aria.json held eleven keys and not one was a resize
key, so ALL five handles have been falling back to English. The T() call sites
were right the whole time.)
THE CHIP ✕ IS NOW BIG ENOUGH TO HIT. The live strip's remove button was a
~13x16px glyph — the smallest control in the product, and the only way out of a
narrowed board, where a miss costs the reader the whole view. The row height
cannot grow: three comment blocks above it record a measured layout-shift bug and
the Driver's "a lot more subtle" instruction that pinned it to 16px. So the HIT
AREA grows and the BOX does not, via an absolutely-positioned 24x24 ::after that
contributes nothing to the flex row's intrinsic size. Nothing moves by a pixel.
2.5.8 is a TARGET SIZE criterion, not a touch one, so it is unconditional. A
focus-visible ring too, since the chips live in an overflow-x strip where being
scrolled into view is not the same as being visible.
The kit's own DashboardFilterChips were spans with click delegation — not
focusable, no click from Enter or Space. They are buttons now, with an aria-label
naming the ACTION rather than the filter ("Religion: Islam" is a good name and a
useless action), type="button" so a partner's form is not submitted by a chip, a
focus ring and a 24px floor. The reset-all chip deliberately gets NO aria-label:
its visible words already are the action, and overriding them breaks voice
control.
Recording what this does NOT fix, because the measurement that prompted it named
the wrong file. DashboardFilterChips is exported from the kit and mounted by
NOBODY — round 1 of BUG-DUPLICATE-FILTER-BADGES removed it, and the strip on
screen belongs to SearchFilterHeader, whose remove button was already a real
<button type="button"> with a localized aria-label. So the keyboard half was a
latent defect closed before the next dashboard mounts it, not a live one. The
live defect in that same control was its size, above.
check:dashboard-filter-a11y guards all of it, on the kit component AND the strip
that actually ships. It walks the parsed template AST rather than grepping,
because grep cannot tell a bound :aria-label from a literal one, cannot tell a
button from a span named like one, and cannot see a live region hiding inside a
v-show'd cell. It also reads the built bundle, since source-correct plus
bundle-stale is a real repeat failure here. Fourteen mutants were run against it;
every one fails, naming the file and the reason. No browser — that is rega's.
STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid (the
composite bakes the shell in). Part D of the gate fails until you do, by design.
rega, on MapLibre, in BOTH locales: Languages > Language > "Arabic" sent
map-filtered with {language:"Arabic", languageCode:""}, the composite handed the
grid an empty code, and 2,107 rows became 0. An empty grid reads as "no
results" — the worst shape a wiring bug can take, because the reader believes
the data.
ONE LINE, AND IT WAS MINE. The emitter looked for a row whose language LABEL
equalled the selected name. A language tier's label is not a label any row
carries: the legend builds family > language > dialect and derives the language
tier by splitting the API label at the comma. Measured on the live source —
2,107 rows, every one carrying primary_language as {value,label} — there is NO
row whose label is "Arabic". There is "Arabic, Sudanese", "Arabic, Levantine",
"Arabic, Gulf", twenty-two codes in all. The exact match could never hit, in any
locale, which is why it failed identically in en and ar and why the locale fix
that prompted this code was right about the disease and wrong about this line.
This is the derived-value trap one tier down from where I last wrote it up: a
value that crosses a component boundary has to exist on a row. "Arabic" is a
grouping the legend invented; primary_language.label is a field.
So the rows are now matched the way the legend's own node matches pins — base,
"base," prefix, " base" suffix — rather than by a fourth hand-typed spelling of
a rule that already exists in two places. 61 tiers resolved an empty code
before; "Arabic" was one of them.
AND ONE CODE ONLY WHEN THERE IS ONE. "Arabic" means 22 ISO codes, so no single
value expresses it. Emitting one would filter the grid to a fraction of the
selection while looking correct; emitting the tier NAME is what showed zero. The
honest answer is to emit nothing for that axis and leave the grid unfiltered —
the map is narrowed, the grid is not, and the reader can see both. languageCodes
carries the whole set alongside, so this becomes a real narrowing the day
filterSet goes multi-value (SPEC-DASHBOARD-KIT D1) rather than a rewrite.
Measured on the fixture: 764 tiers now resolve one code and filter the grid; 41
cover several and abstain.
A DIALECT IS A LANGUAGE SELECTION TOO, and it emitted nothing at all — the
watcher never observed selectedDialect, so drilling from "Arabic" to "Arabic,
Sudanese" changed the map and left the grid reporting the tier above. Its
originalLabels are exact API labels, so it is the one unambiguously resolvable
case.
check:language-code-link, headless, no network at run time. It LIFTS the
emitter's match rule and the composite's gridLanguage() out of the shipping
source rather than carrying its own copy — a gate with its own copy of the rule
passes while the product is broken, which is exactly how this survived a green
check:linked-vocabulary. It proves the emitter's predicate and the legend's
filter expression agree on every (label, tier) pair, that every tier selects at
least one row, and that an ambiguous tier narrows nothing. The fixture is the
864 distinct label/value pairs from the live source, deduplicated rather than
sampled so the counts above are about the whole set. Four mutants; reverting the
emitter to the exact-label rule reproduces rega's finding exactly — 61 tiers
selecting zero rows, "Arabic" named first.
STUDIO: needs a rebuild of doxa-research-map and research-map-plus-grid.
rega: "0 search bars on both engines". The product is right and the check was
wrong, twice, and both mistakes are mine.
UNDER MAPLIBRE, NO SEARCH BAR IS THE CONTRACT. research-map.vue says
`hasGeocoder = mapProvider === 'mapbox'`, and research-map.instances.json says it
in words: "Under maplibre the search bar is deliberately ABSENT —
GeocoderComponent's maplibre path still geocodes through Mapbox, so shipping it
would bill the very key this instance exists to avoid." The previous revision had
just been taught that a bar-less leaf is a hollow pass, and applied that lesson
to the one engine where absence is the point. An absent bar is in fact the
STRONGEST form of this gate's own assertion: a search that cannot be typed into
cannot send anyone's IP to Mapbox.
The rule is conditional now rather than hardcoded, because a keyless geocoder
may ship one day and this gate should not need rewriting when it does: no bar
under maplibre is recorded and passes; a bar under maplibre must work and still
make zero Mapbox requests. Either way the zero is what is asserted.
THE MAPBOX RUN WAS NEVER GIVEN A TOKEN, so it could not have rendered a bar
either. The leaf bakes BAKED_TOKEN = "" and nothing else supplies one, so
mapbox-gl cannot construct a Map, `map` stays null, and the bar's
`v-if="appReady && map && hasGeocoder"` never fires. Note the mechanism, because
it is not the obvious one: an explicit ?provider=mapbox does NOT fall back to
maplibre — explicitMapProvider reads the URL first — so the engine is Mapbox, it
simply has no key. A gate that fails a product for a key the gate did not
provide is measuring its own environment.
It takes MAPBOX_TOKEN from the env, or `token` from the build config — the same
ladder check:experimental-shelf uses — and injects it as window.MAP_TOKEN the way
check:auto-engine and check:a11y-button-names do. Only for the mapbox run: a
token present during the maplibre run is the very variable this gate holds at
zero. With no token the mapbox half is NOT MEASURED, reported through the SKIP
line run-parallel reads, so the wave prints it as unmeasured rather than counting
it as a pass. The maplibre half is measured either way, keyless being the whole
point of that engine.
Also separates "the bar was removed" from "the map never constructed" by probing
for a canvas, since those are different bugs with one symptom — and the second is
how this check misread itself.
The token is never printed, only its presence.
Every one of these bundle entries carried its own copy of
if (window.mapboxgl?.setRTLTextPlugin) { setRTLTextPlugin(url, null, true) }
which is a strict SUBSET of what library/maps/rtl-text.js already does, so the
copies could not be covering anything the library misses:
- useMapInstance calls installRtlTextPlugin(gl) at line 450, before it builds a
map at 537/567 -- registration still precedes construction, the one ordering
Mapbox cares about.
- the call is identical in effect: same v0.3.0 URL, same null callback, same
lazy flag.
- the block is mapboxgl-only. The library installs on MapLibre too, so every
keyless embed was covered by the library and never by these blocks.
- the single raw `new gl.Map` site (upg-100-list.vue:1894) installs for itself
at 1835 and never depended on its entry.
I first justified this as deleting DEAD code, on the theory that the engine is
injected lazily so window.mapboxgl would not exist when an entry runs. That is
wrong and the measurement says so: every leaf index.html loads mapbox-gl.js as a
blocking <head> script while ./index.js is a deferred module, so the guard did
pass and the blocks were live. The reason to delete them is redundancy, which is
the weaker and truer claim.
A three-line pointer replaces each block so the next reader does not re-add a
Mapbox-only copy.
- check:dashboard-geocoder-provider: 0 search bars on BOTH engines. Its default leaf (research-map) hides
the bar under MapLibre on purpose, and the tokenless Mapbox run could not draw. It now defaults to
doxa-simple-map (a bar on both engines), and the Mapbox run gets a host token: TEST_MAPBOX_TOKEN, or a
well-formed fake whose token-carrying requests are aborted, never sent.
- check:composite-embed-knobs: every tk case SKIPPED (no baked token since the auto engine), so every
provider case went UNMEASURED and the gate still PASSed. It takes TEST_MAPBOX_TOKEN, and a run where no
case measured anything exits 75 NOT MEASURED instead of PASS.
- check:linked-vocabulary: it attached its groups-filtered listener after goto and could miss the grid's
first emit ("the grid never reported a total"). The totals are now recorded from page start
(addInitScript).
Not browser-run here (browsers are rega's): rega's next gates:post pass is the proof.
PPLR's SemanticTreeLegend (a diverged copy of the library's; retiring it is card
RETIRE-PPLR-SEMANTICTREELEGEND) still typed in five strings: "Loading {label}…",
"Loading data…", "No data at this level", "Showing top {n} of {m}" and "use the
geocoder above to find the rest". The keys exist in all 11 locales since
d4359163 (legend.loadingRows / loadingData / noDataAtThisLevel / showingTop /
useGeocoderHint), which the library copy uses.
They now go through the component's existing guarded translator (ta), with the
English added to its fallback table, so a host without i18n still reads
English, never a key. Numbers keep their current toLocaleString (numerals held).
Headless: build OK, and check:no-free-identifiers, i18n-parity and
profiles-mount pass. (check:ui-strings-have-keys, which found this, is not on
main yet.)
studio's APPROXPIN (dee9f9c9) moved library/ — legends, PinHint, useMapInstance,
i18n — so the rebuild was certain rather than conditional this time. Checked, as
always, with `git diff --name-only <base> <main> | grep '^library/'` rather than
assumed: that test was EMPTY on one earlier merge and NON-EMPTY on the next two,
and it is the only thing that decides whether the bundle must follow.
All conflicts were DERIVED artifacts again — my two leaf HTMLs and the manifests,
zero source conflicts. Resolved to main's fresh build, then regenerated from the
merged source, because picking a side of a generated file is meaningless.
VERIFIED BY HASHING, the step whose absence shipped a blank page: fix:sri rewrote
the two manifests, and I then recomputed sha384 over the actual bundle —
upg-100-list, upg-100-mfe and poster-wall all MATCH, whole-tree sweep 57 integrity
attributes with 0 mismatches. fix:sri, not tree: studio had already regenerated the
pages and the gate itself warns that tree re-reads .vue source on a shared branch.
The shared approximate-pin note (pins.approximate) turned gospel-resources' own hardcoded copy of the
same sentence into 'has a key but typed in'. Recorded as known debt for gospel-plugin to switch to
ApproxPinNote; no string was added.
Third and final batch of the shared-library strings, in the shepherd's order:
SemanticTreeLegend (4), PosterPreview (2), PosterWall (2) and
VerticalTabsLayout (2), plus PosterWall's "people groups" which already had a
key and was still typed in.
These are all LOADING and OVERFLOW states — "Loading data…", "Showing top 500 of
2,107", "use the geocoder above to find the rest", "Loading map snapshot…",
"preview unavailable", "Could not load X". Text a reader meets exactly when
something is slow or has gone wrong, which is the worst moment to be handed a
language they do not read. PosterWall's own source called one of them "i18n
DEBT" in a comment; it is paid now.
The counted ones take a placeholder rather than being split around an
interpolation — legend.showingTop is "Showing top {n} of {m}" — so a translator
can move the numbers within the sentence. Languages do not agree about where a
count goes, and a sentence assembled from fragments cannot be reordered at all.
VerticalTabsLayout needed a different shape: it is an options-API component, so
a module-level helper compiles fine and then renders nothing, because its
template can only see what the component returns. T is exposed from setup().
★ THE NEW GATE CAUGHT SOMETHING I WOULD HAVE MISSED, on its first real batch.
Adding these keys flipped two strings in
plugin-designs/pplr-data-maps/src/components/SemanticTreeLegend.vue from "no
catalogue entry" to "has a key and is still hardcoded" — that file is a COPY of
the library component I just fixed, carrying the same debt. It is another lane's
plugin so I have not touched it, and it is reported rather than quietly
baselined away. The baseline is refreshed to today's truth so the gate guards
the new line: 285 → 273 without a key, and the two copies recorded as known debt
rather than as a regression.
check:composite-locale-leaks asks a good question — "does the catalogue
translate this string, and is the page showing English anyway?" — but its
criterion is that the catalogue already HOLDS the string, so a string nobody
ever added is invisible to it by construction. And it runs in a browser, so it
only ever sees what happens to be on one screen.
That blind spot was measured, not guessed. After both composites passed the leak
gate with zero findings, a source audit of the same two trees found 31 more
user-visible strings. Most were aria-labels, titles and tooltips: text only a
screen-reader user or a hovering mouse ever meets. Our localization verification
had been "what can I see", and the blind spot was exactly the readers who most
need the words.
TWO CLASSES, because they are different defects with different fixes:
NO KEY wording must be written and translated — sometimes the Driver's call.
HAS A KEY the translation already ships in eleven locales and the reader is
shown English anyway. The cheapest defect in the catalogue: the
work was done and wasted. Nobody was looking for this one; it only
appeared because the report was split in two.
★ WARN-ONLY AGAINST A BASELINE, DELIBERATELY. Failing on the whole backlog — 285
without a key, 29 hardcoded-with-a-key across 126 components — would make it
unlandable and it would be switched off within a day. It fails on what is NEW, so
the count can only fall; --update refreshes the baseline when a batch lands.
QaCorner is excluded BY NAME, with the reason in the file: an opt-in developer
widget that studio is making host-neutral or removing. Eleven locales for a
corner only we open is not worth the translations, and a judgement like that is
better written down than left as a silent gap.
NEGATIVE-TESTED ON BOTH CLASSES, because a gate never seen to fail is not
evidence: a new text node and a new title attribute are both caught and named;
an aria-label hardcoded to "Reset all" — which already ships in every locale — is
caught as the second class with the fix stated. Restored, it passes.
The connectivity map is built raw (new gl.Map), not through useMapInstance, so
it never handed the engine a UI locale: the canvas's "Map" name, "Zoom in",
"Toggle attribution" and the gesture hints stayed English under ar on both
engines (rega, row 34, _qa-cycle/rega/rows31-34-0f136d25/).
It now passes locale: engineUiLocale(readerLang(), provider) at construction,
with the same reader-language ladder useMapInstance uses (ProfileLoader's lang,
profile-config, ?lang=, <html lang>). The provider argument is ready for
studio's per-engine logo label; today's engineUiLocale ignores it.
Headless: the bundle builds and carries the engine key map (Map.Title...), the
ar catalogue has mapui.map = "الخريطة", and check:no-free-identifiers,
profiles-mount and universal-props pass. Not rendered in a browser.
This leaf calls `new gl.Map` directly rather than going through useMapInstance, so
it never inherited the locale map the shared composable passes: under Arabic every
string we own was Arabic and the engine's own were not — "Toggle attribution", and
the canvas's accessible name, which is what a screen reader announces the map as.
The strings were already in the bundle; nothing was handing them to the engine.
Measured that way: the Arabic mapui catalogue is present in the built bundle both
before and after, while ENGINE_UI_KEYS — the map from the engine's key names to
ours — appears only after. Set at construction, the only time either engine reads
it. Needs eyes to confirm on screen.
The one English legend tooltip now goes through t(), with the English kept in the
template as the fallback so it renders exactly as it does today until the key
lands, rather than showing "gospel.legend.hnrTip" in a tooltip.
studio/mirror (c85eeee7) rebuilt every bundle and touched library/ —
PinHint, LegendDesktop, LegendTools, MapControlMenu, ShareButton, PosterDialog
and others — so unlike the previous merge this one genuinely required rebuilding
my bundle rather than just re-hashing it. Checked rather than assumed: the
decision of whether to rebuild turns on `git diff --name-only <base> <main> |
grep '^library/'`, and last time that was empty and this time it is not.
ALL SIX CONFLICTS WERE DERIVED ARTIFACTS — my bundle, my three leaf HTMLs and the
two manifests — and ZERO source files conflicted. Resolved to main's freshly built
versions and then regenerated from the merged source, because picking a side of a
generated file is meaningless: neither side is authoritative, the source is.
VERIFIED BY HASHING, which is the step whose absence shipped a blank page three
commits ago. fix:sri rewrote files this time (it reported nothing to do last time
only because `tree` had already fixed them), so the summary alone would have been
even less informative. Recomputed sha384 over the actual bundle: upg-100-list,
upg-100-mfe and poster-wall all MATCH, and a whole-tree sweep is 57 integrity
attributes with 0 mismatches.
Used fix:sri and NOT tree, per the gate's own advice — tree re-reads .vue source
and on a shared branch can bake in another agent's half-finished work. studio had
already regenerated the pages, so there was nothing to regenerate.
BLAST RADIUS: the only bundle differing from main is mine. Pins-first source
confirmed intact after the merge (boundaryPromises, paintPinsEarly, the deferred
boundary await).
The Driver's ask: one folder per BUNDLE with its ELEMENTS inside, bundles sorted by capability, highest
first. The rail listed every element flat under its own capability, so a bundle's elements were
scattered (gospel-resources' five screens sat in three sections). Now each capability section (5 → 1)
holds the bundles whose HIGHEST capability it is, in name order; a bundle with several elements is one
folder (country-posters, gospel-resources, my-upg-100-list, regions-dashboard, cities-internet), and a
single-element bundle is shown as its element. Composites keep their folded composition trees.
StudioTree identifies a plugin's row by a `canonical` flag rather than "depth 0", so auto-reveal opens
the bundle folder above an element chosen by URL or restored tab. New folder icon; 'elementsCount' and
'elements' in all 11 studio catalogues.
check:studio-rail (headless, gates:post) runs the studio's real rail code on the built manifest and
asserts the layout and one canonical node per plugin (21 bundles, 33 elements); it fails on the previous
rail. Also: poster.json:dpi is declared identical on purpose in check:i18n-never-translated (a unit
symbol kept as-is by project decision), which had turned that gate red on main.
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.
MY LAST THREE COMMITS SHIPPED A PAGE THAT COULD NOT RUN, and rega caught it: the
committed bundle hashed to sha384-k1mPDt9L… while every leaf's integrity attribute
still pinned sha384-GEhDNkbg… — the PREVIOUS bundle. The browser refuses the script
outright, so 6 of 6 of their runs loaded only the html and the js and never created
a map. A blank page.
I RAN `vite build` FOUR TIMES AND NEVER RAN THE OTHER TWO STEPS. dx-shepherd's
recipe is build -> tree -> fix:sri, and I ran the first third of it and reported
success each time because vite exited clean. It did — the BUILD was fine. What was
broken is the ARTIFACT PAIR: index.html and my-upg-100-list.js are two files that
must agree about a hash, and "vite exited 0" says nothing about whether they do.
That is the same measure-the-artifact-not-the-property mistake I have spent this
whole session finding in other places, committed four times in a row in my own
build step.
FIXED PROPERLY THIS TIME:
· merged main (d3d3c3f8). rega also flagged the stale base — the branch was on
c2af6b16 and missing main's catalogue trimming, which is why the bundle was
1,026,943 B against main's 811,632. After the merge it builds to 792 kB.
· the ONLY merge conflict was public/dx-maps/my-upg-100-list/my-upg-100-list.js,
a DERIVED artifact — so neither side is authoritative and picking one would be
wrong on principle. Regenerated it from the merged source instead.
· then `bun run tree`, then `bun run fix:sri`.
VERIFIED BY HASHING, not by trusting the tool's own summary. fix:sri reported "115
already agreed, rewrote 0", which is only reassuring if `tree` had already fixed
them — so I recomputed sha384 over the actual bundle and compared it against every
leaf that loads it: upg-100-list, upg-100-mfe and poster-wall all MATCH. Then swept
the whole tree: 57 integrity attributes checked, zero mismatches.
AND CHECKED THE BLAST RADIUS, because `tree` regenerates every leaf's HTML: the
other bundles under public/ (all-people-groups.js, big-picture.js) are byte-identical
to main — the merge brought main's versions, my rebuild did not touch them. Only my
own bundle differs, as it should.
NOT SIMPLIFIED YET, for the record: the merge brought gb-IND-ADM1-full.geojson
(1,796 KB) as an ADDED higher-detail variant. The SERVED files are unchanged
(world-adm0 1,866 KB, gb-IND-ADM1 1,628 KB), so the 3.6 MB parse cost still stands
and the boundary-simplification work is still pending elsewhere.
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.
rega's BASELINE TRACE OVERTURNED MY OWN PROFILE, and this commit is built on their
measurement rather than my arithmetic. Baseline at main ccc2b1bb, tokenless
MapLibre, software GL: every key byte arrives by 1.2-2.9 s, and the first territory
draws 5.6-6.4 s LATER. Only ~2.6 s of that gap is main-thread long tasks. The rest
is MapLibre's WORKERS parsing and tiling ~3.6 MB of boundary GeoJSON.
I HAD PROFILED FILE SIZES AND REQUEST ORDER — the artifact — and concluded the
download was the cost. On localhost those 3.6 MB arrive in ~230 ms. Reordering
requests was never going to reach 3 s, because PARSING is the bottleneck, and my
own two-pass-zones plan would have been just as blocked: resolving even the 15
record-free zones still needs world-adm0 parsed. dx-shepherd dropped it in favour
of pins-first (ENDTOKEN-DXS-UPG-PINSFIRST). Right call.
THE PINS NEED NONE OF THE GEOMETRY, verified in the code rather than assumed: they
are built from `records` (lat/lng) plus slugToZone, and the evaluate() in the pin
build maps slugs to zone keys without touching a boundary file. 68 KB, arriving at
~2.9 s cold.
SO THE BOOT ORDER IS NOW: records (68 KB) -> build pins -> PAINT PINS -> await the
boundaries (3.6 MB parse) -> tapestry -> search index -> full repaint.
Three things make that safe rather than clever:
· all three boundary downloads are still started BEFORE any await, so nothing is
serialised — they fetch and parse while the pins are being drawn;
· paintAll now paints WHAT IS READY instead of refusing until everything is. Its
old guard `if (!map || !tapestry) return` was what forced the pins to wait on
the parse. Every step below it is idempotent and guards its own source AND
layers, so calling it twice adds each thing exactly once — which is only true
because of ea90e9b2;
· `tapestry` STAYS WHOLE-OR-NULL. Nothing paints a partial tapestry; the pins
simply stop depending on one. boundsFor(), the search index and the status
telemetry keep their invariant, so a reader can never search or fly-to against
a half-built index. That was the correctness constraint dx-shepherd and I
agreed before rega's trace, and it survives the change of plan.
The early paint is FIRE-AND-FORGET: it uses the same style-readiness rule as the
boot gate, and if the style is slow the pins simply appear later while the boot
gate repaints everything regardless. It must not delay the boundary await.
⚠ NOT MEASURED BY ME. Every claim about timing here is rega's; I have only verified
that the code order now matches the plan and that the build is clean. Sending them
branch@sha for the same cold trace, and the honest comparison is baseline-cold vs
this-cold in THEIR environment — software GL inflates rendering, so the absolute
numbers are not the point.
ALSO CONFIRMED BY THEIR TRACE, about the previous commit: the list API sends
`public, max-age=14400`, so dropping cache:'no-store' is a real win and not a
guess — the warm reload was still refetching 68 KB over the network, which fits a
client-side no-store exactly.
BUNDLE=my-upg-100-list vite build clean; check:packet-strings-parity PASS.
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.
THE BOOT CHAIN WAS STRICTLY SERIAL and the sizes are the story. Measured
2026-09-26: `await Promise.all([3 boundaries])` and only THEN `await fetch(API)`,
so 3,549 KB of geojson — world-adm0 1,866, gb-IND-ADM1 1,628, gb-CHN-ADM1 54 —
had to download AND JSON.parse before the API request was even issued. rega
measured the first paint at 9.5-16.5 s, which is why a 7 s screenshot wait caught
a blank map.
THREE CHEAP WINS, no restructuring of the paint itself (dx-shepherd
ENDTOKEN-DXS-UPG-PERFGO, cheap first):
1. THE API REQUEST IS ISSUED FIRST, NOT LAST. Nothing needed to build that URL
depends on the boundaries, so the fetch starts immediately and is awaited where
it always was — the rejection still surfaces at the same place in the same
try/catch. Creating it early changes WHEN the request goes out, not where its
failure lands.
2. gb-IND-ADM1 (1,628 KB) IS OFF THE CRITICAL PATH. Only the India pg_slugs zones
need it, and they are resolved well after the first layer could be drawn. It now
downloads alongside the other two and is awaited beside the records.
⚠ AND THE ORDER OF THAT LINE IS LOAD-BEARING. My first version put the
getJSON() under the Promise.all and it overlapped NOTHING: a promise created
after an await does not start until that await resolves, so the code read as
three parallel downloads while behaving exactly as serially as before — a
parallel-looking shape with serial behaviour. Caught by checking the line
numbers rather than the intent.
3. `cache: 'no-store'` DROPPED from the boot fetch. It forced a full ~870 KB
re-download on every reload and could never hit the HTTP cache. This list
changes on the order of days, not within a session, and the default lets the
API's own headers decide — which is the server's call rather than this client's.
⚠ The other three no-store fetches are DELIBERATELY LEFT: packetRecords and
selectionAsList are user-triggered (a reader asking for a packet should get
today's data), and enrichLegendFromRecords runs only on the degraded paths where
no engine or no token means the map pipeline never ran at all — so it is a
fallback, not a duplicate of the boot fetch. Verified both call sites.
The blocking await is now 1,920 KB instead of 3,549 KB plus a serial API round
trip, with three requests in flight from the start.
⚠ NOT MEASURED AT THE RENDERED LAYER. Every number here is file sizes and code
order; the 9.5-16.5 s is rega's. dx-shepherd is having rega take a BASELINE trace
and a second on this branch so the gain is measured rather than inferred, and I
would rather that number came from them than from me. The two-pass paint (15
record-free zones first, then the 12 pg_slugs zones and the pins) is next and
deliberately a separate commit.
BUNDLE=my-upg-100-list vite build clean.
`if (map.getSource('tapestry')) return` returns having added NOTHING whenever the
source outlives the layers — and that state is real, not hypothetical: setStyle()
wipes LAYERS, which is why paintAll is re-armed on 'style.load' at :1625. The
re-add is a genuine code path (the light/dark toggle) and this guard could silently
no-op it, leaving a map with a source, no polygons and no pins.
addPinLayers had the identical bug on getSource('upg'), which is why territories
and pins would disappear together: one mistake, made twice.
ensureSelectionLayers():652 ALREADY HAD THE RIGHT SHAPE — `if (!map ||
!map.getSource('tapestry')) return; if (map.getLayer('upg-sel-fill')) return`. The
correct pattern was sitting in this file and its two siblings did not copy it.
Presence of a source and presence of the layers drawn from it are two properties;
a function whose job is the second must not test the first.
EACH LAYER IS GUARDED SEPARATELY because addLayer THROWS on an id that already
exists — a half-built style is exactly the state this fix is for, so re-adding a
survivor would abort the function and skip everything after it.
⚠ THIS IS NOT THE CAUSE OF THE BLANK ENGLISH README SHOT, and I am recording that
because I diagnosed it as such and was wrong. I had a confident chain — source
survives layers, half-built style, hence en blank while ar painted — and started
fixing it as the cause. rega settled it with the measurement I did not have: the
leaf paints 9.5-16.5 s after load and the screenshot tool waited 7 s. A timeout,
not a defect. The chain explained the symptom without being true of it, and
claiming this commit fixes that shot would be exactly the false attribution I have
been correcting in other people's reports today.
Kept on its own merits. BUNDLE=my-upg-100-list vite build clean.
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.
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.
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.