Both halves of studio's L13U refusal were correct and both are mine.
1. THE LEGACY upg100WallIntro FALLBACK IS GONE. It existed for exactly one window — the three
plain-text keys were landing in studio's catalogue while this consumer was already written, so the
branch rendered the old markup-bearing key until they arrived. They are on main in all eleven
locales, so the condition can no longer be true: it is DEAD CODE that keeps a key alive, because
studio cannot delete upg100WallIntro while a line still reads it. A fallback whose condition cannot
fire is not safety, it is a reference.
The three remaining mentions in this file are COMMENTS, not code — verified that no live call site
anywhere in plugin-designs/ or library/ reads it, so it is now safe to delete.
2. THE GATE DEFAULTED TO A GITIGNORED FILE. check:zoning-declared read
library/scripts/posters/out/_records.json, which .gitignore:94 excludes, so on a clean checkout it
exited 75 — NOT MEASURED forever, indistinguishable from an unwired gate. studio measured exactly
that and correctly refused to wire it into gates:post. This tree has been here before:
check:packets-all SKIPped on every build for weeks for the same reason.
★ THE FIX IS A COMMITTED FIXTURE, NOT A LOOSER GATE. data/people-groups/zoning-records.json carries
slug plus every field a selector actually reads — 2,107 records, ~300 KB. country_code alone would
not do: the deaf zone reads rop1 and five African/European zones read wagf_block/wagf_region, which
is the same omission that made six zones silently resolve to nothing in the packet generator.
The live cache is still PREFERRED when present, so a local run measures the freshest data and CI
measures the committed one.
VERIFIED on a genuinely clean tree (git archive of the staged tree, no cache, no network):
PASS — 28 zones, 2,107 records, 2,096 covered, all five assertions green, exit 0.
check:i18n-keys-exist PASS, check:sfc-parse PASS.
The P1 that emptied Big Picture's Religion and Region charts was a free identifier:
useDashboardAggregations.js read NEUTRAL five times and never imported it. This gate exists for exactly
that class and did not fire, because it walked only .vue files. A bundle compiles .js and .vue alike,
so the gate was seeing half of what ships.
Two things were needed, and the first attempt got only one of them. Adding .js to the walker is not
enough: a .js module was then handed to Vue's SFC parser, which found no <script>, and acorn reported
"Unexpected token (2:1)" — the second line of the file's own doc comment. A .js file is its own script
block, so it now parses directly, with offset 0 so reported line numbers are the file's real ones.
VERIFIED BOTH DIRECTIONS, which is the only way to know a gate works:
- clean main: rc=0, no false positives
- NEUTRAL import removed again: rc=1, naming useDashboardAggregations.js:173 and :216
So it would have caught the P1 before it reached the Driver's console.
Blast radius measured on main first: exactly 2 findings, both false — DOMException and
createImageBitmap, standard browser globals missing from the known-globals list. Added. No other
owner's file is implicated, and nothing was changed outside this gate.
Two certain defects from source. What I could NOT settle without a browser is stated at the end
rather than guessed at.
1. THE PROBE READ THE WRONG NODE. The sign has to match the element the GRID lays out with. Both
probes read the SHELL; the handle is a grid child, so whatever direction the grid resolves the
handle inherits — and the shell is one node further out. A host that sets dir between the two, or
a shellRef that is not the grid itself, yields a sign that disagrees with the mirroring the reader
can see. Both the pointer and the keyboard path now try the handle first and fall back to the
shell, then to ltr, so a detached handle cannot throw mid-gesture.
2. research-dashboard's two handles used PHYSICAL margins (margin-right / margin-left) while the grid
they sit in mirrors — grid-template-columns and justify-self both follow the inline direction — so
in Arabic each handle's 4px overhang sat on the wrong side of its own column. go-int's board
already used the logical pair; this makes them agree.
WHAT WAS ALREADY RIGHT, so nobody re-fixes it: the xSign inversion itself is correct for a mirroring
grid and is DEPLOYED — 2 of 2 rtl checks present in the built gospel-internet, dashboard-plus-grid and
research-dashboard bundles, so this was never a stale-rebuild problem.
MEASUREMENT I NEED (rega, one line): during a drag in ar on the Gospel x Internet board, log
getComputedStyle on BOTH the handle and the shell and report each .direction. If the handle says rtl
and the shell says ltr, item 1 above is the whole bug. If both say ltr while the grid is visibly
mirrored, the mirroring comes from somewhere my probe cannot see and I will need that.
Source only. Rebuild: research-dashboard, dashboard-plus-grid, gospel-internet (library change).
On a phone the brief covers the bottom half, and the selected group and its neighbors
were framed behind it; both views now fit them into the visible strip. The prayer
map's "Tap a coloured dot" coach-mark, which covered the brief, is turned off here.
country-posters-mfe reads profile-config `view` ("map" or "wall") to choose its
opening pane, so an embed can start on the map.
Five lenses answered one question five ways on a 40-country snapshot, and the default
one silently dropped 27% of its senders. They are replaced by two views over one
dataset built from peoplegroups.org (7,307 unreached groups):
Mobilize pick an unreached group; see its reached neighbors at E-1 (same language),
E-2 (near culture) and E-3 (nearby, other culture), its P-level, what
resources exist in its language, and export them as audience segments.
Campaigns bridge languages spoken by believers and by unreached groups, ranked by
the unreached population one campaign in that language reaches: where to
run it, whom it reaches, exportable.
The dataset is fetched (bundle 1.27 MB -> 0.55 MB); the old view names open the new
views so existing embeds keep working. 65 new strings in all eleven languages.
The tapestry is laid out at half its former size in the map's world square,
so from the opening view the reader can zoom out two levels instead of
one. Every zoom-dependent stop (dot sizes, label entry zooms, framing
limits) moves up one level with it, so the opening view looks the same.
profile-config tk, then the token ProfileLoader provides (a tokenUrl
token-service answer or the page's shared token), then window.MAP_TOKEN.
It read only the first and last, so a host using tokenUrl got no Mapbox
token on either of the board's maps.
stage:experimental puts it on the Plugin Studio's Experimental shelf until it
works as a production map; heavy:true makes the Studio unmount it when
left, since it holds up to two WebGL contexts (map + language tree).
The Driver reported the list page being slow and guessed the posters. The
posters are behind a v-if and never load until a region is opened. The cost
was the mount fetch: it requested the full PEOPLE_GROUP_LIST_FIELDS for every
people group, and this page renders none of it -- a region's label, group
count, country count and country names all come from country_code and
wagf_region alone. Verified by running summarizeRegions on two-field rows:
the four values this page binds all come out.
SIZE OF THE SAVING: NOT MEASURED, and I will not put a number on it. Six of
eight fields are dropped per row across the whole dataset, including two
coordinates, a population integer and three status/count fields, so the
reduction is real -- but sizing it needs the live endpoint, which I cannot
call from here. My first attempt quoted a proxy off
public/assets/people-groups/people-groups-slim.json; that file is the IMB
dataset in PascalCase and carries NONE of the eight field names, so both
halves of that comparison were measuring empty objects. The figure was
meaningless and is removed rather than rounded.
PEOPLE_GROUP_LIST_FIELDS is untouched: the dashboard tab needs all eight for
its map pins and stat cards.
It owns up to two WebGL contexts (its map, plus the language-tree
whiteboard once opened). Kept mounted by v-show, it spent the context
budget that silently drops the oldest map. go-int's edit here was
dropped at landing.
82 lines, one importer (useDashboardShell), and every import already @map/ — so it moves as-is with
no adapter and no shim. Its inject('profileConfig') stays inside its try/catch: it can be called from
outside setup, and that is the property the move had to preserve.
Source only. research-dashboard and dashboard-plus-grid need a rebuild.
Landing 4a extracted buildSearchAccessors / makeSearchMatcher / filterRowsBySearch into
library/composables/useDashboardSearch.js. It left them DEFINED in useDashboardShell.js as well —
4.3KB of code-identical copies, including the Fuse.js matcher. I compared all three function bodies
with comments and whitespace stripped: identical, byte for byte.
Nothing had diverged, so nothing failed, and that is exactly why it survived: the facet rail ran the
library copy while the shell's KPIs and three panels ran the shell's. The comment that used to sit
above those definitions said the point of ONE declaration is that "the searchable text can never
drift from what the panels show" — and there were two. This tree has already paid for that shape once,
at Jaccard 0.82, with useNeighborT and usePplrT.
The shell now imports them like every other consumer, and DashboardChartsRail, DashboardDataTable and
DashboardMapPanel read '@map/composables/useDashboardSearch.js' instead of reaching through the shell.
No re-export shim: all four consumers are repointed in this commit, so there is nothing for a shim to
serve, and an unretired shim is how three older ones sat in the tree unnoticed until this afternoon.
check:filter-multivalue grows arm E, deliberately beside arm B. Arm B exists because a copied FILTER
predicate lets one panel disagree with the others about what is on screen; a copied SEARCH is the same
hazard with a different name. Arm E asserts the library module declares all three, that the shell
declares none of them, and that no file anywhere imports them from the shell. Its importer sweep walks
plugin-designs and library rather than a hardcoded list, because a hardcoded list of "files that might
import this" is the assumption that let 4a's copies survive.
Groundwork for 4d-4: this removes 4.3KB from the file that has to be split into a generic kernel and a
research composition root, and it removes three exports from the surface that split has to preserve.
Source only. research-dashboard and dashboard-plus-grid need a rebuild.
The Driver: a public plugin that is not yet a production map shows under Experimental until it is. A
profile's .meta.json declares "stage": "production" | "experimental" (default experimental; a bundle is
production if any profile says so) and "heavy": true for a live WebGL owner. generate-tree carries both into
manifest.json, so the Studio and the gallery read the same field and neither keeps a list.
The seven production bundles are marked: dashboard-plus-grid, research-map-plus-grid, regions-dashboard,
my-upg-100-list, doxa-simple-map, cities-internet, gospel-resources. The Studio rail keeps its capability
sections for them and puts every experimental bundle (gospel-internet among them) in one final Experimental
section. The gallery's auto-discovered Experimental folder skips a production bundle. gospel-internet-board
joins HEAVY_ELEMENTS (it can own two WebGL contexts). check:studio-rail asserts the split.
The numerals policy is HELD — whether counts follow the reader's locale is the Driver's decision — so
this changes nothing anyone can see. Every caller renders exactly the digits and separators it
rendered before. What changes is how many places that decision is written down: thirteen call sites
across four bundles becomes one, library/utils/formatCount.js, and eight of the thirteen (mine) now
read it.
That is the whole value while the policy is held. If the answer becomes "follow the locale" it is a
one-line edit in formatCount.js instead of thirteen edits by four owners, each free to pick a
different locale source — and the first divergence in that world would be invisible: two panels in
one dashboard, same number, different separators.
★ IT FORMATS. IT DOES NOT DECIDE WHAT EMPTY MEANS, and that is the bug this fold would otherwise have
shipped. I told dx-shepherd these three helpers were "byte-identical". They are not:
DashboardDataTable null/undefined -> '' and '' falls through to Number('') === 0, so "0"
DashboardDetailDrawer null/undefined/'' -> '—'
useDashboardShell null/undefined -> '—' and '' likewise becomes "0"
Three different answers to "what does a missing count look like", inside one dashboard. Folding them
onto one empty policy would have silently changed two of the three — a table cell going from blank to
an em dash, or an em dash where a zero used to be. So each caller KEEPS its own guard and hands
formatCount a value it has already established is real. The locale decision folds; the empty policy
does not, and the difference is written into the helper's header so the next person does not re-merge
them.
Folded: DashboardDataTable, DashboardDetailDrawer, useDashboardShell (its exported fmt keeps working
for the KPI strip), DashboardFacetRail's three inline template calls, people-groups-grid's two.
NOT folded, because they are other owners': regions-dashboard (3) and pplr-data-maps (2). The helper
is theirs to import when they want it; nothing forces them, and no gate demands it — a gate requiring
the helper everywhere would fail on their files today, which is not a thing to land on someone else.
Source only. research-dashboard, dashboard-plus-grid and people-groups-grid need a rebuild.
THE GROUP WAS SCHEDULED AS ONE LANDING AND DELIVERED AS TWO. Section 8 of the spec puts the facet
rail, the chip bar and the BREADCRUMB in a single landing. The rail moved in 4c-3 and the chips in
4c-2; the breadcrumb was left behind, and nothing failed, because it is mounted by nobody. A leftover
like that is invisible unless someone re-reads the spec against the tree, which is what this landing
came out of.
The breadcrumb gets exactly the inversion the chip bar got: no useTreeLegendLabels import, the
resolver read from dashboardConfig.legend, and the SAME unconfigured default — identity, joining an
array. Identical on purpose rather than by copy-paste: the breadcrumb and the chips render the same
filterSet in the same band, so if their defaults differed a half-configured dashboard would show one
value resolved and the other raw, side by side. Its dead `dataStore` / `searchSourceId` injects go
too; they existed only to build the composable that is no longer imported.
★ AND THREE ONE-CYCLE SHIMS FROM LANDINGS 2 AND 3 WERE NEVER RETIRED. plugin-designs/research-
dashboard/src/composables/ still held useDashboardLayout.js (11 lines), useSelectionCamera.js (11) and
useCsvExport.js (14), each a pure re-export of the library module. MEASURED before deleting: ZERO
importers. Every consumer — including DashboardMapPanel, DashboardDataTable, DashboardDetailDrawer,
useDashboardShell, gospel-internet's board and research-dashboard.vue — already reads
'@map/composables/…' directly, and a tree-wide grep for a relative import of any of the three returns
nothing. So they were dead code that made the plugin look like it still owned three library modules.
I wrote "a one-cycle shim that nobody retires is permanent" into the previous landing's gate comment
while three older ones were sitting in the tree unnoticed. They were not found by a gate; they were
found by listing what is still in plugin-designs and comparing it against the spec.
check:legend-mirrors arm 5 now guards three moved names instead of two, so a re-export of the
breadcrumb creeping back into the plugin barrel is caught the same way.
Source only. research-dashboard and dashboard-plus-grid need a rebuild.
The chip bar (4c-2) and the facet rail (4c-3) each moved into library/components/dashboards/ and were
re-exported from the plugin barrel for ONE cycle, so that moving a file and changing its importers
were separate, reviewable landings. That cycle is over.
BLAST RADIUS, measured on main rather than assumed: exactly ONE real importer. research-dashboard.vue
imported DashboardFacetRail from './src/components/dashboards'; it now reads
'@map/components/dashboards/DashboardFacetRail.vue'. DashboardFilterChips had ZERO importers, so its
shim served nobody and is simply gone. Everything else that named either component was a gate already
pointing at the library path, or prose.
check:legend-mirrors arm 5 INVERTS, and the inversion is the point rather than an edit. While the
shim existed, the risk was an importer failing to resolve, so the arm REQUIRED the re-export. With the
shim retired the risk is the opposite and larger: a re-export creeping back makes the plugin barrel
look like the owner of a library component, which is exactly how check:filter-multivalue once ended up
reading a shim instead of the panel it was written to guard, and passing vacuously. A one-cycle shim
that nobody retires is permanent, so the gate now holds the window closed — it fails on a returning
re-export AND on any file still importing either name through the barrel or its old path.
★ THE THREE FILES MUST LAND TOGETHER. Measured: the new gate produces 3 arm-5 failures against
unmodified main, because main still has both shims and the barrel import. check:legend-mirrors is in
gates:post, so landing the gate without the source changes turns every build red. All three failures
are in the two files this commit fixes, so nothing outside it is affected and no other owner is
blocked.
Two false positives of my own, both caught by running the gate rather than reading it:
- The importer test used `import … <Name> … [\s\S]{0,200}? from '…'`, and that span CROSSED INTO THE
NEXT STATEMENT: after the fix, research-dashboard.vue has the new single-name import immediately
followed by a braced import ending `from './src/components/dashboards'`, so the regex started on the
corrected line and found the barrel path two statements later. It reported the file it had just been
fixed in. Now bounded to one statement with a negative lookahead for the next `import`.
- That is the third unbounded-span false positive I have written today — the others walked a brace
match past a callback-less watch() and read a chip's internal name as prose. The shape is the
lesson, so it is written down beside the fix: never let a span cross a statement boundary.
Verified on main + these changes: check:legend-mirrors, check:sfc-parse, check:no-free-identifiers,
check:filter-multivalue, check:no-cross-plugin-imports, check:composites-mount, check:library-index and
check:setup-order all pass. Arm 5 negative-tested both ways: a returning barrel re-export and a
reverted importer are each caught, and the clean state passes.
Source only. research-dashboard and dashboard-plus-grid need a rebuild.
Six public/ conflicts, zero in source. They take main's side here and are regenerated from the merged
source in the next commit — a bundle is an output, and a hand-reconciled middle version is wrong by
construction.
P1, rega on main a71007f1: "NEUTRAL is not defined", both engines, both languages, and the two charts
render nothing.
Kit landing 4c-1 moved NEUTRAL into library/composables/useFacetAggregation.js and updated the
RE-EXPORT line in useDashboardAggregations.js, but not the IMPORT line beside it — so the module
re-exported a name it could not itself read. colorFor() and buildCountryRegionColors() read NEUTRAL
five times as the "this facet has no palette for that key" colour, and every read threw.
One line: NEUTRAL joins the import. Nothing else changes.
The wall opened at the whole-wall view, where a card is 11-15 CSS px, under wallLod's 48 px photo
threshold, so every card showed its empty image slot: the README picture read as a blank grid (the
Driver: "zoomed too far out... hard to see the people groups"). It now opens at the top-left corner,
where the store puts the biggest clusters, with cards ~96 px wide. Checked headless with lodPlan on
the component's own geometry (2,107 groups, 53x40): desktop 1440x820 k=7.5, 77 photos; 1280x720
k=8.6, 60; phone 390x700 k=20 (max), 15 photos, all 'hd' and far under the 300 cap, so the memory
win holds. Reset Zoom still shows the whole wall. Rows were 3 units apart against 8 between columns
and left a 200-unit empty band at the bottom of the 4ft x 3ft canvas; 8 fills it exactly.
Built with BUNDLE=wall-of-people-groups only, then the tree; check:sri PASS.
PRODUCTION-MATRIX upg-100-mfe composite -> map (doxa:select-zone), the last red cell in the 7.
CAUSE, and it is one missing line rather than the delivery race it looked like: applySelectZone sets
_pendingZoneId on every FAILING path and clears it on the no-op path, but the SUCCEEDING path stamped
data-doxa-select-zone-state=applied and returned true WITHOUT clearing it. So a request retained once stayed
outstanding forever, and ensureLayers() drained it again on every subsequent call. Re-entering
onLegendSelect for an already-isolated zone TOGGLES the isolation back OFF, which is the reported symptom:
the Map view opens, the zone is selected, and then it is silently lost.
The retain/drain machinery (the attribute-as-state, the MutationObserver, the layer-readiness signal) was
already correct from the earlier fixes; only the release was missing. Not the dispatch-before-listener race
the report suspected -- the composite already writes select-zone as an ATTRIBUTE before dispatching, so
delivery survives a late listener.
check:sfc-parse PASS, check:no-free-identifiers PASS.
iso3-to-iso2 mapped SJM to NO, so every research fetch keyed through it filed
Norway's figures under Svalbard. With SJM -> SJ on main, the four fetched tables now
hold what the pipeline produces for Svalbard itself, re-fetched for SJ through each
script's own code:
network-operators 60 Norwegian networks -> none (RIPEstat: 0 ASNs for SJ)
platform-reachability 15 rows of Norway's OONI -> 15 not-measured (no SJ probes)
social-platforms 8 rows of Norway, 2026-07 -> 4 rows of Svalbard, 2026-09
messaging-channels 3 rows from Norway's store -> none (no SJ storefront)
Norway is not added: it is not in the DX country spine these tables are built over,
so no build produces Norway rows here. Svalbard's own people group rows elsewhere
are untouched, and every other country's rows are unchanged and in order.
Behind any login gate (basic auth, a session cookie, OIDC) the gallery said "Could not load Big Picture":
doxa-maps-spa fetched its same-origin manifest, and plugin-studio its token URL, with credentials:'omit', so
the gate's cookie never went and the request was refused (found on the new dev container; dx-shepherd).
Both now use 'same-origin', which never sends credentials cross-origin, so CDN and CORS behaviour is
unchanged.
check:no-credentials-omit (+ :self-test, in gates:pre) fails any credentials:'omit' in library/ or
plugin-designs/ unless the line says why (// credentials-omit: <reason>). Measured before: exactly these two
sites; it fails on the old code and passes on the fix.
goal (d). Both composites reflect the reader's direction onto their custom-element host with
reflectDir(), and reflectDir read `getCurrentInstance()` at the moment it ran. That call answers only
while setup or a lifecycle hook is on the stack. reflectDir is called from BOTH:
onMounted(reflectDir) -> Vue has set the current instance; this worked
watch(textDir, reflectDir) -> nothing has; the read returned null and nothing was reflected
So a host that declared lang=ar BEFORE mount got dir=rtl correctly, and a host that SWITCHED to ar
afterwards got nothing at all. Arabic then rendered in a left-to-right layout — readable glyphs in the
wrong places, which the comment above this code already names as reading like "a styling quirk rather
than the bug it is".
THAT SPLIT IS WHY IT SURVIVED REVIEW, and it is the part worth remembering: the function is not wrong
and neither caller is wrong; the pairing is. Either line read alone is correct code, and first paint —
the thing anyone actually checks — was right. Only the later switch was broken.
The instance is now captured in setup as _selfInstance, which is the fix research-map.vue already
carries for the same failure in emitFilterState (measured there under Vue 3.5.42).
TWO THINGS DELIBERATELY NOT CHANGED, so nobody has to wonder:
- reflectDir still only ever SETS dir and never clears it. That is documented directly above it — a
host page that declares its own dir owns that decision — so switching ar -> en leaving the attribute
is intended, not a second bug.
- research-map-plus-grid.vue's onMounted(() => { getCurrentInstance()... }) at :422 is CORRECT and is
left alone. Vue sets the current instance for the duration of a lifecycle hook, so that read
resolves. Changing it would be churn, and flagging it would be a gate crying wolf on working code.
When the map failed to construct (Mapbox with no token, for one), every map view
called map.value.on('load') on null and threw "Cannot read properties of null" in
its mounted hook. All seven map views now stop there and show the shared, translated
"Map unavailable" in their status line.
It loaded and constructed Mapbox GL whatever the page asked for, so under
provider=maplibre Mapbox warned about missing CSS and an empty style. The engine now
comes from the library's provider resolution (auto = Mapbox only with a token at hand,
including the injected one), the engine's stylesheet goes into the shadow root, and the
style is the registry pair connectivity-map uses: ink (dark-v11 / OpenFreeMap dark) or
paper (light-v11 / positron). No silent fallback to Mapbox; the map stays gated.
The element declares an instanceId PROP and also exposed an instanceId member,
so Vue warned "Exposed property 'instanceId' already exists on custom element".
The exposed member is the RESOLVED id (the prop, or a generated one), so it is now
resolvedInstanceId. The window.PrayerWalkPlanner registry keeps keying on it,
unchanged, and no reader used the exposed name.
With no token the engine is MapLibre and the basemap is OpenFreeMap fiord, slate-blue
everywhere: sea and land 13 units apart, blue-grey borders. The Mapbox Prayer map is
Standard at night: near-black navy sea, slate land, rose borders. The keyless map now
recolours fiord to the colours measured on the Mapbox render of this leaf (ocean
#03112d, land #323f52, border #c95d74), on every style load, on MapLibre and the dark
basemap only. The Mapbox path is untouched.
The Research Gallery keeps main's source and bundle: per its owner (rega,
relaying the Driver) the board appears under Experimental, which the
gallery discovers from the manifest with no gallery change.
studio landed upg100WallIntroA / Emph / B in all eleven locales (landing 10, f4871401) and kept the legacy
upg100WallIntro, so this consumer can land without a flag day. dx-shepherd UPGHTML / studio END-STU-WALLINTRO.
★ THE CATALOGUE NO LONGER CARRIES MARKUP. vue-i18n warned "[intlify] Detected HTML in 'Hand away the prayer
task…'" twice per tab pass, and it was right: I measured every en catalogue area and upg100WallIntro was 1 of
1 — the only key in the whole tree with a tag in it. Not a convention I was following; my outlier.
⚠ THE OBVIOUS FIXES DID NOT APPLY, which is why this took a design rather than a one-liner. "Markup in the
template" and "<i18n-t> interpolation" both assume this profile renders the sentence. It does not: `intro` is
a STRING PROP on the library PosterWall, rendered there with v-html (PosterWall.vue:955), and that component
has no slots. So the markup must arrive inside the string; the only question was who builds it. Building it
here keeps the catalogue clean with no library change.
★ AND IT SHRINKS WHAT v-html TRUSTS, which matters more than the warning. v-html on a whole catalogue string
trusts the CATALOGUE: any tag a translator or a bad merge puts in any of the eleven files renders as markup.
Composing from parts escapes each part and emits exactly one tag I control, so the trusted surface goes from
"every translation of this key" to this line. Verified: a tag planted in a part comes out as <img …>,
not as an element.
THREE PARTS, NOT A HEAD+EMPHASIS WRAPPER, because the emphasis is not last in every language. All eleven have
exactly one <strong> and no other tags, but de, hi and zh carry real text AFTER it (" beten.",
" प्रार्थना करते हुए।", "為他們禱告。") where the others end on a full stop. A two-key split would have
silently dropped those tails.
RENDERS IDENTICALLY BEFORE AND AFTER: while the three keys were absent it fell through to the legacy single
key, unescaped, exactly as before — escaping THAT would have printed a literal "<strong>" to the reader, a
visible regression traded for a console warning. Verified against main's catalogue: all eleven locales
recompose A + <strong>Emph</strong> + B to the current upg100WallIntro BYTE-FOR-BYTE, which is the same check
studio said they would run before committing, run independently here rather than taken on trust.
check:sfc-parse PASS. check:i18n-keys-exist still reports the three keys missing IN THIS WORKTREE, which is
correct and expected — they are on main and this branch has not merged it yet; the merge in my landing window
resolves it.
The folded explainer justifies showing a COUNT per country rather than a percentage with
"106 of these 175 countries hold one or two groups". Audited today against the roster:
both exactly right. They were also typed in, which is how every stale claim I have fixed
this flight began, so they are computed from the same pin rows the map draws and the
sentence cannot outlive the data. Renders 106 of these 175, unchanged.
Measured at 390x844: the Gap / Resources toggle rendered 48x26 and 129x26, and the
legend's fold button 316x32. None of them FAILED anything — WCAG 2.5.8 puts the AA floor
at 24x24 — but they are small for a thumb on a map that is mostly drag and pinch, where a
missed tap pans the map instead of doing what you asked. They are 54x44 and 316x44 now,
narrow screens only: on a desktop they sit in a 290px card beside a pointer and 26px is
the right density. The legend grows 124px to 154px of an 844px screen, which it can afford.
TWO MISTAKES WORTH RECORDING, both mine, both caught by measuring rather than by reading:
1. Written above .gm-mode's own rule, the media query changed the font size and nothing
else. A media query adds NO specificity and that rule sets min-height:26px, so the
later plain selector won. Reading the computed style is what told me — min-height
resolved to 26px while my font-size had applied, so the rule was live and merely
losing. It has to come last, and the comment says so.
2. Moving it "to the end" put it after the CSS argument, where it concatenated onto the
style element's own id — so the stylesheet lost the rule and the id became
"@media(...){...}doxa-gospel-map". The build passed: it is valid JavaScript. Only
re-measuring the rendered button showed it, which is the second time today a green
build meant nothing about the thing I had changed.
Still 32x32 on a phone: the five .mcb toolbar buttons, which are the shared library
control and every map's, not this leaf's. Reported rather than patched here — one leaf
quietly disagreeing with the fleet about control size is worse than the size.
TWO ITEMS, both found by checking claims rather than accepting them.
★ 1. THE MAPBOX "missing CSS declarations" WARNING (rega END-REGA-SRV / dx-shepherd END-DXS-U100C). The
routed hypothesis was "the leaf doesn't load mapbox-gl.css". It does not — and neither does
cities-internet's leaf, which does NOT warn, so that was not the difference. The real difference, measured:
connectivity-map.vue:398-399 calls `engineStylesheetUrl(provider)` then `await ensureEngineCSS(...)`. This
profile called NEITHER; it hand-rolled its own injection, which failed two ways:
(a) it ran only when the map element was inside a ShadowRoot, so on a standalone BUILT leaf — light DOM
— no stylesheet was ensured at all; and
(b) even in the shadow case it fired and forgot, so `new gl.Map` could run before the sheet parsed.
Mapbox checks its declarations AT CONSTRUCTION, which is exactly when the warning appears.
ensureEngineCSS covers both DOM cases and AWAITS load/error with a 2s cap. It also takes the PROVIDER's own
URL, where the old line asked for mapbox's stylesheet even under MapLibre behind an !isMaplibre guard — so
a maplibre page now gets maplibre's sheet or none, and never touches api.mapbox.com, which is the keyless
contract the maplibre-embed gate enforces. The shadow injection stays as a belt for a partner embedding the
custom element with no wrapper <link>; both paths check by href, so neither duplicates the other.
★ 2. THE SELECTION PACKET'S COVER WAS HARDCODED ENGLISH. Found while checking whether batch selection was
already done (END-DXS-U100B — it is, and render-verified). selectionAsList built its label with
`names.join(' + ')` and `` `+${n} more` ``, printed onto page 1 and into the print dialog title. So a reader
choosing three territories under ?lang=ar got their names in Arabic joined by an English tail.
zone_join / zone_join_more already exist in all eleven packet-strings blocks — translated once for exactly
this shape when the zone-title generator landed — so this is reuse, not new strings. The block is resolved
with per-KEY fallback to English, as packet.mjs and packet-render already do it, so a half-translated locale
cannot blank a key that en has.
check:sfc-parse PASS, check:no-free-identifiers PASS.
⚠ THE 0×0 CONTAINER IS NOT MINE AND NOT FIXED HERE — diagnosed and routed to studio. It is
doxa-maps-spa's HEAVY_ELEMENTS set: the gallery computes `heavy: HEAVY_ELEMENTS.has(tag) ||
HEAVY_ELEMENTS.has(id)`, the set contains `my-upg-100-list-map` (the inner MAP element) and the gallery
mounts the COMPOSITE, so neither the mfe tag nor the bundle id `my-upg-100-list` matches. A non-heavy tab
mounts under `v-show` + `:hidden`, i.e. display:none, and a map built into a display:none container is 0×0
by construction. The gallery's own comment says heavy means "anything that owns OR NESTS a map" — the
composite nests one, so it qualifies and is simply missing from the list. regions-dashboard/regions-mfe has
the same shape and the same gap.
NOT caused by my inert change, despite the timing pointing there (absent at a1c8f3f6, present at 5d83febd):
`inert` sets no layout at all and nothing in the tree keys CSS on [inert], so it cannot collapse a box. The
window contained seven candidate commits and the timing alone could not distinguish them.
The table said Jing and Pana were "outside the twelve-province subset". Both provinces
are IN that file — Guangxi and Yunnan. Measured: Jing sits 8.7 km outside Guangxi's
outline and Pana 5.4 km outside Yunnan's. They are across the border, which is a fact
about one coordinate standing for a whole people, not about a missing province. I shipped
that wrong explanation twice, in the meta and in a commit message.
Sourcing China's COMPLETE 34-unit ADM1 from geoBoundaries changes neither distance by a
metre — same source geometry, 22 more provinces, none of them theirs. So better data is
not the answer here, the downloaded file is deleted rather than staged for nothing, and
the finding is written down so nobody repeats the experiment.
A point within 15 km of exactly ONE province now joins it, carries near_border_km on its
row, and is counted in meta as placed by nearest province. Two provinces inside the
tolerance leave it unassigned: a near-miss is a hint, and a hint between two answers is
not one. 1,007 assigned becomes 1,009 and unassigned 2 becomes 0 — with the two
exceptions visible per row rather than dissolved into the total, because gospel-internet
reads this file and should be able to tell a point inside a province from one 9 km out.
f728831d switched the interactive maps to the web-sized boundaries — 232 KB against
1.84 MB, which matters on a leaf built for thousands of embeds — and this builder kept
reading the full-precision file. The map would have drawn provinces from one outline
while the table counted them from another. My own staged-assets gate caught it on the
merge, which is exactly the drift it was added for a day ago.
Measured before following the map rather than after: same 36 states, same names, 0 groups
move province, 0 dropped, and 2 GAINED. Great Andamanese and the Sentinelese had been
listed as unassigned because their points fell just outside the detailed coastline of
islands that ARE in the file; on the simplified outline both land inside Andaman and
Nicobar. 1,005 assigned becomes 1,007 and unassigned 4 becomes 2 — only Jing and Pana on
China's Vietnam and Laos borders, outside its twelve-province subset.
A coarser outline is not a worse answer here. It is a more forgiving one at the scale a
single coordinate for a whole people is honest to.
The note that called those two a precision artefact said "Four remain" and named them; it
would have been false the moment this landed, so it says what is true now and records what
the switch measured.
The three goal cards, the stat row and their bars were the regions
dashboard's own markup. They are library components now, and the plugin
renders them instead of holding a copy:
GoalCard title + figure + a goal line pinned to the card's bottom,
with a slot for whatever sits between
StatCard one figure and its caption
ProgressBar a single-value track, or a segmented one with a legend
useCardSurface the card surface both cards share, injected once per
root under a stable label instead of copied into each
The bar and its legend now read from ONE list of segments, so a width and
the number printed beside it come from the same figure and cannot drift
apart; before, each was computed separately. A segment's colour is a prop
carrying the same token its .rd-seg--full/--partial/--none class used to
set, so a shared bar needs no vocabulary about prayer and those three
rules are deleted rather than left unreachable.
ProgressBar carries its own reduced-motion guard. The plugin's reset is
scoped to .rd-root and would not have followed the component anywhere
else, which would have reintroduced the one-second sliding bar their own
site suppresses.
The rendered DOM is unchanged for all four instances, measured by
server-rendering the real components and comparing against the markup
that was deleted, including that the bar widths equal the percentOfSubject()
they replace.
check:regions-uses-library grows to cover all four, asserting both halves
again: the plugin imports and renders them, and no longer ships their
markup or CSS. The .rd-root strong rule STAYS in the plugin -- UupgsList
renders a <strong> in the same shadow root and depends on it, which a
file-scoped read would have called dead.
go-int planted T('poison_key') behind "const T = useGiT()" and this gate reported the tree clean.
That is the one failure a check is not forgiven: a red gate gets fixed, a green gate that should be
red gets believed, and this is the gate that guards goal (d).
Three independent blindnesses were stacked, and any one alone hid the key:
1. the file never writes the word tStatic, so the precondition skipped it before anything ran;
2. useGiT is not tStatic, so the per-file alias discovery found no alias at all;
3. the alias is PREFIXED, because useOptionalT carries a namespace, while its call sites pass BARE
keys -- which the full-key pattern cannot match, because it requires a dot.
A translator bound from a composable is now resolved like any other, and the namespace is READ from
the factory rather than listed here -- useOptionalT and useOptionalI18n directly, plus any
"export function useXxxT() { return useOptionalT('ns') }", which is how useNeighborT and usePplrT
are written. That keeps the next author's factory covered the day they write it, which is the same
reason the wrapper NAMES were already read per file instead of hardcoded. Because qualify() prefixes
a bare key and leaves an already-qualified one alone, one binding registers as both shapes; the two
patterns are disjoint, so nothing is counted twice.
MEASURED before and after, with the same poison on disk: the previous version PASSES it, this one
fails it as "neighbor.zzz_not_a_real_key (via nt())". Coverage went 683 -> 743 full-key and 49 -> 56
prefixed call sites, so 67 call sites were being scored as clean without being read. The tree itself
is still clean: no real key was hiding behind those 67, so there is nothing to route to any owner.
A self-test asserts all three fixes SEPARATELY and exits 3 if any one goes blind, because testing
them together would not say which was fixed -- and because this gate had already been trusted once
while lying.
Empty-namespace bare keys stay outside the model and are COUNTED rather than assumed away. Measured
at zero today: all five such sites pass fully dotted keys, so every one is checked by the full-key
pass. The counter stays because the shape is writable.
Gate and description only. No product code, no rebuild.
The rail's rendering is unchanged. What changed is where its last two plugin imports point. 4b had
already removed the research COLOUR and LABEL tables -- those stay in plugin-designs and arrive on
dashboardConfig.legend -- which left exactly two, and both now read the module that OWNS the
function rather than a re-export:
useDashboardAggregations -> @map/composables/useFacetAggregation.js (extracted by 4c-1)
useDashboardShell -> @map/composables/useDashboardSearch.js (extracted by 4a)
Pointing at the shim instead would have been invisible and wrong in a way this tree has already
measured: check:filter-multivalue carries the note from landing 3 that a gate left at the plugin path
read the shim, found no predicate to object to, and PASSED VACUOUSLY.
The dead injects go too. `dataStore` and `searchSourceId` were here only to construct
useTreeLegendColors and useTreeLegendLabels, and 4b removed both constructions. An unused inject is a
claim about the host: a kit component that appears to need a research dataStore is one a new
dashboard will dutifully provide before finding out it was never read. The chip bar shed the same
pair in 4c-2.
check:filter-multivalue and check:legend-mirrors follow the file rather than being left pointing at a
path that no longer exists -- the multivalue gate fails loudly on a missing panel, which is what it
is for, and it is how I would have caught forgetting.
UNLIKE THE CHIP BAR, THIS ONE IS ON SCREEN. 4c-2's component is mounted by nobody, so its gate was
the whole story. The rail is mounted by research-dashboard.vue and is 770 lines of tree, search and
drag behaviour: "the imports resolve" says nothing about whether it still opens four trees by
default or whether its search still narrows. It needs eyes.
The barrel re-exports from the library for one cycle, so no importer's line changes in the same
landing that moves the file. documentation/reference-library-index.md regenerated.
DashboardFilterChips.vue moves to library/components/dashboards/. Nothing about the rendering
changed. What made the move possible was 4b, which inverted the one dependency that blocked it: this
file imported useTreeLegendLabels — the People CODE -> NAME tables, research-specific and staying in
plugin-designs — and a library component importing a plugin composable is what
check:no-cross-plugin-imports refuses. The resolver now arrives on dashboardConfig.legend.
THREE THINGS BEYOND THE MOVE, all found by assessing the file rather than by moving it:
1. THE UNCONFIGURED DEFAULT NOW JOINS A LIST. 4b's fallback returned the value untouched, so a
multi-value filter (spec D1) printed String(['A012','A013']) = "A012,A013", comma-jammed, while a
dashboard that DID supply a resolver got "South Asian Peoples, Bengali Peoples". Configured and
unconfigured should differ in whether CODES RESOLVE, never in punctuation — the second difference
reads as a rendering bug rather than as missing configuration, and go-int is the consumer who will
see the unconfigured state first.
2. THE DEAD INJECTS ARE GONE. `dataStore` and `searchSourceId` existed only to construct
useTreeLegendLabels and have been unused since 4b. Removed rather than left in place: an unused
inject is a claim about the host, and a kit component that appears to need a research dataStore is
one a new dashboard will dutifully provide before discovering it is never read.
3. AN EXPLICIT EXCEPTION TO library/components/WARNING.md, recorded in the file's own header. That
file says a component graduates into the library only once MORE THAN ONE map uses it; this one is
used by ZERO, because the topbar dedup pass unmounted it when SearchFilterHeader became the single
source of truth for showing and clearing filters. It is here because the kit's promise runs the
other way round — a NEW dashboard mounts kit components, so waiting for a second consumer before
providing the first one is a deadlock. The honest consequence is written down too: a passing gate
means "it mounts and degrades", not "it looks right", and nobody has SEEN this component since the
dedup. Whoever wires it in is its first real reader, and must not mount it beside a
SearchFilterHeader that is already showing chips.
check:legend-mirrors grows arm 5, and it asserts the two things a "move" gets wrong that reading the
new file cannot show: that the OLD copy is actually gone (a move that leaves the original behind is a
copy, and two chip bars over one filterSet drift the moment one is edited — this tree has already
paid that with useNeighborT/usePplrT at Jaccard 0.82), and that the UNCONFIGURED behaviour is right,
which no dashboard here would reveal because they all supply a resolver. The default is LIFTED out of
the source and RUN, the way arm 3 drives the rail's fallbacks — not mounted, because a no-op renderer
cannot show a chip and the rendered result is rega's. Five mutants, all caught: old copy left behind,
the join reverted to 4b's bare value, a dead inject re-added, the supplied resolver ignored, and a
relative import restored.
The barrel re-exports from the library for one cycle, so no consumer's import line changes in the
same landing that moves the file. documentation/reference-library-index.md regenerated, per landing.
PARENTED ON a285e06c (4b), not on main, because 4b edits this same file — landing them the other way
round would revert 4b's inversion. Source only: research-dashboard and dashboard-plus-grid need a
rebuild.
useDashboardAggregations has a split personality, and only half of it could ever
move. `lbl` and `aggregate` are pure counting — eleven lines, a Map, a sort, a
topN rollup, no facet name and no colour. Everything else in that file is
research: `colorFor` with its hardcoded religion / wagf_region / affinity_bloc
branches, the religion palettes, the donut and bar builders. Those stay, and
DashboardChartsRail keeps importing them.
The generic pair was what blocked kit landing 4c: the facet rail needs only that
half, and while it lived beside the palettes the rail could not follow it into the
library. Same split, same one-cycle re-export as 4a. Bodies verbatim.
★ AND THE ROLLUP BUCKET SAID "Other" IN EVERY LOCALE. aggregate() pushes
`{ key: 'Other', other: true }` for the tail, and that English word was rendered
directly by the facet rail and the charts rail. check:i18n-keys-exist could not
see it, because there was no T() call to inspect — the string was never a lookup,
it was a KEY being used as a LABEL. I found it while ASSESSING this landing rather
than while doing it, and it is fixed here rather than later for one reason: the
moment this module becomes kit code, an unfixed bug in it is exported to every
dashboard built from the kit.
THE TWO HALVES PULL IN OPPOSITE DIRECTIONS, which is the whole difficulty. The KEY
must stay English: the rail tests it to make the row inert, and the charts rail
refuses to filter on it, so a localized key would make the bucket CLICKABLE in
Arabic and filter on a value no row carries — an empty board, reading as a data
problem. The LABEL must not stay English. So OTHER_KEY is the sentinel,
otherLabel() reads dashboard.other, and they live next to the function that
produces the bucket so they cannot drift. Eleven locales, catalogue first.
This is the value/label trap for the third time — religion families, the language
tiers, and now a bucket in generic code. The third is the worst place for it,
which is why the rule now travels WITH the code that creates the value rather than
being remembered at each render site.
check:legend-mirrors grows arm 4, asserting both directions: translate the
sentinel and it fails; render the key instead of the label and it fails. Both
mutants run.
check-one-palette now scans the new module as well. Its subject — private palette
declarations — stayed in the plugin half, so nothing moved; adding the library
path means a palette smuggled into kit code later is caught by the same rule
instead of escaping into the library.
Library index regenerated in-commit. Source only. STUDIO: needs a rebuild of
research-dashboard and dashboard-plus-grid. The internal/ hunk is four lines in
check-one-palette.mjs.
The facet rail and the chip bar imported useTreeLegendColors and
useTreeLegendLabels — the DOXA palettes and the People code tables. Those are
research-specific by this kit's own reckoning and they STAY in plugin-designs, so
that import is precisely what stopped the rail from being able to move into the
kit at all: a library component importing a plugin composable is what
check:no-cross-plugin-imports refuses, and rightly.
So the dependency inverts rather than the file moving. The panels declare what
they need — two functions — and the SHELL supplies them as
dashboardConfig.legend. The shell is the right supplier because it is the only
thing that already holds the config, the dataStore and the source id those
composables require, and because a composable cannot be called at config-module
scope.
ON dashboardConfig, NOT A NEW INJECT KEY, because section 5.1 of the spec says a
panel needing a new capability gets a new CONFIG key and never a new channel. It
would have been easy to add a seventh inject key and quietly exempt myself from a
rule I wrote; the rule is better than the exception. Merged rather than assigned,
so a profile supplying its own legend wins.
THE NO-LEGEND STATE IS HONEST, NOT A CRASH. A dashboard that supplies nothing
gets uncoloured swatches and raw values — which is what go-int sees before it
wires its palette, and a half-configured dashboard should look PLAIN rather than
broken. The kit's neutral is its OWN constant and deliberately not the research
palette's NEUTRAL, which happens to hold the same value today: importing it to
save a literal would re-create the dependency this landing removes, and the
research palette must stay free to change its own neutral without reaching in
here.
Big Picture is unchanged because the fallbacks fire only when legend is ABSENT,
and for Big Picture the shell always supplies it. Every hex still comes from the
same strategy modules the map's pins paint with, so the legend still cannot drift
from the map — it is handed the strategies instead of fetching them.
check:legend-mirrors grows arm 3: the research imports are gone, cfg.legend is
read, and the two no-legend fallbacks are LIFTED OUT OF THE SFC AND DRIVEN, so
"neutral swatch and raw value" is measured rather than asserted. Three mutants,
all caught. What it deliberately does NOT do is mount the component — a no-op
renderer cannot show a colour, so a mount would prove only "it did not throw" at
the cost of a dev server in a gate that runs in milliseconds. The rendered result
is rega's.
★ AND MY OWN CHECK FAILED ON MY OWN COMMENT, FOR THE FIFTH TIME. The comment
beside the kit's neutral explains the decision by NAMING the research NEUTRAL, so
testing raw source reported a coupling that does not exist. I had already fixed
that shape four times in other gates and still wrote the raw-source version here,
which says the fix belongs in a habit rather than in each gate: every text search
over source strips comments first, without thinking about it.
Source only. STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid.
DashboardResearchFilters builds the :active-chips array the live strip renders, and its search chip
read `Search: ${term}` — a raw English label sitting beside six localized facet chips in all ten
non-English locales. dashboard.search_live was already in the catalogue, in all eleven locales,
written for that exact line and never wired.
The punctuation is why it has to come from the catalogue rather than be assembled here: French is
"Recherche : {term}", with a narrow space before the colon, and Chinese is "搜索:{term}" with a
full-width one. Translating the word and composing the colon in code gets both of those wrong even
with a correct translation. tStatic takes no params, so {term} is filled at the call site.
Also closes a LATENT gap beside it: an array value is a multi-value filter (D1), labelFor() joins one
for display at its single owner, but the prayer arm skips labelFor deliberately — its values are
already human labels — so it skipped the join too and a two-value pick would have read "A,B" with no
spacing. Nothing in this plugin calls toggleFilterValue, so no caller produces a prayer array today;
it is closed because the next caller that does would not think to look in a chip label for the reason.
check:dashboard-filter-a11y gains arm A13, and the arm is the part worth reading. Everything else in
that gate checks the chips where they are RENDERED; neither of those files is where the WORDS come
from, which is how an English label survived a gate written for this strip. A13 reads the computed
that builds the labels and allows an English literal only as the fallback of a catalogue read.
It also took two rounds, both caught by negative-testing rather than by review:
1. it flagged '__search__', the chip's internal name — reporting an identifier as untranslated
prose, which is the crying-wolf failure arriving from the other direction. Now scoped to the
`label:` expression, by brace-balanced scan rather than by line.
2. it then PASSED the real defect, because the English was in a TEMPLATE literal and the arm only
read '…' strings. A chip label is exactly where a template literal is natural, so the one shape
the defect actually takes was the one shape the arm could not see.
Verified failing on the restored defect ("Search: ") and passing on the fix.
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.
The behaviour-visible half of the zones seam, kept in its own commit so a regression bisects to one
change. Every list still resolves to UPG-100's zones today, so this is a no-op until a second zone set is
registered — the point is that the seam now exists for the second list to be built against.
★ allLists()'s OWN NOTE PROMISED "a one-line change instead of an audit". IT WAS FOUR SITES, and I found
them by grepping the IDENTIFIER rather than trusting the note:
allLists() the documented seam
bootstrapLegend() builds the tree AND attaches selectors from the buckets directly
two legend rebuilds buildLegendNodes(clusters, {records}) — these pass the WHOLE OBJECT, so a
`clusters.` grep misses them entirely
Left on the static import, bootstrapLegend would have built the legend from UPG-100's zones while the list
showed another list's, and the two would have disagreed with nothing reporting it. A convention comment
saying "do not read this directly" is a statement of intent, not a guarantee that nobody did.
⚠ AND poster-wall WAS ALREADY HALF LIST-AWARE, which is the worst of the two states. It filtered its
RECORDS by list (applyPeopleGroupList) while taking its ZONE NAMES from clusters.json — so another list's
people groups appeared arranged under UPG-100's zone names, which LOOKS right. Now both follow the list.
RESOLVED PER CALL, NOT ONCE AT SETUP, and that ordering is forced rather than chosen: profileConfig is
injected at upg-100-list.vue:126, AFTER the import block, so a module-level
`const clusters = zonesForList(...)` throws on the temporal dead zone — my first attempt did exactly that.
It is also more correct: profileConfig is a ref a host can change, and the list can arrive later than
setup. Cheap, because zonesForList is a registry lookup returning a shared object.
⚠ check:no-free-identifiers CAUGHT A REAL BUG HERE: I used resolvePeopleGroupList in poster-wall without
importing it. That is a name that compiles and throws at runtime in exactly the path nobody clicked —
which is the gate's own stated reason for existing, doing its job on me.
Also corrected a stale comment: poster-wall said "the same 27 the map paints". The legend has 28 leaves;
this spread yields 27 because it reads the three geographic buckets and the deaf row is synthesised by
buildLegendNodes. The number was right and the sentence was describing the wrong thing.
Verified: check:sfc-parse PASS, check:no-free-identifiers PASS, Vite builds (833.06 kB, +1.0 kB for the
resolver — not duplicated data), check:zoning-declared still PASS, and all four zone labels plus the zone
ids are still inlined exactly once in the built bundle, so the data path is intact.