PosterWall remembered the card it had shown, so after Show zone -> Map -> back the same press toggled the
selection off instead of showing it (rega row 79; UPG-100 and country-posters share the shape). The wall
now forgets its last-shown card when its pane goes inactive: it watches its ancestor chain, through shadow
roots, for the inert / hidden / aria-hidden a composite puts on an inactive view (the PinHint contract).
dx-shepherd ruled option (a). Needs eyes: rega row 79 plus the same arm on country-posters.
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).
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.
createAppI18n already read ?lang= / ?locale=, so an embed opened with ?lang=ar showed Arabic text, but
ProfileLoader's own lang read only profile-config, so dir stayed ltr and the data language did not follow.
The URL is now its last rung; profile-config still wins. From upg100-plugin (ProfileLoader.vue:161).
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.
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.
A media query adds no specificity. The mobile block's
`.map-toolbar { gap: 6px }` is (0,1,0) exactly like
`.map-toolbar--square { gap: 0 }`, and it comes later in the sheet, so it
won: on every phone a square toolbar fell back to separate buttons inside
one ringed panel. The shape broke precisely where the Driver is most likely
to look at it.
The --no-geocoder pair is (0,2,0) and outranked the variant whatever the
order, so it needs the two-class answer.
This is the same lesson as the goal cards' gap-vs-margin: a ported mechanism
is only ported if it still WINS, and specificity is the part that decides
whether it does. Found by following the phone thread rather than by looking
at the variant again.
check:square-map-controls now asserts the override is inside the mobile
block, that the two-class form answers --no-geocoder, and -- as a control --
that the base rule really does come first, which is why the override is
needed at all. Removing it turns the gate red.
The CSS comment explaining it cost one more mistake: I wrote the selectors
in backticks, inside the template literal that holds the stylesheet. A
backtick ends the literal, which is the trap check:sfc-parse names in its own
failure text, and it caught me with it.
The Driver asked for square controls by name. The fact that shaped this:
the square look is not a DX-life design at all. Their CountryMap.vue adds
NavigationControl + FullscreenControl and overrides nothing, so what he
admired is the ENGINE'S default grouped card -- which makes the target
published and exact instead of a matter of taste. Every value here is read
from node_modules/maplibre-gl/dist/maplibre-gl.css, and the check compares
ours against that file rather than against my memory of it.
SQUARE IS NOT A RADIUS SWAP, which is why it lives on the TOOLBAR. Round
controls are separate floating circles with a gap and a shadow each; the
engine's square form is ONE panel that owns the background, the 4px corners
and the 2px ring while its buttons own nothing and are divided by 1px lines.
Setting border-radius:0 on the buttons alone gives squares floating 8px
apart -- neither shape. So MapToolbar takes controls="round"|"square", and
MapControlButton keeps a shape prop only for a button used outside a
toolbar, where a lone control gets the engine's 4px.
THE 29px IS DELIBERATELY NOT PORTED. Ours stay --map-btn-size, capped to
32px in this toolbar. 29px would cut the touch target below what this
project has already ruled on twice, so the variant takes the shape and
leaves the size. It is therefore not pixel-identical to the engine, and
that is a choice, stated.
No overflow:hidden. Clipping was the quick way to round the panel, but it
also clips a focus ring on any inner button, and the engine does not do it
-- it rounds the first and last buttons instead. Ported as their mechanism.
MapControlMenu teleports out of the toolbar, so clipping would not have
hidden a menu; the focus ring was the real cost, and the invisible kind.
Forced colours ship with it, which the round buttons never had: an rgba()
ring and a #ddd divider both vanish under a replaced palette, leaving one
undifferentiated block where the controls were.
BLAST RADIUS: round is the default and none of the nine MapToolbar
consumers passes controls=, so every map renders exactly as before.
Asserted, not claimed -- opting one consumer in turns the check red.
The regions dashboard is NOT switched. Its map already uses the engine's
native controls, ported from DX-life, so it already looks like this;
replacing them with ours would risk clone parity to arrive at the same
picture.
One near-miss, recorded because of how it hid: the first cut added a second
:class to a <button> that already had one, and Vue rejects a duplicate
attribute, so the SFC did not parse. check:sfc-parse said so IMMEDIATELY --
and I had piped it through `head -2`, which printed its counting line and
cut off the FAIL beneath. The block count fell 133 -> 132 in that same
output, I noticed, and then checked script TAGS rather than parseability and
talked myself out of it. Two separate signals, both read past. The two
bindings are merged into one; the count is 133 again.
A page's own reduced-motion reset never crosses a shadow boundary, so library components rendered inside
custom elements slid, faded and spun for readers who asked for less motion: thirteen of them, including the
legends and map controls (regions-plugin found it on ProgressBar and named the class).
useShadowStyles now injects one prefers-reduced-motion reset into each shadow root it styles, which covers
every component styled through it, including later ones. PosterDialog, FilterDropdown and SearchFilterHeader
style themselves with <style> blocks and carry their own guard.
check:shadow-reduced-motion (in gates:pre): useShadowStyles must inject the reset, and every animating
library component must be covered. It fails without the reset (negative-tested) and found the three
<style>-block components on its first run.
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.
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.
gospel.tableTitles.<tab id> and gospel.columns.<English title>, requested by gospel-plugin
(KEY-REQUEST-gospel-tables.md): under ar the tabs were Arabic and every heading and header above the
table was English. Standards stay as they are (ISO 639-3, ISO3, JSON, CSV, ROP1, SUN; a bare "ISO" header
gets no key and keeps its English). JESUS Film uses each locale's existing title; "Heart language" uses
each locale's own-language wording.
Reviewed against the data by gospel-plugin before insertion, which corrected five wrong-sense readings:
ALIGNED (conditions met, not layout), Band (a coverage level), Basis (the level the answer came from),
Code unlookupable (no source indexes it, not an invalid code), Scope (extent). Machine-drafted; not
native-reviewed.
upg100WallIntroA / Emph / B split the one catalogue string that carried markup (a <strong>), which vue-i18n
flags as "Detected HTML" on every mount. Values from upg100-plugin's split, each re-asserted here to
recompose byte for byte (A + <strong>Emph</strong> + B) in every locale; three parts because de, hi and zh
carry text after the emphasis. upg100WallIntro stays until upg100's consumer, which composes the parts and
escapes each, lands.
It has no consumer yet, and that is deliberate rather than unfinished.
Intl's English names differ from the canonical names in
library/data/countries.json for 42 of 242 countries -- "Antigua & Barbuda"
vs "Antigua and Barbuda", "Cape Verde" vs "Cabo Verde", "Hong Kong SAR
China" vs "Hong Kong", "Myanmar (Burma)" vs "Myanmar".
So it is not a drop-in replacement for a name already on screen. The
regions surfaces prefer the API's localized label on purpose
(region-dashboards.vue:370) and are measured against live doxa.life;
swapping the source would change 42 English names and break clone parity.
Which name is canonical is a product decision, not a refactor.
Its real gap is the case where only an alpha-3 is in hand, or where a
label arrives in English whatever the reader's locale. Recorded in the
file because the failure mode is silent -- the wrong names still look
like names.
Our data speaks ISO alpha-3. Intl.DisplayNames('region') takes alpha-2 or
UN M49 and NOT alpha-3, so every country name was stuck in English for want
of the join.
Vendored CLDR cldr-core 48.2.2 (Unicode-3.0, 36KB, sha256 and source URL
recorded in library/vendor/cldr/README.md). No npm dependency: a package
would pull the whole supplemental tree and make a shipped data file depend
on an install step, where a vendored copy lets the generator reproduce the
map offline -- which is the point of vendoring rather than merely having a
copy.
generate-iso3-to-alpha2.mjs joins it against the 242 codes in
countries.json and GRADES ITS OWN OUTPUT, writing nothing on failure: 242
coverage, injectivity, two-letter well-formedness, and that each documented
exception is still needed. KOS -> XK is the only exception -- Kosovo has no
ISO 3166-1 assignment, so no refresh will ever supply it, and XK is the
user-assigned code CLDR itself uses in locale ids. It is recorded with its
reason instead of patched into the vendored bytes.
countryName(iso3, locale) falls back localized name -> canonical English
name -> the code. Intl.DisplayNames THROWS on a missing or malformed code
rather than returning null, so the guard is checked before asking; the
probe demonstrates that throw rather than asserting it in a comment. An
unassigned-but-well-formed region returns the input unchanged, so that case
falls back instead of rendering "DE" as a name.
THE RESEARCH SPINE MAP IS NOT TOUCHED. iso3-to-iso2.json is a different
file with a different job -- the 176-country spine that drives the data
fetches -- and expanding it is a Driver decision, not a side effect of
adding country names. The generator cross-checks it and REPORTS
disagreements without rewriting. There are none: CLDR independently agrees
with all 176 values, including the two corrected this session.
Also fixes a duplicate "check:religion-label-not-value" key in
package.json, left by a merge. JSON keeps the last, so one description was
silently dead and check:doc-scripts could not see it; bun's parser warned.
The surviving entry absorbs the only fact the dead one carried, the two
library sites by name.
NOR was absent from iso3-to-iso2, so Norway appeared in no research table:
consumers filter on ISO3_2[iso3], which makes a missing code a silent
omission rather than an error. Its absence is also why SJM -> NO read as
injective -- "NO" had exactly one claimant, the wrong one.
NOR -> NO, from ISO 3166-1, which is what CLDR's codeMappings encodes. The
full CLDR vendor still comes with the 242-country table and will re-derive
this alongside the other 66 missing codes; this is the one line
exp-shepherd needs first to re-key the mislabelled Svalbard rows onto
Norway.
NOR joins the named spot-check, and the completeness floor rises 175 -> 176
with it. Left at 175 that check would have called a regression back to the
pre-NOR map clean, which is the opposite of what it is for. Removing NOR
now fires both guards; injectivity still reports "all 175 codes map to a
DISTINCT ISO2" while it does, for the third time in this defect.
SJM mapped to NO, which is Norway. Every research fetch keyed to Svalbard
and Jan Mayen therefore asked the APIs about Norway. SJM -> SJ.
Injectivity could not see this, and that is the part worth keeping: the
check passes as long as no two ISO3 codes claim the same ISO2, and NOR is
absent from the map entirely, so "NO" had exactly one claimant and read as
clean. A uniqueness test cannot tell a right code from a wrong one nobody
else wanted. So SJM joins the named spot-check beside PSE and HKG, which
were wrong the same way -- mapped to their administrator's code. Reverting
the value now names it; before, the verifier still printed "all 175 codes
map to a DISTINCT ISO2".
The mapping is the half that stops a future fetch being wrong. The rows
already fetched are still Norway's, keyed to Svalbard: 60 network
operators (Telenor Norge AS, GlobalConnect AS, Marlink AS), 15 platform
reachability rows and 8 social-platform rows, in the shipped tables under
public/assets/tables/. Those belong to other plugins, so they are routed
rather than rewritten here.
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 Driver's ask, and his negotiating point with the DX-life author depends on it
being true: the components cloned from their /regions page must be REUSABLE,
PARAMETERIZED components in the shared library, not private to one plugin. He named
the failure mode himself — someone opens the regions plugin folder and finds the
components are not reusable after all. I checked before building, and for the UI he
named he was right: only the DATA and LOGIC had been promoted.
So library/components/regions/RegionSectionCard.vue takes title, summary, countries
and footerLabel, emits what was clicked, and knows nothing about where regions come
from. The plugin version fetched its own data and owned the layout, which is exactly
what made it unusable anywhere else.
THE DESIGN TRAVELS WITH IT. The card carries the region-card, countries-grid and
country-link CSS lifted unchanged — including the RTL chevron rules, where a
"forward" chevron must mirror in Arabic while a disclosure caret must not, and where
[dir="rtl"] .region-card__icon at 0-2-0 would otherwise beat the 0-1-0 --open
modifier. A consumer gets the look, not a lookalike. It needs dir on an ancestor for
that mirroring, which is documented in the file as a requirement.
★ THE CARD OWNS THE DISCLOSURE ROW, after the first cut got it wrong. I put the
"View country posters" toggle in the consumer's slot — and immediately had to copy
the footer-link styling and the rotating caret back into the plugin, re-creating the
duplication this extraction exists to remove. A disclosure inside a card is a card
feature; only the revealed CONTENT is the consumer's, so PosterWall stays in the
slot with its fourteen props and does not become every consumer's API.
★ THE CHEVRON IS INLINE, NOT A <use href="#id">. The plugin declared the symbol once
and every card referenced it. A shadow-root id is scoped to that root, so the same
card mounted elsewhere would reference a symbol that does not exist — and an
unresolved <use> renders NOTHING, silently. The plugin's own comment warns about
that class for external hrefs; a cross-root id fails the same way.
check:regions-uses-library is registered and asserts BOTH halves: the plugin imports
from @map/ and renders the component, AND no longer holds the markup or the CSS it
delegated. A reusability claim only a human can audit is one that rots; this one
fails the day someone re-inlines a copy. Two negative controls keep it honest — the
plugin still uses its own .rl-root classes, and the other tab is asserted NOT to use
the card yet, so the check describes what is extracted rather than what I hope for.
Two of my own mechanical edits produced valid JS and broken content on the way here,
both caught by checking rather than by the parser: a line-numbered splice that
dropped PosterWall's closing /> (fixed by locating boundaries by CONTENT), and a
line-filtered CSS deletion that left 9 orphaned declarations and braces at 12/21
(fixed by removing rules brace-aware, then re-validating the concatenated stylesheet
rather than trusting check:sfc-parse, which only proves the JS parses).
Thirty keys in all 11 locales, before the code that calls them: the
Map / Language tree switch, the tree's levels, colours and legend, and the
Gospel resources tab.
"One Language, How Many Peoples", requested by gospel-plugin for a new gospel tables tab (bundler-3/work
c72b25e5). Its tabLabel() carries the English until the key lands and switches on its own after.
On upg-100-mfe at phone width the map pane is stacked (position:absolute; inset:0) BEHIND Prayer Resources,
so the IntersectionObserver, which measures geometry and not occlusion, still reported the map as in view
and the pill floated over the posters (the Driver's report; flagged by repo before the README retake).
The contract: a composite marks an inactive view inert (or hidden / aria-hidden="true"), which is also what
assistive technology needs. The pill hides while any ancestor of its map carries one of those, walking up
through shadow roots, and a MutationObserver on that chain brings it back when the reader picks the map.
Still typed-in English after the lens pass: MAP / FLOWER, the sender counter, the
sort buttons, the Details tooltip rows (% Christian, Bridge, Adjacency, Code), the
legend ribbon, the flower ring legend and the Flowers cluster-by modes. 21 keys in
all eleven catalogues; the ring legend reuses the Flower Mesh wording already there.
Method names (SEND, 4-D SEND join, Flower-Mesh, Bounded sets), the provenance
footers and the scripture references stay English, per the scope ruling.
The chrome around the maps was typed-in English: the search placeholder, Base map and
Data sources, the legend titles, each view's header pill, the popup row labels, the pin
card and the Rosetta legend. 42 keys in all eleven catalogues, reusing the product's
own words for Legend, Close, Choose a view, Religion and Language.
Registry names stay as they are (ROP3, ROP25, PEID, IMB PGID, JP Code, JoshuaProject,
WCD, PGIC GeoID), as do the Data sources provenance labels. The reference pages
(semantic trees, index, name locales) wait on the English-only ruling.
The classifier matched "line" as a bare word, so "LINE MAN: Food Delivery & more"
(Thailand) and "Malé Taxi Line" (Maldives) each ranked as the country's top messaging
app, ahead of WhatsApp. LINE now matches only as the whole name or before a colon.
The classifier moves to app-kind.mjs so the fetcher and the table builder share it,
and the builder re-derives each kind from the app name, so a cached fetch made under
an older rule cannot carry its answer forward. The headline count is computed from
the rows instead of typed: WhatsApp (Messenger or Business) is top in 73 countries.
Seven consumers switch to the -web copies exp-shepherd built (5410842a: every feature and island kept, about
a fifth of the bytes): useCountryOutline, useMapLayers, regions RegionCountryMap, research-dashboard
DashboardMapPanel, cities-internet connectivity-map (world + India states), gospel resources-map (world +
India provinces) and my-upg-100-list (world + UPG-100's India partition; China unchanged). generate-tree
stages the three -web files beside the full ones. Posters, print and every build-time point-in-polygon keep
the full files, as REPORT-WEB-BOUNDARIES.md requires: no runtime code tests membership on these.
rega measured ~3.6 MB of boundary GeoJSON costing 5-6 s of MapLibre worker
parsing before the first territory draws, on every partner page. These copies
are for interactive maps; the full files stay for posters, print and
build-time point-in-polygon.
world-adm0-web.geojson 1,866 KB -> 383 KB (gzip 596 -> 134) 162 features, 572/572 parts
gb-IND-ADM1-full-web.geojson 1,796 KB -> 227 KB (gzip 451 -> 69) 36 features, 452/452 parts
gb-IND-ADM1-web.geojson 1,628 KB -> 178 KB (gzip 412 -> 55) 34 features, 40/40 parts, listId kept
(gb-CHN-ADM1 is 56 KB already: no copy)
library/scripts/boundaries/build-web-boundaries.mjs writes them with mapshaper
(pinned 0.6.121, via npx; not a repo dependency): -explode, then topology-
preserving Visvalingam with keep-shapes, then -dissolve with every other
property copied. The explode makes each island its own shape, so keep-shapes
protects every one: without it, the world drops from 572 to 240 parts. The run
FAILS on a changed feature count, a lost key, a changed property or a dropped
part. Negative-tested (without -explode: 297/572 parts -> FAIL), and a second run
writes identical bytes.
Measured (_qa-cycle/03-deployment-cycle/REPORT-WEB-BOUNDARIES.md): median area
error 0.57% (world), 0.04% (India). Of 14,486 real people-group points, 95.9%
stay in the same country in the world file and 99.9% in the India file; the
misses are archipelago coasts. No runtime code does point-in-polygon on these
files, so this is visual only.
Not wired: consumers still fetch the full files, and generate-tree does not
stage the -web copies yet. Both are in the report's switch table.
Six public/ conflicts, ZERO in source — checked before the lock rather than discovered inside it, and it
held: the only overlap between main and this branch is my own bundle, its three leaf HTMLs and the two
manifests, because main rebuilt them and so did I.
They take main's side HERE and are regenerated from the merged source in the next commit. A bundle is an
OUTPUT: the only correct content is whatever the build emits from the merged inputs, and any
hand-reconciled middle version is wrong by construction even when the diff looks plausible.
studio's landing 9 (19682d2c, carrying my ee0bf338 by identical blob 9bdc45a9) has not landed, so mine
goes in first and theirs becomes the no-op. Both orders were agreed safe with studio and dx-shepherd
because the content is byte-identical either way.
Driver, 2026-09-27: zone titles must be GENERATED from already-translated parts, because UPG-100 is
becoming a pattern and a hand-translated title is a cost paid again for every project list (7000 / 4700
/ 3000 / 2000 / 700) forever. The Arabic map reads English today because every label is a hand-written
English string.
★ MEASURED BEFORE DESIGNING, and the measurement killed the more attractive half of the idea:
country_code_label from the records API all 175 codes, every locale (formal names)
Intl.DisplayNames({type:'region'}) all 175, every locale THROWS on ISO-3
Intl.DisplayNames({type:'language'}) 213 of 2242 = 9.5% ECHOES THE CODE
LANGUAGE IS OUT. `.of('kfa')` returns the STRING "kfa" rather than throwing, so a miss prints a raw code
onto a printed poster with nothing raising a hand — and the 90.5% miss is structural, not a gap to fill:
unreached peoples speak small languages by definition and CLDR carries the big ones. This module never
names a language.
TIERS, stated in the module because a generator that silently produces a bad title is worse than one
that declines:
TIER A (3) country_code -> one country name. GENERATED
TIER B (7) country_codes -> n names + a joiner. GENERATED
TIER C (18) everything else NULL, caller keeps its label
Tier C cannot be done from countries: the 12 India/China zones are pg_slugs whose every record is ONE
country (measured: asia-india-kerala 78/78 IND), so countries would render "India" twelve times and
could not tell "Malabar Coast" from "Northwest India" — those labels are SUB-NATIONAL, and Intl has no
subdivisions. The curated-table-vs-keep-English choice is the Driver's (repo's digest item D). Returning
null until then means nothing regresses.
⚠ AND I HAD THE SOURCE PRIORITY BACKWARDS, corrected by looking at what the two sources actually say.
I preferred the records' label because it is already localized and needs no code mapping — both true,
and it generated "Lao People's Democratic Republic" for the zone whose hand-written label was "Laos": a
generated title WORSE than the one it replaced. 28 of our 175 labels are formal forms like that
("Islamic Republic of Iran", "People's Republic of China"). Intl gives the short common form in every
locale, so Intl is now first and the records are the fallback. The ISO-3→ISO-2 mapper is INJECTED, not
imported, because regions-plugin owns that table (their card) and it has not landed: without it a
3-letter code falls through to the formal name, with it the titles read "Iran + Yemen + 11 more".
THE ONLY NEW TRANSLATED STRINGS ARE TWO: zone_join "{a} + {b}" and zone_join_more "{a} + {n} more", one
pair per locale block in packet-strings.json (22 keys across 11 blocks). That is the entire translation
cost for every project list that ever uses this — a new language stays a NEW BLOCK, never a code change.
VERIFIED against the real 28 zones in en AND ar, with live Arabic records from the API:
Laos -> لاوس | Middle East Core -> إيران + اليمن + 11 أخرى | Maghreb + Egypt -> الجزائر + ليبيا + 5 أخرى
9 generated, 18 declined, in both locales. Every decline path returns null rather than a code or a
partial title, including a country_codes zone where one of the names it would SHOW is missing — a title
naming two of three countries and dropping the third silently misinforms and cannot be told apart from a
correct one downstream.
NOT WIRED IN YET: this is the generator only. The caller change (legend + poster + packet header) and
the deaf zone — global by construction, which breaks the geography-first rule and which the Driver says
is unsolved — are the next steps on the pattern card.
The Driver's console, P0: in Plugin Studio's gallery, switching tabs threw "SyntaxError: Unexpected error"
from createI18n in createAppI18n. vue-i18n's createGlobal() runs inside scope.run() and throws
UNEXPECTED_ERROR when that returns undefined; Vue 3.5 creates a new EffectScope INACTIVE when the current
one is stopped; and a custom element's connectedCallback -> configureApp -> createAppI18n ran while the
released tab's stopped scope was still current. Root cause traced by dx-shepherd.
The i18n instance and its locale watcher are now created in effectScope(true), which never inherits the
ambient scope. The install wrapper also makes a second provide('appI18n') of the same instance a no-op, so
the bundle entries that still provide it themselves stop warning "App already provides property".
check:i18n-stopped-scope (in gates:post): a bare createI18n() inside a stopped scope must throw (control),
createAppI18n() there must succeed, translate and keep its locale watcher live, and a second provide must
not warn. On the previous index.js it fails with the Driver's exact error.
The Gospel x Internet board's strings, landed before the code that calls
them. English wording kept; numerals and percent signs as the other
catalogues write them. Coded labels (access ladder, internet band, way in,
resource kinds) are keyed by their stable code.
SPEC-DASHBOARD-KIT D3, and the last of the three decided-not-built items. The bus
carried two coordinated channels — filterSet NARROWS scope, selectionSet MARKS
individuals — and was missing the third: hoverKey POINTS at the one record under
the pointer. It is how a reader sweeps a table and watches the map answer without
choosing anything, and kepler.gl treats hover as a configured interaction beside
filters for the same reason.
IT IS A PLAIN ref AND NOT A SET, AND THAT IS THE WHOLE DESIGN. Hover fires on
every mousemove across a 2,107-row table. selectionSet had to become a plain Set
with a version counter precisely because a reactive collection fires per element;
a single ref has none of that problem — one dependency, one write, no iteration.
A repeat is a no-op, so an unchanged value never wakes a panel.
HOVER IS NOT SELECTION, AND MUST NEVER BECOME IT. The cheap implementation is to
put the key in selectionSet "just while hovering", and every consequence of that
is bad while none is obvious at the call site: useSelectionCamera watches
selectionVersion, so a hover would fly the camera at every mousemove — the
resize-strobe bug wearing a new hat, on a Driver who is photosensitive. The CSV
exporter reads selection > filter > all, so a hover would silently change what an
export contains. The table's selection pill and the shell's live region both
report selection counts, and both would narrate the pointer. So a panel may fade
or outline on hover; it may not fly, select or filter.
NO PANEL IS WIRED, deliberately, and I want that on the record rather than left
to be noticed. D3 says panels opt in, and the first opt-in belongs with a
consumer that has a reason: wiring one into Big Picture speculatively would be a
visual change to the flagship that no reader asked for, landed while the kit
stack is paused. That does leave a channel nothing uses — the same criticism I
made of the unmounted filter chips — so the difference has to be earned rather
than asserted: this is a documented, gated contract with its first consumer
named, not a component that was quietly orphaned. check:hover-channel is what
makes it a contract instead of dead code; the first panel to use it inherits the
rules instead of inventing them.
Three mutants, all caught: routing setHover through selectionSet, dropping the
equality guard, and removing hoverKey from the returned surface.
SOURCE ONLY. Stacked on landing 2. No rebuild needed — nothing renders it yet.
go-int found both by reading the kit before importing it. Both are in landed code
and both reach Big Picture in Arabic, not only a new consumer.
THE DRAG HAD NO READING DIRECTION. facetW and railW were computed from
(clientX - startX), written once, in LTR. Under dir=rtl the grid mirrors — the
facet rail sits on the RIGHT — so dragging its handle toward the map moves clientX
the other way, and in Arabic the facet handle shrank the CHARTS rail. Both handles
fight and neither does what the cursor says. The delta now carries a sign read from
the computed direction per drag, not cached: a host can flip dir between gestures,
because our own composites set it on the custom-element host from the runtime
locale.
The ARROW KEYS take the same sign. If only the pointer mirrored, the two input
methods would disagree about which way widens — and aria-valuenow reports the same
ref for both, so the announced value would move opposite to the key for someone
who cannot see the drag.
AND THE LEGACY READ-THROUGH WAS MINE, FROM KIT LANDING 2. I added it so
namespacing would not discard a reader's saved layout, and applied it
unconditionally. So a caller passing its OWN storageKey — which is the entire point
of namespacing — still inherited Big Picture's rail widths on first load. The
migration written to protect one dashboard's layout was handing that layout to
every other dashboard. It is gated on the default key now, and the default is a
named constant rather than a repeated literal, because a fallback comparing against
a copy of its own default stops matching the day someone bumps the version suffix.
check:layout-key-namespaced grows arms D and E. D asserts BOTH widths take the sign
and that the arrows share it — mirroring one and not the other is worse than
mirroring neither, because then the handles disagree with each other. E asserts the
legacy read is gated. Comments are stripped before both assertions, for the reason
that is now written down four times over.
Source only. STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid.
NEEDS EYES: the drag is a gesture, so this wants rega in ar — drag the facet handle
toward the map and confirm the FACET rail follows the cursor and the charts rail
does not move, then the same with ArrowLeft/ArrowRight.
★ LANDING 4 AS I SCOPED IT IS NOT POSSIBLE, AND THE SPEC WAS WRONG ABOUT THAT.
go-int asked for the facet rail next and I agreed. Then the first step of a
landing — look before moving — found that DashboardFacetRail imports FOUR
plugin-local modules: useDashboardAggregations, useDashboardShell,
useTreeLegendColors and useTreeLegendLabels. Moving the rail into library/ would
have made a library component import plugin composables, which
check:no-cross-plugin-imports rightly refuses. And two of those four are
research-specific by my own spec's reckoning — useTreeLegendColors carries the
DOXA colour strategies and is supposed to STAY in the plugin — so the rail cannot
become library code by moving alone. Its colour and label dependencies have to be
inverted into injection first, which section 8 named as a risk and then ordered as
if it were free.
So landing 4 is three commits, not one, and this is the first: the piece the rail
stands on.
THE SEARCH WAS NEVER A SHELL CONCERN. buildSearchAccessors, makeSearchMatcher and
filterRowsBySearch were exported from useDashboardShell, and EIGHT files reached
into the shell to get them — the table, the map panel, the charts rail, the facet
rail, the research filters, the top bar, the profile and the config. A module that
eight things import is a module, not a corner of another one. Nothing in it is
domain-specific: the accessors come from config.facets / config.columns /
config.searchKeys, and the matching comes from the already-shared fuzzy.js.
Bodies are verbatim; this is a move. The shell re-exports the three names for one
cycle, so none of the eight importers changes in this commit — a barrel line
rather than a shim file, so there is nothing to delete later, only a line to
remove when the eight point at the library directly.
The now-dead fuzzy.js import is removed from the shell rather than left. An import
nothing uses is the kind of thing that survives three more landings because it
looks load-bearing.
WHAT THIS DELIBERATELY DOES NOT TOUCH: the shell keeps rowMatchesFilters and the
URL-hash escape helpers, which are what check:filter-multivalue reads out of it by
path. I checked that before slicing, so no gate path moves in this commit.
Library index regenerated in-commit. Source only; no rebuild needed beyond the
usual, since every importer still resolves through the shell.
rega, row 46 at 835ec1ac: on the prayer map neither the shared-point sentence nor the standing pin note
appeared. research-map and countries-map provide normalizedPeopleGroups to their legends; the prayer map
did not, so its legends fell back to dataStore's cache, which holds the RAW API rows: field names the
co-location index cannot read, and no GeoJSON geometry for the standing-note detector to find.
useMapData now provides the normalized rows it draws, so every map built on it offers them. LegendMobile
and SemanticTreeLegend read them first (the cache stays the fallback), and the detector accepts a row with a
name and a position as well as a GeoJSON point. check:approx-pin-note asserts the provide and the
normalized-row match; the provide check fails on the previous useMapData.
go-int read useCsvExport before importing it and found four things that are only
visible from outside. Every one is a pray-tools assumption this file made
silently, and I could not have told you they were assumptions while there was one
caller. That is what a second consumer is FOR, and it is worth more than the code
it produced.
columns — it exported every key of the row, ordered by EXPORT_FIELDS. go-int's
spec asks for the VISIBLE columns. Their workaround was to project rows before
handing them over, which loses the ordering this file exists to impose. An
explicit `columns` is now taken AS GIVEN, order included, and deliberately NOT
filtered to keys that happen to be present: a column the reader can see must
appear even when every row in this scope has it empty, or the file's shape
would depend on the filter, which is the opposite of what a column picker
promises.
wide — exportCsvAsync always fetched the pray-tools list endpoint. A
static-table dashboard has nothing to widen FROM, so that is a wasted network
call that would also pollute its rows with pray-tools fields. It is skippable,
and it now also short-circuits when there is no source at all, because that
case can be recognised rather than configured.
filename and hookPrefix — 'research-dashboard.csv' and the __rdash* window
hooks were literals. Both are options; both defaults are the existing strings,
and the default matters more than the option here: __rdash* names are read
elsewhere in this tree, so changing it would have broken checks with nothing to
do with this landing.
keyOf — a row identified itself as `slug || id`. go-int's rows carry a slug so
this costs them nothing, but they were right to flag it against
config.data.primaryKey: two spellings of one idea that nothing forced to agree.
EVERY DEFAULT IS TODAY'S BEHAVIOUR, so Big Picture's file is unchanged to the
byte. check:csv-shape and its self-test pass, and check:kit-options-reachable
passes across 21 options — which is the gate that exists because I shipped a dead
option in the commit below this one.
Source only, stacked on kit landing 3. No rebuild beyond the one that branch
already needs.
REGREL. Two library sites rendered RELIGION_FAMILIES values as English in every
locale: the Religion legend's rows (useLegendData.js) and the search aggregate that
collapses sub-types into their family (useSearch.js). Both now project the LABEL
through religionFamilyLabel(), which resolves through the catalogue and keeps
English as the last resort.
The half worth a gate is the half that did NOT change. A translated value matches
nothing — the moment a localized string lands in a filter, a bus payload or an
element attribute, the link works in English and fails silently in Arabic. So `key`
and `filterKey` stay the canonical family letter, useSearch's key function stays
bare getReligionFamily(), and the check asserts all of that as well as the
projection. RELIGION_FAMILIES is no longer imported in either file: the label was
its only local use, and religionFamilyLabel reads the canonical map internally.
★ A COMMENT IN useSearch.js ARGUED AGAINST THIS FIX, and it was wrong. It read
"label = family name so selection can resolve the family letter back (see
research-map onGeocoderAggregateResult religion branch)" — presenting an English
label as a REQUIREMENT of selection. Measured: onGeocoderAggregateResult does not
exist anywhere in this repo (three comments name it, nothing defines it), and
selection resolves by value — aggregate() emits `key` and `label` as separate
fields, and the consumer reads detail.expectedValue (countries-map.vue:1611).
Retracted in place with the measurement, not deleted, because a reader who has seen
the old claim needs to know it was tested.
check:religion-label-not-value is registered and negative-tested. It executes
religionFamilyLabel's REAL source against the SHIPPED ar/ and en/ religion.json
rather than a stub, because religion.js imports tStatic through an i18n index that
calls import.meta.glob and cannot load in plain Node. Under ar the label is
المسيحية while RELIGION_FAMILIES.C is still "Christianity", and a control asserts
the two really differ — an English-only projection fails that assertion.
One assertion of mine failed on its own retraction first: it required the stale
phrase to be ABSENT, but the corrected comment quotes it in order to record it.
Requiring the absence of a phrase punishes documenting the error, so it now asserts
the retraction is present instead.
★ THE TWO INSTRUCTIONS I WAS GIVEN CONTRADICTED EACH OTHER ON ONE LINE, so this records how it was
resolved rather than leaving the next reader to guess. UPGHK said delete humanizeKey; UPGW3 said merge
1bd5f109 and "keep regions' version" — and regions' version KEEPS humanizeKey
(`useOptionalT('', { fallback: (k) => humanizeKey(k) })`). Read as structure-vs-fallback they agree:
UPGHK's reasoning is about what a reader SEES ("a real English or translated string beats a title-cased
key segment"), so "keep regions' version" is about the SHAPE of the call. Kept theirs in every respect
but the fallback:
- useOptionalT('') with the EMPTY namespace, theirs, not my 'messages'/'tabs'. Verified to be a
behavioural no-op here (every key this code passes is already fully qualified, so qualify() returns
it unchanged either way) — and '' is the more honest of two identical options, because it says
"these keys arrive qualified" instead of implying a prefix is being added.
- their "one behaviour gain" note, which is a real gain I had not documented: the old path was the
bare useI18n().t, so on a host WITH i18n a missing key rendered the raw dotted key.
- the fallback deleted, per UPGHK.
AND THE REGISTER KEEPS BOTH WAVES. Their side of the conflict added a wave-3d row for
all-people-groups that mine would have dropped — it stays, with its own reading intact, which is the
OPPOSITE of the call taken for my two files (its inline EN map stays because the file is UNOWNED and an
unowned file is the wrong place to make a product decision by default). Both readings are right for
their own file; that is the point.
⚠ AND I FIXED A FALSE SENTENCE THE GATE WAS ABOUT TO PRINT. With a null third column the register said
"takes the CATALOGUE fallback, as it always did" — true for the two composables that always did, false
for my two, which used humanizeKey until today. That branch already existed to stop exactly this for
gospel's four; my files needed the same treatment, so the "moved" list now names both owners and says
what each moved FROM. A register can be right about its assertion and still teach the next reader
something untrue in the line it prints, and a gate message is the one place a reader has no reason to
doubt.
Verified: check:optional-t-equivalence PASS on all sites, including gospel's unchanged message.
check:packets-all reported "zones covered : 2 of 28", which reads as twenty-six nobody had got
round to. Six of those twenty-six could not be generated by ANY command, for two causes, and the
report could not tell the difference between a gap you close by working harder and a gap you
cannot close at all.
1. `deaf` WAS UNREACHABLE BY CONSTRUCTION. allZones() looped three keys of clusters.json —
lists (13) + asia_lists (12) + uncovered_lists (2) = 27 — and the file carries zones in a
FOURTH, deaf_lists (2), which legend-tree.js collapses into ONE unified leaf (affinity bloc
A017). legend-tree.js:106 states the real arithmetic: "13 + 12 + 2 + 1 deaf = 28". So `deaf`
was a poster and a legend row with no obtainable packet: ONLY=deaf exited 1, "no zone matches".
Fixed by enumerating through leafZones(buildLegendNodes(...)) — the SAME seam the poster
generator and upg-100-list.vue use, so all three now agree on what a zone is. Leaves do not
carry selectors, so they are re-attached by id exactly as generate.mjs:2801-2808 does it.
2. THE FIELD LIST WAS PART OF THE SELECTOR CONTRACT AND NOBODY HAD SAID SO. The records query
asked for country_code and no other selector field, while selector-evaluator reads three more:
rop1 (affinity_bloc -> deaf), wagf_block (west/east/central-africa) and wagf_region (europe,
and the southern-africa-islands union). Those six zones evaluated to an EMPTY set for every
record and were skipped as "no groups resolved" — which reads as missing DATA and was missing
FIELDS. The tell: the records cache holds 123 groups with "Deaf" in the name and evaluate()
still returned 0 slugs. Verified both directions against the evaluator — 0 slugs when a record
lacks the field, the correct slug (and only that one) when it carries it, for all six selectors.
The list is now DERIVED from presentation fields + the evaluator's own requirements, so a new
selector type is a change in one place rather than a superset someone must remember to widen.
3. OFFLINE=1 WAS DOCUMENTED AT THE TOP OF THIS FILE AND COULD NEVER HAVE WORKED. loadRecords()
has an OFFLINE branch that needs no API, but the fail-closed guard ran first and exited 1
without consulting it. The note above that guard records it being taught about PACKET_RECORDS
for this exact reason; OFFLINE was never added. Worse than an undocumented flag: it sends a
reader down a route the program refuses. Any env var that makes the API unnecessary is now
named at the guard.
MEASURED, not assumed: an ordinary zone still builds identically through the new enumeration
(Tibet, 43 groups / 7 pages, check-packet-render PASS with --expect-funnel), all 28 zones now
carry a selector, and OFFLINE=1 generated 22 of them from the cache. The other six need a
records source carrying wagf_block/wagf_region/rop1; the cache predates the fixed field list, so
their render is still unverified and the gate now says so by name rather than by count.
Both are zero-domain in the sense the spec meant: they import only `vue` and read
rows through accessors. Bodies are what research-dashboard shipped apart from the
two parameters the spec named and one policy object, and the old paths re-export
for one cycle.
★ THE FIRST STEP FOUND TWO GATES THAT READ THESE FILES BY PATH, which is why it is
now the first step. Landing 1 nearly disarmed check-facet-cascade the same way.
internal/check-csv-export-shape.mjs does not import useCsvExport; it READS the
source and extracts esc() out of it. Left at the plugin path it would have read
the one-line shim, found no esc(), and failed with "could not extract esc()" — a
gate accusing a working exporter because its subject moved.
check-filter-multivalue.mjs — MINE — lists the same file in its PANELS array.
Left at the plugin path it would have read the shim, found no predicate to
object to, and PASSED VACUOUSLY. That is worse: a gate going quiet about the
thing it guards. Both move in this commit.
THE COORDINATE IS A PARAMETER; WHAT MAKES A COORDINATE VALID IS NOT. `coords`
replaces only the READ of row.longitude / row.latitude, which is pray-tools'
spelling and not a universal one. Every guard stays shared: Number() on API
strings, 0 accepted per axis, the exact 0,0 null-island sentinel rejected, the
out-of-range check. A kit that let each caller re-implement that validation would
hand the next dashboard the same four bugs to rediscover.
★ AND THE OPTION WAS DEAD WHEN I FIRST WROTE IT. `coords` was accepted in the
signature and never passed to selectionBounds, so a caller could set it,
type-check clean, build clean, and watch it do nothing. This tree has paid for
that shape before — a defineProps on a component the custom element never binds —
and it is worse than a missing feature because it READS as supported. Nobody files
a bug against a knob they assume they misused. check:kit-options-reachable now
asserts, across all four shared composables, that every option accepted is used.
THE CAMERA'S EIGHT CONSTANTS ARE ONE DECISION, so CAMERA_POLICY exports them
together. The failure that prevents is a second dashboard re-picking them one at a
time and arriving at a camera that feels different from Big Picture's for no reason
anybody wrote down. It is read-only and deliberately not yet overridable: making
each a per-caller option means threading eight of them through every use, and no
consumer has asked for a different camera. Exporting the decision so it can be
cited is the half with a use today; saying so is better than shipping eight
options nobody passes.
THE EXPORTER GAINS exportLabel and sources. "Research Dashboard" was this
dashboard's name, and the provenance line said nothing about where the data came
from — which is what a provenance line is for. Both default to today's output, to
the byte.
★ AND THE NEW GATE HAD THE COMMENT HOLE TWICE IN ONE SITTING. First it searched
the raw body, and the comment beside the fixed call site says "`coords` IS PASSED
HERE" — so the mutant that removed the argument left it green. Then, stripping
comments only per-part, it split the signature's comment PROSE on commas and
reported that useCsvExport accepts `and`. Comments are text; every text search has
to say so, in both directions. That is the fourth variant of one mistake this
week, and all four were caught by running the mutant rather than by reading.
Library index regenerated in the same commit, 103 items. Source only.
STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid. The
internal/ hunk is one line in check-csv-export-shape.mjs.
Dispatch work item (5) asked for useTextHighlight in library/. RULE 2 wants a named
SECOND consumer before anything moves there, and it exists: regions-dashboard's
UupgsList and people-groups-grid both consume the same Fuse-compatible shim, both
read match.indices, both wrap hits in <span class="search-highlight">, and both
append the tail only on the last range. Line for line the same construction, apart
from variable names.
So library/composables/useTextHighlight.js exports highlightRanges(value, indices),
and UupgsList calls it. Its local escapeHtml goes too: every caller was inside the
highlight body that moved.
★ ESCAPING NOW COMES FROM library/utils/html.js, AND THAT IS A CHANGE FOR BOTH
CALLERS, stated rather than smuggled. Three implementations existed and differed:
library/utils/html.js escapes & < > " ' AND backtick; returns '' for null
UupgsList no backtick; guarded null via String(v ?? '')
people-groups-grid no backtick; NO NULL GUARD — s.replace(null) THROWS
Both deltas move in the safe direction — one more character escaped, one crash
removed — and neither changes the output of any value that renders today. The gate
pins both, with controls proving each delta is a real difference rather than a
no-op: the old copy really does leave a backtick unescaped, and their escaper
really does throw on null.
people-groups-grid is NOT migrated here: it is another owner's file, so it is
handed over as source. The gate asserts it still builds its own highlighter, so
this check states the truth rather than implying the job is finished.
check:text-highlight is registered (script line, gates:post token, hand-inserted)
and negative-tested: an implementation that escaped nothing fails it, and the
output is asserted to depend on the indices.
gospel-plugin's decision, in their words: "most partners will drop a custom
element or an iframe and never install vue-i18n ... an Arabic ministry site
embedding this map serves its readers English while the bundle already carries all
11 catalogues. That is a bug, not a decision."
So all four gospel translators drop `{ fallback: enLookup }` and take the
helper's catalogue default, which resolves through tStatic: the reader's locale
first, English as the last resort. Their three conditions are met and each is
asserted, not assumed:
1. resources-map passes NO namespace — its keys span gospel.* and pins.*;
2. the fallback walks the catalogue ROOT, which tStatic does by construction;
3. params survive, via the null-safe fillParams.
★ THE ROW THEY ASKED FOR, and it is the only thing here that could catch the
regression this migration risked: a BARE host under ar must render
pins.approximate in ARABIC — not the key, not gospel.pins.approximate, not the
English. It reads the SHIPPED ar/ and en/ pins.json and rebuilds tStatic's own
resolution order over them, so it measures the real catalogue rather than a stub.
Three negatives beside it, plus a control that both catalogues really carry the
key, so the comparison is measuring a difference that exists.
enLookup is deleted from all four, and the EN_MESSAGES import from three —
data-tables keeps it, because it still builds a map from EN_MESSAGES.gospel.res at
:182. Checked before deleting rather than after: check:no-free-identifiers would
have caught a wrong removal, since removing something still used creates a name
that is valid JS until it runs.
Two corrections that came from other agents reading this work:
- studio: the JSDoc carried <area>.<key>-shaped examples, which
check:i18n-keys-exist reads as real keys and reports missing. They reworded it
twice because my file copy restored it. Reworded, with a standing note in the
file so a third copy cannot bring them back.
- the register said each site "takes the CATALOGUE fallback, as it always did".
True of the two composables, FALSE of gospel's four, which were moved here by
their owner. A false statement inside a gate message is worse than no message,
because a reader has no reason to doubt it. Now accurate per row.
check:i18n-keys-exist is red on messages.engineNotLoadedDetail. NOT mine and not
touched here: it is in cities-internet/connectivity-map.vue, whose own comment says
the key "arrives with upg100's ade12e4c", and the call already guards for its
absence.