Commit graph

48 commits

Author SHA1 Message Date
doxa-maps-88655c70
413fa30d19 isMap per profile: a map-less leaf no longer bakes, or preloads, a map engine it never uses
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
generate-tree decided a single leaf's map-ness as "its own .vue
references an engine OR anything under the BUNDLE's src/ does", so a
map-less profile inherited it from a sibling. regions-dashboard/
regions-list shows a list and no map, but src/RegionCountryMap.vue made
it isMap=true. Its leaf loaded the Mapbox engine for nothing, and since
4a6886d2 its embed snippet also preloaded it.

A leaf's map-ness now follows the RELATIVE imports reachable from its
own .vue, bounded to its bundle, so a map several imports deep
(research-dashboard's DashboardMapPanel) still counts. An explicit
boolean isMap in <profile>.meta.json wins. Composites keep their
separate rule, since they do mount their siblings.

Measured: across all 34 profiles exactly one changed (regions-list,
true -> false). 18 map profiles keep isMap and their preload; 0 non-map
snippets carry one. On a served built tree regions-list makes 0 engine
requests and 0 api.mapbox.com requests and renders its list (673
elements, no errors), where before it downloaded the 1.75 MB engine.
regions-mfe still loads the engine and draws its map. The regenerated
files are only the regions-list leaf, the two manifest copies (still
byte-identical), the staging page and the gallery; check:sri passes.
2026-09-25 10:17:33 +00:00
doxa-maps-88655c70
bf9644854d Apply the engine preload to the committed tree's map snippets
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
manifest.json, the staging page's baked copy and the regenerated
gallery, by the same rule generate-tree now uses (map profiles only).
check:sri passes; no non-map snippet carries a preload.
2026-09-25 08:56:10 +00:00
doxa-maps-88655c70
a47b232122 Remove the Vision Card's old fake stats from the manifest and the gallery
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
33bd7ca3 stripped the frontier instance's typed-in stats (1,910 / 2.1B /
0.1%) by text search for '"stats":[', which missed the manifest because
it is pretty-printed ('"stats": ['). The gallery, regenerated from that
manifest, carried them back. Removed by parsing the manifest as JSON,
then regenerated the gallery. No old figure is left in the card's pages,
the manifest, the staging page or the gallery; check:sri passes.
2026-09-25 07:19:23 +00:00
doxa-maps-88655c70
33bd7ca387 All People Groups loads fast on a light map; the Vision Card becomes a true funnel from data
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
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.
2026-09-25 07:18:30 +00:00
doxa-maps-88655c70
193127e257 Stop publishing the template scaffold's placeholder pages
Some checks are pending
build-gate / build (push) Waiting to run
template-bundle is the scaffold DX copies to start a plugin: its source
ships, but it is not a plugin. The dev manifest already skips it by
name (discover.mjs), while the build published it, so the built studio
listed template-map-a and template-map-b as two blank-looking plugins
that the dev studio never showed.

generate-tree now skips it by the same name rule, so the built
manifest lists exactly what dev lists: 21 bundles, 33 profiles. The
committed tree is brought in line without a full regeneration: the
entry is removed from manifest.json and from the staging page's baked
copy, the two leaf pages are deleted, and the archived gallery is
regenerated from the manifest. It is not added to gatedProfiles, which
is the private-data list.

Verified: check:sri and check-manifest-covers-discovery pass, the
staging page renders with no errors, and a sweep of the built studio
shows 31 plugins and no template rows.
2026-09-25 04:05:59 +00:00
doxa-maps-88655c70
baf302983e Rebuild the served tree after merging main, and unswallow two self-test flags
The merge surfaced what main's new script-flags gate was written for: two
gospel-resources entries advertised --poison inside an inline # comment, where
the shell drops it — a self-test that never runs is a gate certifying itself.
Each flag now has a runnable sibling entry (check:snapshot-guard:poison,
check:coverage-consistent:poison) and the prose points at the entry instead of
the raw flag. Both siblings executed: the injected defects are caught (12/12
clauses). capability-exemplars regenerated; full build green, 34/34 post gates.
2026-09-24 14:23:20 +00:00
doxa-maps-88655c70
387dc3bb3f Refresh the gallery's baked SRI after rebasing onto main
Some checks are pending
build-gate / build (push) Waiting to run
The rebuild that follows a rebase re-hashes the bundles, and the gallery bakes those
integrity hashes into its page. A stale hash is the silent failure this repo has
paid for: the browser refuses the script and the page mounts nothing.
2026-09-24 11:42:33 +00:00
doxa-maps-88655c70
9c802b556f Rebuild the served tree after rebasing onto main
Some checks are pending
build-gate / build (push) Waiting to run
Five conflicts, all in public/ — build OUTPUT, where neither side is authoritative,
so it is resolved by rebuilding rather than picking hunks. Six files differ from the
hand-resolution, which is why that rebuild is not optional.

No source file overlapped: the upstream commits were regions-dashboard work, mine
is the packet-browser gate and its two renderer test hooks.
2026-09-24 10:52:47 +00:00
doxa-maps-88655c70
3519ca3934 check:packet-browser asserts its headings, and stops reading them in English
This gate printed nine metrics, folded six into `const bad = a || b || ...` — so a
failure said "FAIL" without naming which — and left three unchecked: page size and
the two headings, which are the very strings its own header claims it covers ("the
gate reports which headings came back translated"). Reports, not asserts.

★ IT LOOKED LIKE IT HAD FOUND A PRODUCT BUG, AND HAD NOT. Under ?lang=es it printed
an EMPTY wheel heading; under hi it blanked BOTH — while the catalog holds "Rueda de
Oración" and "प्रार्थना चक्र". That reads exactly like a broken i18n path, and it
PASSED anyway, because nothing asserted the values.

The cause was the harness. wheelTitle was innerText.match(/^.*Wheel.*$/m) and
pointsTitle /Prayer Points|Puntos/ — neither can match Hindi, and /Wheel/ cannot
match Spanish. The product was correct in every locale all along: verified after the
fix in en/es/hi/ar/zh, RTL and Devanagari included. Had I reported that as a defect
I would have been wrong.

mark5 carried the same fault: innerText.includes('Gadarenes'), an English proper
noun that goes RED the moment point4_passage is translated. Now asserted by
.p4-passage length — the same fix already applied in check-packet-render.

Headings are now compared to the CATALOG entry for the packet's language, with
per-key fallback to English exactly as the renderer resolves it. packet-render.js
gains class="points-title"/"wheel-title" as TEST HOOKS: both were inline-styled with
no selector, which is what forced the English greps in the first place.

MUTATION-TESTED: a renderer forced to ignore the language FAILS with both headings
named and the expected values shown; a renderer emitting an empty heading FAILS
("a blank heading is worse than an English one").

★ ONE MUTATION CORRECTLY PASSED, and it marks a real limit: reverting es
wheel_title to English IN THE CATALOG passes, because rendered and expected then
legitimately agree. This gate cannot tell a translated catalog from an untranslated
one — that is check:packet-strings-parity's job, and it does assert it. Two gates,
two questions.

Partial translation re-verified through the BROWSER path: a 1-of-21-key Swahili
block renders "Mambo 12 ya Maombi" with the wheel heading falling back to "Prayer
Wheel" — translated where present, English where not, neither blank.

That makes four gates with one root cause (tabs-localized, packet-render,
deck-click, and this one twice). Any new gate matching on user-visible text should
resolve it from the catalog.
2026-09-24 10:50:27 +00:00
doxa-maps-88655c70
aff07bc234 Refresh the gallery's baked SRI after rebasing onto main
Some checks are pending
build-gate / build (push) Waiting to run
The rebuild that follows a rebase re-hashes the bundles, and the gallery bakes
those integrity hashes into its page. A stale hash is the silent failure this repo
has paid for: the browser refuses the script and the page mounts nothing.
2026-09-24 10:20:21 +00:00
doxa-maps-88655c70
87bd98d610 Restore the clickable poster preview — the last capability the migration dropped
Some checks are pending
build-gate / build (push) Waiting to run
Migrating my-upg-100-list onto the shared wall left one behind, and it was not
decoration: clicking the poster preview opened it for printing, a Driver-named
behaviour ("show the poster in the browser to help print it"). I removed it, so
this is a regression I introduced and am closing.

previewAction: 'none' | 'print'. Default 'none' keeps the image inert, which is
exactly what country-posters and key-cities render today.

★ IT IS A KEYBOARD CONTROL, NOT JUST A CLICK TARGET. The original carried
role="button", tabindex="0" and Enter/Space handlers alongside @click. Re-adding
the click alone would have been a half-restoration that LOOKS complete — an <img>
with only @click is unreachable without a mouse, and the focus-visible outline is
how a keyboard user sees where they are on a control that is otherwise a picture.
The alt and title change with the mode too: "prayer poster" and "click to preview
and print" are different promises to a screen reader.

buttons.previewPrintPoster added to all eleven locales in the same change;
check:i18n-parity still reports 0 keys of debt.

VERIFIED AT THE RENDERED LAYER, and exercised BY KEYBOARD rather than by mouse:
  100-list (preview-action="print") — 27 previews, is-clickable, role=button,
  tabindex=0, cursor zoom-in, translated title, descriptive alt. Focused the image
  and dispatched ENTER: 1 print frame opened, 0 downloads, 0 errors, with every
  .webp ABORTED so it ran through the rasterise fallback.
  country-posters (default) — 174 previews, is-clickable false, role null,
  tabindex null, original alt. Inert exactly as before.

That completes the consolidation: my-upg-100-list 890 -> 179 lines, and all four
walls now render from one 633-line component with nothing left unported.
2026-09-24 10:02:22 +00:00
doxa-maps-88655c70
649b0872eb my-upg-100-list renders from the shared PosterWall — 890 lines to 174
Some checks are pending
build-gate / build (push) Waiting to run
The original wall is gone: 716 lines of duplicated markup, CSS, export modal and
print wiring deleted. This profile now supplies only what cannot be shared —
REGION_OF/regionFor (its taxonomy, keyed by list id because a selector can be a
wagf_block, a country_codes array or pg_slugs and none of those implies a
continent) and fetchListRecords (the people-groups API, the selector evaluator and
the pg_slugs special case). It joins country-posters (94 lines) and key-cities (75)
on one component, which is what the Driver asked for: "don't recreate the wheel but
imitate upg 100 list".

It took three capability additions across three ticks, because migrating without
them would have REGRESSED this wall rather than consolidated it: cardStocks, then
posterAction="print" and hidePrayerListWhenSoon — the last two a Driver ruling, not
a preference.

★ TWO REAL BUGS, BOTH INVISIBLE AT THE SOURCE LAYER, BOTH BUILDING CLEANLY.

1. groupBy SIGNATURE MISMATCH. regionFor takes an ID; the shared wall's groupBy
takes the whole ITEM. `:group-by="regionFor"` compiled, the page loaded, and then
threw `c.startsWith is not a function` at first render — the whole wall blank, zero
cards, nothing wrong in the source. Adapted in the template rather than changing
regionFor, since the id is what the taxonomy is keyed by.

2. A THROWN FETCH IS A MISS, NOT AN ERROR — and this one is a robustness gap in the
SHARED component, affecting every consumer. fetch REJECTS rather than returning
!ok when a request is blocked or offline, and exportPoster let that reject escape to
the outer catch, skipping the rasterise fallback and showing "Failed to fetch" on a
poster the browser could have generated from posters/<id>.html. The wall this
component was EXTRACTED FROM already guarded it; the extraction did not carry the
guard across. check:poster-print-fallback went PASS -> FAIL with 0 .html requests,
and is green again.

VERIFIED AT THE RENDERED LAYER, all three print actions on the migrated wall:
27 cards, 27 stock pickers, 4 buttons per card — the original's three prints plus
show-on-map. CARD DECK 116 cards / 116 QR codes / @page 2.5in x 3.5in / 0 errors,
identical to pre-migration. POSTER PRINT with every .webp aborted: 1 .html fetched,
"generating the poster", clean finish. PRAYER LIST: 1 people-groups fetch, 0 errors
— positive evidence the provider ran, since that print cannot work without it.
REGRESSION CHECK: country-posters still downloads (0 print frames, 1 download), so
the default is unchanged for the walls already on this component.
2026-09-24 09:54:40 +00:00
doxa-maps-88655c70
626603ca3f Rebuild the served tree after rebasing onto main
Some checks are pending
build-gate / build (push) Waiting to run
Another agent pushed during the build, so main-capture's scan refused the push —
working as intended, and the lock released on the ';' as it must. The conflict was
in public/doxa-maps/index.html: build OUTPUT, where neither side is authoritative,
so it is resolved by rebuilding rather than by picking hunks. Ten files differ from
the hand-resolution, which is why that rebuild is not optional.

No source file overlapped: their commits were worldmap.svg provenance, mine is
PosterWall. 22/22 post-gates green after the rebuild.
2026-09-24 09:42:50 +00:00
doxa-maps-88655c70
9f076ffbc8 PosterWall can print the poster instead of downloading it — the Driver-rejected UX it still encoded
★ THE BLOCKER ON MIGRATING THE 100-LIST WAS NEVER ONLY A MISSING CAPABILITY. It was
a Driver-rejected UX baked into the shared component, and migrating as planned would
have shipped it.

my-upg-100-list/poster-wall.vue:280 records the ruling verbatim (2026-09-23): "three
print buttons, each opening an in-browser print dialog, no silent file download as
the default action. A raw 'Download' button and a 'Prayer List: coming soon'
placeholder were exactly the two buttons he objected to." exportPoster(),
downloadPacket() and download() were REMOVED from that wall for that reason.

PosterWall still had both: exportPoster() downloads a .webp unconditionally, at two
call sites, and downloadPacket() renders the "coming soon" placeholder whenever
there is no records provider. Consuming it would have reintroduced the exact two
buttons the Driver named.

Two props close that, both defaulting to today's behaviour so country-posters and
key-cities are untouched:

  posterAction: 'download' | 'print' — 'print' opens the OS print dialog on the
  poster. BOTH delivery paths (the shipped .webp and the browser-rasterised
  fallback) now route through ONE deliverPoster(), so the modes cannot drift or be
  applied to only one of them. Printed blob urls are held until unmount rather than
  revoked at print time: the preview <img> is still reading from them, and revoking
  early prints a blank sheet.

  hidePrayerListWhenSoon — suppress the Prayer List button rather than show it as a
  placeholder. The placeholder stays honest where the feature is genuinely unbuilt;
  it is only wrong on a wall that DOES print the list.

VERIFIED AT THE RENDERED LAYER, both modes, by counting the two outcomes that
actually distinguish them:
  poster-action="print"  -> 1 print frame opened, 0 downloads
  default (prop absent)  -> 0 print frames,       1 download
Counted at appendChild, where openPrintFrame attaches the iframe, rather than by
polling for iframes — the frame SELF-REMOVES on afterprint, so polling finds none
and reads as a failure.

⚠ Two probe mistakes worth recording, because both looked like product bugs: a HUD
selector that matched <script>/<style> text and returned the page's whole token
ladder, and a first click that landed on INDIA — which country-posters marks
`unsupported` (855 groups do not fit a sheet), so it short-circuited with zero
poster requests. Skipping cards showing .ph.is-soon fixed it; the next click fetched
country-chn.webp and completed.
2026-09-24 09:40:31 +00:00
doxa-maps-88655c70
a501f98e2c PosterWall can offer a card-stock picker — the capability keeping the 100-list on its own copy
Some checks are pending
build-gate / build (push) Waiting to run
The card's first step was a gap analysis: what does the 888-line my-upg-100-list wall
render that the shared component's props cannot express? Diffed both templates. They
are STRUCTURALLY THE SAME — wall/tag/sub, sections with head and count, a card with
"N UPG · Show on map", main with image-or-placeholder, a Print Options fieldset, the
same export modal. Exactly TWO real deltas:

  1. the CARD-STOCK PICKER — closed here;
  2. a clickable preview image (.img.is-clickable) — still open, and small.

Everything else that wall "owns" is data and taxonomy: REGION_OF, sections,
fetchListRecords. That is precisely what country-posters (94 lines) and key-cities
(75) already supply to this same component.

`cardStocks` takes true for every stock card-render.js supports, or an array for a
subset; `cardStock` remains the DEFAULT selection. Default false is today's behaviour
byte-for-byte — neither migrated consumer mentions the prop and both still build.

★ LABELS ARE DERIVED FROM card-render.js STOCK, never hand-written. The 100-list
carries its own DECK_STOCKS array duplicating all five labels, beneath a comment
saying they "come from the renderer's own STOCK table so this list cannot drift".
That was the intent, not the implementation. Deriving them makes it true, and the
migration will delete the duplicate.

⚠ The import is LAZY on purpose. usePosterPrints imports card-render.js dynamically
to keep a 10KB renderer out of every wall's initial bundle; importing it statically
for five labels would undo that for every consumer, including the ones that show no
picker at all. Options fill in when the module resolves and the <select> is not
rendered until then, so there is no flash of unlabelled ids. If the configured
cardStock is not among the offered stocks, the selection falls back to the first that
is, rather than printing at a size the reader was never shown.

buttons.cardStockAria added to ALL ELEVEN locales in the same change:
check:i18n-parity still reports 0 keys of debt. An English-only key would have broken
the ratchet emptied two ticks ago.

VERIFIED: the derivation unit-tested against the real STOCK table — all five stocks,
a subset, a subset EXCLUDING the configured default (falls back correctly), and an
unknown id (dropped, not rendered blank). Not yet exercised in a browser because the
picker is gated on a `records` provider and NO wall passes :records to the shared
component yet — the 100-list still holds its provider in its own file. Opting
country-posters in temporarily gave 0 pickers and 0 errors, which is that gate working
as designed. The migration will exercise it end to end.
2026-09-24 09:24:29 +00:00
doxa-maps-88655c70
faa78dfe2c Rebuild the served tree after rebasing onto main
Some checks are pending
build-gate / build (push) Waiting to run
The rebase brought in a tree regeneration, which re-hashes the bundles and rewrites
the gallery's baked SRI. A stale integrity hash is the silent failure this repo has
already paid for: the browser refuses the script and the page mounts nothing.

Both i18n gates re-verified at the rebased HEAD rather than assumed — packet-strings
parity and locale parity are green, and gates:post is 22/22.
2026-09-24 09:02:19 +00:00
doxa-maps-88655c70
e0f2d522bb All eleven locales complete — the last 162 keys of i18n debt, and the ratchet empties
Some checks are pending
build-gate / build (push) Waiting to run
regions.json for it/ro/zh, 54 keys each. check:i18n-parity now reports "0 key(s) of
known translation debt remain", closing the 429-key gap it found when it was written.

★ THE DEBT MAP IS EMPTY RATHER THAN FULL OF ZEROS, and that is the whole point of
this ratchet: a locale with NO entry must be complete — the gate fails it outright
for a single missing key — so an empty DEBT is the strongest state it can be in, not
an unset one. Writing { it: 0, ro: 0, zh: 0 } would mean the same today and quietly
permit a regression the moment someone "adjusted" a number. Mutation-tested both
ways: removing ONE key fails ("it is missing 1/176 key(s) and has no DEBT entry"),
deleting the whole file fails (54/176).

THE CARD'S ESTIMATE WAS WRONG AND IS CORRECTED. It recorded "54 keys of full
SENTENCES". Measured: 45 short labels (median 14.5 chars) + 9 longer strings, three
carrying %count%/%countries%/%region%/%country% interpolation tokens. The catalog is
keyed by its own English TEXT, not symbolic keys — which is exactly why the gap was
invisible: the fallback renders the English key itself, so key and value are
identical in English and nothing ever looks broken.

PLACEHOLDERS HELD BY A PROGRAMMATIC CHECK, not by care: every %token% in every
English value must appear, same set, in all three translations. 0 mismatches, 0
empty values. Mangling one breaks interpolation at runtime with nothing to catch it.

VERIFIED AT THE RENDERED LAYER on the regions-list leaf, en/it/ro/zh: labels
translated (Regions -> Regioni), the intro sentence translated, and the INTERPOLATED
count sentence correct — "315 people groups in 41 countries" -> "315 popoli in 41
paesi", both placeholders substituted with real numbers. Zero page errors. Each
locale asserted to DIFFER from English, so a silent fallback could not pass.
★ That sentence is the exact bug regions-list.vue:144 records: the Russian string was
"ALREADY in library/i18n/locales/ru/regions.json — copied, never wired", caught by
reading a screenshot rather than by any assertion. Now it is asserted.

zh is SIMPLIFIED, measured not assumed: the existing zh catalogs carry 33
Simplified-only characters against 4 Traditional. Vocabulary follows each locale's
own established terms — Popolo / Popor / 民族, Regione / Regiune / 区域 — and WAGF
stays an acronym, as fr and de already had it.
2026-09-24 08:48:02 +00:00
doxa-maps-88655c70
63ee25d48a Rebuild the served tree after rebasing onto main
Some checks are pending
build-gate / build (push) Waiting to run
The rebase brought in a shared map-controls change (the restored keyboard focus
ring), which re-hashes every bundle that imports them and rewrites the gallery's
baked SRI. A stale integrity hash is the silent failure this repo has paid for:
the browser refuses the script and the page mounts nothing.

The one conflict was in public/doxa-maps/index.html — build OUTPUT, where neither
side is authoritative, so it is resolved by rebuilding rather than by picking hunks.

Re-verified after the rebase rather than assumed: prayer-walk-planner's share url
still resolves 200 text/html and reopens as <prayer-walk-planner> with a rendered
canvas, with the shared control styling those commits touched.
2026-09-24 08:26:21 +00:00
doxa-maps-88655c70
e5fe33b230 prayer-walk-planner can be shared — the last published map with no sharing path
check:share-policy's known-debt line is now GONE: it named two published maps, then
one, now zero. Every published map has exactly one decided sharing path.

★ THE OPEN QUESTION WAS ANSWERED BY THE CODE, NOT BY A RULING. This was held back
asking "is it MEANT to be shareable? a link carrying no route arrives as an empty
planner, and a button that reliably produces a blank map is worse than none". That
framing assumed a design choice existed. It did not: the profile keeps polygons in
localStorage and has NO BACKEND — its own header says so — so a URL cannot carry a
drawn route under any design. The shareable artefact is the PLANNER, exactly as
every other map shares its own page. The failure only exists if the link CLAIMS to
carry the route, so the status strip says once, on click: "This link shares the
planner. Your drawn zones stay on this device." — using the status mechanism the
profile already had rather than adding a surface.

★ ADDING <ShareButton> WAS NOT ENOUGH, AND check:share-policy COULD NOT SEE IT.
The button rendered NOWHERE. This bundle mounts its SFC directly as the custom
element — deliberate, it is what surfaces the defineExpose()d client-side API on the
DOM element ("the only API we can build has to be CLIENT SIDE") — and so installed
no i18n. ShareButton calls useI18n(), which throws in setup(), and index.js's own
header records the cost: "a thrown setup() removes the whole shadow-DOM subtree
silently." Measured: a runtime SyntaxError, five toolbar buttons rendered, the sixth
absent — while the policy gate, which counts <ShareButton> in SOURCE, said 1 button
and PASSED. That blind spot is now written into the gate, pointing at the smoke that
does catch it.

Fixed WITHOUT changing the mounting shape: defineCustomElement takes configureApp
whichever component is the root, so i18n installs and the API stays on the element.
The direct-mount decision and i18n were orthogonal; I had assumed they were coupled.

VERIFIED AT THE RENDERED LAYER: share-roundtrip-smoke resolves the url 200 text/html
and REOPENS it as <prayer-walk-planner> with a rendered canvas. The client-side API
was re-verified after the change — all 7 exposed methods present, addPolygon ->
listPolygons round-trips, 0 page errors. i18n verified in 6 locales: Share /
Compartir / مشاركة / साझा करें / 分享 / Partager. Added to smoke:share-all, now
12/12 pass 0 fail.
2026-09-24 08:23:30 +00:00
doxa-maps-88655c70
3a468c2bc3 The poster print preview rasterises too, and a deployment can ship sources without rasters
Some checks are pending
build-gate / build (push) Waiting to run
posters/*.webp is not shipped by default (42MB; bundling pushed the tree to 80MB,
git-packed to 57MiB, and the catalog rejected the push). The DOWNLOAD path has
handled that since d154a76 — PosterWall fetches posters/<id>.html and rasterises
it in the browser. The PRINT path did not: it handed an <img> a url it never
checked, so on a default drop the SAME poster downloaded fine and PRINTED as
"Poster not available yet." That is the bad first impression
IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF called out by name.

Three things were missing; all three are here:

1. printPoster now climbs the same ladder as the download path — probe the webp,
   and only on a miss fetch the .html, rasterise, and verify PIXELS before opening
   the dialog (an SVG in an <img> is an isolated document: a lost stylesheet prints
   a black square with no error). Blob urls are held until unmount, not revoked at
   print time — the preview <img> is still reading from them.

2. publish-html.mjs wrote into public/assets/posters, which is a GENERATED MIRROR
   that every `bun run tree` rmSync's. Its output never survived, which is a large
   part of why the fallback still had no input months after it shipped. Retargeted
   at data/posters, the tracked source the mirror is made from.

3. bundlePosters is one boolean over a directory holding 40.9MB of rasters AND the
   28 sources, so it could only ever ship both or neither — and "neither" is what
   made both full-size paths 404. posterSources:true ships the sources without the
   rasters, the combination that was previously inexpressible. DEFAULT UNCHANGED:
   28 sources are 27.9MB raw (6.9MB packed, measured with pack-objects), real
   weight in a published tree that needs a deployment decision, not a silent one.

VERIFIED AT THE RENDERED LAYER, not by reading source. check:poster-print-fallback
clicks the real control with every posters/*.webp ABORTED at the network layer —
the default-drop condition forced rather than hoped for — and asserts the HUD
enters "generating the poster" and settles clean. Mutation-tested: removing the
fallback produces 4 failures including "no posters/*.html was fetched". The other
branch is verified too — with a webp present it is used directly, zero html
fetches, no rasterise — so the extra probe is paid only on a miss.

★ THE GATE WAS WRONG THREE TIMES FIRST, each time blaming the product for a
harness fault, which is why it now asserts the HUD instead of an internal:
  - it matched /print/i, but the control reads "UPG prayer poster" — the product
    names the ARTEFACT, not the action;
  - it patched Document.prototype in the PARENT realm while openPrintFrame writes
    into the iframe's own;
  - openPrintFrame calls doc.open() AFTER append, and open() RESETS the document,
    discarding a write patched at append time.
Through all three the .webp/.html request counters looked corroborating, because
the wall loads both by itself. The HUD is what a user sees, survives a refactor of
the print internals, and cannot silently miss.

The 28 poster sources are committed because the fallback has no input without
them. city-* posters are deliberately NOT covered: they fetch OSM street tiles at
generation time, so they are not reproducible offline and their rasters must stay.
2026-09-24 08:07:22 +00:00
doxa-maps-88655c70
f47ee3dfbe Refresh the gallery's baked SRI after rebasing onto main
Some checks are pending
build-gate / build (push) Waiting to run
The rebuild that follows a rebase re-hashes the bundles, and the gallery bakes
those integrity hashes into its page. A stale hash is the silent failure this
repo has already paid for: the browser refuses the script and the page mounts
nothing, with no error a reader would see.
2026-09-24 07:43:31 +00:00
doxa-maps-88655c70
e5c2c77561 all-people-groups can be shared: its own button, verified by a round-trip
Some checks are pending
build-gate / build (push) Waiting to run
It was published, embeddable and reachable with NO sharing path by any mechanism —
no ShareButton, no shareUrl config, no composite parent overriding one on its
behalf — while every other published map had exactly one. A reader who landed on
it could not send it to anyone, which is the point of an embeddable jamstack map.
check:share-policy's known debt falls from 2 published maps to 1.

The button is in a MapToolbar (pure layout shell: position, z-index, mobile
offsets) rather than hand-positioned, so it sits where every other map's controls
sit. has-geocoder=false because this profile mounts no search pill. Only the share
control is added — zoom/location/fullscreen each need a live mapRef this bundle
does not expose, and the card asks for a sharing path, not a toolbar.

NOT gated on appReady, unlike this bundle's legend. Sharing a map does not require
its data to have loaded, and gating it would remove the affordance exactly when a
slow or failed load makes a reader most likely to want the link.

★ WHY A RENDERED CHECK WAS NEEDED. check:share-policy counts <ShareButton> in
SOURCE. Mutation-tested by gating the toolbar off: the policy gate still read
"1 button / own-button" and stayed GREEN while the button was absent from the DOM.
A source count cannot tell a rendered button from an unrendered one.

So this is verified by the EXISTING share-roundtrip-smoke, not a new gate. I wrote
one, then deleted it on finding that smoke does strictly more — it fetches the URL
and REOPENS it, asserting the same custom element renders, which catches SPA
fallbacks and hash-stripping that a link-text check cannot. A second share checker
would be the fourth-print-path mistake this repo has already paid for once.
Result: share URL resolves 200 text/html and reopens as <all-people-groups> with a
rendered canvas.

i18n verified at the rendered layer: under ?lang= the button title reads Share /
Compartir / مشاركة / साझा करें / 分享, so the new affordance is translated in every
locale, RTL included. buttons.share is one of the keys restored to parity earlier.

ALSO FIXED, found by running the suite: smoke:share-all was testing the LEAF urls
of the two 'header'-policy bundles, whose button lives in the MFE shell by design,
so it reported 10/12 with two failures that were the policy working as intended.
Swapped country-posters/countries-map for country-posters-mfe and dropped the stale
my-upg-100-list leaf (upg-100-mfe was already listed). Now 11/11 pass, 0 fail.
check-share-policy records the coupling so the list cannot drift from the policy
again.

prayer-walk-planner stays debt-no-share, deliberately. It builds a personal walking
route from polygons the reader draws; a link carrying none of that arrives as an
empty planner, and a button that reliably produces a blank map is worse than none
because it looks like sharing works. That is a semantics decision, not an
implementation one.
2026-09-24 07:25:19 +00:00
doxa-maps-88655c70
596b5fe5e8 Rebuild the served tree after rebasing onto main
Some checks are pending
build-gate / build (push) Waiting to run
The rebase conflicted in nine public/ files — build OUTPUT, not source. Neither
side of such a conflict is authoritative, so the resolution is to rebuild rather
than hand-pick hunks: these nine differ from what the manual resolution produced,
which is precisely why the rebuild was required before pushing.

No source file conflicted except package.json, where upstream had added
check:maplibre-drops and this branch check:packets-all. Both are kept, and
gates:post is upstream's 20 steps plus this branch's one addition — not either
side's version of the list.
2026-09-24 06:59:37 +00:00
doxa-maps-88655c70
e952e01e4c Assert the packet numbers that were only being printed, and run the gate on every locale
check-packet-render's own header states the law it exists for — "never print a number
without asserting on it" — and two of the nine numbers it printed were decoration.

PAGE SIZE. Printed "612 x 792 pt", never compared. The landscape check only catches
h <= w, so a wrong PORTRAIT size sailed through: A4 is 595x842, taller-than-wide like
Letter, and would have passed on a packet printing with wrong margins on every US
printer. Not hypothetical — @page{size:...} silently falls back in Chromium, which this
repo has already paid for once. Now asserts every page's MediaBox against Letter.

CARD COUNT. `if (!r.cards)` passed a packet carrying ONE card. Counts must stay relative
(a per-zone packet has its own group count), but relative does not mean unasserted:
sheets === 1 + ceil(cards/PER_PAGE) + 2 holds for any zone.

★ That was not enough, and the mutation test is what showed it. Deleting a whole list
sheet removed 15 cards AND one sheet, so the invariant still held — 80 cards / 7 sheets
is valid arithmetic — and the packet PASSED while missing 15 people groups. Internal
consistency is not completeness. So it now cross-checks an INDEPENDENT witness: the
{from}-{to} of {count} header the renderer prints on each list page, where count comes
from groups.length and does not move when a page is lost. It also requires the ranges to
TILE [1..count], which catches a missing MIDDLE page that the totals alone cannot see.

Those headers are TRANSLATED and the languages disagree on order — en/ar read "1-20 of
95" while hi reads "95 में से 1–20" and zh "95 之中的 1–20", total FIRST. A positional
regex would read those backwards. Parsed as digits with max() as the total instead, and
locale digit forms normalised to ASCII, so it is grammar-independent. Verified by
running the missing-middle-page mutation in Hindi as well as English.

MARK 5 WAS ASSERTED BY AN ENGLISH WORD. body.includes('Gadarenes') would go RED on a
correctly-translated packet, and passes today only because every locale falls back to
English for that key — the check and the bug were locked together. The renderer now
emits class="p4-passage"/"p4-credit" as test hooks (the block is styled inline and had
no selector at all), and the gate asserts length >= 1200, which holds in any language.

check:packets-all runs the gate over EVERY generated packet rather than one hand-named
file. The single-file gate SKIPs without an argument, so it was in package.json, passing
in all 11 locales, and in NEITHER gates:pre NOR gates:post — it ran only when someone
typed a filename. Now in gates:post (21/21, 11 packets through a real browser). SKIPs
when no packets are generated, so a clean checkout is unaffected.

Mutation-tested: A4 FAILs, a dropped sheet FAILs, a missing middle page FAILs in en and
hi, a truncated passage FAILs, a removed passage FAILs. i18n verified end to end by
adding a PARTIAL new language as a data block only — 3 of 23 keys — and confirming the
translated strings render, untranslated keys fall back to English, and nothing is blank.
2026-09-24 06:55:58 +00:00
doxa-maps-88655c70
4cb04480cc Localize tab labels for it/ro/zh, and prove the catalog is actually read
Some checks are pending
build-gate / build (push) Waiting to run
120 keys across three locales: legend.json (12 each), search.json kinds (10
each) and tabs.json (18 each, previously absent entirely). Each locale follows
its own established vocabulary rather than a parallel one — Popolo/Popor/民族,
matching the terms already used in that catalog's detail and legend strings.
zh stays Simplified here, as the rest of this tree is, even though the prayer
packet's zh block is Traditional: a reader sees 民族 next to 祷告进度, never
the two files side by side.

Ceilings drop 94 -> 54 per locale. regions.json is the only i18n debt left in
the app: 54 keys of full sentences, three locales.

WHY THE NEW GATE. check:i18n-parity compares catalogs TO EACH OTHER, so it
certifies a file no t() call ever reaches — perfect symmetry, zero consumers,
green forever. Grepping for t('tabs. found ZERO call sites and tabs.json was
nearly recorded as dead. It is not: the only consumer builds the key
dynamically (SemanticTreeLegend.vue:40, 'tabs.' + (tab.i18nKey || tab.id)),
which no literal-prefix grep can see. The reverse error appeared in the same
pass — regions. and options. matched only poster-regions.mjs and a JS options
parameter. Static reachability is unavailable in both directions, so
check:tabs-localized renders the labels and reads them.

It asserts DIFFERENCE from English, not mere presence: te() falls back to the
hardcoded English label, so "shows some text" passes on the exact failure
being tested. Mutation-tested three ways — missing catalog FAILs, an all-English
catalog FAILs (first draft PASSed it, taking a per-pair exemption seven times),
one legitimate collision still passes with a note.

Both i18n gates now run in gates:post; neither was wired into the build before.

Also fixed, found by the build rather than sought:
- check-i18n-parity hardcoded an absolute path, so the studio copy silently
  graded wt-packet's locales and reported PASS on a tree it never read. Now
  repo-relative. That exposed the studio's own locale tree as a strict subset,
  103 keys behind including buttons.json:share — the very orphan this gate's
  header says it was written to catch, reappeared unseen in a second repo.
  Synced forward: zero studio-only keys, zero text conflicts, lossless.
- index.json still declared field-access-visas with rows:1146, a leftover from
  the commit that removed that table as private. The generator already filters
  private tables; the committed artifact predated the filter. Removing the
  declaration also removes the row-count leak. Copying the file in would have
  reopened the leak.
- check:sfc-parse needs an installed node_modules and is the first pre-gate to
  fail without one, so a dependency-less worktree reads as a parse error.
  Documented, with the reason switching it to bun only appears to fix it.
2026-09-24 06:23:30 +00:00
doxa-maps-88655c70
d9fbff14bb Galaxy style on MapLibre, and restore both dashboard resizes
Some checks are pending
build-gate / build (push) Waiting to run
GALAXY + MAPLIBRE. New 'galaxy' style in the shared registry -- deep-space
ground, blue oceans -- with both a mapbox and a keyless MapLibre URL, and a
paint override that is applied only if the loaded style actually has the
layer, so a miss is a silent no-op rather than a break. The dashboard now
runs provider 'maplibre' and mapStyle 'galaxy'. Reversible by two config
keys; the Mapbox path is untouched.

BOTH RESIZES RESTORED. My seven-row change had hard-capped the table's
keyhole at 7 x 26px, which read like a default and was not: nothing writes
--rd-table-rows, while the drag handles write --rd-shell-h and --rd-map-h.
So the cap was unreachable by any gesture and the Driver lost both resizes
at once, plus a grey gap where the freed pixels had no consumer.

Fixed by restoring the derivation and moving the default instead: the
keyhole is once again shell - map - bar - foot, and DEFAULTS.shellH drops
720 -> 602 (map 360 + bar 32 + 7 rows x 26 + foot 28) so the table OPENS at
seven rows while staying fully draggable. CSS fallback matched to 602.

Measured before the revert: shell handle 849 -> 731 with rows 7, table
divider map 360 -> 280 with rows 10, gap 1px after each.

Also carries the charts' frozen count column (sticky right:0 with an opaque
background, so a scrolled label cannot slide under the number) and the
ViewSelect library-index entry.
2026-09-23 20:35:38 +00:00
doxa-maps-88655c70
6ba3ad76aa Legend rises into the removed search bar's space -- but only where it was removed
Some checks are pending
build-gate / build (push) Waiting to run
The UPG-100 legend was pinned 58px below the map top by a profile !important
rule whose job was to clear the geocoder pill. Measured with a real token in
both hosts: the standalone leaf DOES mount the geocoder (top 12, h 35), so 58
is correct there; the cap-5 shell mounts NO geocoder at all, so the same rule
stranded the legend below a pill that was not there -- the dead gap reported
repeatedly.

One host must not be fixed by breaking the other, so the offset is now
conditional on the pill existing: default 8px, pushed to 58px only when
:has(.mapboxgl-ctrl-geocoder) finds a rendered pill in the tree.

Also parameterizes the shared SemanticTreeLegend's offset as
var(--stl-top, 60px) with the max-height mirroring it, so any profile that
drops its search bar can raise its legend without an !important arms race.

Measured after: leaf legendOffsetInMap=58 with geocoder present; shell
legendOffsetInMap=8 with no geocoder. Full authoritative rebuild included --
composite freshness PASS, SRI PASS 45/45.
2026-09-23 18:30:00 +00:00
doxa-maps-88655c70
0b58ab4560 Commit the rebuilt tree so integrity hashes match the shipped bundles
29 built files were rebuilt but uncommitted, including manifest.json -- the
file the cap-5 shell fetches at runtime to decide what to mount. Committed
source and committed output disagreeing on an SRI hash makes the browser
refuse the script, which presents as a blank pane and reads exactly like a
mount failure.

Diff is hash churn only, no structural change.
check:sri PASS 45/45, check:composite PASS 23 edges, settled-tree PASS.
2026-09-23 17:34:12 +00:00
doxa-maps-88655c70
3ac4175e98 Land four agents' dashboard work: resize handle, slimmer stats row, closed rails
Some checks are pending
build-gate / build (push) Waiting to run
Four delegated agents left verified work uncommitted in shared files. Committing
it together because it is interdependent and all of it passes the gates; each
agent's own measurements are in _qa-cycle/02-builder-cycle/delegation/.

RESIZE HANDLE (resize-sections). A draggable separator between the dashboard
band and the grid, so the band's height is the user's choice rather than a fixed
compromise. Driver: "if I were to open this on a large monitor and I wanted to
resize the height of the dashboard compared to the grid, being able to do that
would be really cool." Verified with real pointer events: 522px -> 672px on a
150px drag, stable before/during/after, no snap-back; keyboard-operable with
aria-valuenow tracking the live value. A real bug was caught pre-ship: 200
localStorage writes on a single keypress.

TABLE/MAP DRAG FIX (table-resize). The handle did nothing in page-scroll mode --
measured 522px before AND after a real 200px drag, every frame. The shell is
height:auto there and grows with content, so the percent-of-shell formula was
meaningless. It now measures against the map element's own top edge captured at
drag start, re-deriving each frame rather than summing a delta. Four drags both
directions with persistence: 417.6->336, 336->283, 283->548, and 548->470 after
a reload that restored 548.

SLIMMER STATS ROW (slim-headers). The KPI strip's content is an 18px line inside
a row fixed at 42px -- 24px of empty space. Tokenized as --rd-stats-h:30px,
recovering 12px with no information loss, and tokenized rather than inlined
because 42px repeated in three row templates that must stay in sync.

CLOSED FACET SECTIONS (sidebar-collapse). Driver: "once the data fills all the
sidebars it causes the dashboard to become really tall... without data it looks
fine." Measured: five open sections rendered 69 rows, the rail's own scroller
never engaged (clientHeight === scrollHeight === 2117px), band 2325px against a
900px viewport. All five now start closed -- the default changes, not the
mechanism; per-section state and toggle() already existed.

Build exit 0, 21 bundles / 30 profiles. check:sri, check:settled, sfc-parse,
template-literal, one-palette, synced-css all pass.
2026-09-23 16:27:39 +00:00
doxa-maps-88655c70
b59fb66cc9 Add Regions as the third Dashboards row, completing the seven
Some checks are pending
build-gate / build (push) Waiting to run
The Driver named seven plugins across three sections. Six shipped; Regions was
the gap.

  Dashboards          Big Picture · Simple Research · Regions
  Prayer Mobilization UPG-100 List · Prayer Progress
  Gospel Research     Cities with Internet · Gospel Resources

What ships today is tab 1 of 2. regions-dashboard currently builds a single
profile, regions-list -- the WAGF region list refactored from DX-life's /regions
page, which emits region-selected / country-selected. The scrollable
region/country detail dashboard those events will drive lands as an ADDITIONAL
profile in the same bundle, and this row needs no edit when it does, because the
row names the BUNDLE and the shell resolves the profile from the manifest.

Listed now rather than held back: a working list that reaches the rail is worth
more than a complete feature nobody can open.

Verified in a browser on the built tree at 1440x900, recursive shadow-DOM walk:
four rail sections render as Dashboards / Prayer Mobilization / Gospel Research
/ Experimental; the seven curated rows appear in the Driver's order with Regions
third; 14 tabs total, 14 unique, zero duplicates, zero console errors.
check:sri PASS, check:settled PASS before and after.
2026-09-23 15:43:43 +00:00
doxa-maps-88655c70
10fdda338b Rebuild the tree so every leaf's baked SRI matches its bundle
Some checks are pending
build-gate / build (push) Waiting to run
A single-bundle build regenerates the JS but not the leaf pages, so four
big-picture leaves carried a stale sha384 and the browser refused to execute the
script at all. That is why a nav probe measured 0 sections on a shell whose
source was correct, and it is the likeliest cause of a teammate's 'pggMounted:
false on big-picture everywhere' reading.

check:sri named both the cause and the fix; ran the full build it prescribes on
a settled tree. Now PASS: 44 script references across 45 leaf pages, every
leaf's integrity matching the bundle it loads.
2026-09-23 15:37:18 +00:00
doxa-maps-88655c70
c27d16cd4b Show the film link the list already had, for 218 languages that showed nothing
Some checks are pending
build-gate / build (push) Waiting to run
resources-list rendered links only from `resources[]`, which holds SCRIPTURE
editions (eBible entries with ot_books/nt_books/redistributable). The JESUS Film
lives in its own fields -- has_film/jesus_film plus film_url -- and was never
read. Measured over all 862 languages in resources.json:

    resources[] non-empty ............ 120
    film_url present ................. 306
    film_url but resources[] EMPTY ... 218   <- a real link the UI never offered

So 218 languages displayed no link at all while carrying a working one. Arabic,
Sudanese (apd, 66 people groups, 18.7M speakers) has
https://www.jesusfilm.org/watch/?languageId=13562 and rendered nothing.

The pill is deliberately left alone. It reads full_bible/has_scripture, so
"none found" is TRUE -- of scripture, which those 218 genuinely lack. Widening
it to mean "no resources at all" would make it say something false about a
language that has a film. The row now says both things: the pill reports the
scripture gap, the links report what exists.

Film links are marked (muted, with a leading marker) rather than styled as
another Bible, because a film is a real resource but not a text and this list is
about text availability first. Guarded on film_url itself, not on the boolean
flags: a truthy flag with no URL would render an anchor to nowhere.

Verified in a browser on the built tree: default "No scripture found" tab now
renders 218 film anchors where it previously rendered zero, all 218 on rows
whose pill reads "none found"; the sample href matches the apd row in the data.
"Has scripture" shows 120 rows with 155 scripture anchors plus 88 film; "All
languages" 862 rows, 306 film -- matching the pre-change data counts exactly.
0 failed requests.
2026-09-23 13:58:18 +00:00
doxa-maps-88655c70
1449d47573 Add the Gospel Access filter, the ladder the binary flag was flattening
The Driver asked for "the phases of engagement, measured in the live data."
engagement_status cannot express them: over 2,108 live rows it is 2,081
Unengaged / 27 Engaged, so filtering on it separates 1.3% of the corpus and
tells a researcher almost nothing about where a group actually sits.

imb_gsec is the ordinal ladder underneath that flag, present on 2,106/2,108:

    0   No Evangelicals, Churches or Resources ........... 225
    1   Less than 2% Evangelical, No Active CP Activity . 1,853
    2   Less than 2% Evangelical, Concentrated CP ......... 23
    4   2% or Greater but Less than 5% Evangelical ......... 4
    6   10% or Greater Evangelical ......................... 1

Cross-tabbed against the flag, ALL 225 groups with no evangelicals, churches or
resources -- the most lost category in the data -- are merely "Unengaged",
indistinguishable from the 1,841 groups that have some evangelical presence.
That distinction is the one the map exists to show.

Verified safe to filter on before wiring, the same way cluster/people_group
were: 5 distinct {value,label} pairs and NO value-vs-label cross-collision, so
valueMatches() selects the same rows whichever vocabulary arrives. It is a real
row field, unlike the derived language_family/religion_family facets, so it is
mapped in FACET_TO_PROP from the day it lands rather than joining the
dashboard-only class.

Wired through every layer that would otherwise silently drop it: the shared
sources.json defaultFields (the dashboard reads rows through that seam), the
grid's own fetch field list, the prop, the API-field-name filter key (imb_gsec,
not gospelAccess), the re-filter watch list, and the composite's prop binding.

Verified in a browser on the built tree with live data: the section renders all
five rungs with counts matching the API exactly; clicking "No Evangelicals"
takes dashboard scope 2,108 -> 225 and the grid to "Showing 225"; clicking
"Concentrated CP" gives 23 and "Showing 23"; deselecting returns both to
baseline. 0 failed requests.

Gates: facet-one-field, facet-cascade, facet-changes-map, csv-shape,
label-joins, one-palette, sfc-parse, parse all pass. verify:gate1 still 13/13
against 2,108 live records.
2026-09-23 13:19:22 +00:00
doxa-maps-88655c70
9076ec1d61 Mark the two dashboard-only facets, so they stop looking like broken buttons
language_family and religion_family are clickable, they highlight, and they
narrow every chart above them -- and they cannot move the grid below. Measured
2026-09-23: dashboard 2107 -> 677/306/222/151 and 2107 -> 693/649/610/83, grid
unchanged every time. To a user that is indistinguishable from a dead control;
the only way to learn it was to click and squint.

Wiring them is not the fix and would be worse. Both values are DERIVED -- a
language family resolved via langFamilyByIso for ~96% of rows, a religion family
computed from the first letter of a religion code -- so they exist on no row
field. Mapping religion_family onto `religion` would compare "Hinduism" against
a CODE and filter the grid to zero.

So the honest fix is a label, not a wire. A facet now declares
`dashboardOnly: true` in dashboard.config.js; DashboardFacetRail reads that from
config rather than hardcoding facet names, and renders a muted half-circle plus
a title and an extended aria-label saying the row updates the charts only.

These rows stay fully clickable, deliberately unlike `zero` and `other` rows,
which are inert because they would do nothing at all. Dimming them the same way
would understate what they really do.

Verified in a browser on the built tree: 41 of 66 rows marked, 41 glyphs
rendered, marked facets are exactly language_family and religion_family and
nothing else, 40 still clickable (the one that is not is a zero row that
correctly keeps its mark). Behaviour unchanged -- a marked row still takes the
dashboard 2,108 -> 677, an unmarked row still filters at 548.

Gates: facet-cascade, facet-one-field, facet-changes-map, one-palette,
synced-css, composite-srcs, template-literal, sfc-parse all pass;
a11y-button-names passes at 769/769 buttons named.
2026-09-23 13:04:52 +00:00
doxa-maps-88655c70
bd2a77e82d Resolve the shared asset base per bundle, so nested tabs stop 404ing
Eleven profiles across six bundles each carried the same line:

    profileConfig?.assetsBase || './assets'

That default is right on a standalone leaf page, where the generated host passes
'../../assets' and the tree root is two segments up. It is wrong the moment the
element is nested in the cap-5 shell, because a relative path resolves against
the SHELL's page, not the child's. Measured in a browser on the built tree:

    leaf  /doxa-maps/gospel-resources/resources-map/index.html ... 0 failed
    shell /doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html ...... 404 assets/tables/gospel-by-country.json
                                                                   404 assets/boundaries/world-adm0.geojson

No build-time gate could see it: './assets' is a valid string in both hosts, and
only the host decides what it resolves to. check-composite-srcs proves a nested
src exists on disk, which is a different question.

resolveAssetsBase() in library/utils/embedBase.js now derives an absolute base
from the bundle's own script URL (or the treeBase the shell already forwards),
and deliberately REJECTS a relative assetsBase from config -- honouring it would
reintroduce the defect through the config meant to cure it.

The fix is in the child, not the shell, so a new bundle that fetches assets
works nested on day one with no edit to doxa-maps-spa.vue.

Verified: shell 404s 2 -> 0 on Gospel Resources; Cities & Internet, UPG-100 and
Big Picture also 0 asset 404s; leaf hosts unchanged at 0.
2026-09-23 12:14:52 +00:00
doxa-maps-88655c70
546916c41e Say which population the studio's count describes
The Plugin Studio header read "18 bundles · 27 elements" while the manifest
holds 19 and 28. The arithmetic was always right: rowsFromManifest skips the
studio itself, because it cannot be one of its own tabs, and 19-1 / 28-1 is
exactly what the header showed.

The problem was that nothing said so. The golden-path self-test — linked from
the golden-path doc as of this flight — reports 19 bundles and 28 profiles from
the same manifest. Two authoritative surfaces disagreeing by one, neither
explaining why, is how a reader stops trusting both; and the studio is the first
thing an admin agent opens, so a number that looks wrong there discredits
everything beside it.

"listed here" is the whole change. It names the population, so the difference
from the catalog total reads as a deliberate exclusion rather than an off-by-one.
The count stays derived from this.rows, which is the honest source — it is a
statement about what the studio shows, and now it says that.

Verified the exclusion accounts for the gap exactly before touching anything:
manifest 19/28, minus the single plugin-studio bundle and its one profile, is
18/27. Then read the rendered header at 1600px — 220px wide, sitting beside the
language selector without crowding it — and confirmed it in the regenerated
screenshot.

Nine gates pass, including check:synced-css: there are two .ps-count rules in
this file, the synced copies that must stay byte-identical, and this change is
JS rather than CSS so both are untouched.

Same shape as the self-test scoping documented earlier today: a number that is
correct for the question it answers, with nothing saying which question that is.
2026-09-23 10:49:06 +00:00
doxa-maps-88655c70
7547abb109 Keep the poster-wall header pill on one line whatever the count
The wall's header pill reads "<n> UPG · Show on map" and is a fixed 82% of the
card. A three-digit count pushed it over that width and it wrapped, so "116 UPG"
cards rendered a two-line pill beside one-line "92 UPG" cards in the same row.
Measured at 1440px on the shipped wall: the same element came out h=34 for
three-digit counts and h=22 for two-digit ones, a 12px step that reads as two
different components sitting side by side.

One property, and the correct value was already three lines away: the sibling
footer pill (.tape-l) carries white-space:nowrap and .tape-h did not. So this was
an inconsistency inside one component rather than a design question. The
.hcount rule sets font-variant-numeric:tabular-nums, which says the author
already wanted digit width not to move things — wrapping undid that intent.

Now h=22 on every card regardless of count, confirmed in the regenerated
screenshot: the three-digit pills in the India and Africa rows sit on one line
like the rest.

Found by looking at the shipped screenshot while verifying
BUG-UPG100-POSTER-PRINT-DIALOG, which the standing brief asks to check visually
and which had closed with verification_kind "state" and no recorded evidence.
The poster wall itself is sound — real posters, named people groups, PRINT
OPTIONS on every card.

Nine gates pass, including template-literal, which matters because this CSS
lives in a JS string and a backtick in a comment there has broken the build
twice before.

Not fixed, and not a product defect: the "Print poster" labels show a tofu box
on this machine. check:shot-fidelity already names these six bundles — the
markup carries the right codepoint and the shot box has no emoji font. The fix
is a font here, never removing emoji from the product.
2026-09-23 10:35:24 +00:00
doxa-maps-88655c70
2fdfee37ac Start the wrapped filter line on the same edge as the one above it
The dashboard ships eight filter dropdowns. Seven fit on the first line and
Status wraps to a second — centred, not aligned with Prayer above it. A lone
centred control under a left-aligned row does not read as a layout decision, it
reads as a mistake.

Measured on the shipped dashboard at three viewports, line-2 left edge against
line-1: 1440px 36 vs 636, six hundred pixels adrift; 1280px 16 vs 556; 1024px
33 vs 132. Never aligned at any width.

The cause is `justify-content: space-evenly`, which I kept when wrapping landed
here earlier today. The comment defending it is about a SCROLLER — a single line
that could push its first item to a negative offset, which `safe` rescues — and
that reasoning does not survive wrapping, because space-evenly distributes EACH
LINE independently. Seven items distribute across the width; one item lands in
the middle.

flex-start keeps every line on one edge at any width. The `safe` keyword goes
with it: it exists to rescue centred and space-* distributions from negative
offsets, and flex-start cannot produce one, so keeping it would preserve a
guard against a hazard the change removes.

Now 14 / 14 / 14 at all three viewports, matching the row's own left edge, and
the regenerated screenshot shows Status sitting directly beneath Prayer.

Blast radius checked before editing, since this is the high-bar library zone:
SearchFilterHeader is consumed by research-dashboard only. check:synced-css
passes, so the two CSS copies remain byte-identical. Nine gates pass and
verify:gate1 is unchanged at 13/13 against 2,107 live records.

Separately noted, not fixed here: check:filter-width fails on HEAD too —
verified by stashing this edit and re-running. It reports "UNMEASURED: no
.filter-dropdown__trigger found in the composed tree", and that class DOES exist
(FilterDropdown.vue:278), so the selector is right and the gate is not reaching
the page — the same stale-navigation family as the tab-order gate fixed earlier.
Left for its own commit rather than bundled into a styling change.
2026-09-23 09:52:58 +00:00
doxa-maps-88655c70
94b356d2b3 Tell the user when the data source is down, not that their filter matched nothing
people-groups-grid caught a failed fetch, logged to the console and cleared its
loading flag. The template's `v-else-if="!displayedUUPGs.length"` branch then
rendered "No people groups match this filter." So when pray.doxa.life was
unreachable, the element told the reader their FILTER was wrong.

Measured by blocking that host in a browser: two console errors logged, and the
only thing on screen was the ordinary empty state. A user adjusts filters that
were never the problem; an operator glancing at the page sees normal operation.
The console held the truth and nobody opens it — least of all on a partner site
or inside the cap-4/cap-5 composites, which is exactly what this element is for.

Now distinguishes the two. A `loadFailed` ref drives its own branch: "People-group
data could not be loaded. This is a connection problem, not your filter."

Also checks response.ok, which was a second silent path. fetch() only rejects on
a NETWORK failure, so a 404, a 500 or an HTML error page all resolve — and
response.json() on them either throws into the same silent catch or yields an
object with no `posts`, which sets an empty grid and reports success. Both were
indistinguishable from "nothing matched".

Styling adds no colour: the palette is material-light only and --mut is what
this component already uses, so the failure line is separated by weight and a
hairline rather than an alarm state the design language does not have.

Verified both directions. Host blocked: the new message renders, no cards.
Host reachable: no message, the grid renders. And `bun run verify:gate1` still
passes 13/13 against 2,107 live records, including its "nonsense query -> 0
(empty-state path)" case — so genuine zero-match still works and is now a
different thing from a failed load.

Found by reading EXPLORE-dxlife-people-groups-refactor.md (2026-09-18) in the
staleness scan. Its PLAN is superseded — steps 1-5 describe editing DX-life's
own UupgsList.vue, which the standing law forbids and which this flight solved
by cloning instead. But its RISK register was still testable, and risk 3 named
this exactly: "external data dep on pray.doxa.life/api/people-groups/list". Half
of that was already mitigated — prayBaseUrl is injected three ways — and this
was the unmitigated half. Risk 6 ("FIVE dropdowns not four, confirm with the
Driver") is also settled: four are rendered.

A doc can be stale as a plan and still carry a live finding.
2026-09-23 09:31:10 +00:00
doxa-maps-88655c70
4c8f6b4a6d Share the mobile composite too, and make the url absolute so the embed works
research-map-plus-grid had the same defect fixed yesterday on its desktop
sibling: sharing the composite emitted a link to doxa-research-map, the child it
nests. The gate said it exactly — "share URL reopened <doxa-research-map>, not
<research-map-plus-grid>" — and a recipient got the map alone, losing the grid.

Checked all four cap-4 composites rather than assuming the fix generalised.
my-upg-100-list owns its Share button directly and is fine. big-picture composes
a header plus the grid, owns no map and ships no Share button at all; the gate
reports "window.mapboxgl UNDEFINED" against it, which is its map precondition,
not a share failure — that bundle is out of this gate's scope and I put it in
the probe by mistake. So one real instance, now fixed.

Tier 1 again, for the same reason: this child also hardcodes a prop
(research-map.vue:2149 `:embed-path="'doxa-research-map/research-map/index.html'"`)
and the ladder is `embedPath || cfg?.shareEmbedPath`, so a prop outranks host
config and tier 2 is unreachable. Delivered through the profile-config attribute
rather than Vue provide, because this composite mounts its children as real
custom elements via src — there is no provide chain to reach them.

The url had to be ABSOLUTE, and the first version was not. resolveShareUrl
returns a tier-1 shareUrl verbatim (embedBase.js:95, `return whole.trim()`, no
resolution), and ShareButton feeds the same string into its embed snippet:
<iframe src="{shareUrl}">. A relative '../../…' would have resolved correctly in
the share LINK, because the browser resolves it against this page, and then
404'd in the EMBED, because it resolves against the partner's page instead. That
would have passed the gate — the gate navigates the link — while breaking the
deployment method the link exists for. Tier 1 is documented as "a WHOLE url" for
precisely this reason. Now computed with resolveEmbedBase() so it stays correct
on doxa.life, on nuxtbase.app and in a dropped tree.

Verified the desktop sibling did not have the same latent flaw: it produces a
fully absolute url, confirmed in the live gate output.

smoke:share-all now carries eleven targets. Full suite 11/11 pass · 0 fail.
Eleven gates pass, including sri, composite, composite-srcs and synced-css.
2026-09-23 08:32:50 +00:00
doxa-maps-88655c70
fe8e785c96 Share the composite, not the child it nests
Sharing from dashboard-plus-grid produced a link to
research-dashboard/research-dashboard/index.html — its own nested child. A
recipient opened the inner dashboard alone and silently lost the grid half of
the composite. The gate said it precisely: "share URL reopened
<research-dashboard-map>, not <dashboard-plus-grid>".

The nested dashboard's DashboardTopBar owns the Share button, and inside the
composite that button had no idea it was a passenger, so it shared its own
address.

Two attempts, and the first one is worth keeping in the record because it built
and shipped and changed nothing. I set profileConfig.shareEmbedPath — tier 2 of
ShareButton's documented ladder, and the natural-looking channel. The ladder is
`embedPath || cfg?.shareEmbedPath`, and DashboardTopBar passes an explicit
prop (`<ShareButton :embed-path="SHARE_EMBED_PATH" />`), so a child that has
already made up its mind always wins. The value was baked into the bundle,
resolveShareUrl reads that key, and the rebuilt bundle failed the gate
identically — a fix that is present in the artifact and unreachable at runtime.
Tier 1, `shareUrl`, is the only rung above a prop, and its docs say exactly what
it is for: "a WHOLE url the HOST supplies wins".

Tier 1 takes a whole url, so it is computed rather than hardcoded, via the same
resolveEmbedBase() helper ShareButton itself uses: profileConfig.embedBaseUrl →
window.MAP_EMBED_BASE_URL → the root the built bundle derives from its own
script url. The link therefore stays correct on doxa.life, on nuxtbase.app and
in a dropped tree with no configuration. An absolute url would have pinned every
share to whichever host happened to build it.

Also adds the two uncovered bundles to smoke:share-all. Nine bundles ship a
Share affordance and the hardcoded --url list covered seven; research-dashboard
and dashboard-plus-grid were absent, which is why this survived. The list is now
ten targets over nine bundles. Note the structural difference from
smoke:embeds, which enumerates from manifest.json and therefore cannot omit a
bundle that ships later — a hardcoded list silently omits whatever arrives after
it was written, and that is what happened here.

This is the two-host defect class exactly: research-dashboard standalone PASSES
this gate, and the identical component inside the composite FAILED. No
single-host gate can tell those apart.

Verified: the composite now reopens <dashboard-plus-grid> with a canvas, and the
full suite is 10/10 pass · 0 fail, so nothing regressed. Eleven gates pass,
including sri, composite, composite-srcs, synced-css and one-palette. The share
gate aborts rather than reporting a false failure when handed Firefox, which has
no WebGL here — it told me to re-run with chromium, and it was right.
2026-09-23 08:16:18 +00:00
doxa-maps-88655c70
34ef223619 Name the UPG-100 legend's tabs, instead of shipping the placeholder
The legend read "GENERATION 1 / 2 / 3" above a list of world regions. A UUPG
list has no notion of a generation, so the label described nothing and invited
the reading that the tabs filter the map by some cohort. They do not.

The cause is a fallback, not a typo. SemanticTreeLegend derives its tab strip
from the `tabs` prop and, where an entry is missing, substitutes
`{ id: 'gen'+(i+1), label: 'Generation '+(i+1) }` so the strip has something to
render. This bundle passed no `tabs` at all, so all three were placeholders.
country-posters and doxa-research-map both pass real tab sets; doxa-simple-map
passes an explicit null. This was the only bundle relying on the fallback.

What a tab actually is: a DEPTH LEVEL of the tree, which is why selecting a node
jumps the strip to that node's depth. The three levels were read out of
data/legend-tree.js rather than guessed — regions are assembled by grouping
leaves under regionFor(), zones are the middle nodes (Frontier Asia (uncovered),
India, Deaf (affinity bloc A017)), and the leaves are the individual groups each
dot belongs to. Hence Region / Zone / People Group, each with an `info` note in
the shape AFFINITY_TABS and REGIONS_TABS already use.

The ids are bundle-scoped, and that was measured rather than chosen on style.
tabLabel() resolves `tabs.<id>` against the shared i18n catalog FIRST and only
falls back to the English label when no entry exists, so a tab id is a global
namespace. The first version used id:'region' and the legend rendered "WAGF
Region" — library/i18n/locales/en/tabs.json already maps that key to the
country-posters taxonomy. id:'group' collides the same way and would have been
right here only by coincidence, since the catalog means the ROP-2.5 sense. The
prefixed ids own no catalog entry, so the labels in this file are what render.

Desktop only, deliberately. LegendMobile computes
`hideTabs = nodes ? !tabs : _isFlatTabMobile`, so with nodes supplied and no
tabs the mobile sheet hides the strip entirely — it never showed the
placeholder. Passing tabs there would reveal a strip that is currently hidden,
which is a design change rather than a bug fix.

Verified by driving the built bundle in Chromium and reading the three chips
back: Region / Zone / People Group, each carrying its info tooltip, where the
same probe returned "WAGF Regioni / Zonei / People Groupi" before the id fix.
Then the README shot was regenerated and read as an image — the strip renders
REGION / ZONE / PEOPLE GROUP and the column header now says REGION where it said
GENERATION 1. Element count moved 311 -> 314, the three info buttons.

Eleven gates pass, including sfc-parse, template-literal, sri, composite,
one-palette and synced-css.

Found while verifying BUG-UPG100-DOTS-HARD-TO-SEE, which is genuinely fixed: the
dots carry a solid dark stroke that frames them against both the pale zone fills
and the white basemap, confirmed by reading the image rather than the card field.
2026-09-23 06:52:18 +00:00
doxa-maps-88655c70
7311434a71 Commit the built bundles for the ten fixes that are already serving live
The working tree carried a rebuild of all 19 bundles that was never committed.
Ten source commits had landed since the last build output went in: the
page-scroll layout that makes the grid reachable, the eighth filter and its
wrapping row, the tab radius token, the studio's remembered tab, the poster
print window, the fitBounds duration, the configurable pin colour, and the
synced-CSS gate repair.

These are not unshipped fixes. check:catalog-currency fetches all 19 bundles
from the live origin and reports 19 current / 0 stale, and research-dashboard.js
compares byte-identical at 413021 bytes against what nuxtbase.app serves. The
publish happened; only the git record of public/ lagged. Committing it closes
the gap so the next reader of this history does not conclude the tree is ahead
of production when it is level with it.

Worth recording how the verification nearly went wrong. Checking the live bundle
by hand with LIVE=$(curl ...) gave a hash that shared not one character with the
local file, which reads as a clear mismatch. Command substitution strips trailing
newlines, so the fetched copy was 413020 bytes against 413021 on disk, and one
absent byte changes every bit of a sha384. The single-byte size difference was
the only visible clue. Fetching to a file instead makes them identical.

The gate and the ad-hoc check disagreed, and this time the gate was right. The
rule that has been serving here is to suspect a gate when it contradicts
plausible reality; the sharper form is to suspect the newer, less-exercised
instrument. The gate fetches through a Node buffer and had just run that path
nineteen times. The curl pipeline was a minute old.

Nineteen gates pass on exit code, including composite freshness, SRI
consistency, catalog identity and catalog currency. Grading them by their last
printed line does not work and briefly showed ten false failures: these gates
print a verdict and then several lines explaining what they do not cover, so the
last line is teaching prose, not a result. Exit status is the contract the build
chain already uses.

Also drops two untracked scratch probes from 2026-09-22 that have served their
purpose.
2026-09-23 05:41:29 +00:00
doxa-maps-88655c70
1de86ede8f Rebuild the shipped bundles for the heartbeat-red ramp and orange pins
The ramp's top bin is now a label red (#cd2027 — the OKLCh cluster that
Heinz, Campbell's and fire-engine all sit in) so the countries holding the
most unreached people groups read as a heart rather than as clay.

Pins move to Material Orange 800 (#ef6c00) and the selection ring takes over
the navy the pins vacated (#1a237e): an orange pin against the old neon-orange
selection ring measured 1.07 contrast, which would have made the selected
state invisible.

Legend labels the top bin 'heartbeat zone', derived at binLabel() so a host
that moves the threshold cannot strand the name on a bin that is no longer
the top one.
2026-09-22 22:36:23 +00:00
doxa-maps-88655c70
d4ab3a8e09 Anchor the gallery's assets to the tree, not to the page directory
Some checks are pending
build-gate / build (push) Waiting to run
The archived gallery resolved ASSETS as BASE + 'assets' — this PAGE's own
directory. That was correct only while the gallery WAS the root page, where the
two coincide. Since it moved to /__gallery it asked for /__gallery/assets/...
Measured on the built tree with the page at /__gallery/:

    /__gallery/assets/vendor/vue/vue.global.prod.js ... 404
    /doxa-maps/assets/vendor/vue/vue.global.prod.js ... 200

Every leaf page already agrees where assets live: a leaf at
/<tree>/<bundle>/<profile>/ bakes assetsBase "../../assets" -> /<tree>/assets.
So anchor to the tree root, which TREE_URL already names.

This degraded QUIETLY rather than blanking, which is why it outlived the move:
loadVue() falls back to the jsDelivr CDN, so the page kept working while
silently losing the portable / offline / no-CDN property the vendored copy
exists to provide. After the fix the built page loads Vue from
/doxa-maps/assets/vendor/vue/ and reports zero 4xx/5xx.

TREE_URL moves above ASSETS because ASSETS now depends on it; both are `var`,
so the old order would have yielded the string "undefined/assets" rather than
an error. Its explanatory comment moves with the declaration.

The dev server passes assetsBaseUrl explicitly, so this is the built-tree half
of the fix its comment already documents for dev.
2026-09-22 22:03:58 +00:00
doxa-maps-88655c70
7a648f1e1c Rebuild so a fresh clone carries the tree-expand fix
public/ ships to every clone, so build output lagging source means a pull gets working
code and a stale bundle.
2026-09-22 16:48:49 +00:00
doxa-maps-88655c70
0f5ffa28b2 Regenerate the gallery so a row carries its identity in the DOM
The smoke check reads a data attribute to confirm a deep link selected the row it asked
for. That attribute now exists in the built page, so the gate measures the contract it
was written against instead of reading undefined.
2026-09-22 16:07:21 +00:00
doxa-maps-88655c70
e678b1ecab Rebuild the tree so a fresh clone serves working bundles
Some checks are pending
build-gate / build (push) Waiting to run
public/ is committed so a developer can run the dev server immediately without a
four-minute build first, which means stale build output ships to every clone. This
rebuild carries the heading-prop rename in both capability-5 shells and relocates the
archived gallery to its own path.
2026-09-22 15:23:00 +00:00