- 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.
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.
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.
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.
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.
rega's L43 check: three of four pass — the row-click shader compile is 0 ms on both engines (was
3.8 s / 7.5 s), the tab visit is no worse, the highlight stays. The collapse did not: still nine
setPaintProperty calls per row click instead of three.
Their hypothesis is right, and I had the evidence before I wrote the code. The two callers build the
same region from different lists — node.isoCodes and mapLayers.regionIsoMap[slug] — and they do not
agree on ORDER. rega said so in the very first stack trace on this bug: "a case over ['IND',…]" from one
path, "a case over ['MYS',…], a different literal order" from the other. I then wrote a JSON-string
compare, which cannot match two orderings of one set. The pre-warm half worked because that expression
is a shared constant; this half compared text that was never going to be equal.
The list is now canonical before both the compare and the write — de-duplicated, upper-cased, sorted —
so the expression is a function of the SET, which is the thing the two callers actually agree on. Order
is irrelevant to `in` membership, and the case normalisation is a no-op on today's data (both lists come
from the same regions dataset, and the highlight matches now, so they are already upper-case); it is
there so a future source that ships lower-case cannot silently select nothing.
★ IT IS ALSO A TEST, and worth reading as one. If the count drops to 3, the two sources agree about
which countries are in a region. If it does not, they disagree about the CONTENT — a data defect this
compare would otherwise have hidden behind a last-writer-wins repaint, and the next thing to chase.
Verified: gates:pre 13/13. Chained on f7a2f001 (same file). Needs rega: re-count with the same probe.
The claim was wrong when I wrote it and I only checked the built artifact afterwards. jspdf arrives
through useMapPoster.toPdf, which this map still uses for the poster, so the packet path was never the
thing holding it. The bundle in fact GREW about 131 KB (983,264 -> 1,117,687 on L42) when the shared
HTML renderer came in: each bundle is a single .js with no code splitting, so the dynamic imports in
this function are inlined at build time rather than lazy, which also makes the older "keep it off the
critical boot path" note beside them untrue.
Correcting it in the source rather than only in the ledger because the sentence was copied VERBATIM into
country-posters when that map moved to the same renderer — a wrong comment written confidently travels,
and the next person to plan a dependency removal would have started from it.
The change itself stands: the packet prints instead of downloading, and the duplicate implementation is
gone. It cost bytes rather than saving them, and now the file says so.
Chained on 2cf369be because it edits the same function. gates:pre 13/13.
PACKETSEL, found by exp-shepherd: the prayer packet printed the first 50 of EVERY people group whatever
was selected, so a reader who had narrowed the map to one language family got a packet about somebody
else. I carried that behaviour through verbatim when I moved the packet to the shared printer — it was
already wrong and I preserved it, which is worth saying plainly.
The rows now come from the pins currently SELECTED: the layer's own filter decides which pins render,
`_pinMatchExpr` decides which of those are lit (the affinity tab dims rather than filters), and both
must match — a pin at opacity 0 is not what the reader is looking at.
★ ASKED OF THE MAP'S FEATURES, NOT OF THE ROWS, and this is the part that had to be got right rather
than guessed. Pin properties are DERIVED from rows: useMapLayers fills languageFamily in when the API
returns null for all 2,069 groups, and canonicalizes doxaRegion onto the palette's key forms precisely
so setFilter matches. So evaluating a pin filter against a raw data row is wrong in a way that NEVER
THROWS — it just selects a different, plausible-looking subset. The features the map holds are the only
place the two vocabularies agree.
Answering that question already existed, privately, inside useMapFly: zoomToLegendRow has to solve it to
fit the camera to a legend row, including the reason it cannot use querySourceFeatures (viewport-clipped,
so an off-screen region silently selects nothing). Those three functions move VERBATIM to
library/maps/pin-selection.js and useMapFly imports them, so there is one evaluator rather than two —
and country-posters, which prints the same kind of packet from the same kind of selection, has the same
helper to reach for instead of a second copy.
Two failure modes are distinguished rather than collapsed: pins selected but NO row matching them is an
id mismatch, so it prints the full list and warns; a genuinely empty selection still reaches the shared
printer's own "that list resolved to no records", which is honest. And when the 50-row safety cap bites,
the cover now says "— 50 of 214" instead of quietly being a different packet than the one asked for.
Verified: gates:pre 13/13, check:composite-interop PASS. Needs eyes: rega — select one language family,
print, and check the cards are that family and the cover carries the count.
Both halves are rega's measurements on main 373b7979b, now that the WAGF selection holds and it is worth
optimising rather than optimising a bug.
NINE WRITES PER CLICK -> THREE. A single "Asia (Region)" click produced nine regions-* paint writes:
fill-opacity, line-color and line-width, three times each inside ~110 ms. Two paths painted the same
highlight from DIFFERENT sources — onSemanticTreeSelect from node.isoCodes, applyDimFilter from
mapLayers.regionIsoMap — each triggering the other through mapStore. Two sources for one visual is not
just wasted work: it is how they drift, and it already cost a debugging cycle, because when the highlight
was being cleared we first had to establish WHICH writer had done it. All four paths (click, watcher,
clear, and the layer-add below) now go through paintRegionScope, which writes a property only when the
value actually differs — asked of the MAP rather than a remembered key, so a layer torn down and re-added
cannot leave a skip holding a paint that is no longer there.
5.5 s AND 11.1 s OF SHADER COMPILE, MOVED OFF THE GESTURE. rega measured the row click compiling a new
variant — 5.5 s MapLibre, 11.1 s Mapbox on SwiftShader — because that click was the first moment these
three properties stopped being constants. Identical to the pins defect and fixed the same way: the idle
value is now ['case', ['has', 'iso_3166_1_alpha_3'], x, x], both branches the same so not one pixel
changes, and it is installed the moment addRegionsLayer returns. The variant therefore compiles on the
tab visit that is already loading a 6.4 MB GeoJSON, not in the middle of a gesture.
That placement is the whole point, and it is what I got wrong on the pins the first time: an idle
expression that is never installed pre-warms nothing. Here it is painted at layer-add, where the layers
first exist.
LEFT ALONE DELIBERATELY: highlightCountryByIso (0.30/0.02) and applyDimFilter's dim path (0.20) are
different states of the same layers, not duplicates of this one, so collapsing them into this writer
would have merged three features into one function to save nothing.
Verified: gates:pre 12/12, check:composite-interop PASS. Needs eyes: rega — expect 3 regions-* writes per
click instead of 9, and no compile on the row click; the compile should appear on the TAB visit instead.
Caught by reading the normalizer instead of waiting for the render: my adapter in f6a9d9d2 read
`g.religion` first, and DataSourceManager normalizes that field to the CODE — so every people-group card
would have printed MSN or BLA under the name, with the readable form sitting unused in `religionName`
one line away (DataSourceManager.js:357-358).
The country line had the same shape of error for the same reason. The renderer prints
flat(g.country_code) as display text next to the population, but the FIELD NAME invites handing it an
ISO code; normalized rows keep the code in countryIso and the label in countryName. Both now read the
readable form first and fall back toward the code, rather than the reverse.
This is the failure the commit before it flagged as the thing that could not be verified headlessly — a
wrongly mapped field prints blank or cryptic rather than throwing. It was findable without a browser
after all: the answer was in the normalizer, not in the rendering.
Verified: gates:pre 12/12. rega's check still stands and is still worth doing — this fixes the two fields
I could prove wrong, not the ones a rendered page would reveal.
Driver-lens audit card 2: the packet button downloaded packet.pdf while its poster sibling, two
functions above it, opened the print dialog. Same map, same menu, two different answers to "export".
The fix is not to wrap that PDF in a dialog. A PDF blob cannot go in the <img> the in-place print frame
uses, and every workaround — embed, object, the browser's own viewer — prints on some engines and
silently does nothing on others, which is the dead-button class this file has already paid for twice.
usePosterPrints.printPacket ALREADY does this: the same packet, authored as HTML with @page rules and
printed in place. This map was running a private second implementation of a shared product feature, with
jspdf behind it. Routing to the shared one deletes the duplicate, drops jspdf from this bundle, and the
packet gains the pages the shared renderer has that the local one never did — the life calendar, the
funnel and the prayer wheel, in the Driver's own page order.
★ AN EXPLICIT ADAPTER, because the mismatch would have been SILENT. The renderer reads slug / name /
population / religion / country_code, and usePosterPrints reads image_url; this map's normalized rows
carry country and imageUrl in camelCase. Handed over unmapped, the packet would have printed with blank
names and missing photos rather than failing — so the mapping sits in one visible place instead of
either side renaming its vocabulary.
Page 1 keeps the LIVE map rather than a fetched poster: this half is the one that knows what is on
screen. The shared renderer interpolates mapSvg as raw markup, so the PNG this map already knows how to
produce rides in as an <img>, as a data URL because the print frame is a separate document and an object
URL created here would be revoked out from under it. If that render fails the packet still prints — a
cover without the map beats no packet, which is the call the shared composable already documents.
Dead code removed with it: the download helper that served packet.pdf, and the lazy-import cache for the
composable this no longer uses. Neither export hands over a file now.
Verified: gates:pre 12/12. NOT verified here, and it needs to be before anyone calls it done: I cannot
see a rendered packet. The specific risk is the adapter — a field I mapped wrongly prints blank rather
than failing. rega should print one and look at the people-group cards before this is released.
The Driver's "the research map isn't connecting with the legend", on the WAGF Regions tab. It connected
and then wiped itself 0.4 s later, which is why it read as not connecting at all.
rega's stack trace ended three rounds of my guessing. The highlight paints at +51 ms; at +438 ms
clearAllHighlights runs, reached from applyDimFilter({kind:null}) through the mapStore.selectedRegion
watcher — so selectedRegion had gone FALSY while externalRegion still read "asia". Not the echo guard I
suspected first, not the JS filter scan I suspected second, not the shared legend's nodes reset I fixed
third (that was a real and separate bug). The round trip did it.
A row click emits map-filtered; the composite pushes the state back down as externalFilters; that payload
carries every key it knows — region:"asia" AND language:"". This handler set the region and, one line
later, "cleared" a language that had never been set. mapStore.selectLanguage() nulls selectedRegion by
design (exclusive selection, Bug 13) and does so even when its argument is null, so selectLanguage(null)
is a clear-everything in disguise. The 0.4 s is the debounce plus the trip through the composite and the
grid, which is why it looked like a mysterious timer.
An empty incoming value is now a NO-OP unless that dimension is actually lit. A genuine host clear still
clears — the dimension is lit and the new value is empty — and a genuine host selection still swaps
exclusively, which is what Bug 13 asked for. Guarded on BOTH axes, not just the one that bit: region and
language are symmetric here and fixing one would have left its mirror image.
★ THE SHARP EDGE IS IN THE SHARED STORE, and I am not moving it from here. selectLanguage(null),
selectRegion(null) and their siblings each null the other dimensions unconditionally, so every caller that
means "clear this one axis" is silently clearing all of them. That is load-bearing for other maps and
changing it belongs to whoever owns the store, so this fix is profile-local and the edge is recorded
rather than patched from a plugin lane.
Verified: gates:pre 12/12. Needs eyes: rega — the WAGF "Asia (Region)" click should now still be lit at
5 s, with the legend row keeping its state class.
70a9ce16 gave the cleared state a data-driven shape and stopped there, which was half a fix. The pins
layer is ADDED with a constant "circle-opacity": 1, and applyPinOpacity is otherwise first reached from
a legend click — so the new expression was only ever the CLEARED state, and the first selection was
still the moment MapLibre needed a new shader variant. The 3.2-3.5 s freeze rega profiled would have
survived the fix meant to remove it.
One line: applyPinOpacity(map.value) in the map-ready path, right after the layer is added. matchExpr is
left UNDEFINED on purpose — "re-apply what is current" — so a selection already restored from a
share-link hash is preserved rather than cleared at boot.
★ HOW IT WAS FOUND, because the method is the point: reading the BUILT bundle, not the source. The
addLayer call with its constant opacity is right there in the shipped JS, next to the pre-warm
expression I had just added — two facts that cannot both be true of a working fix. I had been about to
hand rega a tree to measure and would have read a still-slow first click as "the engine folds the
expression away", which is the wrong lesson and a day of chasing it.
Verified: gates:pre 12/12. Unchanged: this is still zero visual difference — both branches of the
boot-time expression are the same alpha.
The Driver: on Simple Research the research map is not connecting with the legend. It was connecting —
two seconds later, which a reader correctly reads as "nothing happened".
I was wrong about the cause twice, and both wrong answers were cheap to discard because they were
measured. First guess: a late echo through applyDimFilter's 80ms dedupe, whose toggle branch would read
the echo as a second click and clear the highlight. rega instrumented the pin layer every frame for 2 s:
ZERO 'legend:highlight' events on every click and no apply-then-revert anywhere. Second guess: the JS
filter scan in zoomToLegendRow, which walks 2,106 features evaluating ['in',['get','uniqueId'],
['literal', ~700 ids]] linearly. Benchmarked at the real shapes: 13.9 ms per full scan, 4.4 ms with a
memoised Set. Two orders of magnitude short, so nothing there was "optimised".
rega's CPU profile named it. ONE task of 3.2-3.5 s, self time getShaderParameter 3,583 ms +
getProgramParameter 551 ms: the GPU compiling a NEW SHADER VARIANT. The click was the first moment
circle-opacity stopped being a constant and became a per-feature expression, and a data-driven paint
property needs a different program. The second click on the same map costs 205 ms — the program is
cached — which is the signature of a one-time compile and not of anything in our logic. The legend's DOM
was never involved: 34 rows before the click, 34 after.
So the fix is not a smaller expression. It is that the first SELECTION must not be the moment the
variant is first needed. With nothing selected the value is now ['case',['has','uniqueId'], a, a] —
both branches identical, so not one pixel changes — and circle-opacity is data-driven from the first
paint. The program compiles during boot beside every other one, and a selection afterwards only
re-uploads attribute values.
The trade, stated rather than hidden: boot compiles one more program. On a real GPU that is tens of
milliseconds; rega measured seconds only because that box rasterises on the CPU through SwiftShader.
Paying it at load beats freezing the map mid-gesture, and the variant was going to be compiled on the
first interaction anyway.
Second finding from the same profile, fixed here too: circle-opacity was being rewritten 4-8 times for a
single click (+1.52, +1.57, +1.94, +5.17 s) because the selection handler, the glow sync and the slider
watcher all end up in this one function, and every write re-uploads the per-feature attribute. It now
writes only when the value actually differs — asked of the MAP rather than of a remembered key, so a
layer that gets torn down and re-added cannot leave the skip holding a dim that is no longer painted.
Verified: gates:pre 12/12 (check:setup-order included, since this touches module-scope state next to a
function that reads it). Needs eyes: rega, the same affinity click — expect the dim at the first frame
and one long task at BOOT instead.
The Driver, 2026-09-28: an export-poster button must open the PRINT DIALOG with a poster preview,
never a direct download. Simple Research was doing exactly the forbidden thing — render a PNG, hand it
to a download helper — so a reader got doxa-prayer-poster.png in their downloads folder with no
preview, no paper size, and no way to print it without opening it somewhere else first.
The dialog IS the preview. printImageBlob() puts the rendered poster on a sheet of exactly the spec's
size (@page 8.5in x 11in, margin 0 — the renderer already composed the margins in) and raises the
dialog over the map. It goes in library/composables/printInPlace.js, which is THE print mechanism here
and already carries the Driver's earlier ruling that killed the pop-up path: an iframe cannot be
blocked and needs no user gesture, so the same call works for a human click and for an agent.
The ellipsis comes back on the label. It was stripped while the button handed over a file directly,
and an ellipsis promises a dialog — which is now what happens.
★ AND THE GATE ASSERTED THE OLD RULING, which is the part worth naming. check-export-one-click passed
while the button did the thing the Driver has now ruled against: it waited for a download and checked
the PNG's dimensions. A gate that encodes a superseded ruling does not just miss the defect, it defends
it — the next person to "fix" this button would have had to break a green gate to do it. It is replaced
by check-export-poster-prints, same four cases (leaf and gallery, en and ar), asserting the new
contract: one click, some frame calls window.print() within 120s, the printing document is the
1700x2200 poster on an 8.5in x 11in sheet, and NO download arrives. window.print is stubbed in every
frame — the native dialog is modal and would hang the run — so this measures everything up to the
dialog and not the browser's own chrome, which is the right boundary for a headless gate.
Why the download half is asserted separately: a button that opens the dialog AND saves a file still
breaks the rule, and a download that lands late is still a download, so it is judged over the whole run
rather than at a moment.
Verified: gates:pre 12/12, check:doc-scripts and check:no-credentials-omit PASS with the rename.
NOT verified here: the new gate needs a browser, and this agent does not start one — rega runs it.
Both came from the Driver's console in the gallery's Simple Research tab, and both come from one
gap: where a line SITS in a setup block is not WHEN it runs.
research-map.vue — "Cannot access '_lastFitKey' before initialization", thrown during setup, which
left the map blank. The externalFilters watcher is immediate:true, so it runs while setup is still
on the stack and calls applyFitSlugs() 350 lines below it. The CALL resolved, because a function
declaration is hoisted; its constants are not, so the first read hit their temporal dead zone. A
hoisted function promises that its NAME resolves early, not that its BODY can run early, and that
asymmetry is invisible from both ends: the watcher looks like it only touches the store, and the
constants look like they sit right beside the function that reads them. They and the guard now sit
above the watcher, with a note saying why they cannot live next to applyFitSlugs().
DashboardMapPanel.vue — "onBeforeUnmount is called when there is no active component instance".
A lifecycle registrar closes over Vue's current instance, which exists only while setup is on the
stack, so a hook registered after "await initializeMap()" is silently dropped: a ResizeObserver on
document.body plus a window resize listener outlived the component. Nothing threw and the feature
worked, so it showed only as a studio that slowed down the more tabs a reader had visited. The fix
is not to move the line — the code that creates those handles is past the await either way. The
handles move to setup scope and are torn down in the hook this file already registers correctly,
which is the pattern it was using three lines away for containerRO and attribRO.
research-map.vue held a second copy of the same leak, its scroll-zoom resync listener. The new
gate found that one; reading the file had not.
check:setup-order covers both classes. Its own false positives are the instructive part: 6 hits
down to 1 over three rounds, and every one it dropped was prose or shadowing read as code — an
onBeforeUnmount inside a console.error STRING, the word "table" inside a new Error(...) template
literal in four files, and a destructured byCountry shadowing a computed 220 lines away. So it
blanks comments, string text and template text while keeping ${...}, which is code, and subtracts
every name a span declares for itself. It self-tests on both shipped defects, both fixes and all
three decoys, and exits 3 if an arm goes blind rather than passing quietly.
Source only. doxa-research-map, research-dashboard and dashboard-plus-grid need a rebuild.
rega, row 43 case 3 at 835ec1ac: the all-empty guard catches the cascade on
MapLibre and NOT reliably on Mapbox — in ar/mapbox the single event is still
EMPTY and the grid stays at 2,107. Three of four.
THIS IS THE THIRD TIME I HAVE TUNED A NUMBER FOR THIS ONE DEFECT, so the number
does not get another value. 60ms was "long enough for the cascade" and was not.
3 x 60ms was "long enough for the cascade" and is not either. I cannot measure
the cascade from inside the element, and every value I pick is therefore a
measurement of whichever machine I picked it on — which is the lesson already in
COMMON.md, arriving a third time because I applied it to the duration and not to
the DESIGN.
So the number's JOB changes. It was a guess at how long the cascade takes, which
is unknowable from here. It is now a CEILING on how long an empty report waits
before it is believed, which is a choice — and the two fail in opposite
directions. Too short reintroduced the bug three times; too long merely delays a
genuine clear by an amount no reader perceives. The loop now re-reads until the
state is NON-EMPTY, which is the condition that actually matters, and gives up
only at the ceiling.
900ms, chosen to sit under the composite's 1.2s push window so the two cannot
interact, and to be generous rather than tight — the failure mode of generous is
invisible.
Driven through the rule outside the browser: a cascade that fills at 400ms reports
FILLED at 420, one that fills at 800ms reports FILLED at 840, and a genuine clear
reports empty at 960. The old budget gave up at 180.
check:language-code-link's section G already asserted BOUNDEDNESS, and it failed
when I changed the shape — correctly, because that is the invariant it guards. It
now asserts the ceiling instead of the count, and says in the file why the shape
changed while the invariant did not: without SOME bound a genuine clear is
deferred for ever and the grid can never be un-narrowed. Both arms
negative-tested.
Source only. STUDIO: rebuild doxa-research-map.
NEEDS EYES: rega's ar/mapbox run is the one that matters — the other three already
pass, so a green ar/mapbox is the whole result.
rega, row 50: a region row click emitted {region:"asia"} and then an EMPTY event,
identically on both engines. The grid stayed narrowed only because the
composite's 1.2 second push window happened to swallow the second one — and a
pass that depends on a swallow is not a pass. rega said so, and was right to
refuse to score it green.
THE CAUSE IS MINE AND IT IS NOT A TIMING QUIRK. emitFilterState has always opened
with `if (applyingExternal) return`: when the HOST pushes state DOWN through
externalFilters the store changes, and reporting that back up is an echo of the
host's own instruction. That guard worked because the emit ran SYNCHRONOUSLY
inside the watcher, while the flag was still set. My coalescing moved the emit
60ms later, and applyingExternal is released on nextTick — so by the time the
emit ran the flag was false and the echo went out.
I fixed a burst and broke a guard that depended on the burst's timing. Changing
WHEN something runs is a change to everything that depended on when, and I did
not re-read what else was standing on that synchrony.
So the decision moves to schedule time, where the flag still means what it says:
a change that arrived from the host is not a change the reader made, and is not
reported back. The check inside emitFilterState stays as a second line of
defence.
This is the same shape as the dialect case on the branch below, and it is why
that one is a guard rather than a longer timeout: both are "the emit is no longer
where it thought it was".
★ AND THE GATE CAUGHT THE SAME HOLE IN ITSELF FOR THE THIRD TIME. The assertion
searched the scheduler for `if (applyingExternal) return`, and the comment
explaining the fix QUOTES that line — so deleting the code left the gate green.
It strips comments before asserting now. A check its own documentation can
satisfy is not a check, and I have now written that mistake three different ways:
a probe that yields zero when its assumption is wrong, a mutation aimed at one of
three identical bindings, and this.
Source only, stacked on sr/dialect-empty-cascade. STUDIO: rebuild
doxa-research-map (the same rebuild that branch already needs).
rega, row 43 case 3 at 52eeb0db: the coalescing landed — one event per click,
exactly as intended — and in 3 of 4 runs that one event was EMPTY,
{language:"", languageCode:"", languageCodes:[]}, so the grid stayed at 2,107.
The name fix works whenever it fires; the event just carried nothing to name.
THE CASCADE PASSES THROUGH A CLEARED STATE. mapStore.selectLanguage() sets
selectedDialect = null BEFORE it sets the language, so a dialect click walks the
store through "nothing selected" on its way to the answer. The settle timer fired
inside that window and read exactly what was there. The composite then did the
right thing with what it was told: an all-empty report IS how this element says
"the reader cleared everything".
MY FIRST FIX WAS A TIMEOUT, AND A TIMEOUT WAS THE WRONG KIND OF ANSWER. 60ms was
enough in my seat and not under headless swiftshader. That is the third time I
have tuned a duration on one machine and called it settled, so this one does not
get lengthened — lengthening moves the failure rather than removing it.
The guard is SEMANTIC instead. An all-empty read arriving straight after a filled
one is the one genuinely ambiguous case — it is what a mid-cascade read looks
like AND what a real clear looks like, and nothing in the payload separates them.
So it is re-taken rather than believed: the transient fills in and is reported
correctly, while a real clear stays empty through every re-take and is reported a
few tens of ms later, which no reader can perceive. The re-takes are BOUNDED
(3 x 60ms) so a clear can only ever be delayed, never swallowed, and the whole
budget stays far below the composite's 1.2s push window so the two cannot
collide. The budget resets on every fresh user action.
Note what is NOT assumed: the retries re-read the store on their own timer rather
than waiting for another watcher fire. In the failing runs the watcher fired only
ONCE, so a fix that depended on a second fire would not have worked.
check:language-code-link grows a section G for it, asserting both halves — the
guard exists, and it is bounded. Without the first the transient ships; without
the second a clear can never be reported at all. Both negative-tested.
Source only. STUDIO: needs a rebuild of doxa-research-map.
rega's Arabic survey on main 52eeb0db: the research-dashboard legends sat on the
wrong edge with English text, 70 of 85 letters Latin, in Big Picture and in the
Research Gallery. Two faults that look like one, and they fail in opposite
directions if you fix them carelessly.
A PHYSICAL INSET DOES NOT MIRROR. right:8px pins a box to the physical right edge
in every locale, so in Arabic it stays where an English reader expects it while
everything around it flips. Both surfaces the survey named are logical now, and
inset-inline-start:auto travels with inset-inline-end as a pair rather than as
decoration: it cancels a physical inset inherited from a control container, which
otherwise stretches the box across the map once the anchored edge moves.
The second surface is research-map's geocoder column, which studio found in its
own sweep. That column exists ONLY to share an x-edge with the legend below it —
the alignment is recorded in the file — and the legend already mirrored while the
search bar did not, so under ar the pair came apart and the bar was stranded on
the side the legend had vacated. A shared edge that one of the two honours is
worse than no alignment: it reads as a broken layout rather than as a choice.
THE ENGLISH WAS NOT CHROME, AND THAT CHANGES THE FIX ENTIRELY. The legend renders
a facet VALUE, and for the religion donut that value is a family name DERIVED in
English by library/colors/religion.js — the same table people-groups-grid
re-derives per row so the two ends byte-match. Translating those strings is the
obvious fix and the dangerous one: it would work in English and match nothing in
Arabic, SILENTLY, because an empty intersection reads as "no results". That is
the language-link defect one facet over, and it cost a card to learn the first
time.
So the value stays canonical English and only the visible text is projected, the
same rule the shareable URL follows, where the code is canonical and the slug is
display-only. religionFamilyLabel() lives beside the table it projects, so the
next consumer finds it rather than inventing a second spelling, and the table
itself now carries a comment saying these are values and must not be translated.
Ten family names in eleven locales, English wording held.
check:legend-mirrors guards both halves — and its own negative test found a hole
in it. A mutation aimed at the legend's data-key silently hit a DIFFERENT
:data-key, the donut segment, and the gate stayed green while a projection sat on
a wire. There are three such bindings and it was guarding one. It now checks
every :data-key and :data-facet in the file; all three mutants are caught.
★ AND I BROKE THE FILE'S PARSE ON THE WAY, worth recording because the rule is
already written down. I used backticks around CSS property names in a comment
that lives INSIDE a shadow-CSS template literal, where a backtick ends the
literal and everything after it is read as code. check:sfc-parse and
check:no-free-identifiers both caught it immediately. The comment now says so, in
the file, without backticks.
Source only. STUDIO: needs a rebuild of research-dashboard, dashboard-plus-grid,
doxa-research-map and research-map-plus-grid.
rega, row 43 case 3, both engines and both locales: clicking a dialect emitted
the right languageCode every time and narrowed the grid in only ONE of four runs,
while the name it reported read ", Afro-Asiatic__Arabic__Sudanese". Two defects
in one click, and both are mine.
THE NAME WAS A TREE NODE ID. selectedDialect.key is family__baseLang__dialect,
and the watcher fifteen lines above the offending template says so in as many
words — I read that comment and then used the key as a display name anyway.
selectedLanguage is also empty by the time a dialect is active, so the template
produced a leading comma in front of an internal identifier: a string no reader
could make sense of, in the one slot meant for a human name. The dialect's
originalLabels are the API labels the legend grouped ("Arabic, Sudanese"), which
are both the right words and already in the reader's locale. When a dialect
groups several labels all of them are reported, because that is what the
selection covers.
THE GRID LOTTERY WAS A BURST MEETING A GUARD. Clicking a dialect does not change
one store value; it clears the language, sets the family and sets the dialect,
across ticks. So the watcher fired three or four times and this element
dispatched three or four map-filtered events for ONE click. The composite ignores
anything arriving while it is pushing state into its children — a guard it needs,
because the grid re-emits during a push and the round trip would otherwise cancel
the selection just made — and that window is 1.2 seconds. Whether the LAST event,
the one carrying the settled state, arrived inside or outside the window was
timing. Three runs lost it; one did not, which is exactly the 1-in-4 rega
measured.
The fix belongs in this file rather than the other end: the guard is correct, and
the burst is what this element should not have been sending. One click, one
event, nothing to drop. A short settle window rather than nextTick because the
cascade crosses ticks (the legend bridge does async work); 60ms is far below the
1.2s guard and below what a reader notices.
I am NOT touching the composite's 1.2s window in this commit. It is now only
reachable by a genuinely rapid second click, and changing a guard that exists to
stop a self-cancelling round trip is not something to do in the same commit as
the bug it was masking.
check:language-code-link grows a section E for both, asserted against the source
because both are shapes. Three mutants, and the SECOND one found a hole in my own
check: searching the whole file for `emitFilterStateSettled()` is satisfied by the
helper's own declaration, so reverting the WATCHER left the gate green while the
burst came back. It now reads the watcher line specifically. A check its own
subject can satisfy is not a check.
Also corrects 22 to 23. rega counted 23 codes under the "Arabic" tier and was
right: 22 came from a cruder comma-split grouping of mine, while the tier's real
three-arm rule selects 23. The gate's computed numbers were always right; only my
prose was wrong. Two more comments still saying 22 live on sr/kit-d1-multivalue-filters,
which is already in studio's queue — I have not rewritten a branch someone is
landing; they need the same one-word change once it lands.
C3, buckets A and B. Each site was sniffing `window.maplibregl.Map` directly,
which breaks the moment a leaf stops loading a static engine peer: the global is
simply absent and the sniff answers about whatever else the page happened to load.
A needed a helper that did not exist. Both A sites want a provider NAME from a
live map. engineFor() returns the engine NAMESPACE, and resolveMapProvider()
reads a CONFIG rather than a map, so on a dual-engine page it answers about the
wrong thing. Hence providerFor(map) -> 'mapbox' | 'maplibre' | undefined, with two
named consumers on arrival.
★ WHY providerFor RETURNS undefined INSTEAD OF A DEFAULT. The obvious rewrite was
in terms of the existing isMapboxMap(). It is wrong in a way no per-engine test
catches: both originals test for MAPLIBRE and fall through to mapbox/false, so an
isMapboxMap() version inverts the fall-through to maplibre/true. On a stub, a
destroyed map, or a page where neither global loaded, that changes what a
diagnostic field reports AND which attribution a poster prints -- while agreeing
on both real engines. Returning undefined forces each call site to keep its own
default visible:
pplrData.js engine: providerFor(map) || 'mapbox'
research-map.vue const onMaplibre = providerFor(map.value) === 'maplibre'
Changing a default is not a curation migration.
B is engineGlobal(mapProvider), which already defaults to Mapbox for anything
unrecognised -- exactly what the ternary's else branch did.
All three files now carry zero raw window.mapboxgl|maplibregl references in code.
Verified by _qa-cycle/02-builder-cycle/_chk-c3-provider-helper-equivalence.mjs:
27 assertions, each old pattern against its replacement on both engines and on
{}, null and undefined, plus an executable proof of the isMapboxMap near-miss and
three negative controls.
rega, on MapLibre, in BOTH locales: Languages > Language > "Arabic" sent
map-filtered with {language:"Arabic", languageCode:""}, the composite handed the
grid an empty code, and 2,107 rows became 0. An empty grid reads as "no
results" — the worst shape a wiring bug can take, because the reader believes
the data.
ONE LINE, AND IT WAS MINE. The emitter looked for a row whose language LABEL
equalled the selected name. A language tier's label is not a label any row
carries: the legend builds family > language > dialect and derives the language
tier by splitting the API label at the comma. Measured on the live source —
2,107 rows, every one carrying primary_language as {value,label} — there is NO
row whose label is "Arabic". There is "Arabic, Sudanese", "Arabic, Levantine",
"Arabic, Gulf", twenty-two codes in all. The exact match could never hit, in any
locale, which is why it failed identically in en and ar and why the locale fix
that prompted this code was right about the disease and wrong about this line.
This is the derived-value trap one tier down from where I last wrote it up: a
value that crosses a component boundary has to exist on a row. "Arabic" is a
grouping the legend invented; primary_language.label is a field.
So the rows are now matched the way the legend's own node matches pins — base,
"base," prefix, " base" suffix — rather than by a fourth hand-typed spelling of
a rule that already exists in two places. 61 tiers resolved an empty code
before; "Arabic" was one of them.
AND ONE CODE ONLY WHEN THERE IS ONE. "Arabic" means 22 ISO codes, so no single
value expresses it. Emitting one would filter the grid to a fraction of the
selection while looking correct; emitting the tier NAME is what showed zero. The
honest answer is to emit nothing for that axis and leave the grid unfiltered —
the map is narrowed, the grid is not, and the reader can see both. languageCodes
carries the whole set alongside, so this becomes a real narrowing the day
filterSet goes multi-value (SPEC-DASHBOARD-KIT D1) rather than a rewrite.
Measured on the fixture: 764 tiers now resolve one code and filter the grid; 41
cover several and abstain.
A DIALECT IS A LANGUAGE SELECTION TOO, and it emitted nothing at all — the
watcher never observed selectedDialect, so drilling from "Arabic" to "Arabic,
Sudanese" changed the map and left the grid reporting the tier above. Its
originalLabels are exact API labels, so it is the one unambiguously resolvable
case.
check:language-code-link, headless, no network at run time. It LIFTS the
emitter's match rule and the composite's gridLanguage() out of the shipping
source rather than carrying its own copy — a gate with its own copy of the rule
passes while the product is broken, which is exactly how this survived a green
check:linked-vocabulary. It proves the emitter's predicate and the legend's
filter expression agree on every (label, tier) pair, that every tier selects at
least one row, and that an ambiguous tier narrows nothing. The fixture is the
864 distinct label/value pairs from the live source, deduplicated rather than
sampled so the counts above are about the whole set. Four mutants; reverting the
emitter to the exact-label rule reproduces rega's finding exactly — 61 tiers
selecting zero rows, "Arabic" named first.
STUDIO: needs a rebuild of doxa-research-map and research-map-plus-grid.
SPEC-LIBRARY-CURATION.md's first migration, in the order the shepherd approved.
Source only; studio rebuilds (rule 5B — library/ plus eight other owners' plugins).
THE ASYMMETRY, which is why 12 sites each solved it or failed at it separately:
Mapbox getProjection() -> { name } setProjection('mercator')
MapLibre getProjection() -> { type } | undefined setProjection({ type: 'mercator' })
Passing the Mapbox shape to MapLibre THROWS. Measured on regions-dashboard, where
the throw aborted the camera fly so a region dashboard never framed its region.
WHAT THE 12 SITES WERE DOING:
· NINE passed a bare string, so on MapLibre the projection silently never changed.
Eight swallowed the throw in a try/catch; doxa-simple-map.vue:1244 did NOT, so
there it threw uncaught.
· TWO had solved it independently and differently: pplr-data-maps'
setProjectionFor() (instance-tested — the correct one, and the shape adopted)
and regions-dashboard's local pair (config-derived, which is weaker: it trusts
what the page was configured with rather than what the map IS).
· ONE probed by exception, trying the object form and falling back on throw.
The helper reads the engine from the INSTANCE, never from config, because a page can
hold a map built by the other engine. Both functions return something observable —
setProjectionOn returns whether it applied, projectionOf returns null rather than
throwing — on upg100-plugin's argument that a precondition which bails without
recording it is indistinguishable from the request never arriving.
pplrData.setProjectionFor stays as a delegating named export so its three views need
no change.
GATE: _chk-c2-projection-helper-equivalence.mjs, 20 assertions. It runs all THREE old
patterns and the helper against the same stub maps and compares outcomes, rather than
testing the helper alone. Controls prove pattern A really does fail on MapLibre and
leave the projection untouched, and that the bare string really throws — so the
"equivalence" claim has something to be equivalent to. A NEG control catches a helper
that reports success it did not achieve.
★ I DESTROYED 984 LINES OF ANOTHER TEAM'S CODE GETTING HERE, and only the build caught
it. My import-inserting script did `s[:at] + IMPORT` and never appended `s[at:]`, so
every file where the import was ADDED rather than extended was truncated at its last
import line: MapCell.vue -458, FlowerClusters.vue -334, lang-cluster.vue -192.
check:no-free-identifiers PASSED on all three, because a truncated script block is
still valid script. Restored from HEAD and redone with an assertion that a migration
must change the line count by exactly +1 and never remove a line — which is the check
that would have caught it in the first second.
Verified after: all 9 affected bundles build, both gates green, and a per-file line
delta on all 13 changed files with the only negative being the 2 lines I deliberately
deleted from RegionCountryMap when its local pair became an import.
Localizing the grid's data (ae5e27c3) would have broken the language link
silently, and the shepherd asked me to prove the coupling with a test. Measuring
it first showed the coupling should not exist.
MEASURED against the live API, en vs ar, 2,107 records:
primary_language LABEL 864 names in en, 864 in ar, ZERO shared → localized
primary_language VALUE 864 codes, ALL 864 shared → stable
religion / wagf_region VALUE also identical across locales
So the fix is not "make both halves fetch the same locale and test that they do".
It is to link on the form that does not move. Region already links on a slug and
religion on a family letter; language was the one facet still travelling as a
display name.
The map now emits `languageCode` alongside `language`, the composite carries
{value, label} for language as it always has for region, the GRID is driven by
the code (valueMatches compares the normalized value as well as the label, so a
code matches in every locale) and the MAP is still driven by the name, because
its legend selects by name. Each half gets the form it can actually use, and
neither depends on the other's locale.
★ THIS TREE HAD ALREADY PAID FOR THIS ONCE. deriveLanguageFamily in useMapData
carries a comment about the 2026-06-22 locale colour bug: deriving a family from
the localized label collapsed every pin onto one bucket, and the fix was to use
primary_language.value. Same trap, one facet over, three months later.
check:linked-vocabulary now runs per LOCALE as well as per provider — en as the
control, ar as the case that would have broken in silence — and asserts the
language facet with a poison case (a language that cannot exist must NOT narrow
the grid). It is a browser gate, so rega runs it; I have not.
★ AND check:no-free-identifiers CAUGHT MY OWN BUG IN THIS COMMIT on its first
outing: I wrote `rows.find(...)` in the emitter where no `rows` was in scope. The
gate named the file and line before anything was built. That is the class of
mistake that broke both composites yesterday, found in seconds this time.
Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').
- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
(71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
The default list returns the same array, unfetched. A list whose membership cannot load shows no
groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
thread `list` to their children like `provider`.
Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.
Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.