Under provider=maplibre (the keyless gallery) four of the seven map views never
finished loading. They built `new mapboxgl.NavigationControl` in the load
handler, and that global does not exist under MapLibre, so they sat on
"Loading…". Popups and the pin marker had the same fault. Every such call now
goes through glFor(map), the namespace of the engine that built the map. The
views' styles and the Dark/Satellite/Light switch now use registry names (ink,
hybrid, paper), which are the same Mapbox styles under Mapbox and OpenFreeMap
under MapLibre.
The search box loads its geocoder by engine, as GeocoderComponent does. Under
MapLibre it is MaplibreGeocoder over this map's own local index, so the keyless
gallery makes no request to api.mapbox.com at all.
The legend's row caret, clear chip and collapse/reopen buttons carry accessible
names and aria-expanded from legend.a11y.* and aria.* (optional i18n, English
fallback), the keys the library legend uses. check:shared-reached now counts
pplr-data-maps as carrying legend-a11y-keys.
window.__pplrMap() reports what the active view's map did (engine, load fired,
style layers, this bundle's data sources), so "loaded" is asserted, not inferred.
Measured on a served built tree, host page on another origin: all ten views
loaded under MapLibre and under Mapbox (load fired, style layers and data
sources present, canvas not blank, 0 errors), with 0 api.mapbox.com requests
under MapLibre. The same check on the parent build fails four map views under
MapLibre. A control with no provider makes 13 Mapbox requests.
Driver: the batch tool is "super buggy", and letting you change the
colour of regions is NOT what is wanted - "the batch selection tool
should let you create your own poster or prayer packet based on your own
selection."
So the selection stops being a thing you PAINT and becomes an INPUT to
the two artefacts this app already makes. Gone: the eight colour
swatches, the custom hex input, and "Copy selection", which produced a
clipboard string and a recolour and nothing a reader could pray from.
In their place the HUD offers Prayer List, Poster Map and Clear, in all
eleven locales - the keys already existed, so this adds no translation
debt.
NO THIRD PIPELINE, which the brief rules out. Both actions end in
printPacket / printPoster, the same calls the legend and the poster wall
make. The selection is handed over as a SYNTHETIC list whose selector is
a pg_slugs union, because 12 of the 27 lists already use pg_slugs and
the evaluator THROWS on that type: resolving the union to concrete slugs
once, against a single fetch of the people-groups catalogue, gives every
existing consumer the shape it already handles. Resolving per zone would
refetch the whole catalogue per zone and still leave the caller to merge.
The packet's map page is per-zone artwork, so a multi-zone selection
prints a cover WITHOUT the map rather than letting one zone's map stand
in for all of them. A poster has no artwork for an arbitrary union at
all, so with several territories selected it says so plainly instead of
silently printing the first zone's poster as though it covered the
selection - generating one would be the new pipeline the brief forbids.
MEASURED at the rendered layer, driving the documented __UPG_SELECT__
gate API rather than guessing at the interaction:
selection 3 territories, exactly Middle East Core / East Africa /
Sudan, batch mode on
recolour UI swatches gone, hex gone, copy gone
buttons en "Prayer List / Poster Map / Clear selection",
ar "قائمة الصلاة / خريطة الملصق / إلغاء التحديد"
page errors 0
Wrote this after shipping a page that returned 200 for every asset, reported
zero console errors, and was completely dead: a throw during setup dies inside
the custom-element boundary. Zero errors is not the same as working, so this
does not trust their absence — it looks for PRESENCE, on all five leaves, in
five steps that each name their own failure: the tag is defined, the host is in
the document, a shadowRoot is attached, it has children, it renders text.
Its first real run went red on all five, and it was right. Every gospel leaf was
dead with "Identifier 'Ge' has already been declared" — while the SRI hash
still matched and every request still returned 200. My own doing: four profiles
imported en/gospel.json directly for an English fallback, and library/i18n
already pulls that exact file in through an eager glob, so the IIFE carried two
instances of one module.
The fix is one module: library/i18n now exports EN_MESSAGES and the profiles
read it from there. The duplicate is gone.
Measured after: all five leaves mount with 3,228 to 233,307 characters of
rendered text, verify-gospel-map passes with 2,107 pins and the gap cutting to
370, and --poison confirms the check goes red on a page with no element at all.
The lesson is the gate's whole reason to exist. I had rich assertions about pin
counts and filters and none of them ran on data-tables, charts or the list, so a
bundle-level failure could take out every leaf while the one gate I did run was
pointed elsewhere. Cheap and universal beats rich and narrow for THIS question.
Every map translated its own chrome but left the basemap's country, city
and street names in the style default (English on Mapbox, Latin + native
on OpenFreeMap) in all eleven locales. A reader in Arabic saw an Arabic
legend over an English map.
library/maps/localize-labels.js uses each engine's supported mechanism:
- Mapbox: map.setLanguage(lang). It also localizes the Standard style's
IMPORTED basemap, which a layer rewrite cannot reach.
- MapLibre: basemap symbol layers point at
['coalesce', name:<lang>, name:latin, name]. English restores each
layer's original expression, re-captured on every style load so a theme
switch never restores the previous style's labels.
Only layers whose text-field reads an OSM name field are touched. A
plugin's own label layers (zone titles, group names) are never rewritten.
useMapInstance applies it on every style.load and when the reader's
language changes (ProfileLoader's 'lang', else profile-config locale, else
?lang / <html lang>). The Prayer Walk Planner drops its private copy for
the shared one.
Measured on the Prayer Progress map, ar/zh/ru/hi:
- Mapbox getLanguage() == locale in all four.
- MapLibre: 13/14 symbol layers localized, and the plugin's own label
layer untouched.
Read in the Arabic screenshots: القاهرة, الإسكندرية, السويس, الأردن on
both engines. check:gallery-embed (+ :maplibre) and
check:prayer-walk-planner pass. The shared change reaches all 11 bundles
that compile initializeMap, and all are rebuilt here. check:shared-reached
marker basemap-labels-localized: 10/10 consumers.
The table offered 107 groups a next step the map did not show. The candidate is
now computed once, in the pin build beside the curated bridge, and both surfaces
read it — the same rule that put the tier in one place. 1,167 candidates across
the research index; 107 of them reach Doxa groups the curated bridge misses.
The map's sheet renders it as its own weaker panel, and the words carry the
limit: "Possibly a way in — worth checking locally", with the language, how
many groups there speak it and what share of the country they are, and "not
ranked and not recommended". No strength, because none can honestly be
computed. Never for a sign language.
A REGRESSION I INTRODUCED AND ALMOST SHIPPED. The edit that wired this used
pinCandidates without declaring it — a string replace that matched nothing and
said nothing. The ReferenceError died inside the custom-element boundary, so the
map never mounted AND the page reported zero errors. My probe then failed with a
30-second timeout that I had caused separately, by passing playwright's timeout
as the waitForFunction ARG rather than its options, which had been silently
using the default in eleven scripts. Two faults stacked: a broken page that
looked error-free, and a probe too impatient to say so.
Zero page errors is not the same as working, and that is the only reason this
was caught: __grState never appeared, which no assertion about pins could have
explained away. Verified after the fix: 2,107 pins, gap cuts to 370, the
candidate panel renders for Abai Sungai naming Malay, and 25 Deaf sheets still
show no bridge at all.
E RESIDUALS, from rega's in-gallery re-sweep. All three lived in SHARED
components, which is why a grep of my own plugin found nothing:
SearchFilterHeader its label map was a literal object of English defaults —
the fallbacks every host gets, since no host passes
`labels`. Filters, Selected, World and the chip remove
button now come from the catalogue; a host's own labels
still win, which is what the prop is for.
ResearchMapFilterPanel the two "Any" options and the "{n} active" pill.
DashboardFacetRail the Expand/Collapse aria template, which repeats per row:
one untranslated template is dozens of English strings in
a screen reader.
dashboard.config.js the facet SECTION titles (People, Place, …). The earlier
pass converted `label:` and missed `title:`.
★ AND A LOOKUP BUG THAT LOOKED EXACTLY LIKE A MISSING TRANSLATION. 'Any' and
'0 active' stayed English after being catalogued, because the panel asks for
`controls.<key>` while I had authored them under `dashboard.<key>`. The
catalogue had the words; the component was looking one namespace over. Keys now
live where the panel reads, and its lookup falls back across both, so a string
authored in either place renders.
MEASURED after, ru, on the built tree: 0 English chrome strings on Simple
Research and 0 on Big Picture.
One probe correction worth keeping: a substring match on 'People'/'World'
flagged 'South Asian Peoples' and 'Arab World' inside aria labels whose frame
was already translated — data, not chrome. Whole-string matching separates them.
TOUCH TARGET (regions' phone sweep): .rd-proj "🌐 Globe" measured 74x23 at
390x844, one pixel under WCAG 2.5.8 AA. min-height:24px under
@media (pointer:coarse) — stating the 24 rather than growing padding, because
the height is padding plus a line box the renderer derives from the font and the
arithmetic lands at 23.7. Mouse sizing untouched. MEASURED after: 73.5x34.
Every bundle embedding the changed shared components is rebuilt in this commit —
a shared fix is not landed until its consumers are.
composition/_switcher.scss has three rules and the clone carried two. The third —
`.switcher > :nth-last-child(n + 6), .switcher > :nth-last-child(n + 6) ~ *
{ flex-basis: 100% }` — gives every child a full row once there are six or more,
instead of letting them share. It is now ported onto .rd-switcher.
It changes nothing today and that is measured, not assumed: the rendered page has
3 direct children at widths 372/372/372, the same three goal cards their
RegionDetail.vue lays out, so a threshold of 6 is out of reach. It is carried
because the brief is an exact clone, and because a fifth or sixth goal card would
otherwise diverge silently from doxa.life — a one-line rule now, or a puzzle later
about why six cards behave differently here.
The other two rules in that file are deliberately NOT ported, which is a
measurement rather than an oversight: .switcher-item{display:flex} never applies
because their switcher's children are `.stack | card`, not `.switcher-item`, and
.three-part-switcher belongs to a component the regions page does not use.
The probe proves both halves, because a rule that cannot be shown to fire is
decoration. It reads the real switcher, asserts no child has flex-basis:100% at
the live count, then clones cards INTO it until there are six and reads the basis
back as 100% before restoring it. Two of my own errors are recorded in it: a first
run drove the page with ?region=africa, got the chooser, and reported "no
.rd-switcher" — a reading of the wrong page state, since the selection comes from
the hash; and a second hardcoded "+2 clones = 6" from a static tag-count that said
4, so it only reached 5 and called the rule dead when its own arithmetic was wrong.
It now derives the clones to add from the count it just measured.
The doxa.life script embed was blocked on a server change: the live root
manifest.json answers without Access-Control-Allow-Origin, and the gallery
fetch()es it cross-origin, so no tab could mount. Measured on nuxtbase,
though, the same static layer DOES send ACAO on every path inside a folder
of the tree (doxa-maps-spa/<anything>, even a 404; assets/…).
So generate-tree writes a byte-identical manifest copy at
doxa-maps-spa/manifest.json in the same step as the root one (they cannot
diverge). The gallery reads it FIRST when the tree is on another origin (no
CORS error in the host's console) and the root first when same-origin.
Script URLs still resolve against the tree root, never the copy's folder.
check:gallery-embed:live-cors serves the tree with the live rule
(serve-strict LIVE_CORS=1: no ACAO on the root manifest only).
- The current gallery under it: "did not boot within 60s" (negative
control, reproducing doxa.life).
- With this change: js 14, iframe 14, neighbour 14, linked 4. The default
gate also passes all phases.
check:embed-targets, check:drop and check:public-hygiene pass with the copy
present.
The server-side CORS rule (serving/traefik-embed-headers.yaml) is still the
right long-term fix, and it needs server access. This unblocks the embed
without it once the tree is published.
Every conflict was a build artifact — another agent had pushed a full rebuild of
all 23 bundles. Minified .js and baked-SRI leaf pages are generated files: merging
their text is meaningless and can produce a bundle that matches neither side, so
they are resolved by taking one side and then rebuilding from the merged source.
The incoming diff carries 68 source files, among them library/composables/
useMapInstance.js and the dashboard/gospel catalogues in 10 locales, so the
rebuild is all 23 bundles and not only the ones I touched — regenerating the tree
without rebuilding has shipped a stale .js here before.
The curated bridge covers 168 of Doxa's 369 with nothing, because it comes from
a curated subset rather than from the data. A candidate IS computable for the
rest: the largest language in their country carrying both Scripture and the film.
WHAT I COULD NOT COMPUTE, AND THEREFORE DO NOT CLAIM. Ranking those candidates
needs a notion of "is this a lingua franca", and neither available proxy
survives contact. By GROUP COUNT, Oman picks Swahili — two groups — for an
Arabic-speaking people. By SHARE OF PINNED POPULATION, a 10% floor REFUSES Urdu
in Pakistan at 7.3%, the national language, because few Pakistani groups are
pinned as Urdu speakers. That is the same bad-denominator mistake this map
already fixed on the choropleth. So no strength is invented: the candidate ships
with its group count and its share, under column names that say "candidate",
and a reader who knows Pakistan can see Urdu at 7.3% and know what to do with it.
A score would have thrown it away on their behalf.
AND THE ERROR CAUGHT BEFORE SHIPPING. The candidate logic was offering 89 Deaf
groups a SPOKEN language — Deaf Afghans to Turkmen, Deaf Arubans to Dutch. That
is not a weak bridge, it is not a bridge, and a mobiliser acting on it would be
sent to do something that cannot reach the people. This build already refuses to
search GRN for sign languages because GRN publishes audio; the candidate logic
had inherited the gap. Now: 92 Deaf groups are offered none, and their gap is
named as the access problem it is.
Of the 369 with nothing: 168 curated bridge, 107 computed candidate, 92 Deaf,
2 with neither.
inheritAttrs:false on the studio root, so a host's attributes (style,
class, id) stay on the <plugin-studio> element instead of also landing
on the layout inside it: the trap that made the Prayer Walk Planner
76px tall in the gallery. Layout verified unchanged in four hosts on the
built bundle: its own page (shell 1280x900, body 836), a bare partner
snippet (85vh default), a host that sets its own inline height (the
style stays on the host; the shell root has no style attribute), and
the app-shell mounter from BUG-EMBEDDED-PLUGIN-STUDIO-BLACK-SCREEN,
display:block forced on the host: body 656px, rail and tree rendered.
That bug no longer reproduces on the dx-maps side.
internal/measure-map-load.mjs: per production map, the cold, throttled
waterfall (bundle, engine script, style, data, basemap up, visible
tiles loaded) on a served built tree, from the leaf page or, with
--embed, from a partner page that pastes only the embed snippet. It
showed that Vue is already inlined in every bundle and that the engine
waits on the bundle only on partner pages.
The access table could say "nothing found" and stop. For 168 of Doxa's 369
that is not the whole truth: a language already spoken widely in their country
has Scripture and the film. Afitti in Sudan has nothing of their own; Sudanese
Arabic, spoken by 65 groups around them, has both. That is the difference
between no way in and a way in that is not their heart language, and it is the
next action a mobiliser can take — which is why it belongs in the EXPORT and not
only in the map's sheet, where it has been sitting unexportable.
Four columns: the bridge language, its ISO code, the strength, and how many
groups in that country speak it. Keyed by code|country, because the same
language bridges differently in different places.
IT CLOSES NO GAP AND THE DEFINITION SAYS SO. A bridge is a way in, not a
translation. These 168 are still counted in "nothing found", because they still
have nothing in the language they think in, and the first believers among them
still cannot be trained from a text that does not exist.
The other 201 have nothing AND no bridge. That number is worth having on its own:
it is the isolated set, where neither their own language nor anything spoken
around them carries the gospel.
Verified end to end: the CSV exports 2,107 rows carrying Bridge language, Bridge
ISO, Bridge strength and Groups speaking it, and the Afitti row reads Arabic,
Sudanese.
library/maps/rtl-text.js installed the RTL text plugin for MAPBOX only
(window.mapboxgl, from api.mapbox.com). So every MapLibre map, i.e. every
keyless embed, drew RTL place names as disconnected, reversed glyphs.
Measured on the Prayer Walk Planner's printed Arabic sheet.
Under it was a second gap. Bundle entries call the installer at LOAD
time, before useMapInstance has lazily injected a MapLibre engine, so even
an engine-aware installer found nothing to install into.
- rtl-text.js is engine-aware. Mapbox is unchanged (same URL, same lazy
call). MapLibre gets the same plugin package from jsDelivr, keyless and
not api.mapbox.com, so keyless pages still make zero Mapbox requests.
New ensureRtlTextPlugin(gl) waits until it is LOADED, for callers that
capture a single frame (print).
- useMapInstance installs it the moment an engine global exists, the one
point every map passes through.
- The planner drops its private copy for the shared helper.
Measured: a print map of downtown Cairo (MapLibre, keyless) shows shaped
Arabic street and place names (شارع التحرير, مسجد الطباخ …); plugin
status "loaded", fetched from jsDelivr. check:gallery-embed passes js,
iframe, neighbour and linked (14 tabs); :maplibre passes;
check:prayer-walk-planner passes.
The shared change reaches all 11 bundles that compile initializeMap, and
all are rebuilt here. check:shared-reached gains an rtl-text-maplibre
marker (10/10 consumers carry it; cities-internet, gospel-resources and
my-upg-100-list boot maps another way and are not affected).
Also: the new Experimental bundle pplr-data-maps loads the Mapbox geocoder
under maplibre (pplrData.js:38). It is an owned, printed MAPBOX_KNOWN entry
routed to its owner; delete it in their fix commit.
TWO FIXES THAT BOTH SHOW UP ONLY WHEN THESE PLUGINS ARE SOMEBODY ELSE'S GUEST.
1. ATTRIBUTE FALLTHROUGH. These SFCs mount AS custom elements, so a style the
host puts on the tag lands on the component's own root element. MEASURED with
a host style of "display:flex; position:relative; height:100%": that exact
string appeared verbatim on the inner root div of people-groups-grid,
stack-demo, research-map-plus-grid and dashboard-plus-grid. The gallery sets
one on every tab, and the same fallthrough collapsed the Prayer Walk Planner
to 76px. inheritAttrs:false on each root — a host styling the TAG is styling
the box, not our internals.
AFTER: the inner root carries no inline style on any of the four, and each
still renders full height (2997-4010px in the harness), so the guard did not
become the collapse it prevents.
Worth knowing for the next probe: shadowRoot.firstElementChild is a <style>
node — Vue injects styles first — so a probe that trusts it measures nothing
and reports "no fallthrough, nothing to fix". Take the first non-style child.
2. THE RESIDUAL CHROME the in-gallery re-measure found: the dashboard title,
Align Dashboard and its tooltip, World, Vertical Legend, groups in scope, the
per-row aria templates (Filter by / Remove filter / groups), and the map
drawer's aria label. Catalogued in all 11 locales and read through tStatic,
reusing existing wording where the catalogue already had the word.
The aria TEMPLATES matter more than their size suggests: they repeat per row, so
one untranslated template is dozens of English strings in a screen-reader's ear.
resources-list and charts turned out to be almost entirely explanatory prose.
Their actual chrome is four strings — two page titles, the loading line and
Close — and those are now in all eleven locales. The blanket dir=ltr is off both
roots, so Arabic gets its layout, and each English prose block declares its own
direction where it sits.
That completes option (b) across all five gospel profiles. What stays English is
what carries doctrinal and statistical precision: which pools were searched, what
"nothing found" does and does not claim, why Open Bible Stories is not
Scripture. A machine translation of those would read as authoritative while
being wrong, and one translated line in each locale says so in the reader's own
language.
Verified on a served built tree whose identity I proved first — the Russian title
appears once served and once on disk. en/ar/ru on both leaves: heading
translated, 0 clipped, dir=rtl only for Arabic, no page errors and nothing 4xx.
A composite shell copies a whitelist of keys from its own profile-config down to
each map it nests. `provider` was not on any of the three lists, so a nested pane
read it as undefined and fell back to Mapbox: a partner who asked for MapLibre,
and who holds no Mapbox token, got Mapbox maps in every inner pane. Same shape as
the missing `urlSync` here before it -- an inner map cannot honour a key the shell
never passed down.
The report named country-posters-mfe. All three composites had it: regions-mfe and
upg-100-mfe were missing `provider` too, so fixing only the one named would have
left two thirds of the defect in place.
Verification is a probe, not an inspection, and it needs a control. "0 requests to
api.mapbox.com" passes trivially on a page where nothing mounted -- the most likely
way this measurement goes wrong -- so each composite is loaded twice: once with
provider=maplibre expecting zero, once with provider omitted expecting more than
zero. The second run is what proves the first measured anything; a run that
renders no canvas is reported UNPROVEN rather than passing.
It also cannot be measured on a leaf page. Every leaf loads its map peer in its
own <head> (regions-mfe/index.html line 13 is a hardcoded api.mapbox.com script),
so on a leaf zero mapbox requests is unreachable whatever provider says. The
question only has meaning on a host that picks the peer itself, which is what a
partner embedding the .js actually does.
The last commit recorded "in RTL the leftmost data column clips against the
edge" as known debt. Measured rather than eyeballed, that is wrong. Counting
cells falling outside the table holder: English and Arabic are IDENTICAL — 13 of
20 columns off-screen in both, scrollWidth 3146 against clientWidth 1400 either
way.
A twenty-column table is wider than 1,400px, and RTL mirrors which edge the
overflow sits on. What I saw in the Arabic screenshot was English's right-edge
overflow appearing on the left, which is what mirroring means. I read a picture
instead of measuring, and filed the result as debt — which would have sent the
next person chasing a defect that does not exist.
Whether twenty columns is right for a 1,400px screen is a real question. It is a
design question, and it is the same question in English.
PPLR renamed the aCountry fields upstream (GENCalpha3 -> GENCAlpha3, CtryNm -> Name,
ctryNmDisp -> DisplayName, ISOalpha2 -> ISOAlpha2). The query asked for the old
names, ArcGIS answered HTTP 200 with { error }, and the loop read that as "no
features". Every view that draws country polygons silently lost them. The
query now asks for the new names and gives each feature the old ones back, and an
{ error } body throws.
Paging also stopped after one page: f=geojson carries exceededTransferLimit under
data.properties, so only 200 of 251 countries loaded. A full page now means ask
for more.
Coverage: the status no longer claims a 6,000-row sample, and a __pplrCoverage()
handle reports how many polygons matched a coverage row.
The Driver: "the prayer walk planner is still a very tiny map, not
expanding the full page", and the printed map was a ~10px sliver.
Tiny map. Measured: the leaf filled (1003x739), but inside the Research
Gallery the map was 747x76 on desktop and 390x0 on a phone. The planner
mounts its SFC as the custom-element root (that is what puts its client
API on the element). So Vue's attribute fallthrough copied the gallery's
inline host style onto the root .pwp, and that inline display:block beat
.pwp{display:flex}. inheritAttrs:false keeps host attributes on the host.
The map now fills at 1280x800 and 390x844, in the leaf and in the gallery.
The gallery gets the same guard: its .vtl had survived only through
display:grid !important.
Sliver print. Print used to snapshot the LIVE map, so the page got that
map's on-screen size and shape. Each zone now renders in its own
temporary MapLibre map:
- page-shaped (1200x1344 at pixelRatio 2, a 2400x2688 image, ~320 dpi on
the 7.5x8.4in frame)
- keyless OpenFreeMap liberty, with streets and building footprints
- fitted to the zone, with the zone outlined on top
- destroyed once its pixels are taken, and the reader's map never moves
One zone = one letter page, with the map filling it.
Localized. The planner read no language at all. It now follows
language → profile-config locale → ?lang → <html lang>, in all 11
locales (private src/strings.js): toolbar, list, status, default zone
names, and the printed sheet (lang/dir set, so Arabic prints RTL).
Street and place labels prefer the OSM name:<lang>. Two Arabic defects
were found by reading the printed sheet, and fixed:
- coordinates were bidi-scrambled → isolated LTR
- map labels were unshaped glyphs → the RTL text plugin is installed
for MapLibre, from jsDelivr, and awaited before the print frame
(library/maps/rtl-text.js only does it for Mapbox)
renderZoneMap(id) joins the client API: the print PNG without the dialog.
New gate check:prayer-walk-planner:
- 4 size cells (leaf/gallery × desktop/phone)
- a real zone in Balat, Istanbul renders a >=2000px non-blank print map
- a Spanish toolbar
- 0 page errors
Both gates now prove the served tree is this checkout's (manifest equals
disk) before measuring, and exit 75 NOT MEASURED otherwise.
check:gallery-embed's Big Picture link now asserts that the grid shows
exactly the facet's own count.
All twelve tab labels, the page title and the filter chip come from the
catalogue in eleven locales. The blanket dir=ltr is gone from this profile's
root, so Arabic gets the RTL layout it should have; the English prose blocks —
the subtitle, the hero sentence, the footnote and the two caveats in the chip —
declare dir=ltr where they sit, the same split the map profile uses.
The translator is named tr(), not t(), and that is load-bearing: the tab loop
binds v-for="t in TABS", so a t() here would be shadowed inside the one block
that needs it most and every label would render as whatever a tab object
stringifies to.
Verified on a served built tree whose identity I proved first — the Russian
string I had just added appears once in the SERVED bundle and once on disk, and
the port owner's cwd is this worktree. en/ar/zh/ru/hi: heading and tabs
translated, 50 rows rendering, 0 clipped chrome, dir=rtl only for Arabic.
STILL ENGLISH, and not claimed otherwise. The table's own column headers and
title come from the DATA FILE's columns[].title and meta.title, not from this
component, so translating them means translating the table schemas — a separate
job. And in RTL the leftmost data column clips against the edge; my clip check
only measured the chrome, so it did not catch it. Both are named here rather
than left for someone to discover.
ALL PEOPLE GROUPS. The Driver: "not even loading, takes a very long
time", and its DOXA pins were dark on a dark map.
- It downloaded the 7.5 MB people-groups-slim.json (22 columns) to draw
ten fields. It now fetches people-groups-points.json: the same rows in
column order, repeated strings in dictionaries, coordinates at 3
decimals. 0.74 MB, generated deterministically by
library/scripts/people-groups/build-points.mjs and staged into the tree
by generate-tree. It falls back to the old file on an older tree.
- Pins no longer wait for the live prayer API. Every pin draws by GSEC
from the file, and the DOXA overlay recolours when the API answers. The
DOXA count reads "…" until then, not 0.
- Named style 'paper' (Mapbox light-v11 / MapLibre positron) instead of
'ink'. The four categories keep their meaning; yellow and green are a
step darker, and pins get a thin dark ring, so they read on near-white.
- Its words are in all 11 locales (allPeopleGroups.json), with a guarded
useI18n and values passed to vue-i18n so {n} is not blanked.
Measured on a served built tree, 3 fresh runs each, median time to first
rendered pins: 8.3 s before, 5.2 s after, on localhost. This box renders
GL in software, so most of the remaining time is map start-up; the 10x
smaller download matters more on a real connection.
DOXA VISION CARD. The Driver: "the data doesn't make sense … super false
information". The card rotated four typed-in figures with no source.
It is now a proportional funnel, every number computed from data by
library/scripts/people-groups/build-vision-funnel.mjs:
12,379 people groups (IMB rows: one group in one country)
7,341 unreached (IMB LostnessPriority 0-2, < 2% evangelical)
4,754 frontier (LostnessPriority 0, < 0.1% evangelical)
2,107 DOXA groups (the DOXA prayer list, snapshot saved in data/;
1,745 of them frontier)
The first three nest; DOXA groups do not all sit inside frontier, so the
card says how many do. The rules are on the card ("How these are
counted") and in HOW-THE-NUMBERS-ARE-COUNTED.md, which also explains why
the total is not the 16-17 thousand of Joshua Project's count. Material
light, all 11 locales, right-to-left in Arabic, sources and dates shown.
The old fake stats are removed from the instances file, the frontier
page, the manifest, the staging page and the gallery.
CHECKS.
- check:vision-card-numbers recomputes the funnel independently (own CSV
reader, the published rules) and compares with vision-funnel.json and,
with --url, the rendered bars. Negative-tested: a corrupted count exits
1. On the built tree: bars 12,379 / 7,341 / 4,754 / 2,107 and "1,745 of
them are frontier", all matching.
- check:map-embed-cells measures PRODUCTION-MATRIX cells B and C for the
seven gallery maps on a foreign origin; C proves the host's token is
used by handing the host a distinct token and checking every Mapbox
request. 14 of 14 pass.
The Driver, as a first-time user: "I select one and it gives me print
options at the top right of the legend ... that's the only way that map
would be usable."
The affordance already existed - SemanticTreeLegend renders an export
menu gated on a selection, the plugin passes :export-enabled, and
@export was wired. What the handler did with it was the problem: the
packet branch delegated to the shared composable, and the poster branch
hand-rolled an <a download> of posters/<id>.webp. That is wrong twice.
It downloads a file silently, which was ruled out on 2026-09-23 ("no
silent file download as the default action"), and it has no fallback, so
for the lists whose .webp is not shipped it saved a 404.
The wall had a correct poster print the whole time, private to
PosterWall.vue, so this surface could not reach it. It moves into
usePosterPrints.printPoster and both surfaces now call the one copy -
what the card asked for: "reuse the existing three print actions rather
than growing a fourth implementation." Two guards moved with it, each
learned the hard way: a THROWN fetch is a miss rather than an error (or
the rasterise fallback is skipped entirely), and a blank raster fails
loudly (an SVG in an <img> is an isolated document, so a missing
stylesheet paints black with no error).
Also in this commit, both from measurement rather than review:
THE STICKER OVERLAP IS NOW A CONSTANT, NOT HALF A VARIABLE. Centring the
print row on the banner edge put half its height on the banner - and
that height is content-dependent: 68px in en, 54px in zh, taller when ro
wraps at 390px. Measured ro/390px: 27 of 27 stickers on the map by 4px
while en and zh were clean at the same padding. No fixed padding can
clear half of a variable. The row's top is now pinned --tape-overlap
above the edge and extra height grows downward into the row gap, so the
amount on the banner is identical in all 11 locales at every width.
15 width x locale combinations now pass with map-covered 0.
THE PER-ROW PREFERENCE IS CLAMPED TO WHAT THE VIEWPORT HOLDS. It renders
as an inline grid-template-columns, and inline beats the stylesheet, so
the Driver's default of 2 overrode minmax(210px,1fr) at every width -
including 390px, where two columns give a ~162px card. Reported from a
390x844 phone shot. The preference is for desktop density, not an
instruction to overflow a phone; it survives, clamped by the same 210px
minimum the stylesheet already uses.
THE ROW GAP WAS CLEARING A DISTANCE NOTHING OCCUPIES. 88px was tuned
when the print row hung fully below its card. It now straddles the
banner edge, so the same shot showed a ~180px empty band between rows.
56px still clears a two-line row in the long-label locales.
And the composite shell re-dispatches doxa:select-zone, so a list chosen
in the poster wall is ISOLATED on the map rather than merely scrolled
to. The map half was already built and listening on its own element; the
shell owed it one re-dispatch and nothing here re-implements selection.
THE RECHECK, reported at its real size. Re-asking all 293 Doxa codes the old
title catch-all had defaulted to "none" recovered EIGHT: the rule was wrong
2.7% of the time, not catastrophically. That moves 12 Doxa people groups out of
the gap — 381 to 369 — and each now carries a working Words of Life link. Modest,
and worth saying plainly rather than dressing up: the bug was real, its blast
radius was small, and the fix is that the classifier measures instead of
defaulting.
THE THREE LINES. rega's in-gallery count was right that these are chrome, not
prose: what a dot is, what "none found" does not mean, and that a country is
tappable. They are what a reader needs to use the map AT ALL, so leaving them
English made it unusable rather than merely untranslated. Translated in all
eleven; the long explanatory paragraphs around them stay English and are pinned
dir=ltr where they sit, which is option (b) unchanged.
The card is a BANNER that displays a map, the way a label displays a
design on a bottle. A banner taped up gets a sticker at each end, and
each sticker straddles the edge it tapes: part on the banner's own white
space, part overhanging. Neither one touches the map.
MEASURED at 1440px before this change:
banner 240-426, map 241-425 -> 1px of white space at each end
header sticker 35px above the banner's top edge, -1px below
footer sticker 1px above the bottom edge, 67px below
So the map filled the banner edge to edge and there was no banner
surface for a sticker to sit on. The footer row could only hang off into
space, which is what shipped, and every assertion in the gate was green
through all of it.
The banner now reserves white space at both ends (--banner-pad-top /
--banner-pad-bottom on .main), and each sticker centres on its edge:
header sticker 17px on the banner / 17px above it
footer sticker 35px on the banner / 33px overhanging
map covered by either: 0px
The print row also moved INSIDE .main. It was a sibling, so top:100%
resolved against the CARD, 27px past the banner - the re-parenting and
07d3341c's centring transform are one fix, and neither works alone.
That commit's per-row localStorage preference is preserved.
Card Deck is no longer a peer button with a 162px <select> beside it.
The stock picker is a disclosure: press Card Deck, choose a size, and
that choice prints. A native dropdown in the row collides with the host
page's own dropdowns, which is why it is buttons rather than a <select>.
Print-button labels WRAP instead of clipping. With the per-row default
now 2, a 390px viewport halves the card to ~170px, where Romanian's
"Pachet de cartonașe" cannot share a line: measured 54 of 81 controls
clipped at ro/390px, en and zh clean. The ruling on this wall is that a
long label wraps; nowrap was forcing a clip.
Also fixes the transparent strip above a pinned section header. Sticky
pins to the scroll container's PADDING box, so with .wall's 22px
padding-top the header rested 22px down and posters scrolled visibly
through the gap. A ::before extends the header's opaque fill upward by
exactly that padding, read from the same var so the two cannot drift.
Measured by sampling the strip's pixels: 1 colour, 100% header
background, where before it was 1338-2831 colours of poster artwork.
The MFE shell's own chrome is translated, in all 11 locales. Measured
with ?lang=ar: "Select View", "Views", "Prayer Resources" and "Map"
rendered English while the poster wall's buttons inside the panes
translated correctly, because each pane installs i18n and the wrapper
around them never did - the chrome a reader meets FIRST was the
untranslated part. The wall's own heading, tag and intro go through t()
for the same reason, with the <strong> inside the translated string so
each language emphasises its own words.
The gate grew four assertions, each mutation-tested to fail on the
defect it was written for: off-frame (row floated off the banner),
head-off (header sticker not straddling), map-covered (a sticker on the
poster), and selects (a native dropdown back in the row).
Includes a rebuild of the four PosterWall consumers, which also picks up
studio's shared useMapInstance unmount guard and exp-shepherd's
translated LegendMobile labels.
Not for main yet. All ten views mount on the dev server with real data and no
page errors. Left: the coverage view's status still says 'sample of 6,000',
its choropleth needs checking, and the bundle is not built or tree-verified.
rega measured that a Regions nested in the Research Gallery STILL rewrote the
host's #anchor and tab title after my urlSync guard landed, and named the cause
exactly: regions-mfe.vue INHERITED_PAGE_KEYS lacked `urlSync`.
They were right, and the lesson is mine. Guarding writeUrl, syncTitle and
popstate in region-dashboards.vue was necessary and NOT sufficient: a nested
pane has no leaf page of its own, so it only ever sees what this composite
forwards. Without the key the panes read cfg.urlSync as undefined and the guard
never fired. A guard you cannot reach is not a guard, and I had verified only
the half I could see from my own file.
It is the same lesson this file already records for cap-4 asset paths: page
scoped config must be forwarded explicitly. The existing filter keeps `false`,
which is the value that matters here -- it rejects undefined, null and '' only.
VERIFIED ON THE WHOLE PATH, composite -> pane -> guard, on a foreign origin
(tree on 127.0.0.1, host page on localhost) with urlSync:false and a host
#anchor: 868 nodes and 2 nested pane roots rendered, and the host's
#host-anchor-must-survive and its tab title both came through untouched.
The probe asserts the embed RENDERED before it believes the URL result, and
that guard earned itself twice: a first run used <regions-mfe> as a tag, which
is a profile of <regions-dashboard> and not an element, so nothing mounted --
7 inert nodes, with the host URL "untouched" only because no code had run.