sr, upg100, gospel and regions each wrote a scanner for user-visible English
that never reaches a translator, and each found real defects. Four copies means
four sets of blind spots and four allow-lists drifting apart, so this merges
them. Every rule earns its place from a defect one of those scanners caught.
Templates and script blocks are parsed, never matched with a regex: a regex for
string literals matches from the closing quote of one to the opening quote of
the next, which produced 137 phantom findings before this was parsed properly.
Translator aliases are read out of each file, because `const t2 = t` defeats a
check that knows only the base names. Guarded fallbacks are not leaks, in both
shapes that exist here: an existence check around the call, and a proxy whose
member read misses. Bound attributes are parsed too, in either quote style,
which is the blind spot every one of the four scanners shared.
The self-test plants a leak of each kind and requires the gate to see it, then
plants the correct forms and requires silence. It also asserts the fixtures are
valid Vue first, because a malformed fixture and a blind scanner produce the
same silence.
The header records what this cannot see: single-word strings. A bare 'Other'
used as a fallback label is user-visible English and will not be reported. That
trade keeps the signal readable, and the self-test pins it so that closing the
gap forces the header to be corrected rather than left stale.
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.
retryableAsyncComponent: after the three retries the pane rendered blank. It now shows
"This view couldn't load." with a Retry button (messages keys, 11 locales). Retry swaps in a
fresh async component, since Vue keeps a rejected load for the life of the page.
Each map control wrapper passes data-control (zoom-in, zoom-out, fullscreen, theme, location,
help, share) to its button: an untranslated hook for probes, where title and aria-label are
localised.
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-internet
gospel-resources
key-cities
my-upg-100-list
pplr-data-maps
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.
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.
The WALK string the facet gate injects into the page moves to a browser-free
module, internal/lib/facet-walk.mjs; the gate imports it, and
internal/check-facet-shape.mjs imports the same string and evaluates it on both
engines' source shapes without a browser. check:facet-shape ran from the browser
gate's own file, so the runner routed it to the browser slot; now it is headless
and joins gates:pre. Poisoned (the { geojson } unwrap disabled) it reports 4/5.
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.
check:composite-freshness found stale on main: doxa-research-map. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
The comment still said the code reads map.getSource()._data, which invites a copy
of the private field back in; the code reads through the shared helper that knows
both engines' source shapes.
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.
Six conversions, one deliberately kept physical. Same method as the legends.
CONVERTED
DataTable.vue:238 .tabulator-cell border-right -> border-inline-end. A per-cell
trailing border is the COLUMN SEPARATOR between adjacent cells; in RTL the
separator has to sit on the other side or it draws on the table's outer edge.
DataTable.vue:269 .dt-actions margin-left:auto -> margin-inline-start:auto
DataTable.vue:293 .dt-close margin-left:auto -> margin-inline-start:auto
Both are "push me to the trailing end of a flex row", which is the left in RTL.
DataTable.vue:299 .dt-dl ul padding-left -> padding-inline-start. A list's
marker indent follows the reading direction.
StackLayout.vue:161,305 .stl-meta margin-left:auto -> margin-inline-start:auto
(twice: the light and dark theme blocks).
★ KEPT PHYSICAL, AND IT WOULD HAVE BEEN A REGRESSION TO CONVERT
DataTable.vue:245 '.dt:dir(rtl) … .tabulator-cell{text-align:right}'
It is already SCOPED TO :dir(rtl), so right IS the trailing edge wherever it
applies. More importantly the cells carry unicode-bidi:plaintext, which makes
`end` resolve PER CELL from that cell's own first strong character -- which is
the exact behaviour this line exists to override (English cells left-aligning
inside an rtl table and clipping at its left edge, rega L20). text-align:end
would silently undo the fix. Comment added at the site so the next reader does
not "correct" it.
No consumer overrides any of the four classes: .dt-actions, .dt-close, .dt-dl and
.stl-meta appear in no other file in library/ or plugin-designs/, so a physical
override cannot defeat the logical rule and produce a half-mirrored table.
LTR is unchanged: inline-start/inline-end resolve to left/right under dir=ltr.
check:sfc-parse PASS. 13 plugin files use DataTable, 9 use StackLayout.
library/ is the build owner's under rule 5B -- source sha to studio; rega eyeballs ar.
BUG-FACET-GATE-CANNOT-MEASURE-MAPLIBRE. The gate read source._data four times,
and the two engines store it differently -- cited from the bundles this repo
loads, the same constructor in each:
mapbox-gl v3.24.0 this._data = i.data the raw GeoJSON
maplibre-gl this._data = "string"==typeof i.data
? {url:…} : {geojson: i.data}
A GATE blind to MapLibre passes a map that is broken there, which is worse than
a plugin carrying the same bug.
★ ALL FOUR READS WERE BROKEN, NOT THREE. The census called :145 "guarded" because
it had `s._data || (s.serialize && s.serialize().data)` -- but `||` fires only
when the left side is FALSY, and {geojson: …} is TRUTHY, so the fallback never
ran. The wrapper has to be UNWRAPPED, not fallen back from. That correction is
the reason this is a shared helper rather than a copied one-liner.
__srcFeatures() goes in the WALK bundle -- the in-page script the gate already
injects -- NOT as an import of library/maps/pin-selection.js: all four call sites
run inside page.evaluate / page.waitForFunction bodies, which are serialised to
the browser, where a Node-side import does not exist. Same logic as
readSourceFeatures(), duplicated across a process boundary, which is the one
place duplication is not a smell.
A remote (url) source returns null, not 0: null means "could not measure", while
0 would mean "the map drew nothing", which is the finding this gate reports.
THE MAPLIBRE SKIP IS RETIRED. It exited 0 while announcing SKIP -- NOT MEASURED,
correct for the limitation but a gate that never looked at half the product. A
source present with an unreadable payload is now a real failure with a
diagnostic, because the known cause of that state is fixed.
check:facet-shape negative-tests the helper headlessly against both engine
shapes, in sr's check-pin-selection style, evaluating it OUT OF THE WALK STRING
so the thing tested is the thing injected. 5/5. Poison-tested: with the unwrap
removed, ONLY the maplibre case fails (4/5), so it catches the regression and is
not indiscriminate.
internal/ is the build owner's under rule 5B -- source sha to studio. Running the
gate itself launches a browser and is rega's.
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.
check:composite-freshness found stale on main: cities-internet
country-posters
doxa-research-map
doxa-simple-map
my-upg-100-list
pplr-data-maps. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
Follow-up to the doc landing in 49, which was written before the guard existed.
Adds the guard: every browser gate reaches Playwright through
internal/lib/playwright-guarded.mjs and refuses unless browser-slot.sh exported
DOXA_BROWSER_SLOT; an unslotted run exits 75 with the command to use, quoted
verbatim. And says the slot is a COUNTING SEMAPHORE, not a mutex, with the reason
from its own header -- a single lock queued 10-19 deep for hours because the real
constraint is memory, ~300-500MB per chrome, not exclusivity.
Replaces the grep advice with `bun run browser-gates`. The grep still works and
is fine for a quick look, but it is a heuristic over file text: my own count from
grepping internal/check-*.mjs said 87 browser gates; the repo's own detector says
97, because browser gates also live in plugin-designs/*/scripts/. Corrected. The
authoritative list cannot drift from what is enforced, because
check:browser-gates-guarded uses the same detector.
One line the guard's header earns and this doc had not stated: being able to
launch is not the same as it being yours to launch. The guard stops the accident;
it does not hand a newcomer the seat. Testing whether the guard refuses is itself
a browser launch.
Does NOT claim path-into-node_modules detection: studio has that in landing 50,
and it is not in this tree, so the doc does not describe it yet.
check-experimental-shelf imported resolve(ROOT, 'node_modules/playwright/index.mjs')
dynamically, named no package specifier, and so launched Chromium outside the
slot while check:browser-gates-guarded passed it (upg100 found and measured it).
It now imports the guard, and an unslotted run exits 75. The check also fails a
path to node_modules/playwright (or playwright-core) anywhere in code; with the
old import restored it fails that file.
Landing 48's browser guard made check-packet-render refuse to launch outside the
browser slot and exit 75. This gate read `ok = res.status === 0` -- two outcomes,
not three -- so it reported "5 packet(s) FAILED, 0 passed" about five packets it
had never opened, naming five innocent files. Someone would have gone looking for
a rendering bug that does not exist.
run-parallel.mjs:262 states the contract: 75 (EX_TEMPFAIL) means the gate COULD
NOT LOOK, which is neither a pass nor a failure. Unmeasured packets now print ⊘
and are excluded from the pass/fail count, with the count of them in the summary.
AND THE CASE THAT MATTERS MORE: zero failures and zero looks are the same
`failed.length === 0`. Without a guard, a run that could not open a single packet
prints "PASS -- every generated packet renders" -- the most flattering possible
lie about a measurement nobody took, and the same shape as the stale-artifact bug
this file was fixed for earlier today. When nothing was measured it now exits 75
itself, naming the command that runs it in the slot, so the runner buckets the
whole gate NOT MEASURED rather than green.
Verified: outside the slot the gate now exits 75 and says so, instead of exiting
1 with five false failures.
Seven conversions, one deliberate keep, and a stale comment.
CONVERTED -- each is text that should follow the reading direction:
LegendDesktop.vue:138,139 the count and population chips, text-align:right ->
end. They are numeric badges at the trailing edge of a row; in RTL the
trailing edge is the left.
LegendDesktop.vue:154,157 the stats and footer VALUE columns, right -> end.
Same argument, and they already used padding-inline-end, so the rule was
half-logical and read physical.
SemanticTreeLegend.vue:711,1105 two button labels, text-align:left -> start.
Label text in a flex row; it follows the reader.
SemanticTreeLegend.vue:764 .stl-tab-info border-left -> border-inline-start.
This is a DIVIDER between two adjacent chips. In RTL the info button sits on
the other side of its sibling, so the divider has to move with it or it
draws on the outside of the pair.
KEPT PHYSICAL, deliberately:
SemanticTreeLegend.vue:805 .stl-hdr-num::after { left:50%; top:50% } with
transform:translate(-50%,-50%). That is the CENTRING IDIOM for a 24x24 WCAG
2.5.8 hit target, not a direction: it computes identically in both, and
converting it would add review noise that hides the real conversions.
PROVEN, not asserted: measured the GLYPH POSITION in a browser rather than the
computed keyword, because getComputedStyle returns text-align as the keyword
("end"), so a naive comparison reports DIFFER in LTR for rules that lay out
identically. Measuring the span's left edge in a 200px box:
LTR right=201px end=201px IDENTICAL left=8px start=8px IDENTICAL
RTL right=201px end=8px MIRRORS left=8px start=201px MIRRORS
And for the border, where computed style does resolve: LTR 1px/0px both ways
(identical), RTL 1px/0px vs 0px/1px (mirrors).
Also fixes a comment that said "margin-left:auto pins it to the right edge"
above a rule that already read margin-inline-start:auto. Prose naming the
physical side is how a reader talks themselves back into a physical property.
library/ is the build owner's under rule 5B -- source sha to studio.
It counted any non-zero exit as FAIL, so a gate that could not measure (no
browser slot, a server mid-build) read as a broken product, the same bug
upg100 found in check:packets-all once the browser guard made gates refuse
outside the slot. Exit 75 is now NOT MEASURED, shown as such, and a run with
no failures but any unmeasured gate exits 75 itself (INCOMPLETE), never green.
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.
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-internet
gospel-resources
key-cities
my-upg-100-list
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.
dx-shepherd asked for this with a7b12b44 and the reason is exact: every caller of readSourceFeatures is
written to do NOTHING when it finds no features, deliberately, so that a legend row for an off-screen
region degrades quietly instead of throwing. That is why the MapLibre bug produced no error, no console
line and no visual difference except a camera that did not move — and why it survived until a packet
printed the wrong 50 people.
check:pin-selection drives the reader with fixtures for both engines and no map at all:
· the MAPBOX shape (_data IS the FeatureCollection) reads its features;
· the MAPLIBRE shape (_data is { geojson }) reads the SAME features — the regression that shipped;
· a REMOTE source ({ url }) returns [] AND WARNS;
· the serialize() fallback works, a missing source is [] rather than a throw, a bare Feature is one;
· selectedPinIds ANDs its expressions, and with none returns every pin rather than none.
★ NEGATIVE-TESTED, which is the only thing that makes it worth having: run against the reader as it
shipped, it fails with exactly four findings — the maplibre shape reads 0 instead of 3, selectedPinIds
narrows to nothing, "no expressions" returns nothing, and the remote case returns [] silently. Against
the fixed reader it passes. A test that cannot fail on the code it was written for proves nothing.
★ AND THE REMOTE CASE IS NOW A DECISION, not an accident. dx-shepherd asked what { url } should do. It
returns [] and warns once per source: the features live on a server and cannot be enumerated locally, so
"I cannot read this" is a different fact from "there is nothing here". Collapsing those two is precisely
how the MapLibre bug hid for as long as it did, and no caller can tell them apart from an empty array.
Verified: check:pin-selection, check:mjs-parse, check:doc-scripts, check:package-json-unique-keys all
PASS. gates:pre was NOT run clean here — my main clone sits three landings behind origin/main, so a
newer package.json lists check:targets while that file is absent from the working tree; that failure is
my verification setup, not this change, and I checked it on an untouched tree before reporting.
rega's console answered the question in one line: "0 pin(s) selected but no rows matched", for both a
language-family selection and a prayer selection, with the pins plainly on screen. Not an id mismatch and
not a missing filter — readSourceFeatures returned an empty list.
THE TWO ENGINES STORE _data DIFFERENTLY. maplibre-gl's GeoJSONSource wraps it:
this._data = typeof options.data === 'string' ? { url: options.data } : { geojson: options.data }
so `data.features` is undefined there. Mapbox stores the GeoJSON directly. This code was written against
Mapbox, where it works, and has been returning nothing on MapLibre ever since — which is the keyless
engine, the one the public builds use.
★ THE PACKET IS THE SMALL HALF. This reader is what zoomToLegendRow uses to answer "which pins does this
legend row select", and it backs research-map, country-posters and doxa-simple-map. On MapLibre every one
of them has been failing to fit the camera to a clicked legend row — silently, because the function is
written to no-op rather than throw when it finds no features. The original comment above it explains that
querySourceFeatures is viewport-clipped and would make off-screen rows "silently no-op"; the replacement
did exactly that on one engine, for a different reason.
The wrapper is unwrapped, and a URL source returns [] explicitly — having no local features to read is a
different fact from finding none, and collapsing the two is how this hid.
Verified: gates:pre 13/13. Needs eyes, and the second one matters more than the first:
· the packet on MapLibre prints the SELECTED groups, cover count no longer the whole list;
· clicking a legend row on MapLibre FITS THE CAMERA to that row — on research-map, country-posters and
doxa-simple-map. If that has been dead on the keyless engine, this is the commit that revives it.
regions-plugin challenged the rule and was right. A BUNDLE reads
`stage: production` when ANY ONE of its profiles is production, so scoping the
loop by b.stage drags every experimental sibling in with it.
Measured: ALL SEVEN violations it reported were false. cities-internet is a
production bundle whose connectivity-list and data-tables are EXPERIMENTAL;
gospel-resources likewise for charts / data-tables / resources-list; and
my-upg-100-list's own poster-wall and upg-100-list are experimental too -- only
upg-100-mfe is production, and it has carried a label all along. With p.stage the
gate reports 0.
THE SAME TRAP A THIRD TIME. I moved off stageOf (bundle-keyed) after catching it
once, and moved to b.stage -- the same mistake wearing a different field name.
The profile's own stage is the only one that answers "is this row production".
THE SELF-TEST HAD A HOLE THAT MADE THE BUG INVISIBLE: every fixture bundle had
exactly one profile, so bundle-stage and profile-stage always agreed and scoping
by the wrong one could not be detected. A fixture where the two CANNOT disagree
cannot test which one you read. Adds a mixed bundle whose production profile must
FAIL and whose experimental profile must PASS; 6/6.
The two meta files in be398ce8f stay. They were a fix for a non-problem -- both
profiles are experimental -- but a named row is better than a humanized id, and
they cost nothing.
The rule is extracted as a pure undeclaredLabels(manifest) so it can be tested
over a fixture without standing up Vite or needing a built tree, and runs before
that setup so `--self-test` works on a fresh clone.
Four planted cases, and the SECOND is the one that matters -- a rule that fails
everything is as useless as one that fails nothing:
production + no label must FAIL
experimental + no label must PASS (the stage scope is the design)
production + a real label must PASS
production + whitespace-only must FAIL (`label: " "` satisfies a null
check and still renders blank)
Wired as check:studio-rail:self-test. Verified the extraction changed no
behaviour: the live gate still reports the same 8 violations it did before.
It exists because the first version of this rule PASSED on a manifest that
contained the very defect it was written for -- humanize(id) meant the rendered
label was never empty, so nothing could fail.
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.
238 checks; 148 run anywhere, 87 need a real browser, 3 shell out. A newcomer
cannot tell from the name -- check:packet-render sounds like a file check and
drives a browser; check:composites-mount sounds like it needs one and does not.
The test is one grep for playwright/chromium, and the doc says so.
The part worth writing down is exit 75. run-parallel.mjs:262 defines three
outcomes, not two: 75 (EX_TEMPFAIL) is NOT MEASURED -- the gate could not look --
and a gate that exits 0 while announcing SKIP is also NOT MEASURED. Only another
non-zero is a FAIL. Two traps follow: exit 0 alone does not mean a gate looked at
anything, and your own `timeout` produces 124, which says you stopped it, not
that the code failed.
Also states the rule the split exists for -- one machine, one browser seat, so
one person runs them -- and why it is not just logistics: a source read and a
render are different measurements. Four catalogue gates can pass while a page
still paints English; and of 16 cards parked as "needs a browser", 7 were about
markup, a path, a manifest field or a gate, and settled headlessly.
Every number measured on main d8baaefd2 rather than estimated, including
correcting gates:pre from 13 to 15 after re-running it.
check:composite-freshness found stale on main: none. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.