Commit graph

2428 commits

Author SHA1 Message Date
Doxa Maps
3d2c16a707 check:facet-shape tests the facet gate's injected helper headlessly and runs in gates:pre
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The WALK string the facet gate injects into the page moves to a browser-free
module, internal/lib/facet-walk.mjs; the gate imports it, and
internal/check-facet-shape.mjs imports the same string and evaluates it on both
engines' source shapes without a browser. check:facet-shape ran from the browser
gate's own file, so the runner routed it to the browser slot; now it is headless
and joins gates:pre. Poisoned (the { geojson } unwrap disabled) it reports 4/5.
2026-09-28 10:27:43 +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
4f3858eab8 Rebuild the consumers main left stale (build owner, README rule 5)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
check:composite-freshness found stale on main: doxa-research-map. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-28 10:19:30 +00:00
Doxa Maps
5ae7d31b04 useMapFly's zoomToLegendRow comment names readSourceFeatures, not the engine-private _data
The comment still said the code reads map.getSource()._data, which invites a copy
of the private field back in; the code reads through the shared helper that knows
both engines' source shapes.
2026-09-28 10:13:29 +00:00
Doxa Maps
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
4fbe44ce17 Rebuild upg-neighbor, and regenerate the tree
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
2026-09-28 10:09:00 +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
b2142ac0b0 DataTable + StackLayout: physical left/right -> logical, where the rule mirrors
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Six conversions, one deliberately kept physical. Same method as the legends.

CONVERTED
  DataTable.vue:238  .tabulator-cell border-right -> border-inline-end. A per-cell
    trailing border is the COLUMN SEPARATOR between adjacent cells; in RTL the
    separator has to sit on the other side or it draws on the table's outer edge.
  DataTable.vue:269  .dt-actions   margin-left:auto -> margin-inline-start:auto
  DataTable.vue:293  .dt-close     margin-left:auto -> margin-inline-start:auto
    Both are "push me to the trailing end of a flex row", which is the left in RTL.
  DataTable.vue:299  .dt-dl ul padding-left -> padding-inline-start. A list's
    marker indent follows the reading direction.
  StackLayout.vue:161,305  .stl-meta margin-left:auto -> margin-inline-start:auto
    (twice: the light and dark theme blocks).

★ KEPT PHYSICAL, AND IT WOULD HAVE BEEN A REGRESSION TO CONVERT
  DataTable.vue:245  '.dt:dir(rtl) … .tabulator-cell{text-align:right}'
  It is already SCOPED TO :dir(rtl), so right IS the trailing edge wherever it
  applies. More importantly the cells carry unicode-bidi:plaintext, which makes
  `end` resolve PER CELL from that cell's own first strong character -- which is
  the exact behaviour this line exists to override (English cells left-aligning
  inside an rtl table and clipping at its left edge, rega L20). text-align:end
  would silently undo the fix. Comment added at the site so the next reader does
  not "correct" it.

No consumer overrides any of the four classes: .dt-actions, .dt-close, .dt-dl and
.stl-meta appear in no other file in library/ or plugin-designs/, so a physical
override cannot defeat the logical rule and produce a half-mirrored table.

LTR is unchanged: inline-start/inline-end resolve to left/right under dir=ltr.
check:sfc-parse PASS. 13 plugin files use DataTable, 9 use StackLayout.

library/ is the build owner's under rule 5B -- source sha to studio; rega eyeballs ar.
2026-09-28 09:48:45 +00:00
Doxa Maps
a98cbe8055 check:facet-changes-map — measure MapLibre too, not just Mapbox
BUG-FACET-GATE-CANNOT-MEASURE-MAPLIBRE. The gate read source._data four times,
and the two engines store it differently -- cited from the bundles this repo
loads, the same constructor in each:

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

A GATE blind to MapLibre passes a map that is broken there, which is worse than
a plugin carrying the same bug.

★ ALL FOUR READS WERE BROKEN, NOT THREE. The census called :145 "guarded" because
it had `s._data || (s.serialize && s.serialize().data)` -- but `||` fires only
when the left side is FALSY, and {geojson: …} is TRUTHY, so the fallback never
ran. The wrapper has to be UNWRAPPED, not fallen back from. That correction is
the reason this is a shared helper rather than a copied one-liner.

__srcFeatures() goes in the WALK bundle -- the in-page script the gate already
injects -- NOT as an import of library/maps/pin-selection.js: all four call sites
run inside page.evaluate / page.waitForFunction bodies, which are serialised to
the browser, where a Node-side import does not exist. Same logic as
readSourceFeatures(), duplicated across a process boundary, which is the one
place duplication is not a smell.

A remote (url) source returns null, not 0: null means "could not measure", while
0 would mean "the map drew nothing", which is the finding this gate reports.

THE MAPLIBRE SKIP IS RETIRED. It exited 0 while announcing SKIP -- NOT MEASURED,
correct for the limitation but a gate that never looked at half the product. A
source present with an unreadable payload is now a real failure with a
diagnostic, because the known cause of that state is fixed.

check:facet-shape negative-tests the helper headlessly against both engine
shapes, in sr's check-pin-selection style, evaluating it OUT OF THE WALK STRING
so the thing tested is the thing injected. 5/5. Poison-tested: with the unwrap
removed, ONLY the maplibre case fails (4/5), so it catches the regression and is
not indiscriminate.

internal/ is the build owner's under rule 5B -- source sha to studio. Running the
gate itself launches a browser and is rega's.
2026-09-28 09:48:45 +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
Doxa Maps
7b4171495e Rebuild the consumers main left stale (build owner, README rule 5)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
check:composite-freshness found stale on main: cities-internet
country-posters
doxa-research-map
doxa-simple-map
my-upg-100-list
pplr-data-maps. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-28 09:14:41 +00:00
Doxa Maps
f2e2509167 documentation: the browser guard, and ask the repo rather than grepping
Follow-up to the doc landing in 49, which was written before the guard existed.

Adds the guard: every browser gate reaches Playwright through
internal/lib/playwright-guarded.mjs and refuses unless browser-slot.sh exported
DOXA_BROWSER_SLOT; an unslotted run exits 75 with the command to use, quoted
verbatim. And says the slot is a COUNTING SEMAPHORE, not a mutex, with the reason
from its own header -- a single lock queued 10-19 deep for hours because the real
constraint is memory, ~300-500MB per chrome, not exclusivity.

Replaces the grep advice with `bun run browser-gates`. The grep still works and
is fine for a quick look, but it is a heuristic over file text: my own count from
grepping internal/check-*.mjs said 87 browser gates; the repo's own detector says
97, because browser gates also live in plugin-designs/*/scripts/. Corrected. The
authoritative list cannot drift from what is enforced, because
check:browser-gates-guarded uses the same detector.

One line the guard's header earns and this doc had not stated: being able to
launch is not the same as it being yours to launch. The guard stops the accident;
it does not hand a newcomer the seat. Testing whether the guard refuses is itself
a browser launch.

Does NOT claim path-into-node_modules detection: studio has that in landing 50,
and it is not in this tree, so the doc does not describe it yet.
2026-09-28 09:07:30 +00:00
Doxa Maps
a9dfe29200 Close the browser guard's path-shaped bypass: check-experimental-shelf imported playwright by file path
check-experimental-shelf imported resolve(ROOT, 'node_modules/playwright/index.mjs')
dynamically, named no package specifier, and so launched Chromium outside the
slot while check:browser-gates-guarded passed it (upg100 found and measured it).
It now imports the guard, and an unslotted run exits 75. The check also fails a
path to node_modules/playwright (or playwright-core) anywhere in code; with the
old import restored it fails that file.
2026-09-28 09:07:30 +00:00
Doxa Maps
5a36f17467 check:packets-all — exit 75 is NOT MEASURED, not FAIL
Landing 48's browser guard made check-packet-render refuse to launch outside the
browser slot and exit 75. This gate read `ok = res.status === 0` -- two outcomes,
not three -- so it reported "5 packet(s) FAILED, 0 passed" about five packets it
had never opened, naming five innocent files. Someone would have gone looking for
a rendering bug that does not exist.

run-parallel.mjs:262 states the contract: 75 (EX_TEMPFAIL) means the gate COULD
NOT LOOK, which is neither a pass nor a failure. Unmeasured packets now print ⊘
and are excluded from the pass/fail count, with the count of them in the summary.

AND THE CASE THAT MATTERS MORE: zero failures and zero looks are the same
`failed.length === 0`. Without a guard, a run that could not open a single packet
prints "PASS -- every generated packet renders" -- the most flattering possible
lie about a measurement nobody took, and the same shape as the stale-artifact bug
this file was fixed for earlier today. When nothing was measured it now exits 75
itself, naming the command that runs it in the slot, so the runner buckets the
whole gate NOT MEASURED rather than green.

Verified: outside the slot the gate now exits 75 and says so, instead of exiting
1 with five false failures.
2026-09-28 09:07:29 +00:00
Doxa Maps
462dd67982 legends: physical left/right -> logical, where the rule should mirror
Seven conversions, one deliberate keep, and a stale comment.

CONVERTED -- each is text that should follow the reading direction:
  LegendDesktop.vue:138,139  the count and population chips, text-align:right ->
    end. They are numeric badges at the trailing edge of a row; in RTL the
    trailing edge is the left.
  LegendDesktop.vue:154,157  the stats and footer VALUE columns, right -> end.
    Same argument, and they already used padding-inline-end, so the rule was
    half-logical and read physical.
  SemanticTreeLegend.vue:711,1105  two button labels, text-align:left -> start.
    Label text in a flex row; it follows the reader.
  SemanticTreeLegend.vue:764  .stl-tab-info border-left -> border-inline-start.
    This is a DIVIDER between two adjacent chips. In RTL the info button sits on
    the other side of its sibling, so the divider has to move with it or it
    draws on the outside of the pair.

KEPT PHYSICAL, deliberately:
  SemanticTreeLegend.vue:805  .stl-hdr-num::after { left:50%; top:50% } with
    transform:translate(-50%,-50%). That is the CENTRING IDIOM for a 24x24 WCAG
    2.5.8 hit target, not a direction: it computes identically in both, and
    converting it would add review noise that hides the real conversions.

PROVEN, not asserted: measured the GLYPH POSITION in a browser rather than the
computed keyword, because getComputedStyle returns text-align as the keyword
("end"), so a naive comparison reports DIFFER in LTR for rules that lay out
identically. Measuring the span's left edge in a 200px box:
  LTR  right=201px end=201px IDENTICAL   left=8px start=8px IDENTICAL
  RTL  right=201px end=8px   MIRRORS     left=8px start=201px MIRRORS
And for the border, where computed style does resolve: LTR 1px/0px both ways
(identical), RTL 1px/0px vs 0px/1px (mirrors).

Also fixes a comment that said "margin-left:auto pins it to the right edge"
above a rule that already read margin-inline-start:auto. Prose naming the
physical side is how a reader talks themselves back into a physical property.

library/ is the build owner's under rule 5B -- source sha to studio.
2026-09-28 09:07:29 +00:00
Doxa Maps
cdcdd84cf9 verify:browser-all reads a gate's exit 75 as NOT MEASURED, not as a failure
It counted any non-zero exit as FAIL, so a gate that could not measure (no
browser slot, a server mid-build) read as a broken product, the same bug
upg100 found in check:packets-all once the browser guard made gates refuse
outside the slot. Exit 75 is now NOT MEASURED, shown as such, and a run with
no failures but any unmeasured gate exits 75 itself (INCOMPLETE), never green.
2026-09-28 09:07:29 +00:00
Doxa Maps
a5eb9f6fa6 Make the all-regions chooser's resting pins easier to see
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The Driver reported the pins as hard to see. On the chooser this is the state
that answers it: what the page shows on load, before any hover.

Two things make a pin harder to read here than on a cloned country page, and
neither is a matter of taste. The chooser renders the globe, so every pin sits
at the lowest useful zoom instead of a framed close-up; and the 1px white stroke
is a fixed cost, so at radius 3 it consumes a third of the pin's width and the
coloured core carrying the region's identity is only about 2px. Raising the
resting radius from 3 to 4 at zoom 2 grows that core from 2px to 3px -- a ~50%
gain in the part a reader actually distinguishes, for ~33% more footprint.
Opacity goes to 1 for the same reason: 10% transparency over a basemap costs
contrast and buys nothing while nothing is being de-emphasised.

Only the chooser changes. The cloned country and region pages keep DX-life's
exact geometry, and the hover behaviour is untouched -- dimming the other
regions is the design saying "these pins are one group", which is a taste call
rather than a legibility defect.

Whether this is now easy enough to see needs eyes; the argument here is only
that the readable part grows faster than the footprint.
2026-09-28 09:03:26 +00:00
Doxa Maps
3747122e69 README: Rudder's desktop packaging exists now (bun run desktop:build rudder-voice, with its guide)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
2026-09-28 08:50:11 +00:00
Doxa Maps
e7fef6bb6f Rebuild the consumers main left stale (build owner, README rule 5)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
check:composite-freshness found stale on main: all-people-groups
cities-internet
country-posters
dashboard-plus-grid
demo-map
doxa-research-map
doxa-simple-map
gospel-internet
gospel-resources
key-cities
my-upg-100-list
pplr-data-maps
prayer-walk-planner
regions-dashboard
research-dashboard
template-bundle
upg-neighbor
wall-of-people-groups. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-28 08:41:26 +00:00
Doxa Maps
36887f4f28 gates:pre runs check:pin-selection, the headless test of both engines' GeoJSON source shapes 2026-09-28 08:31:24 +00:00
Doxa Maps
f44da45258 A test for the source reader, because a no-op cannot be seen any other way
dx-shepherd asked for this with a7b12b44 and the reason is exact: every caller of readSourceFeatures is
written to do NOTHING when it finds no features, deliberately, so that a legend row for an off-screen
region degrades quietly instead of throwing. That is why the MapLibre bug produced no error, no console
line and no visual difference except a camera that did not move — and why it survived until a packet
printed the wrong 50 people.

check:pin-selection drives the reader with fixtures for both engines and no map at all:
  · the MAPBOX shape (_data IS the FeatureCollection) reads its features;
  · the MAPLIBRE shape (_data is { geojson }) reads the SAME features — the regression that shipped;
  · a REMOTE source ({ url }) returns [] AND WARNS;
  · the serialize() fallback works, a missing source is [] rather than a throw, a bare Feature is one;
  · selectedPinIds ANDs its expressions, and with none returns every pin rather than none.

★ NEGATIVE-TESTED, which is the only thing that makes it worth having: run against the reader as it
shipped, it fails with exactly four findings — the maplibre shape reads 0 instead of 3, selectedPinIds
narrows to nothing, "no expressions" returns nothing, and the remote case returns [] silently. Against
the fixed reader it passes. A test that cannot fail on the code it was written for proves nothing.

★ AND THE REMOTE CASE IS NOW A DECISION, not an accident. dx-shepherd asked what { url } should do. It
returns [] and warns once per source: the features live on a server and cannot be enumerated locally, so
"I cannot read this" is a different fact from "there is nothing here". Collapsing those two is precisely
how the MapLibre bug hid for as long as it did, and no caller can tell them apart from an empty array.

Verified: check:pin-selection, check:mjs-parse, check:doc-scripts, check:package-json-unique-keys all
PASS. gates:pre was NOT run clean here — my main clone sits three landings behind origin/main, so a
newer package.json lists check:targets while that file is absent from the working tree; that failure is
my verification setup, not this change, and I checked it on an untouched tree before reporting.
2026-09-28 08:31:19 +00:00
Doxa Maps
8ce5b9fdd1 Reading a GeoJSON source found nothing on MapLibre, which broke more than the packet
rega's console answered the question in one line: "0 pin(s) selected but no rows matched", for both a
language-family selection and a prayer selection, with the pins plainly on screen. Not an id mismatch and
not a missing filter — readSourceFeatures returned an empty list.

THE TWO ENGINES STORE _data DIFFERENTLY. maplibre-gl's GeoJSONSource wraps it:

    this._data = typeof options.data === 'string' ? { url: options.data } : { geojson: options.data }

so `data.features` is undefined there. Mapbox stores the GeoJSON directly. This code was written against
Mapbox, where it works, and has been returning nothing on MapLibre ever since — which is the keyless
engine, the one the public builds use.

★ THE PACKET IS THE SMALL HALF. This reader is what zoomToLegendRow uses to answer "which pins does this
legend row select", and it backs research-map, country-posters and doxa-simple-map. On MapLibre every one
of them has been failing to fit the camera to a clicked legend row — silently, because the function is
written to no-op rather than throw when it finds no features. The original comment above it explains that
querySourceFeatures is viewport-clipped and would make off-screen rows "silently no-op"; the replacement
did exactly that on one engine, for a different reason.

The wrapper is unwrapped, and a URL source returns [] explicitly — having no local features to read is a
different fact from finding none, and collapsing the two is how this hid.

Verified: gates:pre 13/13. Needs eyes, and the second one matters more than the first:
  · the packet on MapLibre prints the SELECTED groups, cover count no longer the whole list;
  · clicking a legend row on MapLibre FITS THE CAMERA to that row — on research-map, country-posters and
    doxa-simple-map. If that has been dead on the keyless engine, this is the commit that revives it.
2026-09-28 08:31:18 +00:00
Doxa Maps
1223fa7b45 check:studio-rail — scope the label rule by PROFILE stage, not bundle stage
regions-plugin challenged the rule and was right. A BUNDLE reads
`stage: production` when ANY ONE of its profiles is production, so scoping the
loop by b.stage drags every experimental sibling in with it.

Measured: ALL SEVEN violations it reported were false. cities-internet is a
production bundle whose connectivity-list and data-tables are EXPERIMENTAL;
gospel-resources likewise for charts / data-tables / resources-list; and
my-upg-100-list's own poster-wall and upg-100-list are experimental too -- only
upg-100-mfe is production, and it has carried a label all along. With p.stage the
gate reports 0.

THE SAME TRAP A THIRD TIME. I moved off stageOf (bundle-keyed) after catching it
once, and moved to b.stage -- the same mistake wearing a different field name.
The profile's own stage is the only one that answers "is this row production".

THE SELF-TEST HAD A HOLE THAT MADE THE BUG INVISIBLE: every fixture bundle had
exactly one profile, so bundle-stage and profile-stage always agreed and scoping
by the wrong one could not be detected. A fixture where the two CANNOT disagree
cannot test which one you read. Adds a mixed bundle whose production profile must
FAIL and whose experimental profile must PASS; 6/6.

The two meta files in be398ce8f stay. They were a fix for a non-problem -- both
profiles are experimental -- but a named row is better than a humanized id, and
they cost nothing.
2026-09-28 08:31:18 +00:00
Doxa Maps
114df6f6c4 check:studio-rail — a fixture self-test for the declared-label rule
The rule is extracted as a pure undeclaredLabels(manifest) so it can be tested
over a fixture without standing up Vite or needing a built tree, and runs before
that setup so `--self-test` works on a fresh clone.

Four planted cases, and the SECOND is the one that matters -- a rule that fails
everything is as useless as one that fails nothing:
  production + no label          must FAIL
  experimental + no label        must PASS   (the stage scope is the design)
  production + a real label      must PASS
  production + whitespace-only   must FAIL   (`label: "   "` satisfies a null
                                              check and still renders blank)

Wired as check:studio-rail:self-test. Verified the extraction changed no
behaviour: the live gate still reports the same 8 violations it did before.

It exists because the first version of this rule PASSED on a manifest that
contained the very defect it was written for -- humanize(id) meant the rendered
label was never empty, so nothing could fail.
2026-09-28 08:31:18 +00:00
Doxa Maps
112f4cee77 check:studio-rail — a production profile must DECLARE its name
The rail showed tiles nobody had named while this gate stayed green. The obvious
fix is a no-op, and writing it first is how that came out.

THE NO-OP: testing the rail's label for null/empty can never fail, because
rowsFromManifest falls back to humanize(id) (plugin-studio.vue:421,429). A
profile that declares nothing still arrives as "Doxa Simple Map" -- not empty,
and not equal to the bare id either, so the existing bare-id rule misses it too.
humanize() defeats BOTH rules. That is why country-posters could ship a tile
nobody had named.

So the property is the DECLARATION, read from the manifest, not the rendered
label: does this profile's <profile>.meta.json carry a "label"? A humanized id is
a reasonable DEFAULT and a bad NAME -- "Doxa Simple Map" tells a reader nothing
the folder did not already say.

PRODUCTION-STAGE ONLY, deliberately: 15 profiles declare no label today and 14
are experimental -- work in progress nobody has named yet, which is not a defect.
A gate that is red for a condition the repo accepts gets ignored.

Also declares the two labels my own plugin was missing: upg-100-list becomes
"UPG-100 — Zoned Map" and poster-wall "UPG-100 — Poster Wall".

⚠ THIS GATE IS RED ON MAIN TODAY, 8 violations across 4 plugins and 4 owners
(cities-internet 2, doxa-simple-map 1, gospel-resources 3, my-upg-100-list 2).
It must NOT land before those labels do, or it blocks every other agent's
landing for a defect that is not theirs. The two meta files here fix my share;
they reach the manifest only through the full build, which is the build owner's.

internal/ is the build owner's under rule 5B -- source sha to studio, sequenced
after the other three owners declare their labels.
2026-09-28 08:31:18 +00:00
Doxa Maps
a02458a171 documentation: which gates need a browser, and what exit 75 means
238 checks; 148 run anywhere, 87 need a real browser, 3 shell out. A newcomer
cannot tell from the name -- check:packet-render sounds like a file check and
drives a browser; check:composites-mount sounds like it needs one and does not.
The test is one grep for playwright/chromium, and the doc says so.

The part worth writing down is exit 75. run-parallel.mjs:262 defines three
outcomes, not two: 75 (EX_TEMPFAIL) is NOT MEASURED -- the gate could not look --
and a gate that exits 0 while announcing SKIP is also NOT MEASURED. Only another
non-zero is a FAIL. Two traps follow: exit 0 alone does not mean a gate looked at
anything, and your own `timeout` produces 124, which says you stopped it, not
that the code failed.

Also states the rule the split exists for -- one machine, one browser seat, so
one person runs them -- and why it is not just logistics: a source read and a
render are different measurements. Four catalogue gates can pass while a page
still paints English; and of 16 cards parked as "needs a browser", 7 were about
markup, a path, a manifest field or a gate, and settled headlessly.

Every number measured on main d8baaefd2 rather than estimated, including
correcting gates:pre from 13 to 15 after re-running it.
2026-09-28 08:31:18 +00:00
Doxa Maps
1a48e26a82 i18n: a page the plugin owns gets <html lang> = the locale, not only dir=rtl 2026-09-28 08:31:18 +00:00
Doxa Maps
a59db6c4a1 Rebuild the consumers main left stale (build owner, README rule 5)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
check:composite-freshness found stale on main: none. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-28 08:26:01 +00:00
Doxa Maps
ba131ca4a7 check:browser-gates-guarded's comment no longer spells a literal playwright call
The runner recognises a browser gate from its file, comments included, so the
check's own comment made it one and it went NOT MEASURED on a headless seat.
The comment is reworded; detection is unchanged (stripping comments instead
dropped four real browser gates).
2026-09-28 08:20:21 +00:00
Doxa Maps
79296f87fb A fresh clone's first bun run dev line says Rudder is optional and needs Go 1.22+
The dev server's first line on a fresh clone was a warning that rudder-engine is
not built, with a go build command and no word that it is optional or needs Go.
A backend can now carry an optional note, printed as information instead; Rudder's
says it is desktop voice-to-text, fine to skip for the maps, needs Go 1.22+, and
points at the desktop HOWTO.
2026-09-28 08:20:21 +00:00
Doxa Maps
22a1561d2f check:browser-gates-guarded matches a real playwright import, so no detector needs a whole-file exclusion
Excluding the two detector files by path also skipped any real import added to
them. The check now matches only a static import statement at the start of a
line or a literal import('playwright') / require('playwright') call, which the
detectors' escaped regex literals never are; only the guard itself is excluded.
Planted a real import in browser-gates.mjs and one in this check: both fail.
2026-09-28 08:20:21 +00:00
Doxa Maps
462f553442 check:browser-gates-guarded names its detector files by path
internal/lib/browser-gates.mjs holds the regex that looks for a playwright
import, so the check read it as an import once the file was tracked. The guard
and the two detectors are excluded by path, so a real import added to one of
them still fails.
2026-09-28 08:20:21 +00:00
Doxa Maps
78eb6bdcb8 check:packets-all — skip stale artifacts, and follow the series on the funnel
Two defects, both found by the done-card sweep rather than by looking for them.

1. A STALE ARTIFACT WAS GRADED AS A DEFECT, which is this gate's own recorded bug
   recurring. BUG-PACKET-SWEEP-GRADED-AN-ACCIDENTAL-SET-OF-GITIGNORED-ARTIFACTS is
   marked DONE, but the card was closed without the mechanism changing: the gate
   still readdirSync'd every .html with no freshness test. It went red again on 43
   packets left from a run three days earlier -- reporting "0 prayer tick boxes"
   about files generated before tick boxes existed, so they could not have had
   them.
   The directory is gitignored (.gitignore:94), so nothing in it reaches main or
   another agent; it is one developer's scratch. The gate already knew an EMPTY
   directory is not a failure ("a clean checkout has none"); a directory of
   LEFTOVERS is the same fact wearing different clothes. A packet older than the
   renderer that produces it is now SKIPPED, loudly, with the command to
   regenerate it. The renderer's mtime is the reference because that is what
   decides the shape a packet should have -- comparing against "today" would
   re-fail every morning.

2. THE UNCONDITIONAL --expect-funnel STOPPED BEING TRUE. The comment above it says
   every packet comes from packet.mjs "which always passes FUNNEL". That held
   until SERIES landed: a country packet DROPS the funnel page deliberately,
   because the funnel narrows every people group on earth down to THIS UPG-100
   list, which is not a description of a country. So the gate failed a correct
   packet.
   The expectation now follows the series that produced the packet, identified by
   the series' own NETWORKS text surviving into the artifact, and read from
   series.json rather than hardcoded -- adding a series with funnel:false must not
   require editing this gate. A packet whose series cannot be determined keeps the
   strict expectation, because the safe default for "I do not know" is the
   stricter check.

Verified: PACKETS-ALL PASS with 43 stale skipped and the country packet accepted;
and a POISON TEST inside the tree (a upg100 packet with its funnel-title removed)
is still caught -- so the relaxation discriminates rather than passing everything.
The first poison attempt ran outside the checkout and failed on a missing strings
catalogue, proving nothing; re-run in place.

internal/ is the build owner's under rule 5B -- source sha to studio, not landed
here.
2026-09-28 08:20:21 +00:00
Doxa Maps
fbc517c94a A browser gate refuses to launch outside the browser slot; check:browser-gates-guarded keeps it that way
Only the browser seat runs browsers, and only inside internal/browser-slot.sh.
That rule was remembered, not enforced: an agent ran a gate it took for a
headless source check and it launched Chromium outside the slot.
internal/lib/playwright-guarded.mjs is now the one way a repo file reaches
playwright: its launch(), launchPersistentContext(), launchServer(), connect()
and connectOverCDP() exit 75 (NOT MEASURED) with a message naming
browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it
for the command it runs, so a bash -c grandchild inherits it. All 72 files that
imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates;
plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2,
research-map-plus-grid 2, research-dashboard 1): a mechanical import swap.
check:browser-gates-guarded (gates:pre) fails a direct import;
bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate
moves to internal/lib/browser-gates.mjs so the runner and the check share it.
2026-09-28 08:20:21 +00:00
Doxa Maps
80167548c5 Rebuild doxa-simple-map, and regenerate the tree
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
2026-09-28 08:09:12 +00:00
Doxa Maps
e335d56409 Prayer Progress: the control column follows the reading direction, on MapLibre too
The legend and search column were placed with left:10px and styled only under Mapbox's
.mapboxgl-ctrl-top-left, so on the keyless engine they kept MapLibre's defaults and in
Arabic stayed on the left. Both engines' classes now take inset-inline-start (the same
fix country-posters carries), and the help close button and the detail labels use
logical sides.
2026-09-28 08:08:19 +00:00
Doxa Maps
e893db3718 Name the three gospel profiles the Studio was calling by their ids
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
charts, data-tables and resources-list had no <profile>.meta.json at all, so the manifest carried
no label for them and the Studio rail humanized the folder name: "Charts", "Data Tables",
"Resources List". Those say what the file is called, not what the page answers.

Each now has a meta with a label and its stage:

  charts          Gospel Resources -- where the gap is, by language
  data-tables     Gospel Resources -- every resource and every gap, in tables
  resources-list  Gospel Resources -- what each people group has

All three declare "stage": "experimental" EXPLICITLY, which is what they already were. A meta-less
profile and a meta without a stage key both default to experimental in profile-meta.mjs, so the
value would otherwise rest on that default -- and these three sit in a bundle whose stage reads
production through gospel-dashboard, so a later change to the default would silently promote them.
Measured after the change: the three stay experimental, gospel-dashboard stays production, and
bundleStage still returns production.

Type is deliberately not declared. generate-tree resolves the composition tier from meta.json
`type`, not from whether the file exists, so omitting it leaves all three inferred `single`
exactly as before. Nothing in the manifest moves except the three labels and three stage values
that already held.
2026-09-28 07:54:11 +00:00
Doxa Maps
96ba45b966 Name the two unlabelled Cities with Internet profiles
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
connectivity-list and data-tables had no profile meta at all, so the Studio and
the gallery fell back to a humanised profile id: a reader picking a view saw
"connectivity list" and "data tables" rather than what the pane actually is.
The sibling connectivity-map already carried a label; these two are now in step,
so the three read as one family in the picker instead of sorting apart.

The wording is not invented. Each pane renders its own heading from i18n —
connectivity.list.title is "Cities with Internet" and connectivity.tables.title
is "Cities Data" — so the labels lead with the product name the reader already
sees, then name the view, matching the shape connectivity-map established.

Both metas declare stage experimental explicitly. That is the existing default
for a profile with no meta, so nothing is promoted and the manifest is byte-for
-byte unchanged; it is stated because bundleStage turns a whole bundle
production as soon as one profile claims it, and a stage left implicit is one
default change away from moving on its own.

Source only, no bundle rebuild: the label is read from the meta by the gallery's
discovery, and never travels into manifest.json.
2026-09-28 07:44:45 +00:00
Doxa Maps
d8baaefd2e Country posters and Prayer Progress: every profile has a name in the manifest
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
countries-map, country-poster-wall and doxa-simple-map carried label:null, so the Studio
rail and the gallery showed their ids. Each now has a label in its meta; types are
unchanged (doxa-simple-map stays parameterized).
2026-09-28 07:33:23 +00:00
Doxa Maps
cb72f1b786 Rebuild country-posters, and regenerate the tree
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
2026-09-28 07:27:12 +00:00
Doxa Maps
97903f756a Country posters: pre-warm skips only paints that already read the feature
An array is not the same as data-driven: a zoom-only interpolate is an array and
still compiles a variant on the first gesture. The warm-up now leaves alone only values
that read a feature property, and wraps constants and zoom-only expressions.
2026-09-28 07:26:59 +00:00
Doxa Maps
4b941be4bf Country posters: the packet prints the selected groups, not the first fifty loaded
Choosing "Asia" in the legend printed the same first 50 groups as any other row,
Sudan included. The packet now takes the pins the selection shows, through the shared
library/maps/pin-selection.js (reading pins as drawn, not raw rows). Selected pins
that match no rows fall back to the full list with a warning; an empty selection
reaches the printer's own error; over 50, the cover says '50 of N'.
2026-09-28 07:26:58 +00:00
Doxa Maps
16b2e7aeaf Country posters: the first country highlight no longer freezes on a shader compile
highlightCountryByIso makes the regions fill and border paints data-driven on a click,
and the first such click paid a one-off shader-variant compile (rega measured 5.5 s /
11.1 s on the research map). The three paints are now data-driven from the moment the
layer is added, with identical branches so nothing changes on screen.
2026-09-28 07:26:58 +00:00
Doxa Maps
7c360016fa Rebuild the consumers main left stale (build owner, README rule 5)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
check:composite-freshness found stale on main: dashboard-plus-grid
doxa-maps-spa
doxa-research-map
gospel-resources
plugin-studio
research-map-plus-grid
stack-demo. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-28 07:16:22 +00:00
Doxa Maps
e020e68bca Skip a paint that is already data-driven, not one that merely looks like an expression
exp-shepherd's copy of this pre-warm is better than mine in one way and accidentally right in another,
and both are worth taking.

BETTER: it leaves an existing expression alone instead of wrapping it. Mine wrapped regions-border's
line-color — which arrives as a region-coloured match on a feature property — into a case around that
match. No visual change, but a gratuitous rewrite of an expression that was already data-driven and had
no variant left to warm.

ACCIDENTALLY RIGHT: their test is "is it an array". For this layer that coincides with the right answer,
because the only array here is that match. It is not the right test in general — a zoom expression like
['interpolate',['linear'],['zoom'],…] is also an array and is NOT data-driven, so skipping it would
leave that property to compile its variant on the first gesture. That is the partial pre-warm this whole
exercise exists to avoid, and it would measure as a failure with no visible cause.

The test is now whether the expression reads the FEATURE — get, has, or feature-state, at any depth.
Constants and zoom-only expressions get wrapped; anything already keyed on a feature is left exactly as
the layer wrote it. The regions layers have no zoom expression today, so this changes nothing that runs
right now; it means the rule is correct for the reason it is correct.

Verified: gates:pre 13/13. Chained on 790d8838.
2026-09-28 07:07:34 +00:00
Doxa Maps
f83b3a195d The pre-warm was repainting the borders it was only supposed to warm
Found by reading addRegionsLayer while checking exp-shepherd's copy of this fix, not by anything that
failed. My pre-warm called paintRegionScope(map.value, []) — the CLEARED look: flat grey borders at
width 0.6. The layer does not start there. useMapLayers adds regions-border with line-color set to the
REGION-COLOURED match expression and line-width 1, so the moment the Regions tab was first opened, every
border turned grey and thinner than the layer had defined.

Nothing was going to catch it. The shader compile moved as intended, the write count was unaffected, the
highlight still worked, and the checks in flight were a timing measurement and a write count — a change
of appearance is invisible to both. It would have been found by a person looking at the map, eventually,
and blamed on something else.

prewarmRegionPaint reads each property back and re-writes it wrapped in a case whose two branches are
the value it already had. The appearance is preserved exactly as the layer defined it, and the property
still becomes data-driven, which is all the shader variant depends on. A property the layer does not
define is skipped rather than invented, and one already wrapped is left alone so a second call cannot
nest cases.

paintRegionScope keeps its idle values for the CLEAR path, which is a different thing from the initial
state and always was — conflating the two is what caused this.

Verified: gates:pre 13/13. Chained on 196eca13. Needs eyes: rega — on the first visit to the Regions
tab, the country borders should be REGION-COLOURED at their original width, not grey.
2026-09-28 07:07:34 +00:00