The dashboard's five resize handles are role="separator" widgets with arrow-key
operation and live aria-valuenow — properly built, and four of them already read
their names through T(). None of the six keys they asked for existed. aria.json
held eleven keys and not one of them was a resize key, so every handle fell back
to its English default in all eleven locales: a screen reader in Arabic
announced "Resize filters rail" over an otherwise Arabic board. The T() call
sites were right the whole time; the catalogue simply never caught up, which is
the quiet half of this defect class and the half no gate was watching.
Ten resize keys (each handle's accessible name plus its tooltip hint) and
aria.removeFilter for the kit's chip strip, all eleven locales.
Three dashboard keys for the shell's new polite live region: results_live,
filters_live, search_live. They are the sentence a screen-reader user gets when
a brush changes the row count — "693 of 2,107 groups shown · Filters: Religion".
The counts arrive already formatted by the shell, so numerals are untouched here
and the placeholders pass straight through.
Keys are appended, never reordered: a catalogue diff that reshuffles is a diff
nobody can review. check:i18n-parity and check:english-leaks pass, and
check:i18n-never-translated finds none of these fourteen identical across
locales. (It still reports poster.json:dpi, tables.json:csv and tables.json:json
from before this branch.)
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.
Their 24px floor sat only behind @media (pointer:coarse), so a desktop mouse still got the small target
(regions' finding; the phone probe runs with touch, so it measured the passing branch). 2.5.8 is a target-
size criterion, not a touch one. .mobile-collapse-caret (22x20 in a 20+4px slot), .stl-hdr-num (a 9px line
box), .rm-link-btn (11px, padding 0) and .pin-hint-x (16px) each get a 24x24 absolutely-positioned ::after
hit area at every width (the f29b8151 pattern). It adds a target without a box, so no row or slot
geometry moves; the coarse query still sizes the box on touch.
PosterWall's .pbtn, .pstock and .tape-h are HELD: their spacing is the Driver's sign-off.
The desktop verdict (2.5.8's spacing exception, an ancestor with overflow:hidden) needs a rendered layout,
so this is a needs-eyes item for rega.
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.
gospel-resources reads it through tOr() with the English inline as the fallback, so it switches to the
catalogue as soon as this key is on main, with no plugin change (gospel-plugin's request).
- 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.
check:composite-freshness found stale on main: all-people-groups
cities-internet
country-posters
dashboard-plus-grid
demo-map
doxa-maps-spa
doxa-research-map
doxa-simple-map
gospel-resources
key-cities
my-upg-100-list
plugin-studio
pplr-data-maps
prayer-walk-planner
regions-dashboard
research-dashboard
template-bundle
upg-neighbor
wall-of-people-groups. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
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.
rega's row 34 (map controls in ar) found the engine logo's label English: "Mapbox homepage" /
"MapLibre logo" — one engine key with two meanings. engineUiLocale(lang, provider) now takes the engine
and sets LogoControl.Title from mapui.mapboxHomepage or mapui.maplibreLogo (11 locales). gospel-resources
and cities-internet build their maps RAW; engineUiLocale is documented as exported for them. (PinHint's
close button was already translated on main as aria.dismissHint.)
22b92ae0 made the starter's first screen a real map (CRITIC-11), so the gate
found a map bundle with no share decision. It is recorded as 'none': the starter
is not published, and a copy adds a ShareButton when it becomes a real map. The
gate enforces that assumption: if template-bundle is ever published, 'none'
fails it. Now PASS, 12 map bundles.
The gate reads facetRail out of dashboard.config.js with /\{[^{}]*\}/, which
matches only objects with no braces inside. 53e2c662 translated the rail titles
as getters (get title() { return tStatic(...) || 'People' }), so the four drill
sections stopped parsing. The bus was then built with no hierarchy, and the gate
reported 21 'orphaned child' failures in a dashboard that cascades correctly
(the real one reads the live config object, never this parser). Red on main
since 53e2c662 (bisected by swapping the gate's only two inputs: 7f700935 OK,
53e2c662 FAIL).
The parser now splits the array into top-level objects by brace depth, skipping
quoted strings, and throws if a section has neither a drill nor a facet. So a
parse miss is an error, never a false pass or a false fail.
Headless: it passes on main's real files. With a poisoned bus (configureHierarchy
installs no hierarchy) it FAILS; restored, it passes.
A skipped or unmeasurable gate and a failing one shared one exit code, so real reds hid among skips
(regions measured 15 of 67 red at d3d3c3f8, mixed with gates that simply could not look).
- Exit 75 (EX_TEMPFAIL) from ANY step is NOT MEASURED, as is a gate that exits 0 announcing SKIP or NOT
MEASURED. Only any other non-zero exit is a FAIL. The summary line is
"PASS n · FAIL n · NOT MEASURED n", then the unmeasured by name, then each failure in full.
- Browser gates run only on the browser seat (DOXA_BROWSER=1; the project rule that browsers are one
agent's). Elsewhere they are NOT MEASURED without being started, instead of failing to launch.
- `verify` is graded static: verify-all runs its browser guards only with --full, so the plain step
no longer goes through the browser slot, where it printed "ALL GUARDS PASS (browser guards skipped)"
and still exited non-zero.
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.
A people-group pin is a representative point, not an address, and some pins ARE the same point: on the
Doxa roster 2,107 groups sit on 2,048 coordinates, 108 of them on a point shared with another (gospel-
plugin's measurement). Every map with people-group pins must say so, in one wording.
- library/components/legends/ApproxPinNote.vue: "The dot marks where this people is found, not a town or a
boundary." always; when the selected pin's point is shared, "It sits on the same point as <names> — <n>
groups share this coordinate…". Never prints the raw coordinate. Wording: gospel-plugin's P5 proposal,
in a new pins catalogue (11 locales).
- library/utils/colocation.js: the co-location index, built from the roster THE MAP draws (rows or
GeoJSON features), never a hardcoded count.
- LegendDesktop and LegendMobile (research map, simple map, country posters, cities, UPG-100) show the
note under the legend, and under the detail card with the shared names.
- check:approx-pin-note (headless, gates:post): the index, the two sentences, no coordinate, Arabic.
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).
Second of the shared-library batches, in the shepherd's order: ShareButton,
DataTable, LegendTools, and PinHint's dismiss label.
★ THE ONE THAT MATTERS MOST IS THE PARAGRAPH A PARTNER READS. ShareButton's
embed tab explains where to paste the snippet — "WordPress, Squarespace, Wix, or
any site that accepts HTML widgets" — and it was English on every locale of a
product whose whole pitch is that a partner can drop it into their own page. The
two tab labels beside it likewise.
Also wired: the data table's CSV and JSON buttons, its row-detail hint and
dialog label; the legend's Clusters button and the Minimum Spanning Tree option
with its explanation; and the pin hint's Dismiss.
★ SIX SITES NEEDED NO NEW WORDING AT ALL — they already had translations in all
eleven locales and were still typed into the template: four "Close" buttons
(buttons.close) across PosterDialog, DataTable, MapControlMenu and the QA
corner, "Expand legend" (aria.expandLegend) and PosterWall's "people groups".
Those are the cheapest defect in the whole catalogue: the work was done, the
reader just never saw it.
CSV and JSON are keyed but identical in all eleven, like DPI in the poster
batch. A format acronym is not an untranslated string; the key exists so a
language that does translate it can, without anyone editing a component.
★ LIBRARY CHANGE — studio's to land under rule 5B. I have NOT rebuilt anything.
Until it lands, every string falls back to the English it reads today.
Measured with the source audit: shared-library strings with no catalogue entry
go 32 → 21, and the has-a-key-but-hardcoded class goes 6 → 2. Remaining, in
order: QaCorner (10, a developer tool), SemanticTreeLegend (4), PosterPreview,
PosterWall and VerticalTabsLayout (2 each).
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.
From exp-shepherd's web-boundaries report, checked against main (afb34488): no file in dx-maps/ is
content-hashed today, so everything takes public, max-age=86400, stale-while-revalidate=604800
with an ETag, and immutable only once names are hashed. The boundary file is 1,866 KB raw / 597 KB
gzipped (measured); the smaller -web copies are not on main yet, so not cited. Script embeds need
ACAO * because every embed.script carries integrity + crossorigin=anonymous. The first-paint
figures are rega's cold loads of the UPG-100 list (9.5-16.5 s) and studio's bundle/engine split.
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.
check:composite-freshness found stale on main: all-people-groups
cities-internet
country-posters
dashboard-plus-grid
demo-map
doxa-research-map
doxa-simple-map
gospel-resources
key-cities
my-upg-100-list
prayer-walk-planner
regions-dashboard
research-dashboard
template-bundle. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
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.
First of the shared-library batch, and the one the shepherd put first because a
reader operates it: PosterDialog had seventeen English strings with no catalogue
entry — the whole dialog. Size, Orientation, Portrait, Landscape, DPI, Format,
both file-type options, Title, Subtitle, Slots, Scale bar, North arrow, the
footer's attribution note, Cancel, and its own heading and aria-label.
Every English string is kept exactly as it reads today, per the shepherd's
ruling, and the eleven locales are filled in. A new poster.json per locale: the
i18n layer assembles catalogues with an eager `import.meta.glob` over
locales/*/*.json, so a new area file is picked up with no index to edit.
Two of the nineteen sites needed no new wording at all — "Close" and "Legend"
already ship in every locale as buttons.close and legend.header.default, so they
reuse those rather than gaining a second spelling. That is the same rule that
sent the grid's "Clear all" to controls.reset_all: two keys for one word is how a
control ends up saying different things in different languages.
DPI is left as DPI in all eleven. It is a standard acronym, not an untranslated
string, and inventing an expansion per language would be the opposite of what
this batch is for.
★ THIS IS A LIBRARY CHANGE, so it is studio's to land under rule 5B — the
catalogue is compiled into every bundle, so every consumer needs rebuilding. I
have deliberately NOT rebuilt anything. Until it lands, every one of these falls
back to the English it reads today, which is the tStatic contract.
Measured before and after with the source audit: the shared-library
no-catalogue-entry count goes 49 → 32, and PosterDialog leaves the list entirely.
ShareButton, DataTable, LegendTools and QaCorner are the remaining batches, in
the shepherd's order.
check:composite-freshness found stale on main: all-people-groups
big-picture
cities-internet
country-posters
dashboard-plus-grid
demo-map
doxa-research-map
doxa-simple-map
gospel-resources
key-cities
my-upg-100-list
people-groups-grid
pplr-data-maps
prayer-walk-planner
regions-dashboard
research-dashboard
template-bundle
upg-neighbor
wall-of-people-groups. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
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.
THE GAP rega NAMED AND I LEFT OPEN: "the gate passed: it counts rows, not the
row's language." It was right. When the list's label was English among Devanagari,
every assertion on that page was green — sheets, rows, bars, share line,
zero-populations. I asserted the page's STRUCTURE and nothing about whether its
prose reached the reader, which is my own standing rule turned on my own gate.
IT ONLY WORKS IN A NON-LATIN-SCRIPT LOCALE, and that is exactly why it is worth
having rather than aspiring to a general "is this translated?" test, which is not
decidable. On a hi/ar/ru/zh sheet a run of Latin letters is a proper name, a
number, or an untranslated string — three possibilities, two of them enumerable.
In de/fr/es an untranslated string looks like ordinary text and this measures
nothing, so those locales are NOT covered and the gate says so rather than
implying coverage it does not have. rega offered to chase the de/fr/es case; I
declined it for that reason, not for cost.
MEASURED FIRST, ASSERTED SECOND. Across every non-Latin packet in out/ (hi, ar,
ru, zh) exactly TWO Latin runs appear, both the data source's name. That is what
makes a floor of "nothing else" defensible instead of a guess.
THE ALLOWLIST IS THREE ENTRIES AND ALL THREE ARE DECISIONS, NOT BOOKMARKS:
IMB, peoplegroups.org — the publisher and its domain. A reader following the
citation meets the English name at the destination, so translating it would
hide the thing it identifies. Same reasoning the i18n allowlist uses for
"Open Bible Stories" and "Scripture Earth".
Project — the campaign designation. Digits break a run, so "Project 4700"
surfaces as "Project" and the numeral is never part of the match; the WORD is a
name, while the description beside it ("frontier people groups") is prose and
must be translated. That is the line check-i18n-never-translated draws around
"UUPGs", applied in the other direction.
A fourth entry must state whether it is settled or deferred: an allowlist that
does not distinguish the two turns an open defect into a verdict.
MUTATION-TESTED AGAINST THE REAL DEFECT, not a synthetic one. Restoring the
pre-5c254970 English list label on the hi sheet reports ['Project', 'frontier
people groups']; replacing a translated key with English reports ['THIS packet'];
the current sheets report clean. So it fails on the bug it was written for.
Verified: all 18 packets in out/ pass sheets, rows==bars, zero-population and
this new script check — 6 non-Latin packets actively checked, 12 correctly out of
scope. The gate also prints the runs it found, so a reader can see what it
measured rather than trusting a silent pass.
TWO FINDINGS FROM rega'S ?list= RENDER, and the first one is a hazard I created
in the same commit where I argued against it.
I MADE PACKET_LIST FAIL CLOSED because "a packet that showed the wrong list is
printed and handed to someone" — and then wrote the frontier packet to the SAME
FILENAME as the default-list packet of that zone and language. rega measured the
result: out/packets/ held frontier packets under the DEFAULT names, beside an `ar`
file that was still the default list, and nothing in the name could tell them
apart. Guarding the input while leaving the output ambiguous protects against
asking for the wrong list, not against being unable to tell what you got.
Fixed both ways. The list is in the FILENAME
(asia-china-tibet-autonomous-region.frontier.hi.html) and STAMPED INTO THE
DOCUMENT as data-packet-list, because a filename can be renamed, copied or
emailed while the packet itself should be able to say what it is. Same reasoning
as data-packet-build: out/ is gitignored, so a sweep grades whatever files happen
to be there, and a packet that cannot describe its own provenance makes an
accidental set indistinguishable from a current one. The gate prints it too.
THE LIST'S LABEL WAS ENGLISH IN EVERY NON-ENGLISH PACKET. "Project 4700: frontier
people groups" sat among Devanagari labels in a Latin serif face, because it came
verbatim from library/lists/lists.json — which carries ONE label per list and has
no locale dimension at all. The gate passed because it counts rows, not the row's
language.
It is translated in packet-strings.json rather than by restructuring the shared
lists.json: that file is the packet's OWN catalogue, where a new language is a new
block and never a code change, and it degrades per key like every other string. An
unknown list still falls back to the data's English label.
THE NUMERIC DESIGNATION STAYS — "Project 4700" / "مشروع 4700" / "4700 计划" —
because it names the campaign, while "frontier people groups" is prose. That is
the same line check-i18n-never-translated draws around "UUPGs": translate what a
bilingual reader would not dispute, keep what is an identity.
rega'S MEASUREMENTS, all confirming predictions I could only compute: 6 pages, 37
of 43 records, rows 12,379 -> 7,341 -> 4,754 -> 2,107 -> 1,745 -> 37, bars
1080/640/415/184/152/26 identical in en and hi, share "2.1% of the 1,745" with no
"1 of 27 lists", provenance-only footnote, fail-closed rc=1 on an unknown list.
And the number I most wanted: share-to-footnote clearance 314px en, 222px hi — so
hi+frontier, the tightest combination that exists (128px rows, gap down to 147px),
still clears by 222px and no label wraps.
Verified: all 18 packets in out/ pass sheets, rows==bars and zero-population.
The 11 laos packets FAILED the zero-population assertion until regenerated — they
predated the fix, which is the gate distinguishing stale artefacts from current
ones exactly as data-packet-build was added to do. Default path unchanged (43
groups, 7 sheets, 5 rows). frontier renders 6/6 in en, ar and hi with the label in
each script. check:packet-strings-parity PASS with the 5 new keys.
A fixed 7 s wait shot the upg-100-list counter over a bare basemap: that leaf paints 9.5-16.5 s after
load (rega, 3 cold loads at fc2fa2e4). Every map a shot's page or frames create is now recorded (an init
script on the engine global's Map.prototype.setStyle, as in rega's probe), and the capture waits until
every visible map has its style and tiles loaded and has drawn features on OUR layers (GeoJSON sources;
the basemap's are vector tiles), capped at 60 s with a WARN naming the unready map. Each shot's `wait`
becomes a 3 s floor, and the full fallback for a surface with no map. Not browser-run here (browsers are
rega's): the next shot run is the proof.
rega measured it at fc2fa2e4 (3 cold loads, tokenless, judged by rendered features on the leaf's own
layers): first paint +9.5 to +14.3 s, idle +12.1 to +16.5 s. The 7 s wait then network-quiet plus
1.5 s made the English shot a coin toss; the Arabic shot of the same leaf (12 s) only won it. Both go
to 20 s. A stopgap: the lasting fix is to wait on the map (style + tiles loaded, rendered features)
rather than a timer.