Commit graph

1888 commits

Author SHA1 Message Date
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
ca13f04bd2 Regions: delete a dead prop, after auditing what the probes can actually prove
The source change is small: countryName was declared on UupgsList and
passed by region-dashboards but never read — residue of the invented
count line removed earlier. Rule 3 prefers deleting a workaround to
keeping one, so both the prop and its call site are gone. Audits for
the two shapes that revealed real gaps earlier — a prop declared and
never used, and state read but never written — now come back clean
across all four components, and every reachable prop their CountryDetail
passes is implemented.

The substantial finding is about the probes themselves, and it lands in
_qa-cycle rather than here. Scanning all 42 for "no ok() calls AND
never exits non-zero" returned 23, including both numbers reported every
tick: region-vs-live's 79/79 and country-vs-live's 32/32 printed
"N IDENTICAL · M differ" and returned success either way, so the
headline parity was read from text rather than enforced.

_probe-standalone-app.mjs had already drifted for exactly that reason:
it printed its table, counted gaps, and exited 0 regardless. Read
properly it was 9 of 10, not the 10/10 reported for several ticks. The
gap was "one h1 on the page: h1 count 2", and measuring resolved it
rather than silencing it — the composite is a scroll column, both panes
are genuinely on the page (both h1s compute hiddenBy null at 1200x75),
so they are two cloned pages in one document. "One h1 per document"
collides with clone parity, since region-vs-live and list-vs-live both
diff an h1 selector; the clone requirement wins and the property now
states "one h1 per pane (scroll column)" with that measurement
attached.

All three now exit non-zero on a real failure, each proven by a
negative test. Two of those tests were wrong first: one swapped a
selector on BOTH sides so they stayed identical, and one read $? after
a pipe, which reports tail's exit rather than node's.

Also made the chooser's pin assertion wait for the condition instead of
a stopwatch. It had read queryRenderedFeatures at a fixed 4200ms and
measured 0 once while consecutive runs gave 1991 and 2107 — a flaky
check, which is worse than a missing one because it teaches you to
ignore a red. Three runs now return 2107 every time.

Regression: 18 suites green, 9 gates green.
2026-09-24 07:22:38 +00:00
doxa-maps-88655c70
02351725ba Gate the shape of last tick's bug, not just the instance
70 story sets were typed as Scripture for seven ticks because NOTHING compared a row's
label to where it points. Clause H, added last tick, catches that exact mislabel. It would
not have caught the same mistake made anywhere else.

Clause I asserts the general shape: Film only on jesusfilm.org or arc.gt, Bible Stories
only on git.door43.org and only under that name, Scripture never on a film host. Audited
across all 3,550 rows first — 0 violations, plus 0 duplicate (language, url) pairs — so
this ships as a guard rather than a fix. The 8 languages carrying more than one Scripture
URL were checked and are correct: Cebuano really does have an eBible ULB alongside Door43's
ULB and UDB, which is three translations, not three copies.

THE PASS LINE NOW NAMES EVERY CLAUSE. It listed four of nine, which reads as a narrower
check than it is, and the next reader calibrates their trust on that line rather than on
the source. Nine clauses, nine names, and --poison still fails 9/9 on demand.

The table also states its scope breakdown where a reader meets it — 855 New Testament, 254
Portions of both Testaments, 136 Full Bible, 14 Old Testament, 13 Portions — with the
reason it is counted from each publication's own book list rather than its subject label.
"How much Scripture exists" is the question under every other number in that table and it
was previously visible only by sorting a column.

Verified: gate passes, self-test 9/9, the table renders its new Scope column, and pins,
scope and deep-link probes all pass.
2026-09-24 07:06:21 +00:00
doxa-maps-88655c70
25405d571a Scope the Door43 Scripture, and stop 70 story sets being typed as Scripture
Door43 entries carry their own book list. Each ingredient is categorised bible-ot or
bible-nt, so a Door43 Scripture entry can say how much Scripture it is, exactly as an
eBible one does — and it is already in the response we fetch, so this costs no extra
requests. 35 entries scoped: 15 New Testament, 13 Portions, 4 Full Bible, 3 Portions of
both Testaments.

THE SUBJECT LABEL IS NOT THE SCOPE, which is why it is counted from the data. An entry
filed under "Bible" can be 27 NT books and nothing else. Calling that a Bible because of
its folder name is the same error as calling Open Bible Stories Scripture.

★ AND THAT ERROR WAS ALREADY SHIPPING, 70 ROWS OF IT, BY MY OWN HAND. Tick 15 added owner
disambiguation so two same-named entries could be told apart — "Open Bible Stories
(OBS-TLF)". The type test was `d.s === 'Open Bible Stories'`, an exact match, so every
disambiguated label fell through to Scripture. 70 story sets have been typed as Scripture
for seven ticks: Bhojpuri, Maithili and 68 more told a reader they had a translation when
they have 50 illustrated narratives. Found only because the type counts moved when this
tick changed the test to a prefix.

An exact match against a label that can grow a suffix is the whole defect, and the fix is
not enough on its own — so clause H now asserts that nothing named "Open Bible Stories" is
typed as Scripture, poisoned with the rest. 8/8 clauses fail on demand.

The Door43 chip in the sheet had the same exact-match and now reads "Bible Stories
(OBS-TLF) · Door43 · <scope>". The one `===` left in the fetcher is correct and was
checked rather than blanket-replaced: it compares the RAW subject from the API, which
never carries an owner suffix.

Every Scripture row on the map now has a scope. The 2,278 blanks reconcile exactly to
2,087 film plus 191 Bible Stories — none of which is a portion of the canon.

Verified: gate passes, its self-test fails 8/8 on demand, and the Door43, pins, table,
scope and deep-link probes all pass.
2026-09-24 06:58:12 +00:00
doxa-maps-88655c70
a32097751b Regions: their second filters panel was missing, and it carries two of their bugs
The expanded filters panel was the largest interactive surface never
compared to theirs, and comparing it found a whole panel absent.
Measured on their live country page: TWO .filters__panel blocks and
THREE .filter-toggle buttons — Adopted, Engaged, Exact Search — against
our one panel and zero toggles. The dropdowns already matched exactly,
four of them, with their country dropdown correctly hidden by
lockCountryCode.

A tell I had missed: the search already read active.exact for the Fuse
threshold, so Exact Search's BEHAVIOUR was ported while the control
that sets it never was.

Ported their panel, their three toggles, their toggle semantics (a
second click removes the filter), their activeFilters shape so each
toggle also renders as a chip, and .filter-toggle styled from the
MEASURED computed values of their live button rather than by chasing
tokens. The labels needed no new keys — Adopted and Engaged were
already translated in all eight locales.

Two of their bugs are cloned deliberately, both measured on their live
/regions/india at 855 rows:

- ENGAGED RETURNS ZERO. Their branch filters on u.people_praying, and
  people_praying is not in the field list they request — they fetch
  people_committed. The field is always undefined, so the filter
  matches nothing: their page goes 855 to 0. Reading people_committed
  instead would make us differ, and the ruling is that what exists on
  DX-life must match. The upstream fix is one word in their file.
- THE ACTIVE STATE IS WHITE ON WHITE. Their rule sets background-color
  var(--color-surface-brand-lighter, rgba(0,80,160,0.05)) with color
  var(--color-white), and --color-surface-brand-lighter is not defined
  in their token files. Their active button computes background
  rgb(255,255,255) and color rgb(255,255,255) — an invisible label,
  now visible in our screenshot too. Defining that one token upstream
  fixes both sides.

Both are flagged for the Driver rather than quietly corrected: a bar
chart was already removed under this ruling for the opposite reason,
being in ours and not theirs.

Proof: _probe-filter-toggles.mjs, all assertions pass — two panels,
three toggles, nine measured style properties, Adopted narrowing 24 to
2 and appearing as a chip, a second click restoring, Engaged emptying
to 0 exactly as theirs does, Exact Search changing nothing alone.

Regression: 22 suites green.
2026-09-24 06:53:22 +00:00
doxa-maps-88655c70
91dc9e6b33 Name and scope every Scripture link, including the 24 on an older edition
Last tick left 547 Scripture links with no scope and 24 with no name. Both had a cause
worth finding rather than papering over.

★ THE 547 WERE A PATCH THAT SILENTLY DID NOTHING. Two code paths create an eBible entry.
The edit that added `ss` matched a string containing httpsify(ee.u); the second path reads
`s: ee.u` without it, so the replacement found no match, reported success, and changed
nothing. 523 entries kept a title and a full-Bible flag while losing their scope — a
half-applied change is harder to see than a failed one, because every field you happen to
check is present. That branch now sets every field the other branch sets.
Scope coverage 690 -> 1,213.

THE 24 POINT AT AN OLDER EDITION than the catalogue's current pick — tel2010 where the
catalogue says tel2017, ben2006 where it says benobcv. BOTH RESOLVE, checked 8 of 8 at
200, so the curated choice is a choice and fill-never-overwrite keeps it. What was missing
was only the description, and the language index cannot supply it: it answers "the best
translation for this code", not "what is THIS link". The catalogue is now also indexed BY
URL, 1,545 entries, so a link is described by the edition it actually points to. Telugu
keeps ebible.org/tel2010/ and is now named "Telugu Easy Reading Version — Full Bible".

RESULT: 1,237 of 1,237 Scripture links carry a real translation name and a scope. Zero
rows still read "Scripture in this language". 840 New Testament, 251 Portions of both
Testaments, 132 Full Bible, 14 Old Testament.

The 2,311 blank scopes in the table reconcile exactly — 2,087 film, 121 Bible Stories and
103 Door43 Scripture entries, which are not eBible publications and have no book counts to
scope. Blank there is the honest answer, not a gap.

Verified: the scope line renders on a real group and is ABSENT on a group with no
Scripture, so it still discriminates; coverage gate passes; pins, table and deep-link
probes pass.
2026-09-24 06:47:23 +00:00
doxa-maps-88655c70
cc350514f3 Say which Scripture it is, and how much of it
Every Scripture row on this map said "Scripture". The catalogue knows the translation's
actual name for 1,213 of the 1,237 languages that have one, and the difference it hides is
the one a mobiliser most needs: "Ahirani Matthew and Mark" is two Gospels, "Bhadrawahi
Bible" is a Bible. The table said "Scripture in this language" on all 1,237 rows and the
sheet showed a bare chip.

The sheet now names the translation and its scope — "German ELBBK Bible — Full Bible" —
and the table carries the real name in its Resource column plus a new Scope column: 101
Full Bible, 461 New Testament, 122 Portions of both Testaments, 6 Old Testament. Scope is
its own column rather than folded into Type, so Type stays a clean three-value filter.

SCOPE DESCRIBES THE TRANSLATION WE ACTUALLY LINK TO. A curated scripture_url pointing at
the same eBible page inherits it; one pointing elsewhere does NOT, because the catalogue's
book counts would then be describing a different publication. That distinction recovered
96 entries and correctly leaves 547 blank rather than guessing.

EBIBLE LINKS WERE THE TICK'S ACTUAL QUESTION and they came back clean: ebible.org/cmn2006/
renders the Mandarin Bible with its full book list in-script, png.bible/aai/ renders
Minaifia NT from Matthew to Revelation. They land where a person can read, which is what
the film links did not do. The scope work came out of reading those pages.

★ THE DEEP-LINK PROBE FAILED ON THIS COMMIT AND THE PRODUCT WAS FINE. It read the ISO code
from cell index 4; inserting the Scope column moved it to 5, so it reported a filter
failure while the filter was returning exactly the right 2 rows. It now finds the column
by its HEADER. A column index is a position, a header is a contract — the same mistake the
tooltip probe made two ticks ago by asserting wording instead of the rule.

Verified: the scope line renders "German ELBBK Bible — Full Bible" on a real group and is
ABSENT on a group with no scope, so it discriminates. Coverage gate passes, its self-test
still fails 7/7 on demand, and pins, table, deep-link, tooltip and sign probes all pass.
2026-09-24 06:40:37 +00:00
doxa-maps-88655c70
a5628171ea Merge main into bundler-1/work (regenerate the tree, re-hash) 2026-09-24 06:30:29 +00:00
doxa-maps-88655c70
9dfcf3551b Correct a wrong finding: Scripture Earth's links are not dead, its shelves are empty
Two ticks ago this repo recorded that Scripture Earth was rejected because its JESUS Film
links are dead, on the evidence that the /watch/jesus.html/<slug>.html scheme 404s —
english.html included, so not merely rare languages. That was wrong, and it was published
where the next agent would inherit it.

It 404s TO A SERVER-SIDE FETCH ONLY. Loaded in a browser, jesus.html/english.html and the
Ghotuo link both render "FEATURE FILM JESUS · 128 min FHD", and a bogus slug renders
"404 PAGE NOT FOUND". The scheme is alive. The lesson was already in this file for
?languageId= and I applied it inconsistently: no jesusfilm.org URL can be graded by
fetching, because the site is client-routed — a fetch 404s pages that work and returns a
shell for pages that do not.

THE REJECTION STANDS, for the reason that actually holds. MEASURED on a random sample of
20 of the 730 tier-0 languages with a Scripture Earth directory: 19 are EMPTY — no video
files, mostly no subdirectories at all. A code directory is a roster row, not a resource.
Perhaps ~5% hold something real, recoverable only by crawling all 730, which is now
recorded as a small-return future option rather than a dead end.

This is the difference between rejecting a source and rejecting it for a reason that is
true. The first would have been re-litigated the moment someone opened one of those links
and watched it play.

ALSO VERIFIED, since the tick started there: all 33 arc.gt links we ship resolve to a real
JESUS Film page — every one, not a sample. Arclight returns those shortlinks as the
shareUrl for some languages, notably American, Russian and Iranian Sign Language, which
matters because sign languages are the emptiest part of this map. A bogus keyword answers
with JSON, {"error":"Keyword not found or invalid"}, NOT a 404 page — so the probe's
control had been grading the bogus case as alive and failing for the wrong reason. Fixed,
and the string recorded so the next check knows what a dead arc.gt looks like.
2026-09-24 06:29:52 +00:00
doxa-maps-88655c70
e8e2be1498 Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	package.json
#	public/doxa-maps/all-people-groups/all-people-groups.js
#	public/doxa-maps/all-people-groups/all-people-groups/index.html
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
2026-09-24 06:25:29 +00:00
doxa-maps-88655c70
2ddb09b42c Regions: RTL kept three rules on the wrong side, and the tab strip never localised
Every vs-live diff so far ran at LTR, so RTL was the next coverage gap —
and it is where layout actually breaks, because a physical `left`
silently survives a direction flip while a logical
`inset-inline-start` does not. Their CSS uses the logical form; ours had
physical `left` in three direction-sensitive places.

The new probe compares each element's offset from the INLINE-START edge
— the left edge in LTR, the right edge in RTL — which is the one number
that goes wrong when a physical property survives. Measured against
their live /ar/regions/cameroon:

- .search-icon sat 566px from the inline-start edge against their
  19.5px: it had stayed on the physical left, which is the inline END in
  Arabic. Replaced with their rule verbatim (blocks/_form.scss:119 —
  inset-inline-start:1em, top:0/bottom:0, margin:auto 0, 1em square),
  which also fixed a second difference, since our fixed 18px square
  could not track the input's type where theirs is 1em. Ours now
  measures their exact 19.5px.
- The country bullet kept left:-16px, so in RTL it detached on the
  wrong side — computed right 256.938px instead of a -16px overhang.
- The chooser's region legend was pinned to the physical left. That one
  is a sanctioned addition rather than a clone, but a legend stuck on
  the physical left in an RTL layout reads as broken whoever designed
  it.

Reading the Arabic screenshot then showed every heading, strapline and
map note in Arabic and RTL while the SELECT VIEW strip still read
"Regions List" — the one piece of chrome that never localised, because
regions-mfe hardcoded its labels and imported no i18n at all. Now
routed through t(), with the legend going through ViewSelect's existing
prop so the shared component and its three consumers stay untouched.

No catalogue keys were added, deliberately. Their common.json has
"Regions" and "View" but none of the composites, so there is nothing to
copy; translating our own additions is not this agent's call; and
check:i18n-parity tracks a per-locale debt ceiling — it/ro/zh sit at
exactly 94 missing because DX-life translates 8 of our 11 locales — so
English-only keys would push them to 97 and fail it. The shim falls
back to the key, so today's rendering is byte-identical. Proven rather
than assumed: a sentinel injected into ar/regions.json rendered on tab
1 at locale=ar while tab 2 stayed English, then was reverted.

Two of my own assertions were wrong first. The probe set
profile-config.locale on every custom element, which on the composite
means three regions-dashboard elements — host and both panes — and
writing a fresh {locale:'ar'} onto a pane that had inherited its config
by provide() clobbered it, leaving every root LTR; I nearly "fixed" RTL,
which works on all three leaves. And checking inset-inline-start in an
LTR context is meaningless, since it merely mirrors left.

Worth flagging: check:i18n-parity's DOXA_REPO defaults to wt-packet,
another worktree, so it must be pointed at this repo to mean anything.

Regression: 22 suites green.
2026-09-24 06:24:45 +00:00
doxa-maps-88655c70
9056b9329c All 12,379 pins were missing on MapLibre, and now a gate says so
Having found that MapLibre drops a whole layer for one unrecognised paint
property, I swept all 17 isMap leaves under ?provider=maplibre listening to
the map's own error event -- the only place this class surfaces. One more
real instance:

    layers.apg-pins.paint.circle-sort-key: unknown property

circle-sort-key is a LAYOUT property, written into paint. Mapbox tolerated
the misplacement; MapLibre rejected the layer, so every pin on
all-people-groups was absent under the keyless engine while the page looked
perfectly healthy. Moved to `layout`; the sort order itself is unchanged.
Screenshots read on both engines: 12,379 pins, identical.

NEW GATE check:maplibre-drops loads every isMap leaf under maplibre and
fails on the layer/source-dropping error family. It skips cleanly when the
server is unreachable, because an UNMEASURED check is not a FAILED one --
the flaw already filed against check:filter-width.

★ THE NEGATIVE CONTROL WAS VACUOUS TWICE BEFORE IT WAS REAL, and that is
worth more than the fix. The first poison renamed the layout block, which
does not reproduce the bug -- the property simply vanishes, no error. The
second poison was faithful, circle-sort-key back in paint, and the gate
STILL printed PASS. Cause: I rebuilt with `BUNDLE=... vite build` and did
not run `bun run tree`, so the leaf carried stale SRI, the browser REFUSED
the script, no map mounted, and the gate saw zero errors. A check that ran
correctly and measured nothing, reading as green -- the first entry in
AGENT-BRIEF's troubleshooting table, walked into anyway.

After `bun run tree` (check:sri 0) the gate FAILED on the real bug, naming
the exact property, and PASSED again once restored. Both directions seen on
this codebase, which is the only thing that makes its green mean anything.

gates:pre 0, gates:post 17/18 (pre-existing verify/tables-complete),
check:doc-scripts 0, check:no-orphan-gates 0.
2026-09-24 06:24:40 +00:00
doxa-maps-88655c70
7b27bf1b83 Every link https, and a gate so it stays that way
1,237 of the URLs we ship were plain http — all of eBible.org, png.bible and alkitab.pw,
with the host spelled `eBible.org` on six of them. Every one of those hosts serves https
directly (sampled 12/12 at 200) and redirects http there anyway, so the plain link bought
nothing but a redirect hop and a downgraded scheme in a field worker's browser, on a page
that may itself be embedded over https.

NORMALISED WHERE A URL ENTERS THE INDEX, not per fetcher. Doing it in the eBible fetcher
first left 120 links behind — the curated scripture_url values, which fill-never-overwrite
preserves and which therefore never pass through a fetcher at all. One httpsify at every
emit point takes it to 3,548 https and 0 http.

Scheme and HOST only. The path is left exactly as published, because a path is
case-sensitive and "tidying" it would break the link rather than improve it.

★ THE GATE CAUGHT MY OWN DRIFT, ten seconds after I added the clause. Clause G failed —
but as clause E, "1237 table URL(s) exist in no pin record": I had rebuilt the pins and
not the table, so the two surfaces disagreed about every eBible link in the corpus. That
is precisely the class this gate was written for last tick, and it found it on its first
real use rather than three ticks later by hand.

Clause G is poisoned in the self-test alongside the rest: 7/7 clauses now fail on demand.

Verified: 0 plain-http URLs remain, six upgraded links sampled live all answer 200 over
https, the coverage gate passes, and pins, table, deep-link, tooltip and list probes all
still pass.
2026-09-24 06:15:58 +00:00
doxa-maps-88655c70
799345cba2 Give the film links a destination: 2,049 real per-language deep links
Last tick established that ?languageId= is ignored by the site — a real id and a bogus id
render the same generic library — so 2,087 groups had a film link that did not take them
to the film. Arclight publishes the missing piece: media-components/1_jf-0-0/languages/<id>
returns a shareUrl, and that IS a deep link — jesusfilm.org/watch/jesus/<language-slug>.

2,082 of 2,087 languages now carry one; 304 existing links were upgraded in place and the
rest were written as deep links from the start. The 5 with no shareUrl keep the old URL
and their tooltip says so, rather than every group getting the same hedge.

FETCHED, NOT DERIVED, and that was the judgement call. The slug looks derivable — "Pashto,
Southern" -> pashto-southern, and 8 of 8 sampled names transformed correctly. Eight is not
2,087, and a wrong slug is not a broken link but a link to SOMEONE ELSE'S language, which
is the worst failure available here. So the API was asked 2,087 times, rate-limited at
250ms, cached and resumable so it is asked once. 5 missing, 0 wrong.

★ AND THIS CHECK CAN FAIL, which none of the previous jesusfilm checks could. A real slug
returns a page carrying the media component id `1_jf-0-0`; a bogus slug returns 0 of them.
Sampled 8 deep links: 8 real, 0 bad, and the bogus control returns 0 hits. In a browser
the Bhilali link renders "FEATURE FILM JESUS · Watch now · 128 min FHD" while the bogus
slug renders "404 PAGE NOT FOUND". That is the first time a jesusfilm URL has been
verified rather than assumed in either direction.

THE TOOLTIP PROBE FAILED ON THIS COMMIT, correctly: it asserted the old caveat wording, so
fixing the links broke the test that described the defect. It now asserts the CONTRACT —
a deep link must NOT carry the caveat and a legacy link MUST — which survives the next
change to the words.

SOURCES-CHECKED-AND-REJECTED.md records the shareUrl endpoint and the `1_jf-0-0`
discriminator, so the next agent inherits the answer instead of the dead end.

Verified: coverage gate, pins, table and deep-link probes all pass; the film chip on
German renders the real deep link with the accurate tooltip.
2026-09-24 06:09:11 +00:00
doxa-maps-88655c70
10f83a943b Regions: links we render as buttons had lost their anchor styling
Found by a COVERAGE AUDIT, not a screenshot. region-vs-live diffs
/regions/africa and country-vs-live diffs only UupgsList's nine
elements, so the country page's head and the region card had no
property diff at all. Adding both produced four defects sharing one
root cause: DX-life renders these as <a>, we render them as <button>
because this element has no router, and a button inherits neither the
UA underline nor their `a` rule (_globals.scss:132 — color
var(--color-brand-light), font-weight medium,
text-decoration-thickness 2px, text-underline-offset 4px).

- The dashboard back-link was #4e594f with no underline against their
  #cbc5b9 underlined. Their .light-link recolours to
  --color-brand-secondary = $color-beige-300 = #cbc5b9, exactly the
  measured rgb(203, 197, 185). Wrong on BOTH our region and country
  pages.
- The region-card link was font-weight 400 against their 500.
- Its icon spacing came from gap:10px where theirs comes from
  .with-icon .icon.right { margin-inline-start: 1rem } — the same 10px
  on screen, but their rule would never have applied to our markup, so
  the mechanism is now theirs.
- The country entries were rgb(42, 51, 42), our body ink, against
  their rgb(78, 89, 79) = $color-green-400 = --color-brand-light.
  Their country links carry no class at all — they simply take the `a`
  rule, which is why '.country-link' matched nothing on their side and
  the element read as unmeasured until the selector became
  '.countries-grid a'.

Coverage added so none of this can come back: a new
_probe-country-head-vs-live.mjs at 29/29 (back-link, h1, strapline, map
note, search-box container); the back-link added to region-vs-live,
which went 73 to 79 IDENTICAL; the search-box CONTAINER added to
country-vs-live, 28 to 32, so the 50ch cap cannot regress; the region
card added to list-vs-live at 30/30.

Three of my own assertions were wrong before the code was: I omitted
fontWeight and the underline geometry from the back-link spec while
their `a` sets all three; I compared `gap` on an element where theirs
never sets it; and my sub-pixel tolerance used Number('246.938px'),
which is NaN, so NaN <= 0.5 was false and it silently never fired.

Regression: 22 suites green, 9 gates green.
2026-09-24 05:58:43 +00:00
doxa-maps-88655c70
984f86d10b Say that the film link is not language-specific, because it is not
A tick spent on Scripture Earth ended by turning the check on our own links, and they do
not do what the chip implies.

MEASURED IN A BROWSER: https://www.jesusfilm.org/watch/?languageId=<id> renders the same
generic library page for id 529, for id 1304, and for id 999999999. languageId is a query
parameter on a client-rendered page and the current site ignores it. The film is real —
the JESUS Film Project's own API confirms a feature film in that language — but the URL
does not carry you to it, and the tooltip said nothing about that. A field worker follows
the link and has to find the language themselves. The chip now says so.

★ THE URL CANNOT BE VERIFIED BY FETCHING, IN EITHER DIRECTION, and both wrong answers are
available. The server returns a byte-identical 867,227-byte shell for every id: grading by
STATUS reports ALIVE for everything, including a bogus id; grading by CONTENT reports DEAD
for everything, because "WatchNotFound" lives in an i18n bundle present on every page. I
ran the content check first and it returned 10 of 10 of our own links DEAD — Fon and
Southern Pashto among them, which is not believable, and that implausibility is the only
thing that caught it. Reported as-is it would have ripped out 2,087 correct links.

No constructible scheme works either: the legacy /watch/jesus.html/<slug>.html form is
retired (english.html 404s, not just rare languages) and /watch/<slug>.html/videos — a
shape the live page itself renders — also 404s to a fetch, resolving client-side. The
Arclight API is the only thing that discriminates, and it stays the basis for the claim.

SCRIPTURE EARTH IS REJECTED, with the reasoning kept next to the code so the next agent
does not re-walk it. Its /data/ index looks like the largest win available — 3,477 ISO
639-3 directories, 730 of them languages we currently show as "nothing found" — and it is
not coverage: aab/video and aac/video are EMPTY, its JESUS Film links use the retired
scheme, and it files Ghotuo under `aaa` where the publisher files it under `ikh`. Shipping
from it would have added ~730 dead links to the groups that most need real ones.

SOURCES-CHECKED-AND-REJECTED.md records that, plus what is credential-blocked and why,
plus that sign.bible is a parked domain answering 200. It sits beside the bundle rather
than in _qa-cycle, which is gitignored — a note the next agent cannot read is not a note.

Verified: the film chip's title renders the honest text on a real group (German), and the
pins, country and table probes still pass.
2026-09-24 05:44:55 +00:00
doxa-maps-88655c70
ba6b9229f3 Merge main into bundler-1/work (regenerate the tree, re-hash)
Some checks are pending
build-gate / build (push) Waiting to run
Six conflicts, all in generated artifacts under public/, including this
bundle's own — another builder's tree regeneration had rebuilt it.
Regenerated rather than hand-merged: the three PosterWall consumers
rebuilt, then the tree and the hashes.

PosterWall.vue itself auto-merged with both sides intact — this
branch's embedded/sectionHeads props and main's usePosterPrints
refactor — and the four suites that exercise it through the regions
poster dropdown were re-run green after the merge, since their change
lands inside the component this feature renders.
2026-09-24 05:33:32 +00:00
doxa-maps-88655c70
117467f7e7 Stop claiming Scripture in a language recorded as "Undetermined"
Reading the list profile's own screenshot turned up a row for "Undetermined (und)" —
14 people groups — offering two Open Bible Stories links. ISO 639-3 reserves und, mul,
mis, zxx and qaa-qtz for exactly the case where the language is NOT known, so a resource
attached to one is attached to nothing. Telling 14 groups there is Scripture in their
language when the record says the language is undetermined is the single worst thing this
map can do, and it took looking at the page to see it. Door43 publishes 4 entries under
und; 16 pins sit on a special code. All are now excluded at the index.

COVERAGE GOES DOWN, 8,935 -> 8,921 pins with a real URL. That is the right direction: the
14 were false.

The same screenshot showed Dogri rendering "Open Bible Stories  Open Bible Stories" —
two links a reader cannot tell apart. 40 languages carry more than one entry under the
same subject, because a language can have more than one independent translation effort.
The owner now disambiguates, and ONLY where it is needed: Dogri reads "Open Bible Stories
(translationCore-Create-BCS)" and "(OBS-TLF)", while a language with one entry is
unchanged.

The list profile also gained the pool it predated. It bundles its own resources.json at
build time, so Door43 never reached it: 49 languages, 63 links. Checked before writing —
120 of its 862 languages already carry a scripture link and those are exactly the 120 the
enriched index has, so nothing there needed correcting, and its curated 862-language scope
is this profile's subject rather than staleness. The enrichment is idempotent: it drops
what a previous run added before re-adding, or a stale title would survive the index
learning to disambiguate.

NOT DONE, DELIBERATELY: the 506 zero-reach rows still never match a COUNTRY filter.
Arclight carries a primaryCountryId that could attribute them, but the only alpha-2 to
alpha-3 mapping in this repo disagrees with itself on 18 countries and cannot resolve 51
of the 168 codes. A wrong attribution would point a real link at the wrong field, so the
behaviour is documented in the table meta instead of guessed at.

Verified: und/mul/mis/zxx absent from the index, the list renders 49 Door43 anchors where
it rendered none, three sampled new links answer 200, and pins, table, deep-link, country
and sign-language probes all still pass.
2026-09-24 05:22:59 +00:00
doxa-maps-88655c70
a0910bcdef refactor(packet): the legend's packet export uses the shared action
Some checks are pending
build-gate / build (push) Waiting to run
Continues the consolidation. The card deck moved to usePosterPrints last tick;
the PACKET export in upg-100-list.vue was still a second hand-maintained copy of
the same shape — its own renderer import, its own strings fetch, its own
language resolution. Two copies of one fact drift.

printPacket now lives in the composable, so the browser export and the
build-time generator share one renderer AND one calling convention. What stays
in the bundle is what genuinely cannot be shared:

  packetRecords  who is in this list — the people-groups API, the selector
                 evaluator, and the pg_slugs special case that 12 of the 27
                 lists need (the evaluator intentionally THROWS on that type)
  packetMapSvg   where this bundle's posters are served from. Absent is fine:
                 the packet prints a cover without the map rather than failing

⚠ AND THE MIGRATION WOULD HAVE MADE THE PACKET PRINT SILENTLY. The composable
reports through its own exportState; this profile has no HUD for that and shows
`status` instead — exportState appears ZERO times in the file. Without a bridge
there would be no "building…", and a failure would vanish with nothing said: the
mechanism working while the feedback does not, which is the exact trap the wall
consolidation hit from the other direction. A watcher now maps the composable's
phases onto `status`.

Verified: check:poster-prints drives the shared action and asserts the packet's
SHAPE — 4 sheets for 2 groups (map + people page + points + wheel), 12 wheel
segments, Mark 5:1-20 present — not merely that bytes were produced. Confirmed
to fail before acceptance by removing the wheel page (3 sheets, 0 segments). The
build-time generator still passes check:packet-render at 8/8, and
check:deck-click still prints 116 cards through the real button.
2026-09-24 05:22:29 +00:00
doxa-maps-88655c70
63fad20739 Regions: their worldmap watermark, and two defects it uncovered
Measured first, as the Oswald lesson requires, and this one was real:
their UupgsList root carries worldmap.svg inline (UupgsList.vue:451),
and it genuinely renders on their live country page — HTTP 200, 62,740
bytes over the wire, computed background-size 60%, position 50% 0%,
repeat no-repeat, min-height 400px. Confirmed by screenshotting the
ELEMENT, not the viewport: the watermark sits below the fold, so a page
screenshot shows nothing at all.

public/assets/images/worldmap.svg is their file byte-for-byte — 311KB on
disk, small against this tree's existing assets, which run 644KB to
42MB. The inline background-image takes its base from a new imagesBase
prop, since a dropped bundle cannot assume their absolute /assets path.

generate-tree's staged allowlist needed 'images' added — the same trap
that silently broke the vendored fonts. A dir absent from the allowlist
404s, and an unfetched background-image is quieter than the font
failure was: nothing paints and nothing reports. So the probe asserts
the REQUEST returned 200 rather than that the CSS names the file.

Then, reading the screenshot after every number was already green, two
more defects:

- A CLASS WITHOUT ITS RULE. The search label uses their class name,
  sr-only — the right instinct — but their rule
  (utilities/_visibility.scss:2) was never ported, so the class matched
  nothing and the label rendered as a visible line of text above the
  input, duplicating its own placeholder. Borrowing a class name only
  works if its declarations travel with it, and a class matching no rule
  is invisible to a property diff. Now 1x1px and clipped, still in the
  DOM and still announced.

- A PIXEL CAP WHERE THEIRS IS RELATIVE. .search-box was capped at a
  hardcoded max-width:480px; theirs composes .max-width-md, which is
  max-width:50ch (utilities/_sizes.scss:11). Wrong value and wrong unit —
  ch tracks the input's font, px does not — and ours truncated the
  placeholder mid-word. 50ch now resolves to exactly 600px, their
  measured width.

Regression: 18 suites green, including a new _probe-watermark.mjs at
15/15.
2026-09-24 05:15:06 +00:00
doxa-maps-88655c70
694f3859d6 List every link, including the 377 languages no pin speaks
The table is titled "every link" and was silently dropping 506 real resources across 377
languages, because the generator skipped any code with no people group on this map. They
are not edge cases: Ahirani has Scripture, a film and Open Bible Stories; Pahari Mahasui
has Scripture, a film and five Door43 resources. Someone searching for that language
still needs to find it, and the reach columns reading 0 is the true answer to "how many
groups on THIS map speak it" rather than a reason to hide the resource.

3,043 -> 3,549 rows, 2,307 -> 2,684 languages. The meta says what a zero-reach row means
so the column is not read as "this resource reaches nobody".

THE INCLUSION EXPOSED A BLANK. Those codes are named nowhere in the people-group dataset
— it only names languages some group speaks — so their rows rendered a bare ISO code with
an empty language column, which tells a reader looking for a language precisely nothing.
The eBible index now carries languageNameInEnglish alongside the translation title (two
different things), and a final pass names any remaining code from whichever catalogue
knows it. Codes carrying a resource with no language name: 377 -> 0.

An audit of the reach columns against an independent recount off the pins found 0
disagreements on people_groups and 0 on speakers, so the table and the map agree about
who speaks what.

Verified: a seeded sample of 12 newly-exposed rows — 9 resolved 200, 3 skipped as
unverifiable, because jesusfilm.org/watch answers 200 for any id and a status check there
proves nothing. Three Door43 links first returned 000, which is curl failing to CONNECT
rather than an HTTP status; retried with spacing they all answer 200, so the failure was
my own request rate and not a dead link. Table, deep-link, pins, gap-list and country
probes all still pass, and the country sheet still never fetches the retired 1.4MB file.
2026-09-24 05:13:57 +00:00
doxa-maps-88655c70
6c32623856 refactor(poster): the 100-list wall stops carrying its own copies
Finishes the half this card left open last tick. The shared print composable
existed but the 903-line 100-list wall did not use it, so the card deck lived in
TWO places — I wrote both this flight, and they could already drift.

Two duplicates removed:

  printCardDeck   now delegates to usePosterPrints, sharing THIS wall's
                  exportState so the HUD keeps rendering one surface. The bundle
                  still owns what cannot be shared — fetchListRecords knows the
                  people-groups API, the selector evaluator and the pg_slugs
                  special case — and hands it over as the `records` provider. The
                  preview-only "no API configured" hint is preserved, because the
                  shared action cannot know about it.

  openPrintWindow was a local reimplementation of printInPlace.js — same
                  off-screen iframe, same doc.write, same autoprint script,
                  differing only in an unused `title` argument. A FOURTH copy of
                  one mechanism is how a fix to the blank-first-page behaviour
                  reaches one caller and not the others. Now a one-line adapter.

★ AND check:share-policy CAUGHT A REGRESSION I HAD CAUSED. Adding the
country-posters two-tab MFE last tick gave that bundle a second ShareButton —
the BUG-UPG100-DOUBLE-SHARE state the 100-list already fixed once. Fixed the
same way the 100-list fixed it: the MAP's button is REMOVED and the MFE header
owns sharing. Removed, not flag-gated — a `v-if="false"` button is dead code
that still reads as an affordance to every reader and every counter, which is
exactly why the gate counts tags rather than renders.

Verified by clicking the REAL button in a browser, not by driving the renderer:
116 cards at 2.5in 3.5in with 116 QR codes, and 3.5in 2.5in when the stock
picker is switched to landscape — so records provider, picker, language, shared
composable and print frame are connected end to end. check:mfe-two-tab still
reports exactly one share affordance on the country-posters MFE.
2026-09-24 05:10:44 +00:00
doxa-maps-88655c70
916ef35ee8 Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/country-posters/countries-map/index.html
#	public/doxa-maps/country-posters/country-poster-wall/index.html
#	public/doxa-maps/country-posters/country-posters-mfe/index.html
#	public/doxa-maps/country-posters/country-posters.js
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
2026-09-24 05:09:42 +00:00
doxa-maps-88655c70
fc69adbbd5 Country boundaries load on MapLibre in the shared library, not per plugin
main added country-posters-mfe (isMap=true). It inherits the engine swap
correctly -- 14 -> 4 requests to api.mapbox.com, maplibregl loaded -- but
threw

    Fetch API cannot load mapbox://mapbox.country-boundaries-v1.
    URL scheme "mapbox" is not supported

A new map arriving is exactly what the rollout has to cover, so this was
worth chasing rather than noting.

TWO shared library sites, not one, which only measuring found. Fixing
useCountryOutline.js (consumers: doxa-simple-map, countries-map,
research-map) cleaned two of the four leaves and left countries-map -- and
the new element nesting it -- still throwing, from a SECOND site:
useMapLayers.js addRegionsLayer. Both now branch per engine, using the
keyless assets/boundaries/world-adm0.geojson the tree already ships, with
iso3 normalised once to iso_3166_1_alpha_3 so every downstream expression
keeps reading the name it always read, and 'source-layer' set for the
vector-tile path alone.

The engine is read off the LIVE map -- its container carries
maplibregl-map -- rather than from config, because both composables are
called with nothing but a map getter.

MEASURED: 0 mapbox:// errors across country-posters-mfe, countries-map,
doxa-simple-map, research-map and demo-map; the adm0 fetch confirmed firing;
provider sweep 15/17. The Mapbox path is unchanged -- its control screenshot
still shows the coloured WAGF region fills.

Also fixes check:descriptions, which the new profile red-gated by shipping
an empty description. Written from the component's own header, not a
placeholder: 34/34 profiles described.

NOT FIXED, and recorded in the ledger rather than glossed: under MapLibre
countries-map still paints NO coloured region fills. The error is gone and
the geojson loads, but the fill does not appear. That is a separate defect
from the source failing, and claiming this commit finishes it would be
false.
2026-09-24 05:09:12 +00:00
doxa-maps-88655c70
a1ccc683a6 Make a country describe the same people groups the map draws
The country sheet was built from people-group-resources.json, a curated 2,106-record
subset, while the map draws 12,379 pins. The two described different universes and the
sheet silently undercounted. MEASURED before this change: Indonesia listed 36 groups with
669 pins under your finger, Nigeria 38 against 433, India 899 against 2,146. A map that
contradicts itself on tap is worse than one that says less.

Matching was also done by PARSING a comma-separated country-name string against a
longest-first vocabulary — a second way to lose rows, since a name that does not match
the vocabulary exactly drops out with no error. Pins carry ISO3, so the index is now an
exact key rather than a text search.

ONE SHAPE, BUILT IN ONE PLACE. openPin used to assemble its own group object while the
country list built another, so a field added to one path silently did not exist on the
other — which is how is_sign and the Door43 list would have gone missing from a group
opened out of a country. groupAt(i) is now the only constructor.

THE HEADER HAD TO FOLLOW, and reading the screenshot is what caught it. Moving the list
to the pins left the stat block still reading the curated table, so the card contradicted
itself inside one view: "People groups 36" sitting directly above "Gospel resources found
(282)". The stats are now derived from the same groupsForCountry the list renders.
Growing-and-unreached and deaths/day are NOT derivable from pins — they come from the
curated analysis — so they are marked and footnoted with how many groups that analysis
actually covers, rather than sitting unlabelled among numbers with a different basis.

RETIRES A 1.4MB FETCH. people-group-resources.json is no longer requested at all: the
pins are already in memory and carry richer resource data than that file ever did. Only
the 136KB link-verification fetch remains, and it is still allowed to fail.

Verified on the served tree: for IDN, NGA, IND, NPL and TCD the sheet's group count now
equals the layer's pin count exactly (669/669, 433/433, 2146/2146, 171/171, 150/150), and
with + without partitions the total in every case. The network log confirms
people-group-resources.json is never fetched. Indonesia reads 669 groups, 387 with
nothing, 282 with a resource — and 669 - 387 = 282. All seven browser probes still pass.
2026-09-24 05:07:31 +00:00
doxa-maps-88655c70
7f8b75d661 Experimental shelf: Kingdom-impact order + curated exclusions
Some checks are pending
build-gate / build (push) Waiting to run
The Experimental folder now presents most-impactful first per the
finalized roundup ranking (EXPERIMENTAL-MAPS-ROUNDUP.md): Prayer Walk
Planner, the rescued legacy set (entry: send-map), All People Groups,
Country Posters, Vision Card -- rank is editorial curation and lives
beside TAB_DEF; unranked bundles still discover and sort A-Z after.
Verified in the built shell rail.

Exclusions tightened: demo-map + golden-path-check (scaffolding /
authoring tooling -- the studio keeps them), doxa-research-map (the
part already composed into Simple Research), stack-demo (broken and
deferred; a broken tab on the giveaway shelf reads as damage).
Wrapper description corrected: the legacy IIFE holds FIVE maps.
2026-09-24 05:02:21 +00:00
doxa-maps-88655c70
c1ab013f9e Merge main into bundler-1/work (regenerate the tree, re-hash)
Some checks are pending
build-gate / build (push) Waiting to run
The one conflict was in public/doxa-maps/index.html, which generate-tree
writes. Built files are regenerated, not hand-merged: country-posters
was rebuilt because main changed its source (a new two-tab MFE),
regions-dashboard because this branch did, then the tree and the hashes.
2026-09-24 04:54:42 +00:00
doxa-maps-88655c70
c268d13a42 Regions: the list tab was half-localised, and Oswald must not be vendored
Two findings, both from measuring at locale=ru instead of trusting the
plan.

DO NOT VENDOR OSWALD. The work list called for it for Russian headings.
Their rule is [lang="ru-RU"] on h1/h2/h3 (_globals.scss:40) — but the
live document's lang is "ru", so the selector never matches. It is dead
code on their own site. Oswald never loads there either:
document.fonts reports it "loading"/"unloaded" while Bebas, Poppins and
Brandon all report "loaded", and the glyph oracle agrees — Oswald
renders "HAMBURGEFONS" at 855 against sans-serif's 856.36 (0.2%, noise),
where Bebas moves it to 486 (43.2%). Bebas itself has no Cyrillic (501
vs 501.42), so their Russian headings fall back to the system face at
letter-spacing normal. Vendoring Oswald would have made us DIFFER.
_probe-ru-parity.mjs now asserts that parity instead of assuming it:
10/10, both sides falling back identically at 444.88.

The list tab was half-localised, and three of the four defects were
invisible in English:

- .rl-title forced text-transform:uppercase. Their h1 is none on both
  locales. Invisible in English because Bebas is an all-caps face, and
  wrong in Russian, where theirs reads "Регионы" and ours read
  "РЕГИОНЫ". This file's own comment already recorded the correct
  finding for .region-card__title; this rule was missed.
- summaryLine() was a hardcoded English template where theirs is
  t('%count% people groups in %countries% countries'). The Russian
  string was already in our catalogue — copied, never wired.
- dashboardLink() likewise: their ru value is "Открыть панель: %region%",
  a different word order no English template could be patched into.
- Nothing refetched on a locale change. The region and country labels
  come from the API's ?lang=, and their site serves Азия/Африка/
  Азербайджан on /ru/regions. region-dashboards.vue already had
  watch(languageCode, loadRegions); this tab did not, so a locale switch
  translated the chrome and left every label English.

Why none of it was caught: _probe-region-vs-live.mjs does compare
textTransform and reports 73/73 — but it diffs the DASHBOARD tab's
.rd-title. Nothing ever property-diffed the LIST tab's head against
/regions. The gap was coverage, not the property list.
_probe-list-vs-live.mjs closes it at 8/8.

The loading state now uses their own 'Loading results...' key rather
than new English, since that string is already translated in all eight
locales.

Regression: 18 suites green.
2026-09-24 04:53:36 +00:00
doxa-maps-88655c70
5e54e601aa Say that audio was never searched, on the groups whose need it is
MEASURED: has_audio is 'unknown' for all 2,106 source records and therefore for all
12,379 pins. Not one group on this map has had its audio checked, because GRN — the main
audio-gospel pool — is named as unchecked on every record. The map was nonetheless
showing 3,444 groups as "nothing found", covering 347,125,659 people, and for an oral
culture audio is not one resource among several, it is the one that matters.

So the claim was overstated in the place it is least affordable. Three changes, all copy,
no data:

The audio chip said "unknown", which did no work — it is unknown for EVERY group, so it
read as a property of that group rather than of our search. It now says "not searched
(GRN)" and names the pool in its tooltip. A shrug becomes something a reader can act on.

The gap list now leads with it: 3,444 groups, 347,125,659 people, and "Audio has never
been searched for any of them" before the ranked rows rather than after.

The legend caveat names the pool and what its absence costs, instead of the passing
"GRN audio is unchecked".

NOT ADDED, DELIBERATELY: Arclight publishes an audioPreview for 2,074 of these codes and
it is a real, resolving URL. It is also ~24.4KB of AAC — MEASURED at a uniform 11.8
seconds — a language-identification sample, not Scripture. Shipping it under "Audio ·
found" would be the same error as calling Open Bible Stories a Bible, so it is left out.
Real audio still needs GRN or FCBH credentials.

Verified on the served tree: the legend carries the warning, a group sheet reads "Audio ·
not searched (GRN)" with no bare "unknown" anywhere in it, the gap panel carries the
sentence, and the people-count renders 347,125,659 — the same number the build measures.
Pins, filter, gap-list and sign-language probes all still pass.
2026-09-24 04:48:45 +00:00
doxa-maps-88655c70
45354c95ec Say what "none found" means for a Deaf group, because it does not mean the same thing
A sign language cannot be served by a text translation at all. eBible is a text pool, so
its silence for a Deaf group describes OUR SEARCH, not their need — and the sheet was
reporting it as "Scripture · none found" exactly as it does for a spoken language. That
is the map telling a true-sounding thing that misleads.

Sign languages are now flagged in the pin build and the sheet says so: what counts for
them is Scripture FILMED in that sign language, and the text pool was never the right
place to look. Counts are computed, not typed — 150 sign languages on the map covering
84,159,050 people, and the copy reads "none yet has both" only while that is true, since
it is generated from the same pins the layer draws.

THE SCALE: 234 pins on 150 sign languages, 183 of them with nothing found, 0 with both a
film and a text Scripture. 13 have a JESUS Film through Arclight and 1 has Open Bible
Stories through Door43; everything else is empty.

★ A NOTE LEFT IN THE BUILD FOR WHOEVER FILLS THIS GAP. The largest producer of
sign-language Bible content by a wide margin is the Watchtower/JW organisation, which is
excluded by standing rule — added canon. Anyone researching this will meet jw.org first
and it will look like the obvious answer. The legitimate partners are the Deaf Bible
Society and DOOR International, and both need credentials this build does not have:
deafbible.com is a key-gated app, doorinternational.org answers 403. sign.bible is NOT a
source — it is a parked domain listed for sale, which a status-code check would have
happily accepted as live.

No sign-language resource is added here, because none could be verified. What changed is
that the map stops implying the absence was measured against the right pool.

Verified on the served tree with a negative control: the note renders for Deaf Nicaraguan
(Nicaraguan Sign Language, ncs) and does NOT render for German, so it discriminates
rather than always firing. The rendered counts match the build's own tally exactly. Pins,
deep-link, filter, gap-list and language-naming probes all still pass.
2026-09-24 04:39:10 +00:00
doxa-maps-88655c70
2d8361a4a0 feat(country-posters): two-tab element, reusing the shared view switcher
Driver: "Country Posters is supposed to have two tabs, structured like the
UPG-100 list."

★ THE CARD'S REAL POINT WAS THE CONTROL, and it was already settled: this is the
THIRD consumer of the SELECT VIEW switcher, and library/components/ViewSelect.vue
already exists precisely because a third hand-rolled copy was imminent — its own
header says a fourth divergent copy is not what the library is for. So this
element imports the shared component rather than adding one, and the whole
feature is one new profile following an established shape.

country-posters-mfe.vue mirrors upg-100-mfe deliberately (index.js already
records that this bundle is "the same shape as my-upg-100-list"): the
pane-wrapper column, each pane under its own ProfileLoader/Pinia, one
ShareButton in the header sharing the COMPOSITE leaf — not a child, which is the
BUG-SHARE-FROM-CAP4-COMPOSITE bug this pattern already paid for.

LIST FIRST, MAP SECOND, per the Driver's principle that maps are a secondary
view: the poster wall is the concrete artefact a newcomer can act on, the map is
where it gets placed. Pane order lives in at[0] and in the array, because
buildPaneColumn sorts by at[0] while ViewSelect renders the array verbatim —
desynchronising them is a recorded trap.

It also CLOSES A DEAD SEAM. country-poster-wall has dispatched
'doxa-country-posters:show-on-map' since it was written, but only the app shell
listened — so in a dropped embed or a nested composite the action fired into a
document with no handler and did nothing. This shell owns the event and swaps to
the map tab, which is what makes the two tabs one element rather than two pages
that share a bundle.

Verified in a browser by the new check:mfe-two-tab: host 900px, tabs
"Country Posters | Map", both panes mounted at 1280x843, exactly one share
affordance, zero page errors. The same gate passes unchanged on upg-100-mfe and
regions-mfe, so the three composites are now held to one contract.
2026-09-24 04:32:05 +00:00
doxa-maps-88655c70
f68ffc33ba Rank the gap, and name the languages that have nothing
The filter shows WHERE the gap is; it could not say which part of it is biggest. The
3,444 groups with nothing were reachable only by tapping dots until a large one turned
up, which is not how anyone chooses a field.

"See the 40 largest" now opens a ranked list in the sheet — the slot the country and
group views already use, so no new geometry and no new collision with the control rail.
Sorted by population, because choosing where to go is choosing how many people are
behind the decision. Tapping a row flies there and opens that group. Fly first, THEN
open: opening sets `selected`, which swaps the sheet away from the list, so the other
order loses the map movement behind a re-render.

THE LIST EXPOSED A REAL DEFECT. The largest gaps are exactly the groups with no entry
in any pool, so they had no record to carry a language name and the sheet rendered
"Language: (blank) cjy" for a 60-million-speaker group. Every language is now named
from the people-group dataset, creating the entry where no pool has one: 2,251 codes
are named-only — the language is known, nothing was found for it anywhere.

That makes "has a code entry" ~100% by construction, so it is relabelled in the build
output as naming and NOT coverage. Coverage is the line under it and still reads 8,935
of 12,379, 72.2%. A metric that reaches 100% because its meaning changed is worse than
no metric.

Verified on the served tree: 40 rows, populations strictly descending 60,310,000 ->
974,000 so "largest" is not a claim taken on trust; tapping row 1 moves zoom 1.3 -> 4.2
and opens Han Chinese Jinyu, whose scripture_url and film_url are empty and whose
Door43 list is empty — so the list is not mislabelling groups that have something. 200
sampled zero-resource groups all name their language, where the screenshot before this
change shows that field blank. Pins, table, deep-link and filter probes all still pass,
and the full a11y sweep passes 810 of 810 buttons across 21 leaf pages.
2026-09-24 04:30:11 +00:00
doxa-maps-88655c70
61dcd9b28e Merge main into bundler-1/work (regenerate the built tree, re-hash)
Some checks are pending
build-gate / build (push) Waiting to run
The nine conflicts were all in generated artifacts under public/. Built
files are not hand-merged: every bundle whose source either side touched
was rebuilt, the tree regenerated against main's new generate-tree, and
the hashes re-baked. Source merged clean — main never touched
PosterWall.vue or en/regions.json, they only appeared in a two-dot diff
because this branch changed them.
2026-09-24 04:28:25 +00:00
doxa-maps-88655c70
ddde518a85 Regions: expand a region to its country posters, reusing the shared wall
Driver request: "a toggle or carrot added to the bottom of the region
section that says view country posters, which will let you view country
posters for each of the countries in the regions" — with View-on-map and
Print, "the same reusable code from upg 100 list". This is a third
sanctioned addition alongside the tab strip and the no-selection chooser.

Almost nothing was built. library/components/poster/PosterWall.vue was
already the shared, fully parameterised grid, and it already owned the
card layout, the "Show on map" header button, the PRINT OPTIONS footer
and the client-side raster. regions-list.vue supplies only data and a
toggle — the same division country-poster-wall.vue states in its own
header.

- A "View country posters" toggle at the bottom of every region section,
  carrying aria-expanded and aria-controls, using the same caret SVG the
  header and footer links already use, rotated when open.
- Opening a region renders that region's countries. Measured: 41 cards
  for Africa and all 41 previews LOAD (naturalWidth>0 — a 404 still
  yields an <img>, so the load is the real check). No assets were
  copied: public/assets/previews already holds 174 country-*.webp and
  generate-tree already stages them.
- "Show Chad on the map" reaches the Region Dashboards tab with title
  "Chad" and hash "#chad", by mapping PosterWall's CustomEvent listId
  back through the country-selected the tabs already exchange.
- India prints its own reason with the exact number from the data; China
  renders a real poster. Only India is unsupported — 855 groups and no
  sheet, against China's 154 on a 3x4.

Two defects the screenshot caught and no assertion did:

- PosterWall's root is position:absolute;inset:0, because in its own
  bundles it IS a pane. Embedded in a region card it painted 1440x1100
  over the region's country list. Fixed by parameterising the shared
  component rather than overriding its CSS locally: new `embedded` and
  `sectionHeads` props, both defaulting to today's behaviour, so
  country-posters and key-cities are unchanged by construction. Both
  re-checked. Re-measured: root static, 1200px wide, panel below the
  grid, no overlap.
- With one unnamed group the wall printed a section head reading the
  literal "Other" plus a count duplicating the region card's own
  summary. Suppressed by sectionHeads=false.

The dataset moved to library/data/country-posters.json now that it has a
second consumer, one import line changed in country-posters, and
emit-countries.mjs was retargeted so the next generator run cannot
recreate the duplicate.

The three new strings do not exist on DX-life, so there was nothing to
copy and nothing was invented in another language: en only, with the
other seven locales falling back to the key.

Regression: 15 suites green, including region-vs-live 73/73,
country-vs-live 28/28, mobile 44/44 and 26/26, and a new
_probe-region-posters.mjs. check:library-index drifted for real this
time (3 new library files) and is regenerated here.
2026-09-24 04:26:29 +00:00
doxa-maps-88655c70
fc04f14a52 Let the key filter the map, so the gap can be looked at on its own
The map could say what one group has, on tap. It could not answer the question it
exists for — show me only the ones with nothing — so the 3,444 groups that still have
no resource were spread through 12,379 dots and could not be seen as a pattern.

THE KEY IS THE FILTER. A reader who has just worked out what a colour means is exactly
the reader who wants to see only that colour, so the legend entries became the controls
rather than adding a second row beneath them. Each carries its live count: 4,711 with
Scripture and film, 4,224 with some resource, 3,444 with nothing — summing to 12,379,
which is the check that the three buckets partition the set rather than overlap it.

FILTERED ON THE LAYER, NOT THE SOURCE. setFilter is a GL-side predicate, so all 12,379
features stay on the GPU and nothing is re-parsed or re-uploaded on a toggle. Rebuilding
the FeatureCollection per click would re-upload a megabyte to redraw a subset it already
holds.

Clicking an active filter clears it, and the state line names what is hidden with a
"Show all 12,379" out — a filtered map that does not say it is filtered is how a reader
concludes the data is missing.

Verified on the served tree: rendered features 12,379 -> 3,444 on the "nothing found"
filter, getFilter returns ["==",["get","tier"],0], and the only tier visible afterwards
is 0. All three buttons expose an accessible name and aria-pressed. The filtered view
shows the remaining gap concentrated in the Sahel, the Horn, north India and Nepal,
Myanmar, Indonesia and the Philippines.
2026-09-24 04:22:40 +00:00
doxa-maps-88655c70
716e7c84eb Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 04:22:01 +00:00
doxa-maps-88655c70
86a3c3bb99 Experimental roundup: FIVE legacy maps resurrected, keyless, in Experimental
Some checks are pending
build-gate / build (push) Waiting to run
The pre-bundler upg-neighbor IIFE holds five lost experimental maps --
send-map, upg-neighbors (cockpit), lang-cluster, bloom-cockpit,
flower-mesh. One wrapper bundle now rescues all five as preset
instances, each mounted in a sandboxed iframe whose host page:
  - loads our self-hosted MapLibre and aliases it as the mapboxgl
    global with a patched Map constructor that rewrites the one
    mapbox:// style to keyless OpenFreeMap Positron -- the whole set
    runs WITHOUT a Mapbox token;
  - shims relative /api fetches to the live API and sets
    MAP_APP_API_URL (the cockpit fails closed without it);
  - passes the legacy profile-config contract (the elements share the
    modern loader lineage).

Wrapper reads profile-config as a PROP (direct-defined element --
inject('profileConfig') is null outside ProfileLoader; the send-map
default masked the miss because send-map is instance #1). The
vocabulary patch rides in the shipped copy ('Lower pressure').

Verified by screenshots read: send-map renders the full 4-D SEND JOIN
app; flower-mesh blooms (India, 2,040 UPGs); the cockpit renders map,
charts and bloom pane. Auto-flows into the SPA's Experimental section
via manifest discovery.
2026-09-24 04:20:24 +00:00
doxa-maps-88655c70
f9808d0c8b Harvest the whole Arclight catalogue: film coverage 306 -> 2,087 languages
Same shape as the eBible finding, same cause. Arclight was ALREADY a checked pool and
only 306 language codes carried a film link, while the catalogue lists 2,330 media
languages with 2,087 carrying a feature film. Being inside a pool is not the same as
having been read.

COVERAGE: pins with a real URL 6,985 -> 8,935, 56.4% -> 72.2%. Language codes 2,068 ->
3,196. 1,950 pins lift off "nothing found" and 1,214 move to Scripture AND film. The
resource table grows 1,497 -> 3,043 rows across 2,307 languages.

★ THE VERIFICATION HAD TO BE REDONE, and that is the part worth keeping. The obvious
check is to sample watch URLs and confirm 200. Ten of ten passed. The check was
worthless: https://www.jesusfilm.org/watch/?languageId=<id> is a single-page app and
answers 200 for EVERY id — measured, `0` and `abcdef` both return 200. A check that
cannot fail proves nothing, and that one would have certified 1,781 links on a status
code that is always 200.

The API discriminates where the page does not: media-languages/<id> returns a real
counts.featureFilm for a genuine language and 404 for a bogus one. So existence is
established from the API and the watch URL is the deep link for a language already
confirmed. Re-verified that way: 10/10 sampled languages carry featureFilm >= 1, and
the negative control — a bogus id — returns featureFilm 0, so the check can fail and
does, on the case that should fail.

featureFilm >= 1 IS THE COVERAGE TEST, not roster membership: 21 of the 2,330 listed
media languages have no feature film and are dropped. Only featureFilm is taken;
Arclight also carries short films and other media that were not screened.

FILL, NEVER OVERWRITE, as with eBible — a curated film_url stands, and the catalogue
only supplies a link where there was none. 1,695 of the codes had no row in the
resources table at all.

Verified: all browser probes pass — pins, table, deep link — 12,379 pins rendered, and
the map now reads green across Papua New Guinea, Indonesia, Brazil and Central Africa
where it was red, leaving the remaining red concentrated in the Sahel, the Horn, north
India and Central Asia. The pin table is 1.1MB but 281KB gzipped; the four repeated
pool strings compress 4:1, so interning them would buy nothing on the wire.
2026-09-24 04:08:30 +00:00
doxa-maps-88655c70
a68b7c2cc2 Merge main into bundler-2/work
# Conflicts:
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
#	public/doxa-maps/my-upg-100-list/my-upg-100-list.js
#	public/doxa-maps/my-upg-100-list/poster-wall/index.html
#	public/doxa-maps/my-upg-100-list/upg-100-list/index.html
#	public/doxa-maps/my-upg-100-list/upg-100-mfe/index.html
#	public/doxa-maps/regions-dashboard/region-dashboards/index.html
#	public/doxa-maps/regions-dashboard/regions-dashboard.js
#	public/doxa-maps/regions-dashboard/regions-list/index.html
#	public/doxa-maps/regions-dashboard/regions-mfe/index.html
2026-09-24 04:05:36 +00:00
doxa-maps-88655c70
b5f831701e Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work 2026-09-24 04:02:02 +00:00
doxa-maps-88655c70
9b9f2765b4 Regions: one rollup, ported from theirs, instead of two copies
summarizeRegions() was duplicated in regions-list.vue and
region-dashboards.vue — two copies of the same rollup, so two chances
for the region page and the dashboard to disagree about one number.
Both now import a port of DX-life's config/countries-meta.ts, placed in
library/ because it has two named consumers.

Ported verbatim: slugify's replace chain, FULL_PRAYER_COVERAGE_COUNT,
addToStats's conditions, fitView's constants, lngExtent's largest-gap
walk, WORLD_VIEW, both sort orders. Changed only: TypeScript to JS
(interfaces to JSDoc), and their `import { COUNTRIES } from
'../app/utils/countries'` becomes library/data/countries.json — their
same 242-entry {value,label} array, in their order. toNumber and
emptyStats are exported beyond their surface, and only those two: their
pages need no cross-region total because DX-life has no "all regions"
view, and ours does for the no-selection chooser.

Two things the copies had wrong, both fixed by porting rather than
patching:

- They slugged a country from the API's LOCALIZED label. Theirs slugs
  from the canonical English name, deliberately. The live site settled
  it: /regions/the-united-states is 200 and /regions/united-states is
  404; /regions/the-democratic-republic-of-the-congo is 200 and
  /regions/dr-congo is 404. Since the dashboard writes the slug into
  the URL hash, our deep links pointed at pages that do not exist
  there.

- They dropped centre/zoom/bounds entirely, so our maps derived their
  own frame. Theirs computes a fit view per country and per region,
  including the antimeridian: Oceania's bounds come out east 187.9,
  where a naive max() says 178 and spans the globe backwards.

Also fixed regions-list.vue's formatNumber, still hardcoded en-US after
the same fix landed in region-dashboards.vue, and its region sort, which
used (a.label || 'Other') where theirs compares a.label directly.

Proof: region-vs-live 73/73 and country-vs-live 28/28 UNCHANGED — the
rollup is equivalent. New _probe-countries-meta.mjs is 20/20 and
includes a negative test showing the antimeridian assertion really
discriminates. oceania-and-list, regions-swap, standalone-app,
dashboard, total and search all green. 159 lines of duplication gone.
2026-09-24 04:01:34 +00:00
doxa-maps-88655c70
4716e62ec5 Harvest the whole eBible catalogue, not a tenth of it
eBible was ALREADY one of the two pools this repo checked, and that is what made the
gap invisible: 120 language codes carried an eBible link while the catalogue lists
1,550 translations across 1,239 codes with canonical books. The pool had been read at
roughly a tenth of its content, and ~1,600 people groups were being told "nothing
found" about a published translation in their own language.

COVERAGE: pins carrying a real URL 5,349 -> 6,985, 43.2% -> 56.4%. Language codes
978 -> 2,068. The resource table grows 581 -> 1,497 rows across 1,314 languages. 1,090
of the new codes had no row in the resources table at all, which is why filling gaps
in the existing 862 would have missed most of it.

FILL, NEVER OVERWRITE. An existing scripture_url was curated and verified by an earlier
pass; the catalogue is broad but not hand-checked, so it only supplies a link where
there was none. Where the two disagree, curation wins.

CANON TEST: OTbooks + NTbooks > 0. A deuterocanonical-only publication is not the
66-book canon and is dropped; DC books ALONGSIDE OT/NT are not a reason to drop, since
the 66 are present either way. Measured on this snapshot: 3 rows carry no books at all.

DOMAINS ARE ALLOW-LISTED AFTER A SCREEN, because publicationURL does not always point
at ebible.org. Four domains appear and each was fetched and read: ebible.org (1,242),
png.bible (302, "Papua New Guinea Scriptures"), alkitab.pw (3, an eBible mirror), and
inscript.org (1) — EXCLUDED, because its page establishes no publisher or doctrinal
provenance and an unclear source is left out. A domain not on the list is dropped, so a
future catalogue update cannot introduce a publisher nobody screened.

TWO BROKEN LINKS CAUGHT STRUCTURALLY: nko and nlx publish 'http:///nkoNT/' — a
malformed URL with an EMPTY host, broken in eBible's own data. Requiring a non-empty
host rejects them; a looser check for the 'http://' prefix passes them happily, which
is how a "real" link that resolves nowhere gets shipped. badUrl and badDomain are
counted separately for the same reason — folded together, this hid.

Verified: the CSV parser was diffed against a reference parser across all 1,550 rows,
0 mismatches, so no URL is attached to the wrong language. A seeded random sample of 20
catalogue URLs resolved 20/20 at 200. All five browser probes still pass — pins, table,
deep link, Door43 lift, and 12,379 pins rendered — and the map now shows Papua New
Guinea, Indonesia and South America carrying resource colour where they were solid red.
2026-09-24 04:01:04 +00:00
doxa-maps-88655c70
507cf68e12 feat(cards): horizontal prayer cards — the layout changes, not just the page
Some checks are pending
build-gate / build (push) Waiting to run
Driver asked for "horizontal cards" alongside the vertical ones, with the print
dialog's aspect ratio chosen at the print point.

★ LANDSCAPE IS NOT TRANSPOSED DIMENSIONS. The portrait card stacks the photo
ABOVE the text, occupying the top 46%; at 3.5 x 2.5in that same band is a strip
too short to carry a face AND leave room for the rows under it. The landscape
stocks put the photo BESIDE the text instead — full-bleed down the left 42%,
name over a scrim at its foot, data and QR in the right column. So `side` is a
LAYOUT switch the CSS reads, not a cosmetic flag.

It is driven by the stock rather than by comparing width to height: a square
stock would be ambiguous, and the composition is a deliberate property of the
format, not an inference from its numbers.

Two landscape stocks added and exposed in the poster-wall picker:
  pokerLandscape      3.5 x 2.5 in  ->  252 x 180 pt
  postcardLandscape   6 x 4 in      ->  432 x 288 pt

Verified at the rendered layer by the new check:print-geometry, which measures
the MediaBox rather than reading the CSS — and read as a picture, not only
asserted on: photo left, "Aheu" over the scrim, country/people/language/religion
in the right column, QR and "PRAY FOR THEM" at the foot, zero overflow.
2026-09-24 03:55:02 +00:00
doxa-maps-88655c70
316939796b Merge main into bundler-2/work
# Conflicts:
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
#	public/doxa-maps/regions-dashboard/region-dashboards/index.html
#	public/doxa-maps/regions-dashboard/regions-dashboard.js
#	public/doxa-maps/regions-dashboard/regions-list/index.html
#	public/doxa-maps/regions-dashboard/regions-mfe/index.html
2026-09-24 03:52:54 +00:00
doxa-maps-88655c70
9f0df8b579 Exact sits in the search row, where DX-life puts it
Driver: "my filter underneath the simple research map does not look like the
same CSS styling of the one on dx-life."

Read the reference: UupgsList.vue puts the input and the Exact button in ONE
`.repel` row inside `.search-box`, so the modifier reads as part of the
field it changes. Ours had Exact in a SECOND filters__panel below the four
dropdowns, where it looked like a fifth filter -- and, because that panel
only exists while filters are expanded, a user searching with the panel
collapsed could not reach it at all.

★ COPYING THE REPEL WITHOUT THE WIDTH CAP MADE IT WORSE, and the screenshot
was the only thing that said so. dx-life's markup is
`.search-box | center | max-width-md` wrapping the repel: the repel only
reads as a PAIR because the box is narrow. Dropped into our full-width row
it pushed Exact against the far edge -- input ending at x=843, button at
x=1231, 390px apart -- which reads as an unrelated control. The row is now
capped at 600px and centred, and repels inside that. Wraps on a phone so the
button drops under the input rather than squeezing it.

NOT DONE, and deliberately left for the Driver: he listed Country, Adopted
and Engaged among dx-life's controls. This element ships FOUR dropdowns by
an explicit earlier decision of his, recorded twice in this file -- "COUNTRY
IS PROP-ONLY, NOT A FIFTH DROPDOWN ... the Driver's exclusion of Country was
about this element's OWN CONTROLS". Describing dx-life's controls is not the
same as asking for Country back, and adding it would silently reverse that.
Written up in the rollout ledger as a question instead.

gates:pre 0. gates:post 17/18, the single failure being the pre-existing
verify/tables-complete break from the visa-leak fix.
2026-09-24 03:52:44 +00:00
doxa-maps-88655c70
72be2bee84 The map keeps the wheel; the edge strips carry the page
Driver: "two finger scrolling on the map should allow me to interact with
the map, but instead it just scrolls down past the map and does not
interact with the map -- that's what the scroll side bars are there for, to
let me interact with the map AND have a way to scroll down past the map."

REPRODUCED before touching anything: wheel over the centre of the map in
research-map-plus-grid moved scrollY 0 -> 480 and the camera never moved.
The map was inert exactly where it is the main control.

CAUSE: _syncScrollZoomToHost disabled scroll-zoom whenever the document or
an ancestor could scroll -- which is ALWAYS true in a composite that stacks
a grid under a map. It was not careless: it fixed
BUG-CANNOT-SCROLL-DOWN-TO-THE-GRID, where the map ate every wheel and the
grid below could not be reached. But that bug already had a second fix --
the .map-scroll-edge gutters, a dedicated page-scroll lane down both sides,
which the Map constructor's own note calls "the single-finger page-scroll
escape". Two fixes for one bug, and the second silently undid the feature
while leaving the first redundant.

So scroll-zoom now stays enabled and the strips carry the page. The
docScrolls/ancestorScrolls probe stays, because the edge affordance keys its
gray indicator off the same question.

MEASURED on the built tree, 1280x900:
  wheel x4 over map centre    scrollY 0 -> 0     map takes the gesture
  wheel x4 over edge strip    scrollY 0 -> 480   escape still works
  (two strips, 46px wide each)
And the repo's own fitness gate, which exists for this property:
  check:fits-screen 17/17 PASS -- including "the grid is reachable" and
  "the wheel is targeted", so the bug this replaced has not returned.

DashboardMapPanel carries the same helper and was deliberately NOT changed:
different layout contract (map inside a fixed shell with facet/table/rail
columns), and fits-screen passes it today as "wheel escapes every column".
Flagged in the ledger for the Driver rather than altered on the strength of
a report about a different surface.

gates:pre 0. gates:post 17/18, the single failure being the pre-existing
verify/tables-complete break from the visa-leak fix.
2026-09-24 03:47:28 +00:00
doxa-maps-88655c70
3e54c7f6a3 Regions: their search, and three places I was not a clone
Port dx-life's fuzzy search into UupgsList onto the repo's existing
@map/components/search/fuzzy.js shim: their 7 SEARCH_KEYS, threshold 0
when exact else 0.4, minMatchCharLength 3, diacritic stripping, their
highlight() construction and match routing, and .search-highlight in
#92b195 from their blocks/_cards.scss:258. A typo now finds the row and
the matched run is wrapped, neither of which the old substring filter
did. _probe-search.mjs: 6/6.

Then, reading the screenshot taken to prove that search worked, three
clone-fidelity defects that every numeric check had certified green:

- The count line above the list read "N People Groups · Cameroon". It
  was invented; it is not on their page. Theirs
  (UupgsList.vue:577-583) is "Total: N" in .font-size-sm inside a
  .repel, with the see-all link suppressed by hide-see-all-link.
  _probe-total.mjs: 7/7.

- formatNumber was hardcoded en-US. Theirs (RegionDetail.vue:16) is
  n.toLocaleString(`${locale}-u-nu-latn`) — locale separators, Latin
  digits. We were wrong in de, fr and es.

- The country strapline formatted its count. Theirs
  (CountryDetail.vue:40) is the raw {{ country.count }} — that
  component never defines formatNumber. Their asymmetry with
  RegionDetail; a clone matches it instead of improving on it.

Why nothing caught the invented line: every check compares properties
of elements present on BOTH sides, so an element present only on ours
is invisible to all of them. _probe-total.mjs now carries a surplus
check — a phrase assertion scoped to .ul-root, since the first draft
swept the whole element and failed on their own legitimate "People
Groups" in the stat cards and the country table header.

Regression: region-vs-live 73/73, country-vs-live 28/28, mobile 44/44
and 26/26, i18n, chooser, dashboard, standalone-app, search, switcher,
goalcards, h1-highlight, total — all green.
2026-09-24 03:40:47 +00:00
doxa-maps-88655c70
9202469197 The grid's filter block gets DX-life's world-map backdrop
Driver: "there is an SVG of a map that shows up under the search bar, where
the toggles are... my filter underneath the simple research map does not
look like the same CSS styling of the one on dx-life."

Read the reference rather than guessing at it:
dx-life/app/components/public/UupgsList.vue puts
  background-image: url(<images>/worldmap.svg); background-size: 60%;
  background-position: top
on the container wrapping the search box AND the toggles. So it is a
BACKGROUND behind the whole block, not an icon beside the input -- which is
the detail that makes it read as a map tool instead of a form. Ported
faithfully, per the house rule to imitate DX-life's decisions rather than
substitute our own tokens.

The 311KB file is served as a SHARED ASSET, not inlined: a data: URI would
put it in every bundle that imports this grid, and it is decorative.
resolveAssetsBase() is the same helper the boundary geojson already uses, so
it resolves wherever the tree is dropped -- CDN, sub-path, or the studio --
instead of assuming a root-relative path. It lives in public/assets/, the
shared pool, which `clean` does not wipe, so no change to the tree verb.

It is drawn ONLY when the block exists. With show-search and show-filters
both off there is nothing to sit behind, and a backdrop with no controls on
it is noise.

MEASURED on the built tree:
  /assets/brand/worldmap.svg              HTTP 200, 0 failed requests
  #filters computed backgroundSize        60%
  #filters computed backgroundPosition    50% 0%   (top centre)
  grid leaf (defaults on)                 backdrop ON
  research-map-plus-grid (Simple Research) backdrop ON, behind search +
                                          Hide Filters + 4 dropdowns + Exact
  dashboard-plus-grid (both props false)  no #filters element at all
Screenshot read, not just counted.

gates:pre 0, check:public-hygiene 0. gates:post 17/18 -- the single failure
is the pre-existing verify/tables-complete break from the visa-leak fix,
filed and not ours.
2026-09-24 03:40:35 +00:00
doxa-maps-88655c70
dfb4f755be Carry the map selection into the resource table
The table listed every link but had no idea what you had just tapped, so answering
"what exists for THIS group" meant reading a language name off the map and typing it
into a filter box. The map and the table are separate PAGES, so the selection travels
as a URL: ?tab=links&iso=<code> from a people group, &country=<ISO3> from a country.

FILTERED IN THE PROFILE, NOT IN THE SHARED COMPONENT. DataTable has other consumers
and none of them need a filter prop; teaching it one would put a change with repo-wide
blast radius behind a feature only this bundle uses. The rows are filtered before they
are handed over, which is invisible to every other caller.

COUNTRY FILTERING WITHOUT A SECOND FETCH. Rows carry country_codes, the ISO3 set the
language is spoken in, so a country filter is a membership test on data already loaded
rather than pulling the 679KB pin table just to resolve which languages a country has.
+23KB for the column, against 679KB avoided.

THE EMPTY CASE SAYS WHY. A selection with no match renders "nothing found for this
selection in the pools checked, which is not proof that nothing exists" rather than an
empty grid, which would read as a broken filter or, worse, as proof of absence.
Switching to a tab that has no language key says so too instead of showing zero rows.

Verified end to end on the served tree: clicked a real pin, followed the link THE SHEET
RENDERED rather than one built in the test, and landed on 2 rows both carrying cmn with
"Showing 2 of 581 resources for Chinese, Mandarin". Country path checked the same way —
Nepal 51, Chad 22, unfiltered 581 with no banner, and an unmatched code showing the
honest empty state.
2026-09-24 03:35:35 +00:00
doxa-maps-88655c70
cb75ba38b5 Regions map: country boundaries load on MapLibre too
Caught by spot-checking after main moved, not by the sweep. RegionCountryMap
added its country polygons from mapbox://mapbox.country-boundaries-v1, a
Mapbox-hosted VECTOR TILESET that MapLibre cannot fetch:

    Fetch API cannot load mapbox://mapbox.country-boundaries-v1.
    URL scheme "mapbox" is not supported

The basemap then drew perfectly with the highlighted countries simply
absent. My own sweep called this leaf "SWAPPED" because it counts canvases,
engines and request totals -- all of which were right. A coarse metric can
be green while the primary data layer is missing; only the console said so.

Fixed by the pattern DashboardMapPanel already proved, copied rather than
reinvented: under maplibre the source is geojson, fed from the keyless
assets/boundaries/world-adm0.geojson the tree already ships and three other
plugins already consume through resolveAssetsBase. Added EMPTY first and
setData after the fetch, so the addLayer calls stay synchronous and the
fills remain beneath the pins. The tileset names the ISO field
iso_3166_1_alpha_3 and the geojson names it iso3, so the property is
normalised ONCE at load and countryFilter() keeps reading the single name it
always read -- the Mapbox path is byte-for-byte unchanged. 'source-layer' is
set for Mapbox alone; MapLibre rejects a layer that declares one on geojson.

MEASURED via the PUBLIC querySourceFeatures (not a private field -- that
lesson cost two reverts earlier in this flight):
  mapbox    400 boundary features, 0 console errors
  maplibre  467 boundary features, 0 console errors   (was 1 scheme error)
Both engines expose the same three layers.

gates:pre 0. gates:post 17/18, the single failure being the pre-existing
verify/tables-complete break from the visa-leak fix (filed, not mine).
2026-09-24 03:34:19 +00:00
doxa-maps-88655c70
c279899990 Merge main into bundler-2/work
# Conflicts:
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
#	public/doxa-maps/regions-dashboard/region-dashboards/index.html
#	public/doxa-maps/regions-dashboard/regions-dashboard.js
#	public/doxa-maps/regions-dashboard/regions-list/index.html
#	public/doxa-maps/regions-dashboard/regions-mfe/index.html
2026-09-24 03:27:22 +00:00
doxa-maps-88655c70
410f101846 A table of every resource and its link, keyed by the resource not the group
Every table in this bundle is keyed by PEOPLE GROUP and answers "who has nothing".
None of them answered the question a mobiliser asks first: what exists, what is it
called, and where do I open it. Answering it from a group-grained table means reading
the same JESUS Film link off forty rows.

581 rows, one per language x resource, across 413 languages, together reaching 5,349
people groups — the same 5,349 the pin build counts as carrying a real URL, so the two
tables cannot drift without one of them being wrong.

Reach is MEASURED, not assumed: people_groups, speakers and countries are aggregated
across the pins that actually speak that language, so a row says how many people that
one link serves. Sorted by reach, because the link serving the most people is the one
to open first.

The column is named resource_url because DataTable keys on /_url$/ to render a real
anchor instead of printed text. That is a contract with the shared component, not a
naming preference — rename it and every link silently becomes a string.

TYPES STAY HONEST. 146 Scripture, 306 Film, 129 Bible Stories. Open Bible Stories is
typed as Bible Stories and never as Scripture, and the meta says why. The pools caveat
and the doctrine boundary both render under the table rather than living in a file
nobody opens: only 66-book-canon publishers are included, and the excluded ones are
named so their absence reads as a rule rather than an oversight.

Verified at 1280x900 on the served tree: the tab loads first, renders all ten columns
and 50 rows a page, and 50 rows carry real anchors to ebible.org, jesusfilm.org and
door43 — clicked through from the page, not read out of the JSON.
2026-09-24 03:25:13 +00:00
doxa-maps-88655c70
efe2e506a8 Their heading highlight, and the page head tab 1 never had
A 68-of-68 computed-style diff was reporting the region page identical while two
visible things were wrong.

1. THEIR TITLES HIGHLIGHT THEIR LAST WORD. Every heading on their regions
surface is <h1 class="highlight" data-highlight-last>, and their
useTextHighlight wraps the final word in .color-primary. Measured live:
/regions/africa is ENTIRELY rgb(146,177,149) because "Africa" is the last word,
and /regions/middle-east renders 'Middle <span class="color-primary">East</span>'.
Ours was entirely dark.

★ HOW A 68/68 DIFF MISSED AN ENTIRELY GREEN HEADING: the h1 spec listed
fontSize, lineHeight, fontFamily, textAlign and textTransform — and not COLOUR.
The lesson is not "add colour to the list". It is that a property absent from
the spec is NOT MEASURED, and unmeasured is not identical. Same shape as the
font miss, where both sides named Poppins and only a glyph-width oracle showed
we were not rendering it. Colour now covers the h1, its highlight span, the stat
values, the headline figures and the goal lines: 68 -> 73 properties, still 0
differ. I found this reading their source for a different card, not from a check.

Ported declaratively rather than as their DOM walk. Theirs mutates innerHTML
once onMounted and stamps a processed flag — right for a Nuxt page whose title
never changes. Ours changes on every selection, so an imperative one-shot would
either fight Vue's re-render or go stale after the first subject. Same logic,
expressed where Vue can keep it true.

2. TAB 1 HAD NO PAGE HEAD AT ALL. pages/regions/index.vue:47-58 opens with that
h1 plus a .color-brand-lighter strapline; we rendered only the region cards.
Invisible inside the composite, where the tab strip names the view, and plainly
missing on the regions-list leaf, which is a page in its own right.

★ AND THE IMAGE CAUGHT MY OWN DEFECT. A whitespace-only <span> </span> between
two inline elements COLLAPSES: "Middle East" rendered "MIDDLEEAST". Every
assertion was green — the colour was right, the span was there, the text
content was right once whitespace-normalised. Only the screenshot showed it.
The space now lives inside the text interpolation, and an innerHTML assertion
guards it that was first shown to FAIL on the collapsed form.

Verified: region 73/73 vs live, country 28/28, mobile 44/0 and 26/0 at 390+768,
i18n, chooser and dashboard suites green, no gate newly red.
2026-09-24 03:22:15 +00:00
doxa-maps-88655c70
12e4c5e8f7 resources-map honours the provider switch
The last leaf the sweep found still pinned to Mapbox. Like
connectivity-map and my-upg-100-list it builds its map RAW (new gl.Map on
window.mapboxgl), so the resolution useMapInstance performs for every other
bundle never reached it: MEASURED at ?provider=maplibre it made 18 requests
to api.mapbox.com, identical to its mapbox run.

Same fix, third application, deliberately identical: the engine comes from
the shared ensureEngineScript/engineGlobal, and the two hardcoded mapbox://
style URLs are paired through the registry — 'ink' (dark-v11 / OpenFreeMap
dark) and 'paper' (light-v11 / positron). The engine loader is imported,
never re-declared.

No geocoder is mounted here, so the deferred-search-bar rule does not apply
and nothing about search behaviour changed.

MEASURED on the built tree:
  mapbox    canvas=1  18 mapbox requests  (unchanged)
  maplibre  canvas=1   4 mapbox requests  maplibregl loaded
Screenshot read: positron basemap, the gospel-resource gap choropleth, its
legend and controls all intact.

gates:pre 0. gates:post is 17/18 and the single failure is verify, which is
the PRE-EXISTING tables-complete break: commit 4475581 deliberately deleted
field-access-visas.json to close a visa-data leak while index.json still
declares it. 4475581 is an ancestor of main, so main was already red before
this flight. Filed as BUG-TABLES-COMPLETE-RED-AFTER-VISA-LEAK-FIX; not
touched here, because guessing risks re-adding leaked data.
2026-09-24 03:20:44 +00:00
doxa-maps-88655c70
af10d727c4 Add Door43 as a third pool, so 240 groups stop reading "nothing found"
Two pools had ever been checked — eBible and JESUS Film — and the map said "nothing
found" for everyone outside them. For 240 people groups that claim was simply untrue:
unfoldingWord publishes a released resource in their language and we were not looking.

WHAT WAS ADDED. A dated snapshot of the Door43 catalog (stage=prod only) keyed by ISO
639-3, 165 codes across Open Bible Stories, Bible and Aligned Bible. Cached rather than
fetched live so the pin table is reproducible offline and does not depend on someone
else's uptime; the source URLs travel in the file.

stage=prod IS THE WHOLE CARE. Door43 LISTS more languages than it HOSTS, and this repo
already recorded that trap — "roster membership is not coverage". Released entries only,
and the result reconciles cleanly against what was already known: of 38 languages
previously flagged as having Open Bible Stories, 38 are confirmed and 0 contradicted.
It also found 11 flagged false that do have a published resource.

THE BIGGER HALF WAS INVISIBLE. 116 of the 165 codes have no row in
people-group-resources.json at all, so there was no record to hang a link on and their
pins stayed "nothing found" no matter what Door43 published. Those codes are now created
from the catalog, which is where 213 of the 240 lifted pins come from.

COVERAGE: pins carrying a real URL 5,109 -> 5,349 (41.3% -> 43.2%); codes 862 -> 978.
Tier 1 now means SOMETHING was found rather than "one of scripture/film", because that
is what the colour has to mean once a third pool exists. Tier 2 still means Scripture
AND film — a story set does not get promoted into a Bible.

OBS IS LABELLED AS STORIES. The chip reads "Bible Stories · Door43", never Scripture:
Open Bible Stories is 50 illustrated narratives drawn from the canon, and telling a
reader they have a translation when they have a story set is the one lie this map
cannot afford. 2-letter and region-suffixed tags are DROPPED rather than guessed into
639-3 — a wrong language match ships a real link to the wrong people.

Verified on the served tree: the Kir people group (Mandeali, mjl) previously showed
nothing and now renders a real anchor to git.door43.org/OBS-TLF/mjl_obs, which answers
200. The existing pin probe still passes 12,379 rendered with both Han Chinese links
at 200, so the new pool added coverage without disturbing the old.
2026-09-24 03:18:27 +00:00
doxa-maps-88655c70
874186660e Merge main into bundler-2/work
# Conflicts:
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
#	public/doxa-maps/my-upg-100-list/my-upg-100-list.js
#	public/doxa-maps/my-upg-100-list/poster-wall/index.html
#	public/doxa-maps/my-upg-100-list/upg-100-list/index.html
#	public/doxa-maps/my-upg-100-list/upg-100-mfe/index.html
#	public/doxa-maps/regions-dashboard/region-dashboards/index.html
#	public/doxa-maps/regions-dashboard/regions-dashboard.js
#	public/doxa-maps/regions-dashboard/regions-list/index.html
#	public/doxa-maps/regions-dashboard/regions-mfe/index.html
2026-09-24 03:17:16 +00:00
doxa-maps-88655c70
34cb921b96 connectivity-map honours the provider switch
Found by verifying the GOAL rather than the checklist. The rollout table
only ever listed plugins with a search bar, so a sweep of all 16 isMap
leaves was run under both providers: 12 of 16 swapped, and this leaf made
18 requests to api.mapbox.com under ?provider=maplibre — identical to its
mapbox run, because it builds its map RAW (new gl.Map on window.mapboxgl)
and never reaches the resolution useMapInstance does for everyone else.

Same shape and same fix as my-upg-100-list, minus the token gate this file
never had: the engine comes from the shared ensureEngineScript/engineGlobal
rather than a hardcoded global, and the two hardcoded mapbox:// style URLs
are paired through the registry — 'ink' (dark-v11 / OpenFreeMap dark) and
'paper' (light-v11 / positron) — so both engines show one intent. The
engine loader is imported, never re-declared; a second ENGINES table in a
profile is how two copies of one fact drift apart.

No geocoder is mounted here, so the deferred-search-bar rule does not apply
and nothing about the search behaviour changed.

MEASURED on the built tree:
  mapbox    canvas=1  18 mapbox requests  (unchanged)
  maplibre  canvas=1   4 mapbox requests  maplibregl loaded
Screenshot read: positron basemap, connectivity choropleth, legend and
controls all intact.

Also recorded from the sweep: poster-wall and regions-list draw NO canvas on
either engine — they are the already-filed isMap misclassification, not
gaps, and must not be "fixed".

7/7 pre, 18/18 post.
2026-09-24 03:14:35 +00:00
doxa-maps-88655c70
0cd68de228 Merge remote-tracking branch 'upstream/main' into bundler-1/work 2026-09-24 03:11:17 +00:00
doxa-maps-88655c70
50ce92ef8a Mobile parity: one missing property was making every phone width wrong
Mobile was the last dimension with no measurement behind it. Diffed ours against
LIVE doxa.life at 390 and 768, the same way desktop was diffed. Result after the
fix: region page 44 IDENTICAL, 0 differ; country page 26 IDENTICAL, 0 differ; no
horizontal overflow on either side at either width.

★ THE FIND. Their base/_globals.scss html rule carries `scrollbar-gutter: stable`
and we had nothing equivalent, so EVERY mobile width was 13.5px wider than
theirs — their container 353.5px against our 367px at 390.

It was invisible at 1440, because both sides cap at 1200 there. That is exactly
why a desktop-only diff could report 68 of 68 identical while every phone width
was wrong, and it is the clearest case yet for the rule about not trusting a
number measured at one size.

And it CASCADED into a different LAYOUT, not just different numbers: at 768 our
container came out 707.2px against their 693.7px, and their .switcher flips at
700px of container — so their three goal cards STACKED and ours stayed
three-across. One missing property, a visibly different page.

Ported onto .rd-scroll and .rl-scroll, this element's own scrolling box. A
shadow root cannot set the host document's html rule and should not try; our
scroller is the box every child measures against, so reserving the gutter there
reproduces their effect exactly (the width probe now reads pane 375 -> container
353.5 at 390, matching them).

Their 576px card reflow is verified rather than assumed: at 390 our .uupg__card
resolves to "image adopted" / "header header" / "content content" / "footer
footer" and at 768 to the three-column form, both byte-identical to theirs.

ACCEPTED, with its magnitude rather than buried: four vw-scaled clamps land
2-4% low (h1 52.71 vs 53.76 at 768, the prayer bar 22.5 vs 23.39 at 390). Their
clamps scale with vw and ours with cqw, deliberately, so the element sizes
correctly as one pane of the regions-mfe composite — and vw includes the
scrollbar while cqw does not, so the gap is now a systematic ~15px. The probe
prints these as a residual instead of counting them identical; switching to vw
would buy ~1px of heading and break a shipping surface, which is the wrong
trade, but it is the Driver's to overrule.

Desktop unaffected: region 68/68, country 28/28, every width still matching
their container formula, no gate newly red.
2026-09-24 03:11:00 +00:00
doxa-maps-88655c70
0ebde03046 Say what a dot means, and that it is not what the country means
The pins landed with no key, so the map shipped two colour systems using the SAME
four hexes for two different claims and nothing telling them apart. A country is an
AVERAGE across its groups; a dot is one group's own language. Red-on-green is the
most interesting case on the map — a group with nothing found inside a well-supplied
country — and with no key it reads as a rendering fault instead of a finding.

The key says so in those words, and the SHAPE now carries the level: squares for the
country average, circles for a single group. Shape is the honest separator here,
because the hexes are deliberately shared — a dot and its country agreeing on what
"nothing found" looks like is the property worth keeping.

Counts are computed from the layer rather than written down: 12,379 dots, 5,109 with
a link. A number typed into a legend rots the first time the data moves.

Verified at 1280x800 on the served tree: the legend renders both key blocks, quotes
12,379/5,109 matching the generator's own tally, and the pin probe still passes
12,379 rendered with both Han Chinese links answering 200.
2026-09-24 03:05:22 +00:00
doxa-maps-88655c70
fb44125c18 Merge main into bundler-2/work 2026-09-24 03:05:09 +00:00
doxa-maps-88655c70
305fd93c97 Merge main into bundler-2/work
# Conflicts:
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
#	public/doxa-maps/my-upg-100-list/my-upg-100-list.js
#	public/doxa-maps/my-upg-100-list/poster-wall/index.html
#	public/doxa-maps/my-upg-100-list/upg-100-list/index.html
#	public/doxa-maps/my-upg-100-list/upg-100-mfe/index.html
#	public/doxa-maps/regions-dashboard/region-dashboards/index.html
#	public/doxa-maps/regions-dashboard/regions-dashboard.js
#	public/doxa-maps/regions-dashboard/regions-list/index.html
#	public/doxa-maps/regions-dashboard/regions-mfe/index.html
2026-09-24 03:04:47 +00:00
doxa-maps-88655c70
4350113041 my-upg-100-list joins the provider switch, and drops its search bar
The last plugin on the rollout list, and the only one the switch could not
reach: it builds its map RAW (new gl.Map on window.mapboxgl) instead of
through useMapInstance, so resolving the provider inside the factory --
which fixed every other bundle for free -- did nothing here. MEASURED at
?provider=maplibre before this: still Mapbox, 19 requests to api.mapbox.com,
while five sibling leaves swapped cleanly.

Three things blocked the keyless engine, not one:

  - the engine was hardcoded to window.mapboxgl. It now loads whatever the
    provider selects, through ensureEngineScript/engineGlobal EXPORTED from
    useMapInstance. Imported, not copied: a second ENGINES table living in a
    profile is how two copies of one fact drift apart.
  - a `if (!token) return` early exit. A token is a MAPBOX requirement, not
    a map requirement -- left in place it would have refused to draw the
    engine that needs no token, turning the keyless option into a blank
    cell. Now mapbox-only.
  - a hardcoded mapbox://styles/mapbox/light-v11. Now paired through
    resolveNamedStyle('paper'), the registry entry that already pairs
    light-v11 with OpenFreeMap positron, so both engines show one intent.

And the search bar follows the standing decision -- deferred to Mapbox,
because GeocoderComponent's maplibre path still geocodes through Mapbox, so
a keyless map with a search bar is not keyless. MapToolbar collapses its
reserved gap and the legend rises into the vacated space.

MEASURED on the built tree, both engines:
  mapbox    search@12   legend@58   19 mapbox requests   (all unchanged)
  maplibre  search@NONE legend@8     5 mapbox requests   maplibregl loaded
The remaining 5 are the leaf's own static preloads -- already filed, and
not something this file can reach.

Screenshot read, not just counted: positron basemap, region polygons, all
2107 pins, legend flush at the top, no gap and no collision.

22/22 bundles, 7/7 pre, 18/18 post, browser sweep of five StackLayout
consumers and all seven studio dashboards.
2026-09-24 03:04:11 +00:00
doxa-maps-88655c70
4475581c1b Close the visa-data leak at its factory + harden the gate
Some checks are pending
build-gate / build (push) Waiting to run
The field-access-visas table (580 KB -- how workers enter closed
countries) was found PUBLIC in three copies and LIVE-SERVED at 200:
the key-cities bundle data dir, the research out/ dir, and a stale
staging tree inside the served catalog. Root cause: emit() enforced
PRIVATE_TABLES but the ROUTE bundle-copy loop did not -- a privacy
rule enforced at one of two writers is not a rule. The loop now skips
PRIVATE_TABLES; the redundant source copies are deleted (data-private/
keeps the one canonical file); and the hygiene gate flags the
filename and visa_ fields as HARD findings in every mode -- this data
class never rides the strict-tier softening.

All three public copies and the live URL were purged first and
verified 404 by anonymous fetch before this commit.
2026-09-24 03:03:53 +00:00
doxa-maps-88655c70
b05d1f13b4 Pin every people group, and link what exists in their own language
The map shaded COUNTRIES and stopped there, so the one question it could not answer
was the only one that matters in the field: for THIS group, what exists right now?
12,379 people-group pins now sit on it, each one clickable into the resources for
its language.

THE JOIN. people-groups-slim.json carries Lat/Long per people-group-in-country;
people-group-resources.json carries the real scripture_url/film_url per LANGUAGE.
The key is ISO 639-3 (slim.LanguageCode == resources.iso639_3), never the group
name — a group is reached in its language, not its nationality, and names collide
across unrelated languages.

MEASURED at build time: 5,981 of 12,379 pins (48.3%) join a resource record and
5,109 (41.3%) join one carrying a real URL, across 862 language codes. The other
59% is not a gap in the data to be hidden — it IS the subject. A pin with nothing
found sorts ABOVE a covered one, so the gap wins the z-fight at world zoom rather
than disappearing under a green dot.

NO URL IS INVENTED. Every link is copied from the resources table, which records
its own checked_pools/unchecked_pools, and the sheet keeps saying "none found
means absent from the pools checked, never proof that nothing exists". has_audio
keeps its third state: absent means the pool was never queried, not a no.

PIN COLOURS READ OFF BANDS rather than declaring a fourth palette, so a pin and
the country under it can never disagree about what "nothing found" looks like.

The table is generated into data/tables/ — the REPO ROOT copy, which is what
generate-tree publishes. The plugin-local data/tables/ is a stale divergent
duplicate that nothing serves; building into it ships a file no page fetches.

Verified on the served tree at 1280x800: 12,379 pins loaded AND rendered, layer
and source present, and a click on Han Chinese returns ebible.org/cmn2006/ plus
jesusfilm.org languageId=53352, both rendered as real anchors in the sheet and
both answering 200. window.__grState() exposes those counts so the next check does
not have to guess at screen coordinates to hit a 3px dot.
2026-09-24 03:02:06 +00:00
doxa-maps-88655c70
b0bd797689 Merge remote-tracking branch 'upstream/main' into bundler-1/work 2026-09-24 03:01:05 +00:00
doxa-maps-88655c70
2b08a4e8de i18n: 408 strings copied from DX-life, 8 locales, RTL, and the data follows too
Localisation was the lowest dimension on the board at 12. The repo already had
the vue-i18n layer — 11 locales, one JSON per area, eager glob, fallbackLocale
en — so this adds an AREA, not a mechanism: library/i18n/locales/<lc>/
regions.json, 51 keys x 8 locales, every string COPIED from their own
common.json rather than translated by me. The composer takes the namespace from
the FILENAME, so the files are flat like legend.json (a "regions" wrapper would
have produced regions.regions.*).

★ THE REASON IT DIDN'T WORK, AND WHY IT FAILED SILENTLY. With the Spanish
catalogue provably in the bundle, every string still rendered English. The
bundle installed i18n with app.use() but never app.provide('appI18n') — so
ProfileLoader could not drive the locale (it does exactly that at
ProfileLoader.vue:168-169, from profile-config.locale, its "established
contract"), and createAppI18n resolves its locale ONCE from <html lang>, which
every generated leaf page bakes as "en". One line, copied from
doxa-simple-map/index.js:84-86. It degraded quietly because the t() shim falls
back to its key, so a missing catalogue renders as English instead of throwing —
convenient in production, and exactly what hid this.

t() is a shim, not vue-i18n's t(), because their keys ARE English sentences and
several contain dots ('Loading results...', 'Type to search...') which is
vue-i18n's key separator. tm() returns the namespace object and we index it by
the literal string, so the port reads 1:1 with their source: t('Prayer
Coverage'). Plugin-local, per the placement rule — it is four lines shaped
entirely by how these pages were written.

THE DATA FOLLOWS THE LOCALE TOO, which the chrome alone would have hidden. The
prayer API localises its labels from ?lang=, and our components read
cfg.languageCode while the locale arrives as cfg.locale. Measured with ar: the
whole UI translated and every country in the table stayed English. Now the fetch
falls through locale/lang and refetches on change (Angola -> the Arabic list).

RTL: ar sets dir on OUR OWN root via RTL_LOCALES, the house pattern
(doxa-simple-map.vue:246/1346) — never document.documentElement, which belongs
to whatever page embeds us. Verified in a screenshot, not just an attribute: the
goal cards reverse order, the table right-aligns, the progress bars fill from
the right.

A CLONE FIX came free with it. Porting their uupgListTranslations.ts — the
authoritative list of what this component's labels are called — corrected
strings I had written from the markup instead: the rop1 filter is "People Group"
and I had "Affinity Bloc"; the search placeholder is the full "Search names,
country/continent, religions..."; prayer_coverage reads "Intercessors".

Two assertions of mine were wrong again, both failing on correct behaviour: the
back-link compared for equality when it carries their leading arrow, and the RTL
check read document.documentElement.dir when an embedded element must not set it.

Not done: it/ro/zh have no regions.json — DX-life translates 8 of our 11 — and
resolve through fallbackLocale en, which library/i18n/README says is fine.
Oswald (their Cyrillic heading face) is still unvendored.

English unchanged throughout: region 68/68 vs live, country 28/28, chooser, map
and dashboard suites all green, no gate newly red.
2026-09-24 03:00:38 +00:00
doxa-maps-88655c70
7bba1c0771 feat(upg-100): the prayer card deck prints as CARDS, not as paper
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "the print dialog that opens up needs to show all the cards not in a
paper format but a CARD format, the aspect ratio that is used in the card
generator."

The deck set @page{size:letter portrait} and gave each card height:100%, so 95
cards printed as 95 mostly-empty 8.5x11 sheets — and with NO photo, because
image_url was never in the fetch. Measured: 95 of 95 groups DO have a portrait,
so the omission cost every card the one thing that makes it a prayer card.

Now the PAGE IS THE CARD. @page takes the stock's own inches, so one card is
exactly one page: 95 cards -> 95 pages at 180x252pt (2.5x3.5in). Print on
cardstock and cut, or send the PDF to a card printer. The stock is a user
choice, per the Driver's ask to "choose the layout of the print dialog to choose
the aspect ratio": poker 2.5x3.5in (default), postcard 4x6in, A6.

⚠ @page SIZE MUST BE EXPLICIT INCHES. Chromium SILENTLY falls back to letter for
a named size it does not know: `size: A6 portrait` yields 612x792pt with no
error of any kind, while `size: 2.5in 3.5in` yields exactly 180x252pt. The gate
therefore asserts the MEASURED MediaBox — a check that grepped the stylesheet
would pass a deck that prints as paper.

Card content follows doxa-life/promotional-material-generation, the Python data
pipeline feeding the InDesign template: per language it emits Description,
Religion, Area, Country, a QR to that group's page, and the URL. Ported to
JavaScript here so the deck generates in the map app on click. Description and
Area are NOT available from this API — measured 0 of 2107 records carry either —
so the card is built from what genuinely exists: portrait, name, country,
population, language, religion. An empty block reserved for data that never
arrives is worse than a card designed around the data there is.

QR is hand-rolled (library/packet/qr.js, ~200 lines): byte mode, level M,
smallest fitting version, mask 0. A symbol is pure computation, so it costs one
small file instead of a package and runs identically under Node and in a tab —
the standing constraint is "without adding a lot of dependencies on the
internet". A QR that does not scan looks exactly like one that does, so it is
verified by DECODING: an independent reverse-path decoder re-derives the data
placement, unmasks, de-interleaves and parses the header. 5/5 round-trip,
including version selection (v2 for a short URL, v3 for real ones).

I18N: the QR encodes the LOCALISED profile URL, so a Spanish card scans through
to the Spanish page — verified, pray.doxa.life/es/aheu. Strings fall back per
key; checked with a partial Spanish block ("Ora por ellos" rendering while
Language/Religion stayed English).

★ ONE BUG THE NUMERIC GATE PASSED, worth recording. The first deck rendered
"LAO", "thm", "EAN" — raw codes on a card a person is meant to pray from —
because normalizeRecords() flattens each { value, label } dict to its VALUE and
parks the label under <field>_label. A gate counting "4 rows rendered" passed
it: the rows were there, just unreadable. The gate now asserts the values are
HUMAN-READABLE, not merely present.

Verified by check:card-deck across all three stocks: 95 cards / 95 pages, 0
broken portraits, 95 QR codes with real symbols, 0 raw codes, 0 overflow.
2026-09-24 02:56:20 +00:00
doxa-maps-88655c70
301cc38fba feat(upg-100): print a prayer packet from the map legend
Some checks are pending
build-gate / build (push) Waiting to run
Driver, as a first-time user: "I select one and it gives me print options at
the top right of the legend... that's the only way that map would be usable."

The affordance was already built and switched OFF. The shared legend emits
'export' with 'poster' | 'packet' for the selected node, the profile wired
@export, and SHOW_EXPORT_BUTTON was held false with the note "until the export
packet is finished: the button currently produces a broken packet". That
precondition is now met, so the switch is on.

ONE RENDERER, TWO CALLERS. The pages move to library/packet/packet-render.js,
imported by BOTH the build-time generator and the browser export, so the printed
packet and the generated one cannot drift. The card asked for exactly this:
"reuse the existing print actions rather than growing a fourth implementation."
The extraction is behaviour-preserving — the generated packet is byte-identical
to before apart from leading whitespace (the code is now inside a factory), and
check:packet-render still passes 8/8.

★ THE PACKET IS GENERATED ON CLICK, NEVER SHIPPED. One packet is ~3.5 MB (photos
dominate) and 28 zones would add ~100 MB to a repo that ALREADY had a publish
blocked at a 74 MB asset tree. Generating client-side costs zero repository
bytes; the photos come from URLs the records already carry. This is the Driver's
instruction: "instead of fetching the poster from the public folder I want the
client side code to generate the poster real time when you click on the button
so it does not take a lot of mb storage." Bundle cost: +21 KB (494 -> 516 KB),
which includes all 12 prayer points and Mark 5:1-20 in full.

Printing goes through the SHARED openPrintFrame — a hidden same-page iframe, not
window.open(). A popup is blockable and drops the reader out of the map.

Two traps the browser path had to handle, both already known to this codebase:
  - pg_slugs BYPASSES the shared selector evaluator (it intentionally throws on
    that type, and 12 of the 27 lists use it). Same special-case the map and the
    poster wall apply, so all three agree about who is in a list.
  - evaluate() returns a Set, not an array.

VERIFIED IN A BROWSER, not by reading source (new gate check:packet-browser):
live records -> shared renderer -> page.pdf(), giving 8 sheets -> 8 pages at
612x792pt, 95 people cards, 12 wheel segments, Mark 5 in full, 2 metacamp.org
attributions, 0 page errors. I18n verified with a partial Spanish block: the
translated headings rendered in Spanish while Mark 5 and the attributions fell
back to English, per key, never blank.

Card BUILD-UPG100-MAP-LEGEND-LEADS-WITH-THE-UPG-100-LIST part (d). Parts (a) and
(b) shipped earlier. Part (c) — removing the People Group tab and retabbing the
legend — stays OPEN: that card is explicitly driver_gated on "what is the
legend's tree once Region/Zone/People Group are removed", and the current tabs
are wired to real hierarchy data.
2026-09-24 02:43:49 +00:00
doxa-maps-88655c70
2a0976f834 The no-selection state is now a map you pick from
Some checks are pending
build-gate / build (push) Waiting to run
The Driver's brief, with explicit creative freedom: "some type of map that would
show the regions and countries and let you select one that you want to select,
which will then show you the page results". It was two dropdowns and a table —
a form, not a map.

Now the map IS the chooser. Pins are coloured by WAGF region so the seven read
AS regions the moment it loads, with a seven-pill key rendered in REGION_ORDER
beside it. The key is a control as well as a key: hovering one lifts that
region's pins and recedes the rest, clicking one opens that region's dashboard,
and clicking any individual pin opens ITS COUNTRY's page. The two dropdowns stay
as the keyboard and screen-reader path, and the key items are real <button>s
with aria-labels so check:a11y-button-names has a name to find.

Colours come from library/colors/wagf-region.js — never a table in the
component, because check:one-palette exists precisely to stop that. Rendered in
REGION_ORDER because that palette's CVD separation was validated on ADJACENT
pairs in that order.

HIT TARGET IS PINS, NOT COUNTRY POLYGONS. Polygons would be the obvious choice
and would be dead here: the Mapbox country-boundaries tileset 403s on this box
(measured, 1 request, 1x403, URL-restricted token), so polygon hit-testing would
be both broken and untestable. Pins are our own GeoJSON and always render.

★ TWO THINGS THE SCREENSHOT CAUGHT THAT NO ASSERTION DID — both on the one
screen whose entire job is to invite a choice:
  · a world-wide fitBounds pulled the globe into a skewed crescent of pins over
    mostly empty white. Twelve assertions were green through that. Replaced with
    a fixed world view; bounds are now refused outright when the subject is
    everything.
  · Mapbox v3 defaults to the GLOBE projection below about zoom 6, so the world
    view rendered as a small sphere floating in a 1200x780 frame. setProjection
    ('mercator') for the CHOOSER ONLY — the cloned pages are deliberately left
    alone, because DX-life sets no projection either and their region page shows
    the same globe at the same zoom. Matching them is the point.

And one the mobile screenshot caught: a fixed zoom that fills 1200px shows
barely a third of the world at 390px, with Asia off the right edge. The zoom now
follows the CONTAINER WIDTH — log2(width/512), the width at which Mercator's
world fits — clamped to [0,2], re-derived on resize via ResizeObserver. A phone
still crops (Mapbox will not go below zoom 0) but ~24% rather than ~67%, and it
pans.

Verified: 12 chooser assertions, including that the CLONED pages are untouched —
their prayer colouring and their four-row prayer key both return on a selected
region. Region page still 68/68 vs live, country 28/28, app 10/10, no gate
newly red.
2026-09-24 02:43:42 +00:00
doxa-maps-88655c70
c36d431105 Search bar off on MapLibre for country-posters, legend rises to fill it
Second plugin in the MapLibre rollout, copying doxa-research-map's pattern
rather than inventing a second one: hasGeocoder gates the search bar on
provider, MapToolbar collapses its own reserved gap, and the legend rises
into the vacated space.

The geocoder stays Mapbox-only by decision, not omission: its maplibre path
still calls makeMapboxForwardGeocode(accessToken), so the search REQUEST
goes to Mapbox whichever engine draws the tiles — a keyless map with a
search bar is not keyless and would bill the very key the option exists to
avoid.

★ RAISING THE LEGEND COLLIDED WITH THE FLY/CLUSTERS TOOLBAR, and only the
screenshot showed it. LegendTools is absolutely positioned at top:14px,
centred on the legend column — the SEARCH BAR's slot. With the bar gone and
the legend raised to 14, the toolbar printed straight across the legend's
"WAGF REGIONS" header. Every number was green while the panel was unusable.
A collision is worse than the gap this work exists to close.

So LegendTools now EXPORTS showsFlyTools(legendType), and hosts ask it
instead of re-typing its condition — two copies of one rule is how they
drift. A host with no search bar stops at 56px when the toolbar renders and
rises to 14px when it does not. doxa-research-map picked up the same fix.

MEASURED on the built tree, both engines:
  country-posters   mapbox  search@12   legend@60   (unchanged)
  country-posters   maplibre search@NONE legend@56  (no gap, no overlap)
  doxa-research-map maplibre search@NONE legend@58

doxa-simple-map needed NOTHING and was not touched: FEATURES.geocoder has
been false on both engines since 2026-05-04 and its legend already sits
flush at 10px. Recorded as a no-op so the next pass does not "fix" it.

22/22 bundles, 7/7 pre, 18/18 post, browser sweep of five StackLayout
consumers and all seven studio dashboards.
2026-09-24 02:43:14 +00:00
doxa-maps-88655c70
b9f6ecf12e Regions is a standalone app now: URL is the state, 4/10 -> 10/10
Some checks are pending
build-gate / build (push) Waiting to run
MEASURED first, with _qa-cycle/02-builder-cycle/_probe-standalone-app.mjs: 4 of
10 app properties OK. Five of the six failures were ONE missing thing — the URL
never reflected the selection. So nothing was linkable, a reload lost the
subject, Back unmounted the element, a deep link did nothing, and Share shared
the index. DX-life gets all of it free from Nuxt routing; /regions/oceania IS
the state. A custom element has to do it deliberately.

THE HASH, NOT THE PATH: a leaf page is a static file at a fixed URL with no
server to route /regions/oceania, and the hash is the only part an EMBEDDED
element may touch without fighting its host page. Slugs are DX-life's own, so a
link reads the way theirs does. replaceState on first paint, pushState on a user
choice — that is what makes Back mean "the previous subject" rather than "leave
the page". A slug resolves region-first then country, the same order their
[slug].vue matches in; "neither" lands on the chooser rather than a 404, because
for this element the chooser is a legitimate state.

The hash is applied AFTER the fetch, not on mount: it cannot be resolved to a
subject until the regions are known, and resolving early sent every deep link
silently to the chooser.

One watcher on the selection rather than a writeUrl() call at each of the five
places a selection is made (tab-1 event, table row, two dropdowns, back-link) —
those would drift. popstate sets a guard so it cannot push an entry for a state
it just applied, which is the classic loop where Back appears to do nothing.

Also: document.title follows the subject, as theirs does via useSeoMeta, and the
scroll body is a <main> landmark — the probe measured none on the page.

★ THREE OF MY OWN ASSERTIONS WERE WRONG BEFORE THE CODE WAS, each failing on
correct behaviour, and each time my first instinct was that the app was broken:
  · h1/share/landmark counts were read AFTER a goBack had unmounted the element,
    so "0 h1s" was a reading of a blank page. A reading taken in an unknown
    state is not a finding.
  · the Back test clicked the SAME region twice — selecting the subject you are
    already on correctly creates no history entry.
  · the next version clicked two table rows, but picking a COUNTRY renders
    CountryDetail, which has no region table, so the second click hit nothing
    and both picks read identical.
It now sequences only through controls that exist in each state: pick a region,
clear with the in-page back-link, Back returns the region.

No regression: region page still 68/68 vs live, country 28/28, both browser
suites green, no gate newly red.
2026-09-24 02:20:37 +00:00
doxa-maps-88655c70
1b7db04a5b Vendor Poppins: the body face every line of text on these pages uses
Poppins is DX-life's --font-family-base. Ours was NOT rendering it, and a
computed-style diff could not see that: both sides merely NAME "Poppins" first
in font-family, so a first-family comparison matched and the region page
reported 68 of 68 properties identical while every line of body text was a
system fallback.

The glyph-width oracle is what caught it — the same string at the same size
measured 334.58px here against 317px on their page, 5.5% wider, i.e. a
different face. After vendoring: 317 vs 317, exact. Bebas stays 279 vs 279.
document.fonts.check() is not usable for this; it returned true for Oswald and
Brandon Grotesque, which were never vendored, and false for Bebas on DX-life's
own page.

COPIED, NOT RECREATED, and deliberately narrow: weights 400/500/600/700 only —
the four the regions surface actually asks for, measured across their _globals,
_cards, _buttons and _filters (medium x10, regular x4, bold x2, plus the 600 in
the prayer-coverage legend). Subsets latin + latin-ext only. Google's own
unicode-ranges (css2 v24) reproduced verbatim so the subset split behaves as it
does on their site. 44 KB; assets/fonts is now 0.1 MB of a 129.8 MB tree.

useDocumentFont gained per-face weights, because a real family is several files
where Bebas was one. A face without its own weight falls back to the call's
single weight, so the existing Bebas caller is unchanged.

PLACEMENT, per the zone rule: the composable stays in library/ — it has two
consumers already (both regions profiles) and nothing about it is specific to
these pages. The fonts sit in public/assets/fonts beside Bebas, self-hosted,
never a CDN.

BLOCKED ON A DRIVER DECISION: Brandon Grotesque (--font-family-buttons, every
.button) is COMMERCIAL — Google Fonts returns HTTP 400 for it, so it cannot be
vendored from there. Measured 134.34px against their 128px on FULL PROFILE.
Either license it, substitute a close free face and document the deviation, or
supply the file if Doxa already holds a licence. Recorded on
PORT-REGIONS-TYPEFACES-AND-SVG-ASSETS.

Region page still 68/68 vs live, country page 28/28, every gate green.
2026-09-24 02:05:56 +00:00
doxa-maps-88655c70
9e1c5ec196 Merge main into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
Generated-file conflicts only; resolved from main then regenerated with tree + fix:sri.
2026-09-24 01:36:06 +00:00
doxa-maps-88655c70
b55044ed95 Vendor Bebas Neue: the last 7 region-page differences were all one font
MEASURED before: the region dashboard matched live doxa.life on 61 of 68
computed properties, and every one of the 7 differences traced to a single
cause — Bebas Neue could not be loaded. Three were the font-family itself, and
four were workarounds it forced: an uppercase hack imitating Bebas's caps, and
two font-sizes stepped down because a wider fallback face overflowed their card
widths. So it was never seven problems, it was one with six knock-ons.

DX-life does not ship the file either — @nuxt/fonts downloads and self-hosts it
for them, which is why their computed family reads "Bebas Neue", "Bebas Neue
Fallback: ...". We could not @font-face from their origin: their file is served
without CORS headers, so a cross-origin face fails silently. The font is a
Google Font under the OFL, so it is vendored here from Google Fonts — both
subsets, their own unicode-ranges (css2 v16) — self-hosted, never a CDN, the
same rule public/assets/vendor/tabulator follows.

library/composables/useDocumentFont.js is shared because @font-face is
DOCUMENT-scoped: a face declared inside a shadow root is ignored, so a custom
element cannot register its own webfont through useShadowStyles. That asymmetry
is the reason this is a separate composable rather than a flag on that one.
Idempotent per document, so a composite registers one face for both panes; both
profiles call it because each has a standalone leaf page where the other is
absent.

With the real face loaded, the workarounds are DELETED rather than kept: the
goal-card heading is back to their 59.6046px, the headline figure back to
59.6046px, and text-transform:uppercase and font-stretch:condensed are gone —
Bebas is already caps and already condensed, and stacking those changed the
glyphs. The three-across switcher still holds at 372/372/372 with no overflow,
which is the geometry that forced the step-downs in the first place.

★ THE CATCH THAT MATTERED. With the font vendored the diff said 68 of 68
IDENTICAL, 0 differ — and it was WRONG. document.fonts reported our face in
"error" status: generate-tree stages an ALLOWLIST of asset dirs into the tree,
fonts/ was not in it, so the leaf resolved assets/fonts/... to a 404 and every
heading silently rendered on the fallback. Both sides were agreeing on a
FALLBACK, which is not a match. A font that 404s is invisible to any check that
reads font-family instead of asking the engine whether the family is usable, so
the probe now calls document.fonts.check() and the staging list carries fonts/.
After the fix: usable true, face "loaded", and the headings render in Bebas.

Two honest notes. The fallback TAIL still differs — theirs carries @nuxt/fonts'
generated metric-fallback names we do not produce — so the probe compares the
FIRST family (the face actually used) and reports the tail as an accepted
difference rather than counting it either way. And document.fonts.check()
returns false on THEIR page, which contradicts it visibly rendering in Bebas,
so that check is trusted in one direction only: it proves ours loaded, it does
not prove parity.

The licence ships beside the font, extensionless: check:public-hygiene refuses
*.txt in a served folder and is right to, while the OFL requires the licence to
accompany redistributed font files. The conventional name satisfies both.
2026-09-24 01:35:48 +00:00
doxa-maps-88655c70
b93b4cb984 One prop swaps the engine for every map, not just three
Some checks are pending
build-gate / build (push) Waiting to run
The switch existed and did not reach. resolveMapProvider has long read
profile-config.provider -> ?provider= -> window.MAP_PROVIDER, but
useMapInstance defaulted `provider` to the LITERAL 'mapbox', so the page
switch only reached a map whose bundle happened to pass provider through.
MEASURED: 3 of the 11 bundles that mount a map. The other 8 pinned Mapbox
whatever the page asked — it LOOKED global and was not.

The factory now resolves it when a caller leaves it unset, which moves the
decision to the one place every map already goes through. An explicit
caller still wins (prayer-walk-planner pins maplibre deliberately), and
with no switch set resolveMapProvider returns 'mapbox', so the default is
unchanged.

DashboardMapPanel collapsed anything that was not the literal 'maplibre' to
'mapbox', which outranked the page switch. But dashboard.config.js pins
Mapbox DELIBERATELY — the Driver's ruling for Mapbox Standard's native
starfield globe — so that default had to survive. Both hold by putting the
page switch BETWEEN the per-element config and the bundle default:
  1. profile-config.provider   this embed
  2. ?provider= / MAP_PROVIDER the page — "swap every map on this site"
  3. dashboard.config.js       this bundle's shipped default (Mapbox)

MEASURED across seven map leaves, default vs ?provider=maplibre, counting
requests to api.mapbox.com:
  doxa-simple-map          21 -> 4    engine swaps
  country-posters          24 -> 4    engine swaps
  research-dashboard       25 -> 4    engine swaps (was pinned)
  doxa-research-map        16 -> 4    engine swaps
  research-map-plus-grid   16 -> 0    fully keyless
  my-upg-100-list          19 -> 19   NOT swapped — see backlog
  regions-dashboard         4 ->  4   no map on that leaf

Simple Research reaches ZERO Mapbox requests, which is the case that
matters most: it is the one carrying the search bar, and the geocoder is
the quota risk (GeocoderComponent's maplibre path still geocodes through
Mapbox, so a keyless map with a search bar is not keyless).

Two gaps left, both filed rather than hidden: my-upg-100-list bypasses
useMapInstance entirely and cannot see the switch, and generated leaves
still preload mapbox-gl.js/.css + the geocoder from api.mapbox.com even
under maplibre (library downloads, no token, but still a CDN dependency —
the composite leaf proves 0 is reachable). The second belongs to the tree
verb, which is not mine to change.

22/22 bundles, 7/7 pre, 18/18 post, browser sweep of five StackLayout
consumers and all seven studio dashboards.
2026-09-24 01:34:03 +00:00
doxa-maps-88655c70
390b90b343 Prayer-walk POSTERS: per-zone walking-scale street map on a poster sheet
Some checks are pending
build-gate / build (push) Waiting to run
The runtime sibling of the UPG-100 poster generator (that one is
build-time over 28 fixed zones; walk zones are drawn live). For each
selected zone the camera fits the zone bounds (maxZoom 17), waits for
idle, and snapshots the keyless MapLibre canvas INSIDE a render frame
-- triggerRepaint + once('render'), because MapLibre does not preserve
the WebGL buffer and toDataURL outside a frame returns a blank white
image that still counts as a 'valid' data URL (measured: the first
sheet shipped a white rectangle; pixel check now reads avg 212
brightness, 0% blank). Camera restored afterwards.

The sheet borrows the poster design language: dark-green title band,
kicker with the zone centre, framed street map with the zone polygon
painted on it, a prayer charge line, zone corners, and the honest
credit (Map (c) OpenFreeMap, Data (c) OpenStreetMap contributors) --
letter portrait, printed in place via the shared
@map/composables/printInPlace.js. Agent flow re-verified end-to-end:
addPolygon -> selectPolygon -> print() raises the dialog once.

(Also restores the public API wrappers a block edit clobbered --
caught by the page's own ReferenceError before commit.)
2026-09-24 01:06:51 +00:00
doxa-maps-88655c70
245e0f960e Merge main into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
Generated-file conflicts only; resolved from main and regenerated with bun run
tree + fix:sri so the derived tree describes the merged source.
2026-09-24 00:59:30 +00:00
doxa-maps-88655c70
06c6455fdc Regions: the selected page is now an exact clone; the chooser is the one addition
The rule: a SELECTED region or country must look identical to the DX-life page
you land on when you click it, and the ONLY thing we add is the state their
site has no route for. Three things followed from that.

1. THE BAR CHART IS GONE. It was ours, not theirs. RegionDetail.vue is four
   stat cards, the map, three goal cards and the country table — nothing else.
   The goal cards and the country table now belong to a SELECTED region only;
   they were showing on the no-selection view as totals, which made the chooser
   look like a region page wearing the wrong numbers.

2. THE COUNTRY LIST IS THEIR UupgsList, ported. CountryDetail.vue:63-69 nests
   <UupgsList :lock-country-code hide-see-all-link />, so cloning that page
   means cloning that component — a DataTable of ours was a stand-in, not a
   clone. src/UupgsList.vue carries their markup and their class names so the
   CSS is theirs rather than a lookalike: .filters / .filters__toggle /
   .filters__panel / .filters__active / .filter-chip, the .search-box with its
   1.5em text-indent, the .grid[data-width-lg] 400px track, and the
   .uupg__card grid-template-areas with its 576px reflow. Their endpoint and
   field list too, and .uupg__content stays display:none as theirs is.
   Scoped to what the page can actually reach: CountryDetail passes none of
   useSelectCard / useHighlightedUUPGs / randomizeList / dontShowListOnLoad, so
   the select-card variant, the /highlighted endpoint and the see-all link are
   branches no consumer runs.

3. THE CHOOSER is the one deliberate difference, and it is now the only one.
   Opening tab 2 first is a state DX-life cannot reach (/regions is the list,
   /regions/<slug> always has a subject, [slug].vue 404s otherwise), so it gets
   a generic map of everything plus three ways in: a region dropdown, a country
   dropdown (both the shared FilterDropdown), and the all-regions table whose
   rows were already clickable. Choosing a subject replaces it with the clone.

Also removed a visible difference nothing had flagged: the shared PinHint's
"Tap a coloured dot" coach-mark was floating over the cloned map, and their
page has no such overlay. pinHint:false now, and the fix had to go in the
COMPOSITE — a child profile's meta only configures its own leaf page, while a
nested pane is handed the config regions-mfe.vue builds, and PinHint reads it
off the injected profileConfig. Same cap-4 inheritance gap assetsBase hit, so
pinHint joined INHERITED_PAGE_KEYS as well.

Verified against the LIVE page, not the stylesheet: doxa.life/regions/cameroon
at 1440x1000, filter panel open on both sides, 28 of 28 computed properties
IDENTICAL, 0 differ. That diff found the one real error — their .button sets no
font-size so it inherits 19.5312px, and I had hardcoded 15.625px. Two rows it
could NOT measure are reported as unmeasured rather than counted either way:
.filter-chip is absent on both sides with no filter selected.

Every suite still green: the map draws 934 pins / 51 outline for a region and
61 / 5 for a country, the country page renders 8 UupgsList cards with their
filter toggle and search box, no page has a coach-mark, and all six viewports
still match their container formula.
2026-09-24 00:59:10 +00:00
doxa-maps-88655c70
04a55c66b2 Prayer Walk Planner: full-page dev mount + agent-triggerable in-place print
Some checks are pending
build-gate / build (push) Waiting to run
Two halves of the Driver's directive:

1. FULL PAGE (BUG-PRAYER-WALK-PLANNER-MAP-DOES-NOT-FILL-THE-PAGE).
   The BUILT leaf was already full-viewport; the DEV staging page boxed
   the element in a centred 640px card -- measured 781x584 at y=206 on
   a 1440x900 viewport, exactly 'the map is not filling the full
   page'. The dev harness now mounts fixed inset:0 like the leaf
   (two-host rule).

2. AGENT-OPERABLE PRINT. PrayerWalkPlanner.print() used window.open,
   which is pop-up-blocked without a user gesture -- an agent calling
   the documented API got silence. The poster-wall's in-place
   hidden-iframe mechanism (e957cbe) graduates VERBATIM to
   @map/composables/printInPlace.js (library bar: 2+ consumers,
   parameterized) and the planner uses it; @page pins letter portrait
   (the 839b0c4 ruling).

Verified end-to-end as an agent would: registry -> addPolygon ->
selectPolygon -> print() => {opened:true,count:1}, hidden iframe
print() fired once, portrait pinned, zero pop-ups.
2026-09-24 00:58:10 +00:00
doxa-maps-88655c70
ad534beb64 Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/doxa-research-map/doxa-research-map.js
#	public/doxa-maps/doxa-research-map/research-map/index.html
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
2026-09-24 00:44:26 +00:00
doxa-maps-88655c70
e7bc86d4f2 Map engine is one prop: MapLibre drops the search bar, legend rises
Simple Research showed a Fly + Clusters toolbar where the search bar used
to be, and the map offered no way to pick an engine without writing a
second map.

FLY + CLUSTERS ARE NOW LEAF-ONLY. LegendTools renders at top:14px centred
on the legend column — the search bar's exact slot — so on the Regions and
Languages tabs (the legendTypes its predicate allows) it reads as having
REPLACED the search bar. MEASURED on the leaf: fly toolbar at y=58 against
the search bar at y=56, the two on top of each other. In a stack whose
bottom half is a grid the reader is scanning, a fly-tour that moves the
camera underneath fights the other half, so the composite passes
legendTools:false. The standalone leaf keeps both.

THE ENGINE IS ONE PROP. resolveMapProvider already read profileConfig
.provider, so research-map.instances.json publishes the maplibre instance
and a partner running thousands of embeds changes one value instead of
maintaining a second map. Mapbox stays the default: it carries the richer
feature set. MapLibre is the keyless option for volume.

MAPLIBRE SHIPS NO SEARCH BAR, ON PURPOSE. GeocoderComponent's maplibre path
still calls makeMapboxForwardGeocode(accessToken) (:526) — the geocoding
REQUEST goes to Mapbox whichever engine draws the tiles. Shipping the bar
under maplibre would keep billing the exact key the option exists to avoid
while LOOKING free. Under mapbox it stays, unchanged.

AND THE LEGEND RISES INTO THE VACATED SPACE. SemanticTreeLegend's
--stl-top defaults to 60px that exists only to reserve room for a search
bar; leaving it when the bar is gone is a dead band. MapToolbar's
has-geocoder collapses its own reserved gap the same way.

MEASURED against the built tree:
  leaf mapbox      search@56  legend@104   fly@NONE (Prayer) / @58 (Regions)
  leaf maplibre    search@NONE legend@58   — legend rose 46px
  composite        search@NONE legend@104  fly@NONE on Regions

7/7 pre, 18/18 post.

KNOWN GAP, pre-existing and NOT introduced here: the mapbox composite still
shows no search bar. mapboxToken is injected per element and the composite
passes neither tk nor tokenUrl to its child, so the child's geocoder gets
an empty token and bails while the tiles render from another path. Isolated
but not fixed.
2026-09-24 00:43:44 +00:00
doxa-maps-88655c70
3b7aa1fed4 Merge main into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
One conflict, in the generated root page (it inlines the whole manifest).
Resolved the way a derived file should be: taken from main, then regenerated
with bun run tree so it describes the merged manifest, followed by fix:sri.
Nothing else in the tree moved — no bundle was rebuilt, so main's own build
outputs are untouched.
2026-09-24 00:35:08 +00:00
doxa-maps-88655c70
8578854cbc Search tier 2: a misspelled country resolves to THE country (best match, 2-edit cap)
Some checks are pending
build-gate / build (push) Waiting to run
Driver: 'if I search the name of the country and add n to it, like
Austrian or Canadian, I would like for there to be a selection of that
country -- the table under the map shows the result for that country.'

Three changes, one ladder:
1. resolveFuzzyValues(): WHOLE-VALUE Levenshtein against the DISTINCT
   values of every search key -- 'Sudann' is a misspelling of the
   VALUE 'Sudan', so the rows returned are exactly Sudan's, identical
   to picking it as a Place. The windowed matcher stays only as the
   final rescue tier.
2. Best match wins: only the minimal-distance value(s) resolve. The
   under-the-cap union admitted 47 rows for 'Canadian'.
3. Both fuzzy tiers cap at 2 edits absolute (the did-you-mean bound);
   threshold*len alone reached 3 errors at 8+ chars, which is where
   the cross-facet noise lived.

Measured (dashboard scope / grid count agree): Sudann -> 105/105
Sudan only; Indonesa -> 36/36 Indonesia; Banladesh -> 3/3 Bangladesh;
Bhutann -> 13/13 Bhutan; exact terms unchanged (tier 1).

NOTE: Canada/Austria themselves have NO rows in this dataset (it
holds only the 175 countries with unreached people groups), so
'Canadian'/'Austrian' cannot select them -- a handful of genuine
2-edit neighbours return instead.

Also ships @map/composables/retryableAsyncComponent.js wired at every
defineAsyncComponent site (ProfileLoader chokepoint + 8 lazy
components): a transient ERR_NETWORK_CHANGED no longer permanently
kills a lazy plugin (3 retries, growing backoff). Live fault-injection
proof pending; the wrapper is the documented Vue onError contract.
2026-09-24 00:34:19 +00:00
doxa-maps-88655c70
54860914bf Merge remote-tracking branch 'upstream/main' into bundler-1/work 2026-09-24 00:34:05 +00:00
doxa-maps-88655c70
a334e8ebff Merge main into bundler-1/work
Main moved 20+ commits while the regions work was on its own branch. Every
conflict was a GENERATED file — manifest.json, the root page, and the
regions-dashboard bundle's .js and leaf pages. Zero source conflicts: all six
of my source files (the two regions .vue, the new map child, fix-sri.mjs,
sri-consistency-check.mjs, package.json) auto-merged.

Generated files are not hand-merged. They were resolved to main's side and then
REGENERATED from the merged source — BUNDLE=regions-dashboard vite build, then
bun run tree, then bun run fix:sri. `bun run tree` rewrote every bundle's pages
and produced byte-identical output to main's committed ones for all twenty
other bundles, so the only files this commit changes beyond the merge are the
six that had to be rebuilt. That agreement is also evidence main's committed
tree matches main's source.

The pinHint:false regression I saw before is gone: main's 566ad46 committed the
two metas that were previously only on disk, which was the root cause — a tree
regeneration now preserves them instead of dropping them.

Re-verified against the merged library, which is the part that mattered: main
changed useMapInstance.js, StackLayout.vue and ViewSelect.vue, all of which the
regions element sits on. All three suites still pass — the map draws 934 pins /
51 outline features for a region and 61 / 5 for a country, the pin mode still
follows the subject, the country page still renders CountryDetail's shape, and
every width still matches DX-life's container formula at six viewports.

23 of 25 gates green. The two that fail are pre-existing and not in this diff:
check:library-index is an mtime artifact (regenerating the index produces
byte-identical content), and check:backlog-status reports 6 of 546 non-enum
statuses in the shared _qa-cycle board, which lives outside every repo.
2026-09-24 00:33:28 +00:00
doxa-maps-88655c70
7d38f90b3d The map legend says what it shows, and opens there
Some checks are pending
build-gate / build (push) Waiting to run
"Zone" was our internal word leaking into the UI. The thing the map paints IS a
UPG-100 list -- 27 of them, one per printable packet -- so the tab is named for
that, and the legend opens on it. Region stays, described as what it actually
does: sort those lists by world area when you want that.

ORDER IS DELIBERATELY UNCHANGED, and the comment now says why. Tab INDEX is
coupled to tree DEPTH inside the shared legend -- `activeTab = Math.min(sel.depth,
...)` and again on breadcrumb -- and the tree nests region(0) -> zone(1) ->
group(2). Moving the list tab to index 0 to make it open first would have made
selecting a zone jump to the Region tab instead, and SEVEN profiles share that
component. I started that way and backed it out.

Opening on the list tab uses the mechanism already built for it: `:default-tab`,
added for country-posters with the same need ("it opens to the country's semantic
level in the legend"). One prop at the call site, no shared behaviour touched,
and the component clamps an out-of-range value so it degrades to the top
generation rather than blanking.

LegendMobile takes neither tabs nor defaultTab -- it derives its own view -- so
there is nothing to wire there.

Card: BUILD-UPG100-MAP-LEGEND-LEADS-WITH-THE-UPG-100-LIST (the rename + default
half; print-from-selection is still open and is the packet's entry point).
2026-09-24 00:30:14 +00:00
doxa-maps-88655c70
98cb50cc4b Search is TIERED: exact matches win outright, fuzzy is only the typo rescue
Some checks are pending
build-gate / build (push) Waiting to run
Driver: 'fuzzy search is too fuzzy -- Bhutan returns more results than
just the country, and there is no interoperability with the other
filters like selecting it as a place.' The first fuzzy pass was a
UNION (exact + every <=k-error neighbour), so the exact term Bhutan
counted 132 rows while Country=Bhutan counted 13 -- two controls
disagreeing about one place.

Now tiered, the conventional search contract: when a term has ANY
exact substring match, only exact matches return -- search agrees
with the facet (13 = 13). The k-errors pass runs solely when exact
yields zero (Bhutann -> 13, Banladesh -> Bangladesh). Applied in the
shared Fuse.search (grid) AND a filterRowsBySearch helper used by the
shell + all four panels (table, facet rail, charts rail, map), so
every surface computes the same tier.

Measured: Bhutan 132 -> 13 (grid 'Showing 13' agrees), Bhutann 13,
Banladesh rescued, gibberish 0, clear 2,107.
2026-09-24 00:21:32 +00:00
doxa-maps-88655c70
39b9a4cca9 Regions: port DX-life's map on our map library, and clone the country page
Completes the clone. Two things were missing: the map both their pages render,
and the fact that a COUNTRY is a different page from a region.

THE MAP — plugin-designs/regions-dashboard/src/RegionCountryMap.vue.
Layers are DX-life's, expression for expression (CountryMap.vue:186-240): the
country-boundaries fill at 0.12, the 2px outline, the single-worldview filter
that stops the highlight drawing three times, the prayer / in-country colour
cases, the circle-sort-key that puts better-covered pins on top, the
zoom-interpolated radius, 0.75 opacity on neighbours, their seven colours.
The machinery is ours: theirs does `new mapboxgl.Map` in a Nuxt page, which
cannot work in a shadow root — container-by-id does not cross the boundary, the
async CSS link gives a first-initialising map a 0-height canvas, and the
wordmark duplicates per instance. @map/composables/useMapInstance.js already
solves all three, so this supplies only the layers. Points come down from the
parent instead of a second fetch, so the pins and the figures above them
provably describe the same rows.

THE COUNTRY PAGE — CountryDetail.vue is head + map note + map + the people-group
list, with NO stat cards and no goal cards. It now renders that, not the region
page wearing a country's numbers. Its map passes no color-by-prayer, so pins
read in-country vs surrounding, which is what its own note tells you to look
for. The list uses @map/components/tables/DataTable.vue rather than a third copy
of their 662-line UupgsList — this repo already ships that as people-groups-grid,
and nesting the real element is a cap-4 composition question.

Four defects found by measuring, not by reading:

1. A CANVAS AT THE RIGHT SIZE IS NOT A MAP. With a <template> branch per subject
   kind, region -> country unmounted one map and mounted another into a
   zero-size container: 1200x780 canvas, correct centre and zoom, layers [],
   nothing drawn, a blank white card — and every assertion passed. One instance
   driven by props now, and the suite asserts queryRenderedFeatures (934 pins /
   51 outline for Africa, 61 / 5 for Cameroon) instead of canvas dimensions.

2. isStyleLoaded() IS THE WRONG GATE for adding layers. It also waits for the
   style's sources, which never arrive at zoom 6 on this box because the local
   token is URL-restricted and its high-zoom tiles 403. Same code drew a region
   and drew nothing for a country. Keyed on 'style.load' — the style being
   PARSED is what makes addSource/addLayer legal — with a parsed-style fallback.

3. THE PIN MODE WAS FROZEN at whatever it was when the layer was created, so a
   country page painted prayer colours. Caught in a screenshot by one yellow
   "1+ praying" pin; invisible to every assertion, because the pins were
   rendered and the legend was correctly absent. applySubject now re-applies
   circle-color and circle-sort-key.

4. THE PROFILE CONFIG ARRIVES BY INJECT, NOT AS A PROP. The element this bundle
   registers IS ProfileLoader, which parses the profile-config attribute and
   provides the parsed object (ProfileLoader.vue:195). Both tabs read it off
   their own string prop, which is always empty — prayBaseUrl, languageCode,
   instanceId and assetsBase all silently defaulted. Nothing looked broken until
   assetsBase mattered: the vendored Tabulator 404'd twice with no error and the
   country list just never drew. Fixed in both tabs, prop kept as a fallback.

Token delivery needs no wiring: the generator detects a map under the bundle and
bakes the ladder itself, so all three leaves now carry it and
check:nested-token passes.
2026-09-24 00:10:27 +00:00
doxa-maps-88655c70
566ad4676e Commit the two pinHint:false metas that were only on disk
Some checks are pending
build-gate / build (push) Waiting to run
cities-internet/connectivity-map.meta.json and
gospel-resources/resources-map.meta.json carry the pinHint:false
opt-out (no 'Tap a coloured dot' on maps with no dots). They were
authored and BUILT INTO the leaf pages but never committed -- a clean
clone would silently regress the fix on its first rebuild.
2026-09-24 00:01:08 +00:00
doxa-maps-88655c70
bd94cf6ed4 Dashboard -20px, map untouched: shellH 582 -> 562
Driver fine-tune: 'almost perfect height -- keep the map the same
height, but decrease the height of the dashboard by about 20px.' Map
row stays 340; the 20px comes out of the table's derived share.
Storage key v5 -> v6. Measured live: shell 703 -> 683, map 340,
table 244 -> 224.
2026-09-24 00:01:08 +00:00
doxa-maps-88655c70
32132459cf Merge main into bundler-2/work
# Conflicts:
#	public/doxa-maps/dashboard-plus-grid/dashboard-plus-grid.js
#	public/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/default/index.html
#	public/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/index.html
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
2026-09-23 23:54:22 +00:00
doxa-maps-88655c70
da3e3dc589 Map +40px again: shellH 542 -> 582, mapH 300 -> 340
Some checks are pending
build-gate / build (push) Waiting to run
Second Driver raise, same routing as the first: shell and map move
together so the whole gain lands in the map row; the table's derived
share stays at its height. Storage key v4 -> v5 so the new default is
seen immediately. Measured live: map row + canvas 340px, table 244px,
shell 703px.
2026-09-23 23:49:55 +00:00
doxa-maps-88655c70
be77ea954c Merge main into bundler-2/work 2026-09-23 23:48:10 +00:00
doxa-maps-88655c70
02fc4bd45c Regions tab 2: clone DX-life's region dashboard
Tab 2 was a subject band, four tiles and one bar chart. DX-life's /regions/<slug>
is five blocks, and the gap was the whole point of the tab. Ported from
RegionDetail.vue, block for block:

  • back-link / title / summary, centred          RegionDetail.vue:47-51
  • four stat cards                               :53-70
  • three DOXA goal cards in their .switcher      :88-141
      Prayer Coverage — the segmented 100+/1+/none bar with its legend
      Adoption Progress and Engagement Progress — progress bars
      each goal sentence verbatim; they are the DOXA goals, not our copy
  • the five-column country breakdown             :144-171

Every figure comes from the rollup this element already computed, so tab 1 and
tab 2 still cannot disagree about a number. Nothing was invented to fill a card.

WHAT HAPPENS WITH NOTHING SELECTED, which was the open question: DX-life has no
answer to borrow — /regions is the list, /regions/<slug> always has a subject,
and [slug].vue 404s when the slug matches neither. A 2-tab element can't do
that, since tab 2 is reachable before anything is clicked. So it keeps their
invariant (the dashboard always has a subject) and makes the widest honest
subject the default: All Regions, every figure summed, the table switched from
countries to regions so it still compares like with like, and a line saying how
to narrow it. The back-link returns there instead of navigating.

Two substitutions, both forced by Bebas Neue being uncloneable (their font file
is served without CORS) and both measured, not guessed:
  • goal-card h2 pinned to --font-size-2xl (38.147px), the FLOOR of their own
    clamp. Measured: 'Engagement' is 380.1px at their 59.6046px in our stand-in
    face and 243.2px at 38.147px, against 261.55px of inner width. Their size
    made each card's min-content ~490px, so the third wrapped to its own row —
    579/579/1200 instead of their 372/372/372.
  • goal-card figures stepped one rung to --font-size-3xl and pinned nowrap, so
    '21 / 2,107' cannot break after the slash and read as two numbers.
Also min-width:0 on the switcher children: a flex item's automatic minimum is
its min-content, which is what blocked their basis from governing.

Verified against the LIVE site, not against the stylesheet: 33 computed
properties on doxa.life/regions/africa at 1440x1000 are now IDENTICAL, 0 differ,
and the four stat figures match their API exactly (315 / 41 / 43,045,275 / 300).
That diff is also what caught a real error — I had the low end of their 1.33
spacing scale shifted a step (xs is 1rem = 10px, 2xs is 7.5188px, 3xs is
5.6532px), so table cells and stat labels were a step tight. Every ported value
is now exact token math rather than one decimal place.

23 assertions across the three selection states, plus the tab-1 swap: reverting
this file to HEAD and rebuilding fails 21 of them.

Not ported: RegionDetail's CountryMap. It needs a per-region centre/zoom/bounds
rollup and a token-bearing map child, and this profile ships isMap:false — a
separate piece of work, not a detail. The comparison chart stands where their
map goes until it lands.
2026-09-23 23:33:51 +00:00
doxa-maps-88655c70
bfcb4eece9 Share hash is readable: code-canonical value + display-only label slug
Some checks are pending
build-gate / build (push) Waiting to run
Was URL-encoded JSON (#f=%7B%22affinity_bloc%22%3A%22A008%22%7D) --
unreadable twice over, and a bare API code is unauditable by whoever
receives the link. Now the Stack Overflow /id/slug pattern:

    #f=affinity_bloc:A012*south-asian-peoples;religion_family:Hinduism

The CODE stays canonical; the *slug comes from the same labelFor()
resolver the chips and CSV provenance use, and the parser STRIPS it --
so links are locale-independent and survive label renames. Literal
%;:*+ in values are percent-escaped so the delimiters cannot collide;
spaces ride as '+'. Legacy JSON hashes (old shared links) detected by
their leading brace and still restore.

Measured: click -> readable hash; fresh page with it -> scope 881
restored; legacy JSON hash -> 881 restored.
2026-09-23 23:28:00 +00:00
doxa-maps-88655c70
3685b196cb Show the cap-4 composite in the studio's Simple Research slot
The cap-5 shell's "Simple Research" tab rendered an error panel instead of
the map, in both trees:

  Could not load <doxa-research-map> from ../../doxa-research-map/
  doxa-research-map.js

StackLayout already anchored sibling-bundle srcs to the tree root, but it
anchored on the /doxa-maps/ segment of window.location.pathname. The studio
mounts at the SERVER ROOT (/index.html), which has no such segment, so the
lookup missed and the literal '../../' was returned untouched. The browser
resolved it against '/', where '../' cannot climb, and requested
/doxa-research-map/doxa-research-map.js — 404, both children, empty stack.

That is why it survived: every gate and every screenshot used a LEAF url,
and the studio is the one host that is not one. The same composite at its
own leaf renders perfectly.

resolveBundleSrc now falls back to bundleTreeRoot() — the bundle's OWN
script URL, which rollup rewrites to document.currentScript.src in the IIFE
build. That is a fact about where the code came from, not a guess about
where the page is, which is the lesson stack-demo.vue wrote down when this
defect appeared for the third time. It returns null in dev ESM and for any
non-<name>/<name>.js layout, so it never guesses. The pathname rule stays
FIRST and the untouched-string fallback stays LAST, so every path that
worked before is byte-identical: this can still only fix a path, never
break one.

research-map-plus-grid also adopts stack-demo's bundleSrc() dev/prod
helper. In dev, vite serves entries at /plugin-designs/<bundle>/index.js and
there is no <bundle>/<bundle>.js to reach, so the dev surface 404'd too.
import.meta.env.DEV is statically replaced, so the built output is
byte-identical to the literal it replaces.

Verified in a browser against the built tree, reading the screenshots:
studio root -> Simple Research paints the world map with its prayer legend,
on the dev server too. Negative-tested: disabling the new fallback and
rebuilding restores the exact 404 and the empty canvas.

No regression across every StackLayout consumer — rmpg shallow leaf, rmpg
deep default/ leaf (the case the pathname rule was written for),
spa -> Simple Research, dashboard-plus-grid, stack-demo — and all seven
studio dashboards. 7/7 pre, 22/22 bundles, 18/18 post.
2026-09-23 23:26:40 +00:00
doxa-maps-88655c70
37abfc3129 Rename the facet rail title: Tree Legend -> Vertical Legend
Some checks are pending
build-gate / build (push) Waiting to run
Driver naming ruling: no 'tree'/'semantic' in the user-facing name;
the drill-down vocabulary is NESTING/FOLDERS. Verified live: the
rail's fr-title renders 'Vertical Legend'.
2026-09-23 23:15:35 +00:00
doxa-maps-88655c70
ff0a996778 Search is FUZZY again, Fuse-parity: typo tolerance restored on every surface
Some checks are pending
build-gate / build (push) Waiting to run
DX-life's search bakes in real Fuse.js { threshold: 0.4, ignoreLocation,
minMatchCharLength: 3 } (UupgsList.vue), which tolerates typos -- an
extra trailing letter ('Bhutann') or a substitution ('Indoa') still
finds the country. Two things were lost in migration:

1. The vendored @map fuzzy.js 'fuzzy' fallback was a strict SUBSEQUENCE
   matcher -- an EXTRA needle char always fails a subsequence, so the
   exact typo class Fuse tolerates was the class it rejected. Replaced
   with the standard k-errors approximate-substring DP (Sellers):
   match iff errors/pattern-length <= threshold, same score contract
   as Fuse with ignoreLocation. Highlight indices = best window.

2. The dashboard's makeSearchMatcher was a plain .includes() -- fuzzy
   never reached the topbar search at all. It now delegates to the
   shared fuzzyMatches() with the exact DX-life options, so the
   dashboard and the grid agree on one matcher.

Measured: dashboard 'Bhutann' 0 -> 13, 'Indoa' 0 -> 1,319, 'zzqqx' 0,
clear 2,107; grid standalone 'Bhutann' -> 13 Bhutan cards, highlights
render. Exact terms now also admit <=k-error neighbours ('Bhutan' 13
-> 132) -- the same widening real Fuse applies in DX-life.
2026-09-23 23:08:48 +00:00
doxa-maps-88655c70
ea390cee7f Regions: honour DX-life's 1200px container cap, and its mobile clamps
Our regions cards stretched to the full pane at every width. Measured at a
1920px viewport they were 1880px wide; DX-life's /regions caps at 1200px and
centres. Ported their .container verbatim from
app/assets/styles/composition/_container.scss:

    width: calc(90% + 16px); max-width: 1200px; margin-inline: auto

so the column is fluid until ~1316px of width, then pinned at 1200px. Applied
to both tabs — RegionDetail.vue and CountryDetail.vue both carry
class="container", so the dashboard half is capped by the same rule.

The mobile half was a separate miss: the first port resolved every clamp() at
its DESKTOP maximum, so a 390px phone got a 47.7px region title and 41.6px of
side padding. DX-life gives that phone the clamp FLOORS — 30.5px and 17.7px.
The clamps are clamps again, in container-query units rather than vw: this
element is also one pane of the regions-mfe composite, where the viewport is
not the width available, so cqw honours their decision (scale with the width
you have) instead of transcribing a unit that only works in their layout. At
the leaf page the pane is the viewport, and the two resolve identically.

Measured against their formula at six viewports, both tabs, real browser:
1920/1440 -> 1200.0, 1280 -> 1168.0, 1024 -> 937.6, 768 -> 707.2, 390 -> 367.0,
each within 1.5px of min(0.9*pane + 16, 1200), no horizontal overflow. Title
font-size tracks clamp(30.5, 4cqw, 47.7) exactly: 47.7 at 1280+, 40.96 at 1024,
30.72 at 768, 30.5 at 390. Reverting both files to HEAD and rebuilding flips
all 12 assertions to FAIL, so the measurement is not vacuous.
2026-09-23 23:07:52 +00:00
doxa-maps-88655c70
e957cbe85f Print the poster in place, on a portrait sheet
Some checks are pending
build-gate / build (push) Waiting to run
Two Driver asks on the UPG-100 poster wall, both measured at the source.

PORTRAIT. The poster path was the only one of the three print paths using
@page{size:auto}; the list and card-deck paths already pin `letter portrait`.
The poster art is a 3ft x 3ft SQUARE, and a square on an auto page has no
reason to prefer the tall axis, so it landed landscape. Pinned to
`letter portrait` to match its siblings.

The print img rule then had to change with it: `max-height:none;width:100%`
scaled the square to the full page WIDTH, which on portrait letter is taller
than the printable height -- it would have overflowed onto a second page.
Constrained both axes so object-fit letterboxes the square inside the sheet.
VERIFIED via page.pdf({preferCSSPageSize}): 612x792pt, ONE page.

IN PLACE. openPrintWindow() opened a popup and document.write()'d into it.
That was deliberate -- an earlier Driver ask wanted a preview window -- and is
now reversed: the dialog must open with no second browser window. A hidden
same-page iframe keeps the entire existing contract (one full <html> string per
caller, the markup's own <img onload> -> print()) while the dialog belongs to
this page. All three print paths stay on the one mechanism.

It also deletes a failure mode: an iframe cannot be pop-up blocked, so the
"Pop-up blocked -- allow pop-ups" branches every caller carried are unreachable.
Their messages now name something the user can act on instead.
VERIFIED in Chromium: 0 window.open calls, 1 iframe, context page count stays 1.

The noopener post-mortem above the helper is KEPT as history -- the code it
described is gone, but "a flag that returns null by spec looks exactly like an
environment problem" is the lesson worth carrying.
2026-09-23 23:00:41 +00:00
doxa-maps-88655c70
446ec38c5b Religion/Language tree TOP TIERS now filter the grid (derived-axis interop)
Some checks are pending
build-gate / build (push) Waiting to run
The vertical legend's Religion and Language top tiers emit DERIVED
family names ('Hinduism', 'Indo-European') that exist on no row field,
so they narrowed the dashboard but never the people-groups grid below
-- the last two dead axes of the tree-sidebar interop bug. The grid
now runs the SAME derivations (DERIVED_AXES) from the SAME library
modules the dashboard config reads (RELIGION_FAMILIES,
canonicalFamilyName, langFamilyByIso), routed through the shared
valueMatches() by a one-line rowAxisValue() resolver in BOTH filter
loops. imb_language_family added to the grid fetch; composite maps
religion_family/language_family onto the new props; the dashboardOnly
stopgap flags are retired.

Root cause of the first failed attempt: the re-filter watcher
hand-enumerated every filter prop -- the THIRD copy of the axis list
-- so the new props changed propFilters but never triggered a
re-filter. The watcher now watches propFilters itself: one owner,
future axes cannot miss it.

Measured round-trip: Hinduism -> dashboard 693 / grid 'Showing 693';
Indo-European -> 677/677; clear -> 2,107 both.
2026-09-23 23:00:00 +00:00
doxa-maps-88655c70
7a22290fc6 Route the +40px dashboard gain to the MAP row: mapH 260 -> 300
Some checks are pending
build-gate / build (push) Waiting to run
Driver follow-up: the taller dashboard's display room belongs to the
map, not the table. shellH stays 542, so the table's derived share
returns to its prior height and the map row carries the whole gain.
Storage key v3 -> v4 so a just-persisted layout cannot mask it.
Measured live: map row + canvas 300px, table 244px, shell 663px.
2026-09-23 22:51:09 +00:00
doxa-maps-88655c70
e3a82afdc4 Choropleth: darken greyscale bin 1 so low-count countries read as data
Some checks are pending
build-gate / build (push) Waiting to run
Bangladesh (3 groups) WAS rendering -- queryRenderedFeatures returned
the BGD polygon -- but painted #e8e8e8 against the #f3f3f1 no-data
surface: a 1.06 contrast ratio at 0.78 opacity, pixel-identical to an
empty country. Bin 1 is now #c9c9c9: the ramp's first step must be
separated from NO-DATA at least as clearly as from bin 2. Legend
swatch follows automatically (derived from BIN_COLORS).
2026-09-23 22:47:14 +00:00
doxa-maps-88655c70
3ca6912381 Dashboard +40px: shellH default 502 -> 542, extra height flows to the table
Some checks are pending
build-gate / build (push) Waiting to run
One owner: DEFAULTS.shellH, CSS fallbacks kept byte-equal at 542.
Storage key v2 -> v3 so a persisted 502 layout cannot mask the new
default. Measured live: shell 623 -> 663px.
2026-09-23 22:44:23 +00:00
doxa-maps-88655c70
e2ff53cb96 Vertical legend: sticky section headers, markdown-file style
Some checks are pending
build-gate / build (push) Waiting to run
PEOPLE/PLACE/LANGUAGE/RELIGION headers stay pinned while their rows
scroll, and the next section's header pushes the previous one away --
position:sticky top:0 inside the scrolling .fr-body, the exact GitHub
markdown-preview mechanism. Opaque panel background so rows scroll
under the pinned label instead of through it.
2026-09-23 22:39:32 +00:00
doxa-maps-88655c70
9bddccbb5e The typed search is a filter, so it now looks like one
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "I searched Bhutan in the search bar and it did not switch the
toggle to show the country was selected in the filters."

MEASURED what actually happens: typing narrows the dashboard live through
bus.searchTerm (scope 2,107 -> 13) while filterSet stays empty -- the data
changed and no control anywhere said why. Invisible state, the same defect
class as the silent grey pane.

The conventional faceted-search answer: free text is its OWN chip beside
the facet chips -- "Search: Bhutan x" -- removable like any of them, and
Clear all clears it too. It deliberately does NOT masquerade as a Country
selection, because the term matches across ALL searchKeys (name, region,
language, religion...) and labelling it a country filter would lie whenever
it matches something else.

One archaeology note that cost a first attempt: the fix initially landed in
DashboardFilterChips.vue, a component the topbar dedup pass had already
UNMOUNTED -- its own comments name the filters band as the single source of
truth for chips, which is where this now lives.

Playwright round trip: type -> chip present, scope 13; Clear all -> chip
gone, scope 2,107.
2026-09-23 22:35:13 +00:00
doxa-maps-88655c70
ab83fd4ca7 Chart legends: the conventional pattern, nothing cleverer
Some checks are pending
build-gate / build (push) Waiting to run
Driver, after rejecting both experiments: "I cannot scroll right, and the
column numbers have this super white background -- it looks odd, it was to
be invisible... I am not impressed with that design. Use conventional
designs only."

He is right, and the two rejected variants earned it: scroll-to-read hid
the numbers' geometry behind a gesture, and the frozen column needed a
painted backing precisely BECAUSE something could slide beneath it. The
conventional row every chart library ships has neither problem: swatch,
label that ELLIPSIZES at the available width (full name in the row's
existing tooltip), count right-aligned in a fixed-width slot. No horizontal
scroll, no sticky, no background -- the number needs no backing because
nothing can ever slide beneath it.

Measured, both legends: transparent count background, position static,
right-aligned, ONE shared right edge, labels ellipsizing, zero horizontal
scroll. Screenshot read before pushing.
2026-09-23 22:14:33 +00:00
doxa-maps-88655c70
80d5981229 People-group links go to doxa.life from any host; stale layouts retire
Some checks are pending
build-gate / build (push) Waiting to run
THE LINK (Driver pasted localhost:5174/research/pitaguari/): the card's
"Full profile" base defaulted to root-relative /research/, which resolves
against whatever origin hosts the element -- right on doxa.life, wrong on
the dev server, an iframe, or a partner site. Same class as the ../../
child-src bug. Adopted the pattern the shared PeopleGroupDetail already
uses: window.DOXA_SITE_URL override, else the absolute canonical origin.

TWO owners had to move, and the first fix alone measured as a no-op: the
computed's fallback AND the prop's own default -- a non-empty prop default
is always truthy, so the canonical branch was unreachable until the prop
default became ''. Unset must mean unset.

Playwright on a non-doxa origin: all 24 rendered links now
https://doxa.life/research/<slug>, zero relative.

LAYOUT KEY v2: the Driver sees unequal sidebars because his browser
restores a layout saved under the OLD defaults (rail 264), and a restored
value outranks any default by design. Defaults changed materially today, so
the storage key is versioned: everyone starts once from the new symmetric
defaults; a fresh drag re-personalises from there.
2026-09-23 22:10:18 +00:00
doxa-maps-88655c70
8d41a8fdef Selection turns the table bar into a toolbar instead of a flood
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "when I click on a people group the header of the table is flooded
with buttons that overflow and don't make sense" -- and, on one of them:
"it does not need a Clear Selected button in the header because the footer
of the page already gives me that."

THE CLEAR X IS GONE from the header pill. Verified what actually clears
before repeating the premise: the thead master checkbox and the detail
drawer's close both call clearSelection, so the removal deletes a duplicate
trigger, not the capability.

THE FLOOD HAD TWO CAUSES, both measured at 1280 where the middle column is
686px: the centred hint, and -- after hiding it -- the in-flow title
squeezed to 48px and wrapped INTERNALLY ("People"/"Groups" on two lines),
growing the bar to 43px and tumbling the spacer. While a selection exists
the title and count duplicate what the pill and the section header already
say, so all three yield, state-scoped, and return on clear. The title also
gets white-space nowrap unconditionally: a title may never two-line itself.

Playwright, both 1280 and 1440, real dispatched clicks, full round trip:
select -> bar 32px, one line, zero overflowing controls, no clear x;
uncheck -> count 0, title and count return.
2026-09-23 21:46:21 +00:00
doxa-maps-88655c70
d9eca08c44 Chart counts are a real table column, on the Driver's own prescription
Some checks are pending
build-gate / build (push) Waiting to run
"The number column needs to use some type of TABLE css so the numbers
really are set apart as a column, uniform and justified right, so it does
not move when I scroll left and right."

He is right that table layout is the honest tool: display:table guarantees
every row's count cell shares ONE column edge in every browser ever
shipped. The previous subgrid attempt measured correct here but subgrid is
25 years younger than table layout; the table needs no support caveat.
Rows are table-rows, swatch/label/count are cells; the count cell keeps
sticky right:0 with an opaque background so a scrolling label slides under
a pinned column.

Measured, both legends: display table, cells right-aligned, exactly ONE
unique right edge at rest AND while scrolled. And with the rail widened to
296 in the previous commit, neither legend currently overflows at all --
the wider sidebar bought the words their room, as intended.
2026-09-23 21:35:40 +00:00
doxa-maps-88655c70
564af57c4e Symmetric sidebars: the charts rail matches the left sidebar, centring the map
Some checks are pending
build-gate / build (push) Waiting to run
Driver: 'the charts sidebar can be wider so it is the same width as the
left sidebar, which will put the map in the exact centre.' rail 264 -> 296
everywhere the number lives (inline var, both CSS fallbacks, the
composable's DEFAULTS, the doc comment) -- one quantity, every owner moved
together this time.

Measured: facet 296, rail 296, map margins 297 both sides, centred true.
2026-09-23 21:33:07 +00:00
doxa-maps-88655c70
e315487e2b Reclaim the fold: dashboard 849 -> 623, and the people are visible
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "it is a priority to reclaim height... so I can see half of the
first row of the people groups grid." Measured after: shell bottom 623,
first card top 640, half the card inside a 900px viewport -- the faces show.

THE HEIGHT: map 360 -> 260 and shell follows the same row arithmetic
(260 + 32 + 7x26 + 28 = 502); headers slim again (topbar 44 -> 38, stats
26 -> 22). Seven data rows unchanged.

THE ZOOM: a shorter map now shows MORE world, not less. Default zoom
1.45 -> 1.1 opens on the whole sphere, minZoom 1 -> 0.5, and the mercator
"no duplicate world" floor is skipped on the globe -- a sphere has no
second world, so the floor only stole zoom-out range.

TWO MORE SECOND-OWNER BUGS FOUND ON THE WAY, both now dead:
- research-dashboard.vue passed its own defaults {mapH:360, shellH:720} to
  useDashboardLayout, silently overriding the composable's DEFAULTS -- the
  bundle carried BOTH and the stale copy won, so two reclaim passes changed
  nothing. The override is deleted; one owner.
- one grid-template-rows fallback spelled 40svh WITHOUT a space and
  survived the fallback sweep -- at 900px that IS 360, re-pinning the row.

ALSO: the chart count column is now a real GRID column (label 1fr, count
auto, rows joined via subgrid), because per-row flex put each count at its
own row's right edge the moment the legend scrolled -- "less like a column,
more like cards". Measured: one shared right edge before and after scroll.
2026-09-23 21:31:05 +00:00
doxa-maps-88655c70
11bd2bcd5c Religion chart shows high-level religions once; count columns align
Some checks are pending
build-gate / build (push) Waiting to run
RELIGION ROLLUP. "It should not mention Ethnoreligion and Islam multiple
times -- just the high-level religions." The rollup already existed as the
religion_family facet; the donut was simply pointed at the fine-grained
religion facet. One word moves it. Measured after: Hinduism 693, Islam 650,
Ethnoreligion 610, Buddhism 83 -- each named once. The full Sunni/Folk
breakdown still lives in the facet rail's Religion tree. religion_family is
dashboardOnly by design, so a slice click narrows the dashboard but not the
grid below -- documented, and the right trade for a glanceable donut.

COUNT COLUMN. Uniform, right-justified, immovable, in BOTH donut legends:
min-width 3.4em + text-align right on the shared .rd-li rule, on top of the
existing sticky right:0 and tabular-nums. Measured: every count 42px wide,
right-aligned, digits in one scannable column.

Known nit, deliberately left: Religion can show two "Other" rows -- one is
a real family label, one is the topN overflow bucket. Worth a rename of the
overflow bucket later, not a silent merge of different things.
2026-09-23 21:20:59 +00:00
doxa-maps-88655c70
64f1f2bf7b Table fullscreen: native Fullscreen API, so exit and Export CSV are reachable
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "there is no way to exit full screen or export csv when you enter
full screen on the table."

The bar carrying both controls IS inside the wrap, but the overlay used
position:fixed, which inside a shadow root resolves against any
transformed or contained ANCESTOR rather than the viewport. In the cap-5
shell that pinned the wrap to a pane whose top sat above the visible area,
so the bar -- and its exit and export controls -- rendered off screen. The
leaf host has no such ancestor, which is why every leaf measurement passed
while the shell was broken: the two-host rule again.

element.requestFullscreen() is the wheel the platform ships: guaranteed
viewport coverage regardless of ancestor transforms, native ESC, and a
fullscreenchange event so the button state follows every exit path. Two
shadow-boundary details mattered and are documented in the code:
document.fullscreenElement retargets to the shadow HOST (and
host.contains(inner) is false across the boundary), so the sync reads
getRootNode().fullscreenElement; and our ESC handler delegates to
document.exitFullscreen() so all consumers stay in sync. The .is-full
overlay stays as styling hook and API-less fallback.

Verified in the shell with a screenshot read before pushing: native
fullscreen engages, "Exit full screen" and "Export CSV" both visible
top-right, full column headers, ESC exits.
2026-09-23 21:14:29 +00:00
doxa-maps-88655c70
df1ce0feed Collapsing the table gives its space to the map, in page-scroll too
Some checks are pending
build-gate / build (push) Waiting to run
Driver: collapsing the table is supposed to increase the size of the map;
instead it introduced white space under the collapsed bar.

The collapsed template still used 1fr for the map row, and 1fr cannot grow
in an auto-height shell -- no fixed total to divide -- so the freed space
pooled under the bar. The page-scroll collapsed state now derives from the
same shell budget as the open state: map = shell minus the bar, on both the
row and the element. One owner, every state.
2026-09-23 21:06:00 +00:00
doxa-maps-88655c70
a9655e319e One fallback, one owner: the sidebar cap now agrees with the table about the shell
Some checks are pending
build-gate / build (push) Waiting to run
Two rules read --rd-shell-h with DIFFERENT fallbacks: the table keyhole fell
back to 602px (seven rows) while the sidebar cap fell back to 80svh = 720px.
Nothing sets the var at rest, so the sidebars believed the shell was 119px
taller than the table did. The spanning side columns stretched the auto
table row to fit, and the 118px surplus painted as the grey band under and
around the table -- and made divider drags look broken, because the row
would not follow; the slack just moved.

The one-quantity-one-owner rule applies to FALLBACKS too: same var, same
fallback, everywhere. Cap unified at 602px.

Measured through real drags, screenshot read before pushing:
  at rest        gap 1px, 7 rows, table dead-space 118 -> 2px, sidebars -2px of table bottom
  divider up 80  gap 1px, 10 rows, sidebars still flush
  shell +120     gap 1px, 15 rows, shell 733 -> 855, sidebars follow
2026-09-23 21:02:15 +00:00
doxa-maps-88655c70
510af26fb5 Close the grey gap: pin the map ROW to the same var as the map element
Some checks are pending
build-gate / build (push) Waiting to run
In page-scroll the map row was 1fr, which stretches to the implied content
height while the map element stays pinned at --rd-map-h -- the row grew,
the element did not, and the difference painted as dead space above the
table. Reported three times. The row now uses the same var as the element,
so they are one quantity and the slack cannot exist.

This exact rule was applied earlier today and reverted on a false signal --
a probe said no shell, but the real cause was a dead dev server, not the
CSS. Re-verified this time against a live server: map bottom 488, table top
489, gap 1px, seven data rows, with a screenshot read before pushing.
2026-09-23 20:56:41 +00:00
doxa-maps-88655c70
ae01086662 The galaxy the way Mapbox does it: engine fog with stars, black space
Some checks are pending
build-gate / build (push) Waiting to run
Mapbox renders space and the starfield engine-side through the fog API
(documented in their globe guide: stars and atmosphere are customized with
the fog property). Applied their documented example values with one change:
space-color is pure black on the Driver's instruction, star-intensity 0.6.

The container behind the sphere is plain black now, no gradient -- on
Mapbox the engine paints its own space so it never shows; on an engine
without fog (MapLibre) black is the honest fallback.

Verified by reading the rendered screenshot: stars in black space,
atmosphere glow at the limb, vivid blue oceans, the choropleth legible.
2026-09-23 20:54:57 +00:00
doxa-maps-88655c70
3bd2a7bebf Dashboard globe runs on Mapbox Standard: the look the Driver kept naming
Some checks are pending
build-gate / build (push) Waiting to run
Every wording of the ask described Mapbox itself -- the globe space style
with stars, the atmosphere, water like the Mapbox style. Mapbox Standard
renders all of that natively; no found clone matches the original. The
working baked token is already accepted as public by explicit ruling.

One key changed. The MapLibre path (VersaTiles Colorful, keyless) stays
fully built and reversible by flipping provider back.
2026-09-23 20:51:22 +00:00
doxa-maps-88655c70
1fbc29e6e1 Galaxy corrected: day-side Earth in space, not a dark basemap
Some checks are pending
build-gate / build (push) Waiting to run
The Driver, correcting the first attempt: "I don't want a dark basemap, I
didn't ask for one. I asked for a spherical globe with a black background --
the galaxy, like the Mapbox map -- with blue oceans like the Mapbox map, and
the land was not to be black."

The first galaxy tinted dark tiles: black land, recoloured water -- the
night side of Earth. What was asked for is the DAY side seen from space.

So the style entry is now a normal-coloured basemap with no paint overrides
at all (mapbox standard / OpenFreeMap liberty), and the blackness lives
where it belongs: the map CONTAINER behind the sphere, a deep radial space
gradient scoped to [data-projection="globe"] so flat mercator keeps its
neutral ground.

Verified by reading the rendered screenshot before pushing: a real sphere,
blue oceans, normal land, the greyscale choropleth legible on top, black
void behind the globe, and the old caption gone.
2026-09-23 20:42:26 +00:00
doxa-maps-88655c70
1dec28b2d8 Remove the map caption, and make the globe projection reach MapLibre
Some checks are pending
build-gate / build (push) Waiting to run
The caption "UPG choropleth - click a country to filter - click a pin to
select" is gone, along with its now-dead .rd-maptag rule and a comment that
claimed the bottom-left corner was occupied.

GLOBE: the projection default was already 'globe', but the request was not
arriving. Mapbox GL takes setProjection('globe') -- a bare string. MapLibre
v5 takes setProjection({type:'globe'}) -- an object. Passing a string to
MapLibre throws, an empty catch swallowed it, and the map silently fell back
to flat mercator with no console error. That is why it appeared exactly when
the dashboard moved to provider maplibre.

Now tries the object form first, then the string, so one call serves both
engines. The catch stays so an engine with no projection support degrades
rather than breaking the panel -- but it now catches a real unsupported case
instead of masking a shape mismatch.
2026-09-23 20:39:18 +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
378ecb9ae6 UPG-100: lead with Prayer Resources, map second
Some checks are pending
build-gate / build (push) Waiting to run
The first tab a new user meets is now the poster/resources pane, not the
map. That pane already explains what a UPG-100 list is at the top and shows
the posters, so it needs no explanation; a choropleth of zones does. Maps
need not be primary — a familiar list or grid is the on-ramp people use
before they transition into the map.

Renames the tab from "Prayer Lists" to "Prayer Resources".

Pane order lives in at[0], not in array order: buildPaneColumn sorts by
at[0] while ViewSelect renders the array verbatim, so both are swapped
together or the tab strip desyncs from the scroll column. The active view
is now derived from that same sort instead of a hardcoded 'map'.

In the wrapper shell the map pane is resolved by PROFILE rather than by
"pane 0 is the map": under the positional rule, reordering mounted the map
element into the poster pane, and "Show on map" scrolled to the wall you
clicked from. The shell's tab strip also resets to a profile's first view
on switch, since a stale 'map' id is valid in nearly every profile.

ViewSelect keeps each tab on one line (nowrap + ellipsis), ported from the
shell's own mobile rule that the original extraction left behind. Measured
at 390px: "Prayer Resources" wrapped inside its button and grew the strip
45 -> 60px; it now holds 45px at every width from 1280 down to 390.
2026-09-23 20:33:00 +00:00
doxa-maps-88655c70
1d4fe185e3 Full table headers via horizontal scroll, and a full-screen table
Some checks are pending
build-gate / build (push) Waiting to run
Driver, on the people-groups table under the map: "I would like to add a
horizontal scroll bar so that instead of ABBREVIATING the headers, we can have
the FULL headers in the table", and "I would also like to add a FULL SCREEN
button to the table, so that if you want you can open up the table to be full
screen."

These ship together on purpose. The table now shows seven data rows by default
because we are reclaiming vertical space; a deliberately small table is only an
honest trade if the reader can get a big one on demand. The cap and its release
are one change.

HEADERS ARE NO LONGER ABBREVIATED. text-overflow:ellipsis is gone from .rd-th.
Ellipsis destroys information with no way to recover it -- "Engageme..." is not
a column name, and unlike a truncated VALUE it has no menu behind it. Horizontal
scroll preserves it: the full text always exists and is always reachable. This
is the same mechanism already applied to the chart legends (nowrap +
overflow-x:auto), deliberately reused rather than a second invention.

THE FLOOR IS MEASURED, NOT ESTIMATED. Columns are flex:<w> 1 0, which can never
overflow -- flex-basis 0 plus shrink means every column collapses to whatever
the container offers, so scrollWidth always equalled clientWidth and overflow-x
had nothing to scroll. Each column now carries a min-width derived from its own
header. A first pass guessed 7.1px/char, which put the summed floor at 675px
against an 878px scrollport, so it never bound and no scrollbar could appear at
any width. Measured live by cloning each header out of flex flow: worst case is
9.75px/char (Language 78px/8ch), so 9.9 covers every label, +12px reserves the
sort indicator. Summed floor 742px + 30px checkbox.

That floor sits BELOW the 1440px scrollport on purpose. At 1440 the eight
columns genuinely fit and there is nothing to scroll; forcing a scrollbar there
would invent a defect. It binds at 1280 and below, which is exactly where the
truncation being complained about was happening.

ROW WIDTH IS SHARED, NEVER INTRINSIC. An intermediate pass used width:max-content
to create the overflow. It did -- and sized every row to ITS OWN content, so a
row holding "Lao People's Democratic Republic" laid out wider than the row above
it. That is not a table but a stack of independent flex lines: screenshot-
verified ragged, with Country/Region/Language stepping left and right on
consecutive rows while the header, correctly aligned, disagreed with all of
them. The measurement said so too and was read past: scrollWidth was 969 at the
top of the list and 1051 after scrolling, i.e. the table's width depended on
which rows happened to be rendered. Head, rows and footer now share one explicit
width (summed floor as min-width, width:100%), so nothing is intrinsic and the
column grid holds at every scroll offset.

The sticky header stays pinned on the vertical axis only, which is correct: it
must travel sideways with the body or the labels detach from the columns they
name. The footer is a SIBLING of the scrollport, so it is translated in lockstep
from the scroll handler -- otherwise the population total slides out from under
its own column.

FULL SCREEN is CSS pseudo-fullscreen, not requestFullscreen(). The native API
promotes the element to the browser's top layer, detaching it from the cascade
this table is built on, and needs allow="fullscreen" to work in the iframe embed
this dashboard actually ships in. Pinning the wrapper with position:fixed keeps
the element where it is in the tree, so inherited tokens and ancestor rules keep
applying and only the two rules we mean to override are overridden. Esc exits.

The seven-row cap is released with max-height:none !important, and !important is
the honest tool rather than a specificity war: the first attempt lost 0-2-0 to
the shell's 0-3-0 selector and rendered a 900px box containing a 182px table --
the exact failure its own comment warned about. That shell rule is also being
rewritten concurrently, so matching its selector would break on the next edit.

library/components/map-controls/FullscreenButton.vue was read first and does not
fit: it resolves its target as the SHADOW HOST, so pointed here it would
fullscreen the whole dashboard rather than the table; it renders MapControlButton,
a circular control built to float over a canvas; and it depends on vue-i18n,
which this bundle does not use. Making it fit means adding a target prop and a
chromeless variant to a component with seven map consumers to serve one non-map
caller, so this stays local until a second table wants it.

Verified at 1280 and 1440, real browser, live data: headers complete with
text-overflow clip and no cell clipped; scrollWidth 867 vs clientWidth 718 with
the last column's left edge moving 1045 -> 896; rows recycle (abai-sungai ->
deaf-comorans) with the rendered count bounded at 14; zero ragged columns and
zero head-vs-row drift at every scroll offset; fullscreen 244 -> 900px with the
keyhole 182 -> 838 and 45 rows rendered, restoring exactly on exit.
check:fits-screen 17/17.
2026-09-23 20:25:08 +00:00
doxa-maps-88655c70
afe6abe556 Plugin studio: show what a composite is made of, in one panel
Some checks are pending
build-gate / build (push) Waiting to run
A "Composed of" panel now sits under the plugin title, above the live
preview. For a composite it lists one row per directly-nested child:
name, element tag, and a capability badge, each row a link to that
child's own entry. A leaf plugin shows no panel.

Composition is no longer navigation. The rail had carried a collapsible
folder per composite, which re-listed children that already had their
own home — people-groups-grid held six nav nodes, so there was no single
name to call it by when reporting a problem. The rail is back to one
node per plugin in its own capability tier (31 nodes, 31 manifest rows,
none repeated) and the relationship lives on the plugin's own page.

Depth is one. A nested composite shows as a single row and does not
expand its own children; its breakdown is one click away, on its page.

The badge reads the manifest's declared profile type, so retyping a
profile moves its badge with no code change here. A declared child that
is published nowhere is counted as "not published" beside the heading
rather than dropped in silence — big-picture names bp-research-header,
which is an internal part, not a plugin. The list caps its own height:
the layout's header row is auto-sized, so ten uncapped rows would have
taken height from the preview instead of overflowing.

Rows are buttons with descriptive aria-labels, reachable and activatable
by keyboard. No emoji glyph on a row: this box has no emoji font, so
every one rendered as an empty box, and the badge says it better.
2026-09-23 20:21:48 +00:00
doxa-maps-88655c70
a69386582c Reclaim height: seven data rows, slimmer headers, charts scroll sideways
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "we are trying to reclaim height."

TABLE = SEVEN DATA ROWS, stated in ROWS rather than pixels. The old formula
derived the keyhole by subtraction (shell - map - bar - foot), so the row
count was whatever fell out -- measured 300px = 11 rows. Now
calc(var(--rd-table-rows,7) * var(--rd-table-row-h,26px)), so the promise is
the declaration: change the row height and seven rows stays seven rows.
His own arithmetic settles an earlier ambiguity (he had said both 77 and 7):
seven is DATA rows, excluding field, footer and table header.

HEADERS 137px -> 125px: topbar 52 -> 44, stats 30 -> 26, both now vars so a
host tunes them in one place instead of three grid-template-rows that must
agree.

CHARTS scroll sideways instead of wrapping. This is the third pass on that
trade and it resolves rather than reverses it: pass 1 was nowrap + ellipsis +
overflow-x HIDDEN, so labels were truncated and unrecoverable -- the defect
he rejected. This is nowrap + overflow-x AUTO, so the full label still exists
and is reachable. Legend cap 176 -> 120.

Measured at 1440x900: grid-template-rows 44/55/26/476/244, keyhole 182px,
exactly 7 data rows, shell 863 -> 849 with the map gaining 116px.
2026-09-23 20:03:52 +00:00
doxa-maps-88655c70
e5c66272c2 Tighten the vertical legend's row spacing without touching the type
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "the spacing between the rows is too thick, they take up way too much
height -- the padding should be decreased per row."

Row vertical padding 3px -> 1px, min-height 22px -> 18px, section header
padding 8px -> 5px.

Font-size is deliberately untouched at 12px. It is not a lever here: the
Driver previously rejected shrinking it ("super tiny text that cannot be
read"), and the note beside .fr-lbl records that the width for the count came
from the bar and the gutters for the same reason. Only whitespace moved.
min-height 18px still clears a 12px glyph at line-height 1.3, so no descender
is clipped.

Measured live at 1440x900 with four sections open, 69 rows rendered:
  row height 24px -> 20px
  computed padding 1px/1px, font-size 12px unchanged
  rail content height 1966px -> 1424px (542px saved)
2026-09-23 19:58:39 +00:00
doxa-maps-88655c70
02baa2d42b Switch the choropleth from heartbeat-red to the greyscale ramp
Some checks are pending
build-gate / build (push) Waiting to run
Driver ruling: "instead of the red, we need to use the grey scale from grey
to black." The greyscale preset was built earlier at his request as a
swappable alternative; this is him taking the swap.

One config key moves both the choropleth and its legend together, which is
why it was built as config rather than scattered hex. Ramp is
#e8e8e8 / #a9a9a9 / #6f6f6f / #3a3a3a / #060606.

Verified in the shipped bundle: colorRamp "greyscale", the five stops
present, sfc-parse passes.

Worth knowing for the follow-up: the colour research measured the ramp
change as roughly neutral for pin visibility (worst case 1.96 greyscale vs
1.84 red). The real fix for pin contrast is the lightness-conditional stroke
-- dark ring under a light pin, white under a dark one -- which lifts the
worst case to 4.51 and works on either ramp. Not done here; that is its own
change.
2026-09-23 19:57:08 +00:00
doxa-maps-88655c70
ca4343c25d Regions mounts its cap-4 composite; UPG-100 drops its duplicate share and shrinks its dots
Some checks are pending
build-gate / build (push) Waiting to run
REGIONS WAS RENDERING AS A CAP-1. The regions-mfe composite (nested-elements-mfe
over regions-list + region-dashboards, with the shared Select View strip) had
shipped, but the shell entry still named the bare `regions-list` leaf -- the
cap-1 half -- so one flat list rendered and the composite sat unused. The cause
was in the shell, not the regions bundle. Compare the UPG-100 entry, which names
`upg-100-mfe` and is why that tab has tabs.
Measured in the shell after: Select View present, options "Regions List" /
"Region Dashboards".

DUPLICATE SHARE BUTTON. The map carried its own ShareButton while the composite
header already mounts one. Removed the map-level control here rather than in the
shared component, because sharing is per-map policy: Prayer Progress is the map
meant for embedding elsewhere and keeps its own button; a map only ever seen
inside a composite does not need one. Unused import dropped.

DOTS WERE OVERCORRECTED. An earlier pass raised z=1 radius 4 -> 6 to fix "hard to
see". Sound reasoning, wrong amount: it made this the largest-dotted map in the
set (all-people-groups is 2.2 at z=0). Now 5 at z=1, keeping a deliberate step
above the dense map without leaving the family; the halo carries visibility.

Gates: template-literal, sfc-parse, sri all pass.
2026-09-23 19:37:55 +00:00
doxa-maps-88655c70
1cf4e89dab Resolve a composite's child bundles against the tree root, not a guessed depth
Some checks are pending
build-gate / build (push) Waiting to run
Driver-reported console text:
  Could not load <doxa-research-map> from ../../doxa-research-map/doxa-research-map.js

A composite declares children as src: '../../<bundle>/<bundle>.js'. That literal
is right from a one-deep leaf and wrong from a two-deep one, and the generator
emits both:
  .../research-map-plus-grid/index.html          ../../ -> /doxa-maps/          200
  .../research-map-plus-grid/default/index.html  ../../ -> /doxa-maps/rmpg/     404

So the path encoded an assumption about the host's depth that the host does not
control and cannot see. It survived because every gate exercised the shallow
leaf, which is genuinely fine.

StackLayout now derives the tree root from the document's own pathname (up to
and including the /doxa-maps/ segment) and rebuilds the path from there --
depth-independent by construction, so a third leaf depth cannot reintroduce it.
Absolute, protocol-relative and root-relative srcs pass through untouched, and
a missing /doxa-maps/ segment returns the original string, so this can only fix
a path, never break a working one.

Measured, both leaves, real browser with live data:
  one-deep  mapEl=true mapH=792 canvasH=856 0 4xx
  two-deep  mapEl=true mapH=792 canvasH=856 0 4xx   (was 404, map absent)

Also carries source that had been left behind by its own built output:
the cities/gospel legend bounding (shipped in 5008a6f as output only) and the
per-profile config carrier in generate-tree.mjs, plus a new fits-screen clause
that wheels inside every column rather than one.
2026-09-23 19:34:28 +00:00
doxa-maps-88655c70
d97cf9eab5 Say why a tab could not load instead of painting an empty grey pane
Some checks are pending
build-gate / build (push) Waiting to run
The Simple Research tab rendered as a featureless grey rectangle with no
message, no overlay and nothing in the console. It reads as a broken app,
and it hid a plain missing-file condition through four separate reports.

Two silent failure paths produced it, one layer apart:

- doxa-maps-spa swallowed a failed manifest.json fetch. Every tab except
  Big Picture is composed from that manifest (Big Picture alone is baked),
  so losing it leaves each tab with no scriptUrl. mountTab then returns
  early and the pane stays in state 'idle' -- which renders neither the
  loading hint nor the error hint. The manifest fetch now tries the built
  tree location as a second candidate and, if every candidate fails, sets a
  message the pane displays.

- StackLayout's per-section catch deliberately left a failed section's host
  empty so the rest of the stack still renders. Keeping the stack alive was
  right; leaving the band blank and silent was not. A failed section now
  shows which element failed and which URL it came from, and still does not
  abort its siblings.

Tree-relative scriptUrls are also resolved against the manifest they came
from rather than the current document. The two roots are identical in the
built tree and differ elsewhere, which turned every composed tab's bundle
into a 404 against a path that exists nowhere.

Measured, built tree: research-map-plus-grid mounts both children, 4778px
of content, live canvas, zero 4xx -- unchanged. dashboard-plus-grid,
stack-demo and research-map-plus-grid all render with a painted canvas and
no failure cards. Where the bundles genuinely cannot be reached, the pane
now names the element and the URL instead of showing grey.
2026-09-23 19:14:27 +00:00
doxa-maps-88655c70
bca3d58c3e Regions element gets the Select View tabs, shared with UPG-100
Some checks are pending
build-gate / build (push) Waiting to run
The Regions bundle shipped only tab 1, so the Select View switcher the
UPG-100 list shows had nowhere to live. Adds the composite that carries
it over two panes -- Regions List and Region Dashboards -- plus the
Region Dashboards tab itself, which summarises a region or a country
from the same live endpoint and the same rollup tab 1 uses.

Clicking a region heading or a country row on tab 1 now forwards that
selection and swaps to tab 2 with it applied.

The switcher itself is extracted into library/components/ViewSelect.vue
rather than copied a third time: it had two implementations already, in
the app shell and hand-copied into upg-100-mfe. It takes the views and
the current id as props and knows nothing about maps or panes, so the
placement stays with each header. UPG-100 now renders from the shared
component and is unchanged on screen -- same 45px height, 13px labels,
8px radius.
2026-09-23 19:08:00 +00:00
doxa-maps-88655c70
5008a6ff5f Bound the cities and gospel legends so the map controls stay clear
Some checks are pending
build-gate / build (push) Waiting to run
Both maps drew their legend as a full-width strip (left:8px right:8px --
measured 1264px of a 1280px map, 98.75%), which ran underneath the floating
control rail. MapToolbar sits at inset-inline-end:3px with 32px buttons, so
any box reaching right:8px intersects it at every viewport; the rail's higher
z-index kept the buttons clickable, which is why the defect survived three
reports as "looks broken but works". Gospel ran under four of five buttons,
cities under two, the difference being only legend height.

The legend is now a bounded, left-anchored panel imitating the shared
SemanticTreeLegend: left:8px with width var(--map-legend-width,380px). Being
bounded on the left, it cannot reach the right rail at all -- a geometric
guarantee rather than a tuned z-index. Below 480px it keeps a 46px gap so the
rail still clears. Measured after: legend 380px of 1280 (29.7%), zero
intersection with all five buttons, 857px clearance, and a dispatched click on
the top control landing on button.mcb via composedPath. Mobile at 390px: 336px
wide, 11px clearance, zero intersections. Both files kept byte-identical.

Both maps also showed the shared "Tap a coloured dot" hint while shading
country polygons and drawing no dots at all -- a hint pointing at something the
reader cannot see. PinHint documents a pinHint:false opt-out, but the
standalone leaf page built its config from a hardcoded object, so no profile
could reach that key. <profile>.meta.json now carries an optional `config` map
merged into the leaf's BASE_CONFIG, with generator-owned keys reserved and the
carrier stripped before the manifest is written so its published shape is
unchanged. Both maps opt out; the hint is gone from both.
2026-09-23 19:07:13 +00:00
doxa-maps-88655c70
5520b32173 Size the UPG-100 dots for a hundred marks, not twelve thousand
Some checks are pending
build-gate / build (push) Waiting to run
At world zoom the dots were radius 4 with a 1.2px halo -- the same scale
all-people-groups uses for ~12,379 marks, where density itself carries the
signal. This map draws ONE HUNDRED, alone on a whole planet, so the same
radius reads as almost nothing.

Radius 4 -> 6 at z=1 (+50%), tapering back to the original 9 by z=8 where
geography supplies the context. Halo widened 1.2 -> 1.8 in step, so it stays
a frame rather than a smudge: a 1.2px ring around a 6px dot is
proportionally thinner than around a 4px one, which would have spent the
size increase and lost the contrast that made small dots findable at all.

Verified in the shipped bundle: new interpolation present, old values gone,
0 data 404s, legend mounts, canvas at real height.
2026-09-23 18:47:36 +00:00
doxa-maps-88655c70
1bf42e2110 Let the dashboard element size to its content when it is page-scrolling
Some checks are pending
build-gate / build (push) Waiting to run
:host carries height:100%, which the fixed-viewport host needs and a
page-scroll host must not have: in a tall scroller, 100% is thousands of px,
so the element claimed a large empty tail below its own content. The shell
inside already switched to height:auto for this reason; the host never did.

A host cannot be matched by a class on its own descendant, so the pageScroll
flag is reflected outward as a data attribute and :host([data-page-scroll])
switches to height:auto with the min-height dropped.

Labelled UNPROVEN in the file, deliberately: dashboard-plus-grid imports this
as a plain Vue component rather than mounting the custom element, so the rule
is not reachable from there -- and that composite was measured stacking
correctly regardless (dashboard 0..864, grid 864..3031, gap 0). The rule
applies where research-dashboard is mounted as its own element.
2026-09-23 18:41:07 +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
cb07c556e1 Plugin studio: collapsible composition sections per composite bundle
Some checks are pending
build-gate / build (push) Waiting to run
Every composite bundle (one whose manifest entry carries `composes`) now
gets its own collapsible folder in the rail, with the composite itself as
the top row and its composed children underneath, grouped/labelled the
same way the existing capability-tier rail already is. Reuses the
existing folder collapse/expand mechanism (VerticalTabsLayout, the same
one Experimental already uses) rather than inventing a second
interaction. Driven entirely from manifest.json's composes edges, so a
future composite needs no code change to appear. A child can legitimately
appear in more than one section (e.g. people-groups-grid nests into four
different composites) — that duplication is the point, not a bug.
2026-09-23 18:18:04 +00:00
doxa-maps-88655c70
eaa2c35286 Give the dashboard its own height handle, separate from the map/table split
Some checks are pending
build-gate / build (push) Waiting to run
Dragging the map/table divider in page-scroll mode was also resizing the
whole element, because --rd-map-h alone answered both "how is the map/table
space split" and "how tall is the element" -- Driver: "you improperly bound
that control to the separator line between the map and the table... that's
not the request I was making to resize the height of the page."

--rd-shell-h is now the independent total-height budget; the new bottom
handle (.rd-rz-shell) is the only thing that writes it. The existing
map/table divider still drives --rd-map-h, but only as the map's share of
that fixed budget, so it redistributes between map and table without
changing the sum. Table/facets/rail max-height all re-derive from
--rd-shell-h so the sidebars keep spanning exactly map+table at every drag.

Also closes a 28-30px sidebar gap the re-derivation exposed: the table
budget only ever subtracted the header bar's height, never the summary
footer's, so facets/rail undershot the table's real bottom edge.
2026-09-23 18:16:30 +00:00
doxa-maps-88655c70
5d87bd9db1 Poster print options: two per line, clear of the blue hover outline
Some checks are pending
build-gate / build (push) Waiting to run
The fieldset is anchored bottom:-26px and grows UPWARD as content wraps, so
at one-button-per-line (three lines tall) its top reached into the image --
which is where the blue hover outline lives. Widened the row from 82% to 96%
so two labels share a line.

Measured on all three poster cards at 1440x900: 2 button lines, height 50px,
top 3px BELOW the image bottom, zero overlap with the outline.

Includes the tree regeneration so every leaf's SRI matches its bundle
(a scoped BUNDLE= build alone leaves leaves stale -- check:sri PASS 45/45).
2026-09-23 18:10:35 +00:00
doxa-maps-88655c70
2a8318433f Remove a second stray backtick from research-dashboard shadow-CSS
Some checks are pending
build-gate / build (push) Waiting to run
Shadow CSS is emitted inside a JS template literal, so a backtick in a comment
terminates the string and the bundle stops compiling. Second instance in this
file; the first one already cost a broken build this session.
2026-09-23 17:25:55 +00:00
doxa-maps-88655c70
0ebb3e34ac Sidebars span map AND table; facet rail opens with its own scrollbar
Some checks are pending
build-gate / build (push) Waiting to run
The side columns stopped at the map's bottom edge, leaving the people-groups
table outside them. Cause was a single rule capping both side columns at the
map's height in page-scroll mode:

  .rd-shell.rd-page-scroll > [data-region="facets"],
  .rd-shell.rd-page-scroll > [data-region="rail"]{ max-height:var(--rd-map-h); }

The grid already spanned both rows correctly ("facets map rail" / "facets table
rail") -- this max-height was overriding it, which is why earlier passes that
adjusted the rail's CONTENTS never moved the bound. Replaced with min-height:0,
letting the grid do the bounding it was already doing.

Facet rail sections people/place/language/religion open by default again, with
.fr-body's own overflow-y:auto providing the scrollbar.

Measured, both hosts, 1440x900, real browser:
  dashboard-plus-grid (page-scroll): facets 140..2158, table bottom 2158, delta 0
  research-dashboard (fixed-viewport): facets 140..900, table bottom 900, delta 0
  facet rail scrolls: scrollHeight 1966 in clientHeight 708
2026-09-23 17:24:43 +00:00
doxa-maps-88655c70
4bed933001 Keep map scroll-zoom enabled now that the page-scroll map row is bounded
Driver: "I can't zoom in and out on the map. The scrolling is causing that
to change." Scroll-zoom was force-disabled whenever an ancestor scrolled, a
fix from when the dashboard band had no bounded height and a wheel over the
map was the only gesture available in the lower two-thirds of the page.
--rd-map-h changed that precondition: the map row is now a fixed, bounded
height, so the cursor naturally leaves it once a user scrolls past. Verified
on the live mapboxgl.Map instance (not a pixel-diff proxy): zoom now moves
1.45 -> 1.95 on a wheel over the map in the cap-5 shell, standalone
dashboard-plus-grid leaf, and standalone research-dashboard leaf, with no
change to pane scroll position in any of them.
2026-09-23 16:57:36 +00:00
doxa-maps-88655c70
f53dbcea72 Drop the redundant verb from the print labels, and clear the artwork
Some checks are pending
build-gate / build (push) Waiting to run
TWO SMALL FIXES, both Driver-reported by eye.

LABELS. The fieldset's legend already reads "Print Options", so "Print a" in
every button was three redundant words competing for the row's width, and the
printer emoji beside that legend added no information. Labels are now the object
only -- "UPG-100 list" / "UPG prayer poster" / "card deck" -- which fits two on
the first row and one on the second, as asked.

The aria-label keeps the full sentence ("Print the <list> UPG card deck"): a
screen-reader user does not get the legend's framing as reliably as a sighted
user reading down the fieldset, and a control announced as "card deck" alone is
ambiguous. Short visible label, descriptive accessible name.

POSITION. The options row overlapped the poster artwork by 12px. My first
measurement said that 12px was letterboxed white margin, because the image is
object-fit:contain -- but these posters fill the card's WIDTH, so contain
letterboxes at the SIDES and the bottom 12px is real artwork. Reading the
screenshot showed the legend printed across the poster's own footer line.
Geometry said white space; the image said otherwise.

bottom:-14px -> -26px. Overlap measured 12px -> 0px, and the screenshot now
shows the poster's footer text fully legible with the legend on the card's white
edge below it. The negative offset is kept rather than moving the row into flow,
because the tape header at top:-11px uses the same overhang idiom and the 26px
row gap was sized for that pairing.
2026-09-23 16:54:54 +00:00
doxa-maps-88655c70
1a00cac2f7 Fix regions-list clone: real chevron SVG, correct title/link weights, grid indent
Some checks are pending
build-gate / build (push) Waiting to run
Second pass against Driver feedback. Measured getComputedStyle() on the live
doxa.life/regions page and diffed against ours:

- Chevron was a text glyph (&rsaquo;), not DX-life's real SVG icon
  (public/assets/icons/arrow-chevron.svg#chevron-up). Ported the actual path
  geometry and rotation.
- Region title was bold + letter-spaced + uppercase; the live page measures
  weight 400, letter-spacing normal, no text-transform — Bebas Neue's own
  glyphs carry the bold/condensed/caps look, DX-life applies nothing on top.
- Country links were weight 400; live page measures weight 500.
- countries-grid was missing the page's global list indent (35.2px
  padding-left, measured live).
- region-card__footer had a stray ~19.5px bottom margin from the <p>'s
  user-agent default; DX-life's rule is margin-top only.

Colours/fonts/radii/spacing here are an intentional exact clone of DX-life's
own design (Driver's instruction) — not this repo's material-light chrome
tokens. Do not "fix" this back to our house palette.
2026-09-23 16:36:35 +00:00
doxa-maps-88655c70
b71b4bbfa3 Fix UPG-100 poster print-options row to wrap instead of stretch
Some checks are pending
build-gate / build (push) Waiting to run
.pbtn had flex:1 + white-space:nowrap, forcing every print button to
stretch full-width and forbidding its label from wrapping — one very
wide button per line. Now .pfs wraps and .pbtn sizes to its own
content, so short labels sit two per row when they fit and long ones
wrap to their own line without stretching or clipping.
2026-09-23 16:35:45 +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
79b12baee9 Add prayer-walk-planner: experimental polygon prayer-walking map
Some checks are pending
build-gate / build (push) Waiting to run
Draw/delete/batch-select polygons on a keyless MapLibre+OpenFreeMap basemap,
print a per-zone prayer-walking plan (title + coordinates) via an in-browser
print dialog, everything persisted to localStorage only — no backend, per
the JAMstack constraint. Root SFC mounts directly as the custom element
(no ProfileLoader — needs no token/dataSource/profile-switching), so
defineExpose() bridges addPolygon/deletePolygon/selectPolygon/print/list
straight onto the DOM element for agent-driven use; window.PrayerWalkPlanner
mirrors the same API for discovery. Researched keyless satellite imagery
(Esri World Imagery, NASA GIBS/MODIS, USGS orthophotos): none qualified —
Esri restricts free use to OSM tracing only, GIBS/MODIS tops out at 250m/px
(useless for a neighborhood walk), USGS is US-only — so this ships the
keyless vector basemap already proven in library/styles/registry.js.
2026-09-23 16:22:56 +00:00
doxa-maps-88655c70
4a8b872891 Slim the research-dashboard's KPI stats row from 42px to 30px
Some checks are pending
build-gate / build (push) Waiting to run
The Driver asked for the three dashboard headers to read slimmer so the
people-groups grid peeks below the fold as a scroll hint. Measured each
header's actual content height: the KPI strip's content is 18px tall
(a single baseline row) inside a grid row hardcoded at 42px, so 24px was
dead space around it — the largest untouched margin of the three. The
topbar and research-filters bands are already at a Driver-fought floor
from prior cards, so they're left alone. Tokenized as --rd-stats-h since
the literal appeared in three grid-template-rows declarations that must
stay in sync.
2026-09-23 16:21:47 +00:00
doxa-maps-88655c70
2772486663 Replace poster-wall's Download/Prayer-List buttons with three real print actions
Some checks are pending
build-gate / build (push) Waiting to run
Driver, 2026-09-23: the UPG-100 list card should offer three PRINT options, each
opening the browser's own print dialog on a rendered preview (never a silent file
download), in this order: Print a UPG-100 list (the point of the app), Print a UPG
prayer poster, Print a UPG card deck.

- printList100() and printCardDeck() reuse printPoster()'s print-preview window
  mechanism (extracted to openPrintWindow()) rather than inventing a second one.
- Both fetch real per-people-group records via the same normalize.js +
  selector-evaluator.js pipeline the sibling map profile already uses, so the map,
  the list print and the card deck can never disagree about which PGs belong to a
  list. Found and fixed along the way: 12 of 27 lists carry a `pg_slugs` selector
  that selector-evaluator.js intentionally throws on (a documented special case in
  upg-100-list.vue) — my first pass called it unconditionally, which silently ate
  every click on the India/China lists before reaching window.open(). Caught only
  by driving a real click and polling the HUD, not by reading the code.
- Card deck is new code (no card/deck generator exists in this repo or in
  root-dx-life to port — searched first); one card per people group, print-paginated
  with `break-after:page`, styled after the poster-wall's own tape/card vocabulary.
- Removed the now-orphaned exportPoster()/downloadPacket()/download() — the raw
  Download button and the "Prayer List: coming soon" placeholder were exactly the
  two buttons the Driver objected to.

Verified live: instrumented window.open/window.print via Playwright, clicked all
three buttons for real. Each opens exactly one window and calls print() exactly
once (List: 1/1, Card Deck: 1/1; Poster: 1 window opened, print() correctly does
NOT fire when the full-res poster 404s in this default, bundlePosters:false tree —
the documented degraded state, confirmed by screenshot).
2026-09-23 16:20:04 +00:00
doxa-maps-88655c70
873a99d1a6 Pay the agent discovery tax once, in the repo
Some checks are pending
build-gate / build (push) Waiting to run
MEASURED: agents produced 21 report docs totalling 2,079 lines of prose and 27
scratch probe files against a handful of product commits. The cause is the spawn
prompts, which is to say me: each carried ~15 environment rules, several cited
lessons, a three-clause invariant, a mandatory screenshot read and a
what-I-could-not-verify section. Per report that is 7-12 headings and 10-16
verification mentions. The prompt made PROVING the job and BUILDING the side
effect.

Two costs compounded. Every agent re-derived the same environment from scratch
-- which port, is the tree settled, does stale SRI block the script, does this
box have WebGL. And several spent that discovery budget, hit a build queue they
could not control, and died on a session limit before committing; their work
survived only because it was found untracked (one 511-line gate was a `git
clean` away from being lost).

This file pays that tax once. Spawn prompts can now be short: what to build,
which files you own, and the one assertion that would falsify it.

It also states the rule that was missing: ship a commit and PUSH, then write two
sentences -- not the reverse. A long honest report attached to no commit is the
failure mode. And it sizes verification to blast radius explicitly, because
over-verifying is a real cost rather than a virtue.

check:doc-scripts passes, so every command quoted here exists in package.json.
2026-09-23 16:05:47 +00:00
doxa-maps-88655c70
6d9ace4cc7 Clone DX-life's /regions design exactly into regions-list.vue
Some checks are pending
build-gate / build (push) Waiting to run
The Driver's exact-clone complaint: our element kept DX-life's markup shape but
reinvented the styling — lighter header green, small radius, generic font, and
no footer link at all. This replaces every design value with DX-life's own,
resolved from their SCSS tokens and cross-checked against getComputedStyle()
on the live https://doxa.life/regions page.

Key finding: DX-life's <html> sets font-size:10px as its rem root, so every
rem token in their CSS resolves to 62.5% of the raw number (their 3.5rem
border-radius is 35px, not 56px). Ported: dark #3b463d header band, 35px
radius, bold/condensed/uppercase title (Bebas Neue approximated — their font
file has no CORS header so it can't be cross-origin loaded), and the missing
"See <Region> dashboard" footer link bottom-right with its permanent underline.
Also fixed a real wrap bug the port introduced: the countries-grid bullet was
flex/baseline-aligned and stranded the (count) beside line 2 on multi-line
country names; now a plain inline row with an absolutely-positioned dot.

Colours/fonts/radii here are an intentional, Driver-directed exception to this
repo's material-light chrome tokens — the clone wins for this element.

Full property-by-property table: _qa-cycle/02-builder-cycle/delegation/RESULT-regions-design-clone.md
2026-09-23 16:01:58 +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
3b08fb5962 Stop Big Picture showing the same total twice
The composite's own header renders "<N> shown" from @groups-filtered, and the
nested grid rendered its own "Total: <N>" line 139px below it. Measured live:
"2107 shown" and "Total: 2,107" — the same number twice, both above the fold.

That is the redundant-header shape the standing rule forbids (the hint lives in
the EXISTING header, never a second one). show-total=false here matches the
pattern already applied in dashboard-plus-grid.vue:279 for the same reason.

The grid keeps its own total when embedded ALONE, where it is the only count on
screen — the prop is host-controlled, not removed.

Work started by a delegated agent that hit its session limit mid-task; verified
the cited sibling pattern exists before committing. check:sfc-parse and
check:template-literal pass.
2026-09-23 15:32:28 +00:00
doxa-maps-88655c70
4c74bd717e Restore three-section tab structure: Dashboards / Prayer Mobilization / Gospel Research
Big Picture now lives in its own Dashboards section alongside a renamed Simple
Research (was Research Map, recategorised as the mobile-default dashboard).
Prayer Mobilization narrows to UPG-100 List + Prayer Progress; the renamed
Gospel Research section takes Cities with Internet + Gospel Resources.

The device rule changes shape to match: every row now stays in the rail on
every width (nothing is hidden any more), Big Picture is disabled rather than
removed on mobile with a "desktop only" badge and an explanatory toast on
click, and the auto-selected tab swaps to Simple Research on mobile / Big
Picture on desktop.

Added a loud, module-load-time assertion that no tab id is declared twice
across the curated sections, on top of the existing silent runtime dedupe --
this is the gate the duplicate-key regression needed and didn't have.
2026-09-23 15:21:33 +00:00
doxa-maps-88655c70
c48d1d401a Make the legend show the colour the map actually paints
Some checks are pending
build-gate / build (push) Waiting to run
The swatch rendered the raw bin hex. The map never paints that colour: the
choropleth composites at fill-opacity 0.78 over Positron's #f2f3f0, so every
swatch showed a colour that appears nowhere on the map.

Measured, chip vs actual fill:

    #f4cec4 -> #f4d6ce   dE  4.07
    #ecaea1 -> #edbdb2   dE  7.53
    #e08678 -> #e49e92   dE 11.93
    #ce544b -> #d6776f   dE 17.13
    #b10010 -> #bf3541   dE 21.14

The error grows with the bin, and at the top it is the size of a whole bin step:
the gap between the top two bins is 25.45 as painted, while the legend's own
error on the heartbeat bin is 21.14. A reader comparing the two darkest swatches
was comparing two colours neither of which was on the map -- a strong candidate
for the report that "60-149 red is the same red as the heartbeat zone",
independent of the ramp itself.

Composited in the legend rather than dropping the map's opacity: 0.78 is
deliberate, it lets country borders and labels read through the choropleth, and
this file's own contrast notes are measured at 0.78. The legend was the half
that was lying.

Verified: all five bins produce the measured target values, and non-hex and null
inputs pass through untouched. check:one-palette, check:synced-css,
check:template-literal, check:sfc-parse all pass.
2026-09-23 15:20:30 +00:00
doxa-maps-88655c70
f48cad8dbb Widen the red ramp, add a greyscale ramp, and gate cap-4 interoperability
Some checks are pending
build-gate / build (push) Waiting to run
THE RAMP. The Driver reported that 60-149 reads as the same red as the 150+
heartbeat bin. The old ramp was mathematically even and he was still right --
that tension was the finding, not a contradiction. Measured in plain sRGB
distance the top step (60.0) looked like the LARGEST in the ramp; that
measurement was lying, because RGB distance is not perceptual. Re-measured as
deltaE the old steps were 15.5 / 15.8 / 16.7 / 17.7 -- nearly flat, while chroma
collapses at the dark end so hue and saturation stop carrying information there.

The new ramp deliberately ACCELERATES toward the dark end: 14.1 / 17.9 / 21.4 /
24.9. Top-bin separation 17.67 -> 24.94, a 41% improvement on exactly the pair
he named, and total lightness range widens from 37.2 to 49.0 L*.

A named greyscale ramp ships alongside it, grey to black, as he asked ("just in
case i need to swap from red to black scale"). Its steps measure 22.8 / 22.4 /
22.4 / 22.8 -- near-perfectly even, every step well above the ~10 threshold for
clear distinguishability. It is an OPTION, selected by name; the red ramp
remains the default, so no prior decision is silently reversed.

Bin EDGES are untouched -- check:heartbeat passes, which replays the bin
assignment and fails if a default moves.

THE INTEROP GATE. verify:interop drives the actual compiled cap-4 composites in
a real browser against live data, closing the gap verify:gate1 leaves open. On
its first run it PASSED both desktop directions: a facet-rail click narrowed the
grid to "Showing 1,309", and selecting three rows moved the camera from
28.000,18.000@1.45 to 73.666,13.162@2.48 -- read back off the live map, not
inferred. It asserts the grid's live count line rather than card count, because
card count is a proven false-negative (always 24).

It ships with verify:interop:self-test, a two-mode negative test -- grid-prop
freeze and camera freeze -- each required to fire its own combination-named
failure. A harness that has only ever reported PASS is unproven.

Gates: sfc-parse, template-literal, one-palette, synced-css, mjs-parse,
heartbeat, doc-scripts all pass.
2026-09-23 14:53:51 +00:00
doxa-maps-88655c70
1a782109c3 Give the shell pane a scroll, and drop the engagement filter
TWO CHANGES, both Driver-reported.

1. THE SHELL WOULD NOT SCROLL, so the grid under the Big Picture dashboard was
unreachable. Measured before: window.scrollY stayed 0 and .vtl-content reported
scrollTop 0 against max 4155 -- it HELD 4155px of content and would not move; 0
grid cards reachable. The same composite on its own leaf page scrolled fine
(scrollY 3712, 9 cards), so this was a HOST defect, not broken interop.

Cause: every mounted tab got position:absolute;inset:0;height:100%
unconditionally. That is right for a fixed-viewport map, which fills the pane
exactly. It traps a StackLayout composite -- dashboard-plus-grid and
research-map-plus-grid are natural-height by design, and pinning them to inset:0
forces ~2600px of real content back into one viewport with nowhere to go, while
BOTH the element and its container were absolutely positioned with no scroller.

Fix follows the bounded-middle shape already proven here: the PANE stays bounded
and gains overflow-y:auto (plus overscroll-behavior:contain so a bottomed-out
wheel does not chain to the document); .dms-slot becomes position:relative with
min-height:100% so it can grow past the pane. Removing the pane's bounds instead
was measured previously and produced a worse defect -- the grid landed far below
the fold.

Verified in the shell on the built tree: pane overflow-y auto, scrollTop 0 ->
4155 of a 4155 max, grid cards visible 0 -> 8, .rd-grid present, 0 failed
requests. Read the screenshot as well as the numbers: the people-groups grid
renders with photos, country and affinity-bloc labels, Full-profile buttons and
Load more. Fixed-viewport map tabs are unaffected -- they fill the pane exactly,
so scrollHeight equals clientHeight and overflow:auto paints no scrollbar.

2. ENGAGEMENT AND STATUS FILTERS REMOVED on instruction. engagement_status is
gone from the dropdown row (seven remain, all fitting the first row on the
measured 178px pitch) and the Status section is gone from the facet rail. The
facet accessor, the table column and the FACET_TO_PROP mapping all stay, so the
grid can still filter on it and nothing downstream breaks -- only the two
offered controls are withdrawn.

Gospel Access stays. It is measurably a different question: over 2,108 live rows
the two fields disagree on 25 -- 12 groups are Engaged at gsec rung 1 and 13 are
Unengaged at rung 2.

Gates: sfc-parse, template-literal, one-palette, synced-css, facet-cascade,
facet-one-field all pass.
2026-09-23 14:41:58 +00:00
doxa-maps-88655c70
f7cee9640e Add regions-list element: tab 1 of the Regions Dashboard plugin
Scrollable list of WAGF regions and their countries, refactored from the
DX-life /regions page into a standalone custom element. Clicking a region
or country emits a selection event instead of navigating, so a host shell
can drive a second tab from it.
2026-09-23 14:24:04 +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
8b3d638de7 Correct two measured claims in the filter-row note
Both were assumptions written as measurements, and one of them kept a P0 card
shut.

(1) "GSEC is the underlying field ... engagement_status is the label derived
from it." They are independent fields. Over 2,108 live rows they disagree: 12
groups are "Engaged" at gsec rung 1, and 13 are "Unengaged" at rung 2. Because
the note claimed the dropdown already covered "the phases of engagement", the
phases looked built when they were not.

(2) "168px on a 200px pitch, so eight occupy 1600px and MUST wrap." The pitch
was never measured. It is 178px: seven dropdowns fit the first row ending at
x=1250 and the eighth wraps, at both 1440 and 1280, with no horizontal
scrollbar. The conclusion held; the arithmetic behind it did not, which would
have misled the next author sizing this row.

Gospel Access stays out of the dropdown list because the Driver named eight, not
because a ninth would overflow -- the layout would tolerate one. A five-rung
ordinal reads better in the tree rail regardless.

Comments only; no behaviour change.
2026-09-23 13:24:06 +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
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
ad223d3b1a Read the pin colour and stroke from config instead of hardcoding them
The selection ring beside these already read cfg.map.selectedColor while the pin
fill and its stroke were literals, so half the pin styling was parameterised and
half was not, for no reason. Both now follow the same shape: a config read with
the current value as the fallback.

This matters beyond tidiness. The heartbeat threshold was made a variable so the
dashboard can point at a dataset that is not these 2107 people groups, and a
different corpus can want a different pin -- the orange was chosen against THIS
choropleth, and its legibility is a property of the pair, not of the colour.
Leaving the pin fixed would have made the dashboard reusable in its data and
fixed in its palette.

Verified: with no config the built map reports circle-color #ef6c00 and
circle-stroke-color #ffffff, byte-identical to the literals they replaced.

Not verified: that an override takes effect. Two runtime injections left the
defaults in place, which is expected rather than broken -- like SEL_COLOR, this
is read once when the element upgrades, and I could not prove my injection beat
that moment. The config channel is proven by check:heartbeat, but statically,
never through a live attribute. The comment says so rather than implying a
capability nobody has exercised.

The white stroke is noted as load-bearing: it carries the pin over the dark bins
where the orange falls to about 1.03 contrast, and it is what stopped the pins
reading as eyes. A host that sets it to the fill colour brings that back.
2026-09-23 05:19:44 +00:00
doxa-maps-88655c70
9fff7c4af2 Let the poster print-preview window actually open
The preview branch was unreachable for every user. window.open with `noopener`
returns null by specification -- severing the opener relationship is precisely
what the flag does, so there is no handle to hand back. The document.write that
builds the print sheet ran against that null, so every click fell through to the
fallback: the raw .webp in a tab, no print dialog. The card claimed it opened an
in-browser print preview with a dialog.

Measured in a default browser, no headless flags: window.open('','_blank',
'noopener') returns null, the identical call without the flag returns a usable
handle. I had attributed the null to this headless box last tick, and that was
wrong -- it is what the flag means, everywhere.

noopener stays on the fallback, which only navigates to a URL and never needs a
handle, so the protection is kept where it costs nothing. The window we write
into carries same-origin markup we generate, so the opener reference it regains
is our own.

Verified after rebuilding: the click now opens ONE window titled "Doxa UPG-100
poster — Northwest India" carrying the @page rule, where it previously opened two
and wrote into neither. That window then shows "Poster not available yet." from
the img onerror, which is correct and documented: the full-size posters are 42MB
and deliberately not in the default tree, so the preview degrades honestly
instead of hanging. Set bundlePosters or point assetsBase at a host serving
posters/ to see the image.
2026-09-23 04:31:43 +00:00
doxa-maps-88655c70
2b5ff1328b Show the eighth filter, and wrap the row instead of scrolling it
The Driver named eight filters -- people, region, block, country, language,
religion, prayer, engagement -- and the rail rendered seven. engagement_status
was already fully built: a label and getter, the Status section in the tree, and
the Engagement column in the table. It had simply never been registered in the
dropdown list, so the one piece missing was a single entry.

Adding it exposed the constraint that goes with the count. The dropdowns are a
fixed 168px on a 200px pitch, so eight need about 1428px. At a 1440 viewport that
just fits; at 1280 the row reported scrollWidth 1414 against clientWidth 1252 --
a horizontal scrollbar, which is exactly what the Driver asked not to see.

The row now wraps rather than scrolls. Wrapping is the right lever because the
fixed 168px width is itself a Driver decision: a flexible width was what made the
dropdowns jump as a selection changed. The justify-content pair is kept, not
simplified -- its own comment records that `safe` degrades space-evenly to
flex-start rather than pushing the first item to an offset a scroller can never
reach. That hazard belonged to the scroller and costs nothing now, so it stays.

Verified on a settled tree at three widths. 1440, 1280 and 1024 all render eight
filters across two rows at identical offsets, with the row reporting no
horizontal overflow and every filter fully inside the viewport.
2026-09-23 02:49:38 +00:00
doxa-maps-88655c70
d4a8184ad7 Match the tab radius to the rest of the app, and open on the tab you were last on
The vertical tabs were a 999px stadium on a 48px row. That was a real earlier
instruction -- imitate the YouTube active row -- and it shipped, but measured
against the product it is the only thing shaped that way: the collapse button is
8px, the filter toggles are 8px, and 8px is already the most common radius in
the layout file. They now read var(--vtl-radius,8px), so a consumer has one knob
and the three surfaces cannot drift apart. The 48px Material row height is
untouched. The YouTube rationale stays in the comment as history with an explicit
note not to restore the pill from it, because a superseded rationale that still
reads as current is how a reversed decision gets quietly undone.

The .vtl-cap badge keeps its pill. The complaint was about the ratio of radius to
size -- a 16px chip at 999px is a chip, a 48px row at 999px is a stadium.

The studio opened on nothing and said "Choose an element from the list." It now
resolves a tab in priority order: the URL hash, since an explicit shareable
request should beat a remembered one; then localStorage, so a reload is not a
fresh start; then the first row, so the pane is never empty. This lives in the
studio rather than the layout because VerticalTabsLayout is controlled via
v-model:active and deliberately holds no selection state.

Storage is keyed by pathname so two shells on one origin do not fight over one
key, every access is wrapped because localStorage throws in private mode and an
exception in setup would blank the whole subtree, and the key is only written
after the row is confirmed to exist so a bad key cannot poison the next load.

Verified in the built shell on a settled tree: first load auto-selects with no
hint and stores the key; clicking big-picture moves the selection and updates
storage; a reload restores big-picture; and a deliberately poisoned key falls
back to the first tab and rewrites itself rather than rendering an empty pane.
2026-09-23 01:03:15 +00:00
doxa-maps-88655c70
3c594386b4 Give the dashboard a page-scroll layout so the grid beneath it is reachable
The Driver could not scroll down to the people-groups grid. The cause was a
layout mismatch rather than any one property: research-dashboard is a
fixed-viewport app whose table scrolls internally, and the cap-4 composite
stacked it above a grid and expected the page to scroll, pinning the band to one
screen so height:100% would resolve. At a 900px viewport that left the table a
269px window over 54834px of rows, so a wheel anywhere in the lower third landed
on the table and never reached the page.

research-dashboard now accepts layout:page-scroll through profileConfig, the
channel ProfileLoader already exposes as the profile-config attribute. A prop
would not work here: the registered custom element is ProfileLoader, which
renders the profile with no props bound, so a declared prop type-checks, builds
clean and never fires. Default is unset, so every existing embed is unchanged.

In that mode the shell height is auto, the map row carries an explicit height so
it cannot collapse, and the table keeps an internal scroll bounded at 62svh.
Both extremes were measured and both are wrong: at 34% of a screen the table is
a keyhole that captures every wheel event, and released to overflow:visible it
renders all 54834px and puts the grid at y=57777 -- reachable in principle,
useless in practice. A tall bounded panel is neither.

Verified in the cap-5 shell on a settled tree: wheeling over the map and
wheeling below it now both move the page 1500px, which is its entire scroll
range, and the grid lands on screen at top 851. The two gestures agreeing is the
real proof -- the broken state read 300 against 2311, the signature of something
consuming the wheel. The standalone leaf is unchanged: no page-scroll class,
900px shell, internal table scroll intact.
2026-09-23 00:53:29 +00:00
doxa-maps-88655c70
b40fd5b48d Take overscroll-behavior back off the table, and record what the wheel actually hits
The previous commit set overscroll-behavior:contain on .rd-grid to stop it
swallowing the wheel. That is backwards. contain prevents a scroll which bottoms
out in an element from chaining to its parent, and chaining to the parent is the
behaviour that would let the page keep moving once the table is exhausted.
Measured before and after: the symptom did not move at all, because a table with
54834px of content never bottoms out in the first place.

It is left at the default.

The diagnosis that produced it was reached by geometry, and geometry was the
wrong instrument. getBoundingClientRect placed this element at y=673-880, well
below the y=420 gesture, which appeared to exonerate it; a deep shadow walk had
earlier reported its scrollTop moving, which appeared to convict it. Both are
readings of state at some moment, not of what the browser dispatched. A
capture-phase wheel listener reading event.composedPath() settles it: at y=420
the path is rd-td, rd-row, rd-grid, rd-table-wrap. Use the event path when
geometry and behaviour disagree.

The underlying bug is still open and is a layout mismatch rather than a CSS
property: the research dashboard is a fixed-viewport grid that gives its table
34% of the band and legitimately owns the wheel inside it, hosted in a composite
that expects the page to scroll past it. That needs a design decision, not a
fourth attempt at a one-line fix.
2026-09-23 00:40:33 +00:00
doxa-maps-88655c70
285ba39c31 Stop the data table from swallowing the wheel, and grade the gesture honestly
The Driver could not scroll down to the people-groups grid. Three separate
diagnoses blamed the map, including one of mine that shipped. Measured in the
cap-5 shell: a ten-tick wheel with the cursor over the dashboard moved the outer
pane 300px while .rd-grid's own scrollTop went 0 to 2606. The wheel was being
consumed by the table -- a virtualized grid whose scrollHeight is 54834px inside
a ~207px window. That is ordinary scroll-chaining: a nested scrollport eats wheel
events until it reaches its own end, and with 54 thousand pixels of table it
never reaches the end. The Mapbox canvas sits above the cursor and was never
involved.

overscroll-behavior:contain does not stop the table scrolling; it stops it
chaining, so the remainder now reaches the pane.

The gate that should have caught this had two defects of its own. It measured the
gesture the Driver makes and then asserted on a different number, so it passed at
2611px while his gesture moved 300. When that was fixed the threshold was written
as "moved less than 200px" -- a figure picked without comparing it to the value
already measured as broken, which was 300, so it still passed. The threshold is
now derived from a measured baseline: the same gesture performed lower down the
page, where it is known to work. Two identical gestures on one surface should
move it comparably, and there is no constant left to rot.
2026-09-23 00:31:35 +00:00
doxa-maps-88655c70
92fae9937d Stop the pin comments claiming a navy pin and a 7:1 floor that no longer ship
Three comment sites still described the pre-heartbeat product in the present
tense, which is the confidently-wrong failure this file warns about elsewhere:

- the measured contrast table cited the old pale-coral hexes
- 'WHAT IT IS NOW. Fill #1a237e ... under a white ring' described a pin that
  no longer exists, and listed 'reverting to orange' as a live rejection when
  the Driver has since asked for orange
- the constraints list asserted 'navy pin >= 7:1 on every bin. PASS', which
  reads as an acceptance criterion someone would try to restore

An orange pin cannot reach 7:1 against a red ramp at any tuning, so the
criterion is replaced rather than quietly lowered: effective visibility is
max(orange disc, white stroke) per bin, worst 1.83.

Also answers the standing 'MEASURE the pale end' note: the two bands swapped
roles. The disc is strongest on the pale bins where the ring is weak, and
collapses on the dark bins where the ring takes over, so both must stay.
2026-09-22 22:49:22 +00:00
doxa-maps-88655c70
34cbb39a5c Make the heartbeat-zone threshold a variable, not a constant
The choropleth's bin edges were a 5-stop literal (5/20/60/150/Infinity) in
DashboardMapPanel.vue. Those numbers are not a property of choropleths; they
are a property of THIS dataset — ~2000 people groups in focus. Pointed at a
200-group dataset, a 150 cutoff puts every country in the bottom bin and the
map renders uniformly pale: no crash, no error, correct code, meaningless map.
A dataset assumption wearing the costume of a constant.

The top band is the HEARTBEAT ZONE — the countries holding the most people
groups. Its edge now comes from dashboard.config.js as map.heartbeatThreshold,
alongside the other per-dataset facts (facets, columns, kpis). Re-pointing the
dashboard at a new dataset is a config edit, not a source edit.

Splits the old fused BINS array into the two independent facts it always was:
BIN_COLORS (measured against the navy pin, dataset-independent) and the edges
(pure dataset assumption). Fused, moving a threshold meant restating every hex
— silently reverting the contrast work — and restyling meant restating every
edge. Set either alone now.

Setting heartbeatThreshold alone rescales the three lower edges by their
original ratios, so the ramp keeps its shape (long tail of small counts, short
head of large ones) at any dataset size. Legend labels derive from the edges,
so a moved threshold cannot leave a stale "150+" on the legend. Opt-in
'quantile' mode derives the edges from the data actually loaded; it is NOT the
default, because 150 is a deliberate number for this dataset and a derived
default would silently change today's map.

Two bugs found and fixed while measuring:
- Edges could land at 1, creating a bin no count can enter (colorFor tests
  `count < upTo`): the ramp painted 4 of its 5 colours while the legend
  advertised 5. Appeared at threshold 40 and again in quantile mode.
- profileConfig merged shallowly, so a host passing the single key it cares
  about — {map:{heartbeatThreshold:40}} — replaced the whole map object and
  dropped mapStyle:'paper'. The one override a host is most likely to write
  was the one that broke the basemap. `map` now merges one level deep; every
  other key stays shallow.

No defineProps: the registered custom element is ProfileLoader, which renders
the profile with no props bound, so a prop declared on the profile would read
as configurable and never fire. The reachable attribute path is ProfileLoader's
existing profile-config JSON, verified end-to-end.

Default rendering is unchanged, verified by measurement rather than eyeball:
bin assignment over the 114-row committed fixture (59 countries) plus a 17-point
count sweep is identical before and after, with and without config supplied.

internal/check-heartbeat-threshold.mjs executes the resolver from the live SFC
and asserts the bins MOVE when the config moves — a grep for "150" would both
false-positive (it is the legitimate default) and false-negative (a
re-hard-coding at 200 sails through). Its --self-test re-hard-codes the ramp in
memory and requires the gate to reject it.
2026-09-22 22:25:38 +00:00
doxa-maps-88655c70
2ec6c00e68 Stop the map from swallowing the wheel, and grade the gesture the user makes
syncScrollZoomToHost decided whether Mapbox should keep scrollZoom by sampling
the layout at 0, 1200 and 4000ms and then never asking again. That is a race,
not a sync: the cap-5 dashboard tab lazy-mounts on activation, so at every one
of those instants the scrollport ancestor had not reached its final height. The
map kept scrollZoom enabled and ate the wheel. Wheeling over the map moved the
grid 300px; wheeling below it moved 2611px. The cursor is over the map, so the
people-groups grid under the dashboard was unreachable.

The condition is layout-dependent, so observe layout instead of guessing when it
settles: a ResizeObserver on the map container, the scrollport that owns the page
scroll, and body, plus a resize listener. Every event that can flip the answer now
re-asks it.

verify-shell-scroll already measured this. It computed movedByMap for the gesture
over the map, then asserted only on movedTotal, which sums in a second gesture
below the map -- so it reported PASS at 2611px while the real gesture moved 300px.
It now asserts on the number it was built to measure. Worth remembering as its own
failure family: a gate that measures the right thing and grades a different
variable, which fails honestly but never on the condition it exists for.
2026-09-22 22:06:59 +00:00
doxa-maps-88655c70
9533cca050 Land the prayer facet and the scrim helper
Some checks are pending
build-gate / build (push) Waiting to run
The prayer filter the Driver asked for, first in the dropdown order as he
specified. Keyed on people_praying, a live per-row pray-tools field already
read by three shipping consumers, and it reuses getPrayerLevel plus its LABELS
from the prayer-progress colour strategy rather than re-deriving the split --
so the filter can never disagree with the colour of the pin it filters to.

It is a real facet in config.facets with a get() accessor, which is the part
that matters: useDashboardShell builds FACET_ACC from config.facets alone and
filteredRows FAILS OPEN on an unknown key, so a facet declared only in an
adapter would render a working dropdown, land a chip, and filter nothing,
silently.

Also lands library/utils/viewportScrim.js, shared by DataTable,
SemanticTreeLegend and PosterDialog for the modal-scrim fix -- one helper
rather than six copies of the same workaround.

Committed by the coordinator rather than the authoring agents, same reason as
ad6da54: verified work sitting in the working tree is how the shell-scroll fix
was lost earlier today.
2026-09-22 21:50:32 +00:00
doxa-maps-88655c70
ad6da54674 Checkpoint the swarm's in-flight work
Some checks are pending
build-gate / build (push) Waiting to run
Five lanes had 465 insertions across 18 files sitting uncommitted, some for
15 minutes. That is the state that silently lost the shell-scroll fix earlier
today: a verified fix existed only in the working tree, a second agent's edit
to the same file overwrote it, and the reverted version got committed and
described as the fix.

Nothing here is finished and no card is being closed on it. This is a
snapshot so the work survives an overwrite, taken at a moment when every file
had been quiet 15s or more, so no agent was mid-write.

Verified coherent before staging rather than committed blind: template-literal,
mjs-parse, sfc-parse, no-orphan-gates, kpi-unit, composite-reg, script-flags,
quoted-scripts and backlog-status all PASS. check:synced-css fails on
PeopleGroupDetail.vue and doxa-simple-map.vue only -- the same two files and
the same counts as before the swarm started, both untouched by any agent, both
already carded.

Two untracked files included because both are load-bearing: internal/
check-facet-one-field.mjs is wired into package.json with a self-test, and
library/utils/viewportScrim.js is imported by DataTable, SemanticTreeLegend
and PosterDialog -- a shared helper for the modal-scrim fix rather than six
copies of the same workaround.
2026-09-22 21:42:18 +00:00
doxa-maps-88655c70
f33f7afb1c The map must not make lostness look good — green ramp -> ashen clay
The choropleth ran the product ACCENT up the lostness axis. Bins went pale green ->
saturated green as the count of UNREACHED PEOPLE GROUPS rises, so the deepest, richest
green marked the places with the MOST LOST PEOPLE. And #92b195 is not an arbitrary hue:
it is --brand in 48 places, the selected-tab underline, the active-row marker — the
colour this product uses to mean GOOD/SELECTED.

DRIVER: "it does not make sense for the tiles to be green because green means good while
it is not good to be lost, and the darkest spots are where the most lost people are at...
it should not make it look green and nice that these nations are so lost."

The vision is explicit that FAR means "the farthest, 99.9%-lost places... eyes to see
where the Kingdom isn't, and be SENT there". The dark end of this ramp is not a high
value — it is where people are who have never heard. A palette tuned for comfort
anaesthetises the exact response the map exists to provoke. Beautiful is still the goal:
the artwork of a LAMENT, not of a garden.

=== TWO MEASUREMENT FINDINGS, both of which changed the answer ===

1. THE HANDED-OVER CANDIDATES FAIL. EMBER (said 7.04) and CLAY (said 7.03) were
   composited over PURE WHITE. This panel sets style `paper` -> OpenFreeMap Positron,
   whose background layer paints rgb(242,243,240) = #f2f3f0. On the ground that really
   paints: EMBER 6.86, CLAY 6.84 — both BELOW the 7:1 floor; old green 7.09, only just
   over. Confirmed rather than guessed: compositing over #ffffff reproduces 7.04 / 7.03
   / 7.29 exactly.

2. THE LEVER WAS NEVER DARKNESS. The brief framed this as structural — "a hue that reads
   as grave must go DARK, and darkness is what kills contrast". But WCAG contrast follows
   relative luminance, which LIGHTNESS dominates; CHROMA is nearly free. Solved across
   hue 30-50 deg at chroma 0.06-0.12, every restrained candidate lands at 7.15-7.20 — the
   muted ones measure the SAME as the saturated ones. "Lament, not fire alarm" cost
   nothing in contrast. Nobody had separated the two variables.

=== WHAT SHIPS: ASHEN CLAY ===
OKLCh hue ~36-41 deg (the deficit family), chroma held deliberately low (0.013 -> 0.060)
so it reads as ash and dried earth, not terracotta or alarm-orange. The 6-step scale runs
from the product SURFACE #f3f3f1 (= NO_DATA, so the ground is unchanged and lightness
still carries the ORDER) down to the darkest endpoint the pin floor allows on the real
basemap; the 5 DATA bins are the steps below the surface. Interpolated in OKLab so the
lightness intervals are perceptually even.

  NO_DATA #f3f3f1 11.91
  #eee3df 10.82 · #e8d4cd 9.82 · #e2c5bc 8.90 · #dcb5aa 7.99 · #d6a699 7.20

THE THREE CONSTRAINTS, each verified not asserted:
  1. navy pin #1a237e >= 7:1 on every bin at the real fill-opacity 0.78 — worst 7.20.
     PASS, and an IMPROVEMENT on the green it replaces (7.09) and on both candidates.
  2. no red-vs-green dependence. Simulated deuteranopia and protanopia: bin luminances
     stay strictly decreasing, so ORDER survives — read by lightness, never by
     discriminating a hue from green.
  3. accent #92b195 absent, and not even NEAR: it sits at OKLCh hue 148 deg and the
     nearest chromatic bin is 106 deg away. Checked as a hue DISTANCE, not an exact-hex
     match — a near-miss reads as a rendering artefact, which is worse than an obvious
     clash because nobody files it.

Also measured, and never checked before: buildPaint() repaints at 0.92 for in-filter
countries and 0.12 for dimmed ones. Worst pin at 0.92 is 6.53 (old green 6.40 on the same
path) and at 0.12 is 11.06. The 0.92 path is the true worst case for EVERY ramp including
the old one and sits below 7 — a PRE-EXISTING condition this change improves rather than
introduces, recorded so it is measured rather than rediscovered and blamed on the hue.

Second-order numbers re-measured rather than left stale: border #6d766e now 4.23 / 3.84 /
3.49 / 3.16 / 2.84 / 2.56; white ring vs ground 1.11-1.84 (its real job, dot-from-dot
separation vs the navy pin, is unchanged at 13.24); legend swatches 1.26-2.15 on white.

BUG-PINS-READ-AS-EYES-CREEPY verified still held: exactly one circle layer on rd-bubbles,
two colour bands (navy fill + white stroke), no third band, rd-bubble-halo absent. Not
touched, and no band reintroduced.

Repro: _qa-cycle/01-ideation-research/scratch-lament-ramp-contrast.mjs (all ramps, three
targets, both CVD sims), scratch-lament-ramp-solve.mjs (the solve), and
scratch-lament-ramp-swatch.mjs (renders the swatches so the choice can be looked at).
2026-09-22 21:26:24 +00:00
doxa-maps-88655c70
5d12841fe7 Make the charts rail a tab strip, with Gospel Resources disabled
Some checks are pending
build-gate / build (push) Waiting to run
The Driver asked "a long time ago" that the chart become a tab so Gospel
Resources could sit beside it, with Charts selected by default. The rail had a
plain "Charts" heading.

Builds the half that is buildable. The container needs no gospel data and no
ruling; what the second tab ENCODES does -- he described it as the same facet
breakdown re-measured on a different axis, "people groups by their gospel
resources availability", and three decisions are still open: the official
lostness number, key-less versus a registered Joshua Project key, and the UI
shape. Guessing any of them ships a wrong picture that looks right, which is
this codebase's most expensive failure mode.

So the second tab renders DISABLED rather than hidden, carrying its reason in
the title attribute. A hidden tab is indistinguishable from an unbuilt one; a
disabled tab that names what it waits on is a status report the Driver sees
every time he looks at the rail.

The strip inherits .rd-h's exact typography -- weight 700, --fs-md, uppercase,
.5px tracking -- so the rail's visual weight is unchanged and this reads as the
same header gaining an option, not as new chrome. The selected tab uses the
accent underline the facet rail already uses for an active row, so one visual
language covers both rails. .rd-h is kept but was verified unused in this file
and everywhere else under research-dashboard/.

Verified the backtick rule that has broken this build three times: zero
backticks inside the useShadowStyles literal (chars 5459-18249); the one in a
JSDoc comment sits outside it. check:template-literal and check:sfc-parse pass.
2026-09-22 20:19:32 +00:00
doxa-maps-88655c70
c959ed6323 Load the RTL text plugin from one place, and in the composite too
Some checks are pending
build-gate / build (push) Waiting to run
The cap-4 composite bakes research-dashboard's .vue directly, so it never runs
that bundle's entry file and has to repeat everything the entry does. It
repeated Pinia, i18n and the colour strategies. It did not repeat this one.

Measured in the shipped bundles:
  research-dashboard.js ..... 1 occurrence of mapbox-gl-rtl-text
  doxa-research-map.js ...... 1
  dashboard-plus-grid.js .... 0

So Arabic, Hebrew and Persian labels rendered reversed inside the cap-5 shell
and correctly standalone. Not cosmetic here: Arab World has 99 rows and
Persian-Median 49, and a reversed label is a wrong NAME for exactly the groups
this map is about. It failed silently -- no error, no warning, just mirrored
text that reads as a font problem to anyone who does not read the script.

Fixed as a shared library function rather than a fourth hand-copy, which is
what the composite-registration card asks for: one definition, two callers,
nothing to drift. Idempotent by construction, since Mapbox throws on a second
setRTLTextPlugin in a session and that throw is swallowed.

Four of the five entry side effects fail silently; only provide('profileModules')
throws, and that is the one safe to omit because a baked composite does not use
ProfileLoader. So "it renders" is no evidence the entry's work was done.

Found by the map-beauty agent, which I verified independently before acting
rather than taking the bundle counts on report.
2026-09-22 19:55:58 +00:00
doxa-maps-88655c70
00bf294d41 Compact the pane-scroll comments, keeping both declarations
Some checks are pending
build-gate / build (push) Waiting to run
An agent collapsed the multi-line notes on .ps-pane and .vtl-host into
one-liners. Both declarations survive in both synced copies -- overflow-y:auto,
overscroll-behavior:contain, and --profile-min-h:0 -- and check:shell-scroll
still passes on the built tree: 2611px scrolled, grid on screen, no map-tab
scrollbar.

Committing straight away rather than leaving it in the working tree. These are
the exact two files where the scroll fix was silently lost last tick: it
existed only uncommitted, a gate passed against those bytes, and a second
agent's edit overwrote them before anything reached git.

Worth noting a false alarm in my own check: grepping for a formatting-
sensitive string reported one copy where there are two, because the compaction
left them with different whitespace. The semantics are identical, which is why
check:synced-css compares property maps rather than text.
2026-09-22 19:18:11 +00:00
doxa-maps-88655c70
35de2f443a Neutralise the dvh floor on hosted panes so a map tab keeps no scrollbar
Some checks are pending
build-gate / build (push) Waiting to run
Restoring the pane scrollport exposed the other half of the same fix, also
lost: check:shell-scroll passed the grid assertion and then failed with "a MAP
tab grew a scrollbar (131px overflow: 900/769)".

131 is exactly 900 minus 769. A hosted map ships
.profile-loader { height:100%; min-height:100dvh } and the two resolve against
different bases -- height against the pane, which sits inside a .ps-stage inset
by the studio chrome, and the dvh floor against the viewport. The floor wins,
the element is taller than its container, and overflow-y:auto faithfully draws
a scrollbar on a tab that should never have one. Making a pane scrollable is
only correct if it stays a no-op for fixed-viewport elements.

The floor is deliberate and must stay for the standalone leaf, where it fixes
the opposite bug: min-height:100% against a body with no definite height
collapses to 0. So it is neutralised on the hosted subtree instead.

Sizing the host is not enough, and this is the part that had gone missing.
.profile-loader lives inside the map's SHADOW ROOT, where a host-page selector
cannot reach it. Inherited properties can, so ProfileLoader reads
min-height:var(--profile-min-h, 100dvh) -- already in the file at line 282 --
and the pane now actually sets it to 0. Three comments described this
declaration; none of them was the declaration.

Not overflow:clip, which bounds the map correctly and also bounds the
scroll-stack composite whose content is 3380px against a pane-height host, so
the grid goes unreachable and the gate returns to 0px scrolled. One blanket
rule cannot serve both.

Both synced copies. Committed before re-verifying.
2026-09-22 19:09:38 +00:00
doxa-maps-88655c70
043b40ddbd Give the shell pane its scrollport back
check:shell-scroll went back to "wheel moved 0px, grid UNREACHABLE" with
2586px of grid mounted. The cause is not a later edit undoing a committed fix:
git history shows .vtl-host never had overflow-y:auto in any commit. Seven
commits, all `position:absolute; inset:0`.

The fix only ever existed in a working tree. An agent edited the file live,
the gate passed against those uncommitted bytes, and a second agent editing
the same file overwrote them before anything was committed. Every observation
was true and none of it was durable. Worse, 762953a was committed while the
tree was in the reverted state and described as landing the scroll fix.

Restored on .vtl-host and .ps-pane, both synced copies of each, and the reason
is now in the file rather than in a session transcript: the composite reports
scrollHeight 2838 against clientHeight 769 here, 2069px of mounted grid with
nowhere to go, while the document measures 900 === 900 so a wheel anywhere has
nothing to move. It is a host gap, not a plugin bug -- the same composite is
perfect standalone because its generated leaf gives the document the scroll.

The comment says the line has been lost once and points at the gate, because
`inset:0` looks like the simpler form and someone will reach for it again.

Committing before re-verifying, deliberately. A gate result is only as durable
as the bytes it ran against, and this bug is the proof.
2026-09-22 19:04:21 +00:00
doxa-maps-88655c70
762953a054 Land the swarm's dashboard work: colour, rails, cascade, scroll
Some checks are pending
build-gate / build (push) Waiting to run
Sixteen source files from five agents, all verified against source gates
before commit: template-literal, sfc-parse, kpi-unit, composite-reg,
facet-cascade, one-palette, script-flags, no-public-binds, no-orphan-gates and
quoted-scripts all pass. public-hygiene reports 0 findings.

Colour. The choropleth ramp is now derived by interpolating the product's own
tokens (surface #f3f3f1 to accent #92b195) rather than adding a sixth grey, and
that fixes a pincer the earlier research had not named: on the old grey-to-black
ramp the navy dot and the white ring failed at OPPOSITE ends, so no fixed pin
colour could win. Worst-case dot contrast moves 1.13 to 7.29. Second-order
breaks fixed with it -- the country outline would have measured 1.08-1.82 as
white on the pale fill, so it is #6d766e at 2.59-4.34, and the legend swatch
border.

Religion colours no longer key by array index. colorFor takes no index at all;
colour comes from the registry by facet plus value. All 31 distinct live
religion labels now match the map, 0 mismatches. Tree-rail grey rows 142 to 0
against the live 2,107-row API.

Cascade. Clearing a parent facet now clears its descendants, in the selection
bus rather than in any component, because every clear path already funnels
through setFilter/clearFilters -- a component fix would have fixed one button
and left four. The hierarchy is derived from config.facetRail[].drill, so it
cannot drift from the tiers the rail renders. Place turned out to be three deep
(region, block, country), not two.

Rails. Tree rows wrapping 18/66 to 3/66; the tree rail is now the wider of the
two at 296 vs 264, with the map cell unchanged because both pairs sum to 560.

Also lands the shell scroll fix and the facet-cascade gate with its committed
fixture -- 73 sampled rows, no network, so the assertion that reproduces the
empty map runs on a fresh clone instead of silently skipping.
2026-09-22 18:57:39 +00:00
doxa-maps-88655c70
2f5cf655f4 Draw the pin as one circle with a white stroke, not three colour bands
Some checks are pending
build-gate / build (push) Waiting to run
The pins read as eyes. The cause is structural: two stacked circle layers
produced, outward from the centre at z1, navy disc r=1.6, white field r=3.4,
navy stroke 0.6px. Dark centre, light middle, dark rim -- pupil, sclera, iris
outline. A comment in the file already called it "a navy/white/navy sandwich"
without noticing what a sandwich of those bands draws.

Concentric rings sharing a centre are the pattern the visual system fuses into
one object, which is why a bullseye is named after an eye. The inner disc sat
at 47% of the halo radius at z1, the proportion that reads as a pupil rather
than a dot with a border, and worst when zoomed out -- where a world map lives.

The outer navy band is the specific culprit: it closes the iris. Removing it
leaves the same colours reading as an ordinary pin, so this deletes a band
rather than changing a colour. Both Driver rulings survive: blue dots, white
rings, one fixed colour at full opacity.

One layer with a stroke is also the documented Mapbox idiom --
circle-stroke-width draws outside circle-radius, so a single layer already
gives disc-plus-ring, and the old code hand-rolled that and collected a third
band for the trouble. Stroke now scales with zoom, the documented way to stop a
ring swallowing the fill at low zoom. Cartographic practice agrees: Axis Maps
and the ICA Commission both put casings on symbols that OVERLAP rather than a
global opaque halo, and Esri names halos the biggest cost in marker drawing
performance -- paid here on a second full circle layer per point.

Research and sources in
_qa-cycle/01-ideation-research/RESEARCH-pin-design-why-they-read-as-eyes.md

Not yet verified: no hardware WebGL here, so the appearance is unproven. The
pale end of the ramp needs measuring, since the outer band was what gave the
white ring a hard boundary on any ground, and a perception complaint needs the
Driver's eye rather than a contrast ratio.

Also removes the table header caption "Filtering or selecting above updates
this list". Its centring history is kept in a comment: it records a reversed
judgement, not a mistake -- v1 put it in a second header, it was moved to the
existing bar on request, and centred by absolute positioning because flex
spacers landed it 55px off centre. All correct, and it still had to go: it
narrates the one behaviour on this surface that needs no narration.
2026-09-22 18:46:16 +00:00
doxa-maps-88655c70
a11ec14852 Print each KPI percent sign once
Some checks are pending
build-gate / build (push) Waiting to run
The dashboard rendered "99%  % UNREACHED" and "1%  ADOPTED %". kpiText returns
k.value unchanged for kind:'pct' and that value already carries its percent
sign, while the labels in dashboard.config.js carried a second one.

Each half is correct alone. A label reading '% unreached' is a reasonable
label; a formatter returning a ready-formatted percentage is a reasonable
formatter. Only the composition is wrong, and neither file mentions the other,
which is why it was found by reading the rendered screenshot rather than by
reading either source.

check:kpi-unit asserts no kind:'pct' label contains a '%'. The value owns the
unit because it is produced by the formatter that knows the number; the label
says what is being measured.

Parses the config as text rather than importing it -- the research-dashboard
module graph reaches library/colors/_registry.js, which uses import.meta.glob
and throws under plain node. Text parsing keeps it hermetic enough for
build:unlocked. Comments are stripped first, because the config now documents
this defect right above the fixed line and a naive scan fails on the
explanation of the fix.

It also fails when it finds no pct entries at all, rather than printing a
green PASS: if the config is refactored so the parse stops matching, silence
would read as success forever.

Negative-tested: restoring either label fails by name with exit 1.
2026-09-22 18:28:53 +00:00
doxa-maps-88655c70
5e6bed7d7a Hand the wheel back when a scrollable ANCESTOR owns the scroll
Some checks are pending
build-gate / build (push) Waiting to run
Mapbox calls preventDefault on every wheel over its canvas. The panel already
knew that and disabled scroll-zoom when the page could scroll -- but it asked
whether the DOCUMENT scrolls:

    canPageScroll = document.documentElement.scrollHeight > innerHeight + 4

Inside the cap-5 shell the document is pinned to one screen and an ancestor
ELEMENT owns the scroll. Measured on the dashboard-plus-grid tab at 1600x900,
walking the composed tree from .pgg-root upward:

    div.stl          oy=visible  ch=769  sh=2838  over=2069
    section.ps-pane  oy=auto     ch=769  sh=2838  over=2069   <- real scrollport
    div.ps-stage     oy=hidden   ch=769  sh=769
    document         sh=900      ch=900                       <- "nothing scrolls"

So the condition computed false, scroll-zoom stayed enabled, and the map ate
every wheel with 2069px of grid mounted below the fold.

Proven at the event level rather than inferred: the wheel at (800,600) lands
on canvas.mapboxgl-canvas, and .ps-pane.scrollTop stays 0 after a real
mouse.wheel at both y=600 and y=860, while a programmatic scrollTop = 800
moves it to 800. The scroller was never broken; the gesture was consumed
before it arrived.

That is why three earlier fixes to the containers were each correct and
changed nothing -- they made the page scrollable, and the wheel still never
reached it. The honest question is "is there a scrollable ancestor", not "does
the document scroll"; the document is one possible answer and in an embedded
host it is the wrong one.

The ancestor walk hops shadow boundaries via parentNode.host, because
parentElement stops at one and every plugin here is a compiled custom element.

Browser verification still pending: the build queue is draining several
agent-requested rebuilds, so check:shell-scroll has not yet run against a
settled tree carrying this change. The fix is confirmed present in the
minified bundle.
2026-09-22 18:18:22 +00:00
doxa-maps-88655c70
e1d27cb476 Record which kind of verification closed each card
Some checks are pending
build-gate / build (push) Waiting to run
A card was closed on an agent measurement while the bug was still visible to
the Driver, and the card had no field in which that disagreement could live.
The board said done and the product said broken, and nothing in the data knew.

Adds fields.verification (agent|human|both) plus verification_kind
(visual|state) and a conflict flag, back-filled across 179 closed cards, and
a gate that refuses an anonymous close.

The gate does not require human sign-off. Gating the board on human review was
tried and it stalled. What it forbids is losing the distinction, so the
question "which of these did a human actually look at?" stays answerable.

The visual/state split encodes the asymmetry the Driver named: human
verification is more reliable on visual bugs, agent verification on non-visual
ones. A headless probe reads geometry, not perception -- it can measure a menu
at dTop=4 and still be blind to it feeling wrong. So a visual card closed on
agent evidence is a hypothesis; a state card closed on agent evidence is a
result. The counts are reported separately.

Negative-tested three ways: stripping a tag, claiming human verification with
no quote, and marking a conflict card done each exit 1 by name.
2026-09-22 17:00:26 +00:00
doxa-maps-88655c70
9f1f657209 Expand a branch under its own parent, even when a filter is active
Some checks are pending
build-gate / build (push) Waiting to run
Expanding a node that had just been selected pushed its children to the bottom of the
tree instead of under the node. The Driver isolated it precisely: clicking only the
arrow always worked, and clicking the row first and then the arrow did not — which
pointed at the filtered path rather than at the expand logic.

The re-admission of filtered-out rows walked the unfiltered baseline and appended
anything the baseline did not recognise. A child revealed by expanding a selected node
is exactly that: it never existed in the unfiltered snapshot, so it had nowhere to go
but the end. With no filter active that path is never taken, which is why the arrow
alone behaved.

It now walks the current rows and splices the missing ones back at their anchors, so
live order is preserved and the baseline is only consulted for what is absent.

Verified three ways: expanding alone puts the first child at index one; selecting then
expanding also puts it at index one, one level below its parent; and a filtered rail
still keeps its shape, going from sixty-six rows to sixty-seven with thirty-five dimmed
in place and none lost. A ten-step random walk left one hundred and fifteen selectable
rows and no page errors, and Clear All returned the rail to sixty-five.
2026-09-22 16:45:03 +00:00
doxa-maps-88655c70
d79f01590f Put the CSV header on the first line so the export opens as a table
Some checks are pending
build-gate / build (push) Waiting to run
The export carried its provenance comment above the header, and every spreadsheet
importer treats line one as the header — so the scope sentence became the column names
and the real header became the first data row. A single-column line above a sixty-eight
column table is what "glitched" looks like on screen.

Three other explanations were plausible and all three are wrong, measured before
anything changed: the file is comma-separated, not tab-separated, so the tabs in the
report were terminal rendering rather than bytes; the escaper is genuinely RFC-4180, and
embedded commas, quotes and newlines all survive a strict parser round trip; and
injection-prefixed cells were already neutralised. Only the position was wrong, and
chasing the other three would have produced churn and no fix.

The provenance line moves to the end rather than away. It records which filters produced
the file, which is the difference between a dataset and an anecdote, and a trailing
single-column row lands as one populated cell instead of destroying the column mapping.
The field counter that read line two moved with it — left alone it would have counted
the first data row, usually the same number, so it would have looked right and been
wrong.

A new check parses the export the way a spreadsheet would, which no existing gate did:
forty-six checks and not one read the file. It extracts the shipped escaper rather than
restating the rule, so it cannot agree with itself while the product disagrees.
Negative-tested by putting the comment back on top.
2026-09-22 16:30:36 +00:00
doxa-maps-88655c70
6b29e146df Let the deepest two tree tiers reach the grid
Some checks are pending
build-gate / build (push) Waiting to run
Cluster and People Group changed the dashboard correctly and could never move the grid,
because the fields behind them were not in the grid's fetch. They are real fields, unlike
the two family axes, which are derived and have no row field to filter against at all —
those stay unmapped rather than being aimed at a lookalike and quietly showing the wrong
rows.

Verified against the live data before mapping, since a wrong mapping is worse than no
mapping: both fields are present on 2,106 of 2,107 rows in the same value-and-label shape
as the axes already wired, with 194 and 1,736 distinct values, no collisions once
normalised, and no case where one entry's label matches another's code. Codes and names
never overlap, so either vocabulary can be accepted safely.

Measured end to end: selecting the Telugu cluster narrows the grid to 93, which is
exactly the number of rows the API holds for it.
2026-09-22 16:22:21 +00:00
doxa-maps-88655c70
56dbb6770d Let the wheel reach the page when a map is only part of it
Some checks are pending
build-gate / build (push) Waiting to run
The people-groups grid under the dashboard could not be reached by scrolling. Every
earlier investigation looked at section heights and overflow, because that is what the
documented fix addressed — but the cause was the map itself: Mapbox binds the wheel for
zoom, so the document never saw the event. Measured before: fourteen wheel ticks left
scrollY at zero and the first grid card exactly where it started.

Scroll-zoom is now disabled only while the page has somewhere to scroll, so a map that
is the whole page still zooms on wheel and a map stacked above a grid gets out of the
way. Measured after: scrollY 1549 and the first card moved from 897 to -652.
2026-09-22 16:03:33 +00:00
doxa-maps-88655c70
ec012f45b6 White rings on the pins, one place for selections, and two gates that told the truth
Some checks are pending
build-gate / build (push) Waiting to run
Pins keep their navy but gain a white ring and a radius that shrinks as you zoom out, so
neighbouring dots separate instead of merging into one blue mass. The Driver asked for
rings rather than clustering, explicitly to avoid more logic.

The top header no longer carries selection state. That belongs under the filter toggles,
in the second header, and nowhere else.

The filter menu is anchored to its trigger again. It had been moved to fixed positioning
to escape a scroller that was clipping it out of existence, and viewport coordinates go
stale in ways that put the menu somewhere unrelated to the box that opened it.

A gallery row now carries its identity as an attribute. A Vue key is a reconciliation
hint and is never rendered, so the smoke check read undefined and reported a broken deep
link on a page that works — measured zero occurrences of that attribute in both the
generator and the built page. Asserting on the visible label instead would have coupled
a machine check to display text.

That repaired gate immediately earned its keep: it caught the gallery asking for assets
one directory too deep. The relative asset base was correct while the gallery was the
root page and silently wrong once it moved aside for the studio, and the failure surfaced
as a JSON parse error rather than a 404, because an unknown path answers with HTML.
2026-09-22 16:01:16 +00:00
doxa-maps-88655c70
5725f5b9d9 Let a stacked composite find its siblings in either tree
Some checks are pending
build-gate / build (push) Waiting to run
stack-demo rendered two empty sections in dev. Its sibling bundles were addressed with
a tree-relative path, which the browser resolves against the document URL, so no host
and no attribute can redirect it — correct in the built tree, a 404 in dev. Measured:
the dev entry answers 200 while the path the relative form resolves to answers 404. The
sources now resolve at runtime, so the same file is right in both trees, and the
production branch compiles away to the string it replaces.

That was the third place this defect appeared. Both capability-5 shells carried the same
hardcoded paths and were cured the same way, and each instance was found separately
after the previous one was fixed — because a literal parent-directory path reads as
correct in review and is invisible to a gate that only ever checks the built tree.

Removing the 404 was not enough: the sections were still empty with zero failed
requests. The shared layout injected each bundle with a classic script, and a classic
script pointed at a dev ES module fires its load handler successfully and only then
throws, so the promise resolved while the tag never registered and the wait for
definition quietly timed out. A load handler cannot detect that.

The module rule now lives in the shared layout rather than in a fourth private copy of
the same loader, so the two other composites that inject through it are covered too.
Verified after: both elements define, sixty-six facet rows and twenty-four cards on
stack-demo and on dashboard-plus-grid, zero failed requests on each.
2026-09-22 15:37:41 +00:00
doxa-maps-88655c70
8b1e3d3f5d Stop the capability-5 shells claiming a native element property
Some checks are pending
build-gate / build (push) Waiting to run
Both shells declared a `title` prop. defineCustomElement maps every prop to an
attribute, and `title` already exists on every HTMLElement as the tooltip, so Vue warned
on every single mount that it would overwrite the native property. Harmless in effect —
nothing here wants a tooltip — but a warning repeated on every mount is where a real
error goes to hide, and this repo has already paid for that once when a thrown setup()
silently removed three buttons behind noise that looked ignorable.

Renaming the prop was safe because nothing outside ever set it: no host passes title=,
no profile-config reads it, and the value is only forwarded to the shared layout, which
is an ordinary Vue component rather than a custom element and so has no collision of its
own. Both shells carried the identical declaration — the same duplicate-implementation
pattern that earlier turned one resolver assumption into two separate outages — so both
were fixed together.

The studio smoke check now samples several tabs instead of only the first, and measures
node depth inside the mounted element rather than counting the slot's children. The old
check read one child for every pane whether the element rendered a full dashboard or
nothing at all, which is the "mounts is not works" trap its own header warns about. It
polls for content rather than sleeping a fixed interval, because a fixed wait either runs
too slow to use on every edit or marks a slow tab broken, and a gate that cries wolf gets
switched off.

Measured after: zero Vue warnings in the browser console.
2026-09-22 15:03:52 +00:00
doxa-maps-88655c70
38a7dbb939 Land the swarm's work: no iframes anywhere, a tree that keeps its shape, deeper facets
Some checks are pending
build-gate / build (push) Waiting to run
Checkpointing thirteen files of agent work that were sitting uncommitted while three
agents wrote to a live tree the Driver was watching through hot reload. Uncommitted work
is work at risk, and a tree being edited under an observer oscillates between states that
look like regressions and are really just partial saves.

The second capability-5 shell now mounts its tabs as real elements too, so no iframe
remains in either shell. It was the last holdout: eight tabs, all iframe mode, each
pointing at a hardcoded built-tree leaf that does not exist under the dev server.

The facet rail keeps its shape when a filter narrows it. Selecting one place took the
rail from sixty-six rows to twenty-one — forty-six rows deleted, none dimmed — so the row
a user was about to click vanished under the cursor, which is what "the buttons went
dead" actually was. Rows whose count falls to zero are now re-admitted at their original
slot and depth, dimmed and inert, which also restores the component's own promise to lose
nothing against the tree it replaced.

Expanding a branch and filtering by it are now separate, visible affordances: the whole
row filters, and expand is a real button with its own hit area and label. Rows that were
never clickable are unmistakably inert rather than silently ignored.

Three more facet axes reach the grid — affinity bloc, engagement status and primary
language — each verified to share a field with an existing prop rather than being mapped
on name resemblance. Two derived facets remain unmapped on purpose: they have no matching
row field, and mapping them would filter confidently to the wrong rows.
2026-09-22 14:51:52 +00:00
doxa-maps-88655c70
9424b86d99 Hand the tree base to nested shells so they stop guessing it
Some checks are pending
build-gate / build (push) Waiting to run
A capability-5 shell mounted inside the studio carries its own tab list and resolves
it against its own tree base, which it derives from its script URL when nobody supplies
one. Nested here that guess was empty, so the resolver took its parent-directory
fallback and requested paths above the leaf at the server root — three bundles 404'd
that way. The requests were non-fatal, because each tab still rendered from its
fallback list, which is exactly why they survived: a silent 404 behind a working
screen. The studio already knows the correct base, since it is the one it resolved
against, so it now passes it down through the existing profile-config path rather than
letting the child re-derive it.
2026-09-22 14:13:49 +00:00
doxa-maps-88655c70
50937cf766 Plugin studio embeds real elements, and a rooted URL is no longer double-prefixed
Some checks are pending
build-gate / build (push) Waiting to run
EVERY TAB FAILED TO LOAD. Introducing tree-base for the dev server prefixed it onto
URLs that were already rooted, producing /__plugin-gallery//plugin-designs/<b>/index.js
and a 404 on all 29 tabs. Both capability-5 shells had byte-identical resolve()
implementations, so both carried the same defect; both now return an already-absolute
ref untouched and join through exactly one slash. The built tree hands relative refs
and keeps its base, so this is one rule for both trees rather than a dev-only branch.

THE STUDIO NO LONGER FRAMES ANYTHING. Map elements were nested in iframes pointing at
their standalone leaf pages, to dodge a one-shot customElements.define collision. That
collision cannot occur here: 19 bundles claim 19 distinct tags, none shared, and two map
elements were proven to coexist in one document with the first unaffected by the second.
Every pane now mounts the plugin's real custom element, which is what makes a row both
reviewable and nestable.

Panes keep two lifetimes, and the difference is the WebGL context budget rather than
taste. Light rows stay mounted so switching back is instant and state survives. Map rows
are destroyed on deactivate, because a hidden map holds its GPU context: the browser
caps live contexts, then silently reclaims the OLDEST, so a map visited ten tabs ago
would go blank with no error anywhere. getContext never returns null and contextlost
never fires on the new canvas, which is what makes that failure so hard to attribute.

The dev server serves a leaf route for one element with no chrome, and passes the tree
base to nested shells so they discover the live tree instead of a baked fallback.
2026-09-22 14:02:20 +00:00
doxa-maps-88655c70
012c79328a Free the filter menu from its clipping ancestor; square the people-group images
FILTER DROPDOWNS WERE INVISIBLE. Opening a filter rendered its options underneath
the surrounding content, making the dashboard's primary control unusable.

The cause was CLIPPING, not stacking order, and that distinction is why an earlier
attempt failed. The filter band is a one-row horizontal scroller authored as
overflow-x:auto with overflow-y:visible — but CSS Overflow 3 coerces a visible axis
to auto when the other axis is not visible, so it computes to auto/auto and clips
vertically. Clipping happens during painting, before stacking order is consulted, so
raising the band to z-index 55 changed nothing: it won a stacking fight that was never
the problem. You cannot re-order pixels that were never generated.

The menu is now position:fixed, anchored to the trigger's viewport rect, which takes it
out of the clip chain entirely. Fixed coordinates go stale whenever anything scrolls or
resizes, so they are recomputed on open and while open, listening in the capture phase
so the dashboard's inner scroll regions are seen too.

PEOPLE-GROUP IMAGES WERE LETTERBOXED. A fixed 130px height against a 220px column
cropped each person to a band. The image is now a square via aspect-ratio, and the
column floor rises to 240px, roughly doubling the visible area. object-fit:cover is
kept deliberately — stretching to a square would distort faces, which is worse than
the letterbox it replaces.
2026-09-22 13:31:11 +00:00
doxa-maps-88655c70
44dd2d01f3 Make dashboard selections actually filter the grid, and fit the camera to the data
Some checks are pending
build-gate / build (push) Waiting to run
Four fixes to the research dashboard and the cap-4 composite, all Driver-reported.

GRID SHOWED ZERO ON EVERY SELECTION. Picking a region showed "1,309" in the chart
above an empty grid below. The two halves spell the same value differently: the
dashboard emits the human LABEL ("Asia", "North America & non-Spanish Caribbean")
while the API stores the machine VALUE ("asia", "north_america_&_non-spanish_caribbean"),
and the comparison was a strict ===, so it never matched. Measured over the 2,107
live records. Adds a shared valueMatches() normaliser used by BOTH applyDropdownFilters
and applyWith, so facet counts and shown rows cannot disagree. Compares value OR label,
case- and separator-insensitively, chosen over a label-to-value lookup table that would
need maintaining per axis and would rot when the API adds a region.

The grid's own dropdowns were never affected, because they build their options from the
data itself; only the host-prop path could inject a label into the comparison. That is
why the standalone grid is fine and the gate passes 13/13 on the same records.

COUNTRY NOW REACHES THE GRID. Clicking India in Top Countries did nothing because
country_code had no matching prop and was deliberately dropped rather than guessed at.
Adds a real countryCode prop and maps it. Country is prop-only, not a fifth dropdown.

LONG FACET LABELS HID THE COUNTS. white-space:nowrap let a long label eat the row, and
the rail scrolls vertically only, so there was no way to recover the number. Labels now
wrap; the bar yields; the count never shrinks.

CAMERA FLEW TO THE NORTH POLE on globally dispersed selections. Fits now trim outliers,
reject invalid and null-island coordinates, and unwrap longitudes across the antimeridian,
so the view lands on the data instead of a pole.
2026-09-22 12:50:37 +00:00
doxa-maps
d085a541ca Let a host reach the dashboard's own settings, so the provider is really swappable
Some checks are pending
build-gate / build (push) Waiting to run
The provider option landed last change but nothing a host passed could reach it:
the dashboard's config is a static import, so the setting existed in source and
was unreachable by an embedder. Real and unusable is the same trap as a nav
control that exists off-canvas. Hot-swappable has to mean a partner can choose
without a rebuild.

profileConfig is the channel every other plugin already uses for this, so this
adds no new mechanism — it stops the dashboard being the one plugin that ignores
it. The merge is shallow and the host wins. Shallow is deliberate: the static
config's nested objects are a coherent set that a partial override would corrupt,
and a deep merge would invite half-replaced facet maps that fail far from the
edit. What a host legitimately sets is top-level scalars.

With no host config the behaviour is byte-for-byte what it was, verified against
the baseline captured before any of this work: same node count, same canvas, same
row count, same engine, same ten tile responses.
2026-09-22 11:53:26 +00:00
doxa-maps
6a239201c0 Let the research dashboard choose its map provider, Mapbox still the default
Some checks are pending
build-gate / build (push) Waiting to run
The Driver wants MapLibre available without breaking Mapbox: keep every Mapbox
component working, add the option beside it, start with this dashboard.

The change is deliberately additive. provider defaults to mapbox, so with no
config the behaviour is what it was and nothing Mapbox-shaped can regress. A host
opts in through dashboardConfig, which is how every other dashboard setting
already arrives, so the cap-4 composites can pass it down with no new mechanism.

The plumbing already existed and was simply unreachable from here: useMapInstance
takes a provider and loads the matching engine, and the style registry maps every
named style to both engines with the MapLibre side pointing at keyless URLs. This
call passed neither provider nor style, so it silently took the default.

The token stays wired when MapLibre is selected. MapLibre ignores it, and keeping
it means switching back is one word with nothing else to restore, which is what
hot-swappable has to mean to be worth anything.
2026-09-22 11:39:10 +00:00
doxa-maps
236fef5682 Give nested ProfileLoader children the profile-config they require
Some checks are pending
build-gate / build (push) Waiting to run
Two composites nested a child by tag and URL without a profile-config. Those
children do not render a component directly — their bundle entry mounts
ProfileLoader, which reads profile-config.profile to choose which view to load.
With none, the element still mounts and still reports its tag, so every
structural check said both children were present, while the loader rendered
"missing or invalid profile-config prop" where the map should be. The grid half
of each page worked, which is how it survived.

Found by reading a regenerated README screenshot, not by any gate. The mobile
screenshot showed an error card and an empty pane; the fixed one shows the real
research map with its data, controls and progress figures.

research-map-plus-grid was mine, shipped that way. stack-demo predates it and had
the same defect against research-dashboard. Both now pass the shape their own
generated leaf pages use, so nested and standalone mounts ask for the same thing.

check:composite-srcs now requires a profile-config on any child whose bundle
mounts ProfileLoader, derived from which entries import it rather than a list.
The first version of that check tested for the word and passed on its own
explanatory comment; it now matches the quoted attribute key, and a negative test
that mutates only the key fails as it should.

Also regenerates three derived artifacts that had drifted behind this flight's
changes: the library index, the capability exemplars table, and all twelve README
screenshots.
2026-09-22 09:37:35 +00:00
doxa-maps
94dc3c64e8 Correct the shell's account of how it mounts its tabs
Some checks are pending
build-gate / build (push) Waiting to run
The file header said tab 1 was bp-big-picture, mounted directly via its real tag.
Both halves are false: tab 1 is dashboard-plus-grid, and every tab is an iframe —
there is no direct-tag mount left in this shell. A reader trusting that text would
look for a mount path that does not exist and maintain the wrong bundle believing
it was the one users see. The big-picture bundle is still a valid element; it is
simply one of the parts already composed into the tab-1 composite.

The locale switcher also poked slot zero by hand, writing a profile-config named
big-picture onto what is now an iframe, which ignores it. Harmless, and that is
why it survived: the effectiveLocale watcher was doing the real work all along,
branching on the tab's mode and re-pointing the iframe src that carries the
locale. Removed rather than repaired, since a second hardcoded locale path is one
more thing to keep in sync for nothing. Verified by driving the switcher: the
active frame moves from locale=en to locale=es.

The direct-tag branch in the template is kept. It is generic, keyed on the tab's
mode rather than a bundle name, and remains the right way to nest a future
token-free element. Only the comment claiming tab 1 uses it was wrong.
2026-09-22 08:33:24 +00:00
doxa-maps
684b209704 Show the research-map composite instead of Big Picture on a phone
Some checks are pending
build-gate / build (push) Waiting to run
The Driver's ordering: desktop opens on the Big Picture dashboard; mobile opens
on the research map and does not show Big Picture at all. Both capability-4
composites already existed, so this is the shell choosing between them. A row in
the nav model can now declare onlyWhen desktop or mobile; everything below the
first rung is identical on both, which is why only the first two rows carry it.

One nav model rather than two, so the teaching order lives in one place. Two
parallel models would be two things to keep in sync and the failure would be
silent: a row added to one and forgotten in the other simply never appears.

Crossing the breakpoint can remove the row that is currently active, which would
leave the user on a mounted but unlisted tab with no highlight. The shell now
snaps to the first surviving row, and establishes the viewport class before
opening the first tab so a phone never opens Big Picture for a frame.

Also fixes a pre-existing bug this exposed: with no header there was no way to
open the rail on mobile. The drawer sits off-canvas, the only toggle lives inside
the header, and the collapse control is inside the drawer itself — measured
off-screen at x:-244. Every tab except the one that happened to open was
unreachable. A floating button now appears only when narrow, headerless and shut,
so no existing consumer can see a change.
2026-09-22 08:13:41 +00:00
doxa-maps
914b0f0ca5 Fix the poster URL helper's wrong extension, and correct a stale header
Some checks are pending
build-gate / build (push) Waiting to run
posterUrl() said .png. There are 378 poster .webp files and no matching .png, so
the helper could only ever have produced a 404 — but it was never called, because
the export and print paths each built the same URL inline. A dead helper carrying
a wrong constant is worse than no helper: it is primed to break whoever tidies up
by routing a call through it. Point it at .webp and route both callers through
it, so the format lives in one place.

The file header still said posters "DO NOT EXIST YET. Nothing has been generated."
378 previews are generated, shipped and served live, and the wall paints from
them. A reader following that line would go build a generator that already ran.
Replace it with what actually ships: previews by default, full-resolution posters
excluded because bundling them pushed the tree past what the catalog would accept.
2026-09-22 07:47:29 +00:00
doxa-maps
b57aef457c Add the mobile capability-4 composite: research map above the people-groups grid
Some checks are pending
build-gate / build (push) Waiting to run
The desktop composite pairs the research dashboard with the grid. This is its
mobile sibling: the monolithic 7-tab research map on top, the same grid beneath,
stacked to scroll. Which one a shell shows is an aspect-ratio choice made at the
SPA level, so neither plugin knows about the other and both stay embeddable.

The grid's own search and filters are ON here. The desktop composite turns them
off because the dashboard header owns those controls and drives the grid through
props; the research map has no such header, so switching them off would leave the
grid with no way to be searched at all.

Composes its children by tag and bundle URL rather than importing their .vue
files. Importing the research map directly would bypass its bundle entry, which
is where its three bundle-private colour strategies, its profile registry, its
Pinia and i18n pair and the RTL text plugin are installed — and losing those is a
runtime defect in a green build, because a thrown setup() removes the subtree
with an empty error.

Add check:composite-srcs, which resolves every nested src against the built tree
and asserts every nested tag is registered. A wrong relative depth is a 404, and
the stack layout swallows a failed section on purpose so one broken child cannot
take the whole stack down — which means the cost of that resilience is a silently
empty band. Negative-tested both ways: breaking the depth and renaming a tag each
fail with the file and path named; restoring returns to pass.
2026-09-22 07:34:02 +00:00
doxa-maps
e7eeb4d02a golden-path-check: walk the develop-a-plugin-and-see-it-live path end to end
Some checks are pending
build-gate / build (push) Waiting to run
The north star is that an admin agent can author a plugin, build it, publish
it and see it live. Every step was documented and individually verified, but
never walked in one pass by following the doc as written. This plugin is that
walk, authored from the documented three-file minimum.

It stays useful afterwards: it reads the manifest.json it was published
beside and reports what the catalog actually contains -- bundle and profile
counts, how many profiles describe themselves, and whether it is listed. If
you can see it answering, the path worked. Deliberately dependency-free: no
map engine, no token, no data API, so it can mount when everything else is
broken, which is when someone reaches for it.

The walk found a real defect in my own plugin, which is the point of doing
it. The first version wrapped the element in ProfileLoader (copied from a MAP
bundle) without createPinia. ProfileLoader builds three stores in setup(), so
setup threw -- and a thrown setup removes the subtree silently. Build green,
manifest listed it, nothing complained. Only smoke:embeds caught it: "shadow
DOM nearly empty (1 nodes)". That is exactly what Step 3b exists for and why
the doc puts it before publish.

The fix is the cap-1 pattern doxa-vision-card already uses: mount the root
SFC directly. ProfileLoader earns its cost for a map plugin; it is the wrong
wrapper for an element that draws no tiles and takes no token.

Verified: 17 -> 18 bundles, 26 -> 27 profiles, auto-discovered with no config
edit; renders 19 shadow nodes reporting live catalog state; embed suite 41/41.
2026-09-22 03:55:18 +00:00
doxa-maps
344c4dc831 Describe every plugin: 15 of 26 profiles shipped with no description
Some checks are pending
build-gate / build (push) Waiting to run
More than half the catalog had an empty description in manifest.json, so the
plugin-studio — the surface a Doxa inheritor browses to learn what exists —
showed flagship plugins as a bare "bundle - <tag>" with no explanation, while
a demo bundle explained itself fully.

Cause: the generator reads a JSDoc @description TAG, and these files carried
only prose. Every description added here is a summary of what that file's own
header already documented, not new invention. Claims were checked against the
artifacts: doxa-simple-map's "three presets" was verified against
simple-map.instances.json (prayer, engagement, adoption).

The two cap-5 shells needed a second pass. Their headers are HTML comments
with no leading asterisk, and the real TAG_RE requires `* @tag value`, so the
first attempt extracted nothing.

Result: 26/26 profiles described, 0 empty; the built gallery went from 15
empty / 11 populated to 0 / 26. Verified in a browser, not just in the
manifest. Embed suite 40/40.
2026-09-22 03:11:07 +00:00
doxa-maps
fc1159151f Give country-posters a real description so the SPA stops calling it "experimental bundle"
Some checks are pending
build-gate / build (push) Waiting to run
Both country-posters profiles had a prose header but no @description tag, and
the generator reads the TAG, not the prose. It emitted an empty description
into manifest.json, so the cap-5 SPA fell back to its generic label and the
Experimental rail showed a nameless tile — the only bundle in the app that
did. A Doxa inheritor browsing the SPA met "experimental bundle".

Added @description (and the @element tag both files were also missing, which
validates against the build's choke-point rule) to countries-map.vue and
country-poster-wall.vue, with text grounded in what each profile does.

Verified at the symptom, not the intermediate: the rendered rail now shows
the real description and the placeholder count is 0. Embed suite 40/40.
2026-09-22 02:45:07 +00:00
doxa-maps
7f08d09edc Remove the tile-403 on-screen banner; connect dashboard selections to the grid
Some checks are pending
build-gate / build (push) Waiting to run
Two Driver-reported defects.

1. No more "Basemap tiles blocked" popup on the map. The banner reported an
   operator problem (one Mapbox account setting on one deployment origin) to
   the end user, who can do nothing about it, on every map and every load. The
   console diagnosis stays, because it reaches the person who can act.

2. Selections in the dashboard now filter the grid beneath it. The two halves
   were never connected: research-dashboard had no defineEmits and its bus is a
   per-instance factory that is never provided, while dashboard-plus-grid passed
   the grid no filter props. The grid's receiving end already worked. Added the
   emit, plus a translation layer between the dashboard's snake_case facet keys
   and the grid's camelCase props, mirroring big-picture.

   Proven live: typing "Turk" takes the grid from 24 cards to 22 with wholly
   different rows. Only 4 of 12 facets map; the rest are dropped rather than
   guessed, because religion_family carries a family name while the grid's
   religion prop matches a code, and mapping them would filter to zero rows.
2026-09-22 01:43:56 +00:00
doxa-maps-88655c70
9dc4dda8cf consolidate docs into documentation/ and deployment shells into deployment/
Some checks are pending
build-gate / build (push) Waiting to run
Driver 2026-09-21: 'a folder called deployment which will store the capacitor
folder, wordpress folder and tauri folder', and 'a folder called documentation
to store the contributing folder so docs are in one place'.

docs/ + contributing/ + CONTRIBUTING.md -> documentation/ (three doc homes into
one). capacitor/ + wordpress/ + src-tauri/ -> deployment/.

The public/ -> dist/ rename the Driver asked about is NOT here: after seeing the
measured blast radius (855 tracked files, 54 source refs, and public/ is the
served web-root four consumers point at) the Driver withdrew it - 'keep the
public folder the same'.

Path references were rewritten mechanically (258 across 87 files), but three
classes needed hand fixes because a prefix regex cannot see them:
  - join(ROOT,'docs',...) in 10 internal scripts - a quoted SEGMENT, not a prefix
  - tauri frontendDist '../public' -> '../../public', since src-tauri is now one
    level deeper; check:tauri caught this exactly as the card predicted
  - the WordPress contract test's dirname(__DIR__,2) -> 3, same reason

Also reverted one over-reach: the rewrite renamed a FINDING-CATEGORY label
'docs' in public-hygiene-check.mjs, which is a rule name in its own header, not
a path.

Verified: full build ALL GUARDS PASS, SRI consistent; check:tauri, check:mobile
(cap sync + webDir OK), check:wp-embed, quoted-scripts, capability-citations,
capability-exemplars, library-index, public-hygiene, parse and drop all pass.
2026-09-21 23:23:23 +00:00
doxa-maps-88655c70
f1772fda6f correct the dot-visibility comment to match the two-part fix actually shipped
The comment claimed a three-part fix including 'a thin white inner ring for
contrast'. No such property was ever added, and a mapbox circle layer has a
single stroke, so no inner ring is possible on this layer. A reader trusting
the comment would look for a second layer that does not exist.

Verified live against the running map rather than the source: the upg-pins
layer reports stroke-color rgba(15,20,25,0.9), opacity 1, and stroke-width
interpolating 1.2 -> 2 across zoom. The fix is real; only its description
was wrong.
2026-09-21 17:34:37 +00:00
doxa-maps-88655c70
5ddabe26e2 light the remaining unconditional loading overlays and the copy-me starter
A previous sweep found six dark matches, correctly ruled five of them unreachable, and
fixed the one real violation in doxa-simple-map. Re-running it with a luminance test
rather than a hex prefix found more, because the earlier pattern also matched the green
accent #10b981 and so buried the genuine surfaces in noise.

Three further unconditional violations, all the same shape as the one already fixed: the
loading overlays in all-people-groups, country-posters and doxa-research-map each render
on every load via v-if="!appReady", so a near-black flash was the first thing a visitor
saw. All four now use the palette surface with muted text.

The template-bundle starter was also dark in both profiles. That one matters more than a
leaf: the documented recipe is to copy template-bundle and rename it, so every future
plugin would have inherited a surface the house style forbids. The two profiles stay
visibly distinct — a tinted surface with an accent border, b white with a neutral one.

Verified by reading the shipped rule out of each built bundle: 4 of 4 found, all
#f3f3f1 at luminance 243. The found-count is reported so a run that matches nothing
cannot read as a pass — an earlier version of that check found zero rules and printed
ALL LIGHT.
2026-09-21 16:40:53 +00:00
doxa-maps-88655c70
4d92e69377 count only zones that are meant to paint, so the badge reads full health
Deriving the denominator from perZone.size fixed the impossible 27/26 but left a
permanent 27/28, which reads as "one zone is broken". I carded it as a bug on exactly
that reading. It is not a bug.

resolveAllZones deliberately excludes the deaf zone from the region-fill tapestry and
records zero for it, because its A017 affinity selector double-stamps land already owned
by the 13 geographic zones — 16 of oceania-indonesia's 18 Pacific ISO3 codes alone. That
double-stamp showed deaf twice and underlapped every other region. Deaf stays complete in
the list, the legend and the pins; only the polygon fill is withheld.

So a by-design exclusion was being displayed as a shortfall. Counting only zones that are
meant to have geometry makes the badge report 27/27 when everything is healthy, and fall
below its total only when a zone genuinely fails to resolve.

Verified: 28 leaves minus 1 deaf is 27 expected, 27 resolve, and the leaf now shows
195 polys · 27/27 zones · 2107 pins. The composite does not display this badge at all,
which a first probe misread as a failure.
2026-09-21 16:03:54 +00:00
doxa-maps-88655c70
2b72fb6a55 round the top-header search bar to a full pill
The Driver asked for the top-header search bar to be more rounded. It is now 16px, which
is exactly half its measured 32px height, so the ends are true semicircles. Any larger
value renders identically because the browser clamps to height/2, making this the actual
maximum rather than a number picked by eye.

Scoped to the top header only. The card's scope field named the shared SearchBar
component, but that component's own 10px is not what renders here: measuring the computed
style showed 6px, because DashboardTopBar overrides it. Editing the library would also
have changed people-groups-grid and big-picture, which was not asked for.

Verified 6px to 16px at two viewports, confirmed as a full pill, with the magnifier icon
still clear of the curve at a 10px inset.
2026-09-21 15:51:10 +00:00
doxa-maps-88655c70
b08ed8b484 centre the grid explainer in the existing table header
The Driver rejected v1, which added the hint as its own paragraph above the grid: "you
dont have to add a SECOND HEADER to make this show up... it could be CENTRED on the same
header where it says total 2107." So it now rides the bar that is already there, beside
the live count.

Centred by absolute positioning against the bar, not by flex spacers. The first attempt
here used a spacer on each side and measured 55px off centre at both 1440px and 1280px,
because spacers split the leftover space rather than the bar, and the left cluster
(dock, title, count, selection controls) and right cluster (toast, Export CSV) differ in
width. Absolute at left:50% with translateX(-50%) is relative to the bar itself, so the
clusters cannot drag it, and being out of flow it adds no height by construction.

The bar gains position:relative to be that containing block. pointer-events:none keeps
the hint from intercepting a click meant for a control beneath it, and it hides below
1100px where the clusters leave no free middle.

Measured against a pre-change baseline of 32px: 32px at all three viewports, one header
bar, and centre offset 0 at 1440px and 1280px.
2026-09-21 13:42:55 +00:00
doxa-maps-88655c70
15ce17f907 fix the impossible 27/26 zone badge and replace a faked page-2 shot with the real third profile
The badge read "27/26 zones" — a count exceeding its own total — in two shipped README
screenshots. 26 was the leaf count before slice 09b-6 added the Frontier-Asia uncovered
rows; the literal was never updated, so it drifted the moment the data grew. The
denominator is now derived from perZone.size, which tracks the zone list automatically
and cannot disagree again. Measured from clusters.json: 13 regional + 12 asia + 2
uncovered + 1 deaf = 28. The corrected badge also surfaces 1 genuinely unresolved zone
that the stale denominator was masking.

legend-tree.js asserted "33 leaves" and "7 Frontier-Asia uncovered" in a comment; the
shipped data carries 28 and 2. Corrected to point at the arrays instead of a count.

README showed "List — page 1" and "List — page 2" as the same picture. The shot loaded
one URL twice and tried to reach a page 2 by scrolling and clicking Next; neither exists,
the list has no pagination, and every attempt sat in try/catch so the miss was silent.
The PNGs differed by 0.16% render noise, so no checksum would have caught them. Mean-
while poster-wall — a real manifest-listed profile — had no screenshot at all. Shoot the
real third thing rather than fake a fourth.

check:shot-distinctness gates this, blocking in CI. It keys on url+viewport, so the
deploy2 mobile shot of the same SPA stays legitimate rather than a false positive.
2026-09-21 11:29:25 +00:00
doxa-maps-88655c70
4c9aca1321 show the grid filters by default and make the toggle a real control 2026-09-21 05:50:19 +00:00
doxa-maps-88655c70
702333d6de light the simple-map loading overlay; both synced copies 2026-09-21 01:11:05 +00:00
doxa-maps-88655c70
b309140d02 declare research-dashboard as monolithic (cap-3); it was inferred as single (cap-1) 2026-09-20 07:34:50 +00:00
doxa-maps-88655c70
bb5fd9aed4 fix big-picture @element to a bare tag and stop embed-smoke reporting every error as a mount timeout 2026-09-20 07:07:48 +00:00
doxa-maps-88655c70
981cc40633 Shared layout and legend tools on the DX palette; keep dark-surface ink ramps 2026-09-20 06:15:37 +00:00
doxa-maps-88655c70
37b9a550f7 Add show-total prop so an embedded grid defers its row count to the host 2026-09-20 05:57:47 +00:00
doxa-maps-88655c70
9fd520577b Trim table chrome to the 4px grid; recovers 64px of column width at narrow viewports 2026-09-20 05:47:38 +00:00
doxa-maps-88655c70
4a3eb5e445 Size table columns from measured need; derive the summary row from the column set 2026-09-20 05:39:41 +00:00
doxa-maps-88655c70
12ef4a529b Primary button uses a sage shade that meets WCAG AA for text at its rendered size 2026-09-20 05:24:44 +00:00
doxa-maps-88655c70
eae4e2be4c Restore cap-4 i18n context (configureApp) and re-theme shared search components to the DX palette 2026-09-20 05:15:02 +00:00
doxa-maps-88655c70
85014aad11 Session work: strobe fix, grid layout, stacked tree legend, selection camera, chip bounding, asset inheritance, gates 2026-09-20 04:49:25 +00:00
doxa-maps-88655c70
ea0c47b277 Brand-neutral shared VerticalTabsLayout with lazy nested-element hosting; fix cap-5 prayer tab leaf path; neutralize library identifiers; add Forgejo CI build-gate workflow
Some checks failed
build-gate / build (push) Has been cancelled
2026-09-19 17:24:30 +00:00
84fe1da1ab Bake profile type into manifest; plugin-studio cap-5 element; UPG-100 print+dots 2026-09-19 06:56:27 +00:00
doxa-maps-88655c70
2d64e21fbf Complete app-profiles->plugin-designs sweep across comments/docs; build green; layouts kept at root (load-bearing) 2026-09-17 18:18:19 +00:00
doxa-maps-88655c70
1a3925c6a6 Rename app-profiles -> plugin-designs; build green under new naming (load-bearing paths updated, guards pass) 2026-09-17 18:18:18 +00:00