Commit graph

885 commits

Author SHA1 Message Date
Doxa Maps
22dc3ed770 Kit landing 4b: the rail stops reaching for research colours
The facet rail and the chip bar imported useTreeLegendColors and
useTreeLegendLabels — the DOXA palettes and the People code tables. Those are
research-specific by this kit's own reckoning and they STAY in plugin-designs, so
that import is precisely what stopped the rail from being able to move into the
kit at all: a library component importing a plugin composable is what
check:no-cross-plugin-imports refuses, and rightly.

So the dependency inverts rather than the file moving. The panels declare what
they need — two functions — and the SHELL supplies them as
dashboardConfig.legend. The shell is the right supplier because it is the only
thing that already holds the config, the dataStore and the source id those
composables require, and because a composable cannot be called at config-module
scope.

ON dashboardConfig, NOT A NEW INJECT KEY, because section 5.1 of the spec says a
panel needing a new capability gets a new CONFIG key and never a new channel. It
would have been easy to add a seventh inject key and quietly exempt myself from a
rule I wrote; the rule is better than the exception. Merged rather than assigned,
so a profile supplying its own legend wins.

THE NO-LEGEND STATE IS HONEST, NOT A CRASH. A dashboard that supplies nothing
gets uncoloured swatches and raw values — which is what go-int sees before it
wires its palette, and a half-configured dashboard should look PLAIN rather than
broken. The kit's neutral is its OWN constant and deliberately not the research
palette's NEUTRAL, which happens to hold the same value today: importing it to
save a literal would re-create the dependency this landing removes, and the
research palette must stay free to change its own neutral without reaching in
here.

Big Picture is unchanged because the fallbacks fire only when legend is ABSENT,
and for Big Picture the shell always supplies it. Every hex still comes from the
same strategy modules the map's pins paint with, so the legend still cannot drift
from the map — it is handed the strategies instead of fetching them.

check:legend-mirrors grows arm 3: the research imports are gone, cfg.legend is
read, and the two no-legend fallbacks are LIFTED OUT OF THE SFC AND DRIVEN, so
"neutral swatch and raw value" is measured rather than asserted. Three mutants,
all caught. What it deliberately does NOT do is mount the component — a no-op
renderer cannot show a colour, so a mount would prove only "it did not throw" at
the cost of a dev server in a gate that runs in milliseconds. The rendered result
is rega's.

★ AND MY OWN CHECK FAILED ON MY OWN COMMENT, FOR THE FIFTH TIME. The comment
beside the kit's neutral explains the decision by NAMING the research NEUTRAL, so
testing raw source reported a coupling that does not exist. I had already fixed
that shape four times in other gates and still wrote the raw-source version here,
which says the fix belongs in a habit rather than in each gate: every text search
over source strips comments first, without thinking about it.

Source only. STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid.
2026-09-27 04:14:13 +00:00
Doxa Maps
281f9d1def The search chip was the one chip still in English, and the string for it already existed
DashboardResearchFilters builds the :active-chips array the live strip renders, and its search chip
read `Search: ${term}` — a raw English label sitting beside six localized facet chips in all ten
non-English locales. dashboard.search_live was already in the catalogue, in all eleven locales,
written for that exact line and never wired.

The punctuation is why it has to come from the catalogue rather than be assembled here: French is
"Recherche : {term}", with a narrow space before the colon, and Chinese is "搜索:{term}" with a
full-width one. Translating the word and composing the colon in code gets both of those wrong even
with a correct translation. tStatic takes no params, so {term} is filled at the call site.

Also closes a LATENT gap beside it: an array value is a multi-value filter (D1), labelFor() joins one
for display at its single owner, but the prayer arm skips labelFor deliberately — its values are
already human labels — so it skipped the join too and a two-value pick would have read "A,B" with no
spacing. Nothing in this plugin calls toggleFilterValue, so no caller produces a prayer array today;
it is closed because the next caller that does would not think to look in a chip label for the reason.

check:dashboard-filter-a11y gains arm A13, and the arm is the part worth reading. Everything else in
that gate checks the chips where they are RENDERED; neither of those files is where the WORDS come
from, which is how an English label survived a gate written for this strip. A13 reads the computed
that builds the labels and allows an English literal only as the fallback of a catalogue read.

It also took two rounds, both caught by negative-testing rather than by review:
  1. it flagged '__search__', the chip's internal name — reporting an identifier as untranslated
     prose, which is the crying-wolf failure arriving from the other direction. Now scoped to the
     `label:` expression, by brace-balanced scan rather than by line.
  2. it then PASSED the real defect, because the English was in a TEMPLATE literal and the arm only
     read '…' strings. A chip label is exactly where a template literal is natural, so the one shape
     the defect actually takes was the one shape the arm could not see.
Verified failing on the restored defect ("Search: ") and passing on the fix.
2026-09-27 04:14:13 +00:00
Doxa Maps
2c5d9f47ec Two setup-time defects: a TDZ that blanked the map, and a cleanup that never ran
Both came from the Driver's console in the gallery's Simple Research tab, and both come from one
gap: where a line SITS in a setup block is not WHEN it runs.

research-map.vue — "Cannot access '_lastFitKey' before initialization", thrown during setup, which
left the map blank. The externalFilters watcher is immediate:true, so it runs while setup is still
on the stack and calls applyFitSlugs() 350 lines below it. The CALL resolved, because a function
declaration is hoisted; its constants are not, so the first read hit their temporal dead zone. A
hoisted function promises that its NAME resolves early, not that its BODY can run early, and that
asymmetry is invisible from both ends: the watcher looks like it only touches the store, and the
constants look like they sit right beside the function that reads them. They and the guard now sit
above the watcher, with a note saying why they cannot live next to applyFitSlugs().

DashboardMapPanel.vue — "onBeforeUnmount is called when there is no active component instance".
A lifecycle registrar closes over Vue's current instance, which exists only while setup is on the
stack, so a hook registered after "await initializeMap()" is silently dropped: a ResizeObserver on
document.body plus a window resize listener outlived the component. Nothing threw and the feature
worked, so it showed only as a studio that slowed down the more tabs a reader had visited. The fix
is not to move the line — the code that creates those handles is past the await either way. The
handles move to setup scope and are torn down in the hook this file already registers correctly,
which is the pattern it was using three lines away for containerRO and attribRO.

research-map.vue held a second copy of the same leak, its scroll-zoom resync listener. The new
gate found that one; reading the file had not.

check:setup-order covers both classes. Its own false positives are the instructive part: 6 hits
down to 1 over three rounds, and every one it dropped was prose or shadowing read as code — an
onBeforeUnmount inside a console.error STRING, the word "table" inside a new Error(...) template
literal in four files, and a destructured byCountry shadowing a computed 220 lines away. So it
blanks comments, string text and template text while keeping ${...}, which is code, and subtracts
every name a span declares for itself. It self-tests on both shipped defects, both fixes and all
three decoys, and exits 3 if an arm goes blind rather than passing quietly.

Source only. doxa-research-map, research-dashboard and dashboard-plus-grid need a rebuild.
2026-09-27 04:00:31 +00:00
Doxa Maps
de24e633f9 The region card becomes a shared component the plugin consumes
The Driver's ask, and his negotiating point with the DX-life author depends on it
being true: the components cloned from their /regions page must be REUSABLE,
PARAMETERIZED components in the shared library, not private to one plugin. He named
the failure mode himself — someone opens the regions plugin folder and finds the
components are not reusable after all. I checked before building, and for the UI he
named he was right: only the DATA and LOGIC had been promoted.

So library/components/regions/RegionSectionCard.vue takes title, summary, countries
and footerLabel, emits what was clicked, and knows nothing about where regions come
from. The plugin version fetched its own data and owned the layout, which is exactly
what made it unusable anywhere else.

THE DESIGN TRAVELS WITH IT. The card carries the region-card, countries-grid and
country-link CSS lifted unchanged — including the RTL chevron rules, where a
"forward" chevron must mirror in Arabic while a disclosure caret must not, and where
[dir="rtl"] .region-card__icon at 0-2-0 would otherwise beat the 0-1-0 --open
modifier. A consumer gets the look, not a lookalike. It needs dir on an ancestor for
that mirroring, which is documented in the file as a requirement.

★ THE CARD OWNS THE DISCLOSURE ROW, after the first cut got it wrong. I put the
"View country posters" toggle in the consumer's slot — and immediately had to copy
the footer-link styling and the rotating caret back into the plugin, re-creating the
duplication this extraction exists to remove. A disclosure inside a card is a card
feature; only the revealed CONTENT is the consumer's, so PosterWall stays in the
slot with its fourteen props and does not become every consumer's API.

★ THE CHEVRON IS INLINE, NOT A <use href="#id">. The plugin declared the symbol once
and every card referenced it. A shadow-root id is scoped to that root, so the same
card mounted elsewhere would reference a symbol that does not exist — and an
unresolved <use> renders NOTHING, silently. The plugin's own comment warns about
that class for external hrefs; a cross-root id fails the same way.

check:regions-uses-library is registered and asserts BOTH halves: the plugin imports
from @map/ and renders the component, AND no longer holds the markup or the CSS it
delegated. A reusability claim only a human can audit is one that rots; this one
fails the day someone re-inlines a copy. Two negative controls keep it honest — the
plugin still uses its own .rl-root classes, and the other tab is asserted NOT to use
the card yet, so the check describes what is extracted rather than what I hope for.

Two of my own mechanical edits produced valid JS and broken content on the way here,
both caught by checking rather than by the parser: a line-numbered splice that
dropped PosterWall's closing /> (fixed by locating boundaries by CONTENT), and a
line-filtered CSS deletion that left 9 orphaned declarations and braces at 12/21
(fixed by removing rules brace-aware, then re-validating the concatenated stylesheet
rather than trusting check:sfc-parse, which only proves the JS parses).
2026-09-27 03:26:36 +00:00
Doxa Maps
f716590740 UPG Neighbor cockpit: the view switch, sort buttons, tooltip and legend follow the reader's language
Still typed-in English after the lens pass: MAP / FLOWER, the sender counter, the
sort buttons, the Details tooltip rows (% Christian, Bridge, Adjacency, Code), the
legend ribbon, the flower ring legend and the Flowers cluster-by modes. 21 keys in
all eleven catalogues; the ring legend reuses the Flower Mesh wording already there.

Method names (SEND, 4-D SEND join, Flower-Mesh, Bounded sets), the provenance
footers and the scripture references stay English, per the scope ruling.
2026-09-27 03:26:35 +00:00
Doxa Maps
71826baa3d PPLR maps: the toolbars, legends, pills, popups and pin card follow the reader's language
The chrome around the maps was typed-in English: the search placeholder, Base map and
Data sources, the legend titles, each view's header pill, the popup row labels, the pin
card and the Rosetta legend. 42 keys in all eleven catalogues, reusing the product's
own words for Legend, Close, Choose a view, Religion and Language.

Registry names stay as they are (ROP3, ROP25, PEID, IMB PGID, JP Code, JoshuaProject,
WCD, PGIC GeoID), as do the Data sources provenance labels. The reference pages
(semantic trees, index, name locales) wait on the English-only ruling.
2026-09-27 03:26:35 +00:00
Doxa Maps
1801bbc20f Messaging channels: a taxi and a food app are no longer a country's top messaging app
The classifier matched "line" as a bare word, so "LINE MAN: Food Delivery & more"
(Thailand) and "Malé Taxi Line" (Maldives) each ranked as the country's top messaging
app, ahead of WhatsApp. LINE now matches only as the whole name or before a colon.

The classifier moves to app-kind.mjs so the fetcher and the table builder share it,
and the builder re-derives each kind from the app name, so a cached fetch made under
an older rule cannot carry its answer forward. The headline count is computed from
the rows instead of typed: WhatsApp (Messenger or Business) is top in 73 countries.
2026-09-27 03:25:59 +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
9a955f8f20 Web-sized boundary copies: every feature and island kept, a fifth of the bytes
rega measured ~3.6 MB of boundary GeoJSON costing 5-6 s of MapLibre worker
parsing before the first territory draws, on every partner page. These copies
are for interactive maps; the full files stay for posters, print and
build-time point-in-polygon.

  world-adm0-web.geojson          1,866 KB -> 383 KB (gzip 596 -> 134)  162 features, 572/572 parts
  gb-IND-ADM1-full-web.geojson    1,796 KB -> 227 KB (gzip 451 -> 69)    36 features, 452/452 parts
  gb-IND-ADM1-web.geojson         1,628 KB -> 178 KB (gzip 412 -> 55)    34 features, 40/40 parts, listId kept
  (gb-CHN-ADM1 is 56 KB already: no copy)

library/scripts/boundaries/build-web-boundaries.mjs writes them with mapshaper
(pinned 0.6.121, via npx; not a repo dependency): -explode, then topology-
preserving Visvalingam with keep-shapes, then -dissolve with every other
property copied. The explode makes each island its own shape, so keep-shapes
protects every one: without it, the world drops from 572 to 240 parts. The run
FAILS on a changed feature count, a lost key, a changed property or a dropped
part. Negative-tested (without -explode: 297/572 parts -> FAIL), and a second run
writes identical bytes.

Measured (_qa-cycle/03-deployment-cycle/REPORT-WEB-BOUNDARIES.md): median area
error 0.57% (world), 0.04% (India). Of 14,486 real people-group points, 95.9%
stay in the same country in the world file and 99.9% in the India file; the
misses are archipelago coasts. No runtime code does point-in-polygon on these
files, so this is visual only.

Not wired: consumers still fetch the full files, and generate-tree does not
stage the -web copies yet. Both are in the report's switch table.
2026-09-27 03:25:59 +00:00
Doxa Maps
73782bbbb5 Merge main into packet/vertical
Six public/ conflicts, ZERO in source — checked before the lock rather than discovered inside it, and it
held: the only overlap between main and this branch is my own bundle, its three leaf HTMLs and the two
manifests, because main rebuilt them and so did I.

They take main's side HERE and are regenerated from the merged source in the next commit. A bundle is an
OUTPUT: the only correct content is whatever the build emits from the merged inputs, and any
hand-reconciled middle version is wrong by construction even when the diff looks plausible.

studio's landing 9 (19682d2c, carrying my ee0bf338 by identical blob 9bdc45a9) has not landed, so mine
goes in first and theirs becomes the no-op. Both orders were agreed safe with studio and dx-shepherd
because the content is byte-identical either way.
2026-09-27 03:10:05 +00:00
Doxa Maps
45b07836cb The legend tree accepts a generated title, and keeps its label when there is none
The seam between zone-title.js (84062744) and everything that renders a zone name. buildLegendNodes
already took an opts object, so this is one option — `opts.title(zone)` — consumed at the three places a
leaf takes its name from data: the 13 regional rows, the 12 asia rows, the 2 uncovered rows.

★ NULL MEANS "KEEP YOURS", NOT "NO TITLE", and that is the whole reason this is safe to land while the
Driver's decision on the other 18 zones is still open. 18 of 28 zones decline — their labels are
sub-national ("Malabar Coast") or taxonomic (IMB's wagf blocks), and Intl has no subdivisions — so
falling back to the hand-written label is the NORMAL path here, not an error path. A generated title
replaces a label only where one could actually be composed.

⚠ THIS FILE HAS THREE CONSUMERS, not one: the legend, library/scripts/posters/generate.mjs and
publish-html.mjs both enumerate zones through leafZones(buildLegendNodes(...)), and packet.mjs joined
them in 3ef164c0. So a behaviour change with no option passed would have moved the poster and packet
output too. PROVEN UNCHANGED rather than assumed:
  - no opts.title                      -> 28 leaves, labels byte-identical to before
  - opts.title returning null for all  -> byte-identical again (the decline path is not a special case)
  - opts.title generating for one zone -> exactly that one label changes, nothing else
  - the packet generator still builds  -> Tibet, 43 groups / 7 pages
  - publish-html's enumeration         -> still 28 zone ids

Not yet called with a real resolver anywhere; wiring the legend, the poster header and the packet page-1
title is the next step, and it waits on the caller having records at the reader's locale to build the
country-label index from.
2026-09-27 02:54:25 +00:00
Doxa Maps
84062744ee Zone titles for the 10 zones that can have one, and a null for the 18 that cannot
Driver, 2026-09-27: zone titles must be GENERATED from already-translated parts, because UPG-100 is
becoming a pattern and a hand-translated title is a cost paid again for every project list (7000 / 4700
/ 3000 / 2000 / 700) forever. The Arabic map reads English today because every label is a hand-written
English string.

★ MEASURED BEFORE DESIGNING, and the measurement killed the more attractive half of the idea:
  country_code_label from the records API   all 175 codes, every locale   (formal names)
  Intl.DisplayNames({type:'region'})        all 175, every locale         THROWS on ISO-3
  Intl.DisplayNames({type:'language'})      213 of 2242 = 9.5%            ECHOES THE CODE
LANGUAGE IS OUT. `.of('kfa')` returns the STRING "kfa" rather than throwing, so a miss prints a raw code
onto a printed poster with nothing raising a hand — and the 90.5% miss is structural, not a gap to fill:
unreached peoples speak small languages by definition and CLDR carries the big ones. This module never
names a language.

TIERS, stated in the module because a generator that silently produces a bad title is worse than one
that declines:
  TIER A (3)  country_code   -> one country name.                    GENERATED
  TIER B (7)  country_codes  -> n names + a joiner.                  GENERATED
  TIER C (18) everything else                                         NULL, caller keeps its label
Tier C cannot be done from countries: the 12 India/China zones are pg_slugs whose every record is ONE
country (measured: asia-india-kerala 78/78 IND), so countries would render "India" twelve times and
could not tell "Malabar Coast" from "Northwest India" — those labels are SUB-NATIONAL, and Intl has no
subdivisions. The curated-table-vs-keep-English choice is the Driver's (repo's digest item D). Returning
null until then means nothing regresses.

⚠ AND I HAD THE SOURCE PRIORITY BACKWARDS, corrected by looking at what the two sources actually say.
I preferred the records' label because it is already localized and needs no code mapping — both true,
and it generated "Lao People's Democratic Republic" for the zone whose hand-written label was "Laos": a
generated title WORSE than the one it replaced. 28 of our 175 labels are formal forms like that
("Islamic Republic of Iran", "People's Republic of China"). Intl gives the short common form in every
locale, so Intl is now first and the records are the fallback. The ISO-3→ISO-2 mapper is INJECTED, not
imported, because regions-plugin owns that table (their card) and it has not landed: without it a
3-letter code falls through to the formal name, with it the titles read "Iran + Yemen + 11 more".

THE ONLY NEW TRANSLATED STRINGS ARE TWO: zone_join "{a} + {b}" and zone_join_more "{a} + {n} more", one
pair per locale block in packet-strings.json (22 keys across 11 blocks). That is the entire translation
cost for every project list that ever uses this — a new language stays a NEW BLOCK, never a code change.

VERIFIED against the real 28 zones in en AND ar, with live Arabic records from the API:
  Laos -> لاوس | Middle East Core -> إيران + اليمن + 11 أخرى | Maghreb + Egypt -> الجزائر + ليبيا + 5 أخرى
9 generated, 18 declined, in both locales. Every decline path returns null rather than a code or a
partial title, including a country_codes zone where one of the names it would SHOW is missing — a title
naming two of three countries and dropping the third silently misinforms and cannot be told apart from a
correct one downstream.

NOT WIRED IN YET: this is the generator only. The caller change (legend + poster + packet header) and
the deaf zone — global by construction, which breaks the geography-first rule and which the Driver says
is unsolved — are the next steps on the pattern card.
2026-09-27 02:49:56 +00:00
Doxa Maps
cc1830d79d No "tap a coloured dot" on UPG-100 at all — its map is a poster preview
Driver, 2026-09-27, from the phone screenshots: the pill should not be on this profile, full stop.

★ THIS IS A NARROWER AND MORE CORRECT CLAIM THAN THE ONE I FIXED YESTERDAY. PILLRISK was "the pill shows
over the posters", so I fixed WHERE it shows (inert on the inactive pane, 4642e78d). The Driver's answer
is that it should not show HERE at all: UPG-100's map is a POSTER PREVIEW — the reader is looking at what
will be printed, and there is no per-dot detail to open — so the pill invites a gesture that does
nothing, on a page the screenshots show has no coloured dots on it.

ProfileLoader mounts PinHint by default and self-gates it on a map <canvas> existing. That gate asks a
sound question ("is there a map here?") and it is the WRONG question for this profile: the canvas is
present and the affordance is absent. A self-gate can only test what it can see from inside the library,
and "this map is a preview, not an interactive surface" is not visible from there — it is the profile's
own fact, which is why the opt-out is the right layer.

Uses the contract that already existed — ProfileLoader.vue:150 reads `config.pinHint !== false` — so this
is one config value from my side, not a library change.

The inert signal STAYS, and is no longer what keeps this pill off this app. It is still right for its own
reasons: a stacked pane should not be in tab order or the accessibility tree, and studio's PinHint change
(2e375530) serves the profiles that DO want the pill. Saying so rather than silently leaving two fixes
for one symptom, because the next reader will otherwise assume one of them is redundant and delete the
wrong one.

check:sfc-parse PASS.
2026-09-27 02:40:26 +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
9b3eb99550 The number's job was wrong, not its value
rega, row 43 case 3 at 835ec1ac: the all-empty guard catches the cascade on
MapLibre and NOT reliably on Mapbox — in ar/mapbox the single event is still
EMPTY and the grid stays at 2,107. Three of four.

THIS IS THE THIRD TIME I HAVE TUNED A NUMBER FOR THIS ONE DEFECT, so the number
does not get another value. 60ms was "long enough for the cascade" and was not.
3 x 60ms was "long enough for the cascade" and is not either. I cannot measure
the cascade from inside the element, and every value I pick is therefore a
measurement of whichever machine I picked it on — which is the lesson already in
COMMON.md, arriving a third time because I applied it to the duration and not to
the DESIGN.

So the number's JOB changes. It was a guess at how long the cascade takes, which
is unknowable from here. It is now a CEILING on how long an empty report waits
before it is believed, which is a choice — and the two fail in opposite
directions. Too short reintroduced the bug three times; too long merely delays a
genuine clear by an amount no reader perceives. The loop now re-reads until the
state is NON-EMPTY, which is the condition that actually matters, and gives up
only at the ceiling.

900ms, chosen to sit under the composite's 1.2s push window so the two cannot
interact, and to be generous rather than tight — the failure mode of generous is
invisible.

Driven through the rule outside the browser: a cascade that fills at 400ms reports
FILLED at 420, one that fills at 800ms reports FILLED at 840, and a genuine clear
reports empty at 960. The old budget gave up at 180.

check:language-code-link's section G already asserted BOUNDEDNESS, and it failed
when I changed the shape — correctly, because that is the invariant it guards. It
now asserts the ceiling instead of the count, and says in the file why the shape
changed while the invariant did not: without SOME bound a genuine clear is
deferred for ever and the grid can never be un-narrowed. Both arms
negative-tested.

Source only. STUDIO: rebuild doxa-research-map.
NEEDS EYES: rega's ar/mapbox run is the one that matters — the other three already
pass, so a green ar/mapbox is the whole result.
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
db2b24c753 A bare-host embed under ?lang=ar now renders Arabic here too
dx-shepherd's ruling: the locale-honouring fallback everywhere, carrying the
Driver's goal (d) — a bare-host embed with ?lang=ar must render Arabic — now
recorded in COMMON.md as the embed contract.

all-people-groups passed its inline EN map as the fallback, which served ENGLISH to
exactly the hosts that cannot install vue-i18n: the seven bundles here that install
none. It now takes the catalogue default, so those readers get their own language
with English still the last resort. The EN map is deleted — with the catalogue
complete it could rescue nothing, and a second copy of the English is only a place
for the two to drift.

THE RULING'S CONDITION WAS MEASURED BEFORE THE SWITCH, not after: all eleven keys
exist in all eleven locales, and ar differs from en on 11 of 11, so the change is
observable rather than cosmetic.

★ AND THE CONDITION IS NOW KEPT TRUE RATHER THAN MEASURED ONCE. A hand census
protects the day it is taken. Section 9 of check:optional-t-equivalence re-derives
it every run, because with no local fallback an uncatalogued key renders as a raw
dotted path to a reader — the exact failure upg100 described, and the reason they
had advised keeping a fallback at all.

★ THAT CENSUS UNDER-COUNTED TWICE BEFORE IT WAS RIGHT, both caught by checking
instead of assuming:
 - literal tr('key') extraction found 6 of 11. Four keys are category ids reached
   only through tr(cat.id) / tr(c.id); the census now extracts the CATS ids too, and
   asserts the dynamic call sites still look the way it assumes.
 - the eleventh, 'pray', was lost to codeOnly: it sits in a template literal whose
   `https://` was read as a line comment, because the scanner had already lost
   template state further up a 1000-line SFC. The census now reads RAW source.
   The asymmetry is the point — over-including a commented-out key makes the check
   STRICTER (a cheap false red); under-including a real one makes it claim
   completeness it does not have. strip-code.mjs carries that rule now, so the next
   author picks raw or stripped for a reason rather than by habit.
2026-09-27 02:17:35 +00:00
Doxa Maps
4642e78d66 The pill over the posters: tell the stacked pane it is not the one being read
My half of PILLRISK (studio's PinHint half is 2e375530). `inert` on every pane except the active one,
which is the signal studio's contract asked for (END-STU-PILL / UPGINERT).

★ PinHint'S OBSERVER WAS NOT BROKEN, AND THAT IS THE WHOLE DIAGNOSIS. The panes are
`position:absolute; inset:0` in one scroll-snap column, so the map pane sits STACKED BEHIND the active
view at full size: 100% of its geometry is inside the viewport while none of it is visible. An
IntersectionObserver measures INTERSECTION, not OCCLUSION, and no tuning can separate those two states
— they are identical to it. The missing information was never geometric. Only the composite knows which
pane the reader chose, so only the composite can say it.

⚠ inert, NOT display:none or v-show, and this constraint decides the approach. Hiding the pane would
tear down a live map: a MapLibre/Mapbox canvas in a zero-size container loses its size and
re-initialises on the way back — the cold-start path rega had to verify separately in row 52. inert
leaves the pane mounted, painted and warm and only declares it non-interactive; unlike aria-hidden it
also removes it from tab order and the a11y tree, so a keyboard user cannot tab into a map they cannot
see.

ALL FOUR PATHS THAT CHANGE THE VIEW ARE COVERED, which took reading rather than guessing:
  - FRESH LOAD — marked in onMounted from column.current(). This is the case the Driver actually hit:
    without it no pane carries the attribute until the reader touches the switcher, so the pill is over
    the posters on ARRIVAL. A signal that only fires on transitions never describes the initial state.
  - THE SWITCHER — marked inside go(), immediately rather than via the scroll callback, because
    scrollTo is SMOOTH and onScroll debounces 120ms: waiting would leave the pill over the posters for
    the length of the animation, a shorter version of the same bug.
  - A DRAG/SCROLL — marked in onScroll, UNCONDITIONALLY rather than inside the existing
    `cur !== view.value` guard. go() has already set view.value by then, so a switcher-driven scroll
    takes that early exit and would skip the marking; a scroll-driven one needs it, and a
    switcher-driven one is idempotent.
  - "Show on map" from the poster wall — routes through go(), so it inherits it.

Plugin-only: upg-100-mfe.vue, no library change. mountEl(paneId) already exposes the container, so the
attribute goes on from my side of the fence.
Correct at EVERY viewport, not just the phone: pane-wrapper renders ONE column with
`scroll-snap-type: y mandatory` and `.pane { height:100% }`, so exactly one pane fills the stage at any
width and there is no v1 side-by-side mode whose second pane would be wrongly marked (pane-wrapper.js:56-59,
:101 — read, not inferred from the phone report).
check:sfc-parse PASS, check:no-free-identifiers PASS. The one-pane-non-inert invariant is proven across
switches including the return trip; the browser assertion is rega's (fresh load: no pill -> Map: pill ->
Prayer Resources: no pill).
2026-09-27 02:07:10 +00:00
Doxa Maps
5a4a0d77e0 Delete the two legend-content RTL rules the library owns — one was dead all along
rega's row 56b at main 4ca2dcf9 measured Simple Research and Prayer Progress under ?lang=ar with NO
override at all: their tree-legend rows compute direction rtl and their counts keep digit order. So the
library legend is RTL on its own and these two rules are mine to remove (dx-shepherd UPGDEL, studio
END-STU-UPG-56B). Rule 1, .upg-detail-panel, stays — that element is mine, not the library's.

★ BUT THE TWO WERE NOT EQUALLY PROVISIONAL, and the difference is the point of this commit:

  '[dir="rtl"] .stl-panel,[dir="rtl"] .stl-panel *{direction:rtl}'   was REDUNDANT.
It matched real elements — .stl-panel appears 69 times in SemanticTreeLegend — but the library reaches
the same state without it, using the :dir(rtl) PSEUDO-CLASS (:645-646), which reads the INHERITED
direction instead of needing a declaration. Two other profiles carrying nothing prove it.

  '...stl-count,...stl-pop{direction:ltr;unicode-bidi:isolate}'      was DEAD CSS, and always was.
The library's count and population cells are `.stl-num` (:1314-1315). `.stl-count` and `.stl-pop` exist
NOWHERE in library/ or any other plugin — git grep finds them only inside my own override. The rule
matched zero elements from the day I wrote it. The digit order rega verified was the library's doing and
never mine, and I had cited this rule as the reason it worked.

★ THE LESSON IS ABOUT EVIDENCE, NOT CSS. This rule was written against a real bidi problem, believed to
have fixed it, and quoted as the fix in a commit message and a card — while matching nothing. A style
rule fails SILENTLY when its selector is wrong: no warning, and the page looks correct if something else
already handles it. Checking that a selector EXISTS is a different act from looking at the rendered page,
and only the first one can tell those two states apart. Both are recorded in the source so nobody
re-adds the dead rule believing it once worked.

check:sfc-parse PASS.
2026-09-27 02:03:54 +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
9df086e5f0 merge main (gospel) into packet/vertical
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-27 01:44:55 +00:00
Doxa Maps
2f260ab700 Merge main (studio landing 6) into packet/vertical
★ THE TWO INSTRUCTIONS I WAS GIVEN CONTRADICTED EACH OTHER ON ONE LINE, so this records how it was
resolved rather than leaving the next reader to guess. UPGHK said delete humanizeKey; UPGW3 said merge
1bd5f109 and "keep regions' version" — and regions' version KEEPS humanizeKey
(`useOptionalT('', { fallback: (k) => humanizeKey(k) })`). Read as structure-vs-fallback they agree:
UPGHK's reasoning is about what a reader SEES ("a real English or translated string beats a title-cased
key segment"), so "keep regions' version" is about the SHAPE of the call. Kept theirs in every respect
but the fallback:
  - useOptionalT('') with the EMPTY namespace, theirs, not my 'messages'/'tabs'. Verified to be a
    behavioural no-op here (every key this code passes is already fully qualified, so qualify() returns
    it unchanged either way) — and '' is the more honest of two identical options, because it says
    "these keys arrive qualified" instead of implying a prefix is being added.
  - their "one behaviour gain" note, which is a real gain I had not documented: the old path was the
    bare useI18n().t, so on a host WITH i18n a missing key rendered the raw dotted key.
  - the fallback deleted, per UPGHK.

AND THE REGISTER KEEPS BOTH WAVES. Their side of the conflict added a wave-3d row for
all-people-groups that mine would have dropped — it stays, with its own reading intact, which is the
OPPOSITE of the call taken for my two files (its inline EN map stays because the file is UNOWNED and an
unowned file is the wrong place to make a product decision by default). Both readings are right for
their own file; that is the point.

⚠ AND I FIXED A FALSE SENTENCE THE GATE WAS ABOUT TO PRINT. With a null third column the register said
"takes the CATALOGUE fallback, as it always did" — true for the two composables that always did, false
for my two, which used humanizeKey until today. That branch already existed to stop exactly this for
gospel's four; my files needed the same treatment, so the "moved" list now names both owners and says
what each moved FROM. A register can be right about its assertion and still teach the next reader
something untrue in the line it prints, and a gate message is the one place a reader has no reason to
doubt.

Verified: check:optional-t-equivalence PASS on all sites, including gospel's unchanged message.
2026-09-27 01:39:28 +00:00
Doxa Maps
4ca2dcf912 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-27 01:35:26 +00:00
Doxa Maps
b97897b7bd The wall and the tab strip translate on a host with no vue-i18n
Both files hand-rolled the same guard — useI18n() in a try/catch, falling back to humanizeKey() — and
both now route through library's useOptionalT (host i18n -> tStatic -> the key). The consolidation is
incidental; deleting the FALLBACK is the product change, and dx-shepherd took it (UPGHK) under the
Driver's standing goal that the production maps be localized.

WHAT humanizeKey ACTUALLY DID, measured against the catalogues rather than reasoned about:
  messages.upg100WallIntro     "Upg100 Wall Intro"  ->  "Hand away the prayer task in clusters of …"
  messages.upg100WallHeading   "Upg100 Wall Heading" -> "My UPG 100 List — Prayer Lists & Posters"
  tabs.selectView              "Select View"        ->  "Select View" (en) / "दृश्य चुनें" (hi)
  tabs.prayerResources         "Prayer Resources"   ->  "Prayer Resources" (en) / "प्रार्थना संसाधन" (hi)

Two different defects, and the second is why this survived so long. On the messages keys it printed
GARBAGE — a title-cased identifier where a whole sentence exists, wrong even in English. On the tabs
keys it printed the RIGHT English by coincidence, because the key was named after the word, so it
looked correct forever while every non-English reader silently got English. A fallback that happens to
agree with en is indistinguishable from a working translation until someone changes locale.

★ AND I HAD THE GUARD'S PURPOSE WRONG, which is the part worth recording. I told regions-plugin to keep
humanizeKey because "my wall/poster keys are not fully catalogued". Enumerated: this is SEVEN keys
total across both files, all string literals, no computed keys, and every one present in all ELEVEN
locales — 0 gaps. The premise was false; I asserted a catalogue state from memory and presented it as a
measurement. humanizeKey never guarded a MISSING KEY at all, it guarded a host that never installed
vue-i18n (useI18n() throws; seven bundles here install none). Those are different failures, and
useOptionalT's own header says so. Correction sent to regions, who has unwound the register row.

Verified: check:sfc-parse PASS, check:no-free-identifiers PASS across 128 SFCs (which is what proves
useOptionalT resolves and no orphaned useI18n reference is left behind), check:i18n-keys-exist PASS.
2026-09-27 01:34:08 +00:00
Doxa Maps
c790b39840 Every shipped link has evidence, and 1,412 of them did not
Three findings, one thread: rule 4 says verify a URL before shipping it, and the
check I wrote for it yesterday was measuring one file.

1. THE TABLES PAGE WAS SHIPPING DEAD FORM. people-group-resources.json — the
"Resources by People Group" tab, which a reader clicks — carried 2,294 URL cells, of
which 1,412 were jesusfilm.org/watch/?languageId=<id>. That form is IGNORED by the
site: a real id and a bogus one render the same generic library page, which is why
the map migrated onto Arclight shareUrls. The migration never reached this table, so
the booleans beside those links were being refreshed while the links themselves went
stale for a year of builds. 1,417 film and 882 scripture URLs re-derived from the
index, which is authoritative for both and whose URLs are the verified ones.

2. TWO ARCLIGHT LANGUAGES, ONE ISO CODE — resolved, with evidence rather than a
ruling. jos was SKIPPED here, and the note said resolving it meant deciding whether
the code or the publisher's name was authoritative, "a data question for someone who
can check it". Checked: media-languages?iso3=jos returns 7138 "Jordanian Sign
Language" and 139080 "Lebanese Sign Language", and 7138's shareUrl redirects to
.../jordanian-sign-language.html while a bogus arc.gt code 404s. So the code stays
authoritative for the join and the NAME breaks the tie the code cannot. The old note
also had a fact wrong — it called Lebanese "a different language (lbs)", but lbs is
LIBYAN Sign Language and Lebanese has no code of its own, so this is two Levantine
varieties sharing one code, not a mis-filing. Three Deaf groups now reach the JESUS
Film in their own sign language through a link that resolves; 0 retired-form links
remain in the index.

3. THE GATE'S SCOPE WAS THE BUG. It walked the pin index alone, which is how item 1
hid from it. It now reads every table the SURFACES read, taken from the surfaces, and
resolves each URL against four records: the verification table, the org contacts, the
SUN pages (proved by content against a catch-all control) and doxa.life's own listing
for prayer pages, which cannot be proved by fetch because pray.doxa.life answers 200
for any slug. Provenance fields are excluded by path — a citation is not an offer.
8,445 links measured yesterday; 12,292 shipped links today, 0 unrecorded, 0 broken,
0 that no status code can prove.

Reading every STAGED file instead reported 6,115 unrecorded and 53 broken, all of
them correct behaviour: API endpoints in source fields, and build caches that still
hold the 53 share URLs the verifier proved dead — which is exactly why the pin
builder drops them. A gate that goes red on correct behaviour gets switched off.
2026-09-27 01:26:50 +00:00
Doxa Maps
3179c24561 Publish which province each group falls in
gospel-internet was running the same point-in-polygon over the same two geometry
files and refusing to write unless its per-province counts matched this table's —
which is good discipline and should not be necessary. The assignment ships now: one
row per placed group, 1,005 of them, with the language code, population and tier.

The two files are reconciled BEFORE either is written — per-province counts and the
assigned total must agree or the build refuses — because they are published
separately (the map fetches only the aggregate, lazily, and has no use for a
thousand rows) and separate files can drift. The failing direction of that guard is
not proven, only the passing one: 46 provinces agree, 1,005 = 1,005.
2026-09-27 01:26:31 +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
1bd5f109d8 C4 wave 3c, my-upg-100-list: two humanizeKey guards become one call
poster-wall.vue and upg-100-mfe.vue both had the same two lines — a humanizeKey
fallback, then `try { t = useI18n().t } catch` — and now call
useOptionalT('', { fallback: (k) => humanizeKey(k) }).

No namespace: callers pass FULL keys here, which is what the bare useI18n().t they
used to get required.

humanizeKey stays as the fallback rather than taking the catalogue default, so a
bare host still reads a title-cased last segment. gospel-plugin chose the
locale-honouring default for their bundle; the same one-word choice is upg100's to
make for theirs.

★ ONE BEHAVIOUR GAIN, stated rather than smuggled: the old i18n path was the raw
useI18n().t, so on a host WITH i18n a missing key rendered the dotted key itself.
It now falls through to humanizeKey — the fallback a bare host already got. Nothing
that resolves today changes; a key that is missing reads better.

Register now covers 14 migrated sites.
2026-09-27 01:16:51 +00:00
Doxa Maps
d882f4ec7c C4 wave 3d, all-people-groups: the {t, te, locale} form, English preserved
This site needed more than `t`, so it is the first to take useOptionalI18n: it used
te() to choose between the catalogue and its inline EN map, and i18n.locale to pick
a number format for toLocaleString.

EN stays the fallback, passed in. It is an inline English map rather than the
catalogue, so routing it through tStatic would change what a bare host renders.
gospel-plugin and exp-shepherd each CHOSE the locale-honouring default for their own
bundles; all-people-groups has no owner to ask — studio agreed to treat it as
unowned — so the conservative reading wins and it renders exactly what it rendered.
An unowned file is the wrong place to make a product decision by default.

Two equivalences worth stating rather than assuming:
 - the old code asked te() first and only then t(); the helper calls t() and falls
   back when it returns the key unchanged. Same outcome either way — present key
   gives the translation, absent key gives EN — and it no longer depends on te()
   being implemented by the host.
 - keys arrive BARE here ('hint', 'pray'), and qualify() prefixes 'allPeopleGroups.'
   exactly as the hand-written `const full = 'allPeopleGroups.' + key` did.

locale needs unwrapping: it is a REF when the host installed i18n and a plain string
when it did not. PosterWall.vue records the same variance, which is why the helper
hands it back as-is rather than pretending one shape.
2026-09-27 01:16:51 +00:00
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