Reworked to the consuming dashboard's contract. It emits a zone id or null on
zone-change, not a selector: an id is serialisable into a query parameter, a
profile config and a deep link, which is the chain that dashboard already
resolves its list from, and a predicate is none of those. It also keeps the
component promotable -- a selector would have to know what it selects over, and
the consumer holds four different row shapes across its tabs. The join stays in
one place, where the diacritic folding and the province bridge already are.
A leaf emits its zone id; a region row emits the region's id and the consumer
expands it, because that is where the zone data lives.
And it cannot silently drop a zone. Four joinable zones are absent from the
region file today, one of them the consumer's largest at a hundred groups, so
grouping by region loses them with no error and no empty state -- just a shorter
tree. Given the full zone set, the legend renders the difference in an explicit
bucket, each one selectable. That makes the drop structurally impossible rather
than something to keep checking. Omit the set and the tree is untouched, so the
guard is additive.
Its check includes a negative control that the unaugmented tree really does omit
the largest zone, so the bucket is demonstrably not decoration.
The Gospel x Internet dashboard needs the Driver's vertical legend on the side
to select a people-group list and filter its groups by it. This is that legend,
wired as a selector.
It WRAPS the shared semantic tree legend rather than replacing it. That
component is already the vertical legend and already drives the 26-zone
tapestry, so every fix it receives -- theme-awareness, the progress bar, the
mobile sheet path, scroll-to-selected -- keeps applying. A second widget would
have to re-earn all of it.
It emits the SELECTION, not resolved group ids, and that is ownership rather
than taste. Resolving a zone to ids means joining zone membership against the
group rows, and that join is not trivial: the membership keys are ASCII while
the province data carries diacritics, so a naive join silently loses hundreds of
groups. A legend that resolved ids would hold a second copy of that join and
the copies would drift -- appearing as a wrong count, which nobody notices. The
consumer already holds the rows and the join.
A region row selects the union of its zones, and both a leaf and a parent return
arrays, so a single zone is not a special case the consumer will eventually get
wrong. A cleared selection is representable, because a dashboard must be able to
drop its filter.
The selection rule sits in its own module with its own check, including a
negative control that a parent does not select only itself -- which would filter
to nothing while looking like it had worked.
The tree is a prop. Its builder lives in the plugin that owns the zoning, and a
cross-plugin import is barred, so promoting that helper to the library is a
separate coordinated move.
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.
rega measured the WAGF Regions tab on Simple Research: the polygon highlight lands 51-85 ms after the
click, then 433-667 ms later it is gone — fill-opacity back to 0.7, line-width back to 0.6, no row
marked, every region at full colour. That reads exactly like the Driver's "the legend isn't connecting
with the map", and it is worse than not connecting: it connected and then undid itself.
Those two values are the fingerprint. 0.7 and 0.6 are written only by this profile's CLEAR paths, every
one of which is reached from a deselect — so something sent select(null) half a second after the click,
and nobody clicked twice.
SemanticTreeLegend watches the IDENTITY of its nodes array, and every tree feeding it is a computed that
returns a NEW array each evaluation. Any late async that touches a dependency — pins still arriving, a
lazily loaded layer, a refresh — rebuilds the array with IDENTICAL CONTENT, and the reset in that
watcher then cancelled the reader's selection: expandedIds cleared, selectedNode nulled, select(null)
emitted. The reset is right for genuinely new data; what was wrong is that it could not tell the two
cases apart.
It asks one question now: does the selected row still EXIST in the new nodes? If it does, re-point at
the new object and leave the tab and the expansion alone, because nothing about the reader's state has
become invalid. If it is gone, the reset runs exactly as before and the deselect is honest. Re-pointed
rather than kept, because the old object belongs to the discarded array and holding it would compare the
highlight against a node the tree no longer contains.
This is shared, so it is every tree legend on every map, not only mine: any profile whose data arrives or
refreshes after a click was silently losing that click.
NOT changed, and deliberately: the WAGF Regions TAB click also compiles shaders for regions-fill and
regions-border (rega: 2.9-4.1 s on SwiftShader), because those layers are added on that click. Warming
them at boot would mean paying for two more layers on every reader of every tab to save a one-time cost
on one tab, and the 6.4 MB regions GeoJSON is lazily loaded there on purpose. SwiftShader inflates a
compile by roughly an order of magnitude — the pins variant measured 3.2-3.5 s there and +0.5 s of real
boot delta — so the honest expectation on a GPU is a few hundred milliseconds, once, on a tab that is
already loading megabytes. That is a trade I am declining to make rather than one I missed.
Verified: gates:pre 12/12. Needs eyes: rega, the same WAGF Regions row click — the highlight should now
still be there at 5 s, and the variant compile may appear on that click once the selection sticks.
A zero-width segment button was still rendered: an invisible tab stop, and the
first child that took the bar's rounded left end. Segments with no width are
now left out.
The dev backend answers /status with the same offline status as /events, and
Rudder reads it on mount and whenever the event stream drops, so a proxy that
buffers the stream no longer leaves it saying "Runs on your computer" on a
server that should run the engine. The local-microphone notice no longer shows
off this computer. The legend progress bar paints its grey band as a rounded
strip and rounds the first and last segment, which the 24px touch targets had
squared off.
Each segment was a 10px-tall button, under the 24px minimum (WCAG 2.5.8). The
track is now 24px with a centred 10px painted band, the segment buttons clip
their colour to that band, and negative margins keep the legend's layout
unchanged.
rega, row 46 at 835ec1ac: on the prayer map neither the shared-point sentence nor the standing pin note
appeared. research-map and countries-map provide normalizedPeopleGroups to their legends; the prayer map
did not, so its legends fell back to dataStore's cache, which holds the RAW API rows: field names the
co-location index cannot read, and no GeoJSON geometry for the standing-note detector to find.
useMapData now provides the normalized rows it draws, so every map built on it offers them. LegendMobile
and SemanticTreeLegend read them first (the cache stays the fallback), and the detector accepts a row with a
name and a position as well as a GeoJSON point. check:approx-pin-note asserts the provide and the
normalized-row match; the provide check fails on the previous useMapData.
The Driver's card BUILD-APPROXIMATE-PIN-NOTICE: every map that shows people-group pins must say so plainly.
The standing APPROXPIN note existed only under LegendRows and in the pin card, so maps whose legend is
SemanticTreeLegend (the prayer map's flat tabs among them) never showed it (rega, row 46).
It now renders under the tree when the map's own data holds named Point features (the injected dataStore);
maps without people-group pins show nothing. A pinNote prop overrides for a leaf that already says it.
The five are NOT one shape, and treating them as one would have changed what
readers see. Measured before touching them:
ApproxPinNote, FilterDropdown fall back to a literal map
SemanticTreeLegend, PosterWall fall back to humanizeKey() — 'legend.columns.name' renders 'Name'
PinHint falls back to a one-key map
SemanticTreeLegend, PinHint also capture i18n.te (used 3x and 1x)
PosterWall also captures i18n.locale
So useOptionalT grew an optional `fallback` hook, and useOptionalI18n was added
for the callers that need te and locale. Every site passes the fallback it
already had, so not one rendered string changes. Routing them through the
catalogue instead would have altered what seven bare-host bundles print, and —
because tStatic honours the runtime locale — would have started translating
strings that are English today. That may be an improvement; it is not this
commit's to make. Changing a default is not a migration.
te keeps its existing contract too: FALSE on a bare host, never "true because the
catalogue has it". PinHint and SemanticTreeLegend both record why — te is how
their callers know to use their own defaults.
THE CHECK NOW LIVES ON MAIN, which is the point of this commit as much as the
migration is. dx-shepherd: the 18-assertion version was in _qa-cycle, so it would
never have run again. internal/check-optional-t-equivalence.mjs is registered as
check:optional-t-equivalence — script line, //scripts note and gates:post token,
all inserted by hand rather than by re-serialising package.json. It carries 46
assertions: the helper's contract across all three host states, a branch-for-
branch equivalence against the old hand-written code, and a PER-SITE REGISTER
that every later C4 wave appends a row to. Negative-tested from outside: strip one
site's own fallback and it fails naming that site.
check:library-index went red on the new composable and is green again after
`bun run library-index`. That is the staleness gate working, not a flake.
The shared legend was placed with physical left/right, while the map controls use inset-inline-end. In
Arabic the controls mirrored to the left, onto a legend that stayed left: on Simple Research, Prayer Progress
and UPG-100 (rega's RTL survey at 52eeb0db; traced by upg100-plugin and repo).
SemanticTreeLegend: the panel, its reopen button, the tree-guide hairlines and the export menu use
inset-inline-start/end; the closed panel slides off to the right and its collapse arrow flips under
:dir(rtl) (additive overrides, as VerticalTabsLayout does). LegendDesktop, LegendMobile, LegendRows,
LegendTools and LegendProgressBar: the card, reopen, detail close/collapse buttons, the name gutters that
clear them, indents and spacing are logical; LegendTools re-centres under RTL. LTR renders the same.
check:legend-logical-insets (with :self-test, both in gates:post) fails on any physical left/right in the
legend components unless it is symmetric, an auto reset, or allowed with a reason.
It called useI18n() unguarded at setup. On an embeddable leaf whose host never installed vue-i18n that
throws inside the custom-element boundary and leaves an empty shadow root: the FilterDropdown failure
that blanked people-groups-grid. It now guards the call the way PinHint does, and falls back to the
English pins.* wording with the same {names}/{n} placeholders filled. Reported by gospel-plugin.
Their 24px floor sat only behind @media (pointer:coarse), so a desktop mouse still got the small target
(regions' finding; the phone probe runs with touch, so it measured the passing branch). 2.5.8 is a target-
size criterion, not a touch one. .mobile-collapse-caret (22x20 in a 20+4px slot), .stl-hdr-num (a 9px line
box), .rm-link-btn (11px, padding 0) and .pin-hint-x (16px) each get a 24x24 absolutely-positioned ::after
hit area at every width (the f29b8151 pattern). It adds a target without a box, so no row or slot
geometry moves; the coarse query still sizes the box on touch.
PosterWall's .pbtn, .pstock and .tape-h are HELD: their spacing is the Driver's sign-off.
The desktop verdict (2.5.8's spacing exception, an ancestor with overflow:hidden) needs a rendered layout,
so this is a needs-eyes item for rega.
Third and final batch of the shared-library strings, in the shepherd's order:
SemanticTreeLegend (4), PosterPreview (2), PosterWall (2) and
VerticalTabsLayout (2), plus PosterWall's "people groups" which already had a
key and was still typed in.
These are all LOADING and OVERFLOW states — "Loading data…", "Showing top 500 of
2,107", "use the geocoder above to find the rest", "Loading map snapshot…",
"preview unavailable", "Could not load X". Text a reader meets exactly when
something is slow or has gone wrong, which is the worst moment to be handed a
language they do not read. PosterWall's own source called one of them "i18n
DEBT" in a comment; it is paid now.
The counted ones take a placeholder rather than being split around an
interpolation — legend.showingTop is "Showing top {n} of {m}" — so a translator
can move the numbers within the sentence. Languages do not agree about where a
count goes, and a sentence assembled from fragments cannot be reordered at all.
VerticalTabsLayout needed a different shape: it is an options-API component, so
a module-level helper compiles fine and then renders nothing, because its
template can only see what the component returns. T is exposed from setup().
★ THE NEW GATE CAUGHT SOMETHING I WOULD HAVE MISSED, on its first real batch.
Adding these keys flipped two strings in
plugin-designs/pplr-data-maps/src/components/SemanticTreeLegend.vue from "no
catalogue entry" to "has a key and is still hardcoded" — that file is a COPY of
the library component I just fixed, carrying the same debt. It is another lane's
plugin so I have not touched it, and it is reported rather than quietly
baselined away. The baseline is refreshed to today's truth so the gate guards
the new line: 285 → 273 without a key, and the two copies recorded as known debt
rather than as a regression.
A people-group pin is a representative point, not an address, and some pins ARE the same point: on the
Doxa roster 2,107 groups sit on 2,048 coordinates, 108 of them on a point shared with another (gospel-
plugin's measurement). Every map with people-group pins must say so, in one wording.
- library/components/legends/ApproxPinNote.vue: "The dot marks where this people is found, not a town or a
boundary." always; when the selected pin's point is shared, "It sits on the same point as <names> — <n>
groups share this coordinate…". Never prints the raw coordinate. Wording: gospel-plugin's P5 proposal,
in a new pins catalogue (11 locales).
- library/utils/colocation.js: the co-location index, built from the roster THE MAP draws (rows or
GeoJSON features), never a hardcoded count.
- LegendDesktop and LegendMobile (research map, simple map, country posters, cities, UPG-100) show the
note under the legend, and under the detail card with the shared names.
- check:approx-pin-note (headless, gates:post): the index, the two sentences, no coordinate, Arabic.
Second of the shared-library batches, in the shepherd's order: ShareButton,
DataTable, LegendTools, and PinHint's dismiss label.
★ THE ONE THAT MATTERS MOST IS THE PARAGRAPH A PARTNER READS. ShareButton's
embed tab explains where to paste the snippet — "WordPress, Squarespace, Wix, or
any site that accepts HTML widgets" — and it was English on every locale of a
product whose whole pitch is that a partner can drop it into their own page. The
two tab labels beside it likewise.
Also wired: the data table's CSV and JSON buttons, its row-detail hint and
dialog label; the legend's Clusters button and the Minimum Spanning Tree option
with its explanation; and the pin hint's Dismiss.
★ SIX SITES NEEDED NO NEW WORDING AT ALL — they already had translations in all
eleven locales and were still typed into the template: four "Close" buttons
(buttons.close) across PosterDialog, DataTable, MapControlMenu and the QA
corner, "Expand legend" (aria.expandLegend) and PosterWall's "people groups".
Those are the cheapest defect in the whole catalogue: the work was done, the
reader just never saw it.
CSV and JSON are keyed but identical in all eleven, like DPI in the poster
batch. A format acronym is not an untranslated string; the key exists so a
language that does translate it can, without anyone editing a component.
★ LIBRARY CHANGE — studio's to land under rule 5B. I have NOT rebuilt anything.
Until it lands, every string falls back to the English it reads today.
Measured with the source audit: shared-library strings with no catalogue entry
go 32 → 21, and the has-a-key-but-hardcoded class goes 6 → 2. Remaining, in
order: QaCorner (10, a developer tool), SemanticTreeLegend (4), PosterPreview,
PosterWall and VerticalTabsLayout (2 each).
Six user-facing strings in SemanticTreeLegend were hardcoded English, in the
one surface that generates a document translated into all eleven locales. A
reader in Arabic opened the export menu and met "Poster" and "Packet" beside a
packet whose every page is Arabic — two honest surfaces disagreeing.
legend.export.poster menu item
legend.export.packet menu item
legend.exportButton the trigger's visible label
legend.exportSelection the trigger's title AND aria-label
legend.noDataAtThisLevel the empty state
aria.expandLegend reused for the collapsed-legend reopen button,
which had a title and NO aria-label at all, so a
screen reader announced an unlabelled button
WHY A GATE NEVER CAUGHT THESE. Both menu items only exist after a click opens
the menu, so a check that renders a leaf and scans visible text cannot see
them. The trigger's accessible name is worse: it is never visible text at all.
KEY NAMES CARRY THE FALLBACK, so they are not free. This component has no
i18n on a bare host (useI18n() is guarded) and falls back to
humanizeKey(lastSegment). I first named the keys after their ROLE — .label,
.selection, .empty — and measured the result: the export button rendered
"Label". The last segment must BE the English word, which is why
legend.columns.name -> "Name" has always read correctly. Verified all six:
Poster / Packet / Export / Export Selection / No Data At This Level / Expand
legend. Never blank, never a raw key.
TRANSLATED, NOT COINED. check-i18n-never-translated warns against inventing
terminology because a wrong term looks authoritative to a reader who cannot
check it. That warning was written about "UUPGs", a term of art. These are
ordinary print-shop words with undisputed equivalents, and the test is whether
a bilingual reader would dispute the word: not for "poster", yes for an
unreached-people-group acronym. Leaving them English was the defect.
es/pt legitimately share "Exportar"; the gate fails only on all-eleven.
Verified: check:i18n-parity PASS (11 locales complete, 0 debt),
check:i18n-never-translated PASS (my six keys added nothing to the allowlist),
BUNDLE=my-upg-100-list vite build 318 modules clean, and a static sweep of the
template now finds zero hardcoded English in text nodes or in title/aria-label.
Measured all seven production maps at 390x844 with touch emulation. 195 controls
sat below WCAG 2.5.8's AA minimum of 24x24 CSS px, spread across five maps -- but
they were not five owners' bugs. Grouped by class, 195 failures were ten classes,
and 119 of them lived in library/: the same PosterWall, PinHint, LegendMobile,
SemanticTreeLegend and ResearchMapFilterPanel controls repeated down a list and
across four maps. Routing the per-map counts would have filed hundreds of defects
against owners who do not own the geometry.
Raised the shared controls, scoped to (pointer:coarse). 2.5.8 is a touch
ergonomics rule, and these components sit on maps this change does not own: a
finger gets the bigger target, the mouse keeps the density those owners designed.
The poster row is the reason this matters -- its height was tuned by eye against
the card's blue outline, and growing it for a pointer that needs no help would
have spent the artwork clearance to fix nothing.
min-height states the 24 instead of deriving it. Growing the padding first
MEASURED 23.7px and 23.0px -- under by a third of a pixel and by one, because the
height is padding plus a line box the renderer computes from a 10.5px font. The
declared minimum also survives a font swap or a locale with taller glyphs.
The header tape now self-centres like the footer. It hung on top:-11px, half of
its then-fixed 22px height, which silently becomes wrong the moment the height
changes -- as it just did. top:0 with translate(-50%,-50%) sits on the border at
any height, the mechanism the footer already used, and one less number to keep in
sync.
Two exceptions in 2.5.8 are now encoded in the probe rather than remembered.
Inline: 48 .uupg__country-link "failures" are an <a> inside a paragraph, computed
display:inline, sized by the line-height of the text around it -- the spec exempts
them and the probe now skips display:inline, counting them separately. Equivalent:
a .lpb-seg progress segment is 10px tall inside a track that clips overflow, so no
hit-area extender can reach 24px; it resolves to a tree node and calls the same
selectNode a full-width legend row calls.
195 failures on five maps -> 4 on two. UPG-100 alone went 137 -> 0. The poster
tape gate and per-row persistence still pass.
Cities with Internet was English end to end: the legend title, the four bands,
the World Bank caveat, the new people-group row and the whole country sheet. Its
chrome now comes from a connectivity catalog area in all eleven locales, following
gospel-resources: i18n is optional, English from the shipped catalog is the
fallback, and carrier and platform names stay as data. The legend and the sheets
are anchored with logical insets, so in Arabic the legend sits opposite the control
rail instead of under the zoom buttons.
LegendMobile hard-coded its column labels ("Prayer Status", "Engagement Status",
…) and titles ("Legend", "Languages", …) in English. They now read
legend.colLabel.* and legend.titleBar.*, which every catalog already translates.
English is byte-identical, so other consumers are unchanged until they rebuild.
Measured in the Research Gallery tab on a served built tree, at 1280 and 390, in
en/ar/zh/ru/hi: Cities shows no English UI string (only the Mapbox and
geoBoundaries attribution); ar is right-to-left with 0 clipped and the legend
clear of the control rail.
Both called useI18n() UNGUARDED. That function THROWS ("Need to install with
`app.use` function") on any host that never installed vue-i18n, and seven
bundles here do not — big-picture, doxa-maps-spa, doxa-vision-card,
golden-path-check, people-groups-grid, plugin-studio, stack-demo. In a library
component this widely consumed, an unguarded call is a BLANK PAGE waiting for
the first bare host that renders it: exactly how library FilterDropdown blanked
people-groups-grid, a production plugin, fixed by studio in 6603dc17.
★ THIS IS PREVENTIVE, NOT A FIX FOR AN OBSERVED BREAK, and the distinction
matters. MEASURED: no bare bundle currently consumes either component, so the
blank page cannot be reproduced today — which matches the shepherd's framing
("LIVE today: none; latent debt"). What is fixed is the embedding hazard, and
the gift policy asks for plugins that work "standalone AND embedded".
⚠ THE FALLBACK IS DERIVED, NOT A TABLE. Hardcoding English for these eleven
keys would put a second copy of the `en` catalog inside the component, and two
copies of one fact drift — the exact trap behind the embedBase asset-path bug
this week. Humanizing the key's last segment gives "buttons.prayerList" ->
"Prayer List", "buttons.cardDeck" -> "Card Deck", "legend.columns.name" ->
"Name": readable English that CANNOT go stale, rather than a raw key shown to a
reader. te() answers false so every caller falls through to its own default.
VERIFIED: the fallback produces the correct English for every real key these
components use; with i18n present nothing changes —
check:legend-info-localized PASS (es/ar/zh still translate, 0 blank),
check:poster-card-fits PASS across 5 widths x 3 locales,
check:a11y-button-names PASS, SRI consistent.
I also confirmed 6603dc17 is an ancestor of my HEAD before pushing, per the
shepherd's warning: the FilterDropdown fix is intact in my tree, not reverted.
THE LEGEND OVERFLOWED THE BOTTOM OF THE MAP because two numbers that had to
agree were written out twice.
.stl-panel is capped at calc(100% - var(--stl-top) - 12px), but this profile
set `top` DIRECTLY with !important instead of setting --stl-top. So the panel
moved down to 58px while the cap kept subtracting 8px, and the two silently
disagreed by exactly the difference.
MEASURED at 390px in a 420px-tall host: top 58px, max-height calc(100% - 20px),
panel bottom 458 in a 420px map -- 38px PAST the map. Same 38px at 560px,
because the error is the mismatch, not the map's size. After: -12px, the
intended bottom margin, at 390/420, 390/560 and 1440/500 alike.
Two more copies of the same mistake went with it. The mobile rule paired
top:64px with max-height:calc(100vh - 72px) -- a constant unrelated to the
offset, AND the wrong box: 100vh measures the VIEWPORT while the panel is
positioned inside the MAP. Those agree only when the map fills the screen,
which is true of every standalone leaf and false the moment a host puts a
header above it. .fit-content had the same fixed 72px. Both now derive from
--stl-top, so they cannot drift apart again.
★ THIS IS INVISIBLE ON EVERY PUBLISHED LEAF. A standalone leaf gives the map
the whole viewport, and a panel that overflows a full-height map is still on
screen. It only appears when the host makes the map SHORTER than the viewport
-- which is how the element is actually embedded. Verify in a constrained
host, never on the bare leaf.
The a11y half of this work is NOT here. I fixed the same two nameless legend
controls locally while the remote was unreachable, then found on rebase that
they were already fixed on main -- better, with the row's own label
interpolated ("Expand Africa" rather than a generic string), and in one
key namespace instead of the second one I had introduced. Took main's version
whole and dropped my duplicate locale keys; only the CSS above is mine.
TWO SHARED-COMPONENT DEFECTS, both routed to me, both a step away from where
the routing pointed.
THE 14 NAMELESS BUTTONS ARE ONE COMPONENT. check:a11y-button-names reported 14
visible buttons with no accessible name across two country-posters leaves. The
routing named countries-map.vue, which contains exactly one <button> and it has
visible text — the gate prints the LEAF, so the file was an inference. Running
the gate and then a probe that prints what a developer can grep for — class,
innerHTML, box size, the custom-element host chain — named the real one:
library/components/legends/SemanticTreeLegend.vue:1228, seven
<button class="stl-caret">, 14x14, a 9x9 chevron and nothing else. The 14 is
those 7 counted twice, because country-posters-mfe composes countries-map;
worth saying, or the next reader hunts a second defect that does not exist.
"(no title either)" in the gate's output is the diagnostic half: it rules out
the title-only control this gate was built for, and it rules out Mapbox's own
controls, which ship aria-labels. These were ours and they were empty —
assistive technology said "button" once per expandable branch.
Fixed at the source, which is why it lives in library/: an aria-label naming
the branch the caret discloses, and aria-expanded, because a name tells a
screen-reader user what the control is for and still not whether the branch is
open. Three keys across eleven locales rather than a hardcoded label. And the
same defect one element over, latent: .stl-x-chip carried only a `title` —
precisely what this gate exists to reject — and hardcoded English besides. It
escaped the sweep because it renders only while a row is selected.
Seven surfaces render that legend, so one fix reaches all of them. The
artifacts are per-bundle though: country-posters is rebuilt here because its
cards are routed to me, and my own bundle with it. The other five stay stale
until their owners rebuild, and I have not touched them.
AND THE PIN HINT HAD NEVER BEEN TRANSLATED. The ask was to audit eleven locales
for a stale "red dot". There is nothing stale, because there is nothing: no
locale file contains "Tap a", "pinHint" or "coloured dot"; PinHint.vue imports
no useI18n; the sentence is a JavaScript string literal. So every map that
renders the hint has been showing an English sentence to readers in Arabic,
Russian and Chinese.
Which is worth noticing about the fix that came before it. The colour half was
found and fixed carefully — the comment above it records the Driver reporting it
five or more times, and why a shared component must not name a colour it cannot
know. All correct. Nobody asked whether the sentence was in the catalogue. Same
shape as the Russian headings next door: wrong only for readers who do not file
bug reports in English, and so invisible to everyone who could.
Eleven locales, every one saying coloured and never red. The pinHintLabel
override still comes first, so a profile that does want to name its colour can.
And the key falls back to the English sentence when a locale lacks it, so a
missing translation degrades to today's behaviour rather than rendering the
literal "messages.pinHint.tapDot" — the failure mode of a naive t() swap.
Flagged and not taken, because they are shared components with owners:
FilterDropdown renders a hardcoded English "Clear selection" and "No options
found" (the latter already exists translated in the regions catalogue, so the
component is ignoring a string that is right there), and PosterWall's export
modal renders its reassurance line untranslated.
Measured at the rendered layer: the legend opened on REGION at desktop and
mobile. That is the Driver's first complaint about this legend word for word —
"it shouldn't have me sitting there on Regions, I don't know what to do, it's
confusing" — and the card recorded that part as shipped.
Every source signal said it worked. LEGEND_DEFAULT_TAB = 1 existed, defaultTab
was a declared prop, it seeded a local ref, and it was named in a watch fallback.
The root cause is one layer down. SemanticTreeLegend resolves its tab as
`instance ? instance.activeTab.value : localActiveTab.value`, and
usePplrInstance initialises activeTab to ref(0). Zero is indistinguishable from
"the user chose tab 0", so for every profile sharing an instance store the
defaultTab prop was dead — read on every render, never able to win. An
activeTabTouched sentinel fixes it: the store only wins once something actually
chose a tab, meaning a tab click or a node selection that jumps to that node's
generation. A sentinel rather than a new default, so no other profile's landing
tab moves as a side effect.
And a regression of my own, from the previous commit. There was only one
:default-tab left in the profile and it was on <LegendMobile>, where defaultTab
is not a prop. Last tick I "dropped the inert attribute" with a string replace
that matched the DESKTOP block first, deleting the working one and leaving the
inert copy. A dropped attribute is silent, so the build stayed green. Restored
to SemanticTreeLegend, and LegendMobile now has a real defaultTab prop so the
phone honours it too.
Both widths open on "UPG-100 Lists", and on ?lang=es both open on "Listas
UPG-100" — locale-independent. A user's own choice still sticks: clicking Region
holds at 0, and selecting a row does not snap back, so the sentinel is not a
trap.
check:legend-export-reachable still passes and now selects a real zone rather
than a region, because the legend opens on the Lists tab. The two fixes compound:
a phone user lands on the lists and can export the one they pick.
Last tick I found the export unreachable on mobile and recorded it as
Driver-gated: "WHERE the control goes on a phone is a design call, the sheet has
no titlebar". Both halves of that were wrong, and checking them was this tick.
LegendMobile RENDERS SemanticTreeLegend itself — titlebar and all — and the 0x0
elements my measurement found belonged to the OTHER, hidden desktop instance
mounted below the breakpoint. That geometry made it look as though the sheet had
nowhere to put the control. A Driver decision was never needed.
Two gaps, not one.
The sheet passed neither :export-enabled nor @export, and exportEnabled defaults
to false, so the control's v-if was never satisfied. Fixed via provide/inject,
matching onSemanticTreeSelect immediately above it, because the sheet renders
several levels down where an emit would not reach. Opt-in by construction:
enabled only when a profile provides a handler. The other three LegendMobile
consumers provide none and are verified untouched.
Even then the export did nothing, and that second gap is the one that mattered.
The profile passed :nodes but no :tabs, so hideTabs was true and the sheet showed
a single flat Region tree with no tab strip — no UPG-100 Lists tab, no selectable
zone, and onLegendExport bails on `if (!zoneId) return`. Passing :tabs fixed it.
A :default-tab attribute was added and then removed: defaultTab is not a prop on
LegendMobile, so it would have been inert.
Verified end to end at 390x844: the tab strip appears, a real zone selects,
Export opens, Packet clicks, and one print frame is produced. A packet on a
phone. The gate written last tick — which failed on mobile by design — now
passes at both widths. The newly visible mobile tabs are fully localized in
en/es/zh, so catalog work from earlier ticks now reaches a surface that never
showed tabs at all.
Four of my own probe errors were caught by measuring rather than concluding.
Clicking a REGION row left selectedKey empty, so the handler correctly bailed
and it read as "export does nothing" — the same zero on DESKTOP is what proved
it was the probe and not a regression.
country-posters shows the same mobile gap on its own surface. Not taken; its
owner's call.
The legend's toggle button announced the literal string "aria.toggleLegend" to
screen readers. Not in translated locales only — in English too. vue-i18n echoes
the key when a lookup misses, and `toggleLegend` was absent from every catalog
while its two siblings in the same file, backToLegend and closeDetailView, were
present. Six call sites across LegendDesktop.vue and LegendMobile.vue, desktop
and mobile alike.
aria-label is the one string a sighted tester never sees. Every other
untranslated string in this tree got caught because somebody looked at the
screen; this one is only ever spoken aloud, so it survived every visual QA pass.
A second instance turned up in the same sweep with the opposite shape:
SemanticTreeLegend hardcoded 'Collapse legend' / 'Expand legend' into both title
and aria-label with no t() at all — correct English, and untranslatable. One key
missing FROM the catalog, one string never routed TO it.
Adds toggleLegend, collapseLegend and expandLegend to all 11 aria.json catalogs
and routes the hardcoded pair through t(). check:i18n-parity already guards them.
New gate smoke:aria-labels asserts BOTH shapes, because either assertion alone
is insufficient: "no raw keys" passes a hardcoded English label forever, and
"labels differ across locales" passes a raw key, since a key is identical in
every locale. It detects a raw key by SHAPE — dotted, camelCase, no spaces —
which is locale-independent and works on the Arabic and Chinese pages where no
English word list could.
Mutation-tested twice. Removing the catalog key fails with the raw key named, on
all four viewport/locale combinations. Hardcoding one label back to English
initially PASSED: the differ count dropped 6 to 5 and nothing asserted on it —
the same "a number printed but not asserted on is decoration" shape this tree
keeps rediscovering — so the gate gained a LOCALIZED_FLOOR ratchet and now fails
that too.
Verified the fix generalizes beyond the profile it was found on: country-posters
reports 182 of 190 accessible names localized, 0 raw keys. Rebuilds the six
bundles that embed a legend; check:sri confirms every leaf matches. Wave 34/34.
The Driver asked for the per-tab info buttons to stay — "the tab itself gives
you a little information button that will explain what a UPG-100 list is."
They existed, and they were English-only in every locale.
SemanticTreeLegend rendered `tab.info` RAW in all three places it appears (the
button's title=, and the popover body) while the tab LABEL beside it went
through the catalog. So a Spanish reader saw a translated chip next to an
English explanation of what that chip means — and the explanation is the part a
reader who does not read English actually needs. The three UPG-100 tab labels
were absent from tabs.json entirely, so they fell back to hardcoded English too.
Measured on the served leaf before changing anything: ?lang=es changed 2 of 76
rendered strings ("POPULATION" -> "POBLACIÓN") and left all three tabs and all
three info popovers in English. That positive control is what proves the locale
switch WORKS and the strings were simply never catalogued — without it this
reads identically to a broken ?lang=.
tabInfo() mirrors tabLabel exactly: `legend.tabInfo.<i18nKey|id>`, te() guard,
falling back to the hardcoded English so a tab with no catalog entry renders
unchanged rather than blank. The v-if stays keyed on the raw `tab.info`:
whether the affordance EXISTS is a property of the tab definition, while its
TEXT comes from the catalog.
Catalogued in all 11 locales, labels and info. check:i18n-parity already guards
them — deleting one zh key fails the build by name.
New gate smoke:legend-info renders the REAL served page and clicks each info
button. check:tabs-localized could not see any of this: it tests 7 hardcoded
research-map tabs and REIMPLEMENTS tabLabel inside page.evaluate, so it proves
the mechanism works rather than that a given profile is localized — and it never
looked at `info` at all. Mutation-tested: reverting the popover to the raw prop
fails with "the catalog is present and unreached".
Partial-translation contract verified by deleting 2 of 3 fr info keys and 1 of 3
fr labels: translated keys render French, untranslated fall back to English,
nothing blank, and labels and info resolve INDEPENDENTLY.
Rebuilds the six bundles that embed the legend so the tree stays internally
consistent; check:sri confirms all 55 script references match. Full wave 34/34.
Driver, as a first-time user: "I select one and it gives me print options at
the top right of the legend... that's the only way that map would be usable."
The affordance was already built and switched OFF. The shared legend emits
'export' with 'poster' | 'packet' for the selected node, the profile wired
@export, and SHOW_EXPORT_BUTTON was held false with the note "until the export
packet is finished: the button currently produces a broken packet". That
precondition is now met, so the switch is on.
ONE RENDERER, TWO CALLERS. The pages move to library/packet/packet-render.js,
imported by BOTH the build-time generator and the browser export, so the printed
packet and the generated one cannot drift. The card asked for exactly this:
"reuse the existing print actions rather than growing a fourth implementation."
The extraction is behaviour-preserving — the generated packet is byte-identical
to before apart from leading whitespace (the code is now inside a factory), and
check:packet-render still passes 8/8.
★ THE PACKET IS GENERATED ON CLICK, NEVER SHIPPED. One packet is ~3.5 MB (photos
dominate) and 28 zones would add ~100 MB to a repo that ALREADY had a publish
blocked at a 74 MB asset tree. Generating client-side costs zero repository
bytes; the photos come from URLs the records already carry. This is the Driver's
instruction: "instead of fetching the poster from the public folder I want the
client side code to generate the poster real time when you click on the button
so it does not take a lot of mb storage." Bundle cost: +21 KB (494 -> 516 KB),
which includes all 12 prayer points and Mark 5:1-20 in full.
Printing goes through the SHARED openPrintFrame — a hidden same-page iframe, not
window.open(). A popup is blockable and drops the reader out of the map.
Two traps the browser path had to handle, both already known to this codebase:
- pg_slugs BYPASSES the shared selector evaluator (it intentionally throws on
that type, and 12 of the 27 lists use it). Same special-case the map and the
poster wall apply, so all three agree about who is in a list.
- evaluate() returns a Set, not an array.
VERIFIED IN A BROWSER, not by reading source (new gate check:packet-browser):
live records -> shared renderer -> page.pdf(), giving 8 sheets -> 8 pages at
612x792pt, 95 people cards, 12 wheel segments, Mark 5 in full, 2 metacamp.org
attributions, 0 page errors. I18n verified with a partial Spanish block: the
translated headings rendered in Spanish while Mark 5 and the attributions fell
back to English, per key, never blank.
Card BUILD-UPG100-MAP-LEGEND-LEADS-WITH-THE-UPG-100-LIST part (d). Parts (a) and
(b) shipped earlier. Part (c) — removing the People Group tab and retabbing the
legend — stays OPEN: that card is explicitly driver_gated on "what is the
legend's tree once Region/Zone/People Group are removed", and the current tabs
are wired to real hierarchy data.
Second plugin in the MapLibre rollout, copying doxa-research-map's pattern
rather than inventing a second one: hasGeocoder gates the search bar on
provider, MapToolbar collapses its own reserved gap, and the legend rises
into the vacated space.
The geocoder stays Mapbox-only by decision, not omission: its maplibre path
still calls makeMapboxForwardGeocode(accessToken), so the search REQUEST
goes to Mapbox whichever engine draws the tiles — a keyless map with a
search bar is not keyless and would bill the very key the option exists to
avoid.
★ RAISING THE LEGEND COLLIDED WITH THE FLY/CLUSTERS TOOLBAR, and only the
screenshot showed it. LegendTools is absolutely positioned at top:14px,
centred on the legend column — the SEARCH BAR's slot. With the bar gone and
the legend raised to 14, the toolbar printed straight across the legend's
"WAGF REGIONS" header. Every number was green while the panel was unusable.
A collision is worse than the gap this work exists to close.
So LegendTools now EXPORTS showsFlyTools(legendType), and hosts ask it
instead of re-typing its condition — two copies of one rule is how they
drift. A host with no search bar stops at 56px when the toolbar renders and
rises to 14px when it does not. doxa-research-map picked up the same fix.
MEASURED on the built tree, both engines:
country-posters mapbox search@12 legend@60 (unchanged)
country-posters maplibre search@NONE legend@56 (no gap, no overlap)
doxa-research-map maplibre search@NONE legend@58
doxa-simple-map needed NOTHING and was not touched: FEATURES.geocoder has
been false on both engines since 2026-05-04 and its legend already sits
flush at 10px. Recorded as a no-op so the next pass does not "fix" it.
22/22 bundles, 7/7 pre, 18/18 post, browser sweep of five StackLayout
consumers and all seven studio dashboards.
Driver: 'if I search the name of the country and add n to it, like
Austrian or Canadian, I would like for there to be a selection of that
country -- the table under the map shows the result for that country.'
Three changes, one ladder:
1. resolveFuzzyValues(): WHOLE-VALUE Levenshtein against the DISTINCT
values of every search key -- 'Sudann' is a misspelling of the
VALUE 'Sudan', so the rows returned are exactly Sudan's, identical
to picking it as a Place. The windowed matcher stays only as the
final rescue tier.
2. Best match wins: only the minimal-distance value(s) resolve. The
under-the-cap union admitted 47 rows for 'Canadian'.
3. Both fuzzy tiers cap at 2 edits absolute (the did-you-mean bound);
threshold*len alone reached 3 errors at 8+ chars, which is where
the cross-facet noise lived.
Measured (dashboard scope / grid count agree): Sudann -> 105/105
Sudan only; Indonesa -> 36/36 Indonesia; Banladesh -> 3/3 Bangladesh;
Bhutann -> 13/13 Bhutan; exact terms unchanged (tier 1).
NOTE: Canada/Austria themselves have NO rows in this dataset (it
holds only the 175 countries with unreached people groups), so
'Canadian'/'Austrian' cannot select them -- a handful of genuine
2-edit neighbours return instead.
Also ships @map/composables/retryableAsyncComponent.js wired at every
defineAsyncComponent site (ProfileLoader chokepoint + 8 lazy
components): a transient ERR_NETWORK_CHANGED no longer permanently
kills a lazy plugin (3 retries, growing backoff). Live fault-injection
proof pending; the wrapper is the documented Vue onError contract.
The UPG-100 legend was pinned 58px below the map top by a profile !important
rule whose job was to clear the geocoder pill. Measured with a real token in
both hosts: the standalone leaf DOES mount the geocoder (top 12, h 35), so 58
is correct there; the cap-5 shell mounts NO geocoder at all, so the same rule
stranded the legend below a pill that was not there -- the dead gap reported
repeatedly.
One host must not be fixed by breaking the other, so the offset is now
conditional on the pill existing: default 8px, pushed to 58px only when
:has(.mapboxgl-ctrl-geocoder) finds a rendered pill in the tree.
Also parameterizes the shared SemanticTreeLegend's offset as
var(--stl-top, 60px) with the max-height mirroring it, so any profile that
drops its search bar can raise its legend without an !important arms race.
Measured after: leaf legendOffsetInMap=58 with geocoder present; shell
legendOffsetInMap=8 with no geocoder. Full authoritative rebuild included --
composite freshness PASS, SRI PASS 45/45.
A `position:fixed; inset:0` scrim is only viewport-relative while nothing
establishes a containing block for fixed descendants. VerticalTabsLayout sets
`contain:layout` on .vtl-content, so inside the cap-5 studio every such scrim
covered the CONTENT PANE instead. Measured at a 1280x900 viewport:
.dt-scrim / .modal-scrim / .lt-overlay ... 1024x793 @ (256,107)
captured by div.vtl-content [contain:layout]
256 is the rail width, 107 the header height — left uncovered and CLICKABLE
behind what still looks like a modal. The fix (library/utils/viewportScrim.js)
measures the real origin and subtracts it, rather than removing the shell's
contain, which is load-bearing for a different reason.
This adds the gate that keeps it fixed. A source grep cannot: every one of these
scrims greps as a correct inset:0, because the variable is the ANCESTOR, not the
declaration — and which ancestors exist depends on the HOST, not the component.
check:scrim-viewport:self-test reverts each scrim to plain inset:0 and requires
EVERY case to fail; a gate that cannot fail is worse than no gate, and this one
measures a property that looks fine in a screenshot either way. The self-test
also surfaced that .lt-overlay is captured TWICE — div.lt-root [transform] as
well — collapsing it to 233x30 rather than merely to the pane.
SemanticTreeLegend's .stl-export-backdrop is fixed the same way but marked as
NOT verified through the UI: it sits behind SHOW_EXPORT_BUTTON = false, so the
menu that owns it cannot be opened. Correct-if-enabled, not observed.
Five lanes had 465 insertions across 18 files sitting uncommitted, some for
15 minutes. That is the state that silently lost the shell-scroll fix earlier
today: a verified fix existed only in the working tree, a second agent's edit
to the same file overwrote it, and the reverted version got committed and
described as the fix.
Nothing here is finished and no card is being closed on it. This is a
snapshot so the work survives an overwrite, taken at a moment when every file
had been quiet 15s or more, so no agent was mid-write.
Verified coherent before staging rather than committed blind: template-literal,
mjs-parse, sfc-parse, no-orphan-gates, kpi-unit, composite-reg, script-flags,
quoted-scripts and backlog-status all PASS. check:synced-css fails on
PeopleGroupDetail.vue and doxa-simple-map.vue only -- the same two files and
the same counts as before the swarm started, both untouched by any agent, both
already carded.
Two untracked files included because both are load-bearing: internal/
check-facet-one-field.mjs is wired into package.json with a self-test, and
library/utils/viewportScrim.js is imported by DataTable, SemanticTreeLegend
and PosterDialog -- a shared helper for the modal-scrim fix rather than six
copies of the same workaround.
Driver 2026-09-21: 'a folder called deployment which will store the capacitor
folder, wordpress folder and tauri folder', and 'a folder called documentation
to store the contributing folder so docs are in one place'.
docs/ + contributing/ + CONTRIBUTING.md -> documentation/ (three doc homes into
one). capacitor/ + wordpress/ + src-tauri/ -> deployment/.
The public/ -> dist/ rename the Driver asked about is NOT here: after seeing the
measured blast radius (855 tracked files, 54 source refs, and public/ is the
served web-root four consumers point at) the Driver withdrew it - 'keep the
public folder the same'.
Path references were rewritten mechanically (258 across 87 files), but three
classes needed hand fixes because a prefix regex cannot see them:
- join(ROOT,'docs',...) in 10 internal scripts - a quoted SEGMENT, not a prefix
- tauri frontendDist '../public' -> '../../public', since src-tauri is now one
level deeper; check:tauri caught this exactly as the card predicted
- the WordPress contract test's dirname(__DIR__,2) -> 3, same reason
Also reverted one over-reach: the rewrite renamed a FINDING-CATEGORY label
'docs' in public-hygiene-check.mjs, which is a rule name in its own header, not
a path.
Verified: full build ALL GUARDS PASS, SRI consistent; check:tauri, check:mobile
(cap sync + webDir OK), check:wp-embed, quoted-scripts, capability-citations,
capability-exemplars, library-index, public-hygiene, parse and drop all pass.
Last tick's fix covered the shared map-control base. Sweeping the rendered DOM of all 17
bundles found three more unnamed buttons — the same "Collapse legend" control in three
map bundles, again from one shared component. Its only content is an svg caret, so with
title alone its accessible name was empty.
It is a disclosure control, so it now reports aria-expanded as well. Verified by toggling
it: the label flips Collapse/Expand and aria-expanded flips true/false.
The gate measures the rendered DOM through shadow roots, because a source grep cannot
answer this question. There are 132 <button> hits here and most have visible text, which
IS a valid accessible name; aria-label is often on a following line. Only the DOM knows
which buttons ended up nameless. It counts aria-label, a resolved aria-labelledby, or
visible text, and deliberately does not count title, which is the defect it was written
for. Hidden buttons are skipped since assistive tech cannot reach them either.
The sweep went 319/322 before the legend fix to 322/322 after, which is the evidence it
detects real defects rather than passing vacuously.
- useMapTheme: one look rule (any prayer strategy → night/default; everything else →
day/monochrome) applied at boot and on every tab switch in the simple map and the
research map; an unknown strategy keeps the current look
- Legends and the research search pill are 380px again; desktop numeric columns 70px at
13px so every row stays on one line with no overlap
- Map containers carry their map instance for gates (no global)
- SemanticTreeLegend: the UUPGs/Population columns were a fixed 44px, sized for the old
9px header / 10.5px numbers; at the desktop scale "POPULATION" collided with "UUPGs"
and "139.0M" spilled into the neighbouring column. Base column is now 60px, desktop
76px (header 10px, numbers 13.5px, no wrapping)
- The panel follows --map-legend-width like the detail card and the geocoder pill; the
research map's wide-host legend is 400px so "Full Prayer Coverage" stays on one line
- New probe: `bun run probe:legend-columns` measures cell boxes vs real text extents and
fails on any spill or overlap (desktop and phone sheet)
- New shared LegendProgressBar: [has-prayer span | needs-prayer remainder] with the
full-coverage colour filling inside the has-prayer span; text "covered of total · %";
each span is a focusable button with the legend's info popover (name · count · %),
hover highlight, and click selects the matching legend row
- useLegendData exposes a `progress` payload for the prayer legend (mutually exclusive
counts from /statistics: covered = has + full); null for every other legend
- SemanticTreeLegend and LegendRows render the bar as the header's second line and title
the prayer legend "Progress" (desktop, mobile sheet, collapsed pill); profiles pass and
provide the payload so the mobile sheet gets it unchanged
- legend.progress.* strings in all 11 locales
- No height transition while dragging; the resize grab zone is the top 44px (and, in detail
mode, the content when it is scrolled to the top); one geometry source for the JS clamp and
the CSS max-height; release snaps with hysteresis (open ↔ fullyOpen ↔ collapsed)
- The sheet publishes its height as --legend-sheet-h on the element host; the Mapbox/MapLibre
bottom controls translate up by it, so the logo and attribution stay visible in every state
- New gate: internal/mobile-sheet-probe.mjs (Firefox, touch) taps a pin, drags the detail
sheet up/down and checks heights, states and control overlaps