Seventeen lines in 15 files. Six quoted short fragments from feedback; eleven used "design note" where a
person once stood ("design note selects 2+ rows", "a design note design call"). Each now states the
reason in neutral terms and keeps the technical content and dates.
All edits are comments, except one string: the safety_note text in
library/scripts/research/derive-city-candidates.mjs, a research script that is not bundled and whose
output is not committed. No logic changes.
The census on the assembled branches still found eleven comments that attributed a choice to a dated
request, quoted it, or spoke in the first person: pplr-data-maps (geocoder, pin card, pin indicator, tree,
families, religion, semantic trees), research-dashboard's ramp note, upg-100-list's funnel note, and two
dated QA tags each in countries-map and research-map. Comments only; behaviour unchanged.
Scrub remaining internal role names, quoted directives, person-tied
dates and first-person phrasing from comments in connectivity-list.vue,
build-adm1-internet.mjs, research-map.vue and research-map-plus-grid.vue,
keeping each technical fact. Comments only; behaviour unchanged.
Rewrite code comments across doxa-research-map, upg-neighbor,
research-map-plus-grid, rudder-voice, people-groups-grid, big-picture,
prayer-walk-planner and all-people-groups to drop internal role names
from measurement and provenance notes, keeping each technical fact.
Comments only; behaviour unchanged.
In a right-to-left footer the range "10:11–15" printed as "15–10:11": the numbers of each reference now sit in a
left-to-right isolate (packet-render's own split of book and numbers). The research-map-plus-grid composite
forwards the host's mapTitle to its map, which it did not pass on before. And the packet strings are fetched
when the map mounts (when the browser is idle), so the print's 3 s wait reads them from the cache instead of
dropping the line on a slow network.
The map's "Export poster…" now prints through a shared template: always landscape Letter; a header with the
map's name (from the host's profile-config mapTitle), the view on screen as its subtitle; the legend as a
side panel; and a footer line from the prayer packet's final page (prayer point 6 with Luke 10:2 and
John 10:11-15, in the reader's language). While it renders, a loading screen like the poster wall's shows
the renderer's own progress. Without a mapTitle the view on screen stays the title, as before.
The paper turns landscape when the map is wider than it is tall (11in x 8.5in, a 2200x1700 image),
and the gate still asserted portrait only, so it failed a correct print. It now accepts either
orientation of US Letter, at least 1600 px on the short side and 2100 on the long one, and fails an
image whose shape is not its sheet's. The shape test is a negated <=, so a missing image fails.
The phone sheet spans the map's bottom edge and covered the end of the bottom-left badge, which read
"MapLibr". The map's credit already names the engine. Desktop is unchanged.
Every other caller of the in-place print moves onto the phone path the poster wall already uses:
printImageBlob (the map's poster/download dialog and research-map) prints the page itself on a phone,
and the gospel tree, the prayer-walk planner and the wall of people groups call printDocument. A caller
without its own progress dialog gets a shared Print bar when the tap has expired, labelled through the
caller's own translator. Desktop keeps the in-place frame unchanged. Source only; the bundles are rebuilt
when it lands.
The research map draws the ⓘ as the first button of its own control column (MapInfoButton), and the
gallery's Simple Research row switches to trigger: 'control' in the same commit, so the tab never
shows two ⓘ, and never shows none. At mount the map tells the gallery that its control is coming, so
the floating ⓘ does not flash while a slow phone loads. A failure or an unmount withdraws that
promise, and the floating ⓘ comes back at once. On a page with no gallery nothing answers, and the
map is unchanged.
When the reader switches tab while the research map is still starting, the gallery releases the
pane, and initializeMap returns quietly with no map. The handler logged that as "Map failed to
initialize — check token and container", so the console error landed in whichever tab was then open
and blamed the token. A released container is now a quiet cancel.
- research-map.vue's openPacket read and wrote _packetBusy, a name 8ab3dc0f5 replaced with the
reactive packetBusy ref. Reading an undeclared name throws, so the packet export threw a
ReferenceError on every click, in doxa-research-map and in research-map-plus-grid, the production
map that compiles it. It uses packetBusy.value now (check:no-free-identifiers).
- check:list-prop-unit and check:optional-t-equivalence read every entry of library/i18n/locales as
a locale, and the folder has held a README.md since bba33e783. They read locale FOLDERS now.
- people-groups-grid's regions-look instance previewed two props (filters-toggle, backdrop) of a grid
merge that is not on development, so the page showed nothing different and
check:instance-config-keys failed. It returns with that merge.
Resolving package.json conflicts by unioning unique lines is right for a file
that holds one entry per line. Applied to ordinary source it duplicates
declarations, because both sides of a hunk keep their copy and nothing
de-duplicates across the join.
Eleven plugin entry points ended with the same import twice, and the poster
renderer with two identical `const sheet`. Each is a SyntaxError: every one of
those bundles would have failed to build, and the poster export would not run.
Found by parse-checking every changed .js against the merge base, not by the
gates -- gates:pre passed 101 of 102 with all twelve files broken, because its
parse gates do not cover plugin entry points.
One measurement trap worth recording. `node --check` on a .js file parses it as
CommonJS, so a bare `import` is a syntax error and ALL 28 entry points appear
broken, on development too. That reading says the tree is on fire and is simply
wrong. Copying each file to a .mjs and checking that parses it as a module,
which separates the eleven that are genuinely broken here from the rest that
are fine.
The consolidation brought back two Driver citations from older branches that
the reference sweep had already cleared from development. Both keep their
engineering reason and lose the speaker: a gate's rationale for existing, and
the mechanism of the print collapse.
Caught by check:no-new-driver-references, which is the point of running the
full chain rather than the seven gates the merged package.json had left.
useLegendMapBridge took the idle paint helper as an injected dependency and
called it five times, while each consuming map defined its own copy of the
one-line body and handed it over. The library owned the paint logic and not the
expression it painted with, so the copies could drift apart silently and the
two maps would render the same idle state differently.
Export it from the composable instead, and take it from there in this map. The
injected parameter is deliberately KEPT for one round: inside the composable
the parameter shadows the module export, so what the bridge paints with is
still exactly what its callers pass, and this change cannot move behaviour. It
is a deletion in the plugin and an addition in the library, nothing more. The
parameter goes when the second map has adopted the export too.
This map held a THIRD copy under a different name -- a function-scoped helper
in the paint prewarm, byte-identical body, used once. It now refers to the
library helper.
Verified rather than assumed: the library version and both original plugin
versions produce identical output for every value the maps actually pass,
including the dynamic one. check:region-paint-single-source is green for this
map and stays red for the other until it adopts the export, which is the state
the gate is meant to report.
The second map is the other plugin's file and is not touched here; its owner
takes that half.
Twenty-six references in capitals, which the earlier case-sensitive pass
could not see. Every requirement, date and measurement is kept: the 2026-09-23
instruction that removed the engagement and status filters, the MapLibre ask
with its Mapbox-compatibility condition, the blue-water-black-galaxy style,
the red ramp and the bin that collided with the heartbeat zone, the white
rings around the blue dots, the zoom that did not work inside the bounded map.
Two that are not quotes and were handled differently. The reduced-motion gate
opens with a medical reason — a reader of this map is photosensitive, and a
camera that sweeps the globe on every filter is a strobe — which is the whole
justification for the gate, so it stays as a fact about a reader rather than
about a person. And BpResearchHeader's "CAT-1 DRIVER CONTRACT" was a code
term; it is now the CAT-1 HEADER CONTRACT, checked for other references first.
Eleven lines broke and were found by re-reading every changed line, not by the
count. Removing an attribution left seven continuation lines holding the
closing half of a quotation that no longer opened, and four sentences carried
a pronoun for the speaker who had just been removed. The reference count was
correct at every point while the prose was wrong.
grep -wi driver across the three plugins: 0. The widened gate reports 40
swept, 14 still baselined. All six SFCs parse.
From the fleet census: 620 function bodies indexed across 29 plugin trees,
16 identical in two or more. These are the four cheapest — none closes over
anything beyond its own parameters, so each is a file move plus an import
rather than a new dependency to thread.
hostElement resolves the custom element a component is mounted inside, one
hop out through the shadow root. It was identical in three plugins, and it
is the piece worth sharing for a reason other than line count: every plugin
ships through defineCustomElement, a composite nests two shadow roots, and
code that forgets the hop operates on the wrong node while looking correct.
A document-level query for a node inside a shadow root returns null on a
working build, which reads as a broken feature rather than a wrong query.
Three copies were three chances to write that.
findScrollportAncestor walks up to the nearest ancestor that actually
scrolls, crossing shadow boundaries on the way — parentElement stops at a
root, so a plugin in a custom element would otherwise find no scrollport and
conclude the page does not scroll.
findNodeInTree and buildFlatLegendMatchExpr join useLegendMapBridge, which
already holds four functions from the same seam. They sit at module level
rather than inside the factory because they close over nothing: a pure
function hidden in a closure can only be reached by constructing the whole
bridge.
Call sites keep their old names through a one-line local binding, so no
call changed and the diff is the declaration plus an import.
Rebuild-and-compare, all five affected bundles, before -> after:
regions-dashboard 1,098,469 -> 1,098,479 +10
country-posters 1,484,053 -> 1,484,028 -25
doxa-research-map 1,354,051 -> 1,354,061 +10
dashboard-plus-grid 915,660 -> 915,644 -16
big-picture 366,907 -> 366,719 -188
net -209, and country-posters gains 25 bytes against the 1.5 MB ceiling.
Every behaviour-bearing token counted, not set-compared, and unchanged in
all three map bundles: regions-fill, peoplePraying, engagementStatus,
adoptionStatus, overflowY, scrollHeight, getRootNode, _flat_filter, religion.
Owners cleared their own files first: regions for regions-list.vue, upg100
for countries-map.vue. check:selection-kinds, check:legend-matches-source,
check:legend-data-contract, check:packet-variants, check:no-dead-library and
check:no-cross-plugin-imports all pass.
gospel found 12 real defects — including a search box's aria-label, which is
screen-reader text — inside 88 findings they had already described as mostly
deliberate. None were visible from the count. They classified every remaining
finding by hand and named the two shapes the gate miscounts.
A string compared with === against table data is a value, not a label.
`scope === 'Full Bible'` tests a cell of the shipped resource table; the
reader never sees that literal, and the row it selects renders through a key.
Translating it makes the comparison miss and the feature stop, while the
count falls and it looks like progress — the same inversion as a lookup-table
key, which is why gospel classed them together.
A separator followed by a brand is not a sentence. `{{ t('…') }} · GRN`
splits into a keyed half and the text node "· GRN", and only the second
reaches this scan.
The names are LISTED, not patterned. "Punctuation then a capitalised word"
would swallow "· Not found in any pool", a real caveat, and that is the class
gospel's 12 defects were hiding in. A name earns its place by being a
publisher or standard that is the same word in every language.
Each quiet case is paired in the self-test with the nearest thing that must
stay loud: a caveat after the same separator, prose that merely begins with a
brand, a real English sentence used as a table key, and an .includes()
argument. Poisoning the name list into a punctuation rule fails.
Poisoning the operator check did NOT fail, which is why there is now a
concatenation case: `greeting + 'Welcome to the prayer map'` is a
BinaryExpression too, so accepting any operator would silence a sentence
assembled for the reader. Only an equality test means "compared against
data".
Also the last finding in this owner's plugins. All seven main tabs resolve
through tabs.<id> in all eleven locales, so their English labels could never
render — a second copy of each name that could only drift. The sub-tab tables
lost theirs the same way.
Fleet 152 -> 143. This owner's plugins: 1 -> 0.
countries-map.vue and research-map.vue share 1,756 lines across 60
contiguous blocks, and 78% of that is one concern: the wiring between a
semantic-tree legend and the map beside it. The two files already import
the same 41 library modules and the same five legend-data composables, so
the data layer was never the duplication. The glue was, and glue is where
a fix lands in one copy and not the other: the packet kind-split, the
poster attribution reading and the packet page-1 map hand-off each shipped
fixed in one twin and broken in the other.
Only the four functions that are BYTE-IDENTICAL in both files move:
clearCountryHighlight, _flatItemsToStlNodes, onLegendRowClick and
_bridgeSelection. Measured, not assumed — applyDimFilter (185 lines against
170) and switchTab (49 against 50) genuinely differ and stay where they
are. Sharing code that merely looks similar is how a refactor picks one
plugin's behaviour for both.
A composable rather than a component: the legend markup already lives in
SemanticTreeLegend and its two shells, and a component would have forced
both plugins to give up the template where their real differences live.
Nothing is injected or reached for, so a second dashboard can adopt it
without inheriting research-map's provide tree.
Both calls sit after countryOutline and pplrInstance. The declarations they
replace were hoisted and ran above those consts; a composable call is not
hoisted, and placed too early it would read undefined at the first call
rather than at import — the legend would stop highlighting with no error.
The gate asserts both the dependency list and that ordering, for every
caller.
Rebuild-and-compare on both bundles: research-map 1,237,385 -> 1,237,709
bytes, country-posters 1,550,988 -> 1,551,317, and every behaviour-bearing
token identical in both — regions-fill, regions-border, fill-opacity,
fill-outline-color, line-color, line-width, visibility,
doxa:legend-row-click, _flat_filter, iso_3166_1_alpha_3. Counted rather
than set-compared, because minified output renames freely.
104 lines out of the plugins, shared lines 1,756 -> 1,702.
Page 1 keeps the live map rather than a fetched sheet, and it was handed
across as a bare <img> in mapSvg — raw markup the renderer interpolates.
The page-1 redesign no longer draws that full page, so the map arrived
shrunk inside a layout built for a stored sheet. It reads as a styling
quirk rather than a wiring mistake, which is why it survived here after
country-posters was fixed. The URL now goes through mapImage, which places
it as the whole page.
This is the third defect of the same shape between these two files: a fix
lands in one twin and not the other, and nothing reports it. So the gate
asserts the seam for every host that rasterises a live map, rather than for
one file.
Two fixes to the check itself, both found by poisoning it rather than by
reading it. `[^)]*` cannot cross the arrow function's own parens, so
`useMapPoster(() => map.value).render(` never matched and the new section
skipped both files — it passed while testing nothing. And an older
assertion pinned the exact literal `{ mapSvg, variant }`, so moving to the
seam failed a check that was still describing the shape it replaced; it now
asserts that the variant travels, not the argument list it travelled in. A
gate that names one spelling of a call turns every refactor into a false
alarm, and a false alarm is how a real one gets waved through.
Both twins now use the seam. The render is skipped for a card deck, which
has no map page; this map's legend menu only asks for full or lightweight,
so that arm guards a future caller rather than a path taken today.
Eighteen keys across eleven locales for text that was written in English in
the components: the rail and filter toggles, the selection count, the CSV
export toast, the drawer's prayer and information links, the dashboard
title, the map's failure message, and the four search placeholders that name
the tiers of whichever tab is open.
The export toast is interpolated rather than concatenated. Its counts keep
going through fmt(), which groups digits the way each locale does, and only
the words around them come from the catalogue — the order of "rows",
"fields" and the outcome is not the same in every language, so a sentence
built by joining fragments cannot be translated at all. It uses the shared
fillParams, which leaves a placeholder visible when a value is missing
instead of rendering the word "null".
The map's failure message no longer names the token and the container: that
is for the console line, which stays English for whoever is debugging. What
the reader sees says what to do.
Three more shapes the scan could not see, each of them correct code: tStatic
called directly rather than through a wrapper; `config || t(key) || English`,
where the translator is not the immediate left operand because || is
left-associative; and the licence credits, media queries and product name
that must not be translated at all.
Own leaks 91 -> 1, the last being a tab label behind a key that resolves in
all eleven locales. Fleet 249 -> 159. check:i18n-parity,
never-translated, treeshake and stopped-scope all pass.
The legend emits 'packet:full' or 'packet:lightweight' — it widened its
existing event rather than adding a second one — and this router still
tested the whole string for 'packet'. Neither submenu item matched any
branch, so the Packet export opened nothing, logged nothing, and looked
like a menu item nobody had wired. Found by upg100, who had already hit
the same thing in its own copy.
The kind is split before it is compared, the way upg-100-list does it, and
a bare 'packet' still means the full packet so an older emitter keeps
working. The variant then has to survive the call as well: openPacket
ignored it and printPacket defaults to full, so lightweight would have
printed the full packet even once the menu fired.
The gate asserts the seam rather than one file — it reads the kinds the
legend actually emits and requires every consumer to split before
comparing, because this bug cannot be seen by reading either side alone.
It currently fails on country-posters/countries-map.vue, which has the
identical dead menu and is not mine to change.
Two fixes to the check itself while writing it: a fixed-size window read
only part of a long router and reported correct code as broken, so the
function's extent is found by brace depth; and a trailing \b after a
quoted literal never matches, which made the assertion fire on every
consumer including the working ones.
The legend gives each tab a small "i" button explaining what that tier
means — what an Affinity Block is, how a people group differs from a people
group in a country. SemanticTreeLegend already read that text from
legend.tabInfo.<id> behind a te() guard, and not one of the ten keys the
research map needs had ever been written. The tab chips were translated in
all eleven locales and the explanations beside them were not, so the reader
least able to guess what "PGIC" means was the one reader who got it in
English.
The keys are written for all eleven locales, following the terminology each
catalogue already uses for these same tiers — People Group as Volksgruppe,
Peuple, Pueblo, 民族, Народ, जनसमूह — with WAGF and UPG left untransliterated
as the corpus leaves them. The reader-facing text drops the source field
names and row counts that the English carried; those are notes for whoever
maintains the tables, so they stay in the source as a comment.
With the keys in place the English in the tab tables can never render, and
a second copy of a string drifts from the catalogue without anyone noticing,
so it is removed. `info: true` stays because the legend gates the "i" button
on that field being set — dropping it would have removed the affordance
instead of translating it — and tabInfo now treats only a string as a
fallback, so the flag cannot reach the popover as the word "true".
Verified every changed tab renders a real label and a real explanation in
all eleven locales. check:i18n-parity, check:i18n-never-translated and
check:i18n-treeshake pass; hardcoded-english 205 -> 190.
One hundred and forty-three comments across the research dashboard, the research
map, Big Picture and the two composites attributed a decision to a person: by
name, by quotation, by date, or as "per Driver". Shipped code should carry the
reason a thing is the way it is, not who said so. A reader of the giveaway repo
has no way to ask that person anything, and the name tells them nothing the
reason does not.
So a quotation becomes the requirement it was quoting, a ruling becomes the
ruling, "the Driver reported" becomes what was reported, and a decision still
waiting on an answer now reads as provisional and pending a product decision
rather than as somebody's pending word. Where a comment already stated the
reason -- most of them did -- only the attribution went.
Two things were removed rather than rewritten. A personal medical fact appeared
twice ("the Driver is photosensitive"), once in a comment and once in a gate's
console output where it printed on every run; the motion-safety requirement
stands on its own and now reads that readers may be photosensitive. Internal card
ids that named nobody stayed, since they point at this repo's own history.
The bulk went in three batched passes of meaning-preserving substitutions, and
that is where the risk was: a blind replacement produces sentences that are
grammatically wrong but still look plausible in a diff. Re-reading every changed
line found thirteen -- "the the report describes", "including the two that were
had just complained about", "because was asked for WHITE rings" -- each repaired
individually. The count of remaining references caught the tail three separate
times, when a file type, an escaped apostrophe, and a scripts/ path each fell
outside a filter I had assumed was total.
Comment bodies and two console strings only. All twenty-one files parse, both
meta.json files are still valid JSON, and no declaration moved.
The desktop legend gets :export-enabled and @export from this file's template, so
a reader at a desk can print a poster or a packet. LegendMobile does not read
props from here. It reaches for an injected handler and decides whether to offer
the control at all by whether one exists -- mobileExportEnabled is
`typeof onLegendExport === 'function'`. Nothing here provided it, so the phone
showed no export while the same map on a desktop showed both.
That is why it went unreported for so long: it is an ABSENCE, not a broken
button. Nobody clicks a control that was never drawn, and the desktop, where this
kind of thing gets checked, was always correct.
The provide carries EXPORT_FEATURE_ENABLED rather than the bare function. The
desktop already passes that flag, so providing unconditionally would hand a phone
an affordance the desktop deliberately hides when the flag is off; and because
"is there a handler" IS the mobile switch, passing null when it is off is exactly
what makes the sheet hide the control. countries-map fixed this first and its
comment records the same two causes on my-upg-100-list, so this is the third
instance of one pattern rather than a new bug.
Measured before building: the sibling item in the same diff, the RTL-safe CSS the
research map was said to owe the country map, is already done -- zero physical
declarations remain once comments are excluded, and the one apparent hit is prose
describing why a padding exists.
closes#15 of COUNTRY-RESEARCH-MAP-DIFF
usePosterLayout has always computed a real layout: the map narrowed by the legend
column and inset by the margins, the title strip and the footer strip. Nothing in
the exporter ever read it. The map was rastered at full page size -- edge to edge,
across the margins and both bands -- and the title, legend and footer were then
composited on top of that image. The legend lay over the map it describes, and
there were no bands at all.
What kept it hidden is that PosterPreview.vue DOES honour layout.map, so the
on-screen preview has always shown the intended design and only the exported file
was wrong. A preview is not evidence for an export.
The obvious fix breaks a different product. country-posters embeds this renderer's
output as a full-sheet <img> inside its own packet page, beneath the packet's own
title, so chrome that is on by default prints a second header and footer within
their page. UPG-100's packet does not call this renderer at all and was never at
risk -- checked rather than assumed. So the composed sheet is requested with
spec.layout === 'poster', and every existing caller, which sets none, keeps what
it had.
That guarantee is stronger than "equivalent": with no layout the renderer passes
the SAME px OBJECT it always did to the rasteriser and composites no sheet, so the
untouched path is unchanged by identity, not merely by value. The check asserts
exactly that, by executing the decision with four real caller shapes -- including
country-posters' A4 at 150 DPI -- rather than reading the source for a promise.
Its second negative test is the ruling's own failure mode: make defaultPosterSpec
set the layout and country-posters is named as silently opted in.
A sheet with no room left for a map after its margins, bands and legend column
falls back to full-bleed and says so, because silence there is the bug above.
Simple Research's export asks for the layout explicitly.
The Driver, on Simple Research: clicking print "breaks the CSS" -- the map
controls and the legend jump upward.
It is not the Export->poster path. That prints an off-screen iframe whose whole
body is a single <img> carrying its own @page, and no CSS in this plugin can
reach inside it. This is the browser's own print of the live page, for which this
plugin had no print rules at all.
The mechanism comes out of the cascade rather than a screenshot. .rm-root is
position:absolute with inset:0 and no positioned ancestor, so its containing
block is the initial one: the viewport on screen, the PAGE BOX in print.
.rm-map-area draws all of its height from that root through flex:1 1 0, and the
canvas, the legend, the badges and the scroll edges are each absolute;inset:0
inside it. In a paged medium the root stops resolving to a viewport height, the
flex child collapses toward zero, and everything pinned inside it stacks at the
top of the sheet. That is the jump the Driver saw.
So in print the root stops being absolute and flows in normal document order,
and the map area is given an explicit height, which restores a real box for its
absolutely-positioned children to sit in.
Overlays that describe INTERACTION rather than content are hidden: the scroll
affordances would print as grey bars down both edges, and a provider badge and a
loading veil say nothing on paper. The legend stays. It is the key to what the
map is showing, and a printed choropleth without its legend is an unreadable
picture.
Verified by reasoning over the cascade, not in a browser: this needs eyes on the
print preview before the card closes.
closes the print half of DRV1650-SR-POSTER-EXPORT
251606460 dropped each component's window hooks on unmount, but deleted them
unconditionally, and that is wrong as soon as two of the same component share a
page -- which research-map-plus-grid and the Big Picture composite both arrange.
B mounts and overwrites A's hooks; A unmounts and deletes them; B is still on
screen with its verify hooks gone. Not a leak, and not visible to a reader: the
cost lands on rega, as a hook reported missing on a map that is perfectly
healthy, which is a more expensive thing to chase than this is to write.
people-groups-grid already compared identity before releasing. research-map (4
hooks) and the dashboard map panel (3) did not, so the commit was uneven with
itself; gospel's d3597f3d1 settled on the same identity-checked pattern, and one
pattern across the fleet is what lets the check enforce it later.
Each hook is now held in a local as it is installed -- window.__x = _hookX = (…)
-- declared outside the block that assigns it, because a const in there is
invisible to onBeforeUnmount. The release compares before deleting, one key per
line, so the check can read both halves.
The check reports how many releases are identity-checked and enforces it under
STRICT_IDENTITY=1, which is off only because other owners have not adopted the
pattern yet. Flipping it later is a smaller change than re-deriving this.
Two things it taught me while I wrote it. Enforcement first flagged the grid,
which WAS identity-checked -- the comparison simply sat on the line above the
delete, and looking only at the delete's own line dictated formatting instead of
recognising correct code; the window now covers the lines leading up to it. And
the pattern is tested for BEHAVIOUR, not presence: two hooks installed in turn,
the first unmount must leave the second working, the second must leave nothing.
rega's heap snapshots (leak-722ac7789): Simple Research and All People Groups
mounted twice went 54.2 MB to 65.1 MB, with live Vue apps 6 to 8. Every unmounted
application was still there, one copy per mount, and the retainers were all the
same shape -- a window key set during setup and never cleared. The closure keeps
its component, the component keeps its shadow root, and a whole app survives its
own unmount. An element property dies with the element; a window key does not.
The named one was window.__doxaLogoOwner -> Map -> div.rm-map-canvas -> ShadowRoot
-> app, released only when the map's 'remove' event fires. That listener cannot be
the guarantee the global is cleared, because it only runs if remove() is reached;
it is a consequence of the guarantee, not the guarantee itself. useMapInstance now
drops the claim inside destroy(), before removing, so it goes even if remove()
throws part-way.
As for why remove() was never reached: research-map's unmount hook ran ten
teardown steps and called destroy() last, and two of those steps were unguarded
calls into other composables. A single throw anywhere above stranded the map, and
with it the app. I could not reproduce which step threw without a browser, so the
fix does not depend on knowing: each step is isolated and destroy() runs from a
finally, where nothing above it can skip it. Ordering teardown so the most
important step is last, behind nine chances to fail, was the real defect.
The globals themselves are released by the components that set them -- four verify
hooks in research-map, three in the dashboard map panel, the vocabulary hook in
the grid, which had no unmount hook at all. Released as SETS: __rdashPinAt was the
one the snapshot named, but __rdashMap holds the map and retains strictly more, so
clearing only the reported one would have looked fixed and leaked nearly as much.
The deletes are written one per line instead of looped over an array of names, so
that the check can read them. A release a checker cannot see is worth no more than
no release, the day someone removes three of the four.
The check flags only values that actually retain. A warn-once boolean latch and a
page-wide cache keyed off itself are deliberate and must not be reported, or it
becomes noise and gets switched off. It is NOT in gates:pre: 19 sites remain in
other owners' plugins, and this is a fleet pattern rather than one plugin's bug.
useLegendData returns { items, columns, title, activeFilter, setFilter,
totalCount, totalPopulation, progress, fmtPop, fmtCount }. There is no `rows`.
research-map read legend.rows in three places, and because JavaScript answers
undefined rather than complaining, each one failed in a different costume:
- provide('legendRows', legend.rows) provided undefined, and nothing in the repo
injects that key -- the comment above it described a contract no component
implements
- openPoster's fallback, legend?.rows?.value || [], could only ever be [], so on
any tab where the flat legend is empty the poster had no legend rows
- the packet's page-1 slot was rows: legend?.rows?.value || [] with NO second
source, so that poster's legend was empty unconditionally
and useMapPoster skips the legend slot entirely when the row list is empty. So the
missing legend on the exported poster had two independent causes: the renderer
painting it over the map, fixed separately, and this -- an empty array arriving
through a fallback that could never be anything else.
Both poster paths now share one builder, so they cannot drift apart again, and it
flattens children because a prayer legend is a parent plus two child tiers and
printing only the parents describes bands the map does not draw.
Two further calls went the same way: legend.refresh?.() and
legend.build?.(normalizedPeopleGroups), against methods the composable has never
defined. The optional call swallowed both, so two lines that read like steps did
nothing. `items` is a computed and re-derives on its own, so they are gone rather
than repointed.
exp-shepherd found the same misspelling in countries-map, where it threw instead
(NE-17). The check therefore guards the class rather than these lines: it reads
the returned key set from the composable and reports any consumer reading outside
it. It is NOT in gates:pre yet, because it still reports countries-map, which
belongs to another owner.
Writing it turned up two traps worth keeping. Stripping block comments with one
global regex DELETED REAL CODE -- a slash-star inside an ordinary string opens a
comment nobody wrote and the replace runs to the next real terminator -- and the
check confidently reported "no consumers at all" while both sat in the files. And
matching property reads by name flagged every t('legend.colLabel.religion'): an
i18n key spelled like a property access is not a read.
An RTL audit of research-map, research-dashboard and its four dashboard
components. Translation was already sound -- 101 keys, all present in all eleven
locales, no hardcoded English anywhere user-visible -- so what was left was
direction: rules that pin a physical side while the reader's text flows the other
way.
Three an Arabic reader would notice immediately. Both search icons sit at
left:8px and left:10px while the input around them mirrors, so the icon ends up
on the far side of the field it belongs to. The detail drawer's border-left
stays on the geometric left, which in Arabic is the drawer's trailing edge, so
the panel appears unattached on the side it hinges from.
The rest are quieter but the same mistake: a badge, a provider chip, the two
edge affordances, a table's numeric columns and its indicator, a result row's
spacer, the projection toggle.
Every change is LTR-NEUTRAL, which is what makes this safe to land on its own.
In left-to-right, inset-inline-start resolves to left, text-align:end resolves
to right, margin-inline-start resolves to margin-left. The English view cannot
move; only the Arabic one can. Fourteen insertions against fourteen deletions,
one property each, nothing else touched.
The pattern is not new here -- .rd-legend-map already uses inset-inline-end,
with a comment recording that a physical right pinned it to the wrong edge in
Arabic. These sixteen are the rules that were missed when that lesson was
applied.
What this does NOT establish: that Arabic renders correctly. Every RTL gate in
the repo needs a browser, so this was read rather than run. The three visible
ones want eyes on ar before anyone calls RTL done.
Four reviewed changes to ONE file. SOURCE ONLY — no built artifacts — see the note below.
PRINT. .rm-map-area is flex:1 1 0 and has no height of its own; print layout has no
viewport height, so the box collapsed and the toolbar and legend, positioned against it,
followed its top edge up. Nothing in this render path declared an @media print rule at
all, so the browser's default print stylesheet laid the page out. A profile with no print
rules is not neutral in print; it is unspecified.
MOBILE EXPORT. LegendMobile is opt-in by construction -- its exportEnabled is
`typeof inject('onLegendExport') === 'function'` -- and this profile provided nothing, so
a reader on a phone had no way to export a poster or a packet while the same map on a
desktop did. Provided guarded by EXPORT_FEATURE_ENABLED, the way country-posters does it.
BUSY STATE (bug-class sweep C7). openPoster guarded re-entrancy with a REF wired to
:active/:disabled/:aria-busy; openPacket guarded it with a plain `let`, which drives no UI
because it is not reactive -- so the SLOWER action showed nothing. packetBusy is a ref
now, and legendExportBusy reports either build for the legend's Export menu.
DEAD PROBES. docScrolls and ancestorScrolls were function-local consts, computed and
discarded, kept alive by a comment claiming the edge affordance keyed its indicator off
them. This profile has no such indicator and never calls useEdgeScrollAffordance.
_findScrollportAncestor STAYS: it is still a ResizeObserver target.
Plus two physical CSS properties made logical for RTL, and the verbatim Driver quote in
the scroll-zoom note paraphrased per the L64 policy.
★ WHY NO BUILT OUTPUT. I built this and tried to self-land it, and backed out: staging
public/ wholesale swept in country-posters' artifact and three leaf pages as they were at
MY build base, so the commit carried a REVERT of exp-shepherd's rebuild and a stale
integrity hash for their bundle -- an SRI mismatch, not a cosmetic diff. Caught by
comparing every bundle's integrity against main rather than trusting that a clean rebase
means a clean diff: a rebase resolves TEXT, and an artifact nobody else edited in the same
lines still loses to whichever commit touched it last. main then moved twice more while I
rebuilt. So this is source-only and the build belongs with the build owner, who is
rebuilding these bundles anyway.
DRV1650-SR-LEGEND-FILLS-SEARCH-GAP. On Simple Research's Regions and Languages tabs the
search bar is gone but the legend does not rise into the space. The other tabs are fine.
The two-tab split was a predicate, not a coincidence. With no geocoder the legend rises
to 14px UNLESS showsFlyTools() is true, and that returns true for exactly 'doxa-regions',
'language-family' and 'language-families'. It stops at 56px to avoid a MEASURED collision
on country-posters, where LegendTools renders at top:14px — the legend's own slot.
★ BUT showsFlyTools() DESCRIBES THE LEGEND TYPE, NOT WHETHER THE TOOLBAR EXISTS. Fly and
Clusters are leaf-only: the composite — which is what a reader calls "Simple Research" —
passes legendTools:false, so the toolbar is never drawn there and the 56px reserved a
slot for nothing. rega measured it at 1440x900 on main e1d0bc20: the band from the map's
top edge (y 44) to the legend card (y 100) is 56px of EMPTY map on both tabs, with no
LegendTools anywhere in the strip.
So the offset now asks whether the toolbar is RENDERED, not whether the type allows one.
Keying it on the tab name would have closed the Driver's gap and reintroduced the
country-posters collision the moment the tools were shown — the guard is kept, it is
simply asked the right question.
showLegendTools moved ABOVE legendTopVar, which reads it. A computed body is lazy so a
later const would have resolved at runtime, but this file has a TDZ history and
check:setup-order exists because of it; position is cheaper to keep honest than to reason
about.
check-legend-top-offset asserts BOTH inputs are consulted — drop either and it fails —
and that the declaration still precedes its reader. Negative-tested.
This is reading (b) of the two I put to dx-shepherd, chosen because rega measured it
rather than because it was likelier. Reading (a) would have been a cosmetic change to a
toolbar that turned out not to be there.
Follow-up on DRV1650-SR-POSTER-EXPORT: the print came out vertical, where a horizontal map fits the shape better.
The export asked for 8.5x11 portrait unconditionally, so a landscape map was letterboxed
onto a tall sheet -- white above and below, the map small in the middle. That was the
other half of the wrong vertical aspect; the first half was the tile slicing, fixed in
859bb268.
Orientation now follows the map: landscape when the map's own container is wider than it
is tall, portrait otherwise. The PAPER is unchanged -- still 8.5x11 stock, only turned --
so the sheet stays a size printers have rather than becoming a custom one.
Measured from map.getContainer() rather than from the window, because the map may be one
pane of a composite and it is the MAP's shape being exported, not the browser's.
My scripted migration inserted the import behind a guard that silently did not fire:
`if 'defineMapElement' not in s.split('function')[0]` became false as soon as the
inserted CALL appeared before the first `function` keyword — so the files that most
needed the import were exactly the ones that did not get it. 11 of 16.
Every one of those elements would have thrown "defineMapElement is not defined" on
load. A bundler does not error on a free identifier; only loading does. studio caught it
in L64 via landing-preconditions, which imports each profile, and fixed it there
(37f75601) plus added an arm to check:element-bootstrap.
This repairs the same 11 on this branch so it is not a landmine if it lands after L64,
and it is byte-equivalent to studio's fix in effect. Verified by PARSING each entry and
resolving the identifier against its import bindings — 16 of 16 bound — rather than by
re-running the kind of regex that wrote the bug.
The element-bootstrap migration called defineMapElement in eleven entry files that never
imported it. A build does not fail on an unbound name; the element does, the moment it loads,
with 'defineMapElement is not defined'. The landing precondition caught it by importing each
profile. check:element-bootstrap now fails when an entry calls the helper without importing it.
Goal (c), and it turned into a goal (d) fix. Every profile element is
defineCustomElement(ProfileLoader, { configureApp }), and the configureApp has four
steps. The fourth, app.provide('appI18n', i18n), is the one that gets dropped.
ProfileLoader injects that instance (ProfileLoader.vue:176) to drive its locale from
profile-config. Without the provide the watcher holds null and the drive NEVER RUNS --
while setRuntimeLocale() on the next line still fires, so tStatic strings DO follow the
host. The element renders a convincing MIXTURE of the reader's language and English, and
nothing throws, because the t() shim falls back to its key. It reads as "a few strings
not translated yet".
SHIPPING THAT TODAY: country-posters, key-cities, gospel-resources, cities-internet and
my-upg-100-list. regions-plugin censused all 19 entry files independently and found the
same five and no sixth. The measurement is regions-dashboard's, from when they hit it:
a Spanish catalogue present, es set, and every t() string still English.
All sixteen entries now call library/elements/defineMapElement.js, so the five are fixed
by construction rather than by five separate edits. Behaviour is otherwise identical --
same options, same element names, same per-plugin extras. wall-of-people-groups needed
the helper to hand its pinia back, because it takes a store off its own instance and a
helper that hid pinia would have given it a second one: two stores and a silent state
split.
★ AND THE CENSUS THAT RECOMMENDED THIS SAW FOUR OF SIXTEEN. My detector hashed function
BODIES; eleven sites are not `function buildXElement()` at all, they are
`const El = defineCustomElement(...)`. I wrote "every number here is a lower bound" in
the census as a caveat and it was the headline within the hour -- a 4x undercount of the
item it recommended. So this gate greps for the SHAPE, not for a known list: a check
written around the four that matched would have passed the twelve that did not.
check-element-bootstrap has two arms. No entry may build a profile element by hand, and
the helper itself must provide appI18n -- PLANTED as a negative case, so the regression
cannot return by editing library instead of a plugin. It also fails if it finds fewer
than ten entries, because a detection that stops matching reports the same green as a
tree with no problems.
THE PLANT CAUGHT TWO BUGS IN THIS GATE BEFORE IT CAUGHT ANYTHING ELSE. The predicate
matched the helper's DOCSTRING, so it would have passed a helper that only mentions the
provide in prose -- comments are stripped now, because a check that reads comments is
reading the author's intentions rather than the program. And the plant itself used /m
instead of /gm, so it deleted the documentation and left the code, then reported that it
could not detect an absence that was not there.
Goal (c), and it turned into a goal (d) fix. Every profile element is
defineCustomElement(ProfileLoader, { configureApp }), and the configureApp has four
steps. The fourth, app.provide('appI18n', i18n), is the one that gets dropped.
ProfileLoader injects that instance (ProfileLoader.vue:176) to drive its locale from
profile-config. Without the provide the watcher holds null and the drive NEVER RUNS --
while setRuntimeLocale() on the next line still fires, so tStatic strings DO follow the
host. The element renders a convincing MIXTURE of the reader's language and English, and
nothing throws, because the t() shim falls back to its key. It reads as "a few strings
not translated yet".
SHIPPING THAT TODAY: country-posters, key-cities, gospel-resources, cities-internet and
my-upg-100-list. regions-plugin censused all 19 entry files independently and found the
same five and no sixth. The measurement is regions-dashboard's, from when they hit it:
a Spanish catalogue present, es set, and every t() string still English.
All sixteen entries now call library/elements/defineMapElement.js, so the five are fixed
by construction rather than by five separate edits. Behaviour is otherwise identical --
same options, same element names, same per-plugin extras. wall-of-people-groups needed
the helper to hand its pinia back, because it takes a store off its own instance and a
helper that hid pinia would have given it a second one: two stores and a silent state
split.
★ AND THE CENSUS THAT RECOMMENDED THIS SAW FOUR OF SIXTEEN. My detector hashed function
BODIES; eleven sites are not `function buildXElement()` at all, they are
`const El = defineCustomElement(...)`. I wrote "every number here is a lower bound" in
the census as a caveat and it was the headline within the hour -- a 4x undercount of the
item it recommended. So this gate greps for the SHAPE, not for a known list: a check
written around the four that matched would have passed the twelve that did not.
check-element-bootstrap has two arms. No entry may build a profile element by hand, and
the helper itself must provide appI18n -- PLANTED as a negative case, so the regression
cannot return by editing library instead of a plugin. It also fails if it finds fewer
than ten entries, because a detection that stops matching reports the same green as a
tree with no problems.
THE PLANT CAUGHT TWO BUGS IN THIS GATE BEFORE IT CAUGHT ANYTHING ELSE. The predicate
matched the helper's DOCSTRING, so it would have passed a helper that only mentions the
provide in prose -- comments are stripped now, because a check that reads comments is
reading the author's intentions rather than the program. And the plant itself used /m
instead of /gm, so it deleted the documentation and left the code, then reported that it
could not detect an absence that was not there.
rega RQ3(b): the packet's "Has Prayer" selection showed 50 of 997 where the legend
row said 699. The legend row is right and the packet was wrong.
A pin's prayer count arrives TWICE. The GeoJSON property is whatever was true when
the page loaded; from then on polling writes the current count with setFeatureState,
and prayer-progress.js:94 colours from ['feature-state','peoplePraying'] once polling
is on. So the lit pins a reader counts are feature-state, and pin-selection.js had no
'feature-state' case at all -- it resolved to undefined, to-number defaulted it to 0,
and the comparison selected only the pins whose FROZEN property happened to be above
zero. 50 of them. The other 649 people groups had been prayed for and the packet left
them out, which is the one thing a prayer packet must not get wrong.
resolveExpr and matchesExpression now take the state alongside the properties, and
selectedPinIds reads it per pin off the MAP rather than off the feature -- the source
still holds the GeoJSON it loaded, setFeatureState never touches it. The source is
added with promoteId:'uniqueId', so the id feature-state is keyed by is the same
props[idKey] the selection already uses.
Degrading is deliberate in both directions: before the first poll there is no state
and property expressions still select, so an early packet is thinner and never empty;
getFeatureState throws on an unknown source, so it is guarded and a bad source loses
the selection rather than aborting the print.
ALSO, rega RQ3(a), a candidate and not yet a confirmed cause. prewarmRegionPaint
wrapped any non-feature-reading paint value in a case to make it data-driven,
including a zoom interpolate -- I argued for that explicitly. useMapLayers.js:319
already held the rule and I had not read it: zoom interpolation is kept by putting the
case in the interpolate OUTPUTS, and "the reverse (zoom nested inside a 'case') is what
breaks the layer, so don't do that." That reverse is the shape Mapbox classifies as
composite before reaching for an interpolation factor the expression cannot supply,
which is the class of the interpolationFactor page error. The wrap now skips zoom
expressions too. A missed pre-warm costs a frame; an illegal paint costs the layer.
None of the three properties it touches is a zoom interpolate at boot, so this is not
proof -- it removes the only writer that can build that shape from a runtime value.
check-pin-selection asserts the live count wins over the stale property, that the
stale property alone selects nothing, that a map with no feature-state still selects
on properties, and that a throwing getFeatureState degrades. Negative-tested:
removing the case, not reading the state, and dropping the throw-guard each turn it
red; restored, green.
Found by cross-reading exp-shepherd's country-posters port, not by anything failing here. Their
73438b43b routes all eighteen of that map's region clears through a regionIdle() wrap. Mine still wrote
NINE raw constants — fill-opacity 0.20 and 0.7, line-color rgba(60,60,80,0.35), line-width 0.6 — across
clearCountryHighlight, the deselect path and the dim path.
A raw value makes the property CONSTANT again. So the first time a reader cleared a highlight, or ran a
country search and dismissed it, the three region paints stopped being data-driven and the next
highlight recompiled the shader variant that b5894ec3 exists to have compiled at layer-add. rega
measured 0 ms on the row click and that measurement was honest — it was taken on a fresh tab, where no
clear had run yet. The path nobody exercised is the one that pays.
Every region paint write now goes through regionIdle(v) — ['case', ['has','iso_3166_1_alpha_3'], v, v],
both branches the value it already had, so nothing looks different and the property stays data-driven.
Nine sites, no behaviour change, and the pre-warm now survives a clear.
★ DEFINED ABOVE ITS FIRST USE, deliberately. The first caller sits four hundred lines above where the
other idle constants live, and a const read before its declaration line has executed is the TDZ this
file has already shipped twice this week (_lastFitKey, then _lastPinOpacityKey). Its callers only run
after mount, so it would probably have been fine — "probably fine" is what the gate exists to stop me
saying. check:setup-order PASS.
Verified: check:sfc-parse and check:setup-order PASS. Needs eyes: rega — clear a region highlight, then
click another region row, and the compile should still be 0 ms. That is the case no run has covered.
Found auditing my own map for goal (d), in code I added this morning. The cover note is the one string on
that document a reader in Arabic or Hindi cannot read, on a document whose entire purpose is that they
can pray from it in their own language.
The slash needs no translation and is not a guess: zh's professional translation of legend.progress.short
renders exactly this relationship as "{covered} / {total}". The WORD is available per locale in that same
key — ar "من", ru "из", fr "sur" — but it carries "· {pct}%" with it, so reusing it would mean either a
wrong suffix on the cover or slicing a translated string on a separator I cannot guarantee survives
translation. Ten invented translations for one connector is the worse trade.
Wrapped in U+2066/U+2069 isolates, which is the convention the Arabic catalogue already uses five times
for numeric ranges ("30–59%"). Without them the pair reorders against the Arabic label beside it and a
reader sees "214 / 50" — the numbers held but the meaning inverted. NUMERALS ARE HELD, so the digits
themselves are untouched.
CENSUS BEHIND IT, on current main, reusing the existing gates rather than adding one: check:i18n-parity,
check:i18n-never-translated and check:i18n-keys-exist all PASS; and of the 766 catalogue strings the three
Simple Research bundles ship across 21 areas, ZERO fall back to English in any of the ten locales. The
English still visible to a non-Latin reader is five or six items and every one was already classified:
two external resource names (Open Bible Stories, Scripture Earth), one pure format string, two acronyms
(DPI, MST), and UUPGs, which is the Driver's deferred ruling. This cover note was the only NEW leak, and
it was mine.
Verified: check:sfc-parse and check:i18n-keys-exist PASS. Needs eyes: rega — the packet cover at ?lang=ar.
Only the browser seat runs browsers, and only inside internal/browser-slot.sh.
That rule was remembered, not enforced: an agent ran a gate it took for a
headless source check and it launched Chromium outside the slot.
internal/lib/playwright-guarded.mjs is now the one way a repo file reaches
playwright: its launch(), launchPersistentContext(), launchServer(), connect()
and connectOverCDP() exit 75 (NOT MEASURED) with a message naming
browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it
for the command it runs, so a bash -c grandchild inherits it. All 72 files that
imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates;
plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2,
research-map-plus-grid 2, research-dashboard 1): a mechanical import swap.
check:browser-gates-guarded (gates:pre) fails a direct import;
bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate
moves to internal/lib/browser-gates.mjs so the runner and the check share it.
exp-shepherd's copy of this pre-warm is better than mine in one way and accidentally right in another,
and both are worth taking.
BETTER: it leaves an existing expression alone instead of wrapping it. Mine wrapped regions-border's
line-color — which arrives as a region-coloured match on a feature property — into a case around that
match. No visual change, but a gratuitous rewrite of an expression that was already data-driven and had
no variant left to warm.
ACCIDENTALLY RIGHT: their test is "is it an array". For this layer that coincides with the right answer,
because the only array here is that match. It is not the right test in general — a zoom expression like
['interpolate',['linear'],['zoom'],…] is also an array and is NOT data-driven, so skipping it would
leave that property to compile its variant on the first gesture. That is the partial pre-warm this whole
exercise exists to avoid, and it would measure as a failure with no visible cause.
The test is now whether the expression reads the FEATURE — get, has, or feature-state, at any depth.
Constants and zoom-only expressions get wrapped; anything already keyed on a feature is left exactly as
the layer wrote it. The regions layers have no zoom expression today, so this changes nothing that runs
right now; it means the rule is correct for the reason it is correct.
Verified: gates:pre 13/13. Chained on 790d8838.
Found by reading addRegionsLayer while checking exp-shepherd's copy of this fix, not by anything that
failed. My pre-warm called paintRegionScope(map.value, []) — the CLEARED look: flat grey borders at
width 0.6. The layer does not start there. useMapLayers adds regions-border with line-color set to the
REGION-COLOURED match expression and line-width 1, so the moment the Regions tab was first opened, every
border turned grey and thinner than the layer had defined.
Nothing was going to catch it. The shader compile moved as intended, the write count was unaffected, the
highlight still worked, and the checks in flight were a timing measurement and a write count — a change
of appearance is invisible to both. It would have been found by a person looking at the map, eventually,
and blamed on something else.
prewarmRegionPaint reads each property back and re-writes it wrapped in a case whose two branches are
the value it already had. The appearance is preserved exactly as the layer defined it, and the property
still becomes data-driven, which is all the shader variant depends on. A property the layer does not
define is skipped rather than invented, and one already wrapped is left alone so a second call cannot
nest cases.
paintRegionScope keeps its idle values for the CLEAR path, which is a different thing from the initial
state and always was — conflating the two is what caused this.
Verified: gates:pre 13/13. Chained on 196eca13. Needs eyes: rega — on the first visit to the Regions
tab, the country borders should be REGION-COLOURED at their original width, not grey.
Canonicalising the ISO list made the two region painters COMPARABLE. Reading the two sources shows it
cannot make them EQUAL, so the write count would have landed at six rather than three — and the reason
is a real defect underneath, not a tuning problem.
The two sets are different by construction:
· node.isoCodes comes from useWagfRegionsLegendData, which builds its regions by walking the
PEOPLE-GROUP LIST, so it holds only countries that have pins in the current data.
· mapLayers.regionIsoMap comes from regionsData.isoToRegion — every country assigned to the region.
So a region highlight painted from the legend has HOLES wherever a country in that region has no
unreached group in the list, and which of the two a reader saw depended on which writer ran last. The
feature is "highlight the countries belonging to the selected WAGF region", so the regions dataset is
the right answer and the legend's pin-derived list is not.
region: rows therefore stop painting here and defer to the canonical path they already trigger
(selectRegion -> the store watcher -> applyDimFilter). block: and country: rows keep painting from
node.isoCodes, because nothing else paints them — only region: reaches selectRegion — and for those two
the legend's own list IS the scope being asked for.
★ THIS IS A PREDICTION, and it is falsifiable: region clicks should now be 3 writes, not 6. If rega
counts 6, the two sources disagree for some other reason and my reading of them is wrong. If they count
3 but the highlight covers FEWER countries than before, then the pin-derived set was the intended one
and this commit is the thing to revert — the previous behaviour was last-writer-wins, so nobody has
decided which is correct until now.
Verified: gates:pre 13/13. Chained on 1be024ac.