Commit graph

127 commits

Author SHA1 Message Date
Doxa Maps
63276767cf The poster credited Mapbox for tiles OpenFreeMap had served
The footer's attribution is a licence statement, so it has to be true rather than
merely present. posterDefaults ships "(c) Mapbox (c) OpenStreetMap" and any caller
that did not override it printed exactly that -- including country-posters, whose
maps run on the fleet's MapLibre default, where Mapbox serves nothing at all and
OpenFreeMap, OpenMapTiles and OpenStreetMap are the three parties whose licences
actually apply. research-map had a hand-written pair for the two providers, which
was right for research-map and helped nobody else.

The obvious approach is to read the style's sources. Checked against the live
style rather than assumed, and it does not work: tiles.openfreemap.org/styles/
liberty declares two sources and NEITHER carries an attribution field. The credit
lives in the TileJSON the vector source points at, which the engine fetches and
merges into the SOURCE OBJECT once it loads. The style is the request and the
source is what came back, and only the second one knows. map-attribution.js reads
the live source first and falls back to any style-declared value.

The TileJSON value is HTML with anchors and a © entity, and a poster footer
is text, so it is flattened rather than printed raw. Repeated credits collapse.
When nothing can be read -- no map, a style that throws before it has loaded -- it
returns empty and the caller keeps its own string, because a blank licence line is
the one outcome that is never acceptable.

The check uses the real TileJSON string as its fixture. A tidy invented style
would have passed while the real map printed the wrong credit, and its first
negative test is exactly that: reading the style alone returns nothing for
OpenFreeMap.

Cut directly on main rather than on the branch this began life in, so it does not
wait behind a poster-layout change it has nothing to do with. A wrong tile credit
is a licence problem; the layout question it was sitting behind is not.
2026-09-29 00:06:52 +00:00
Doxa Maps
8b8e9fee9c The wall-printed packet gets all five of its card pages
Diffing what the lightweight packet ACTUALLY prints on each path against
its card (map, triangle, profiles, wheel, points) found the wall path
missing two pages and half of a third -- while the legend path matched.
Same class as the sets page: the renderer could draw every page, and
only one of the two hosts supplied the arguments.

Three gaps, three suppliers:

The map. printPacketAs passed no mapSvg and there was no default, so a
wall-printed packet opened on a title-only cover. printPacket now lifts
the bundle's own poster (posters/<id>.html, the same file printPoster
already reads) when the host passes nothing -- via a liftPosterSvg
helper shaped for unit-testing against a real generated poster, no
fetch. Verified on laos: the svg is found and lifted. And PosterWall now
actually passes assetsBase into its print opts; without that one line
the lift's guard would skip silently and the wall would STILL print a
mapless packet -- the seam-without-supplier failure one layer down,
caught in the same pass.

The triangle. No funnel was supplied and none defaulted. The default
states ONLY the steps: listCount and unassigned stay absent, so
funnelPage omits the share line and the tip rather than printing a
denominator that is wrong under a non-default list. Proved two-sided:
steps-only renders the triangle with no share claims; adding listCount
renders both.

The profiles. The wall's fetch carried primary_language but not
imb_gsec, so every wall-printed description ran at half length -- the
third fetch site with the same missing-field defect, found the same way.
2026-09-28 23:13:47 +00:00
Doxa Maps
9799ebdba4 The full packet's sets page gets its data, from every print path
setsPage landed as a renderer capability with no data supplier: it
returns '' without data -- correctly -- which meant the FULL-DETAIL
packet had never once printed its distinguishing back page in real use.
The full-vs-lightweight split existed in code while the full packet was
missing the page that makes it full.

packetSets.js is the supplier: a pure decode+join with zero imports, so
the identical function runs under Node (the generator reads the table
from disk) and under Vite (the browser fetches it) and the join cannot
fork between the two. The table stays with the caller -- the same seam
as strings, christian and describe.

The join is measured, not assumed: 2,091 of 2,107 groups match by slug,
rings distribute 1-10 with real shape, and the 16 unmatched are inked as
UNKNOWN on the bounded figure rather than guessed onto a side --
an un-inked category reads as zero on paper.

Wired at every path that prints a packet: the offline generator (all 28
zones verified carrying the page, e.g. laos: 0 inside, 94 outside, 1
unknown; 94 placed with "25 same dialect, same people"), the legend
Export menu, the poster wall, and the selection packet -- the last three
through one opts.sets loader fetching the SAME served asset-pool copy
the generator reads, so an embedded bundle prints the same page as the
dev tree.

The decode is a third reading of near-neighbors.json's cols/dict format
(nearRows.js documents being a copy of upg-neighbor's, pending promotion
to library/). Flagged in the header rather than hidden: when the loader
is promoted, the decode here collapses into it and only the join
remains.
2026-09-28 23:13:47 +00:00
Doxa Maps
b1dbd9bc87 The research dashboard asked the API for English, then translated half of it
useDashboardData read `lang` straight out of sources.json, where it is frozen at
"en", and nothing ever overrode it. So every row the dashboard fetched came back
English regardless of the reader's locale.

The result was not a uniformly English page, which would at least be honest. It
was worse. DashboardChartsRail localizes its country labels through
countryName(), so at ?lang=ar the top-countries chart read الصين while the table
row directly beneath it read "People's Republic of China" — and a page that is
half translated looks like a bug in the half that works. Three surfaces rendered
the raw English label: the data table's country column
(dashboard.config.js:299), the detail drawer (DashboardDetailDrawer.vue:171) and
the search-result subtitle (DashboardTopBar.vue:327).

Fixing it at the SOURCE rather than at the three render sites is what makes them
agree: ask the API in the reader's language and the labels arrive correct, with
no countryName() call and no third spelling of a country's name to reconcile.

DataSourceManager.js:261 has done exactly this for its own callers all along —
`if (this.locale) extraParams.lang = this.locale` — and doxa-research-map,
doxa-simple-map and country-posters all pass a locale in. This composable simply
never got the same treatment.

BOTH LOCALE CHANNELS, because it genuinely arrives by either: `lang` is provided
by ProfileLoader for an element configured through profile-config, and
window[PAGE_LOCALE_KEY] is what a generated leaf sets from ?lang= — the URL a
person actually shares. Reading only one left the regions dashboard's data
labels English on the shared-link path while its chrome translated, which is the
measurement this copies rather than rediscovers.

Resolved at REQUEST time, not captured at setup, so a locale switch cannot pin
the dashboard to whichever language happened to load first.

AND A LOCALE CHANGE NOW REFETCHES. load() ran only onMounted, so switching
language mid-session would have re-rendered chrome over data still in the old
language — the same defect one layer up. Same shape as region-dashboards.vue:680,
guarded on a real change so the mount fetch is not doubled.

Caught while writing it: my first version of that watch called inject() inside
the callback, where it does not work — it warns on every tick and returns null.
The ref is captured in setup and only read later, which is what this file already
does for profileConfig, for the same reason.

Proved headless: locale resolution across all four channels (leaf ?lang=,
injected profile-config, neither, and injected-beats-runtime), then that the
chart, the table column, the drawer and the search subtitle produce the SAME
string for CHN, LAO and COD at ar — the disagreement that started this. The
API's ?lang= behaviour itself is not my assumption: it was measured live at ar
and zh on region-dashboards (its comment at :366).
2026-09-28 22:25:31 +00:00
Doxa Maps
8350617853 The country link went to the people group; four links broke on a missing slash
Two interop defects on production plugins, found by reading the links rather
than the code around them, and corrected against DX-life's own component once
dx-shepherd pointed me at it.

THE COUNTRY LINK POINTED AT THE PEOPLE GROUP. On the regions dashboard's card,
the country name under the title linked to that people group's own profile —
the same destination as the "Full Profile" button directly beneath it. Two
links on one card, one labelled with a country, both landing in the same place.

Theirs, in BOTH card variants (app/components/public/UupgsList.vue:610, :638):

    localePath(`/regions/${countrySlug(country_code.value, country_code.label)}`)

A page on the HOST site — not a research profile, and not the ../countries/
path my first pass reached for. So the base is a PROP, not a constant: only the
host knows where its country pages live, and DX-life's are under /regions/.
Prop, then profile-config, then the site base, so an embedder can point country
names anywhere without touching the research base.

people-groups-grid is ported from that same component and had the same defect
in a different disguise: `${researchUrl}../countries/<slug>`, climbing out of
the research base to a section DX-life does not use for this. Both now build
the same URL from the same countrySlug.

Our countrySlug is theirs: identical body, identical slugify (every transform,
in order), and the tables behind it match exactly — 242 codes each, zero
missing, zero differing labels. My first comparison of those tables parsed
their file into ZERO entries and reported "no differences", which is the shape
of a check that cannot fail; the number above is from the run that actually
parsed 242.

FOUR LINKS DEPENDED ON A TRAILING SLASH NOBODY WAS TOLD ABOUT. researchUrl and
prayBaseUrl are set by whoever embeds the element, in someone else's HTML. The
joins were bare concatenation, and RegionCountryMap's own prop comment records
the contract without ever telling the embedder:

    Pray -> prayBaseUrl/<slug>,  Full Profile -> researchUrl<slug>

One needs a trailing slash, the other must not have one. An embedder writing
research-url="https://doxa.life/research" — the spelling a human would choose —
got "https://doxa.life/researchbantu-tswana" on every profile link the plugin
draws: a 404, with nothing logged anywhere.

We cannot test what an embedder passes, so joinBase() encodes the rule instead
of documenting it: exactly one slash, whatever we are handed. Eight shapes
checked, including a doubled slash, a leading slash on the path, an empty path
and a null base.
2026-09-28 21:59:29 +00:00
Doxa Maps
a79a92d70b One shared print busy state, and the dashboard waits for its map before printing
Driver test round 2, #2 — the half the @media print rules could not do. The
complaint was "no loading indicator on a click": the reader presses Print, the
dialog takes a moment to rise, and nothing says anything is happening. It is
worse on this page than most, because what the dialog waits for is a MAP —
press it a moment early and the sheet carries a grey rectangle.

printInPlace.js gains printWhenReady({ waitFor, onBusy, timeoutMs }). This
pattern already exists five times in the repo (upg-100-list c54a11d5a, four of
exp's 786a83461, PeopleGroupsWall.printWall); they agree on the easy half —
set a flag, print, clear it — and each invents the hard half, which is what
"ready" means and what happens when ready never comes. The hard half is the one
that strands a reader, so it is the half that graduates:

  · THE TIMEOUT PRINTS. It does not reject and does not cancel. A reader who
    clicked Print and waited has asked for paper; a slow tile server is a reason
    to print a plainer map, not a reason to produce nothing.
  · allSettled, not all — one waiter that rejects must not cost the print.
  · onBusy clears in a finally, on every path: a rejected wait, a throwing
    print, a host with no window.print. A busy state that outlives its own
    operation makes the page look broken rather than slow.

RegionCountryMap answers when it is done, and map.loaded() is not that answer —
it reports true while tiles still stream, which is how gospel's charts printed
blank twice today. The condition is: style applied, camera stopped, tiles for
this camera in. It checks synchronously FIRST and only then subscribes to
'idle', because this component's own header already records that once('load')
measured unreliable in both directions here — an event that fired before you
listened never arrives. Always resolves, always removes its listener.

The button is quiet, sits under the title, and is hidden on paper. Busy state
carries aria-busy and :disabled, not just a changed label: the screen reader is
told, and disabled is what actually stops a second print. Both strings are in
all 11 locales; "Print" was already translated 23 times elsewhere in the tree
under other keys, so this adds the key the dashboard's own catalogue lacked.

Mutation-tested, since the failure this guards is the path nobody takes: a
waiter that never settles returns "timeout" after the ceiling and prints anyway
(201ms measured on a 200ms ceiling), a rejecting waiter still prints, and the
busy flag returns to false in all three.
2026-09-28 21:59:29 +00:00
Doxa Maps
d1d2d1dec5 Two poster comments state the defect instead of quoting its report 2026-09-28 17:53:38 +00:00
Doxa Maps
3b5a86aaef Auto resolves to MapLibre; a token no longer picks the engine
The Driver wants MapLibre as the default everywhere, on cost and on how it
looks, with Mapbox supported as an explicit choice.

One line decided it. resolveMapProvider read
  explicit || (token present ? 'mapbox' : 'maplibre')
so merely HOLDING a Mapbox token selected Mapbox. That conflated two different
things: having a token is a capability, wanting Mapbox is a decision, and only
the decision belongs in this function. It now reads explicit || 'maplibre'.

The token is still honoured for what it is — with provider:'mapbox' it
authenticates the map, and the token argument stays in the signature because
callers pass one they hold outside the config. It simply no longer votes.

The migration surface is exactly one shape: a host that pasted a real token into
a published embed snippet and set no provider. All 36 shipped snippets carry a
tk placeholder and no provider key, so any host who filled one in is in it. An
unfilled snippet was already MapLibre, because hasMapToken rejects YOUR_
placeholders, and the DX Life branches pass no token at all. Such a host keeps
Mapbox by adding "provider":"mapbox"; ?provider= and window.MAP_PROVIDER are
unchanged.

The gate's four token-selects-Mapbox assertions were the old specification, so
they are inverted rather than deleted — the diff should show what changed for
whom. Two are added: the opt-in still works with a token in hand, which is the
path a migrating host takes, and a real token is still SEEN, asserted apart from
the engine because otherwise the engine could be right while authentication
broke silently.

check:map-props-unit was not in gates:pre, so it proved nothing on a normal run.
It is now, and the suite reads 32 of 32 with it named.
2026-09-28 17:53:38 +00:00
Doxa Maps
84e1e0cf42 The poster sliced its rows in degrees, and Mercator charges more for a degree up north
DRV1650-SR-POSTER-EXPORT, the "too much Antarctica" half. The big-poster renderer
stitches a grid of tiles, each drawn into an EQUAL-HEIGHT slot of the output image, and
it chose each row's latitude band by dividing the degree span evenly:

    latStep = (ne.lat - sw.lat) / rows

Screen height is proportional to ln(tan(pi/4 + lat/2)), not to latitude, so equal DEGREE
bands are not equal SCREEN bands. MEASURED on a -60..75 view in 4 rows: the top band is
37.0% of the true rendered height and was being painted into 25% of the poster; the next
19.7% into 25%; the bottom 25.2% into 25%. Every row is squeezed or stretched, which is
the wrong aspect, and the bands where a degree buys the most pixels are the most
over-represented -- the Antarctica the Driver saw.

Sliced in Mercator y instead, every row is 25.0%, exactly, because that IS the screen
coordinate.

★ THIRD PLACE TODAY FOR THE SAME MISTAKE. A whiteboard chart nearly shipped with a linear
scale into degrees; a measurement harness nearly reported a false threshold by correcting
the wrong axis; and this has been shipping. Treating latitude as a screen coordinate is
the error, the conversion is one line, and it now lives in library/maps/mercator-slice.js
rather than being rediscovered a fourth time.

check-mercator-slice asserts the PROPERTY across five view/row combinations -- every band
an equal share of rendered height, contiguous, covering the span, north-first -- and its
CONTROL is the old code: degree-linear slicing must FAIL the same assertion, which it does
by 12.0 points. Without that control the test would pass on a tree where nothing changed.
A -90..90 view clamps at the Mercator limit instead of going infinite.

NOT FIXED HERE, because it is a product decision and not a defect: the export also asks
for an 8.5x11 PORTRAIT sheet (research-map.vue:1410), so a landscape map is letterboxed
onto a tall page. That is a deliberate default, it is the other half of the wrong vertical aspect, and changing the poster's default shape is a product decision, not a fix.
2026-09-28 17:53:38 +00:00
Doxa Maps
4a99453cda The packet's list becomes the profile TABLE: picture, description, three boxes
Brief A1 item 1. The 2x10 card grid becomes 20 full-width rows, each one
picture | generated localized description | three prayer checkboxes.

PER_PAGE stays 20, so this changes the layout SHAPE and not the
pagination -- which is what lets check-packet-render's dropped-groups
assertion keep passing unrelaxed. That assertion refuses a +/-1 tolerance
because slack is exactly where a lost list page hides, so a rewrite
needing it loosened would be the wrong rewrite.

The description column is ~1036px. An entire card in the old grid was
683px, so the sentence alone is 50% wider than a whole card was; that is
the point, since a generated sentence needs width a name and two metadata
lines did not.

The geometry closes with ZERO SLACK -- 20 rows plus 19 gutters fill the
sheet to exactly the bottom margin. That is the finding, not the
reassurance: a description wrapping to a third line overflows by ~16px.
Hence FIT BEFORE CLAMP: step 17 -> 15 -> 14px, and truncate at a WHOLE
WORD only as a last resort, because a mid-word cut reads as a rendering
fault rather than an editorial one -- especially inside a people-group's
name, where a reader cannot tell it from a misspelling.

Deterministic by character count, never a DOM measure, because this module
runs under Node for the offline generator as well as in the browser. The
0.52em advance is deliberately wider than Georgia's true ~0.50 so a locale
wider than English is absorbed by the estimate rather than by an overflow.
Measured against the real catalogue, every shipped locale fits at full
size -- German is longest at 178 chars against a 234 budget -- so the
ladder is insurance, not routine.

describeGroup is INJECTED, not imported: its country resolver reaches a
Vite-only alias and imports JSON without an attribute, which would make
every `node internal/check-*.mjs` unrunnable. usePosterPrints composes it
on the browser path and tolerates its absence, so a packet that cannot
resolve a describer still prints, degrading to the metadata line.

check-profile-table.mjs was mutation-tested, and two of my own assertions
were dead. "The rows close on the bottom margin" is a TAUTOLOGY -- the row
height is derived by dividing the page by PER_PAGE, so it holds for any
row count; a 21-row mutant sailed through it. It is replaced by the real
constraint, that a row stays tall enough for its content (fires at 24
rows). And the word-aware check fed 'word word word', where a mid-word cut
is indistinguishable from a clean one; with distinct words it now names the
broken token.
2026-09-28 16:49:01 +00:00
Doxa Maps
642fc15310 Prayer List gets the cards' caret, and starts printing the packet
The Driver asked for the SAME caret the cards already have on the Prayer
List, with two entries -- Full detail and Lightweight -- and said plainly
not to destroy the print options that exist. So nothing is merged, nothing
is removed, and the Poster is untouched.

There was no dropdown COMPONENT to reuse: the cards' caret is an inline
disclosure, one ref plus a sibling tape of plain buttons, deliberately not
a <select> because a native dropdown collides with other dropdowns on the
host page. So the PATTERN is generalized rather than copied. deckOpenFor
now holds a compound `${id}:${which}` key, which keeps "opening one closes
the others" free -- copy/paste would have given two open-states, and two
tapes could then sit open at once and overlap in the grid gap.

While wiring it, a real defect surfaced: THE WALL'S PRAYER LIST BUTTON HAS
NEVER PRINTED A PACKET. downloadPacket calls printList, a columnar table of
name/country/population/religion. The packet -- map, life calendar,
%-evangelical grid, triangle, profiles, wheel, points -- is printPacket,
which every other surface calls and this wall never did. That is a direct
cause of the packet "not following the design that was asked for" here: no
change to the renderer could reach a button that was not calling it.
printList stays exported for any surface that still wants the list.

The two packets are a FILTER over the existing page array, not a second
code path, so they cannot drift apart in layout, i18n, RTL or print scale.
The filter is keyed and never indexed -- an index filter reads identically
today and drops the wrong page the first time the order changes, and that
order is the Driver's. listPages is deliberately outside the filter: a
variant may drop a FIXED page, never one whose count depends on the data,
because that drops people silently. An unknown variant falls back to full.

internal/check-packet-variants.mjs pins all of it, and was mutation-tested
rather than trusted: dropping list pages, reverting to printList, adding a
second open-state ref, and typoing a variant id each fail with their own
message.
2026-09-28 16:49:01 +00:00
Doxa Maps
1aa9a0ac99 A composite embedded bare dropped tokenUrl, and passed because a ProfileLoader sat above it
PM-tok, PRODUCTION-MATRIX.md:285. `tokenUrl` names a token SERVICE a host points at
instead of pasting a key: the element asks that URL when it was handed no token. Only
ProfileLoader ever read it. A pane composite bakes its children in rather than mounting
them through ProfileLoader, so a composite embedded BARE -- one tag on a partner's page,
which is the entire point of a composite -- dropped a tokenUrl-only config on the floor.

It worked in every place anyone looked, because a ProfileLoader elsewhere on the page
fetched and published the token: the composite was passing an exam somebody else sat.
And the failure wears the wrong face. No token is "No Mapbox access token provided",
0 tiles, no canvas -- identical to a missing `tk`, which IS threaded -- so the natural
reading is that the host mistyped their config rather than that a knob is unread.

The rule lives in library/composables, not in each composite, because it has already
been copied once (ProfileLoader.vue:132) and a third and fourth copy is how one of them
later learns to ask ALWAYS -- which a token service billed per call notices before we do.
The pure decision is split into host-token-url.js with NO framework import, because a
fresh worktree has no node_modules and a module importing vue is unloadable by the gate
that proves it right. Same shape as describeGroup keeping countryName injectable.

check-composite-token-url takes its expectation from ProfileLoader -- the contract a
composite REPLACES -- not from the composites it audits, so it cannot pass by
construction; it exercises the decision rather than grepping for it; and it finds its
subjects by a PROPERTY, a production bundle already forwarding `tk: hostToken`, so a
third composite is covered the day it is written. stack-demo has the same shape and is
excluded by its STAGE, not by name, so promoting it pulls it in automatically.

Negative-tested 5 ways. The fifth found a bug in the gate itself: /tokenUrl/ also matches
`tokenUrlX`, so the check meant to notice the contract being renamed could not fail. It
is \btokenUrl\b now. A guard that cannot fail is the one nobody ever finds by reading.
2026-09-28 15:52:47 +00:00
Doxa Maps
54c4d9be48 A prayer packet printed from the browser includes its evangelical-share page
buildPacket takes the flock-wide evangelical share as its sixth argument. The Node generator
passed it; the browser print path did not, so the page returned an empty string and a user's
packet had one sheet fewer, with nothing to say so. It now loads the same
data/people-groups/christian-share.json (about 1 KB, lazily, beside the strings), and a host
may supply its own figure through opts.christian like opts.strings.
2026-09-28 13:57:23 +00:00
Doxa Maps
98065a34ab The packet's generated description, and the language name it prints was index-ordered
describeGroup(group, locale, opts) -> one localized sentence pair for a people
group's profile row: where they live, what they speak, and how reached they are.
Pure, exported, no refs or fetch; strings and the country resolver are injected.

The level is phrased from GSEC, a number, never from GSECDescription, which is
English prose the API hands us -- interpolating it would leave the widest column
of every non-English packet in English.

THE LANGUAGE FIELD WAS BOTH WRONG FIELDS AT ONCE.

primary_language is in normalize.js's NESTED_FIELDS, so the API's { value, label }
is flattened to its VALUE: an ISO 639-3 code. Reading it prints "and speak abf".
The name is in primary_language_label, and it arrives INDEX-ORDERED -- "Arabic,
Shihhi" for what a reader calls "Shihhi Arabic". 725 of the 12,379 rows carry an
inverted label, one row in seventeen.

The un-inversion is not a second copy: useLanguageFamilyLegendData already built
one to make a lookup key, so the split moves to library/utils/languageLabel.js and
both call it. Doing that surfaced why the two uses are not the same use -- 13 real
names carry a comma INSIDE a parenthetical ("Boano (Indonesia, Central Sulawesi)"),
and a naive split reverses those into "Central Sulawesi) Boano (Indonesia". As a
lookup key that garbage simply misses and the next candidate is tried; printed in a
sentence there is no such safety net. The shared split is top-level-only, and a
trailing parenthetical is hoisted past the reversal so "Naga, Tangkhul (India)"
reads "Tangkhul Naga (India)".

The legend's own behaviour is unchanged, checked rather than assumed: old and new
lookup compared over all 3,498 labels in langFamilyByLanguage plus every
LanguageName in the dataset, 0 differences.

countryName.js is NOT imported. It resolves only through the Vite '@map/' alias and
imports two JSON files without an import attribute -- fine in a bundle, fatal to a
node gate. Importing it would make this module unloadable by the test that proves it
right in eleven languages, so the renderer passes it in, and a missing injection
degrades down countryName.js's own ladder (localized -> English label -> alpha-3)
rather than blanking the clause.

check-describe-group asserts every GSEC level, each missing field, both locales,
truncation, the inverted label, the parenthetical that must not be shredded, and
that a bare language code is dropped rather than printed. Negative-tested: reading
primary_language, skipping the un-inversion, a naive split, and dropping the
parenthetical hoist each turn it red; restored, it is green.

Not yet wired into the renderer and not yet in gates:pre -- register with
"check:describe-group": "node internal/check-describe-group.mjs".
2026-09-28 13:56:06 +00:00
Doxa Maps
726b11ae79 A lazy view that fails to load says so and offers Retry; map controls carry data-control
retryableAsyncComponent: after the three retries the pane rendered blank. It now shows
"This view couldn't load." with a Retry button (messages keys, 11 locales). Retry swaps in a
fresh async component, since Vue keeps a rejected load for the life of the page.

Each map control wrapper passes data-control (zoom-in, zoom-out, fullscreen, theme, location,
help, share) to its button: an untranslated hook for probes, where title and aria-label are
localised.
2026-09-28 11:42:54 +00:00
Doxa Maps
5ae7d31b04 useMapFly's zoomToLegendRow comment names readSourceFeatures, not the engine-private _data
The comment still said the code reads map.getSource()._data, which invites a copy
of the private field back in; the code reads through the shared helper that knows
both engines' source shapes.
2026-09-28 10:13:29 +00:00
Doxa Maps
c73d713b43 Delete useMapPacket.js: nothing imports it since both packets print through the shared HTML renderer
research-map and countries-map moved to the shared printer, leaving the
composable with no importer on main. jsPDF stays: useMapPoster.toPdf,
posterSizes and my-upg-100-list still reach it. The file map row goes and the
library index is regenerated.
2026-09-28 06:44:14 +00:00
Doxa Maps
2ea3c53e0f The packet prints what the reader selected, and the question "which pins is that" becomes shared
PACKETSEL, found by exp-shepherd: the prayer packet printed the first 50 of EVERY people group whatever
was selected, so a reader who had narrowed the map to one language family got a packet about somebody
else. I carried that behaviour through verbatim when I moved the packet to the shared printer — it was
already wrong and I preserved it, which is worth saying plainly.

The rows now come from the pins currently SELECTED: the layer's own filter decides which pins render,
`_pinMatchExpr` decides which of those are lit (the affinity tab dims rather than filters), and both
must match — a pin at opacity 0 is not what the reader is looking at.

★ ASKED OF THE MAP'S FEATURES, NOT OF THE ROWS, and this is the part that had to be got right rather
than guessed. Pin properties are DERIVED from rows: useMapLayers fills languageFamily in when the API
returns null for all 2,069 groups, and canonicalizes doxaRegion onto the palette's key forms precisely
so setFilter matches. So evaluating a pin filter against a raw data row is wrong in a way that NEVER
THROWS — it just selects a different, plausible-looking subset. The features the map holds are the only
place the two vocabularies agree.

Answering that question already existed, privately, inside useMapFly: zoomToLegendRow has to solve it to
fit the camera to a legend row, including the reason it cannot use querySourceFeatures (viewport-clipped,
so an off-screen region silently selects nothing). Those three functions move VERBATIM to
library/maps/pin-selection.js and useMapFly imports them, so there is one evaluator rather than two —
and country-posters, which prints the same kind of packet from the same kind of selection, has the same
helper to reach for instead of a second copy.

Two failure modes are distinguished rather than collapsed: pins selected but NO row matching them is an
id mismatch, so it prints the full list and warns; a genuinely empty selection still reaches the shared
printer's own "that list resolved to no records", which is honest. And when the 50-row safety cap bites,
the cover now says "— 50 of 214" instead of quietly being a different packet than the one asked for.

Verified: gates:pre 13/13, check:composite-interop PASS. Needs eyes: rega — select one language family,
print, and check the cards are that family and the cover carries the count.
2026-09-28 06:29:49 +00:00
Doxa Maps
ae10e4b978 Maps start their engine download at setup, and the dashboard's country boundaries download beside the basemap
rega's load waterfall: the engine was requested only after the component tree
had rendered, and the dashboard's 384 KB country boundaries only after the whole
basemap style had loaded, so each waited on the other. The engine download now
starts when the map composable is set up, and the boundaries when the dashboard's
map panel mounts; the style handler awaits that same download.
2026-09-28 02:38:49 +00:00
Doxa Maps
1b70728d6f useMapClustering: no console.log in shipped bundles
Two informational logs ran on every clustering pass and shipped in every bundle
that clusters (check:public-hygiene).
2026-09-28 02:01:47 +00:00
Doxa Maps
2ea9b22daa Export poster opens the print dialog on the poster, and downloads nothing
The Driver, 2026-09-28: an export-poster button must open the PRINT DIALOG with a poster preview,
never a direct download. Simple Research was doing exactly the forbidden thing — render a PNG, hand it
to a download helper — so a reader got doxa-prayer-poster.png in their downloads folder with no
preview, no paper size, and no way to print it without opening it somewhere else first.

The dialog IS the preview. printImageBlob() puts the rendered poster on a sheet of exactly the spec's
size (@page 8.5in x 11in, margin 0 — the renderer already composed the margins in) and raises the
dialog over the map. It goes in library/composables/printInPlace.js, which is THE print mechanism here
and already carries the Driver's earlier ruling that killed the pop-up path: an iframe cannot be
blocked and needs no user gesture, so the same call works for a human click and for an agent.

The ellipsis comes back on the label. It was stripped while the button handed over a file directly,
and an ellipsis promises a dialog — which is now what happens.

★ AND THE GATE ASSERTED THE OLD RULING, which is the part worth naming. check-export-one-click passed
while the button did the thing the Driver has now ruled against: it waited for a download and checked
the PNG's dimensions. A gate that encodes a superseded ruling does not just miss the defect, it defends
it — the next person to "fix" this button would have had to break a green gate to do it. It is replaced
by check-export-poster-prints, same four cases (leaf and gallery, en and ar), asserting the new
contract: one click, some frame calls window.print() within 120s, the printing document is the
1700x2200 poster on an 8.5in x 11in sheet, and NO download arrives. window.print is stubbed in every
frame — the native dialog is modal and would hang the run — so this measures everything up to the
dialog and not the browser's own chrome, which is the right boundary for a headless gate.

Why the download half is asserted separately: a button that opens the dialog AND saves a file still
breaks the rule, and a download that lands late is still a download, so it is judged over the whole run
rather than at a moment.

Verified: gates:pre 12/12, check:doc-scripts and check:no-credentials-omit PASS with the rename.
NOT verified here: the new gate needs a browser, and this agent does not start one — rega runs it.
2026-09-28 02:01:46 +00:00
Doxa Maps
e79d6dc8f4 A shared link reads a list separated by , or |, so partner links already published keep working
Links on partner pages write band=yes|partial, and the reader split on ',' only,
so such a link read back as one value and matched no row. Reading now accepts
either separator; writing stays ','. A literal '|' in a value is percent-encoded
before any join, so every separator left in a URL is a real one.
2026-09-27 19:10:08 +00:00
Doxa Maps
34573a27c9 A CSV export honours the typed search, so the file matches the table on screen
With no facet filter and no selection, a table narrowed by typing exported every
row in the source. The export now takes an optional search transform, and the
data table passes the matcher it already uses for its visible rows. An explicit
selection still outranks the search, and a caller that passes nothing is
unchanged.
2026-09-27 19:01:10 +00:00
Doxa Maps
e2cebd9130 Shared links keep partner param names, and a CSV export matches multi-value filters
A shared table link can now be written as one query param per facet, and a
param name may map to a different bus key, so links already published on
partner pages keep working. The CSV export matched no rows once a reader picked
a second value in a filter; the default row match now reads a multi-value filter
as OR, and the gate no longer exempts that line.
2026-09-27 18:40:47 +00:00
Doxa Maps
655bbdc561 A filtered table becomes a link anyone can open, and the link now carries the typed search
Card BUILD-SHAREABLE-FILTERED-TABLES, for researchers and media-to-movements teams: narrow the table to
the peoples you are working on, then give that exact view to a teammate or reopen it on a phone.

TWO GAPS, both measured on main before building.

1. IT WAS NOT REUSABLE. The share hash was a PRIVATE function inside
   plugin-designs/research-dashboard/src/composables/useDashboardShell.js, so exactly one dashboard in
   the fleet could produce a link. go-int's board had none — grepped, zero matches — and every future
   kit dashboard would have had none either. Nothing in it was research-specific except its address.
   It is now library/composables/useShareableView.js, parameterized: the bus and labelFor are passed
   in, the filter and search param names are options, and no dashboard is named inside it. The
   research dashboard supplies the one genuinely research thing — labelFor, for the display-only slug.

2. THE LINK WAS INCOMPLETE, AND SILENTLY SO. The hash serialized filterSet ONLY. Typing in the search
   box is the commonest way this persona narrows a table, so a shared link reopened with MORE ROWS than
   the sender saw, with nothing to tell the recipient their set was wider. For population data passed
   between teams that is worse than a link that plainly fails. `q` is appended only when a term is
   present, so a dashboard with an empty search box writes byte-identically the hash it wrote before.

`&` joins params now, so it is reserved in values alongside `;` `:` `*` `,` `%` — otherwise a search for
"rice & beans" would truncate the link at the ampersand.

check:share-policy grows a DRIVEN arm: the two pure functions are lifted from the kit source and RUN,
so nine guarantees are measured rather than asserted — the old format byte-for-byte on an empty search,
slug still display-only, multi-value still dropping the slug, the typed search reaching the link and
coming back, a literal comma in a people-group name surviving, an ampersand in the search surviving, and
both legacy shapes (JSON hashes, and old #f= links with no q) still restoring. No browser needed to
prove a string round-trips.

Source only. Rebuild: research-dashboard, dashboard-plus-grid.

check:filter-multivalue arm C follows the file: it lifted escapeValue/unescapeValue from the shell to
drive the hash round-trip, and said so plainly when they moved ("the hash format moved and this gate is
blind to it") rather than passing vacuously. Repointed to the kit module.
2026-09-27 18:25:01 +00:00
Doxa Maps
02faaab75e A missing Mapbox token is one console.warn per page, not an error per map
With provider=mapbox explicit and no token, useMapInstance logged 'No Mapbox access token provided' as
console.error once per map (7x on pplr), while the UI already says "Map unavailable". It is now a single
console.warn per page, so production consoles stay at 0 errors (dx-shepherd, TKWARN).
2026-09-27 08:57:42 +00:00
Doxa Maps
9c9fcd5572 RTL drag: the direction probe reads the handle, and two handle margins become logical
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).
2026-09-27 06:42:54 +00:00
Doxa Maps
68c7a6d4b6 Kit 4d-2: useDashboardData moves to the library
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.
2026-09-27 06:04:09 +00:00
Doxa Maps
8c7a52ed6f Every shadow root honours prefers-reduced-motion
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.
2026-09-27 05:05:04 +00:00
Doxa Maps
458ca5ff59 The prayer-progress cards become shared components too
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.
2026-09-27 04:14:31 +00:00
Doxa Maps
25c102f5cd Kit 4c-1: the counting half, and the bucket that said "Other" in every locale
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.
2026-09-27 04:14:13 +00:00
Doxa Maps
f728831d77 The interactive maps fetch the web-sized boundaries
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.
2026-09-27 03:25:59 +00:00
Doxa Maps
bad6991b03 D3: the bus can point at a record without choosing it
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.
2026-09-27 02:17:35 +00:00
Doxa Maps
273785b2e6 The layout drag was LTR-only, and my own migration was too eager
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.
2026-09-27 02:17:35 +00:00
Doxa Maps
c2ab779623 Kit landing 4a: the shared search, because the facet rail cannot move yet
★ 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.
2026-09-27 02:17:35 +00:00
Doxa Maps
a7c9e9557f The prayer map's legends see the pins it draws
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.
2026-09-27 02:17:35 +00:00
Doxa Maps
72dc172479 Four pray-tools assumptions the exporter did not know it was making
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.
2026-09-27 01:57:50 +00:00
Doxa Maps
2525c61151 Religion families follow the reader; the value they filter on does not
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.
2026-09-27 01:57:23 +00:00
Doxa Maps
15820f1432 Dashboard kit, landing 3: the camera and the CSV exporter
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.
2026-09-27 01:16:52 +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
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
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
09aba6dc92 C4 wave 2: five library components share the guard, and keep their own English
The five are NOT one shape, and treating them as one would have changed what
readers see. Measured before touching them:

  ApproxPinNote, FilterDropdown   fall back to a literal map
  SemanticTreeLegend, PosterWall  fall back to humanizeKey() — 'legend.columns.name' renders 'Name'
  PinHint                         falls back to a one-key map
  SemanticTreeLegend, PinHint     also capture i18n.te (used 3x and 1x)
  PosterWall                      also captures i18n.locale

So useOptionalT grew an optional `fallback` hook, and useOptionalI18n was added
for the callers that need te and locale. Every site passes the fallback it
already had, so not one rendered string changes. Routing them through the
catalogue instead would have altered what seven bare-host bundles print, and —
because tStatic honours the runtime locale — would have started translating
strings that are English today. That may be an improvement; it is not this
commit's to make. Changing a default is not a migration.

te keeps its existing contract too: FALSE on a bare host, never "true because the
catalogue has it". PinHint and SemanticTreeLegend both record why — te is how
their callers know to use their own defaults.

THE CHECK NOW LIVES ON MAIN, which is the point of this commit as much as the
migration is. dx-shepherd: the 18-assertion version was in _qa-cycle, so it would
never have run again. internal/check-optional-t-equivalence.mjs is registered as
check:optional-t-equivalence — script line, //scripts note and gates:post token,
all inserted by hand rather than by re-serialising package.json. It carries 46
assertions: the helper's contract across all three host states, a branch-for-
branch equivalence against the old hand-written code, and a PER-SITE REGISTER
that every later C4 wave appends a row to. Negative-tested from outside: strip one
site's own fallback and it fails naming that site.

check:library-index went red on the new composable and is green again after
`bun run library-index`. That is the staleness gate working, not a flake.
2026-09-26 22:06:57 +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
e35a86253c useSelectionBus: its header no longer promises a shim deletion and a D1 that have both landed
Comment only. The research-dashboard shim is deleted and multi-value filterSet (D1) is in; the header
said both were still to come.
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
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
36ad9ae73c The engine logo's label follows the reader's language, on both engines
rega's row 34 (map controls in ar) found the engine logo's label English: "Mapbox homepage" /
"MapLibre logo" — one engine key with two meanings. engineUiLocale(lang, provider) now takes the engine
and sets LogoControl.Title from mapui.mapboxHomepage or mapui.maplibreLogo (11 locales). gospel-resources
and cities-internet build their maps RAW; engineUiLocale is documented as exported for them. (PinHint's
close button was already translated on main as aria.dismissHint.)
2026-09-26 13:04:16 +00:00