Commit graph

29 commits

Author SHA1 Message Date
Doxa Maps
86034174b0 WIP Mark 5 page (source) 2026-09-30 09:32:33 +00:00
Doxa Maps
8105b1e76a Prayer packet: point 4 prints a short reading list, and the 12 points are larger
Point 4 (READ THE WORD) printed Mark 5:1-20 in full at 14px, the longest
and smallest text in the packet. It now prints THE HEAVENLY FATHER'S
HEART: one heading, one framing line and seven short readings (Noah,
Lot, Rahab, the Gerasene, Cornelius, Lydia, the jailer), each a
household the Father saved through one person. The names come from each
locale's block; the addresses stay canonical English in the data and the
book is named in the packet's language, like every other reference
(Genesis and Joshua added to the 11 books tables).

That room goes to the twelve points: body 17 -> 22px, heading 19 -> 24px,
references 14 -> 17px, and 13 -> 16px between points. The page still fits
one sheet in all 11 locales (the tightest, de, ends 59px above the
bottom).

The gates that asserted the full passage (packet-render, packet-browser,
poster-prints) now assert the list: a .p4-heart block with its seven
readings. Rebuilt: my-upg-100-list, country-posters, regions-dashboard,
doxa-research-map, upg-neighbor; the tree and SRI regenerated.
2026-09-30 08:29:54 +00:00
Doxa Maps
d78e485643 "Who among them knows Christ" counts people, not weeks
The page reused the life calendar's grid and counted its shaded boxes in
weeks ("0.61% · 32 weeks"), under a week axis and a year axis, with a lede
calling it "one human life in weeks". On this page a box is an equal share
of the list's PEOPLE, so the same 5,200 boxes are now drawn with no week or
year axis, and the legend states each side in people: how many follow
Jesus and how many do not yet, each with its share (the two percentages
sum to 100 as printed), plus how many people one box stands for. Numbers
are formatted in the reader's locale.

The labels become "Followers of Jesus" / "Not yet following Jesus" and the
lede drops the weeks, in all eleven languages; the served strings file is
re-pinned. check:evangelical-share asserted the weeks figure; it now
asserts the people totals, the bare percentage and the absence of weeks
and axes, with the life page (which keeps its axes) as the negative
control. check:packet-variants found the grid by an exact class string
and now matches the class prefix.
2026-09-29 20:08:55 +00:00
Doxa Maps
58e113549c The %-evangelical page says how many people one box stands for
Brief A1.4 asks the page to state how many people each cell represents; it printed the share and the
population but never the unit a reader needs to read the grid ("each box ≈ 40,208 people" for the
whole DOXA list). Computed with the screen chart's own peoplePerCell so paper and screen divide the
same way, in all eleven languages, and omitted when the list has no published data.
2026-09-29 05:26:26 +00:00
Doxa Maps
0c40927dcf The prayer points name their scripture in the packet's language
Every translated packet printed its references in English — "Matthew 27:27" in Chinese, Arabic
and the other nine — because refs are kept as canonical addresses and were printed verbatim. The
address stays canonical in the data; the BOOK is now named from a per-language table (the ten books
the points cite, in each language's common form: 马太福音, متى, Мф., Matthäus …). A localized
name in a right-to-left script is isolated rtl so it reads on the right with its chapter:verse
intact. Measured: 24 references per packet, 0 English book names left in zh, ar and ru.

Point 4's printed passage is untouched: which licensed translation to print per language is still
an open decision.
2026-09-29 05:26:23 +00:00
Doxa Maps
4e2685cdad The packet follows the printed Module 1 sheets: page 1 is the map alone, the calendar holds Jesus' 33 years, the sets share one page
A review of the full-detail packet, page by page:
- Page 1: a poster carries its own title, so the packet's title above it printed two headers and
  shrank the map to fit beneath them. A poster sheet now fills the page inside the white margin by
  itself; in another language its kicker and title become the packet's own, fitted to the English
  width. A map-only sheet keeps the packet's title in the band it leaves empty.
- The life calendar is laid out like the Module 1 "Life Calendar in Weeks": square boxes, week
  numbers across the top, the year down the side, a rule between decades, framed and centred. The
  first 33 years are shaded as the life of Jesus (1,716 weeks).
- The %-evangelical grid fills from the top in reading order instead of scattering.
- The flow pages had no page padding at all, so their headings sat on the sheet's edge; they keep
  the same margin as every other page now.
- The bounded and centered sets are one page, two square panels side by side as on the Module 1
  list of 100, each headed by name with a cross at its centre. The centered set is a target of nine
  numbered bands, its dots spread through their bands by the shared ringBands geometry (no overlaps,
  none on the numbers), and the legend explains every ring with its count.
- The lightweight packet lists every group as a worksheet: a short note, one column header per page,
  numbered rows sized for paper with room to tick, no photos or descriptions.

Seven new strings in all eleven languages. check:packet-a1-structure and check:packet-variants
assert the new contract two-sided (a poster page 1 has one header, a map-only page 1 still has the
packet's; every ring is named, a populated one with its count and an empty one with a dash).
2026-09-29 05:26:23 +00:00
Doxa Maps
6af71bce45 Name the Deaf packet in its reader's language, not by its internal label
The Deaf zone printed "Deaf (affinity bloc A017)" as its title - on
page 1, at the head of every profile page and in the document's own
title. That is the legend's internal label: the unified Deaf row is an
affinity bloc spanning every region, so there is no country to name it
and zone-title.js declined it, leaving the label to print.

An affinity-bloc zone has a name of its own even though it has no
country. zone-title.js now reads it from the packet strings, keyed by
bloc (zone_affinity_A017: "Deaf people groups", in all eleven
languages), so another affinity bloc a list adds is named with one
string and no code. With no strings it still declines and the label
stays - it never invents a name. Both print paths now pass the
language's strings, English per key, the same fallback the renderer
applies.

check-zone-title-tier-a covers the unified Deaf zone, which it never
saw (it enumerates clusters.json entries, where Deaf is two older
lists): the translated name in en/es/ar, never internal text, and a
decline without strings. It now tests with the real packet strings,
which carry the multi-country joiner, so a broken one-country guard
would generate a title and be caught.
2026-09-29 02:16:32 +00:00
Doxa Maps
bc422bb678 Each packet's % evangelical page states its own list's share
The page says each box is a share of the people in this list. It
printed one DOXA-wide figure - 0.6133% of 209,079,159 people - in
every packet, true of no single list.

That figure had no builder in the repo. build-evangelical-share.mjs
makes its method reproducible, per group: the IMB CSV joined to the
DOXA snapshot by ROP3 + country, each group's EvangelicalLevel band at
its midpoint (0 for No Known Evangelicals, the midpoint read from the
band's own wording), weighted by IMB population. It writes
evangelical-by-slug.json and refuses to write unless the table, summed
over every DOXA group, reproduces christian-share.json exactly: 2,091
matched, 209,079,159 people, 1,282,178 evangelical, 0.6133%. Change a
single band's midpoint rule and it refuses.

Not the API's imb_gsec: GSEC puts 225 groups at "no evangelicals" where
EvangelicalLevel puts 1,444, because its "less than 2%" band also
carries church-planting status. A share from it sits on a different
scale from the method the page's own footnote states.

evangelicalShare.js sums a packet's own groups - pure, zero imports, so
the browser print and the offline generator run the same sum. Across
the 28 zones the page now carries 28 different figures, from 0.182%
(Deaf) to 0.922%. A host that supplies its own figure still wins.

A list with no published levels prints a dash and no number. The old
guard, Number.isFinite(Number(x)), passes null as 0 and would have
printed "0% evangelical" for no data. Where some of a list's groups
lack a level, the footnote says what the figure is computed over; it
says nothing when coverage is whole. That clause is in all eleven
languages.

check-evangelical-share (gates:pre): the table reproduces the committed
figure; different lists differ; no data is a dash; coverage appears
only when partial. Mutation-tested four ways, each caught - the fourth
only once it corrupted an existing group rather than adding one no sum
reads.
2026-09-29 02:16:31 +00:00
Doxa Maps
bad51cef2b Print the Chinese prayer packet in one script
The app's Chinese is Simplified, and so were 39 keys of the packet's zh
block. The other 18 were Traditional - page 1's subtitle, the list
header, the card labels, and the whole prayer wheel and all twelve
prayer points - so a Chinese packet switched script partway through.
The block had been extended by matching the neighbouring line, which
was itself the drift; the missing-map sentence added in 74e5c4b0d
followed that same neighbour and is corrected here too.

Converted with a real converter (zhconv, MediaWiki's tables), not a
hand-picked character list: it converts characters (為 to 为, 禱 to 祷)
and mainland vocabulary (列印 to 打印), and a hand list had found only
17 of the 18 keys. A second pass changes nothing, so the block is
Simplified by the converter's own definition. 祂 is kept. The block
carries no licensed scripture - Mark 5 falls back to English there - so
only prayer prose and labels changed.

check-i18n-parity's zh arm walked the app catalogues and never the
packet's, which is how 18 keys drifted unseen. It now walks the packet
block as well; pointed at the block before this commit it fails with 41
values, and passes after. Its character set remains a floor, as it says.
2026-09-29 02:16:29 +00:00
Doxa Maps
2367c2ef64 Build the prayer packet page by page to its specified structure
FULL DETAIL is four front pages, the profiles, four back pages:
  map, triangle, life calendar, % evangelical, profiles,
  bounded set, centered set, prayer wheel, 12 prayer points.
LIGHTWEIGHT is two front, the profiles, two back:
  map, triangle, profiles, prayer wheel, 12 prayer points.
Both are generated from data, in every packet language, from the
browser and from the offline generator.

PAGE 1 - the map, on a vertical page. One lift for every caller
(packet-render.js liftMap): the browser composable, the UPG 100
profile and the offline generator each carried an identical copy of
the old wrapper (250px down, scale 0.62), and three copies of a layout
decision is how page 1 drifts between print paths. The lift prefers
the map-only vertical sheet (mapSheetFiles: <zone>.packet.html, then
the poster) and places it so its names land on the same 60px margin
every other page keeps; a poster falls back fitted under the title at
0.81 rather than 0.62. The title block is drawn after the map, since a
map-only sheet begins with a full-page background. Anything that is not
a map sheet - an icon-sized <svg> from a host that answers a missing
file with a 200 page - is refused, and page 1 says the map is missing.
Page 1 now states which set it belongs to ("One of 28 UPG 100 prayer
lists"), only when the caller supplies the count.

PAGE 2 - one inverted triangle in the Vision Card's look. The card's
steps are the top (every people group, the unreached, the frontier,
the network's list), narrowing to the UPG 100 packets and, at the tip,
this packet. The card's greens, darkening as it narrows, walked
through its own colour stops; every numeral picks white or ink by the
WCAG ratio (all at least 3.4:1, large bold type). Tier widths are
geometric, not proportional - 2,107, 28 and ~100 against 12,379 would
be hairlines - so where two sets truly nest the caption states the
step removed, and the network's list, which does not sit inside
frontier, gets the card's "N of them are frontier" note instead of a
false subtraction. The packets tier and its count appear only when the
caller supplies one.

BACK PAGES - the bounded and centered sets get a page each at twice the
width, under the one page key, so lightweight drops the pair together.

THE WHEEL - the Metacamp Module 1 prayer wheel: its twelve colours,
sampled from the fill operators in the source PDF's content stream; its
orientation, the opening PRAISE at nine o'clock and READ THE WORD at
twelve; white labels with a shadow and a faint outline, so a print
path that drops SVG filters still leaves them legible on the palest
segments; "5 minutes" badges; the attribution with the website. The
labels stay the packet's own rhythm names, translated in every
language and matching the 12 points page.

The offline generator makes both packets (PACKET_VARIANT=lightweight;
the full packet keeps its names, lightweight is <zone>.lightweight and
stamped with its variant).

STRINGS: four new keys, in all eleven blocks - each block's own words
for "packet" and "prayer list", the frontier note aligned to the word
the frontier tier uses on the same page.

GATES: check-packet-a1-structure (registered in gates:pre) reads every
sheet's CONTENT for the order of both variants, page 1's lift in all
four cases, the triangle's tiers and steps, the wheel's colours and
orientation, and all 28 zones. Mutation-tested eleven ways, each caught
by its own message. check-packet-variants now asserts both sets pages
and a four-sheet difference. The browser gate check-packet-render
counted funnel rows and bars by inline-style substrings, which an SVG
triangle has none of - both would have read zero and passed - so it
now counts .funnel-n against .funnel-tier, its sheet arithmetic counts
the sets pages, and its Latin allowlist carries the renamed product
name. check-packet-browser used a copy of the old wrapper; it now uses
the product's lift.
2026-09-29 02:16:28 +00:00
Doxa Maps
646c23cc0e Put the missing-map sentence in every packet language
The page-1 notice for a missing map existed only in English, which
turns check:packet-strings-parity red: the packet catalogue must be
whole in every language, and English-by-choice is not one of its
declared exceptions (only the licensed Mark 5 passage is).

All eleven blocks now carry the sentence, reusing each block's own
page-1 wording for "prayer list" so the notice reads like the line
above it. The English says "prayer list" rather than "zone": zone is an
internal term, and the page itself calls it a prayer list. The Chinese
block is Traditional script, so the sentence is too, with the Taiwan
term for printing.

The loud gate's note described the old English-only state; it now
reports which locales fall back, or that none do.
2026-09-29 00:59:14 +00:00
Doxa Maps
ef6000c5d7 Say so on page 1 when a prayer packet's map is missing
Page 1 of every packet draws the zone map from mapSvg. When the map
fetch fails, the page interpolated an empty string: a title, a
subtitle, a privacy footnote, and nothing where the map belongs. A
failed fetch and a successful render were byte-indistinguishable in
the printed PDF, and the packet read as undeveloped.

It is not a rare edge. The served asset pool ships boundaries, fonts,
images, people-groups, pplr, previews, tables and vendor, and no
posters/ directory at all, because generate-tree gates it behind
posterSources and bundlePosters, both default false. Over live HTTP
against the served tree: previews/sudan.webp 200,
boundaries/world-adm0-web.geojson 200, posters/sudan.html 404. So on a
default build every packet's page 1 has been blank.

The browser path's catch around the poster fetch is right to keep
printing -- a missing map must not cost the reader the people pages --
but degrading and degrading silently are different things, and only
the renderer knows the map is absent when it draws the page. It now
prints one quiet sentence in the map's place.

The sentence is English-only on purpose. t() falls back to English per
key, so the other ten locales print it in English until a translator
supplies theirs, rather than ten invented translations nobody can
check.

The real fix for the blank map is shipping the poster sources in the
asset pool; that is a tree-config change and goes separately.

check-packet-map-missing-loud is two-sided: loud when the map is
missing AND quiet when it is present with the SVG intact, in en/es/ar,
both variants. Shown capable of failing four ways: the silent cover, a
notice that always prints, a notice that displaces the map, and the raw
key printed instead of a sentence.
2026-09-29 00:59:14 +00:00
Doxa Maps
4b45285b7d Hold the packet's ring names to the screen's, and adopt its exact words
The agreed ring vocabulary now lives in two catalogues in eleven locales
each -- gospelInternet.json for the screen legend, packet-strings.json for
the printed sheet -- and nothing held them together. They legitimately
want different SHAPES (the screen renders the axes as standalone labels,
the packet composes them into one string per ring), so neither can import
the other; but the WORDS must match.

The failure that makes this worth a gate is quiet: one cell is provisional
on the Driver's row 89, and it appears in rings 1, 4 and 7. Answer it in
one file and not the other and the sheet and the screen state different
things about the same ring, each correct in isolation, with no error
anywhere. Only a reader holding both would notice.

Measuring first showed 36 of 77 cells already differed, in two classes.
Most were grammar -- the packet's culture terms follow a comma
mid-sentence, so they are lowercase -- but some were genuinely different
words: "Same family" against "Same language family", German "verwandte"
against "Nahe", Hindi "वही जन" against "वही जनसमूह". Those are drift, and
the screen's wording wins because it is what readers already see. The
packet now adopts every term verbatim, lowercasing only the first
character of the culture half, which is a no-op in zh/ar/hi and leaves
German common nouns capitalised.

That adoption caught a bug in my own assertion: I had hardcoded ring 7-9
as "Same family" from CenteredSetTarget.vue's header, while the shipping
string is "Same language family". A summary in a comment is a description
of the data, never a second source of it.

The gate also refuses to pass when it compared nothing -- rename a key on
either side and every lookup returns undefined, every pair is skipped, and
a silent check reports success. Mutation-tested both ways: answering row
89 on the screen only, and renaming a packet key.
2026-09-28 17:53:37 +00:00
Doxa Maps
fccf6ee2fe Name the rings on the packet's centered set
The figure printed rings without saying what they mean -- honest but thin.
It now names each one with the vocabulary gospel and exp settled, the same
words the screen legend renders, so a printed sheet cannot disagree with
the screen.

COMPOSED FROM band x culture, NOT A FLAT 1..10 TABLE, and that is the
whole design decision. "Same people" in column 1 is provisional on the
Driver's row 89 -- and it appears in rings 1, 4 AND 7. A ten-string table
would need three edits kept in step, which is exactly how two of them end
up saying different things. Composed, it is one string
(ring_culture_same), and if he picks "culture" the ring numbering, the
columns and the colours are all untouched.

Ring 10 stands alone ("Nearby only") because it is not a band-and-culture
reading at all -- it is what is left when no linguistic relation was
found. The gate asserts that separately, and a mutant that composes it
like the other nine fails.

ONLY THE RINGS THAT HOLD SOMEONE. Ten rows of vocabulary beside a figure
is a glossary, not a legend, and a named ring with no dot on it invites
the reader to hunt for something that is not there. ringTally's per-ring
counts drive the list, so it states what this zone actually contains.

Mutation-tested: a wrong band order, listing all ten rings regardless of
count, and composing ring 10 like the others each fail with their own
message. Eight new strings x eleven locales; packet-strings-parity green.
2026-09-28 17:53:37 +00:00
Doxa Maps
3c1d513cc3 The packet gets its bounded and centered sets page
Brief A1 back matter: after the profiles, before the wheel, full detail
only. The lightweight packet is map, triangle, profiles, wheel, points --
these two figures are the part a reader sits with rather than carries.

The layouts are regions' library/components/charts modules, imported
rather than injected: unlike describeGroup's country resolver they have
zero imports and run under plain node, and packet-render already imports
scatterCells from that directory. Verified by importing and CALLING them,
not by reading them. They return data, so the SVG is ours.

Gospel's non-negotiable is carried through and checked rather than
trusted: the bounded set keeps a deliberate gap either side of the
boundary because a dot ON the line is the one thing a bounded set cannot
express, and print has no hover to disambiguate it. Measured on the real
module with 40 groups -- inside max 85.3 of 92 allowed, outside min 119.8
of 112 required. The figure scales through the viewBox, so the gap lives
in coordinates the scaling cannot touch; shrink the figure, never the gap.

UNKNOWN POINTS ARE STATED, NOT PLOTTED. layoutBoundedSet gives them no
x/y, which is the module being right: `reached` undefined means we do not
know which side they belong on, so there is no honest place to draw them.
My first version mapped them through the same dot() helper and crashed on
p.x.toFixed -- a better outcome than silently drawing them at (0,0), which
would have put "we don't know" inside the circle. They go in the tally,
because on a printed page a category the reader cannot see reads as zero.

check-packet-variants' sheet delta goes 2 -> 3, updated in this commit
rather than relaxed into a range. It also now SUPPLIES the sets fixture:
setsPage returns '' without data, so a build() that omitted `sets`
exercised the filter for every page except the newest one, and the gate
sat green through the whole rewrite while asserting a delta that had
become wrong. A fixture missing an input silently narrows what the whole
file covers.

Seven strings x eleven locales; packet-strings-parity green. Gospel's own
ring names live in gospelInternet.json, a plugin catalogue, so there is no
collision with these.
2026-09-28 17:53:37 +00:00
Doxa Maps
1061e9c5c4 The packet's generated profile sentence has its catalogue: ten keys in eleven locales
describeGroup returned an empty string against the shipping packet-strings.json because none of
its keys existed, while its check passed on its own fixture strings. desc_where_* says where a
group lives and what it speaks, dropping whichever clause has no data; desc_gsec_0..6 says how
evangelical it is. Each locale's under-2% sentences reuse that locale's funnel_unreached wording,
so a packet does not define the threshold two ways on two pages. English from the UPG-100 owner.
2026-09-28 15:01:19 +00:00
Doxa Maps
f7a7619695 Stop shipping an internal role noun in public bundles
check:public-hygiene flags "internal role noun (Driver) — process vocabulary"
inside shipped bundles. Three of those reach regions-dashboard.js, and they
are not from the regions components: they come from the shared poster and
packet code, which every bundle using PosterWall carries.

The worst is not a comment at all. packet-strings.json's note_on_scripture is
a shipped STRING, so it is data a reader can read. The other two are a JS
comment and a CSS comment that both live INSIDE template literals, which
makes them string content rather than comments -- the minifier cannot strip
what is not a comment, and the shadow-CSS stripper does not reach a print
stylesheet.

Rewritten to say what the decision was rather than who made it. Three lines,
no behaviour, no reformatting. regions-dashboard.js goes from 3 occurrences
to 0 after a rebuild.

This is the white-label requirement, and it matters most for the artifact we
intend to hand over: a recipient reading our bundles should not meet our
internal process vocabulary.
2026-09-28 00:20:34 +00:00
Doxa Maps
b22df1381e Mark 5 in five more languages, each a cited public-domain translation
rega measured the Arabic packet (row 83) and found page 8 still English. dx-shepherd's rule, which this
follows exactly: NEVER machine-translate Scripture — use a published public-domain translation per locale,
cited with its name (END-DXS-MK5GO).

  ar  Arabic Van Dyck Bible        ru  Russian Synodal Bible     es  Reina Valera 1909
  de  German Luther Bible 1912     fr  French Ostervald Bible

Fetched from bible.helloao.org, which ships licenceUrl + website + sha256 per translation and points at
ebible.org's own licence pages. Full provenance — translation id, name, licence URL, sha256 — is recorded in
data/people-groups/scripture-sources.json, because a credit line on the page can carry a NAME but not a
verifiable source, and the two are different claims.

★ VERSE NUMBERING VERIFIED AGAINST THE ENGLISH, not assumed. All five extract exactly verses 1-20 of 43 in
the chapter; verse 1 matches the WEB's "they came to the other side of the sea, into the country of the
Gadarenes" and verse 20 ends at "Decapolis ... everyone marveled". Lengths land within 1,689-2,399 chars
against the English 2,141, which is the spread you would expect across languages rather than a truncation.
The English passage carries no verse numbers, so these are joined as continuous prose the same way.

⚠ AND I DID NOT WRITE ANY OF IT FROM MEMORY. Scripture reproduced from recall is machine translation with
extra confidence — the same failure the rule forbids, one step removed. Every character came from the API
and was checked against the English before storing.

hi/zh/pt/it/ro STAY ENGLISH and stay in DECLARED_ABSENT, which is now narrowed rather than deleted: PD
candidates exist (Hindi 1851 Serampore-lineage, Chinese Union Version 1919, Almeida 1819, Diodati 1649,
Cornilescu 1924) but their licence status varies by jurisdiction and I could not confirm it. The entry
shrinks one locale at a time as each is cited.

VERIFIED: check:packet-strings-parity PASS, and the ar and de packets both render 9 sheets PASS with the
passage and its credit on page 8.
2026-09-27 09:07:17 +00:00
Doxa Maps
524e5ebfae Translate the new packet pages into all eleven locales
The gate caught what I shipped: rendering the Tibet packet at PACKET_LANG=ar failed with "the funnel page
carries Latin-script prose in a ar packet: 'This packet is', 'UPG-', 'lists … How each number is reached'".
Exactly right — my new strings were English-only, and two of them PRINT on a page whose siblings
(funnel_title, funnel_intro, funnel_people) are fully translated. English text on an otherwise Arabic page
is not a graceful fallback, it is a half-translated page.

So all 17 new keys are now genuinely translated across es fr pt de it ro ru ar hi zh — 150 values — and
DECLARED_ABSENT is back to its original two entries (the Mark 5 passage and its credit). The declaration
mechanism was the right call while they were untranslated and the wrong place to leave them.

⚠ 'UPG-' IS ALLOWLISTED, AND THE ORDER MATTERS. It is the Latin run left by "UPG-100", the product name every
locale keeps verbatim — the same reason 'Project' is already in LATIN_OK. But it is allowed only AFTER the
real translations landed: an allowlist entry added when the failure first appeared would have silenced the
gate on fifteen genuinely untranslated strings. The allowlist is for names, not for work not yet done.

VERIFIED at the rendered layer in BOTH locales: en and ar each PASS with 9 sheets, map painted, content
intact. The Arabic packet carries its life-calendar title, christian-share title, both legend labels, the
three prayer columns and the "1 of 28" tip in Arabic, with 2 grids and 32 shaded cells — the same numbers as
the English one, which is the point: the arithmetic is language-independent and only the words change.
check:packet-strings-parity PASS.
2026-09-27 07:37:26 +00:00
Doxa Maps
de243597db The prayer packet the Driver asked for: life calendar, the same grid as a percentage, a triangle
Driver, 2026-09-27, in his own page order. Nine pages now, was seven.

  1  map            the zone's vertical one-pager (unchanged)
  2  LIFE CALENDAR  100 years x 52 weeks = 5,200 boxes — a whole human life on one page
  3  THE SAME GRID  shaded by the evangelical share of these groups' POPULATION
  4  funnel         now an UPSIDE-DOWN TRIANGLE with an equation sidebar and a "1 of 28" tip
  5..n groups        20 per page with photos AND three prayer tick columns
  then the 12 prayer points and the prayer wheel (both unchanged)

★ THE 12 PRAYER POINTS WERE NOT LOST. The Driver believed they had gone; all 12 are intact in
packet-strings.json with his rhythms and sub-titles — praise/Two-Cross Gospel, wait/Holy Spirit &
deliverance, confess, read/the Father's love, ask/baptisms, intercede/laborers & shepherds,
pray_word/Persons & Houses of Peace, thank/rest, sing, meditate/communion, listen/accountability,
praise_end/great-greater-greatest — plus the Mark 5 passage and the Metacamp attribution.

★ PAGES 2 AND 3 ARE ONE GRID READ TWICE, which is the whole idea. 5,200 is the Driver's own arithmetic
(100 x 52), not a layout choice, so both pages derive from it and cannot drift into two grids that merely
look alike. A percentage drawn as a bar is an abstraction; drawn on the grid the reader has just
understood as their own life, 32 shaded boxes of 5,200 is the same fact made unavoidable.

★ AND PAGE 3's NUMBER IS POPULATION-WEIGHTED, because he asked for "% of Christian population vs lost".
That matters: by GROUP COUNT 69.1% of the 2,091 matched DOXA groups have no known evangelicals, but by
POPULATION the evangelical share is 0.61% of 209,079,159 people. The group-count version is a materially
milder picture of the same data. Computed in data/people-groups/christian-share.json, joined IMB
CSV ROP3Code+ISOAlpha3 to DOXA's snapshot — the same join the Vision Card uses — and READ here, never
re-derived, so one fact has one source.
⚠ THE BAND MIDPOINT IS AN ASSUMPTION AND THE PAGE SAYS SO. peoplegroups.org publishes EvangelicalLevel
as a BAND ("Less than 2%", "No Known Evangelicals"), never a number, so any percentage requires choosing
a value inside each band. The file records the method, the page prints it, and 0.61 + 99.39 = 100 exactly
— my first formatter rounded the large side to "99%", which loses the asymmetry that IS the point and
leaves a reader checking the arithmetic to find the page wrong.

⚠ GEOMETRY MEASURED, NOT EYEBALLED: square cells would need 26.5px x 100 rows = 2,654px of grid in
1,521px of printable height, overflowing the sheet 1.7x. 13px rows put 100 rows in 1,498px with 23px
spare. Caught by computing the height before rendering.
⚠ AND THE TIP SAID "1 of 27". listCount spread the three cluster keys and omitted `deaf`, whose two
deaf_lists entries buildLegendNodes collapses into one leaf — the same omission allZones() had before
3ef164c0, and this was its last copy. It prints on the page, so a 27 there is a visible wrong fact.

THE GATE IS TAUGHT, NOT TOLERANT: the two new sheets are DETECTED (.sheet-life/.sheet-christian) and
added as known terms, exactly as the funnel was, because that assertion exists to catch silently dropped
people groups and a ±1 tolerance is the slack a lost list page hides in. New assertions: 5,200 cells per
grid, the shaded count neither 0 nor 5,200 (either means the percentage never arrived), and exactly
3 x cards prayer boxes. Probes scoped to .lc-grid — the legend swatches share the cell class and made the
count 5,201 on the first run.
The 17 new strings are English-only and DECLARED_ABSENT with reasons rather than back-filled: English
copied into ten locale blocks would read as translated to this gate and to a reader, and the per-key
fallback already renders English without the lie.

VERIFIED: check-packet-render PASS (9 sheets, map painted, content intact) and
check:packet-strings-parity PASS on the Tibet fixture.
2026-09-27 07:37:25 +00:00
Doxa Maps
84062744ee Zone titles for the 10 zones that can have one, and a null for the 18 that cannot
Driver, 2026-09-27: zone titles must be GENERATED from already-translated parts, because UPG-100 is
becoming a pattern and a hand-translated title is a cost paid again for every project list (7000 / 4700
/ 3000 / 2000 / 700) forever. The Arabic map reads English today because every label is a hand-written
English string.

★ MEASURED BEFORE DESIGNING, and the measurement killed the more attractive half of the idea:
  country_code_label from the records API   all 175 codes, every locale   (formal names)
  Intl.DisplayNames({type:'region'})        all 175, every locale         THROWS on ISO-3
  Intl.DisplayNames({type:'language'})      213 of 2242 = 9.5%            ECHOES THE CODE
LANGUAGE IS OUT. `.of('kfa')` returns the STRING "kfa" rather than throwing, so a miss prints a raw code
onto a printed poster with nothing raising a hand — and the 90.5% miss is structural, not a gap to fill:
unreached peoples speak small languages by definition and CLDR carries the big ones. This module never
names a language.

TIERS, stated in the module because a generator that silently produces a bad title is worse than one
that declines:
  TIER A (3)  country_code   -> one country name.                    GENERATED
  TIER B (7)  country_codes  -> n names + a joiner.                  GENERATED
  TIER C (18) everything else                                         NULL, caller keeps its label
Tier C cannot be done from countries: the 12 India/China zones are pg_slugs whose every record is ONE
country (measured: asia-india-kerala 78/78 IND), so countries would render "India" twelve times and
could not tell "Malabar Coast" from "Northwest India" — those labels are SUB-NATIONAL, and Intl has no
subdivisions. The curated-table-vs-keep-English choice is the Driver's (repo's digest item D). Returning
null until then means nothing regresses.

⚠ AND I HAD THE SOURCE PRIORITY BACKWARDS, corrected by looking at what the two sources actually say.
I preferred the records' label because it is already localized and needs no code mapping — both true,
and it generated "Lao People's Democratic Republic" for the zone whose hand-written label was "Laos": a
generated title WORSE than the one it replaced. 28 of our 175 labels are formal forms like that
("Islamic Republic of Iran", "People's Republic of China"). Intl gives the short common form in every
locale, so Intl is now first and the records are the fallback. The ISO-3→ISO-2 mapper is INJECTED, not
imported, because regions-plugin owns that table (their card) and it has not landed: without it a
3-letter code falls through to the formal name, with it the titles read "Iran + Yemen + 11 more".

THE ONLY NEW TRANSLATED STRINGS ARE TWO: zone_join "{a} + {b}" and zone_join_more "{a} + {n} more", one
pair per locale block in packet-strings.json (22 keys across 11 blocks). That is the entire translation
cost for every project list that ever uses this — a new language stays a NEW BLOCK, never a code change.

VERIFIED against the real 28 zones in en AND ar, with live Arabic records from the API:
  Laos -> لاوس | Middle East Core -> إيران + اليمن + 11 أخرى | Maghreb + Egypt -> الجزائر + ليبيا + 5 أخرى
9 generated, 18 declined, in both locales. Every decline path returns null rather than a code or a
partial title, including a country_codes zone where one of the names it would SHOW is missing — a title
naming two of three countries and dropping the third silently misinforms and cannot be told apart from a
correct one downstream.

NOT WIRED IN YET: this is the generator only. The caller change (legend + poster + packet header) and
the deaf zone — global by construction, which breaks the geography-first rule and which the Driver says
is unsolved — are the next steps on the pattern card.
2026-09-27 02:49:56 +00:00
Doxa Maps
5c2549701b A packet says which list it holds, in the reader's language
TWO FINDINGS FROM rega'S ?list= RENDER, and the first one is a hazard I created
in the same commit where I argued against it.

I MADE PACKET_LIST FAIL CLOSED because "a packet that showed the wrong list is
printed and handed to someone" — and then wrote the frontier packet to the SAME
FILENAME as the default-list packet of that zone and language. rega measured the
result: out/packets/ held frontier packets under the DEFAULT names, beside an `ar`
file that was still the default list, and nothing in the name could tell them
apart. Guarding the input while leaving the output ambiguous protects against
asking for the wrong list, not against being unable to tell what you got.
  Fixed both ways. The list is in the FILENAME
(asia-china-tibet-autonomous-region.frontier.hi.html) and STAMPED INTO THE
DOCUMENT as data-packet-list, because a filename can be renamed, copied or
emailed while the packet itself should be able to say what it is. Same reasoning
as data-packet-build: out/ is gitignored, so a sweep grades whatever files happen
to be there, and a packet that cannot describe its own provenance makes an
accidental set indistinguishable from a current one. The gate prints it too.

THE LIST'S LABEL WAS ENGLISH IN EVERY NON-ENGLISH PACKET. "Project 4700: frontier
people groups" sat among Devanagari labels in a Latin serif face, because it came
verbatim from library/lists/lists.json — which carries ONE label per list and has
no locale dimension at all. The gate passed because it counts rows, not the row's
language.
  It is translated in packet-strings.json rather than by restructuring the shared
lists.json: that file is the packet's OWN catalogue, where a new language is a new
block and never a code change, and it degrades per key like every other string. An
unknown list still falls back to the data's English label.
  THE NUMERIC DESIGNATION STAYS — "Project 4700" / "مشروع 4700" / "4700 计划" —
because it names the campaign, while "frontier people groups" is prose. That is
the same line check-i18n-never-translated draws around "UUPGs": translate what a
bilingual reader would not dispute, keep what is an identity.

rega'S MEASUREMENTS, all confirming predictions I could only compute: 6 pages, 37
of 43 records, rows 12,379 -> 7,341 -> 4,754 -> 2,107 -> 1,745 -> 37, bars
1080/640/415/184/152/26 identical in en and hi, share "2.1% of the 1,745" with no
"1 of 27 lists", provenance-only footnote, fail-closed rc=1 on an unknown list.
And the number I most wanted: share-to-footnote clearance 314px en, 222px hi — so
hi+frontier, the tightest combination that exists (128px rows, gap down to 147px),
still clears by 222px and no label wraps.

Verified: all 18 packets in out/ pass sheets, rows==bars and zero-population.
The 11 laos packets FAILED the zero-population assertion until regenerated — they
predated the fix, which is the gate distinguishing stale artefacts from current
ones exactly as data-packet-build was added to do. Default path unchanged (43
groups, 7 sheets, 5 rows). frontier renders 6/6 in en, ar and hi with the label in
each script. check:packet-strings-parity PASS with the 5 new keys.
2026-09-26 07:39:35 +00:00
Doxa Maps
373fdd2595 The funnel states the list it was actually drawn from
A LIVE WRONG NUMBER, not a future risk. The universal `list` prop is already on
main (e3d25ff6) and this profile already honours it — applyPeopleGroupList()
filters the rows at all four fetch sites, packetRecords included — and the
membership files are on disk. So under ?list=frontier-500k the packet's rows were
the 71 network groups that are Project 700, while the funnel took its denominator
from the last step of vision-funnel.json and printed "of the 2,107". Off by a
factor of 30, with nothing on the page looking broken.
Measured from data/people-groups/lists/*.json (counts.doxa, what a Tier A map
shows): frontier-500k 71 · frontier 1,745 · unengaged 1,929 · unreached 2,082 ·
the default 2,107.

THREE NUMBERS WERE WRONG, NOT ONE, because all three descended from one total:
the share's denominator; the footnote's "N are in no list" (total minus assigned);
and "1 of 27 lists", which is a property of the UPG-100 ZONE CATALOGUE, not of the
active list — the zones partition the default list and nothing else.
A non-default list now gets its own step, labelled with the list's own label, so
the narrowing does not jump from one list's total to another list's packet.

AND EACH CLAUSE IS SUPPRESSED SEPARATELY, because they are provable separately.
A first pass dropped the whole share line whenever listCount was absent, throwing
away a true statement ("5.4% of the 1,745") to avoid an unprovable one. The
footnote splits the same way: provenance holds for every list, coverage does not.
⚠ The footnote is NOT split by parsing the translated sentence — hi ends in "।"
and zh in "。", and reading translated prose for structure is the trap
check-packet-render's own comment warns about. It is a second key.
⚠ And `funnel.unassigned ?? 0` would have printed "0 of the 1,745 are not yet in
any list" — a confident falsehood whose zero reads as a completeness claim.

── AND THREE FINDINGS FROM rega'S RENDER, none of which I could see ──────────
"0 people" PRINTED ON THE READER'S ROW OF EVERY PACKET. Number(null) is 0 (only
Number(undefined) is NaN), so `Number.isFinite(Number(x))` admitted the
deliberate `population: null` marker as the number zero. I asked "is this a
finite number?" when the question was "did anyone give me a population?" — the
same presence-vs-value confusion as inferring "did it work" from a field that
answers "did it isolate". Zero is suppressed too: a people group with no people
is missing data wearing a number's clothes.

THE SHARE LINE READ AS PART OF THE FOOTNOTE. Measured: 218px below the reader's
row, 42px above the footnote, because the last row kept the full inter-row gap.
Proximity is the only thing grouping them, so an even gap grouped the share with
the wrong element. The last row's margin is now 14px: share 14px under its row,
288px clear of the footnote.

THE GATE ASSERTED PAGE 2 AND PRINTED NOTHING ABOUT IT. rega: "a reader cannot see
WHAT it asserted." A silent pass is the mirror of a printed-but-unasserted number
— the check is real and the evidence invisible. It now reports rows, bars and the
share line, and gained two assertions: every row must have a BAR (the reader's
step had none, so the funnel never visibly narrowed to "yours"), and no row may
print a zero population.

Verified: all five list cases render their own true numbers — doxa keeps the full
claim, unreached/frontier/unengaged get their own step and a percentage with no
false "1 of K", frontier-500k reads "100% of the 71" with the duplicate row
suppressed (a step that repeats its neighbour is not a step). The four fixtures
are byte-for-byte unchanged on the default path, so the list-awareness is purely
additive. check:packet-strings-parity PASS with the three new keys. Both new gate
assertions mutation-tested in en and ar: removing a bar is DETECTED, injecting a
zero population is DETECTED. 5 rows = 5 bars, 0 zero-population rows, "0 people"
absent. Six-row layout recomputed: fits with 318px headroom, 206px if every label
wraps. BUNDLE=my-upg-100-list vite build clean.

NOT re-rendered in a browser: rega measured the previous state and these are
responses to it, so the new geometry wants one more look.
2026-09-26 07:15:20 +00:00
Doxa Maps
65017fefa4 The prayer packet shows how its list was narrowed out of all the earth
Page 2 of every packet is now the funnel: 12,379 people groups on earth, 7,341
unreached, 4,754 frontier, 2,107 on this prayer network's list, and N of those
in the reader's hand. A partner holding one list can see where it sits in the
whole, which is the argument the packet is making.

★ IT RENDERS NUMBERS IT DID NOT COMPUTE. Every count is read from
data/people-groups/vision-funnel.json, which build-vision-funnel.mjs generates
from the IMB CSV and the live network list and which already drives the Vision
Card. Re-deriving them in the packet would give one fact two sources; they would
drift, and two internally-consistent surfaces disagreeing is worse than one
wrong number because a reader cannot tell which to trust.

K AND THE SHARE ARE MEASURED AT RENDER TIME, NEVER TYPED, and this mattered:
the brief said "~30 lists of 50-100" and the catalogue says K=27 with counts
from 8 to 118 (west-africa 118, asia-india-rajasthan 116, uncovered-scatter 8).
Only about half sit in that band, so no copy claims it. The page states what the
data can defend -- "1 of 27 lists" and "N of the 2,107" -- and a footnote states
plainly that 21 of the 2,107 are in no list, because a partner adding up rows
would find the gap anyway. Reported to the Driver; K=27 pending his ruling on
whether the two residual buckets count.

A SHARE MUST NEVER ROUND TO ZERO. One decimal gave the single-group fixture
"0.0%", which reads as "this packet covers none of them". Verified exhaustively
over all 2,108 possible list sizes against 2,107: no size prints 0 and none is
malformed. 1 group -> 0.05%, 20 -> 0.95%, 21 -> 1%, 43 -> 2%, 118 -> 5.6%.

NUMERALS STAY WESTERN, as required. num() is toLocaleString(lang), and CLDR's
default numbering system for the bare ar/hi tags is latn -- verified 12,379 and
2,107 in both. Reusing num() rather than writing a second formatter is also what
keeps the funnel's digits identical to every other page of the packet.

PAGE COUNT NOW COUNTS THE ARTIFACT. packet.mjs computed
`1 + ceil(n/PER_PAGE) + 2` under a comment claiming the number was "asked of the
renderer, not recomputed here". It WAS recomputed, and the funnel page silently
made it wrong by one -- exactly the drift the comment warned of. It now counts
the emitted sheets.

THE GATE IS TAUGHT, NOT LOOSENED. check-packet-render went red on every fixture
("43 cards should lay out as 6 sheets, but the packet has 7") -- correct
behaviour on a structure it had not been told about. The wrong fix is to accept
N or N+1: that assertion exists to catch silently dropped people groups, and a
±1 tolerance is the slack a lost list page hides in. So the funnel is DETECTED
via .funnel-title and added as a known term, and a funnel page that renders its
title but no share figure is now its own failure -- a number printed but not
asserted on is decoration.

The page is OPTIONAL: buildPacket omits it when no funnel data was supplied, so
every existing caller keeps working and a host without the data still gets a
correct packet. Better a 4-section packet than one asserting figures it could
not read. A multi-list batch selection keeps the steps but drops "1 of 27",
which is false for a custom selection, while "N of the 2,107" stays exactly true.

Verified: check:packet-strings-parity PASS (31 string keys x 11 locales, my 10
new ones present and translated in all 10 non-English blocks, 0 declared-absent
additions). A partial locale with 3 of 10 funnel keys translated renders those 3
translated and the other 7 in English, nothing blank. Arabic packet generated:
dir="rtl", funnel is sheet 2 of 7, all four steps carry Western digits. All 16
generated packets satisfy the corrected sheet arithmetic, including the 11 older
laos packets that have no funnel page. Mutation-tested: dropping a list sheet is
caught by the independent page-header witness (20 DROPPED), a funnel page with no
share figure is caught by the new assertion, and removing the funnel page passes.
BUNDLE=my-upg-100-list vite build 319 modules clean.

NOT verified in a browser: check:packet-render launches Chromium, which the
Playwright policy reserves to rega. The simulations above parse the same
attributes from the same generated HTML.
2026-09-26 04:38:59 +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
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
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
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
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