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.
This commit is contained in:
Doxa Maps 2026-10-03 00:58:33 +00:00
commit 69510aae86
459 changed files with 13815 additions and 4094 deletions

110
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,7 +37,7 @@ 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,
Every poster is in Posterverse (`plugin-designs/posterverse/`): UPG100 Posters, Country Posters,
Poster Design and Wall of People Groups. They are all in the Plugin Studio (`bun run dev`) too.
# dx-maps — one build, maps that embed everywhere
@ -119,7 +119,7 @@ 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.
@ -149,7 +149,7 @@ 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>
@ -163,7 +163,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 +173,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 | ✅ | ✅ | ✅ | 🧪 | ✅ |
@ -251,8 +275,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>
@ -317,8 +341,8 @@ 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
│ ├─ 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
├─ Gospel Research
@ -340,11 +364,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 +378,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.
@ -572,13 +596,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
@ -655,9 +679,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 |
|---|---|---|---|---|
@ -697,25 +721,29 @@ your own page, set `"locale"` in its `profile-config`. Arabic reads right to lef
**Many AI agents can build many plugins at once without breaking each other's work.** Every plugin
has one agent that owns it, and the work moves through three kinds of branch:
- **The pilot branch (`development`)** is the shared working version. A development server with hot
reload follows it, so each change that lands can be seen there within about 15 seconds.
- **The landing branch (`main`)** is production. It moves only when you publish, and the production
server rebuilds from it.
- **PR branches** hold one feature each. They start from the landing branch and land on the pilot
branch, one at a time, after the checks pass.
- **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 landing branch and join the
pilot branch, one at a time, after the checks pass and a second reader 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 merges onto the pilot in order, or a
gate-keeper that reads each change before it lands, are jobs your harness gives to its agents.
gate-keeper that reads each change before it joins the pilot, are jobs your harness gives to its agents.
![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 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 coordinator and a gate-keeper over the 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. **The landing branch (`main`) is production.** It moves only when you publish (step 7).
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
@ -732,7 +760,7 @@ gate-keeper that reads each change before it lands, are jobs your harness gives
```bash
bun x vite --port 5301 --strictPort # the next agent uses 5302, and so on
```
5. **Land on the pilot one at a time, through a lock.** Inside the lock, merge your PR branch into a
5. **Join the pilot one at a time, through a lock that works like a merge queue.** Inside the lock, merge your PR branch into a
throwaway copy of the pilot branch, run the checks, and push that merge only if nothing fails
(`gates:pre` exits non-zero when a check fails, so the push never runs then). Your PR branch itself
stays one feature on the landing branch; only the pilot gets the merge:
@ -743,7 +771,7 @@ gate-keeper that reads each change before it lands, are jobs your harness gives
bun run gates:pre && git push origin HEAD:development
git switch regions/work'
```
If the merge stops on a conflict, run `git merge --abort`, fix the clash on your PR branch and land
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:
@ -751,8 +779,8 @@ gate-keeper that reads each change before it lands, are jobs your harness gives
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
```
7. **Publish:** fast-forward the landing branch to a pilot commit that passed the checks, tag it, and
deploy from the tag:
7. **Publish, when a person approves it:** 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 merge --ff-only <development-commit> # a commit that passed the checks
@ -760,12 +788,14 @@ gate-keeper that reads each change before it lands, are jobs your harness gives
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)).
([how to deploy](./documentation/DEPLOY.md)). 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 forge you host yourself: a swarm pushes often, and on a public host one wrong
push publishes work you did not mean to share. Give each app two servers: a **pilot server** that
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
@ -787,19 +817,19 @@ 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 *pilot lock* first catches up with
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.** A coordinator can put the merges onto the pilot 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. **The landing branch moves only when you publish**, after the checks pass on that exact version.
The pilot server follows the pilot branch; the production server rebuilds from the landing branch.
each change by its content before it joins the pilot. 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.
@ -812,7 +842,7 @@ That's how many plugins get built at the same time, in one project, without the
|---|---|
| **repo** | the project folder, with its full history |
| **development** (the pilot) | the shared working version: every agent's change is added here, one at a time |
| **main** (the landing) | the production version: it moves only when `development` is published |
| **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 |
| **merge** | combining your branch with everyone else's latest work; here it happens inside the pilot lock, in a throwaway copy of the pilot |
@ -936,7 +966,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

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

@ -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

@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 KiB

After

Width:  |  Height:  |  Size: 926 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,7 +9,7 @@
<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"/>
<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"/>
@ -41,7 +41,7 @@
<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">lands here</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"/>
@ -72,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"/>
@ -143,7 +143,7 @@
<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 each merge</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>
@ -158,7 +158,7 @@
<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="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"/>
@ -173,12 +173,12 @@
<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">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 the owner approves,</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">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 production server rebuilds from main</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>
@ -197,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

@ -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 29 bundle(s).
Generated from 30 bundle(s).
### One canonical exemplar per tier (generated — quote these)
@ -38,6 +38,7 @@ Generated from 29 bundle(s).
|---|---|---|
| `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` |

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

@ -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

@ -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

@ -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

@ -97,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)}`)
@ -116,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

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')
}

View file

@ -0,0 +1,163 @@
#!/usr/bin/env bun
/**
* check-pinned-fetch-fresh.mjs — a STALE cached copy of a pinned file never decides a print.
*
* THE DEFECT (2026-10-02, on a phone). The print-time files are pinned (poster-render.js, its classic copy,
* packet-strings.json, ne-places.json, india-states.json) and 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 (the phone's packet), a
* non-English packet would print in English, and India's state cards would be lost. library/packet/sha384.js
* fetchPinned fetches force-cache first and, on a copy that is not served or does not match, ONCE more with cache:
* 'reload'; every pinned loader calls it.
*
* A browser's HTTP cache is modelled: force-cache answers from it when it holds the URL, reload always asks the
* server and refreshes it. The SERVER holds the current files (public/dx-maps/assets); the CACHE holds a stale copy.
* A. fetchPinned: a stale cached copy → the current text, after exactly one fresh refetch;
* B. a cached copy that matches is used as is: ONE request, force-cache (the fast, offline-friendly path stays);
* C. a server that also serves the wrong bytes → rejected, err.integrity, after one fresh refetch;
* D. a file that is not served → rejected (not an integrity failure);
* E. loadPacketStrings with a stale cached table → the full table in every language (not the English fallback);
* F. loadIndiaStates with a stale cached table → India's 36 states (not a rejection);
* G. generateSheet with a stale cached generator → a drawn sheet (not an integrity refusal);
* H. wiring: every pinned loader fetches through fetchPinned with its own pin, and none fetches its pinned file
* directly; the classic-script path refreshes the HTTP cache and retries a refused tag once.
* `--self-test`: fetchPinned without its fresh refetch, one that never uses the cache, one that accepts a
* mismatch, and a loader that fetches its pinned file directly must each FAIL.
* Two-sided by hand at the change: the previous loaders (the pilot before this) fail E, F and G.
*/
import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
const REPO = new URL('..', import.meta.url).pathname
const ASSETS = join(REPO, 'public/dx-maps/assets')
const SHA_SRC = join(REPO, 'library/packet/sha384.js')
const LOADERS = {
'library/packet/posterSheets.js': ['POSTER_RENDER_SHA384', 'NE_PLACES_SHA384'],
'library/packet/packetStrings.js': ['PACKET_STRINGS_SHA384'],
'library/data/indiaStates.js': ['INDIA_STATES_SHA384'],
}
const PINNED_FILES = ['lib/poster-render.js', 'lib/packet-strings.json', 'boundaries/ne-places.json', 'people-groups/india-states.json']
const served = (p) => { const f = join(ASSETS, p); return existsSync(f) ? readFileSync(f, 'utf8') : null }
const asModule = (src) => import('data:text/javascript;base64,' + Buffer.from(src).toString('base64'))
const realFetch = globalThis.fetch
// The browser, modelled: { cached: path -> stale text } in the HTTP cache, { wrong: path -> text } served instead of
// the real file, { missing: [path] } not served at all.
function world(host, { cached = {}, wrong = {}, missing = [] } = {}) {
const http = new Map(Object.entries(cached).map(([p, t]) => [`${host}/${p}`, t]))
const requests = []
globalThis.fetch = async (input, init = {}) => {
const url = String(input)
if (!url.startsWith(host + '/')) return realFetch(input, init)
const path = url.slice(host.length + 1).split('?')[0]
const mode = init.cache || 'default'
requests.push({ path, mode })
let text = null
if (mode !== 'reload' && mode !== 'no-store' && http.has(url)) text = http.get(url)
else {
text = missing.includes(path) ? null : (path in wrong ? wrong[path] : served(path))
if (text !== null) http.set(url, text)
}
return text === null ? new Response('not found', { status: 404 }) : new Response(text, { status: 200 })
}
return { requests }
}
const stale = (p) => `/* an older build */\n${served(p)}`
async function auditFetchPinned(shaSrc) {
const fails = []
const { fetchPinned, sha384 } = await asModule(shaSrc)
if (typeof fetchPinned !== 'function') return ['A: library/packet/sha384.js exports no fetchPinned']
const P = 'lib/packet-strings.json', current = served(P), pin = await sha384(current)
// A
{ const w = world('https://a.test', { cached: { [P]: stale(P) } }); let got = null, err = null
try { got = await fetchPinned(`https://a.test/${P}`, pin) } catch (e) { err = e }
if (got !== current) fails.push(`A: a stale cached copy did not give the current file (${err ? `rejected: ${err.message}` : 'returned other bytes'})`)
else if (w.requests.length !== 2 || w.requests[0].mode !== 'force-cache' || w.requests[1].mode !== 'reload') fails.push(`A: expected force-cache then ONE reload, got ${JSON.stringify(w.requests)}`) }
// B
{ const w = world('https://b.test', { cached: { [P]: current } }); let got = null
try { got = await fetchPinned(`https://b.test/${P}`, pin) } catch { /* reported below */ }
if (got !== current || w.requests.length !== 1 || w.requests[0].mode !== 'force-cache') fails.push(`B: a matching cached copy should be used with ONE force-cache request, got ${JSON.stringify(w.requests)}`) }
// C
{ const bad = current.replace('"en"', '"en" ') + ' '
const w = world('https://c.test', { cached: { [P]: stale(P) }, wrong: { [P]: bad } }); let got = null, err = null
try { got = await fetchPinned(`https://c.test/${P}`, pin) } catch (e) { err = e }
if (got !== null || !err || err.integrity !== true) fails.push(`C: wrong bytes on the server were not refused as an integrity failure (${got !== null ? 'ACCEPTED' : err?.message})`)
else if (w.requests.length !== 2) fails.push(`C: expected exactly one fresh refetch before refusing, got ${w.requests.length} request(s)`) }
// D
{ world('https://d.test', { missing: [P] }); let err = null
try { await fetchPinned(`https://d.test/${P}`, pin) } catch (e) { err = e }
if (!err || err.integrity) fails.push(`D: an unserved file should reject as not served (${err ? err.message : 'it resolved'})`) }
globalThis.fetch = realFetch
return fails
}
async function auditLoaders() {
const fails = []
// E: the packet text
{ world('https://e.test', { cached: { 'lib/packet-strings.json': stale('lib/packet-strings.json') } })
const { loadPacketStrings } = await import(join(REPO, 'library/packet/packetStrings.js'))
const warn = console.warn; console.warn = () => {}
const t = await loadPacketStrings('https://e.test').finally(() => { console.warn = warn })
const src = JSON.parse(served('lib/packet-strings.json'))
if (Object.keys(t).join() === 'en' || t.zh?.zone_affinity_A017 !== src.zh?.zone_affinity_A017) fails.push(`E: a stale cached packet-strings.json made the packet English (got languages: ${Object.keys(t).join(',')})`) }
// F: India's states
{ world('https://f.test', { cached: { 'people-groups/india-states.json': stale('people-groups/india-states.json') } })
const { loadIndiaStates } = await import(join(REPO, 'library/data/indiaStates.js'))
let n = -1, err = null
try { n = (await loadIndiaStates('https://f.test')).states.length } catch (e) { err = e }
const want = JSON.parse(served('people-groups/india-states.json')).states.length
if (n !== want) fails.push(`F: a stale cached india-states.json lost India's states (${err ? `rejected: ${err.message}` : `${n} of ${want}`})`) }
// G: page 1's generator
{ world('https://g.test', { cached: { 'lib/poster-render.js': stale('lib/poster-render.js'), 'boundaries/ne-places.json': stale('boundaries/ne-places.json') } })
const { generateSheet, countryZone } = await import(join(REPO, 'library/packet/posterSheets.js'))
const zone = countryZone({ id: 'country-tcd', iso3: 'TCD', label: 'Chad', count: 1, region: 'Africa' })
const pins = [[12.1, 15.0], [13.8, 20.8], [9.1, 18.4]].map(([lat, lng], i) => ({ name: `Probe ${i + 1}`, lat, lng, slug: `probe-${i}`, country_code: 'TCD', population: 1000 }))
const adm0 = JSON.parse(served('boundaries/world-adm0.geojson'))
let html = '', err = null
const warn = console.warn; console.warn = () => {}
try { html = await generateSheet({ zone, pins, format: '3x3', seriesId: 'country', adm0, assetsBase: 'https://g.test', date: '2026-09-29' }) } catch (e) { err = e }
console.warn = warn
if (!(typeof html === 'string' && /<svg\b/.test(html))) fails.push(`G: a stale cached generator stopped the sheet from drawing (${err ? err.message.slice(0, 110) : 'no sheet'})`) }
globalThis.fetch = realFetch
return fails
}
function auditWiring(srcOf) {
const fails = []
for (const [file, pins] of Object.entries(LOADERS)) {
const s = srcOf(file)
if (!/import \{[^}]*\bfetchPinned\b[^}]*\} from '[^']*sha384\.js'/.test(s)) fails.push(`H: ${file} does not import fetchPinned`)
for (const pin of pins) if (!new RegExp(`fetchPinned\\([^)]*,\\s*${pin}\\)`).test(s)) fails.push(`H: ${file} does not fetch its ${pin} file through fetchPinned`)
if (/fetch\(url,\s*\{\s*cache:\s*'force-cache'\s*\}\)/.test(s)) fails.push(`H: ${file} still fetches a pinned file directly with force-cache`)
}
const ps = srcOf('library/packet/posterSheets.js')
const classic = (ps.match(/function loadClassic\(url\) \{[\s\S]*?\n\}/) || [''])[0]
if (!/fetch\(url, \{ cache: 'reload' \}\)/.test(classic) || !/return tag\(\)\s*\n?\s*\}\)/.test(classic)) fails.push('H: the classic-script path does not refresh the HTTP cache and retry a refused tag once')
for (const p of PINNED_FILES) if (served(p) === null) fails.push(`H: ${p} is not in the served tree (the gate would measure nothing)`)
return fails
}
const shaSrc = readFileSync(SHA_SRC, 'utf8')
const read = (f) => readFileSync(join(REPO, f), 'utf8')
if (process.argv.includes('--self-test')) {
const FOR = "for (const cache of ['force-cache', 'reload']) {"
const poisons = [
['fetchPinned without its fresh refetch', () => auditFetchPinned(shaSrc.replace(FOR, "for (const cache of ['force-cache']) {")), /^A: /],
['fetchPinned that never uses the cache', () => auditFetchPinned(shaSrc.replace(FOR, "for (const cache of ['reload', 'reload']) {")), /^B: /],
['fetchPinned that accepts a mismatch', () => auditFetchPinned(shaSrc.replace(' if (got === pin) return text\n', ' return text\n')), /^C: /],
['a loader that fetches its pinned file directly', () => auditWiring((f) => (f === 'library/packet/packetStrings.js'
? read(f).replace('fetchPinned(url, PACKET_STRINGS_SHA384)', "(await fetch(url, { cache: 'force-cache' })).text()") : read(f))), /^H: library\/packet\/packetStrings\.js/],
]
let missed = 0
for (const [name, run, rx] of poisons) {
const f = await run(); 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 auditFetchPinned(shaSrc), ...await auditLoaders(), ...auditWiring(read)]
if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:pinned-fetch-fresh (${f.length})`); process.exitCode = 1 }
else console.log('PASS check:pinned-fetch-fresh — a stale cached copy of a pinned file is refetched once, fresh: page 1 still draws, the packet keeps its language, India keeps its states; a matching cached copy is used as is; wrong bytes are refused; every pinned loader goes through fetchPinned')
}

View file

@ -11,7 +11,7 @@
* ★ IT CALLS WHAT THE BROWSER CALLS. The first version rebuilt the feed itself and so could not see a
* defect in the real entry points: generateCountrySheet passed no province boundaries, and a country
* beside India or China printed without them — while this gate, feeding them by hand, stayed green. So:
* · a UPG-100 zone through makeZoneSheet (data/zone-sheet-print.js), the map-only packet sheet, an India
* · a UPG-100 zone through makeZoneSheet (data/zone-sheet-print.js), the packet's page 1 (the full letter poster), an India
* zone so the ADM1 path runs;
* · a country through generateCountrySheet, NEPAL, which sits between India and China;
* both fetching the served boundaries exactly as the page does (file:// here, the asset URL there).
@ -110,7 +110,9 @@ const results = []
const { makeZoneSheet } = await import(join(REPO, 'plugin-designs/my-upg-100-list/data/zone-sheet-print.js'))
const zoneSheet = makeZoneSheet({ allRecords: async () => records, assetsBase: () => assetsBase, profileConfig: null, date: DATE })
const id = 'asia-india-gujarat'
results.push({ what: `${id} (packet sheet, makeZoneSheet)`, browser: await zoneSheet({ id }, 'packet'), node: nodeSheet({ FORMAT: 'packet', ONLY: id }, `${id}.packet.html`) })
// The UPG-100 packet's page 1 is the FULL letter poster (zone-sheet-print answers 'packet' with it), so the
// Node side is the letter format without SPARSE.
results.push({ what: `${id} (packet page 1 = the full letter poster, makeZoneSheet)`, browser: await zoneSheet({ id }, 'packet'), node: nodeSheet({ FORMAT: 'letter', ONLY: id }, `${id}.letter.html`) })
}
{ // a country, through the function the country wall calls — Nepal, between India and China
const { generateCountrySheet } = await import(join(REPO, 'library/packet/posterSheets.js'))

View file

@ -40,7 +40,7 @@ const G = (i) => ({ slug: `g${i}`, name: `Group ${i}`, country_code: 'MYS', prim
// which meant the assertion checked MY arithmetic against MY arithmetic and stayed green when the
// source's ROWS changed — it was the pagination check that caught that mutant, by luck. PER_PAGE is
// exported; the rest come out of the rendered geometry, so this compares the SOURCE to the sheet.
const { PER_PAGE, W, H } = mk(describe)
const { PER_PAGE, FD_FIRST, W, H } = mk(describe)
const SRC = read('library/packet/packet-render.js')
const numOf = (re, what) => {
const m = SRC.match(re)
@ -91,7 +91,10 @@ for (const n of [1, 19, 20, 21, 45, 100]) {
const rows = (html.match(/class="card"/g) || []).length
ok(rows === n, `${n} groups rendered ${rows} rows — a layout rewrite dropped people`)
const pages = R.listPages({ id: 'z', label: 'Z' }, Array.from({ length: n }, (_, i) => G(i)), new Map()).length
ok(pages === Math.ceil(n / 20), `${n} groups made ${pages} pages, expected ${Math.ceil(n / 20)}`)
// The first page gives its top to the explanation band (2026-10-02) and holds FD_FIRST rows; the rest PER_PAGE.
const want = 1 + Math.ceil(Math.max(0, n - FD_FIRST) / PER_PAGE)
ok(Number.isInteger(FD_FIRST) && FD_FIRST > 0 && FD_FIRST < PER_PAGE, `FD_FIRST should be a row count below PER_PAGE, got ${FD_FIRST}`)
ok(pages === want, `${n} groups made ${pages} pages, expected ${want} (${FD_FIRST} on the first, ${PER_PAGE} after)`)
}
// Each row must carry its three prayer boxes — the packet is a WORKING sheet, and a row without
@ -104,7 +107,11 @@ for (const n of [1, 19, 20, 21, 45, 100]) {
// ── 3. THE FIT LADDER PICKS THE SIZE IT CLAIMS, AND EVERY TIER FIRES ───────────────────────────
// Budgets are recomputed here from the same inputs rather than hardcoded, so this tests the LADDER
// and not my arithmetic twice.
const DESC_W = 1036, ADV = 0.52, LINES = 2
// DESC_W READ FROM THE SOURCE'S OWN INPUTS, not restated: it was a typed 1036 and went stale the moment the tally
// cells widened (PRAY_CELL 84 → 96). The row is the sheet less its margins, the photo, the three tally cells and
// two gutters.
const PRAY_CELL = numOf(/const PRAY_CELL = (\d+)/, 'PRAY_CELL')
const DESC_W = (W - 2 * M) - Math.min(CH - 8, 64) - 3 * PRAY_CELL - GUT * 2, ADV = 0.52, LINES = 2
const budget = (px) => Math.floor(DESC_W / (px * ADV)) * LINES
const probe = (txt) => {
const html = mk(() => txt).listPages({ id: 'z' }, [G(0)], new Map()).join('')
@ -114,7 +121,9 @@ const probe = (txt) => {
const words = (n) => 'word '.repeat(n).trim()
ok(probe(words(10)).px === 17, 'a short description did not render at the full 17px')
ok(probe(words(Math.floor(budget(17) / 5))).px === 17, 'a description just inside the 17px budget stepped down anyway')
ok(probe(words(Math.floor(budget(17) / 5) + 6)).px === 15,
// Between the 17px and the 15px budgets, wherever they fall for this row width: '+6 words' was 30 characters, and
// when the tally cells widened the gap between the two budgets shrank to 24, so the probe skipped 15 for 14.
ok(probe(words(Math.floor((budget(17) + budget(15)) / 2 / 5))).px === 15,
'a description over the 17px budget did not step to 15px')
const long = probe(words(Math.floor(budget(14) / 5) + 40))
ok(long.px === 14, `a very long description did not clamp at the smallest size (got ${long.px}px)`)

View file

@ -148,7 +148,7 @@ function assetsBaseFor(depth) {
// depth is measured from the WEB-ROOT; the tree root is one level in, hence depth - 1.
return DEST_CONFIG.assetsBaseUrl || `${'../'.repeat(Math.max(0, depth - 1))}assets`
}
// design note 2026-07-11: the staging TOP must not mention deployment orgs (doxa/GO/...). The visible
// The staging TOP must not mention deployment orgs (doxa/GO/...). The visible
// title is the PRODUCT title (config displayTitle, default org-free), never the deployment TREE_NAME
// (TREE_NAME stays in URLs/paths where it is a folder name, not branding).
const DISPLAY_TITLE = (typeof DEST_CONFIG.displayTitle === 'string' && DEST_CONFIG.displayTitle.trim())

View file

@ -14,7 +14,7 @@
* a rect×rect overlap of the sheet against .mapboxgl-ctrl-bottom-left/-right
* (their VISIBLE children) in every sheet state.
*
* WHY: "it feels wrong on my phone" is not evidence a fix can be checked against. This
* WHY: a sense that the sheet feels wrong on a phone is not evidence a fix can be checked against. This
* probe turns the gesture into a per-step table (finger delta vs measured sheet height),
* the release into an expected/actual state, and the attribution into overlap px² —
* run it BEFORE a fix (baseline json) and AFTER (same json shape) and diff.

View file

@ -1,7 +1,7 @@
<!--
ApproxPinNote.vue — the ONE wording every map with people-group pins uses (APPROXPIN, 2026-09-26).
The first sentence always: a dot is where a people is found, not a town or a boundary. The second only
The first sentence always: a dot marks a people group, not a town or a boundary. The second only
when the pin's point is SHARED, naming the other groups (from library/utils/colocation.js, built from the
roster the map draws), because "approximate" alone hides that some dots are the same dot.
Never shows the raw coordinate: six decimal places beside a caveat that says "approximate" reads as a
@ -21,8 +21,8 @@ const props = defineProps({
// vue-i18n, inside the custom-element boundary, and the reader gets an empty shadow root. Without it
// the English below renders, with the same placeholders filled.
const APPROX_FALLBACK = {
'pins.approximate': 'The dot marks where this people is found, not a town or a boundary.',
'pins.sharedPoint': 'It sits on the same point as {names} — {n} groups share this coordinate, so the position tells you the locality and not which group is where.',
'pins.approximate': 'The dot marks a people group, not a town or a boundary.',
'pins.sharedPoint': 'It sits on the same point as {names} — {n} groups share this dot.',
}
// C4: the guard is library/composables/useOptionalT.js now. The fallback MAP stays exactly as it was,
// passed in, so a bare host renders the same English it rendered before.

View file

@ -233,7 +233,7 @@ const packetOpen = ref(false)
const { scrimRef, scrimStyle } = useViewportScrim(exportMenuOpen)
// Feature flag: show the Export affordance. Gated here in the SHARED legend so
// one switch covers every bundle. It was false by an earlier coder decision;
// design note 2026-07-31 asked for the button back on the 100-list legend, and the
// the button came back on the 100-list legend (2026-07-31), and the
// export path is functional (the menu emits 'export' with 'poster' | 'packet',
// and a profile wires @export to a real download).
// A profile STILL has to opt in with :export-enabled="true" — this flag only

View file

@ -74,20 +74,27 @@ export function openPrintFrame(bodyHtml, { autoprint = false } = {}) {
* may ignore, and a cancel that removes the document if the reader walks away. A caller without its own progress UI
* passes none and gets the shared Print bar (showTapToPrint).
*
* ★ ON A PHONE, AFTERPRINT IS NOT THE END OF THE PRINT (2026-10-02). Android Chrome's window.print() does not block,
* and afterprint fires once the PREVIEW is built; the file "Save as PDF" writes is rendered later, after the reader
* taps Save, from the page as it is THEN. The document used to be dropped at afterprint, so the preview showed a
* packet's 11 pages and the saved PDF was the bare host page (one letter page with the page's own title). The
* document is now kept until the reader is BACK on the page (mountPrintRoot says how), and onRelease is called then.
*
* @returns Promise<'frame' | 'page' | 'needs-tap' | 'blocked'>
*/
export async function printDocument(html, { onNeedsTap = null, imageWaitMs = 20000, labels = null } = {}) {
export async function printDocument(html, { onNeedsTap = null, imageWaitMs = 20000, labels = null, onRelease = null } = {}) {
if (!isPhonePrint()) return openPrintFrame(html, { autoprint: true }) ? 'frame' : 'blocked'
const job = mountPrintRoot(html)
const job = mountPrintRoot(html, { onRelease })
if (!job) return 'blocked'
await job.imagesLoaded(imageWaitMs)
const act = typeof navigator !== 'undefined' ? navigator.userActivation : null
if (act && act.isActive === false) {
const printNow = () => { if (job.live()) window.print() }
const printNow = () => { if (job.live()) { job.printing(); window.print() } }
if (typeof onNeedsTap === 'function') onNeedsTap(printNow, job.drop)
else showTapToPrint(printNow, job.drop, labels || {})
return 'needs-tap'
}
job.printing()
window.print()
return 'page'
}
@ -140,7 +147,7 @@ function showTapToPrint(printNow, cancel, labels = {}) {
export const PRINT_ROOT_ID = 'dxm-print-root'
let currentJob = null
function mountPrintRoot(html) {
function mountPrintRoot(html, { onRelease = null } = {}) {
if (typeof document === 'undefined' || !document.body || typeof DOMParser === 'undefined') return null
if (currentJob) currentJob.drop()
// EVERY BUNDLE CARRIES ITS OWN COPY OF THIS FILE, so currentJob only knows this bundle's root: a root (and its
@ -169,11 +176,35 @@ function mountPrintRoot(html) {
document.head.appendChild(style)
document.body.appendChild(root)
let ceiling = null
const onAfter = () => setTimeout(drop, 0)
// ★ KEPT UNTIL THE READER IS BACK, never dropped at afterprint (printDocument says why). From the moment print() is
// called (printing(), not at mount: the Print tap that starts a late print must not count), the reader is back when
// the page is shown again after being hidden, the window is focused again after losing focus, or the page is
// touched (the system print UI covers the page, so a touch there means it has gone). A touch in the first
// TOUCH_SETTLE_MS after print() does not count (a double tap while the preview builds). It goes BACK_GRACE_MS after
// that, since a slow phone can still be writing the file after its dialog closes; if the page goes AWAY again during
// the grace (the print UI opened after an early touch), the pending drop is cancelled and the next "back" counts. A
// new print replaces it, a cancelled one drops it, and FRAME_CEILING_MS is the ceiling. On screen it is display:none,
// so keeping it shows nothing.
let ceiling = null, settle = null, armed = false, away = false, leaving = false, settling = false
const back = () => { if (leaving) return; leaving = true; clearTimeout(ceiling); ceiling = setTimeout(drop, BACK_GRACE_MS) }
const awayAgain = () => { away = true; if (leaving) { leaving = false; clearTimeout(ceiling); ceiling = setTimeout(drop, FRAME_CEILING_MS) } }
const onVisibility = () => { if (document.visibilityState === 'hidden') awayAgain(); else if (away) back() }
const onBlur = () => { awayAgain() }
const onFocus = () => { if (away) back() }
const onTouch = () => { if (!settling) back() }
const job = {
live: () => currentJob === job,
drop,
printing: () => {
if (armed) return
armed = true
settling = true
settle = setTimeout(() => { settling = false }, TOUCH_SETTLE_MS)
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('blur', onBlur)
window.addEventListener('focus', onFocus)
window.addEventListener('pointerdown', onTouch, true)
},
imagesLoaded: (ms) => {
const pending = [...root.querySelectorAll('img')].filter((img) => !img.complete)
if (!pending.length) return Promise.resolve()
@ -186,12 +217,16 @@ function mountPrintRoot(html) {
}
function drop() {
clearTimeout(ceiling)
window.removeEventListener('afterprint', onAfter)
clearTimeout(settle)
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('blur', onBlur)
window.removeEventListener('focus', onFocus)
window.removeEventListener('pointerdown', onTouch, true)
root.remove()
style.remove()
if (currentJob === job) currentJob = null
if (onRelease) { const done = onRelease; onRelease = null; try { done() } catch { /* the caller's own clean-up */ } }
}
window.addEventListener('afterprint', onAfter)
ceiling = setTimeout(drop, FRAME_CEILING_MS)
currentJob = job
return job
@ -201,6 +236,10 @@ function mountPrintRoot(html) {
export const FRAME_KEEP_MS = 60000
/** The absolute ceiling for a print frame whose load never ends. */
export const FRAME_CEILING_MS = 600000
/** A phone's in-page print document goes this long after the reader is back on the page (mountPrintRoot). */
export const BACK_GRACE_MS = 60000
/** A touch this soon after print() is not the reader coming back (a double tap while the preview builds). */
export const TOUCH_SETTLE_MS = 2000
/**
* Print a rendered poster IMAGE, at its real paper size, through the same in-place frame.
@ -234,14 +273,11 @@ export function printImageBlob(blob, { widthIn = 8.5, heightIn = 11, title = '',
+ 'html,body{margin:0;padding:0;background:#fff}'
+ 'img{display:block;width:100%;height:100%;object-fit:contain}</style></head>'
+ `<body><img src="${url}" alt="${esc(alt || title)}"></body></html>`
// A PHONE PRINTS THE PAGE (printDocument). The object URL lives until the page has printed, or the ceiling.
// A PHONE PRINTS THE PAGE (printDocument). The object URL lives as long as the in-page document does: until the
// reader is back on the page, a new print or the ceiling, NEVER afterprint (a saved PDF is rendered after it).
if (isPhonePrint()) {
let clock = null
const onAfter = () => setTimeout(release, 0)
const release = () => { clearTimeout(clock); window.removeEventListener('afterprint', onAfter); try { URL.revokeObjectURL(url) } catch { /* gone */ } }
window.addEventListener('afterprint', onAfter)
clock = setTimeout(release, FRAME_CEILING_MS)
printDocument(html, { onNeedsTap, labels }).then((how) => { if (how === 'blocked') release() }, release)
const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }
printDocument(html, { onNeedsTap, labels, onRelease: release }).then((how) => { if (how === 'blocked') release() }, release)
return true
}
const win = openPrintFrame(html, { autoprint: true })

View file

@ -21,7 +21,7 @@
* either edge can be summoned where you reach for it. A HOVERED strip is
* pinned: it never auto-hides (even while you scroll) until you leave it.
*
* RIGHT is the primary side on EVERY form factor (design note, 2026-07-30): people
* RIGHT is the primary side on EVERY form factor (since 2026-07-30): people
* expect a scrollbar on the right, and a scroll hint that moves between edges
* depending on device is the one thing it must not do. An earlier version flipped
* to LEFT on mobile for thumb reach; consistency won. Both strips stay summonable
@ -71,7 +71,7 @@ export function useEdgeScrollAffordance(wrapper, opts = {}) {
const show = (e) => { e.style.opacity = '1' }
const hide = (e) => { e.style.opacity = '0' }
// Primary side: RIGHT on BOTH desktop and mobile (design note, 2026-07-30).
// Primary side: RIGHT on BOTH desktop and mobile (since 2026-07-30).
// It used to flip to LEFT on mobile on a thumb-reach argument, but that made the
// affordance appear on a different edge depending on device — the one place a
// scroll hint must be predictable. People expect a scrollbar on the right, and

View file

@ -15,7 +15,7 @@
// t.byShapeName → Map(shapeName → entry) (the map's ADM1 features carry shapeName)
// t.byIso → Map(iso → entry)
import { INDIA_STATES_SHA384 } from './indiaStates.integrity.js'
import { sha384 } from '../packet/sha384.js'
import { fetchPinned } from '../packet/sha384.js'
const PAGE_BASE = globalThis.document?.baseURI || undefined
const cache = new Map()
@ -30,10 +30,11 @@ export function loadIndiaStates(assetsBase) {
try { url = indiaStatesUrl(assetsBase) } catch (e) { return Promise.reject(e) }
if (!cache.has(url)) {
const p = (async () => {
const res = await fetch(url, { cache: 'force-cache' })
if (!res.ok) throw new Error(`india-states.json not served (${res.status})`)
const text = await res.text()
if ((await sha384(text)) !== INDIA_STATES_SHA384) throw new Error('india-states.json failed its integrity check')
// force-cache, and once more fresh on a mismatch: a stale cached copy after a re-pin must not cost a returning
// reader India's state cards (../packet/sha384.js fetchPinned says why).
let text
try { text = await fetchPinned(url, INDIA_STATES_SHA384) }
catch (e) { throw new Error(`india-states.json ${e?.message || e}`) }
const data = JSON.parse(text)
const states = Array.isArray(data.states) ? data.states : []
return {

View file

@ -0,0 +1,15 @@
{
"title": "ملصقات باركوا الهند",
"rangeBadge60x96": "شارات النطاق · 60×96 (5×8 قدم)",
"count": "{count} مجموعة شعبية",
"desktopOnly": "سطح المكتب فقط حاليًا — لم يَثبُت بعد أن هذا الملصق الكبير يُطبع بأمان على الهاتف.",
"experimental": "تجريبي",
"intro": "كل مجموعة شعبية في الهند على ورقة واحدة قابلة للطباعة، تُولَّد مباشرةً وتُعرَض كأوراقها القابلة للطباعة مع شرائط اللصق — بالطريقة نفسها التي يعرض بها عرض المقاسات التنسيقات الكبيرة.",
"print": "اطبع هذا الملصق",
"attempt": "شارات النطاق · 60×96 (5×8 قدم) · 3 أوراق",
"stat": "{groups} مجموعة · {badges} شارة نطاق · 0 تداخل",
"generating": "جارٍ توليد الملصق من البيانات الحية…",
"error": "تعذَّر توليد الملصق: {msg}",
"photos": "فقط {real} من {total} مجموعة في الهند لديها صورة في البيانات؛ المستند المطبوع يحملها، بينما الصورة المسطحة لا تستطيع. هذا التصميم المرقَّم يطبع الأسماء بدون صور لتكون كل مجموعة متساوية وواضحة.",
"posterHeader": "باركوا الهند باسم يسوع"
}

View file

@ -151,7 +151,7 @@
"messaging": "تطبيق المراسلة الأول (البلد)",
"messagingNote": "الأكثر تنزيلًا في متجر App Store الخاص بالبلد: iOS فقط، ترتيب تنزيلات لا استخدام.",
"province": "الهند والصين: الولاية أو المقاطعة",
"provinceNote": "تقريبي: بحسب مكان وجود الشعب، لا بحسب مدينة.",
"provinceNote": "تقريبي: مأخوذ من نقطة المجموعة على الخريطة، لا من مدينة.",
"region": "المنطقة",
"religion": "الديانة",
"wayIn": "المدخل حين لا يوجد شيء بلغتهم"
@ -200,11 +200,11 @@
"aria": "خريطة المجموعات العرقية، مظلّلة بحسب نسبة استخدام الإنترنت في كل بلد"
},
"notice": {
"pin": "تشير النقطة إلى مكان وجود هذا الشعب في {country}، لا إلى مدينة.",
"pins": "تشير النقطة إلى مكان وجود الشعب في بلده، لا إلى مدينة. {n} مجموعة تشترك في نقطة مع مجموعة أخرى.",
"pin": "تمثّل النقطة هذا الشعب، لا مدينة.",
"pins": "تمثّل النقطة شعبًا، لا مدينة. {n} مجموعة تشترك في نقطة مع مجموعة أخرى.",
"shares": "تشترك في نقطتها مع {n} مجموعة أخرى.",
"sources": "المصادر:",
"standing": "النقاط تقريبية (مكان وجود الشعب في بلده، لا مدينة). أرقام الإنترنت هي أرقام البلد أو الولاية الهندية، لا أرقام المجموعة أبدًا. «وُجد» تعني وُجد في المصادر التي فُحصت."
"standing": "النقاط تقريبية: النقطة تمثّل شعبًا، لا مدينة. أرقام الإنترنت هي أرقام البلد أو الولاية الهندية، لا أرقام المجموعة أبدًا. «وُجد» تعني وُجد في المصادر التي فُحصت."
},
"panel": {
"charts": "رسوم بيانية",

View file

@ -1,12 +1,12 @@
{
"map": {
"title": "حيث تعيش الشعوب غير المُبشَّرة",
"title": "عناقيد نقاط الشعوب",
"bands": {
"named": "مركز مُسمّى",
"multi": "متعدد الشعوب",
"single": "شعب واحد"
},
"note": "النقطة عنقود من مراكز الشعوب ضمن نحو 25 كم، وليست حدودًا بلدية. النقاط المُسمّاة مأخوذة من معجم جغرافي موثّق.",
"note": "النقطة عنقود من نقاط الشعوب ضمن نحو 25 كم، وليست حدودًا بلدية. النقاط المُسمّاة مأخوذة من معجم جغرافي موثّق.",
"unnamed": "موقع بلا اسم — {lat}، {lng}",
"close": "إغلاق",
"country": "الدولة",
@ -17,13 +17,13 @@
"unknown": "غير معروف",
"pgHere": "الشعوب هنا",
"languages": "اللغات",
"sheetNote": "عنقود من مراكز الشعوب ضمن نحو 25 كم، وليس حدودًا بلدية. النقاط المُسمّاة مأخوذة من معجم جغرافي موثّق (Nominatim/OSM)؛ والنقاط بلا اسم تُظهر الإحداثيات بدل اسم مختلَق.",
"sheetNote": "عنقود من نقاط الشعوب ضمن نحو 25 كم، وليس حدودًا بلدية. النقاط المُسمّاة مأخوذة من معجم جغرافي موثّق (Nominatim/OSM)؛ والنقاط بلا اسم تُظهر الإحداثيات بدل اسم مختلَق.",
"national": "أرقام الإنترنت وطنية.",
"engineMissing": "تعذّر تحميل محرك الخريطة."
},
"list": {
"title": "المدن الرئيسية",
"intro": "حيث تعيش الشعوب غير المُبشَّرة: مراكز سكانية مُسمّاة من معجم جغرافي موثّق: {named}، ضمن عناقيد محددة الموقع: {clusters}.",
"intro": "عناقيد نقاط الشعوب: عناقيد مُسمّاة (الأسماء من معجم جغرافي موثّق): {named}، ضمن عناقيد محددة الموقع: {clusters}.",
"filters": {
"named": "المراكز السكانية المُسمّاة",
"multi": "عناقيد متعددة الشعوب",
@ -33,7 +33,7 @@
"peopleGroups": "الشعوب: {n}",
"caveat": {
"rowLead": "ما الذي يمثّله الصف.",
"row": "عنقود من مراكز الشعوب ضمن نحو 25 كم، وليس حدودًا بلدية. تدل النقاط على مكان تمركز الشعب، لا على مستوطنة، لذلك لا تُسمّى العناقيد بالاستنتاج أبدًا: حُدِّدت المراكز متعددة الشعوب وعددها {n} عبر {source} (ODbL، مع ذكر المصدر في كل صف)، وتبقى البقية بلا اسم بدل التخمين.",
"row": "عنقود من نقاط الشعوب ضمن نحو 25 كم، وليس حدودًا بلدية. تمثّل كل نقطة شعبًا، لا بلدة، لذلك لا تُسمّى العناقيد بالاستنتاج أبدًا: حُدِّدت المراكز متعددة الشعوب وعددها {n} عبر {source} (ODbL، مع ذكر المصدر في كل صف)، وتبقى البقية بلا اسم بدل التخمين.",
"internet": "تُعرض كسياق: بيانات الاتصال لكل مدينة غير متاحة للعموم بهذا المستوى، ولا يجوز قراءة الرقم الوطني على أنه قياس للمدينة.",
"scope": "تحمل هذه الصفحة مواقع الشعوب وبيانات الاتصال فقط — لا بيانات عن كنائس أو مؤمنين أو أماكن اجتماع، وذلك عن قصد."
},

View file

@ -7,7 +7,7 @@
"gospelResources": "موارد الإنجيل",
"languageFamilies": "العائلات اللغوية",
"prayer": "تقدم الصلاة",
"upg100List": "قائمة UPG 100 الخاصة بي",
"upg100List": "قائمة UPG100 الخاصة بي",
"batchSelect": "تحديد متعدد"
},
"header": {
@ -58,8 +58,8 @@
"languages": "اللغات"
},
"tabInfo": {
"upg100Region": "المنطقة — ترتّب قوائم UPG-100 حسب منطقة العالم (أفريقيا، الهند، الشرق الأوسط، الصمّ، …). الأعداد هي مجموع UUPG والسكان لكل قائمة تحتها.",
"upg100Zone": "قوائم UPG-100 — كل واحدة هي قائمة صلاة للشعوب غير الموصول إليها في منطقة ما، وهي ما ترسمه الخريطة وتلوّنه. تُطبع كل قائمة ككُتيّب خاص بها.",
"upg100Region": "المنطقة — ترتّب قوائم UPG100 حسب منطقة العالم (أفريقيا، الهند، الشرق الأوسط، الصمّ، …). الأعداد هي مجموع UUPG والسكان لكل قائمة تحتها.",
"upg100Zone": "قوائم UPG100 — كل واحدة هي قائمة صلاة للشعوب غير الموصول إليها في منطقة ما، وهي ما ترسمه الخريطة وتلوّنه. تُطبع كل قائمة ككُتيّب خاص بها.",
"upg100Group": "مجموعة شعبية — المجموعات غير الموصول إليها داخل القائمة، الأكثر حاجة للصلاة أولاً. كل نقطة على الخريطة هي واحدة منها.",
"family": "عائلة اللغة — مجموعة لغات منحدرة من أصل مشترك (مثل الهندية الأوروبية، الأفروآسيوية).",
"language": "اللغة — نظام تواصل يستخدمه شعب ما (مثل العربية، البنغالية، الهندية).",
@ -80,7 +80,7 @@
},
"export": {
"poster": "ملصق",
"packet": "UPG 100",
"packet": "UPG100",
"packetExplain": "قائمة صلاة من نحو ٥٠ إلى ١٠٠ شعب غير مبلَّغ — يستطيع شخص واحد أن يحمل واحدة.",
"packetAria": "تفاصيل حزمة الصلاة",
"packetFull": "تفاصيل كاملة",

View file

@ -14,7 +14,7 @@
"p2": "اعثر على الفجوات. انتقل إلى «المشاركة» لترى أين بدأ العمل وأين لم يُخدَم أحد بعد. تلك الفجوات هي المناطق ذات الأولوية للتحشيد.",
"p3": "ابحث في جزء واحد من العالم في كل مرة. ضيِّق حسب المنطقة أو اللغة أو الديانة، أو ابحث في القائمة بالاسم. تتبع القائمة الخريطة، فتقرأ الأسماء التي وراء النقاط.",
"p4": "تعرّف على شعب. اضغط على نقطة لترى بلده وعدد سكانه ولغته وديانته، ثم استخدم «صلِّ من أجلهم» لتبدأ الصلاة من أجل ذلك الشعب باسمه.",
"p5": "اجعله بسيطًا. تتابع هذه الخريطة التقدّم على مستوى بسيط وبلمحة. وحين تحتاج إلى البيانات الكاملة، انتقل إلى «الصورة الكاملة»؛ ولموارد الصلاة القابلة للطباعة، انتقل إلى «المناطق» أو «قائمة UPG-100»."
"p5": "اجعله بسيطًا. تتابع هذه الخريطة التقدّم على مستوى بسيط وبلمحة. وحين تحتاج إلى البيانات الكاملة، انتقل إلى «الصورة الكاملة»؛ ولموارد الصلاة القابلة للطباعة، انتقل إلى «المناطق» أو «قائمة UPG100»."
},
"regions-dashboard": {
"bottom": "لنحو 170 دولة ملصقها وحزمتها ومجموعة بطاقاتها. حشِّد مجموعة واحدة لتصلّي من أجل كل دولة، فتُغطّى كل دولة بالصلاة.",
@ -22,7 +22,7 @@
"p2": "ابدأ قريبًا من بيتك، بالتلاميذ والكنائس المحلية. ثم انتقل إلى القادة والعاملين المحليين على الأرض في تلك الدول، وأرسل إليهم ملصق الدولة وحزمتها وبطاقاتها ليطبعوها حيث هم.",
"p3": "حزمة الصلاة هي الأكثر استراتيجية بين الثلاثة: كل ما تحتاجه للصلاة من أجل دولة، في حزمة واحدة قابلة للطباعة.",
"p4": "تفيد البطاقات أكثر حين تكون لديك مجموعة توزّعها عليها: شعب واحد في كل بطاقة.",
"p5": "تناسب حزم الدول قاعدة تحشيد أكبر، أو دولة واحدة تريد أن تراها مُبلَّغة. وإن كان التحشيد بطيئًا، فقوائم UPG-100 هي المكان الأبسط للبدء."
"p5": "تناسب حزم الدول قاعدة تحشيد أكبر، أو دولة واحدة تريد أن تراها مُبلَّغة. وإن كان التحشيد بطيئًا، فقوائم UPG100 هي المكان الأبسط للبدء."
},
"prayer-map": {
"bottom": "تنمو حركة الصلاة حين يعرض كل شريك الخريطة نفسها، فضع هذه الخريطة على كل موقع في شبكتك.",
@ -36,7 +36,7 @@
"bottom": "أجلس يسوع الخمسة آلاف في مجموعات من خمسين ومئة قبل أن يطعمهم (لوقا 9:14). وهذه القوائم تفعل الشيء نفسه لنحو 2,100 شعب غير مُبلَّغ: حين تُقسَّم إلى قوائم بهذا الحجم، تصير المهمة كلها حملًا يستطيع شخص واحد أن يحمله.",
"p1": "27 قائمة تعني 27 ملصقًا و27 حزمة صلاة و27 مجموعة بطاقات. ضع واحدة في يد كل واحد من 27 تلميذًا أو كنيسة أو قائدًا محليًا، فيُصلّى من أجل كل شعب بالاسم.",
"p2": "أعطِ كل قائمة لمن سيواظب على الصلاة من أجلها. ويستطيع القادة المحليون أن يصلّوا بلغات الشعوب نفسها.",
"p3": "هذا يتبع ممارسة مُثبتة في الحركات: يصلّي التلاميذ بانتظام من أجل خمسين إلى مئة شخص يعرفونهم (العائلة والأصدقاء والجيران وزملاء العمل والدراسة). وقائمة UPG-100 هي الحمل نفسه، لأناس لم يلتقوهم قط.",
"p3": "هذا يتبع ممارسة مُثبتة في الحركات: يصلّي التلاميذ بانتظام من أجل خمسين إلى مئة شخص يعرفونهم (العائلة والأصدقاء والجيران وزملاء العمل والدراسة). وقائمة UPG100 هي الحمل نفسه، لأناس لم يلتقوهم قط.",
"p4": "ابدأ بقائمة واحدة وشخص واحد. تحمل الحزمة كل ما تحتاجه تلك القائمة في مطبوعة واحدة، وتساعد البطاقات حين تتقاسم مجموعة القائمة."
},
"cities-internet": {

View file

@ -3,7 +3,7 @@
"pinHint": {
"tapDot": "اضغط على نقطة ملوّنة — لا يزال هناك من يحتاج إلى الصلاة"
},
"upg100WallHeading": "قائمتي UPG 100 — قوائم صلاة وملصقات",
"upg100WallHeading": "قائمتي UPG100 — قوائم صلاة وملصقات",
"upg100WallTag": "تعبئة للصلاة",
"upg100WallIntroA": "كيف تُطعم جمعًا من خمسة آلاف؟ أجلسهم يسوع جماعاتٍ من خمسين ومن مئة، فصار المستحيل قابلًا للمشاركة (لوقا ٩:١٤). فعلنا المثل مع ٢١٠٦ شعوب غير مبلَّغة: ٢٧ قائمة من نحو خمسين إلى مئة. تُطبع كل قائمة حزمةَ صلاة وملصقًا، ليحمل ٢٧ تلميذًا أو كنيسة أو قائدًا محليًا كلٌّ واحدة — مصلّين لأجلهم ",
"upg100WallIntroEmph": "باسم شعبهم",

View file

@ -1,4 +1,4 @@
{
"approximate": "تشير النقطة إلى المكان الذي يوجد فيه هذا الشعب، وليست بلدة أو حدودًا.",
"sharedPoint": "تقع في النقطة نفسها مع {names} — {n} مجموعات تتشارك هذا الإحداثي، لذا يدل الموقع على المنطقة لا على مكان كل مجموعة."
"approximate": "تمثّل النقطة شعبًا، لا بلدة ولا حدودًا.",
"sharedPoint": "تقع في النقطة نفسها مع {names} — {n} مجموعات تتشارك هذه النقطة."
}

View file

@ -23,7 +23,7 @@
"within": "ضمن {selection}",
"allData": "كل بيانات DOXA"
},
"upg100Placeholder": "ابحث عن شعب أو قائمة UPG-100 أو منطقة…",
"upg100Placeholder": "ابحث عن شعب أو قائمة UPG100 أو منطقة…",
"tabPlaceholder": {
"languageFamilies": "ابحث في عائلة اللغة أو اللغة أو اللهجة/التنوع",
"affinityBlocks": "ابحث في كتلة التقارب أو المجموعة أو الشعب",

View file

@ -18,7 +18,7 @@
"block": "كتلة WAGF",
"country": "الدولة",
"upg100Region": "المنطقة",
"upg100Zone": "قوائم UPG-100",
"upg100Zone": "قوائم UPG100",
"upg100Group": "مجموعة شعبية",
"selectView": "اختر العرض",
"views": "العروض",

View file

@ -4,7 +4,7 @@
"selectionCardsFailed": "فشل إنشاء بطاقات التحديد: {reason}",
"selectionPosterNeedsZone": "الملصقات متاحة لكل منطقة في مفتاح الخريطة — استخدم «قائمة الصلاة» لتحديد على الخريطة",
"mapDataFailed": "تعذّر تحميل بيانات الخريطة",
"infoLead": "قائمة UPG-100 على خريطة واحدة: الشعوب غير المُبشَّرة، مجمّعة في مناطق يمكنك أن تتبنّاها بالصلاة.",
"infoLead": "قائمة UPG100 على خريطة واحدة: الشعوب غير المُبشَّرة، مجمّعة في مناطق يمكنك أن تتبنّاها بالصلاة.",
"infoTakeaway": "لكل منطقة: ملصق للحائط، وحزمة صلاة للتوزيع، ومجموعة من بطاقات الصلاة. المثال أدناه هو المنطقة التي تضم أكبر عدد من الناس في شعوب لا يعمل أحد بعد على غرس كنائس بينها.",
"selectionColourGroup": "لون التحديد",
"selectionColour": "تلوين التحديد {hex}",
@ -16,8 +16,8 @@
"copySelection": "نسخ",
"exportCsv": "تصدير CSV",
"copyNotRegroupable": "لا يمكن إعادة تجميعها معًا: انسخ ولايات أو مقاطعات بلد واحد، أو البلدان، كلًّا على حدة",
"displayName": "ملصقات قائمة UPG 100",
"wallHeading": "ملصقات قائمة UPG 100",
"displayName": "ملصقات UPG100",
"wallHeading": "ملصقات UPG100",
"wallIntroA": "كيف تُطعم جمعًا من خمسة آلاف؟ أجلسهم يسوع جماعاتٍ من خمسين ومن مئة، فصار المستحيل قابلًا للمشاركة (لوقا ٩:١٤). فعلنا المثل مع {n} شعوب غير مبلَّغة: ٢٧ قائمة من نحو خمسين إلى مئة. تُطبع كل قائمة حزمةَ صلاة وملصقًا، ليحمل ٢٧ تلميذًا أو كنيسة أو قائدًا محليًا كلٌّ واحدة — مصلّين لأجلهم ",
"peopleStillLoading": "لا تزال الشعوب قيد التحميل، حاول مرة أخرى بعد لحظة",
"posterDesignTerritory": "ملصق منطقة UPG-100"

View file

@ -0,0 +1,15 @@
{
"title": "„Segne Indien“-Poster",
"rangeBadge60x96": "Nummern-Marken · 60×96 (5×8 Fuß)",
"count": "{count} Volksgruppen",
"desktopOnly": "Vorerst nur am Desktop — es ist noch nicht erwiesen, dass dieses große Poster sicher auf einem Telefon druckt.",
"experimental": "experimentell",
"intro": "Jede Volksgruppe Indiens auf einem druckbaren Bogen, live erzeugt und als ihre druckbaren Bögen mit Klebestreifen gezeigt — so wie die Größen-Ansicht von Poster Design die großen Formate zeigt.",
"print": "Dieses Poster drucken",
"attempt": "Nummern-Marken · 60×96 (5×8 Fuß) · 3 Bögen",
"stat": "{groups} Gruppen · {badges} Nummern-Marken · 0 Überlappungen",
"generating": "Poster wird aus den Live-Daten erzeugt…",
"error": "Poster konnte nicht erzeugt werden: {msg}",
"photos": "Nur {real} von {total} indischen Gruppen haben ein Foto in den Daten; ein gedrucktes Dokument trägt sie, ein flaches Bild nicht. Dieses nummerierte Design druckt Namen ohne Fotos, damit jede Gruppe gleich und lesbar ist.",
"posterHeader": "Segne Indien im Namen Jesu"
}

View file

@ -151,7 +151,7 @@
"messaging": "Top-Messenger (Land)",
"messagingNote": "Am meisten heruntergeladen im Apple App Store des Landes: nur iOS, ein Download-Rang, keine Nutzung.",
"province": "Indien und China: Bundesstaat oder Provinz",
"provinceNote": "Ungefähr: danach zugeordnet, wo ein Volk lebt, nicht nach einem Ort.",
"provinceNote": "Ungefähr: aus dem Punkt der Gruppe auf der Karte abgeleitet, nicht aus einem Ort.",
"region": "Region",
"religion": "Religion",
"wayIn": "Zugang, wenn nichts in ihrer Sprache vorliegt"
@ -200,11 +200,11 @@
"aria": "Karte der Volksgruppen, schattiert danach, wie viele Menschen im Land das Internet nutzen"
},
"notice": {
"pin": "Der Punkt markiert, wo dieses Volk in {country} lebt, nicht einen Ort.",
"pins": "Ein Punkt markiert, wo ein Volk in seinem Land lebt, nicht einen Ort. {n} Gruppen teilen sich einen Punkt mit einer anderen Gruppe.",
"pin": "Der Punkt steht für dieses Volk, nicht für einen Ort.",
"pins": "Ein Punkt steht für ein Volk, nicht für einen Ort. {n} Gruppen teilen sich einen Punkt mit einer anderen Gruppe.",
"shares": "Er teilt seinen Punkt mit {n} anderen Gruppe(n).",
"sources": "Quellen:",
"standing": "Punkte sind ungefähr (wo ein Volk in seinem Land lebt, nicht ein Ort). Internetzahlen gelten für das Land oder den indischen Bundesstaat, nie für die Gruppe. „Gefunden“ heißt: in den geprüften Quellen gefunden."
"standing": "Punkte sind ungefähr: Ein Punkt steht für ein Volk, nicht für einen Ort. Internetzahlen gelten für das Land oder den indischen Bundesstaat, nie für die Gruppe. „Gefunden“ heißt: in den geprüften Quellen gefunden."
},
"panel": {
"charts": "Diagramme",

View file

@ -1,12 +1,12 @@
{
"map": {
"title": "Wo die unerreichten Volksgruppen leben",
"title": "Cluster von Volksgruppen-Punkten",
"bands": {
"named": "benanntes Zentrum",
"multi": "mehrere Gruppen",
"single": "eine Gruppe"
},
"note": "Ein Punkt ist ein Cluster von Volksgruppen-Mittelpunkten im Umkreis von ~25 km, keine Gemeindegrenze. Benannte Punkte stammen aus einem belegten Ortsverzeichnis.",
"note": "Ein Punkt ist ein Cluster von Volksgruppen-Punkten im Umkreis von ~25 km, keine Gemeindegrenze. Benannte Punkte stammen aus einem belegten Ortsverzeichnis.",
"unnamed": "unbenannter Ort — {lat}, {lng}",
"close": "Schließen",
"country": "Land",
@ -17,13 +17,13 @@
"unknown": "unbekannt",
"pgHere": "Volksgruppen hier",
"languages": "Sprachen",
"sheetNote": "Ein Cluster von Volksgruppen-Mittelpunkten im Umkreis von ~25 km, keine Gemeindegrenze. Benannte Punkte stammen aus einem belegten Ortsverzeichnis (Nominatim/OSM); unbenannte zeigen Koordinaten statt eines erfundenen Ortsnamens.",
"sheetNote": "Ein Cluster von Volksgruppen-Punkten im Umkreis von ~25 km, keine Gemeindegrenze. Benannte Punkte stammen aus einem belegten Ortsverzeichnis (Nominatim/OSM); unbenannte zeigen Koordinaten statt eines erfundenen Ortsnamens.",
"national": "Internetzahlen sind NATIONAL.",
"engineMissing": "Die Karten-Engine konnte nicht geladen werden."
},
"list": {
"title": "Schlüsselstädte",
"intro": "Wo die unerreichten Volksgruppen leben: benannte Bevölkerungszentren aus einem belegten Ortsverzeichnis: {named}, in lokalisierten Clustern: {clusters}.",
"intro": "Cluster von Volksgruppen-Punkten: benannte Cluster (Namen aus einem belegten Ortsverzeichnis): {named}, in lokalisierten Clustern: {clusters}.",
"filters": {
"named": "Benannte Bevölkerungszentren",
"multi": "Cluster mit mehreren Gruppen",
@ -33,7 +33,7 @@
"peopleGroups": "Volksgruppen: {n}",
"caveat": {
"rowLead": "Was eine Zeile ist.",
"row": "Ein Cluster von Volksgruppen-Mittelpunkten im Umkreis von ~25 km, keine Gemeindegrenze. Die Punkte markieren, wo eine Volksgruppe ihren Mittelpunkt hat, nicht eine Siedlung, daher werden Cluster nie durch Schlussfolgerung benannt: Die {n} Zentren mit mehreren Volksgruppen wurden über {source} bestimmt (ODbL, pro Zeile zitiert), der Rest bleibt unbenannt, statt geraten zu werden.",
"row": "Ein Cluster von Volksgruppen-Punkten im Umkreis von ~25 km, keine Gemeindegrenze. Jeder Punkt steht für eine Volksgruppe, nicht für einen Ort, daher werden Cluster nie durch Schlussfolgerung benannt: Die {n} Zentren mit mehreren Volksgruppen wurden über {source} bestimmt (ODbL, pro Zeile zitiert), der Rest bleibt unbenannt, statt geraten zu werden.",
"internet": "Sie dienen als Kontext: Konnektivität pro Stadt ist in diesem Maßstab nicht öffentlich verfügbar, und eine nationale Zahl darf nicht als Messung für eine Stadt gelesen werden.",
"scope": "Diese Seite enthält nur Standorte von Volksgruppen und Konnektivität — bewusst keine Daten zu Gemeinden, Gläubigen oder Treffpunkten."
},

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