A back-to-top on mobile, next to the hamburger circle, for the people-group grid and the regions list. Both scroll their own container,
so the container is a prop and this component owns only the affordance.
It does not know where the circle is, and must not guess. .vtl-fab is 44px at a
12px inset today, and it lives in the layout outside every plugin's shadow root.
Hardcoding 64px here would put the two on top of each other the day that changes,
silently, on a phone. So the layout publishes --map-fab-beside alongside the
clearance it already published, and this reads it — falling back to the plain
inset when there is no circle, which is also the standalone case where nothing
sets it. The circle's geometry is stated once, by the component that owns it.
It also refuses to appear when it cannot work: visible tests that the container
actually scrolls, not only that scrollTop is large. An arrow that does nothing
when pressed is worse than no arrow, because the reader spends a tap finding out.
And it honours prefers-reduced-motion, since a long list jumping is exactly the
motion that rule exists for.
The failure mode here is a var NAME drifting between publisher and consumer:
nothing throws, the fallback applies, and the button quietly sits under the
circle again. Checked rather than assumed — every published name is consumed and
every consumed name is published.
NEEDS EYES: I cannot see 44px or a hover state. The logic is small; whether it
lands beside the circle rather than over it is a render question.
The Driver scrolls a legend to the end on a phone and the last rows sit under
the hamburger circle. The circle is .vtl-fab: 44px, pinned bottom-left, rendered
only when narrow and headerless with the drawer shut.
It wins on paint for a reason no z-index can fix. The FAB lives in the LAYOUT,
outside every plugin's shadow root, so it and the legend sheet are in different
stacking contexts; the sheet is z-index 1000 and still loses. Raising the sheet
would not have worked, which is why this reserves space instead.
The layout now publishes --map-fab-clearance, and only while the button is
actually on screen; custom properties inherit through the flattened tree, so it
reaches inside each plugin's shadow root. The mobile legend's scroll area adds
that to its bottom padding, defaulting to zero. No circle, no reserved space,
and a plugin used without this layout is unchanged. The padding is additive, so
nothing that works today moves.
The button's v-if and the clearance are now ONE computed rather than two copies
of the same condition. Two copies is the bug waiting to happen: drift one way
and the legend ends under the circle again, drift the other and there is a strip
of dead whitespace with no circle to justify it. Neither has a symptom any gate
can see, so disagreement is made impossible rather than tested for.
Measured, not guessed: the earlier reading of this card found the top-left map
control at top:8px and concluded the geometry did not match the report. It did
not — that is a second, real collision in two plugins, and a different bug.
DRV1650-SHARE-MENU-MOBILE-OFFSCREEN. On mobile the share/embed menu opens off-screen to
the right on Cities with Internet and Gospel Resources, while Simple Research and Prayer
Progress are correct.
THE SPLIT IS NOT A PLUGIN DIFFERENCE. MapControlMenu resolved the area it places inside
with closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]') -- three
hardcoded plugin class names. doxa-simple-map and research-map are on that list. Everyone
else resolved `|| null`, and place() answers a null area by returning an EMPTY STYLE,
which leaves .mcm{position:absolute} at its STATIC position: below-and-right of a button
already at the top right. Off the page.
So the two maps that worked worked because they were named in a string in another
component, and the bug was decided by a literal rather than by geometry. It is also the
SAME failure the 2026-09-22 dashboard fix documents twenty lines below -- not "a wrong
side was chosen" but "nothing chose anything" -- returning through a different door.
MEASURED: cities-internet, gospel-resources, key-cities and upg-neighbor carry no
map-area class and no data-map-area, so FOUR plugins were unanchored. The Driver reported
two. A fix aimed at the two he named would have left the other two, and a gate written
around them would have passed.
The named area is still preferred, because it is a real map's walls and a menu must not
outgrow them. When a plugin declares none, the anchor falls back to the toolbar's own
offsetParent -- the containing block this menu is absolutely positioned against, so
placing against it is correct by definition rather than by registration. A plugin that
adopts a class later simply gets the better anchor; nothing has to be registered anywhere
for a menu to be on screen.
check-map-menu-anchor asserts the PROPERTY rather than the list: that the resolution
cannot produce null from a real toolbar, that place() still guards a genuinely absent
one, and that a declared map area is still preferred over the fallback. Negative-tested:
restoring the allowlist-only resolution turns it red.
NEEDS EYES: this is rendered geometry and I work headless. What I can prove is that the
anchor is no longer decided by a name; whether the menu now sits correctly at 390px on
all four is rega's to measure.
The ten ring names settled between gospel and exp, derived from exp's ringFor()
rather than hand-numbered so the numbering cannot drift from the code that
produces it. The screen legend already renders these words, so a printed sheet
using them cannot disagree with the screen.
Written as vocabulary in the header a caller reads, and deliberately NOT as a
prop default. One cell is still open on the Driver's row 89 — "people" versus
the brief's "culture" in column 1 — and if he keeps his, exactly three cells
change, 1, 4 and 7, and nothing else: not the numbering, not the columns, not
the colours.
A default would bake a provisional word into every consumer that forgot to pass
one, and would make this component the thing that decided a Driver question.
Same reason ShareCalendar refuses to name its own metric.
Measured on the published file: those labels run 7 to 206 characters and the
long ones are the zone's member list, not its name. Tibet's is a ten-part list
of provinces. Only two of the fourteen carry no member list at all.
Used as a heading on a printed sheet that sets a 206-character title, and paper
has no truncation-with-tooltip to rescue it. The name a reader wants is the
humanised slug, uniform by construction and about 34 characters at worst, with
the label carried separately as the definition it actually is.
This component asserts nothing about the words in labels.rings, so the whole
hazard lives at the call site — which is why the warning belongs in the file the
caller reads rather than in a message they will not see. gospel found it.
gospel's boundedSet and centeredSet move to library/components/charts, and every
importer follows in the same commit: gospel-internet's two components and the
gate. One implementation, no window in which someone edits the wrong copy.
gospel chose this over copying, for the reason the shared scatter already showed.
Two print-safe components come with them, adapted from gospel's rather than
written fresh: theirs fetch and hold board state, these take joined groups and
every label as props, so the packet and the board draw the same figure from the
same code. gospel's already inked their tallies and used no hover, so the
adaptation is the data path and the labels, not the drawing.
The labels had to change, and not for style: gospel's read T('gospelInternet.…'),
and a gospelInternet-namespaced key cannot resolve for another area's consumer,
so the packet would have rendered the English fallback in every locale. Labels
are props now.
Three invariants gospel gave me that the comments did not carry are pinned here:
No seed prop. Position is hashed from the group's own id, so a group lands in the
same place on every render, forever — two printings of one packet are identical
and a reader comparing sheets sees real movement rather than reshuffling. I had
specced a seed; it would have silently rescrambled every dot while the data was
unchanged, which on paper cannot be corrected. Asserted through the parser, and
adding one back turns it red.
Every ring is listed, empty ones included. Under a filter several of the nine go
to zero, and a decoder that omits its empty rows stops being a decoder: a reader
cannot tell "none here" from "this ring does not exist".
And an arrow is a claim about a relationship, so a group with no recorded
neighbour gets none rather than a default one toward the centre.
The unknown-is-counted rule is now in the module header where the next reader
meets it first, at gospel's request: a printed page has no hover, so a category
the reader cannot see is one they will assume is zero.
By the regions owner. An anchor week past the grid, negative or fractional passed an isFinite
guard and matched no cell, which on paper reads the same as the deliberate no-anchor default.
anchorPlacement() returns a reason, LifeCalendar prints it, and check:packet-charts covers six
edge cases while pinning that no anchor asked for stays silent.
The Driver asked for the SAME caret the cards already have on the Prayer
List, with two entries -- Full detail and Lightweight -- and said plainly
not to destroy the print options that exist. So nothing is merged, nothing
is removed, and the Poster is untouched.
There was no dropdown COMPONENT to reuse: the cards' caret is an inline
disclosure, one ref plus a sibling tape of plain buttons, deliberately not
a <select> because a native dropdown collides with other dropdowns on the
host page. So the PATTERN is generalized rather than copied. deckOpenFor
now holds a compound `${id}:${which}` key, which keeps "opening one closes
the others" free -- copy/paste would have given two open-states, and two
tapes could then sit open at once and overlap in the grid gap.
While wiring it, a real defect surfaced: THE WALL'S PRAYER LIST BUTTON HAS
NEVER PRINTED A PACKET. downloadPacket calls printList, a columnar table of
name/country/population/religion. The packet -- map, life calendar,
%-evangelical grid, triangle, profiles, wheel, points -- is printPacket,
which every other surface calls and this wall never did. That is a direct
cause of the packet "not following the design that was asked for" here: no
change to the renderer could reach a button that was not calling it.
printList stays exported for any surface that still wants the list.
The two packets are a FILTER over the existing page array, not a second
code path, so they cannot drift apart in layout, i18n, RTL or print scale.
The filter is keyed and never indexed -- an index filter reads identically
today and drops the wrong page the first time the order changes, and that
order is the Driver's. listPages is deliberately outside the filter: a
variant may drop a FIXED page, never one whose count depends on the data,
because that drops people silently. An unknown variant falls back to full.
internal/check-packet-variants.mjs pins all of it, and was mutation-tested
rather than trusted: dropping list pages, reverting to printList, adding a
second open-state ref, and typoing a variant id each fail with their own
message.
The funnel computed each bracket with Math.min and Math.max over the step
indices an overlay touches. On an empty list those return Infinity and
-Infinity, so the bracket rendered at y=Infinity with height -Infinity: an
invalid rect that SVG discards without a word. The overlay vanished and took its
groups with it — no error, no gap, just a chart missing a band nobody knew to
look for.
An overlay is unplaceable when its `within` is missing, empty, or names a step
that does not exist, and all three are easy to write. `within` is the unusual
half of the API, so an overlay written as { id, label, groups } looks complete
and is silently undrawable, and a typo in a step id deletes a band rather than
failing.
bracketSpan now returns null for all three, the component lists them in ink
instead of dropping them, and the arithmetic moved out of the template where it
could be asserted. An overlay with no known relationship to the steps must not
be drawn, because a drawn bracket asserts a span.
Found by reading the template rather than by a test, which is worth recording:
these components are in L61 and had never been rendered once. The negative
control pins the cause — Math.min of an empty span really is Infinity — so the
assertions rest on the measured behaviour and not on my account of it.
VisionFunnel, LifeCalendar and ShareCalendar, all props-driven, no hardcoded
list, print first: fixed px on the packet's 816x1056 canvas, nothing revealed by
hovering, and the packet's own ink rather than a new palette, so a reader cannot
tell which page came from the component and which from the renderer.
The funnel draws steps as stacked trapezoids and overlays as brackets BESIDE
them, never inside. Being inside a step is precisely the claim an overlay cannot
make: 362 of DOXA's 2,107 groups are not frontier at all. It asks
overlayPlacement whether an overlay is a slice rather than deciding, and prints
each overlay's parts as text, because a bracket nobody can hover over explains
nothing. Given steps that do not nest, or that mix publishers, it refuses and
prints why instead of drawing a plausible picture.
It is also not mirrored under RTL, which is worth saying because the obvious
reading of "mirror it" is wrong here: the triangle is vertically directional, so
a horizontal flip reverses nothing meaningful and inverts any text inside it.
What mirrors is the label and bracket side, so those use logical properties.
Two decisions now live in prop declarations, and a default is how either dies
quietly. ShareCalendar refuses to name its own metric: the shipped figure is the
evangelical share while the brief says "% Christian", and one default would
reintroduce the confusion this project already retracted a P0 over, with nothing
looking wrong. LifeCalendar's anchor defaults to null because the packet prints
for a reader we do not know and shading their life is a guess presented as fact.
Both are asserted through the SFC and JS parsers rather than a grep, since a
regex cannot tokenise JavaScript, and a sibling prop that does carry a default
is the negative control proving the arm is not vacuous. Adding
default: '% Christian' turns it red, which I checked rather than assumed.
packet-render's lifeGrid already held a carefully reasoned shading: deterministic
so a packet reprints identically, and scattered because a contiguous run of 32
fills most of row 1 of 52 and prints as a solid bar across the header, which is
the ordinary chart the life grid exists to avoid. The message is rarity, and the
eye has to hunt for the 32 among 5,200.
That logic is now in packetCharts.js and packet-render imports it, so the
printed packet and the reusable chart cannot drift into two answers. The move is
behaviour-preserving: I ran the extracted function against the pre-refactor code
over 49 seed and count combinations with zero differences. That probe was
one-off, so the durable half is a golden fingerprint here — the exact cells for
one seed. If the algorithm is ever tidied, those change and every packet already
printed stops being reproducible.
The assertion that matters most is the one that did not exist when the bar
shipped. The existing check asserts the shaded COUNT is between 0 and 5,200,
which is equally true of a bar and of a scatter: a count cannot distinguish a
distribution, so no amount of that gate passing was ever evidence. rega's eyes
caught it, and nothing else could have. Now the spread is asserted — 32 cells
landing on 27 different rows — with the contiguous block as the negative
control, so the arm can fail in exactly the shape it exists to reject.
The rounding flag upg100 asked to keep reachable stays on the return value, so
the packet can print the honest caption when a share is too small to fill one
cell rather than inking one and asserting "some".
These numbers print. A packet is handed to a reader on paper: no hover, no
tooltip, no correction, no redeploy. So the arithmetic lives in its own module
and is asserted against the shipped data rather than a fixture.
DOXA IS NOT A SLICE. Of its 2,107 groups, 2,082 are unreached and 1,745 are
frontier, so 25 are not unreached at all and 362 are not frontier. That is one
group in six. A chart drawing DOXA as a fourth band nested inside frontier
misstates 17.2% of the list with a clean shape and no error of any kind, which
is why steps and overlays are different props and an overlay reports whether it
is a slice rather than being assumed to be one. upg100 asked for the numbers to
reach the caption as text too, because a printed band nobody can hover over
explains nothing.
Three bugs I wrote, each caught by an assertion rather than by review:
The publisher warning sat in the wrong function. I put "Joshua Project counts
16,426 where IMB counts 12,379" into the nesting error, and the test proved the
claim false: 16,426 on top of 12,379 is perfectly monotonic. The two are not the
same kind of count, and no comparison of magnitudes can see that, so it needed a
separate check reading what the numbers are OF. An error message naming a cause
its own code cannot detect sends the next reader to look for the wrong thing.
A percent read as a fraction filled 3,189 cells of 5,200 instead of 32. My
heuristic was "above 1 means percent", and pctEvangelical is 0.6133, which is a
valid fraction and a valid percent with nothing in the value to separate them.
The refusal I added catches a percent of 1 or more and provably cannot catch
ours, and every share this packet prints is below 1% because the subject is
unreached peoples. So the guard is not the protection, the name is:
fractionFromPercent states the unit in the source line. Both halves are
asserted, including the half that does not work, because a guard believed to
cover more than it does is the worse failure.
And the rounding flags were untested until the test used values that could
reach them. 0.6133% of 5,200 is 31.9 cells; the reader sees 32 and cannot see
31.9, so the exact value goes in the caption and a share too small to fill one
cell is flagged rather than drawn as one.
gospel found a latent ambiguity in the emit contract while wiring, and checked
it instead of building past it. selectedId emits one bare string with no kind,
and region ids and zone ids share a namespace. Re-measured here rather than
taken on report: 33 zone ids, 7 region ids, and the intersection is exactly
europe, whose region is itself. So the string "europe" cannot tell a consumer
whether the Europe region row was clicked, meaning every European zone, or the
Europe zone leaf, meaning that one.
It is not live today, and only for a reason that is a coincidence rather than a
design: region europe contains exactly one zone, so both readings give the same
set. It goes live the day a second European zone is filed, and then it fails the
quiet way this whole file exists to catch — a smaller result, no error, no empty
state.
So this is an alarm, not a fix. The consumer builds the tree, so only the
consumer knows which rows are regions, and gospel is namespacing theirs. Two
arms: the colliding set is pinned and says re-derive if it changes, and each
colliding id's region must still contain exactly one zone. The second fails with
the instruction rather than the symptom, naming the zones and telling whoever
hits it to namespace their region rows.
Both falsified before trusting them: filing a second European zone fires the
ambiguity arm with both zones named, and adding a second colliding id fires the
drift arm with both ids listed. Data restored, clean diff.
The same note is on selectedId, because the next consumer to build a tree from
this zone data inherits the collision, and the doc as written invited it.
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.
The eight labels came from tStatic inside a computed. tStatic reads a plain window global the
computed cannot track, so its only dependency was props.labels and the row stayed in the boot
language after the host switched locale. They now go through useOptionalT, which calls the
host's vue-i18n (a reactive locale) when installed and falls back to tStatic when not; English
stays the fallback and a host's own labels still win.
retryableAsyncComponent: after the three retries the pane rendered blank. It now shows
"This view couldn't load." with a Retry button (messages keys, 11 locales). Retry swaps in a
fresh async component, since Vue keeps a rejected load for the life of the page.
Each map control wrapper passes data-control (zoom-in, zoom-out, fullscreen, theme, location,
help, share) to its button: an untranslated hook for probes, where title and aria-label are
localised.
Six conversions, one deliberately kept physical. Same method as the legends.
CONVERTED
DataTable.vue:238 .tabulator-cell border-right -> border-inline-end. A per-cell
trailing border is the COLUMN SEPARATOR between adjacent cells; in RTL the
separator has to sit on the other side or it draws on the table's outer edge.
DataTable.vue:269 .dt-actions margin-left:auto -> margin-inline-start:auto
DataTable.vue:293 .dt-close margin-left:auto -> margin-inline-start:auto
Both are "push me to the trailing end of a flex row", which is the left in RTL.
DataTable.vue:299 .dt-dl ul padding-left -> padding-inline-start. A list's
marker indent follows the reading direction.
StackLayout.vue:161,305 .stl-meta margin-left:auto -> margin-inline-start:auto
(twice: the light and dark theme blocks).
★ KEPT PHYSICAL, AND IT WOULD HAVE BEEN A REGRESSION TO CONVERT
DataTable.vue:245 '.dt:dir(rtl) … .tabulator-cell{text-align:right}'
It is already SCOPED TO :dir(rtl), so right IS the trailing edge wherever it
applies. More importantly the cells carry unicode-bidi:plaintext, which makes
`end` resolve PER CELL from that cell's own first strong character -- which is
the exact behaviour this line exists to override (English cells left-aligning
inside an rtl table and clipping at its left edge, rega L20). text-align:end
would silently undo the fix. Comment added at the site so the next reader does
not "correct" it.
No consumer overrides any of the four classes: .dt-actions, .dt-close, .dt-dl and
.stl-meta appear in no other file in library/ or plugin-designs/, so a physical
override cannot defeat the logical rule and produce a half-mirrored table.
LTR is unchanged: inline-start/inline-end resolve to left/right under dir=ltr.
check:sfc-parse PASS. 13 plugin files use DataTable, 9 use StackLayout.
library/ is the build owner's under rule 5B -- source sha to studio; rega eyeballs ar.
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.
The poster dialog had only a download path: its Render button saved a file, the
same trap PosterWall's old default was (gospel's Driver-lens audit, card 7).
A new action prop defaults to 'print': a PNG poster opens the print dialog in
place (printImageBlob, sized to the chosen page), a PDF opens in the browser's
viewer, and a blocked print frame or popup falls back to the file. 'download'
stays an explicit opt-in.
Driver rule 2026-09-28: an export-poster button opens the print dialog with a
preview, never a direct download. PosterWall defaulted posterAction to
'download', so country-posters and key-cities, which set nothing, downloaded.
The default is 'print'. check:poster-action-print (gates:pre) keeps the default
and fails a consumer that opts into 'download' without a
"posterAction-download: <why>" comment. PDF packets and CSV exports are not
poster buttons and stay downloads.
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.