Compare commits

...

462 commits

Author SHA1 Message Date
Doxa Maps
3a6d392a1a Rebuild 8 bundles for the print warnings (a sheet that cannot be generated; a page that is not secure), and regenerate the tree and SRI 2026-10-03 11:42:25 +00:00
Doxa Maps
e209096fd7 Merge remote-tracking branch 'origin/development' into studio/print-warn 2026-10-03 11:22:36 +00:00
Doxa Maps
96a0cd6ca8 Rebuild the sheet generator's carriers for the UPG100 page-1 names and header sizes: poster-render re-pinned, the tree and SRI regenerated 2026-10-03 11:16:41 +00:00
Doxa Maps
3e226bd807 Merge the UPG100 page-1 names change (larger people-group names, smaller header-column text) 2026-10-03 11:09:38 +00:00
Doxa Maps
67ac9a8670 Rebuild the sheet generator and packet carriers from the merged pilot for the UPG100 header's even columns; the tree and SRI regenerated 2026-10-03 11:05:20 +00:00
Doxa Maps
1855c8cad4 UPG100 poster page 1: larger people-group names and smaller header-column text (header columns 22 to 18px; names capped at 19px instead of 15, with tighter rows on dense zones) 2026-10-03 11:03:34 +00:00
Doxa Maps
149e8d144b Merge the pilot (development 1c5711e33) into the UPG100 header's even columns 2026-10-03 11:00:11 +00:00
Doxa Maps
1fca636297 Rebuild the sheet generator's carriers for the UPG100 header's even columns (the three end at about the same height; scripture references stay whole): poster-render re-pinned, the tree and SRI regenerated 2026-10-03 10:51:32 +00:00
Doxa Maps
1c5711e339 Rebuild the packet carriers for the 12 Prayer Points wording and the page's tighter smallest tier, then the tree and SRI 2026-10-03 10:46:54 +00:00
Doxa Maps
ca2d36c8fd Merge the pilot (development 6d48d8b17) into the UPG100 header's even columns 2026-10-03 10:45:03 +00:00
Doxa Maps
926815240d UPG100 packet, 12 Prayer Points: the page's smallest size tier is a little tighter so the new wording fits one sheet
At the floor tier the gap between points is 3px (was 5px) and the point text 18px (was 18.5px). All 11
languages fit one sheet; several now take a larger tier. The packet-strings pin is renewed for the new wording.
2026-10-03 10:44:36 +00:00
Doxa Maps
62fad6ed5a UPG100 packet, 12 Prayer Points: new English wording for the twelve points and the Father's-heart lines
Spelling and capitalization made consistent across the page (lowercase people groups, nations and
churches; heavenly Father; neighbors), verse ranges set with an en dash, and point 9's and point 12's
references updated in every language (references are shared addresses). Revelation joins the book table
in all 11 languages. Other languages keep their wording.
2026-10-03 10:42:41 +00:00
Doxa Maps
4c3a118d2e UPG100 poster header: scripture references stay whole; the classic header drops bold markers
- A scripture reference never breaks between its book and its
  chapter:verse in the header's text columns ("Mark 6:40"); before, two
  zones printed "(Mark" at a line's end and "6:40)." on the next.
- (With the parent commit: the three header columns end at about the same
  height. The facts column widens when it runs long, the side columns stay
  equal and mirrored; across the 28 zones the height spread drops from
  99 to 72 px, mean 54 to 34.)
2026-10-03 08:59:50 +00:00
Doxa Maps
5d3b2cf95d Pinned files: a page that is not secure is told so in words
A browser gives crypto.subtle only to a secure page (https, or http://localhost). Opened any other way, for example a dev server reached over plain http by its LAN address from a phone, every pinned file was refused with a bare "reading 'digest'": the packet printed in English and page 1 fell back to its stored sheet. sha384() now refuses with a sentence that names the cause. check:pinned-fetch-fresh arm D2 removes crypto.subtle and requires that sentence; its self-test plants the unguarded call.
2026-10-03 08:58:09 +00:00
Doxa Maps
e8a1a1f12c Poster prints: a sheet that cannot be generated says so before the stored one prints
A generated sheet (opts.sheet) that fails still falls through to the stored sheet, so a print never comes out blank, but no longer silently: the console names the format, the sheet and the reason. A stored sheet with an old header once printed in place of the generated one with no word of why. An empty result stays quiet: a generator returns '' on purpose when it cannot draw a sheet.
2026-10-03 08:49:58 +00:00
Doxa Maps
4ec5905492 UPG100 poster header: the three columns end at about the same height (WIP: verifying) 2026-10-03 08:46:36 +00:00
Doxa Maps
6d48d8b178 Rebuild the Share button's importers for the Embed tab's three codes (shareable pages P3b); tree and SRI regenerated
The 14 bundles that import ShareButton (or read the share catalogue area), built
one at a time on the merged pilot cee32f273, then bun run tree + fix:sri. The
build-config key and define reach every bundle; the others are byte-identical
(the in-lock freshness check runs on every bundle).
2026-10-03 08:33:24 +00:00
Doxa Maps
fb1c646553 Merge remote-tracking branch 'origin/development' into deep-research-plugin/share-p3b-clean 2026-10-03 08:28:14 +00:00
Doxa Maps
cee32f2732 Rebuild the sheet generator and packet renderer carriers for the set-caption count, the list's page-1 flow, the poster's header words and mirrored columns, the UPG100 label and the header Share menu: poster-render and packet-strings re-pinned, the tree and SRI regenerated 2026-10-03 08:20:42 +00:00
Doxa Maps
3a7e4a3eaf Share > Embed: with no plugin page to point at, the plain note, never the app page
The URL option is the plugin's own page and nothing else. Where no base resolves
(the dev server, where modules are not bundled) or the embed path is not
tree-relative, URL and Iframe now show the same plain note the JavaScript option
does, instead of falling back to the page's share link (the app around the
plugin). Share this page is unchanged.
2026-10-03 08:15:39 +00:00
Doxa Maps
a28ee3f5ee Merge the pilot (development 05ae488ea) into the UPG100 combined landing 2026-10-03 08:11:51 +00:00
Doxa Maps
896775903b Share > Embed: three codes for THIS plugin (URL, Iframe, JavaScript), from the tree it is served from (shareable pages P3b)
The Embed tab now offers the plugin's own page (URL), that page in an iframe
(with the sizes), and its own bundle + element (JavaScript), each with one plain
sentence and one Copy button. They embed the plugin whose Share was pressed, even
nested in Plugin Studio, Deep Research or Posterverse.

Where the codes point: a new build-config key shareBaseUrl, baked as
__DOXA_SHARE_BASE__ and overridable per embed (profile-config.shareBaseUrl) or
per page (window.MAP_SHARE_BASE_URL), https only (or a local test server). It is
committed EMPTY: the codes then point at the tree the bundle is served from, so
the stable site gives its own public URLs by construction. embedBaseUrl stays
empty, so the manifest's snippets and a dropped tree are unchanged.

The JavaScript code is the plugin's embed.script from that tree's manifest
(library/utils/publicEmbed.js, fetched once when Embed opens, default
credentials), made absolute, with integrity dropped from our unversioned bundle
URL (a pinned hash would break every partner's embed at the next release). A
plugin that tree does not carry gets a plain note instead of codes that would
404. Share this page stays first; the explanation and the 5 steps stay. English
only (parity debt 19 per locale). The library index lists publicEmbed.js.
2026-10-03 08:10:31 +00:00
Doxa Maps
8e13e53389 Merge the pilot (development bcbaa816a, P2 addresses inside tabs) into the UPG100 combined landing 2026-10-03 07:59:14 +00:00
Doxa Maps
05ae488eac The view-address census: data-tables.vue no longer writes history directly (Show all goes through commitUrl) 2026-10-03 07:58:01 +00:00
Doxa Maps
d94e5351a3 Merge remote-tracking branch 'origin/development' into gospel/gr-clearfilter
# Conflicts:
#	internal/check-view-address.mjs
2026-10-03 07:58:00 +00:00
Doxa Maps
bcbaa816ae Merge remote-tracking branch 'origin/development' into regions-plugin/view-address-p2 2026-10-03 07:48:30 +00:00
Doxa Maps
04057b9177 Merge remote-tracking branch 'origin/development' into regions-plugin/view-address-p2 2026-10-03 07:38:38 +00:00
Doxa Maps
32c6eb4fd4 Rebuild gospel-resources from the merged pilot, and regenerate the tree 2026-10-03 07:34:30 +00:00
Doxa Maps
3be82cb23f Shareable pages P2: rebuild the reached bundles from the merged source, then the tree and SRI 2026-10-03 07:34:06 +00:00
Doxa Maps
f85e880b2c Gospel Resources: its two checks run in gates:pre again after the pilot merge 2026-10-03 07:31:02 +00:00
Doxa Maps
055b17b5b1 Merge remote-tracking branch 'origin/development' into gospel/gr-clearfilter
# Conflicts:
#	package.json
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/gospel-resources/charts/index.html
#	public/dx-maps/gospel-resources/data-tables/index.html
#	public/dx-maps/gospel-resources/gospel-dashboard/index.html
#	public/dx-maps/gospel-resources/gospel-resources.js
#	public/dx-maps/gospel-resources/resources-list/index.html
#	public/dx-maps/gospel-resources/resources-map/index.html
#	public/dx-maps/manifest.json
2026-10-03 07:31:01 +00:00
Doxa Maps
8fcb784556 Merge development into repo/readme-desktop 2026-10-03 07:30:42 +00:00
Doxa Maps
98e8cace71 Merge origin/development into studio/leaf-escape 2026-10-03 07:25:28 +00:00
Doxa Maps
ef49411405 Merge remote-tracking branch 'origin/development' into regions-plugin/view-address-p2 2026-10-03 07:23:34 +00:00
Doxa Maps
0b066ba1ae Merge the Research Gallery's dev manifest fallback (shareable pages P2 assembly) 2026-10-03 07:19:40 +00:00
Doxa Maps
edbd280d4d Research Gallery dev fallback: the manifest at the public root first, so the dev server serves children as the raw build
Reached as /public/<tree>/, the dev server treats a built bundle as source and
injects an import into its IIFE, so a child loaded as a classic script threw
"Cannot use import statement outside a module" on the staging page. The root
path is tried first; /public/<tree>/ stays last, for a plain static server.
Built trees resolve the manifest before these fallbacks and are unaffected.
2026-10-03 07:19:22 +00:00
Doxa Maps
49b0fff62b Merge Posterverse's dev manifest fallback (shareable pages P2 assembly) 2026-10-03 07:18:40 +00:00
Doxa Maps
792def78b6 Merge the pilot (development f6594b77c, P3 the Share button) into the UPG100 combined landing 2026-10-03 07:18:19 +00:00
Doxa Maps
09d884ce3a Posterverse: on the dev server, read the built tree from its root, not /public/
On a staging page the manifest fallback was /public/<tree>/manifest.json, which
made /public/<tree>/ the base for every child script. The dev server treats a
/public/… request as source and injects an import into the built IIFE, so the
child failed as a classic script ("Cannot use import statement outside a
module"). The fallback is now /<tree>/manifest.json, where the dev server serves
the built tree unchanged. Built trees are unaffected (they resolve the manifest
before this fallback).
2026-10-03 07:18:00 +00:00
Doxa Maps
be28255bd0 UPG100: the header no longer declares the Share menu's area
The shared Share menu now finds its place at open time and opens
beside its button by itself, so the header's declared area is no
longer needed (measured on the shared fix's build: 340px, beside the
button, at 1440 and 390, en and ar).

This reverts commit 6f3a45f44.
2026-10-03 07:09:55 +00:00
Doxa Maps
3e041ba646 Rebuild gospel-resources for the loading line's table name 2026-10-03 07:05:25 +00:00
Doxa Maps
a6c5ca5a86 Gospel Resources tables: the loading line names the table it is loading 2026-10-03 07:05:01 +00:00
Doxa Maps
492229c5ba Rebuild gospel-resources for the tables fixes, and regenerate the tree 2026-10-03 07:04:04 +00:00
Doxa Maps
723cd53b43 Gospel Resources tables: the loading line calls the page's own translator, so it no longer throws on every load; a gate keeps template translators bound 2026-10-03 07:01:47 +00:00
Doxa Maps
1fee1009bb Dev server: the leaf check runs before every landing; the framed reason corrected
check:dev-leaf-escape and its self-test join gates:pre. The comment on the
dev pages' owner rule named the wrong reason for leaving a framed page
unowned: the frame is a partner page that iframes a leaf (the iframe
embed). Plugin Studio mounts its rows directly.
2026-10-03 06:55:10 +00:00
Doxa Maps
15d0ef3558 Gospel Resources: Show all writes through the one history writer, only on a page it owns, and its check runs in gates:pre 2026-10-03 06:54:24 +00:00
Doxa Maps
5d3218acfd Merge remote-tracking branch 'origin/development' into studio/leaf-escape 2026-10-03 06:53:17 +00:00
Doxa Maps
05d82ca558 README: agents join the pilot; only the landing branch is landed on
Four lines outside the swarm section still used 'land' for a change joining development: the agents' first bullet, the Plugin Studio line, the cloud dev server and the laptop-follows paragraph. They now say join / add to the pilot, so 'land' only ever means the landing branch.
2026-10-03 06:52:48 +00:00
Doxa Maps
2b1f55e7cf Merge the pilot (development ceb7168bf, P1 tab addresses) into the UPG100 combined landing 2026-10-03 06:52:42 +00:00
Doxa Maps
dcb005a734 Merge origin/development into gospel/gr-clearfilter 2026-10-03 06:51:57 +00:00
Doxa Maps
f6594b77c7 Rebuild the Share button's importers for the Share menu (shareable pages P3); tree and SRI regenerated
The precondition's scope against the pilot (its own step-4 rule): every bundle
that imports ShareButton/MapControlMenu or reads the share catalogue area, plus
the P1 shells. Built one bundle at a time, then bun run tree + fix:sri.
gates:pre 141/0/1, gates:post 99/0/16.
2026-10-03 06:48:36 +00:00
Doxa Maps
dacb9a0762 useViewAddress: unchanged means the same view-key VALUES, not the same string
Measured in a browser on a fresh load: ?owner=x&pane=map came back as ?pane=map&owner=x through one replaceState.
viewSearch puts the view keys first, and writeViewKeys compared strings. The link meant the same thing and no Back
step was added, but it was a needless write, and on a host it would be a needless router.replace. writeViewKeys now
compares the parsed tab/pane/place. check:view-address B covers it, with a poison (44); the real-file revert fails.
2026-10-03 06:45:39 +00:00
Doxa Maps
6f94ff0e30 pane-wrapper: a pending link is also retried by a bounded rAF with an observer present, and by every settled scroll
From the 5B: an observer fires only when the COLUMN resizes. A pane whose height arrives later (styles adopted late
in a shadow root) would have left the link pending, and a pending link blocks the settle's write. check:view-address
covers it: a fresh load whose observer never fires again still opens the link (43 poisons).
2026-10-03 06:42:17 +00:00
Doxa Maps
2b62cc0120 Merge Country Posters' pane address (shareable pages P2) 2026-10-03 06:39:09 +00:00
Doxa Maps
7ad84e5610 pane-wrapper: a fresh load with ?pane= opens that pane once the column has its layout
Measured in a browser on Country Posters and on Regions: a fresh load with ?pane=<second pane> stayed on the first
pane, with the column's scrollTop 0 at 6 s. The column is built in the composite's onMounted, before it has a
layout, so the pane's offsetTop was 0 when the link was shown. And a ResizeObserver's first callback, which comes
after layout, only recorded the height.

Now the link waits until the column has a height and the pane a top, then shows it once. The wait is the observer's
callback, or a bounded rAF without one. A scroll that settles while the link waits does not overwrite it. The
composite's view readout follows from the scroll event it already listens to.

check:view-address F models a column with no layout at build, whose observer first fires after layout. There are 2
new poisons (42 in all), and restoring the old immediate show in the real file fails the gate.
2026-10-03 06:39:09 +00:00
Doxa Maps
4428156bfe Country Posters' panes are in the page address: on its own leaf the open pane is ?pane= (Map; the first pane is the default), and nested it announces to the shell, the same opt-in as Regions and UPG100 2026-10-03 06:36:09 +00:00
Doxa Maps
6884c2a052 README: the production count follows the manifest; the rollback point is the outgoing release's version tag
manifest.json marks 8 maps production (the gallery's 7 plus the country map); Country Posters and Poster Design are in the gallery but still marked experimental, and the README now says so. Publishing tags the outgoing release with its own version (v0.2.0 in the example), which is the rollback point; going back means deploying the previous release's tag.
2026-10-03 06:33:28 +00:00
Doxa Maps
54fdead5e1 check:view-address: every composite that opts into the address also owns its leaf (a census from the tree, 40 poisons) 2026-10-03 06:32:22 +00:00
Doxa Maps
8a90eb5000 README: describe how the work actually lands, and today's map counts
PR branches start from the pilot and take in the latest pilot by merging, never rebasing; a second
agent, usually the repository's agent, reads the change (and looks at it where it shows). In the lock
the pilot is merged once more, gates:pre and gates:post run with the landing checks
(internal/landing-preconditions.mjs), and the branch is pushed only if nothing fails. There is no
single build owner: each change rebuilds the bundles it reaches, then the tree and SRI. Publishing
tags the outgoing release as a rollback point, runs both gate sets on the exact commit, and builds the
landing demo from the release tag. The counts follow the code: the Research Gallery composes nine maps
(plus Country Posters and Poster Design, both in Prayer Mobilization, so they leave the Experimental
list), ten maps are in production with the country map, and Posterverse brings six poster plugins
together. The glossary's "merge" row matches.
2026-10-03 06:32:00 +00:00
Doxa Maps
19bf214650 Merge UPG100's pane address (shareable pages P2) 2026-10-03 06:31:53 +00:00
Doxa Maps
08d236e0df check:view-address: each shell's ownAddress wiring (G), with a poison per shell (38 poisons) 2026-10-03 06:31:40 +00:00
Doxa Maps
ce15aba511 UPG100: the composite's pane in view is part of the page address (?pane=) 2026-10-03 06:31:14 +00:00
Doxa Maps
68842185ea Merge Plugin Studio's ownAddress (shareable pages P2) 2026-10-03 06:30:42 +00:00
Doxa Maps
00577668d6 Merge the Research Gallery shell's ownAddress (shareable pages P2) 2026-10-03 06:30:32 +00:00
Doxa Maps
86a601b3b2 Merge the Posterverse shell's ownAddress (shareable pages P2) 2026-10-03 06:30:18 +00:00
Doxa Maps
e4c31b6583 Research Gallery: a nested composite's pane and place reach the page address (shareable pages P2)
ownAddress on the shell's element, while the page owns its address: a nested
composite's open pane or selected place is written by this shell, the page's
one writer, and a nested element can never switch the shell's tab. Released
in beforeUnmount.
2026-10-03 06:30:10 +00:00
Doxa Maps
5a3194bd08 Plugin Studio: a nested composite's pane and place reach the address
Studio owns the page address and writes ?tab=. A composite nested in a
tab announces its own choice (its open pane, its selected place); Studio
now records that beside ?tab=, while it owns the address, so a reload
or a sent link opens the same pane and place. Only pane and place: the
tab stays Studio's own. The listener is released on unmount, before the
hot-update return.
2026-10-03 06:30:07 +00:00
Doxa Maps
8c05f6207a Posterverse: a nested plugin's view inside a tab reaches the address
The shell owns the address for the views inside its tabs too: a nested
composite's ?pane= / ?place= choice is recorded through the shared helper, only
while this page owns its address, never as a tab switch, and the event stops at
the shell.
2026-10-03 06:29:51 +00:00
Doxa Maps
c963a2a9fa P2: studio's 5B notes. A nested element cannot switch the owner's tab, a kept-alive pane or place is claimed back when its tab is shown again, and scrollTo takes null
- ownAddress records only pane and place. tab is the owner's own key, so a nested announcement cannot switch it.
- A shell keeps Regions (and UPG100) alive when another tab is open. Switching back dropped ?pane= and ?place=
  (the tab change), while the screen still showed them. pane-wrapper and region-dashboards now watch for being
  shown again (a ResizeObserver: no height, then height) and claim their pane / place back with a replace. An
  address that names one wins, because Back to an entry of this tab is applied while the column has no layout.
- scrollTo(id, null) behaves as scrollTo(id).

check:view-address covers each of these (34 poisons, each caught). The slow pane-wrapper arm runs only for
pane-wrapper poisons, so the self-test takes 16 s.
2026-10-03 06:26:53 +00:00
Doxa Maps
9c170fd143 README + diagram: three roles, one shepherd agent that all the other agents talk to
The agent model names exactly three roles: one agent per repository (handles its branches, watches the work in it), one agent per plugin, and one shepherd agent that all the other agents talk to, which keeps order and puts the merges onto the pilot in order. The harness paragraph, the kitchen list, the alt text and the diagram's circle, caption and legend say shepherd. The PNG is re-rendered at 2x.
2026-10-03 06:26:51 +00:00
Doxa Maps
e7879fb8c6 README + diagram: the repo agent reads every change
With the roles as they are (one coordinator, one repo agent per repository, one agent per plugin), the step that read every change before it joins the pilot is the repo agent's, not an unnamed second reader: the summary row, the PR-branch bullet, the diagram's banner and its step 2 card say so. The PNG is re-rendered at 2x.
2026-10-03 06:25:13 +00:00
Doxa Maps
31ebb77a07 README + diagram: the roles as they are, one coordinator, one repo agent per repo, one agent per plugin
The swarm section and the delegation diagram named a gate-keeper that checks each merge; that role
does not exist. The roles are one coordinator agent that puts the merges onto the pilot in order, one
repo agent per repository that handles its branches and watches the work being done in it, and one
agent per plugin. The harness paragraph, the kitchen list, the alt text, the diagram's second circle,
its caption and its legend say so, and the Experimental shelf's "roaming agent" becomes an agent per
plugin. The PNG is re-rendered at 2x.
2026-10-03 06:23:47 +00:00
Doxa Maps
a97312142f README: drop the old desktop card that said the app was not yet live
The deploy5-desktop card read "Proven, not yet live", which the desktop section now contradicts: the desktop app works and Rudder is its proof. The README's only use of it goes, and so do the card and its SVG source, which nothing else uses. The sentence that the map app runs as a desktop app the same way stays.
2026-10-03 06:20:13 +00:00
Doxa Maps
c21bcd2e13 Share this page never copies a token; neutral wording in the P3 comments
pageLink() drops ?tk= from the page's address: a leaf page reads a token from
there and leaves it in place, so pressing Share would have sent it. The URL is
rewritten only when tk is present; every other link is the address as it stands.
The comments name the page, not who measured it, and no longer cite a request.
check:map-menu-anchor strips a same-line comment before testing each return, so
a fallback named only in a comment no longer passes.
2026-10-03 06:18:57 +00:00
Doxa Maps
003af1caa8 Shareable pages P2: a composite's open pane and Regions' subject are in the page address
The address gets its second and third keys:
- ?pane= is the open view of a composite;
- ?place= is Regions' subject.
Both keep the ONE-writer rule:
- On its own leaf the composite owns the address and writes.
- Nested in a shell it announces, and the shell writes.
- On a partner's page nothing is written; a link is still read.

useViewAddress:
- claimView() is the one call for a reader's choice: the owner writes it, any other element announces it.
- ownAddress() lets an owner record its nested elements' announcements, and stops them there.
- An announcement carries only the view keys, one event per action.
- pane and place are siblings: a tab change drops both, but a pane change keeps the place, because Regions keeps
  its region while the reader is on the list.

pane-wrapper (buildPaneColumn) takes an opt-in `address`:
- a link opens its pane without writing;
- a click is a push, and a selection made by the same click rides in the same push (one Back step);
- where the reader's scroll settles is a replace;
- Back/Forward scroll and never write;
- the first pane is the default and is never written.

Regions:
- regions-mfe opts its column in and records its panes' announcements on its own leaf. A list click opens the
  dashboard with its place in one write.
- region-dashboards claims ?place= instead of writing its #slug with its own pushState (replace at first paint,
  push on a choice, re-applied on Back). An old #slug link still opens, and on its own leaf it is rewritten to
  ?place=.
- urlSync:false now guards only the tab title.

check:view-address adds:
- E: nested claims and the owner;
- F: pane-wrapper on a simulated column;
- G: the Regions wiring.
The census drops region-dashboards (5 writers now). Self-test: 29 poisons, each caught. Real-source mutations are
caught too.
2026-10-03 06:09:51 +00:00
Doxa Maps
484f07382e Dev server: a leaf URL cannot write markup into the page it opens
The dev leaf route wrote the URL's parts into the page's <title> and the
element's profile-config attribute, refusing only .. and /. A quote or a
tag in a crafted link broke out of the attribute on a publicly reachable
dev server. Each part must now have the shape every bundle, profile and
instance id has, before it reaches either; the title is escaped too.
check:dev-leaf-escape drives the plugin's own middleware: every leaf the
dev manifest advertises is still served in both URL forms, hostile parts
are refused in every position, and the title escapes on its own.
2026-10-03 06:09:09 +00:00
Doxa Maps
ac71afaf55 Share opens on "Share this page"; Embed is its own tab; a header menu opens beside its button (shareable pages P3)
ShareButton: the first tab is "Share this page", the link to exactly what the
reader is looking at. Where the page owns its address (urlOwner 'app' or 'host',
on the map's own profile-config or any element above it across shadow roots)
that is the page's own URL, read at open and at copy; on a partner's page it is
the map's own page, as before. The second tab, "Embed", says in plain words what
embedding is and why it helps, keeps the buttons.shareSteps list, then the paste
hint, the sizes and the code. Copy/sizes labels come from the catalogue.

MapControlMenu: a standalone anchor (a Share button outside any map toolbar)
resolves where its menu lives when it opens, and places it below the button,
toward the side with room, inside the viewport, at the readable width (340 or the
viewport minus its edges), converting into the menu's real containing block. The
render-time area tag that sent header menus far left and narrow is gone. Map
toolbars keep their placement unchanged.

English only for now (the new share.json keys are recorded as translation debt in
check:i18n-parity). check:map-menu-anchor reads a resolution that lives in a named
function, every named-area return must keep its structural fallback.
2026-10-03 06:05:46 +00:00
Doxa Maps
b843fcade1 Gospel Resources: Show all drops only the table filter's own keys, keeping the tab, a partner's keys, the hash and the history state 2026-10-03 06:00:01 +00:00
Doxa Maps
6f3a45f44f UPG100: the header Share menu opens beside its button, at the maps' width
The composite's header now declares itself the Share menu's area. The
Share button's fallback looked for its area during render, before
layout, and settled on its own narrow parent, so the menu clamped to
its 200px floor and opened at the far left of the header (at 1440 and
390, en and ar). With the header declared, the menu opens beside the
button at the maps' width, mirrored in right-to-left pages.
2026-10-03 05:47:39 +00:00
Doxa Maps
ceb7168bf3 Merge remote-tracking branch 'origin/development' into regions-plugin/view-address
# Conflicts:
#	package.json
2026-10-03 05:43:09 +00:00
Doxa Maps
f69a10c8e0 UPG100 poster: new header words, mirrored columns, UPG100 spelled as one term
- The Jesus column and the "What is a UPG100 List?" column take their new
  wording (series.json); "informed intercession." prints bold, a **...**
  run in the head-columns band.
- The two prose columns take the full measure, so each sits the same
  distance from its rule and they mirror each other round the centred
  facts column.
- The Poster Design label of the UPG100 zone posters spells UPG100 and
  matches Poster Design's own card title, in all 11 languages.
2026-10-03 05:42:42 +00:00
Doxa Maps
5c03996dbb Merge the Research Gallery staging page's address ownership (shareable pages P1) 2026-10-03 05:42:23 +00:00
Doxa Maps
bb970b441f Library index: list useViewAddress 2026-10-03 05:41:31 +00:00
Doxa Maps
7285fda93d Merge the dev server's address ownership for Plugin Studio and the leaf pages (shareable pages P1) 2026-10-03 05:41:25 +00:00
Doxa Maps
d65747a300 Merge the Posterverse staging page's address ownership (shareable pages P1) 2026-10-03 05:41:25 +00:00
Doxa Maps
8bd66a3131 Merge the pilot (development 789d90eb2) into the set-caption count 2026-10-03 05:40:18 +00:00
Doxa Maps
fba3187cd4 Dev server: Plugin Studio and the leaf pages own the address too
The dev pages passed no urlOwner, so on the dev server a shell read ?tab=
but never wrote it: a clicked tab was lost on reload and a copied link
opened the first tab. The root page, every /__leaf/ page and the Studio
staging page now own the address, as the built leaves do. A framed leaf
(Studio iframes its map rows) stays unowned, so it never adds entries
to the top page's Back stack.
2026-10-03 05:39:23 +00:00
Doxa Maps
34073e7c13 Shareable pages P1: rebuild the reached bundles from the merged source, then the tree and SRI 2026-10-03 05:37:18 +00:00
Doxa Maps
dc32000bfe Research Gallery dev page: it owns its address (urlOwner 'app'), so a tab pick writes ?tab=
The dev server serves plugin-designs/doxa-maps-spa/index.html as-is, with no
profile-config, so the shell read ?tab= there but never wrote it. The page is
ours: it now passes urlOwner 'app'. A partner's page still leaves it out.
2026-10-03 05:36:47 +00:00
Doxa Maps
ce1d3b6a46 Posterverse: the dev staging page owns its address
The staging page the dev server serves sets profile-config urlOwner 'app', so on
the dev server the shell writes the open tab to ?tab=: a reload keeps it and a
copied link opens it. Inside the Plugin Studio, Posterverse is nested and still
writes nothing (the Studio owns the address there).
2026-10-03 05:36:19 +00:00
Doxa Maps
789d90eb2f Merge commit '9c381b619e5596e3cc7c1909c242c4985c59712e' into poster-design-plugin/experimental 2026-10-03 05:33:11 +00:00
Doxa Maps
da14272f96 Merge remote-tracking branch 'origin/development' into regions-plugin/view-address 2026-10-03 05:33:02 +00:00
Doxa Maps
b90fb29960 Rebuild bible-posters and my-upg-100-list for their merged catalogue keys; tree and SRI regenerated
The merged posterDesign blocks brought biblePosters.posterDesign.label and
upgMap.posterDesignTerritory, catalogue areas these two bundles ship, so
their committed bytes were stale against the merged source. Rebuilt only;
no source edit.
2026-10-03 05:27:37 +00:00
Doxa Maps
1118219bd5 Merge Plugin Studio's view address and the generated pages' urlOwner (shareable pages P1) 2026-10-03 05:27:34 +00:00
Doxa Maps
7d7ae092b3 Merge the Posterverse shell's view address (shareable pages P1) 2026-10-03 05:27:33 +00:00
Doxa Maps
dfed65c8af Merge the Research Gallery shell's view address (shareable pages P1) 2026-10-03 05:27:33 +00:00
Doxa Maps
8c674c0447 Posterverse: don't overwrite a link to a tab that arrives with the manifest
The first-paint write is skipped when the address names a tab the shell
doesn't know yet (a row that only resolves once the manifest is in). Writing
tab 0 there would replace the link before applyManifest could honour it;
applyManifest's own first-paint replace writes the honoured tab.
2026-10-03 05:26:59 +00:00
Doxa Maps
4241609572 Plugin Studio: the open tab is in the address, so a reload keeps it and a sent link opens it
Plugin Studio read its tab from #<bundle/profile> or from localStorage, and
never wrote it, so a reload worked for one reader but a sent link did not. It
now writes the view address through useViewAddress: ?tab=<bundle>__<profile>
(a row key's "/" is not a plain id; the double underscore keeps a sent link
meaning one row even if another bundle later adopts the profile's name). A
reader's tab click is a push, the first paint a replace, and Back/Forward
re-open the named tab without writing. route() reads ?tab= first (or a bare
profile name while it names exactly one row), then the old #bundle/profile
(rewritten to ?tab= on first paint, its hash dropped), then the remembered
tab, then the first row. The listener is removed before the hot-update return.

generate-tree bakes urlOwner 'app' into our own leaf pages only (never into an
embed snippet), and a framed leaf drops it at runtime, so a page embedded in a
partner's iframe reads its address but never adds entries to the partner's
Back button.
2026-10-03 05:26:13 +00:00
Doxa Maps
32cba5f8fe Posterverse: on a page it owns, the address's tab beats a baked config tab
When the shell owns the address, a reload or a shared ?tab= now wins over a
profile-config tab, so the first-paint replace can never overwrite a sent link
with the default. Elsewhere the order is unchanged (attribute, config, URL).
2026-10-03 05:25:36 +00:00
Doxa Maps
f0c97577e3 Research Gallery: the tab is in the page address (shareable pages P1)
A reader's pick in the rail writes ?tab= through useViewAddress as a push,
so a reload keeps the tab, a sent link opens it, and Back returns to the
previous tab. Only the address owner writes: urlOwner 'app' writes history,
'host' gets a dxm-address event for its router, and a partner's page is
never written. Programmatic switches (the manifest's re-select, the device
fallback, a host's tab attribute, Back/Forward) write nothing, and there is
no first-paint write. Back/Forward re-open the tab the address names on a
page that owns its address. ?tab= is read through readViewKeys.
2026-10-03 05:25:14 +00:00
Doxa Maps
86f1c1a463 Posterverse: the open tab is in the page address
The shell reads ?tab= through the shared view-address helper and writes the
open tab back through it: a reader's choice is a push, first paint a replace,
and Back / Forward re-apply the tab without writing. Only the address owner
writes (urlOwner 'app' on our own pages, 'host' hands the change to a host's
router, absent on a partner's page = read only), so a reload keeps the tab
and a sent link opens it.
2026-10-03 05:24:16 +00:00
Doxa Maps
00782796a9 useViewAddress: say which writers share commitUrl, and why a push copies the router's state
The header claimed that every write to the address bar goes through commitUrl. Only the view keys and the filters
do. The tree's other history writers are pinned by check:view-address's census. The header also says why a push
copies the previous state: a Vue Router page is a 'host' owner, so its router does its own pushes.

Comment only.
2026-10-03 05:23:18 +00:00
Doxa Maps
30dc9a1457 Shareable pages P1: one address helper, and the filters write through it
library/composables/useViewAddress.js is the page's address for the view a reader is on: ?tab= &pane= &place=, with
a level rule, an id whitelist, and ONE history write (commitUrl). It keeps history.state and sets its `current` to
the new URL, so a link survives the next in-app navigation on a Vue Router host. The OUTERMOST owner writes,
named by profile-config urlOwner:
- 'app' writes history itself;
- 'host' hands its router one dxm-address;
- absent, nothing is written and links are still read.
Nested elements announce dxm-view. Back/Forward is read and never written.

useShareableView's filter write used to call replaceState(null, ...) itself, which wiped the router's state. It
now goes through commitUrl (writeFilterView). A filter write keeps tab/pane/place, and a tab/pane/place write keeps
the filters.

Level 2 is `pane`, not `view`: ?view= is already published by four of our pages.

check:view-address (gates:pre) runs on a simulated browser history. It covers:
- the whitelist and the level rule;
- one push per tab change, with state + current kept;
- no write when unchanged, on a partner page, or by a host owner;
- the filters and the view keys keeping each other through writes, Back and Forward;
- a census of every history writer in library/ and plugin-designs/ (6 today). A new writer fails until it is listed.

The self-test has 14 poisons, each caught. Real-source mutations are caught too: drop current, drop the unchanged
guard, drop the level rule, an uncomposed event, no length cap, an aliased pushState in a plugin.
2026-10-03 05:20:43 +00:00
Doxa Maps
446b0bfc8f UPG100 packet: Full detail page 1 rows follow the explanation
Page 1 of the Full detail list reserved a fixed block for its
explanation and placed the rows below the reserve, so every language
shorter than the tallest left a white gap above the head row (0.36 to
1.30 in). The explanation (at most the old reserve) and the rows now
share one column: the head row sits right under the explanation and
the spare room falls to the page's foot. The page still holds the same
number of rows; the rows stay absolutely placed, only their origin
moves.
2026-10-03 05:15:12 +00:00
Doxa Maps
8b30be2481 Merge commit '1d8f5f1cc' into poster-design-plugin/experimental 2026-10-03 05:10:30 +00:00
Doxa Maps
0a4c9cc600 Merge the pilot (development 1d8f5f1cc) into the set-caption count 2026-10-03 05:09:07 +00:00
Doxa Maps
9c381b619e Rebuild wall-of-people-groups from the merged source (the LORD + the pilot 1d8f5f1cc), and regenerate SRI and the tree 2026-10-03 05:07:52 +00:00
Doxa Maps
2717d0ffe7 Merge remote-tracking branch 'origin/development' into wall-of-people-groups-plugin/work 2026-10-03 05:07:07 +00:00
Doxa Maps
ffd85f0590 Wall of People Groups: every verse says "the LORD", from the World English Bible British Edition
Every verse now comes from one translation, the WEB British Edition (public domain), which reads "the LORD"
(the WEB reads "Yahweh"). Every text was re-checked verbatim against it (bible.helloao.org eng_webpb), 31 of
31 matching; the check found three mid-sentence names written with the sentence-start placeholder ("says the
LORD of Armies", "Now the LORD said", "as the LORD had told him") and the British spellings (towards, honour,
offence, amongst, marvellous). The credit line names the one translation.
2026-10-03 05:07:07 +00:00
Doxa Maps
095aa67fbf README desktop section: the gist up front, the rest in a fold-out
Output 3 opens with three sentences (browser to desktop with the same code, Rudder as the working Linux proof, its private-by-design goal) and one screenshot. How it works, Rudder's goal in full, and what isn't run yet move into a details fold-out. The two Studio bullets go back to one-line gists.
2026-10-03 05:01:44 +00:00
Doxa Maps
6dfaf630e1 README: Rudder's goal, private voice-to-text that you own
Under the desktop output, Rudder's goal from the start: offline, with no telemetry and no cloud. Your
own code and your own self-hosted speech model (NeMo-Speech.cpp and a ~742 MB model) on a modest
amount of CPU; after a one-time download of the runtime and model nothing needs the network (the
runtime was traced: zero sockets, zero DNS). Working out of the box is the aim; the first-run download
is still manual. Because a voice-to-text tool hears everything, changes to rudder-voice and
rudder-engine should come only from trusted people, each read by a second person. Planned: an egress
monitor that shows Rudder sends no data out.
2026-10-03 04:58:23 +00:00
Doxa Maps
f2c8c0a90b README: the desktop app works; one code base from the browser to the desktop
The desktop output now leads with what it shows: what you run on the development server in your
browser ships as a desktop app from the same code, wrapped by Tauri in a small Rust program. Rudder is
the proof: the offline voice-to-text plugin runs in the Plugin Studio and as its own desktop app, and
the Linux app works and is used to dictate the work on many of these maps. The summary table, the
"speak instead of typing" and "one plugin, several outputs" bullets and the desktop section say so;
the details keep what is not run yet (macOS, Windows, the automatic production build).
The desktop HOWTO's proven table records the launched Linux app.
2026-10-03 04:56:56 +00:00
Doxa Maps
1d8f5f1ccd Merge remote-tracking branch 'origin/development' into regions-plugin/no-country-posters-tab 2026-10-03 04:33:34 +00:00
Doxa Maps
1e53b531b0 Poster Design draws UPG100's zone posters, from a library copy of UPG100's zone code
UPG100's zone-poster code lived only inside its plugin, so Poster Design
(which draws from the library) could list UPG100's posters but never show
them. It is now COPIED, unchanged, into library/packet/upg100Zones/
(zone-sheet-print, zone-sheet, legend-tree, selector-evaluator,
zone-geometry, zones-for-list, clusters.json; only the library import paths
are shorter). UPG100's own files are untouched.

Poster Design adds a UPG100 zone posters family drawn from that copy with a
real zone (middle-east-core) and all the records, as UPG100's own wall
does: the letter, 3 x 3 and 3 x 4 zone posters under UPG100's own design
names (so the drawn card replaces the no-preview plugin card), and the
prayer packet's page 1 (the full letter zone poster). Keys in 11 locales.

check:upg100-zone-copy (in gates:pre) keeps the copy equal to UPG100's
originals beyond the import paths; once UPG100 switches to the library copy
and deletes its originals it becomes a one-path gate. Its self-test proves
a one-character edit, a changed clusters.json, a missing file, a half
switch and a re-definition each fail. check:poster-design-catalogue allows
a template to carry a plugin's design name only when it declares that
plugin (poisoned). Source only: poster-design is rebuilt when the heavy
slot reopens.
2026-10-03 04:26:50 +00:00
Doxa Maps
c6abc67706 Merge the pilot (development 1fa35309a) into wall-of-people-groups-plugin/work
The four Wall sources both sides changed (blessPoster.js, wall.css, wallCanvas.js, check-wall-no-bg-photos.mjs)
merged without conflict: the pilot's side is the comment-only privacy rewording of those files (c1a2f3698); both
sides are kept (no agent names; every branch change present). The generated files (the wall bundle, its leaf page
and the two manifests) were taken from the pilot and rebuilt from the merged source, then fix:sri and the tree.
2026-10-03 04:20:24 +00:00
Doxa Maps
e043402006 Poster Design: production designs first; experimental ones in a collapsed group
Styles now lists the production designs by family, and puts every
experimental design last, in one Experimental (in progress) group that
starts collapsed (a click opens it). Experimental: the side-lane country
sheets (66x66, 36x60, and 60x96, still being designed), the map print (no
preview: it prints the live map), and every other plugin's design (made by
its own plugin, so not drawn here). templates.js marks them experimental;
posterDesign.family.experimental is new in all 11 locales.
2026-10-03 04:19:37 +00:00
Doxa Maps
0777bd6d1c Regions dashboard: two views again, Regions List and Region Dashboards; the Country Posters view and its profile are removed (the list's per-region country posters stay) 2026-10-03 04:17:42 +00:00
Doxa Maps
e380ab8cbb Wall of People Groups: UPG-style section dividers, the cornerstone line as the call to action, and the priestly blessing as the footer
The Bless poster follows the UPG 100 List posters' structure:
- the header and the prayer-themes chart close on dividers that run the page's width (to a quarter of the
  border from the sheet's edge), while the text stays in the content column;
- at the foot: the call to action in a band ruled across the page ("Jesus Christ is the cornerstone, and we
  are living stones. ... bless them in his name, and join the global family in prayer.", moved down from the
  header), then the journey's table, a page-wide rule, and the footer: the whole priestly blessing,
  Numbers 6:24-26 (World English Bible British Edition, public domain, verbatim), in two lines that fill
  the width, with its reference and the credit line.

Measured on the print at 3 x 4, 2 x 3 and 5 x 8 ft, en and ar: the blessing fills 99.2% of the text width
(49 pt at 3 x 4, 74 pt at 5 x 8); the call to action is one line, 97.6% of its band in English; no text
overflows (the only overflow is the dividers' intended reach past the column). Both em constants come from
those measurements. Cards: 3 x 4 0.49 in, 2 x 3 0.35 in, 5 x 8 0.92 in (one verse per row).
2026-10-03 04:13:07 +00:00
Doxa Maps
547760b512 Re-add upg100's posterDesign block to its poster-wall meta, on the pilot's new label
The three upg100-territory-poster designs (2759ff493), unchanged, on top of
the pilot's renamed label. Tree and SRI regenerated.
2026-10-03 00:58:39 +00:00
Doxa Maps
69510aae86 Merge the pilot (development) 1fa35309a into poster-design-plugin/size-labels
Conflicts resolved by taking the pilot's side: upg100's
my-upg-100-list/poster-wall.meta.json (its new label) and the generated
files (regenerated). upg100's posterDesign block is re-added in the next
commit.
2026-10-03 00:58:33 +00:00
Doxa Maps
042d2ec10f UPG100 packet: the set captions count every group of the list
A group with no record in a set figure's data (no near-neighbour data,
for example) was in none of that figure's numbers, so the captions
added up to less than the list: Laos printed 94 of its 95 groups. Each
caption now names the groups without data ("1 without data", in all 11
languages), so its parts add up to the list. check:packet-variants
asserts the sum both ways.
2026-10-03 00:55:43 +00:00
Doxa Maps
81dc4a9a5d Merge the pilot (development 1fa35309a) into the no-India-tab fix 2026-10-03 00:54:30 +00:00
Doxa Maps
1fa35309af Rebuild the sheet generator and packet renderer carriers for the full-width life charts, the shorter page-1 strategy and the self-fitting points page: poster-render re-pinned, the tree and SRI regenerated 2026-10-03 00:25:39 +00:00
Doxa Maps
e3d56d2f15 UPG100 packet: full-width life charts, a shorter page-1 strategy, self-fitting points
- The life calendar and the reached chart share one grid at the page's
  full printable width: each week a 22x11 box, bold year numbers every
  5 years (larger every 10), week numbers 1/13/26/39/52, a clear rule
  every 10 years and light rules at the quarters. The reached chart has
  no week or year numbers; each 10-row block is labelled with its share
  of the list's people, 10% to 100%.
- Page 1: the UPG100 series sets its own one-paragraph Jesus column
  (series.json strategyBody) and prints the explainer's first paragraph
  only (sideBoxMore), so the three columns balance and the map grows.
  The long text is one constant shared by both header layouts; the
  Country and India-state posters print it unchanged.
- The points page takes, per language, the largest size tier that fits
  one sheet, up to the earlier two-sheet sizes. The tier comes from a
  character-count estimate; the render gate fails if the real page is
  ever taller than that estimate.
2026-10-03 00:20:58 +00:00
Doxa Maps
daa757d972 No India tab on the pilot by dev mode alone: the India preview (its tab and its whole-country poster) needs an explicit opt-in too, ?preview=india or localStorage 'dxm-preview', and every built bundle still compiles the whole read away. The gates hold the opt-in and that dev mode alone stays withheld. 2026-10-03 00:20:35 +00:00
Doxa Maps
649038a917 Rebuild the sheet generator and packet renderer carriers for the packet points on one sheet and the reordered page-1 columns: poster-render re-pinned, the tree and SRI regenerated 2026-10-02 23:41:25 +00:00
Doxa Maps
f22dc7db00 UPG100 packet: points on one sheet, page-1 columns reordered
- The twelve prayer points and the themes fit one sheet again, as large
  as the page allows (points 18.5px, themes 19px); measured in all 11
  languages, the tightest keeps 75px under the last point.
- Page 1 header columns read: Jesus' Strategy to Feed the 5000 | the
  facts | What is a UPG100 List? The facts' labels are bold.
- The header columns are measured for a fallback font too, so the right
  column no longer runs to the border when Lato is absent.
- check:packet-render expects one points sheet.
2026-10-02 23:38:26 +00:00
Doxa Maps
789c3b8744 Merge the pilot (development 0dc73cc48, the docs rename) into the packet v2 final landing 2026-10-02 22:55:41 +00:00
Doxa Maps
98764ecae4 Rebuild the sheet generator and packet renderer carriers for the packet v2 final pages: poster-render re-pinned, the tree and SRI regenerated 2026-10-02 22:53:24 +00:00
Doxa Maps
4d4fa4f6d0 Packet print at reading size: the prayer points flow over two sheets at ~9pt, and Full detail's tally heads take two lines over the worksheet's 160px columns
Points: text 22.5px (~9.2pt on letter), headings 25px, the Mark 5 passage 20px, the themes 21px; the themes and
points 1–6 on the first sheet, 7–12 and the credit on the second, each titled with its range. Measured in all 11
languages: the fullest first sheet keeps ~330px. Full detail: tally columns 160px (the worksheet's), heads 15px
in two lines in every language, the first-page block 720px (the tallest, ru, uses 692), 11 rows on the first page.
Gates: the render gate measures BOTH points sheets (pointsOver), counts the points by .pt-h across the document,
expects 2 points sheets; the structure gate reads consecutive points sheets as one entry; profile-table probes the
15px tier between the two budgets wherever they fall.
2026-10-02 22:50:16 +00:00
Doxa Maps
2e0422f6fa Packet page 1 reads on paper: the full UPG-100 sheet's header band and footer at ~8pt with darker ink, the verse on two lines
Only the full layout (HEAD_COLUMNS, the packet's page 1, printed on letter at 92%): the column text 14 → 22px
(~8.3pt), the fact keys and the verse reference 19px (~7pt), the column headings 23px, the footer's three blocks
13.5 → 22px with their lines re-measured; the Matthew 9:36 verse is set on two balanced lines at 19.5px. The band's
measure fills its column (0.96), a country list longer than twelve prints ten and '+N' (the Deaf bloc names 66),
and the full footer band is 400px. All 28 zones render; each map is 858px (it was 1,206). The series-wide rules
are unchanged, so the SPARSE posters are byte-identical.
2026-10-02 22:49:49 +00:00
Doxa Maps
360e361614 Rebuild the packet renderer and text carriers for the packet v2 Full detail pages: packet-strings re-pinned, the tree and SRI regenerated 2026-10-02 22:29:27 +00:00
Doxa Maps
9910b7e410 check:evangelical-share reads the life grids as SVG: the week and year numbers are text.lc-t, and the reader's words are checked with the tags stripped (the grid's markup names data-weeks as geometry) 2026-10-02 22:28:30 +00:00
Doxa Maps
63cef78455 Packet print: the prayer themes at 21px (~8.6pt on letter), the credit and privacy lines 0.28in above the sheet's edge, and a Hindi font stack beyond the vendored face
The fine print (the Metacamp credit on the wheel and points pages, the privacy line) sat 22px (0.14in) from the
edge on letter, inside the band printers cannot ink; it now sits at 50px. The points page gives back the room:
point text 18.5 → 18px, 6 → 5px between points, the Mark 5 passage 17 → 16px, the body 14px higher. Measured in
all 11 languages: the tightest (fr, hi) keep 46px under the last point (check:packet-render's pointsOver gates it).
Hindi (and Marathi, Nepali) name Noto Sans Devanagari, Mangal, Nirmala UI and Kohinoor Devanagari after the
vendored Noto Serif Devanagari, so a print without the inlined face still lands on a Devanagari font.
2026-10-02 22:24:43 +00:00
Doxa Maps
0dc73cc485 Docs: the plugin is UPG100 Posters, the series is the UPG100 List
The product now names the plugin and its tab "UPG100 Posters" (its parts "Poster Wall" and "Zoned Map")
and keeps "UPG100 List" for the series. Where the docs mean the plugin (the gallery, the capability and
audience tables, Posterverse's tabs, the print steps, the screenshots' alt text, the layout tree, the
mobile-app list, the profiles and the diagram's bubble), they now say UPG100 Posters; the strategic list,
the poster-generation title and SERIES_NAME keep UPG100 List. The tree's list leaf is the zoned map, as in
the product. The diagram PNG is re-rendered at 2x.
2026-10-02 22:23:58 +00:00
Doxa Maps
ea6f4efcb3 Merge development into repo/upg100-docs 2026-10-02 22:22:49 +00:00
Doxa Maps
26f9177083 Merge the pilot (development 1ca13f60a, packet v2 landing 1) into the Full detail branch 2026-10-02 22:21:57 +00:00
Doxa Maps
bd85830f1c check:packet-render measures the prayer-points page against its sheet, and counts the points by their headings
The points page fits ONE sheet only because its sizes are tuned to the longest language (studio's ask): pointsOver
fails a page whose content outgrows its box (two-sided: 26px text fails; all 11 languages PASS at 18.5px).
Each point's heading carries class pt-h, and the gate counts those: counting every line that starts 'N. ' read
German's '1. Petrus 5:6–11' (its name for 1 Peter, point 11's reference) as a thirteenth point.
2026-10-02 22:13:32 +00:00
Doxa Maps
56278cabfe Packet print readability (rega's read of landing 1): Full detail's tally heads at 15px over wider cells, the prayer themes at 19px
Full detail: tally columns 96 → 116px, the once-per-page question heads 12 → 15px in a 62px head row (three short
lines), so they read on paper; the first page holds 11 rows. Points page: the themes and scenes 15 → 19px (~7.7pt
on letter), the twelve points 19 → 18.5px to make room. Measured in all 11 languages: every first list page,
Full detail's reserved block (612 of 640px) and the points page (the tightest, fr, keeps 43px) still fit.
2026-10-02 22:08:08 +00:00
Doxa Maps
d1851b0e75 The ring legend says what a ring grades: shared language and culture with the nearest believers who could reach them, and ring 9 differs in culture too, x11 (gospel's correction) 2026-10-02 22:03:23 +00:00
Doxa Maps
0a407907d5 Full detail pages: the centered set reads upward to the cross with a legend that says what a ring is; the life in weeks and the reached grid are one light SVG each, the same shape, x11
Centered set: the ring numbers ride the axis BELOW the cross, 9 at the bottom, so the eye climbs 9 → 1 to it;
the dots turn half a circle to keep the number lane clear. The ring legend opens with what a ring means in
plain words (sets_rings_lede x11) before listing the rings that hold someone.
Life in weeks + the reached grid: each grid is ONE inline SVG, two paths of square subpaths (empty, shaded),
hairlines between decades, small week and year numbers; measured on the two pages together: 10,553 page nodes
→ 86, load 474 → 214 ms, print 641 → 320 ms. The reached grid now has the life grid's exact shape (same
geometry, no week or year numbers, centred), shaded from the top by this list's share.
Gates: variants and render decode each grid's subpaths back to box indexes in reading order (5,200 distinct,
Jesus' 33 years = the first 1,716, the share from the top, the same viewBox for both); two-sided: shading
the LAST 33 years fails both. Gospel's three optional nits folded in (it un’altra, example_note x5, ru чёрточка).
2026-10-02 22:01:16 +00:00
Doxa Maps
1ca13f60ae Rebuild every bundle for the packet v2 (the renamed catalogues are shared areas): poster-render and packet-strings re-pinned, the tree and SRI regenerated 2026-10-02 21:56:58 +00:00
Doxa Maps
e811693653 check:profile-table reads Full detail's first-page rows (FD_FIRST) and the description width from the renderer's own inputs
It asserted ceil(n/20) pages and a typed DESC_W 1036; the first list page now gives its top to the explanation
band and the tally cells widened, so both went stale. Pagination is 1 + ceil((n - FD_FIRST) / PER_PAGE); DESC_W
is the row less the photo, the three tally cells and two gutters, read from packet-render's constants.
2026-10-02 21:56:19 +00:00
Doxa Maps
7e18948698 Prayer points page: the seven prayer themes and three prayer scenes head the twelve points, x11; point 12 is the John 17 blessing ladder
Themes (bless the nations, perfect unity, protection from evil, hearing God's voice, true identity in Christ,
deep relational health, the priesthood of believers) to weave into the wheel's five-minute blocks, then the
scenes treasure, mountain and sea, each with its two references from the Wall of People Groups' poster
(Haggai 2:7 · Matthew 13:44, Isaiah 2:2 · Matthew 21:21, Habakkuk 2:14 · Matthew 8:26). Point 12: the blessing,
the great, the greater and one of the greatest blessings of John 17, unity and protection, beyond all we ask
or think (John 17:3 · John 17:11–23 · Ephesians 3:20). Words drafted by the gospel lane x11; books Haggai,
Isaiah, Habakkuk, Ephesians x11. The page still fits one sheet in every language (19px text, 6px between points).
Gates: parity fails a point whose references are not English's (ten languages kept point 12's old ones) and
needs 7 themes + 3 scenes per language; variants checks the themes block and point 12 in both packets.
2026-10-02 21:51:18 +00:00
Doxa Maps
424ef6f8a4 UPG-100 poster previews and stored posters regenerated with the UPG100 List name: the 28 card thumbnails, the 28 downloads and the 28 published poster sources
bun run posters (SPARSE 3x3, POSTER_DATE 2026-10-02, the offline records cache), posters:shoot, posters:previews,
publish-html. 'List of 100 UPG' left in the zone sources: 0; 'UPG100 List' in all 28.
2026-10-02 21:49:54 +00:00
Doxa Maps
db3ef22481 The plugin is 'UPG100 Posters', x11 (the series stays 'UPG100 List'), and every spaced 'UPG 100' label becomes 'UPG100'
The tab label in both shells (doxa-maps-spa, posterverse) x11 in each language's own grammar (de 'UPG100-Poster',
es 'Carteles UPG100', zh 'UPG100 海报', …), upgMap displayName and wallHeading x11, the three meta labels, the
nav item and the dev title; the spa's default tab label, the posterverse description, the UPG-100 legend's
count column and the poster-sections noun. Values only: ids, keys, paths and file names stay.
2026-10-02 21:48:33 +00:00
Doxa Maps
7de63201bf The UI names the series UPG100, one term, x11: the tab and wall titles ('UPG100 List Posters'), 'My UPG100 List', 'UPG100 Lists', the search hint, the export label and the help text
Values only: keys, ids, paths and file names stay (my-upg-100-list, tab.upg-100). Catalogues legend, mapStrategy,
messages, search, tabs and upgMap in all 11 languages (13 keys); the shells' tab label in doxa-maps-spa and
posterverse x11 (German 'Poster der UPG100-Liste'); the three meta labels, the dev page title and the nav
label. poster-render's last-resort literals (used only by a series with no name of its own) follow:
'UPG100 List', 'What is a UPG100 List?'.
2026-10-02 21:44:57 +00:00
Doxa Maps
3c1d38bdc8 Packet lists: the three questions head each page ONCE over one grid of tally cells, and both packets open their list with the explanation band, x11
Full detail: the question no longer repeats under every tally cell; the heads stand once per page over the
cells' own columns, and each cell is all tally room (96px columns, taller boxes). The worksheet's boxes
get a little more room too.
Both lists' first page: the intro and the blessing (Numbers 6:24-26, its credit, one line of references, the
wheel pointer) at full width, then ONE band like the poster's (ruled above and below, three columns between
short rules): the pace table (strongest to slower, weekly marked, each load computed from the list), what the
three columns ask, and an EXAMPLE row of tally cells under the table's own heads, exactly as wide as the
tally block and directly over it. One code path (explainTop, explainBand) for both packets.
Measured with a 100-group list in all 11 languages: the worksheet's first page holds 10 rows (the tallest,
ru, keeps 67px), Full detail's 12 under a 640px block (the tallest uses 612); the example sits over the real
cells to the pixel. Gates: packet-variants checks the band in BOTH packets (once, before the first row,
computed paces, one example row, heads once per page); packet-render reads the page sizes from the
renderer, fails a clipped first-page block, and measures text direction against the content box (a padded
cell is still pinned left). New strings x11: howto_bless_col, example_title, example_note.
2026-10-02 21:43:24 +00:00
Doxa Maps
a8db328ad5 Docs: the series is the UPG100 List
The README and documentation call the series what the product now calls it: "UPG100 List", one term,
no hyphen. 28 prose mentions of "UPG-100", "UPG 100 List" and "List of 100 UPG's" change across the
README (19 lines, including the print section and the layout tree, whose columns stay aligned), four
documentation files and the delegation diagram's plugin label (PNG re-rendered at 2x). Paths, ids and
identifiers stay as they are (my-upg-100-list, upg-100-mfe, the image file names); the one code value
named, SERIES_NAME's default, follows series.json's seriesName "UPG100 List".
2026-10-02 21:41:03 +00:00
Doxa Maps
9d1d1aa272 The series is the UPG100 List, one term: the posters' series line and side box ('What is a UPG100 List?'), and the packet's series line and funnel, x11
series.json upg100: seriesName 'UPG100 List', sideBoxHeading 'What is a UPG100 List?', label 'UPG100 — …'.
packet-strings: every 'UPG 100' / 'UPG-100' in the four strings that name the series, in all 11 languages
(map_page_series, funnel_kicker, funnel_packets, funnel_tip; 44 values). The structure gate's literals follow.
2026-10-02 21:35:04 +00:00
Doxa Maps
af6c013bed Rebuild the sheet generator's pin carriers and series.json's importers on the merged pilot: poster-render re-pinned, the tree and SRI regenerated 2026-10-02 20:09:47 +00:00
Doxa Maps
4386b4e5bf Arabic scripture in the packet uses plain alef: U+0671 alef wasla becomes U+0627 in the stored ar strings (the blessing and Mark 5, 13 letters), display only, words and vowels unchanged
A device whose fonts lack U+0671 printed it as a box. Only those 13 code points change (0671: 13 -> 0, 0627:
+13); the bold verse is still a substring of the passage. check:packet-strings-parity fails on any U+0671 in
an ar value (it fails on the previous file). The Arabic scripture blocks also name Arabic faces before serif.
2026-10-02 20:04:41 +00:00
Doxa Maps
2d01e80002 Merge the pilot (development 3184b58c8, the packet's tally columns, description and pace table) into the poster header branch 2026-10-02 20:03:09 +00:00
Doxa Maps
3184b58c8c Rebuild the packet text's pin carriers on the merged pilot: packet-strings re-pinned, its importers rebuilt, the tree and SRI regenerated 2026-10-02 19:47:03 +00:00
Doxa Maps
75e581019e UPG-100 packet page 1 is the full UPG-100 poster: the letter sheet with its title, scripture banner, three-column header and footer, generated at the click
generateSheet gains `sparse` (default: every format but 'packet', so every existing caller is unchanged);
the UPG-100 zone sheet answers the packet's 'packet' request with the full letter poster, the same
1500x1941 page. check:poster-browser-equiv compares that sheet with Node's letter format without SPARSE.
2026-10-02 19:44:34 +00:00
Doxa Maps
381abd0623 Merge the pilot (development 9222c7f36) into the poster header branch 2026-10-02 19:42:57 +00:00
Doxa Maps
36eb0cc66a Merge the pilot (development 9222c7f36, the pinned-file cache fix) into the packet pace branch 2026-10-02 19:41:57 +00:00
Doxa Maps
9222c7f36b Merge origin/development into studio/pinned-fresh 2026-10-02 19:35:39 +00:00
Doxa Maps
4feaf751d9 Merge remote-tracking branch 'origin/development' into cities-plugin/circular-layout 2026-10-02 19:30:23 +00:00
Doxa Maps
550a69b8b9 check-circular-layout: use globalThis.gc, not the free identifier global
check:no-free-identifiers (gates:post) flagged `global` at the budget
arm's optional gc() call. globalThis is the portable form. Script only.
2026-10-02 19:30:09 +00:00
Doxa Maps
7ffda69ef3 Rebuild 8 bundles for pinned print files that refetch a stale cached copy, and regenerate the tree and SRI 2026-10-02 19:23:49 +00:00
Doxa Maps
84e40a965f Merge origin/development (9652986c1) into studio/pinned-fresh 2026-10-02 19:18:54 +00:00
Doxa Maps
c74b642ebd README print section: only UPG-100 List prints when the poster picture is clicked 2026-10-02 19:17:52 +00:00
Doxa Maps
832294ffd8 README print section: the poster read's corrections
India's whole-country poster is withheld for now, so it leaves the needs-a-computer list and step 1 says
its state posters print. The poster walls print Letter and offer no size choice, so the large-size
paragraph names the two plugins that do (Bible Posters and the Wall of People Groups, up to 5 x 8 ft) and
describes only the glued print that ships today: the Wall's 5 x 8 ft poster as 3 sheets, one per page,
with a 6 in overlap, numbered and marked "glue here".
2026-10-02 19:16:57 +00:00
Doxa Maps
3b871aa024 README print section: only the whole-India card needs a computer; its state posters print on a phone 2026-10-02 19:15:45 +00:00
Doxa Maps
536989a3d5 UPG-100 posters: the scripture banner sits under the title, then one three-column band like the footer's (the facts | Jesus' strategy | What is a UPG-100 List?), the text a size larger; the series is named UPG-100 List
Opt-in per series (series.json poster.headColumns, on for upg100 only), so every other series' sheet is
unchanged. The band is laid out by ONE function that both measureHeader() and the renderer call, so the
predicted header height is the drawn one. Each block is centred in an equal column between short rules,
with the inset the first column lacked. The footer's three blocks are drawn at the 13.5px they are wrapped
and spaced for (they were drawn at 12px), so the text grows with no change to the lines or the band's
height, and they are centred for that size.
SOURCE ONLY: the served copies, the integrity pin and its bundles are regenerated at landing.
2026-10-02 19:14:35 +00:00
Doxa Maps
9d0a818768 Merge development into repo/print-first-poster 2026-10-02 19:14:20 +00:00
Doxa Maps
774004ffd8 README: print your first poster
A short section after "On a phone" for readers: open a poster wall (UPG-100 List, Country Posters,
Regions), pick Poster Map, Prayer List or Card Deck under Print Options, and the browser's print window
opens as the preview, with Save as PDF to keep a file. It says which page and card sizes print, that
the large sizes (18 x 24 in and up) go to a print shop as a PDF with one page per sheet, that 66 x 66 in
and 5 x 8 ft print as 2 and 3 sheets glued with a 6 in overlap, and what a phone can print, including
the few prints that need a computer. The labels and sizes are the ones the code uses.
2026-10-02 19:14:11 +00:00
Doxa Maps
9652986c1a Rebuild posterverse for the Bless India rail label, and regenerate the tree
BUNDLE=posterverse vite build, then bun run tree and fix:sri. Only the
posterverse entry changes in the manifest (its integrity).
2026-10-02 19:08:38 +00:00
Doxa Maps
d329a3a612 Posterverse: the Bless India Posters rail label is the plugin's own title
The rail row now uses the plugin's own title in all 11 locales, copied from its
catalogue (blessIndiaPosters.title), so the rail and the plugin name it the same
way. Copied rather than read at runtime: naming that catalogue area from the
shell would ship the plugin's whole catalogue inside posterverse.js.
check:posterverse-shelf arm F asserts the two stay equal in every locale; its
self-test plants a drifted label.
2026-10-02 19:08:38 +00:00
Doxa Maps
06d0e16e26 Merge origin/development (988bc0cf5) into studio/pinned-fresh 2026-10-02 19:08:36 +00:00
Doxa Maps
b95607cadc check:packet-render counts the prayer wheel the way it is drawn now: its filled arc segments and its wheel-label runs
The wheel took the source's hex palette, rim badges with a minutes tag and a Start-here mark; the gate still
counted fill^=hsl paths (0) and every non-numeric text (25), so it failed every packet with a correct wheel,
the pilot's included. Two-sided: PASS on the lightweight packet in en and ar; FAIL on a copy with the
segment labels blanked (0 labels) and on one with the segments unfilled (0 segments).
2026-10-02 19:08:11 +00:00
Doxa Maps
b391988a0f Print files: a transient failure to load the places table is retried on the next print
loadPlaces memoised an empty table on ANY failure, so one network error meant no
city dots for the rest of the session, while the other pinned loaders retry. A
table that is not served (or a reader offline) is now forgotten, so a later print
tries again; wrong bytes, refused after the fresh refetch, stay refused for the
session.
2026-10-02 19:07:51 +00:00
Doxa Maps
ef439a5d8e Print files: a stale cached copy of a pinned file is fetched once more, fresh
The print-time files are pinned (poster-render.js and its classic copy,
packet-strings.json, ne-places.json, india-states.json) and were fetched with
cache: 'force-cache', which returns any cached copy, however old. After a
re-pin, a reader who had printed before kept the previous copy: it failed its
pin on every print and the print fell back without a word. Page 1 became the
stored poster, a non-English packet would print in English, and India's state
cards would be lost.

sha384.js fetchPinned fetches force-cache first (fast, and offline once
cached) and, when the copy is not served or fails its pin, once more with
cache: 'reload', which skips and refreshes the HTTP cache; only that answer
decides. posterSheets (the generator and the places table), packetStrings and
indiaStates all fetch through it. The classic-script path refreshes the cache
the same way and retries a refused tag once.

check:pinned-fetch-fresh models the HTTP cache with stale copies: page 1 still
draws, the packet keeps every language, India keeps its 36 states; a matching
cached copy is used with one request; wrong bytes are refused. The previous
loaders fail it. Self-test poisons: no fresh refetch, never the cache, a
mismatch accepted, a loader fetching its pinned file directly.
2026-10-02 19:05:37 +00:00
Doxa Maps
988bc0cf51 Merge remote-tracking branch 'origin/development' into repo/readme-swarm-2 2026-10-02 19:03:56 +00:00
Doxa Maps
8dcbba6dbe Packet list wording, after the scripture read: the pace note ties daily prayer to praying for every group on the list, x11; the blessing credits all name ebible.org with an en dash; Russian column questions without a gendered past tense 2026-10-02 19:01:28 +00:00
Doxa Maps
db5cd42841 Packet list: the first page explains the list before it lists anyone, with a prayer-pace table, x11
What the three columns ask; the blessing to pray over each group, Numbers 6:24-26 in full with its credit
(the public-domain text per language, compared verse by verse with its source; ro and hi print the English,
as the packet's other scripture does); one line of further blessing references (the Synodal's own Psalm
numbers in Russian); the prayer wheel at the end; and a pace table, daily (strongest) to every three months
(slower), weekly marked as the healthiest, each pace's daily load computed from the list's size.
The first page holds 11 rows (16 before); later pages keep 18. Measured with a 100-group list in all 11
languages: the longest first pages (ru, hi) keep 77px under their last row, the references fit one line.
check:packet-variants asserts the description, once, before the first row, and the computed pace; the
render gate follows the new pagination.
2026-10-02 18:54:44 +00:00
Doxa Maps
e5e5b55e77 Merge remote-tracking branch 'origin/development' into drp/gospel-railname 2026-10-02 18:50:32 +00:00
Doxa Maps
bba4c0c79f Packet list: three tally columns headed by the questions asked of Jesus (Did I bless them? / What do you want me to know? / What do you want me to do?), x11
The tick boxes become tally cells, on the lightweight worksheet and on the full packet's profile rows, so a
list prayed through again and again counts every time. The headings are the questions themselves, broken
into balanced lines; the worksheet's tally columns widen to 160px so English heads take two lines. The
worksheet's note no longer says there is no pace to keep. Measured in all 11 languages: every worksheet
sheet and every profile row still fits (16 + 18 rows a sheet unchanged).
2026-10-02 18:48:17 +00:00
Doxa Maps
0f6ecd978d README + diagram: "landing" means only the landing branch
A change joins the pilot; only the landing branch is landed on. The swarm section and the diagram used
"land" for both, so the harness paragraph, step 5, the kitchen list, the alt text and the diagram's
centre, panel title and second card now say "join". The lock "works like a merge queue": it lets one
change through at a time, while the order comes from the coordinator. The kitchen list says "landing
server", and the glossary says main moves only when a person approves publishing development. The PNG
is re-rendered at 2x from the SVG source.
2026-10-02 18:47:41 +00:00
Doxa Maps
a1bb974651 circularLayout: register check:circular-layout after merge
Re-add the circular-layout catalogue entries (script, gates:pre,
description) in a separate commit, since the merge took development's
package.json.
2026-10-02 18:46:38 +00:00
Doxa Maps
ad63cd56d8 Merge remote-tracking branch 'origin/development' into cities-plugin/circular-layout
# Conflicts:
#	package.json
2026-10-02 18:46:19 +00:00
Doxa Maps
6e6afb1499 Research Gallery: a short rail name for Gospel × Internet in 11 languages
The Experimental row showed the titleized id ("Gospel Internet") in every
language: tabLabel() translates a discovered tab only when strings.js has
'tab.<id>', and the manifest carries no title. Add 'tab.gospel-internet'
after 'tab.bless-india-posters' in each locale, with the plugin's own
strings. Rebuilt the gallery bundle; it differs from the pilot's only by
these 11 strings.
2026-10-02 18:45:39 +00:00
Doxa Maps
7438c25230 README + diagram: the agent swarm at scale, and where to publish
The swarm section now says what each branch is for: the landing branch ships and moves only when a
person approves a publish; the pilot branch is where reviewed PR branches come together; each plugin
or feature has its own PR branch. A new paragraph says how it scales (many people, each with several
agents, on many plugins: one PR branch each, seen on the pilot server once it joins). Step 5 names the
lock as a merge queue. Publishing to a public host happens only from the landing branch, when it is
ready, and a self-hosted git service is recommended because an agent can publish by accident.
The diagram gets the same: "a person approves" for the landing, "landing server", and a bottom band
for the scale and publishing rules. The PNG is re-rendered at 2x from the SVG source.
2026-10-02 18:31:10 +00:00
Doxa Maps
e855e6f371 Rebuild the wording's bundles on the merged pilot
The merge took the pilot's built files where both sides had rebuilt them; this rebuilds the landing's
freshness scope (22 bundles, now including bless-india-posters) on the merged source, then the tree and
SRI. Against the pilot's bundles, with data-v ids normalised and the old->new wording applied, eight are
identical, gospel-resources differs only by minifier renaming, and gospel-internet's one further change
is notice.pin no longer passing {country}. Pages and manifests differ from the pilot only in integrity
hashes.
2026-10-02 18:21:00 +00:00
Doxa Maps
fc946bd5fe Merge development into repo/p2-neutral (built files from development; rebuilt next) 2026-10-02 18:13:27 +00:00
Doxa Maps
d63951cf39 Merge remote-tracking branch 'refs/remotes/origin/development' into deep-research/bless-india-label 2026-10-02 18:08:26 +00:00
Doxa Maps
224b477158 Rebuild the gallery for the Bless India label, and regenerate the tree and SRI 2026-10-02 17:46:43 +00:00
Doxa Maps
f89c353548 Merge origin/development (a57ecb2aa) into studio/phone-print-keep 2026-10-02 17:46:38 +00:00
Doxa Maps
421a13edc8 Gallery: Bless India Posters' Experimental row takes the plugin's own title in every language 2026-10-02 17:44:16 +00:00
Doxa Maps
540e9ee04a Merge origin/development (8845347f8) into studio/phone-print-keep 2026-10-02 17:44:16 +00:00
Doxa Maps
a57ecb2aa5 Rebuild bless-india-posters (localized poster header) 2026-10-02 17:40:48 +00:00
Doxa Maps
db1c1da808 Localize the poster header per locale (phrase, not scripture)
The poster sheet's own header 'Bless India in Jesus' name' printed in
English for every reader. It's a phrase, so add a posterHeader key x11
and pass the reader's-locale header to the generator. The footer verse
stays the English PD blessing until the strict-PD per-locale sourcing
lands. Verified: ar poster header = باركوا الهند باسم يسوع.
2026-10-02 17:33:17 +00:00
Doxa Maps
3eb10d637b Rebuild prayer-walk-planner regions-dashboard upg-neighbor wall-of-people-groups for phone prints that save what the preview shows, and regenerate the tree and SRI 2026-10-02 17:29:35 +00:00
Doxa Maps
8845347f8e Rebuild bless-india-posters (ar localization) 2026-10-02 17:25:29 +00:00
Doxa Maps
bc1e82a93e Rebuild 7 bundles for phone prints that save what the preview shows, and regenerate the tree and SRI 2026-10-02 17:24:01 +00:00
Doxa Maps
6cd0a8b73c Localize Bless India plugin UI in all 11 locales + RTL
The plugin's own page chrome (title, intro, buttons, stats, notes) was
English in every locale, and in RTL shells its full stops jumped to the
start. Add the page strings to blessIndiaPosters.json x11, look them up
from the reader's locale (profile-config from the shells; ?lang / <html
lang> on the standalone leaf), set dir=rtl for ar/he/fa/ur, and format
counts via the locale. Verified: ar leaf draws dir=rtl with the Arabic
title; en draws dir=ltr.
2026-10-02 17:22:33 +00:00
Doxa Maps
967457f65e Phone print: going away again cancels a pending drop; a 60 s grace; no early touch
A second read measured a hole in the phone print keep: a touch after print()
started the grace, and if the print UI then opened (the page hidden, or the
window blurred), nothing cancelled it, so the document could go while the
reader was still choosing Save as PDF. Now:

- going away again during the grace cancels the pending drop, and the next
  "back" counts;
- BACK_GRACE_MS is 60 s (was 5 s): a slow phone can still be writing an
  11-page packet after its dialog closes, and the kept document is invisible
  on screen and replaced by the next print;
- a touch in the first TOUCH_SETTLE_MS (2 s) after print() is not "back" (a
  double tap while the preview builds).

check:phone-print-keep gains arm H (a touch, then the print UI, by hiding and
by blur: still mounted past the grace, released after the next back) and arm I
(a touch in the settle window), with the poisons "no cancel when away again"
and "no touch settle window"; arm C now taps Print a few seconds after the
document is ready and stays in the dialog past the grace. The first cut fails
H and I; the pilot's file fails A to H. Desktop stays byte-identical.
2026-10-02 17:15:56 +00:00
Doxa Maps
0b1fcde1db Phone print: keep the in-page document until the reader is back, so a saved PDF is the preview
On a phone, printDocument mounts the document into the page (#dxm-print-root and
a print-only style) and calls window.print(). It dropped both at afterprint. But
Android Chrome's print() does not block: afterprint fires once the preview is
built, and the file "Save as PDF" writes is rendered later, after the reader
taps Save, from the page as it is then. The preview showed a packet's 11 pages
and the saved PDF was the bare host page.

The document now stays until the reader is back on the page: shown again after
being hidden, focused again after losing focus, or touched after print(), then
BACK_GRACE_MS more for a job still rendering. A new print replaces it, a
cancelled one drops it, and the ten-minute ceiling stays. Listening starts at
print(), so the Print tap of a late print does not count. printImageBlob's
object URL now follows the document (onRelease) instead of afterprint. On
screen the document is display:none, as before. Desktop prints through the
frame exactly as before.

check:phone-print-keep runs the phone timeline on fake timers and a stub DOM
with an Android user agent; the previous file fails it (gone after afterprint,
image revoked). Its self-test poisons the old drop at afterprint, no release
when back, listeners armed at mount, no grace, printImageBlob revoking at
afterprint, and no ceiling.
2026-10-02 17:10:09 +00:00
Doxa Maps
2e0800c5ad Rebuild bless-india-posters (asset-base fix) on merged pilot 2026-10-02 16:59:11 +00:00
Doxa Maps
44ca332b65 Merge remote-tracking branch 'origin/development' into reg-post/bless-india-posters 2026-10-02 16:53:19 +00:00
Doxa Maps
824a7ef2c2 Fix Bless India asset base: resolveAssetsBase(profileConfig) not runtime deriveTreeBase
deriveTreeBase() called inside build() returned '' (document.currentScript is
null by then), so the India boundaries loaded from /assets (the host's HTML
page) and the poster failed with 'Unexpected token <'. Use the house helper
resolveAssetsBase(profileConfig): the shells pass an absolute assetsBase, and
the standalone leaf falls back to the bundle's own tree root (import.meta.url,
captured at load). Verified: the leaf draws the 3-sheet poster, 0 errors.
2026-10-02 16:52:55 +00:00
Doxa Maps
ef635c6793 Rebuild posterverse for the confirmed Bless India Posters row, and regenerate the tree
BUNDLE=posterverse vite build, then bun run tree and fix:sri. Only the
posterverse entry changes in the manifest (its integrity and composes).
2026-10-02 16:40:24 +00:00
Doxa Maps
6d4ce5129b Posterverse: Bless India Posters is in the tree, so composes declares it
The bless-india-posters bundle is now on the pilot branch, so its Experimental
row is no longer provisional and posterverse.meta.json composes it.
2026-10-02 16:40:24 +00:00
Doxa Maps
f486f4ad4e Rebuild posterverse for the Bless India Posters row, and regenerate the tree
BUNDLE=posterverse vite build, then bun run tree and fix:sri. Only the
posterverse entry changes in the manifest (its integrity).
2026-10-02 16:40:24 +00:00
Doxa Maps
00dbf9a79b Posterverse: a Bless India Posters row in Experimental, and unpinned mounting for scroll pages
The Experimental folder gains a bless-india-posters row, provisional until
its bundle is in the tree (a missing bundle prunes the row, so nothing shows
before it lands), with its label in all 11 locales. Its root is a
natural-height page of poster cards, so a row may set scroll: the element is
then mounted unpinned at its natural height and the pane scrolls it, instead
of being pinned to the pane, which would trap the page's own content.
check:posterverse-shelf expects the row and the scroll set by name; its
self-test drops the row and drops the flag.
2026-10-02 16:40:24 +00:00
Doxa Maps
01197e2121 Merge remote-tracking branch 'refs/remotes/origin/development' into deep-research/bless-india-shelf 2026-10-02 16:35:57 +00:00
Doxa Maps
441fb6d92d Rebuild the gallery for the Bless India Posters mount, and regenerate the tree and SRI 2026-10-02 16:31:01 +00:00
Doxa Maps
b8473733a9 Rebuild bless-india-posters on the merged pilot library 2026-10-02 16:29:39 +00:00
Doxa Maps
174385d75e Gallery: Bless India Posters mounts at natural height when it joins Experimental 2026-10-02 16:27:53 +00:00
Doxa Maps
bffde832f5 Merge remote-tracking branch 'origin/development' into reg-post/bless-india-posters
# Conflicts:
#	public/README.md
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
2026-10-02 16:25:55 +00:00
Doxa Maps
c25bcf4f5c Add Bless India Posters plugin (generative, experimental)
A public preview plugin for the whole-India poster: every India people
group on one printable sheet, generated live from the current data
(nothing stored) and shown as its printable sheets with the glue strips.
Each poster carries the blessing — header "Bless India in Jesus' name"
and the Aaronic blessing (Numbers 6:24-26), switchable.

Generates with the plugin's own copy of the renderer; shows the sheets
via the shared PanelSheets and prints as a document at true size.
Public and experimental; a phone sees the preview but Print is blocked
(desktop only) until a design is proven phone-safe. Registers the
blessIndia poster type for the catalogue; label translated in 11
locales. Replaces the earlier India Posters review draft (not landed).
Element <bless-india-posters>.
2026-10-02 16:22:30 +00:00
Doxa Maps
3d7314b6e6 Rebuild the bundles the comment and pin-wording changes reach
Every bundle in the landing freshness scope was rebuilt once (21; 12 came out byte-identical), then
the tree and SRI. Nine bundles change. With data-v ids normalised and the old->new wording applied,
seven are identical and gospel-resources differs only by minifier renaming. gospel-internet's one
further change is notice.pin no longer passing {country}. The tree restages the four corrected
tables, and pages and manifests change only their integrity hashes.
2026-10-02 16:06:24 +00:00
Doxa Maps
8bb4e7a8e6 circularLayout: static-import forkRoute + register gate after merge
Switch forkRoute/placePinsInState to static imports from their sibling
library modules (now on development) and delete the vendored forkRoute
fallback; opts may still override for a Worker. Re-register
check:circular-layout (script + gates:pre + description) after the merge
took development's package.json.
2026-10-02 15:59:38 +00:00
Doxa Maps
d6be5806a6 Merge remote-tracking branch 'origin/development' into cities-plugin/circular-layout
# Conflicts:
#	package.json
2026-10-02 15:57:21 +00:00
Doxa Maps
e651da54f8 fork-leaders: register check:fork-leaders (script, gates:pre, description)
Re-adds the fork-leaders catalogue entries after merging the pilot
(the merge took the pilot's package.json; this restores the new gate's
registration in a separate, reviewable commit).
2026-10-02 15:51:44 +00:00
Doxa Maps
66d6b12a17 Merge commit '6d89e6b8c' into cities-plugin/fork-leaders
# Conflicts:
#	package.json
2026-10-02 15:51:24 +00:00
Doxa Maps
77125daf9d fork-leaders: neutral wording in the MODES comment
Drop the fleet role name from the JSDoc MODES header, keeping the design
labels. Comment-only; behaviour unchanged.
2026-10-02 15:50:55 +00:00
Doxa Maps
ebbee89eea Merge development into repo/p2-neutral 2026-10-02 15:49:50 +00:00
Doxa Maps
4a3132b5db Poster Design: the names ledger gains bible-poster.36x60; tree regenerated 2026-10-02 13:33:18 +00:00
Doxa Maps
29a347c2bd Merge bible-posters' Poster Design block (bible-poster.36x60) 2026-10-02 13:33:11 +00:00
Doxa Maps
654b8e4423 Poster Design: the names ledger gains the seven plugin designs; rebuild
country-poster.letter, upg100-territory-poster.letter / .3x3 / .3x4 and
wall.bless-36x48 / -24x36 / -60x96 join src/catalogue-names.json, so they
can never be renamed or removed. poster-design rebuilt; tree and SRI
regenerated (the manifest carries the seven blocks and their titles).
2026-10-02 13:32:19 +00:00
Doxa Maps
f82809384f Merge the Wall of People Groups' Poster Design blocks (three Bless posters) 2026-10-02 13:32:19 +00:00
Doxa Maps
96cb44cc05 Merge upg100's Poster Design blocks (three territory posters) 2026-10-02 13:32:19 +00:00
Doxa Maps
d979420d82 Merge country-posters' Poster Design block (country-poster.letter) 2026-10-02 13:32:19 +00:00
Doxa Maps
58c6bb28af Poster Design names sizes, and plugins' designs, in the reader's language
Styles and Sizes now name every size the one way (src/sizeText.js, shared):
templates.js carries each size as data (inches, side lanes, panels, paper),
and posterDesign.sizes gains sideLanes, packetPage1, panels and anySize in
all 11 locales, so Arabic readers no longer see "5 x 8 ft, side lanes" or
"Letter, portrait". A plugin's declared size (a ladder key, letter, a4) is
named through the shared ladder.

A plugin's design title came from its labelKey, but Poster Design's bundle
ships only the catalogue areas its own code names, so a plugin's own area
(upgMap, ...) read English. internal/profile-meta.mjs now resolves each
block's labelKey in every locale at build time and carries the titles in
manifest.json (posterDesignLabels, only beside a posterDesign block), and
Styles reads the reader's language from there.
2026-10-02 13:32:19 +00:00
Doxa Maps
088b92451a Bible Posters: register its poster design in the Poster Design catalogue
Add a posterDesign block to bible-posters.meta.json (family biblePoster,
name bible-poster.36x60, size 36x60, mobileFriendly false — a whole-book poster
is a large-format print) and a biblePosters.posterDesign.label key in all 11
locales, so the Bible Posters design appears as a reference card in Poster Design.
2026-10-02 13:31:52 +00:00
Doxa Maps
aa2a65051e Wall of People Groups: its three Bless posters are listed in Poster Design
The profile's meta.json declares one posterDesign block per poster the Wall prints, so they show under
Poster Design, Styles, From other plugins: wall.bless-36x48 (3 x 4 ft), wall.bless-24x36 (2 x 3 ft) and
wall.bless-60x96 (5 x 8 ft, three glued sheets). Family wall, not mobile-friendly (the Wall is desktop only),
titled by the existing wall.blessBanner key (all 11 locales). check:poster-design-catalogue: PASS, 3 valid
blocks, 3 new ledger names; its self-test PASS; i18n-keys-exist PASS.
2026-10-02 13:29:52 +00:00
Doxa Maps
2759ff493e UPG-100: its territory posters in the Poster Design catalogue
poster-wall.meta.json declares the poster designs this plugin makes, one block
each (family upgList):
- upg100-territory-poster.letter: the home-printer print (the sheet is generated
  at the click). mobileFriendly: true.
- upg100-territory-poster.3x3 and upg100-territory-poster.3x4: the large-format
  downloads. mobileFriendly: false (not measured on a phone, so not claimed).
The card title is upgMap.posterDesignTerritory, "UPG-100 territory poster", in
all 11 locales. It uses the word the plugin already uses for a territory in each
language.
2026-10-02 13:28:27 +00:00
Doxa Maps
62c739f57d Country Posters lists its poster in Poster Design: country-poster.letter (letter, phone-friendly), titled by the Country Posters tab label 2026-10-02 13:27:54 +00:00
Doxa Maps
6d89e6b8ce Merge commit '85c27e94f7d349722e1ade77fc17b71baf450aab' into poster-design-plugin/catalogue 2026-10-02 13:17:10 +00:00
Doxa Maps
86ade3356d Merge commit '56571a9bd72d6470514c34d71982f63e290125f3' into poster-design-plugin/catalogue 2026-10-02 13:11:33 +00:00
Doxa Maps
85c27e94f7 Merge remote-tracking branch 'origin/development' into regions/card-action 2026-10-02 13:04:01 +00:00
Doxa Maps
c9a494024b Merge commit '3cb185da1f299194b8c9940abc9e81503d855d77' into poster-design-plugin/catalogue 2026-10-02 13:00:56 +00:00
Doxa Maps
4e60f35ea9 Merge remote-tracking branch 'origin/development' into regions/card-action 2026-10-02 12:53:44 +00:00
Doxa Maps
56571a9bd7 Rebuild the gallery on the merged pilot (the after-deploy freshness check + the Experimental exclusion), and regenerate the tree and SRI 2026-10-02 12:48:46 +00:00
Doxa Maps
8931f6a28c Merge remote-tracking branch 'refs/remotes/origin/development' into deep-research/manifest-revalidate
# Conflicts:
#	public/dx-maps/doxa-maps-spa/doxa-maps-spa.js
#	public/dx-maps/doxa-maps-spa/doxa-maps-spa/app/index.html
#	public/dx-maps/doxa-maps-spa/doxa-maps-spa/index.html
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
2026-10-02 12:47:06 +00:00
Doxa Maps
849383f762 Rebuild country-posters, poster-sections and regions-dashboard for the card-action attribute, then the tree and SRI 2026-10-02 12:44:56 +00:00
Doxa Maps
ea8a74b350 check:poster-section covers card-action, with poisons for an ignored attribute and an event that cannot leave the shadow root 2026-10-02 12:43:51 +00:00
Doxa Maps
18d1d389be Poster section element: card-action="dashboard" turns each card's sticker into Show dashboard and dispatches dxm-poster-dashboard for the host (absent keeps Show on map) 2026-10-02 12:43:50 +00:00
Doxa Maps
3cb185da1f Merge remote-tracking branch 'refs/remotes/origin/development' into deep-research/poster-sections-not-experimental 2026-10-02 12:41:24 +00:00
Doxa Maps
7f272a4cd4 circularLayout gate: close two tautological checks (5B P2/P3)
P2: test fourSides numbering against GEOMETRY per side (L cy↓, R y↑,
T x↑, B x↓) with a de-overlap drift tolerance, not label-emission order
(which numbering walks, so it could never fail). P3: assert every
pairedRows leader carries polys.length===2. Both mutation-tested: a
reversed side sort and a dropped polys each make the gate FAIL.
2026-10-02 12:35:00 +00:00
Doxa Maps
f9a28bde47 Merge remote-tracking branch 'origin/development' into regions/poster-sections 2026-10-02 12:31:04 +00:00
Doxa Maps
00db24ed0c circularLayout: fix 5B findings
Clear check:no-fleet-names (neutral wording for the classic4 reference,
the vendored-fork note, the browser-budget comment, the gate's format
comment and the package.json description). Fix pairedRows leaders[].polys
(was null on every leader — store fork.polys; drop dead polysForCount).
Tie-break ids by code unit (cmpId), not localeCompare, for byte-identity
across runtimes/locales. Harden the gate: assert the fourSides side
sequence L<T<R<B and monotone numbering within each side (by label
emission order), and recount pins-outside-state and pairedRows crossings
from the OUTPUT, not from stats.
2026-10-02 12:27:56 +00:00
Doxa Maps
3c50c14ec1 Rebuild posterverse for the open Experimental folder, and regenerate the tree
BUNDLE=posterverse vite build, then bun run tree and fix:sri. Only the
posterverse entry changes in the manifest (its integrity).
2026-10-02 12:26:16 +00:00
Doxa Maps
544ffed652 Posterverse: the Experimental folder starts open
Closed, the folder sat mid-drawer under the one short Posters section with
empty space below, so it read as a caption with nothing under it and was easy
to miss on a phone. Open, its plugins show like any section's rows; a reader
can still collapse it. check:posterverse-shelf asserts the open default, and
its self-test plants a closed folder.
2026-10-02 12:26:16 +00:00
Doxa Maps
5a4c8174a1 Rebuild poster-sections for the poster section element's min-card-px and show-header attributes, then the tree and SRI 2026-10-02 12:24:29 +00:00
Doxa Maps
6ccbe827bc check:poster-design-catalogue: poison a sample that exists but cannot be drawn
The samples arm is now a function the self-test can poison: with withholding
switched off, India (no sheet tier, no outlines) must fail as a sample that
cannot be drawn. Closes the branch the arm had no poison for.
2026-10-02 12:20:40 +00:00
Doxa Maps
6e593f2ae9 packet: circularLayout — poster circular logic as one pure module
New library/packet/circularLayout.js: ring order, leader routing,
numbered four-sides and paired-row forks as a single pure, Worker-safe
module (geometry in, geometry out; no SVG, no DOM, deterministic). Modes
sideLanes (today's L/R), fourSides (numbered, no leaders) and pairedRows
(two leaders per row). measure/slotOf and placePinsInState/forkRoute are
injected; forkRoute falls back to a vendored copy until fork-leaders is
on this tree. Adds check:circular-layout (frozen 855-pin 60x96 fixture:
ring order, 0 dupes, 0 pins-outside-state, 0 crossings on paired rows,
determinism, a poison, and a node time/heap budget).
2026-10-02 12:16:39 +00:00
Doxa Maps
2f4cabf85f Poster Design: the 66x66 and 36x60 side-lane cards get a real sample
India, the one large-sheet country, now prints at 5 x 8 ft only, so the
5.5 x 5.5 ft and 3 x 5 ft side-lane cards would have drawn India's 60x96 sheet
under their own label. They now sample the largest drawable countries (China
at 66x66, Sudan at 36x60) through the same shared sheet path, and their
details say that no country prints at this size today
(posterDesign.missing.noCountryAtSize, 11 locales). The 60x96 card keeps
India and stays withheld. check:poster-design-catalogue now checks every
sample: drawable, or withheld by countrySheet.js.
2026-10-02 12:12:18 +00:00
Doxa Maps
24a32b203e Rebuild the gallery for the Experimental exclusion, and regenerate the tree and SRI 2026-10-02 12:11:51 +00:00
Doxa Maps
a4b57256ab Gallery: Poster Sections stays out of Experimental (a template plugin other plugins and sites embed) 2026-10-02 12:09:37 +00:00
Doxa Maps
0b47851a41 Merge commit '8fe8603ca2c5d42a252d3f372c9770046e924727' into poster-design-plugin/catalogue
# Conflicts:
#	package.json
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
#	public/dx-maps/poster-design/poster-design-mfe/index.html
#	public/dx-maps/poster-design/poster-design.js
#	public/dx-maps/poster-design/poster-sizes/index.html
#	public/dx-maps/poster-design/poster-styles/index.html
2026-10-02 12:07:13 +00:00
Doxa Maps
d6fde2c017 Regenerate the tree and SRI on the merged pilot (Poster Sections) 2026-10-02 12:06:22 +00:00
Doxa Maps
2deff898c3 Merge remote-tracking branch 'origin/development' into regions/poster-sections
# Conflicts:
#	public/README.md
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
2026-10-02 12:06:01 +00:00
Doxa Maps
8fe8603ca2 Merge commit 'b9202eb830222e056aed0c06334058cbfbf9d72d' into poster-design-plugin/poster-sections 2026-10-02 12:01:26 +00:00
Doxa Maps
789c9c407f Rebuild country-posters and regions-dashboard from the merged pilot (legend-shrink), then the tree and SRI 2026-10-02 12:01:13 +00:00
Doxa Maps
ddf3d828d4 Merge remote-tracking branch 'origin/development' into regions/poster-sections
# Conflicts:
#	public/dx-maps/country-posters/countries-map/index.html
#	public/dx-maps/country-posters/country-poster-wall/index.html
#	public/dx-maps/country-posters/country-posters-mfe/index.html
#	public/dx-maps/country-posters/country-posters.js
#	public/dx-maps/country-posters/poster-section/index.html
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
2026-10-02 11:56:06 +00:00
Doxa Maps
893f3aee00 Rebuild the gallery for the after-deploy freshness check, and regenerate the tree and SRI 2026-10-02 11:42:40 +00:00
Doxa Maps
c590e9fef4 Gallery: after a deploy, a returning reader runs the new bundles, not stale cached copies
A host that serves the tree with Last-Modified and no Cache-Control lets a browser run its cached files for
hours. Measured: a typed-URL revisit after a deploy requested none of the gallery, the manifest or the bundles,
so a returning reader ran the old files, or an old manifest against a new bundle.

- The manifest is fetched with cache 'no-cache', so it is always revalidated (a 304 when unchanged).
- A fingerprint of the manifest (every bundle's integrity) is kept per tree. When it differs from the reader's
  last visit, every bundle the browser already holds gets one conditional request before any tab mounts:
  a 304 refreshes it, and a 200 is cancelled at its headers, which drops the stale copy. The plain <script>
  that follows, the gallery's own or a composite's for its children, then runs the new file at the same URL.
- A version query is not used: loadScriptOnce keys its registry by the full href, so X.js?v= next to a
  composite's plain X.js would run the bundle twice.
- A first visit with an empty cache costs nothing extra (only-if-cached finds nothing held), and so does an
  ordinary visit with an unchanged manifest. The mount waits at most 8 s for the check.
2026-10-02 11:39:17 +00:00
Doxa Maps
813dbb73d1 Merge commit '1bddf78a676b33782e5c7e54890497dc39f235f5' into poster-design-plugin/poster-sections 2026-10-02 11:34:59 +00:00
Doxa Maps
fc5b26ace9 check:country-sheet-one-path self-test: the Regions-list phone-block poison runs only while the list draws its own sheets 2026-10-02 11:31:18 +00:00
Doxa Maps
b9202eb830 Rebuild country-posters my-upg-100-list for the legend drag fixes on the merged pilot (the India tab), and regenerate the tree and SRI 2026-10-02 11:30:13 +00:00
Doxa Maps
ed04c8f747 Rebuild country-posters and regions-dashboard from the merged pilot (the India tab), then the tree and SRI 2026-10-02 11:29:46 +00:00
Doxa Maps
3ea2bbc44c Merge origin/development (1bddf78a6) into studio/legend-shrink 2026-10-02 11:29:28 +00:00
Doxa Maps
a2d37a5abb Merge commit '2fc9b94f0e1f69d71be5dd5419fadb879bde6b78' into poster-design-plugin/poster-sections
# Conflicts:
#	package.json
2026-10-02 11:21:44 +00:00
Doxa Maps
cd31ec1dfd Merge remote-tracking branch 'origin/development' into regions/poster-sections
# Conflicts:
#	plugin-designs/country-posters/scripts/check-poster-section.mjs
#	plugin-designs/regions-dashboard/regions-list.vue
#	public/dx-maps/country-posters/countries-map/index.html
#	public/dx-maps/country-posters/country-poster-wall/index.html
#	public/dx-maps/country-posters/country-posters-mfe/index.html
#	public/dx-maps/country-posters/country-posters.js
#	public/dx-maps/country-posters/poster-section/index.html
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
#	public/dx-maps/regions-dashboard/region-dashboards/index.html
#	public/dx-maps/regions-dashboard/regions-dashboard.js
#	public/dx-maps/regions-dashboard/regions-list/index.html
#	public/dx-maps/regions-dashboard/regions-mfe/index.html
2026-10-02 11:19:59 +00:00
Doxa Maps
1bddf78a67 Rebuild country-posters, regions-dashboard, poster-design, my-upg-100-list on the merged tree 2026-10-02 11:10:12 +00:00
Doxa Maps
50266ad32e Merge development (2fc9b94f0) into the India tab
# Conflicts:
#	public/dx-maps/country-posters/countries-map/index.html
#	public/dx-maps/country-posters/country-poster-wall/index.html
#	public/dx-maps/country-posters/country-posters-mfe/index.html
#	public/dx-maps/country-posters/country-posters.js
#	public/dx-maps/country-posters/poster-section/index.html
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
#	public/dx-maps/poster-design/poster-design-mfe/index.html
#	public/dx-maps/poster-design/poster-design.js
#	public/dx-maps/poster-design/poster-sizes/index.html
#	public/dx-maps/poster-design/poster-styles/index.html
#	public/dx-maps/regions-dashboard/region-dashboards/index.html
#	public/dx-maps/regions-dashboard/regions-dashboard.js
#	public/dx-maps/regions-dashboard/regions-list/index.html
#	public/dx-maps/regions-dashboard/regions-mfe/index.html
2026-10-02 11:05:14 +00:00
Doxa Maps
1142425978 Rebuild cities-internet country-posters doxa-research-map doxa-simple-map my-upg-100-list for the legend drag fixes on the merged pilot (the host floor), and regenerate the tree and SRI 2026-10-02 11:00:47 +00:00
Doxa Maps
9b82a5e96e Merge origin/development (2fc9b94f0) into studio/legend-shrink 2026-10-02 10:59:07 +00:00
Doxa Maps
ed0a1a57b1 Poster Sections: country presets only until another poster type can load on the page
A preset page loads only poster-sections.js, so a preset of a poster type
registered by another plugin's script (upg100-list) could only say No
posters. The generator now gives no presets to a type whose file is not on
the tree, and check:poster-sections fails on any such preset: 9 presets
remain (7 regions, all regions, configure).

The tree generator writes a page per preset but never deletes one, so the
dropped presets' pages (the 10 upg100 ones and countries-other) are removed,
and a new arm holds the built preset pages to exactly the presets.
2026-10-02 10:56:43 +00:00
Doxa Maps
40cfb1da68 Add India Posters review plugin (preview-only)
A review gallery for the whole-India poster challenge — fitting every
India people group on one printable sheet. It gathers each design
attempt with what was tried and the measured result, and lets a
reviewer label it, score it 1-10, and tag it a best practice or one to
avoid (saved locally, exportable as JSON).

Withheld from every public build: the attempts, their descriptions,
and the poster documents load only under the dev server, in a dev-only
gallery module that compiles away. A production build shows only the
"solution in progress" notice and emits zero whole-country-poster
bytes. Element <india-posters>; stage experimental, visibility team.
2026-10-02 10:55:46 +00:00
Doxa Maps
2fc9b94f0e Rebuild country-posters my-upg-100-list poster-design regions-dashboard for the measure-once host floor on the merged pilot (region fix + pane scroll), and regenerate the tree and SRI 2026-10-02 10:42:53 +00:00
Doxa Maps
ade3f08e6f Rebuild country-posters and regions-dashboard for the show-header attribute, then the tree and SRI 2026-10-02 10:41:26 +00:00
Doxa Maps
00ff2d56eb Merge origin/development (b1477615c) into studio/resize-floor 2026-10-02 10:41:04 +00:00
Doxa Maps
5802f47718 Merge commit 'b1477615c69b68fb1cd15603986f3c0c9e0ad1ec' into poster-design-plugin/poster-sections 2026-10-02 10:40:02 +00:00
Doxa Maps
dc9014bb83 check:poster-section covers min-card-px and show-header, with poisons for a dropped attribute and an unwired view 2026-10-02 10:38:59 +00:00
Doxa Maps
aed7849bd2 Poster section element: show-header="false" hides the section's head so the host's layout is its header (absent keeps today's head); a headless section never collapses 2026-10-02 10:38:58 +00:00
Doxa Maps
7e3023d680 Poster Sections: presets regenerated on the corrected regions, countries-other gone
The pilot's country data now places every country in a real region, so the
country type has 7 regions. gen:poster-sections-presets regenerated the list
(19 presets); check:poster-sections had failed on the stale countries-other
preset and passes now. Bundle, tree and SRI rebuilt.
2026-10-02 10:36:06 +00:00
Doxa Maps
b1477615c6 Merge remote-tracking branch 'origin/development' into upg100/pane-scroll
# Conflicts:
#	public/dx-maps/country-posters/countries-map/index.html
#	public/dx-maps/country-posters/country-poster-wall/index.html
#	public/dx-maps/country-posters/country-posters-mfe/index.html
#	public/dx-maps/country-posters/poster-section/index.html
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
#	public/dx-maps/poster-design/poster-design-mfe/index.html
#	public/dx-maps/poster-design/poster-design.js
#	public/dx-maps/poster-design/poster-sizes/index.html
#	public/dx-maps/poster-design/poster-styles/index.html
#	public/dx-maps/regions-dashboard/region-dashboards/index.html
#	public/dx-maps/regions-dashboard/regions-list/index.html
#	public/dx-maps/regions-dashboard/regions-mfe/index.html
2026-10-02 10:30:40 +00:00
Doxa Maps
073ee575df Merge remote-tracking branch 'origin/development' into poster-design-plugin/poster-sections 2026-10-02 10:25:29 +00:00
Doxa Maps
0ece5d928d Rebuild country-posters and regions-dashboard for the min-card-px attribute, then the tree and SRI 2026-10-02 10:15:33 +00:00
Doxa Maps
73db18cc51 Rebuild every bundle for the measure-once host floor (ProfileLoader is in each), and regenerate the tree and SRI 2026-10-02 10:14:40 +00:00
Doxa Maps
823b2b603b Regions list: its poster sections keep two posters a row on a phone 2026-10-02 10:07:33 +00:00
Doxa Maps
a14da6f2fa Poster section element: a min-card-px attribute, the narrowest a card gets before a row holds one fewer (absent keeps today's layout) 2026-10-02 10:07:32 +00:00
Doxa Maps
5362d84bc6 Host floor: re-probe only when the host may have changed kind, and never move a scrolled child
The measure-once design missed a host restyled from sized to height:auto after mount WITH in-flow content: it
stayed sized at its content height instead of getting the floor back, as the old probe did.
- After mount, follow() re-probes only when the reading says the host may have changed kind: floor on and the
  host shorter than the floor (styled after mount, or capped), or floor off and the host changed size (it may now
  follow its content). The re-probe saves every scrolled descendant (through shadow roots) and puts it back
  before the frame paints, so no reader state is lost. A window-height change on a content-sized host still
  writes nothing.
- A host capped below the floor (max-height) is latched by the probe that finds it, until it reaches the floor
  again, so it is never re-probed in a loop. The 1 s post-flip window is gone.
- check:host-floor gains arm H (sized → auto with 420 px of content: the floor returns, at most one flip, the
  scrolled child keeps its scrollTop) and counts re-probes in D/E. Poisons: the old per-resize probe, no capped
  latch, no re-check of a sized host, no scroll restore.
2026-10-02 10:06:37 +00:00
Doxa Maps
4bde3d7c90 Regions list: each region's View country posters toggle embeds the shared poster section, and one Show on map handler serves the list and the Country Posters view 2026-10-02 10:02:30 +00:00
Doxa Maps
cd55cfd877 Host floor: one module, a latch for a max-height host, and a gate that pins measure-once
- library/elements/hostFloor.js holds the decision ProfileLoader uses: probeOnce() at mount, follow() on every
  host resize (reads only; at most one flip of the floor).
- A host CAPPED below the floor (max-height) follows its content up to the cap. It is found at mount, or, if
  the cap arrives later, by the host shrinking right after a flip; either way it is latched as content-sized
  and never flipped again, so it cannot loop. The post-flip check runs before the collapse rule, so a capped
  host with empty content is latched rather than flipped back and forth.
- check:host-floor (gates:pre; self-test in gates:post): a stub DOM with a scrolled child that clamps when the
  loader collapses. After mount, host resizes never write the loader nor move the child; a sized host fills;
  late sizing flips once; a max-height cap (at mount: 0 flips; after mount: at most one flip and one revert)
  never loops; a collapsed host gets the floor back; an inline host is left alone. Poisons: the old per-resize
  probe, and a missing latch.
2026-10-02 10:01:46 +00:00
Doxa Maps
fcd6d51910 Merge the pilot (c7b77b0a1) and rebuild the 3 region-data bundles
Merge studio's poster type registry + pins-in-state + preview-flag pilot into
the region data fix. Built-output conflicts resolved by taking the pilot's
public/ then rebuilding the three bundles that embed country-posters.json
(country-posters, poster-design, regions-dashboard) on the merged source, so
they carry both the registry changes and the corrected regions. Tree + SRI
regenerated. check-poster-section's dropped 'other' scope and the 18 region-only
records are preserved through the merge.
2026-10-02 09:55:45 +00:00
Doxa Maps
2ff1cf207e Rebuild the four pane-wrapper bundles on the merged pilot, and regenerate the tree and SRI 2026-10-02 09:54:53 +00:00
Doxa Maps
9a8d31d18e Embedded apps keep their view through a phone's URL-bar resize: the host is measured once, then only followed
A regression from the host-sized floor: ProfileLoader re-measured its host on EVERY host resize by briefly setting
the loader's min-height to 0 and then to a huge value. On a content-sized host (100svh tall) a window-height
change, as a phone's URL bar makes, resized the host and ran that probe; collapsing and expanding the loader
clamped a scrolling child's scrollTop, and the embedded UPG-100 app, whose view follows its column's scroll,
flipped from Map back to its first view.
- The two-value probe now runs ONCE, at mount, before the profile renders.
- After that the loader only READS sizes and flips one CSS value: a block host shorter than the floor (only a
  host sized from outside can be) turns the floor off; a block host that collapsed to nothing turns it back on;
  a window-height change on a content-sized host changes nothing.
2026-10-02 09:54:23 +00:00
Doxa Maps
6932bebd2c check:country-sheet-one-path accepts a Regions list that delegates to <dxm-poster-section>: the list is checked like the wall only while it calls makeCountrySheet itself, and the library country type must withhold India's rows and poster either way (arm H). 2026-10-02 09:53:06 +00:00
Doxa Maps
59bb677665 Rebuild the LegendMobile and PeopleGroupDetail importers for the legend drag fixes (the floating picture as a handle; contained scroll), and regenerate the tree and SRI 2026-10-02 09:50:40 +00:00
Doxa Maps
446ed37d1c Poster Sections: generate the presets from each poster type's own regions
gen:poster-sections-presets writes poster-sections.instances.json from each
type's scopes() (all regions, then one preset per region titled by the type's
own section title in the site's English, then configure), the same list
check:poster-sections holds the file to. Today it reproduces the 20 presets
exactly, in the data's region order; when the data's regions change, running
it is the whole fix.
2026-10-02 09:50:33 +00:00
Doxa Maps
0223e13059 Merge remote-tracking branch 'origin/development' into upg100/pane-scroll
# Conflicts:
#	public/dx-maps/country-posters/countries-map/index.html
#	public/dx-maps/country-posters/country-poster-wall/index.html
#	public/dx-maps/country-posters/country-posters-mfe/index.html
#	public/dx-maps/country-posters/country-posters.js
#	public/dx-maps/country-posters/poster-section/index.html
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
2026-10-02 09:49:51 +00:00
Doxa Maps
f979eb6209 Rebuild regions-dashboard on the poster type registry, then the tree and SRI 2026-10-02 09:46:13 +00:00
Doxa Maps
859cad37af Merge remote-tracking branch 'origin/development' into poster-design-plugin/poster-sections
# Conflicts:
#	package.json
2026-10-02 09:46:08 +00:00
Doxa Maps
2c80f28962 Rebuild the four bundles that compile pane-wrapper (country-posters, my-upg-100-list, poster-design, regions-dashboard) for the column-only scroll, and regenerate the tree and SRI 2026-10-02 09:45:27 +00:00
Doxa Maps
cbe28dd4c1 Merge the pilot (c7b77b0a1: the poster type registry) into the legend fix 2026-10-02 09:42:13 +00:00
Doxa Maps
6de350919d Phone legend: the floating picture is a drag handle too, so a drag from it no longer scrolls the page
Measured headless (390, real touch, a host on a page that scrolls): in the detail view, a drag up from the people
group's floating picture above the sheet scrolled the PAGE (scrollY 0 to 135, en and ar), while the same drag from
the sheet's strip did not. The picture sits outside the sheet and its grab lip, so it had none of their handlers.
It now takes the sheet's capture-phase touch handlers and touch-action:none: a drag from it resizes the sheet (the
grab-zone test holds above the sheet, as for the lip), and a tap is handed back as a click that reaches its
existing @click (open the sheet fully), as before.
2026-10-02 09:42:13 +00:00
Doxa Maps
b2bb946f2b Merge remote-tracking branch 'origin/development' into regions/poster-sections 2026-10-02 09:41:23 +00:00
Doxa Maps
c7b77b0a12 Merge origin/development into studio/poster-registry 2026-10-02 09:35:21 +00:00
Doxa Maps
fc3ea94af8 pane-wrapper: switching views scrolls the pane column, never the embedding page
scrollTo(paneId) called target.scrollIntoView(), which scrolls every scrollable
ancestor, the embedding page included. On another site, "Map" / "Show on map"
moved the host's own page so that the map's top met the window's top, and a host
shorter than the window then showed its white space under the map. The column
is the scroller (position:absolute; overflow-y:auto) and each pane's
offsetParent, so col.scrollTo({ top: target.offsetTop }) lands the pane exactly
and leaves the page alone.
2026-10-02 09:32:00 +00:00
Doxa Maps
0d09a72bf2 check:preview-flag-dev-only: the built tree may not even NAME the preview flag
Tightened before the India tab lands. A built bundle that READS globalThis.DXM_PREVIEW_UNRELEASED would ship the
withheld path itself, behind a global that any embedding page or console can set before the element loads, so
withheld content would print from production.
- A: no file under public/ names the flag at all (was: no assignment).
- B: every source line that reads OR writes it carries import.meta.env.DEV (was: writes only), so each read
  compiles away in a build.
- Self-test: a built setter, a built read, an unguarded source setter and an unguarded source read must each
  fail; the DEV-guarded forms and comments pass.
2026-10-02 09:26:40 +00:00
Doxa Maps
f9bdd8edff Key Cities: the title and intro name clusters of pins, not where groups live
A pin's position is generalised, so a cluster of pins is not a claim about where people groups live
or about a population centre. keyCities map.title is now "Clusters of people-group pins", and
list.intro counts "{named} named clusters (names from a cited gazetteer), across {clusters} located
clusters", in all 11 locales. The country-level copy is unchanged: its rows are per country.
2026-10-02 09:24:24 +00:00
Doxa Maps
7cd40a6e8e Pin wording: shared points and key-city clusters claim only what is certain
A pin's position is generalised, so it does not tell a reader the locality or where a group is
centred.

- pins.sharedPoint in all 11 locales and ApproxPinNote.vue's fallback: the groups sharing the point
  are named and counted ("{n} groups share this dot"); the locality claim is gone.
- keyCities map.note, map.sheetNote and list.caveat.row in all 11 locales: a cluster is of
  people-group pins, not centroids, and each pin marks a people group, not a town.
- The key-cities and languages-per-city caveats in build-tabulator-tables.mjs and in every committed
  copy of those two tables (only that one field changes), derive-city-candidates.mjs's caveat, and
  the matching comments in key-cities, name-clusters and the poster generator.
- check-approx-pin-note's pass line no longer calls pins localities. Two UI-strings baseline entries
  whose strings no longer exist in their files are dropped.
2026-10-02 09:21:54 +00:00
Doxa Maps
e273d451cc Phone legend: shrinking the detail sheet past its content no longer scrolls the page
Reported from a phone (Home and Pray, the people-group detail sheet): dragging the handle down worked until the
sheet became shorter than its content; from then on the page scrolled while the finger kept dragging.
- The sheet and the detail panel's scrollers (.people-group-detail, .detail-content) contain their overscroll,
  so a list that reaches its end can never chain a scroll to the page.
- The drag handlers on the sheet and its grab lip listen in the CAPTURE phase, so every touch reaches the drag
  first and no child's handler can stop one from being claimed.
2026-10-02 09:21:04 +00:00
Doxa Maps
a56e775bf8 Pin wording: a dot marks a people group, not where it is found
Locations are generalised, so a dot's position is not a claim about where a people group lives.
The pin notes now say only what is certain: a dot marks a people group, not a town or a boundary,
and its position is approximate.

- pins.approximate in all 11 locales, and ApproxPinNote.vue's fallback and header.
- gospelInternet notice.pin / notice.pins / notice.standing / facet.provinceNote in all 11 locales,
  their T() fallbacks (GiDrawer, GiMap, GiFacetRail, board) and the README line. notice.pin no longer
  takes {country}. The shared-point and internet-figure clauses are unchanged.
- gospel-resources: the province sheet's English sentence, and the `approximate` notes that
  build-gospel-by-province.mjs writes into the shipped tables. Both tables and gospel-internet.json
  are regenerated: only those notes and the generated dates change, every row is identical.
- Comments and the embed README say the same. The UI-strings baseline follows the new sentence.
2026-10-02 09:16:04 +00:00
Doxa Maps
d12d926870 packet: fork-leaders — octilinear forked leaders for paired rows
New library/packet/fork-leaders.js: a pure, deterministic router for one
paired-row leader, forking a stem at a 45° chamfer into two short legs
(one per pin), in row and trunk modes. All segments H/V/45°. Returns
polylines for drawing and a deduped segment list (stem once) so a fork
counts as one polyline for crossings, plus octilinear and through-pin
reports. Imitates the poster-render octilinear lane routing (read, not
called). Adds check:fork-leaders (both modes, 0 crossings on a 220-row
monotone set, through-pin + poison arms).
2026-10-02 09:07:49 +00:00
Doxa Maps
394d973a75 Rebuild country-posters, regions-dashboard, poster-design and my-upg-100-list for the India tab (no opener argument) 2026-10-02 09:03:39 +00:00
Doxa Maps
94abee0fd6 Rebuild country-posters, poster-design, regions-dashboard for the region data change
country-posters.json is statically imported by these three bundles (via
countryPosterType.js, poster-styles.vue and regions-list.vue), so the region
re-derive stales their built output. Rebuild the three, regenerate the tree
(manifest + leaf pages) and refresh SRI. The composite-freshness gate named
exactly these three; the other 26 bundles are unaffected. Only these three
bundle .js change; the doxa-maps-spa leaf updates its embedded integrity hash
for country-posters.
2026-10-02 08:56:25 +00:00
Doxa Maps
b3daa34ab1 P2 (2/2): five more lines without "design note" as a person, in the UPG-100 data scripts
clusters.json (one decision_pending value; the field is not in the built tree), retag-adm1.mjs (a
comment and a console message), search-index.js and uncovered-proof.mjs (comments) state the reason
without a persona. derive-city-candidates.mjs's safety_note no longer repeats "separate".
2026-10-02 08:51:19 +00:00
Doxa Maps
02b44a7343 No caller can open India's withheld poster: posterWithheld()/largeSheet() read the DEV-only preview themselves (no opener argument). The open preview is proved under Vite (check:poster-section C2, DEV true as on the pilot's dev server); check:country-sheet-one-path fails if an opener argument comes back. 2026-10-02 08:50:58 +00:00
Doxa Maps
9bd2177687 Poster Design lists every plugin's poster designs, declared in the plugin's own meta
A poster plugin now lists its designs in its own <profile>.meta.json under
posterDesign ({ family, name, size, mobileFriendly, labelKey?, instance? },
one or an array). internal/profile-meta.mjs carries the block into
manifest.json only when present, so every existing manifest keeps its bytes
(the built and the dev manifest share that function).

Poster Design's Styles reads the tree's manifest at runtime and gives each
declared design a card under From other plugins: the card says it is made by
its own plugin, and Show full details shows that plugin's page (or preset
page) live, with a link to open it. No plugin code is imported, and a plugin
adding its block does not rebuild Poster Design.

check:poster-design-catalogue now also validates every block, keeps names
unique across templates.js and all blocks, and keeps a names ledger
(src/catalogue-names.json): a shipped name that is renamed or removed fails.
Its new self-test (in gates:pre) proves an invalid block, a name clash, a
renamed ledger name and a missing preset each fail, and the documented
example passes.
2026-10-02 08:50:48 +00:00
Doxa Maps
249e69f2a5 Merge the pilot (development cf0146775) into the India tab
# Conflicts:
#	public/dx-maps/country-posters/countries-map/index.html
#	public/dx-maps/country-posters/country-poster-wall/index.html
#	public/dx-maps/country-posters/country-posters-mfe/index.html
#	public/dx-maps/country-posters/country-posters.js
#	public/dx-maps/country-posters/poster-section/index.html
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
#	public/dx-maps/poster-design/poster-design-mfe/index.html
#	public/dx-maps/poster-design/poster-design.js
#	public/dx-maps/poster-design/poster-sizes/index.html
#	public/dx-maps/poster-design/poster-styles/index.html
#	public/dx-maps/regions-dashboard/region-dashboards/index.html
#	public/dx-maps/regions-dashboard/regions-dashboard.js
#	public/dx-maps/regions-dashboard/regions-list/index.html
#	public/dx-maps/regions-dashboard/regions-mfe/index.html
2026-10-02 08:48:59 +00:00
Doxa Maps
8b83c34a79 P2: comments state the reason without quoting anyone or naming a role
Seventeen lines in 15 files. Six quoted short fragments from feedback; eleven used "design note" where a
person once stood ("design note selects 2+ rows", "a design note design call"). Each now states the
reason in neutral terms and keeps the technical content and dates.
All edits are comments, except one string: the safety_note text in
library/scripts/research/derive-city-candidates.mjs, a research script that is not bundled and whose
output is not committed. No logic changes.
2026-10-02 08:45:40 +00:00
Doxa Maps
84cc385386 Rebuild country-posters, regions-dashboard, poster-design and my-upg-100-list for the DEV-only India preview at 60x96 2026-10-02 08:45:33 +00:00
Doxa Maps
dd4131907a Merge the pilot (cf0146775: pins-in-state) into the poster type registry 2026-10-02 08:43:22 +00:00
Doxa Maps
6fa746224c Rebuild the bundles that carry the poster section element for the page-global type registry and columns, and regenerate the tree and SRI 2026-10-02 08:42:49 +00:00
Doxa Maps
f1b2cd34f4 Re-derive country-posters.json region fields from live wagf_region
Each country's region is set to the live wagf_region its groups carry (file
label spelling); only the region field changes (stats, bbox, count, India row
untouched). The stale 'Other' bucket (18 countries) empties out and each moves
to its real region: USA and the non-Spanish Caribbean + the Guianas to North
America & Non-Spanish Caribbean; Andorra/Monaco/San Marino to Europe; Cocos,
Christmas, Niue, Norfolk, Tokelau, Wallis & Futuna to Oceania. Europe goes
27 -> 30, matching the live Regions dashboard.

check:poster-section drops 'other' from its covering scopes (the bucket is now
empty, so the /regions/other scope resolves to zero countries); the seven real
regions still cover all 175 countries exactly once.

Region-only diff (18 region lines); B25 India withheld unchanged
(check:country-sheet-one-path, poster-section, poster-browser-equiv,
poster-design-catalogue all PASS).
2026-10-02 08:38:11 +00:00
Doxa Maps
6060694545 check:poster-sections: the presets must equal each poster type's own regions
The presets file now has to hold, per poster type, exactly an all-regions
preset plus one per region the type's adapter returns from scopes(). A region
the data drops (a stale "other") or gains then fails the check, instead of
lingering as a preset. 'country' is measured always; 'upg100-list' once its
type file is on the tree. Self-test: a preset for a region the type does not
have must fail.
2026-10-02 08:32:35 +00:00
Doxa Maps
c6425ed1cf Poster Sections: presets, one per region (capability 2), with Configure as one more
Poster Sections becomes a parameterized profile like doxa-simple-map: its
props (type, scope with 'all' = every region, locale, columns, collapsed)
choose the <dxm-poster-section> sections it renders, and
poster-sections.instances.json lists 20 presets, each built into its own page
and embed snippet in manifest.json:
- country posters: all regions + each of DX Life's /regions regions (8);
- UPG 100 posters: all regions + each of its 9 regions (they show once the
  upg100-list type is registered on the page);
- configure: the panel of choices and Copy embed, for making new presets.

The element's columns attribute now comes from the library (landing
separately), so this commit drops its library edits; check:poster-sections
reads columns back through that attribute and passes once it is on the pilot.
2026-10-02 08:30:54 +00:00
Doxa Maps
cf0146775b Merge remote-tracking branch 'origin/development' into cities-plugin/pins-in-state
# Conflicts:
#	package.json
2026-10-02 08:30:30 +00:00
Doxa Maps
58e0d8d5fe Regenerate the capability exemplars for the Regions country posters view 2026-10-02 08:29:36 +00:00
Doxa Maps
21e2200029 Poster sections: one type registry per page; the element takes a columns attribute
- ONE REGISTRY PER PAGE. Every bundle inlines its own copy of posterTypes.js, and <dxm-poster-section> is
  defined by whichever bundle loads first, so with a module-level Map that element saw only its own bundle's
  types: type="upg100-list" rendered "none" when the country-posters bundle had defined it. The registry now
  lives on the page (globalThis.__DXM_POSTER_TYPES__, created once): every copy registers into it and reads
  from it; a name registered twice keeps the first (a dev warning; the built-in country is silent); every
  registration is announced on window (dxm-poster-type-registered), and PosterSectionView looks again when
  its type arrives after it asked.
- COLUMNS (carried from the Poster Sections plugin PR so the element's library lands once): columns="1".."5"
  maps to profile-config.columns and to PosterWall's existing columns prop; absent or out of range is
  today's default, and a reader's saved per-row choice still wins.
- check:poster-type-registry (gates:pre; self-test in gates:post): two independent copies of the module side
  by side, as two bundles on one page; a type registered through one is found through the other; first wins;
  announced. The self-test's per-bundle registry fails.
2026-10-02 08:29:16 +00:00
Doxa Maps
4e8e3158f5 packet: pins-in-state — de-overlap pins while keeping each in its own state
New library/packet/pins-in-state.js: a deterministic pixel-space pin
de-overlap for the whole-India poster that also keeps every pin inside
its own ADM1 state (multipolygon + holes), pulling a pushed-out pin to
the nearest point just inside. Reports displacement, remaining overlaps,
pins-outside (0), and an infeasible list for states too small to hold
their pins — never a silent overlap. Imitates the poster-render pin
relaxation (read, not called). Adds check:pins-in-state (real India data
when present, else a 60x96 fixture; hole/island + poison arms).
2026-10-02 08:23:06 +00:00
Doxa Maps
fe58a301cf Rebuild country-posters (provisional, for the DEV-only preview check; Phase A rebuilds the set) 2026-10-02 08:18:15 +00:00
Doxa Maps
10c33affbf Regions: on a phone the three view tabs scroll between the corner buttons, and a poster card's Show on map opens that country's dashboard 2026-10-02 08:17:59 +00:00
Doxa Maps
790d7d2400 Poster Sections: a plugin page that sets up <dxm-poster-section> and hands out the tag to embed it
A new bundle, plugin-designs/poster-sections, with one profile, Poster Sections.
A panel of choices (poster type from the registry at runtime, region or All
regions, language, posters per row, start collapsed) drives the library's
<dxm-poster-section> elements shown live below it, and Copy embed gives the
bundle script plus the exact tags for those choices, with absolute URLs so they
work on another site. The bundle also registers <dxm-poster-section>, so a site
that loads only poster-sections.js can place the tags.

Library: the element takes a columns attribute (posters per row, 1-5), passed
to the wall's existing columns prop; absent keeps today's default, so existing
importers are unchanged. New catalogue area posterSections in all 11 locales.

check:poster-sections (in gates:pre) reads every panel choice back through the
element's own attribute reader; its self-test proves a builder that drops
columns, drops collapsed, or repeats a region under All regions fails.
2026-10-02 08:16:40 +00:00
Doxa Maps
665ef7608a Whole India prints at 5 x 8 ft (60x96) ONLY (the Driver); other countries' sizes are unchanged. check:country-sheet-one-path: every India request but a packet page gets 60x96; a poison offers 66x66 again. 2026-10-02 08:14:09 +00:00
Doxa Maps
0dc9765f77 The preview read is DEV-only: no built bundle can open India's withheld poster from a global
previewUnreleased() is import.meta.env && import.meta.env.DEV && globalThis.DXM_PREVIEW_UNRELEASED === true, and the
India tab uses the same expression inline, so every BUILT bundle compiles both away. A page, an embed or a reader's
console setting the global can no longer print the withheld poster from production; only the Vite dev server (the
pilot's Plugin Studio, whose element bootstrap sets it) previews it. posterWithheld()/largeSheet() take the preview
state as an optional argument, so a gate can test an open preview directly.
check:country-sheet-one-path arm G: a global alone does NOT open the preview outside DEV; withheld by default; an
open preview prints 66x66; the India tab is gated on the DEV read; and the built tree mentions the flag 0 times
(the stale ungated build failed this check, as it should). A poison for each.
2026-10-02 08:08:26 +00:00
Doxa Maps
eb4896d821 Rebuild country-posters, regions-dashboard, poster-design and my-upg-100-list for the preview-gated India poster 2026-10-02 08:05:57 +00:00
Doxa Maps
edff06b96a Regions: a third view, Country Posters, with one collapsible poster section per region
The view places the library's <dxm-poster-section type="country"> once per region the poster data files its
countries under, in the dashboard's region order, starting shut, in the page's locale. Rebuilt regions-dashboard,
then the tree (the new leaf page) and SRI.
2026-10-02 08:02:55 +00:00
Doxa Maps
078378199a The pilot's hot-reload server previews withheld content; no built bundle can
globalThis.DXM_PREVIEW_UNRELEASED lets a page show what is withheld from the public (the whole-India country
poster). defineMapElement.js, every element's first module, now sets it behind import.meta.env.DEV: true only
under the Vite dev server (the pilot's hot-reload preview), a compile-time false in every build, so the build
drops the line. The built tree, which goes to the research landing and DX Life's /dx-maps, never carries it.
check:preview-flag-dev-only (gates:pre; self-test in gates:post): no file under public/ assigns the flag in any
spelling a minifier writes, and every source assignment is behind import.meta.env.DEV (2 planted defects).
2026-10-02 07:56:01 +00:00
Doxa Maps
f804bd8bcc India's whole-country poster stays withheld everywhere except an explicit PREVIEW context
It is not released yet, and the pilot syncs to DX Life (and the partner handoff) within minutes. So WITHHELD.IND is
{ poster: true, rows: true, preview: true }: posterWithheld() is false ONLY when the host page sets
globalThis.DXM_PREVIEW_UNRELEASED === true (the Plugin Studio on the pilot, where it is reviewed); never by a URL
parameter. Every other host keeps the withheld sentence on India's card, and Country Posters' India tab exists only
under that flag. The rows (list, packet, cards) stay withheld everywhere.
check:country-sheet-one-path arm G proves (a) withheld by default and (b) printing at 66x66 with the flag (only
=== true opens it), rows withheld in both, the India tab gated, with a poison for each; check:poster-section arm C
expects the sentence by default.
2026-10-02 07:53:41 +00:00
Doxa Maps
2992f5b941 The whole-India poster ships on an India tab (66x66 by default); its list, packet and cards stay withheld
countrySheet.js WITHHELD now says WHAT of a whole country is withheld: { IND: { poster: false, rows: true } }, with
posterWithheld() and rowsWithheld() (sheetWithheld() keeps meaning the poster, the name Poster Design reads).
- India's whole-country POSTER prints again, 66x66 by default (60x96 and 36x60 on request); largeSheet() names it.
- Its ROWS (the prayer list, packet and cards: about a thousand pages) stay withheld with the same sentence, in the
  wall, the Regions list and the 'country' poster type. India's state posters are unchanged.
- Country Posters gains an INDIA tab: the same wall filtered to India, labelled with India's own name in the
  reader's language (Intl), between the posters and the map.
check:country-sheet-one-path arm G checks the split (poster live at 66x66, rows withheld, state cards never
withheld, both callers read the right half), with poisons for un-withheld rows and a wall that prints them;
check:poster-section arm C follows. The by-state, leaderless India layout follows as its own PR.
2026-10-02 07:51:10 +00:00
Doxa Maps
8aa8568acb Rebuild country-posters, my-upg-100-list, poster-design and regions-dashboard on the merged poster section + phone fixes, and regenerate the tree and SRI 2026-10-02 07:37:01 +00:00
Doxa Maps
83eb1fe641 Merge the poster section (3056db68a) into the phone fixes, ahead of its landing 2026-10-02 07:32:27 +00:00
Doxa Maps
3056db68a6 Gates that read the poster wall's markup read the lifted PosterSection too: check:packet-variants (its card-deck and packet tapes) and check:poster-fetch-guard (no direct poster fetch) 2026-10-02 07:23:19 +00:00
Doxa Maps
2bedd54ecb Rebuild the LegendMobile importers for the tap fixes, and regenerate the tree and SRI 2026-10-02 07:16:20 +00:00
Doxa Maps
8772636b34 Legend drag: a tap right after a drag works again; a jittery tap stays a tap
- The post-drag click guard (400 ms) was armed by every drag, but a drag that claimed its touchstart
  produces no browser click to guard against, so it only ate the reader's next real tap (a caret tap right
  after a drag did not collapse the sheet). It is now armed only for a drag that did not claim its start.
- A claimed touch becomes a drag only past 6 px (a thumb tap moves 1-3 px), so a slightly moving tap on the
  caret or the ✕ keeps its click.
- A comment no longer names an internal role.
2026-10-02 07:10:35 +00:00
Doxa Maps
2cab21c5a9 Poster section page: its @description; the library index and capability exemplars regenerated; the tree rebuilt 2026-10-02 07:07:18 +00:00
Doxa Maps
ce88df2074 Rebuild country-posters, my-upg-100-list, poster-design and regions-dashboard for the lifted PosterSection and <dxm-poster-section> 2026-10-02 07:00:27 +00:00
Doxa Maps
0a5d2fea53 Rebuild the bundles for panel-close and the legend drag (MapControlMenu, LegendMobile), and regenerate the tree and SRI 2026-10-02 06:59:06 +00:00
Doxa Maps
a0819ebed4 Rebuild for the poster section (provisional; Phase A rebuilds every PosterWall importer) 2026-10-02 06:58:22 +00:00
Doxa Maps
a95b88ed65 <dxm-poster-section>: one poster section, any poster type, as a reusable element
The section the Country Posters tab draws (a collapsible region head + its poster cards) is now usable on its
own, for any poster set, by parameters:
- library/components/poster/posterTypes.js: a TYPE REGISTRY. A type is an adapter (items for a scope, the
  section's title, the print actions). The library registers 'country' (library data + the shared sheet path,
  so a withheld whole-country poster stays withheld); plugins register their own types from their own code
  (UPG 100 lists, regions, continents later). No plugin code in the library.
- 'country' scopes: a region key in any spelling. DX Life's /regions slug ('latin-america-and-caribbean'),
  the API's value, the wall's slug and the English name all reduce to one canonical key.
- PosterSectionView.vue: one section, drawn by a PosterWall in a new SECTION-ONLY mode (no wall head; off by
  default, so every existing wall renders as before), embedded so the page scrolls it.
- <dxm-poster-section type scope collapsible collapsed locale assets-base api-base-url is-dark>: the
  attributes map onto the standard element's profile-config (posterSectionConfig.js, pure), so it keeps the
  per-instance i18n and store. registerPosterSectionElement() from any bundle; Country Posters ships it first.
- country-posters/poster-section: a page showing two sections (Africa open, Oceania collapsed).
- poster.sectionNone (11 locales): an unknown type or an empty scope says so, never a blank.
check:poster-section (gates:pre) + self-test: the registry, DX Life's 8 scopes covering every country once,
withheld India, the attribute mapping, and PosterSection.vue injecting every name its template uses; 3 poisons.
The known collapse-while-scrolled landing is left exactly as it is.
2026-10-02 06:56:48 +00:00
Doxa Maps
c535c0f031 Phones: a panel announces it closed; the legend's resize drag owns its gesture
- MapControlMenu dispatches 'dxmaps:panel-close' (composed, bubbling), the twin of 'dxmaps:panel-open', when a
  panel closes by ✕, an outside tap or Esc, or is unmounted while open. A host can then hide its own chrome
  while a panel is up (DX Life's top scroll strip sat over the Share panel's ✕).
- The mobile legend's resize drag scrolled the page. Only the strip and the title rows carried
  touch-action:none, so a drag that began elsewhere in the 44px grab zone let the browser start panning
  before touchmove could cancel it, and its later touchmoves were not cancelable. touchstart in the grab zone
  now claims the gesture (preventDefault), and a touch that never becomes a drag is clicked on release
  (inputs keep their native tap). The height is written once per frame (rAF), and the last frame lands
  before the snap.
2026-10-02 06:51:32 +00:00
Doxa Maps
194006412f Merge the pilot (development a3225e072: the PosterWall gate loader) into the poster-section branch
# Conflicts:
#	public/dx-maps/country-posters/countries-map/index.html
#	public/dx-maps/country-posters/country-poster-wall/index.html
#	public/dx-maps/country-posters/country-posters-mfe/index.html
#	public/dx-maps/country-posters/country-posters.js
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
#	public/dx-maps/my-upg-100-list/my-upg-100-list.js
#	public/dx-maps/my-upg-100-list/poster-wall/index.html
#	public/dx-maps/my-upg-100-list/upg-100-list/index.html
#	public/dx-maps/my-upg-100-list/upg-100-mfe/index.html
#	public/dx-maps/poster-design/poster-design-mfe/index.html
#	public/dx-maps/poster-design/poster-design.js
#	public/dx-maps/poster-design/poster-sizes/index.html
#	public/dx-maps/poster-design/poster-styles/index.html
#	public/dx-maps/regions-dashboard/region-dashboards/index.html
#	public/dx-maps/regions-dashboard/regions-dashboard.js
#	public/dx-maps/regions-dashboard/regions-list/index.html
#	public/dx-maps/regions-dashboard/regions-mfe/index.html
2026-10-02 06:49:01 +00:00
Doxa Maps
6a97b45be4 Rebuild country-posters, my-upg-100-list, poster-design, regions-dashboard for the lifted PosterSection (provisional; Phase A rebuilds on the merged tree) 2026-10-02 06:48:51 +00:00
Doxa Maps
a107faadaa PosterWall: each section is a PosterSection (lifted, not copied), drawn from the wall's own state
A section of a poster wall (its head: the name, the count, the collapse toggle; and its cards: the preview,
"show on map", the print options) is now its own component, library/components/poster/PosterSection.vue. The
markup moved VERBATIM; PosterWall renders every group through it and PROVIDES what a section uses (its props,
its collapse state, the print actions and their progress overlay) under POSTER_WALL_CTX. The section's CSS stays
in PosterWall's one shadow-style string, which applies to this markup in the same root.
Every wall renders the same DOM: measured in the shared browser, before (the pilot's built tree) vs after, at
1440 and 390, en and ar: Country Posters, UPG 100's poster wall, Poster Design's styles and the Regions list
(with every region's posters open) are identical once the server port and the root's scope hash are normalised;
the same cards, sections, intro text and scroll widths. The first step towards a reusable poster section.
2026-10-02 06:48:41 +00:00
Doxa Maps
a3225e072f check:poster-wall-host-seams: load any .vue PosterWall imports, and prove it with arm L
The gate compiled PosterWall.vue alone, as a client component, and runs
Vite without the Vue plugin. A child component imported by PosterWall
(a section component, which a refactor is about to add) would fail to
load, turning the gate red on correct code.

One resolveId/load hook (enforce 'pre', so Vite's resolver does not load
the raw SFC first) now client-compiles PosterWall and every .vue it
imports, each at its real path, so their own relative imports resolve as
in the build; the earlier '../../' path rewrite is gone. Arm L loads a
parent fixture that renders a child .vue by relative import
(internal/fixtures/poster-wall-host-seams/) and asserts the child's text.
It fails without the hook and without 'pre'.
2026-10-02 06:39:49 +00:00
Doxa Maps
230be4f119 Rebuild the MapInfo importers for its shadow-root listener, and regenerate the tree and SRI 2026-10-02 06:22:36 +00:00
Doxa Maps
7a5d93437f MapInfo hears panel-open on its shadow root when it has no parent element
A MapInfo mounted at the top of a map's own shadow tree has no parentElement, so it attached no listener and a
standalone leaf would have kept the pointer over Share. It now falls back to its root node (the shadow root),
which the panel's event reaches.
2026-10-02 06:14:49 +00:00
Doxa Maps
4649304e2a Rebuild every bundle for the panel-opens-pointer-away fix (MapControlMenu and MapInfo), and regenerate the tree and SRI 2026-10-02 06:12:49 +00:00
Doxa Maps
a143992d0e An opening map panel puts the "What can this map do?" pointer away
On a phone, at first load, the onboarding pointer could sit over the open Share panel and cover its ✕
(cities-with-internet). The pointer can be drawn by the host page, outside the map's shadow root, so no
z-index inside the map can lift a panel above it. Instead, MapControlMenu (Share, and every menu built on it)
announces that it opened with a composed 'dxmaps:panel-open' event, and MapInfo, hearing it on its own
container (so only that map's panels count), hides its pointer; a page-load pointer is then done for this
load, as if its popup had been opened.
2026-10-02 06:08:51 +00:00
Doxa Maps
bb56908600 Merge branch 'dx-maps/readme-pilot' into HEAD 2026-10-02 05:49:53 +00:00
Doxa Maps
cb74b60009 README + diagram: the pilot lock and one merge at a time, so "landing" means only the landing branch
The lock guards the pilot, so it is the pilot lock, and the act is a merge onto the pilot: the
README's summary row, the harness paragraph, the kitchen list, the glossary and the alt text, and the
diagram's title, the lock ring, the coordinator line and the legend. "Publish to landing" and
"landing (main)" keep the word for the landing branch. Also: about 15 seconds to see a landed change;
a "merge" glossary row instead of "rebase", with pilot and landing beside development and main; the
cloud paragraph names BRANCH=development and PRODUCTION_BRANCH=main. The PNG is re-rendered at 2x.
2026-10-02 05:38:20 +00:00
Doxa Maps
bf140e38c9 README: land on the pilot through a throwaway merge, so the PR branch stays one feature on the landing branch
Step 5 merged the pilot INTO the PR branch, which made the PR branch carry everyone's unpublished
work. It now merges the PR branch into a detached copy of the pilot inside the lock, checks, pushes
that merge, and switches back; a conflict is aborted and fixed on the PR branch. Step 4 says the
per-agent preview servers run on your own machine, and the cloud paragraph says to set the container's
BRANCH to development for the pilot server (its default is main).
2026-10-02 05:36:24 +00:00
Doxa Maps
8683107b46 Rebuild every bundle for the phone top bar and the host-sized map floor (ProfileLoader is in each), and regenerate the tree and SRI 2026-10-02 05:31:28 +00:00
Doxa Maps
dceb824e3f README: the cloud paragraph says why one pilot server per app, and names the landing rebuild from accepted pull requests as planned too 2026-10-02 05:31:23 +00:00
Doxa Maps
919f5fa46e README: the agent-swarm section in pilot and landing words, with the diagram redrawn to match
The pilot branch (development) is the shared working version a hot-reload server follows; the
landing branch (main) is production, which its server rebuilds from; PR branches hold one feature
each, start from the landing branch and land on the pilot after the checks. The worktree, lock,
gates and build-owner steps stay. A short paragraph on cloud development with a swarm: a self-hosted
forge, a pilot and a landing server per app, and the pilot server's PR switching named as planned
only. The diagram's labels follow (pilot at the centre behind the lock, landing below); the PNG is
re-rendered at 2x from the SVG.
2026-10-02 05:28:21 +00:00
Doxa Maps
d533431997 Phones: one top bar on a website too; maps fill a sized host, so the legend is never cut off
- WEB MODE GETS THE TOP BAR BACK. The corner ☰ with no header was rejected on a real phone. Both shell modes
  now show the one top bar on a phone, the ☰ at its start and the language at its end; web mode's bar takes no
  camera inset (the shell is not at the top of the screen in a website). The corner circle and the drawer-foot
  language are gone. check:shell-header renders both modes (13 planted defects).
- THE FLOOR NEVER EXCEEDS A SIZED HOST. ProfileLoader's 100svh floor is for a host sized by its content (the
  scroll-page leaf). Inside a host with a definite height (DX Life's map slot, overflow hidden) it made the map
  taller than the host, which clipped the map's bottom and its legend footer, and tied the map's height to the
  window's. ProfileLoader now MEASURES the host (the floor at 0 and at a huge value: a sized host keeps its
  height) and drops the floor inside a sized one, re-measuring when the host resizes (a ResizeObserver on the
  host, not a window resize).
- LegendDesktop caps itself at the map's height (100% - 80px), not the window's (100vh - 80px).
2026-10-02 05:27:37 +00:00
Doxa Maps
443d2efdaa Rebuild doxa-maps-spa, plugin-studio and posterverse for the phone drawer fix, and regenerate the tree and SRI 2026-10-02 05:05:13 +00:00
Doxa Maps
326eccb465 Phone drawer: starts closed from the first paint; the language sits at its foot
- On a phone the drawer started OPEN (navOpen = initialNavOpen) and mounted() closed it, so every load drew it
  on screen and slid it away (measured: up to ~1.6 s, both shells, app and web mode). data() now reads the
  same (max-width: 760px) query, so the first render is already the phone's closed state; mounted() still
  tracks breakpoint crossings.
- In web mode the language at the drawer's foot sat right under the last row: margin-top:auto needs the nav's
  flex column (.has-rail-end), which only the desktop rail-end set. The phone drawer's foot sets it too.
- check:shell-header asserts the flex column in web mode (14 planted defects).
2026-10-02 05:04:36 +00:00
Doxa Maps
f4066c36fc Rebuild every bundle for the phone shell modes and the svh map floor (ProfileLoader is in each), and regenerate the tree and SRI 2026-10-02 04:49:01 +00:00
Doxa Maps
6032512edf Phones: one header on a website, and maps that hold their size while you scroll
ONE HEADER ON A WEBSITE. Inside a host site that has its own header, the shell's 44px phone bar stacked a
second white bar under it. VerticalTabsLayout gains shellMode (prop, or the host element's shell-mode
attribute): web | app | auto.
- app (a Capacitor or standalone app, or a DX Maps leaf page): the phone bar, unchanged.
- web: no bar. The rail opens from a 40px corner ☰ at the pane's bottom-start, which publishes its footprint
  (--map-fab-clearance / --map-fab-beside) so the legend sheet, the pin hint and back-to-top step aside; the
  language menu moves to the drawer's foot.
- auto: the host's shell-mode attribute, else app for Capacitor, display-mode standalone or a leaf
  (window.MAP_OWNS_PAGE), else web.
Desktop is unchanged. check:shell-header renders both modes (13 planted defects); check:nav-reachable-mobile
accepts the corner ☰.

MAPS HOLD THEIR SIZE. ProfileLoader floored every map at min-height 100dvh, and the leaf pages' slot did the
same. dvh follows the mobile URL bar, so scrolling grew every map mid-scroll and moved its legend sheet. Both
now use 100svh (the viewport with the bar showing, which never changes on scroll), with 100vh where svh is
unsupported.
2026-10-02 04:44:33 +00:00
Doxa Maps
d1536d94e3 Rebuild doxa-simple-map for <dxm-simple-map>, and regenerate the tree and SRI 2026-10-02 03:22:55 +00:00
Doxa Maps
31d594b83f Prayer Progress registers as <dxm-simple-map>; the older names only on request
A host page can define a tag name before our bundle loads, and the first definition wins. DX Life's own
/js/doxa-simple-map.js defines <doxa-map> and <doxa-simple-map>, so on those pages our guarded define was
skipped and the page drew the host's element under our name.
- The doxa-simple-map bundle now defines <dxm-simple-map>, DX Maps' own name, first. The manifest, the leaf
  pages and the gallery mount it (generate-tree reads the first define).
- <doxa-map> and <doxa-simple-map> are defined only when the host asks: data-legacy-tags on the bundle's
  <script>, or window.DXM_LEGACY_TAGS = true before it loads.
2026-10-02 03:22:36 +00:00
Doxa Maps
0413c49176 Rebuild doxa-maps-spa and posterverse for the default home link, and regenerate the tree 2026-10-02 02:15:56 +00:00
Doxa Maps
68bcb28624 Posterverse: "Back to homepage" leads to the DOXA site by default, in the shell's language; a host's own home link still wins 2026-10-02 02:15:28 +00:00
Doxa Maps
b3de39a39e Research Gallery: "Back to homepage" leads to the DOXA site by default, in the gallery's language; a host's own home link still wins 2026-10-02 02:13:47 +00:00
Doxa Maps
56337e3861 Rebuild poster-design for its withheld-country card message 2026-10-01 22:25:21 +00:00
Doxa Maps
c11d179d3d Poster Design: a withheld country's large-format cards say why, and are never drawn or printed
countrySheet.js now withholds the whole-India poster (WITHHELD,
sheetWithheld), and makeCountrySheet returns nothing for India. The three
large-format Styles cards use India as their sample, so they showed
"preview unavailable" and their print fell through to a stored poster
that does not exist. They now answer PosterWall's unsupported with
messages.countryPosterTooMany, in the reader's language: the card shows
that sentence instead of a preview, and a print opens only that notice.
The designs stay in the catalogue, so they return when India is no longer
withheld.
2026-10-01 22:24:53 +00:00
Doxa Maps
5226c8f4bc Rebuild country-posters, regions-dashboard and poster-design for the withheld whole-India poster 2026-10-01 22:21:13 +00:00
Doxa Maps
fe743ff48d The whole-India poster is withheld for now: "Too many people groups to fit on one poster legibly"
India's whole-country poster does not ship yet; a solution is in progress. countrySheet.js gains WITHHELD (India)
and sheetWithheld(item):
- makeCountrySheet draws nothing for a withheld country, in any format, before fetching a single record.
- largeSheet() is null for it, so the Country Posters wall and the Regions list show their existing
  messages.countryPosterTooMany ("Too many people groups to fit on one poster legibly — a solution is in
  progress.", all eleven locales) on the card instead of a poster.
- Both callers also withhold its list, packet and cards with the same sentence (about a thousand pages, offered
  when the poster is).
- India's STATE and union-territory posters are separate cards (indiaState) and are untouched; so are the India
  section and its first place.
- sheetFormatFor keeps the large-sheet size rule itself (it returns with the poster).
check:country-sheet-one-path arm G: India stays withheld (no sheet in any of 7 formats, 0 records fetched, its
state cards never withheld, both callers show the sentence and reject its rows), with a poison for un-withholding
it and one for a wall that prints its list.
2026-10-01 22:18:31 +00:00
Doxa Maps
96de705202 Rebuild country-posters, my-upg-100-list, poster-design and regions-dashboard (and the served poster-render copy) for the whole-India sheet 2026-10-01 19:53:07 +00:00
Doxa Maps
26dc5949d9 Country names for 13 territories with their own posters; Kosovo's label
The facts row's COUNTRIES cell is ISO3_NAME[iso3] || iso3, and 13 territories that have country posters were
missing from the table, so their shipped sheets printed the raw code there (CCK, CXR, ESH, GIB, HKG, JEY, KOS,
MAC, MCO, NFK, SJM, SXM, TKL). Kosovo's own label in country-posters.json was the code too, so its title and
its wall card read "KOS".
- ISO3_NAME gains the 13 (the short names the posters already print as each label; SXM "Sint Maarten").
- KOS's label is "Kosovo".
Every country in country-posters.json now has a name, and no label is a raw code.
2026-10-01 19:28:27 +00:00
Doxa Maps
c5e6e12952 Rebuild country-posters, my-upg-100-list, poster-design and regions-dashboard (and the served poster-render copy) for the whole-India sheet 2026-10-01 19:09:50 +00:00
Doxa Maps
6dfc7dd7a8 check:side-lane-photo-pixels: every name clear of every OTHER photo too (a long inner-column name must not run into the outer column's photo above or below) 2026-10-01 18:46:44 +00:00
Doxa Maps
1b496b3b31 Large side-lane sheets: a photo sits in a fixed slot beside its name, never on its letters
Measured on the shipped India sheets in the browser (getBBox, Lato loaded): 74 of 151 photos on the 60x96 and
32 on the 66x66 overlapped their own name, by up to 7 px (half a letter at 20.7 pt). The photo was placed from
an ESTIMATED text width, and the real width runs wider.
Now the photo sits in a fixed slot on the LEADER side of its name, and the name moves out by the slot. A
side-lane name is anchored at its leader-side end (left column: text-anchor end; right: start), so its glyphs
lie wholly on the far side of that anchor and the photo wholly on the near side: they cannot overlap whatever
width the font draws. Only a name with a photo takes the slot, so the columns and the type keep their size
(India 60x96 stays 20.7 pt).
Measured after: India 60x96 and 66x66 have 0 photos on, and 0 touching, their name. 0 proper crossings on all
three India formats. 36x60 (no photos) is byte-identical. Re-pinned.
- check:side-lanes: every photo on its name's anchor side, at the 4 px gap (a source twin, always measured).
  A self-test poison restores the estimated-width placement: 167 photos off their side, caught.
- check:side-lane-photo-pixels (browser): every photo box clear of its name's real drawn box (getBBox), with
  the same poison in its self-test.
2026-10-01 18:42:18 +00:00
Doxa Maps
66488dea64 Merge remote-tracking branch 'origin/development' into repo/b21-contribute 2026-10-01 16:45:21 +00:00
Doxa Maps
aa5f2c4c73 Merge origin/development into studio/release-hygiene-2 2026-10-01 16:40:09 +00:00
Doxa Maps
771e68cbcb B21 (2/2): documentation/contribute/, a tutorial for taking contributions from a contributor's own forge
Copied unchanged from the bridge kit (727fb55): TUTORIAL.md (for the repository's GitHub admin,
about 10 minutes), pull-contributor.yml (the workflow it sets up; it refuses a contributor's
changes to .github/) and test-pull-contributor.sh (the TUTORIAL's "check the guard yourself",
which sits next to it). None of them is under .github/, so nothing runs by itself here.
README: one line in the agent section links the tutorial; nothing else in README changes.
2026-10-01 16:35:38 +00:00
Doxa Maps
52802bd471 The exclusion list and the hygiene detector no longer match the export's own content rule (bracket forms, a pattern built from parts) 2026-10-01 16:32:23 +00:00
Doxa Maps
63aad26e92 The private entry-policy table is built by its own script outside the public export; the public research script carries no trace of it 2026-10-01 16:32:22 +00:00
Doxa Maps
6598d95893 Mark 5 scripture sources: the provenance note gives the date, not who asked 2026-10-01 16:32:22 +00:00
Doxa Maps
07745f7fc5 B21 (1/2): the GitHub Pages workflow moves into the docs, so it never runs by itself
.github/workflows/pages.yml is now documentation/deploy/github-pages.workflow.yml, with a
one-line README beside it: copy it into .github/workflows/ in your own repository to deploy to
GitHub Pages. Nothing referenced the old path. The repo carries no .github/ folder any more, so
a host never runs it automatically.
2026-10-01 16:27:05 +00:00
Doxa Maps
8ff611b841 Rebuild the four PosterWall bundles (country-posters, my-upg-100-list, poster-design, regions-dashboard) for the collapsible-head width, and regenerate the tree and SRI 2026-10-01 13:40:20 +00:00
Doxa Maps
5a62ed0827 Merge remote-tracking branch 'origin/development' into upg100/wall-toggle-width 2026-10-01 13:39:25 +00:00
Doxa Maps
86230f93b9 Merge remote-tracking branch 'origin/development' into upg100/phone-bigjob 2026-10-01 13:36:15 +00:00
Doxa Maps
eec0c2f280 Merge origin/development into gospel/gi-render-exit 2026-10-01 13:32:00 +00:00
Doxa Maps
6d4fc6cccc Merge remote-tracking branch 'origin/development' into upg100/phone-bigjob 2026-10-01 13:30:56 +00:00
Doxa Maps
3e2dc121de Gospel x Internet render check: exit once it has its verdict, so the dev backend it starts no longer holds it open 2026-10-01 13:26:46 +00:00
Doxa Maps
c4b756b65a Merge remote-tracking branch 'origin/development' into upg100/pg-names
# Conflicts:
#	package.json
2026-10-01 13:25:01 +00:00
Doxa Maps
2df1c5c373 Rebuild my-upg-100-list for the HUD message line at the top, and regenerate the tree and SRI 2026-10-01 13:16:56 +00:00
Doxa Maps
98eeb4183d UPG-100 batch HUD: the message line sits at the top, where nothing covers it
On a phone the first-visit PinHint toast sits over the HUD's bottom edge, and the
message line under the buttons was covered there. A hit-test across the text
found the toast at 30%, and the screenshot read "This needs a" and no more.
The line (Copy's flash, the phone ceiling's notice, failures, the print's
progress) now sits under the hint at the top of the HUD, at 11px instead of 9px.
check:batch-selection-export pins it above the action rows.
2026-10-01 13:01:11 +00:00
Doxa Maps
c4b0cfc5c2 Rebuild my-upg-100-list for the visible batch HUD line on the merged tree, and regenerate the tree and SRI 2026-10-01 12:55:26 +00:00
Doxa Maps
ea5a5bf8ae Merge origin/development into gospel/b14-gate-fp 2026-10-01 12:50:29 +00:00
Doxa Maps
eeda3f168c Merge remote-tracking branch 'origin/development' into upg100/phone-bigjob 2026-10-01 12:48:41 +00:00
Doxa Maps
c78a829810 UPG-100 batch HUD: on a phone, the block notice and the print's progress are visible
.upg-status is display:none below 768px (the legend sheet owns the bottom edge),
so the phone ceiling's "This needs a desktop or laptop computer." and the print's
progress ("fetching people groups…", "printing 905 people groups…") were written
only where a phone shows nothing. A reader tapped Prayer List and nothing visible
happened.

hudSay writes a batch action's message to the batch HUD's line (.upg-hud-flash,
now a polite live region, visible at every width) as well as to the status
badge. The block notice stays 6 s and failures 5 s. The print's progress is
mirrored into the same line whenever no flash is up, and is cleared when the
print ends.

check:batch-selection-export arm C checks that the notice, every batch action's
failure text and the progress reach the HUD line, that no batch action writes
via flashStatus alone, and that no rule hides .upg-hud-flash. Four mutants each
fail, including "notice written only to .upg-status".
2026-10-01 12:48:39 +00:00
Doxa Maps
b64b021546 Merge remote-tracking branch 'origin/development' into upg100/wall-toggle-width 2026-10-01 12:29:17 +00:00
Doxa Maps
bdd4d700f4 Merge origin/development into studio/gate-fixes 2026-10-01 12:18:23 +00:00
Doxa Maps
7853689771 Rebuild my-upg-100-list for the phone print ceiling, and regenerate the tree and SRI 2026-10-01 12:13:12 +00:00
Doxa Maps
1fde892d4b UPG-100 batch selection: on a phone, a selection too big to print asks for a computer
Building a selection's Prayer list or Card deck is never the problem (the whole
selection is ready in under 30 s on a 4×-throttled phone). PRINTING it is: in a
browser capped at 2.5 GB, the print of 1,857 groups (101 sheets, a 138 MB PDF)
finished, and 2,209 groups (113 sheets) crashed the page at 1× and at 4×. A phone
tab has roughly half that memory, so above 1,000 groups (PHONE_GROUP_CEILING,
with the measurement beside it) a phone or tablet gets the house text
"This needs a desktop or laptop computer." (poster.phoneLimitAll, as Country
Posters shows for whole India) and nothing starts. India alone (905) still
prints on a phone. A desktop is never blocked. Copy and CSV stay allowed.

The device test is isPhonePrint(), the same platform test the print path uses.
check:batch-selection-export arm A7 tests the ceiling both ways. Arm C checks that
Prayer list, Card deck and Poster each ask BEFORE they print, and arm D checks
the string ×11. Four mutants each fail.
2026-10-01 12:08:38 +00:00
Doxa Maps
9ed868052c Rebuild country-posters, my-upg-100-list, poster-design and regions-dashboard on the merged tree 2026-10-01 12:08:35 +00:00
Doxa Maps
6bef434b4e Merge development (1cd287094, B4b) into the India state names landing
# Conflicts:
#	public/dx-maps/country-posters/countries-map/index.html
#	public/dx-maps/country-posters/country-poster-wall/index.html
#	public/dx-maps/country-posters/country-posters-mfe/index.html
#	public/dx-maps/country-posters/country-posters.js
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
2026-10-01 12:06:08 +00:00
Doxa Maps
84d5800106 Merge origin/development into gospel/b14-gate-fp 2026-10-01 11:57:59 +00:00
Doxa Maps
1cd2870941 Merge origin/development into gospel/lm-image-seat 2026-10-01 11:51:57 +00:00
Doxa Maps
1ad2929849 PosterWall: a collapsible wall no longer scrolls sideways by 4px
A collapsible section head is a <button> with width:100%, but it keeps the
2px side margins of .sec-head, so it overflowed the wall's content box by 4px.
Its full-bleed ::before (left/right -20px) then reached 2px past the wall on each
side, so every collapsible wall (Poster Design's Styles, Country Posters) had
scrollWidth = clientWidth + 4 at 390, 768 and 1024. The head is now
calc(100% - 4px), the width a non-collapsible head already gets.

Measured on the built poster-design and country-posters bundles (div.wall
scrollWidth vs clientWidth at 390/768/1024/1440): before, +4 at the first three
widths on both walls; after, equal at all four. UPG-100's wall (no toggles)
was already equal.
2026-10-01 11:46:43 +00:00
Doxa Maps
9eb31ad10c Rebuild country-posters, my-upg-100-list, poster-design and regions-dashboard (and the served poster-render copy) for the whole-India sheet 2026-10-01 11:45:35 +00:00
Doxa Maps
a63a873ef4 Country Posters: India's state and union territory cards named in the reader's language; the state table retried after a failure
countryPosters.stateNames x11, keyed by ISO 3166-2, for the India section's CARD labels (the wall, the print HUD, the
packet and list titles). The printed sheet stays English by recorded decision, like every country poster.
Generated, not typed: scripts/emit-india-state-names.mjs reads Wikidata labels by ISO 3166-2 (P300), dissolved items
excluded; Wikidata is CC0 (source, licence, query, date and every fallback in data/india-state-names.meta.json).
- Four codes ISO has renamed (IN-CT/OR/TG/UT -> CG/OD/TS/UK) are queried under the current code.
- English is the table's own name. Where the table is briefer than its item ("Delhi" vs "National Capital Territory
  of Delhi"), the same-named item's labels win, so hi/ru match the English.
- Reviewed by eye (hi, ar, zh): two explicit overrides with their evidence (ar Odisha = the ar.wikipedia title,
  not the pre-2011 label; zh Dadra and Nagar Haveli and Daman and Diu = Simplified, the only label being
  Traditional). A zh name from a plain "zh" label with no reviewed override FAILS the generator (poisoned: it did).
The India section retries a failed table fetch at 5 s, 20 s and 60 s (bounded, cancelled on unmount), instead of
staying hidden until the filter changes.
check:country-sheet-one-path ARM D: every locale's stateNames == the table's 36 codes (a runtime-built key the i18n
call-site gates cannot see), English == the table, and the licence recorded; its two poisons join the self-test.
2026-10-01 11:41:37 +00:00
Doxa Maps
3313649901 Merge origin/development (671295b5b) into studio/gate-fixes 2026-10-01 11:39:54 +00:00
Doxa Maps
671295b5b1 Merge remote-tracking branch 'origin/development' into poster-design-plugin/b3-land 2026-10-01 11:07:05 +00:00
Doxa Maps
02f1a1825a Merge origin/development into gospel/lm-image-seat, and rebuild the five LegendMobile bundles 2026-10-01 10:54:12 +00:00
Doxa Maps
fd032702d2 Merge remote-tracking branch 'origin/development' into poster-design-plugin/b3-land 2026-10-01 10:53:34 +00:00
Doxa Maps
c626a1fc02 Packet fixtures build in their own directory, so the real Tibet zone packet survives check:packet-offline 2026-10-01 10:52:55 +00:00
Doxa Maps
81fd6dc7d5 Landing precondition: a library change's freshness scope includes shipped bundles the manifest does not list (template-bundle) 2026-10-01 10:52:55 +00:00
Doxa Maps
3901bf219f LegendMobile: the card image holds its seat through the snap to full open, and the reduced-motion rule names its fade 2026-10-01 10:50:17 +00:00
Doxa Maps
bf3c221926 P: rebuild 3 bundle(s) from the merged source, and regenerate the tree and SRI 2026-10-01 10:46:26 +00:00
Doxa Maps
1df3f92d0d Merge origin/development (592397194) into studio/bible-push 2026-10-01 10:45:55 +00:00
Doxa Maps
3b32c09617 Merge origin/development into gospel/lm-image-seat 2026-10-01 10:41:57 +00:00
Doxa Maps
65f3102ce4 Merge remote-tracking branch 'origin/development' into poster-design-plugin/b3-land 2026-10-01 10:38:55 +00:00
Doxa Maps
5923971946 Embed guide: the Nuxt form of the language listener, and carrying the open tab across a locale route 2026-10-01 10:34:51 +00:00
Doxa Maps
4e5a9646e0 P: rebuild 3 bundle(s) from the merged source, and regenerate the tree and SRI 2026-10-01 10:27:21 +00:00
Doxa Maps
35742337fc Merge origin/development (6f9ab5498) into studio/bible-push 2026-10-01 10:26:54 +00:00
Doxa Maps
aabaff9c28 Merge origin/development into gospel/lm-image-seat
# Conflicts:
#	public/dx-maps/country-posters/countries-map/index.html
#	public/dx-maps/country-posters/country-poster-wall/index.html
#	public/dx-maps/country-posters/country-posters-mfe/index.html
#	public/dx-maps/country-posters/country-posters.js
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/doxa-research-map/doxa-research-map.js
#	public/dx-maps/doxa-research-map/research-map/index.html
#	public/dx-maps/doxa-research-map/research-map/maplibre/index.html
#	public/dx-maps/manifest.json
#	public/dx-maps/my-upg-100-list/my-upg-100-list.js
#	public/dx-maps/my-upg-100-list/poster-wall/index.html
#	public/dx-maps/my-upg-100-list/upg-100-list/index.html
#	public/dx-maps/my-upg-100-list/upg-100-mfe/index.html
2026-10-01 10:23:59 +00:00
Doxa Maps
abdc5dfe61 Merge remote-tracking branch 'origin/development' into deep-research-plugin/lang-menu2 2026-10-01 10:22:23 +00:00
Doxa Maps
952d3c0d7e Rebuild poster-design for the Sizes tab, the Styles thumbnails and the rounded Share 2026-10-01 10:19:40 +00:00
Doxa Maps
fd43836920 Poster Design: Styles draws the Bless wall panels from the shared panel geometry
wall.bless-panels drew nothing. It now draws the ladder's 5 x 8 ft size
(60x96: three 60 x 36 in sheets, 6 in joins) through panelSheets'
planPanels and screenLayout, the same plan the Bless print uses: the
sheets pulled apart, each join a red glue band with a dashed edge, a
banner on the first sheet, a footer on the last, and rows of portrait
cards inside each sheet's content box, never crossing a join.

The cards are a layout (src/panelsSvg.js): the Bless wall draws its real
cards in its own plugin, which the gallery cannot import. The card is not
offered for printing here.
2026-10-01 10:18:56 +00:00
Doxa Maps
02ba16a7de i18n posterDesign: the Bless panels note says what is drawn and what is a layout
The Styles tab now draws the Bless wall panels from the shared panel
geometry, so missing.bless no longer says the gallery cannot draw them.
It says the sheets, the glue strips and the card rows come from the
shared geometry, and the cards are shown as a layout because the Bless
wall draws its cards in its own plugin.
2026-10-01 10:18:55 +00:00
Doxa Maps
6164cb6190 Poster Design: Styles draws the prayer packet and every card stock
The seven templates that showed 'preview unavailable' for the packet and
the cards now draw from the shared renderers with the Nepal sample:
- each card stock is the deck the wall prints (createCardRenderer with
  that stock, buildDeck), its first card the thumbnail;
- each packet variant is the packet renderer's page 1, its map page,
  built from the sample's map-only sheet as the wall's packet builds it.

src/pageThumb.js turns a document's first page into an image: it keeps
that page, re-serialises it as XHTML inside an SVG foreignObject and
returns it as an SVG image, which a plain <img> draws. Photos are inlined
only from the page's own origin; the people-group photo host sends no CORS
headers, so a card's photo frame stays empty in the thumbnail (the printed
deck still carries the photo), and no refused fetch reaches the console.

A card stock's print button prints its whole deck. A packet card is not
offered for printing here, since only its page 1 is built.
2026-10-01 10:18:55 +00:00
Doxa Maps
6341be6070 i18n posterDesign: drop the two 'cannot draw yet' notes the packet and the cards no longer need
The Styles tab now draws the prayer packet's page 1 and every card stock,
so missing.packet and missing.cards are referenced nowhere. Only the notes
for the two templates still not drawn (the Bless panels, the map print)
remain.
2026-10-01 10:18:32 +00:00
Doxa Maps
828fd22e86 Poster Design: the Sizes tab draws every ladder size to scale; the header's Share is a rounded square
Sizes reads the one shared ladder (POSTER_LADDER in POSTER_LADDER_ORDER)
and feeds each size's sheets straight into the library's PanelSheets, the
component the Wall uses for its 5 x 8 ft preview: multi-sheet sizes show
their sheets pulled apart with the red glue strip. A lineup draws every
size at one scale on one baseline, its scale fitted to the row's width so
a phone keeps every size in view. Each card also states the sheets, the
glued overlap, whether photos fit beside names, and how it prints.

The sheets carry the 3 x 3 ft country poster's layout at that size
(src/layoutSvg.js: header, map, names, table, scripture footer, no words),
labelled as a layout, since the renderer does not draw the large sizes
yet. Every size is sized from panelSheets' own screenLayout, so the fit
computed here is the box PanelSheets renders.

The composite header's Share button takes the header form
(shape="rounded-square"), as Big Picture's top bar does.
2026-10-01 10:18:32 +00:00
Doxa Maps
29227cb6d3 i18n posterDesign: the Sizes tab's words, in all 11 locales
The Sizes tab now draws every ladder size, so it needs its own words: the
intro, the to-scale lineup, the paper names (Letter / A4), the dimension
patterns in feet and inches, the per-size rows (sheets, each sheet, glued
overlap, photos beside names, how it prints) and the note that the sheets
show the 3 x 3 layout. The placeholder sentence that said the tab was
waiting for the size table is removed. Only the posterDesign area changes.
2026-10-01 10:18:32 +00:00
Doxa Maps
ec32bf3c34 Rebuild doxa-maps-spa for its language menu, and regenerate the tree 2026-10-01 10:08:46 +00:00
Doxa Maps
840c373e8f Rebuild the five LegendMobile bundles for the seated card image, and regenerate the tree 2026-10-01 10:08:20 +00:00
Doxa Maps
ccc3a038ea Bible Posters: the profile says what it is (its @description), and the capability exemplars list it 2026-10-01 10:08:05 +00:00
Doxa Maps
8b83e97449 P: rebuild 3 bundle(s) from the merged source, and regenerate the tree and SRI 2026-10-01 10:02:51 +00:00
Doxa Maps
d6a92665af Merge origin/development into gospel/lm-image-seat 2026-10-01 10:02:21 +00:00
Doxa Maps
929d35f17f Research Gallery: the language menu in the phone bar and at the bottom of the rail
The gallery fills the tab layout's two new slots (the end of the phone bar, and the bottom of the desktop
rail) with the shared ShellLanguageMenu, showing the language in effect. The menu first asks the host with a
cancelable doxa-language-request. A host that takes it changes the language itself and sets `language`.
Otherwise the gallery switches on its own, and that pick holds over a `language` the host never updates, until
the host changes it. A saved choice never outranks the host page's own language. EMBED.md shows the listener.
2026-10-01 10:02:16 +00:00
Doxa Maps
56d9a111f6 Merge origin/development (a77a3280f) into studio/bible-push 2026-10-01 10:02:11 +00:00
Doxa Maps
b32ccdc832 Bible push: merge 0ab6ca169 2026-10-01 09:08:47 +00:00
Doxa Maps
245eea9cb7 Bible push: merge 12e2ebafa 2026-10-01 09:08:47 +00:00
Doxa Maps
c0eafc4e10 Bible push: merge 0a4e53993 2026-10-01 09:08:46 +00:00
Doxa Maps
0a4e539935 Bible Posters: consume the shared poster size table
Replace the temporary local size mirror with the shared ladder
(library/constants/posterSizes.js via resolvePosterSize): the plugin selects its
five keys (paper, 36x36, 36x60, 66x66, 60x96), mapping widthIn/heightIn from the
entry's inches and the panel count from its sheets, and keeps its own
locale-neutral dimension labels until the shared picker lands. One remaining
comment reference is neutralized.
2026-10-01 08:52:21 +00:00
Doxa Maps
2da7f15ec7 Bible Posters: neutral source comments + CSP note for the text host
- Reword source comments in agent-written, neutral terms and drop internal task-file
  paths, so shipped source cites no process documents or attributions (passes
  check:no-new-driver-references).
- documentation/how-to.md: note that Bible Posters needs https://bible.helloao.org in
  connect-src (text is fetched per book at render time; none is bundled), so a host
  under a strict CSP does not silently block it.
2026-10-01 08:44:14 +00:00
Doxa Maps
cdf0df268d Bible Posters: language-tagged columns, atomic book fetch, cleaner credits
Address studio's 5B nits (non-blocking, folded in before the first render):
- Tag each translation with a BCP-47 code and set lang on the poster (html + body)
  so hyphens:auto works in narrow justified columns — removes rivers on Latin text.
- Fetch a whole book atomically: a single chapter failure now aborts the sibling
  requests and surfaces the error, instead of leaving a half book.
- Drop the English word "via" from the per-poster credit lines (a bare separator).
- Correct the entry comment: defineMapElement installs i18n; the hook only adds provides.
2026-10-01 08:44:14 +00:00
Doxa Maps
5c44b3cdcd Add Bible Posters plugin: a whole book of the Bible on one poster
A new experimental generator (Posterverse) that renders an ENTIRE BOOK of the Bible
in justified, phone-width columns with a bold book-name header, in the languages of
the unreached people groups. Text is fetched at render time from public-domain
translations via the Free Use Bible API; no Bibles are stored in the repo or public/.

v1 offers strict public-domain texts only, with a per-poster credit line: the World
English Bible plus the prayer packet's Reina-Valera 1909, Ostervald, Luther 1912,
Synodal and Van Dyck (Arabic, right-to-left). Book, text-language and size pickers;
the generator reports which size fits a book at a readable type size, and standard
paper is honestly shown as too small for a whole book. Interface strings are
localized in all 11 locales. Starts from the 12 books the UPG-100 packet cites.
2026-10-01 08:44:14 +00:00
Doxa Maps
7d67757961 Merge origin/development into gospel/lm-image-seat 2026-10-01 08:39:57 +00:00
Doxa Maps
1e925c4d71 Gospel checks: the field-card shards are expected from the roster, not read as one file, and German 'Ring' is the same word on purpose 2026-10-01 08:37:17 +00:00
Doxa Maps
b8cf0db0c8 Merge remote-tracking branch 'origin/development' into upg100/pg-names
# Conflicts:
#	package.json
2026-10-01 08:35:25 +00:00
Doxa Maps
2bcf371f72 Wall of People Groups: the cornerstone line stays on the poster, as a dedication under the title
The footer is scripture only (the journey and Numbers 6:24). The poster's one line of its own words, "Jesus
Christ is the cornerstone, and we are living stones. These people groups are the reward of his suffering.
Take up our priestly calling: bless them in his name, and join the global family in prayer.", now sits in
the header, in small italic under the DOXA line, translated as before (wall.posterClosing). The header
gains one line; the title and the DOXA line keep their size. Measured on the print at every size in en
and ar: 0 overflowing blocks, the dedication included.
2026-10-01 07:47:28 +00:00
Doxa Maps
699d77167d Wall of People Groups, Social: the sign-up link opens in the reader's language
pray.doxa.life serves each group's page under /<lc>/ for ar, de, es, fr, hi, it, pt, ro, ru and zh (read
from its routes; /ar/abu-junuk and /ro/abu-junuk answer 200, and the Arabic page carries the same
#signup-section), and English unprefixed. The heart dialog's "Sign up to pray" link now takes the wall's
locale prefix, so an Arabic reader lands on the Arabic sign-up (measured: ar ->
https://pray.doxa.life/ar/abu-junuk#signup-section, en -> https://pray.doxa.life/abu-junuk#signup-section).
2026-10-01 07:33:34 +00:00
Doxa Maps
cdbda65a06 People-group names: the threshold pinned, no trust without Latin evidence, raw indexing flagged
Folds in the review's four points:
(a) check:pg-name-gate plants a row with EXACTLY two far Latin locales, which must
    show English, so a rule that needs three fails.
(b) A row with fewer than two Latin names cannot be checked (117 rows), so it now
    shows English too. The whole table: 8,172 of 21,419 (38.2%).
(c) The one-reader scan also flags a file that imports the helper but still
    indexes rows itself (`.names[`), with negative controls both ways.
(d) The helper documents that callers always pass `english`.
Seven mutants of the helper each fail the gate.
2026-10-01 06:04:41 +00:00
Doxa Maps
6e80da212c People-group names: one helper decides when a localized name can be shown
library/utils/peopleGroupName.js (pure) returns a group's name in the reader's
language only when the row's names are transliterations. When two or more of its
Latin-script names (fr pt es tl id de) are far from the English one (bigram Dice
< 0.5), the row was machine-translated as words (Balantak → "Balance",
Balo → "Sac à dos"), and every locale of that group shows English. That is
8,055 of 21,419 rows (37.6%). it and ro have no column and show English; zh
reads zh_CN. A fallback shows the caller's own English name.

check:pg-name-gate (gates:pre) proves it on the real table: the 4 known
translated rows show English in 7 locales, Balangao Bontoc keeps its names,
planted rows are two-sided, the whole-table fallback stays between 30% and 45%,
and no file outside the helper reads pg-names rows (pplr-data-maps is the
exception; the scanner is negative-controlled). Five mutants of the helper each
fail it. No consumer is wired yet.
2026-10-01 05:52:31 +00:00
Doxa Maps
12e2ebafab Rebuild posterverse for the confirmed Bible Posters row
BUNDLE=posterverse vite build only. The tree is regenerated once on the merged
tree that also carries the bible-posters bundle (generate-tree refuses the new
composes ref until that bundle is present).
2026-10-01 05:47:33 +00:00
Doxa Maps
ac108b5117 Posterverse: Bible Posters' ids are confirmed, and composes declares it
bible-posters/bible-posters is the id the Bible Posters plugin ships, so its
Experimental row is no longer provisional, and posterverse.meta.json composes
it. Lands in the SAME push as the bible-posters bundle: without it the ref is
unresolved (check:capability-derived), and with the bundle but without this
commit check:posterverse-shelf fails arm D.
2026-10-01 05:47:24 +00:00
Doxa Maps
192c652ad4 Wall of People Groups, Social: comments say "coming soon", and the heart links to the group's prayer sign-up
- COMMENT (and "View prayers") showed an empty comments board; it now says "Comments are coming soon."
  The unreachable CommentsDialog is removed.
- HEART: "Yes, I'm praying" still stores the prayer in this browser (localStorage, wall-pg:prayers), and the
  dialog now links "Sign up to pray for <group>" to the group's sign-up on pray.doxa.life
  (https://pray.doxa.life/<slug>#signup-section, the page's own sign-up section), in a new tab.
- The prayer dialog scrolls when it is taller than the window: it clipped at 90vh with overflow hidden,
  so on a laptop everything under the photo could not be reached. The photo now gives way (at most 38vh),
  so the question, "Yes, I'm praying" and the sign-up link all show at 1440 x 900.
- SHARE already shares the group's pray.doxa.life page (unchanged).

The two sentences live in a NEW area, wallSocial (11 locales), which only this bundle names (0 other
bundles carry it). Exercised at 1440 in en and ar: the toast and the link (href, new tab, noopener).
2026-10-01 05:44:40 +00:00
Doxa Maps
b61cc36492 Wall of People Groups: the poster closes on scripture, "The LORD bless you, and keep you.", across the full width
The footer is now scripture only:
- the seven passages of the journey (Abraham to the church), unchanged;
- a full-width rule, the same as the header's;
- Numbers 6:24 from the World English Bible British Edition (public domain; verified at the source),
  set as its two clauses, "The LORD bless you," / "and keep you.", filling the footer's width;
- its reference, and a credit line naming both public-domain translations on the poster.

Removed: the long Numbers 6:22-27 block, and the closing sentence (it was our own words, not scripture).
The card rows now spread their spare height between them, so the photos fill each sheet.

BENEDICTION_FILL is the one number per size that sets how much of the width the verse fills (1 = all of
it). Measured on the rendered print, the longer clause fills 98.4% of the text width in en and ar at every
size, with nothing overflowing (the em constant comes from that measurement). At 5 x 8 ft a full-width
verse costs the per-card verses: the cards go from 1.04 to 0.91 in, with one verse per row; 0.3 there keeps
a verse under every card.
2026-10-01 05:39:57 +00:00
Doxa Maps
0ab6ca1695 Research Gallery: Bible Posters stays off the Experimental shelf
Bible Posters lives on Posterverse's Experimental shelf, so the gallery excludes it, as it already excludes
Posterverse itself. One poster plugin, shown once.
2026-09-30 22:59:45 +00:00
Doxa Maps
0b3600b473 LegendMobile: the people-group image stays seated while the sheet is dragged, and fades in and out 2026-09-30 12:46:08 +00:00
692 changed files with 30761 additions and 6084 deletions

253
README.md
View file

@ -26,7 +26,7 @@ Made by Vibe Coders for Vibe Coders to prepare the way of the Lord and make His
relational-stewardship training (bounded and centered sets).
- **What each map is for:**
- **Big Picture:** the library's own interface, the one agents use to build maps.
- **UPG-100:** the most strategic. It divides the task into prayer packets.
- **UPG100 List:** the most strategic. It divides the task into prayer packets.
- **Gospel maps:** the resources that exist in each language.
- **UPG Neighbor:** the reached neighbors near each unreached group.
- **Gospel × Internet:** all of it, together. Built for linguists, catalysts, translators (human
@ -37,8 +37,9 @@ Built with love, with the prayer that these maps bless the nations.
**A blessing for you.** These maps and posters are given to bless the people who bless the nations.
We love you, and we are thankful for your work. We bless you in Jesus' name.
Every poster is in Posterverse (`plugin-designs/posterverse/`): the UPG 100 List, Country Posters,
Poster Design and Wall of People Groups. They are all in the Plugin Studio (`bun run dev`) too.
Posterverse (`plugin-designs/posterverse/`) brings the six poster plugins together: UPG100 Posters, Country
Posters, Poster Design, Wall of People Groups, Bible Posters and Bless India Posters. They are all in the
Plugin Studio (`bun run dev`) too.
# dx-maps — one build, maps that embed everywhere
@ -60,7 +61,7 @@ capabilities, three outputs, one shared library, one build.
| 🔗 **Maps that talk to each other** | Maps share props and events, so one app can nest them and a click in one can move another. |
| 🪶 **Small bundles** | Each map's file carries only the parts of the shared library it uses (tree-shaken). |
| ⚙️ **One build step** | `bun run build` makes every map at once. |
| 📦 **Three outputs** | A web folder (iframe, script embed), a mobile app, a desktop app — [the 3 outputs](#what-the-build-makes--3-outputs). |
| 📦 **Three outputs** | A web folder (iframe, script embed), a mobile app, and a desktop app built from the same code you run in the browser — [the 3 outputs](#what-the-build-makes--3-outputs). |
| What dx-maps set out to do | Proof |
|---|---|
@ -70,7 +71,7 @@ capabilities, three outputs, one shared library, one build.
| **One app nests every map** — and each stays embeddable on its own | [capability 5](#capability-5--app-shell-the-research-gallery) · `bun run check:composite-reg` |
| **5 capabilities, every one shippable in every output** | `bun run verify:5-caps` |
| **Shared library, private plugins** — reuse without breaking each other | `bun run check:library-index` |
| **One owner per plugin, a second reader for every change, one landing at a time** | [agent swarm](#build-it-with-an-agent-swarm) |
| **One agent per plugin, the repo agent reads every change, one merge at a time** | [agent swarm](#build-it-with-an-agent-swarm) |
## For vibe coders: build, open the Studio, embed a map
@ -119,15 +120,15 @@ people groups of the world. When you work here, the goal is one of these:
| **Reach a city** | Regions (`regions-dashboard`) and the country map (`country-map`): the people groups of each country. The production maps work by country and region; city-level planning (Prayer Walk Planner) is still experimental |
| **Mobilize for people groups** | Big Picture (`dashboard-plus-grid`); Simple Research (`research-map-plus-grid`) |
| **Plan outreach** | Gospel Resources (`gospel-resources`): what exists in each language; Cities with Internet (`cities-internet`): whether a group can be reached online |
| **Pray: packets and prayer resources** | UPG 100 List (`my-upg-100-list`), Regions and Simple Research: posters and prayer packets; Prayer Progress (`doxa-simple-map`) |
| **Pray: packets and prayer resources** | UPG100 Posters (`my-upg-100-list`), Regions and Simple Research: posters and prayer packets; Prayer Progress (`doxa-simple-map`) |
- **Start with the skill:** [`skills/plugin-designer`](./skills/plugin-designer) teaches the patterns, the shared
library and the checks.
- **One plugin per folder** in `plugin-designs/`; shared code goes in `library/` only if it stays reusable for
every other plugin.
- **Land on `development`**, one change at a time through the lock, with `bun run gates:pre` passing. `main` is
- **Join `development`** (the pilot), one change at a time through the lock, with `gates:pre`, `gates:post` and the landing checks passing. `main` is
production and moves only on a publish.
- These 8 maps are in production: the 7 in the gallery, plus the country map for DX Life's country pages. The
- These 8 maps are in production: the 7 production maps in the gallery, plus the country map for DX Life's country pages. The
other maps are experimental: leave them as they are unless your task is one
of them.
@ -135,11 +136,12 @@ people groups of the world. When you work here, the goal is one of these:
---
## The 7 production maps
## The production maps
**Seven maps DX uses today** — each one a plugin you can embed on its own, and all seven together in
the Research Gallery, which DX Life publishes as **Deep Research**. An eighth production map, the country map
(`country-map`), draws DX Life's country pages; it isn't in the gallery.
**Seven production maps DX uses today, all together in the Research Gallery**, which DX Life publishes
as **Deep Research**; each one is also a plugin you can embed on its own. The gallery also carries two
poster plugins still marked experimental, Country Posters and Poster Design (see Posterverse). An eighth
production map, the country map (`country-map`), draws DX Life's country pages; it isn't in the gallery.
<table>
<tr>
@ -149,10 +151,10 @@ the Research Gallery, which DX Life publishes as **Deep Research**. An eighth pr
<td width="25%" valign="top"><img src="documentation/readme-images/cap2-prayer.png" alt="Prayer Progress"><br><b>Prayer Progress</b><br><sub>where prayer is happening, and where it is still needed</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><img src="documentation/readme-images/cap4-upg100-mfe.png" alt="UPG-100 List"><br><b>UPG-100 List</b><br><sub>prayer lists of 100 people groups, with posters to print</sub></td>
<td width="25%" valign="top"><img src="documentation/readme-images/cap4-upg100-mfe.png" alt="UPG100 Posters"><br><b>UPG100 Posters</b><br><sub>prayer lists of 100 people groups, with posters to print</sub></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-cities-internet.png" alt="Cities with Internet"><br><b>Cities with Internet</b><br><sub>internet access where unreached people groups live</sub></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-gospel-resources.png" alt="Gospel Resources"><br><b>Gospel Resources</b><br><sub>which people groups have scripture, film or audio — and which have nothing</sub></td>
<td width="25%" valign="top"><img src="documentation/readme-images/cap5-doxa-maps-spa.png" alt="Research Gallery"><br><b>Research Gallery</b><br><sub>all seven in one app — capability 5</sub></td>
<td width="25%" valign="top"><img src="documentation/readme-images/cap5-doxa-maps-spa.png" alt="Research Gallery"><br><b>Research Gallery</b><br><sub>all nine in one app — capability 5</sub></td>
</tr>
</table>
@ -163,7 +165,7 @@ the Research Gallery, which DX Life publishes as **Deep Research**. An eighth pr
<td width="25%" valign="top"><img src="documentation/readme-images/prod-simple-research-phone.png" alt="Simple Research on a phone"><br><b>Simple Research</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-regions-phone.png" alt="Regions on a phone"><br><b>Regions</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-prayer-phone.png" alt="Prayer Progress on a phone"><br><b>Prayer Progress</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="UPG-100 List on a phone"><br><b>UPG-100 List</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="UPG100 Posters on a phone"><br><b>UPG100 Posters</b></td>
</tr>
<tr>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-cities-internet-phone.png" alt="Cities with Internet on a phone"><br><b>Cities with Internet</b></td>
@ -173,12 +175,36 @@ the Research Gallery, which DX Life publishes as **Deep Research**. An eighth pr
</tr>
</table>
### Print your first poster
1. **Open a poster wall:** *UPG100 Posters*, *Country Posters*, or *Regions* (its **View country posters**
button). Every card on the wall has a **Print Options** strip. India's whole-country poster isn't
available yet; its state posters are.
2. **Pick what to print:** **Poster Map** (the poster), **Prayer List** (choose **Full detail** or
**Lightweight**) or **Card Deck** (choose a card size). On *UPG100 Posters*, clicking the poster
picture prints it too.
3. **Your browser's print window opens, and it is the preview.** Choose the printer and print. Nothing
is downloaded; to keep a file, choose **Save as PDF** instead of a printer.
Posters, lists and packets print on Letter-size pages (8.5 × 11 in). Cards print at their own size:
2.5 × 3.5 in, 4 × 6 in or A6.
**Bigger posters.** *Bible Posters* and the *Wall of People Groups* also offer bigger sizes, up to 5 × 8
ft, for a print shop: in the print window choose **Save as PDF** and send the file. The Wall's 5 × 8 ft
poster prints as 3 sheets, one per page, stacked so that each overlaps the next by 6 in; every sheet is
numbered (1 / 3, 2 / 3 …) and marked **glue here** where the next one goes.
**On a phone.** The same print window rises over the page; choose **Save as PDF** to keep the file or
send it on. A phone prints page sizes only (Letter or A4 paper), and the largest poster sizes are
switched off there. A few prints say they need a computer: *Bless India Posters*, the *Wall of People
Groups* and a selection of more than 1,000 groups on *UPG100 Posters*.
### Every capability ships every way
| Capability | Example | iframe | Mobile app | JavaScript embed | WordPress / Disciple Tools | Desktop |
|---|---|:-:|:-:|:-:|:-:|:-:|
| **5 — app shell** | Research Gallery | ✅ | ✅ | ✅ | 🧪 | ✅ |
| **4 — composite** | UPG-100 List | ✅ | ✅ | ✅ | 🧪 | ✅ |
| **4 — composite** | UPG100 Posters | ✅ | ✅ | ✅ | 🧪 | ✅ |
| **3 — multi-view** | research map | ✅ | ✅ | ✅ | 🧪 | ✅ |
| **2 — presets** | Prayer Progress | ✅ | ✅ | ✅ | 🧪 | ✅ |
| **1 — single element** | all people groups | ✅ | ✅ | ✅ | 🧪 | ✅ |
@ -235,10 +261,9 @@ If you build with AI agents, this repo is made for you:
updating the moment you save.
- **Speak instead of typing.** Rudder, an offline voice-to-text tool, sits as a corner widget in the
Studio: look at a plugin, say what you see, and hand the text to your agent. Nothing leaves your
computer (Linux today).
- **One plugin, several outputs.** The same plugin ships as a web embed or a mobile app, and a plugin
can target the desktop: Rudder is the desktop example, a corner widget in the Studio and its own
desktop app (`bun run desktop:build rudder-voice`, see [HOWTO-build-the-desktop-app](./documentation/HOWTO-build-the-desktop-app.md)).
computer. It also runs as its own desktop app.
- **One plugin, several outputs.** The same code ships as a web embed, a mobile app or a desktop app
([the desktop app](#output-3--the-desktop-app--works-today)).
- **Build together.** [Run it in the cloud](#run-it-in-the-cloud) gives a whole team one shared dev
server.
@ -251,8 +276,8 @@ embed just the one you need.
|---|---|---|
| a **translator** (or an AI translation pipeline) | Gospel Resources (map, list, data tables) | which people groups have no Scripture, film or audio in their language, and which bridge language already reaches them |
| **media to movements** | Cities with Internet · Gospel × Internet | where unreached people groups live with internet access, country by country and down to province where data exists |
| a **catalyst** or mobilizer | Big Picture · Prayer Walk Planner · UPG 100 List · Gospel × Internet | find **gospel resources** for the people you are reaching real time, build prayer walking maps for real outreach, as you prayer walk each key zone in your cities of choice, find indigenous disciples, groups, and leaders mobilize them each to have prayer packets, cards and posters to blanket all the people groups in prayer as a prayer mobilizer catalyst |
| **praying** | Prayer Progress · UPG-100 List | where prayer is covered and where it is still needed; print a **Prayer List**, **Poster Map** or **Card Deck** for a list of 100 |
| a **catalyst** or mobilizer | Big Picture · Prayer Walk Planner · UPG100 Posters · Gospel × Internet | find **gospel resources** for the people you are reaching real time, build prayer walking maps for real outreach, as you prayer walk each key zone in your cities of choice, find indigenous disciples, groups, and leaders mobilize them each to have prayer packets, cards and posters to blanket all the people groups in prayer as a prayer mobilizer catalyst |
| **praying** | Prayer Progress · UPG100 Posters | where prayer is covered and where it is still needed; print a **Prayer List**, **Poster Map** or **Card Deck** for a list of 100 |
| a **teacher** or presenter | the Research Gallery | every map in one app, multiple languages support |
<details>
@ -302,7 +327,7 @@ improvement on the next build.
</details>
<details>
<summary><b>What the Research Gallery is made of — seven production maps, an Experimental shelf, all five capabilities in one plugin</b></summary>
<summary><b>What the Research Gallery is made of — seven production maps, two poster plugins, an Experimental shelf, all five capabilities in one plugin</b></summary>
```text
Research Gallery <doxa-maps-spa> capability 5
@ -317,10 +342,12 @@ Research Gallery <doxa-maps-spa> capability 5
│ ├─ regions list regions-list capability 1
│ └─ region dashboards region-dashboards capability 1
├─ Prayer Mobilization
│ ├─ UPG-100 List upg-100-mfe capability 4
│ │ ├─ list upg-100-list capability 1
│ ├─ Prayer Progress doxa-simple-map capability 2
│ ├─ UPG100 Posters upg-100-mfe capability 4
│ │ ├─ zoned map upg-100-list capability 1
│ │ └─ poster wall poster-wall capability 1
│ └─ Prayer Progress doxa-simple-map capability 2
│ ├─ Country Posters country-posters-mfe capability 4
│ └─ Poster Design poster-design-mfe capability 4
├─ Gospel Research
│ ├─ Cities with Internet connectivity-map capability 1
│ └─ Gospel Resources resources-map capability 1
@ -328,7 +355,6 @@ Research Gallery <doxa-maps-spa> capability 5
├─ prayer walk planner prayer-walk-planner capability 1
├─ UPG neighbor upg-neighbor capability 3
├─ all people groups all-people-groups capability 1
├─ country posters country-posters capability 4
├─ vision card doxa-vision-card capability 2
├─ PPLR data maps pplr-data-maps capability 3
└─ Wall of People Groups wall-of-people-groups capability 3
@ -340,11 +366,11 @@ Research Gallery <doxa-maps-spa> capability 5
**Several plugins glued into one**, talking to each other — and each piece still embeddable alone.
![Capability 4 — the UPG-100 composite (my-upg-100-list)](documentation/readme-images/cap4-upg100-mfe.png)
![Capability 4 — the UPG100 Posters composite (my-upg-100-list)](documentation/readme-images/cap4-upg100-mfe.png)
| List (capability 1, embeddable alone) | Poster wall (capability 1, embeddable alone) |
|---|---|
| ![UPG-100 list leaf](documentation/readme-images/cap4-upg100-list.png) | ![UPG-100 poster-wall leaf](documentation/readme-images/cap4-upg100-poster-wall.png) |
| ![UPG100 Posters zoned-map leaf](documentation/readme-images/cap4-upg100-list.png) | ![UPG100 Posters poster-wall leaf](documentation/readme-images/cap4-upg100-poster-wall.png) |
<details>
<summary><b>More about capability 4 — composites</b></summary>
@ -354,7 +380,7 @@ as views you switch between — and ships the set as **one new element**. The pi
**props** (settings passed in) and **events** (messages sent out): click a region in one piece and
another piece updates. Nothing is copied, so each piece can still be embedded on its own.
- **Example:** `plugin-designs/my-upg-100-list/` — the UPG-100 list and its poster wall, joined into
- **Example:** `plugin-designs/my-upg-100-list/` — the UPG100 Posters zoned map and its poster wall, joined into
`upg-100-mfe`. Big Picture, Simple Research and Regions are composites too.
- **How to declare one:** a `<name>.meta.json` beside it with `"type": "nested-elements-mfe"` and a
`"composes"` list of the plugins inside.
@ -474,7 +500,7 @@ files in the repo. The `Dockerfile` and `compose.yaml` sit at the repo root; rec
**Plugin Studio: the public Studio, with PocketBase.** The highest route: the same container with
`ACCESS=public`, on your research subdomain.
- The Plugin Studio follows `development` and is open to everyone, so what agents land shows up live.
- The Plugin Studio follows `development` and is open to everyone, so what agents add to the pilot shows up live.
- Its `/cdn`, built from `main`, serves your site's production maps.
- Publishing and PocketBase's dashboard ask for a password you set when you deploy; OIDC stays available.
@ -572,13 +598,13 @@ installed.
**The same maps, wrapped as a phone app.** No new code — the maps are JAMstack: they fetch their data
from a remote API, which is exactly what a mobile app needs.
<p align="center"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="Mobile app — the UPG-100 list at phone size" width="320"></p>
<p align="center"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="Mobile app — UPG100 Posters at phone size" width="320"></p>
<details>
<summary><b>More about the mobile app</b></summary>
[Capacitor](https://capacitorjs.com) wraps the built folder into an Android app. Shown here: the
UPG-100 prayer lists and posters at phone size, each poster with its own print options.
UPG100 prayer lists and posters at phone size, each poster with its own print options.
```bash
bun run check:mobile # checks the app is wired up correctly — needs no Android tools
@ -591,21 +617,38 @@ bun run phone # builds the app and installs it on a phone or emulator
### Output 3 — the desktop app ✅ works today
**We got the JAMstack map app running as a desktop app.**
![Desktop app (Tauri), working](documentation/readme-images/deploy5-desktop.png)
**Rudder, the voice-to-text plugin, runs as its own desktop app too**, built with
`bun run desktop:build rudder-voice` ([HOWTO-build-the-desktop-app](./documentation/HOWTO-build-the-desktop-app.md)).
**Run it in your browser, then ship the same code as a desktop app.** [Tauri](https://tauri.app) wraps
it in a small Rust program that runs on your computer. Rudder, the offline voice-to-text plugin, is the
proof: its Linux desktop app works and is used to dictate the work on many of these maps. Its goal is
private voice-to-text that you own: offline, no telemetry, no cloud.
![Rudder, the offline voice-to-text desktop app, running](documentation/readme-images/deploy5-desktop-rudder.png)
<details>
<summary><b>More about the desktop app</b></summary>
<summary><b>More: how it works, Rudder's goal, and what isn't run yet</b></summary>
[Tauri](https://tauri.app) wraps the same built folder into a desktop program: it produced a Linux
app and a `.deb` installer. It isn't in the dx bundler for production yet — and the Research Gallery
could become a strong desktop app.
**How it works.** What you build and try on the development server (`bun run dev`) is what ships to
the desktop; there is no second code base. Rudder runs as a corner widget in the Plugin Studio and as
its own desktop app, built with `bun run desktop:build rudder-voice`
([HOWTO-build-the-desktop-app](./documentation/HOWTO-build-the-desktop-app.md)): look at a plugin, say
what you see, and hand the text to an agent. The map app runs as a desktop app the same way.
**Rudder's goal, from the start: private voice-to-text that you own.**
- **Offline, with no telemetry and no cloud.** Your own code and your own self-hosted speech model
(NVIDIA's open NeMo-Speech.cpp runtime and a small speech model, about 742 MB) run on a modest amount
of your computer's CPU. After a one-time download of the runtime and the model, nothing needs the
network; a trace of the speech runtime showed zero sockets and zero DNS lookups. The aim is that it
works out of the box: today the first-run download is still a manual step.
- **Be careful who commits to Rudder.** A voice-to-text tool hears everything said near it, so one
bad change could turn it into spyware. Take changes to `plugin-designs/rudder-voice/` and
`backend/rudder-engine/` only from people you trust, and have a second person read every one.
- **Planned: an egress monitor** that shows you, as it runs, that Rudder sends no data out to anyone.
**What isn't run yet.** Tauri builds a Linux app and a `.deb` installer today. The macOS and Windows
builds are set up but have not been run ([the HOWTO](./documentation/HOWTO-build-the-desktop-app.md)
says which steps are proven). The desktop build runs on demand with the commands below; the automatic
production build doesn't make it yet. The Research Gallery could become a strong desktop app too.
```bash
bun run check:tauri # checks the desktop setup — needs no Rust tools
@ -655,9 +698,9 @@ with the bundle. `bun run check:sri` keeps the manifest and the built pages in s
**Every map speaks 11 languages** — add `?lang=<code>` to a map's page address, or, when a map is embedded in
your own page, set `"locale"` in its `profile-config`. Arabic reads right to left.
![UPG-100 List in Arabic, right to left](documentation/readme-images/lang-arabic-upg100.png)
![UPG100 Posters in Arabic, right to left](documentation/readme-images/lang-arabic-upg100.png)
<sub>UPG-100 List with `?lang=ar`: the interface is in Arabic; people-group and place names come from the data.</sub>
<sub>UPG100 Posters with `?lang=ar`: the interface is in Arabic; people-group and place names come from the data.</sub>
| Code | Language | | Code | Language |
|---|---|---|---|---|
@ -695,61 +738,106 @@ your own page, set `"locale"` in its `profile-config`. Arabic reads right to lef
## Build it with an agent swarm
**Many AI agents can build many plugins at once without breaking each other's work.** Every plugin
has one agent that owns it, and changes are added to `development`, the shared working version, one
at a time. `main` is the production version: it moves only when you publish a release.
has one agent that owns it, and the work moves through three kinds of branch:
- **The pilot branch (`development`)** is where reviewed PR branches come together. A pilot server
with hot reload follows it, so each change that joins can be seen there within about 15 seconds.
- **The landing branch (`main`)** is what ships. It moves only when a person approves a publish, and
the landing server rebuilds from it.
- **PR branches** hold one plugin or feature each. They start from the pilot branch, take in the latest
pilot by merging (never rebasing), and join it one at a time, after the checks pass and a second agent,
usually the repository's agent, has read the change.
**It scales by adding PR branches, not by sharing one.** Many people, each with several agents, can
work on many plugins at the same time: every plugin or feature gets its own PR branch, and each one
can be seen on the pilot server as soon as it joins the pilot.
**Bring your own harness.** dx-maps works with whatever AI coding agent or agent harness you already
use. The repo gives you the branch strategy below, the checks (`bun run gates:pre`) and the Plugin
Studio; it does not ship a harness. Roles such as a coordinator that puts the landings in order, or a
gate-keeper that reads each change before it lands, are jobs your harness gives to its agents.
Studio; it does not ship a harness. There are three roles: one agent per repository, which handles its
branches and watches the work being done in it; one agent per plugin; and one shepherd agent that all the
other agents talk to, which keeps order and puts the merges onto the pilot in order. Your harness gives
these jobs to its agents.
![The delegation model: development at the centre behind a landing lock, inside the Plugin Studio; eight plugins around it, each owned by one agent; a coordinator and a gate-keeper over the agents; below, how a change lands in four steps, and the three outputs](documentation/readme-images/delegation-model.png)
![The delegation model: the pilot branch (development) at the centre behind the pilot lock; publishing to the landing branch (main) and its outputs below it; inside the Plugin Studio; eight plugins around it, each owned by one agent; a shepherd and a repo agent over the plugin agents; below, how a change joins the pilot and then reaches the landing branch, in four steps, the three outputs, and how it scales: one PR branch per plugin, a self-hosted git service, and publishing to a public host only from the landing branch](documentation/readme-images/delegation-model.png)
<sub>[Open the diagram full size](documentation/readme-images/delegation-model.png): on a phone its small text needs it.</sub>
### Set up the branch strategy
1. **`main` is production.** It moves only when you publish a release (step 7).
2. **`development` is the shared working branch.** Create it once, from `main`:
1. **The landing branch (`main`) is what ships.** It moves only when a person approves a publish (step 7).
2. **The pilot branch (`development`) is the shared working branch.** Create it once, from `main`:
```bash
git fetch origin && git switch -c development origin/main && git push -u origin development
```
3. **One branch and worktree per plugin agent**, from `origin/development`, then install once in each:
3. **One PR branch and worktree per feature**, each started from the pilot branch, then install
once in each:
```bash
git worktree add -b regions/work ../dx-maps-regions origin/development
git worktree add -b upg100/work ../dx-maps-upg100 origin/development
cd ../dx-maps-regions && bun install --frozen-lockfile
```
4. **One preview server per agent, each on its own port:**
4. **One preview server per agent on your own machine, each on its own port** (in the cloud, the one
pilot server below takes this role):
```bash
bun x vite --port 5301 --strictPort # the next agent uses 5302, and so on
```
5. **Land one at a time, through a lock.** Catch up with `development`, run the checks, and push only
if nothing fails (`gates:pre` exits non-zero when a check fails, so the push never runs then):
5. **Join the pilot one at a time, through a lock that works like a merge queue.** First merge the
latest pilot into your PR branch (merge, never rebase) and fix any clash there. A second agent reads
the change, and looks at it on screen where it shows. Then, inside the lock, merge the pilot once more
in case it moved, run the checks (`gates:pre`, `gates:post`) and the landing checks (fresh bundles,
the tree, the integrity hashes, no conflict markers), and push only if nothing fails:
```bash
git fetch origin && git merge --no-edit origin/development # on your PR branch: the latest pilot
flock /tmp/dx-maps-development.lock sh -c '
git fetch origin && git rebase origin/development &&
bun run gates:pre && git push origin HEAD:development'
git fetch origin && git merge --no-edit origin/development &&
bun run gates:pre && bun run gates:post &&
node internal/landing-preconditions.mjs &&
git push origin HEAD:development'
```
`flock` guards one machine. Agents on several machines need a shared lock, such as one your
If the merge stops on a conflict, run `git merge --abort`, fix the clash on your PR branch and join
again. `flock` guards one machine. Agents on several machines need a shared lock, such as one your
harness provides.
6. **One agent owns the full build**; the others build only their own plugin:
6. **Each change rebuilds what it touches.** There is no single build owner: rebuild every bundle your
change reaches, then the tree and the integrity hashes, and commit them with the change. The landing
checks refuse a change whose bundles are stale:
```bash
BUILD_ALLOW=1 bun run build # the build owner: every plugin
BUNDLE=<name> bun x vite build && bun run tree && bun run fix:sri # everyone else: one plugin
BUNDLE=<name> bun x vite build # each bundle your change reaches
bun run tree && bun run fix:sri # then the tree and the integrity hashes
```
7. **Release:** fast-forward `main` to a `development` commit that passed the checks, tag it, and
deploy from the tag:
Only a change to the build itself (its config or its dependencies) reaches every bundle:
`BUILD_ALLOW=1 bun run build`.
7. **Publish, when a person approves it:** make sure the release the landing branch is on now has its
own version tag (that tag is the rollback point), fast-forward the landing branch to a pilot commit
that passed the checks, tag it, and deploy from the tag:
```bash
git fetch origin && git switch main && git merge --ff-only origin/main
git tag -a v0.2.0 -m "Release v0.2.0" && git push origin v0.2.0 # the outgoing release, if untagged
git merge --ff-only <development-commit> # a commit that passed the checks
bun run gates:pre # the checks must pass on exactly this commit
bun run gates:pre && bun run gates:post # the checks must pass on exactly this commit
git tag -a v1.0.0 -m "Release v1.0.0" && git push origin main v1.0.0
```
The tag's `public/dx-maps/` folder is the built site: deploy that
([how to deploy](./documentation/DEPLOY.md)).
The tag's `public/dx-maps/` folder is the built site: deploy that, and build the landing demo from
the same tag ([how to deploy](./documentation/DEPLOY.md)). To go back, deploy the previous release's tag. Publish to a public host, such as GitHub, only from
the landing branch and only when it is ready.
### Developing in the cloud with a swarm
Keep the repository on a git service you host yourself: a swarm pushes often, and an agent can
publish by accident. On a public host, one wrong push publishes work you did not mean to share, so
publish there only from the landing branch, when it is ready. Give each app two servers: a **pilot server** that
follows the pilot branch with hot reload, and a **landing server** that rebuilds from the landing
branch. The cloud container in `deployment/cloud/` runs both today (`BRANCH=development` for the pilot server,
`PRODUCTION_BRANCH=main` for the landing server; `BRANCH` defaults to `main`). One pilot server per app, rather
than one per pull request, keeps the memory the same however many pull requests are open. Each
developer can still build on their own machine with the Plugin Studio and push their branch.
Planned, not built yet: switching the pilot server to show one pull request at a time (with a table of
the open pull requests under it), and rebuilding the landing branch from the accepted pull requests in
order, so a rejected one is simply left out of the next rebuild.
How to contribute, and the bar for shared code: [documentation/CONTRIBUTING.md](./documentation/CONTRIBUTING.md).
Taking contributions from a contributor's own forge into your GitHub repository: [documentation/contribute/TUTORIAL.md](./documentation/contribute/TUTORIAL.md).
Diagram source: [`delegation-model.svg`](./documentation/readme-images/src/delegation-model.svg).
<details>
@ -759,19 +847,20 @@ All the maps here share **one project, one library and one build step**. That's
work together, and it's also the danger: if one agent's change breaks the shared library, **every
map breaks at once**. And when several agents work in the same project at the same time, they step on
each other — two agents edit the same file, one change quietly erases another, or half-finished work
lands in the version everyone else is using.
ends up in the version everyone else is using.
So the project works like a busy kitchen:
1. **Each agent works in its own branch and copy of the project** — its own counter, so nobody bumps
anybody. One owner per plugin, and one per shared file.
2. **Only one change lands at a time.** Whoever holds the *landing lock* first catches up with
everyone else's latest work, fixes any clash itself and runs every check; the change goes into
`development` only if nothing fails. `development` always works.
3. **Someone keeps order.** A coordinator can put the landings in order, and a gate-keeper can read
each change by its content before it lands. Your harness gives these jobs to its agents.
4. **`main` moves only when you publish a release**, after the checks pass on that exact version.
A preview can follow `development`; the published site follows `main`.
2. **Only one change joins the pilot at a time.** Whoever holds the *pilot lock* first catches up with
everyone else's latest work, fixes any clash itself and runs every check; the change goes into the
pilot branch only if nothing fails. The pilot branch always works.
3. **Someone keeps order.** One shepherd agent, which all the other agents talk to, keeps order and puts
the merges onto the pilot in order; one agent per repository handles its branches and watches the work
being done in it. Your harness gives these jobs to its agents.
4. **The landing branch moves only when a person approves a publish**, after the checks pass on that exact version.
The pilot server follows the pilot branch; the landing server rebuilds from the landing branch.
That's how many plugins get built at the same time, in one project, without the maps breaking.
@ -783,12 +872,12 @@ That's how many plugins get built at the same time, in one project, without the
| Word | In plain terms |
|---|---|
| **repo** | the project folder, with its full history |
| **development** | the shared working version: every agent's change is added here, one at a time |
| **main** | the production version: it moves only when `development` is published |
| **development** (the pilot) | the shared working version: every agent's change is added here, one at a time |
| **main** (the landing) | the version that ships: it moves only when a person approves publishing `development` |
| **worktree** | a private copy of the project for one agent to work in |
| **merge conflict** | two changes touched the same lines; someone has to decide how to combine them |
| **rebase** | catching your work up with everyone else's latest before adding it |
| **landing lock** | only one change lands on `development` at a time; its holder catches up and runs every check first |
| **merge** | taking everyone else's latest work into your branch: the latest pilot goes into your PR branch (never a rebase), before joining and once more inside the pilot lock |
| **pilot lock** | only one change merges onto `development` at a time; its holder catches up and runs every check first |
| **tag** | a fixed name for one release commit, such as `v1.0.0`, so you can always deploy exactly it |
</details>
@ -871,7 +960,7 @@ The studio is a plugin like any other: `plugin-designs/plugin-studio/plugin-stud
### Run it in the cloud
**One shared dev server for the whole team, no laptop setup.** A container clones the repo, follows
`development`, and hot-reloads every change an agent lands, so everyone sees the Plugin Studio update in
`development`, and hot-reloads every change that joins the pilot, so everyone sees the Plugin Studio update in
their browser within seconds, with no rebuild. Add OIDC settings and it puts a "Sign in with *your
provider*" page in front of everything, open only to the groups you allow. Recipe and settings:
[deployment/cloud/](./deployment/cloud/README.md).
@ -882,7 +971,7 @@ The three ways dx-maps ships (a drop-in into your own site, the container on its
### Cloud development environment
**Your laptop can follow what agents land somewhere else.** By default `bun run dev` is fully local: no
**Your laptop can follow what agents add to the pilot somewhere else.** By default `bun run dev` is fully local: no
settings, nothing fetched, and only your own edits hot-reload. To also see what agents push to a shared
branch (an agent on a server, or the whole team), add two lines to `.env.local` (gitignored):
@ -908,7 +997,7 @@ doxa-maps/
├── plugin-designs/ YOUR maps — one folder per plugin (the free zone)
│ ├── doxa-maps-spa/ the Research Gallery (capability 5)
│ ├── plugin-studio/ the Plugin Studio (capability 5)
│ ├── my-upg-100-list/ UPG-100 List (capability 4)
│ ├── my-upg-100-list/ UPG100 Posters (capability 4)
│ ├── doxa-simple-map/ Prayer Progress and its presets (capability 2)
│ ├── all-people-groups/ a single map (capability 1)
│ └── template-bundle/ copy this to start a new plugin

View file

@ -1,6 +1,6 @@
{
"_what": "Provenance for the Mark 5:1-20 passage on packet page 8 (prayer point 4), per locale.",
"_rule": "Scripture is NEVER machine-translated and never reproduced from memory. Each text is a published PUBLIC-DOMAIN translation fetched from a citable source that ships licence metadata, stored with the translation's NAME. dx-shepherd END-DXS-MK5GO, 2026-09-27.",
"_rule": "Scripture is NEVER machine-translated and never reproduced from memory. Each text is a published PUBLIC-DOMAIN translation fetched from a citable source that ships licence metadata, stored with the translation's NAME (added 2026-09-27).",
"_verified": "Verse numbering checked against the English (World English Bible): all five extract exactly verses 1-20, verse 1 matches 'they came to the other side of the sea, into the country of the Gadarenes' and verse 20 ends at 'Decapolis ... everyone marveled'.",
"_api": "https://bible.helloao.org/api/<id>/MRK/5.json",
"locales": {

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 222 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 187 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 249 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 244 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 224 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 230 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 231 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 236 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 235 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 257 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 230 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 240 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 238 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 242 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 211 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 264 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 238 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 172 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 231 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 191 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 200 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 220 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 207 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 174 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 233 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 207 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 100 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 246 KiB

View file

@ -2,11 +2,11 @@
"meta": {
"title": "Which province each people group falls in — India and China",
"why": "Published so no other build has to repeat this point-in-polygon. gospel-internet ran the identical ray-casting over the identical geometry and could only check itself against our aggregates; a second implementation of the same geometry is a second place for it to drift.",
"generated": "2026-09-27",
"generated": "2026-10-02",
"grain": "one row per PLACED people group; the four that fall outside every polygon are in the aggregate file's `unassigned`",
"rows": 1009,
"agrees_with": "gospel-by-province.json — per-province counts and the assigned total are reconciled at build time, and neither file is written if they differ",
"approximate": "Every row is approximate. The coordinate marks where a PEOPLE is found, not a settlement, and near a border it can fall on the wrong side. No surface may present a province as a fact about a group."
"approximate": "Every row is approximate. The coordinate marks a PEOPLE group, not a settlement; its position is approximate, and near a border it can fall on the wrong side. No surface may present a province as a fact about a group."
},
"columns": [
{

View file

@ -2,7 +2,7 @@
"meta": {
"title": "Gospel access by province — India and China",
"why": "These two countries hold 47.9% of Doxa's roster (855 + 154 of 2,107). No other country is close; the third is Sudan at 105. A national fill cannot answer \"where would a team go\" for half the map.",
"generated": "2026-09-27",
"generated": "2026-10-02",
"provinces_with_groups": 46,
"provinces_available": 48,
"groups_assigned": 1009,
@ -13,7 +13,7 @@
"near_border_note": "NONE unassigned now, and two rows say why. Jing and Pana fell in no province: measured 8.7 km outside Guangxi and 5.4 km outside Yunnan — across the border, not outside the file, which is what an earlier version of this note wrongly said (both provinces are in it). Sourcing China's complete 34-unit ADM1 moved neither distance: same source geometry, 22 more provinces, none of them theirs — so better data is not the answer and nobody need repeat it. A point within 15 km of exactly ONE province now joins it and carries near_border_km; two provinces within the tolerance leave it unassigned, because a hint between two answers is not one. A single coordinate for a whole people is not precise to 9 km, which is why this is a correction and not a guess.",
"china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.",
"ways_in": "Chosen FOR THE PROVINCE where the province can answer: the languages of groups standing in it that already have Scripture AND film, ranked by the people here who already hold them. The column says which basis was used. This replaces a country-level answer that produced a measured absurdity — Arunachal Pradesh, in the far north-east, was offered TELUGU, because the curated bridge table is keyed by country and a curated bridge outranks a computed one. A province with no tier-2 language of its own still falls back to the country answer, and ways_in_basis says so rather than hiding it. It is a NEIGHBOUR, not a recommendation: whether a group actually speaks it is a question for someone on the ground.",
"approximate": "A province is assigned by point-in-polygon on the group's coordinate, and that coordinate marks where a PEOPLE is found, not a settlement — 108 groups across the roster even share a coordinate with another group. Near a border the point can fall on the wrong side. Every row is flagged approximate and the unassigned are counted, never dropped. No surface may present a province as a fact about a group."
"approximate": "A province is assigned by point-in-polygon on the group's coordinate, and that coordinate marks a PEOPLE group, not a settlement; its position is approximate, and 108 groups across the roster even share a coordinate with another group. Near a border the point can fall on the wrong side. Every row is flagged approximate and the unassigned are counted, never dropped. No surface may present a province as a fact about a group."
},
"columns": [
{

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,7 @@
"meta": {
"title": "Key Cities / Location Clusters",
"grain": "one row per ~25km location cluster",
"caveat": "A cluster is people-group CENTROIDS, not a settlement. place_name comes from a cited gazetteer (Nominatim/OSM, ODbL); an unnamed row is honest, not missing.",
"caveat": "A cluster is people-group PINS (each marks a people group, not a town), not a settlement. place_name comes from a cited gazetteer (Nominatim/OSM, ODbL); an unnamed row is honest, not missing.",
"internet_caveat": "Internet figures are NATIONAL. Per-city connectivity is not publicly available at this scale."
},
"columns": [

View file

@ -3,7 +3,7 @@
"title": "Languages per City x Resources",
"grain": "one row per location cluster x language spoken there",
"why": "399 of 1,646 clusters are multi-language — a city-level bridge can exist that a country average hides.",
"place_caveat": "A location is a cluster of people-group centroids within ~25km, NOT a municipal boundary. An empty place_name is an unnamed cluster, never an invented one.",
"place_caveat": "A location is a cluster of people-group pins within ~25km, NOT a municipal boundary. An empty place_name is an unnamed cluster, never an invented one.",
"resolution_caveat": "resolved:false means the language LABEL did not match an ISO 639-3 record here — its resource status is UNKNOWN, not absent.",
"pools_caveat": "Resource status is from eBible + JESUS Film only. GRN and FCBH audio are key-blocked."
},

View file

@ -12,11 +12,12 @@ AGENT-BRIEF.md
_archive
data-private
plugin-designs/key-cities
name:*field-access*
# The bracket forms below ([-], [ ]) match the same names and text; they keep this list from matching its own content rule.
name:*field[-]access*
name:*visa*.json
name:.env
name:.env.*
name:*.pem
name:*.key
keep:.env.example
content:field-access|Visa Requirements
content:field[-]access|Visa[ ]Requirements

View file

@ -4,8 +4,9 @@ Rudder (`plugin-designs/rudder-voice`) is voice-to-text that runs on your own co
local engine (`backend/rudder-engine`, Go) that drives NVIDIA's NeMo-Speech.cpp speech server. The
desktop app is a Tauri window around the built web tree, with the engine started as a **sidecar**.
This page is written for an agent following it cold. **Read "What is proven" first**: most of the
desktop path has never been run by anyone, and the steps say so where that is the case.
This page is written for an agent following it cold. **Read "What is proven" first**: the Linux app is
proven and in use; the macOS and Windows path has not been run yet, and the steps say so where that is
the case.
## What is proven, and what is not
@ -18,8 +19,8 @@ desktop path has never been run by anyone, and the steps say so where that is th
| The engine **compiling** for macOS (arm64, x86_64) and Windows (x86_64) | Proven by cross-compiling. It has **never been run** on either. |
| The engine accepting a WebSocket from the Tauri webview (`tauri://localhost`, `http://tauri.localhost`) | Proven against a running engine; another site's origin is refused. |
| NeMo-Speech.cpp runtime for macOS / Windows | Published by NVIDIA (release v0.1.0: `macos-aarch64-cpu`, `macos-aarch64-metal`, `macos-x86_64-cpu`, `windows-x86_64-cpu` / `-cuda` / `-vulkan`). **Never run here.** |
| `bun run desktop:build rudder-voice` on **Linux** | Proven: produced `Rudder_0.1.0_amd64.deb` (8.8 MB) holding the app and the `rudder-engine` sidecar side by side. The app has **not been launched** from it yet (no display on the build box). |
| The shell starting and stopping the engine sidecar | Built (`deployment/src-tauri/src/main.rs`), compiled into that installer; not yet run. |
| `bun run desktop:build rudder-voice` on **Linux** | Proven: produced `Rudder_0.1.0_amd64.deb` (8.8 MB) holding the app and the `rudder-engine` sidecar side by side. The installed Linux app has since been launched and is in use: it is used to dictate the work on these maps. |
| The shell starting and stopping the engine sidecar | Built (`deployment/src-tauri/src/main.rs`), compiled into that installer. The installed Linux app works (the row above); not yet run on macOS or Windows. |
| First-run model download | **Not built.** Run `setup-runtime.sh` into the app's data folder by hand (step 7). |
## 1. Prerequisites

View file

@ -42,7 +42,7 @@ Two things to know before you publish a link:
- **No zoom or centre parameter.** The frame is derived from the pins the link selects, so a link
stays correct when the data moves under it.
- **Placement is approximate.** A dot marks where a *people* is found, not a town or a boundary,
- **Placement is approximate.** A dot marks a *people group*, not a town or a boundary,
and 108 of the placed groups share a coordinate with another group. The sheet says so in the
reader's language.
- **An unknown parameter is ignored, never fatal.** `?country=ZZZ` renders the map, not an error —

View file

@ -1,4 +1,4 @@
# Architecture — UPG-100 poster generation
# Architecture — UPG100 List poster generation
> The simple overview is in `library/scripts/posters/README.md`. This is the full reference for the
> generator: files, environment, pipeline, and the gotchas that have bitten before.
@ -53,7 +53,7 @@ the app is rebuilt.**
| `VITE_API_BASE_URL` | — | Satisfies the fail-closed config gate. No request is made under `OFFLINE=1`. |
| `ONLY` | — | Build a single zone by `zoneKey`. |
| `UPG100_DATA_PATH` | the profile in this repo | Only override to test against other data. |
| `SERIES_INDEX` / `SERIES_NAME` | derived / `List of 100 UPG's` | Series line on the sheet. |
| `SERIES_INDEX` / `SERIES_NAME` | derived / `UPG100 List` | Series line on the sheet. |
## Two things that will bite you

View file

@ -0,0 +1,48 @@
# Connect a contributor's repo to your GitHub
*For the repository's GitHub admin. About 10 minutes. The contributor needs no GitHub account, and you keep control of every step.*
**What you'll have at the end:** a GitHub Action in your repo that, about once an hour, copies the contributor's `release` branch from their own forge into a `contributor` branch of yours. Nothing reaches your main branch until you review and merge it.
**Before you start:** the contributor sends you the URL of their release repo. Try it on a test repo first if you like; the steps are the same.
## 1. Add the secret
**Settings → Secrets and variables → Actions → New repository secret.**
- Name: `UPSTREAM_URL`
- Value: the release-repo URL the contributor sent you
It's a secret so the URL stays out of your public files.
## 2. Add the workflow
Create `.github/workflows/pull-contributor.yml` **on your default branch** with the contents of [`pull-contributor.yml`](pull-contributor.yml). Scheduled workflows run only from the default branch. Read it first: it's about 25 lines, and it:
- fetches the contributor's `release` branch from `UPSTREAM_URL`;
- **stops** if the contributor's commits add, change or delete anything under `.github/`, so nothing from outside can touch a workflow. Your own workflows are fine: it compares their commits with the history you share. If you share none (a fresh release), any `.github/` content is refused;
- updates your `contributor` branch **without force-pushing**, so if their history is ever rewritten, the run fails and waits for you.
## 3. Run it once
**Actions → Pull contributor → Run workflow.** When it turns green, you'll see a new `contributor` branch under **Code → Branches**.
If the push step fails with a permission error, check **Settings → Actions → General → Workflow permissions**, or your organization's Actions policy.
## 4. Protect your main branch
**Settings → Rules → Rulesets → New branch ruleset**, targeting your default branch:
- **Require a pull request before merging**, with at least one approval;
- **Block force pushes.**
(Or use **Settings → Branches → Add branch protection rule** with the same two settings.) Don't restrict pushes to `contributor` itself, because the Action has to update it.
## 5. Review and merge
**Pull requests → New pull request**, base: your main branch, compare: `contributor`. Review it like any outside contribution, and merge when you're happy. If the pull request stays open, it updates itself each time the Action brings new commits.
## Good to know
- **Turn it off:** delete the file, or use **Actions → Pull contributor → ⋯ → Disable workflow**.
- **A red run** means the Action refused something (a change under `.github/`, or a rewritten history). Nothing was changed. Tell the contributor.
- **"Entirely different commit histories":** the release was made fresh, as a new project. Ask the contributor to base it on your main branch, or use it as the start of a new repo.
- **Quiet repos:** GitHub pauses scheduled workflows in a public repo after 60 days with no activity. Re-enable it under **Actions**.
- **Please don't** download the code and re-upload it by hand. It's slower, and it breaks the link to the source.
- **Check the guard yourself:** `bash test-pull-contributor.sh` (next to this file) runs the workflow's exact lines against throwaway local repos, with no network, and shows what it accepts and refuses.

View file

@ -0,0 +1,27 @@
# .github/workflows/pull-contributor.yml
# Copies the contributor's `release` branch into this repo's `contributor` branch,
# about once an hour. Commit it to your default branch. Delete it to stop.
name: Pull contributor
on:
schedule: [{ cron: '17 * * * *' }]
workflow_dispatch: {}
permissions: { contents: write }
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- env: { URL: '${{ secrets.UPSTREAM_URL }}' }
run: |
git fetch --no-tags "$URL" release
# refuse any change they make to .github/
BASE=$(git merge-base HEAD FETCH_HEAD) \
|| BASE=$(git mktree </dev/null)
CHANGED=$(git diff --name-only \
"$BASE" FETCH_HEAD -- .github)
if [ -n "$CHANGED" ]; then
echo "refusing .github/ changes"; exit 1
fi
git push origin \
FETCH_HEAD:refs/heads/contributor

View file

@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Runs the sync step of pull-contributor.yml against throwaway local repos and
# checks what it accepts and refuses. Needs only git and bash; touches no network.
# Usage: bash test-pull-contributor.sh [path/to/pull-contributor.yml]
# What it can't check: GitHub's own behaviour (checkout auth, GITHUB_TOKEN, the schedule).
set -uo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
wf="${1:-$here/pull-contributor.yml}"
t="$(mktemp -d)"; trap 'rm -rf "$t"' EXIT
# the run: block, unindented: exactly what GitHub executes
sed -n '/run: |/,$p' "$wf" | tail -n +2 | sed 's/^ //' > "$t/sync.sh"
export GIT_AUTHOR_NAME=Test GIT_AUTHOR_EMAIL=test@example.invalid
export GIT_COMMITTER_NAME=Test GIT_COMMITTER_EMAIL=test@example.invalid
fails=0
c() { git -C "$t/$1" "${@:2}" -q; }
sync() { # $1 = receiver bare repo, $2 = contributor bare repo, $3 = clone depth (0 = full)
rm -rf "$t/runner"
if [ "${3:-0}" = 0 ]; then git clone -q "$t/$1" "$t/runner"; else git clone -q --depth "$3" "file://$t/$1" "$t/runner"; fi
(cd "$t/runner" && URL="$t/$2" bash --noprofile --norc -eo pipefail "$t/sync.sh" >/dev/null 2>&1)
}
check() { # $1 = name, $2 = expected (ok|refused), then the sync arguments
local name="$1" want="$2"; shift 2
local before after got; before=$(git -C "$t/$1" rev-parse -q --verify refs/heads/contributor || echo none)
if sync "$@"; then got=ok; else got=refused; fi
after=$(git -C "$t/$1" rev-parse -q --verify refs/heads/contributor || echo none)
if [ "$got" = refused ] && [ "$before" != "$after" ]; then got="refused BUT the branch moved"; fi
if [ "$got" = "$want" ]; then echo "PASS $name ($got)"; else echo "FAIL $name: wanted $want, got $got"; fails=$((fails+1)); fi
}
commit() { (cd "$t/$1" && git add -A && git commit -qm "$2"); }
# A receiver whose main already has workflows; the contributor works on top of it.
git init -q --bare -b main "$t/gh.git"; git init -q --bare -b release "$t/up.git"
git init -q -b main "$t/pm"; mkdir -p "$t/pm/.github/workflows"
echo ci > "$t/pm/.github/workflows/ci.yml"; echo m > "$t/pm/m"; commit pm m1; c pm push "$t/gh.git" main
git clone -q "$t/gh.git" "$t/cw"; c cw checkout -b release
echo work > "$t/cw/w"; commit cw w1; c cw push "$t/up.git" release
echo pull > "$t/pm/.github/workflows/pull-contributor.yml"; commit pm add-pull; c pm push "$t/gh.git" main
check "receiver has workflows; release = its main + 1 commit" ok gh.git up.git
echo ci2 > "$t/pm/.github/workflows/ci.yml"; commit pm ci2; c pm push "$t/gh.git" main
echo more >> "$t/cw/w"; commit cw w2; c cw push "$t/up.git" release
check "receiver changed .github/ since; contributor didn't" ok gh.git up.git
good=$(git -C "$t/cw" rev-parse HEAD)
reset() { c cw reset --hard "$good"; c cw push -f "$t/up.git" release; }
echo evil > "$t/cw/.github/workflows/ci.yml"; commit cw edit; c cw push "$t/up.git" release
check "contributor edits a workflow" refused gh.git up.git
reset; echo new > "$t/cw/.github/workflows/new.yml"; commit cw add; c cw push "$t/up.git" release
check "contributor adds a workflow" refused gh.git up.git
reset; rm "$t/cw/.github/workflows/ci.yml"; commit cw delete; c cw push "$t/up.git" release
check "contributor deletes a workflow" refused gh.git up.git
reset
c cw fetch origin; (cd "$t/cw" && git merge -q --no-edit origin/main)
echo m3 >> "$t/cw/w"; commit cw w3; c cw push "$t/up.git" release
check "contributor merges the receiver's main (its .github/ changes come along)" ok gh.git up.git
(cd "$t/cw" && git commit -q --amend -m rewritten); c cw push -f "$t/up.git" release
check "contributor rewrites history (no force-push)" refused gh.git up.git
# A fresh-history release: no history shared with the receiver.
git init -q --bare -b main "$t/gh2.git"; git init -q --bare -b release "$t/up2.git"
git init -q -b main "$t/pm2"; mkdir -p "$t/pm2/.github/workflows"; echo ci > "$t/pm2/.github/workflows/ci.yml"
commit pm2 init; c pm2 push "$t/gh2.git" main
git init -q -b release "$t/fr"; echo app > "$t/fr/app"; commit fr fresh; c fr push "$t/up2.git" release
check "fresh history without .github/" ok gh2.git up2.git
mkdir -p "$t/fr/.github/workflows"; echo x > "$t/fr/.github/workflows/x.yml"; commit fr add-gh; c fr push "$t/up2.git" release
check "fresh history with .github/ (nothing shared to compare: strict)" refused gh2.git up2.git
# Fails closed when the checkout is shallow and no shared base can be found.
git init -q --bare -b main "$t/gh3.git"; git init -q --bare -b release "$t/up3.git"
git init -q -b main "$t/pm3"; mkdir -p "$t/pm3/.github/workflows"; echo ci > "$t/pm3/.github/workflows/ci.yml"
commit pm3 m1; echo 2 > "$t/pm3/f2"; commit pm3 m2; echo 3 > "$t/pm3/f3"; commit pm3 m3; c pm3 push "$t/gh3.git" main
git clone -q "$t/gh3.git" "$t/cw3"; c cw3 checkout -b release; c cw3 reset --hard HEAD~2
echo evil > "$t/cw3/.github/workflows/ci.yml"; commit cw3 edit; c cw3 push "$t/up3.git" release
check "shallow checkout + a workflow edit" refused gh3.git up3.git 1
[ "$(git -C "$t/gh.git" rev-list --count main)" = 3 ] && echo "PASS the receiver's main was never touched" || { echo "FAIL main moved"; fails=$((fails+1)); }
echo; [ "$fails" = 0 ] && echo "ALL PASS" || { echo "$fails FAILED"; exit 1; }

View file

@ -0,0 +1,3 @@
# Deploy workflows
To deploy to GitHub Pages, copy `github-pages.workflow.yml` into `.github/workflows/` in your own repository.

View file

@ -12,7 +12,7 @@ Deep Research is the plugin that is already a whole app. It is the app shell in
- Simple Research;
- Regions;
- Prayer Progress;
- UPG-100 List;
- UPG100 Posters;
- Cities with Internet;
- Gospel Resources.
@ -95,7 +95,7 @@ It is the same command with another start page. Name the page's folder inside `p
```bash
APP_START=doxa-simple-map/doxa-simple-map bun run phone:from-tree # Prayer Progress
bun run phone:from-tree # unset: the UPG-100 list
bun run phone:from-tree # unset: UPG100 Posters
```
Pick a page that works on a phone; Big Picture, for one, is desktop only. The start-page step

View file

@ -237,7 +237,7 @@ snippets (leave it empty for relative URLs that work wherever the folder is drop
## Hosting the dx-maps folder: caching, compression, CORS
**Why it matters.** On a cold load a map's layers draw about 10–17 s after the page opens (measured
on the UPG-100 list page). The bundle and the map engine take under 2 s of that; the rest is basemap
on the UPG100 Posters page). The bundle and the map engine take under 2 s of that; the rest is basemap
tiles and data files such as the country-boundary file (1,866 KB raw, 597 KB gzipped). Compression
cuts every visit; caching means a returning reader doesn't download the same files again.
@ -415,6 +415,7 @@ every third-party script is pinned with an integrity hash (`library/constants/cd
| `style-src` | `'unsafe-inline'`, the same CDN hosts | each plugin styles its own shadow root with a `<style>` element |
| `worker-src` | `blob:` | the map engines run their tile workers from a blob URL |
| `connect-src` | the tile/style hosts you use (`https://tiles.openfreemap.org` for MapLibre, `https://*.mapbox.com` for Mapbox), your data API | tiles, styles, glyphs, data |
| `connect-src` (Bible Posters) | `https://bible.helloao.org` | the public-domain Bible text, fetched per book at render time (no text is bundled) |
| `img-src` | `data:` `blob:` and the same tile hosts | sprites and raster tiles |
---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 858 KiB

After

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="2086" viewBox="0 0 1500 2086" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="2256" viewBox="0 0 1500 2256" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
<defs>
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2563eb"/></marker>
<marker id="aS" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#7c3aed"/></marker>
@ -9,8 +9,8 @@
<path id="agL" d="M 194,850 A 556,556 0 0 1 1306,850"/>
<path id="mqL" d="M 679,884 A 71,71 0 0 0 821,884"/>
</defs>
<rect width="1500" height="2086" fill="#ffffff"/>
<text x="750" y="54" text-anchor="middle" font-size="34" font-weight="800" fill="#0f172a">How dx-maps is built: one owner per plugin, one landing at a time</text>
<rect width="1500" height="2256" fill="#ffffff"/>
<text x="750" y="54" text-anchor="middle" font-size="34" font-weight="800" fill="#0f172a">How dx-maps is built: one owner per plugin, one merge at a time</text>
<circle cx="750" cy="850" r="540" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-dasharray="3 7" opacity=".75"/>
<circle cx="750" cy="850" r="355" fill="none" stroke="#15803d" stroke-width="2.2" opacity=".85"/>
<circle cx="750" cy="850" r="606" fill="none" stroke="#7c3aed" stroke-width="2" stroke-dasharray="8 7" opacity=".6"/>
@ -29,8 +29,8 @@
<circle cx="750" cy="850" r="120" fill="#fef2f2" stroke="#dc2626" stroke-width="3" stroke-dasharray="10 6"/>
<path id="mqT" d="M 646,850 A 104,104 0 0 1 854,850" fill="none"/>
<path id="mqB" d="M 644,850 A 106,106 0 0 0 856,850" fill="none"/>
<text font-size="13" font-weight="800" fill="#b91c1c" letter-spacing="1.6"><textPath href="#mqT" startOffset="50%" text-anchor="middle">LANDING LOCK</textPath></text>
<text font-size="12" font-weight="700" fill="#b91c1c" letter-spacing="1.2"><textPath href="#mqB" startOffset="50%" text-anchor="middle">ONE LANDING AT A TIME</textPath></text>
<text font-size="13" font-weight="800" fill="#b91c1c" letter-spacing="1.6"><textPath href="#mqT" startOffset="50%" text-anchor="middle">PILOT LOCK</textPath></text>
<text font-size="12" font-weight="700" fill="#b91c1c" letter-spacing="1.2"><textPath href="#mqB" startOffset="50%" text-anchor="middle">ONE MERGE AT A TIME</textPath></text>
<rect x="617" y="846" width="26" height="20" rx="4" fill="#dc2626"/>
<path d="M 622,846 v -6 a 8,8 0 0 1 16,0 v 6" fill="none" stroke="#dc2626" stroke-width="3.5"/>
<circle cx="630" cy="856" r="3" fill="#fff"/>
@ -38,9 +38,10 @@
<path d="M 862,846 v -6 a 8,8 0 0 1 16,0 v 6" fill="none" stroke="#dc2626" stroke-width="3.5"/>
<circle cx="870" cy="856" r="3" fill="#fff"/>
<circle cx="750" cy="850" r="72" fill="#166534" stroke="#14532d" stroke-width="3"/>
<text x="750" y="850" text-anchor="middle" font-size="18" font-weight="800" fill="#fff">development</text>
<text x="750" y="872" text-anchor="middle" font-size="12.5" fill="#dcfce7">every change</text>
<text x="750" y="888" text-anchor="middle" font-size="12.5" fill="#dcfce7">lands here</text>
<text x="750" y="842" text-anchor="middle" font-size="24" font-weight="800" fill="#fff">pilot</text>
<text x="750" y="862" text-anchor="middle" font-size="13" font-weight="700" fill="#dcfce7">development</text>
<text x="750" y="882" text-anchor="middle" font-size="12.5" fill="#dcfce7">every change</text>
<text x="750" y="897" text-anchor="middle" font-size="12.5" fill="#dcfce7">joins here</text>
<text font-size="16" font-weight="800" fill="#15803d" letter-spacing="3"><textPath href="#plL" startOffset="50%" text-anchor="middle">PLUGINS</textPath></text>
<text font-size="16" font-weight="800" fill="#2563eb" letter-spacing="3"><textPath href="#agL" startOffset="18%" text-anchor="middle">AGENTS</textPath></text>
<line x1="862.1" y1="579.3" x2="822.7" y2="674.5" stroke="#15803d" stroke-width="1.4" opacity=".45"/>
@ -71,8 +72,8 @@
<line x1="862.1" y1="1120.7" x2="822.7" y2="1025.5" stroke="#15803d" stroke-width="1.4" opacity=".45"/>
<line x1="940.6" y1="1310.1" x2="912.3" y2="1241.7" stroke="#2563eb" stroke-width="2.4" marker-end="url(#aA)"/>
<circle cx="885.9" cy="1178.0" r="62" fill="#f0fdf4" stroke="#15803d" stroke-width="2.6"/>
<text x="885.9" y="1168.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">UPG-100</text>
<text x="885.9" y="1185.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">List</text>
<text x="885.9" y="1168.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">UPG100</text>
<text x="885.9" y="1185.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Posters</text>
<rect x="859.9" y="1193.0" width="52" height="20" rx="10" fill="#15803d"/>
<text x="885.9" y="1208.0" text-anchor="middle" font-size="11.5" font-weight="800" fill="#fff">cap 4</text>
<circle cx="956.6" cy="1348.9" r="40" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
@ -115,20 +116,20 @@
<text x="614.1" y="556.0" text-anchor="middle" font-size="12" font-weight="800" fill="#155e75">Experimental</text>
<text x="614.1" y="570.0" text-anchor="middle" font-size="10.5" fill="#155e75">shelf · 5 plugins</text>
<circle cx="543.4" cy="351.1" r="40" fill="#ecfeff" stroke="#0891b2" stroke-width="2.5"/>
<text x="543.4" y="349.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">roaming</text>
<text x="543.4" y="364.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">agent</text>
<text x="543.4" y="349.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">an agent</text>
<text x="543.4" y="364.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">per plugin</text>
<circle cx="690" cy="248" r="56" fill="#f5f3ff" stroke="#7c3aed" stroke-width="3.5"/>
<text x="690" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#4c1d95">coordinator</text>
<text x="690" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#4c1d95">shepherd</text>
<circle cx="812" cy="248" r="56" fill="#f1f5f9" stroke="#334155" stroke-width="3.5"/>
<text x="812" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#1e293b">gate-keeper</text>
<text x="884" y="192" font-size="17" fill="#4c1d95">a coordinator puts the landings in order;</text>
<text x="884" y="216" font-size="17" fill="#1e293b">a gate-keeper checks each one by its content</text>
<text x="750" y="1502" text-anchor="middle" font-size="20" font-weight="800" fill="#1e3a8a">one owner per plugin, in its own branch · a second reader for every change</text>
<text x="812" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#1e293b">repo agent</text>
<text x="884" y="192" font-size="17" fill="#4c1d95">one shepherd: every agent talks to it; it orders the merges;</text>
<text x="884" y="216" font-size="17" fill="#1e293b">a repo agent per repo runs its branches and watches the work</text>
<text x="750" y="1502" text-anchor="middle" font-size="20" font-weight="800" fill="#1e3a8a">one agent per plugin, in its own branch · the repo agent reads every change</text>
<rect x="36" y="110" width="304" height="280" rx="14" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2"/>
<circle cx="64" cy="142" r="11" fill="#166534" stroke="#14532d" stroke-width="2.5"/>
<text x="88" y="147" font-size="15" fill="#334155">development — shared branch</text>
<text x="88" y="147" font-size="15" fill="#334155">pilot branch (development)</text>
<circle cx="64" cy="174" r="11" fill="#fef2f2" stroke="#dc2626" stroke-width="2.5"/>
<text x="88" y="179" font-size="15" fill="#334155">landing lock — one at a time</text>
<text x="88" y="179" font-size="15" fill="#334155">pilot lock — one at a time</text>
<circle cx="64" cy="206" r="11" fill="#fffbeb" stroke="#d97706" stroke-width="2.5"/>
<text x="88" y="211" font-size="15" fill="#334155">Plugin Studio — the one repo</text>
<circle cx="64" cy="238" r="11" fill="#f0fdf4" stroke="#15803d" stroke-width="2.5"/>
@ -138,11 +139,11 @@
<circle cx="64" cy="302" r="11" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
<text x="88" y="307" font-size="15" fill="#334155">agent — owns one plugin</text>
<circle cx="64" cy="334" r="11" fill="#f5f3ff" stroke="#7c3aed" stroke-width="2.5"/>
<text x="88" y="339" font-size="15" fill="#334155">coordinator — orders landings</text>
<text x="88" y="339" font-size="15" fill="#334155">shepherd — orders merges</text>
<circle cx="64" cy="366" r="11" fill="#f1f5f9" stroke="#334155" stroke-width="2.5"/>
<text x="88" y="371" font-size="15" fill="#334155">gate-keeper — checks landings</text>
<text x="88" y="371" font-size="15" fill="#334155">repo agent — one per repo</text>
<rect x="60" y="1536" width="1380" height="232" rx="20" fill="#fef2f2" stroke="#fca5a5" stroke-width="2"/>
<text x="750" y="1578" text-anchor="middle" font-size="22" font-weight="800" fill="#7f1d1d">How a change lands — one at a time, so many plugins can be built at once</text>
<text x="750" y="1578" text-anchor="middle" font-size="22" font-weight="800" fill="#7f1d1d">How a change joins the pilot — one at a time, so many plugins can be built at once</text>
<rect x="80.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
<circle cx="114.0" cy="1634" r="18" fill="#dc2626"/>
<text x="114.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">1</text>
@ -154,30 +155,30 @@
<rect x="425.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
<circle cx="459.0" cy="1634" r="18" fill="#dc2626"/>
<text x="459.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">2</text>
<text x="487.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Second reader</text>
<text x="445.0" y="1680" font-size="15" fill="#475569">another agent reads every</text>
<text x="487.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Repo agent reads</text>
<text x="445.0" y="1680" font-size="15" fill="#475569">the repo agent reads every</text>
<text x="445.0" y="1702" font-size="15" fill="#475569">change by its content</text>
<text x="445.0" y="1724" font-size="15" fill="#475569">before it may land</text>
<text x="445.0" y="1724" font-size="15" fill="#475569">before it may join</text>
<text x="750.0" y="1682" text-anchor="middle" font-size="26" font-weight="800" fill="#dc2626">→</text>
<rect x="770.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
<circle cx="804.0" cy="1634" r="18" fill="#dc2626"/>
<text x="804.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">3</text>
<text x="832.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">One at a time</text>
<text x="790.0" y="1680" font-size="15" fill="#475569">in the lock: merge development,</text>
<text x="790.0" y="1680" font-size="15" fill="#475569">in the lock: merge the pilot,</text>
<text x="790.0" y="1702" font-size="15" fill="#475569">rebuild, run every check;</text>
<text x="790.0" y="1724" font-size="15" fill="#475569">push only if nothing fails</text>
<text x="1095.0" y="1682" text-anchor="middle" font-size="26" font-weight="800" fill="#dc2626">→</text>
<rect x="1115.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
<circle cx="1149.0" cy="1634" r="18" fill="#dc2626"/>
<text x="1149.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">4</text>
<text x="1177.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Promote to main</text>
<text x="1135.0" y="1680" font-size="15" fill="#475569">main is production: it moves</text>
<text x="1135.0" y="1702" font-size="15" fill="#475569">only when the owner approves,</text>
<text x="1177.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Publish to landing</text>
<text x="1135.0" y="1680" font-size="15" fill="#475569">landing (main), production:</text>
<text x="1135.0" y="1702" font-size="15" fill="#475569">moves only when a person approves,</text>
<text x="1135.0" y="1724" font-size="15" fill="#475569">after full checks on that version</text>
<line x1="750" y1="956" x2="750" y2="1786" stroke="#334155" stroke-width="2.5" stroke-dasharray="2 5" opacity=".0"/>
<rect x="60" y="1812" width="1380" height="254" rx="20" fill="#f8fafc" stroke="#cbd5e1" stroke-width="2"/>
<text x="750" y="1856" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">main → ONE build → one web folder → three outputs</text>
<text x="750" y="1882" text-anchor="middle" font-size="16" fill="#475569">the dev preview follows development · the published site follows main</text>
<text x="750" y="1856" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">landing (main) → ONE build → one web folder → three outputs</text>
<text x="750" y="1882" text-anchor="middle" font-size="16" fill="#475569">the pilot server follows development · the landing server rebuilds from main</text>
<rect x="80.0" y="1908" width="420" height="130" rx="14" fill="#fff" stroke="#15803d" stroke-width="2.5"/>
<text x="290.0" y="1930" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">OUTPUT 1</text>
<text x="290.0" y="1962" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">The web folder</text>
@ -196,4 +197,9 @@
<text x="1210.0" y="1988" text-anchor="middle" font-size="15" fill="#475569">the same folder, wrapped by Tauri</text>
<rect x="1110.0" y="2002" width="200" height="26" rx="13" fill="#15803d" opacity=".13"/>
<text x="1210.0" y="2020" text-anchor="middle" font-size="12" font-weight="800" fill="#15803d">WORKING</text>
</svg>
<rect x="60" y="2090" width="1380" height="146" rx="20" fill="#eff6ff" stroke="#93c5fd" stroke-width="2"/>
<text x="750" y="2132" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">At scale: many people × many agents × many plugins</text>
<text x="750" y="2164" text-anchor="middle" font-size="16" fill="#475569">one PR branch per plugin or feature → joins the pilot through the lock → previewed on the pilot server</text>
<text x="750" y="2192" text-anchor="middle" font-size="16" fill="#475569">in the cloud: a self-hosted git service, because an agent can publish by accident</text>
<text x="750" y="2218" text-anchor="middle" font-size="16" fill="#475569">publish to a public host only from landing (main), when it is ready</text>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
<rect width="1200" height="630" rx="28" fill="#f8fafc"/>
<rect x="1.5" y="1.5" width="1197" height="627" rx="27" fill="none" stroke="#e2e8f0" stroke-width="3"/>
<g transform="translate(110,175) scale(11.5)"><path d="M13.912 0a8.72 8.72 0 0 0-8.308 6.139c1.05-.515 2.18-.845 3.342-.976 2.415-3.363 7.4-3.412 9.88-.097 2.48 3.315 1.025 8.084-2.883 9.45a6.131 6.131 0 0 1-.3 2.762 8.72 8.72 0 0 0 3.01-1.225A8.72 8.72 0 0 0 13.913 0zm.082 6.451a2.284 2.284 0 1 0-.15 4.566 2.284 2.284 0 0 0 .15-4.566zm-5.629.27a8.72 8.72 0 0 0-3.031 1.235 8.72 8.72 0 1 0 13.06 9.9131 10.173 10.174 0 0 1-3.343.965 6.125 6.125 0 1 1-7.028-9.343 6.114 6.115 0 0 1 .342-2.772zm1.713 6.27a2.284 2.284 0 0 0-2.284 2.283 2.284 2.284 0 0 0 2.284 2.284 2.284 2.284 0 0 0 2.284-2.284 2.284 2.284 0 0 0-2.284-2.284z" fill="#24C8DB"/></g>
<text x="480" y="250" font-size="54" font-weight="800" fill="#0f172a">Desktop app (Tauri)</text>
<text x="480" y="315" font-size="27" fill="#475569">We got the JAMstack map app running</text>
<text x="480" y="355" font-size="27" fill="#475569">as a desktop app.</text>
<rect x="480" y="400" width="420" height="62" rx="31" fill="#fef3c7" stroke="#d97706" stroke-width="2.5"/>
<text x="690" y="441" text-anchor="middle" font-size="26" font-weight="800" fill="#92400e">PROVEN · NOT YET LIVE</text>
<text x="480" y="508" font-size="22" fill="#64748b">Proven, not yet in production. The Research</text>
<text x="480" y="538" font-size="22" fill="#64748b">Gallery could become a strong desktop app.</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -13,7 +13,7 @@
> bundle `parameterized` (cap-2) no matter how light it looks, and a `meta.json` `type`
> declares tiers 3/4/5.
Generated from 27 bundle(s).
Generated from 30 bundle(s).
### One canonical exemplar per tier (generated — quote these)
@ -37,12 +37,15 @@ Generated from 27 bundle(s).
| Bundle | Profile | Machine key |
|---|---|---|
| `all-people-groups` | `all-people-groups` | `single` |
| `bible-posters` | `bible-posters` | `single` |
| `bless-india-posters` | `bless-india-posters` | `single` |
| `cities-internet` | `connectivity-list` | `single` |
| `cities-internet` | `connectivity-map` | `single` |
| `cities-internet` | `data-tables` | `single` |
| `country-map` | `country-map` | `single` |
| `country-posters` | `countries-map` | `single` |
| `country-posters` | `country-poster-wall` | `single` |
| `country-posters` | `poster-section` | `single` |
| `golden-path-check` | `golden-path-check` | `single` |
| `gospel-internet` | `board` | `single` |
| `gospel-resources` | `charts` | `single` |
@ -65,6 +68,7 @@ Generated from 27 bundle(s).
| `doxa-simple-map` | `doxa-simple-map` | `parameterized` |
| `doxa-vision-card` | `doxa-vision-card` | `parameterized` |
| `people-groups-grid` | `people-groups-grid` | `parameterized` |
| `poster-sections` | `poster-sections` | `parameterized` |
## Capability 3 — multi-view-element (monolithic)

View file

@ -98,6 +98,10 @@ and a change here can break other maps.
`import … from '@map/components/poster/PosterDialog.vue'`
- **PosterPreview** — PosterPreview.vue — scaled live preview of the poster composition.
`import … from '@map/components/poster/PosterPreview.vue'`
- **PosterSection** — PosterSection.vue — ONE section of a poster wall: its head (the name, the count, the collapse toggle) and its
`import … from '@map/components/poster/PosterSection.vue'`
- **PosterSectionView** — PosterSectionView.vue — ONE poster section on its own: the profile behind <dxm-poster-section>.
`import … from '@map/components/poster/PosterSectionView.vue'`
- **PosterWall** — PosterWall.vue — THE SHARED POSTER WALL.
`import … from '@map/components/poster/PosterWall.vue'`
- **FooterSlot** — FooterSlot.vue — STUB. Poster-rendering slot for FooterSlot.
@ -233,6 +237,8 @@ and a change here can break other maps.
`import … from '@map/composables/useSharedMapToken.js'`
- **useTextHighlight** — highlightRanges(value, indices) — wrap each matched index range in
`import … from '@map/composables/useTextHighlight.js'`
- **useViewAddress** — useViewAddress.js — the page's ADDRESS for the view a reader is on: which app tab, which view inside it, which
`import … from '@map/composables/useViewAddress.js'`
### Utilities (pure helper functions)
@ -270,8 +276,12 @@ and a change here can break other maps.
`import … from '@map/utils/languageRequest.js'`
- **loadPinnedUmd** — loadPinnedUmd — load a PINNED, integrity-checked UMD library and capture it WITHOUT touching the host
`import … from '@map/utils/loadPinnedUmd.js'`
- **peopleGroupName** — peopleGroupName.js — a people group's name in the reader's language, or English if it was machine-translated.
`import … from '@map/utils/peopleGroupName.js'`
- **peopleGroupPoints** — peopleGroupPoints.js — SHARED (@map/utils/peopleGroupPoints.js).
`import … from '@map/utils/peopleGroupPoints.js'`
- **publicEmbed** — publicEmbed.js: the Share menu's Embed codes for THIS plugin, from the tree's PUBLIC address.
`import … from '@map/utils/publicEmbed.js'`
- **scrollport** — findScrollportAncestor — the nearest ancestor that actually scrolls.
`import … from '@map/utils/scrollport.js'`
- **viewportScrim** — viewportScrim.js — make a position:fixed full-screen SCRIM actually cover the VIEWPORT,

View file

@ -347,7 +347,7 @@ The measurements behind the rules above, kept for whoever needs the reasoning.
one. `verify:map-paint` passes under both tokens (it checks GL + attribution, not tiles), so judge tile
loading by the tile response count, not a screenshot — the basemap is Mapbox Light, which looks bare.
- **Share round-trip (2026-09-22).** 8/8 passed with six distinct reopened tags, each matching its plugin
(the two UPG-100 profiles share one tag). A pass count alone cannot catch an SPA fallback that renders
(the two UPG100 Posters profiles share one tag). A pass count alone cannot catch an SPA fallback that renders
*a* map for every URL.
- **Why SRI identity, not HTTP status.** A platform's app URL can return 200 with a sign-in page; comparing
each live bundle's bytes against the manifest's `integrity` hash is a cryptographic identity proof that

View file

@ -50,6 +50,19 @@ function readGated() {
}
export const GATED_PROFILES = readGated()
// The PUBLIC address of the tree for the Share menu's Embed tab (the config's shareBaseUrl): an https URL with no
// trailing slash, or '' (unset). vite.config.js bakes it as __DOXA_SHARE_BASE__; library/utils/embedBase.js reads it.
function readShareBase() {
let v = ''
try {
const cfg = JSON.parse(readFileSync(BUILD_CONFIG_FILE, 'utf8'))
v = typeof cfg.shareBaseUrl === 'string' ? cfg.shareBaseUrl.trim().replace(/\/+$/, '') : ''
} catch { return '' }
if (v && !/^https:\/\/[^\s"'<>]+$/i.test(v)) throw new Error(`[_tree-name] build config "shareBaseUrl" must be an https URL, got ${JSON.stringify(v)}`)
return v
}
export const SHARE_BASE = readShareBase()
// The name escaped for use inside a RegExp source (a '.' in a future name must not match any char).
export const TREE_NAME_RE = TREE_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

View file

@ -26,7 +26,7 @@
*
* Usage: node internal/build-packet-fixtures.mjs
*/
import { existsSync, renameSync, readdirSync } from 'node:fs'
import { existsSync, renameSync, readdirSync, mkdirSync, rmSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
@ -38,6 +38,11 @@ const OUT = resolve(REPO, 'library/scripts/posters/out/packets')
// Every fixture is built against this zone: the records come from the fixture, and the zone only
// supplies page 1's map. Keeping it constant means a shape difference is the ONLY variable.
const ZONE = 'asia-china-tibet-autonomous-region'
// ★ BUILT IN THEIR OWN DIRECTORY. packet.mjs writes <zone>.html into its output dir, and every fixture is built
// against the REAL Tibet zone; built straight into out/packets, each run overwrote the real Tibet packet and the
// rename then removed it, so check:packets-all reported a generated zone as missing. PACKET_OUT sends each build
// to a private directory; only the renamed fixture-<name>.html reaches out/packets.
const BUILD = join(OUT, '.fixture-build')
if (!existsSync(FIXTURES)) {
console.log(`PACKET-FIXTURES: SKIP — no fixtures directory at ${FIXTURES}`)
@ -54,14 +59,16 @@ console.log(`PACKET-FIXTURES: building ${fixtures.length} fixture(s)`)
const built = []
const failed = []
mkdirSync(OUT, { recursive: true })
mkdirSync(BUILD, { recursive: true })
for (const fx of fixtures) {
const name = fx.replace(/^records-/, '').replace(/\.json$/, '')
const res = spawnSync(process.execPath, [join(REPO, 'library/scripts/posters/packet.mjs')], {
cwd: REPO,
encoding: 'utf8',
env: { ...process.env, PACKET_RECORDS: join(FIXTURES, fx), ONLY: ZONE },
env: { ...process.env, PACKET_RECORDS: join(FIXTURES, fx), ONLY: ZONE, PACKET_OUT: BUILD },
})
const produced = join(OUT, `${ZONE}.html`)
const produced = join(BUILD, `${ZONE}.html`)
if (res.status !== 0 || !existsSync(produced)) {
// The ERROR line, not the last line: Node ends a crash with its version banner, so the last line
// of a TDZ ReferenceError read "Node.js v20.20.2" and named nothing.
@ -77,6 +84,8 @@ for (const fx of fixtures) {
console.log(` ✓ ${fx} -> fixture-${name}.html`)
}
try { rmSync(BUILD, { recursive: true, force: true }) } catch { /* best effort */ }
if (failed.length) {
console.error(`\nPACKET-FIXTURES: FAIL — ${failed.length} fixture(s) did not build`)
for (const f of failed) console.error(' - ' + f)

View file

@ -74,4 +74,4 @@ try {
check('Arabic joins the names with و, never an English comma inside Arabic prose', /Koraga وMaila/.test(ar2) && !/Koraga, Maila/.test(ar2), ar2)
} finally { await server.close() }
if (bad) { console.log(`APPROX-PIN-NOTE: FAIL — ${bad} case(s).`); process.exit(1) }
console.log('APPROX-PIN-NOTE: PASS — pins say they are localities, and name the groups that share a point.')
console.log('APPROX-PIN-NOTE: PASS — pins say what they are, and name the groups that share a point.')

View file

@ -183,6 +183,21 @@ ok(!disagree.length, `Copy's regroup warning disagrees with apply-grouping: ${di
`a state-only selection is titled "Gujarāt"; a state + a country names both; a province with no table is not named; Mizoram alone reads "${empty}"`)
}
// ── A7. A selection too big for a PHONE'S PRINT starts nothing there ────────────────────────────────────
// Printing the whole selection (2,209 groups, 113 sheets) crashed the page in a 2.5 GB browser; 1,857 finished. The
// ceiling (half of that, for a phone tab's memory) is recorded beside its measurement in data/selection-export.js.
// Self-test: a phone over the ceiling is blocked; a desktop over it is not; a phone at or under it (India alone, 905)
// is not. The wiring (arm C) checks each of Prayer list, Card deck and Poster asks BEFORE it prints.
{
const c = X.PHONE_GROUP_CEILING
const src = readFileSync(P('plugin-designs/my-upg-100-list/data/selection-export.js'), 'utf8')
const cases = [[2209, true, true], [1001, true, true], [2209, false, false], [c, true, false], [905, true, false]]
const wrong = cases.filter(([n, phone, want]) => X.overPhoneCeiling(n, phone) !== want).map(([n, phone]) => `${n} on a ${phone ? 'phone' : 'desktop'}`)
ok(c === 1000 && !wrong.length && /2,209 groups +113 sheets +the page CRASHED/.test(src) && /1,857 groups/.test(src),
`phone ceiling ${c}: wrong for ${wrong.join(', ') || '(none)'}; the measurement beside it is ${/2,209 groups/.test(src) ? 'present' : 'MISSING'}`,
`a phone over ${c} groups prints nothing (2,209 → blocked); a desktop is never blocked; India alone (905) still prints on a phone`)
}
// ── B. the CSV ───────────────────────────────────────────────────────────────────────────────────
function parseCsv(s) { // RFC 4180: quoted fields, doubled quotes, CRLF records
const rows = []; let row = [], cell = '', q = false
@ -236,6 +251,30 @@ const wired = {
const body = vue.slice(at, vue.indexOf('\n })', at))
return !/toggleTerritory|toggleByProps|toggleZoneSelection/.test(body)
})(),
// Each print action asks the phone ceiling BEFORE it prints, and says so in the house words.
'a big selection on a phone prints nothing': (() => {
const body = (name) => { const at = vue.indexOf(`async function ${name}() {`); return at < 0 ? '' : vue.slice(at, vue.indexOf('\n}\n', at)) }
const before = (name, call) => { const b = body(name); const g = b.indexOf('phoneTooBig('), p = b.indexOf(call); return g > 0 && p > g }
return before('makeSelectionPacket', 'packetPrints.printPacket(') && before('makeSelectionCards', 'packetPrints.printCardDeck(')
&& before('makeSelectionPoster', 'packetPrints.printPoster(') && /hudSay\(t\('poster\.phoneLimitAll'\), 6000\)/.test(vue)
&& /const ON_PHONE = isPhonePrint\(\);/.test(vue)
})(),
// ★ ON A PHONE .upg-status IS display:none, so a batch message written only there is INVISIBLE (a reader tapped
// Prayer List and nothing visible happened). Every batch action's message, and the print's progress, reach the
// HUD's line (.upg-hud-flash, a polite live region that no rule hides) as well as the status badge.
'batch messages and progress reach the visible HUD on a phone': (() => {
const body = (name) => { const at = vue.indexOf(`async function ${name}() {`); return at < 0 ? '' : vue.slice(at, vue.indexOf('\n}\n', at)) }
const actions = ['makeSelectionPacket', 'makeSelectionCards', 'makeSelectionPoster'].map(body)
const tooBig = vue.slice(vue.indexOf('function phoneTooBig('), vue.indexOf('function phoneTooBig(') + 300)
return /const hudSay = \(text, ms\) => \{ flashStatus\(text, ms\); flashRef\(copyFlash, text, ms\); \};/.test(vue)
&& /hudSay\(t\('poster\.phoneLimitAll'\)/.test(tooBig) && !/flashStatus\(/.test(tooBig)
&& actions.every((b) => b && !/flashStatus\(/.test(b))
&& /class="upg-hud-flash" role="status" aria-live="polite">\{\{ copyFlash \|\| hudProgress \}\}/.test(vue)
&& /hudProgress\.value = printStatus;/.test(vue)
// …and it sits ABOVE the action rows: on a phone the PinHint toast covers the HUD's bottom edge
&& vue.indexOf('class="upg-hud-flash"') > 0 && vue.indexOf('class="upg-hud-flash"') < vue.indexOf('class="upg-hud-actions"')
&& !/\.upg-hud-flash\{[^}]*display:\s*none/.test(vue) && !/@media[^{]*\{[^}]*\.upg-hud-flash\{[^}]*display:\s*none/.test(vue)
})(),
'the regroup warning on Copy': /const warn = regroupable\(sel\) \? '' : t\('upgMap\.copyNotRegroupable'\)/.test(vue),
// The packet / card-deck title goes through selectionTitleNames (arm A6 proves it), with the keyed empty case.
'the title names come from selectionTitleNames': /const names = selectionTitleNames\(zoneNames, selectedTerritories\.value, from,\s*\n\s*\(\) => keyedError\('wall\.hud\.noRecords'/.test(vue)
@ -249,6 +288,7 @@ ok(!missingWires.length, `the HUD is missing: ${missingWires.join(', ')}`, 'the
// ── D. the strings, ×11 ──────────────────────────────────────────────────────────────────────────
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
const KEYS = {
poster: ['phoneLimitAll'],
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'peopleStillLoading', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
const cat = (l, f) => JSON.parse(readFileSync(P('library/i18n/locales', l, `${f}.json`), 'utf8'))
const bad = []

View file

@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* check-dev-leaf-escape.mjs — a dev leaf URL cannot write markup into the page it opens.
*
* THE DEFECT. The dev server's leaf route, /__leaf/<bundle>/<profile>[/<instance>] (and a production embed URL,
* <bundle>/<profile>[/<instance>]/index.html, rewritten onto it), writes the URL's parts into the page it serves: the
* <title> and the element's profile-config='…' attribute. It refused only `..` and `/`, so a quote or a tag in the path
* was written raw, and a crafted link broke out of the attribute on a publicly reachable dev server.
* internal/plugin-gallery/dev-plugin.mjs now refuses any part outside LEAF_PART (the shape every bundle folder, profile
* id and instance id has) before either sink, and escapes the <title> as well.
*
* The plugin's own middleware answers each request (no server, no browser):
* A. every leaf the dev manifest advertises (galleryModel), as /__leaf/… AND as its production embed URL → 200 with
* its element: the rule refuses no real page;
* B. a hostile part (a quote, a double quote, a tag, an ampersand, a space, a backtick, an encoded slash or
* backslash, `..`, `.`, an attribute break-out) in each position, both URL forms → refused here (400) or left to
* Vite, never a page;
* C. escHtml leaves none of & < > " ' raw;
* D. with the part rule set back to the old one (`..` and `/` only), a tag in the profile still reaches the <title>
* escaped: the title has its own escaping, not only the rule in front of it.
* Response bodies are never printed: a leaf page carries the build config's token.
* `--self-test`: the old guard, a rule too strict for a real profile, an unescaped <title> and an escHtml that misses
* the quote must each FAIL.
*/
import { readFileSync, writeFileSync, rmSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { discoverSpaRegistry } from './plugin-gallery/discover.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const PLUGIN = join(HERE, 'plugin-gallery/dev-plugin.mjs')
const SRC = readFileSync(PLUGIN, 'utf8')
const GUARD = 'parts.some(s => !LEAF_PART.test(s))'
const OLD_GUARD = "parts.some(s => !s || s.includes('..') || s.includes('/'))"
const RULE = 'export const LEAF_PART = /^[A-Za-z0-9][\\w-]{0,63}$/'
const TITLE = '${escHtml(bundle)} · ${escHtml(profile)}'
const ESC_CLASS = `/[&<>"']/g`
// A copy of the plugin, altered or not, imported from beside the original so its relative imports resolve; the
// file is removed as soon as it is loaded.
let seq = 0
async function load(src) {
if (src === SRC) return import(pathToFileURL(PLUGIN).href)
const tmp = join(HERE, 'plugin-gallery', `.tmp-check-dev-leaf-escape-${process.pid}-${++seq}.mjs`)
writeFileSync(tmp, src)
try { return await import(pathToFileURL(tmp).href) } finally { rmSync(tmp, { force: true }) }
}
// The plugin's middleware, answering one GET: { status, body } ('next' = not answered here, Vite would).
function client(mod) {
let mw = null
mod.autoSpaDevPlugin('').configureServer({ middlewares: { use: (f) => { if (!mw) mw = f } } })
return (url) => new Promise((done) => {
const res = { statusCode: 200, setHeader() {}, end: (b) => done({ status: res.statusCode, body: String(b ?? '') }) }
try { mw({ url, method: 'GET', headers: {} }, res, () => done({ status: 'next', body: '' })) }
catch (e) { done({ status: 'threw', body: String(e && e.message) }) }
})
}
// Every /__leaf/ URL the dev manifest advertises, with the tag its page must mount.
function advertised(mod) {
const out = []
const walk = (o, tag) => {
if (!o || typeof o !== 'object') return
for (const v of Object.values(o)) {
if (typeof v === 'string' && v.startsWith('/__leaf/')) out.push({ url: v.split(/[?#]/)[0], tag })
else walk(v, tag)
}
}
for (const b of mod.galleryModel(discoverSpaRegistry()).bundles) walk(b, (b.elements || [])[0])
return [...new Map(out.map((x) => [x.url, x])).values()]
}
const HOSTILE = ["a'b", 'a"b', 'a<b', 'a>b', 'a&b', 'a b', 'a`b', 'a/b', 'a\\b', '..', '.', "x'><img src=x onerror=alert(1)>"]
async function audit(src) {
const f = []
let mod
try { mod = await load(src) } catch (e) { return [`load: the plugin does not import (${e.message})`] }
const get = client(mod)
const leaves = advertised(mod)
if (leaves.length < 20) f.push(`A: only ${leaves.length} advertised leaves found (the manifest walk is broken)`)
for (const { url, tag } of leaves) {
for (const u of [url, url.replace('/__leaf/', '/') + '/index.html']) {
const r = await get(u)
if (r.status !== 200 || !r.body.includes(`<${tag} `)) f.push(`A: ${u} → ${r.status}${r.status === 200 ? `, no <${tag}>` : ''} (a real page refused)`)
}
}
const [b0, p0] = (leaves[0] ? leaves[0].url : '/__leaf/x/x').slice('/__leaf/'.length).split('/')
for (const h of HOSTILE) {
const e = encodeURIComponent(h)
for (const parts of [[e, p0], [b0, e], [b0, p0, e]]) {
const leaf = '/__leaf/' + parts.join('/')
const r = await get(leaf)
if (r.status !== 400) f.push(`B: ${leaf} → ${r.status} (want 400)`)
const embed = '/' + parts.join('/') + '/index.html'
const r2 = await get(embed)
if (r2.status !== 400 && r2.status !== 'next') f.push(`B: ${embed} → ${r2.status} (want 400, or left to Vite)`)
}
}
const esc = mod.escHtml ? mod.escHtml(`&<>"'`) : null
if (esc !== '&amp;&lt;&gt;&quot;&#39;') f.push(`C: escHtml(& < > " ') = ${JSON.stringify(esc)}`)
if (!src.includes(GUARD)) f.push('D: the leaf guard is not the LEAF_PART rule, so the title cannot be tested behind the old one')
else {
let loose
try { loose = await load(src.replace(GUARD, OLD_GUARD)) } catch (e) { f.push(`D: ${e.message}`) }
if (loose) {
const r = await client(loose)(`/__leaf/${b0}/${encodeURIComponent('<b>x')}`)
const title = (/<title>([^]*?)<\/title>/.exec(r.body) || [])[1]
if (r.status !== 200 || title === undefined) f.push(`D: behind the old rule the page did not render (${r.status})`)
else if (title.includes('<b>') || !title.includes('&lt;b&gt;')) f.push('D: behind the old rule a tag in the profile reached the <title> raw')
}
}
return f
}
if (process.argv.includes('--self-test')) {
const poisons = [
['the old guard (`..` and `/` only)', SRC.replace(GUARD, OLD_GUARD), /^B: /],
['a rule too strict for a real profile', SRC.replace(RULE, 'export const LEAF_PART = /^[a-z]{1,63}$/'), /^A: /],
['an unescaped <title>', SRC.replace(TITLE, '${bundle} · ${profile}'), /^D: /],
['an escHtml that misses the quote', SRC.replace(ESC_CLASS, `/[&<>"]/g`), /^C: /],
]
let missed = 0
for (const [name, src, rx] of poisons) {
if (src === SRC) { console.log(`✗ MISSED: ${name} (not planted: its source line was not found)`); missed++; continue }
const f = await audit(src); const hit = f.find((x) => rx.test(x))
console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`)
if (!hit) missed++
}
if (missed) process.exitCode = 1
} else {
const f = await audit(SRC)
if (f.length) { for (const x of f.slice(0, 40)) console.log('✗ ' + x); console.log(`FAIL check:dev-leaf-escape (${f.length})`); process.exitCode = 1 }
else console.log(`PASS check:dev-leaf-escape — every advertised dev leaf is served in both URL forms; ${HOSTILE.length} hostile parts are refused in every position of both forms; the <title> escapes on its own`)
}

View file

@ -90,15 +90,28 @@ const notes = []
if (!partial.includes(`5 ${ppl}`) || !partial.includes(`995 ${ppl}`)) {
fails.push(`a 0.5% list of 1,000 people does not state both totals ("5 ${ppl}" who follow Jesus, "995 ${ppl}" who do not yet)`)
} else notes.push(' ok both totals are stated in people: 5 who follow Jesus, 995 who do not yet')
if (/week/i.test(partial) || /class="lc-(wk|y)"/.test(partial)) fails.push('the page still counts in weeks or draws a week/year axis')
// The grid is one SVG now (lifeGrid): its week numbers are <text class="lc-t lc-tw">, its year numbers
// "lc-t lc-ty", and its markup names `data-weeks` as geometry, so the WORDS a reader sees are read with the
// tags stripped.
const axis = /class="lc-t lc-t[wy]\b/
if (/week/i.test(partial.replace(/<[^>]+>/g, ' ')) || axis.test(partial)) fails.push('the page still counts in weeks or draws a week/year axis')
else notes.push(' ok no weeks and no week/year axis on the page')
// ★ ITS SIDE LABELS ARE SHARES (2026-10-02): each 10-row block is 520 boxes, a tenth of the list's people,
// labelled 10% ... 100% in the page's own numerals; exactly those ten, in order.
const shares = [...partial.matchAll(/class="lc-t lc-tp[^"]*"[^>]*>([^<]*)</g)].map((m) => m[1])
const want = Array.from({ length: 10 }, (_, i) => `${((i + 1) * 10).toLocaleString('en')}%`)
if (shares.join('|') !== want.join('|')) fails.push(`the people grid's side labels should read ${want.join(' ')}, got ${shares.join(' ') || 'none'}`)
else notes.push(' ok the people grid labels each 10-row block with its share: 10% ... 100%')
// A TINY SHARE: the totals come from the unrounded figure. 0.004% of 1,000,000,000 is 40,000 people,
// which must not print as 0 because the percentage rounds to 0.00.
const tiny = page({ groupsInList: 5, groupsMatched: 5, population: 1e9, evangelicalPopulation: 40000, pctEvangelical: 0.004 })
if (!tiny.includes(`40,000 ${ppl}`) || !/ev-pct">&lt;0\.01%</.test(tiny)) fails.push('a 0.004% share of a billion does not print 40,000 people and "<0.01%"')
else notes.push(' ok a share below the printed digits keeps its real total (40,000 people) and reads "<0.01%"')
// NEG CONTROL: the life page itself keeps its axes, so the axis test above can see one when it is there.
if (!/class="lc-wk"/.test(R.lifePage(zone))) fails.push('the life page lost its week axis (the axis check above cannot fail)')
// NEG CONTROL: the life page itself keeps both axes, so the axis test above can see one when it is there,
// and it carries no share labels, so the share test above is not satisfied by any grid.
const life = R.lifePage(zone)
if (!/class="lc-t lc-tw"/.test(life) || !/class="lc-t lc-ty/.test(life)) fails.push('the life page lost its week or year axis (the axis check above cannot fail)')
if (/class="lc-t lc-tp/.test(life)) fails.push('the life page carries share labels: a week of a life is not a share of people')
}
for (const n of notes) console.log(n)

View file

@ -37,6 +37,7 @@ const STATIC = [
['check:click-sheet', 'the country sheet resolves, links are absolute'],
['check:snapshot-guard', 'no fetcher writes around the guard'],
['check:gospel-tables-staged', 'every asset the map fetches is in the shipped tree'],
['check:gospel-clear-filter-url', 'Show all drops only the filter keys, not a partner\'s'],
]
const BROWSER = [
['check:leaves-mount', 'all five leaves actually mount'],

View file

@ -64,6 +64,44 @@ for (const prefix of dirLike) {
for (const file of named.filter((f) => f.endsWith(ext))) wanted.push(prefix + file)
}
/**
* ⚠ AN INTERPOLATED FILE NAME IS NOT A FILE EITHER. The field card fetches
* `${assetsBase.value}/tables/card-${cardShard(cardSlug)}.json`, so the parse above captures "tables/card-" and
* stops at the interpolation; read as a file it failed as "not staged" while all its shards were there. Its
* expected set comes from the SOURCE, as the rule above says, never from the tree: every roster row the card
* builder keeps (data/tables/doxa-people-groups.json, the same filter as build-field-cards.mjs) hashed with the
* leaf's own cardShard. This file carries a copy of that function and fails if the leaf's copy changes, so the
* two cannot drift apart; any other interpolated name is reported as unresolved, never passed.
*/
const SHARD_FN = 'let h = 5381 for (let i = 0; i < slug.length; i++) h = (((h << 5) + h) ^ slug.charCodeAt(i)) & 0xffffffff return ((h >>> 0) & 0xff).toString(16).padStart(2, \'0\')'
const cardShard = (slug) => {
let h = 5381
for (let i = 0; i < slug.length; i++) h = (((h << 5) + h) ^ slug.charCodeAt(i)) & 0xffffffff
return ((h >>> 0) & 0xff).toString(16).padStart(2, '0')
}
function checkInterpolatedName(rel, missing) {
if (rel !== 'tables/card-') { missing.push(`${rel} — an interpolated file name this check cannot resolve`); return }
const leafFn = (src.match(/const cardShard = \(slug\) => \{([\s\S]*?)\n\}/) || [])[1]
if (!leafFn || leafFn.replace(/\s+/g, ' ').trim() !== SHARD_FN) {
missing.push('tables/card-*.json — the leaf\'s cardShard changed; update this check\'s copy (and build-field-cards.mjs) to match')
return
}
let rows = []
try { rows = JSON.parse(readFileSync(resolve(ROOT, 'data/tables/doxa-people-groups.json'), 'utf8')).rows || [] }
catch (e) { missing.push(`tables/card-*.json — could not read the roster: ${e.message}`); return }
const keys = [...new Set(rows.filter((r) => r && r.slug && r.mapped && r.lat != null && r.lng != null).map((r) => cardShard(r.slug)))].sort()
if (keys.length < 20) { missing.push(`tables/card-*.json — the roster gave only ${keys.length} shard key(s); the roster changed shape`); return }
let ok = 0, bytes = 0
for (const k of keys) {
const p = resolve(POOL, `tables/card-${k}.json`)
if (!existsSync(p)) { missing.push(`tables/card-${k}.json — not staged`); continue }
const b = statSync(p).size
if (b < 200) { missing.push(`tables/card-${k}.json — staged but only ${b} bytes`); continue }
ok++; bytes += b
}
console.log(` ${ok === keys.length ? 'ok ' : 'FAIL'} ${'tables/card-*.json'.padEnd(42)} ${ok}/${keys.length} shard(s) the roster needs, ${bytes} bytes`)
}
const missing = []
for (const rel of wanted) {
if (rel.endsWith('/')) {
@ -73,6 +111,7 @@ for (const rel of wanted) {
if (!found.length) missing.push(`${rel} — a dynamic fetch this check could not resolve to a file`)
continue
}
if (!/\.(?:geojson|json)$/.test(rel)) { checkInterpolatedName(rel, missing); continue }
const p = resolve(POOL, rel)
if (!existsSync(p)) { missing.push(`${rel} — not staged`); continue }
const bytes = statSync(p).size

View file

@ -0,0 +1,139 @@
#!/usr/bin/env node
/**
* check-host-floor.mjs — the profile floor is decided against the host WITHOUT disturbing live content.
*
* library/elements/hostFloor.js (used by ProfileLoader.vue) probes the host at mount, and after that re-probes ONLY
* when the host may have changed kind, saving and restoring every scrolled descendant. The regression this pins:
* the probe ran on EVERY host resize, collapsing the loader under live content; a scrolling child's scrollTop was
* clamped to 0 and the embedded UPG-100 app flipped from Map back to its first view whenever a phone's URL bar
* resized the host.
*
* A stub DOM models the layout that matters: the host's height from its mode and the loader's min-height, the
* floor (100svh) as a number the test moves like a window resize, and a scrolled CHILD whose scrollTop clamps to 0
* whenever the loader collapses.
* A. after mount, a content-sized host following a window resize, and a sized host that keeps its size, never
* write the loader's min-height nor move the child's scrollTop;
* B. a sized host (523 px) is filled: the floor goes off at mount;
* C. a host styled SHORTER than the floor after mount flips to sized ONCE, then stays;
* D. a host capped by max-height (600 < floor) is found at mount: 0 flips and 0 re-probes over 20 resizes;
* E. a max-height cap that arrives AFTER mount: at most 1 flip and 1 re-probe over 20 resizes, ends content-sized;
* F. a sized host restyled to height:auto with NO content gets the floor back;
* H. a sized host restyled to height:auto WITH in-flow content (420) gets the floor back: at most 1 flip over 20
* resizes, and the scrolled child keeps its scrollTop through the re-probe;
* G. an inline host is left alone after mount.
* `--self-test`: the old per-resize probe (A), a missing capped latch (D/E re-probe in a loop), a missing re-check
* of a sized host (H stays sized) and a missing scroll restore (H loses the child's scrollTop) must each FAIL.
*/
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const SRC = join(ROOT, 'library/elements/hostFloor.js')
const asModule = (src) => import('data:text/javascript;base64,' + Buffer.from(src).toString('base64'))
// mode: 'content' (height = max(content, min-height)), 'sized' (a fixed height), 'capped' (content-sized up to a cap)
function world({ mode = 'content', fixed = 523, cap = 600, content = 0, floor = 844, display = 'block' } = {}) {
const w = { mode, fixed, cap, content, floor, display, writes: [], child: { scrollTop: 787 } }
const attrs = new Set()
const minPx = () => {
const v = loader.style._min
if (v === '' || v === undefined) return w.floor
return parseFloat(v)
}
w.hostH = () => {
if (w.mode === 'sized') return w.fixed
const natural = Math.max(w.content, minPx())
return w.mode === 'capped' ? Math.min(natural, w.cap) : natural
}
const loader = {
style: {
_min: '',
get minHeight() { return this._min },
set minHeight(v) {
this._min = v; w.writes.push(v)
if (v === '0px' && w.mode !== 'sized') w.child.scrollTop = 0 // the loader collapsed: the child clamps
},
},
hasAttribute: (n) => attrs.has(n), setAttribute: (n) => attrs.add(n), removeAttribute: (n) => attrs.delete(n),
}
const host = { getBoundingClientRect: () => ({ height: w.hostH() }) }
w.loader = loader; w.host = host
w.style = (n) => (n === host ? { display: w.display, minHeight: '0px' } : { display: 'block', minHeight: (loader.style._min === '' ? w.floor : parseFloat(loader.style._min)) + 'px' })
w.sized = () => attrs.has('data-host-sized')
w.probes = () => w.writes.filter((v) => v === '100000px').length
return w
}
async function audit(src) {
const { createHostFloor } = await asModule(src)
const fails = []
let t = 0
const make = (opts) => { const w = world(opts); w.f = createHostFloor(w.loader, w.host, { style: w.style, scrolled: () => (w.child.scrollTop ? [w.child] : []) }); return w }
// A: content-sized host, a window resize moves the floor and the host with it
{ const w = make({ mode: 'content', content: 0 }); w.f.probeOnce(); w.child.scrollTop = 787; w.writes.length = 0
for (const fl of [830, 760, 700, 844]) { w.floor = fl; w.f.follow() }
if (w.child.scrollTop !== 787) fails.push(`A: a host resize after mount clamped a scrolled child (scrollTop 787 → ${w.child.scrollTop})`)
if (w.writes.length) fails.push(`A: a host resize after mount wrote the loader's min-height (${w.writes.join(', ')})`)
if (w.sized()) fails.push('A: a content-sized host was flipped to sized by a window resize') }
{ const w = make({ mode: 'sized', fixed: 523 }); w.f.probeOnce(); w.writes.length = 0; w.child.scrollTop = 300
for (let i = 0; i < 4; i++) w.f.follow()
if (w.writes.length || w.child.scrollTop !== 300) fails.push('A: a sized host\'s resize callbacks touched the loader') }
// B
{ const w = make({ mode: 'sized', fixed: 523 }); w.f.probeOnce()
if (!w.sized() || w.loader.style.minHeight !== '0px') fails.push('B: a sized host (523) is not filled (the floor stayed on)') }
// C
{ const w = make({ mode: 'content' }); w.f.probeOnce(); let flips = 0, was = w.sized()
w.mode = 'sized'; w.fixed = 600
for (let i = 0; i < 10; i++) { t += 100; w.f.follow(); if (w.sized() !== was) { flips++; was = w.sized() } }
if (!w.sized() || flips !== 1) fails.push(`C: a host sized after mount should flip to sized exactly once (flips ${flips}, sized ${w.sized()})`) }
// D
{ const w = make({ mode: 'capped', cap: 600, content: 0 }); w.f.probeOnce(); let flips = 0, was = w.sized()
for (let i = 0; i < 20; i++) { t += 50; w.f.follow(); if (w.sized() !== was) { flips++; was = w.sized() } }
const probes = w.probes() - 1
if (flips !== 0 || w.sized() || probes > 0) fails.push(`D: a max-height host flipped ${flips} time(s) / was re-probed ${probes} time(s) (it must be found at mount and left alone)`) }
// E
{ const w = make({ mode: 'content', content: 0 }); w.f.probeOnce(); let flips = 0, was = w.sized(); const p0 = w.probes()
w.mode = 'capped'; w.cap = 600
for (let i = 0; i < 20; i++) { t += 50; w.f.follow(); if (w.sized() !== was) { flips++; was = w.sized() } }
const probes = w.probes() - p0
if (flips > 1 || w.sized() || probes > 1) fails.push(`E: a max-height cap that arrived after mount LOOPS or stays sized (flips ${flips}, re-probes ${probes}, sized ${w.sized()})`) }
// H: sized → height:auto with in-flow content
{ const w = make({ mode: 'sized', fixed: 523 }); w.f.probeOnce(); w.child.scrollTop = 300; let flips = 0, was = w.sized()
w.mode = 'content'; w.content = 420
for (let i = 0; i < 20; i++) { t += 50; w.f.follow(); if (w.sized() !== was) { flips++; was = w.sized() } }
if (w.sized()) fails.push('H: a sized host restyled to height:auto with content (420) stayed sized (the floor did not come back)')
if (flips > 1) fails.push(`H: the sized → auto host flipped ${flips} times`)
if (w.child.scrollTop !== 300) fails.push(`H: the re-probe moved a scrolled child (scrollTop 300 → ${w.child.scrollTop})`) }
// F
{ const w = make({ mode: 'sized', fixed: 523 }); w.f.probeOnce(); w.mode = 'content'; w.content = 0; w.f.follow()
// with the floor off and no content the host is 0 high; follow() must bring the floor back
if (w.sized()) fails.push('F: a sized host that collapsed to 0 did not get the floor back') }
// G
{ const w = make({ mode: 'content', display: 'inline' }); w.f.probeOnce(); w.writes.length = 0; w.floor = 700; w.f.follow()
if (w.writes.length) fails.push('G: an inline host was acted on after mount') }
return fails
}
const src = readFileSync(SRC, 'utf8')
if (process.argv.includes('--self-test')) {
const poisons = [
['the old per-resize probe (follow() re-measures, no restore)', src.replace(/function follow\(\) \{/, 'function follow() { decide(); return;'), /^A: /],
['no latch for a capped host', src.replace('if (capped) { if (h + 1 >= f) capped = false }\n else if', 'if'), /^[DE]: /],
['no re-check of a sized host', src.replace('if (lastH === null || Math.abs(h - lastH) >= 1) reprobe()', '/* no re-check */'), /^H: .*stayed sized/],
['no scroll restore in the re-probe', src.replace("for (const [e, top, left] of kept) { if (e.scrollTop !== top) e.scrollTop = top; if (e.scrollLeft !== left) e.scrollLeft = left }", ''), /^H: the re-probe moved/],
]
let missed = 0
for (const [name, poisoned, rx] of poisons) {
if (poisoned === src) { console.log(`NOT MEASURED: the poison anchor for "${name}" moved`); process.exitCode = 75; continue }
const f = await audit(poisoned); const hit = f.find((x) => rx.test(x))
console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}`)
if (!hit) missed++
}
if (missed) process.exitCode = 1
} else {
const f = await audit(src)
if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:host-floor (${f.length})`); process.exitCode = 1 }
else console.log('PASS check:host-floor — probed at mount; after it, re-probed only on a change of kind and without moving a scrolled child; a window resize writes nothing; a sized host fills; late sizing flips once; a max-height cap never loops; a sized host restyled to auto (with or without content) gets the floor back; inline hosts are left alone')
}

View file

@ -68,6 +68,11 @@ const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json'))
// translations, add the locale here with its count and lower it as they arrive.
// Never raise a number; never add a locale that is already complete.
const DEBT = {
// 2026-10-03, the Share menu's 10 new share.json keys (shareable pages P3): localization is English-only for now.
// tStatic falls back to the English text, so these locales show English until the translations arrive;
// lower each to 0 then, and empty the map again.
// + the Embed tab's 9 keys (URL / Iframe / JavaScript, P3b), the same English-only rule.
ar: 19, de: 19, es: 19, fr: 19, hi: 19, it: 19, pt: 19, ro: 19, ru: 19, zh: 19,
}
const fails = []

View file

@ -39,7 +39,19 @@ const m = code.match(/host\.value\s*=\s*([^\n]+)/)
if (!m) {
fails.push('the area resolution moved — this gate cannot find `host.value = …` and would pass blind.')
} else {
const expr = m[1]
let expr = m[1]
// The resolution may live in a named function (resolveHost(), shareable pages P3: a standalone Share button
// re-resolves at open time). Then judge THAT function's body, and fail if it cannot be found.
const fn = expr.match(/^\s*(\w+)\(\)/)
if (fn) {
const body = code.match(new RegExp('function\\s+' + fn[1] + '\\s*\\(\\)\\s*\\{([\\s\\S]*?)\\n\\}'))
// every return that resolves a named area must carry its structural fallback on the SAME expression
// a same-line comment is not a fallback: strip `//…` before testing each return
const rets = body ? [...body[1].matchAll(/return\s+([^\n]*closest\([^\n]*)/g)].map((r) => r[1].replace(/\s*\/\/.*$/, '')) : []
expr = rets.length && rets.every((r) => /offsetParent|parentElement|document\.body/.test(r)) ? rets.join(' ;; ') : (rets.find((r) => !/offsetParent|parentElement|document\.body/.test(r)) || '')
if (!body) fails.push(`the area resolution calls ${fn[1]}(), and this gate cannot find that function to read it.`)
else if (!rets.length) fails.push(`${fn[1]}() returns no named-area resolution, so this gate would pass blind.`)
}
if (!/offsetParent|parentElement|document\.body/.test(expr)) {
fails.push('MapControlMenu resolves its anchor from a class allowlist with no structural fallback:\n'
+ ` ${expr.trim()}\n`

View file

@ -112,12 +112,12 @@ const PROBE = () => {
const rail = all.find(n => /vtl-nav/.test(cls(n)))
if (!rail) return { noRail: true }
// Any control that can bring the rail on screen. Both are legitimate: the header toggle when a
// header exists, the phone bar's ☰ (.vtl-phone-menu, which replaced the floating corner circle) when
// it does not.
// Any control that can bring the rail on screen. All are legitimate: the header toggle when a
// header exists; with none, the phone bar's ☰ (.vtl-phone-menu, app mode) or the corner ☰ (.vtl-fab,
// web mode: a host page with its own site header).
const openers = all
.filter(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu/.test(cls(n)))
.map(n => ({ kind: /vtl-phone-menu/.test(cls(n)) ? 'phone-bar' : 'header-menu', on: onScreen(n), name: name(n) }))
.filter(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu|vtl-fab/.test(cls(n)))
.map(n => ({ kind: /vtl-phone-menu/.test(cls(n)) ? 'phone-bar' : /vtl-fab/.test(cls(n)) ? 'corner' : 'header-menu', on: onScreen(n), name: name(n) }))
return {
railOnScreen: onScreen(rail),
@ -174,7 +174,7 @@ for (const t of targets) {
for (const c of (n.children || [])) w(c)
})(document.body)
const cls = (n) => (n.className || '').toString()
const btn = all.find(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu/.test(cls(n)) &&
const btn = all.find(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu|vtl-fab/.test(cls(n)) &&
n.getBoundingClientRect().width > 0)
if (!btn) return { clicked: false }
btn.click()

View file

@ -93,7 +93,7 @@ for (const bundle of readdirSync(DESIGNS)) {
// ── ★ THE HOST-SIZED SCROLLER IS A THREE-LINE SHAPE, AND IT MUST BE PORTED WHOLE ────────────────
// A pane that fills its host absolutely AND turns on overflow is also, necessarily, the thing that
// scrolls whatever its child overflows by. library/ProfileLoader.vue floors EVERY profile at
// `min-height: var(--profile-min-h, 100dvh)` -- a VIEWPORT measurement -- while such a pane measures
// `min-height: var(--profile-min-h, 100svh)` -- a VIEWPORT measurement -- while such a pane measures
// against its HOST. So on any page with chrome above it, the child is taller than the pane by exactly
// the height of that chrome, and the pane scrolls the whole plugin, tab strip and all.
//
@ -124,7 +124,7 @@ for (const [file, label] of [
if (!/--profile-min-h:\s*0/.test(r)) {
const sel = r.slice(0, r.indexOf('{')).trim().split('\n').pop().trim()
fails.push(`${file}: "${sel}" fills its host and owns the overflow but does NOT set --profile-min-h:0.\n`
+ ' ProfileLoader floors every child at 100dvh (the VIEWPORT), so on a page with a header the\n'
+ ' ProfileLoader floors every child at 100svh (the VIEWPORT), so on a page with a header the\n'
+ ' child is taller than this pane by the header height and this pane scrolls the whole plugin,\n'
+ ' tab strip included. The three lines are one shape: fill, scroll, release the floor.')
}

View file

@ -78,7 +78,7 @@ function sequence(html) {
: /word-title/.test(seg) ? 'word' // point 4's passage in full + the Father's-heart list (2026-09-30)
: /points-title|points_title|Prayer Points/.test(seg) ? 'points'
: `?${cls.trim()}`
if ((kind === 'profiles' || kind === 'worksheet') && out[out.length - 1] === kind) return // one entry per run
if ((kind === 'profiles' || kind === 'worksheet' || kind === 'points') && out[out.length - 1] === kind) return // one entry per run
out.push(kind)
})
return out.join(' · ')
@ -119,9 +119,9 @@ const box = (html) => {
ok(p1.indexOf('class="map-sheet"') >= 0 && p1.indexOf('class="map-sheet"') < p1.indexOf('map-series'),
'page 1 draws its title BEFORE the map, so the map-only sheet\'s background paints over it', 'on a map-only sheet the map is drawn first, the title over it')
// two-sided: the series line WITH a packet count, and NOT without one
ok(/class="map-series"[\s\S]{0,200}One of 28 UPG 100 prayer lists/.test(p1), 'page 1 (map-only) does not say it is one of the 28 UPG 100 prayer lists')
ok(/class="map-series"[\s\S]{0,200}One of 28 UPG100 prayer lists/.test(p1), 'page 1 (map-only) does not say it is one of the 28 UPG100 prayer lists')
const noKp1 = R.mapPage(zone, groups, liftedMap, noK)
ok(!/map-series/.test(noKp1), 'page 1 prints a series line with NO packet count to state', 'a map-only page 1 says "One of 28 UPG 100 prayer lists" — and stays silent without a count')
ok(!/map-series/.test(noKp1), 'page 1 prints a series line with NO packet count to state', 'a map-only page 1 says "One of 28 UPG100 prayer lists" — and stays silent without a count')
// ★ A POSTER PAGE 1 HAS ONE HEADER, THE POSTER'S — two-sided against the map-only page above, which has the packet's.
const posterP1 = R.mapPage(zone, groups, R.liftMap(POSTER_SHEET), withK)
ok(/sheet-map-poster/.test(posterP1) && !/map-series/.test(posterP1) && !posterP1.includes(strings.en.map_page_sub.split('{count}')[0]),
@ -143,10 +143,10 @@ const box = (html) => {
ok(tiers === funnelRaw.steps.length + 2 && nums === tiers,
`with a packet count the triangle should have ${funnelRaw.steps.length + 2} tiers each with its number; got ${tiers} tiers, ${nums} numbers`,
`${tiers} tiers, every one with its number: ${funnelRaw.steps.length} global steps, the packets, this packet`)
ok(/UPG 100 prayer packets divide/.test(page), 'the packets tier is missing although a packet count was supplied')
ok(/UPG100 prayer packets divide/.test(page), 'the packets tier is missing although a packet count was supplied')
const pageNoK = tri(R.buildPacket(zone, groups, new Map(), liftedMap, noK, christian, 'full', sets))
const tiersNoK = (pageNoK.match(/class="funnel-tier/g) || []).length
ok(tiersNoK === funnelRaw.steps.length + 1 && !/UPG 100 prayer packets divide/.test(pageNoK),
ok(tiersNoK === funnelRaw.steps.length + 1 && !/UPG100 prayer packets divide/.test(pageNoK),
`without a packet count the packets tier must be absent (${tiersNoK} tiers)`, 'no packet count, no packets tier')
ok(/class="[^"]*funnel-mine/.test(page), 'the reader\'s own count (.funnel-mine) is missing from the triangle')
// two-sided: a step is subtracted where the sets nest, and only there

View file

@ -117,8 +117,11 @@ const layout = await page.evaluate(() => {
const tb = rng.getBoundingClientRect()
if (tb.width < 20 || box.width < 50 || box.width - tb.width < 12) continue
if (cs.direction === 'rtl') out.rtlComputed += 1
if (Math.round(box.right - tb.right) <= 1) out.pinnedRight += 1
else if (Math.round(tb.left - box.left) <= 1) out.pinnedLeft += 1
// Against the CONTENT box: a padded table cell (the pace table's) starts its run at its padding, which is
// still the left (or right) edge the direction pins it to.
const pl = parseFloat(cs.paddingLeft) || 0, pr = parseFloat(cs.paddingRight) || 0
if (Math.round(box.right - pr - tb.right) <= 1) out.pinnedRight += 1
else if (Math.round(tb.left - box.left - pl) <= 1) out.pinnedLeft += 1
out.sampled += 1
if (out.sampled >= 25) break
}
@ -181,6 +184,24 @@ const scriptureIsEnglish = lang !== 'en'
&& !(catalog[lang] || {}).point4_passage
await page.evaluate((words) => { window.__EXPECTED_RHYTHMS = words }, expected)
// The life grid's decoder: every square subpath (M x y …) of an .lc-svg back to its box index, from the geometry
// the SVG declares. Used by the life-calendar checks below.
await page.evaluate(() => {
window.__lcBoxes = (svg) => {
// data-geom x0,y0,px,dec[,py]: the box pitch across and down (one pitch when py is absent).
const [x0, y0, px, dec, py = px] = String(svg.dataset.geom || '').split(',').map(Number)
const weeks = Number(svg.dataset.weeks)
const idx = (d) => [...String(d || '').matchAll(/M([\d.]+) ([\d.]+)/g)].map((m) => {
const x = Number(m[1]) - 0.5 - x0, y = Number(m[2]) - 0.5 - y0
const b = Math.floor(y / (10 * py + dec))
return (b * 10 + Math.round((y - b * (10 * py + dec)) / py)) * weeks + Math.round(x / px)
})
const off = idx((svg.querySelector('path.lc-off') || {}).getAttribute?.('d'))
const sh = svg.querySelector('path.lc-on, path.lc-reached')
const shaded = sh ? idx(sh.getAttribute('d')).sort((a, b) => a - b) : []
return { total: off.length + shaded.length, shaded, distinct: new Set([...off, ...shaded]).size }
}
})
const r = await page.evaluate(() => {
const body = document.body.innerText
const imgs = [...document.querySelectorAll('.card img, img.ws-ph')] // profile cards, or lightweight rows (2026-09-30)
@ -250,27 +271,43 @@ const r = await page.evaluate(() => {
// stays exact whether or not the caller supplied set data.
setsSheets: document.querySelectorAll('.sheet-sets').length,
christianSheets: document.querySelectorAll('.sheet-christian').length,
lifeCells: document.querySelectorAll('.lc-frame .lc-c').length,
// ★ THE LIFE GRIDS ARE ONE SVG EACH (2026-10-02): two paths of square subpaths (empty and shaded) placed by
// the geometry the SVG states (data-geom x0,y0,px,decadeGap,py). Each subpath is decoded back to its box index
// in reading order, so the checks below still see box by box what is drawn, not a count the renderer asserts.
lifeCells: [...document.querySelectorAll('.lc-frame .lc-svg')].reduce((a, g) => a + window.__lcBoxes(g).total, 0),
prayBoxes: document.querySelectorAll('.pray-box').length,
fdTopOver: (() => { const b = document.querySelector('.fd-top'); if (!b) return 0; const top = b.getBoundingClientRect().top
return Math.round(Math.max(0, ...[...b.children].map((k) => k.getBoundingClientRect().bottom + parseFloat(getComputedStyle(k).marginBottom))) - top - b.clientHeight) })(),
// The points page is ONE sheet by design and fits only because its sizes were tuned to the longest locale;
// measure its content against its box (studio's ask), as fdTopOver does for the list's first page.
// Each points sheet's body is measured, the worst one reported (one sheet today).
pointsOver: (() => { const bs = [...document.querySelectorAll('.points-body')]; if (!bs.length) return null
return Math.max(...bs.map((b) => { const top = b.getBoundingClientRect().top
return Math.round(Math.max(b.scrollHeight - b.clientHeight, ...[...b.children].map((k) => k.getBoundingClientRect().bottom - top - b.clientHeight))) })) })(),
pointsSheets: document.querySelectorAll('.sheet-points').length,
// ★ THE POINTS PAGE SIZES ITSELF FROM AN ESTIMATE (2026-10-03, data-pt-est): the page must never be taller
// than the estimate it was sized by, or the estimate is drifting and only its 3% margin still saves the page.
pointsUnder: (() => { const b = document.querySelector('.points-body[data-pt-est]'); if (!b) return null
const top = b.getBoundingClientRect().top, kids = [...b.children]
const real = Math.max(...kids.map((k) => k.getBoundingClientRect().bottom - top)) + parseFloat(getComputedStyle(kids[kids.length - 1]).marginBottom)
return { real: Math.round(real), est: Number(b.dataset.ptEst), tier: Number(b.dataset.ptTier) } })(),
funnelTip: document.querySelectorAll('.funnel-tip').length,
lifeShaded: document.querySelectorAll('.lc-frame .lc-reached').length,
lifeShaded: (() => { const g = document.querySelector('.sheet-christian .lc-svg'); return g ? window.__lcBoxes(g).shaded.length : null })(),
// ★ FILLED FROM THE TOP, in reading order (the packet's design), measured on the DOM order of the cells:
// the shaded boxes must be EXACTLY the first N. This used to require a SCATTER, the opposite design;
// a count alone still cannot tell a top fill from a scatter or a fill from the bottom.
lifeTopFill: (() => {
const grid = document.querySelector('.sheet-christian .lc-frame')
if (!grid) return null
const cells = [...grid.querySelectorAll('.lc-c')]
const n = cells.filter((c) => c.classList.contains('lc-reached')).length
return cells.slice(0, n).every((c) => c.classList.contains('lc-reached'))
const g = document.querySelector('.sheet-christian .lc-svg')
if (!g) return null
const s = window.__lcBoxes(g).shaded
return s.every((v, i) => v === i)
})(),
// The life page: Jesus' 33 years, the first 33 x 52 = 1,716 boxes and no others.
jesusTopFill: (() => {
const grid = document.querySelector('.sheet-life .lc-frame')
if (!grid) return null
const cells = [...grid.querySelectorAll('.lc-c')]
const on = cells.filter((c) => c.classList.contains('lc-on')).length
return { on, top: cells.slice(0, on).every((c) => c.classList.contains('lc-on')) }
const g = document.querySelector('.sheet-life .lc-svg')
if (!g) return null
const s = window.__lcBoxes(g).shaded
return { on: s.length, top: s.every((v, i) => v === i) }
})(),
funnelEq: document.querySelectorAll('.funnel-eq').length,
funnelSteps: document.querySelectorAll('.funnel-mine').length,
@ -368,7 +405,15 @@ const r = await page.evaluate(() => {
.filter((e) => { const r = e.getBoundingClientRect(); return r.width < 4 || r.height < 4 }).length,
rhythms: RHYTHMS.filter((w) => body.includes(w)).length,
rhythmsTotal: RHYTHMS.length,
wheelSegs: document.querySelectorAll('svg path[fill^="hsl"]').length,
// The segments are the wheel sheet's filled ARC paths (M centre L rim A …), whatever their colour notation:
// counting fill^="hsl" read 0 once the wheel took the source's hex palette, and the gate went red on every
// packet while the wheel was fine. The "Start here" arrow is a path too, but unfilled and arc-less.
wheelSegs: (() => {
const title = document.querySelector('.wheel-title')
const svg = title ? title.closest('.sheet')?.querySelector('svg') : null
return svg ? [...svg.querySelectorAll('path')].filter((p) => / A /.test(p.getAttribute('d') || '')
&& (p.getAttribute('fill') || 'none') !== 'none').length : 0
})(),
// ⚠ COUNT THE WHEEL'S OWN LABELS, SCOPED TO THE WHEEL SHEET. `rhythms` below
// searches document.body.innerText, and the SAME rhythm vocabulary is rendered
// TWICE — once per wheel segment, and once per prayer point (each point prints
@ -389,11 +434,11 @@ const r = await page.evaluate(() => {
const sheet = title ? title.closest('.sheet') : null
const svg = sheet ? sheet.querySelector('svg') : null
if (!svg) return -1
// The segment labels are the non-numeric <text> runs: the pips are all "5" and
// the footer reads "5 <minutes> x 12".
return [...svg.querySelectorAll('text')]
.map((t) => (t.textContent || '').trim())
.filter((s) => s && !/^\d+$/.test(s) && !/^\d[\s\S]*×\s*12$/.test(s)).length
// The segment labels are the wheel's own label runs (class="wheel-label", packet-render wheelPage), counted
// when they carry words. Counting every non-numeric <text> read 25 once each rim badge gained its
// "minutes" tag and the wheel its "Start here": those are furniture, not segment labels.
return [...svg.querySelectorAll('text.wheel-label')]
.filter((t) => (t.textContent || '').trim()).length
})(),
// ⚠ NOT body.includes('Gadarenes'). That was an ENGLISH proper noun, so this
// assertion would go RED on a correctly-translated packet and stays green today
@ -419,6 +464,10 @@ const r = await page.evaluate(() => {
if (!title) return -1
const host = title.parentElement?.parentElement
if (!host) return -1
// Each point's heading carries class="pt-h": a numbered LINE is not a point. German cites "1. Petrus 5:6–11"
// (its own name for 1 Peter), and that reference line was counted as a thirteenth point.
const heads = document.querySelectorAll('.pt-h')
if (heads.length) return heads.length
return (host.innerText || '').split('\n').filter((l) => /^\s*\d{1,2}\.\s/.test(l)).length
})(),
metacamp: (body.match(/metacamp\.org/g) || []).length,
@ -470,7 +519,7 @@ const pdfBytes = pdf.length
const fails = []
if (!r.sheets) fails.push('no .sheet elements — the packet did not render')
if (!r.cards && !r.wsRows) fails.push('no people-group cards or worksheet rows — a packet of nobody')
if (r.wsRows && r.wsBoxes !== r.wsRows * 3) fails.push(`${r.wsRows} worksheet rows should carry ${r.wsRows * 3} tick boxes but the packet has ${r.wsBoxes}`)
if (r.wsRows && r.wsBoxes !== r.wsRows * 3) fails.push(`${r.wsRows} worksheet rows should carry ${r.wsRows * 3} tally cells but the packet has ${r.wsBoxes}`)
if (r.cardsBlank) {
fails.push(`${r.cardsBlank} of ${r.cards} people-group card(s) have no NAME — a card that `
+ 'names nobody is a slot on the page, not an entry in a prayer list. The counts above cannot '
@ -722,19 +771,31 @@ if (!boxes.length) {
// sheets — a 100-year life in weeks, then the SAME grid shaded by the evangelical share of these
// groups' population — so they are DETECTED and added as known terms for exactly the reason the
// funnel was, and for the reason stated above: a tolerance here would hide a dropped list page.
const PER_PAGE = 20
// THE PAGE SIZES ARE THE RENDERER'S, read from it rather than retyped: the first list page of each packet
// gives rows to the explanation band (2026-10-02), so a typed number drifted the moment the band changed.
const { createPacketRenderer: makeR } = await import(resolve(dirname(fileURLToPath(import.meta.url)), '../library/packet/packet-render.js'))
const RR = makeR({ strings: catalog, lang: 'en' })
const PER_PAGE = RR.PER_PAGE
const funnelSheets = r.funnel ? 1 : 0
const lifeSheets = (r.lifeSheets || 0) + (r.christianSheets || 0)
// ⚠ AND THE SETS PAGES (bounded, centered): back matter, DETECTED for the same reason as the rest.
// Before they were counted the arithmetic only held when a fixture omitted set data.
const setsSheets = r.setsSheets || 0
// The worksheet paginates 16 rows on its first page and 18 after (packet-render WS_FIRST / WS_REST; 20 and 24
// before its rows gained a photo and a description, 2026-09-30).
const wsPages = (n) => (n <= 0 ? 0 : 1 + Math.ceil(Math.max(0, n - 16) / 18))
const listSheets = r.cards ? Math.ceil(r.cards / PER_PAGE) : wsPages(r.wsRows)
// Both lists give their first page to the explanation band: the worksheet holds WS_FIRST rows there and
// WS_REST after; the profile pages FD_FIRST, then PER_PAGE.
const wsPages = (n) => (n <= 0 ? 0 : 1 + Math.ceil(Math.max(0, n - RR.WS_FIRST) / RR.WS_REST))
const fdPages = (n) => (n <= 0 ? 0 : 1 + Math.ceil(Math.max(0, n - RR.FD_FIRST) / PER_PAGE))
const listSheets = r.cards ? fdPages(r.cards) : wsPages(r.wsRows)
// The Full detail's first page reserves a block for the explanation and CLIPS what outgrows it (rather than
// let it overlap the rows): only a measurement sees a clipped line, so measure it.
if (r.pointsOver !== null && r.pointsOver > 0) fails.push(`the prayer-points page outgrows its sheet by ${r.pointsOver}px — the twelve points and the themes must fit ONE page in every language`)
if (r.pointsUnder && r.pointsUnder.real > r.pointsUnder.est) fails.push(`the prayer-points page is ${r.pointsUnder.real}px tall, over the ${r.pointsUnder.est}px its size tier ${r.pointsUnder.tier} was chosen by — the estimate (packet-render pointsHeight) under-measures this language`)
if (r.fdTopOver > 0) fails.push(`the Full detail's explanation block outgrows its reserved height by ${r.fdTopOver}px — text is clipped (raise FD_TOP_H)`)
if (r.wsRows && r.wsSheets !== wsPages(r.wsRows)) fails.push(`${r.wsRows} worksheet rows should take ${wsPages(r.wsRows)} page(s) but the packet has ${r.wsSheets}`)
// + 2 again (2026-09-30, the second revision): point 4's Mark 5:18-20 and the Father's-heart list are back ON the
// points page, so the back matter is the wheel and the points.
// + 2: the wheel and the points, the twelve points and the themes on ONE sheet (2026-10-02, at the demo).
if (r.pointsSheets !== 1) fails.push(`the twelve prayer points and the themes should fit ONE sheet, found ${r.pointsSheets}`)
const expectedSheets = 1 + lifeSheets + funnelSheets + listSheets + setsSheets + 2
// ── THE LIFE-CALENDAR PAGES, asserted rather than merely counted ──────────────────────────────────
// A page that EXISTS is not a page that is RIGHT: an empty grid, a grid of the wrong size, or a
@ -771,10 +832,10 @@ if (r.christianSheets) {
+ `reached and 5200 would claim everybody is; both mean the percentage did not arrive`)
}
}
// The three prayer columns: one tick box per group per question, so the count is exactly 3 x cards.
// The three prayer columns: one tally cell per group per question, so the count is exactly 3 x cards.
if (r.cards && r.prayBoxes !== r.cards * 3) {
fails.push(`${r.cards} people cards should carry ${r.cards * 3} prayer tick boxes `
+ `(blessed / what to know / what to do) but the packet has ${r.prayBoxes}`)
fails.push(`${r.cards} people cards should carry ${r.cards * 3} prayer tally cells `
+ `(did I bless them / what do you want me to know / what do you want me to do) but the packet has ${r.prayBoxes}`)
}
if (r.cards && r.sheets !== expectedSheets) {
fails.push(`${r.cards} people cards should lay out as ${expectedSheets} sheets `
@ -824,6 +885,8 @@ if (r.funnel && r.funnelRows !== r.funnelBars) {
// an allowlist entry added BEFORE translating would have hidden fifteen untranslated strings.
// 'UPG' is the same product name as renamed, "UPG 100" with a space: the digits break the run, so it
// surfaces as 'UPG' (page 2's kicker and packets tier). Settled, like 'UPG-' — a name, not prose.
// The series is now written as ONE term, "UPG100" (2026-10-02): the digits still break the run, so it too
// surfaces as 'UPG'.
const LATIN_OK = new Set(['IMB', 'peoplegroups.org', 'Project', 'UPG-', 'UPG'])
const NON_LATIN_LOCALES = new Set(['hi', 'ar', 'ru', 'zh', 'he', 'fa', 'ur', 'bn', 'ta', 'th', 'ko', 'ja'])
if (r.funnel && NON_LATIN_LOCALES.has(String(lang).split('-')[0])) {
@ -907,6 +970,7 @@ console.log(` funnel (page 2) : ${r.funnel
? `${r.funnelRows} row(s), ${r.funnelBars} bar(s)${r.funnelShare ? ` — "${r.funnelShare}"` : ' — no share line'}`
: 'absent' + (EXPECT_FUNNEL ? ' (REQUIRED — see FAIL above)' : ' (not required)')}`)
console.log(` prayer points : ${r.points} / ${POINTS_EXPECT}`)
if (r.pointsUnder) console.log(` points size tier : ${r.pointsUnder.tier} (estimated ${r.pointsUnder.est}px, measured ${r.pointsUnder.real}px)`)
console.log(` wheel segments : ${r.wheelSegs} (${r.wheelLabels}/${WHEEL_LABELS} labelled)`)
console.log(` point 4 : Mark 5:18-20 ${r.mark5Len} chars, verse 19 bold ${r.v19Len} chars, ${r.heartItems} readings`)
console.log(` metacamp.org : ${r.metacamp}`)

View file

@ -56,6 +56,10 @@ const DECLARED_ABSENT = {
point4_passage: 'Mark 5:1-20 — awaiting a CITED public-domain translation for hi/zh/pt/it/ro',
point4_passage_credit: 'the attribution line for the passage above',
point4_passage_bold: 'verse 19, verbatim from the passage above (so only where that passage is on file)',
// Numbers 6:24-26 on the lightweight list's first page: ro and hi have no strict public-domain text on the
// pipeline that vetted the other nine, so they print the English per key rather than an unvetted rendering.
blessing_text: 'Numbers 6:24-26 — no strict public-domain text for ro (only a Cyrillic-script 1924) or hi',
blessing_credit: 'the attribution line for the blessing above',
// ── The new packet pages, 2026-09-27: a 100-year life calendar, the same grid shaded by
// the evangelical share of these groups' population, and the funnel's equation sidebar + tip.
// ENGLISH-ONLY ON PURPOSE, and declared rather than back-filled: English text copied into ten
@ -121,6 +125,38 @@ for (const loc of locales) {
}
}
// ── SCRIPTURE ADDRESSES ARE ENGLISH'S, AND THE THEMES ARE WHOLE ───────────────────
// A point's refs are canonical addresses: a language may not keep an older set (point 12 kept Acts 2 / Matthew 28
// in ten languages after English moved to John 17). The prayer themes are seven and the scenes three in every
// language, or the page falls back to English for the whole list.
for (const loc of locales) {
const b = cat[loc]
;(b.points || []).forEach((pt, i) => {
if (pt && pt.refs !== undefined && JSON.stringify(pt.refs) !== JSON.stringify((en.points[i] || {}).refs)) {
fails.push(`${loc} point ${i + 1} carries references ${JSON.stringify(pt.refs)}, not English's ${JSON.stringify((en.points[i] || {}).refs)}`)
}
})
if (!Array.isArray(b.themes) || b.themes.length !== 7 || b.themes.some((x) => typeof x !== 'string' || !x.trim())) fails.push(`${loc} needs 7 prayer themes`)
if (!Array.isArray(b.scenes) || b.scenes.length !== 3 || b.scenes.some((x) => typeof x !== 'string' || !x.trim())) fails.push(`${loc} needs 3 prayer scenes`)
}
if (!Array.isArray(en.scene_refs) || en.scene_refs.length !== 3 || en.scene_refs.some((r) => !Array.isArray(r) || r.length !== 2)) fails.push('en scene_refs must give each of the 3 scenes two references')
// ── ARABIC: NO ALEF WASLA (U+0671) ─────────────────────────────────────────────
// The Van Dyck texts write the article with alef wasla (ٱ). A device whose fonts lack U+0671 printed it as a
// BOX, three times in the blessing and eight in Mark 5. The stored ar text uses plain alef (U+0627), the
// ordinary print form: display only, no word or vowel changes (_meta.note_on_blessing). Asserted on EVERY ar
// value, nested ones included, so the next passage pasted from the same source cannot bring it back.
{
const hits = []
const walk = (v, path) => {
if (typeof v === 'string') { const n = [...v].filter((ch) => ch === '\u0671').length; if (n) hits.push(`${path} (${n})`) }
else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, `${path}.${k}`)
}
walk(cat.ar || {}, 'ar')
if (hits.length) fails.push(`ar carries U+0671 alef wasla, which prints as a box where a font lacks it — store plain alef (U+0627): ${hits.slice(0, 4).join(', ')}`)
else notes.push('ar: no U+0671 alef wasla in any value (plain alef, the print form every Arabic font draws)')
}
console.log('PACKET-STRINGS-PARITY')
console.log(` catalog : ${CATALOG.replace(REPO + '/', '')}`)
console.log(` locales : ${locales.length} (${locales.join(' ')})`)

View file

@ -18,7 +18,9 @@ import { readFileSync } from 'node:fs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const read = (p) => readFileSync(resolve(ROOT, p), 'utf8')
const WALL = read('library/components/poster/PosterWall.vue')
// The wall's cards are drawn by PosterSection.vue (lifted out of PosterWall.vue, which provides it the shared state),
// so the print-row markup this gate reads lives in BOTH files: read them as one wall.
const WALL = read('library/components/poster/PosterWall.vue') + '\n' + read('library/components/poster/PosterSection.vue')
const PRINTS = read('library/composables/usePosterPrints.js')
const EN = JSON.parse(read('library/i18n/locales/en/buttons.json'))
@ -95,7 +97,7 @@ ok(count(light, /sheet-sets-(bounded|centered)/g) === 0, 'lightweight still carr
// ⚠ THE DELTA IS DERIVED, never relaxed into a range: lightweight drops four back pages (life, % grid,
// bounded, centered) AND lists the same people as a worksheet, whose pages hold more rows than profiles.
{
const profilePages = Math.ceil(45 / R.PER_PAGE)
const profilePages = 1 + Math.ceil(Math.max(0, 45 - R.FD_FIRST) / R.PER_PAGE)
const worksheetPages = count(light, /<div class="sheet sheet-worksheet/g)
const want = 4 + profilePages - worksheetPages
ok(worksheetPages >= 1 && count(light, /class="ws-row"/g) === 45, `the lightweight worksheet should list all 45 groups, got ${count(light, /class="ws-row"/g)}`)
@ -114,27 +116,95 @@ ok(count(light, /sheet-sets-(bounded|centered)/g) === 0, 'lightweight still carr
`each lightweight row should carry ONE photo cell (${pics.size} photos + ${45 - pics.size} "no photo"), got ${imgs} + ${none} in ${rows} rows`)
ok(count(lw, /class="ws-desc"/g) === 45, `each lightweight row should carry its group's description, got ${count(lw, /class="ws-desc"/g)} of 45`)
}
// ★ BOTH LISTS EXPLAIN THEMSELVES FIRST (2026-10-02, v2): the first list page of EACH packet carries, before any
// row, the blessing in full with its credit, ONE line of further references, the wheel pointer, and ONE band
// (class="xb"): a five-pace table whose loads are COMPUTED from the list (45 groups: all 45 a day, 6 at a
// weekly pace), what the three columns ask, and an example row of three tally cells under the table's own
// question heads. The question heads stand ONCE per page over the tally cells, never per group.
for (const [name, html, rowRe, cellRe, headRe, pageRe] of [
['lightweight', light, /class="ws-row"/g, /class="ws-box"/g, /class="ws-head"/g, /<div class="sheet sheet-worksheet/g],
['full', full, /class="card"/g, /class="pray-box"/g, /class="pray-head"/g, /<div class="hd">/g],
]) {
const E = strings.en
const band = html.indexOf('<section class="xb"')
ok(count(html, /<section class="xb"/g) === 1 && band > 0 && band < html.search(rowRe),
`the ${name} list should explain itself ONCE, on its first list page, before the first row`)
ok(html.includes(E.blessing_text.replace(/&/g, '&amp;')) && html.includes(E.blessing_credit), `the ${name} list should print the blessing (Numbers 6:24-26) with its credit`)
ok(count(html, /class="ws-refs"/g) === 1 && count(html, /Psalm 134:3/g) === 1, `the ${name} list should carry ONE line of blessing references`)
ok(html.includes(E.howto_wheel), `the ${name} list should point to the prayer wheel at the end of the packet`)
const pace = (html.match(/<table class="ws-pace-t">([\s\S]*?)<\/table>/) || [])[1] || ''
ok(count(pace, /<tr/g) === 5 && pace.includes('all 45, every day') && pace.includes('about 6 a day') && count(pace, /ws-pace-best/g) === 1,
`the ${name} pace table should show five paces, daily = all 45 and weekly = about 6 a day (from the list size), weekly marked: ${pace.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160)}`)
ok(count(html, /class="xb-cell /g) === 3 && count(html, /class="xb-marks"/g) === 3, `the ${name} band's example should be ONE row of three tally cells with marks`)
ok(count(html, rowRe) === 45 && count(html, cellRe) === 45 * 3, `every ${name} row should carry three tally cells, got ${count(html, cellRe)} for ${count(html, rowRe)} rows`)
ok(count(html, headRe) === count(html, pageRe), `the ${name} question heads should stand once per list page: ${count(html, headRe)} heads on ${count(html, pageRe)} pages`)
}
ok(!/no pace to keep/.test(light), 'the lightweight list should no longer say there is no pace to keep')
// ★ THE PRAYER THEMES HEAD THE POINTS PAGE (2026-10-02): seven themes, then three scenes with two references
// each, in both packets; point 12 is John 17 (its references are John 17:3, John 17:11–23, Ephesians 3:20).
for (const [name, html] of [['lightweight', light], ['full', full]]) {
const th = (html.match(/<div class="pt-themes">([\s\S]*?)<\/div>\s*<div style="margin-bottom/) || [])[1] || ''
const themes = count(th.split('pt-sc-list')[0] || '', /<li>/g), scenes = count(th.split('pt-sc-list')[1] || '', /<li>/g)
ok(themes === 7 && scenes === 3 && count(th, /class="pt-sc-r"/g) === 3 && /Haggai 2:7/.test(th) && /Matthew 8:26/.test(th),
`the ${name} points page should open with 7 themes and 3 scenes (2 references each): ${themes} themes, ${scenes} scenes`)
ok(/John 17:3/.test(html) && /Ephesians 3:20/.test(html) && !/Acts 2:42/.test(html), `the ${name} point 12 should be John 17 (John 17:3 · John 17:11–23 · Ephesians 3:20)`)
}
ok(!/<em>[^<]*<\/em><\/span>/.test(full.slice(full.indexOf('class="card"'))), 'the full packet repeats a question under each tally cell; the heads stand once per page')
// ★ THE LIFE CALENDAR, HEADLESS — the grid's CONTENT, not just its page. An adversarial review mutated the
// renderer to shade the LAST 33 years, to shade nothing, and to draw 90 rows; every packet gate stayed green
// because none looked inside the grid. Read the cells of each frame, in order (the legend's sample boxes
// sit outside the frame and are excluded):
{
const frame = (html, cls) => {
// The grid is ONE SVG per page (packet-render lifeGrid): decode each square subpath back to its box index
// from the geometry the SVG declares, so the check reads the boxes as drawn, in reading order.
const grid = (html, cls) => {
const i = html.indexOf(`<div class="sheet ${cls}`)
// By class PREFIX: the knows-Christ grid is `lc-frame lc-plain` (drawn without week/year axes).
const a = html.indexOf('<div class="lc-frame', i)
return a < 0 ? '' : html.slice(a, html.indexOf('<div class="lc-legend">', a))
const a = html.indexOf('<svg class="lc-svg"', i)
return a < 0 || i < 0 ? '' : html.slice(a, html.indexOf('</svg>', a))
}
const cells = (h) => [...h.matchAll(/<i class="lc-c( lc-on| lc-reached)?"><\/i>/g)].map((m) => (m[1] || '').trim())
const life = cells(frame(full, 'sheet-life'))
ok(life.length === 5200, `the life calendar should hold 100 x 52 = 5,200 boxes, got ${life.length}`)
const gold = life.filter((c) => c === 'lc-on').length
ok(gold === 33 * 52 && life.slice(0, gold).every((c) => c === 'lc-on'),
`the life calendar should shade Jesus' 33 years as its FIRST 1,716 boxes — got ${gold}, from the top: ${life.slice(0, gold).every((c) => c === 'lc-on')}`)
const ev = cells(frame(full, 'sheet-christian'))
const shaded = ev.filter((c) => c === 'lc-reached').length
ok(ev.length === 5200 && shaded === Math.round(0.032 * 5200) && ev.slice(0, shaded).every((c) => c === 'lc-reached'),
`the %-evangelical grid should shade 3.2% = ${Math.round(0.032 * 5200)} boxes FROM THE TOP, got ${shaded} of ${ev.length}`)
const boxes = (svg) => {
// data-geom x0,y0,px,dec[,py]: the box pitch across and down (one pitch when py is absent).
const [x0, y0, px, dec, py = px] = ((svg.match(/data-geom="([^"]+)"/) || [])[1] || '').split(',').map(Number)
const weeks = Number((svg.match(/data-weeks="(\d+)"/) || [])[1])
const idx = (d) => [...String(d || '').matchAll(/M([\d.]+) ([\d.]+)/g)].map((m) => {
const x = Number(m[1]) - 0.5 - x0, y = Number(m[2]) - 0.5 - y0, b = Math.floor(y / (10 * py + dec))
return (b * 10 + Math.round((y - b * (10 * py + dec)) / py)) * weeks + Math.round(x / px)
})
const off = idx((svg.match(/<path class="lc-off" d="([^"]*)"/) || [])[1])
const on = idx((svg.match(/<path class="lc-(?:on|reached)" d="([^"]*)"/) || [])[1]).sort((a, b) => a - b)
return { total: off.length + on.length, distinct: new Set([...off, ...on]).size, on }
}
const life = boxes(grid(full, 'sheet-life'))
ok(life.total === 5200 && life.distinct === 5200, `the life calendar should hold 100 x 52 = 5,200 distinct boxes, got ${life.total} (${life.distinct} distinct)`)
ok(life.on.length === 33 * 52 && life.on.every((v, i) => v === i),
`the life calendar should shade Jesus' 33 years as its FIRST 1,716 boxes — got ${life.on.length}, from the top: ${life.on.every((v, i) => v === i)}`)
const ev = boxes(grid(full, 'sheet-christian'))
ok(ev.total === 5200 && ev.on.length === Math.round(0.032 * 5200) && ev.on.every((v, i) => v === i),
`the %-evangelical grid should shade 3.2% = ${Math.round(0.032 * 5200)} boxes FROM THE TOP, got ${ev.on.length} of ${ev.total}`)
// THE SAME TABLE: the people grid has the life grid's exact shape (one aspect ratio for both pages).
const vb = (svg) => (svg.match(/viewBox="([^"]+)"/) || [])[1]
ok(vb(grid(full, 'sheet-life')) && vb(grid(full, 'sheet-life')) === vb(grid(full, 'sheet-christian')), 'the people grid should have the life grid\'s exact shape')
}
// ★ THE POINTS PAGE FITS ITSELF (2026-10-03): per language it takes the LARGEST size tier whose estimate fits
// one sheet, from the one-sheet sizes up to the two-sheet sizes before them (the cap). Asserted both ways: the
// tier chosen is the largest that fits, and SHORTER words print LARGER (halving the English points raises
// the English tier) while the real words keep theirs. check:packet-render holds the estimate to the real page.
{
for (const lang of ['en', 'ar', 'zh', 'fr']) {
const R = createPacketRenderer({ strings, lang })
const f = R.pointsFit, k = f.chosen(), budget = f.bodyH * f.safe
ok(k === f.tiers || f.height(k + 1) > budget, `${lang}: points tier ${k} is not the largest that fits (tier ${k + 1} estimates ${Math.round(f.height(k + 1))}px of ${Math.round(budget)})`)
ok(k === 0 || f.height(k) <= budget, `${lang}: points tier ${k} estimates ${Math.round(f.height(k))}px, over the ${Math.round(budget)}px budget`)
ok(R.pointsPage().includes(`data-pt-tier="${k}"`), `${lang}: the points page does not print at the tier it chose (${k})`)
}
const top = (lang, s) => createPacketRenderer({ strings: s, lang }).pointsFit
const short = JSON.parse(JSON.stringify(strings))
short.en.points = short.en.points.map((p) => ({ ...p, text: p.text.slice(0, Math.ceil(p.text.length / 2)) }))
const k0 = top('en', strings).chosen(), k1 = top('en', short).chosen()
ok(k1 > k0, `halving the English points' text should raise the English points tier, got ${k0} -> ${k1}`)
// the cap is the two-sheet sizes: the largest tier prints the point text at 22.5px
ok(/font:400 22\.5px Georgia/.test(createPacketRenderer({ strings, lang: 'en' }).pointsPage(top('en', strings).tiers)), 'the largest points tier should print the text at 22.5px (the two-sheet size, the cap)')
}
// ★ UNKNOWN POINTS ARE STATED EVEN THOUGH THEY CANNOT BE PLOTTED. layoutBoundedSet gives them no
@ -147,6 +217,22 @@ ok(count(light, /sheet-sets-(bounded|centered)/g) === 0, 'lightweight still carr
ok(/10/.test(caps.join(' ')), 'the bounded tally does not state its unknown count — an un-inked category reads as zero in print')
}
// ★ EVERY GROUP OF THE LIST IS COUNTED (2026-10-03): a group with no record in a figure's data is named in
// that figure's caption ("15 without data"), so each caption's parts add up to the list. Both ways: the
// fixture's figures lack records (45 groups; 30 bounded, 24 centered), and complete data prints no such part.
{
const sum = (cap) => [...cap.matchAll(/\d[\d,]*/g)].reduce((a, m) => a + Number(m[0].replace(/,/g, '')), 0)
const capsOf = (html) => [...html.matchAll(/class="set-cap">([^<]*)</g)].map((m) => m[1])
const nd = String(strings.en.sets_no_data).replace('{n}', '').trim()
const short = capsOf(full)
ok(short.length === 2 && short.every((c) => sum(c) === 45 && c.includes(nd)),
`each set caption should add up to the list's 45 groups, naming the ones ${nd}: ${short.join(' | ')}`)
const whole = capsOf(R.buildPacket(zone, groups, new Map(), '<svg/>', funnel, christian, 'full', {
bounded: groups.map((g, i) => ({ id: g.slug, reached: i % 2 === 0 })), centered: groups.map((g, i) => ({ id: g.slug, ring: (i % 10) + 1 })) }))
ok(whole.length === 2 && whole.every((c) => sum(c) === 45 && !c.includes(nd)),
`with a record for every group, each caption should add up to 45 and name none ${nd}: ${whole.join(' | ')}`)
}
// ── THE RING LEGEND: THE AGREED VOCABULARY, AND ONLY THE RINGS THAT HOLD SOMEONE ────────────────
// ★ The names are COMPOSED from band x culture rather than a flat 1..10 table, because "same
// people" (provisional, pending a product decision) appears in rings 1, 4 AND 7 — three cells that a

View file

@ -0,0 +1,106 @@
#!/usr/bin/env node
// check-pg-name-gate — a people group's localized name is shown only when it is a transliteration, never a word
// that a machine translated (Balantak → "Balance", Balo → "Sac à dos"). The rule lives in ONE place,
// library/utils/peopleGroupName.js; this gate holds it there and proves it on the REAL table. Headless. Asserts:
// A. the table loads (data/pplr/pg-names.json, the file the tree serves as assets/pplr/pg-names.json);
// B. REAL ROWS: Balantak, Balo, Abaga and Abai Sungai (translated as words) show English in every locale;
// Balangao Bontoc (a transliteration) shows its own names; it and ro, which have no column, show English;
// C. TWO-SIDED on planted rows, AT the threshold: EXACTLY two far Latin locales falls back, one is kept (the rule
// is "two or more"); a row with only one Latin name to check is not trusted (no evidence → English);
// D. the whole table falls back between 30% and 45% (measured 38.2%, 8,172 of 21,419). A similarity that
// always says "same" (0%) or always "different" (100%) fails here even if B and C were tuned around;
// E. THE ONE-READER RULE: a file in library/ or plugin-designs/ that names pg-names must take its names through
// peopleGroupName.js, and must not index its rows itself (`.names[`) even when it imports the helper.
// pplr-data-maps is the carded exception (its coverage views read raw rows on purpose). NEGATIVE CONTROLS: a
// planted raw reader, and a planted file that imports the helper AND indexes rows, must both be caught.
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { resolve, dirname, join, relative } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const P = (...a) => join(REPO, ...a)
const fails = [], notes = []
const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) }
let H, table
try {
H = await import(pathToFileURL(P('library/utils/peopleGroupName.js')).href)
table = JSON.parse(readFileSync(P('data/pplr/pg-names.json'), 'utf8'))
} catch (e) {
console.error(`PG-NAME-GATE: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)
process.exit(75)
}
const { locales, names } = table
const name = (code, lang) => H.peopleGroupName(names[code], locales, lang)
// ── A ──
ok(Array.isArray(locales) && locales.includes('en') && Object.keys(names).length > 20000,
`the table has ${Object.keys(names || {}).length} rows and locales ${JSON.stringify(locales)}`,
`the table loads: ${Object.keys(names).length} groups × ${locales.length} locales`)
// ── B: real rows ──
const TRANSLATED = { 220002: 'Balantak', 220017: 'Balo', 220044: 'Abaga', 220045: 'Abai Sungai' }
const wrong = []
for (const [code, en] of Object.entries(TRANSLATED)) {
if (!names[code] || names[code][locales.indexOf('en')] !== en) { wrong.push(`${code} is not ${en} in this table`); continue }
for (const l of ['fr', 'es', 'de', 'ar', 'hi', 'ru', 'zh']) if (name(code, l) !== en) wrong.push(`${en} in ${l} shows "${name(code, l)}"`)
}
ok(!wrong.length, `translated rows leak: ${wrong.slice(0, 5).join('; ')}`,
`the 4 known translated rows (${Object.values(TRANSLATED).join(', ')}) show English in fr es de ar hi ru zh`)
const bb = names[220001], at = (l) => bb[locales.indexOf(l)]
ok(bb && name(220001, 'ar') === at('ar') && /[\u0600-\u06FF]/.test(name(220001, 'ar')) && name(220001, 'zh') === at('zh_CN') && name(220001, 'hi') === at('hi'),
`Balangao Bontoc lost its own names: ar "${bb && name(220001, 'ar')}", zh "${bb && name(220001, 'zh')}"`,
`Balangao Bontoc keeps its own names (ar "${name(220001, 'ar')}", zh "${name(220001, 'zh')}")`)
ok(name(220001, 'it') === 'Balangao Bontoc' && name(220001, 'ro') === 'Balangao Bontoc' && H.peopleGroupName(bb, locales, 'ar', 'Bontoc, Balangao') === at('ar')
&& H.peopleGroupName(names[220002], locales, 'fr', 'Balantak (Sulawesi)') === 'Balantak (Sulawesi)',
'it/ro or the caller\'s English name are not honoured', 'it and ro show English; a fallback shows the CALLER\'s English name')
// ── C: planted rows, two-sided ──
const planted = (fn) => { const r = bb.slice(); fn(r); return r }
const twoFar = planted((r) => { r[locales.indexOf('fr')] = 'Sac à dos'; r[locales.indexOf('de')] = 'Rucksack' })
const oneFar = planted((r) => { r[locales.indexOf('fr')] = 'Sac à dos' })
const oneLatin = planted((r) => { for (const l of H.LATIN_LOCALES) if (l !== 'fr') r[locales.indexOf(l)] = '' })
const kept = (r) => (H.isTransliterated(r, locales) ? 'kept' : 'English')
ok(kept(twoFar) === 'English' && H.peopleGroupName(twoFar, locales, 'ar') === 'Balangao Bontoc' && kept(oneFar) === 'kept'
&& kept(oneLatin) === 'English' && H.peopleGroupName(oneLatin, locales, 'ar', 'Balangao Bontoc') === 'Balangao Bontoc',
`planted rows: exactly two far → ${kept(twoFar)}, one far → ${kept(oneFar)}, one Latin name only → ${kept(oneLatin)}`,
'planted, at the threshold: exactly two far Latin locales → English (ar too); one far → kept; only one Latin name to check → English')
// ── D: the whole table ──
let back = 0
for (const row of Object.values(names)) if (!H.isTransliterated(row, locales)) back++
const share = back / Object.keys(names).length
ok(share > 0.30 && share < 0.45, `${back} rows (${(share * 100).toFixed(1)}%) fall back; expected 30-45% (measured 38.2%)`,
`${back} of ${Object.keys(names).length} rows (${(share * 100).toFixed(1)}%) fall back to English`)
// ── E: one reader ──
const EXEMPT = ['plugin-designs/pplr-data-maps/']
const rawReaders = (files) => files.filter(({ path, src }) => /pg-names/.test(src) && !EXEMPT.some((x) => path.startsWith(x))
&& (!/peopleGroupName(\.js)?['"]/.test(src) || /\.names\s*\[/.test(src)))
const walk = (dir, out = []) => {
for (const f of readdirSync(dir)) {
if (f === 'node_modules' || f.startsWith('.')) continue
const p = join(dir, f), s = statSync(p)
if (s.isDirectory()) walk(p, out)
else if (/\.(m?js|vue)$/.test(f)) out.push({ path: relative(REPO, p).split('\\').join('/'), src: readFileSync(p, 'utf8') })
}
return out
}
const sources = [...walk(P('library')), ...walk(P('plugin-designs'))].filter((f) => f.path !== 'library/utils/peopleGroupName.js')
const PLANT_RAW = "fetch(base + '/pplr/pg-names.json').then((t) => t.names[code][3])"
const PLANT_BOTH = "import { peopleGroupName } from '@map/utils/peopleGroupName.js'\n" + PLANT_RAW
const PLANT_GOOD = "import { peopleGroupName } from '@map/utils/peopleGroupName.js'\nfetch(base + '/pplr/pg-names.json').then((t) => peopleGroupName(t.names && row, t.locales, lang, name))"
ok(rawReaders([{ path: 'plugin-designs/x/a.vue', src: PLANT_RAW }, { path: 'plugin-designs/x/b.vue', src: PLANT_BOTH }, { path: 'plugin-designs/x/c.vue', src: PLANT_GOOD }]).map((f) => f.path).join() === 'plugin-designs/x/a.vue,plugin-designs/x/b.vue',
'NEGATIVE CONTROL: the one-reader scan missed a planted raw reader (or flagged a helper-only one)',
'negative controls: a raw reader, and one that imports the helper AND indexes rows, are both caught; a helper-only reader passes')
const raw = rawReaders(sources).map((f) => f.path)
ok(!raw.length, `pg-names read outside peopleGroupName.js: ${raw.join(', ')}`,
`no file outside peopleGroupName.js reads pg-names rows (${sources.length} sources; pplr-data-maps carded)`)
for (const n of notes) console.log(n)
if (fails.length) {
console.error('PG-NAME-GATE: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log('PG-NAME-GATE: PASS — a localized people-group name is shown only when it is a transliteration, decided in one place.')

View file

@ -0,0 +1,199 @@
#!/usr/bin/env node
/**
* check-phone-print-keep.mjs — on a phone, the in-page print document outlives afterprint, so a saved PDF is the preview.
*
* THE DEFECT (2026-10-02, on a phone): UPG 100 → Print prayer packet. The print preview showed all 11 pages; "Save as
* PDF" saved the WEBSITE (one letter page with the host page's title). On a phone printDocument mounts the document into
* the page (#dxm-print-root plus a print-only style) and calls window.print(). It dropped both at afterprint, but
* Android Chrome's print() does not block, afterprint fires once the PREVIEW is built, and the saved file is rendered
* later, after the reader taps Save, from the page as it is then.
*
* A timeline on fake timers and a stub DOM, with an Android user agent (Playwright cannot reproduce Android's print
* pipeline; a real phone is the real check):
* A. after print() and afterprint, the document and its print style are still mounted 30 s on (the Save render);
* B. it goes once the reader is BACK: the page shown again after being hidden, the window focused again after
* losing focus, or a touch after print(), then BACK_GRACE_MS later, not before;
* C. a late print (needs-tap): the Print tap itself does not count as being back, so the document is still
* mounted through the dialog;
* D. a reader who never comes back: dropped at FRAME_CEILING_MS, not before;
* E. printImageBlob: its object URL is NOT revoked at afterprint, and is revoked when the document goes;
* F. a new print replaces the previous document (one root mounted, and the previous one released);
* G. the desktop path is the frame, as before (printDocument's first line, verbatim);
* H. a touch AFTER the first TOUCH_SETTLE_MS, then the print UI opening (the page hidden, or the window blurred):
* the pending drop is cancelled, so the document is still mounted well past BACK_GRACE_MS in the UI, and goes
* after the next "back" (a measured hole in the first cut, found on a second read);
* I. a touch in the first TOUCH_SETTLE_MS after print() (a double tap while the preview builds) is not "back".
* `--impl=<path>` runs the timeline against another copy (the pilot's, before this fix, fails A and E: the cause).
* `--self-test`: the old drop at afterprint, no release when the reader is back, the listeners armed at mount (so the
* Print tap drops it), no grace, printImageBlob revoking at afterprint, no ceiling, no cancel when the page goes away
* again during the grace, and no touch settle window must each FAIL.
*/
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const implArg = process.argv.find((a) => a.startsWith('--impl='))
const SRC = implArg ? implArg.slice(7) : join(ROOT, 'library/composables/printInPlace.js')
const ROOT_ID = 'dxm-print-root'
// ── fake timers ─────────────────────────────────────────────────────────────────────────────────
let now = 0, seq = 0
const timers = new Map()
globalThis.setTimeout = (fn, ms = 0) => { const id = ++seq; timers.set(id, { at: now + Math.max(0, ms), fn }); return id }
globalThis.clearTimeout = (id) => { timers.delete(id) }
function advance(ms) {
const t = now + ms
for (;;) {
let next = null
for (const [id, x] of timers) if (x.at <= t && (!next || x.at < next[1].at || (x.at === next[1].at && id < next[0]))) next = [id, x]
if (!next) break
timers.delete(next[0]); now = next[1].at; next[1].fn()
}
now = t
}
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve() }
// ── a stub DOM: just what mountPrintRoot touches ────────────────────────────────────────────────────
function events() {
const L = new Map()
return {
addEventListener(t, f) { if (!L.has(t)) L.set(t, []); L.get(t).push(f) },
removeEventListener(t, f) { const a = L.get(t); if (a && a.indexOf(f) >= 0) a.splice(a.indexOf(f), 1) },
fire(t) { for (const f of [...(L.get(t) || [])]) f({ type: t }) },
}
}
function node(tag) {
const n = {
tag, id: '', attrs: {}, children: [], parent: null, textContent: '', style: {}, ...events(),
setAttribute(k, v) { this.attrs[k] = String(v) }, getAttribute(k) { return k in this.attrs ? this.attrs[k] : null },
appendChild(c) { c.parent = this; this.children.push(c); return c },
append(...cs) { for (const c of cs) this.appendChild(c) },
remove() { if (this.parent) { this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null } },
querySelectorAll() { return [] },
}
return n
}
let revoked = []
function world({ android = true, active = true } = {}) {
now = 0; seq = 0; timers.clear(); revoked = []
const body = node('body'), head = node('head')
const doc = {
body, head, visibilityState: 'visible', ...events(),
createElement: (t) => node(t), importNode: (n) => n,
getElementById: (id) => [...body.children, ...head.children].find((n) => n.id === id) || null,
querySelectorAll: () => [...body.children.filter((n) => n.id === ROOT_ID), ...head.children.filter((n) => 'data-dxm-print' in n.attrs)],
}
globalThis.document = doc
globalThis.window = { ...events(), prints: [], print() { this.prints.push(mounted()) } }
globalThis.DOMParser = class { parseFromString() { return { querySelectorAll: (s) => (s === 'style' ? [{ textContent: '@page{size:letter portrait;margin:0}' }] : []), documentElement: { getAttribute: () => null }, body: { childNodes: [node('div')] } } } }
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: {
userAgent: android ? 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Mobile Safari/537.36' : 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36',
userActivation: { isActive: active }, maxTouchPoints: android ? 5 : 0 } })
globalThis.URL.createObjectURL = () => 'blob:stub/1'
globalThis.URL.revokeObjectURL = (u) => { revoked.push([u, now]) }
}
const mounted = () => Boolean(document.body.children.find((n) => n.id === ROOT_ID)) && Boolean(document.head.children.find((n) => 'data-dxm-print' in n.attrs))
const rootCount = () => document.body.children.filter((n) => n.id === ROOT_ID).length
let bust = 0
const load = (src) => import('data:text/javascript;base64,' + Buffer.from(src + `\n// ${++bust}`).toString('base64'))
async function audit(src) {
const fails = []
const sample = await load(src)
const FRAME_CEILING_MS = sample.FRAME_CEILING_MS || 600000, BACK_GRACE_MS = sample.BACK_GRACE_MS || 0, TOUCH_SETTLE_MS = sample.TOUCH_SETTLE_MS || 0
if (!sample.FRAME_CEILING_MS || !sample.BACK_GRACE_MS || !sample.TOUCH_SETTLE_MS) fails.push('G: printInPlace.js does not export FRAME_CEILING_MS, BACK_GRACE_MS and TOUCH_SETTLE_MS (the timeline runs on, with 0 for each missing one)')
const HTML = '<!doctype html><html><head><style>@page{size:letter portrait}</style></head><body><div>packet</div></body></html>'
// A + B (hidden → visible)
{ world(); const m = await load(src); const how = await m.printDocument(HTML)
if (how !== 'page' || window.prints.length !== 1 || !window.prints[0]) fails.push(`A: a phone print did not print the mounted document (returned ${how}, prints ${JSON.stringify(window.prints)})`)
window.fire('afterprint'); advance(30000)
if (!mounted()) fails.push('A: the print document was gone 30 s after afterprint; a PDF saved then is the bare page')
document.visibilityState = 'hidden'; document.fire('visibilitychange'); advance(20000)
if (!mounted()) fails.push('B: the document went while the page was hidden (the print dialog was still up)')
document.visibilityState = 'visible'; document.fire('visibilitychange'); advance(BACK_GRACE_MS - 1)
if (!mounted()) fails.push('B: the document went at once when the page was shown again (no grace for a job still rendering)')
advance(2)
if (mounted()) fails.push('B: the document stayed after the reader was back on the page (shown again) and the grace')
}
// B (blur → focus) and B (a touch after print)
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); window.fire('blur'); advance(10000)
if (!mounted()) fails.push('B: the document went while the window had lost focus')
window.fire('focus'); advance(BACK_GRACE_MS + 1)
if (mounted()) fails.push('B: the document stayed after the window was focused again (back on the page)') }
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(TOUCH_SETTLE_MS + 3000); window.fire('pointerdown'); advance(BACK_GRACE_MS + 1)
if (mounted()) fails.push('B: the document stayed after the page was touched again') }
// C: a late print (the tap's activation expired): the Print tap is not "back"
{ world({ active: false }); const m = await load(src); let printNow = null
const how = await m.printDocument(HTML, { onNeedsTap: (p) => { printNow = p } })
if (how !== 'needs-tap' || !printNow) fails.push(`C: an expired tap did not hand out printNow (returned ${how})`)
else {
// the reader taps Print a few seconds after the document is ready, then spends longer than the grace in the dialog
advance(TOUCH_SETTLE_MS + 3000); window.fire('pointerdown'); printNow(); window.fire('afterprint'); advance(BACK_GRACE_MS + 20000)
if (window.prints.length !== 1 || !window.prints[0]) fails.push('C: printNow did not print the mounted document')
if (!mounted()) fails.push(`C: the Print tap counted as being back: the document was gone ${(BACK_GRACE_MS + 20000) / 1000} s into the dialog`)
} }
// D: never back
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(FRAME_CEILING_MS - 1)
if (!mounted()) fails.push('D: the document went before the ceiling with the reader never back')
advance(2)
if (mounted()) fails.push('D: the document outlived FRAME_CEILING_MS') }
// E: printImageBlob's object URL
{ world(); const m = await load(src); m.printImageBlob({ size: 1 }, { title: 'poster' }); await flush()
window.fire('afterprint'); advance(30000)
if (revoked.length) fails.push(`E: printImageBlob revoked its object URL at ${revoked[0][1]} ms, before the reader was back (a saved PDF has no image)`)
if (!mounted()) fails.push('E: printImageBlob\'s document was gone 30 s after afterprint')
window.fire('pointerdown'); advance(BACK_GRACE_MS + 1)
if (revoked.length !== 1) fails.push(`E: printImageBlob's object URL was revoked ${revoked.length} time(s) after the reader was back (expected once)`) }
// F: a new print replaces the previous one
{ world(); const m = await load(src); let first = 0
await m.printDocument(HTML, { onRelease: () => { first++ } }); window.fire('afterprint'); await m.printDocument(HTML)
if (rootCount() !== 1) fails.push(`F: ${rootCount()} print roots mounted after a second print (expected 1)`)
if (first !== 1) fails.push(`F: the first print's onRelease ran ${first} time(s) when a second print replaced it (expected 1)`) }
// H: a touch after the settle window, then the print UI opens: away again cancels the pending drop
for (const via of ['hidden', 'blur']) {
world(); const m = await load(src); await m.printDocument(HTML); advance(TOUCH_SETTLE_MS + 500); window.fire('pointerdown'); advance(500)
if (via === 'hidden') { document.visibilityState = 'hidden'; document.fire('visibilitychange') } else window.fire('blur')
window.fire('afterprint'); advance(BACK_GRACE_MS + 20000)
if (!mounted()) fails.push(`H: a touch, then the print UI (${via}): the grace the touch started was not cancelled, and the document went while the reader was still in the UI (a PDF saved then is the bare page)`)
else {
if (via === 'hidden') { document.visibilityState = 'visible'; document.fire('visibilitychange') } else window.fire('focus')
advance(BACK_GRACE_MS + 1)
if (mounted()) fails.push(`H: after the print UI (${via}) closed, the document stayed past the grace (the next "back" did not count)`)
}
}
// I: a touch in the settle window (no hide, no blur: an in-page sheet) is not "back"
{ world(); const m = await load(src); await m.printDocument(HTML); advance(Math.max(1, TOUCH_SETTLE_MS - 1500)); window.fire('pointerdown'); advance(BACK_GRACE_MS + 30000)
if (!mounted()) fails.push('I: a touch right after print() (a double tap while the preview builds) counted as being back, and the document went with the dialog still up') }
// G: desktop unchanged (the frame path)
if (!src.includes("if (!isPhonePrint()) return openPrintFrame(html, { autoprint: true }) ? 'frame' : 'blocked'")) fails.push('G: printDocument\'s desktop path is no longer the frame, verbatim')
return fails
}
const src = readFileSync(SRC, 'utf8')
if (process.argv.includes('--self-test')) {
const MOUNT_END = ' ceiling = setTimeout(drop, FRAME_CEILING_MS)\n currentJob = job\n'
const poisons = [
['the old drop at afterprint', src.replace(MOUNT_END, " window.addEventListener('afterprint', () => setTimeout(drop, 0))\n" + MOUNT_END), /^A: /],
['no release when the reader is back', src.replace('const back = () => { if (leaving) return; leaving = true; clearTimeout(ceiling); ceiling = setTimeout(drop, BACK_GRACE_MS) }', 'const back = () => {}'), /^B: the document stayed/],
['the listeners armed at mount (the Print tap drops it)', src.replace(MOUNT_END, ' ceiling = setTimeout(drop, FRAME_CEILING_MS)\n job.printing()\n currentJob = job\n'), /^C: the Print tap counted/],
['no grace once the reader is back', src.replace('ceiling = setTimeout(drop, BACK_GRACE_MS) }', 'drop() }'), /^B: the document went at once/],
['printImageBlob revoking at afterprint', src.replace(" const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n", " const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n window.addEventListener('afterprint', () => setTimeout(release, 0))\n"), /^E: printImageBlob revoked/],
['no ceiling', src.replace(MOUNT_END, ' currentJob = job\n'), /^D: the document outlived/],
['no cancel when the page goes away again', src.replace('const awayAgain = () => { away = true; if (leaving) { leaving = false; clearTimeout(ceiling); ceiling = setTimeout(drop, FRAME_CEILING_MS) } }', 'const awayAgain = () => { away = true }'), /^H: a touch, then the print UI/],
['no touch settle window', src.replace('const onTouch = () => { if (!settling) back() }', 'const onTouch = () => back()'), /^I: /],
]
let missed = 0
for (const [name, poisoned, rx] of poisons) {
if (poisoned === src) { console.log(`NOT MEASURED: the poison anchor for "${name}" moved`); process.exitCode = 75; continue }
const f = await audit(poisoned); const hit = f.find((x) => rx.test(x))
console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`)
if (!hit) missed++
}
if (missed) process.exitCode = 1
} else {
const f = await audit(src)
if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:phone-print-keep (${f.length})`); process.exitCode = 1 }
else console.log('PASS check:phone-print-keep — on a phone the print document (and printImageBlob\'s image) outlives afterprint, so a PDF saved after the preview is the document; it goes once the reader is back (shown, focused or touched again) plus a grace, or at the ceiling; the Print tap of a late print and a touch right after print() do not count; going away again cancels a pending drop; a new print replaces it; desktop prints through the frame as before')
}

Some files were not shown because too many files have changed in this diff Show more