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.
ShareButton is not a .mcb. It wraps its button in a .share-wrap div carrying
border-radius:50% and a conic-gradient ring on ::before at inset:-2px, so the
button rules missed it and it kept a round rainbow halo inside the square
panel -- breaking the single-card illusion at the corner the eye lands on.
rega measured it at 390 and asked whether square should flatten it. It
should: in this variant the panel owns the outline, so a child may not bring
its own. The round default is untouched.
Gated, with a control asserting ShareButton really does carry a 50% radius,
so the two rules are not decorative.
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.
unicode-bidi:plaintext (row 86) also resolves a cell's alignment from its own text, so English cells
left-aligned inside an rtl table, and at its left edge 'Mandarin' clipped and 'Spanish' rows looked blank
(rega, L20 at ee1cbc05). Cells in an rtl table now align right; the per-cell bidi isolation stays.
rega row 86 (gospel tables at ?lang=ar; every table renders DataTable.vue): English and mixed cells flipped
under the page's rtl ('(Open Bible Stories (Door43-Catalog'), and the English hint tail and the meta caveats
rendered with their full stop leading. Cells take unicode-bidi:plaintext (Tabulator generates them, so CSS is
the lever); the English hint tail and each caveat are isolated in <bdi dir="ltr">. From gospel-plugin.
rega, row 82 (gospel-internet board at ?lang=ar; gospel-resources charts share the component): under an
inherited rtl the SVG's text-anchor start flipped, so category labels ran off the left edge and value
labels drew back over their bars ('869' cut to '9'). The chart's SVG is direction:ltr, since its geometry
is left-to-right by construction; each label still shapes as Arabic. In every locale the value column was a
fixed 74px, which clipped '10,160,000' in a 335px card; it now fits the longest value label (never under
74px). Needs eyes: row 82 in ar and en.
PosterWall remembered the card it had shown, so after Show zone -> Map -> back the same press toggled the
selection off instead of showing it (rega row 79; UPG-100 and country-posters share the shape). The wall
now forgets its last-shown card when its pane goes inactive: it watches its ancestor chain, through shadow
roots, for the inert / hidden / aria-hidden a composite puts on an inactive view (the PinHint contract).
dx-shepherd ruled option (a). Needs eyes: rega row 79 plus the same arm on country-posters.
The numerals policy is HELD — whether counts follow the reader's locale is the Driver's decision — so
this changes nothing anyone can see. Every caller renders exactly the digits and separators it
rendered before. What changes is how many places that decision is written down: thirteen call sites
across four bundles becomes one, library/utils/formatCount.js, and eight of the thirteen (mine) now
read it.
That is the whole value while the policy is held. If the answer becomes "follow the locale" it is a
one-line edit in formatCount.js instead of thirteen edits by four owners, each free to pick a
different locale source — and the first divergence in that world would be invisible: two panels in
one dashboard, same number, different separators.
★ IT FORMATS. IT DOES NOT DECIDE WHAT EMPTY MEANS, and that is the bug this fold would otherwise have
shipped. I told dx-shepherd these three helpers were "byte-identical". They are not:
DashboardDataTable null/undefined -> '' and '' falls through to Number('') === 0, so "0"
DashboardDetailDrawer null/undefined/'' -> '—'
useDashboardShell null/undefined -> '—' and '' likewise becomes "0"
Three different answers to "what does a missing count look like", inside one dashboard. Folding them
onto one empty policy would have silently changed two of the three — a table cell going from blank to
an em dash, or an em dash where a zero used to be. So each caller KEEPS its own guard and hands
formatCount a value it has already established is real. The locale decision folds; the empty policy
does not, and the difference is written into the helper's header so the next person does not re-merge
them.
Folded: DashboardDataTable, DashboardDetailDrawer, useDashboardShell (its exported fmt keeps working
for the KPI strip), DashboardFacetRail's three inline template calls, people-groups-grid's two.
NOT folded, because they are other owners': regions-dashboard (3) and pplr-data-maps (2). The helper
is theirs to import when they want it; nothing forces them, and no gate demands it — a gate requiring
the helper everywhere would fail on their files today, which is not a thing to land on someone else.
Source only. research-dashboard, dashboard-plus-grid and people-groups-grid need a rebuild.
THE GROUP WAS SCHEDULED AS ONE LANDING AND DELIVERED AS TWO. Section 8 of the spec puts the facet
rail, the chip bar and the BREADCRUMB in a single landing. The rail moved in 4c-3 and the chips in
4c-2; the breadcrumb was left behind, and nothing failed, because it is mounted by nobody. A leftover
like that is invisible unless someone re-reads the spec against the tree, which is what this landing
came out of.
The breadcrumb gets exactly the inversion the chip bar got: no useTreeLegendLabels import, the
resolver read from dashboardConfig.legend, and the SAME unconfigured default — identity, joining an
array. Identical on purpose rather than by copy-paste: the breadcrumb and the chips render the same
filterSet in the same band, so if their defaults differed a half-configured dashboard would show one
value resolved and the other raw, side by side. Its dead `dataStore` / `searchSourceId` injects go
too; they existed only to build the composable that is no longer imported.
★ AND THREE ONE-CYCLE SHIMS FROM LANDINGS 2 AND 3 WERE NEVER RETIRED. plugin-designs/research-
dashboard/src/composables/ still held useDashboardLayout.js (11 lines), useSelectionCamera.js (11) and
useCsvExport.js (14), each a pure re-export of the library module. MEASURED before deleting: ZERO
importers. Every consumer — including DashboardMapPanel, DashboardDataTable, DashboardDetailDrawer,
useDashboardShell, gospel-internet's board and research-dashboard.vue — already reads
'@map/composables/…' directly, and a tree-wide grep for a relative import of any of the three returns
nothing. So they were dead code that made the plugin look like it still owned three library modules.
I wrote "a one-cycle shim that nobody retires is permanent" into the previous landing's gate comment
while three older ones were sitting in the tree unnoticed. They were not found by a gate; they were
found by listing what is still in plugin-designs and comparing it against the spec.
check:legend-mirrors arm 5 now guards three moved names instead of two, so a re-export of the
breadcrumb creeping back into the plugin barrel is caught the same way.
Source only. research-dashboard and dashboard-plus-grid need a rebuild.
A media query adds no specificity. The mobile block's
`.map-toolbar { gap: 6px }` is (0,1,0) exactly like
`.map-toolbar--square { gap: 0 }`, and it comes later in the sheet, so it
won: on every phone a square toolbar fell back to separate buttons inside
one ringed panel. The shape broke precisely where the Driver is most likely
to look at it.
The --no-geocoder pair is (0,2,0) and outranked the variant whatever the
order, so it needs the two-class answer.
This is the same lesson as the goal cards' gap-vs-margin: a ported mechanism
is only ported if it still WINS, and specificity is the part that decides
whether it does. Found by following the phone thread rather than by looking
at the variant again.
check:square-map-controls now asserts the override is inside the mobile
block, that the two-class form answers --no-geocoder, and -- as a control --
that the base rule really does come first, which is why the override is
needed at all. Removing it turns the gate red.
The CSS comment explaining it cost one more mistake: I wrote the selectors
in backticks, inside the template literal that holds the stylesheet. A
backtick ends the literal, which is the trap check:sfc-parse names in its own
failure text, and it caught me with it.
The Driver asked for square controls by name. The fact that shaped this:
the square look is not a DX-life design at all. Their CountryMap.vue adds
NavigationControl + FullscreenControl and overrides nothing, so what he
admired is the ENGINE'S default grouped card -- which makes the target
published and exact instead of a matter of taste. Every value here is read
from node_modules/maplibre-gl/dist/maplibre-gl.css, and the check compares
ours against that file rather than against my memory of it.
SQUARE IS NOT A RADIUS SWAP, which is why it lives on the TOOLBAR. Round
controls are separate floating circles with a gap and a shadow each; the
engine's square form is ONE panel that owns the background, the 4px corners
and the 2px ring while its buttons own nothing and are divided by 1px lines.
Setting border-radius:0 on the buttons alone gives squares floating 8px
apart -- neither shape. So MapToolbar takes controls="round"|"square", and
MapControlButton keeps a shape prop only for a button used outside a
toolbar, where a lone control gets the engine's 4px.
THE 29px IS DELIBERATELY NOT PORTED. Ours stay --map-btn-size, capped to
32px in this toolbar. 29px would cut the touch target below what this
project has already ruled on twice, so the variant takes the shape and
leaves the size. It is therefore not pixel-identical to the engine, and
that is a choice, stated.
No overflow:hidden. Clipping was the quick way to round the panel, but it
also clips a focus ring on any inner button, and the engine does not do it
-- it rounds the first and last buttons instead. Ported as their mechanism.
MapControlMenu teleports out of the toolbar, so clipping would not have
hidden a menu; the focus ring was the real cost, and the invisible kind.
Forced colours ship with it, which the round buttons never had: an rgba()
ring and a #ddd divider both vanish under a replaced palette, leaving one
undifferentiated block where the controls were.
BLAST RADIUS: round is the default and none of the nine MapToolbar
consumers passes controls=, so every map renders exactly as before.
Asserted, not claimed -- opting one consumer in turns the check red.
The regions dashboard is NOT switched. Its map already uses the engine's
native controls, ported from DX-life, so it already looks like this;
replacing them with ours would risk clone parity to arrive at the same
picture.
One near-miss, recorded because of how it hid: the first cut added a second
:class to a <button> that already had one, and Vue rejects a duplicate
attribute, so the SFC did not parse. check:sfc-parse said so IMMEDIATELY --
and I had piped it through `head -2`, which printed its counting line and
cut off the FAIL beneath. The block count fell 133 -> 132 in that same
output, I noticed, and then checked script TAGS rather than parseability and
talked myself out of it. Two separate signals, both read past. The two
bindings are merged into one; the count is 133 again.
A page's own reduced-motion reset never crosses a shadow boundary, so library components rendered inside
custom elements slid, faded and spun for readers who asked for less motion: thirteen of them, including the
legends and map controls (regions-plugin found it on ProgressBar and named the class).
useShadowStyles now injects one prefers-reduced-motion reset into each shadow root it styles, which covers
every component styled through it, including later ones. PosterDialog, FilterDropdown and SearchFilterHeader
style themselves with <style> blocks and carry their own guard.
check:shadow-reduced-motion (in gates:pre): useShadowStyles must inject the reset, and every animating
library component must be covered. It fails without the reset (negative-tested) and found the three
<style>-block components on its first run.
The three goal cards, the stat row and their bars were the regions
dashboard's own markup. They are library components now, and the plugin
renders them instead of holding a copy:
GoalCard title + figure + a goal line pinned to the card's bottom,
with a slot for whatever sits between
StatCard one figure and its caption
ProgressBar a single-value track, or a segmented one with a legend
useCardSurface the card surface both cards share, injected once per
root under a stable label instead of copied into each
The bar and its legend now read from ONE list of segments, so a width and
the number printed beside it come from the same figure and cannot drift
apart; before, each was computed separately. A segment's colour is a prop
carrying the same token its .rd-seg--full/--partial/--none class used to
set, so a shared bar needs no vocabulary about prayer and those three
rules are deleted rather than left unreachable.
ProgressBar carries its own reduced-motion guard. The plugin's reset is
scoped to .rd-root and would not have followed the component anywhere
else, which would have reintroduced the one-second sliding bar their own
site suppresses.
The rendered DOM is unchanged for all four instances, measured by
server-rendering the real components and comparing against the markup
that was deleted, including that the bar widths equal the percentOfSubject()
they replace.
check:regions-uses-library grows to cover all four, asserting both halves
again: the plugin imports and renders them, and no longer ships their
markup or CSS. The .rd-root strong rule STAYS in the plugin -- UupgsList
renders a <strong> in the same shadow root and depends on it, which a
file-scoped read would have called dead.
The rail's rendering is unchanged. What changed is where its last two plugin imports point. 4b had
already removed the research COLOUR and LABEL tables -- those stay in plugin-designs and arrive on
dashboardConfig.legend -- which left exactly two, and both now read the module that OWNS the
function rather than a re-export:
useDashboardAggregations -> @map/composables/useFacetAggregation.js (extracted by 4c-1)
useDashboardShell -> @map/composables/useDashboardSearch.js (extracted by 4a)
Pointing at the shim instead would have been invisible and wrong in a way this tree has already
measured: check:filter-multivalue carries the note from landing 3 that a gate left at the plugin path
read the shim, found no predicate to object to, and PASSED VACUOUSLY.
The dead injects go too. `dataStore` and `searchSourceId` were here only to construct
useTreeLegendColors and useTreeLegendLabels, and 4b removed both constructions. An unused inject is a
claim about the host: a kit component that appears to need a research dataStore is one a new
dashboard will dutifully provide before finding out it was never read. The chip bar shed the same
pair in 4c-2.
check:filter-multivalue and check:legend-mirrors follow the file rather than being left pointing at a
path that no longer exists -- the multivalue gate fails loudly on a missing panel, which is what it
is for, and it is how I would have caught forgetting.
UNLIKE THE CHIP BAR, THIS ONE IS ON SCREEN. 4c-2's component is mounted by nobody, so its gate was
the whole story. The rail is mounted by research-dashboard.vue and is 770 lines of tree, search and
drag behaviour: "the imports resolve" says nothing about whether it still opens four trees by
default or whether its search still narrows. It needs eyes.
The barrel re-exports from the library for one cycle, so no importer's line changes in the same
landing that moves the file. documentation/reference-library-index.md regenerated.
DashboardFilterChips.vue moves to library/components/dashboards/. Nothing about the rendering
changed. What made the move possible was 4b, which inverted the one dependency that blocked it: this
file imported useTreeLegendLabels — the People CODE -> NAME tables, research-specific and staying in
plugin-designs — and a library component importing a plugin composable is what
check:no-cross-plugin-imports refuses. The resolver now arrives on dashboardConfig.legend.
THREE THINGS BEYOND THE MOVE, all found by assessing the file rather than by moving it:
1. THE UNCONFIGURED DEFAULT NOW JOINS A LIST. 4b's fallback returned the value untouched, so a
multi-value filter (spec D1) printed String(['A012','A013']) = "A012,A013", comma-jammed, while a
dashboard that DID supply a resolver got "South Asian Peoples, Bengali Peoples". Configured and
unconfigured should differ in whether CODES RESOLVE, never in punctuation — the second difference
reads as a rendering bug rather than as missing configuration, and go-int is the consumer who will
see the unconfigured state first.
2. THE DEAD INJECTS ARE GONE. `dataStore` and `searchSourceId` existed only to construct
useTreeLegendLabels and have been unused since 4b. Removed rather than left in place: an unused
inject is a claim about the host, and a kit component that appears to need a research dataStore is
one a new dashboard will dutifully provide before discovering it is never read.
3. AN EXPLICIT EXCEPTION TO library/components/WARNING.md, recorded in the file's own header. That
file says a component graduates into the library only once MORE THAN ONE map uses it; this one is
used by ZERO, because the topbar dedup pass unmounted it when SearchFilterHeader became the single
source of truth for showing and clearing filters. It is here because the kit's promise runs the
other way round — a NEW dashboard mounts kit components, so waiting for a second consumer before
providing the first one is a deadlock. The honest consequence is written down too: a passing gate
means "it mounts and degrades", not "it looks right", and nobody has SEEN this component since the
dedup. Whoever wires it in is its first real reader, and must not mount it beside a
SearchFilterHeader that is already showing chips.
check:legend-mirrors grows arm 5, and it asserts the two things a "move" gets wrong that reading the
new file cannot show: that the OLD copy is actually gone (a move that leaves the original behind is a
copy, and two chip bars over one filterSet drift the moment one is edited — this tree has already
paid that with useNeighborT/usePplrT at Jaccard 0.82), and that the UNCONFIGURED behaviour is right,
which no dashboard here would reveal because they all supply a resolver. The default is LIFTED out of
the source and RUN, the way arm 3 drives the rail's fallbacks — not mounted, because a no-op renderer
cannot show a chip and the rendered result is rega's. Five mutants, all caught: old copy left behind,
the join reverted to 4b's bare value, a dead inject re-added, the supplied resolver ignored, and a
relative import restored.
The barrel re-exports from the library for one cycle, so no consumer's import line changes in the
same landing that moves the file. documentation/reference-library-index.md regenerated, per landing.
PARENTED ON a285e06c (4b), not on main, because 4b edits this same file — landing them the other way
round would revert 4b's inversion. Source only: research-dashboard and dashboard-plus-grid need a
rebuild.
The Driver's ask, and his negotiating point with the DX-life author depends on it
being true: the components cloned from their /regions page must be REUSABLE,
PARAMETERIZED components in the shared library, not private to one plugin. He named
the failure mode himself — someone opens the regions plugin folder and finds the
components are not reusable after all. I checked before building, and for the UI he
named he was right: only the DATA and LOGIC had been promoted.
So library/components/regions/RegionSectionCard.vue takes title, summary, countries
and footerLabel, emits what was clicked, and knows nothing about where regions come
from. The plugin version fetched its own data and owned the layout, which is exactly
what made it unusable anywhere else.
THE DESIGN TRAVELS WITH IT. The card carries the region-card, countries-grid and
country-link CSS lifted unchanged — including the RTL chevron rules, where a
"forward" chevron must mirror in Arabic while a disclosure caret must not, and where
[dir="rtl"] .region-card__icon at 0-2-0 would otherwise beat the 0-1-0 --open
modifier. A consumer gets the look, not a lookalike. It needs dir on an ancestor for
that mirroring, which is documented in the file as a requirement.
★ THE CARD OWNS THE DISCLOSURE ROW, after the first cut got it wrong. I put the
"View country posters" toggle in the consumer's slot — and immediately had to copy
the footer-link styling and the rotating caret back into the plugin, re-creating the
duplication this extraction exists to remove. A disclosure inside a card is a card
feature; only the revealed CONTENT is the consumer's, so PosterWall stays in the
slot with its fourteen props and does not become every consumer's API.
★ THE CHEVRON IS INLINE, NOT A <use href="#id">. The plugin declared the symbol once
and every card referenced it. A shadow-root id is scoped to that root, so the same
card mounted elsewhere would reference a symbol that does not exist — and an
unresolved <use> renders NOTHING, silently. The plugin's own comment warns about
that class for external hrefs; a cross-root id fails the same way.
check:regions-uses-library is registered and asserts BOTH halves: the plugin imports
from @map/ and renders the component, AND no longer holds the markup or the CSS it
delegated. A reusability claim only a human can audit is one that rots; this one
fails the day someone re-inlines a copy. Two negative controls keep it honest — the
plugin still uses its own .rl-root classes, and the other tab is asserted NOT to use
the card yet, so the check describes what is extracted rather than what I hope for.
Two of my own mechanical edits produced valid JS and broken content on the way here,
both caught by checking rather than by the parser: a line-numbered splice that
dropped PosterWall's closing /> (fixed by locating boundaries by CONTENT), and a
line-filtered CSS deletion that left 9 orphaned declarations and braces at 12/21
(fixed by removing rules brace-aware, then re-validating the concatenated stylesheet
rather than trusting check:sfc-parse, which only proves the JS parses).
On upg-100-mfe at phone width the map pane is stacked (position:absolute; inset:0) BEHIND Prayer Resources,
so the IntersectionObserver, which measures geometry and not occlusion, still reported the map as in view
and the pill floated over the posters (the Driver's report; flagged by repo before the README retake).
The contract: a composite marks an inactive view inert (or hidden / aria-hidden="true"), which is also what
assistive technology needs. The pill hides while any ancestor of its map carries one of those, walking up
through shadow roots, and a MutationObserver on that chain brings it back when the reader picks the map.
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.
It gated on a map canvas EXISTING. A composite (pane-wrapper) keeps the map pane mounted while the reader
scrolls to another pane, so on UPG-100's phone view the pill was clamped into view over the poster pane,
where there is no map to tap (Driver, README picture; traced by upg100-plugin and repo).
An IntersectionObserver on the map box now decides: under a fifth of the map in view hides the pill, and
scrolling back shows it again. Only the reader's close button dismisses it for the visit, as before. Without
IntersectionObserver the old behaviour stands.
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.
The QA widget comments (QaCorner, ProfileLoader, app-wrapper-material) say our own surfaces and the host's
session instead of naming one host; StackLayout describes a normal host page's flow; the live-cors script
note says it was measured on the live test host. Comments and notes only; no behaviour changes.
Cross-filtering is the one interaction whose whole point is that acting on ONE
control changes a number somewhere ELSE. Pick "Islam" in the left rail and 2,107
becomes 693 across the KPI strip, the map, the table and the charts. A sighted
user watches that happen. A screen-reader user heard nothing at all: this
element carried exactly one role="status" (the table's selection pill) and zero
aria-live regions. The filter worked; the feedback did not exist.
ONE polite live region in the shell, not one per panel. Six surfaces change on
every brush, and polite announcements QUEUE — six regions would make the user
wait through five repeats before reaching the next control. It is always
rendered and starts empty, because a live region inserted already holding its
text is routinely not announced; it is visually hidden rather than display:none,
which would take it out of the accessibility tree along with the pixels. It is
debounced at 700ms for the same reason the selection camera is debounced: typing
re-derives the row set per keystroke, and an un-debounced region would queue one
announcement per character. It stays silent on arrival and only ever reports a
change. It names the count, the total, the active facets and the search term,
formatted by the shell's own formatter so it can never read as a different
number from the KPI strip beside it.
ONE RESIZE HANDLE ANNOUNCED ITSELF IN ENGLISH in all 11 locales. Four of the five
read their title and aria-label through T(); the map/table one held raw string
literals. It survived review because each half looks correct on its own — the
ternary is right, the strings are right, only the missing T( is wrong. Both now
resolve through the catalogue. (The keys themselves are on
sr/dashboard-a11y-strings: aria.json held eleven keys and not one was a resize
key, so ALL five handles have been falling back to English. The T() call sites
were right the whole time.)
THE CHIP ✕ IS NOW BIG ENOUGH TO HIT. The live strip's remove button was a
~13x16px glyph — the smallest control in the product, and the only way out of a
narrowed board, where a miss costs the reader the whole view. The row height
cannot grow: three comment blocks above it record a measured layout-shift bug and
the Driver's "a lot more subtle" instruction that pinned it to 16px. So the HIT
AREA grows and the BOX does not, via an absolutely-positioned 24x24 ::after that
contributes nothing to the flex row's intrinsic size. Nothing moves by a pixel.
2.5.8 is a TARGET SIZE criterion, not a touch one, so it is unconditional. A
focus-visible ring too, since the chips live in an overflow-x strip where being
scrolled into view is not the same as being visible.
The kit's own DashboardFilterChips were spans with click delegation — not
focusable, no click from Enter or Space. They are buttons now, with an aria-label
naming the ACTION rather than the filter ("Religion: Islam" is a good name and a
useless action), type="button" so a partner's form is not submitted by a chip, a
focus ring and a 24px floor. The reset-all chip deliberately gets NO aria-label:
its visible words already are the action, and overriding them breaks voice
control.
Recording what this does NOT fix, because the measurement that prompted it named
the wrong file. DashboardFilterChips is exported from the kit and mounted by
NOBODY — round 1 of BUG-DUPLICATE-FILTER-BADGES removed it, and the strip on
screen belongs to SearchFilterHeader, whose remove button was already a real
<button type="button"> with a localized aria-label. So the keyboard half was a
latent defect closed before the next dashboard mounts it, not a live one. The
live defect in that same control was its size, above.
check:dashboard-filter-a11y guards all of it, on the kit component AND the strip
that actually ships. It walks the parsed template AST rather than grepping,
because grep cannot tell a bound :aria-label from a literal one, cannot tell a
button from a span named like one, and cannot see a live region hiding inside a
v-show'd cell. It also reads the built bundle, since source-correct plus
bundle-stale is a real repeat failure here. Fourteen mutants were run against it;
every one fails, naming the file and the reason. No browser — that is rega's.
STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid (the
composite bakes the shell in). Part D of the gate fails until you do, by design.
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).
First of the shared-library batch, and the one the shepherd put first because a
reader operates it: PosterDialog had seventeen English strings with no catalogue
entry — the whole dialog. Size, Orientation, Portrait, Landscape, DPI, Format,
both file-type options, Title, Subtitle, Slots, Scale bar, North arrow, the
footer's attribution note, Cancel, and its own heading and aria-label.
Every English string is kept exactly as it reads today, per the shepherd's
ruling, and the eleven locales are filled in. A new poster.json per locale: the
i18n layer assembles catalogues with an eager `import.meta.glob` over
locales/*/*.json, so a new area file is picked up with no index to edit.
Two of the nineteen sites needed no new wording at all — "Close" and "Legend"
already ship in every locale as buttons.close and legend.header.default, so they
reuse those rather than gaining a second spelling. That is the same rule that
sent the grid's "Clear all" to controls.reset_all: two keys for one word is how a
control ends up saying different things in different languages.
DPI is left as DPI in all eleven. It is a standard acronym, not an untranslated
string, and inventing an expansion per language would be the opposite of what
this batch is for.
★ THIS IS A LIBRARY CHANGE, so it is studio's to land under rule 5B — the
catalogue is compiled into every bundle, so every consumer needs rebuilding. I
have deliberately NOT rebuilt anything. Until it lands, every one of these falls
back to the English it reads today, which is the tStatic contract.
Measured before and after with the source audit: the shared-library
no-catalogue-entry count goes 49 → 32, and PosterDialog leaves the list entirely.
ShareButton, DataTable, LegendTools and QaCorner are the remaining batches, in
the shepherd's order.
maplibre-gl@5 and maplibre-gl-geocoder@1 were floating ranges, and no runtime loader set `integrity`,
so a CDN could serve any bytes to every partner's visitors unnoticed (CRITIC-5).
- library/constants/cdn.js is the one registry: mapbox-gl 3.24.0, maplibre-gl 5.24.0 (the release the
@5 range already resolved to), both geocoders (1.9.4 / 5.0.0) and d3 7.9.0, each with its sha384.
Every host answers Access-Control-Allow-Origin: *, so integrity + crossorigin work cross-origin.
- ensureEngineScript / ensureEngineCSS, the geocoder loaders (library and PPLR) and the generator's
static MapLibre tags set integrity + crossorigin from it; the embed snippet's engine preload matches.
- check:cdn-pins (gates:post): no floating CDN url in shipped code, and every one is in the registry;
--online re-fetches each and compares the hash (all 9 match today).
MapLibre stays on 5.x knowingly. GHSA-jrc7-96c5-q579 (DOM.sanitize, reached through a source's
attribution string) affects <= 6.4.0, and 6.x is ESM-only (no UMD global), so the upgrade is a loader
migration of its own. The attribution strings rendered today come from our configured basemap styles
and our own sources; the note in cdn.js says who is exposed until then.
The button is a toggle (a second press clears the map selection) but carried no pressed state, so a
second press was indistinguishable from a first to a screen reader. Reported by regions from rega's
re-shoot of the show-on-map fix.
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.
A reader on an Arabic page saw a handful of English words among the Arabic:
the table's "People Groups" title, its count line, the Engagement column, the
Gospel Access chart title, and the side drawer's close button. Every one of them
had a correct Arabic entry sitting in the catalogue; nothing looked it up.
THE CHECK CAME FIRST, AND IT REPLACES A SWEEP THAT PASSED A BROKEN TREE.
The old method sorted every rendered string into third-party / data / chrome and
counted only chrome. That split is a guess, and it guessed wrong exactly where
the defect lived: single words like "Region", "Prayer" and "Country" are as
plausible as values as they are as labels, so they scored as data and the sweep
reported zero on a page that was visibly leaking.
check:composite-locale-leaks asks a question with an answer instead:
the English catalogue holds this exact string as a value, AND
this locale holds a DIFFERENT value for that key, AND
the page still renders the English one.
No judgement about what kind of word it is — if the translation exists and the
reader is not getting it, that is the defect. The two surfaces a lookup cannot
judge (vendor map chrome, and table/facet cells carrying API values) are muted
by NAME with a stated reason, never by word, so muting one can never hide the
next real leak that happens to reuse it. The gate failed on this tree naming 9
strings before any of them were fixed.
Also threaded here: both composites now accept `lang` / `locale` in
profile-config and pass it to the children they mount, alongside `tk` and
`provider`. A partner writing {"lang":"ar"} reached nothing before — the knob
was documented and read by nobody.
The prayer facet's label in DashboardResearchFilters is now a catalogue getter
too. Measured before touching it: dashboard.config.js does define prayer_level
and FACETS spreads the config last, so that literal never rendered for this
config — it is a default for a config that omits the facet, and it stays, with
the merge order written down so the next reader need not re-derive it.
Four strings remain English with no catalogue entry at all — "Top countries",
"Select all {n}", the table dock's title, and the singular "group". They need
wording in ten languages that cannot be composed from existing entries, so they
are a DECIDE card for the Driver rather than translations invented here.
E RESIDUALS, from rega's in-gallery re-sweep. All three lived in SHARED
components, which is why a grep of my own plugin found nothing:
SearchFilterHeader its label map was a literal object of English defaults —
the fallbacks every host gets, since no host passes
`labels`. Filters, Selected, World and the chip remove
button now come from the catalogue; a host's own labels
still win, which is what the prop is for.
ResearchMapFilterPanel the two "Any" options and the "{n} active" pill.
DashboardFacetRail the Expand/Collapse aria template, which repeats per row:
one untranslated template is dozens of English strings in
a screen reader.
dashboard.config.js the facet SECTION titles (People, Place, …). The earlier
pass converted `label:` and missed `title:`.
★ AND A LOOKUP BUG THAT LOOKED EXACTLY LIKE A MISSING TRANSLATION. 'Any' and
'0 active' stayed English after being catalogued, because the panel asks for
`controls.<key>` while I had authored them under `dashboard.<key>`. The
catalogue had the words; the component was looking one namespace over. Keys now
live where the panel reads, and its lookup falls back across both, so a string
authored in either place renders.
MEASURED after, ru, on the built tree: 0 English chrome strings on Simple
Research and 0 on Big Picture.
One probe correction worth keeping: a substring match on 'People'/'World'
flagged 'South Asian Peoples' and 'Arab World' inside aria labels whose frame
was already translated — data, not chrome. Whole-string matching separates them.
TOUCH TARGET (regions' phone sweep): .rd-proj "🌐 Globe" measured 74x23 at
390x844, one pixel under WCAG 2.5.8 AA. min-height:24px under
@media (pointer:coarse) — stating the 24 rather than growing padding, because
the height is padding plus a line box the renderer derives from the font and the
arithmetic lands at 23.7. Mouse sizing untouched. MEASURED after: 73.5x34.
Every bundle embedding the changed shared components is rebuilt in this commit —
a shared fix is not landed until its consumers are.
Every conflict was a build artifact — another agent had pushed a full rebuild of
all 23 bundles. Minified .js and baked-SRI leaf pages are generated files: merging
their text is meaningless and can produce a bundle that matches neither side, so
they are resolved by taking one side and then rebuilding from the merged source.
The incoming diff carries 68 source files, among them library/composables/
useMapInstance.js and the dashboard/gospel catalogues in 10 locales, so the
rebuild is all 23 bundles and not only the ones I touched — regenerating the tree
without rebuilding has shipped a stale .js here before.
TWO FIXES THAT BOTH SHOW UP ONLY WHEN THESE PLUGINS ARE SOMEBODY ELSE'S GUEST.
1. ATTRIBUTE FALLTHROUGH. These SFCs mount AS custom elements, so a style the
host puts on the tag lands on the component's own root element. MEASURED with
a host style of "display:flex; position:relative; height:100%": that exact
string appeared verbatim on the inner root div of people-groups-grid,
stack-demo, research-map-plus-grid and dashboard-plus-grid. The gallery sets
one on every tab, and the same fallthrough collapsed the Prayer Walk Planner
to 76px. inheritAttrs:false on each root — a host styling the TAG is styling
the box, not our internals.
AFTER: the inner root carries no inline style on any of the four, and each
still renders full height (2997-4010px in the harness), so the guard did not
become the collapse it prevents.
Worth knowing for the next probe: shadowRoot.firstElementChild is a <style>
node — Vue injects styles first — so a probe that trusts it measures nothing
and reports "no fallthrough, nothing to fix". Take the first non-style child.
2. THE RESIDUAL CHROME the in-gallery re-measure found: the dashboard title,
Align Dashboard and its tooltip, World, Vertical Legend, groups in scope, the
per-row aria templates (Filter by / Remove filter / groups), and the map
drawer's aria label. Catalogued in all 11 locales and read through tStatic,
reusing existing wording where the catalogue already had the word.
The aria TEMPLATES matter more than their size suggests: they repeat per row, so
one untranslated template is dozens of English strings in a screen-reader's ear.
Their tape system supersedes mine and is the better invariant, so it wins whole.
I had made both stickers self-centre on the card's edge; they pin both by
--card-pad-top and --tape-overlap, which makes the OVERLAP onto the banner the
constant instead of the centre. That is what the Driver's rule actually asks for
-- extra height then grows into empty space rather than walking over the artwork
-- and one variable now governs both stickers so neither can drift from the other.
Kept only the orthogonal part of mine: the (pointer:coarse) block that lifts
.pbtn, .pstock and .tape-h to the 24px WCAG 2.5.8 minimum. Dropped my padding
override on .tape-h -- their rule sizes and places that sticker, and re-stating
its padding here would fight a geometry the Driver has already approved. Also
took their new .pstock.is-on state.
Fixed a comment of mine that the merge made false: it claimed both tapes
self-centre, which stopped being true in this very commit.
Rebuilt all 23 bundles, not just the ones I touched. The incoming diff brought
library/i18n/index.js and three shared library components, which reach nearly
every bundle -- and regenerating the tree without rebuilding has shipped a stale
.js here once before. tree + fix:sri after; check:sri PASS across 54 script
references on 55 leaf pages.
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.
The 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 already existed - SemanticTreeLegend renders an export
menu gated on a selection, the plugin passes :export-enabled, and
@export was wired. What the handler did with it was the problem: the
packet branch delegated to the shared composable, and the poster branch
hand-rolled an <a download> of posters/<id>.webp. That is wrong twice.
It downloads a file silently, which was ruled out on 2026-09-23 ("no
silent file download as the default action"), and it has no fallback, so
for the lists whose .webp is not shipped it saved a 404.
The wall had a correct poster print the whole time, private to
PosterWall.vue, so this surface could not reach it. It moves into
usePosterPrints.printPoster and both surfaces now call the one copy -
what the card asked for: "reuse the existing three print actions rather
than growing a fourth implementation." Two guards moved with it, each
learned the hard way: a THROWN fetch is a miss rather than an error (or
the rasterise fallback is skipped entirely), and a blank raster fails
loudly (an SVG in an <img> is an isolated document, so a missing
stylesheet paints black with no error).
Also in this commit, both from measurement rather than review:
THE STICKER OVERLAP IS NOW A CONSTANT, NOT HALF A VARIABLE. Centring the
print row on the banner edge put half its height on the banner - and
that height is content-dependent: 68px in en, 54px in zh, taller when ro
wraps at 390px. Measured ro/390px: 27 of 27 stickers on the map by 4px
while en and zh were clean at the same padding. No fixed padding can
clear half of a variable. The row's top is now pinned --tape-overlap
above the edge and extra height grows downward into the row gap, so the
amount on the banner is identical in all 11 locales at every width.
15 width x locale combinations now pass with map-covered 0.
THE PER-ROW PREFERENCE IS CLAMPED TO WHAT THE VIEWPORT HOLDS. It renders
as an inline grid-template-columns, and inline beats the stylesheet, so
the Driver's default of 2 overrode minmax(210px,1fr) at every width -
including 390px, where two columns give a ~162px card. Reported from a
390x844 phone shot. The preference is for desktop density, not an
instruction to overflow a phone; it survives, clamped by the same 210px
minimum the stylesheet already uses.
THE ROW GAP WAS CLEARING A DISTANCE NOTHING OCCUPIES. 88px was tuned
when the print row hung fully below its card. It now straddles the
banner edge, so the same shot showed a ~180px empty band between rows.
56px still clears a two-line row in the long-label locales.
And the composite shell re-dispatches doxa:select-zone, so a list chosen
in the poster wall is ISOLATED on the map rather than merely scrolled
to. The map half was already built and listening on its own element; the
shell owed it one re-dispatch and nothing here re-implements selection.
The card is a BANNER that displays a map, the way a label displays a
design on a bottle. A banner taped up gets a sticker at each end, and
each sticker straddles the edge it tapes: part on the banner's own white
space, part overhanging. Neither one touches the map.
MEASURED at 1440px before this change:
banner 240-426, map 241-425 -> 1px of white space at each end
header sticker 35px above the banner's top edge, -1px below
footer sticker 1px above the bottom edge, 67px below
So the map filled the banner edge to edge and there was no banner
surface for a sticker to sit on. The footer row could only hang off into
space, which is what shipped, and every assertion in the gate was green
through all of it.
The banner now reserves white space at both ends (--banner-pad-top /
--banner-pad-bottom on .main), and each sticker centres on its edge:
header sticker 17px on the banner / 17px above it
footer sticker 35px on the banner / 33px overhanging
map covered by either: 0px
The print row also moved INSIDE .main. It was a sibling, so top:100%
resolved against the CARD, 27px past the banner - the re-parenting and
07d3341c's centring transform are one fix, and neither works alone.
That commit's per-row localStorage preference is preserved.
Card Deck is no longer a peer button with a 162px <select> beside it.
The stock picker is a disclosure: press Card Deck, choose a size, and
that choice prints. A native dropdown in the row collides with the host
page's own dropdowns, which is why it is buttons rather than a <select>.
Print-button labels WRAP instead of clipping. With the per-row default
now 2, a 390px viewport halves the card to ~170px, where Romanian's
"Pachet de cartonașe" cannot share a line: measured 54 of 81 controls
clipped at ro/390px, en and zh clean. The ruling on this wall is that a
long label wraps; nowrap was forcing a clip.
Also fixes the transparent strip above a pinned section header. Sticky
pins to the scroll container's PADDING box, so with .wall's 22px
padding-top the header rested 22px down and posters scrolled visibly
through the gap. A ::before extends the header's opaque fill upward by
exactly that padding, read from the same var so the two cannot drift.
Measured by sampling the strip's pixels: 1 colour, 100% header
background, where before it was 1338-2831 colours of poster artwork.
The MFE shell's own chrome is translated, in all 11 locales. Measured
with ?lang=ar: "Select View", "Views", "Prayer Resources" and "Map"
rendered English while the poster wall's buttons inside the panes
translated correctly, because each pane installs i18n and the wrapper
around them never did - the chrome a reader meets FIRST was the
untranslated part. The wall's own heading, tag and intro go through t()
for the same reason, with the <strong> inside the translated string so
each language emphasises its own words.
The gate grew four assertions, each mutation-tested to fail on the
defect it was written for: off-frame (row floated off the banner),
head-off (header sticker not straddling), map-covered (a sticker on the
poster), and selects (a native dropdown back in the row).
Includes a rebuild of the four PosterWall consumers, which also picks up
studio's shared useMapInstance unmount guard and exp-shepherd's
translated LegendMobile labels.
Measured under ?lang=ru on both composites: every Latin-script string left on the
page was ours. The map above was fully translated; underneath it the grid, the
map's own side menu and filter panel, and the whole research dashboard were
English in Arabic, Chinese, Hindi, Russian and everything else.
Three surfaces, three reasons, none of them a missing translation:
the grid read labels from a host-supplied `t` object that nothing ever
filled, with an English literal at each call site as fallback
side menu +
filter panel plain string literals
dashboard labels declared as literals in dashboard.config.js — facet
rail, chart titles, KPI strip, table headers, segmented
control, search placeholder
New catalogues for all 11 locales (grid, controls, dashboard), REUSING the
catalogue's existing wording wherever the word already existed — Регион, Блок
родства, Население, Вовлечён, Графики — so two panels never name one thing two
ways, and nothing is coined that the project already has a word for.
Read through a new plugin-free tStatic() rather than useI18n, because these
components are also mounted by entries that install no i18n plugin. An unguarded
useI18n() throws and a thrown setup() removes the subtree with no build error —
the defect that had just blanked people-groups-grid everywhere (6603dc17).
★ THE CONFIG LABELS ARE GETTERS, AND A SPREAD WOULD HAVE LOOKED IDENTICAL.
dashboard.config.js is imported once at load; the locale is only known later. My
first version spread a getter object, and a spread COPIES THE RESOLVED VALUE, not
the accessor — every label would have frozen at whichever locale loaded first,
and it would have looked perfect in English. `get label() {…}` resolves on each
read.
MEASURED after, served built tree, identity checked first: Big Picture ru/ar and
Simple Research ru/zh have ZERO Latin-script chrome strings. What remains is
Mapbox's licence attribution (never translate), country and people-group names
(data), and 'UUPGs' — which every locale file deliberately keeps as the acronym.
Three gates went red on the way, two of them mine and worth recording:
check:locale-reachable slices this i18n module between two markers and EVALS
the slice; I had put tStatic between them, so the gate
hit an `export`. Moved to the end of the file.
check:library-index my import+helper above a component's docblock became
that component's DERIVED description. Moved below it.
check:kpi-unit its parser assumed single-brace entries, so getters made
it find ZERO pct KPIs — and it failed rather than
printing a green PASS about nothing, which is the gate
working. Parse now understands getters AND checks the
translated labels, so a '%' reintroduced in any of the
11 catalogues fails by name. Both controls fired.
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.