Commit graph

855 commits

Author SHA1 Message Date
Doxa Maps
02b2e9d59a The second consumer drops its copy of the highlighter
people-groups-grid is the file whose duplicate earned useTextHighlight its place in
library/ under RULE 2, and studio agreed to treat it as unowned and asked for it to
be cut. Its hand-rolled range walk is replaced by highlightRanges(value,
match.indices), and its local escapeHtml goes with it: once the loop moved, that
function had ZERO callers left.

★ ONE CORRECTION TO THE STATED REASON. studio approved this with "removing the
null-throw is reason enough". Measured before accepting it: that escaper has exactly
three callers, all inside `if (value && typeof value === 'string')`, so the throw
was NOT REACHABLE here. The library escaper's null guard is still an improvement in
principle, but it fixes nothing that could fire in this file, and I would rather
carry the smaller true reason than the larger convenient one.

The reason that does hold is the original one: the same construction existed twice,
character for character apart from variable names. One fact, one copy.

The gate flips with it — check:text-highlight now asserts this file routes through
the helper, that its escapeHtml is gone, and that the hand-rolled range walk is
really removed rather than merely shadowed by the new import.
2026-09-27 01:16:50 +00:00
Doxa Maps
29bbab3967 Two plugins built the same search highlighter; now there is one
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.
2026-09-27 01:16:50 +00:00
Doxa Maps
81ca86e39f Country posters: a religion search names the family in the reader's language
The legend row it selects is keyed on the family letter, so only the shown label changes.
2026-09-27 01:10:49 +00:00
Doxa Maps
165983c2c8 Country posters: the geocoder column is styled on MapLibre too
countries-map styled only .mapboxgl-ctrl-top-left. MapLibre names its
container .maplibregl-ctrl-top-left, so under MapLibre the column got no
position, width or margin rule at all. The three rules (desktop column, its
controls, and the mobile full-width row) now name both engines' classes.

Headless: build OK, and the shipped CSS carries the maplibregl selectors.
Visual check: rega, countries-map on ?provider=maplibre (en and ar).
2026-09-26 22:19:56 +00:00
Doxa Maps
e4f2be71c4 Country posters: the geocoder column follows the reading direction
countries-map pinned its geocoder column with a physical left:8px, so under ar
the search bar stayed on the left while the legend (studio's fixed legend) moved
right, and the two no longer stacked as one column. It now uses
inset-inline-start:8px, which is 8px from the start edge in either direction.
The pinned card's label spacing (margin-right) becomes margin-inline-end too.
The mobile rule already sets both left and right, so it is symmetric and
unchanged.

Headless: build OK, and the shipped CSS carries inset-inline-start:8px. Visual
check needs a browser (ar, both engines).
2026-09-26 22:19:56 +00:00
Doxa Maps
d8e50de16b cities-internet takes the locale-honouring fallback, and drops its English copy
exp-shepherd's call as the plugin's owner, on the reasoning gospel-plugin gave
first: the bundle already carries all 11 catalogues, so a partner embedding it
without vue-i18n being served English is a bug rather than a decision.

So the three files pass no fallback and resolve through tStatic — the runtime
locale first, English as the last resort. Their enLookup was English-only, which is
exactly what served English to those hosts, so it goes, and the per-area
connectivity.json import goes with it: tStatic reads the same area out of the
catalogue the bundle already ships. Three fewer copies of a lookup, three fewer
imports of bytes that were already there.

Two entries added to the register as DELIBERATE absences, so a later reader cannot
"complete" C4 by converting them:
  · pplr-data-maps/src/components/SemanticTreeLegend.vue keeps its hand-rolled
    guard at exp-shepherd's request — it is carded for retirement
    (RETIRE-PPLR-SEMANTICTREELEGEND) in favour of the library component, and a
    migration whose value expires before it lands is not worth the diff;
  · country-posters-mfe.vue and key-cities/cities-map.vue are their owner's to cut,
    so that country-posters stays one landing and key-cities stays gated.
2026-09-26 22:06:59 +00:00
Doxa Maps
ef5ea1840a C4 wave 3b, cities-internet: three identical guards become one call
All three files carried the same block byte-for-byte — enConnectivity + enLookup +
fill + try/catch — and now call useOptionalT('connectivity', { fallback: enLookup }).
English is unchanged on a bare host, because each passes its own enLookup rather
than taking the catalogue default.

gospel-plugin chose the catalogue default for their bundle, on the argument that a
partner host without vue-i18n is served English while the bundle already ships all
11 catalogues. The same one-word choice is open to cities-internet's owner; it is
not a migration's to take, so this preserves what they have.

Their local fill() used `k in params`, so the shared fillParams also removes a way
for these three to render the literal string "null" into a sentence.

Also records, in the stripper itself, the two things codeOnly does NOT strip —
CSS comments inside a JS template literal, and HTML comments in a .vue template.
Both have already cost a real measurement here: a specificity read of (0,5,53),
and a bare-useI18n census that read 3 where the code had 2. gospel-plugin's
generalisation is the one worth keeping: any <!-- --> that quotes an identifier
will keep doing that to you. The stripper must match the LANGUAGE of the text.
2026-09-26 22:06:59 +00:00
Doxa Maps
9816a4244f gospel takes the locale-honouring fallback, and it is their call that made it
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.
2026-09-26 22:06:59 +00:00
Doxa Maps
8ec207c662 My coalescing broke the echo guard it ran past
rega, row 50: a region row click emitted {region:"asia"} and then an EMPTY event,
identically on both engines. The grid stayed narrowed only because the
composite's 1.2 second push window happened to swallow the second one — and a
pass that depends on a swallow is not a pass. rega said so, and was right to
refuse to score it green.

THE CAUSE IS MINE AND IT IS NOT A TIMING QUIRK. emitFilterState has always opened
with `if (applyingExternal) return`: when the HOST pushes state DOWN through
externalFilters the store changes, and reporting that back up is an echo of the
host's own instruction. That guard worked because the emit ran SYNCHRONOUSLY
inside the watcher, while the flag was still set. My coalescing moved the emit
60ms later, and applyingExternal is released on nextTick — so by the time the
emit ran the flag was false and the echo went out.

I fixed a burst and broke a guard that depended on the burst's timing. Changing
WHEN something runs is a change to everything that depended on when, and I did
not re-read what else was standing on that synchrony.

So the decision moves to schedule time, where the flag still means what it says:
a change that arrived from the host is not a change the reader made, and is not
reported back. The check inside emitFilterState stays as a second line of
defence.

This is the same shape as the dialect case on the branch below, and it is why
that one is a guard rather than a longer timeout: both are "the emit is no longer
where it thought it was".

★ AND THE GATE CAUGHT THE SAME HOLE IN ITSELF FOR THE THIRD TIME. The assertion
searched the scheduler for `if (applyingExternal) return`, and the comment
explaining the fix QUOTES that line — so deleting the code left the gate green.
It strips comments before asserting now. A check its own documentation can
satisfy is not a check, and I have now written that mistake three different ways:
a probe that yields zero when its assumption is wrong, a mutation aimed at one of
three identical bindings, and this.

Source only, stacked on sr/dialect-empty-cascade. STUDIO: rebuild
doxa-research-map (the same rebuild that branch already needs).
2026-09-26 22:06:58 +00:00
Doxa Maps
087b005350 Big Picture: the live count speaks, and the locale reaches the children
rega, row 47 at 52eeb0db. Two findings, both mine, and the first is the smaller
and more certain of the two.

"2107 shown" STAYED ENGLISH IN BOTH CHANNELS, because it was never a lookup:
BpResearchHeader rendered the count and then the bare word. It reads the
catalogue now, with the English wording kept exactly as it was — "{n} shown" —
per the standing rule that translating existing wording is not a product
decision. Eleven locales.

THE CHILDREN WERE HANDED ONLY THE LIST. big-picture built its children's
profile-config from `list` alone, so the one documented channel a partner has for
asking a nested element to speak Arabic arrived empty of any locale.
setRuntimeLocale covers everything that reads tStatic, which is why the headline
and much of the chrome were already correct through both channels; it does not
cover a child that reads its own profile-config. Threading the locale is what
ProfileLoader would have done had one existed beneath this composite — and the
reason none does is the (C) failure mode this element is the last example of: a
composite that BAKES its children in has no loader under it, so it must do that
job itself.

★ THIS IS ONE IDENTIFIED GAP, NOT A CLAIM TO HAVE CLOSED THE MEASURED ONE.
check:host-locale-contract reports 402 against 888 for this leaf, and 31 Arabic
text nodes against 79. I can see that the children were given no locale and that
giving it to them is correct regardless; I cannot see from here how much of that
difference it accounts for, and I am not going to assert a number I have not
measured. The gate is a browser gate and rega owns the re-run.

Source only. STUDIO: needs a rebuild of big-picture.
2026-09-26 22:06:58 +00:00
Doxa Maps
c53d356d3f The one event was the cascade's empty moment, not the selection
rega, row 43 case 3 at 52eeb0db: the coalescing landed — one event per click,
exactly as intended — and in 3 of 4 runs that one event was EMPTY,
{language:"", languageCode:"", languageCodes:[]}, so the grid stayed at 2,107.
The name fix works whenever it fires; the event just carried nothing to name.

THE CASCADE PASSES THROUGH A CLEARED STATE. mapStore.selectLanguage() sets
selectedDialect = null BEFORE it sets the language, so a dialect click walks the
store through "nothing selected" on its way to the answer. The settle timer fired
inside that window and read exactly what was there. The composite then did the
right thing with what it was told: an all-empty report IS how this element says
"the reader cleared everything".

MY FIRST FIX WAS A TIMEOUT, AND A TIMEOUT WAS THE WRONG KIND OF ANSWER. 60ms was
enough in my seat and not under headless swiftshader. That is the third time I
have tuned a duration on one machine and called it settled, so this one does not
get lengthened — lengthening moves the failure rather than removing it.

The guard is SEMANTIC instead. An all-empty read arriving straight after a filled
one is the one genuinely ambiguous case — it is what a mid-cascade read looks
like AND what a real clear looks like, and nothing in the payload separates them.
So it is re-taken rather than believed: the transient fills in and is reported
correctly, while a real clear stays empty through every re-take and is reported a
few tens of ms later, which no reader can perceive. The re-takes are BOUNDED
(3 x 60ms) so a clear can only ever be delayed, never swallowed, and the whole
budget stays far below the composite's 1.2s push window so the two cannot
collide. The budget resets on every fresh user action.

Note what is NOT assumed: the retries re-read the store on their own timer rather
than waiting for another watcher fire. In the failing runs the watcher fired only
ONCE, so a fix that depended on a second fire would not have worked.

check:language-code-link grows a section G for it, asserting both halves — the
guard exists, and it is bounded. Without the first the transient ships; without
the second a clear can never be reported at all. Both negative-tested.

Source only. STUDIO: needs a rebuild of doxa-research-map.
2026-09-26 22:06:58 +00:00
Doxa Maps
a94a85cb8c C4 wave 3a, gospel-resources: four guards share one, English unchanged
From regions-plugin e04121f1 re-cut as 7138109f on main 4d18a6f3. charts, data-tables and resources-list call
useOptionalT('gospel', { fallback: enLookup }); resources-map calls useOptionalT('') because its keys span two
areas (gospel.* and pins.*) and its enLookup walks the catalogue root, so a namespace would have turned
pins.approximate into gospel.pins.approximate. Each keeps the English fallback it already had.

useOptionalT's fillParams substitutes only non-null values (the rule main's own fill() already used), so
{ count: null } leaves the placeholder visible instead of printing "null". check:optional-t-equivalence
(59 assertions) asserts the empty namespace for resources-map and strips HTML comments before counting its
bare useI18n() calls.
2026-09-26 22:06:58 +00:00
Doxa Maps
f9020e4910 The legends mirror now, and their labels never became their values
rega's Arabic survey on main 52eeb0db: the research-dashboard legends sat on the
wrong edge with English text, 70 of 85 letters Latin, in Big Picture and in the
Research Gallery. Two faults that look like one, and they fail in opposite
directions if you fix them carelessly.

A PHYSICAL INSET DOES NOT MIRROR. right:8px pins a box to the physical right edge
in every locale, so in Arabic it stays where an English reader expects it while
everything around it flips. Both surfaces the survey named are logical now, and
inset-inline-start:auto travels with inset-inline-end as a pair rather than as
decoration: it cancels a physical inset inherited from a control container, which
otherwise stretches the box across the map once the anchored edge moves.
The second surface is research-map's geocoder column, which studio found in its
own sweep. That column exists ONLY to share an x-edge with the legend below it —
the alignment is recorded in the file — and the legend already mirrored while the
search bar did not, so under ar the pair came apart and the bar was stranded on
the side the legend had vacated. A shared edge that one of the two honours is
worse than no alignment: it reads as a broken layout rather than as a choice.

THE ENGLISH WAS NOT CHROME, AND THAT CHANGES THE FIX ENTIRELY. The legend renders
a facet VALUE, and for the religion donut that value is a family name DERIVED in
English by library/colors/religion.js — the same table people-groups-grid
re-derives per row so the two ends byte-match. Translating those strings is the
obvious fix and the dangerous one: it would work in English and match nothing in
Arabic, SILENTLY, because an empty intersection reads as "no results". That is
the language-link defect one facet over, and it cost a card to learn the first
time.

So the value stays canonical English and only the visible text is projected, the
same rule the shareable URL follows, where the code is canonical and the slug is
display-only. religionFamilyLabel() lives beside the table it projects, so the
next consumer finds it rather than inventing a second spelling, and the table
itself now carries a comment saying these are values and must not be translated.
Ten family names in eleven locales, English wording held.

check:legend-mirrors guards both halves — and its own negative test found a hole
in it. A mutation aimed at the legend's data-key silently hit a DIFFERENT
:data-key, the donut segment, and the gate stayed green while a projection sat on
a wire. There are three such bindings and it was guarding one. It now checks
every :data-key and :data-facet in the file; all three mutants are caught.

★ AND I BROKE THE FILE'S PARSE ON THE WAY, worth recording because the rule is
already written down. I used backticks around CSS property names in a comment
that lives INSIDE a shadow-CSS template literal, where a backtick ends the
literal and everything after it is read as code. check:sfc-parse and
check:no-free-identifiers both caught it immediately. The comment now says so, in
the file, without backticks.

Source only. STUDIO: needs a rebuild of research-dashboard, dashboard-plus-grid,
doxa-research-map and research-map-plus-grid.
2026-09-26 22:06:58 +00:00
Doxa Maps
1a17d3a211 One guarded translator, and the fallback catalogue nobody needed
C4, first wave. Twenty places each stored the same fact — how to translate when
the host never installed vue-i18n — and two of them, usePplrT.js and
useNeighborT.js, were whole-file near-duplicates at Jaccard 0.82. That pair was
the curation audit's evidence that the guard was being COPIED rather than shared.
Both now call library/composables/useOptionalT.js and keep their public names, so
no call site changes.

★ THE SPEC ASKED FOR (namespace, fallbackCatalogue) AND THE CATALOGUE IS
UNNECESSARY. Every copy imported its own English JSON and hand-walked it after
stripping the namespace prefix. But library/i18n builds `messages[locale][area]`
keyed by the JSON's own filename, and tStatic() already walks that tree and
already falls back to English. So `import enPplr from '.../en/pplr.json'` was a
second copy of bytes the bundle already had, and the helper takes one argument.

★ AND IT IS NOT A SIZE REGRESSION, which was the one good reason the narrow
import might have existed. The build NARROWS library/i18n's catalogue glob per
bundle to the areas internal/i18n-areas.mjs finds in that bundle's source graph
(check:i18n-treeshake). A bundle that says 'pplr.' gets the pplr area, not the
other thirty. Verified before writing the helper, because "the obvious dedup
makes every bundle carry every locale" would have killed it.

Resolution order, each step earning its place: the host's own vue-i18n when it
installed one, so a host managing locale keeps control; then tStatic, which
honours the runtime locale and falls back to English; then the key itself, so a
missing string is visible rather than blank. The middle step also covers i18n
being present but empty — i18n.t() returns the key unchanged, which every copy
of this pattern already treated as "not found".

Verified by _qa-cycle/02-builder-cycle/_chk-c4-optional-t-equivalence.mjs: 18
assertions across all three host states, with a branch-for-branch equivalence
check against the old hand-written implementation and two negative controls. It
extracts and executes the module source, because useOptionalT imports vue-i18n
and an i18n index that calls import.meta.glob, which plain Node cannot run.
2026-09-26 22:06:57 +00:00
Doxa Maps
29f4ab7097 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
# Conflicts:
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/gospel-resources/charts/index.html
#	public/dx-maps/gospel-resources/data-tables/index.html
#	public/dx-maps/gospel-resources/gospel-dashboard/index.html
#	public/dx-maps/gospel-resources/gospel-resources.js
#	public/dx-maps/gospel-resources/resources-list/index.html
#	public/dx-maps/gospel-resources/resources-map/index.html
#	public/dx-maps/manifest.json
2026-09-26 22:04:34 +00:00
Doxa Maps
175ba52cf3 Merge main (studio landing 4) into packet/vertical
Landing 4 carries the nine messages keys this branch's packet and poster
funnel call (4be260a0), so the eleven catalogue conflicts resolve to MAIN'S
side in full: measured first, not assumed — my side added exactly those nine
keys to each of the eleven locales, changed no existing key, and all nine are
byte-identical on main. Whole-file take-theirs therefore loses nothing, which
is the only condition under which taking a whole shared catalogue is safe.

The six public/ conflicts are build artifacts and are NOT hand-merged: they
take main's side here and are regenerated from the merged source in the next
commit. A hand-merged bundle is what produced three consecutive SRI
mismatches, where leaf HTML pinned a hash for a bundle that no longer existed
and the browser refused the script — a blank page in 6/6 runs.
2026-09-26 21:45:06 +00:00
Doxa Maps
6aa4cb2ed8 The selection packet names the territories in the order the reader picked them
rega measured it (NEEDS-EYES row 45, main 52eeb0db): selecting Laos, then West
Africa, then Europe produced a packet headed "West Africa + Laos + Europe".

selectedLists() was `allLists().filter((l) => want.has(l.id))` — it used the Set
only for MEMBERSHIP and returned the lists in clusters.json order, discarding the
order the reader chose. west-africa is catalogue index 0 and laos is index 4,
which reproduces rega's heading exactly from the data alone.

The ordering information was never missing: selectedTerritories is documented at
:186 as "an ORDERED list … (insertion order)". The function simply threw it away.

IT MATTERS BECAUSE THE LABEL'S WHOLE JOB IS TO NAME WHAT THEY CHOSE. The comment
on selectionAsList says the cover should "name the territories they actually chose
rather than inventing a title for their selection" — and silently reordering a
reader's own choices is inventing a title, just quietly enough that nobody notices
until someone measures it.

Now maps the selected ids through a byId lookup, preserving insertion order.
Verified against rega's exact case: old -> "West Africa + Laos + Europe",
new -> "Laos + West Africa + Europe".
Checked all three consumers of selectedLists() before changing it: the only
index access is lists[0], and it is guarded by `lists.length === 1`, where order
cannot matter. BUNDLE=my-upg-100-list vite build clean.
2026-09-26 18:43:38 +00:00
Doxa Maps
c58db47ac3 "Arabic" is 23 ISO codes, not 22
rega counted 23 while measuring the language link, and was right. My 22 came
from a cruder comma-split grouping of the labels; the language tier's actual
rule has three arms — exact, "base," prefix, " base" suffix — and selects 23.

Only the prose was ever wrong. Every computed number in the gate and in the
product came from the real rule and has always been 23; these are two comments
explaining it, in two files, one word each. I did not correct them at the time
because the branch carrying them was already in studio's queue, and rewriting a
branch somebody is landing is a worse habit than a stale comment.

A comment that states a measurement is a claim like any other, and this is one a
reader cannot check without redoing the measurement.
2026-09-26 18:38:20 +00:00
Doxa Maps
4f3a954b9a Gate 9 was accusing a working map: ask the engine, not the DOM
rega's gates:post run reported this gate failing its mapbox half — "NO map
constructed", 21 api.mapbox.com requests, 0 bars. Two theories followed and both
were wrong, mine included. rega then MEASURED the leaf on both token rungs: 1
Mapbox map, 2 GL canvases, 3 visible search inputs, 0 page errors, via the host
global AND via ?tk=. The product was never at fault. This gate's detection was.

THE MAP PROBE WALKED THE DOM FOR A CANVAS. That is a chain of assumptions — every
shadow root open, the canvas carrying the class I expected, existing by the time
I looked — and when any link breaks the probe reports ABSENCE, which is
indistinguishable from a broken product. It now hooks the engine's own
Map.prototype.setStyle through both globals, which cannot be wrong about the DOM
because it never looks at it. Both globals, because the leaf loads only the
engine its provider asks for; the property is defined with a setter so the hook
survives the engine script loading afterwards, and the whole thing is wrapped so
a hook can never be the reason a page fails. The canvas walk stays as a second
opinion, so an engine that stops routing through setStyle degrades to the old
behaviour rather than accusing the product again.

THE BAR SELECTOR MATCHED ONE CLASS. input.mapboxgl-ctrl-geocoder--input, and
nothing else — so it reported 0 bars on a page carrying three visible search
inputs. Whether the miss was the class or the shadow walk cannot be told from
here, and that is exactly the problem: a selector narrow enough that one wrong
assumption yields ZERO will accuse the product of being broken when it is only
wrong about a name. It now counts what a reader would call a search box.

Both probes are borrowed from rega's tokrung.mjs, which measured this leaf
correctly while this gate did not. Where a positive control exists that measures
correctly, copy the control.

IT STILL MEASURES BOTH TOKEN RUNGS, per studio. The ?tk= theory turned out to be
wrong, but the two cases are worth keeping on their own merit: the host global is
the rung an embedding partner reaches for, and if the two ever diverge the gate
now says which one failed instead of leaving a channel switch to look like a fix.
Rejected api.mapbox.com responses are recorded too, so "requests but no map" can
be told apart from a refused key — a 401 says the key, not the code.

THE LESSON, recorded in the header because this is the second time in two days
that a probe of mine accused a working product: a measurement that yields ZERO
when its own assumption is wrong will always blame the thing it is measuring.

Source only; this commit edits one gate and changes no product code. I cannot run
it — Playwright is rega's.
2026-09-26 18:38:19 +00:00
Doxa Maps
7f3435cc1b Delete the selection-bus shim: its one cycle is over
Landing 1 left plugin-designs/research-dashboard/src/composables/useSelectionBus.js
re-exporting the moved file, so the move and its consumers did not have to land
together. Both consumers moved in that same landing — useDashboardShell's import
and check-facet-cascade's BUS_SRC — so the shim has had zero importers ever
since. Measured before deleting: a search across plugin-designs, library and
internal finds two references to useSelectionBus and both name the LIBRARY path.

A re-export with no importers is not harmless. It is a second entry point that
reads like a supported one, and the next author who finds it has no way to tell
that it is scaffolding rather than the plugin-local home of the bus.

★ HOLD THIS COMMIT UNTIL 2bb90852 IS ON MAIN. It is deliberately the tip of the
stack and touches nothing else, so it can be dropped or deferred on its own. If
landing 1 is not in main, the file this deletes is the only thing making the old
import path resolve.

Landing 2's own shim (useDashboardLayout) STAYS for one more cycle, on the same
rule that earned this one its deletion: it is one landing old, and it gets the
same measured check before it goes.
2026-09-26 18:38:19 +00:00
Doxa Maps
8fc7e2d62a Dashboard kit, landing 2: the layout engine, and the key it was sharing
useDashboardLayout is the second zero-domain piece: it imports nothing but `vue`
and reads no row. The four mentions of a people-groups grid in it are comments
naming an example of what a host stacks underneath. Body verbatim apart from the
one change the spec required before it could be shared, and the same one-cycle
re-export at the old path.

THE KEY WAS THE REASON THIS COULD NOT JUST BE MOVED. The engine persists the four
divider positions to localStorage under a single constant, so every dashboard
wrote to the same slot. That was invisible while there was exactly one dashboard
— and SHARING THE ENGINE IS PRECISELY WHAT MAKES A SECOND ONE POSSIBLE. Moving it
without fixing this would have shipped a collision into the kit's first reuse:
drag a divider in one dashboard, reload, and the other has moved. Nothing errors,
neither is wrong on its own, and the reader has no reason to suspect the other
element on the page. SPEC-DASHBOARD-KIT section 8 named it as this landing's risk;
the fix belongs in the commit that shares the engine, not in the one that first
trips over it. The key now carries the profile's own id, so a second dashboard
gets its own slot without its author knowing this key exists.

AND NAMESPACING MUST NOT SILENTLY DISCARD A LAYOUT. Every reader who has dragged
a divider has a value under the old shared key; a new key would simply orphan it
and reset them for no reason they could see. The engine reads the old key once
when the new one is absent. It never writes or deletes it: a migration that also
deletes cannot be rolled back, and leaving the old value costs one localStorage
entry.

check:layout-key-namespaced guards all three. It EVALUATES the storageKey
expression the shell actually passes, with two different dashboard ids, rather
than retyping the rule — a gate carrying its own copy of the thing it checks
passes while the product is broken, which is the lesson from
check:linked-vocabulary. Negative-tested: a bare literal fails A, an expression
that ignores the id fails B, and writing or removing the legacy key fails C.

★ THE BUS_SRC LESSON WAS APPLIED BEFORE MOVING, NOT AFTER. Landing 1 nearly
disarmed check:facet-cascade because that gate reads its subject from a path. I
checked first this time: no gate reads useDashboardLayout by path, and the
storage-key literal appears in exactly two places, both of them in this diff.

The library index is regenerated here, per landing, for the same reason as
landing 1 — 101 items now.

SOURCE ONLY. Stacked on D1. STUDIO: needs a rebuild of research-dashboard and
dashboard-plus-grid. package.json is the parent's copy plus two lines beside
check:leaf-engine-per-provider, so D1's registration is preserved.
2026-09-26 18:38:19 +00:00
Doxa Maps
32792c0fb3 Click a province, get its row in the table
The brief's third requirement at the grain the province work added: clicking a place
filters the table to it. Same split as the country and language links — a filter when
the tables are already beneath the map, a link when they are not.

The province key is a NAME, not a code: geoBoundaries' shapeName is what the table
joins on. So it is matched with diacritics folded, because a link that only works for
whoever copied it off the map is not an address — ?province=Arunachal Pradesh finds
"Arunāchal Pradesh". An unknown province matches nothing and says 0, rather than
falling back to every row.

The embed gate grew two arms for it and the first one failed for a reason that was
mine: it sliced the page text to 300 characters and then asked whether the province
name was in it. The row was on screen, 400 characters down, and the gate called the
product broken. A slice belongs in the message, never in the predicate.
2026-09-26 18:35:46 +00:00
Doxa Maps
fa1ab52a0a Drop the two RTL rules the library now owns, keep the three it does not
studio's c79aae95 converts BOTH physical insets in SemanticTreeLegend to logical
ones — :619 .stl-panel and :925 .stl-reopen are now `inset-inline-start: 8px` — so
the panel and its reopen button mirror for EVERY profile rather than just this one.
My overrides for those two are deleted.

Two mechanisms doing one job is worse than either: the next reader cannot tell
which is load-bearing, and deleting the wrong one looks safe right up until an
Arabic page is opened.

THREE RULES REMAIN, and each is something c79aae95 demonstrably does not do —
verified by reading it rather than trusting the summary:
  · .upg-detail-panel is THIS file's own CSS (:449), not a legend class. The
    library cannot reach it, and it must swap or it collides with the
    now-mirrored legend on the opposite side.
  · direction:rtl on the panel's rows, and unicode-bidi:isolate on the counts.
    c79aae95 has ZERO occurrences of direction:rtl or unicode-bidi. It mirrors the
    panel's POSITION; these set its CONTENT direction — a different property. repo
    reported BOTH symptoms ("the panel is on the left" AND "the rows stay LTR"),
    so dropping these would fix the first and silently restore the second.

⚠ THE SECOND PAIR MAY YET BE REDUNDANT, and the comment says so rather than
implying they are settled. studio's argument is that dir on .profile-loader
(ProfileLoader.vue:282) inherits into the legend, since `direction` is inherited
and crosses the shadow boundary. Plausible, and they declined to add a library
rule without a measurement — correctly. rega will read the rows on Simple Research
and Prayer Progress, which carry NO override, after landing 5. If they read RTL
there, these two go too.

Verified after the cut: 3 [dir=rtl] rules in the built bundle, and the specificity
that matters still holds — `[dir=rtl] .stl-panel .stl-count` is (0,3,0) against
`[dir=rtl] .stl-panel *` at (0,2,0), so the universal direction:rtl does not
override the digit isolation. Had that inverted, numbers in the Arabic panel would
reorder and the fix would look correct while being wrong exactly where a reader
checks. BUNDLE=my-upg-100-list vite build clean.
2026-09-26 18:33:15 +00:00
Doxa Maps
5643981601 Cities with Internet: the basemap's place names follow the reader's language
Under ar, Cities with Internet showed an Arabic legend over English country and
city names, while Prayer Progress localized its basemap. The helper already
exists in the library: localizeBasemapLabels (library/maps/localize-labels.js),
which is Mapbox setLanguage, and on MapLibre points each OSM-name label layer
at name:<lang>, then name:latin, then name. useMapInstance applies it for every
map built through it; connectivity-map builds its map raw (new gl.Map), so it
never ran here. It now runs on 'load' with the same reader-language ladder the
engine UI labels use. No library change: the helper was already shared.

Headless, the real helper against engine-shaped maps: under MapLibre ar, the 2
basemap label layers are rewritten and the plugin's own label (['get','iso3'])
is untouched; en restores the originals exactly. Under Mapbox ar-EG,
setLanguage('ar') is called. Build OK; no-free-identifiers, universal-props and
profiles-mount pass.
2026-09-26 18:26:38 +00:00
Doxa Maps
009795a6c5 Cities with Internet: the engine-missing error uses the shared key that landed
626e7229 looked up messages.engineNotLoadedDetail, the name upg100's ade12e4c
used on its branch; the key that reached main (studio's landing 3, 52eeb0db) is
messages.engineMissing ('The {engine} map engine did not load.', 11 locales).
With the old name the error always fell back to English. It now reads the
shared key. Build OK; no-free-identifiers passes.
2026-09-26 18:26:38 +00:00
Doxa Maps
acc1ebbdc8 A comment that warned about a hazard someone else fixed
The template told a future reader not to mount library ApproxPinNote.vue because it
resolved its catalogue without the guard this leaf needs on hosts that never
installed vue-i18n. C4 wave 2 fixed exactly that, so the warning pointed at a
component that is now safe — and the next reader might have duplicated wording to
avoid a hazard that no longer existed.

The decision is unchanged and the real reason is now written down: the component
carries its own inline 11px and #5b6470 while every other note in this sheet is
.gm-note at 14px inheriting the sheet's ink, so mounting it would render one note in
a different size and an off-palette grey. The wording is shared either way — both
read pins.* from the same catalogue.

It also cost regions-plugin a measurement: their bare-call gate counted the comment's
mention of the i18n composable as a third call site, because a JavaScript stripper
does not know an HTML comment. The rewrite does not name the composable.
2026-09-26 18:25:32 +00:00
Doxa Maps
4d18a6f3b4 The legend stops sitting under the controls in Arabic
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Driver-found, rega-measured: under ar the five map controls mirror to the LEFT
while `left:8px` kept the legend physically left, so the legend sat underneath all
five of them. cities-internet shares the gm-legend class and passed for one reason —
it anchors inline. Every direction-dependent physical property in this stylesheet is
logical now: the legend anchor, the search clear button, the accent borders, and the
header's push-right.

Measured at 1440x900 against six controls: ar legend x=1052 with 0 overlaps, en
unchanged at x=8 with 0 overlaps.

Then the Latin rega saw. Once the explainer is folded away, exactly one sentence was
left in English, and it is the legend's only piece of NEWS — that 366 of the 369
groups with nothing have somewhere to start. It goes through tOr() with its numbers
as parameters, so it renders the English today and switches when the key lands. The
caveat trailing it ("a way in is not the gospel in their own language") moved into
the fold, where the rest of the basis lives: it qualifies the news rather than being
it. What is left in an Arabic legend outside the fold is now exactly the two strings
whose keys I have asked studio for, and nothing else.
2026-09-26 18:10:44 +00:00
Doxa Maps
998a75352d The Arabic legend mirrors to the right instead of under the controls
Driver-reported via repo from the README's Arabic picture: under ?lang=ar the
legend panel stayed on the LEFT and the map controls sat on top of its top-left
corner, with the rows still reading left-to-right.

★ THE CAUSE IS ONE COMPONENT BEING RTL-AWARE AND ITS NEIGHBOUR NOT:
    MapToolbar.vue:42           inset-inline-end: 3px       LOGICAL — mirrors to the left under rtl
    SemanticTreeLegend.vue:619  position:absolute; left:8px PHYSICAL — ignores dir entirely
So the toolbar moves left, the panel never leaves, and they land on top of each
other. The overlap is not a z-index or a spacing bug; it is two coordinate systems
disagreeing about which edge "start" means.

Fixed here, scoped to [dir="rtl"] so the LTR layout is untouched — ProfileLoader
binds dir on the .profile-loader wrapper from RTL_LOCALES, so it activates exactly
when the document says so:
  · the legend mirrors to the right;
  · the detail panel swaps to the left — it is the legend's mirror image (top-RIGHT
    in LTR), so without this the two would simply trade places and collide on the
    other side;
  · the panel's rows get direction:rtl, because dir on a wrapper does not reach
    content that sets its own direction — that is why the picture shows a panel
    which mirrors while its contents stay LTR;
  · counts and percentages get unicode-bidi:isolate, since a run that STARTS with a
    digit is reordered by the bidi algorithm inside an RTL line. Same fix the
    packet's scripture references needed.

⚠ THIS IS A WORKAROUND FOR A LIBRARY DEFECT AND THE COMMENT SAYS SO RATHER THAN
PAPERING OVER IT. The real fix is one property in SemanticTreeLegend —
`left:8px` -> `inset-inline-start:8px` — which would mirror for every profile
automatically. That file is library/, so under rule 5B it is studio's and it is
reported. Until they land it, every OTHER profile with an RTL locale has this same
collision; this override fixes only upg-100-list.

NOT FIXED HERE, deliberately: the phone "Tap a coloured dot" hint over the poster
view. PinHint.vue:27 self-gates on "a map <canvas> exists", which on the composite
is true but irrelevant — pane-wrapper.js:42 stacks panes with position:absolute
inset:0, not display:none, so the map pane is mounted and laid out while the
reader is on Prayer Resources. The gate asks whether a canvas EXISTS when the
property it needs is whether the map view is SHOWING. library/, so studio's.

BUNDLE=my-upg-100-list vite build clean.
2026-09-26 17:57:33 +00:00
Doxa Maps
64931139a1 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
# Conflicts:
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/gospel-resources/charts/index.html
#	public/dx-maps/gospel-resources/data-tables/index.html
#	public/dx-maps/gospel-resources/gospel-dashboard/index.html
#	public/dx-maps/gospel-resources/gospel-resources.js
#	public/dx-maps/gospel-resources/resources-list/index.html
#	public/dx-maps/gospel-resources/resources-map/index.html
#	public/dx-maps/manifest.json
2026-09-26 17:55:20 +00:00
Doxa Maps
376ecaee25 Prayer as a move at the grain a team is sent to
A country is not where anyone is sent; a state is. Each province now carries the
same two prayer numbers the country sheet does and ends in the same move — the
largest group there with nothing and nobody praying, one tap from its prayer page,
ranked need-first so drilling from country to province never hands the reader a
second answer. Andhra Pradesh: 64 of its 76 groups with a prayer page have nobody
praying, 3.8M people.

Always N of M. doxa.life publishes a page for 1,723 of the roster, so nobody-praying
can be said of the groups that HAVE one and never of the province; both numbers
ship together so no surface can quote the first alone.

The join here is name+country, not language — you pray for a people in a place, and
that is doxa.life's own key. It is the one join in this build where a name match is
correct rather than forbidden, and the slug is always theirs: pray.doxa.life answers
200 for any path, so a constructed one would look valid and be empty.

The doctrine screen now scans the province table too. It is the SIXTH place a URL
lives in this bundle, and a table added after the scan was written is exactly how
the access table went unscreened for weeks — read from disk, so it is checked even
when the province layer was never opened.
2026-09-26 17:54:39 +00:00
Doxa Maps
a76eb43419 Arunachal Pradesh is no longer offered Telugu
This table carried its own worst case as a caveat: the ways in were chosen for the
COUNTRY, so Arunachal Pradesh — in the far north-east — was offered TELUGU, a South
Indian language, because the curated bridge table is keyed by country and a curated
bridge outranks a computed one. Manipur and Kerala got Telugu too.

The province answers for itself now. Candidates are the languages of groups
STANDING IN IT that already have Scripture AND film, ranked by the people here who
already hold them. Arunachal Pradesh gets Hindi and Assamese, Manipur gets Meitei,
Kerala gets Malayalam — each one a measured neighbour rather than a national
average. 41 of 46 provinces can answer locally; the other 5 keep the country answer
and the new Basis column says which is which, because "no language here has both
Scripture and film" is itself worth reading. The country-level answer stays in its
own column so the two can be compared rather than swapped silently.

It is a NEIGHBOUR, not a recommendation: whether a group actually speaks it is a
question for someone on the ground, and the table says so.
2026-09-26 17:46:32 +00:00
Doxa Maps
b493f5e1b1 Wire the two strategic strings, English until their keys arrive
The fold's summary is a CONTROL — a reader in Arabic could not tell what was behind
it — and "This is a sign language." is the sentence that stops eBible's silence
being read as an absence of Scripture for 150 sign languages and 84.2M people.
Both go through tOr(), so they render today's English until exp-shepherd's keys
land and switch to the catalogue with no further change here.
2026-09-26 17:30:48 +00:00
Doxa Maps
0114ec45cb Merge landing 2 and rebuild against the 50 library files it moved
studio's landing 2 (1d569edd) changed 50 files under library/, so the rebuild was
required — checked with `git diff --name-only 85a41726 1d569edd | grep '^library/'`
rather than assumed, which is the test that decides this every merge and has come
back both empty and non-empty on different rounds.

The only conflict was the built bundle, a derived artifact: resolved to main's
version and then regenerated from the merged source, because picking a side of a
generated file is meaningless. Zero source conflicts.

VERIFIED BY HASHING, not by fix:sri's summary: upg-100-list, upg-100-mfe and
poster-wall all MATCH the rebuilt bundle, whole-tree sweep 57 integrity attributes
with 0 mismatches.

AND VERIFIED MY OWN WORK SURVIVED, because a 50-file library merge is exactly where
a plugin change quietly disappears: flashRef present, messages.engineNotLoaded
wired, tokenUrl still forwarded in the composite, the new gate intact.
The gate is the better witness than a grep, and it now reports what it should:
1 offender, gospel-resources/resources-map.vue:1490, and NONE in my plugin. Before
this merge it named mine too, because ade12e4c was unlanded. check:i18n-parity PASS.
2026-09-26 17:30:33 +00:00
Doxa Maps
ca7144a57b The gate learns the aliases instead of being told them, and can read main
A fleet-wide census with this gate as it stood would have been worth nothing, and
worse than nothing once mailed to seven owners. Three things had to be true
first.

IT HAD TO SEE OTHER PEOPLE'S CALL SITES. The first version matched tStatic/$t/t
and found four missing keys while sailing past the ten it was written for. Adding
`T` by hand took it to 22 — and that fix was the same mistake one level up. A
census of the tree finds FOUR wrapper names, not one: `const T` x16, `const L`
x6, `const tr` x1 (library/layouts/VerticalTabsLayout.vue) and `const v` x1. A
gate that knows about T and not tr is blind to whichever bundle used the other
name and reports it CLEAN. So the names are now read out of each file, and the
next author's alias is covered the day they write it. Negative-tested by renaming
T to Tz in a file: all six of its call sites are still found.

IT HAD TO RESOLVE THE PREFIX HELPERS IT WAS DECLINING TO. All six `L`
declarations carry a static prefix — four are `tStatic('dashboard.' + key)`, two
are a ladder `tStatic('controls.' + key) || tStatic('dashboard.' + key)`. Those
are ordinary keys with a known prefix, not runtime expressions, and counting them
as unresolved left most of this gate's coverage on the floor: 49 call sites moved
from unchecked to checked, and the unresolved count fell from 80 to 28. A key
resolves if ANY prefix in the ladder holds it, because that is what the ladder
does at runtime.
★ AND THE FIRST RUN OF THAT RESOLUTION INVENTED A DEFECT. BpResearchHeader
renders `L('reset_all', '') || tStatic('controls.reset_all') || 'Reset'` — the
author saying "if this prefix has nothing, fall to the next rung", and
controls.reset_all does hold it. Reported as missing, that is a gate manufacturing
a problem out of a working ladder — the precise failure I was about to mail seven
owners about. An empty English fallback is the author's own signal that the miss
is intended, and is now honoured.

IT HAD TO MEASURE A TREE IT IS NOT STANDING IN. A census has to be taken against
current main; every bundle but mine is stale in my worktree, so a local scan would
report keys another owner fixed an hour ago. A second checkout was not available
either (the box is at 97% disk), so `--rev` reads the files as blobs out of the
object store: one ls-tree and one cat-file --batch, parsed by length rather than
by scanning for a delimiter that could occur inside a file. No worktree, no temp
copy, no writes.

AND IT HAD TO STOP ITS OWN SILENCE BEING READ AS A PASS. This gate asks whether
the keys a bundle NAMES exist. A bundle that names none has nothing to be missing
and passes without a word — and seven do, including two of mine whose composites
render no chrome of their own. Reporting only the misses would let "0 missing
keys" be read as "fully localized", which is the opposite of true for exactly
those bundles. Participation is now counted and printed beside the result, and
the two are never merged into one number.

THE CENSUS, at origin/main 1a761741: 16 missing keys, and all sixteen are in this
lane — research-dashboard 14, big-picture 1, doxa-research-map 1. Every other
owner is clean. All sixteen are already fixed by this branch's parent, which
PASSES under `--rev`.
2026-09-26 17:28:56 +00:00
Doxa Maps
67f5740475 A dialect is called "Arabic, Sudanese", and one click is one event
rega, row 43 case 3, both engines and both locales: clicking a dialect emitted
the right languageCode every time and narrowed the grid in only ONE of four runs,
while the name it reported read ", Afro-Asiatic__Arabic__Sudanese". Two defects
in one click, and both are mine.

THE NAME WAS A TREE NODE ID. selectedDialect.key is family__baseLang__dialect,
and the watcher fifteen lines above the offending template says so in as many
words — I read that comment and then used the key as a display name anyway.
selectedLanguage is also empty by the time a dialect is active, so the template
produced a leading comma in front of an internal identifier: a string no reader
could make sense of, in the one slot meant for a human name. The dialect's
originalLabels are the API labels the legend grouped ("Arabic, Sudanese"), which
are both the right words and already in the reader's locale. When a dialect
groups several labels all of them are reported, because that is what the
selection covers.

THE GRID LOTTERY WAS A BURST MEETING A GUARD. Clicking a dialect does not change
one store value; it clears the language, sets the family and sets the dialect,
across ticks. So the watcher fired three or four times and this element
dispatched three or four map-filtered events for ONE click. The composite ignores
anything arriving while it is pushing state into its children — a guard it needs,
because the grid re-emits during a push and the round trip would otherwise cancel
the selection just made — and that window is 1.2 seconds. Whether the LAST event,
the one carrying the settled state, arrived inside or outside the window was
timing. Three runs lost it; one did not, which is exactly the 1-in-4 rega
measured.

The fix belongs in this file rather than the other end: the guard is correct, and
the burst is what this element should not have been sending. One click, one
event, nothing to drop. A short settle window rather than nextTick because the
cascade crosses ticks (the legend bridge does async work); 60ms is far below the
1.2s guard and below what a reader notices.

I am NOT touching the composite's 1.2s window in this commit. It is now only
reachable by a genuinely rapid second click, and changing a guard that exists to
stop a self-cancelling round trip is not something to do in the same commit as
the bug it was masking.

check:language-code-link grows a section E for both, asserted against the source
because both are shapes. Three mutants, and the SECOND one found a hole in my own
check: searching the whole file for `emitFilterStateSettled()` is satisfied by the
helper's own declaration, so reverting the WATCHER left the gate green while the
burst came back. It now reads the watcher line specifically. A check its own
subject can satisfy is not a check.

Also corrects 22 to 23. rega counted 23 codes under the "Arabic" tier and was
right: 22 came from a cruder comma-split grouping of mine, while the tier's real
three-arm rule selects 23. The gate's computed numbers were always right; only my
prose was wrong. Two more comments still saying 22 live on sr/kit-d1-multivalue-filters,
which is already in studio's queue — I have not rewritten a branch someone is
landing; they need the same one-word change once it lands.
2026-09-26 17:28:46 +00:00
Doxa Maps
f79d8f48e2 D1: a filter may be several values, and one function decides what matches
SPEC-DASHBOARD-KIT D1. filterSet[key] may hold an array meaning "any of these";
keys still AND together. The array support is four lines. The reason this is a
landing rather than a patch is the other half.

`a(r) === filterSet[k]` WAS WRITTEN OUT SEVEN TIMES — the shell's filteredRows,
the facet rail twice, the map panel, the table, the charts rail and the CSV
exporter. Seven copies of a one-character rule are survivable while the rule
stays one character. The moment it grows an array case, six of the seven keep
comparing an array with ===, every one of them quietly matches NOTHING, and the
panels disagree with each other about what is on screen. That is not abstract:
the facet rail computes the COUNTS a reader acts on before clicking, so a rail
still using === would print counts for a scope the map and table no longer show.
So the predicate now lives in the bus and every panel calls it. The
de-duplication is what makes the four lines safe.

WHAT A CLICK DOES IS UNCHANGED, deliberately. A plain click still REPLACES. D1
gives the bus the capability; making a rail row additive on an unmodified click
would change what every existing click does to the flagship, which is a product
decision and not this one. The array path is reached programmatically today.

AND THAT PATH IS THE PAYOFF: "Arabic" now narrows the grid. The language tier
covers 22 ISO codes, so no single value expressed it, and the composite had to
send nothing and leave the axis unfiltered — honest, and visibly incomplete,
because the map narrowed and the grid did not. The whole code set now crosses and
the grid ORs it. Verified per tier on the committed fixture rather than on one
example: for all 805 tiers the joined set selects exactly the rows the map
selected.

★ THE SEPARATOR IS `|`, AND MY FIRST DRAFT GOT THAT WRONG IN A WAY WORTH
RECORDING. I joined on `,` and broke label matching outright: 120 of the 864 live
language labels are comma-inverted ("Arabic, Mesopotamian"), so a selection made
BY LABEL was chopped in half and matched nothing. A separator that can also occur
inside a value is not a separator — the rule the URL hash states one file over,
which I had just written and then violated. check:filter-multivalue caught it
before it left this machine, and it caught it because it asserts a behaviour I
was KEEPING (label matching) and not only the one I was adding. Asserting only
the new behaviour would have shipped it.

EMPTY IS NOT A FILTER. An empty array clears the key rather than matching
nothing — a UI deselecting its last chip produces exactly that array, and
treating it as "narrow to zero" is the silent-empty-screen failure the hierarchy
contract already exists to prevent. A one-element array collapses to the scalar
it means, so the hash, the chips and every adapter keep their existing shape
until somebody actually picks two.

THE SURFACES THAT CANNOT SHOW A LIST SAY SO HONESTLY. labelFor resolves each
value and joins them, at the one owner, so the chip bar, the research-filter
chips and the CSV provenance line cannot drift apart. A dropdown shows NO
selection when the key holds several: ticking the first of three would state
something false about the board, which is worse than stating nothing, and the
chip still names every value so the selection is never invisible. The URL hash
carries a list and drops the display slug for it — the slug is one human name for
one code, and there is no honest slug for twenty-two.

check:filter-multivalue runs the real bus, greps the panels for surviving copies
of the predicate, round-trips the hash including a value containing a comma, and
lifts the grid's valueMatches to prove both ends OR the same way. No browser.

Green: check:filter-multivalue, check:language-code-link, check:facet-cascade and
its self-test, check:kpi-unit, check:csv-shape, check:no-free-identifiers,
check:sfc-parse, check:mjs-parse, check:descriptions, check:no-orphan-gates,
check:doc-scripts, check:script-flags, check:quoted-scripts.

SOURCE ONLY. Stacked on kit landing 1, which moved the bus into library/.
STUDIO: needs a rebuild of research-dashboard, dashboard-plus-grid,
doxa-research-map, research-map-plus-grid and people-groups-grid. package.json is
main's copy plus two lines beside check:fits-screen.
2026-09-26 17:28:46 +00:00
Doxa Maps
5d82899d18 Three maps asked the window which engine drew them; now they ask the map
C3, buckets A and B. Each site was sniffing `window.maplibregl.Map` directly,
which breaks the moment a leaf stops loading a static engine peer: the global is
simply absent and the sniff answers about whatever else the page happened to load.

A needed a helper that did not exist. Both A sites want a provider NAME from a
live map. engineFor() returns the engine NAMESPACE, and resolveMapProvider()
reads a CONFIG rather than a map, so on a dual-engine page it answers about the
wrong thing. Hence providerFor(map) -> 'mapbox' | 'maplibre' | undefined, with two
named consumers on arrival.

★ WHY providerFor RETURNS undefined INSTEAD OF A DEFAULT. The obvious rewrite was
in terms of the existing isMapboxMap(). It is wrong in a way no per-engine test
catches: both originals test for MAPLIBRE and fall through to mapbox/false, so an
isMapboxMap() version inverts the fall-through to maplibre/true. On a stub, a
destroyed map, or a page where neither global loaded, that changes what a
diagnostic field reports AND which attribution a poster prints -- while agreeing
on both real engines. Returning undefined forces each call site to keep its own
default visible:

    pplrData.js      engine: providerFor(map) || 'mapbox'
    research-map.vue const onMaplibre = providerFor(map.value) === 'maplibre'

Changing a default is not a curation migration.

B is engineGlobal(mapProvider), which already defaults to Mapbox for anything
unrecognised -- exactly what the ternary's else branch did.

All three files now carry zero raw window.mapboxgl|maplibregl references in code.

Verified by _qa-cycle/02-builder-cycle/_chk-c3-provider-helper-equivalence.mjs:
27 assertions, each old pattern against its replacement on both engines and on
{}, null and undefined, plus an executable proof of the isMapboxMap near-miss and
three negative controls.
2026-09-26 17:28:46 +00:00
Doxa Maps
50a29b463d Dashboard kit, landing 1: the selection bus becomes library code
The first piece of Big Picture to be shared, and it went first for one reason:
useSelectionBus has ZERO domain references and imports nothing but `vue`, so it
is the only piece that could move with no adapter and no behaviour change. The
body is byte-for-byte what main shipped — verified by diffing everything from the
first import onward — and only the file's header block is new. A move that also
edits is a move nobody can verify, and nine of these have to be verifiable in a
row.

THE OLD PATH STILL WORKS, as a re-export, for one cycle. That is not politeness;
it is what keeps this landing small enough to check. A landing that moved the
file AND rewrote every import would be two changes whose failures look identical
from the outside. The shim re-exports and does not duplicate: a second copy would
drift silently while both halves kept passing their gates, which is the failure
check:no-cross-plugin-imports and check:shared-reached exist to stop. It is
deleted in the next landing.

★ THE GATE HAD TO MOVE WITH IT, and this is the part a move like this hides.
check-facet-cascade reads the bus from a PATH (BUS_SRC) and, for its self-test,
copies that file and poisons the copy to prove it can still catch a reverted
cascade. Pointed at the shim, it would have copied and poisoned a RE-EXPORT: the
mutation would match nothing, the "broken" copy would behave correctly, and the
self-test would report that the gate no longer catches anything. One line, and
without it landing 1 would have silently disarmed the only check that proves the
hierarchy contract. Verified after the move: the gate PASSES at the new path and
the self-test still catches a reverted cascade (13 assertions).

The library index is regenerated in the same commit, per SPEC-LIBRARY-CURATION
section 4: check:library-index compares REGENERATED BYTES, so a new file under
composables/ turns it red until `bun run library-index` is re-run. Per-landing
regeneration rather than one pass at the end, because nine landings with a
knowingly-red staleness gate would train nine readings of "ignore that one".
useSelectionBus now appears in the generated menu — which was the whole argument
for the existing taxonomy over a new top-level area.

NOTHING TO REGISTER IN package.json: the gate that guards this file,
check:facet-cascade, was already wired, and check:no-orphan-gates confirms it.
This commit does not touch package.json at all, so it cannot conflict with the
three landings of mine that do.

STILL COMING, deliberately not here: SPEC-DASHBOARD-KIT D1 makes filterSet
multi-value. That changes this file's CONTENTS; this changes only its LOCATION.
Keeping them apart is what makes "Big Picture is unchanged" provable.

SOURCE ONLY — not built, by instruction. Green here on main's tree:
check:facet-cascade and its self-test, check:library-index, check:no-orphan-gates,
check:no-cross-plugin-imports, check:shared-reached, check:no-free-identifiers,
check:mjs-parse, check:sfc-parse, check:descriptions.
2026-09-26 17:28:46 +00:00
Doxa Maps
44a1afba04 Decouple prose: no go-base / nuxtbase / nuxtinator / doxa-base names in plugin comments and docs
Comments and prose only, no code change (DECOUPLE-go-base-mentions.md sections B and C, outside
library/ and README.md): stack-demo's page-flow note becomes 'a normal host page', the share-link
and CORS comments say 'the live test host', embeddability.md's Traefik note says 'a self-hosted
reverse proxy' and drops the internal proxy file path, and how-to's hosting option becomes 'a Git
host you run (for example Forgejo)'. stack-demo.vue's @description feeds the generated tree, so the
landing regenerates the tree. Section A (QA widget), library/ and package.json are left to studio.
2026-09-26 17:28:45 +00:00
Doxa Maps
5739818ecc Never hand a keyless page the engine that needs a token
resources-map resolved its engine as `engineGlobal(provider) || window.mapboxgl`.
engineGlobal already defaults to mapbox, so that fallback could only ever fire for
provider 'maplibre' — and then it returned MAPBOX. A page that chose the keyless
engine silently got the one that needs a token, and because the profile only
assigns accessToken when the provider is not maplibre, the substitute had no token
either: it would have 401'd on every tile. The fix is the deletion. The requested
engine or a named failure, with a console warning for whoever is debugging and the
reader's line through messages.* (English kept inline until the key lands).

check:engine-contract measures it: two providers across engine present, engine
blocked and both blocked, with the CDN blocked by request routing the way a proxy
or an offline kiosk does it. Three of those six arms first failed for reasons that
were mine, not the product's, and each is written down in the gate: Mapbox cannot
mount at all on a static tree without a token, 11s was not long enough for a leaf
that fetches 4 MB before the map exists, and engine attribution read from a debug
handle scored Mapbox-with-a-bad-token as "engine null" — the canvas class cannot
lie about who drew it.

Also in this landing: the pin caveat is translated in 11 locales from the shared
pins.* area, read through this leaf's own guarded t() rather than by mounting
library ApproxPinNote.vue — that component calls useI18n() unguarded, and this
bundle is embeddable in hosts that never called app.use(i18n), where an unguarded
call throws during setup and the shadow boundary swallows it. Same wording either
way. Both "Show all groups" buttons now use the key that already exists in 11
locales, and the engine's own control labels get the provider arm.
2026-09-26 17:21:22 +00:00
Doxa Maps
7f9b0d8983 Twenty-two keys the code asks for that no catalogue has ever had
A localized CALL SITE is not a localized STRING, and nothing in this tree was
watching the join between the two.

The dashboard's five resize handles read their names through
T('aria.resizeFiltersRail', 'Resize filters rail'). Reviewed, that looks
finished: the call goes to the catalogue and the English is only a fallback.
aria.json held eleven keys and not one of them was a resize key. Every handle
had been announcing itself in English in all eleven locales, and the fallback —
which is right to exist, because a missing key must degrade to readable English
rather than to "aria.resizeFiltersRail" on screen — is exactly what kept it
silent.

No existing gate can see this. check:i18n-parity is satisfied because the key is
missing from ALL locales equally. check:english-leaks and
check:composite-locale-leaks both need the catalogue to HOLD the string before
its appearance counts as a leak, and it does not. check:ui-strings-have-keys
reads templates for typed English. Each is looking at a different end of one
wire. check:i18n-keys-exist asks the question none of them asks: does the key
this code names exist?

It found TWENTY-TWO on its first run, all in this lane — the detail drawer's
five rows, the table's two selection toggles and their hints, the map's loading
line, the breadcrumb, the charts rail and the map legend's aria labels, the
empty-search message, and big-picture's footer. Six of them are the resize keys
on this branch's parent; seventeen are added here, English wording kept exactly
as each call site already reads it, in all eleven locales.

★ THE FIRST DRAFT OF THE GATE WOULD HAVE MISSED THE DEFECT IT WAS WRITTEN FOR.
It matched tStatic/$t/t and reported four missing keys while sailing past the ten
resize keys, because the house pattern is a file-local alias —
const T = (key, en) => tStatic(key) || en — and almost every call site in the
dashboard reads T(...). Matching T( took it from 4 to 22. Prefix helpers like
BpResearchHeader's L(), and any concatenated or template-literal key, are COUNTED
as unresolved and never guessed at: inventing the key set a runtime expression
might produce is how a gate starts reporting keys nobody wrote.

AND BIG-PICTURE, the last HOST-LOCALE failure fleet-wide, gets the two halves it
was still missing. Its title was a hardcoded English literal with no key at all —
the largest text on the element, sitting above an Arabic grid. And it never
reflected `dir`, alone among its siblings, so a host asking for Arabic got Arabic
WORDS in a left-to-right layout: a bug that reads as a styling quirk, which is
why it outlived every locale fix around it. The attribute goes on the
custom-element HOST, because `dir` takes effect through the inherited CSS
`direction` and inheritance crosses shadow boundaries — one attribute covers this
element's chrome and both nested elements beneath it. Only ever set, never
cleared: a host page that declares its own dir owns that decision.

The gate is green with this branch and its parent together, and red without
either. Negative-tested: deleting a key that exists fails naming the key and the
call site; a first argument whose area is not a catalogue file is correctly not
reported.

STUDIO: no rebuild needed for the catalogue, but big-picture must be rebuilt.
package.json adds two lines beside check:i18n-never-translated — a different
region from sr/dashboard-filter-a11y and sr/language-code-link, so the three
merge.
2026-09-26 17:10:06 +00:00
Doxa Maps
dc226c2e01 Register the embedder-key gate so it can actually run
check:embedder-keys asserts regions-mfe forwards the documented embedder keys to its panes, and that
profile and instanceId are NOT forwarded (the host's would overwrite each pane's own and collapse both tabs
into one view). Negative-tested both ways: removing dataSource fails naming it; adding profile fails naming
the collapsed-tabs risk.

The gate lives in the plugin's scripts/ folder, as nine check: entries already do. Its comment stripper is
committed beside it (strip-code.mjs), because a committed gate cannot import from the gitignored QA folder.
Registered in scripts (alphabetical, with its //scripts note) and in gates:post; source-only, ~0.2s.
2026-09-26 17:10:06 +00:00
Doxa Maps
c193621bc2 My UPG Neighbors: the cockpit, list, flower and send panels speak the reader's language
The universal-props re-measure (PRODUCTION-MATRIX.md, main dee9f9c9) found
typed-in English still left in upg-neighbor after its first i18n pass. This
translates the rest of the INTERFACE text, under the approved scope (interface
text, not research jargon):
- ListCell: the sender count, "Build send list", the pinned card's People
  group / Country / Language / Population / Religion, and the empty and
  placeholder states (no senders, N hidden, no reached neighbours yet, pick a
  red target, cluster/sender counts, tap a bubble);
- bloom-cockpit: the view switch's aria-label, the "unreached target" legend
  key, the loading overlay;
- FlowerCell: the empty-state instruction;
- send-map: the target's UPG / sender counts and its no-senders message;
- FlowerClusters: the API-down status reuses neighbor.lc.apiDown.

20 new keys (neighbor.list.*, neighbor.cockpit.*, neighbor.flower.*,
neighbor.sm.*) in all 11 locales. Every existing key is unchanged (checked per
locale). Method vocabulary (Bridge, Adjacency, Code, the 4-D SEND panel) and
the scripture / method footers stay English by the scope ruling. Numbers keep
their current toLocaleString (numerals held). No code branches on any of
these strings (swept).

Headless: build OK; no-free-identifiers, i18n-parity, i18n-never-translated
and profiles-mount pass. Library change (catalogues): consumers need
rebuilding.
2026-09-26 17:10:06 +00:00
Doxa Maps
5cdb5cdc3c A partner's dataSource never reached either Regions pane
regions-mfe forwards page-scoped config to its nested panes by an explicit key
list, because a cap-4 composite mounts its children in THIS document and a pane
has no leaf page of its own to hand it anything. Two keys were missing.

dataSource is the one that was actually broken. PRODUCTION-MATRIX's own table for
embedders lists it -- "wrong/partial data ... Regions uses pray-tools" -- and
nothing pinned it in spec.config or either pane.config, so a partner setting it on
<regions-mfe> had it silently dropped and both panes fell back to sources.json's
activeSource. Unlike the token there is no page-wide publish to rescue it.

tokenUrl is latent rather than broken, and it is worth being exact about which.
exp-shepherd's universal-props re-measure named it absent here, and it is; but
this composite sits under a ProfileLoader that already watches tokenUrl, fetches
it, and publishes the answer page-wide, so the panes do hold a token. What they
lacked was the URL, so a pane could never re-ask the token service itself and was
stuck with whatever the page had published.

Of the four keys that table documents, assetsBase was already forwarded, tk needs
no forwarding (ProfileLoader publishes a token it is handed page-wide), and
profile must NOT be inherited -- the host's would overwrite each pane's own and
collapse both tabs into one view. The new gate asserts that exclusion too, so a
future well-meaning addition fails instead of quietly merging the tabs.
2026-09-26 17:10:06 +00:00
Doxa Maps
40a6d48e0f Cities with Internet: a page that asks for MapLibre never silently gets Mapbox
connectivity-map took its engine as engineGlobal(provider) || window.mapboxgl.
engineGlobal already answers for Mapbox, so the fallback fired only when
MapLibre was asked for and failed to load, and it handed back the token-gated
engine on a keyless page (regions' C3 read; upg100's ade12e4c is the same fix).

The fallback is deleted. A missing engine now warns in the console and shows the
map's existing error state, naming the engine, through
messages.engineNotLoadedDetail when that key lands with ade12e4c, and in English
until then. upg100's second defect (the token gated on the engine) does not
apply here: accessToken is passed in the constructor options on both engines.

Headless, the engine pick over both providers x loaded / failed with the other
engine present / failed with nothing: the only change is 'MapLibre failed,
Mapbox on the page', which went from MAPBOX to the error; the other five are
unchanged. Build OK; no-free-identifiers, universal-props and profiles-mount
pass.
2026-09-26 17:01:36 +00:00
Doxa Maps
28c1ed2e7b Country posters: the map's loading and error lines speak the reader's language
countries-map typed in 'Research map loading…' and 'The map could not be
loaded.' (universal-props re-measure, main dee9f9c9). Both now reuse existing
keys through the component's own t(): messages.loadingNamed and
messages.couldNotLoadNamed, with the name tabs.map. That is 11/11 locales with
no new keys, so no library change. Headless: build OK, no-free-identifiers and
profiles-mount pass.
2026-09-26 16:55:14 +00:00
Doxa Maps
ade12e4c0b A page that asks for the keyless engine never silently gets the token-gated one
`const gl = engineGlobal(upgProvider) || window.mapboxgl` — and that fallback can
ONLY fire when the requested provider is 'maplibre' and MapLibre failed to load.
engineGlobal already resolves mapbox's own global for the mapbox case
(useMapInstance.js:115 falls back to ENGINES.mapbox.global only for an UNKNOWN
provider NAME, not for a missing engine). So the `||` silently handed MAPBOX to a
page that asked for the KEYLESS engine, breaking the keyless contract that
research-map.instances.json documents. Found by regions' C3 read, routed by
dx-shepherd (END-SHEP-C3C).

⚠ AND THE FALLBACK COULD NOT EVEN WORK. `gl.accessToken = token` a few lines below
is gated on `if (!isMaplibre)`, so a maplibre request that fell through to Mapbox
would get Mapbox WITHOUT a token — 401s on every tile instead of a legible
failure. Two defects in one expression: a silent engine switch, and a substitute
that was broken by construction.

THE FIX IS A DELETION, not a new path: the existing `if (!gl)` branch already
fails visibly and well — it sets status + mapError, calls
enrichLegendFromRecords() so the curated list and legend survive, and returns. It
just was not being reached. Removing the `||` lets it run.

MADE THE FAILURE DIAGNOSABLE while I was there. It said "The map engine did not
load" — true but useless: a reader could not tell whether the keyless engine they
asked for was even attempted, which is exactly the ambiguity a silent downgrade
creates. It now NAMES the engine, so a partner who set ?provider=maplibre and sees
"MapLibre did not load" knows their request was honoured and the script is the
problem. Plus a console.warn stating the refusal to substitute — the notice is for
the reader, the warn is for whoever is debugging the embed.
Both strings go through messages.* in eleven locales rather than adding to the
English-literal debt I cleared earlier this tick.

VERIFIED HEADLESS ACROSS BOTH PROVIDERS x THREE LOAD STATES, which is the mutation
test this needed: the ONLY behaviour that changes is provider=maplibre with
MapLibre absent, where old=MB (silent Mapbox) becomes new=undefined (the visible
failure path). mapbox/both, mapbox/only-mapbox, mapbox/none, maplibre/both and
maplibre/none are all byte-identical to before. So the fix is surgical rather than
a behaviour change dressed as a bugfix.
check:i18n-parity PASS. BUNDLE=my-upg-100-list vite build clean.
2026-09-26 16:54:12 +00:00
Doxa Maps
56c2da26c5 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
2026-09-26 16:46:48 +00:00
Doxa Maps
1e08837756 The composite stops deciding dataSource for its panes
regions hit this key from one side (a partner's dataSource silently DROPPED,
f0aa5ab3) and the shepherd asked me to check mine. It is not dropped — it reaches
the panes through spec.config — but the check found the MIRROR IMAGE: my composite
overrode a partner's SILENCE.

  config: { profile, dataSource: cfg.value?.dataSource || 'pray-tools', instanceId }

That `|| 'pray-tools'` is a composite-level constant standing in FRONT of the
pane's own lookup. ProfileLoader.vue:142 resolves
    config.dataSource || sourcesConfig.activeSource || 'doxa-csv'
and sources.json's activeSource IS 'pray-tools' today, so the two agree and this
is a NO-OP refactor — verified from source as the shepherd asked, not assumed.

IT MATTERS ANYWAY, FOR TWO REASONS:
(a) the day activeSource changes, these panes would silently keep using
    'pray-tools' while every other consumer moved, and nothing would report it;
(b) ProfileLoader.vue:135-141 RECORDS THIS EXACT BUG ALREADY HAPPENING ONCE — a
    host embedding without profile-config.dataSource fell through to 'doxa-csv',
    whose relative CSV path 404s off a CDN origin, so the map loaded and no data
    fetch fired ("looked like an API access problem; the API is actually
    CORS-open"). `activeSource` was inserted to close that, and a hardcoded
    constant in front of it re-opens the hole. I was regressing someone's fix.

dataSource is now spread in ONLY when the partner sets it. Verified all three
cases by simulating paneElement's merge: partner sets nothing -> no dataSource key
at all, so the pane reaches activeSource; partner sets it -> their value survives;
tokenUrl/lang forward and lang still aliases to locale.

ON profile AND instanceId, which regions' gate forbids: no conflict, and the
shepherd confirmed the reading from their gate's header. Its rule is that those
two must never be INHERITED FROM THE HOST PAGE (i.e. never in
INHERITED_PAGE_KEYS). Setting them in the composite's OWN spec.config is correct
and deliberate — the panes ARE profile 'upg-100-list', and both share one
instanceId so cross-pane events resolve to the same app. The merge order
{ ...inherited, ...spec.config, ...pane.config } the gate checks is unchanged.

BUNDLE=my-upg-100-list vite build clean.
2026-09-26 16:44:23 +00:00
Doxa Maps
c51233182d Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
# Conflicts:
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/gospel-resources/charts/index.html
#	public/dx-maps/gospel-resources/data-tables/index.html
#	public/dx-maps/gospel-resources/gospel-dashboard/index.html
#	public/dx-maps/gospel-resources/gospel-resources.js
#	public/dx-maps/gospel-resources/resources-list/index.html
#	public/dx-maps/gospel-resources/resources-map/index.html
#	public/dx-maps/manifest.json
2026-09-26 16:43:37 +00:00