Commit graph

193 commits

Author SHA1 Message Date
doxa-maps-88655c70
4eec165a2b Merge remote-tracking branch 'origin/main' into shepherd/merge-b3
Some checks are pending
build-gate / build (push) Waiting to run
2026-09-24 14:24:48 +00:00
doxa-maps-88655c70
64fed43449 packet: stamp how a packet was built, into the packet
Some checks are pending
build-gate / build (push) Waiting to run
out/packets/ is gitignored, so check:packets-all grades whatever .html files
happen to be sitting there — an accidental set, not a current one.

On this machine that was 11 freshly generated packets. In
doxa-maps-plugin-studio, which shares main with this worktree, it was two
NOPHOTOS=1 layout probes of mine from earlier ticks, and the sweep was RED there
for anyone who ran the wave. The gate was honest about each file it checked; the
set it checked was accidental.

The underlying problem is that a fast layout-iteration build and a shippable one
were byte-indistinguishable. The packet carried lang and dir and nothing about
its own provenance, so the only way to tell them apart was for the person
running the gate to remember and pass PACKET_EXPECT_PHOTOS=0.

packet.mjs now stamps data-packet-build with the flags it generated under, and
the gate reads it. The GENERATOR writes it rather than the gate inferring it:
had the gate guessed "no photos present, so probably a probe", it would have
excused genuine photo-fetch failures. Tested explicitly — a packet stamped
"full" whose map svg is then stripped STILL FAILS. The stamp records intent and
cannot excuse a defect the build did not declare.

Deliberately not a timestamp. An mtime staleness rule was considered and
rejected on evidence from this same session: packet-strings.json was 303s newer
than the packets while byte-identical to HEAD, because a probe had restored it
from backup, so an mtime rule would have failed a correct tree — the exact
mistake fixed two commits ago in the library-index gate. Content comparison is
not available here (regenerating hits a live API and takes minutes), so the
build records its intent instead of the clock.

The studio sweep goes from "2 checked, 0 passed" to "1 checked, 1 passed", with
the packet self-declaring build="nophotos nomap". Env vars stay as an override
for pre-stamp artefacts, and the declared-deliberate note now names whichever
source actually declared it rather than always crediting the env var.

All 11 locales stamp "full". i18n re-verified with a partial block (1 string,
3/12 points, 6/12 rhythms): wheel 6 translated and 6 English, 12 point rows with
6 translated, 0 blanks. Wave 34/34, packets-all 11/11 here and 1/1 in the studio.
2026-09-24 14:21:45 +00:00
doxa-maps-88655c70
445131cf94 Merge main into bundler-3/work — settle the 21-file conflict, keep both campaigns
Only 2 files conflicted in source: package.json and its CLI doc, both taken to
main's refactored gate pipeline (gates:pre/gates:post/build:bundles) after
verifying every check the old inline chain named still runs there. The other 19
were generated: public/assets/tables mirrors keep main's deliberate deletion,
and public/doxa-maps is regenerated by the build that follows.
2026-09-24 14:16:47 +00:00
doxa-maps-88655c70
e130cb672a packet generator: say when page 1 has no map, instead of printing a tick
Some checks are pending
build-gate / build (push) Waiting to run
`✓ laos  95 groups  8 pages  0 photos` — for a packet whose first page is blank.
The page count stayed 8 because a blank sheet is still a sheet.

The map one-pager is lifted from out/posters/<zone>.html, which a separate
manual step writes. When it is absent packet.mjs deliberately still builds, and
that fallback is right — but it was silent. The empty-groups branch three lines
above already warns and skips; the map branch had the same shape and no warning.

A gate catching this is not the same as the tool reporting it. The gate added
last tick runs later, optionally, and only when someone invokes it. The
generator prints its tick at the moment of production, which is the one output
an operator is guaranteed to see.

Now it prints ⚠ with the reason inline and repeats it in an end-of-run summary
naming every affected zone and the fix — a per-line mark scrolls off in a
27-zone run, and this is the one fault that cannot be seen by counting pages.

Both silent branches are covered, distinctly: a missing poster reports "no
out/posters/laos.html"; a poster that exists but carries no <svg> — a truncated
generate run — reports "out/posters/laos.html has no <svg>". An operator can
tell "never generated" from "generation broke".

Also adds the `packets` script. Its absence is why the posters -> packets
dependency was never expressible anywhere: packet generation was only ever a
hand-typed node invocation, so nothing could state what it reads.

Measured in both repos. doxa-maps-plugin-studio has 0 generated posters against
wt-packet's 28 — out/ is gitignored in both, so posters are build artifacts and
this is a missing build step, not a repo defect. A fresh studio run now warns;
wt-packet prints a clean tick. The size difference corroborates: 0.4 MB with the
map, 0.1 MB without.

i18n re-verified end to end since this touches the generator: a partial locale
block (2 strings, 3/12 points, 4/12 rhythms) renders the wheel 4 translated and
8 English, 12 point rows with 4 translated, 0 blanks, clean tick. All 11 locales
regenerated with photos — packets-all 11/11, each 12/12 wheel labels, 12/12
points, 13 geography paths. Wave 34/34.
2026-09-24 13:53:57 +00:00
doxa-maps-88655c70
a177f98aff legend: the controls no longer read their own i18n key aloud
Some checks are pending
build-gate / build (push) Waiting to run
The legend's toggle button announced the literal string "aria.toggleLegend" to
screen readers. Not in translated locales only — in English too. vue-i18n echoes
the key when a lookup misses, and `toggleLegend` was absent from every catalog
while its two siblings in the same file, backToLegend and closeDetailView, were
present. Six call sites across LegendDesktop.vue and LegendMobile.vue, desktop
and mobile alike.

aria-label is the one string a sighted tester never sees. Every other
untranslated string in this tree got caught because somebody looked at the
screen; this one is only ever spoken aloud, so it survived every visual QA pass.

A second instance turned up in the same sweep with the opposite shape:
SemanticTreeLegend hardcoded 'Collapse legend' / 'Expand legend' into both title
and aria-label with no t() at all — correct English, and untranslatable. One key
missing FROM the catalog, one string never routed TO it.

Adds toggleLegend, collapseLegend and expandLegend to all 11 aria.json catalogs
and routes the hardcoded pair through t(). check:i18n-parity already guards them.

New gate smoke:aria-labels asserts BOTH shapes, because either assertion alone
is insufficient: "no raw keys" passes a hardcoded English label forever, and
"labels differ across locales" passes a raw key, since a key is identical in
every locale. It detects a raw key by SHAPE — dotted, camelCase, no spaces —
which is locale-independent and works on the Arabic and Chinese pages where no
English word list could.

Mutation-tested twice. Removing the catalog key fails with the raw key named, on
all four viewport/locale combinations. Hardcoding one label back to English
initially PASSED: the differ count dropped 6 to 5 and nothing asserted on it —
the same "a number printed but not asserted on is decoration" shape this tree
keeps rediscovering — so the gate gained a LOCALIZED_FLOOR ratchet and now fails
that too.

Verified the fix generalizes beyond the profile it was found on: country-posters
reports 182 of 190 accessible names localized, 0 raw keys. Rebuilds the six
bundles that embed a legend; check:sri confirms every leaf matches. Wave 34/34.
2026-09-24 13:16:18 +00:00
doxa-maps-88655c70
6af75895c7 legend: the info popovers explain the map in the reader's own language
Some checks are pending
build-gate / build (push) Waiting to run
The Driver asked for the per-tab info buttons to stay — "the tab itself gives
you a little information button that will explain what a UPG-100 list is."
They existed, and they were English-only in every locale.

SemanticTreeLegend rendered `tab.info` RAW in all three places it appears (the
button's title=, and the popover body) while the tab LABEL beside it went
through the catalog. So a Spanish reader saw a translated chip next to an
English explanation of what that chip means — and the explanation is the part a
reader who does not read English actually needs. The three UPG-100 tab labels
were absent from tabs.json entirely, so they fell back to hardcoded English too.

Measured on the served leaf before changing anything: ?lang=es changed 2 of 76
rendered strings ("POPULATION" -> "POBLACIÓN") and left all three tabs and all
three info popovers in English. That positive control is what proves the locale
switch WORKS and the strings were simply never catalogued — without it this
reads identically to a broken ?lang=.

tabInfo() mirrors tabLabel exactly: `legend.tabInfo.<i18nKey|id>`, te() guard,
falling back to the hardcoded English so a tab with no catalog entry renders
unchanged rather than blank. The v-if stays keyed on the raw `tab.info`:
whether the affordance EXISTS is a property of the tab definition, while its
TEXT comes from the catalog.

Catalogued in all 11 locales, labels and info. check:i18n-parity already guards
them — deleting one zh key fails the build by name.

New gate smoke:legend-info renders the REAL served page and clicks each info
button. check:tabs-localized could not see any of this: it tests 7 hardcoded
research-map tabs and REIMPLEMENTS tabLabel inside page.evaluate, so it proves
the mechanism works rather than that a given profile is localized — and it never
looked at `info` at all. Mutation-tested: reverting the popover to the raw prop
fails with "the catalog is present and unreached".

Partial-translation contract verified by deleting 2 of 3 fr info keys and 1 of 3
fr labels: translated keys render French, untranslated fall back to English,
nothing blank, and labels and info resolve INDEPENDENTLY.

Rebuilds the six bundles that embed the legend so the tree stays internally
consistent; check:sri confirms all 55 script references match. Full wave 34/34.
2026-09-24 12:49:04 +00:00
doxa-maps-88655c70
e8ad1d56e6 packet: a partial translation renders 12 prayer points, not 3
Some checks are pending
build-gate / build (push) Waiting to run
packet-render.js resolved the prayer points with `L.points || EN.points`.
That is a whole-block fallback when the key is ABSENT, and TRUNCATING when a
translator has done some of the 12 — a 3-element array is truthy. Measured at
the rendered layer with a deliberately partial locale block: a page headed
"12 Prayer Points" containing THREE, points 4..12 silently gone, including the
Mark 5:1-20 passage point 4 prints in full. Nothing blank, no error raised.

Invisible in all 11 shipping locales because every one of them is complete.
It would have appeared on the next language added — and "a new language is a
NEW BLOCK, never a code change" only holds if a PARTIAL block degrades per key,
since half-finished is the normal state of a language in progress.

Now merges per point and per field against English: English fixes the LENGTH
and supplies every missing field, so a point translated for `text` but not
`sub` keeps the English `sub` — and keeps its NUMBER, which the merge supplies.

The sheet-sum invariant could not see this: a 3-point page is still exactly one
sheet, so every number check-packet-render printed stayed correct while nine
points were missing. It now measures and asserts the count. Mutation-tested —
reverting the fix prints "prayer points : 2 / 12" and fails.

All 11 locales regenerated with photos and swept: check:packets-all 11/11, each
12/12 points, including RTL ar and Devanagari hi. Renderer, generator and gate
synced to doxa-maps-plugin-studio, which also lacked the .points-title and
.wheel-title test hooks the new assertion needs.

Also wires check:capability-exemplars into gates:post (33 -> 34 steps). It reads
public/doxa-maps/manifest.json, a build artifact, so ordering mattered: `tree`
writes the manifest and is &&-chained immediately before gates:post, and the
gate SKIPs rather than fails when the manifest is absent. Full wave: 34/34.
The quoted gates:post line in the README is re-synced in the same commit,
because check:quoted-scripts goes red on the drift this commit creates.
2026-09-24 12:20:38 +00:00
doxa-maps-88655c70
3519ca3934 check:packet-browser asserts its headings, and stops reading them in English
This gate printed nine metrics, folded six into `const bad = a || b || ...` — so a
failure said "FAIL" without naming which — and left three unchecked: page size and
the two headings, which are the very strings its own header claims it covers ("the
gate reports which headings came back translated"). Reports, not asserts.

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

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

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

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

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

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

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

That makes four gates with one root cause (tabs-localized, packet-render,
deck-click, and this one twice). Any new gate matching on user-visible text should
resolve it from the catalog.
2026-09-24 10:50:27 +00:00
doxa-maps-88655c70
87bd98d610 Restore the clickable poster preview — the last capability the migration dropped
Some checks are pending
build-gate / build (push) Waiting to run
Migrating my-upg-100-list onto the shared wall left one behind, and it was not
decoration: clicking the poster preview opened it for printing, a Driver-named
behaviour ("show the poster in the browser to help print it"). I removed it, so
this is a regression I introduced and am closing.

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

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

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

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

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

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

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

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

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

VERIFIED AT THE RENDERED LAYER, all three print actions on the migrated wall:
27 cards, 27 stock pickers, 4 buttons per card — the original's three prints plus
show-on-map. CARD DECK 116 cards / 116 QR codes / @page 2.5in x 3.5in / 0 errors,
identical to pre-migration. POSTER PRINT with every .webp aborted: 1 .html fetched,
"generating the poster", clean finish. PRAYER LIST: 1 people-groups fetch, 0 errors
— positive evidence the provider ran, since that print cannot work without it.
REGRESSION CHECK: country-posters still downloads (0 print frames, 1 download), so
the default is unchanged for the walls already on this component.
2026-09-24 09:54:40 +00:00
doxa-maps-88655c70
9f076ffbc8 PosterWall can print the poster instead of downloading it — the Driver-rejected UX it still encoded
★ THE BLOCKER ON MIGRATING THE 100-LIST WAS NEVER ONLY A MISSING CAPABILITY. It was
a Driver-rejected UX baked into the shared component, and migrating as planned would
have shipped it.

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

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

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

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

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

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

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

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

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

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

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

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

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

VERIFIED: the derivation unit-tested against the real STOCK table — all five stocks,
a subset, a subset EXCLUDING the configured default (falls back correctly), and an
unknown id (dropped, not rendered blank). Not yet exercised in a browser because the
picker is gated on a `records` provider and NO wall passes :records to the shared
component yet — the 100-list still holds its provider in its own file. Opting
country-posters in temporarily gave 0 pickers and 0 errors, which is that gate working
as designed. The migration will exercise it end to end.
2026-09-24 09:24:29 +00:00
doxa-maps-88655c70
e0f2d522bb All eleven locales complete — the last 162 keys of i18n debt, and the ratchet empties
Some checks are pending
build-gate / build (push) Waiting to run
regions.json for it/ro/zh, 54 keys each. check:i18n-parity now reports "0 key(s) of
known translation debt remain", closing the 429-key gap it found when it was written.

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

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

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

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

zh is SIMPLIFIED, measured not assumed: the existing zh catalogs carry 33
Simplified-only characters against 4 Traditional. Vocabulary follows each locale's
own established terms — Popolo / Popor / 民族, Regione / Regiune / 区域 — and WAGF
stays an acronym, as fr and de already had it.
2026-09-24 08:48:02 +00:00
doxa-maps-88655c70
26f60dae29 Merge main into bundler-1/work (regenerate the tree, re-hash)
Some checks are pending
build-gate / build (push) Waiting to run
2026-09-24 08:19:29 +00:00
doxa-maps-88655c70
e390454d31 Map controls: restore the keyboard focus ring that outline:none removed
The brief says of the no-selection chooser: "Keep the dropdowns as the
keyboard/screen-reader path." Pin clicking is mouse-only by
construction, so that path is load-bearing — and nothing had ever
pressed Tab. There was zero keyboard coverage across 42 probes.

The path itself is sound, and is now asserted: 30 distinct focus stops;
the chooser's region keys are real focusable buttons and Enter on one
selects it ("All Regions" to "Africa") with no mouse involved; the
SELECT VIEW strip carries role=tab on both buttons with exactly one
aria-selected.

The defect that fell out: of those 30 stops, 29 showed a 2px ring and
one showed outline:none — button.mcb, the shared MapControlButton. Its
line 38 sets outline:none deliberately, to kill the webkit focus box on
click/tap alongside -webkit-tap-highlight-color, but it had no
:focus-visible replacement. So a sighted keyboard user tabbing onto ANY
map control in ANY bundle saw nothing at all. The accessible name was
already correct (aria-label mirrors title, fixed 2026-09-21), so screen
readers were never affected — only the people who navigate by eye and
keyboard, which is the very group outline:none was written for.

The fix is a :focus-visible rule plus a .dark variant. :focus-visible
is the correct half of the pair: it never fires for a mouse click, so
the original reason for outline:none still holds. Consumers re-checked
per RULE 2 — ShareButton, ZoomIn, ZoomOut, Fullscreen, Help,
ThemeToggle, Location, map-controls/index.js, my-upg-100-list and
research-dashboard's DashboardDataTable — all render the button
icon-only and none styled focus themselves, so the rule only adds a
ring where there was none. All five affected bundles rebuilt.

Proven both ways: Tab gives solid 2px rgb(26,115,232) with
:focus-visible true and the "Share" label intact; a real mouse click on
a fresh page gives outline:none with :focus-visible false.

Three of my own errors on the way, each caught before it shipped. I
first read a region key's name as "Africa315" and nearly fixed a
non-bug — the button already carries aria-label="Open the Africa
dashboard", which wins over textContent, and the label and count have a
real 7.5px flex gap. I put backticks in a CSS comment inside a template
literal, which ended the string and broke four of five builds;
check:sfc-parse caught it at once. And my mouse assertion failed twice
on correct behaviour: synthetic PointerEvents with a manual focus() do
not update the browser's pointer-modality heuristic, and clicking the
button that had just held keyboard focus leaves the page in keyboard
modality. Only a fresh page with no key ever pressed is a clean test.

Regression: 22 suites green at the time of commit, including new
keyboard and focus-ring suites.
2026-09-24 08:14:01 +00:00
doxa-maps-88655c70
3a468c2bc3 The poster print preview rasterises too, and a deployment can ship sources without rasters
Some checks are pending
build-gate / build (push) Waiting to run
posters/*.webp is not shipped by default (42MB; bundling pushed the tree to 80MB,
git-packed to 57MiB, and the catalog rejected the push). The DOWNLOAD path has
handled that since d154a76 — PosterWall fetches posters/<id>.html and rasterises
it in the browser. The PRINT path did not: it handed an <img> a url it never
checked, so on a default drop the SAME poster downloaded fine and PRINTED as
"Poster not available yet." That is the bad first impression
IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF called out by name.

Three things were missing; all three are here:

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

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

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

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

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

The 28 poster sources are committed because the fallback has no input without
them. city-* posters are deliberately NOT covered: they fetch OSM street tiles at
generation time, so they are not reproducible offline and their rasters must stay.
2026-09-24 08:07:22 +00:00
doxa-maps-88655c70
e952e01e4c Assert the packet numbers that were only being printed, and run the gate on every locale
check-packet-render's own header states the law it exists for — "never print a number
without asserting on it" — and two of the nine numbers it printed were decoration.

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

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

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

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

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

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

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

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

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

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

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

Also fixed, found by the build rather than sought:
- check-i18n-parity hardcoded an absolute path, so the studio copy silently
  graded wt-packet's locales and reported PASS on a tree it never read. Now
  repo-relative. That exposed the studio's own locale tree as a strict subset,
  103 keys behind including buttons.json:share — the very orphan this gate's
  header says it was written to catch, reappeared unseen in a second repo.
  Synced forward: zero studio-only keys, zero text conflicts, lossless.
- index.json still declared field-access-visas with rows:1146, a leftover from
  the commit that removed that table as private. The generator already filters
  private tables; the committed artifact predated the filter. Removing the
  declaration also removes the row-count leak. Copying the file in would have
  reopened the leak.
- check:sfc-parse needs an installed node_modules and is the first pre-gate to
  fail without one, so a dependency-less worktree reads as a parse error.
  Documented, with the reason switching it to bun only appears to fix it.
2026-09-24 06:23:30 +00:00
doxa-maps-88655c70
7580274ed4 feat(i18n): German and Hindi reach complete — 7 of 11 locales done
Some checks are pending
build-gate / build (push) Waiting to run
Continues paying the debt the parity gate measured, cheapest first.

German and Hindi carried an IDENTICAL 22-key gap — the same legend and search
keys, added to English after those two catalogs were last touched. One batch of
work closed German entirely and took Hindi to 18, which was its whole missing
tabs.json. Both are now complete: 344 -> 282 keys.

Terminology follows each catalog's OWN existing vocabulary rather than inventing
a second one for the same concepts — de keeps Volk/Sprachfamilien, hi keeps
जनसमूह/भाषा परिवार. Tab labels stay SHORT because they sit in a narrow rail:
Spanish already uses "Pueblos" rather than a literal rendering, so Hindi does
the same. UUPG, WAGF and DOXA remain acronyms, as in every locale.

Seven locales (ar es fr pt ru de hi) are now DELETED from the gate's DEBT map
rather than set to zero — an absent entry means "must be complete", so deletion
is what converts finished work into a permanent guarantee. Verified: removing
one Hindi tab key reports "hi is missing 1/176 key(s) and has no DEBT entry".

Verified at the RENDERED layer: "View country posters" comes back as
"Länderposter ansehen" (de) and "देश के पोस्टर देखें" (hi), zero page errors —
which also confirms Devanagari shapes correctly in the APP, not only in the
prayer packet where the font was first vendored.

The three remaining (it, ro, zh at 94 each) are missing WHOLE FILES —
regions.json and tabs.json — so they are real translation work rather than the
propagation gaps closed here.
2026-09-24 05:58:16 +00:00
doxa-maps-88655c70
a487e48cbf feat(i18n): five locales reach complete — popup.json and six orphaned keys
Some checks are pending
build-gate / build (push) Waiting to run
Working the 429-key debt the parity gate surfaced last tick, cheapest first.

popup.json existed ONLY in English and French — the country popup's four labels
(WAGF Region, WAGF Bloc, Country, UUPG Count), which is what a reader sees when
they click a country on the map. Now in all 11. WAGF and UUPG stay as acronyms
in every language, following what French already did: they are proper nouns of
the dataset, not words to translate.

Then six keys that were missing from EVERY non-English locale identically —
added to English at some point and never propagated: legend header.default,
search sections.within/allData, and the three country-poster strings in
regions.json. The {selection} placeholder is preserved.

Result: ar, es, fr, pt and ru are now COMPLETE, and 429 -> 344 keys of debt.
Those five are deliberately REMOVED from the gate's DEBT map rather than set to
zero — a locale with no entry must be complete, so none of them can silently
regress. Verified: deleting one Spanish key reports "es is missing 1/176 key(s)
and has no DEBT entry".

Verified at the RENDERED layer, not by counting files: "View country posters"
comes back as "Ver los carteles del país" under ?lang=es and "Показать плакаты
страны" under ?lang=ru, zero page errors.

★ One key I translated turned out to be DEAD: legend.header.default is consumed
by nothing — my first probe appeared to test it and was actually matching the
substring "Legend" inside this profile's hardcoded LEGEND_TITLE ("My UPG 100
List — Legend"). A probe that matches an artifact proves nothing; the assertion
has to be a string only the translated key can produce.
2026-09-24 05:52:53 +00:00
doxa-maps-88655c70
7fd1c6bcd1 feat(i18n): the poster wall's own labels now translate
Last tick made the 11 locales selectable. That immediately exposed what was
sitting behind the door: the wall's labels — Poster Map, Prayer List, Card Deck,
Print Options, Show on map, Close — were hardcoded English, so a reader who
chose Spanish got Spanish page furniture around English buttons.

All six are now keys in buttons.json, translated across all 11 locales, plus the
aria-label which interpolates the item name rather than concatenating it.

★ AND IT CLOSED TWO OLDER ORPHANS. buttons.json had 21 keys in English and 19
everywhere else: `share` and `shareSteps` — the Share button's title and its
five-step explainer, rendered on EVERY map. Ten locales had been shipping an
English Share button since those keys were added, invisibly, because vue-i18n
falls back to English rather than erroring. Both are translated now.

Verified in a real browser across four languages: the wall's buttons read
"Lista de oración | Mapa del cartel" under ?lang=es, "Liste de prière | Carte de
l'affiche" under ?lang=fr, and the Arabic text renders with dir=rtl — zero page
errors in each. The Share button's title follows too: Compartir, Поделиться.

The wall's `t` and `locale` come from the SAME useI18n instance, so a label and
a printed card deck can never disagree about which language the reader chose.
2026-09-24 05:44:06 +00:00
doxa-maps-88655c70
27e397225e 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
#	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 05:39:04 +00:00
doxa-maps-88655c70
b2f441b075 MapLibre drops a layer whose paint it does not recognise
The region fills did not paint under MapLibre, and three ticks of
instrumenting addLayer() found nothing because the failure never travels
through addLayer's return path. Listening to the map's own `error` EVENT --
the only place it surfaces -- gave it in one run:

    layers.regions-fill.paint.fill-emissive-strength: unknown property
    layers.regions-border.paint.line-emissive-strength: unknown property

*-emissive-strength is a Mapbox GL v3 lighting property. MapLibre rejects a
layer whose paint carries an unknown property and DROPS THE WHOLE LAYER:
addLayer() accepts the call, emits an error event, and silently omits the
layer. getLayer() then returns undefined and the map renders without it, so
it reads as "the fills do not paint" rather than as a failure.

★ THE REPO ALREADY KNEW THIS, FOR CIRCLES ONLY. useMapProvider.emissivePaint()
exists and its own docstring says "the whole layer is dropped -> invisible
pins" -- but it was hardcoded to 'circle', while the fill, line and
clustering layers still wrote the literal and kept being dropped for exactly
the documented reason. Generalised to emissivePaint(map, kind) and applied
at all five literal sites: useMapLayers x3, useCountryOutline,
useMapClustering. One helper, one rule, no sixth copy.

A second defect was hiding behind the first: regions-border still declared
'source-layer' on the geojson source ("Source layer country_boundaries does
not exist on source"), which only became visible once the paint error
stopped masking it. Fixed the same way as regions-fill.

MEASURED on the built tree: 0 error events, both layers present, and the
screenshot shows the WAGF fills painting under MapLibre -- green, blue,
purple, red, orange -- matching the Mapbox control, which is itself
re-shot and unchanged. Provider sweep 15/17 (the 2 render no map at all),
gates:pre 0, gates:post 17/18 with only the pre-existing
verify/tables-complete failure.
2026-09-24 05:38:29 +00:00
doxa-maps-88655c70
f7f4b8c1e8 fix(i18n): let a reader actually CHOOSE one of the 11 locales
The packet ships in 11 languages, every string translated. detectLocale() read
ONLY document.documentElement.lang — and EVERY generated leaf page in this repo
hardcodes lang="en" (measured: 20 of 20, later 34 of 34 after the tree grew).

So on the built static tree — the jamstack case these bundles exist for — ten of
the eleven translations could not be reached by any reader. Correct, complete,
and invisible. Several ticks of translation work landed behind a door with no
handle.

?lang= / ?locale= is now consulted FIRST, then the host page, then English. A
query parameter is the smallest thing that makes them reachable: no per-language
page, linkable and shareable, and the WordPress/Polylang path through <html lang>
is untouched — a gate assertion keeps the leaf pages declaring a lang so that
path cannot silently become the only way in. Both spellings are accepted because
both are in use here (`lang` in profile configs, `locale` in the i18n layer) and
guessing wrong is a silent no-op.

⚠ AND MAKING ARABIC REACHABLE EXPOSED THE NEXT PROBLEM. Only ONE component reads
RTL_LOCALES today, so selecting ar would have given Arabic TEXT inside a
left-to-right layout everywhere else — readable glyphs in the wrong place, which
reads as a styling quirk rather than a bug. createAppI18n now reflects an RTL
locale onto document.dir. It only ever SETS it, never clears it: a host page that
already declares dir owns that decision, and stomping it to "ltr" for an English
map embedded in an Arabic page would break the page around us.

Verified: ?lang=ar sets dir=rtl in a real browser; ?lang=zz correctly falls
through to English. detectLocale is exercised against its SHIPPED source across
7 cases including pt-BR -> pt.

★ One thing this tick taught me the hard way: rebuilding a bundle WITHOUT
regenerating the tree leaves the leaf page carrying the OLD SRI hash, so the
browser refuses the script and NOTHING MOUNTS — silently. I spent three probes
reading an empty page before checking the integrity attribute.
2026-09-24 05:35:11 +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
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
04f165efd3 feat(poster): share the print actions, keep the record fetching per bundle
The poster wall was generalised into library/components/poster/PosterWall.vue
and two bundles migrated onto it — but the ORIGINAL, my-upg-100-list's 903-line
wall, was left on its own copy. So the one wall carrying the real print actions
is the one wall nothing else benefits from, and the shared component's packet
path was still a "coming soon" stub. Every print fix made in the 100-list this
flight (the card deck, the stock picker, the image_url fetch) was invisible to
the three bundles on the shared component, and vice versa.

★ THE SEAM IS WHAT MADE THIS SHAREABLE AT ALL. The PRINT ACTIONS are common; the
RECORD FETCHING is not. fetchListRecords in the 100-list knows the people-groups
API, the selector evaluator and the pg_slugs special case; Key Cities and Country
Posters have entirely different data shapes. usePosterPrints.js therefore takes a
`records(item)` PROVIDER and owns everything after it — the HUD state machine,
the in-place print frame, and the documents.

That also keeps the bundles honest. A wall that CAN supply rows passes the prop
and gets the list + card-deck prints; one that cannot omits it, the Card Deck
button is not rendered, and Prayer List keeps its truthful "coming soon" instead
of a button that fails at click time. Country Posters shows exactly that today:
175 cards, Prayer List and Poster Map, no Card Deck — because it has no provider
yet, not because something broke.

⚠ ONE exportState, not two. The composable owns a ref by default, and a wall
rendering its OWN state while these actions wrote to a private one would show no
progress and swallow every error — the mechanism working and the feedback not,
which is the failure mode this flight keeps finding. PosterWall hands the
composable its own ref so both write to the surface the reader is looking at.

Prints IN PLACE via the shared openPrintFrame, never window.open() — the
mechanism the Driver asked for by name: "not supposed to open up a new browser,
but should open up the print dialog directly in the browser".

NOT DONE, and deliberately: the 100-list wall is NOT yet migrated onto the shared
component. Its template is 107 lines against the shared 72 with a different
structure, and it is a Driver-reviewed surface — that is a rewrite whose risk
does not fit in one tick alongside this. The shared half lands first and is
verified; the migration stays open on the card with this composable now waiting
for it.

Verified: all three bundles on the shared wall build; country-poster-wall renders
175 cards with zero page errors; check:poster-prints drives the composable in a
browser and asserts BOTH directions.
2026-09-24 04:56:31 +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
5d5f162b1c Merge main into bundler-2/work 2026-09-24 04:15:36 +00:00
doxa-maps-88655c70
ec974b4c63 fix(posters): ask the legend which zones are reachable, not clusters.json
Some checks are pending
build-gate / build (push) Waiting to run
publish-html.mjs enumerated the raw cluster keys under a comment that said "the
zone ids the app can actually reach" — which was exactly what it did not do. It
then warned that deaf-old-world and deaf-new-world-asia-pacific were MISSING on
every run, when nothing was missing.

Those two are SUPERSEDED by the unified `deaf` row (affinity bloc A017), not
absent. Verified against live records instead of trusting the comment that says
so: 63 + 60 = 123 = A017 exactly, zero groups on either side of the difference.
They stay in clusters.json deliberately — three isolation-proof scripts assert
deaf_lists.length === 2 — but the legend does not expose them, so no user can
select them and a poster for them is unreachable by construction.

It now derives zone ids from leafZones(buildLegendNodes(...)), the same source
the app itself uses, so the two can never disagree again. Result: 28 posters
published, zero warnings, matching the legend's 28 leaves.

records:null, not records:[] — buildLegendNodes treats an empty array as truthy
and then demands population fields that are not available here.

A recurring warning that is always wrong is worse than no warning: it trains
everyone to ignore the one run where something really is missing.
2026-09-24 04:07:17 +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
467b7f01a2 feat(packet): all 11 locales — Hindi unblocked with a vendored Devanagari face
Some checks are pending
build-gate / build (push) Waiting to run
The packet now ships in every locale the app ships: en es fr pt de it ro ru ar
hi zh. Each carries all 21 translatable keys, the twelve prayer points, the
eleven rhythms, both attributions and the privacy note. point4_passage stays
omitted everywhere and falls back to the licensed English WEB text, for the
reason already recorded — translating scripture here would put unlicensed,
unverified Bible text into a printed prayer packet.

HINDI WAS BLOCKED ON A FONT, not on a translation. Devanagari renders as tofu
boxes in the packet's Georgia/Palatino/serif stack because this machine has no
Devanagari font at all. Noto Serif Devanagari (SIL OFL) is now vendored —
devanagari subset only, 124 KB, licence and provenance beside it, matching how
poppins/ ships latin and latin-ext separately.

It is base64-INLINED into the packet, not linked. A packet is a standalone file
meant to print offline — the Node generator writes HTML with no server, and the
photos are already inlined for exactly this reason — so a URL would break
precisely where the packet is most used. Cost: ~165 KB on a 3.5 MB packet, and
only for that language. One rule prepends the face to the stack rather than
editing 22 hardcoded Georgia declarations, and the browser falls back PER
CHARACTER, so Latin scripture references in a Hindi packet still set in Georgia.
mr and ne map to the same face; a missing font warns loudly rather than silently
producing rectangles.

Chinese needed no font work — CJK already shapes in the system stack — only the
translation.

★ TWO MEASUREMENTS LIED ABOUT SCRIPT COVERAGE, recorded so nobody automates this
cheaply. Comparing shaped width against a missing font reported ar and zh BROKEN
and hi FINE — exactly backwards, because Devanagari tofu boxes happen to match
real glyph advance. An ink-column-profile heuristic then scored CORRECTLY
RENDERED Devanagari the same as tofu (3 vs 2 distinct heights), because its
baseline bar gives a uniform profile. Both were abandoned: a detector that blocks
working packets is worse than none.

Verified: all 11 languages render 8 sheets -> 8 letter portrait pages, map
painted, zero overflow. The Hindi prayer-points page was READ AS A PICTURE —
conjuncts and matras shaped correctly, no boxes, Latin references still Georgia.
2026-09-24 04:03:57 +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
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
ed27b40e66 Put the tables in the house palette instead of Tabulator's stock theme
Every table in the product was shipping Tabulator's DEFAULT theme — #333 header,
#666 rows, #444 filter boxes — against a house style that is Material LIGHT and has
no dark mode. Measured identically in both served consumers before the change:
row rgb(102,102,102), header rgb(51,51,51).

OVERRIDDEN BY SPECIFICITY, NOT BY ORDER, and that is the load-bearing part. The vendor
sheet is fetched and appended AFTER these rules, so any tie goes to the vendor. Each
override is written one class deeper than the rule it replaces: four for the column
header, five for the filter inputs, four for the paginator. Re-ordering the injection
would have been the fragile fix — it breaks the first time injectCss is made sync.

Getting that wrong is visible in stages, which is why it took three passes: at three
classes the columns stayed dark while the header went light; at four the filter inputs
were still #444 and the "Page Size" label was rendering white on a now-light footer,
invisible. The computed-style probe reported the header and row fixed and said nothing
about either — both were caught by looking at the screenshot.

Dark mode is left alone. .dt.is-dark is three classes and still beats these, so callers
using it keep it; this repo ships light only, but silently removing another consumer's
dark mode is not this change's business.

Blast radius named and checked: three consumers import DataTable — gospel-resources,
cities-internet and key-cities. The first two are the ones with a served table page and
both were re-checked, by computed style (row #fff, header #f3f3f1 in both) and by
reading both screenshots. key-cities ships no data-tables page, so it could not be
exercised live. The gospel-resources link and deep-link probes both still pass, so the
restyle did not disturb the anchors the table exists for.
2026-09-24 03:51:06 +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
6ba36817eb Merge main into bundler-2/work 2026-09-24 03:40:36 +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
88ff7acc9f Merge main into bundler-2/work 2026-09-24 03:34:20 +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
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
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
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
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
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