Commit graph

1167 commits

Author SHA1 Message Date
Doxa Maps
9fddcb9901 min-height clamps height, so the pane could not hold the plugin it was pinning
The Driver's least favourite bug: on DX Life's research page a capability-4 plugin
with tabs at the top had a second, element-level scrollbar, and scrolling it scrolled
the TAB HEADER out of view.

doxa-maps-spa pins each fixed-viewport plugin with position:absolute;inset:0;height:
100% and its own comment states the invariant: "fills the pane exactly, so
scrollHeight===clientHeight and overflow:auto paints NO scrollbar." CSS never granted
that. A used height is CLAMPED between min-height and max-height, so a child's own
min-height BEATS the height:100% the shell sets -- it is a floor, not a preference.
Wherever the pane was shorter than that floor the pinned element grew taller than the
pane it was meant to fill, .dms-slot grew with it, and .dms-pane's overflow-y:auto
became a real scroll container wrapping the WHOLE plugin, tab strip included. That is
the second scrollbar: not the plugin's content scroller, the PANE scrolling the plugin
bodily. The invariant was written as a description and was only ever a hope.

The shell now pins with min-height:0. An inline style outranks a child's :host rule,
so that one property restores the invariant for every fixed-viewport plugin at once
without editing any of them. It is NOT added to the scroll-stack branch, where
natural height is the design.

A floor set INSIDE a child's shadow root is out of reach from the shell, and two of
mine had one. research-dashboard's .rd-shell and big-picture's .bp-root now say
min-height:inherit, deferring to :host -- which the gallery pins to 0, and which a
naive page leaves at its own floor, so the collapse guard those floors exist for is
kept rather than deleted. big-picture's 480px MOVED UP to :host rather than being
dropped: :host had no floor there, so inheriting would have quietly resolved to 0
everywhere and thrown away the black-screen guard its own comment calls load-bearing.

check-one-scroll-container asserts both halves, because either alone rots: the shell
still pins with min-height:0, and no plugin grows a NEW floor inside its shadow root.
The second half is what matters later -- a fixed shell plus a plugin that adds
min-height:700px next month is this bug again and nobody would connect it.

The rule is a CONTRADICTION IN ONE DECLARATION, not a large min-height by itself: an
element saying height:100% ("I fill whatever holds me") while refusing to be shorter
than N. My first version flagged any big min-height and caught gospel-resources'
.gd-map, a map deliberately sized height:80svh that scrolls inside its root's own
content scroller -- the one scrollbar a plugin is allowed. Flagging it would have sent
its owner after a non-bug, which is worse than not having the gate.

Negative-tested: removing min-height:0 from the shell, and reverting either .rd-shell
or .bp-root to a constant, each add a failure; restored, only the known one remains.

RED ON PURPOSE, and deliberately NOT in gates:pre. gospel-internet's .gb still carries
min-height:640px and that fix is its owner's. Wiring a red gate into gates:pre would
block the fleet on someone else's file, so the gates:pre entry belongs in the same
landing that fixes .gb -- until then this gate is the work item, afterwards the guard.

Source only: doxa-maps-spa, research-dashboard and big-picture need rebuilding, which
is the build owner's to do.
2026-09-28 15:01:19 +00:00
Doxa Maps
88818589e4 UPG-100: file three India zones into the asia region
zone-regions.json was missing madhya-pradesh, uttarakhand and west-bengal. All
three are in clusters.json AND groupings.json — printable, grouped zones with no
region — so their groups reached a reader only through the vertical legend's
unassigned bucket, which is honest but wrong for a list picker.

★ THE FOUR NAMES IN THE REPORT WERE NOT ZONE IDS, and following them literally
would have created rows that match nothing. The report said guizhou, xinjiang,
bihar, madhya-pradesh; the canonical ids are fully qualified
(asia-china-guizhou-province, asia-india-madhya-pradesh, …), and the bare
`bihar` that exists is an admin-to-list.json KEY whose value IS the zone id.
Only ONE of the four (madhya-pradesh) was a zone at all; the other three are
absent from clusters.json and go to the zone-universe card as a product
question, not a data edit.

★ AND THE REPORTED SET WAS INCOMPLETE. Checking the PROPERTY — which printable
zones lack a region — rather than the four names found uttarakhand and
west-bengal by the identical two-file test. Second time today an enumeration
handed over was short: the wagf fetch was 6 reported / 8 real. regions-plugin's
gap number could never have contained these, because it is admin-to-list VALUES
minus zone-regions KEYS and none of them is an admin-to-list value — their
output now states it is a lower bound.

deaf-old-world and deaf-new-world-asia-pacific stay unfiled by design: the
"deaf": "exempt" umbrella key covers them (dx-shepherd).

Lands ATOMICALLY with regions' f3c050ae, which updates the pinned gap to
[guizhou, xinjiang, bihar] and asserts these three STAY filed — filing is as
revertible as any data edit. Verified the pair before sending: their gate
against this data exits 0.
2026-09-28 15:01:19 +00:00
Doxa Maps
2250ed4fe4 Put the collapse guard on the host, where the rest of the fleet keeps it
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Dropping the floor outright removed a guard. :host here was only display:block,
so an embed that gives the element no height leaves .gb{height:100%} resolving
against an auto-height parent and renders nothing — the collapse the floor was
there to prevent. The standalone leaf hides this, because it sizes the element
with position:fixed and inset:0; an arbitrary embed does not.

The floor belongs on :host, which is where every other plugin keeps it —
research-map at 320px, people-groups at 420px, big-picture at 480px. This plugin
was the one that put it on the inner root instead, and that is the whole bug: on
the host it guards a collapse while a shell that sizes its own pane can override
it from outside, whereas on .gb it could only push the element past its pane and
hand the host a second scrollbar across the tab strip.

The value is carried over unchanged rather than tuned while it moves, though
640px is the largest floor in the fleet and worth a second look on its own.
2026-09-28 14:49:10 +00:00
Doxa Maps
ac21afea21 Rudder's robot voice keeps a sentence whole across a breath, and splits only real run-ons
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
First live use: one sentence came out as two.
- A pause is not a sentence end: the engine finalises after 0.8 s of silence, a breath mid-sentence
  included, and the robot spoke that fragment alone. A final that doesn't end in . ! or ? is now held and
  joined to what's said next; spoken as it is after 2.5 s of silence or on Stop.
- The comma break for run-ons now needs ≥20 words waiting (was 14) and keeps ≥10 in the piece, so ordinary
  long sentences are no longer cut at their comma.
- Checked by running the page's own functions on scripted streams: a sentence paused mid-way → one spoken
  sentence; a 30-word run-on → two clause-sized pieces; a 16-word sentence with a comma → not split; an
  unfinished thought then Stop → still spoken.
2026-09-28 14:45:56 +00:00
Doxa Maps
971162a4b6 Rudder's robot voice keeps up with non-stop talk, skips fillers, and defaults to Male voice 5
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
- Speaks each sentence as soon as it's complete in the stream, not only after a pause. MEASURED: with no
  pause between sentences the model joins them with a comma, not a period, so there may be no sentence end
  for 20+ s; a long stretch (≥14 words) now also breaks at the last comma (≥8 words), and ≥35 words with no
  punctuation speaks the first 25. Short fragments (<3 words) ride along with the next chunk. On 27 s of
  speech with no pause the robot spoke at 8.4 s, 16.5 s and 24.1 s instead of silent until 21.7 s; every
  word is spoken exactly once, in order.
- Cleans what the ROBOT says (the box keeps exactly what was said): drops um/uh/erm/hmm/ah with their
  commas, collapses stuttered repeats ("the the"), tidies spacing, capitalises. Text rules only.
- Default voice: Male voice 5 (speaker 375) — the clearest by ear; the Robot voice itself stays off until
  ticked.
2026-09-28 14:42:09 +00:00
Doxa Maps
45c2ce9bcc Stop the board overflowing a short pane and taking the tabs with it
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The root carried min-height:640px alongside height:100%. In a pane shorter than
that the element is taller than its host, so the host scrolls the whole plugin —
tab strip included — and the reader gets a second scrollbar that hides the tabs
it is meant to reach. That is the scrollbar on DX Life's research page.

The floor becomes min-height:0, which is what every inner grid area already
carries for exactly this reason; the root was the one that did not. Nothing
collapses without it: the standalone leaf gives the element a definite height
with position:fixed and inset:0, and the pixel floor was unique to this plugin
rather than a house pattern.
2026-09-28 14:36:11 +00:00
Doxa Maps
43d290312d Draw the centered-set target from the ring columns
The cross at the middle, each unreached group on the ring its closeness to a
reached neighbour puts it on, arrows pointing at the group that places it. The
opposite claim from the bounded set: there the only fact is which side of a line
a group is on, here there is no line and the whole content is distance.

The rings are read, not recomputed. This plugin could derive them — it has the
columns — but the rule encodes a contested judgement about what "dialect" means,
and that belongs to the data owner. Two implementations would not error, they
would quietly disagree, and the symptom would be two charts on one dashboard
showing different centres. ringNb is the reached row that sets the ring, so the
radius and the arrow come from one field and cannot point at different groups.

Four outcomes, reported as four: placed, already reached and so at the centre,
no relation found, and not in the neighbour table at all. The last is counted at
the join, because a table cannot carry a row it does not have. Folding any of
them together would turn a gap in our knowledge into a claim about a people.

Both panels now share one loader. They read the same file and it is the largest
thing either of them fetches, so a fetch each would double it for nothing.

Measured against the shipped table, standing the province rows in for the
dashboard's: 1009 in, 15 unmatched at the join — the same Indian name variants
the zone join already documents — 990 placed, 4 at the centre, none unplaced,
and every placed group has a named neighbour. The four counts add back to the
input exactly.
2026-09-28 14:36:11 +00:00
Doxa Maps
416cc06eac Rudder speaks and fills the box sentence by sentence; Robot voice in a Settings menu, male by default
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
- Engine: start the speech server with --asr.endpointing.enable=true. End-of-sentence detection is OFF in
  NeMo-Speech.cpp by default, and session.update's endpointing_ms only sets the pause length, so every
  recording came back as ONE final when Stop was pressed (measured: a minute of speech → one 437-character
  final; the robot voice then spoke a minute late). Now each sentence is final ~1 s after it ends: in a
  Firefox run the robot spoke the first sentence 13 s before Stop.
- Settings menu in the full layout too (⚙, closed by default, works at phone width): Language, Microphone
  and Robot voice live in it; Record and the level meter stay visible.
- Robot voice: exactly ten presets, five male then five female, chosen by measured pitch (men ~92–108 Hz,
  women ~206–237 Hz); Male voice 1 is the default. The browser's own voices appear only as a fallback when
  the offline voices are missing, at most ten. A blocked or failed playback is shown instead of silence.
2026-09-28 14:30:56 +00:00
Doxa Maps
a3a7c4b5b6 Rudder: Robot voice — speak each sentence in a synthetic voice, offline, for videos without your real voice
Settings → Robot voice: a checkbox and a voice dropdown. When on, every finished sentence is spoken back
(sentences, not words: TTS needs context for its intonation). speak(text) is the single entry point, so a
future conversational mode can reuse the same voice.

- Providers, both on this computer only: Piper (offline neural TTS run by the engine on the CPU; six
  LibriTTS-R speakers offered as "Offline voice A–F") and the browser's own voices, restricted to
  localService === true (some browsers synthesise "online" voices on a server — never offered).
- backend/rudder-engine/tts.go: POST /api/rudder/tts (text → audio/wav, 1–2000 characters, one synthesis
  at a time), GET /api/rudder/tts/voices, POST /api/rudder/duck. The spoken text is never logged, only
  its length. Piper 2023.11.14-2 (MIT) bundles ONNX Runtime 1.14.1: no telemetry client; a synthesis
  traced with strace made 0 network calls. ~150 MB RAM; a 10.7 s sentence rendered in 2.2 s on CPU.
- While the robot speaks through loudspeakers, the engine feeds the transcriber silence, so it never
  transcribes itself (and that silence is not mistaken for a dead mic). With a Bluetooth headset or
  headphones it keeps listening — pausing would only lose the person's words.
- setup-runtime.sh: Piper and the en_US-libritts_r-medium voice (CC BY 4.0), pinned by SHA-256.
- Verified in Firefox against a dev server: dropdown lists A–F, each finished sentence is synthesised
  (200 audio/wav) between duck on/off, zero requests to any other host.
2026-09-28 14:30:56 +00:00
Doxa Maps
05728da7aa Keep the three ring states apart: placed, the centre, and no relation found
The ring column used to spell "reached" and "no relation found" with the same 0.
The data owner has split them, so this follows: ring 0 is REACHED and belongs at
the centre; ring -1 is unreached with no relation found and is genuinely
unplaced; 1-10 are placed. A fourth case never reaches this module at all — a
group with no row in the source, which the consumer counts at the join.

Treating ring 0 as unusable would have reported the best outcome in the set as
missing data. That is the same mistake as folding an unmatched group into
"unreached", and as letting ring 10 double as a bin for groups with no ring: the
outer category and the absent category keep wearing the same value, and they are
opposite facts.

The existing assertion caught the change rather than being updated after it. Its
fixture used ring 0 as an example of an invalid ring, so the check went red the
moment the meaning moved, which is what a fixture pinned to a real encoding is
for. Reached is now asserted to reach no ring at all.
2026-09-28 14:25:44 +00:00
Doxa Maps
66ffae8435 Follow the ring data to its flattened shape, and prefer the owner's join key
The ring arrived as a separate map and is now plain columns on every row: ring,
ringNb and rop3. The layout module did not need to change — it takes a ring and a
neighbour id and does not care where they were read — but its header described
the old shape, and a header that describes a shape nobody ships is worse than no
header. The note records the change, because the difference matters to anyone
decoding the table by hand.

ringNb is the reached row that SET the ring, so the arrow and the radius come
from one field by construction and the older links ordering cannot disagree
with them.

The bounded set now tries rop3 + iso3 before falling back to name and country.
rop3 is the data owner's key and the reliable one, but it reaches these rows only
if the API document declares it — normalizeRows copies whatever meta.columns
lists rather than a fixed set — and that cannot be confirmed away from the live
endpoint. The fallback was measured at 994 of 1009 against the province bridge.
Either way an unmatched row stays UNKNOWN and is counted where it can be seen, so
a key that turns out to be absent surfaces as a visible unknown count rather than
as a chart full of groups that merely failed to join.
2026-09-28 14:25:44 +00:00
Doxa Maps
afc343a112 Lay out the centered-set target, without re-deriving its rings
The opposite claim from the bounded set: there the only fact is which side of a
line a group is on, here there is no line and the whole content is distance. The
two must not look alike.

Ring membership is data, not a derivation. It is computed by the upg-neighbor
build and written into near-neighbors.json, and this module reads it rather than
working it out again. That is deliberate: the ring rule encodes a contested
judgement — whether "dialect" means an ISO code or an Ethnologue-strict level —
which belongs to the data owner. A second implementation here would be a second
answer, and the first symptom would be two charts quietly disagreeing.

The arrow and the radius are taken from the same field for the same reason. The
ring records the reached group that set it; the older links list records the
nearest neighbour on the E-scale ordering, sorted by believers and distance.
Those can differ, and reading the radius from one and the arrow from the other
draws a group on ring 3 with an arrow pointing at its ring-8 neighbour, with
nothing on screen to say so.

A group with no ring is unplaced and keeps a reason, never defaulted onto the
outer ring: ring 10 is a real answer, so it must not double as a bin for missing
data. A group with no recorded neighbour gets no arrow rather than a guessed one.

The check covers the properties where a wrong answer still looks like a chart —
a dot on the boundary, an unknown folded into unreached, an arrow overshooting
the cross it points at. These are pure functions, so their inputs are their
domain and the assertions are about the modules themselves. The binding to the
data file is not checked here; it belongs where the data is read.
2026-09-28 14:25:44 +00:00
Doxa Maps
3f73cf6d9a Add the bounded set as a centre panel, and stop unknown panels becoming the map
The Driver's order for the centre is Language tree, Bounded set, Centered set,
then Map. Tree and Bounded set are here; the centered-set target is not, because
it needs the ring data that is still on another branch. A tab whose panel cannot
be drawn is worse than a tab that is not there yet, so it arrives with its data
rather than ahead of it.

setCentre had to change first, and it was hiding a trap. It read

    centre.value = mode === 'tree' ? 'tree' : 'map'

so every id that was not 'tree' resolved to the map. Adding a tab to the strip
without touching the setter would have drawn the map under the new tab's label:
no error, no empty state, just the wrong panel wearing the right name. The panels
are now a list, and an unknown id warns once and falls back to the first — the
same way an unknown people-group list is already handled.

The chart claims one thing, which side of the line a group is on, so position
carries no other meaning: no gradient, no ranking, no distance. The layout is a
pure module so it can be tested without mounting anything, and it is SVG rather
than the map engine because there is no zoom, no camera and no labels here; if it
is ever pointed at the full table the answer is to aggregate, not to reach for
the GPU.

Reached-ness is not in these rows — they describe internet and language access —
so it joins near-neighbors on name and country. A group that does not join is
reported as unknown rather than drawn outside the circle. "We could not match it"
and "it is unreached" are different facts and only one of them is about the
group. If the table itself fails to load the panel says so, because a bounded set
drawn from nothing is a circle with everything outside it, which reads as a claim
rather than an error.
2026-09-28 14:25:44 +00:00
Doxa Maps
2e62192c27 Reach the non-India zones through their selectors, not a second bridge
The province bridge only reaches India and China, because that is all
gospel-by-province-groups holds. Every other UPG-100 zone already has a path:
each zone in clusters.json carries a selector, and selector-evaluator.js turns a
selector plus people-group records into the slugs in that zone. So this is an
adapter, not a new join.

Which path a zone needs is a property of the zone rather than a fallback chain:
a pg_slugs selector IS the membership and is matched on slug, the evaluator
rejecting that type on purpose; country selectors go through evaluate(); the
India and China zones use the province fold.

Records must be passed FLAT. The evaluator reads country_code as a string, while
the file header describes the source shape as { value, label } — that is the
shape before normalize.js flattens it. Passing the object form matches nothing
and returns an empty set for every zone, which reads as "this zone has no
groups" rather than as a mistake. It cost a run here, so the check pins the
empty-set arm as well as the working one.

Zones whose selector needs a field the records do not carry return an empty set;
zones the evaluator rejects are reported separately. An empty zone and an
unanswerable zone are different facts and a legend that draws them the same way
is lying about one of them.
2026-09-28 14:25:44 +00:00
Doxa Maps
d6253b34d0 Join a people group to its UPG-100 zone, with admin names folded
The UPG-100 lists are defined by admin region and the people-group tables are
keyed by group, so the join runs through the province assignment. The two files
disagree on spelling: the zone table writes `gujarat`, the province table writes
`Gujarāt`, and the same for Karnātaka, Tamil Nādu, Uttarākhand, Jammu and
Kashmīr and Haryāna.

Matching on toLowerCase alone joins 514 of 1009 rows across 9 zones. Folding the
combining marks joins 1009 of 1009 across 12 zones.

The 50.9% is the number worth naming: a legend built on it would have shown half
the groups and three fewer zones, and nothing on screen would have said so —
every zone still looks populated, only smaller. The fold lives at the lookup
rather than as extra spellings in the zone table, so a future source spelling
cannot reopen it.
2026-09-28 14:25:44 +00:00
Doxa Maps
6c346e603a UPG Neighbor: an unplaced group reads ring -1, so it can never pass for a reached one 2026-09-28 14:21:34 +00:00
Doxa Maps
266ddef381 UPG Neighbor: the ring is a column on every row, with rop3 so other plugins can join their groups to it 2026-09-28 14:21:34 +00:00
Doxa Maps
d679430a5c UPG Neighbor: every unreached group's centered-set ring and the reached group that sets it 2026-09-28 14:21:34 +00:00
Doxa Maps
15e0e02408 UPG-100: bind the last three strings (studio L58 keys)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Completes the i18n card. L58 landed the four keys the first spec was missing.

poster.region_india / region_other — all SEVEN poster-wall headings now
translate. The first key spec had five because check:hardcoded-english reported
five: its header states it cannot see SINGLE-WORD strings, and 'India' and
'Other' are single words. That is the gate's own documented blind spot, not an
oversight in the spec, and it is why a green run is not "no English".

messages.bandInRange / bandOutOfRange — the band ternary was
`'✓ 50–100 band' : 'band 50–100'`. TWO keys rather than a ternary over one,
because these are two STATES of one label: a locale may not phrase "in range"
and "out of range" as a tick plus the same words. The ✓ lives in the catalogue
string so a locale can drop it.

check:hardcoded-english on my two files now reports only REGION_OF's values,
which are grouping IDENTITY by design — regionLabel() translates them at render
while the map keeps English keys, so the grouping cannot re-key per locale.
2026-09-28 14:17:14 +00:00
Doxa Maps
88fe7509d0 The packet was praying over the numbers the page loaded with, not the live ones
rega RQ3(b): the packet's "Has Prayer" selection showed 50 of 997 where the legend
row said 699. The legend row is right and the packet was wrong.

A pin's prayer count arrives TWICE. The GeoJSON property is whatever was true when
the page loaded; from then on polling writes the current count with setFeatureState,
and prayer-progress.js:94 colours from ['feature-state','peoplePraying'] once polling
is on. So the lit pins a reader counts are feature-state, and pin-selection.js had no
'feature-state' case at all -- it resolved to undefined, to-number defaulted it to 0,
and the comparison selected only the pins whose FROZEN property happened to be above
zero. 50 of them. The other 649 people groups had been prayed for and the packet left
them out, which is the one thing a prayer packet must not get wrong.

resolveExpr and matchesExpression now take the state alongside the properties, and
selectedPinIds reads it per pin off the MAP rather than off the feature -- the source
still holds the GeoJSON it loaded, setFeatureState never touches it. The source is
added with promoteId:'uniqueId', so the id feature-state is keyed by is the same
props[idKey] the selection already uses.

Degrading is deliberate in both directions: before the first poll there is no state
and property expressions still select, so an early packet is thinner and never empty;
getFeatureState throws on an unknown source, so it is guarded and a bad source loses
the selection rather than aborting the print.

ALSO, rega RQ3(a), a candidate and not yet a confirmed cause. prewarmRegionPaint
wrapped any non-feature-reading paint value in a case to make it data-driven,
including a zoom interpolate -- I argued for that explicitly. useMapLayers.js:319
already held the rule and I had not read it: zoom interpolation is kept by putting the
case in the interpolate OUTPUTS, and "the reverse (zoom nested inside a 'case') is what
breaks the layer, so don't do that." That reverse is the shape Mapbox classifies as
composite before reaching for an interpolation factor the expression cannot supply,
which is the class of the interpolationFactor page error. The wrap now skips zoom
expressions too. A missed pre-warm costs a frame; an illegal paint costs the layer.
None of the three properties it touches is a zoom interpolate at boot, so this is not
proof -- it removes the only writer that can build that shape from a runtime value.

check-pin-selection asserts the live count wins over the stale property, that the
stale property alone selects nothing, that a map with no feature-state still selects
on properties, and that a throwing getFeatureState degrades. Negative-tested:
removing the case, not reading the state, and dropping the throw-guard each turn it
red; restored, green.
2026-09-28 13:56:22 +00:00
Doxa Maps
ed40117b9a UPG-100: six zones printed an EMPTY packet — the fetch lacked its selector fields
MEASURED, not inferred. The packet fetch requested
slug,name,country_code,population,religion,image_url(+language,gsec) while SIX
of the 27 printable zones select on wagf fields it never asked for:

  west-africa, east-africa, central-africa   wagf_block
  europe                                     wagf_region
  southern-africa-islands                    union of two wagf_block
  oceania-indonesia                          union of wagf_region + country_code

Proved with the real evaluator: a wagf_block selector returns 0 members on
packet rows and 1 on map rows. So those packets resolved to nobody and printed
empty — while the SAME selectors resolved correctly on the map, whose fetch
(:1988) has carried wagf_region,wagf_block all along. One file, two fetches,
two different field sets, and only one of them agreed with the data.

★ oceania-indonesia IS THE DANGEROUS ONE. Its union still matched country_code
IDN, so it did not print empty — it printed a PLAUSIBLE packet silently missing
every Oceania group. An empty packet is noticed on the first page; a packet
that is quietly 40% short is not. Same shape as the 514-of-1009 join gospel
measured today: a result that returns HALF looks like a smaller dataset, while
one that returns NOTHING gets caught immediately.

Verified after the fix: west-africa resolves its wagf_block row, and
oceania-indonesia now finds BOTH branches rather than only IDN.

Found while answering dx-shepherd's question about 9 selectorless zones. Those
9 turned out not to be in clusters.json at all — they are grouping/colour keys
in zone-regions.json and groupings.json, so no packet can be asked to print
them. All 27 zones that ARE printable have selectors; the defect was never a
missing selector, it was a missing FIELD.
2026-09-28 13:56:21 +00:00
Doxa Maps
9d13bef3fb Two labels resolved once and stayed in the boot language
upg100 measured both, and both were created by today's i18n fixes — mine and the batch's. A computed
only re-runs when a REACTIVE dependency changes; tStatic reads a plain window global. So these resolved
at first render and never moved again, while every label around them followed the switch.

  · big-picture.vue — heading depended on config.value.title, which does not change when the host
    changes language. It now reads hostLang, which is reactive and already drives setRuntimeLocale, and
    PASSES it to tStatic so the right catalogue is read. useI18n is not available here: this bundle
    installs no i18n, which is why the whole element reads through tStatic.
  · DashboardResearchFilters.vue — the live-search chip, same shape. research-dashboard DOES install
    i18n (index.js:83), so this takes vue-i18n's reactive locale ref, reads it to re-run and passes it
    for the lookup.

Both land on check-frozen-labels' documented clean shape — an explicit locale argument — rather than
hiding tStatic behind a helper that would satisfy the gate while leaving the label frozen.

The heading also moved BELOW hostLang, so the dependency it now relies on is declared above it rather
than four hundred lines later.

Verified: check:sfc-parse PASS; check-frozen-labels (studio/landing58) reports exactly these two sites
today, and the shape they move to is its own "pinned" self-test case.
2026-09-28 13:55:52 +00:00
Doxa Maps
52a1cf6809 UPG-100: the packet fetch carries language + GSEC, and region ids fold diacritics
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
TWO PREPARATIONS FOR THE GENERATED PROFILE DESCRIPTION (Driver brief A1.5 col 2).

1. THE FETCH. Both packet fetch sites (:1363, :1588) requested
slug,name,country_code,population,religion,image_url. describeGroup needs the
language and a reachedness level, so primary_language and imb_gsec are added.
Both are existing API fields — poster-wall:171 already fetches primary_language
and sources.json:43 lists imb_gsec — so this adds no new API surface. Verified
they survive normalizeRecords, which is non-destructive ({...raw}).

GSEC and not EvangelicalLevel because GSEC is a NUMBER. EvangelicalLevel is
English prose from the API ("2% or Greater but Less than 5%"), so interpolating
it would leave every non-English packet half-English in its widest column. sr
measured both across 12,379 rows; I re-derived the GSEC distribution
independently (266/6573/269/199/1839/1428/1805) and confirmed that no GSEC
description mentions 0.1%, which is why "frontier" cannot be said from this
field and the wording stops at the 2% line the funnel page already states.

2. REGION IDS FOLD DIACRITICS. legend-tree.js built an id with
.toLowerCase().replace(/[^a-z0-9]+/g,'-'), which DELETES a combining mark rather
than folding it: Gujarāt became 'region-gujar-t', a different id from
'region-gujarat'. Nothing is broken today — the labels are regionFor()'s fixed
ASCII set and nothing joins on the id — but the brief's whole point is that a
fork brings its own set and names its own regions, and the first accented label
would split one region in two with no error and no empty state.

Same class gospel measured joining admin-to-list.json: a naive fold matched 514
of 1009 rows and lost THREE whole zones while every surviving zone still looked
populated. A join that returns half is more dangerous than one that returns
nothing. Fixed at the fold, not by enumerating the spellings we know.

Verified behaviour-preserving: Africa -> region-africa unchanged, and Gujarāt
and Gujarat now produce the same id.
2026-09-28 13:30:43 +00:00
Doxa Maps
55cc694fdd Pplr Data Maps: tab and legend labels follow a language change instead of keeping the one they started with 2026-09-28 13:01:16 +00:00
Doxa Maps
439bea6754 Use the keys that landed for three strings the map still said in English
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
L56 published the catalogue keys these three sites were waiting for, so the template now asks for
them instead of carrying the English:

  the gap list's "related:" label            -> gospel.gapList.relatedLabel
  the legend's "Show all N" clear button     -> gospel.filter.showAll, with the count as a placeholder
  the detail panel's "Nothing in X itself"   -> gospel.related.nothingInItself

Each was chosen because the swap costs no markup: the <em> and <b> wrap the whole phrase, so they
stay outside the call and the emphasis survives. The sites where a <b> sits INSIDE the sentence --
"Showing only <b>…</b>." above all -- are deliberately not in this commit, because the key is one
whole sentence and using it would silently drop the emphasis that distinguishes the filter's value
from its label. That needs a markup-preserving form, not a quiet visual regression on a production
map.

Measured, not assumed: hardcoded-english for this plugin 147 -> 144, and every one of the 72 keys
the leaf now references resolves in the catalogue. That last check caught a mistake in this very
change -- a fallback I wrote as t('gospel.state.thisVariety'), which does not exist and would have
rendered the key name to a reader. It is back to its literal until a key exists for it.

i18n parity and never-translated both pass; gospel's own render gate passes in ar, zh, ru and hi.
Rebuilt this bundle only, then fix:sri; check:sri is green at 64 leaves.
2026-09-28 12:57:56 +00:00
Doxa Maps
8a6e5543c0 Localise the region link's screen-reader label
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The new hardcoded-English gate found this in its first run over the production
plugins: an aria-label built by concatenation, so a screen-reader user on a
non-English locale heard "Open the" and "dashboard" in English around a region
name in their own language.

It uses the key the regions list already uses for the same sentence, which is
translated in all eleven locales, so the label now reads as one sentence rather
than two languages spliced together.

Found by the gate in the plugin that wrote it, which is the argument for making
it shared rather than private.
2026-09-28 12:51:28 +00:00
Doxa Maps
eb8d1f699e UPG-100: bind the user-visible strings to the catalogue (studio L56 keys)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Ten strings that rendered to a reader in English whatever locale they chose.
Found by check:hardcoded-english; keys landed by studio L56.

STATUS OVERLAY (renders in .upg-status): noMapboxToken, loadingBoundaries (one
key, both call sites), fetchingUupgs {n}, mapStatusCounts {polys}{zones}{total}
{pins}. The "⚠ " prefix stays in the template, per the key spec. :2084 right
beside these was ALREADY t('messages.engineNotLoaded') — this makes the
neighbours consistent with it rather than introducing a new pattern.

MAP ERROR: noMapboxTokenDetail. It renders immediately after a translated
<strong>, so English sat literally adjacent to localized text.

LEGEND TABS: bound, not added — tabs.upg100Region / upg100Zone and
legend.tabInfo.* already existed in all 11 with exactly this text. ★ LEGEND_TABS
becomes a COMPUTED: t() is destructured at :391, BELOW the old const, so a plain
array could not call it; and even hoisted it would freeze the labels at the
locale active when the module evaluated. The host switches language at runtime,
so the labels have to re-evaluate on access. Both consumers are in the template,
where a ref auto-unwraps, so the call sites are unchanged.

THE PLURAL, deliberately a LABEL form: territor{{ n === 1 ? 'y' : 'ies' }} ->
t('messages.territoriesSelected', { count }). I had proposed a vue-i18n plural
key; studio was right that it would be worse, and I tested it — with no
pluralRules registered, v9 applies an English-shaped choice (count===1 ? 0 : 1)
to EVERY locale, so ru with 3 forms supplied picks wrong at 1, 2 and 21.
Confidently wrong grammar that LOOKS translated is worse than honest English.

POSTER-WALL headings translate at render; REGION_OF keeps its English values as
grouping identity (studio's split). ⚠ TWO OF THE SEVEN HEADINGS HAVE NO KEY:
'India' and 'Other'. The gate reported five because its header states it cannot
see single-word strings — this is that documented blind spot materialising, not
an oversight in the spec. They fall through to English text (never a raw key)
until poster.region_india / region_other exist; raised with studio.

Bundle rebuilt + fix:sri. The manifest churn is the same old->new hash for this
bundle; doxa-maps-spa's manifest changes only because it references it.
2026-09-28 12:48:42 +00:00
Doxa Maps
050f031e56 Prayer Progress: note the accessibility work due before the help modal or a second tab is switched on 2026-09-28 12:41:10 +00:00
Doxa Maps
cb8b2857a0 Cities with Internet: the AI-notice dismiss button meets the 24px target minimum 2026-09-28 12:41:10 +00:00
Doxa Maps
5dc161daa9 Cities with Internet: its load and engine errors speak the reader's language
The engine-missing message read the shared messages.engineMissing through this file's
connectivity-prefixed t(), so it always missed and showed English in every locale.
Shared keys are now read qualified with tStatic: engineMissing, engineNotLoaded, and
couldNotLoadNamed for a data table that fails to load (naming the tab in the reader's
language, not 'table not found (404)'). Four unused English tab labels are removed.
2026-09-28 12:41:10 +00:00
Doxa Maps
c9905dd9db UPG-100 gate: query data-control, drop the i18n shim and the last title matches
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
studio landed data-control on the map controls (L54 3ed7d8d0c), so the gate can
name a control by untranslatable identity instead of its displayed text. Each
wrapper writes data-control="zoom-in|zoom-out|fullscreen|theme|…" onto
MapControlButton; it is not a declared prop, so it falls through onto the
rendered <button>.

REMOVES A CROSS-OWNERSHIP DEPENDENCY. Check (4) resolved its expected strings out
of library/i18n/locales/en/buttons.json — locale-correct, but it made a check in
MY gate depend on a key name in STUDIO's catalogue, so a rename there would have
failed a test here. data-control removes that coupling entirely.

THE THEME BUTTON GAINS THE MOST, and it had two more sites I had not fixed:
:488 (check 5's toggle) and :1502 (17b cold-load) both matched
[title^="Switch to"]. That title is LOCALIZED and DYNAMIC ("Switch to dark" /
"Switch to light"), so no i18n key ever resolved it and a prefix match on English
prose was the only option. All three sites now use data-control="theme", and the
`theme === 1` assertion finally means what it says.

Zero English-literal attribute selectors remain in the gate; every control query
is now a class or data-control. The one surviving `[title="…"]` string is inside
a comment recording why the pattern was abandoned.
2026-09-28 12:36:10 +00:00
Doxa Maps
4dd4d7328e UPG-100: delete the orphaned copy-to-clipboard path and its red gate
fc93847bb ("Batch selection builds a packet, instead of recolouring regions")
replaced copy-to-clipboard with packet-building and left four pieces behind: the
button's handler, its execCommand fallback, the window global they wrote, and the
gate check that read it. This removes all four.

copySelection had exactly ONE reference in the file — its own definition — and
legacyCopy was called only from it, so both were unreachable. The minifier was
already dropping them: __UPG_LAST_CLIPBOARD__, which copySelection set
unconditionally, appears 0 times in the built bundle.

THE GATE HALF MATTERED MORE. visual-gate check (15) asserted the Copy payload,
guarding on .upg-hud-btn.copy existing — a button that no longer exists. But the
guard skipped only the MEASUREMENT: copyOk was initialised false and check() ran
unconditionally, so it reported FAIL ("no .upg-hud-btn.copy in HUD") on every run
since the button went. A permanently red assertion for a deliberately deleted
feature teaches readers to ignore red, which is worse than no check at all.

formatClipboard SURVIVES — 3 references, and one is live: __UPG_SELECT__
.clipboardText, the headless probe surface the gate still uses. Deleting the
"clipboard code" wholesale would have broken it.

Also corrects the D-8 comment, which still named "Copy + Clear" as the HUD
buttons; they are Prayer List / Poster Map / Clear.

VERIFIED BY THE BUILD NOT MOVING: rebuilt after deleting 43 lines of source and
the bundle is byte-identical to its parent (sha 3dda630d5cf5e647, 992356 bytes).
That is the test of the claim — if the output had changed, the code was not dead.
2026-09-28 12:36:10 +00:00
Doxa Maps
02230d3eb8 UPG-100: the lazy-panel comment named the wrong API and a moved line
Two stale claims in one comment. It said `defineAsyncComponent` while the code
below it calls `retryableAsyncComponent` (:56), and it pointed at
research-map.vue:152 for the pattern — which is now a tStatic import; the real
lazy-load moved to :193.

Not cosmetic. Auditing card BUG-LAZY-PLUGIN-DEATH-ON-A-SINGLE-FAILED-IMPORT, the
census of components bypassing the retry wrapper is a grep for raw
`defineAsyncComponent`, and this comment made my own file read as a bypass. The
one real bypass (pplr-data-maps.vue:46, six lazy views) is exp's and routed. A
comment that names the wrong API costs the next reader the same detour.

Says why the wrapper is used rather than only naming it: Vue caches a REJECTED
loader promise, so one dropped chunk request kills the panel for the life of the
page. That is the defect the card exists for, and the reason not to "simplify"
this back to the raw API.

Comment-only, so the build is byte-identical — the strip-shadow-css-comments
pass drops it. Rebuilt and re-ran fix:sri anyway to prove it: 124 hashes already
agreed, 0 rewritten, the committed .js unchanged.
2026-09-28 12:36:10 +00:00
Doxa Maps
7a0305ee94 The research dashboard's table and chart chrome is translated
Five bound English strings no locale could reach, now catalogue keys in all eleven locales:
the column sort tooltip (dashboard.sort_by_column_hint, {column}), the full-screen button's
title and label (fs_*), the export tooltip (export_*_hint, a label form with {n} so no plural
rule is needed), and the donut's accessible name (aria.donutChart, {title}), which read a
translated title followed by the English word "donut". English stays the fallback argument.
2026-09-28 12:04:36 +00:00
Doxa Maps
8adafe952b Two controls announced themselves as buttons and could not be pressed
Accessibility audit of my production surfaces, headless from source, scan negative-tested before I
trusted its zeros. Four hits, two real.

  · THE PROJECTION PILL (DashboardMapPanel) carries role="button" and tabindex="0" and had no key
    handler. A keyboard reader tabs to it, hears "button", presses Enter, and nothing happens — the
    role and the tabindex promise an activation that was never wired (WCAG 2.1.1). It is a span rather
    than a button because the pill sits inside the map chrome, so the promise has to be kept by hand.
    I touched this element earlier today to give it a translated accessible name and did not notice it
    could not be operated; naming a control and making it usable are separate jobs.

  · THE SORTABLE COLUMN HEADERS (DashboardDataTable) carry role="columnheader" and aria-sort and were
    click-only. Screen-reader users were told the sort state of a control they could not reach. Now
    focusable with Enter and Space, and shift+Enter still adds a second column, because onSort reads
    shiftKey off the event it is handed and a KeyboardEvent carries it the same way.

.prevent on space so the page does not scroll under the press.

TWO HITS WERE FALSE POSITIVES and are recorded so the next sweep does not re-raise them: the facet
rail's .fr-body and the chip bar's .rd-chips both take @click on a container, which looks like a
click-handler on a non-focusable div. They are DELEGATION: the facet rows carry their own
:tabindex/:role and the container has @keydown for parity, and every chip is a real <button> with an
aria-label. The control is the row and the chip, not the box around them.

Verified: check:sfc-parse PASS, and the scan reports 0 on both files afterwards. Not landed — the quiet
window is on; this waits for studio.
2026-09-28 12:04:35 +00:00
Doxa Maps
e0092a4317 Nine raw region writes were undoing the pre-warm the moment a highlight cleared
Found by cross-reading exp-shepherd's country-posters port, not by anything failing here. Their
73438b43b routes all eighteen of that map's region clears through a regionIdle() wrap. Mine still wrote
NINE raw constants — fill-opacity 0.20 and 0.7, line-color rgba(60,60,80,0.35), line-width 0.6 — across
clearCountryHighlight, the deselect path and the dim path.

A raw value makes the property CONSTANT again. So the first time a reader cleared a highlight, or ran a
country search and dismissed it, the three region paints stopped being data-driven and the next
highlight recompiled the shader variant that b5894ec3 exists to have compiled at layer-add. rega
measured 0 ms on the row click and that measurement was honest — it was taken on a fresh tab, where no
clear had run yet. The path nobody exercised is the one that pays.

Every region paint write now goes through regionIdle(v) — ['case', ['has','iso_3166_1_alpha_3'], v, v],
both branches the value it already had, so nothing looks different and the property stays data-driven.
Nine sites, no behaviour change, and the pre-warm now survives a clear.

★ DEFINED ABOVE ITS FIRST USE, deliberately. The first caller sits four hundred lines above where the
other idle constants live, and a const read before its declaration line has executed is the TDZ this
file has already shipped twice this week (_lastFitKey, then _lastPinOpacityKey). Its callers only run
after mount, so it would probably have been fine — "probably fine" is what the gate exists to stop me
saying. check:setup-order PASS.

Verified: check:sfc-parse and check:setup-order PASS. Needs eyes: rega — clear a region highlight, then
click another region row, and the compile should still be 0 ms. That is the case no run has covered.
2026-09-28 11:42:54 +00:00
Doxa Maps
20de4c457b Translate the three strings that were built from fragments or hardcoded
Regions: the country and region counts under the dashboard title were English
template literals; they are now label keys ('Countries: %count%') in all eleven
locales, which also sidesteps plural forms.
Gospel: the Zume chip joined 'in' + a language name, which reads backwards in
Hindi; it is now one phrase key with a {language} placeholder.
UPG-100: the Batch Select button title comes from legend.title.batchSelect.
2026-09-28 11:24:16 +00:00
Doxa Maps
01b142eb27 PPLR maps: a failed view import no longer kills that tab until a reload
The six lazy views were built with raw defineAsyncComponent, which caches a rejected
loader, so one network blip left that tab dead until a full reload. They now use the
library's retryableAsyncComponent, which retries the import.
2026-09-28 11:13:33 +00:00
Doxa Maps
db7cc16907 Country posters: clearing a highlight keeps the region paints data-driven
The load-time pre-warm made the regions fill and border paints data-driven, but every
clear (clearCountryHighlight, clearAllHighlights and the reset branches) wrote the
plain constants back. All 18 writes now use regionIdle(v), the same identical-branch
case the pre-warm uses, so the property never returns to its constant variant and a
second highlight cannot re-pay a variant switch. Nothing changes on screen.
2026-09-28 10:59:19 +00:00
Doxa Maps
ad822c0f31 UPG-100 gate: find controls by class, assert the localized title separately
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The (G-rail) probe queried `.upg-rail-batch[title="Batch Select"]`. Once the
title becomes a bound `:title="BATCH_SELECT_TITLE"` (studio L53) that selector
matches under `en` and nowhere else, and the gate reports "button not in the
toolbar" — a PRODUCT failure for what is really a broken query. The class is
already proven unique by the railBtns === 1 assertion in the same probe, so it
is the identity; the title is still checked, as the property that actually
matters for a11y: NON-EMPTY, which holds in every locale. That also catches a
regression the equality missed — a :title binding resolving to '' or undefined.

The (4) map-controls check had the same coupling, three times. Those titles are
ALREADY bound (ZoomInButton.vue: :title="t('buttons.zoomIn')"), but the page is
pinned to en-US at newPage({ locale }) — L4/LESSON-03 — so they are not failing
today. The expected strings now come from the same i18n source the component
reads (library/i18n/locales/en/buttons.json) rather than being hardcoded, so the
gate survives being run in another locale. Verified the three keys resolve to
the literals they replace, so this asserts exactly what it asserted before.

The theme button cannot use that shim: its title is DYNAMIC ("Switch to dark" /
"Switch to light"), so there is no single key to resolve, and it stays a prefix
match on English prose. Its assertion moves from `> 0` to `=== 1` (studio's
call): that does not remove the coupling, but it stops 0 and 2 both collapsing
into "not > 0", and the failure line already prints the count.

Those four .mcb buttons share one class with no stable per-control hook, and
aria-label mirrors the localized title, so the title is the only identity
available to a query. The durable fix is data-control="zoom-in|zoom-out|
fullscreen|theme|location" on library/components/map-controls/MapControlButton
— studio's under 5B. They are riding it on the next landing that rebuilds the
17 consumers anyway rather than spending a rebuild on it alone; this shim comes
out when that lands.
2026-09-28 10:53:37 +00:00
Doxa Maps
824298ff1b Country posters: a region highlight has no holes, and pin dimming writes once
A region legend row painted the country polygons twice: from the region dataset (every
member country, via selectRegion -> applyDimFilter) and from the legend's own list,
which holds only countries WITH PINS, so whichever ran last left holes. region: rows
now leave the polygons to the dataset; block: and country: rows still paint from the
legend. The 18 raw pin-opacity writes go through one read-before-write setPinOpacity,
so a family click no longer rewrites the same value 4+ times, and the undimmed value is
data-driven from load, so the first dim does not stop for a shader compile.
2026-09-28 10:47:30 +00:00
Doxa Maps
ab65c370e9 Gospel x Internet mounts its geographic map on first show, not on open (the board opens on the tree) 2026-09-28 10:40:03 +00:00
Doxa Maps
39b3285f55 The composed grid's locale depends on a spelling neither file mentions
Auditing Big Picture for goal (d) I read this as a live defect: big-picture threads the host's locale into
each child's profile-config as `lang`, while the grid's prop is `languageCode`. On a profile-config embed
that would leave a composed grid in the wrong language — exactly the failure 8b40f6c58 was written to fix.

It is not a defect. people-groups-grid.vue accepts three spellings — languageCode || locale || lang — and
`lang` is the third. Two greps settled it, and the near-miss is the point: nothing at either end said so.

Both ends now do. The writer records that the spelling is load-bearing and which fallback it lands on; the
reader records that `lang` has a caller and why that caller cannot reach it any other way — getRuntimeLocale
is module state, and a separately-built child bundle gets its own copy, so threading the config is the only
channel big-picture has.

★ WHY A COMMENT AND NOT A GATE. The failure is invisible in the obvious place: ?lang= embeds keep working,
because the grid falls through to localeFromUrl(). Only a host passing profile-config sees it, which is the
smaller and quieter half of the traffic. A gate for that would have to stand up a composed embed with a
profile-config locale and read rendered text — which is rega's browser run, not a headless check — so the
honest guard is to make the coupling legible to whoever next tidies either list.

No behaviour change. Verified: check:sfc-parse PASS.
2026-09-28 10:26:43 +00:00
Doxa Maps
f5a9ee40b7 The packet cover said "50 of 214" in every language, because I wrote it that way today
Found auditing my own map for goal (d), in code I added this morning. The cover note is the one string on
that document a reader in Arabic or Hindi cannot read, on a document whose entire purpose is that they
can pray from it in their own language.

The slash needs no translation and is not a guess: zh's professional translation of legend.progress.short
renders exactly this relationship as "{covered} / {total}". The WORD is available per locale in that same
key — ar "من", ru "из", fr "sur" — but it carries "· {pct}%" with it, so reusing it would mean either a
wrong suffix on the cover or slicing a translated string on a separator I cannot guarantee survives
translation. Ten invented translations for one connector is the worse trade.

Wrapped in U+2066/U+2069 isolates, which is the convention the Arabic catalogue already uses five times
for numeric ranges ("⁦30–59%⁩"). Without them the pair reorders against the Arabic label beside it and a
reader sees "214 / 50" — the numbers held but the meaning inverted. NUMERALS ARE HELD, so the digits
themselves are untouched.

CENSUS BEHIND IT, on current main, reusing the existing gates rather than adding one: check:i18n-parity,
check:i18n-never-translated and check:i18n-keys-exist all PASS; and of the 766 catalogue strings the three
Simple Research bundles ship across 21 areas, ZERO fall back to English in any of the ten locales. The
English still visible to a non-Latin reader is five or six items and every one was already classified:
two external resource names (Open Bible Stories, Scripture Earth), one pure format string, two acronyms
(DPI, MST), and UUPGs, which is the Driver's deferred ruling. This cover note was the only NEW leak, and
it was mine.

Verified: check:sfc-parse and check:i18n-keys-exist PASS. Needs eyes: rega — the packet cover at ?lang=ar.
2026-09-28 10:13:29 +00:00
Doxa Maps
beb82f2e2e UPG Neighbor Campaigns: hovering a dot names its group
After a language was picked, its believer (green) and unreached (red) dots were
anonymous until clicked, and a green dot could not be clicked at all. Hovering either
now shows the group and its country, as Mobilize does.
2026-09-28 10:08:43 +00:00
Doxa Maps
77c7b16fef UPG-100: the HUD band follows the reading direction
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
.upg-hud-band used margin-left:auto to push itself to the trailing end of the
flex row .upg-hud-head. That end is the LEFT in Arabic, so the band sat on the
wrong side. margin-inline-start:auto, the same class as the legend and table
conversions. No consumer overrides .upg-hud-band, so nothing can defeat it.

Found by a localization audit of this bundle (upg-100-mfe + poster-wall +
upg-100-list). The rest of the audit came back clean, and both scans were
NEGATIVE-TESTED before their zeros were believed:
  - hardcoded English in templates: a planted sentence IS found, so the zeros in
    poster-wall.vue and upg-100-mfe.vue mean something. The only template hits in
    upg-100-list.vue were `{{ LEGEND_TITLE }}` (a translated computed, a false
    positive of the regex) and one real one, below.
  - script-side user-visible English: none.
  - key coverage: 23 keys used, 0 missing across all 11 locales. A planted
    nonexistent key reports missing in 11/11 and a real key resolves in 11/11, so
    the checker is neither blind nor vacuous.

ONE FINDING IS NOT MINE TO FIX: upg-100-list.vue:2786 has a literal
title="Batch Select" where its siblings at :2811 and :2833 use a translated
:title. The fix needs a new key, and the catalogues live in library/i18n/locales/
— the build owner's under rule 5B. Sent to dx-shepherd rather than landed here.
2026-09-28 10:00:37 +00:00
Doxa Maps
8e13477340 Remove the superseded headline-tiles computed
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
`tiles` built four stat objects that nothing rendered. The commit which cloned
DX-life's region dashboard replaced its template block with `stats` and the
shared StatCard, and removed the .rd-tile styles with it, but left the computed
behind.

It was not merely unused: it carried four bare English labels, so a localisation
scan reports it as hardcoded English every time. It cost this audit the time to
prove it harmless, and would have cost the next one the same. `stats` renders
the same four figures and puts every label through t().

Verified superseded rather than unfinished before deleting: the template usage
and the computed were added by one commit and the usage removed by the next, the
styles are already gone, and nothing in the plugin refers to it. Behaviour is
unchanged.
2026-09-28 09:55:30 +00:00
Doxa Maps
ae960e82b5 UPG-100: read pin features through the shared helper, not source._data
Clicking a pin to open the people-group detail panel did nothing under MapLibre,
with no error of any kind.

openPgDetail read `src._data.features`. The two engines store _data differently,
and both bundles say so in the same constructor (this._options = Object.assign
follows in each):

  mapbox-gl v3.24.0   this._data = i.data                           the raw value
  maplibre-gl         this._data = "string"==typeof i.data
                                   ? {url: i.data} : {geojson: i.data}

So `._data.features` resolves under Mapbox and is UNDEFINED under MapLibre.
Every guard in the chain then turned the wrong shape into a no-op: `|| []` made
it an empty list, find() returned undefined, `if (f)` was false. Silent, and
indistinguishable from a missed click -- the same defect class as sr's landed
MapLibre source-read bug.

Now calls readSourceFeatures() from library/maps/pin-selection.js, which falls
back to the public serialize() that both engines expose. One import, one call, no
new API.

The mapbox citation was taken from the exact CDN bundle the app loads
(library/constants/cdn.js pins v3.24.0), fetched and verified against its pinned
SRI hash before reading, then deleted. No browser involved -- downloading and
grepping a bundle never starts one.

Verified in the BUILT output rather than by function name, which minification
renames: the helper's distinctive public fallback `serialize` appears once in the
rebuilt bundle and zero times in main's.
2026-09-28 09:48:45 +00:00
Doxa Maps
0d85e0dfcc Localise the regions fallbacks that still rendered English
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
A regions audit across 60 keys and 10 locales found the catalogue itself sound
-- every key present everywhere, and the few strings identical to English are
cognates their sibling languages spell differently. The gaps were in the plugin,
in the places a value is absent and a fallback renders instead.

Eight `|| 'Other'` fallbacks rendered the English word in every locale, though
'Other' is translated in all eleven and the regions list already called t() for
exactly this. A country subtitle concatenated ' people groups ' as a literal.
Both now go through t().

The map popup aligned its stat values with text-align:right, which does not flip
for Arabic, so the values sat on the wrong side of a right-to-left popup. It
uses the logical end instead, as nine other declarations in these files already
do.

Two strings are left deliberately: the region and all-regions subtitles build
"N countries" and "N regions" from template literals, and no key exists for
either in any locale. Word order and plural agreement differ per language, so
inventing a construction across eleven of them would be worse than the English
it replaced. Those need catalogue keys and are reported rather than guessed.
2026-09-28 09:44:48 +00:00
Doxa Maps
33362f5592 Let the gospel map's own text follow the reader's direction
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Eight CSS declarations in the production profile still named a physical side, so in Arabic they
pinned translated text to the left: the legend key's wrapped label, the search-match rows, the
people-group buttons in the gap list and in the detail panel, the error line, two chip margins,
and the row-count badge beside each table tab. Every one of them now uses the logical property —
text-align:start, margin-inline-start — which is identical to today in every left-to-right locale
and correct in the two right-to-left ones.

Found by auditing the production profile's localization rather than by a rendering failure, which
is how this kind of defect stays in: the Arabic page looks translated, because it is, and only the
alignment quietly disagrees with it.

Nothing in the English-prose islands changed. Those carry dir="ltr" on purpose — the caveats stay
English by the standing ruling, and the isolation is what keeps them from disturbing the Arabic
text around them.

Rebuilt gospel-resources only, then fix:sri; check:sri passes at 64/64 leaves.
2026-09-28 09:22:02 +00:00