Commit graph

573 commits

Author SHA1 Message Date
doxa-maps-88655c70
347823a92b test(print): each print path must produce its OWN page geometry
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "make sure poster generation works and the aspect ratio of the print
dialog can be custom based on your print point, so print dialog works for
poster, for vertical packet, and for horizontal cards... all working in the
jamstack maps."

One gate over all four paths, because they share one failure: silently falling
back to letter. Chromium does that for ANY @page size it does not recognise
('A6 portrait' -> 612x792pt, no error, no warning), so every assertion here is
on the MEASURED MediaBox from page.pdf(), never on the stylesheet. A gate that
grepped the CSS would pass a deck that prints as paper.

Measured, all from the shared renderers the UPG-100 page itself imports:
  packet (vertical)   612 x 792 pt   letter portrait
  cards (poker)       180 x 252 pt   2.5 x 3.5in
  cards (landscape)   252 x 180 pt   3.5 x 2.5in
  poster              1500 x 2000 px rasterised, painted

Orientation is asserted separately from size, so a transposed stock cannot pass
by accident, and the poster is checked for ink rather than dimensions because it
rasterises rather than paginating.
2026-09-24 03:55:16 +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
47c54b12ac test(packet): assert RTL direction, not just translated strings
Some checks are pending
build-gate / build (push) Waiting to run
An Arabic packet without dir="rtl" renders readable glyphs LEFT-aligned. Every
other assertion in this gate passes on that: the strings are translated, the
rhythms are present, the pages paginate. It reads as a styling quirk rather than
the bug it is, and nothing here would have caught it.

The gate now checks the rendered <html dir> against an RTL language set, in both
directions — an RTL language must be dir="rtl", and an LTR one must not be.
Confirmed to fire before being accepted: an Arabic packet relabelled dir="ltr"
reports 'lang="ar" is right-to-left but dir="ltr"'.
2026-09-24 03:50:59 +00:00
doxa-maps-88655c70
dd14b5fe76 feat(packet): five more languages, and right-to-left support for Arabic
Some checks are pending
build-gate / build (push) Waiting to run
The packet now ships in 9 of the app's 11 locales: en es fr pt de it ro ru ar.
Each carries all 21 translatable keys — page furniture, the twelve prayer points
with sub-headings, the eleven rhythms, both Metacamp attributions, the privacy
note. point4_passage (Mark 5:1-20 in full) stays omitted in every non-en block
and falls back to the licensed English WEB text with its credit, for the reason
already recorded: translating scripture here would put unlicensed, unverified
Bible text into a printed prayer packet.

RTL IS NOW REAL, not assumed. The renderer emits dir on <html> from an RTL
locale set. Without it an Arabic packet renders readable glyphs LEFT-aligned —
which looks like a styling quirk rather than a bug, and no assertion noticed.

★ TWO BIDI FINDINGS, both from reading the render rather than the code:

1. Scripture references are LTR runs inside RTL paragraphs, and the bidi
   algorithm REORDERS a run that starts with a digit: "1 Peter 5:6-11" rendered
   as "Peter 5:6-11 1". Each reference is now dir="ltr" + unicode-bidi:isolate.
2. With isolation, the reference LIST reads right-to-left on an RTL page (first
   item rightmost). That is correct for an RTL reader, not a defect — recorded
   in _meta so nobody "fixes" it. English source order is unchanged, asserted.

★ HINDI IS DELIBERATELY NOT ADDED. Rendering each script at 48px and READING THE
PIXELS: Latin, Cyrillic, Arabic and CJK all shape correctly in the packet's
Georgia/serif stack; DEVANAGARI RENDERS AS TOFU BOXES, because this box has zero
Devanagari fonts (fc-list). A hi block would ship a page of empty rectangles. It
is blocked on vendoring a Devanagari font into public/assets/fonts, which is its
own piece of work.

⚠ THE MEASUREMENT THAT LIED. My first test compared rendered text advance against
a missing-font baseline and reported ar and zh BROKEN and hi FINE — the exact
opposite of the truth, because Devanagari tofu boxes happen to match real glyph
advance. One screenshot settled in a glance what two rounds of clever
arithmetic got backwards. Recorded in _meta.

Verified at the rendered layer in all nine languages: 8 sheets -> 8 letter
portrait pages, 95 people cards, 12 wheel segments, map painted, zero
overflowing sheets, 19 strings differing from English per locale. The Arabic
prayer-points page was read as a picture, not just asserted on.
2026-09-24 03:50:35 +00:00
doxa-maps-88655c70
804562c7f8 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
2026-09-24 03:45:04 +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
cd5e5f5590 test(packet): verify the packet in ITS OWN language, not in English
Some checks are pending
build-gate / build (push) Waiting to run
The gate asserted a hardcoded English word list for the prayer rhythms — which
tests the opposite of what i18n means. It passed a packet whose translation
silently did nothing, and FAILED every correctly translated one: the Spanish
packet reported "only 0/11 prayer rhythms present" while rendering all twelve
correctly as ALABANZA / ESPERAR / CONFESAR.

Expected words now come from the strings catalog for the packet's OWN language,
resolved per key with English fallback — the same contract the renderer honours.
So the gate asserts the PROPERTY (all rhythms present, in this language) rather
than a vocabulary. The catalog path is derived from the packet's own location
instead of a machine-specific constant, with PACKET_STRINGS to override.

Adds the assertion the tick exposed: a non-English packet must ACTUALLY DIFFER
from English. That failure is invisible to every other check, because an
all-English packet is a perfectly valid packet — a mis-keyed block, a block under
the wrong ISO code, or a renderer ignoring its lang argument all produce one.
Confirmed to fire before being accepted: a packet relabelled lang="de" with no
de block reports "lang=de but no string differs from English".
2026-09-24 03:37:24 +00:00
doxa-maps-88655c70
41847076a2 feat(packet): ship the prayer packet in Spanish, French and Portuguese
Some checks are pending
build-gate / build (push) Waiting to run
The app ships 11 locales; the packet shipped 1. A Spanish-speaking reader got a
Spanish interface and then an entirely English prayer packet. The per-key
fallback machinery was built and verified several ticks ago — the catalog was
simply never populated, so i18n was functionally unshipped.

es, fr and pt now carry all 21 translatable keys: the page furniture, the twelve
prayer points with their sub-headings, the eleven prayer rhythms, both Metacamp
attributions and the privacy note. Terminology follows the app's OWN existing
locales rather than inventing a parallel vocabulary — "grupo étnico" for people
group and "no evangelizado" for unengaged come straight from
library/i18n/locales/es.

★ point4_passage IS DELIBERATELY OMITTED FROM EVERY LANGUAGE BLOCK, and that is
the point, not an oversight. It is Mark 5:1-20 printed in full, and the file's
own guidance already required "a public-domain or properly-licensed version for
that language". Translating scripture myself would put unlicensed and
unverified Bible text into a printed prayer packet. Omitting the key makes it
fall back to the licensed English WEB text with its credit line intact —
honest, and visibly English, which invites a real translation rather than
hiding the gap. The note in _meta now says this outright so the next translator
does not "complete" the block.

Also recorded, because the render made it visible: scripture REFERENCES stay in
English in every language ("Matthew 27:27" on a French page), so a reader can
find the passage in their own Bible. That is a deliberate tradeoff and it does
look foreign next to fully translated prose — _meta now names it and says how to
add localised book names properly if a language wants them.

Verified at the rendered layer in all four languages: 8 sheets -> 8 letter
portrait pages, 95 people cards, 12 wheel segments, map painted, zero
overflowing sheets, and Mark 5 correctly falling back to English with its
credit. The French prayer-points page was read as a picture, not just asserted
on: all twelve points translated, Matt 27:27 intact at point 1, "un lieu sûr" at
point 8.
2026-09-24 03:37:05 +00:00
doxa-maps-88655c70
cfea0c8e70 fix(posters): measure the full-version header instead of guessing 460
Some checks are pending
build-gate / build (push) Waiting to run
Four zones had NO POSTER AT ALL. measureHeader() opened with
`if (!SPARSE) return HEADER_BASE` — a flat 460 — so the per-zone measurement its
own comments describe ("HEADER IS PER-ZONE, NOT A SHARED CONSTANT...
measureHeader() predicts the height from the zone's OWN content") only ever ran
in SPARSE mode. Outside it, any zone whose header was taller than 460 tripped
the fail-loud guard and rendered nothing:

  oceania-indonesia                   504   andean-caribbean  504
  asia-china-tibet-autonomous-region  481   deaf              619

Raising the constant was not the fix. HEADER is subtracted from the map height,
so 460 -> 620 would have stolen 160px of map from the 25 healthy sheets to
rescue 4 — the exact tradeoff the per-zone measurement exists to avoid, and the
one its own comment records (tuned to North India, it overflowed west-africa;
tuned to west-africa, it stole from everything else).

MEASURED the real header on six zones instead:

  HEAD_BOTTOM = (headY + 34) + 118      exact on all six
  headY       = 191 + 23 * tableLines   exact on all four probed

The 118 is the two vision columns — a constant 104, because that copy is fixed
and wraps identically on every sheet — plus the 14px trailing gap. ALL per-zone
variation is the facts table, which advances 23px per wrapped line. So this is
arithmetic on the same inputs the renderer wraps, not an estimate, and it needs
no calibration history like the SPARSE model's +24/+52/+66/-8/+6 above it.

★ THE ERROR WORTH RECORDING: my first attempt derived the constant by READING
the code — tracing headY's assignments to 150 + 23n - 12 — and was 53px short on
every zone, because the scripture block between the table and the vision columns
also advances the cursor and I had not traced it. Every zone then failed,
including ones that worked before. Deriving it from four measured renders got it
exact. Reading selectively is not measuring.

Result: 28 posters render, zero failures. And the map GREW on the healthy
sheets, because they no longer pay for the worst case — laos' map frame starts
at y=443 against the old flat 460, while deaf takes the 627 it genuinely needs.

Still missing after this: deaf-old-world and deaf-new-world-asia-pacific, which
never reach the render at all (silent skip, exit 0 — they are `deaf_predicate`
selectors that leafZones() does not return). Different cause, carded separately.
2026-09-24 03:31:01 +00:00
doxa-maps-88655c70
8059eeddd5 Merge main into bundler-1/work (rebuild, regenerate, re-hash)
Some checks are pending
build-gate / build (push) Waiting to run
2026-09-24 03:22:57 +00:00
doxa-maps-88655c70
3919dd9a26 Merge remote-tracking branch 'upstream/main' into bundler-1/work 2026-09-24 03:22:32 +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
52b7a110d8 fix(posters): one bad zone no longer costs the other 28
Some checks are pending
build-gate / build (push) Waiting to run
generate.mjs threw on the first zone whose header overflowed, so a single bad
sheet ended the run: 16 posters written, the remaining 13 silently unbuilt, and
a non-obvious short count as the only symptom.

Failures are now collected, the run continues, and it exits 1 at the end with a
census naming every zone that has no poster. The guard stays loud — an
overflowing header is a real defect and must not be downgraded to a warning —
but it fails the RUN rather than truncating it.

That change immediately surfaced a FOURTH broken zone the abort had been
hiding. All four overflow the same way:

  oceania-indonesia                   504 vs HEADER=460   (+44px)
  andean-caribbean                    504 vs HEADER=460   (+44px)
  asia-china-tibet-autonomous-region  481 vs HEADER=460   (+21px)
  deaf                                619 vs HEADER=460  (+159px)

Root cause, found but NOT patched here: measureHeader() returns a fixed
HEADER_BASE when SPARSE is off instead of measuring the zone's own content — so
the per-zone measurement its own comments describe only runs in SPARSE mode.
That is a separate fix with its own layout risk; carded rather than bolted on.

Also adds publish-html.mjs, the missing half of the browser-rasterise fallback.
That fallback fetches posters/<id>.html and NOTHING published it: out/ is
gitignored and public/ had zero .html, so a zone with no image 404s today. The
script writes zone poster HTML into public/assets/posters/, skips city-* posters
(they need OSM tiles to regenerate, so their images must stay), and warns
loudly about zones with no HTML instead of quietly writing fewer files.

★ IT IS NOT RUN AS PART OF THIS COMMIT, and the reason is worth recording.
Measured with real git packs:

  webp   5.0 MB raw  ->  ~5.0 MB packed  (already compressed; git cannot pack it)
  HTML  21.4 MB raw  ->   5.78 MB packed (text, repetitive across posters)

Swapping zone webp for zone HTML is about +0.8 MB PACKED — a net LOSS, not the
42 MB win the headline implies. The raw-byte comparison that made it look like a
win (21.4 vs 5.0) and the gzip comparison that made it look better still (5.8 vs
5.0) both mislead; what git stores is the number that decides it. The real 22 MB
is 176 city-* posters, which cannot be replaced this way at all.

So the deletion this was leading to is deliberately NOT done: it would have made
the repo bigger while being reported as a saving.
2026-09-24 03:20:50 +00:00
doxa-maps-88655c70
bc06a03ec6 Merge main into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
Three commits: browser-side poster generation, and a visa-data leak closed at
its factory (which deletes two shipped table JSONs). Clean merge. It touched
library/ — PosterWall, library/packet/poster-raster.js, the tables builder — so
the bundle was REBUILT before fix:sri rather than only regenerating the tree.
check:sri and the mobile parity suite both green after.
2026-09-24 03:12:29 +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
d638544870 test(posters): a poster with no pre-rendered image must still download
Some checks are pending
build-gate / build (push) Waiting to run
Gates the client-side poster fallback, and it measures PIXELS rather than
bytes — because the failure mode here is silent and total.

⚠ An SVG loaded through <img> is an ISOLATED DOCUMENT. It cannot see the host
page's CSS, so every var() falls back to its initial value, and `fill`'s initial
value is BLACK. A poster that loses its stylesheet downloads as a black
rectangle with no error, no exception, and a perfectly normal file size. The
prayer packet shipped exactly that bug once already (660 of 661 map elements
painting black, nothing in the console).

So the gate asserts mean luminance and colour variety, not "did it produce
bytes", and it checks the stylesheet actually travelled into the inlined SVG.

Confirmed to FAIL on the real regression before being accepted: with the
<style> block stripped, luminance drops 229 -> 13 and distinct colours 54 -> 2,
and all three assertions fire.

Passing: laos rasterises to 3000x4000, 725 KB, stylesheet inline, luminance 229,
54 distinct colours.
2026-09-24 03:05:27 +00:00
doxa-maps-88655c70
d154a76f64 feat(posters): generate the poster in the browser when none is shipped
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "it would be a big win to not have to have 80mb of images in a public
folder and let the posters get generated by the website."

Downloading a poster used to end at `poster not found (404)` whenever the
pre-rendered .webp was absent. It now falls back to rasterising the poster's own
HTML in the browser — fetch posters/<id>.html, inline its stylesheet into the
<svg>, draw to a canvas, hand back a blob. Same move the prayer packet already
makes: generate on click, ship nothing.

This unblocks dropping the 41 MB of committed .webp posters without degrading a
working feature, which is the part that had to come first. It also covers the
KEY CITY poster wall for free — both walls share this component.

⚠ AN SVG LOADED THROUGH <img> IS AN ISOLATED DOCUMENT: it cannot see the host
page's CSS, so every var() falls back to its initial value and `fill`'s initial
value is BLACK. The packet shipped exactly that bug once (660 of 661 map
elements black, no console error). The stylesheet is therefore inlined INTO the
svg, and the result is checked by measuring pixels — a blank render throws
rather than handing over a black rectangle. Verified by check:poster-raster:
laos rasterises to 3000x4000 with luminance 229 and 54 distinct colours; strip
the <style> block and it drops to 13 and 2 colours and the gate fires.

WHAT I MEASURED AND REJECTED ALONG THE WAY, so nobody repeats it: shipping the
poster HTML *instead of* the webp looked like a clean win on the first sample
(laos: 115 KB gzipped vs 213 KB webp) and does NOT generalise — poster HTML
scales with geometry complexity while webp is a fixed-resolution raster, so
asia-india-rajasthan is 414 KB gzipped vs 239 KB webp. Generalising from one
sample is how that idea nearly shipped.

Also measured: 351 of the 378 committed posters (35.4 MB) are city-* posters
belonging to the Key Cities feature, not to any of the 29 UPG-100 lists. They
are a live feature, not dead weight — which is why this lands as a fallback in
the shared component rather than a deletion.

encodeURIComponent, not btoa, for the data URL: the posters carry non-Latin-1
people-group and place names and btoa throws on those.
2026-09-24 03:05:17 +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
64339a2250 Merge main into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
Four commits of prayer-packet work. Clean merge, but it touched library/ —
SemanticTreeLegend and the new library/packet/* — so the bundle was REBUILT
before fix:sri rather than only regenerating the tree, per the rule that cost
main a stale .js once. i18n suite and check:sri both green after.
2026-09-24 03:02:17 +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
a9faab1662 test(cards): a deck must print as CARDS — assert the measured page size
Some checks are pending
build-gate / build (push) Waiting to run
Gates the prayer-card deck at the rendered layer, and asserts two things a
plausible-looking check would miss.

1. THE MEASURED MediaBox, not the CSS. Chromium SILENTLY falls back to letter
   for a named @page size it does not know — `size: A6 portrait` yields
   612x792pt with no error, while `size: 2.5in 3.5in` yields exactly 180x252pt.
   A gate that grepped the stylesheet would pass a deck that prints as paper,
   which is the entire bug being fixed. It also names the failure: if the page
   comes back 612pt wide it says "this is LETTER: the @page size silently fell
   back".

2. THE VALUES ARE HUMAN-READABLE, not merely present. The first deck rendered
   "LAO", "thm", "EAN" because normalizeRecords() flattens { value, label } to
   the VALUE and parks the label under <field>_label. Counting rows passed it —
   the rows existed, they were just codes on a card meant for praying. The gate
   now flags any field showing a bare 2-4 character token.

Also asserts one card per page, portraits actually loading, a real QR symbol per
card (not an empty path), and no card overflowing its own box.

Expected page sizes are DERIVED from the stock's inches rather than hand-copied:
an earlier constant said A6 was 396.5x559.7pt and FAILED a correct render — the
gate's own arithmetic was wrong, not the renderer.

Verified across all three stocks: poker 180x252pt, postcard 288x432pt, A6
297.12x420pt, 95 cards / 95 pages each.
2026-09-24 02:56:33 +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
097ab02458 test(packet): gate the packet the READER gets, not the one Node writes
Some checks are pending
build-gate / build (push) Waiting to run
check:packet-render verifies a packet FILE from the build-time generator. This
verifies the BROWSER path — the legend's Export > Prayer packet action, which
imports library/packet/packet-render.js, fetches live records and prints
in-place.

Both callers now share that one renderer, and this gate is what proves the
shared module actually runs in a tab. A module can be perfectly correct under
Node and still fail in a browser: a bare process.env reference, a Node-only
import, a JSON import assertion. Node passing says nothing about that.

It drives the real thing: the renderer over HTTP, the app's own normalizer
(the API returns { posts, total } — hand-rolling that extraction is how a probe
ends up testing something the product never does), live records, then
page.pdf() and a PDF page count.

Measured on the browser path: 8 sheets -> 8 pages at 612x792pt, 95 people
cards, 12 wheel segments, Mark 5:1-20 in full, exactly 2 metacamp.org
attributions, 0 page errors.

I18N is asserted here because it is a product requirement, not a nicety: pass a
language and the gate reports which headings came back translated. Verified with
a partial Spanish block — "12 Puntos de Oración" and "Rueda de Oración" rendered
while Mark 5 and both attributions correctly fell back to English. Fallback is
PER KEY, so a half-finished translation renders its finished keys and never
blanks. The stub block was removed after verifying; shipping it would imply a
Spanish translation that does not exist.

Parameterised rather than pinned: PACKET_ORIGIN / PACKET_API / PACKET_ISO3, and
the language as argv. Needs a static server over the repo root.
2026-09-24 02:44:06 +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
7e91040c4a Merge remote-tracking branch 'upstream/main' into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
2026-09-24 02:24:37 +00:00
doxa-maps-88655c70
e0fc1b14d9 Regenerate the derived library index (3 files, not just mine)
check:library-index went red when wagf-region.js landed, and this time the
failure was REAL rather than the mtime artifact it was earlier today —
regenerating produced a genuine content diff, where the earlier run produced a
byte-identical file.

The diff carries three additions, only one of them mine: wagf-region.js, plus
printInPlace.js and retryableAsyncComponent.js, which arrived on main without
the index being regenerated. So the menu was already describing a library that
had moved on. Regenerated whole, since it is derived and partial is meaningless.

Worth keeping the distinction: "the gate is red" and "the gate is red because of
me" are different claims, and the way to tell them apart is to regenerate and
diff rather than to reason about mtimes.
2026-09-24 02:24:36 +00:00
doxa-maps-88655c70
3b6cfc69b5 A validated WAGF region palette, where the one-palette gate requires it
Groundwork for the no-selection chooser: for the seven regions to read AS
regions the moment the map loads, pins have to be colored by region, and no
existing strategy does that (_registry.js and religion.js both reference a
doxa-region.js that is not in this tree).

PLACEMENT IS NOT A PREFERENCE HERE. check:one-palette requires every hex a
consumer paints with to come from a strategy module in library/colors/,
discovered by _registry.js. That gate exists because a dashboard composable
once declared PRIVATE palettes the map had never seen — two surfaces then
disagreed about what a color meant, and one keyed color by ARRAY INDEX over a
count-sorted list, so a religion CHANGED COLOR as you filtered. A lookup table
inside my component is the exact shape that caused it.

COLOR FOLLOWS THE ENTITY, NEVER ITS RANK: the seven slots are assigned in a
fixed ALPHABETICAL order of the region's own value, deliberately not by size.
Asia has 1309 people groups today and Oceania 24; ordering by count would mean
a cross-filter that moves the counts repaints the survivors. Alphabetical is
arbitrary but STABLE, which is the property that matters.

THE PALETTE WAS VALIDATED, NOT EYEBALLED. Run through the dataviz validator and
re-stepped twice until every check passed with NO warning: lightness band PASS ·
chroma floor PASS (an earlier teal #0e7490 FAILED at 0.094, reading gray) · CVD
separation PASS at dE 8.6 deutan for the worst adjacent pair (6–8 is a floor
band that would have been legal only with secondary encoding) · normal-vision
floor PASS at dE 30.6 · contrast vs surface PASS, all seven >= 3:1 (an earlier
amber #f59e0b WARNed at 2.09:1, and the guidance is explicit that a contrast
warning is not dismissable).

Two notes on running that validator, because both cost time. Copied to a new
filename it silently does nothing and exits 0 — its CLI guard requires
process.argv[1] to end in "validate_palette.js", so a rename turns it into a
no-op that looks like a pass. And it is an ES module, so it needs a directory
with {"type":"module"} rather than a bare `node file.js`.

The hex ORDER in the file IS the validated order: the CVD check measures
ADJACENT pairs, so the legend must render regions in that same fixed order for
the result to keep holding. Noted in the file so a future reorder re-validates.
2026-09-24 02:24:08 +00:00
doxa-maps-88655c70
75c16ac32d Hygiene gate: process-vocabulary patterns + JWT claim decoder (public-exposure audit)
Some checks are pending
build-gate / build (push) Waiting to run
Two widenings from the 2026-09-24 anonymous-clone audit:

1. LEAK_STRICT: role nouns (the Driver/Driver), private repo/org names,
   forge/platform names, operational-context vocabulary, CI workflow
   paths. Every one was found live in a public mirror before this rule
   existed. WARNINGS in the normal build (the source tree still
   carries them; failing build:unlocked overnight gets a gate
   disabled), FINDINGS under --strict -- which the publish/export flow
   must run: publishing is a gated action, not a habit.

2. A credential is a DOCUMENT, not a string: every JWT-shaped value's
   payload is decoded and name-like claims flagged. The pk token
   accepted by the 2026-09-21 ruling carries a u claim naming a real
   account -- the ruling covered the token's scope, not the identity
   inside it.

Both modes negative-tested: strict exits 1 against the current tree
(110 findings), normal exits 0 (118 warnings).
2026-09-24 02:23:48 +00:00
doxa-maps-88655c70
dd2e7d3c2a hook probe 5 (locked) 2026-09-24 02:23:40 +00:00
doxa-maps-88655c70
f888327d93 hook probe 2026-09-24 02:22:19 +00:00
doxa-maps-88655c70
fd2077625a feat(posters): capitals and major cities on the country maps
Some checks are pending
build-gate / build (push) Waiting to run
The Driver asked for map detail: "a typical map poster needs details on the
map that is not hard to see, but not so much that it becomes too complicated
— cities and capitals makes sense."

Natural Earth 1:10m, PUBLIC DOMAIN: no attribution to carry onto a printed
packet, vendored so a build is reproducible offline with no runtime
dependency. Trimmed at vendor time from 4.9 MB / 7,342 places to 170 KB /
2,573 (national + admin-1 capitals, plus cities >= 250k) because a poster
cannot legibly carry 7,342 dots and the packet is downloaded.

The layer is deliberately SUBORDINATE. It answers "where am I?"; the
people-group pins answer "who do I pray for?", and they keep the only
saturated colour on the sheet. Capitals are ringed dots (the cartographic
convention), cities plain dots — shape and value carry the rank so no extra
hue is spent. Reading order stays country > province > city > people group.

Three things had to be right, each found by measuring rather than by looking:

1. RANK BY RELEVANCE TO THE SUBJECT, not by global importance. Sorting by
   capital-rank then population is the obvious choice and is wrong: the
   neighbours are usually bigger. On the Laos sheet that sort spent the budget
   on 12 Vietnamese and 8 Thai cities and printed exactly ONE Laotian place
   while 12 more sat available — a poster of Laos labelled with other
   countries' cities. Subject countries now win the budget.

2. DERIVE THE SUBJECT FROM THE PINS, not from the zone. zone.adm0 exists only
   on generated city/country zones; the hand-authored lists carry a selector
   in SEVEN shapes (pg_slugs, country_code, country_codes, wagf_block,
   wagf_region, union, deaf_predicate) and the most common names no country at
   all. Reading zone.adm0 produced an empty set and the whole sort silently did
   nothing. The pins always know their own country.

3. AVOID THE PINS USING WHERE THEY ARE DRAWN. Two distinct errors here. Testing
   the label's ANCHOR is wrong for a right-aligned label, whose glyphs run
   leftward from it — so the box is tested, not the point. And the de-overlap
   relaxation MOVES badges after projection, so proj(pin.lng,pin.lat) is not
   where the badge lands; this block therefore runs AFTER layoutLabels and reads
   laid[].px/py. With the naive tests, 7 of 25 labels sat within 14px of a pin
   and Saravan landed 1.4px away while the check reported it clear. Now: 291
   labels across 16 posters, ZERO overlaps. A city that cannot fit clear of a
   pin is dropped — a missing city costs nothing, an obscured people group
   costs the poster its purpose.

⚠ ROADS WERE REQUESTED AND DELIBERATELY NOT SHIPPED. Natural Earth's 10m roads
layer was fetched and measured: ZERO features for LAO, MMR, KHM, VNM, THA, NPL,
BTN, TCD, NER, MLI, SOM, AFG, YEM, IDN, IND and CHN. Its coverage is essentially
USA/CAN/MEX. It would draw a dense network on a US map and nothing on every
country these packets are for — a broken-looking map exactly where it matters.
Real road coverage needs an OSM extract: a much larger dependency with an ODbL
attribution obligation, so it is carded rather than half-shipped. See
data/README.md.

Note: zone oceania-indonesia fails generation with a header-overflow error.
Verified pre-existing on a clean tree — unrelated to this change.
2026-09-24 02:21:03 +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
2edb83857e Merge remote-tracking branch 'upstream/main' into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
2026-09-24 02:06:10 +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
10567371e3 README: map-cruncher hero, cross-origin deploy certification shots, repo tree
Some checks are pending
build-gate / build (push) Waiting to run
The three hand-off README items the definition-of-done still listed:
(1) the map-cruncher hero -- an honest ASCII flow of the product's
whole argument (many maps -> one library -> one build -> five
targets); (2) the two 2026-09-24 certification screenshots showing the
ENTIRE cap-5 app rendering on a foreign host page via iframe and via
JS embed, with the host-page contract note (window.MAP_TOKEN + CORS);
(3) an annotated repo tree naming the free zone, the shared @map zone
and its high bar, and what the build output is.
2026-09-24 02:00:00 +00:00
doxa-maps-88655c70
0cabe8ee28 serve-strict sends Access-Control-Allow-Origin: * -- the tree is embeddable assets
Some checks are pending
build-gate / build (push) Waiting to run
The drop-anywhere contract means third-party sites FETCH manifest.json
and assets/ cross-origin, and fetch() enforces CORS where a <script>
tag does not. Measured from a second local origin: the cap-5 shell
mounted and every manifest-composed tab died. With the header, the
whole cap-5 app renders on a foreign origin via the manifest's own
embed snippet: globe painted, 2,107 rows, zero console errors.
(The LIVE static layer has the same gap -- Traefik ready-fix carded,
gated on the live-deploy rule.)
2026-09-24 01:55:20 +00:00
doxa-maps-88655c70
4d9ac89f6e test(packet): gate the prayer packet at the RENDERED layer
Some checks are pending
build-gate / build (push) Waiting to run
Every bug this gate exists for produced a file of the right size, with no
error, no broken image, and a generator reporting success. They were only
visible once a browser laid the document out, so this drives a real browser
and reads computed style rather than inspecting the HTML source.

★ The law it encodes: never print a number without asserting on it. The
throwaway probe this replaces PRINTED "PDF pages: 16" for several runs while
reporting "packet verified", because the failure counter never looked at that
value. A number that is displayed but not asserted on is decoration.

Two regressions it catches, both confirmed to FAIL before this was accepted:

  transform instead of zoom -> 16 PDF pages for 8 sheets
  map stylesheet removed    -> unresolved --paper/--sea/--frameline;
                               660/661 map elements fill BLACK

The second is the subtler one. The map SVG carries almost no presentation
attributes; it is styled from the poster document's stylesheet. Lift the
<svg> without those rules and SVG fill falls back to its initial value, which
is black — the whole map paints as a solid rectangle with nothing to see in
the DOM, no console error, and no broken image. It is asserted as a
WHOLE-MAP property because the first attempt tested one hand-picked <rect>
(the one just repaired) and went green while 647 others were still black.

Page count is load-bearing rather than cosmetic: the packet is printed
front-and-back, so "one sheet is exactly one page" is a correctness property,
not a tidiness one.

Counts are relative (sheets vs pages, cards vs photos) so the gate is not
pinned to one zone's group count, and the packet path is an argument or
PACKET_HTML rather than a hardcoded path. With no packet given it SKIPS
rather than failing, since the packet is generated on demand.
2026-09-24 01:53:21 +00:00
doxa-maps-88655c70
bf41240fa5 README: showcase the capabilities MOST-ADVANCED FIRST, with today's product
Some checks are pending
build-gate / build (push) Waiting to run
Driver: 'showcase the capabilities from capability five down to
capability one using screenshots from real maps' -- ordered by
ADVANCEMENT, not complexity ('we don't want to scare people'). The
section now opens on the cap-5 app shell and walks down; the ladder's
point (every rung below stays independently embeddable) is stated up
front.

Four flagship images refreshed to today's product: cap-5 shell now
shows the galaxy globe + Vertical Legend + current nav; cap-4 UPG-100
opens on the Prayer Resources poster wall (the Driver's tab-one
ruling); cap-3 research map and cap-2 prayer preset re-shot current.
The old shots predated the rename, the greyscale ramp and the globe.
2026-09-24 01:42:56 +00:00
doxa-maps-88655c70
1c24759e21 feat(posters): UPG-100 prayer packet generator, letter portrait, i18n
Some checks are pending
build-gate / build (push) Waiting to run
The packet as specified: page 1 the map one-pager, pages 2..n the people
groups 20-per-page with photos, then the 12 prayer points, then the prayer
wheel. Every string is externalised so a new language is a new block in
packet-strings.json, not a code change; fallback is PER KEY, so a partial
translation renders what it has and leaves the rest in English rather than
blanking. Verified with a partial es block before removing the stub.

Three bugs worth recording, each of which rendered silently wrong:

1. zoom, NOT transform:scale(), for print. transform is paint-time, so the
   sheet still RESERVES its 1941px layout box and the paginator breaks it
   across two pages, inking only the top of the first: 8 sheets came out as
   16 PDF pages. Negative margins do not help — they collapse the outer
   space, not the fragmentation box. zoom is layout-time, so pagination sees
   the scaled box. Measured: transform 2 pages/sheet, transform+margins 2,
   zoom 1.

2. The map SVG carries almost no presentation attributes — generate.mjs
   styles it from the poster document's stylesheet. Lifting the <svg> alone
   left those rules behind, and SVG fill falls back to its initial value,
   which is BLACK: 670 elements painted solid black with no error, no broken
   image, nothing any existing assertion could see. Fixed by carrying the
   poster's stylesheet minus its document-level rules, so a palette change
   in generate.mjs still follows the packet automatically.

3. LANG is the shell's POSIX locale ("en_US.UTF-8"), not a BCP-47 tag, and
   throws in localeCompare. PACKET_LANG is the only source.

The verification lesson is the bigger one. The probe PRINTED "PDF pages: 16"
for several runs while reporting the packet verified, because the bad counter
never looked at that value — a number that is displayed but not asserted on
is decoration. Then the first fix for the black map asserted on the first
<rect>, the one element just repaired, and went green while 647 others were
still black. Both assertions now test the property rather than a proxy: one
sheet must be exactly one page, and the map as a whole must not be
predominantly black-filled. Both were confirmed to FAIL on the known-broken
input before being accepted.
2026-09-24 01:39:26 +00:00
doxa-maps-88655c70
32a073d3cc Rebuild regions-dashboard after the merge — main had my source and its old bundle
MY ERROR, caught by re-running the live diff on what I had just pushed. The
push loop resolved the merge's generated-file conflicts from main and then ran
`bun run tree` + `fix:sri` — but NOT `BUNDLE=regions-dashboard vite build`. So
main briefly carried the new source (the vendored font, the restored sizes)
alongside main's older .js, which contains none of it: grep for "bebas-neue" in
the shipped bundle returned 0.

The tell was the same one that caught the 404: document.fonts reported
`registered faces: []` — not an error, NOTHING REGISTERED — because the code
that registers the face was not in the bundle. The computed-style diff dropped
straight back to 61/68 with the 7 font differences, which is exactly what a
stale bundle looks like from the outside.

Rebuilt from the merged source: bundle references the font, face "loaded",
68/68 identical again, check:sri green.

The lesson for the loop: on a merge that touches a bundle's SOURCE, regenerating
the tree is not enough — `tree` rewrites pages and the manifest from source but
never compiles a bundle. The bundle has to be rebuilt before fix:sri, or the
hashes agree about the wrong bytes.
2026-09-24 01:38:02 +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
3bafe7cd55 Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
#	public/doxa-maps/prayer-walk-planner/prayer-walk-planner.js
#	public/doxa-maps/prayer-walk-planner/prayer-walk-planner/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 01:15:03 +00:00
doxa-maps-88655c70
79353ca66e One page, one map token — and the nested search bar comes back
"the element gets the tk to the element itself and does not need to only
gatekeep that to one component only — all components in a plugin can share
that tk" (Driver, 2026-09-23).

Inside ONE element that was already true: ProfileLoader provides
'mapboxToken' and eleven components inject it. The gate is the ELEMENT
BOUNDARY — every custom element mounts its own app with its own
ProfileLoader, which re-derives the token from its own profile-config, so a
composite that nests a real map hands the child nothing.

THE EXISTING FALLBACK COULD NOT FIRE. The ladder already read
window.MAP_TOKEN. MEASURED: that global IS set on the composite leaf and
the child still had no search bar — because the ladder lives in a
computed() and window.MAP_TOKEN is a plain global, not a reactive source.
The composite leaf FETCHES its token, so the computed resolved '' first,
cached, and never re-ran. useSharedMapToken makes it a ref that updates on
publish, and assignments to window.MAP_TOKEN are adopted through an
accessor, so already-generated pages keep working untouched. Storage lives
on window and changes travel as an event: each bundle is its own IIFE, so a
module-level ref is NOT shared between a composite and the bundle it nests.

THAT WAS NECESSARY AND NOT SUFFICIENT, which only measuring showed. With
the token shared and reactive the bar was STILL absent. Real cause:
GeocoderComponent's onMounted hit `typeof MapboxGeocoder === 'undefined'`
and RETURNED SILENTLY — no throw, no console error, no DOM. MapboxGeocoder
is "preloaded via index.html", true for a LEAF and false for a COMPOSITE
leaf, which generate-tree classifies as token-free while it goes on to nest
a real map. Measured, mapbox-gl-geocoder references per page: research-map
leaf 2, research-map-plus-grid leaf 0.

So the script is now fetched on demand when a host did not preload it,
reusing the exact shape the maplibre path already had (one loader, two
callers) and pinned to the same v5.0.0 the leaves preload. It resolves on
error as well as load, so a blocked CDN leaves the map usable and the bar
absent rather than hanging. This fixes every already-generated page without
regenerating any of them.

MEASURED on the built tree:
  leaf mapbox     search@56    legend@104
  leaf maplibre   search@NONE  legend@58    (unchanged: maplibre stays bar-free)
  composite       search@56    legend@104   (was NONE)

library/ reaches every bundle, so all 22 rebuilt: 22/22, 7/7 pre, 18/18
post, and a browser sweep of five StackLayout consumers plus all seven
studio dashboards.
2026-09-24 01:14:37 +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