Commit graph

625 commits

Author SHA1 Message Date
Doxa Maps
ff0c433fce A packet printed from the wall names page 1's countries in its own language
The list's own print relabelled the map sheet (mapLabels.js); the wall's print, through
usePosterPrints, lifted the sheet with no relabeller, so a zh packet printed from the wall kept
INDIA, CHINA … on page 1. The generator's ISO3 -> name table is plugin data the library cannot
import, so the host now supplies it: PosterWall iso3Names -> usePosterPrints iso3Names, and the
UPG-100 wall passes its territory-counts table lazily. A host that passes nothing prints exactly as
before. Measured two-sided through the real printPacket on a tracked sheet: without the table the
page carries CHINA and no 中国; with it, 中国 and no CHINA.
2026-09-29 05:26:23 +00:00
90613f9c88 Translate the three poster-wall nouns the regions list was passing in English
sr's census found item-noun="country", item-noun-plural="countries" and
unit="UPG" passed as literals. All three reach the DOM: PosterWall renders the
noun pair at its section count (n === 1 ? itemNoun : plural) and the unit beside
each row's count, so in Arabic the chrome translated and these stayed English.

The noun pair now goes through t(). Country and Countries were already in the
catalogue in all eleven locales -- Land/Länder, الدولة/الدول, Страна/Страны --
so this adds no strings, and PosterWall keeps making its own singular/plural
choice with the caller supplying both forms, which is what its props are for.

unit stays "UPG" on purpose. It is DX-life's term of art, and their own Arabic
common.json carries the literal "UPG" rather than translating it; matching them
is the point of the port. It is the one entry the census still reports here.

A fourth literal the census did not flag went with them: the prayer-list
"Coming soon" sentence, which reaches exportState.message and so renders too.
That one had no key, so it lands as an English-source key -- the convention
useRegionsI18n exists to serve -- translated in all eleven locales, 77 keys
each, parity green.

MEASURED, this plugin's entries in check:hardcoded-english: 3 before, 1 after.
2026-09-29 04:51:30 +00:00
Doxa Maps
7c1cfc8596 The language tree could write an SVG to disk; now it only prints
regions' fleet audit found the one poster path in the tree that downloaded a
file. The Driver's rule is that a poster action opens the print dialog and never
downloads, and there is no case for an exception here: printPoster builds the
same poster from the same makePoster(), so the download was a second route to one
artefact rather than a capability only it could offer. A reader who needs a file
prints to PDF — the system's own dialog, not a silent write.

The button, the function and the now-unreferenced tree.posterSvg key in all
eleven locales are gone. The comment left in its place names check:poster-action-
print, which regions is extending with this file as its poison case, so a
download added here again fails a gate rather than shipping quietly.

The two CSV export paths in GiGrid and GiResources are deliberately untouched: a
table has no print equivalent, the export is a documented feature that writes a
header line of sources with the data, and the rule — and that gate's own
description — is about POSTER buttons. Flagged to the shepherd rather than left
for the extended gate to trip over.
2026-09-29 04:28:09 +00:00
Doxa Maps
51056bac65 Keep the engineering reason, drop the speaker: 31 Driver references to 0
The rule: never quote or cite the Driver in shipped code. Every one of these
comments had a real engineering reason attached, and the reason is what a future
reader needs — so the reason stays and the attribution goes.

COUNT IS THE CHECK: 31 before, 0 after, across 8 files.

I was pointed at 18 in region-dashboards. That was one file. Sweeping my own area
as instructed found 31 in eight files: region-dashboards 7, RegionCountryMap 13,
regions-list 5, UupgsList 2, plus regions-mfe.vue, regions-mfe.meta.json,
index.js and check-regions-uses-library.mjs with one each. Reporting the wider
number rather than the one I was given.

HOW EACH WAS REWRITTEN, by kind:
  · a REPORTED SYMPTOM keeps the symptom, loses the reporter — "the pins were
    reported too small at rest", "the list page was reported slow, with the
    posters suspected", "instead of flying to the region, which is exactly the
    reported symptom".
  · a REQUIREMENT becomes a requirement — the region key is "a map that shows the
    regions and countries AND lets a reader select one" rather than a quotation;
    the missing fullscreen and zoom controls are "part of the cloned surface"
    rather than someone's complaint about a full clone.
  · an OPEN DECISION becomes provisional. Two of these: the stat-card divergence
    from RegionDetail.vue:176 is now "carried back as a product question", and
    the numeral-grouping parity reads "PROVISIONAL, pending a product decision"
    instead of waiting on a named person's ruling.
  · a CARD ID goes with the name: BUILD-REGIONS-MAP-RESTORE-EDGE-SCROLL-STRIPS
    and the round-2 test numbering are internal handles that mean nothing to a
    reader of a published bundle.
  · a SCOPE RULING is stated as scope: "improve the map's LOOK and add the
    all-regions map, which must be intuitive" — the constraint survives, the
    quotation does not.

Nothing lost that a reader needs: every measurement, pixel value, file:line
citation into DX-life's source and engine-specific caveat is untouched. What went
is a name, four dates and two internal ids.

SAME PROOF AS THE CITATION COMMIT, because a prose sweep across eight files is
exactly where a stray edit hides:
  · the only non-comment changes are an HTML comment in a template and the "note"
    field of regions-mfe.meta.json — and `note` is read by nothing: generate-tree
    never touches it, and the string does not appear in the built manifest.
  · meta.json still parses.
  · rebuilding regions-dashboard and country-map reproduces the committed bytes
    EXACTLY, and a full `bun run tree` leaves public/ clean — so NO bundle needs
    rebuilding for this.
  · gates:pre 66/66.
2026-09-29 03:46:37 +00:00
Doxa Maps
565907adea State what the probes found; drop the paths into the scratch area
gospel's citation rule, applied to mine: a comment citing a PROBE or MEASUREMENT
(_probe-*, _chk-*) states the FINDING and drops the path. A reader cannot open
_qa-cycle from a published bundle, and the path outlives the file — so the number
has to be in the comment or it is lost.

FOUR CITATIONS, all of the probe kind, so all get the same treatment. Every one
already carried its finding; only the dead path is removed:

  RegionCountryMap:36   three engineFor implementations "decide identically in
                        every engine combination" — now says WHICH combinations
                        were checked (mapbox-only, maplibre-only, both globals
                        present) rather than naming the script that checked them.
  RegionCountryMap:462  the globe-fill measurement keeps its numbers (zoom 1.229
                        -> 390.61px = 50.1% of the frame, half the frame dead
                        space) and loses the probe path.
  RegionCountryMap:552  the fit-at-construction comparison keeps both readings
                        (bounds-at-construction zoom 3.210 / mercator at t=0 vs
                        fitBounds zoom 2.777 / globe) and the engine version.
  region-dashboards:522 "4 of 10 app properties OK", with the five failures that
                        were one missing thing already spelled out below it.

I did NOT only fix the one line I was pointed at: I swept my own plugin files,
the six library files I own or moved this session, and the seven gates I wrote.
Four citations total, all in these two files; the gates and the other library
files carry none.

COMMENTS ONLY, and proved rather than asserted: the diff filtered to non-comment
lines is empty; check:ported-globals-identical still reports 4/4 byte-identical
emitted CSS; and rebuilding regions-dashboard and country-map reproduces the
committed bytes exactly — comments are stripped at build, so NO bundle needs
rebuilding for this and public/ stays untouched. gates:pre 66/66.

scripts/strip-code.mjs:5 is deliberately left alone: per the same ruling it
explains why a committed gate cannot import from the scratch area, which is a
fact about the boundary rather than a citation across it.
2026-09-29 03:46:37 +00:00
Doxa Maps
1b2c596011 The share menu's tabs mirrored the wrong way, and the check could not see them
Five physical CSS declarations in the chrome every production map renders, so
under Arabic each of them lands on the wrong side for every map at once:
SemanticTreeLegend's active tab chip drew its border on the left, the share
menu's step list padded from the left, its tab spacing and its auto-spacer both
pushed from the right, and the geocoder's search icon sat at left:7px inside an
input that mirrors. All five become logical properties, which in a left-to-right
reading resolve to exactly what they resolved to before: four insertions against
four deletions in the components, one property each, and the English view cannot
move.

The reason they survived is the more useful half. check-legend-logical-insets
existed and passed, but it read only library/components/legends -- the share menu
is one directory away, and a reader in Arabic does not know which folder a control
came from. So the scan now covers map-controls as well.

Widening it was not enough. It still passed, because its CSS extractor matched a
template literal with the pattern `([^`]*\{[^`]*\}[^`]*)`, which describes a
literal containing ONE brace pair. ShareButton keeps its styles in a 600-line
useShadowStyles literal with hundreds, so the scan captured seven fragments
totalling 7,003 characters of a 629-line file and never saw .share-tab at all. It
was reporting PASS on a directory it was barely reading, which is worse than
having no check: a green gate is read as evidence. Template literals are now
taken whole and kept only when they look like CSS -- 2 blocks, 8,462 characters,
and the rule is finally in scope.

The self-test poisons BOTH directories. Poisoning only a legend would have proved
the check works on the half it always covered, and passed unchanged the day the
wider scan silently narrowed again. Its map-controls arm puts back the exact
declaration this commit removes, and its failure message says what a failure
means.
2026-09-29 03:46:37 +00:00
Doxa Maps
9996bb6fde Comments state their findings instead of citing files outside the repo
Seven comments in the library and the app pointed at probes, checks, a report or research notes that
live outside the repository, where a reader of the code cannot follow them. Each now states what was
found and drops the path: the engine-predicate equivalence, the chooser's globe-fill measurement, the
fit-at-construction probe, the worldview clause, the web-boundaries sizes (1,866 KB to 383 KB, every
feature and part kept). The QA corner and the packet generator lose a dead pointer. The app's nav
comment states the three sections and the third dashboard directly instead of quoting a transcript.
2026-09-29 03:46:37 +00:00
Doxa Maps
334b3d6fa1 Stage the row 130 flip, and caption the chart with a total that does not move
STAGED, NOT FLIPPED. SET_FILL_MODE in GiCenteredSetMap is 'fit' — today's
behaviour, unchanged — with the alternative named beside it so the ruling is one
line and a rebuild. 'fit' is not a placeholder: it is the honest mode for a page
that cannot zoom, so the POSTER should keep it whatever the screen chooses.

The binding constraint is recorded there because it is not obvious from the
numbers: separation can never fall below twice the pin floor (5.2), and an
18.5-unit band therefore holds at most three rows, so no minSep reaches ring 2's
658 groups. minSep 7 places 931 of 2,074; minSep 5 places 1,369; minSep 4 places
1,369 — identical, which is what proves depth rather than separation is binding.
'fill-all' places all 2,074 with none under a label and ring 2 using 18 rows.

THE CAPTION states the TRUE total — the groups that have a ring — not the number
of pins currently drawn. In the dense mode the same groups read as an arc of
colour zoomed out and as separate pins zoomed in, so a caption counting what is
visible would change as the reader moved: true at every instant and misleading
overall. It joins the paragraph that already accounts for the centre, the
no-relation groups and the unmatched ones, so those four numbers reconcile
instead of competing.

centered.shown added in all eleven locales; parity and keys-exist pass, and
hardcoded-english did not rise (257, and that fall is studio's, not mine).
2026-09-29 03:34:22 +00:00
Doxa Maps
83131c64b7 UPG Neighbor keys: How it works names the letter and the prayer packet, beside the CSV 2026-09-29 03:22:40 +00:00
Doxa Maps
51ef8523e4 Two library composables point at the dashboard kit contract in the repo, not a spec outside it
useSelectionBus and useDashboardLayout cited sections of a design spec that lives outside the repository.
The contract as built is now library/dashboards/README.md, so both point there. Comments only.
2026-09-29 03:03:52 +00:00
Doxa Maps
30db4bb763 The people-group list is documented in the repo, library/lists/README.md, not in a file outside it
Four places in the repo (the registry, the resolver, the packet renderer and a gate's allowance) cited a
research spec that lives outside the repository, so a reader of the code could not follow the reference.
library/lists/README.md now documents the list as built: how a list is chosen, the registry and its five
lists with their measured counts, where membership is generated, the two tiers (narrowing DOXA's rows,
failing closed; IMB rows per map), the data sources and their terms, and the questions still open. The
four citations point at it.
2026-09-29 03:03:52 +00:00
Doxa Maps
af22670dfd Offline packets take page 1's map from the tracked posters on a fresh checkout
packet.mjs looked for page 1's map sheet only in
library/scripts/posters/out/posters/, which exists only after
"bun run posters". On a fresh checkout every offline packet printed the
missing-map notice, and check:packets-all failed each fixture packet
with map=NO.

The same 28 zone poster sources are tracked in data/posters/ and ship
with the tree. The generator now looks in out/posters first (a local
run's fresh sheets win), then in data/posters. With out/posters absent,
all four fixture packets now carry their map. Messages that named the
sheet give its real path.
2026-09-29 03:03:51 +00:00
Doxa Maps
65810f872f A back-to-top beside the view-list circle, and lift the Regions attribution clear of it
Two reported mobile bugs (DRV1650 #3 and #4), both settled by rega's
measurements at 390x844 on dx-life /research rather than by reading source.

★ #3 — THERE WAS NO BACK-TO-TOP AT ALL, and I had reported otherwise. I said
VerticalTabsLayout already rendered one targeting the active .vtl-host. rega:
ZERO .vtl-host elements in the live build, button.btt ABSENT from the DOM. The
chain: VTL renders .vtl-host only for `mountedElementRows`, which filters rows
carrying an `element` key — and this SPA passes :groups whose rows carry none,
mounting its own .dms-pane panes imperatively. So activeHostEl is null, and the
layout's button correctly renders nothing. That button is for layouts hosting
their own element rows; it is inert for the composition the gallery uses. I had
read the source and not the configuration.

The SPA now renders its own, bound to the ACTIVE .dms-pane — the scroller rega
measured (clientHeight 712, scrollHeight 12,373, overflow-y:auto) while the
document does not scroll at all. The gallery owns both the circle and the
scroller, so no shadow-root walking is needed. Placement reads --map-fab-beside,
published by the layout only while its circle renders, so the arrow sits BESIDE
the circle and falls back to the plain inset where there is none.
BackToTopButton already refuses to appear when its target does not scroll.

The label goes in all ELEVEN of the SPA's locales. Its translate() falls back to
the KEY, so a missing entry would have rendered the literal string "backToTop"
to every reader — en/ar/zh/ru/hi verified, and the Arabic and Chinese strings
are present in the built bundle.

★ #4 — THE REGIONS ATTRIBUTION SITS UNDER THE CIRCLE, and the geometry was not
what I assumed. rega: at 390 the attribution is details.maplibregl-ctrl-attrib in
the BOTTOM-RIGHT container, expanded to 347x44 at (22,791) — nearly the full map
width — so its LEFT END runs under the 44px fab at (12,788), 1,394 px² overlap.
The bottom-left container is EMPTY (0x0), so a bottom-left-only fix would have
changed nothing.

Both bottom containers are lifted off the published --map-fab-clearance under a
767px query, so a standalone leaf or a <doxa-country-map> with no gallery around
it reads the 0px fallback and nothing moves. The existing z-index:3 cannot help:
the circle lives in the layout, outside this shadow root, so the two are
different stacking contexts. Attribution is a LICENCE TERM of the basemap, which
is why this is a fix and not a polish item.

REBASED ONTO 0428f279c BY REPLAYING SOURCE, not by resolving a conflict: the
rebase collided only in generated bundles, where a conflict is never resolvable
by hand. Three files applied clean; RegionCountryMap.vue had MOVED to
library/components/maps in that same round (my own country-map work), so its rule
was re-applied at the new path by hand — a straight patch would have failed with
"No such file or directory", which is exactly what studio would have hit.
country-map, regions-dashboard and doxa-maps-spa rebuilt; both fixes verified in
the shipped minified bundles, including the new country-map bundle that now
inherits the lift.

NOT PROVEN HERE: the headless DOM proof needs a browser (rega's seat). What I
verified is that the button, its scoped CSS and its ar/zh labels are in the
SHIPPED doxa-maps-spa.js, and the fab lift in both map bundles.
2026-09-29 02:35:35 +00:00
Doxa Maps
807c78cc64 Every Experimental map carries its own hosted ?, where its controls already are
Map controls: prayer-walk-planner (under fullscreen), gospel-internet (the map and the language
tree, the board's default view), country-posters (the country map, its entry profile) and
all-people-groups get the library HelpButton with hosted. Own bars, where there is no control
column: the Wall (after the Bless/Social toggle), the PPLR hub (the end of its view bar) and the
vision card (its top corner, at the end of the reading direction so Arabic mirrors it) use
useHostedHelp. The vision card's label is the library's own "About this map" in each of the
eleven locales, copied into vision.json rather than translated again. UPG Neighbor is left as it
is: its bar already has an About button that opens the full explainer.

Each "?" stays hidden until a host marks its pane, so nothing changes until the app answers.
2026-09-29 02:35:35 +00:00
Doxa Maps
457d16e708 A map's own ? can ask its host for help: HelpButton and useHostedHelp send doxa:help
The Experimental maps' explainer lived in a "?" floating at the top centre of the app, which read
as a game piece rather than part of the map. The map already has a "?" control. This lets that
control answer from the HOST: a click dispatches doxa:help, bubbling and composed, so it crosses
every shadow root between the button and the page, and the app answers with the map's text.

library/composables/useHostedHelp.js is the one mechanism, for the HelpButton map control and for
the maps whose "?" belongs in their own bar. It also decides WHERE the button shows: only under a
host that answers (an ancestor, across shadow roots, carrying data-doxa-help), so the same bundle
embedded on a partner page shows no dead "?". HelpButton keeps its open-help emit, so
doxa-simple-map's own help popup is unchanged; hosted is opt-in.
2026-09-29 02:35:35 +00:00
Doxa Maps
c8dcb02e97 Keep the map artwork left-to-right inside an Arabic packet
Page 1 of an Arabic packet lifts the English 3x4 poster, and the
poster inherited the page's dir="rtl": English punctuation jumped to
line starts, the header's key and value columns collided, names ran
into their numbers, and four text blocks were pushed off the sheet's
left edge (rendered and measured in print emulation). The Chinese
packet, which is ltr, was intact.

The lifted sheet's wrapper now pins dir="ltr". The packet's own title
stays rtl; the relabelled Arabic country names are single runs, so they
keep their order.

check-packet-a1-structure asserts the pin on both kinds of sheet; it
fails without it.
2026-09-29 02:16:34 +00:00
Doxa Maps
88991641cf The offline packet generator runs again, and a gate now runs it
e18c7f05a declared the map relabeller above LANG, which it reads: a
temporal-dead-zone ReferenceError at startup, so every offline packet
run stopped before building anything. node --check passed, because it
checks syntax and not evaluation order, and no gate ran the generator.
The block now sits after LANG and VARIANT, with a note on why.

packet:ci already built the committed fixture packets through
packet.mjs, with no network, in under five seconds - and it catches
this exactly: exit 1 on the broken generator, exit 0 on the fix. It was
in no gate list, so it never ran. It is now check:packet-offline in
gates:pre.

Its failure message printed the last line of stderr, which for a crash
is Node's version banner ("Node.js v20.20.2"). It now names the error
line: "ReferenceError: Cannot access 'LANG' before initialization".
2026-09-29 02:16:34 +00:00
Doxa Maps
87c59e3a1f Name the map's countries on page 1 in the packet's language
The map sheets are generated once, in English, so in an Arabic or
Chinese packet page 1's map labelled every country in Latin capitals,
and its privacy line was English.

The labels are now replaced as the sheet is lifted into the packet.
They are recognised by the generator's own rule rather than a list:
generate.mjs draws each label as esc(ISO3_NAME[iso3].toUpperCase())
from the zone data's ISO3_NAME table, so inverting that table under
that transform names the country behind every label it can draw. The
inverse is unambiguous (162 labels, no two countries sharing one), so a
label is known exactly or left as drawn. The map's privacy line is word
for word the packet's privacy_note, so it takes this language's.

A width guard: the generator placed every label and then dropped any
that would collide, measuring the English name, so a translation is
fitted into that same box - estimated wider, it shrinks in proportion
and cannot reach a neighbour the English name did not. On the Sudan
poster, six Arabic names shrink (the longest, Saudi Arabia, from 15px
to 10px); none of the narrower Chinese ones do. Capitals' tracking is
dropped for scripts without capitals, where it breaks Arabic joining.

English is untouched: its relabeller is null, and the lifted sheet is
byte-identical with and without it. No sheet or poster is regenerated -
the change is in the lift, so the tracked 3x4 posters page 1 now uses
are relabelled as they are.

Both print paths supply the relabeller: the UPG 100 profile from its
own ISO3_NAME and the library's country-name resolver, the offline
generator from the same table and the injected ISO-3 to ISO-2 table.

check-map-labels (gates:pre), on the tracked Sudan poster: English
byte-identical; in ar and zh no known English name left, halo matching
fill, privacy localized; the guard fires for Arabic, not Chinese, and
grows nothing; Arabic tracking off, Spanish kept; an unknown label left
alone. Mutation-tested five ways, each caught.
2026-09-29 02:16:33 +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
b2ad4607ee Title each packet in its reader's language where the title can be generated
Every packet was titled with its zone's hand-written English label, so
a Spanish Sudan packet said "Sudan" and an Arabic one printed Latin
script on page 1, on every profile page's header and in the document's
own title.

A one-country zone's title is that country's name, and Intl supplies it
in every language: the packets now carry it - Sudán, السودان, सूडान,
苏丹 - through zone-title.js's tier A, on both print paths. Every other
zone keeps its hand-written label, since a region's name is not a list
of its countries (tier B and C are a pending product decision). The
selection packet names its cover the same way.

In the browser the title uses the library's country-name resolver; the
offline generator cannot load that module under Node, so it reads the
ISO-3 to ISO-2 table and injects it, the path zone-title.js was built
for. A data set without zone-title.js keeps its labels.

check:zone-title-tier-a was committed unregistered and so never ran; it
is now in gates:pre. Its comments, and zone-title.js's, no longer cite
people or dates.
2026-09-29 02:16:30 +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
c121c63834 The map template draws a map-only vertical page for the prayer packet
Page 1 of a prayer packet is a lightweight vertical one-pager showing
the zone's geography and every pin with its numbered name - not the
poster. Until now the packet lifted the 3x4 poster (1500x2000, its own
title, facts table, vision columns, networks and closing verse), shrank
it to 62% and pasted it under the packet's title: a small poster on a
page, with the English poster prose baked into the artwork.

FORMAT=packet is a new sheet format: 1500x1941, the packet's own page,
with the top band left EMPTY for the title the packet draws in the
reader's language, a slim foot, and everything between given to the map
and its label columns - about 1,600px of height against the letter
sheet's ~1,200, so the names get the larger type the sparse posters use.
It omits all the poster furniture, and its root <svg> says
data-sheet="map-only" so the packet can tell it from a poster.

Paper formats no longer overwrite the poster. Every format used to write
out/posters/<zone>.html, so FORMAT=letter silently replaced the 3x4 the
wall and the site serve. packet and letter sheets are now
<zone>.packet.html and <zone>.letter.html, with their own manifest;
3x4 and 3x3 keep the bare name, which every consumer fetches.
publish-html.mjs read everything before ".html" as the zone id and so
skipped every paper sheet as an unknown zone; it now strips the suffix.

The two fail-loud guards (header overflow, verse off the sheet) stay
armed for posters and stand down only for a sheet that does not draw
the furniture they measure.

Proof that posters are untouched: the default 3x4 output for sudan,
asia-india-kerala, deaf and west-africa is byte-identical before and
after, generated with POSTER_DATE pinned. The first attempt was NOT
identical - an HTML comment added inside the SVG template literal is
emitted into every poster - so the notes live in JS comments instead.

check-zone-census counted paper sheets as posters (it would read 56
for 28 zones); it now counts posters and reports paper sheets apart.
2026-09-29 02:16:27 +00:00
Doxa Maps
502d7107e3 Every zone a reader can pick resolves to its groups, Deaf included
The legend offers 28 zones; every place that turned a picked id back
into a zone spread clusters.json's three geographic buckets, which is
27. The missing one is Deaf, whose two deaf lists the legend collapses
into a single affinity-bloc row. So in the browser, silently:

  - the Prayer packet for Deaf returned on "if (!zone) return";
  - the Deaf poster titled itself by its slug;
  - a selection containing Deaf dropped it in .filter(Boolean);
  - the packet count read 27 while 28 packets exist, and that count
    prints on page 1 and at the tip of page 2's triangle.

The packet fetch also lacked rop1, the field the Deaf selector reads,
so even a found Deaf zone would have resolved to nobody.

The fix is one enumeration. legend-tree.js now exports DEAF_SELECTOR
(the unified row's selector, which exists nowhere in clusters.json) and
zonesWithSelectors(clusters): the legend's leaves with their selectors
attached. The plugin's printableZones() keeps the 27 catalogue objects
exactly as they were and adds only what the catalogue lacks, so the
packet, poster, selection and count all see the same 28. rop1 joins
both packet fetches.

The three hand-typed copies of the Deaf literal (the map, the poster
generator, the offline packet generator) now import the one
definition; the offline generator's own id-to-selector table is
replaced by zonesWithSelectors. The copies left are frozen snapshots and
proof scripts, which keep independent literals on purpose.

The offline sweep gate's coverage had the same 27: it never required a
Deaf packet. It now counts through the legend.

Verified: the live API carries exactly 123 A017 rows, matching the
legend's Deaf count; the default 3x4 posters for sudan, kerala, deaf
and west-africa are byte-identical before and after.
2026-09-29 02:16:27 +00:00
Doxa Maps
de2ec6fe46 bandFill mode: 'fill-all' draws every group and lets the zoom separate them
Rings that zoom apart, as a flag, so either answer to row 130 is one line at
the call site. DEFAULT IS 'fit' — this changes nothing today.

'fit' keeps every pin at least minSep apart at the chart's own scale and reports
what will not fit. That is the right behaviour for a PRINTED sheet, where there
is no zoom and a dot a reader cannot resolve is a dot that is not there.

'fill-all' places all of them. Separation is in map units, so it grows with zoom:
measured on the shipped table, the worst ring is ring 1 (1,190 groups in the
smallest circle) separating to 8px on screen at z=4.45, and ring 8 (1,932) at
z=3.50. The GROUP label tier already starts at 5.2, so every pin is clickable
before the names appear. At the start zoom the band reads as a dense arc of
colour rather than countable dots, which is the intended appearance — and the
reason a caller in this mode must caption the true total rather than a count
that changes with zoom.

The row count had to follow the POPULATION here, not the pin size. Keeping the
'fit' row count would have packed 1,932 pins into the two rows a band allows at
full size, and they would have sat almost on top of each other along the arc —
the single-line defect again, one level down. Ring 8 uses 14 rows in fill-all
against 2 in fit.

Real table: fit places 1,851 of 7,296, fill-all places 7,296 of 7,296, both with
zero pins outside their band. Six new assertions, and the row-growth arm is
poison-tested: making fill-all reuse the fit row count fails it at "2 rows vs 2".
2026-09-29 01:38:08 +00:00
Doxa Maps
6a6950c375 Write down the contract the four dashboard panels already share
A map, a data table, a charts rail and a detail drawer behave as one dashboard
without importing each other, and nothing said how. This records the arrangement
that exists on development today; every claim in it was checked against the code
rather than remembered.

One composable provides five keys and every panel injects what it needs, with
null and {} defaults that are load-bearing: a panel rendered outside a shell must
degrade rather than throw, because a thrown setup() removes the subtree with an
empty error and no build failure.

The part worth writing down is that the bus carries two different kinds of
traffic through one generic on/emit pair. Four STATE events belong to
useSelectionBus and are emitted only by it. Seven UI events are passed through
the same bus by panels with no composable behind them. Reading the second kind as
if it were the first is how a maintainer concludes a contract is dead.

That mistake is named in the file because it has already been made twice while
auditing this kit: two panels listen for 'select' and no panel emits it, which
looks like a broken wire until you notice the emitter is the composable at
useSelectionBus.js:202. Grepping components for a bus's traffic cannot see the
bus itself.

It also records that selection is deliberately NOT reactive -- selectionSet is a
plain Set and selectionVersion is the reactive handle -- and that the charts rail
participates in filtering only, so selecting a pin does not highlight its slice.
That last one is the current contract, not an oversight: whether selection should
reach the charts is a product question and it is open.

The components stay in plugin-designs. A library component with one consumer is a
relocation rather than a promotion, and this repo has already had to move such
modules back; they graduate when a second dashboard consumes them.
2026-09-29 01:38:08 +00:00
Doxa Maps
b3238db361 A band holds concentric rows, so it holds four times what I was counting
Found by running the SHIPPED table through the layout instead of the uniform
sample the module was built against. The real near-neighbours data is heavily
skewed — ring 8 carries 1,932 groups, ring 5 carries 100 — and against it
bandFill placed 962 of 7,296. It dropped 87% of the map, and every drop was
faithfully reported, which is exactly why no assertion caught it: all 16 asked
"is the overflow reported?" and none asked "is the CAPACITY right?".

The capacity model counted one circle of pins at the band's innermost radius. A
band has depth, so it holds several concentric rows, each with its own
circumference. Counting them is a ~2x correction at ringGap 24 and more at
larger gaps; on the real table it takes placement from 962 to 1,851 on the gate's
geometry and every under-full ring now places everything it is given.

Placement had to follow: a hashed radius with one angular step was fine while a
ring held a dozen pins and wrong once it held hundreds, because two pins could
take nearly the same angle at nearly the same radius while the capacity sum
insisted there was room. Pins are now dealt round-robin into rows, each row
evenly spaced across the usable arc, staggered a quarter slot so adjacent rows
do not line up radially and read as spokes.

Two bugs found on the way, both by the gate failing honestly:
- the stagger first added half a step to every odd row, which pushed that row's
  last pin past the end of the arc and into the label's keep-out — 7 overlapping.
  Staggering WITHIN the slot keeps every pin inside its own slot.
- the shrink arm tested at 200 groups, which only crowded a ring while capacity
  was undercounted. It now tests at 2,000, the honest pressure for a 7,296-row
  table, and still reaches the pin floor and reports.

The new capacity arm asserts a RATIO against the single-row count, not a magic
number: its first version hardcoded ">400" from the component's ringGap of 22
while the gate builds radii at 24 — the code was right and my threshold was
guessed. Poison-tested: forcing rows back to 1 gives 1.05x and fails both arms.
2026-09-29 01:38:08 +00:00
Doxa Maps
819051c08e A ring of bands, as pure functions: rings in, positions out
layoutCenteredSet gives every group on ring N the same radius, so a crowded ring
draws as one line of dots on a circle. The target needs each ring to be
a BAND, groups spread evenly through it, never overlapping each other or the
ring's label.

library/components/charts/ringBands.js is that geometry, with no Vue, no engine
and no DOM, so the Wall poster can consume the same module rather than growing a
second copy of it. bandFill() places; curvedRingLabels() returns each label's arc
AND the keep-out that bandFill refuses to place inside — computed from one source
so "pins never overlap the labels" is true by construction rather than usually.

Determinism is a requirement, not a nicety: every position is a pure function of
the item's own id, so two printings of one packet compare sheet to sheet and a
dot that moves means the DATA moved. There is deliberately no seed. Input order
does not affect any position either, so a caller that filters or re-sorts does
not reshuffle the chart.

An over-full ring SHRINKS its pins to a floor before anything is dropped — a
visible pin beats an unplaced one — and only then reports the remainder with the
ring and the counts.

check:ring-bands, 16 assertions, every arm with a negative control. One arm is
there because this gate passed the bug it now catches: uncapped, a long label on
ring 1 wanted 96% of that small circle, so capacity was zero and all twelve
groups on the innermost ring were reported unplaced while every arm stayed green
— "reported, not silently dropped" is not the same promise as "the ring draws".
Labels now truncate at a share of their own circumference and report how many
characters fit. Poison-tested: restoring the old share reproduces "empty rings: 1".
2026-09-29 01:38:08 +00:00
Doxa Maps
443b163682 Share the ported _globals.scss rules through one module
Seven shadow-root components each carry their own copy of DX Life's
base/_globals.scss rules, because page CSS cannot reach into a shadow
root. Measured first: where the copies overlap they are byte-identical
(the Cyrillic stack 3x, strong{font-weight:500} 2x, scrollbar-gutter 2x),
so this is deduplication, not a fix.

The module exports FUNCTIONS, not strings, and the measurement forced
that: the six copies of the Cyrillic rule share one declaration but have
six different subjects (.rd-goal-card__title, .rd-h2, .rd-title,
.region-card__title, .rl-title x2). A fixed rule string would have to
pick one class and silently stop styling the other five. The caller owns
its selector; the module owns the declaration.

cyrillicHeading takes a selector LIST, not a selector. GoalCard styles
one heading class; RegionSectionCard groups two into a single rule
sharing one declaration block. A one-selector signature emits two rules
instead of one -- the same CSS to a browser, but not the same
stylesheet, and the bar here is unchanged, not equivalent.

Migrated GoalCard and RegionSectionCard. regions-dashboard's two files
port the same rules and stay with their owner; the harness records them
so that migration can run against this same baseline.

The img border-radius rule (_globals.scss:80) is documented as
deliberately NOT ported, because an invisible omission is one that gets
"fixed" later.

PROOF: internal/check-ported-globals-identical.mjs executes each
component's setup, captures the string handed to useShadowStyles and
hashes it. The emitted CSS is byte-identical across the refactor
(987d94b1cd66110d, 897d948b17b21efe, both captured from 45e31f102).

The harness's first version hashed the SOURCE of the call and was
wrong: this refactor replaces a literal with a function call, so the
source changes by construction -- a text-hash gate fails a perfect
refactor and cannot tell that from a broken one. It measured the
artifact when the property under test was the output. It failed exactly
that way on first run.

Shown capable of failing, four ways: dropping letter-spacing, a wrong
font name, 500->600, and splitting the grouped rule into two. All four
go red; restoring goes green. Components that cannot be evaluated are
reported NOT MEASURED (exit 75), never as a pass.
2026-09-29 01:38:08 +00:00
Doxa Maps
a2540d9e73 The legend collapse caret stops turning when the reader asks for reduced motion
The caret (C11) and the reduced-motion gate were written in parallel, and only together did the gap
show: the new caret animates its arrow (transform 0.18s) and its colours with no reduced-motion rule.
The rule sits in the useShadowStyles string, where a shadow root can see it. The arrow still flips
to the right direction; it only stops turning on the way there.
2026-09-29 01:38:07 +00:00
Doxa Maps
c5c01deada Delete 7 unreachable library components, and gate the thing a census cannot see
Seven components with no importer, removed. The interesting part is not the
deletion, it is that an import census was NOT sufficient to authorise it, and
two other agents proved that rather than me.

WHAT AN IMPORT CENSUS MISSED. internal/check-gospel-set-charts.mjs iterated
['BoundedSet.vue', 'CenteredSetTarget.vue'] as literal STRINGS and read their
props off disk. No import, so no census and no build would have warned; deleting
them would have turned a gate red on main with nothing to explain it. gospel
found that in their own file after I asked, and retargeted the arm at the
components that actually render (GiBoundedSetMap / GiCenteredSetMap, 2d2e853a7).
Their retarget also fixed a quieter defect: the seed guarantee had been asserted
against two files NOTHING renders, so it would have kept passing while the
shipping component grew a seed prop.

I did not take either agent's word for it. Verified gospel's retarget on their
sha and ran their gate green before deleting; verified upg100's structural claim
that packet-render.js cannot mount a component at all — 0 resolveComponent, 0
defineAsyncComponent, 0 dynamic import(), with the same probe returning a hit
elsewhere in library so an empty result is a real absence.

DELETED: charts/LanguageTreeChart.vue, components/charts/VisionFunnel.vue,
components/charts/BoundedSet.vue, components/charts/CenteredSetTarget.vue,
components/legends/ListSelectorLegend.vue,
components/dashboards/DashboardBreadcrumb.vue, composables/useMapSelection.js.

NOT DELETED, and worth stating because the names collide: the lowercase
boundedSet.js / centeredSet.js HELPERS are load-bearing — packet-render.js and
GiBoundedSetMap import them directly, and check-gospel-set-charts still does too.
Also left alone: colors/resource.js (a colour STRATEGY resolved by MODE NAME
through _registry.js, 'resource' appearing in mapStore.js and GiResources.vue)
and layouts/app-wrapper-material/nav.js (115 mentions). Both look dead to an
import census and would break a working map.

check:no-dead-library scans for a filename as a STRING across internal/,
library/ and plugin-designs/, and:

  · DISTINGUISHES A READ FROM A COMMENT (gospel's ask). check-packet-variants.mjs
    discusses CenteredSetTarget.vue in prose while asserting against JSON. A
    comment hit is reported, never fatal — a gate that cannot tell the two apart
    cries wolf once and is then ignored. It currently reports 4 such mentions.
  · IS TWO-SIDED (upg100's ask). It also asserts a KNOWN-PRESENT dynamic
    reference is found — LegendDesktop lazy-loads LegendMobile through import().
    Mutation-tested: blinding the scanner makes it FAIL on that control instead
    of passing with "no references found", which is what a one-sided version
    would have done.
  · ignores a longer identifier that merely contains the name, so GiBoundedSet.vue
    is not read as a reference to BoundedSet.vue.

Both arms mutation-tested and every mutated file restored byte-identical.
Registered in all three places and verified by re-reading the parsed
package.json — the suite insert silently hit the scripts KEY instead of the suite
string on the first attempt, which verifying caught and trusting would not have.
2026-09-29 01:38:07 +00:00
Doxa Maps
36f2818b37 Every animating library component now honours prefers-reduced-motion itself
17 of 25 library components that ship motion carried no reduced-motion rule, so
they animated regardless of the reader's OS setting.

The reason it cannot be fixed once, centrally: a @media (prefers-reduced-motion)
rule in the host document does not cross a shadow boundary, and these render
inside one. Measured while surveying: NO generated leaf page ships such a rule
at all — 0 occurrences across the whole built tree — so there was not even a
page rule to inherit. Each component owns its own.

CSS-ONLY AND ADDITIVE. Nothing but new @media blocks; no existing rule changed.
Verified by diff: 0 real removals across 17 files. The two lines git reports as
removed are a trailing comma moving as a new string was chained onto an existing
concatenation, and both original rules are still present — checked by grepping
for them, not by reading the diff.

WHERE MOTION CARRIES MEANING, it is kept rather than hidden:
  · SemanticTreeLegend's spinner MEANS "loading". Stopped dead it is a static
    ring that says nothing, so it is slowed to a step crawl (2.4s, steps(8)) —
    visibly working, no smooth spin.
  · LegendProgressBar's only motion is a hover filter, so it simply stops; the
    bar's FILL is geometry, not animation, and still reports its value.

The rule goes where each component's styles actually go, and that varies more
than expected: 13 append into a useShadowStyles template literal, 3 into
string-concatenated sheets, and VerticalTabsLayout into its VTL_SHADOW_CSS
constant AND its duplicate <style> block, because its own header says the two
copies must stay identical.

check:reduced-motion asserts both halves, each mutation-tested to fail before I
trusted a pass: strip the rule from an animating component and it fails; move a
rule from the injected sheet into a <style> block and it fails too, because on a
useShadowStyles component that rule would never reach the shadow root. Both
mutated files restored byte-identical.

Two of my own errors caught by tooling rather than by review, and worth naming:
my first patch asserted useShadowStyles existed in every target and RAISED on
VerticalTabsLayout instead of silently skipping it — the assert-the-anchor habit
from the last registration failure working as intended. Then I appended CSS
after the label argument on two concat-built sheets, corrupting the injection
key; check:shadow-css-parses named both files and the fix was to insert before
the label. A gate I did not write caught what I got wrong.

Registered in all three places and verified by re-reading the parsed
package.json.
2026-09-29 01:38:07 +00:00
Doxa Maps
094647ebee C11: one legend collapse caret, and it says whether the legend is open
Three copies of the same control: SemanticTreeLegend's .stl-collapse-btn, and
two in LegendDesktop — the data-view caret and the detail-mode button, whose own
comment says it "Matches SemanticTreeLegend's .stl-collapse-btn look". It
matched the look. A control is not its look, and the copies lost both of the
parts that do not render:

  · aria-expanded. A disclosure control that never reports its state leaves a
    screen-reader user with a button called "toggle legend" and no way to learn
    whether the legend is open. This is the same defect I fixed in LegendMobile
    last week, in a component sitting in the same folder as the fix.
  · the RTL arrow. STL carries .stl-collapse-btn:dir(rtl) svg{rotate(0deg)}
    because a panel that slides off the other edge needs its caret to point the
    other way. LegendDesktop has no :dir(rtl) rule at all, so in Arabic its
    caret points AT the panel it would open.

The label also now names the ACTION rather than the control: collapseLegend /
expandLegend instead of a fixed "toggle legend". All three keys were already
translated in all eleven locales, so this adds no translation debt.

THE ARROW AND THE ARIA COME FROM ONE PROP. They drifted apart because they were
two independent pieces of markup that happened to agree; `open` now drives both,
so a future copy cannot get one right and the other wrong.

STATE STAYS WITH THE CALLER. Two consumers drive collapse from uiStore's
tri-state cascade (fullyOpen -> open -> collapsed) and one from a local boolean.
A shared control that quietly adopted either would have broken the other, so
this one owns no state and no persistence — it renders what it is told and emits
the intent.

TWO THINGS DELIBERATELY UNCHANGED. The detail-mode control was an INVISIBLE
28x28 hit target — transparent fill, no border, transparent colour — over the
panel's corner; making it suddenly visible is a look change nobody asked for, so
the slot keeps the position, the size and the invisibility, and only the
semantics improve. And LegendDesktop's four light/dark theme rules stay in
LegendDesktop: they are its themes, not the control's. They outrank the shared
base on specificity (0,3,0 and 0,2,0 against 0,1,0), so which sheet is injected
first does not decide the colour.

Caught while writing it: the first draft styled the caret with <style scoped>,
which defineCustomElement silently DROPS for any child loaded through
import.meta.glob — it would have rendered unstyled in every shadow root while
looking right in the source. That is what useShadowStyles exists for, and what
every sibling legend component already does.
2026-09-29 01:38:07 +00:00
Doxa Maps
c99fe68c38 A country map you can drop on one page, without the dashboard around it
DX-life's country pages render nothing where a map belongs. The map they need
has been in this repo since the regions clone landed — it was just unreachable
without loading the whole regions dashboard, a bundle that fetches the people
group list, builds four stat cards, a region chooser and a table, none of which
a country page wants. So this is not a new map. It is the same component, given
its own element and its own bundle.

  <doxa-country-map profile-config='{"countryCodes":["TCD"],"colorByPrayer":true}'>

RegionCountryMap and useRegionsI18n move to library/ because they now have a
second consumer, which is the only thing that earns a place there. Six import
sites updated in this commit; nothing else moved.

THE CONTRACT arrives through profile-config, the channel every element here
uses, so a generated page and a hand-written host page are configured the same
way: countryCodes, center, zoom, bounds, colorByPrayer, researchUrl,
prayBaseUrl. Two translations happen at the edge rather than by widening the
component — colorByPrayer is a boolean here and colorMode a string inside, and
countryCodes accepts an array OR "TCD,NER,MLI", because an HTML attribute is a
string and JSON-inside-an-attribute is the shape people get wrong.

THE ELEMENT FETCHES ITS OWN PINS, and that is what makes prayBaseUrl part of the
contract rather than decoration. An element handed a country code, a base URL
and no way to fetch would draw a country outline with nothing inside it — the
blank page it exists to fix, in a new disguise. Same endpoint, same fields, same
mapping as the dashboard; it asks for a country instead of the world.

THE FIELD LIST TRAVELS WITH THE MAPPING, in peopleGroupPoints.js. The obvious
version of this commit asks for PEOPLE_GROUP_LIST_FIELDS — the list the stats
need — and every pin still draws, every popup comes up empty, and nothing logs
anything. The popup half is what makes it a map you can read, so the two are
one export and neither can be requested without the other. The dashboard's own
request is unchanged: the concatenation produces the identical string.

countryUrl is additive, not in the confirmed contract. The component's country
link needs a base and DX-life's country pages live under /regions/; a host that
sets nothing gets that documented default.
2026-09-29 01:38:07 +00:00
Doxa Maps
c3c6945e71 UPG Neighbor keys: the neighbours prayer packet's button and cover label 2026-09-29 00:59:14 +00:00
Doxa Maps
9ab4710423 The poster's legend was painted over the map, unless you ask otherwise
usePosterLayout has always computed a real layout: the map narrowed by the legend
column and inset by the margins, the title strip and the footer strip. Nothing in
the exporter ever read it. The map was rastered at full page size -- edge to edge,
across the margins and both bands -- and the title, legend and footer were then
composited on top of that image. The legend lay over the map it describes, and
there were no bands at all.

What kept it hidden is that PosterPreview.vue DOES honour layout.map, so the
on-screen preview has always shown the intended design and only the exported file
was wrong. A preview is not evidence for an export.

The obvious fix breaks a different product. country-posters embeds this renderer's
output as a full-sheet <img> inside its own packet page, beneath the packet's own
title, so chrome that is on by default prints a second header and footer within
their page. UPG-100's packet does not call this renderer at all and was never at
risk -- checked rather than assumed. So the composed sheet is requested with
spec.layout === 'poster', and every existing caller, which sets none, keeps what
it had.

That guarantee is stronger than "equivalent": with no layout the renderer passes
the SAME px OBJECT it always did to the rasteriser and composites no sheet, so the
untouched path is unchanged by identity, not merely by value. The check asserts
exactly that, by executing the decision with four real caller shapes -- including
country-posters' A4 at 150 DPI -- rather than reading the source for a promise.
Its second negative test is the ruling's own failure mode: make defaultPosterSpec
set the layout and country-posters is named as silently opted in.

A sheet with no room left for a map after its margins, bands and legend column
falls back to full-bleed and says so, because silence there is the bug above.

Simple Research's export asks for the layout explicitly.
2026-09-29 00:59:14 +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
54ab789650 UPG Neighbor keys: a letter to a reached group's believers, naming the unreached peoples nearest them 2026-09-29 00:26:56 +00:00
Doxa Maps
ca24c91d7a Wall of People Groups keys: the names of the poster footer's seven stations 2026-09-29 00:26:56 +00:00
Doxa Maps
4d417f34df UPG Neighbor keys: the Mobilize list can show reached groups as well as unreached ones 2026-09-29 00:26:55 +00:00
Doxa Maps
b5118db17a UPG Neighbor keys: How it works tells churches to start from a green dot 2026-09-29 00:26:55 +00:00
Doxa Maps
ba54af0113 A destroyed map's container stops holding it
containerRef.__doxaMap was set when the map was created and never cleared, so the dead map (GL
context, sources, tiles) stayed reachable from the DOM after destroy(). It is now cleared, only if
it is still this instance's. The logo-owner claim is released by the commit before this one.
2026-09-29 00:06:52 +00:00
Doxa Maps
1eae8ed80d Every unmounted map stayed in memory, held by one line on window
rega's heap snapshots (leak-722ac7789): Simple Research and All People Groups
mounted twice went 54.2 MB to 65.1 MB, with live Vue apps 6 to 8. Every unmounted
application was still there, one copy per mount, and the retainers were all the
same shape -- a window key set during setup and never cleared. The closure keeps
its component, the component keeps its shadow root, and a whole app survives its
own unmount. An element property dies with the element; a window key does not.

The named one was window.__doxaLogoOwner -> Map -> div.rm-map-canvas -> ShadowRoot
-> app, released only when the map's 'remove' event fires. That listener cannot be
the guarantee the global is cleared, because it only runs if remove() is reached;
it is a consequence of the guarantee, not the guarantee itself. useMapInstance now
drops the claim inside destroy(), before removing, so it goes even if remove()
throws part-way.

As for why remove() was never reached: research-map's unmount hook ran ten
teardown steps and called destroy() last, and two of those steps were unguarded
calls into other composables. A single throw anywhere above stranded the map, and
with it the app. I could not reproduce which step threw without a browser, so the
fix does not depend on knowing: each step is isolated and destroy() runs from a
finally, where nothing above it can skip it. Ordering teardown so the most
important step is last, behind nine chances to fail, was the real defect.

The globals themselves are released by the components that set them -- four verify
hooks in research-map, three in the dashboard map panel, the vocabulary hook in
the grid, which had no unmount hook at all. Released as SETS: __rdashPinAt was the
one the snapshot named, but __rdashMap holds the map and retains strictly more, so
clearing only the reported one would have looked fixed and leaked nearly as much.

The deletes are written one per line instead of looped over an array of names, so
that the check can read them. A release a checker cannot see is worth no more than
no release, the day someone removes three of the four.

The check flags only values that actually retain. A warn-once boolean latch and a
page-wide cache keyed off itself are deliberate and must not be reported, or it
becomes noise and gets switched off. It is NOT in gates:pre: 19 sites remain in
other owners' plugins, and this is a fleet pattern rather than one plugin's bug.
2026-09-29 00:06:52 +00:00
Doxa Maps
63276767cf The poster credited Mapbox for tiles OpenFreeMap had served
The footer's attribution is a licence statement, so it has to be true rather than
merely present. posterDefaults ships "(c) Mapbox (c) OpenStreetMap" and any caller
that did not override it printed exactly that -- including country-posters, whose
maps run on the fleet's MapLibre default, where Mapbox serves nothing at all and
OpenFreeMap, OpenMapTiles and OpenStreetMap are the three parties whose licences
actually apply. research-map had a hand-written pair for the two providers, which
was right for research-map and helped nobody else.

The obvious approach is to read the style's sources. Checked against the live
style rather than assumed, and it does not work: tiles.openfreemap.org/styles/
liberty declares two sources and NEITHER carries an attribution field. The credit
lives in the TileJSON the vector source points at, which the engine fetches and
merges into the SOURCE OBJECT once it loads. The style is the request and the
source is what came back, and only the second one knows. map-attribution.js reads
the live source first and falls back to any style-declared value.

The TileJSON value is HTML with anchors and a &copy; entity, and a poster footer
is text, so it is flattened rather than printed raw. Repeated credits collapse.
When nothing can be read -- no map, a style that throws before it has loaded -- it
returns empty and the caller keeps its own string, because a blank licence line is
the one outcome that is never acceptable.

The check uses the real TileJSON string as its fixture. A tidy invented style
would have passed while the real map printed the wrong credit, and its first
negative test is exactly that: reading the style alone returns nothing for
OpenFreeMap.

Cut directly on main rather than on the branch this began life in, so it does not
wait behind a poster-layout change it has nothing to do with. A wrong tile credit
is a licence problem; the layout question it was sitting behind is not.
2026-09-29 00:06:52 +00:00
Doxa Maps
813a52ebbe One page loaded the same bundle twice and nothing said so
Every plugin ships as a self-contained IIFE, so library/utils/embedScript.js is
compiled separately into each one: eleven bundles on main each carry their own
copy. Its cache was a module-scoped const, which made "load exactly once" true
per BUNDLE rather than per page — the opposite of what the file's own header
claimed. Two shells nesting the same child (the Research Gallery and the Plugin
Studio both mounting doxa-research-map) each injected it.

Nothing failed, which is why it survived. The second request came from the HTTP
cache, so no failed request and no extra network row; the bundle then executed in
full a second time — a second Vue, a second vue-i18n, a second pinia, ~375 KiB
gzip of work — and its guarded customElements.define quietly declined at the very
end. Where the two URLs are different versions of one plugin, whichever executed
first owns the tag and the other is discarded in silence.

The cache now lives on globalThis, the only scope that matches the claim, under a
versioned key so a page mixing old and new bundles falls back to a cache each
rather than misreading a shape it does not expect. Entries are keyed by the
resolved href, because the callers do not pass the same string for one file.
A <script> already in the document is adopted, which is what covers bundles built
before this and host pages that write their own tag; that resolves immediately,
and it is safe only because every caller here follows with a wait on the element
itself — the note in the file says so, since a new caller must keep that pairing.

claimTag is a diagnostic, not a guard: by the time a second bundle arrives the
define is long since won, so the thing that was missing was anyone saying so.

The check imports the module twice under different URLs — two module instances
sharing one globalThis, the same shape as two IIFEs on one page. It asserts the
second caller receives the FIRST one's promise, not merely that one script tag
exists: mutation-testing showed the tag count alone is satisfied by the adoption
path, so it would have passed with the original bug restored.
2026-09-29 00:06:51 +00:00
Doxa Maps
73bc270bc0 The embedded map drew 340px below its card, and said nothing for the 30s it took
Two faults rega measured in the /embed-test re-shoot at 1440, both on main.

THE MAP SIZED TO THE VIEWPORT, NOT ITS HOST. This element had no :host rule at
all, so it defaulted to display:inline with no height of its own and .gm's
absolute inset:0 resolved against whatever ancestor happened to be positioned:
a 1198x900 canvas inside a 1198x560 embed card, with ~340px including the
attribution drawn below the card and clipped. The attribution is a licence
obligation, so clipping it is not cosmetic. Now :host{height:100%} so a
height-bounded slot decides, with min-height:420px as a floor so an auto-height
host still gets a usable map rather than a zero-height one.

NOTHING SAID IT WAS WORKING. First data paint takes 7-30s under SwiftShader —
161 countries, 33 Indian states, 2,105 dots — and for that whole window the
reader saw an empty basemap, indistinguishable from a broken embed. Adds a
notice that starts visible on the first frame and clears after the LAST paint
(paintAdm1), not on 'load': the style fires 'load' early, so clearing there
would retire the notice during exactly the wait it explains. An error replaces
it rather than stacking, and the error path clears it too, so a failure cannot
leave it spinning forever. role="status", since this is progress and not an
interruption.

connectivity.loading added in all eleven locales; parity, keys-exist,
shadow-css and legend-logical-insets all pass. The notice uses inset-inline, not
left/right: it is translated text and mirrors under ar.
2026-09-28 23:43:44 +00:00
Doxa Maps
311b5c24a0 Two drawer links said "Gospel recordings" and "Disciple-making training" in every language
GiDrawer's LINKS table names gospelInternet.drawer.gospelAudio and
.drawer.training at src/components/GiDrawer.vue:30 and :32, but neither key
existed in ANY of the eleven locales. The call-site English then stood in
everywhere, so an Arabic or Chinese reader opening a people group saw two
English labels beside five translated ones.

The keys reach T() from a data table rather than a literal T('key', 'en') call,
which is why a call-site scan does not see them: the gate reports the fallback
string, and the fallback was doing its job too well to notice.

Added in all eleven. Parity and keys-exist pass; key count is +2 per locale,
249 -> 251, with nothing else moved.

The plugin's hardcoded-english count stays at 200 either way: that gate counts
the English fallback ARGUMENT, not whether the key resolves, so it cannot
distinguish a wired string from an unwired one.
2026-09-28 23:23:58 +00:00
Doxa Maps
c54bde307f CLDR plural selection, and the "N countries / N regions" keys it was blocking
vue-i18n v9 pluralizes by an English-shaped INDEX and applies it to every
locale. Measured on a real instance, authoring 'a | b | c':

  ru  n=0 -> the ONE form,  n=2 -> the MANY form     CLDR: 0=many, 2=few
  ar  n=3, 11, 100 -> ALL the dual form              CLDR: few, many, other

That is confidently wrong grammar that looks translated, which is worse than
the single fixed form the regions summary shipped until now. upg100 proposed a
plural key and was correctly refused on this exact ground
(upg-100-list.vue:3006) — "real plural support needs a pluralRules map at
createI18n". The refusal's premise was the missing map, so this adds it.

THE PRECONDITION SWEEP (dx-shepherd), before installing anything: every
catalogue value containing " | " across all 11 locales, and every call site
passing a plural choice.

  pipe keys:    wall.prayedCount, wall.havePrayed — and ONLY in en. Every other
                locale ships a pipe-free label form ("Помолились: {n}").
  call sites:   exactly two, both in wall-of-people-groups
                (CommentsDialog.vue:36, wallCanvas.js:198).

  BEFORE/AFTER, 2 keys x 11 locales x n = 0,1,2,3,11,100:
                132 renders compared, 0 CHANGED.

Zero changed because the map is SCOPED TO EXCLUDE en, which is the one locale
holding pipe strings today. en is also the case dx-shepherd's assertion (3) is
about: vue-i18n's 3-form default is zero|one|many while CLDR for en is
one|other, so an en string written "none | one | {n}" would change meaning at
n=0. Leaving en out means it cannot.

★ THE BUG I SHIPPED INTO MY OWN FIRST DRAFT, caught by reading the rendered
Russian rather than by any assertion: the index was taken against the fixed
zero|one|two|few|many|other list, which is only correct for a locale that HAS
all six. ru has {one, few, many, other} — no zero, no two — so 'few' sat at
global index 3 while the authored string holds it at 1, and n=2/n=3 rendered
"2 стран" instead of "2 страны". ARABIC HID IT COMPLETELY, being the one locale
that does have all six, so its two indexes coincide. The index is now computed
against each locale's OWN category subset, and ru/ro are authored in that order.

  ar  0 لا دول · 1 دولة واحدة · 2 دولتان · 3 دول · 11 دولة · 100 دولة
  ru  0 стран · 1 страна · 2 страны · 3 страны · 11 стран · 100 стран
  (regionCount likewise; all six categories distinct in ar, all three in ru)

check:plural-forms carries studio's three assertions, each mutation-tested to
fail: FORM COUNT (a 2-form ru string fails against its 4 categories), the
tStatic PATH (reading a pipe key through tStatic fails — tStatic does no
selection at all, so the page would show the literal "a | b | c"), and ZERO (a
5-form ar string fails because ar has an explicit zero category). Every mutated
file restored byte-identical afterwards.

The regions shim gains tc(key, n). t() resolves through tm()+rt(), and rt()
takes a message only — there is nowhere to pass the choice — so a pipe string
would come back whole. tc goes through vue-i18n's own t(key, named, choice).

Registered in all three places and VERIFIED by re-reading the parsed
package.json, not by trusting the replace: my last gate registration silently
no-opped on two of three anchors and studio caught it at the landing.
2026-09-28 23:23:56 +00:00