Compare commits

..

11 commits

Author SHA1 Message Date
Doxa Maps
8550142a05 Rebuild poster-design (preview on B1 + B2), and regenerate the tree and SRI 2026-10-01 09:09:57 +00:00
Doxa Maps
eff0945ab9 Merge remote-tracking branch 'origin/country-posters-plugin/india-whole-2' into poster-design-plugin/b3
# Conflicts:
#	package.json
2026-10-01 09:09:10 +00:00
Doxa Maps
dea74fa6ee Merge remote-tracking branch 'origin/development' into poster-design-plugin/b3 2026-10-01 09:08:58 +00:00
Doxa Maps
d1a2dc145b Poster Design: Styles draws the Bless wall panels from the shared panel geometry
wall.bless-panels drew nothing. It now draws the ladder's 5 x 8 ft size
(60x96: three 60 x 36 in sheets, 6 in joins) through panelSheets'
planPanels and screenLayout, the same plan the Bless print uses: the
sheets pulled apart, each join a red glue band with a dashed edge, a
banner on the first sheet, a footer on the last, and rows of portrait
cards inside each sheet's content box, never crossing a join.

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

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

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

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

The composite header's Share button takes the header form
(shape="rounded-square"), as Big Picture's top bar does.
2026-10-01 08:24:39 +00:00
Doxa Maps
13e22fe649 i18n posterDesign: the Sizes tab's words, in all 11 locales
The Sizes tab now draws every ladder size, so it needs its own words: the
intro, the to-scale lineup, the paper names (Letter / A4), the dimension
patterns in feet and inches, the per-size rows (sheets, each sheet, glued
overlap, photos beside names, how it prints) and the note that the sheets
show the 3 x 3 layout. The placeholder sentence that said the tab was
waiting for the size table is removed. Only the posterDesign area changes.
2026-10-01 08:24:39 +00:00
Doxa Maps
b462d8d155 Merge remote-tracking branch 'origin/country-posters-plugin/india-whole-2' into poster-design-plugin/b3
# Conflicts:
#	library/packet/poster-render.integrity.js
#	package.json
2026-10-01 08:24:28 +00:00
Doxa Maps
ddefb6f30a Merge remote-tracking branch 'origin/studio/poster-sizes' into poster-design-plugin/b3 2026-10-01 08:23:47 +00:00
706 changed files with 6098 additions and 31039 deletions

253
README.md
View file

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

View file

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

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 217 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 184 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 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: 244 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: 224 KiB

After

Width:  |  Height:  |  Size: 216 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: 220 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 232 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: 219 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: 219 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 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: 230 KiB

After

Width:  |  Height:  |  Size: 219 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: 223 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: 224 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 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: 211 KiB

After

Width:  |  Height:  |  Size: 197 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 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: 238 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: 172 KiB

After

Width:  |  Height:  |  Size: 213 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 213 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 192 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 189 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: 162 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 184 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: 166 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 226 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 197 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 106 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 236 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-10-02",
"generated": "2026-09-27",
"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 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."
"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."
},
"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-10-02",
"generated": "2026-09-27",
"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 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."
"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."
},
"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 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.",
"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.",
"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 pins 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 centroids within ~25km, NOT a municipal boundary. An empty place_name is an unnamed cluster, never an invented one.",
"resolution_caveat": "resolved:false means the language LABEL did not match an ISO 639-3 record here — its resource status is UNKNOWN, not absent.",
"pools_caveat": "Resource status is from eBible + JESUS Film only. GRN and FCBH audio are key-blocked."
},

View file

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

View file

@ -106,8 +106,8 @@ Details, per host (Cloudflare Pages, jsDelivr, any web server) and per embed sty
Copy the built `dx-maps/` folder into your site's `public/` (renaming it is fine) and your site becomes
the host: no second server and no cross-site setup, because the maps and the page share one origin.
**Live example:** DX-life's drop-in branch copies the folder into its `public/` and fills `/research` with
Deep Research.
**Live example:** DX-life's drop-in branch copies the folder into its `public/` and fills `/research` with the
Research Gallery.
**The two routes, on your laptop:** DX-life has one branch per route: `research-gallery-drop-in` (this
section, no Docker) and `research-gallery-subdomain` (the dx-maps container on its own address, with

View file

@ -4,23 +4,22 @@ Rudder (`plugin-designs/rudder-voice`) is voice-to-text that runs on your own co
local engine (`backend/rudder-engine`, Go) that drives NVIDIA's NeMo-Speech.cpp speech server. The
desktop app is a Tauri window around the built web tree, with the engine started as a **sidecar**.
This page is written for an agent following it cold. **Read "What is proven" first**: the Linux app is
proven and in use; the macOS and Windows path has not been run yet, and the steps say so where that is
the case.
This page is written for an agent following it cold. **Read "What is proven" first**: most of the
desktop path has never been run by anyone, and the steps say so where that is the case.
## What is proven, and what is not
| Piece | State |
|---|---|
| The web tree (`bun run build` → `public/dx-maps/`) | Proven. Every landing builds it. |
| The generic Tauri shell (`deployment/src-tauri/`) | Exists. It opens Deep Research, builds only a Linux `.deb`, and has never been packaged on macOS or Windows. |
| The generic Tauri shell (`deployment/src-tauri/`) | Exists. It opens the Research Gallery, builds only a Linux `.deb`, and has never been packaged on macOS or Windows. |
| The engine on Linux (`bun run dev`, laptop mic) | Proven. |
| The engine taking the **webview's** microphone (`RUDDER_AUDIO=browser`) | Proven on Linux: audio streamed over `/api/rudder/stream` came back as the full test sentence. |
| The engine **compiling** for macOS (arm64, x86_64) and Windows (x86_64) | Proven by cross-compiling. It has **never been run** on either. |
| The engine accepting a WebSocket from the Tauri webview (`tauri://localhost`, `http://tauri.localhost`) | Proven against a running engine; another site's origin is refused. |
| NeMo-Speech.cpp runtime for macOS / Windows | Published by NVIDIA (release v0.1.0: `macos-aarch64-cpu`, `macos-aarch64-metal`, `macos-x86_64-cpu`, `windows-x86_64-cpu` / `-cuda` / `-vulkan`). **Never run here.** |
| `bun run desktop:build rudder-voice` on **Linux** | Proven: produced `Rudder_0.1.0_amd64.deb` (8.8 MB) holding the app and the `rudder-engine` sidecar side by side. The installed Linux app has since been launched and is in use: it is used to dictate the work on these maps. |
| The shell starting and stopping the engine sidecar | Built (`deployment/src-tauri/src/main.rs`), compiled into that installer. The installed Linux app works (the row above); not yet run on macOS or Windows. |
| `bun run desktop:build rudder-voice` on **Linux** | Proven: produced `Rudder_0.1.0_amd64.deb` (8.8 MB) holding the app and the `rudder-engine` sidecar side by side. The app has **not been launched** from it yet (no display on the build box). |
| The shell starting and stopping the engine sidecar | Built (`deployment/src-tauri/src/main.rs`), compiled into that installer; not yet run. |
| First-run model download | **Not built.** Run `setup-runtime.sh` into the app's data folder by hand (step 7). |
## 1. Prerequisites
@ -125,6 +124,6 @@ macOS and Windows the app must do the same on first run:
- `desktop:build` has run on Linux only; the app it produced has not been launched yet. Nothing has run on macOS or Windows.
- The macOS/Windows runtime archive layout, and whether the engine's derived path matches it.
- The first-run download, including resuming an interrupted 742 MB download.
- Mapbox basemaps in Deep Research need the token host to allow the Tauri origin
- Mapbox basemaps in the Research Gallery need the token host to allow the Tauri origin
(`deployment/src-tauri/README.md`). Rudder itself needs no token.
- If the engine crashes, its speech server may keep running (and holding ~1 GB) until the app quits.

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 a *people group*, not a town or a boundary,
- **Placement is approximate.** A dot marks where a *people* is found, 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

@ -65,7 +65,7 @@ The built tree has TWO kinds of page, and they need DIFFERENT framing policy:
| **Direct embed** (the product) | `<bundle>/<profile>/index.html`, `<bundle>/<profile>/<instance>/index.html` — the bare map pages the manifest's `iframeUrl` points at | **`*`** — framable on any partner site (that's the whole point) |
| **Staging** (internal preview) | tree root `index.html`, per-bundle `<bundle>/index.html` | **`'self'`** — preview only |
Deep Research (`doxa-maps-spa/doxa-maps-spa/index.html`) is a direct-embed page like any
The Research Gallery (`doxa-maps-spa/doxa-maps-spa/index.html`) is a direct-embed page like any
other leaf. Its full embed contract (script and iframe) is in
`plugin-designs/doxa-maps-spa/EMBED.md`.

View file

@ -1,4 +1,4 @@
# Architecture — UPG100 List poster generation
# Architecture — UPG-100 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 / `UPG100 List` | Series line on the sheet. |
| `SERIES_INDEX` / `SERIES_NAME` | derived / `List of 100 UPG's` | Series line on the sheet. |
## Two things that will bite you

View file

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

View file

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

View file

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

View file

@ -7,15 +7,14 @@ being duplicated.
## The one rule that keeps everything clean
> **Could serve more than one map → build it in the shared library**, parameterized
> **Parameterized + genuinely reused by more than one map → the shared library**
> (`library/`, alias `@map`).
> **One-off or experimental → it lives in your app-profile folder** (`plugin-designs/<your-map>/`).
> **Not reused → it lives in your app-profile folder** (`plugin-designs/<your-map>/`).
**Reusable by default.** If a component could serve more than one map, build it once in the shared
library, driven by props, and land it through the build owner, so other maps adopt it instead of
copying it. A one-off or experimental piece stays in your map's folder. A new pattern starts as a pilot
on one plugin, is approved, and is then rolled out by each owner. The rule in full:
[`skills/plugin-designer`](../../skills/plugin-designer/SKILL.md), section 3.
"**Local by default, promote deliberately.**" When in doubt, keep your change local to your
own map; something graduates into the shared library only once a *second* map genuinely
needs it. Full reasoning — the promotion / orphan-audit rule — is in
[`../documentation/explanation.md`](../explanation.md).
## Start here

View file

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

View file

@ -104,7 +104,7 @@ rsync -a --delete public/dx-maps/ <your-host-checkout>/dx-maps/
it can see and whether it finds itself. To check that what you published is *current*, run
`bun run check:catalog-currency`.
**Your plugin surfaces itself.** Deep Research (`doxa-maps-spa`) reads the built `manifest.json`
**Your plugin surfaces itself.** The Research Gallery (`doxa-maps-spa`) reads the built `manifest.json`
and puts every bundle it doesn't already curate into its folded **Experimental** shelf, with the label and
description from the manifest — no code edit. Promoting it to a curated tab is an optional edit to
`TAB_DEFS` and `NAV_MODEL` in `plugin-designs/doxa-maps-spa/`. One row per bundle: a bundle's extra

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;
- UPG100 Posters;
- UPG-100 List;
- 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: UPG100 Posters
bun run phone:from-tree # unset: the UPG-100 list
```
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 UPG100 Posters page). The bundle and the map engine take under 2 s of that; the rest is basemap
on the UPG-100 list page). The bundle and the map engine take under 2 s of that; the rest is basemap
tiles and data files such as the country-boundary file (1,866 KB raw, 597 KB gzipped). Compression
cuts every visit; caching means a returning reader doesn't download the same files again.
@ -415,7 +415,6 @@ every third-party script is pinned with an integrity hash (`library/constants/cd
| `style-src` | `'unsafe-inline'`, the same CDN hosts | each plugin styles its own shadow root with a `<style>` element |
| `worker-src` | `blob:` | the map engines run their tile workers from a blob URL |
| `connect-src` | the tile/style hosts you use (`https://tiles.openfreemap.org` for MapLibre, `https://*.mapbox.com` for Mapbox), your data API | tiles, styles, glyphs, data |
| `connect-src` (Bible Posters) | `https://bible.helloao.org` | the public-domain Bible text, fetched per book at render time (no text is bundled) |
| `img-src` | `data:` `blob:` and the same tile hosts | sprites and raster tiles |
---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 931 KiB

After

Width:  |  Height:  |  Size: 858 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

View file

@ -1,4 +1,4 @@
<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">
<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">
<defs>
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2563eb"/></marker>
<marker id="aS" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#7c3aed"/></marker>
@ -9,8 +9,8 @@
<path id="agL" d="M 194,850 A 556,556 0 0 1 1306,850"/>
<path id="mqL" d="M 679,884 A 71,71 0 0 0 821,884"/>
</defs>
<rect width="1500" height="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>
<rect width="1500" height="2086" fill="#ffffff"/>
<text x="750" y="54" text-anchor="middle" font-size="34" font-weight="800" fill="#0f172a">How dx-maps is built: one owner per plugin, one landing at a time</text>
<circle cx="750" cy="850" r="540" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-dasharray="3 7" opacity=".75"/>
<circle cx="750" cy="850" r="355" fill="none" stroke="#15803d" stroke-width="2.2" opacity=".85"/>
<circle cx="750" cy="850" r="606" fill="none" stroke="#7c3aed" stroke-width="2" stroke-dasharray="8 7" opacity=".6"/>
@ -29,8 +29,8 @@
<circle cx="750" cy="850" r="120" fill="#fef2f2" stroke="#dc2626" stroke-width="3" stroke-dasharray="10 6"/>
<path id="mqT" d="M 646,850 A 104,104 0 0 1 854,850" fill="none"/>
<path id="mqB" d="M 644,850 A 106,106 0 0 0 856,850" fill="none"/>
<text font-size="13" font-weight="800" fill="#b91c1c" letter-spacing="1.6"><textPath href="#mqT" startOffset="50%" text-anchor="middle">PILOT LOCK</textPath></text>
<text font-size="12" font-weight="700" fill="#b91c1c" letter-spacing="1.2"><textPath href="#mqB" startOffset="50%" text-anchor="middle">ONE MERGE AT A TIME</textPath></text>
<text font-size="13" font-weight="800" fill="#b91c1c" letter-spacing="1.6"><textPath href="#mqT" startOffset="50%" text-anchor="middle">LANDING LOCK</textPath></text>
<text font-size="12" font-weight="700" fill="#b91c1c" letter-spacing="1.2"><textPath href="#mqB" startOffset="50%" text-anchor="middle">ONE LANDING AT A TIME</textPath></text>
<rect x="617" y="846" width="26" height="20" rx="4" fill="#dc2626"/>
<path d="M 622,846 v -6 a 8,8 0 0 1 16,0 v 6" fill="none" stroke="#dc2626" stroke-width="3.5"/>
<circle cx="630" cy="856" r="3" fill="#fff"/>
@ -38,10 +38,9 @@
<path d="M 862,846 v -6 a 8,8 0 0 1 16,0 v 6" fill="none" stroke="#dc2626" stroke-width="3.5"/>
<circle cx="870" cy="856" r="3" fill="#fff"/>
<circle cx="750" cy="850" r="72" fill="#166534" stroke="#14532d" stroke-width="3"/>
<text x="750" y="842" text-anchor="middle" font-size="24" font-weight="800" fill="#fff">pilot</text>
<text x="750" y="862" text-anchor="middle" font-size="13" font-weight="700" fill="#dcfce7">development</text>
<text x="750" y="882" text-anchor="middle" font-size="12.5" fill="#dcfce7">every change</text>
<text x="750" y="897" text-anchor="middle" font-size="12.5" fill="#dcfce7">joins here</text>
<text x="750" y="850" text-anchor="middle" font-size="18" font-weight="800" fill="#fff">development</text>
<text x="750" y="872" text-anchor="middle" font-size="12.5" fill="#dcfce7">every change</text>
<text x="750" y="888" text-anchor="middle" font-size="12.5" fill="#dcfce7">lands here</text>
<text 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 +71,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">UPG100</text>
<text x="885.9" y="1185.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Posters</text>
<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>
<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"/>
@ -116,20 +115,20 @@
<text x="614.1" y="556.0" text-anchor="middle" font-size="12" font-weight="800" fill="#155e75">Experimental</text>
<text x="614.1" y="570.0" text-anchor="middle" font-size="10.5" fill="#155e75">shelf · 5 plugins</text>
<circle cx="543.4" cy="351.1" r="40" fill="#ecfeff" stroke="#0891b2" stroke-width="2.5"/>
<text x="543.4" y="349.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">an agent</text>
<text x="543.4" y="364.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">per plugin</text>
<text x="543.4" y="349.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">roaming</text>
<text x="543.4" y="364.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">agent</text>
<circle cx="690" cy="248" r="56" fill="#f5f3ff" stroke="#7c3aed" stroke-width="3.5"/>
<text x="690" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#4c1d95">shepherd</text>
<text x="690" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#4c1d95">coordinator</text>
<circle cx="812" cy="248" r="56" fill="#f1f5f9" stroke="#334155" stroke-width="3.5"/>
<text x="812" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#1e293b">repo agent</text>
<text x="884" y="192" font-size="17" fill="#4c1d95">one shepherd: every agent talks to it; it orders the merges;</text>
<text x="884" y="216" font-size="17" fill="#1e293b">a repo agent per repo runs its branches and watches the work</text>
<text x="750" y="1502" text-anchor="middle" font-size="20" font-weight="800" fill="#1e3a8a">one agent per plugin, in its own branch · the repo agent reads every change</text>
<text x="812" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#1e293b">gate-keeper</text>
<text x="884" y="192" font-size="17" fill="#4c1d95">a coordinator puts the landings in order;</text>
<text x="884" y="216" font-size="17" fill="#1e293b">a gate-keeper checks each one by its content</text>
<text x="750" y="1502" text-anchor="middle" font-size="20" font-weight="800" fill="#1e3a8a">one owner per plugin, in its own branch · a second reader for every change</text>
<rect x="36" y="110" width="304" height="280" rx="14" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2"/>
<circle cx="64" cy="142" r="11" fill="#166534" stroke="#14532d" stroke-width="2.5"/>
<text x="88" y="147" font-size="15" fill="#334155">pilot branch (development)</text>
<text x="88" y="147" font-size="15" fill="#334155">development — shared branch</text>
<circle cx="64" cy="174" r="11" fill="#fef2f2" stroke="#dc2626" stroke-width="2.5"/>
<text x="88" y="179" font-size="15" fill="#334155">pilot lock — one at a time</text>
<text x="88" y="179" font-size="15" fill="#334155">landing lock — one at a time</text>
<circle cx="64" cy="206" r="11" fill="#fffbeb" stroke="#d97706" stroke-width="2.5"/>
<text x="88" y="211" font-size="15" fill="#334155">Plugin Studio — the one repo</text>
<circle cx="64" cy="238" r="11" fill="#f0fdf4" stroke="#15803d" stroke-width="2.5"/>
@ -139,11 +138,11 @@
<circle cx="64" cy="302" r="11" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
<text x="88" y="307" font-size="15" fill="#334155">agent — owns one plugin</text>
<circle cx="64" cy="334" r="11" fill="#f5f3ff" stroke="#7c3aed" stroke-width="2.5"/>
<text x="88" y="339" font-size="15" fill="#334155">shepherd — orders merges</text>
<text x="88" y="339" font-size="15" fill="#334155">coordinator — orders landings</text>
<circle cx="64" cy="366" r="11" fill="#f1f5f9" stroke="#334155" stroke-width="2.5"/>
<text x="88" y="371" font-size="15" fill="#334155">repo agent — one per repo</text>
<text x="88" y="371" font-size="15" fill="#334155">gate-keeper — checks landings</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 joins the pilot — 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 lands — 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>
@ -155,30 +154,30 @@
<rect x="425.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
<circle cx="459.0" cy="1634" r="18" fill="#dc2626"/>
<text x="459.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">2</text>
<text x="487.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Repo agent reads</text>
<text x="445.0" y="1680" font-size="15" fill="#475569">the repo agent reads every</text>
<text x="487.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Second reader</text>
<text x="445.0" y="1680" font-size="15" fill="#475569">another agent reads every</text>
<text x="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 join</text>
<text x="445.0" y="1724" font-size="15" fill="#475569">before it may land</text>
<text x="750.0" y="1682" text-anchor="middle" font-size="26" font-weight="800" fill="#dc2626">→</text>
<rect x="770.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
<circle cx="804.0" cy="1634" r="18" fill="#dc2626"/>
<text x="804.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">3</text>
<text x="832.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">One at a time</text>
<text x="790.0" y="1680" font-size="15" fill="#475569">in the lock: merge the pilot,</text>
<text x="790.0" y="1680" font-size="15" fill="#475569">in the lock: merge development,</text>
<text x="790.0" y="1702" font-size="15" fill="#475569">rebuild, run every check;</text>
<text x="790.0" y="1724" font-size="15" fill="#475569">push only if nothing fails</text>
<text x="1095.0" y="1682" text-anchor="middle" font-size="26" font-weight="800" fill="#dc2626">→</text>
<rect x="1115.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
<circle cx="1149.0" cy="1634" r="18" fill="#dc2626"/>
<text x="1149.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">4</text>
<text x="1177.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Publish to landing</text>
<text x="1135.0" y="1680" font-size="15" fill="#475569">landing (main), production:</text>
<text x="1135.0" y="1702" font-size="15" fill="#475569">moves only when a person approves,</text>
<text x="1177.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Promote to main</text>
<text x="1135.0" y="1680" font-size="15" fill="#475569">main is production: it moves</text>
<text x="1135.0" y="1702" font-size="15" fill="#475569">only when the owner approves,</text>
<text x="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 landing server rebuilds from main</text>
<text x="750" y="1856" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">main → ONE build → one web folder → three outputs</text>
<text x="750" y="1882" text-anchor="middle" font-size="16" fill="#475569">the dev preview follows development · the published site follows main</text>
<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,9 +196,4 @@
<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>
<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>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View file

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

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

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

View file

@ -24,8 +24,6 @@ and a change here can break other maps.
`import … from '@map/components/ResearchMapFilterPanel.vue'`
- **ResearchMapSideMenu** — ResearchMapSideMenu.vue — Side drawer specific to the research-map profile.
`import … from '@map/components/ResearchMapSideMenu.vue'`
- **ShellLanguageMenu** — ShellLanguageMenu — the language menu a header-off shell puts in VerticalTabsLayout's #phone-end
`import … from '@map/components/ShellLanguageMenu.vue'`
- **SideMenuDrawer** — SideMenuDrawer.vue — Slide-in side panel for the map application.
`import … from '@map/components/SideMenuDrawer.vue'`
- **ViewSelect** — ViewSelect.vue — the "SELECT VIEW" switcher: a fieldset + legend wrapping a row of
@ -98,10 +96,6 @@ and a change here can break other maps.
`import … from '@map/components/poster/PosterDialog.vue'`
- **PosterPreview** — PosterPreview.vue — scaled live preview of the poster composition.
`import … from '@map/components/poster/PosterPreview.vue'`
- **PosterSection** — PosterSection.vue — ONE section of a poster wall: its head (the name, the count, the collapse toggle) and its
`import … from '@map/components/poster/PosterSection.vue'`
- **PosterSectionView** — PosterSectionView.vue — ONE poster section on its own: the profile behind <dxm-poster-section>.
`import … from '@map/components/poster/PosterSectionView.vue'`
- **PosterWall** — PosterWall.vue — THE SHARED POSTER WALL.
`import … from '@map/components/poster/PosterWall.vue'`
- **FooterSlot** — FooterSlot.vue — STUB. Poster-rendering slot for FooterSlot.
@ -237,8 +231,6 @@ and a change here can break other maps.
`import … from '@map/composables/useSharedMapToken.js'`
- **useTextHighlight** — highlightRanges(value, indices) — wrap each matched index range in
`import … from '@map/composables/useTextHighlight.js'`
- **useViewAddress** — useViewAddress.js — the page's ADDRESS for the view a reader is on: which app tab, which view inside it, which
`import … from '@map/composables/useViewAddress.js'`
### Utilities (pure helper functions)
@ -272,16 +264,10 @@ and a change here can break other maps.
`import … from '@map/utils/html.js'`
- **languageLabel** — languageLabel.js — turn the API's comma-inverted language label into the natural reading.
`import … from '@map/utils/languageLabel.js'`
- **languageRequest** — languageRequest.js — how a shell asks its HOST PAGE before it changes language.
`import … from '@map/utils/languageRequest.js'`
- **loadPinnedUmd** — loadPinnedUmd — load a PINNED, integrity-checked UMD library and capture it WITHOUT touching the host
`import … from '@map/utils/loadPinnedUmd.js'`
- **peopleGroupName** — peopleGroupName.js — a people group's name in the reader's language, or English if it was machine-translated.
`import … from '@map/utils/peopleGroupName.js'`
- **peopleGroupPoints** — peopleGroupPoints.js — SHARED (@map/utils/peopleGroupPoints.js).
`import … from '@map/utils/peopleGroupPoints.js'`
- **publicEmbed** — publicEmbed.js: the Share menu's Embed codes for THIS plugin, from the tree's PUBLIC address.
`import … from '@map/utils/publicEmbed.js'`
- **scrollport** — findScrollportAncestor — the nearest ancestor that actually scrolls.
`import … from '@map/utils/scrollport.js'`
- **viewportScrim** — viewportScrim.js — make a position:fixed full-screen SCRIM actually cover the VIEWPORT,

View file

@ -120,7 +120,7 @@ A URL that returns a public token as JSON (`{token|tk|accessToken}`) or as a bar
found; a token baked at build time wins.
- **The studio:** the reader's URL (localStorage). The studio fetches it itself, and the fetched
token then travels as `tk` / `window.MAP_TOKEN`.
- **Plugins:** none reads `profile-config.tokenUrl` today. The studio and Deep Research
- **Plugins:** none reads `profile-config.tokenUrl` today. The studio and the Research Gallery
forward it, but no element consumes it (see the open points below).
## 5. People-group list: `'doxa'` | `'unreached'` | `'frontier'` | `'frontier-500k'` | `'unengaged'`

View file

@ -48,7 +48,7 @@ Smallest first. Which bundle is the current example of each: **[reference-capabi
| 2 | **preset-element** (prop-table) | `parameterized` | one profile + a table of named presets; each row is a ready-made map with its own embed page | the bundle ships `<name>.instances.json` — `{ "instances": [{ "id", "title", "props" }] }`; one page per row at `<bundle>/<profile>/<id>/index.html` |
| 3 | **multi-view-element** (monolithic) | `monolithic` | one profile holding several screens (tabs or steps), one shown at a time | declared: `<profile>.meta.json` → `{ "type": "monolithic" }` |
| 4 | **composite-element** (nested elements) | `nested-elements-mfe` | a profile that places other plugins together and ships the set as one new element | declared: `<profile>.meta.json` → `{ "type": "nested-elements-mfe", "composes": [ … ] }` |
| 5 | **app-shell** (nested SPAs) | `nested-mfe-spa` | the outer frame — rail, header, navigation — that mounts any of the others; e.g. Deep Research (`doxa-maps-spa`) and the Plugin Studio | declared the same way; a compiled Vue custom element like every other rung |
| 5 | **app-shell** (nested SPAs) | `nested-mfe-spa` | the outer frame — rail, header, navigation — that mounts any of the others; e.g. the Research Gallery (`doxa-maps-spa`) and the Plugin Studio | declared the same way; a compiled Vue custom element like every other rung |
**Tiers 3–5 are always declared, never guessed** (either spelling; see `TYPE_ALIASES` in
`internal/plugin-gallery/discover.mjs`). A folder's apparent simplicity does not decide its tier: an
@ -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 UPG100 Posters profiles share one tag). A pass count alone cannot catch an SPA fallback that renders
(the two UPG-100 profiles share one tag). A pass count alone cannot catch an SPA fallback that renders
*a* map for every URL.
- **Why SRI identity, not HTTP status.** A platform's app URL can return 200 with a sign-in page; comparing
each live bundle's bytes against the manifest's `integrity` hash is a cryptographic identity proof that

View file

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

View file

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

View file

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

View file

@ -112,45 +112,6 @@ ok(!noCountry.length && resolveTerritory({ shapeName: 'Mahārāshtra' }).country
ok(!disagree.length, `Copy's regroup warning disagrees with apply-grouping: ${disagree.join('; ')}`,
`the regroup warning matches apply-grouping's own verdict in all ${Object.keys(REGROUP_CASES).length} cases (India, China, countries, and the three it refuses)`)
// ── A4. A STALLED people-group load can never hang Copy ─────────────────────────────────────────────────
// Copy races the people-group detection against COPY_WAIT_MS through settleWithin(); past it, it copies the territory
// lines alone (they need no network) and says why. The self-test: a promise that NEVER settles must still resolve to
// the marker in time; a value passes through; a failure still rejects (its own message is kept).
{
const MARK = {}, t0 = Date.now()
// The gate's own backstop: if settleWithin waited for ever, this reads 'GATE-TIMEOUT' and FAILS by name.
const stalled = await Promise.race([X.settleWithin(new Promise(() => {}), 60, MARK), new Promise((r) => setTimeout(() => r('GATE-TIMEOUT'), 2000))])
const quick = await X.settleWithin(Promise.resolve('v'), 1000, MARK)
const failed = await X.settleWithin(Promise.reject(new Error('down')), 1000, MARK).then(() => 'resolved', (e) => e.message)
ok(stalled === MARK && Date.now() - t0 < 1000 && quick === 'v' && failed === 'down',
`settleWithin: a stalled source gave ${stalled === MARK ? 'the marker' : JSON.stringify(stalled)}, a value gave ${quick}, a failure gave ${failed}`,
'a NEVER-settling people-group load still lets Copy settle in time (and a value / a failure pass through)')
}
// ── A5. A FAILED load never shows the reader a raw browser message ─────────────────────────────────────
// The second Copy after the 30 s abort flashed "signal is aborted without reason". The self-test aborts a REAL fetch
// (the signal is aborted before the request starts, so nothing is sent) and makes a real one fail (a refused
// localhost port, bounded at 2 s); both must read as the keyed stall text. A keyed error keeps its own text. The NEGATIVE CONTROL is
// the old path, errorText without asReaderError, which must still show the raw message, so this test can see the defect.
{
const { keyedError, errorText } = await import(pathToFileURL(P('library/i18n/keyedError.js')).href)
const en = JSON.parse(readFileSync(P('library/i18n/locales/en/upgMap.json'), 'utf8'))
const tr = (k, p = {}) => { const v = k.startsWith('upgMap.') ? en[k.slice(7)] : undefined; return v == null ? k : v.replace(/\{(\w+)\}/g, (m, x) => (p[x] != null ? String(p[x]) : m)) }
const stillLoading = () => keyedError('upgMap.peopleStillLoading', 'the people groups are still loading, try again in a moment')
const aborted = await fetch('http://127.0.0.1:9/', { signal: AbortSignal.abort() }).then(() => null, (e) => e)
// Bounded: a host running the discard service ACCEPTS port 9 and would hang; a timeout is a failure too.
const refused = await fetch('http://127.0.0.1:9/', { signal: AbortSignal.timeout(2000) }).then(() => null, (e) => e)
const keyed = keyedError('upgMap.selectionAdm1NoData', 'no people-group data for {names}', { names: 'Yunnan' })
const shown = (e) => errorText(X.asReaderError(e, stillLoading), tr)
const want = en.peopleStillLoading
ok(aborted && aborted.name === 'AbortError' && /abort/i.test(errorText(aborted, tr)),
`NEGATIVE CONTROL: an aborted fetch gave ${aborted && aborted.name} "${aborted && errorText(aborted, tr)}" on the old path, so the test cannot see the defect`,
'negative control: on the old path an aborted fetch shows the raw "' + (aborted ? errorText(aborted, tr) : '') + '"')
ok(shown(aborted) === want && refused && shown(refused) === want && shown(keyed) === tr('upgMap.selectionAdm1NoData', { names: 'Yunnan' }),
`a failed load reads: aborted "${shown(aborted)}", refused "${refused && shown(refused)}", keyed "${shown(keyed)}"`,
'an aborted or refused people-group fetch reads as the stall text; a keyed error keeps its own')
}
// ── A6. A STATE-ONLY selection titles its Prayer list / Card deck ───────────────────────────────────────
// Development threw "Reduce of empty array" for a state-only selection: the title named zones and countries only, so
// a selection of Gujarāt alone had NO names. On the REAL served table: Gujarāt alone is titled "Gujarāt"; a state +
@ -183,21 +144,6 @@ ok(!disagree.length, `Copy's regroup warning disagrees with apply-grouping: ${di
`a state-only selection is titled "Gujarāt"; a state + a country names both; a province with no table is not named; Mizoram alone reads "${empty}"`)
}
// ── A7. A selection too big for a PHONE'S PRINT starts nothing there ────────────────────────────────────
// Printing the whole selection (2,209 groups, 113 sheets) crashed the page in a 2.5 GB browser; 1,857 finished. The
// ceiling (half of that, for a phone tab's memory) is recorded beside its measurement in data/selection-export.js.
// Self-test: a phone over the ceiling is blocked; a desktop over it is not; a phone at or under it (India alone, 905)
// is not. The wiring (arm C) checks each of Prayer list, Card deck and Poster asks BEFORE it prints.
{
const c = X.PHONE_GROUP_CEILING
const src = readFileSync(P('plugin-designs/my-upg-100-list/data/selection-export.js'), 'utf8')
const cases = [[2209, true, true], [1001, true, true], [2209, false, false], [c, true, false], [905, true, false]]
const wrong = cases.filter(([n, phone, want]) => X.overPhoneCeiling(n, phone) !== want).map(([n, phone]) => `${n} on a ${phone ? 'phone' : 'desktop'}`)
ok(c === 1000 && !wrong.length && /2,209 groups +113 sheets +the page CRASHED/.test(src) && /1,857 groups/.test(src),
`phone ceiling ${c}: wrong for ${wrong.join(', ') || '(none)'}; the measurement beside it is ${/2,209 groups/.test(src) ? 'present' : 'MISSING'}`,
`a phone over ${c} groups prints nothing (2,209 → blocked); a desktop is never blocked; India alone (905) still prints on a phone`)
}
// ── B. the CSV ───────────────────────────────────────────────────────────────────────────────────
function parseCsv(s) { // RFC 4180: quoted fields, doubled quotes, CRLF records
const rows = []; let row = [], cell = '', q = false
@ -234,13 +180,8 @@ const wired = {
'the Copy button': /@click="copySelection">\{\{ t\('upgMap\.copySelection'\) \}\}/.test(vue),
'the Export CSV button': /@click="exportSelectionCsv">/.test(vue) && vue.includes("t('upgMap.exportCsv')"),
'the prefetch on batch mode': /if \(next\) selectionRecordsLoad\(\)/.test(vue),
'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /settleWithin\(selectionPeople\(\)/.test(vue),
'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /selectionPeople\(\)\.then/.test(vue),
// India's states resolve through the served table (library/data/indiaStates.js), fetched only once a state is selected
'Copy waits at most COPY_WAIT_MS for the people groups': /settleWithin\(selectionPeople\(\), COPY_WAIT_MS, STALLED\)/.test(vue) && /if \(p === STALLED\) \{ note = t\('upgMap\.peopleStillLoading'\); return selectionClipboard\(sel, \{ color \}\); \}/.test(vue),
'the records fetch times out and leaves the cache': /setTimeout\(\(\) => ctrl\.abort\(\), RECORDS_TIMEOUT_MS\)/.test(vue) && /signal: ctrl \? ctrl\.signal : undefined/.test(vue),
'every failed load reads as the stall, never a raw message': /const readerError = \(e\) => asReaderError\(e, stillLoading\)/.test(vue)
&& /\}\)\(\)\.catch\(\(e\) => \{ throw readerError\(e\); \}\);/.test(vue)
&& /note = errorText\(readerError\(e\), t\)/.test(vue) && /csvFailed', \{ reason: errorText\(readerError\(e\), t\)/.test(vue),
// In batch mode a tap on a pin opens NO detail sheet (it covered the whole HUD on a phone); the tapestry
// listener under it toggles the territory. The real-tap proof is a browser check; this pins the guard in the source.
'a pin tap in batch mode opens no sheet': /map\.on\('click', 'upg-pins-hit', \(e\) => \{[\s\S]{0,700}?if \(batchMode\.value\) return\s*\n\s*const f = e && e\.features/.test(vue),
@ -251,30 +192,6 @@ const wired = {
const body = vue.slice(at, vue.indexOf('\n })', at))
return !/toggleTerritory|toggleByProps|toggleZoneSelection/.test(body)
})(),
// Each print action asks the phone ceiling BEFORE it prints, and says so in the house words.
'a big selection on a phone prints nothing': (() => {
const body = (name) => { const at = vue.indexOf(`async function ${name}() {`); return at < 0 ? '' : vue.slice(at, vue.indexOf('\n}\n', at)) }
const before = (name, call) => { const b = body(name); const g = b.indexOf('phoneTooBig('), p = b.indexOf(call); return g > 0 && p > g }
return before('makeSelectionPacket', 'packetPrints.printPacket(') && before('makeSelectionCards', 'packetPrints.printCardDeck(')
&& before('makeSelectionPoster', 'packetPrints.printPoster(') && /hudSay\(t\('poster\.phoneLimitAll'\), 6000\)/.test(vue)
&& /const ON_PHONE = isPhonePrint\(\);/.test(vue)
})(),
// ★ ON A PHONE .upg-status IS display:none, so a batch message written only there is INVISIBLE (a reader tapped
// Prayer List and nothing visible happened). Every batch action's message, and the print's progress, reach the
// HUD's line (.upg-hud-flash, a polite live region that no rule hides) as well as the status badge.
'batch messages and progress reach the visible HUD on a phone': (() => {
const body = (name) => { const at = vue.indexOf(`async function ${name}() {`); return at < 0 ? '' : vue.slice(at, vue.indexOf('\n}\n', at)) }
const actions = ['makeSelectionPacket', 'makeSelectionCards', 'makeSelectionPoster'].map(body)
const tooBig = vue.slice(vue.indexOf('function phoneTooBig('), vue.indexOf('function phoneTooBig(') + 300)
return /const hudSay = \(text, ms\) => \{ flashStatus\(text, ms\); flashRef\(copyFlash, text, ms\); \};/.test(vue)
&& /hudSay\(t\('poster\.phoneLimitAll'\)/.test(tooBig) && !/flashStatus\(/.test(tooBig)
&& actions.every((b) => b && !/flashStatus\(/.test(b))
&& /class="upg-hud-flash" role="status" aria-live="polite">\{\{ copyFlash \|\| hudProgress \}\}/.test(vue)
&& /hudProgress\.value = printStatus;/.test(vue)
// …and it sits ABOVE the action rows: on a phone the PinHint toast covers the HUD's bottom edge
&& vue.indexOf('class="upg-hud-flash"') > 0 && vue.indexOf('class="upg-hud-flash"') < vue.indexOf('class="upg-hud-actions"')
&& !/\.upg-hud-flash\{[^}]*display:\s*none/.test(vue) && !/@media[^{]*\{[^}]*\.upg-hud-flash\{[^}]*display:\s*none/.test(vue)
})(),
'the regroup warning on Copy': /const warn = regroupable\(sel\) \? '' : t\('upgMap\.copyNotRegroupable'\)/.test(vue),
// The packet / card-deck title goes through selectionTitleNames (arm A6 proves it), with the keyed empty case.
'the title names come from selectionTitleNames': /const names = selectionTitleNames\(zoneNames, selectedTerritories\.value, from,\s*\n\s*\(\) => keyedError\('wall\.hud\.noRecords'/.test(vue)
@ -288,8 +205,7 @@ ok(!missingWires.length, `the HUD is missing: ${missingWires.join(', ')}`, 'the
// ── D. the strings, ×11 ──────────────────────────────────────────────────────────────────────────
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
const KEYS = {
poster: ['phoneLimitAll'],
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'peopleStillLoading', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
const cat = (l, f) => JSON.parse(readFileSync(P('library/i18n/locales', l, `${f}.json`), 'utf8'))
const bad = []
for (const [f, keys] of Object.entries(KEYS)) {

View file

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

View file

@ -90,28 +90,15 @@ 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')
// 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')
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')
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 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')
// 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)')
}
for (const n of notes) console.log(n)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -78,7 +78,7 @@ function sequence(html) {
: /word-title/.test(seg) ? 'word' // point 4's passage in full + the Father's-heart list (2026-09-30)
: /points-title|points_title|Prayer Points/.test(seg) ? 'points'
: `?${cls.trim()}`
if ((kind === 'profiles' || kind === 'worksheet' || kind === 'points') && out[out.length - 1] === kind) return // one entry per run
if ((kind === 'profiles' || kind === 'worksheet') && 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 UPG100 prayer lists/.test(p1), 'page 1 (map-only) does not say it is one of the 28 UPG100 prayer lists')
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')
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 UPG100 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 UPG 100 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(/UPG100 prayer packets divide/.test(page), 'the packets tier is missing although a packet count was supplied')
ok(/UPG 100 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 && !/UPG100 prayer packets divide/.test(pageNoK),
ok(tiersNoK === funnelRaw.steps.length + 1 && !/UPG 100 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

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