L56 published the catalogue keys these three sites were waiting for, so the template now asks for
them instead of carrying the English:
the gap list's "related:" label -> gospel.gapList.relatedLabel
the legend's "Show all N" clear button -> gospel.filter.showAll, with the count as a placeholder
the detail panel's "Nothing in X itself" -> gospel.related.nothingInItself
Each was chosen because the swap costs no markup: the <em> and <b> wrap the whole phrase, so they
stay outside the call and the emphasis survives. The sites where a <b> sits INSIDE the sentence --
"Showing only <b>…</b>." above all -- are deliberately not in this commit, because the key is one
whole sentence and using it would silently drop the emphasis that distinguishes the filter's value
from its label. That needs a markup-preserving form, not a quiet visual regression on a production
map.
Measured, not assumed: hardcoded-english for this plugin 147 -> 144, and every one of the 72 keys
the leaf now references resolves in the catalogue. That last check caught a mistake in this very
change -- a fallback I wrote as t('gospel.state.thisVariety'), which does not exist and would have
rendered the key name to a reader. It is back to its literal until a key exists for it.
i18n parity and never-translated both pass; gospel's own render gate passes in ar, zh, ru and hi.
Rebuilt this bundle only, then fix:sri; check:sri is green at 64 leaves.
The new hardcoded-English gate found this in its first run over the production
plugins: an aria-label built by concatenation, so a screen-reader user on a
non-English locale heard "Open the" and "dashboard" in English around a region
name in their own language.
It uses the key the regions list already uses for the same sentence, which is
translated in all eleven locales, so the label now reads as one sentence rather
than two languages spliced together.
Found by the gate in the plugin that wrote it, which is the argument for making
it shared rather than private.
Ten strings that rendered to a reader in English whatever locale they chose.
Found by check:hardcoded-english; keys landed by studio L56.
STATUS OVERLAY (renders in .upg-status): noMapboxToken, loadingBoundaries (one
key, both call sites), fetchingUupgs {n}, mapStatusCounts {polys}{zones}{total}
{pins}. The "⚠ " prefix stays in the template, per the key spec. :2084 right
beside these was ALREADY t('messages.engineNotLoaded') — this makes the
neighbours consistent with it rather than introducing a new pattern.
MAP ERROR: noMapboxTokenDetail. It renders immediately after a translated
<strong>, so English sat literally adjacent to localized text.
LEGEND TABS: bound, not added — tabs.upg100Region / upg100Zone and
legend.tabInfo.* already existed in all 11 with exactly this text. ★ LEGEND_TABS
becomes a COMPUTED: t() is destructured at :391, BELOW the old const, so a plain
array could not call it; and even hoisted it would freeze the labels at the
locale active when the module evaluated. The host switches language at runtime,
so the labels have to re-evaluate on access. Both consumers are in the template,
where a ref auto-unwraps, so the call sites are unchanged.
THE PLURAL, deliberately a LABEL form: territor{{ n === 1 ? 'y' : 'ies' }} ->
t('messages.territoriesSelected', { count }). I had proposed a vue-i18n plural
key; studio was right that it would be worse, and I tested it — with no
pluralRules registered, v9 applies an English-shaped choice (count===1 ? 0 : 1)
to EVERY locale, so ru with 3 forms supplied picks wrong at 1, 2 and 21.
Confidently wrong grammar that LOOKS translated is worse than honest English.
POSTER-WALL headings translate at render; REGION_OF keeps its English values as
grouping identity (studio's split). ⚠ TWO OF THE SEVEN HEADINGS HAVE NO KEY:
'India' and 'Other'. The gate reported five because its header states it cannot
see single-word strings — this is that documented blind spot materialising, not
an oversight in the spec. They fall through to English text (never a raw key)
until poster.region_india / region_other exist; raised with studio.
Bundle rebuilt + fix:sri. The manifest churn is the same old->new hash for this
bundle; doxa-maps-spa's manifest changes only because it references it.
The engine-missing message read the shared messages.engineMissing through this file's
connectivity-prefixed t(), so it always missed and showed English in every locale.
Shared keys are now read qualified with tStatic: engineMissing, engineNotLoaded, and
couldNotLoadNamed for a data table that fails to load (naming the tab in the reader's
language, not 'table not found (404)'). Four unused English tab labels are removed.
studio landed data-control on the map controls (L54 3ed7d8d0c), so the gate can
name a control by untranslatable identity instead of its displayed text. Each
wrapper writes data-control="zoom-in|zoom-out|fullscreen|theme|…" onto
MapControlButton; it is not a declared prop, so it falls through onto the
rendered <button>.
REMOVES A CROSS-OWNERSHIP DEPENDENCY. Check (4) resolved its expected strings out
of library/i18n/locales/en/buttons.json — locale-correct, but it made a check in
MY gate depend on a key name in STUDIO's catalogue, so a rename there would have
failed a test here. data-control removes that coupling entirely.
THE THEME BUTTON GAINS THE MOST, and it had two more sites I had not fixed:
:488 (check 5's toggle) and :1502 (17b cold-load) both matched
[title^="Switch to"]. That title is LOCALIZED and DYNAMIC ("Switch to dark" /
"Switch to light"), so no i18n key ever resolved it and a prefix match on English
prose was the only option. All three sites now use data-control="theme", and the
`theme === 1` assertion finally means what it says.
Zero English-literal attribute selectors remain in the gate; every control query
is now a class or data-control. The one surviving `[title="…"]` string is inside
a comment recording why the pattern was abandoned.
fc93847bb ("Batch selection builds a packet, instead of recolouring regions")
replaced copy-to-clipboard with packet-building and left four pieces behind: the
button's handler, its execCommand fallback, the window global they wrote, and the
gate check that read it. This removes all four.
copySelection had exactly ONE reference in the file — its own definition — and
legacyCopy was called only from it, so both were unreachable. The minifier was
already dropping them: __UPG_LAST_CLIPBOARD__, which copySelection set
unconditionally, appears 0 times in the built bundle.
THE GATE HALF MATTERED MORE. visual-gate check (15) asserted the Copy payload,
guarding on .upg-hud-btn.copy existing — a button that no longer exists. But the
guard skipped only the MEASUREMENT: copyOk was initialised false and check() ran
unconditionally, so it reported FAIL ("no .upg-hud-btn.copy in HUD") on every run
since the button went. A permanently red assertion for a deliberately deleted
feature teaches readers to ignore red, which is worse than no check at all.
formatClipboard SURVIVES — 3 references, and one is live: __UPG_SELECT__
.clipboardText, the headless probe surface the gate still uses. Deleting the
"clipboard code" wholesale would have broken it.
Also corrects the D-8 comment, which still named "Copy + Clear" as the HUD
buttons; they are Prayer List / Poster Map / Clear.
VERIFIED BY THE BUILD NOT MOVING: rebuilt after deleting 43 lines of source and
the bundle is byte-identical to its parent (sha 3dda630d5cf5e647, 992356 bytes).
That is the test of the claim — if the output had changed, the code was not dead.
Two stale claims in one comment. It said `defineAsyncComponent` while the code
below it calls `retryableAsyncComponent` (:56), and it pointed at
research-map.vue:152 for the pattern — which is now a tStatic import; the real
lazy-load moved to :193.
Not cosmetic. Auditing card BUG-LAZY-PLUGIN-DEATH-ON-A-SINGLE-FAILED-IMPORT, the
census of components bypassing the retry wrapper is a grep for raw
`defineAsyncComponent`, and this comment made my own file read as a bypass. The
one real bypass (pplr-data-maps.vue:46, six lazy views) is exp's and routed. A
comment that names the wrong API costs the next reader the same detour.
Says why the wrapper is used rather than only naming it: Vue caches a REJECTED
loader promise, so one dropped chunk request kills the panel for the life of the
page. That is the defect the card exists for, and the reason not to "simplify"
this back to the raw API.
Comment-only, so the build is byte-identical — the strip-shadow-css-comments
pass drops it. Rebuilt and re-ran fix:sri anyway to prove it: 124 hashes already
agreed, 0 rewritten, the committed .js unchanged.
Five bound English strings no locale could reach, now catalogue keys in all eleven locales:
the column sort tooltip (dashboard.sort_by_column_hint, {column}), the full-screen button's
title and label (fs_*), the export tooltip (export_*_hint, a label form with {n} so no plural
rule is needed), and the donut's accessible name (aria.donutChart, {title}), which read a
translated title followed by the English word "donut". English stays the fallback argument.
Accessibility audit of my production surfaces, headless from source, scan negative-tested before I
trusted its zeros. Four hits, two real.
· THE PROJECTION PILL (DashboardMapPanel) carries role="button" and tabindex="0" and had no key
handler. A keyboard reader tabs to it, hears "button", presses Enter, and nothing happens — the
role and the tabindex promise an activation that was never wired (WCAG 2.1.1). It is a span rather
than a button because the pill sits inside the map chrome, so the promise has to be kept by hand.
I touched this element earlier today to give it a translated accessible name and did not notice it
could not be operated; naming a control and making it usable are separate jobs.
· THE SORTABLE COLUMN HEADERS (DashboardDataTable) carry role="columnheader" and aria-sort and were
click-only. Screen-reader users were told the sort state of a control they could not reach. Now
focusable with Enter and Space, and shift+Enter still adds a second column, because onSort reads
shiftKey off the event it is handed and a KeyboardEvent carries it the same way.
.prevent on space so the page does not scroll under the press.
TWO HITS WERE FALSE POSITIVES and are recorded so the next sweep does not re-raise them: the facet
rail's .fr-body and the chip bar's .rd-chips both take @click on a container, which looks like a
click-handler on a non-focusable div. They are DELEGATION: the facet rows carry their own
:tabindex/:role and the container has @keydown for parity, and every chip is a real <button> with an
aria-label. The control is the row and the chip, not the box around them.
Verified: check:sfc-parse PASS, and the scan reports 0 on both files afterwards. Not landed — the quiet
window is on; this waits for studio.
Found by cross-reading exp-shepherd's country-posters port, not by anything failing here. Their
73438b43b routes all eighteen of that map's region clears through a regionIdle() wrap. Mine still wrote
NINE raw constants — fill-opacity 0.20 and 0.7, line-color rgba(60,60,80,0.35), line-width 0.6 — across
clearCountryHighlight, the deselect path and the dim path.
A raw value makes the property CONSTANT again. So the first time a reader cleared a highlight, or ran a
country search and dismissed it, the three region paints stopped being data-driven and the next
highlight recompiled the shader variant that b5894ec3 exists to have compiled at layer-add. rega
measured 0 ms on the row click and that measurement was honest — it was taken on a fresh tab, where no
clear had run yet. The path nobody exercised is the one that pays.
Every region paint write now goes through regionIdle(v) — ['case', ['has','iso_3166_1_alpha_3'], v, v],
both branches the value it already had, so nothing looks different and the property stays data-driven.
Nine sites, no behaviour change, and the pre-warm now survives a clear.
★ DEFINED ABOVE ITS FIRST USE, deliberately. The first caller sits four hundred lines above where the
other idle constants live, and a const read before its declaration line has executed is the TDZ this
file has already shipped twice this week (_lastFitKey, then _lastPinOpacityKey). Its callers only run
after mount, so it would probably have been fine — "probably fine" is what the gate exists to stop me
saying. check:setup-order PASS.
Verified: check:sfc-parse and check:setup-order PASS. Needs eyes: rega — clear a region highlight, then
click another region row, and the compile should still be 0 ms. That is the case no run has covered.
Regions: the country and region counts under the dashboard title were English
template literals; they are now label keys ('Countries: %count%') in all eleven
locales, which also sidesteps plural forms.
Gospel: the Zume chip joined 'in' + a language name, which reads backwards in
Hindi; it is now one phrase key with a {language} placeholder.
UPG-100: the Batch Select button title comes from legend.title.batchSelect.
The six lazy views were built with raw defineAsyncComponent, which caches a rejected
loader, so one network blip left that tab dead until a full reload. They now use the
library's retryableAsyncComponent, which retries the import.
The load-time pre-warm made the regions fill and border paints data-driven, but every
clear (clearCountryHighlight, clearAllHighlights and the reset branches) wrote the
plain constants back. All 18 writes now use regionIdle(v), the same identical-branch
case the pre-warm uses, so the property never returns to its constant variant and a
second highlight cannot re-pay a variant switch. Nothing changes on screen.
The (G-rail) probe queried `.upg-rail-batch[title="Batch Select"]`. Once the
title becomes a bound `:title="BATCH_SELECT_TITLE"` (studio L53) that selector
matches under `en` and nowhere else, and the gate reports "button not in the
toolbar" — a PRODUCT failure for what is really a broken query. The class is
already proven unique by the railBtns === 1 assertion in the same probe, so it
is the identity; the title is still checked, as the property that actually
matters for a11y: NON-EMPTY, which holds in every locale. That also catches a
regression the equality missed — a :title binding resolving to '' or undefined.
The (4) map-controls check had the same coupling, three times. Those titles are
ALREADY bound (ZoomInButton.vue: :title="t('buttons.zoomIn')"), but the page is
pinned to en-US at newPage({ locale }) — L4/LESSON-03 — so they are not failing
today. The expected strings now come from the same i18n source the component
reads (library/i18n/locales/en/buttons.json) rather than being hardcoded, so the
gate survives being run in another locale. Verified the three keys resolve to
the literals they replace, so this asserts exactly what it asserted before.
The theme button cannot use that shim: its title is DYNAMIC ("Switch to dark" /
"Switch to light"), so there is no single key to resolve, and it stays a prefix
match on English prose. Its assertion moves from `> 0` to `=== 1` (studio's
call): that does not remove the coupling, but it stops 0 and 2 both collapsing
into "not > 0", and the failure line already prints the count.
Those four .mcb buttons share one class with no stable per-control hook, and
aria-label mirrors the localized title, so the title is the only identity
available to a query. The durable fix is data-control="zoom-in|zoom-out|
fullscreen|theme|location" on library/components/map-controls/MapControlButton
— studio's under 5B. They are riding it on the next landing that rebuilds the
17 consumers anyway rather than spending a rebuild on it alone; this shim comes
out when that lands.
A region legend row painted the country polygons twice: from the region dataset (every
member country, via selectRegion -> applyDimFilter) and from the legend's own list,
which holds only countries WITH PINS, so whichever ran last left holes. region: rows
now leave the polygons to the dataset; block: and country: rows still paint from the
legend. The 18 raw pin-opacity writes go through one read-before-write setPinOpacity,
so a family click no longer rewrites the same value 4+ times, and the undimmed value is
data-driven from load, so the first dim does not stop for a shader compile.
Auditing Big Picture for goal (d) I read this as a live defect: big-picture threads the host's locale into
each child's profile-config as `lang`, while the grid's prop is `languageCode`. On a profile-config embed
that would leave a composed grid in the wrong language — exactly the failure 8b40f6c58 was written to fix.
It is not a defect. people-groups-grid.vue accepts three spellings — languageCode || locale || lang — and
`lang` is the third. Two greps settled it, and the near-miss is the point: nothing at either end said so.
Both ends now do. The writer records that the spelling is load-bearing and which fallback it lands on; the
reader records that `lang` has a caller and why that caller cannot reach it any other way — getRuntimeLocale
is module state, and a separately-built child bundle gets its own copy, so threading the config is the only
channel big-picture has.
★ WHY A COMMENT AND NOT A GATE. The failure is invisible in the obvious place: ?lang= embeds keep working,
because the grid falls through to localeFromUrl(). Only a host passing profile-config sees it, which is the
smaller and quieter half of the traffic. A gate for that would have to stand up a composed embed with a
profile-config locale and read rendered text — which is rega's browser run, not a headless check — so the
honest guard is to make the coupling legible to whoever next tidies either list.
No behaviour change. Verified: check:sfc-parse PASS.
Found auditing my own map for goal (d), in code I added this morning. The cover note is the one string on
that document a reader in Arabic or Hindi cannot read, on a document whose entire purpose is that they
can pray from it in their own language.
The slash needs no translation and is not a guess: zh's professional translation of legend.progress.short
renders exactly this relationship as "{covered} / {total}". The WORD is available per locale in that same
key — ar "من", ru "из", fr "sur" — but it carries "· {pct}%" with it, so reusing it would mean either a
wrong suffix on the cover or slicing a translated string on a separator I cannot guarantee survives
translation. Ten invented translations for one connector is the worse trade.
Wrapped in U+2066/U+2069 isolates, which is the convention the Arabic catalogue already uses five times
for numeric ranges ("30–59%"). Without them the pair reorders against the Arabic label beside it and a
reader sees "214 / 50" — the numbers held but the meaning inverted. NUMERALS ARE HELD, so the digits
themselves are untouched.
CENSUS BEHIND IT, on current main, reusing the existing gates rather than adding one: check:i18n-parity,
check:i18n-never-translated and check:i18n-keys-exist all PASS; and of the 766 catalogue strings the three
Simple Research bundles ship across 21 areas, ZERO fall back to English in any of the ten locales. The
English still visible to a non-Latin reader is five or six items and every one was already classified:
two external resource names (Open Bible Stories, Scripture Earth), one pure format string, two acronyms
(DPI, MST), and UUPGs, which is the Driver's deferred ruling. This cover note was the only NEW leak, and
it was mine.
Verified: check:sfc-parse and check:i18n-keys-exist PASS. Needs eyes: rega — the packet cover at ?lang=ar.
After a language was picked, its believer (green) and unreached (red) dots were
anonymous until clicked, and a green dot could not be clicked at all. Hovering either
now shows the group and its country, as Mobilize does.
.upg-hud-band used margin-left:auto to push itself to the trailing end of the
flex row .upg-hud-head. That end is the LEFT in Arabic, so the band sat on the
wrong side. margin-inline-start:auto, the same class as the legend and table
conversions. No consumer overrides .upg-hud-band, so nothing can defeat it.
Found by a localization audit of this bundle (upg-100-mfe + poster-wall +
upg-100-list). The rest of the audit came back clean, and both scans were
NEGATIVE-TESTED before their zeros were believed:
- hardcoded English in templates: a planted sentence IS found, so the zeros in
poster-wall.vue and upg-100-mfe.vue mean something. The only template hits in
upg-100-list.vue were `{{ LEGEND_TITLE }}` (a translated computed, a false
positive of the regex) and one real one, below.
- script-side user-visible English: none.
- key coverage: 23 keys used, 0 missing across all 11 locales. A planted
nonexistent key reports missing in 11/11 and a real key resolves in 11/11, so
the checker is neither blind nor vacuous.
ONE FINDING IS NOT MINE TO FIX: upg-100-list.vue:2786 has a literal
title="Batch Select" where its siblings at :2811 and :2833 use a translated
:title. The fix needs a new key, and the catalogues live in library/i18n/locales/
— the build owner's under rule 5B. Sent to dx-shepherd rather than landed here.
`tiles` built four stat objects that nothing rendered. The commit which cloned
DX-life's region dashboard replaced its template block with `stats` and the
shared StatCard, and removed the .rd-tile styles with it, but left the computed
behind.
It was not merely unused: it carried four bare English labels, so a localisation
scan reports it as hardcoded English every time. It cost this audit the time to
prove it harmless, and would have cost the next one the same. `stats` renders
the same four figures and puts every label through t().
Verified superseded rather than unfinished before deleting: the template usage
and the computed were added by one commit and the usage removed by the next, the
styles are already gone, and nothing in the plugin refers to it. Behaviour is
unchanged.
Clicking a pin to open the people-group detail panel did nothing under MapLibre,
with no error of any kind.
openPgDetail read `src._data.features`. The two engines store _data differently,
and both bundles say so in the same constructor (this._options = Object.assign
follows in each):
mapbox-gl v3.24.0 this._data = i.data the raw value
maplibre-gl this._data = "string"==typeof i.data
? {url: i.data} : {geojson: i.data}
So `._data.features` resolves under Mapbox and is UNDEFINED under MapLibre.
Every guard in the chain then turned the wrong shape into a no-op: `|| []` made
it an empty list, find() returned undefined, `if (f)` was false. Silent, and
indistinguishable from a missed click -- the same defect class as sr's landed
MapLibre source-read bug.
Now calls readSourceFeatures() from library/maps/pin-selection.js, which falls
back to the public serialize() that both engines expose. One import, one call, no
new API.
The mapbox citation was taken from the exact CDN bundle the app loads
(library/constants/cdn.js pins v3.24.0), fetched and verified against its pinned
SRI hash before reading, then deleted. No browser involved -- downloading and
grepping a bundle never starts one.
Verified in the BUILT output rather than by function name, which minification
renames: the helper's distinctive public fallback `serialize` appears once in the
rebuilt bundle and zero times in main's.
A regions audit across 60 keys and 10 locales found the catalogue itself sound
-- every key present everywhere, and the few strings identical to English are
cognates their sibling languages spell differently. The gaps were in the plugin,
in the places a value is absent and a fallback renders instead.
Eight `|| 'Other'` fallbacks rendered the English word in every locale, though
'Other' is translated in all eleven and the regions list already called t() for
exactly this. A country subtitle concatenated ' people groups ' as a literal.
Both now go through t().
The map popup aligned its stat values with text-align:right, which does not flip
for Arabic, so the values sat on the wrong side of a right-to-left popup. It
uses the logical end instead, as nine other declarations in these files already
do.
Two strings are left deliberately: the region and all-regions subtitles build
"N countries" and "N regions" from template literals, and no key exists for
either in any locale. Word order and plural agreement differ per language, so
inventing a construction across eleven of them would be worse than the English
it replaced. Those need catalogue keys and are reported rather than guessed.
Eight CSS declarations in the production profile still named a physical side, so in Arabic they
pinned translated text to the left: the legend key's wrapped label, the search-match rows, the
people-group buttons in the gap list and in the detail panel, the error line, two chip margins,
and the row-count badge beside each table tab. Every one of them now uses the logical property —
text-align:start, margin-inline-start — which is identical to today in every left-to-right locale
and correct in the two right-to-left ones.
Found by auditing the production profile's localization rather than by a rendering failure, which
is how this kind of defect stays in: the Arabic page looks translated, because it is, and only the
alignment quietly disagrees with it.
Nothing in the English-prose islands changed. Those carry dir="ltr" on purpose — the caveats stay
English by the standing ruling, and the isolation is what keeps them from disturbing the Arabic
text around them.
Rebuilt gospel-resources only, then fix:sri; check:sri passes at 64/64 leaves.
The Driver reported the pins as hard to see. On the chooser this is the state
that answers it: what the page shows on load, before any hover.
Two things make a pin harder to read here than on a cloned country page, and
neither is a matter of taste. The chooser renders the globe, so every pin sits
at the lowest useful zoom instead of a framed close-up; and the 1px white stroke
is a fixed cost, so at radius 3 it consumes a third of the pin's width and the
coloured core carrying the region's identity is only about 2px. Raising the
resting radius from 3 to 4 at zoom 2 grows that core from 2px to 3px -- a ~50%
gain in the part a reader actually distinguishes, for ~33% more footprint.
Opacity goes to 1 for the same reason: 10% transparency over a basemap costs
contrast and buys nothing while nothing is being de-emphasised.
Only the chooser changes. The cloned country and region pages keep DX-life's
exact geometry, and the hover behaviour is untouched -- dimming the other
regions is the design saying "these pins are one group", which is a taste call
rather than a legibility defect.
Whether this is now easy enough to see needs eyes; the argument here is only
that the readable part grows faster than the footprint.
The rail showed tiles nobody had named while this gate stayed green. The obvious
fix is a no-op, and writing it first is how that came out.
THE NO-OP: testing the rail's label for null/empty can never fail, because
rowsFromManifest falls back to humanize(id) (plugin-studio.vue:421,429). A
profile that declares nothing still arrives as "Doxa Simple Map" -- not empty,
and not equal to the bare id either, so the existing bare-id rule misses it too.
humanize() defeats BOTH rules. That is why country-posters could ship a tile
nobody had named.
So the property is the DECLARATION, read from the manifest, not the rendered
label: does this profile's <profile>.meta.json carry a "label"? A humanized id is
a reasonable DEFAULT and a bad NAME -- "Doxa Simple Map" tells a reader nothing
the folder did not already say.
PRODUCTION-STAGE ONLY, deliberately: 15 profiles declare no label today and 14
are experimental -- work in progress nobody has named yet, which is not a defect.
A gate that is red for a condition the repo accepts gets ignored.
Also declares the two labels my own plugin was missing: upg-100-list becomes
"UPG-100 — Zoned Map" and poster-wall "UPG-100 — Poster Wall".
⚠ THIS GATE IS RED ON MAIN TODAY, 8 violations across 4 plugins and 4 owners
(cities-internet 2, doxa-simple-map 1, gospel-resources 3, my-upg-100-list 2).
It must NOT land before those labels do, or it blocks every other agent's
landing for a defect that is not theirs. The two meta files here fix my share;
they reach the manifest only through the full build, which is the build owner's.
internal/ is the build owner's under rule 5B -- source sha to studio, sequenced
after the other three owners declare their labels.
Only the browser seat runs browsers, and only inside internal/browser-slot.sh.
That rule was remembered, not enforced: an agent ran a gate it took for a
headless source check and it launched Chromium outside the slot.
internal/lib/playwright-guarded.mjs is now the one way a repo file reaches
playwright: its launch(), launchPersistentContext(), launchServer(), connect()
and connectOverCDP() exit 75 (NOT MEASURED) with a message naming
browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it
for the command it runs, so a bash -c grandchild inherits it. All 72 files that
imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates;
plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2,
research-map-plus-grid 2, research-dashboard 1): a mechanical import swap.
check:browser-gates-guarded (gates:pre) fails a direct import;
bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate
moves to internal/lib/browser-gates.mjs so the runner and the check share it.
The legend and search column were placed with left:10px and styled only under Mapbox's
.mapboxgl-ctrl-top-left, so on the keyless engine they kept MapLibre's defaults and in
Arabic stayed on the left. Both engines' classes now take inset-inline-start (the same
fix country-posters carries), and the help close button and the detail labels use
logical sides.
charts, data-tables and resources-list had no <profile>.meta.json at all, so the manifest carried
no label for them and the Studio rail humanized the folder name: "Charts", "Data Tables",
"Resources List". Those say what the file is called, not what the page answers.
Each now has a meta with a label and its stage:
charts Gospel Resources -- where the gap is, by language
data-tables Gospel Resources -- every resource and every gap, in tables
resources-list Gospel Resources -- what each people group has
All three declare "stage": "experimental" EXPLICITLY, which is what they already were. A meta-less
profile and a meta without a stage key both default to experimental in profile-meta.mjs, so the
value would otherwise rest on that default -- and these three sit in a bundle whose stage reads
production through gospel-dashboard, so a later change to the default would silently promote them.
Measured after the change: the three stay experimental, gospel-dashboard stays production, and
bundleStage still returns production.
Type is deliberately not declared. generate-tree resolves the composition tier from meta.json
`type`, not from whether the file exists, so omitting it leaves all three inferred `single`
exactly as before. Nothing in the manifest moves except the three labels and three stage values
that already held.
connectivity-list and data-tables had no profile meta at all, so the Studio and
the gallery fell back to a humanised profile id: a reader picking a view saw
"connectivity list" and "data tables" rather than what the pane actually is.
The sibling connectivity-map already carried a label; these two are now in step,
so the three read as one family in the picker instead of sorting apart.
The wording is not invented. Each pane renders its own heading from i18n —
connectivity.list.title is "Cities with Internet" and connectivity.tables.title
is "Cities Data" — so the labels lead with the product name the reader already
sees, then name the view, matching the shape connectivity-map established.
Both metas declare stage experimental explicitly. That is the existing default
for a profile with no meta, so nothing is promoted and the manifest is byte-for
-byte unchanged; it is stated because bundleStage turns a whole bundle
production as soon as one profile claims it, and a stage left implicit is one
default change away from moving on its own.
Source only, no bundle rebuild: the label is read from the meta by the gallery's
discovery, and never travels into manifest.json.
countries-map, country-poster-wall and doxa-simple-map carried label:null, so the Studio
rail and the gallery showed their ids. Each now has a label in its meta; types are
unchanged (doxa-simple-map stays parameterized).
An array is not the same as data-driven: a zoom-only interpolate is an array and
still compiles a variant on the first gesture. The warm-up now leaves alone only values
that read a feature property, and wraps constants and zoom-only expressions.
Choosing "Asia" in the legend printed the same first 50 groups as any other row,
Sudan included. The packet now takes the pins the selection shows, through the shared
library/maps/pin-selection.js (reading pins as drawn, not raw rows). Selected pins
that match no rows fall back to the full list with a warning; an empty selection
reaches the printer's own error; over 50, the cover says '50 of N'.
highlightCountryByIso makes the regions fill and border paints data-driven on a click,
and the first such click paid a one-off shader-variant compile (rega measured 5.5 s /
11.1 s on the research map). The three paints are now data-driven from the moment the
layer is added, with identical branches so nothing changes on screen.
exp-shepherd's copy of this pre-warm is better than mine in one way and accidentally right in another,
and both are worth taking.
BETTER: it leaves an existing expression alone instead of wrapping it. Mine wrapped regions-border's
line-color — which arrives as a region-coloured match on a feature property — into a case around that
match. No visual change, but a gratuitous rewrite of an expression that was already data-driven and had
no variant left to warm.
ACCIDENTALLY RIGHT: their test is "is it an array". For this layer that coincides with the right answer,
because the only array here is that match. It is not the right test in general — a zoom expression like
['interpolate',['linear'],['zoom'],…] is also an array and is NOT data-driven, so skipping it would
leave that property to compile its variant on the first gesture. That is the partial pre-warm this whole
exercise exists to avoid, and it would measure as a failure with no visible cause.
The test is now whether the expression reads the FEATURE — get, has, or feature-state, at any depth.
Constants and zoom-only expressions get wrapped; anything already keyed on a feature is left exactly as
the layer wrote it. The regions layers have no zoom expression today, so this changes nothing that runs
right now; it means the rule is correct for the reason it is correct.
Verified: gates:pre 13/13. Chained on 790d8838.
Found by reading addRegionsLayer while checking exp-shepherd's copy of this fix, not by anything that
failed. My pre-warm called paintRegionScope(map.value, []) — the CLEARED look: flat grey borders at
width 0.6. The layer does not start there. useMapLayers adds regions-border with line-color set to the
REGION-COLOURED match expression and line-width 1, so the moment the Regions tab was first opened, every
border turned grey and thinner than the layer had defined.
Nothing was going to catch it. The shader compile moved as intended, the write count was unaffected, the
highlight still worked, and the checks in flight were a timing measurement and a write count — a change
of appearance is invisible to both. It would have been found by a person looking at the map, eventually,
and blamed on something else.
prewarmRegionPaint reads each property back and re-writes it wrapped in a case whose two branches are
the value it already had. The appearance is preserved exactly as the layer defined it, and the property
still becomes data-driven, which is all the shader variant depends on. A property the layer does not
define is skipped rather than invented, and one already wrapped is left alone so a second call cannot
nest cases.
paintRegionScope keeps its idle values for the CLEAR path, which is a different thing from the initial
state and always was — conflating the two is what caused this.
Verified: gates:pre 13/13. Chained on 196eca13. Needs eyes: rega — on the first visit to the Regions
tab, the country borders should be REGION-COLOURED at their original width, not grey.
Two findings from upg100's census. check-one-palette read strategy palettes from
library/colors/ only, so a bundle's own src/colors/ strategy (which overrides the
library one for that bundle) was never checked; it now reads both, the bundle's
over the library's, and --self-test plants a private palette. Rudder's
engine-offline notice gave a go build line without saying Go 1.22+ is needed or
that setup-runtime.sh downloads the model; it now says both and points at
bun run desktop:build and the desktop HOWTO.
DEDUP-AUDIT.md (measured today: 253 files, 31 cross-plugin duplicate clusters,
622 duplicated lines) ranks this tenth by raw cost and first by suitability. It
is the only cluster where the copies are identical in code, close over nothing,
and sit in two plugins with one owner between them.
loadScriptOnce (13 lines) and whenTagDefined (11) were byte-identical between
doxa-maps-spa and plugin-studio. deriveTreeBase (9) differed by ONE COMMENT LINE
and not one character of code -- drift in the documentation of a shared function,
which is its own argument for having one copy.
They are one concern, not three: load a bundle's script once, wait for its custom
element to register, and work out the tree base to load it from. That is what a
capability-5 app shell does to nest another plugin, and both plugins here ARE the
capability-5 shells. Any future shell needs the same three.
Verbatim move: no parameterisation, no behaviour change, callers import instead
of declaring. The comments travel with the code because each records a
measurement -- particularly the one explaining why onload is not a usable success
signal for a dev-server ES module.
ONE BEHAVIOUR CHANGE, AND IT IS AN IMPROVEMENT: the _loaded cache is now shared.
Two shells on one page previously kept separate caches and each loaded the same
bundle; the second now reuses the first's promise.
deriveTreeBase reads document.currentScript, so it is only valid AT MODULE
EVALUATION TIME. The call sites did not move -- both entries still call it at
module top level -- and the library file documents the constraint, because a
later call silently returns '' and every nested bundle URL would break.
Verified: both bundles build; each built file contains all three functions
exactly once and still derives currentScript; check:sri, check:series-isolation,
check:studio-rail and check:capability-derived all PASS.
library/utils/embedScript.js is the build owner's under rule 5B. The
plugin-designs and public/ changes are in the same commit because they import it
-- landing them apart breaks the build in between.
Canonicalising the ISO list made the two region painters COMPARABLE. Reading the two sources shows it
cannot make them EQUAL, so the write count would have landed at six rather than three — and the reason
is a real defect underneath, not a tuning problem.
The two sets are different by construction:
· node.isoCodes comes from useWagfRegionsLegendData, which builds its regions by walking the
PEOPLE-GROUP LIST, so it holds only countries that have pins in the current data.
· mapLayers.regionIsoMap comes from regionsData.isoToRegion — every country assigned to the region.
So a region highlight painted from the legend has HOLES wherever a country in that region has no
unreached group in the list, and which of the two a reader saw depended on which writer ran last. The
feature is "highlight the countries belonging to the selected WAGF region", so the regions dataset is
the right answer and the legend's pin-derived list is not.
region: rows therefore stop painting here and defer to the canonical path they already trigger
(selectRegion -> the store watcher -> applyDimFilter). block: and country: rows keep painting from
node.isoCodes, because nothing else paints them — only region: reaches selectRegion — and for those two
the legend's own list IS the scope being asked for.
★ THIS IS A PREDICTION, and it is falsifiable: region clicks should now be 3 writes, not 6. If rega
counts 6, the two sources disagree for some other reason and my reading of them is wrong. If they count
3 but the highlight covers FEWER countries than before, then the pin-derived set was the intended one
and this commit is the thing to revert — the previous behaviour was last-writer-wins, so nobody has
decided which is correct until now.
Verified: gates:pre 13/13. Chained on 1be024ac.
rega's L43 check: three of four pass — the row-click shader compile is 0 ms on both engines (was
3.8 s / 7.5 s), the tab visit is no worse, the highlight stays. The collapse did not: still nine
setPaintProperty calls per row click instead of three.
Their hypothesis is right, and I had the evidence before I wrote the code. The two callers build the
same region from different lists — node.isoCodes and mapLayers.regionIsoMap[slug] — and they do not
agree on ORDER. rega said so in the very first stack trace on this bug: "a case over ['IND',…]" from one
path, "a case over ['MYS',…], a different literal order" from the other. I then wrote a JSON-string
compare, which cannot match two orderings of one set. The pre-warm half worked because that expression
is a shared constant; this half compared text that was never going to be equal.
The list is now canonical before both the compare and the write — de-duplicated, upper-cased, sorted —
so the expression is a function of the SET, which is the thing the two callers actually agree on. Order
is irrelevant to `in` membership, and the case normalisation is a no-op on today's data (both lists come
from the same regions dataset, and the highlight matches now, so they are already upper-case); it is
there so a future source that ships lower-case cannot silently select nothing.
★ IT IS ALSO A TEST, and worth reading as one. If the count drops to 3, the two sources agree about
which countries are in a region. If it does not, they disagree about the CONTENT — a data defect this
compare would otherwise have hidden behind a last-writer-wins repaint, and the next thing to chase.
Verified: gates:pre 13/13. Chained on f7a2f001 (same file). Needs rega: re-count with the same probe.
The claim was wrong when I wrote it and I only checked the built artifact afterwards. jspdf arrives
through useMapPoster.toPdf, which this map still uses for the poster, so the packet path was never the
thing holding it. The bundle in fact GREW about 131 KB (983,264 -> 1,117,687 on L42) when the shared
HTML renderer came in: each bundle is a single .js with no code splitting, so the dynamic imports in
this function are inlined at build time rather than lazy, which also makes the older "keep it off the
critical boot path" note beside them untrue.
Correcting it in the source rather than only in the ledger because the sentence was copied VERBATIM into
country-posters when that map moved to the same renderer — a wrong comment written confidently travels,
and the next person to plan a dependency removal would have started from it.
The change itself stands: the packet prints instead of downloading, and the duplicate implementation is
gone. It cost bytes rather than saving them, and now the file says so.
Chained on 2cf369be because it edits the same function. gates:pre 13/13.
A plugin's <profile>.meta.json targets (web, mobile, desktop, widget; absent = web)
now decide where it is built. internal/plugin-targets.mjs is the one reading.
desktop:build builds a desktop plugin into .desktop/<plugin>/ with a page that
names its engine, builds its backend as a Tauri sidecar for this machine, and runs
tauri build with a --config override; tauri.conf.json and public/ are untouched.
The shell starts the bundled rudder-engine (RUDDER_AUDIO=browser, RUDDER_HOME in the
app data folder) and stops it on exit. A plugin whose targets leave out web
(Rudder: desktop + widget) is no longer built for the web tree: run-parallel,
generate-tree and check-composite-freshness skip it, and its public/ folder is
removed; the dev server still gives the Studio its corner. check:targets
(gates:pre, with a self-test of five planted violations) validates the values,
widget corners and backend folders and fails if a non-web plugin reaches the built
tree. Measured: desktop:build rudder-voice on Linux produced Rudder_0.1.0_amd64.deb
(8.8 MB) with the app and the engine side by side; the app has not been launched.
PACKETSEL, found by exp-shepherd: the prayer packet printed the first 50 of EVERY people group whatever
was selected, so a reader who had narrowed the map to one language family got a packet about somebody
else. I carried that behaviour through verbatim when I moved the packet to the shared printer — it was
already wrong and I preserved it, which is worth saying plainly.
The rows now come from the pins currently SELECTED: the layer's own filter decides which pins render,
`_pinMatchExpr` decides which of those are lit (the affinity tab dims rather than filters), and both
must match — a pin at opacity 0 is not what the reader is looking at.
★ ASKED OF THE MAP'S FEATURES, NOT OF THE ROWS, and this is the part that had to be got right rather
than guessed. Pin properties are DERIVED from rows: useMapLayers fills languageFamily in when the API
returns null for all 2,069 groups, and canonicalizes doxaRegion onto the palette's key forms precisely
so setFilter matches. So evaluating a pin filter against a raw data row is wrong in a way that NEVER
THROWS — it just selects a different, plausible-looking subset. The features the map holds are the only
place the two vocabularies agree.
Answering that question already existed, privately, inside useMapFly: zoomToLegendRow has to solve it to
fit the camera to a legend row, including the reason it cannot use querySourceFeatures (viewport-clipped,
so an off-screen region silently selects nothing). Those three functions move VERBATIM to
library/maps/pin-selection.js and useMapFly imports them, so there is one evaluator rather than two —
and country-posters, which prints the same kind of packet from the same kind of selection, has the same
helper to reach for instead of a second copy.
Two failure modes are distinguished rather than collapsed: pins selected but NO row matching them is an
id mismatch, so it prints the full list and warns; a genuinely empty selection still reaches the shared
printer's own "that list resolved to no records", which is honest. And when the 50-row safety cap bites,
the cover now says "— 50 of 214" instead of quietly being a different packet than the one asked for.
Verified: gates:pre 13/13, check:composite-interop PASS. Needs eyes: rega — select one language family,
print, and check the cards are that family and the cover carries the count.
country-posters-mfe's @element said country-posters-mfe, a tag nothing defines; the
bundle registers country-posters-map, which the embed snippet and leaf page already
use with profile country-posters-mfe. Anything building the tag from the manifest
(check:composite-embed-knobs) mounted an element that never upgraded, so its map
cells could not be measured.
The map's packet built a jsPDF file and downloaded packet.pdf while its poster already
printed. It now goes through usePosterPrints.printPacket, the same path as Simple
Research (8457b5dc): the live map rides in as an image data URL, and an explicit
adapter maps the rows, reading religionName and countryName so a card never prints a
religion code ("MSN") under a people group's name.
Both halves are rega's measurements on main 373b7979b, now that the WAGF selection holds and it is worth
optimising rather than optimising a bug.
NINE WRITES PER CLICK -> THREE. A single "Asia (Region)" click produced nine regions-* paint writes:
fill-opacity, line-color and line-width, three times each inside ~110 ms. Two paths painted the same
highlight from DIFFERENT sources — onSemanticTreeSelect from node.isoCodes, applyDimFilter from
mapLayers.regionIsoMap — each triggering the other through mapStore. Two sources for one visual is not
just wasted work: it is how they drift, and it already cost a debugging cycle, because when the highlight
was being cleared we first had to establish WHICH writer had done it. All four paths (click, watcher,
clear, and the layer-add below) now go through paintRegionScope, which writes a property only when the
value actually differs — asked of the MAP rather than a remembered key, so a layer torn down and re-added
cannot leave a skip holding a paint that is no longer there.
5.5 s AND 11.1 s OF SHADER COMPILE, MOVED OFF THE GESTURE. rega measured the row click compiling a new
variant — 5.5 s MapLibre, 11.1 s Mapbox on SwiftShader — because that click was the first moment these
three properties stopped being constants. Identical to the pins defect and fixed the same way: the idle
value is now ['case', ['has', 'iso_3166_1_alpha_3'], x, x], both branches the same so not one pixel
changes, and it is installed the moment addRegionsLayer returns. The variant therefore compiles on the
tab visit that is already loading a 6.4 MB GeoJSON, not in the middle of a gesture.
That placement is the whole point, and it is what I got wrong on the pins the first time: an idle
expression that is never installed pre-warms nothing. Here it is painted at layer-add, where the layers
first exist.
LEFT ALONE DELIBERATELY: highlightCountryByIso (0.30/0.02) and applyDimFilter's dim path (0.20) are
different states of the same layers, not duplicates of this one, so collapsing them into this writer
would have merged three features into one function to save nothing.
Verified: gates:pre 12/12, check:composite-interop PASS. Needs eyes: rega — expect 3 regions-* writes per
click instead of 9, and no compile on the row click; the compile should appear on the TAB visit instead.
Caught by reading the normalizer instead of waiting for the render: my adapter in f6a9d9d2 read
`g.religion` first, and DataSourceManager normalizes that field to the CODE — so every people-group card
would have printed MSN or BLA under the name, with the readable form sitting unused in `religionName`
one line away (DataSourceManager.js:357-358).
The country line had the same shape of error for the same reason. The renderer prints
flat(g.country_code) as display text next to the population, but the FIELD NAME invites handing it an
ISO code; normalized rows keep the code in countryIso and the label in countryName. Both now read the
readable form first and fall back toward the code, rather than the reverse.
This is the failure the commit before it flagged as the thing that could not be verified headlessly — a
wrongly mapped field prints blank or cryptic rather than throwing. It was findable without a browser
after all: the answer was in the normalizer, not in the rendering.
Verified: gates:pre 12/12. rega's check still stands and is still worth doing — this fixes the two fields
I could prove wrong, not the ones a rendered page would reveal.
Driver-lens audit card 2: the packet button downloaded packet.pdf while its poster sibling, two
functions above it, opened the print dialog. Same map, same menu, two different answers to "export".
The fix is not to wrap that PDF in a dialog. A PDF blob cannot go in the <img> the in-place print frame
uses, and every workaround — embed, object, the browser's own viewer — prints on some engines and
silently does nothing on others, which is the dead-button class this file has already paid for twice.
usePosterPrints.printPacket ALREADY does this: the same packet, authored as HTML with @page rules and
printed in place. This map was running a private second implementation of a shared product feature, with
jspdf behind it. Routing to the shared one deletes the duplicate, drops jspdf from this bundle, and the
packet gains the pages the shared renderer has that the local one never did — the life calendar, the
funnel and the prayer wheel, in the Driver's own page order.
★ AN EXPLICIT ADAPTER, because the mismatch would have been SILENT. The renderer reads slug / name /
population / religion / country_code, and usePosterPrints reads image_url; this map's normalized rows
carry country and imageUrl in camelCase. Handed over unmapped, the packet would have printed with blank
names and missing photos rather than failing — so the mapping sits in one visible place instead of
either side renaming its vocabulary.
Page 1 keeps the LIVE map rather than a fetched poster: this half is the one that knows what is on
screen. The shared renderer interpolates mapSvg as raw markup, so the PNG this map already knows how to
produce rides in as an <img>, as a data URL because the print frame is a separate document and an object
URL created here would be revoked out from under it. If that render fails the packet still prints — a
cover without the map beats no packet, which is the call the shared composable already documents.
Dead code removed with it: the download helper that served packet.pdf, and the lazy-import cache for the
composable this no longer uses. Neither export hands over a file now.
Verified: gates:pre 12/12. NOT verified here, and it needs to be before anyone calls it done: I cannot
see a rendered packet. The specific risk is the adapter — a field I mapped wrongly prints blank rather
than failing. rega should print one and look at the people-group cards before this is released.