Merge the pilot (development 1fa35309a) into wall-of-people-groups-plugin/work
The four Wall sources both sides changed (blessPoster.js, wall.css, wallCanvas.js, check-wall-no-bg-photos.mjs)
merged without conflict: the pilot's side is the comment-only privacy rewording of those files (c1a2f3698); both
sides are kept (no agent names; every branch change present). The generated files (the wall bundle, its leaf page
and the two manifests) were taken from the pilot and rebuilt from the merged source, then fix:sri and the tree.
158
README.md
|
|
@ -26,7 +26,7 @@ Made by Vibe Coders for Vibe Coders to prepare the way of the Lord and make His
|
|||
relational-stewardship training (bounded and centered sets).
|
||||
- **What each map is for:**
|
||||
- **Big Picture:** the library's own interface, the one agents use to build maps.
|
||||
- **UPG-100:** the most strategic. It divides the task into prayer packets.
|
||||
- **UPG100 List:** the most strategic. It divides the task into prayer packets.
|
||||
- **Gospel maps:** the resources that exist in each language.
|
||||
- **UPG Neighbor:** the reached neighbors near each unreached group.
|
||||
- **Gospel × Internet:** all of it, together. Built for linguists, catalysts, translators (human
|
||||
|
|
@ -37,7 +37,7 @@ Built with love, with the prayer that these maps bless the nations.
|
|||
**A blessing for you.** These maps and posters are given to bless the people who bless the nations.
|
||||
We love you, and we are thankful for your work. We bless you in Jesus' name.
|
||||
|
||||
Every poster is in Posterverse (`plugin-designs/posterverse/`): the UPG 100 List, Country Posters,
|
||||
Every poster is in Posterverse (`plugin-designs/posterverse/`): UPG100 Posters, Country Posters,
|
||||
Poster Design and Wall of People Groups. They are all in the Plugin Studio (`bun run dev`) too.
|
||||
|
||||
# dx-maps — one build, maps that embed everywhere
|
||||
|
|
@ -70,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 owner per plugin, a second reader for every change, one landing at a time** | [agent swarm](#build-it-with-an-agent-swarm) |
|
||||
| **One owner per plugin, a second reader for every change, one merge at a time** | [agent swarm](#build-it-with-an-agent-swarm) |
|
||||
|
||||
## For vibe coders: build, open the Studio, embed a map
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ people groups of the world. When you work here, the goal is one of these:
|
|||
| **Reach a city** | Regions (`regions-dashboard`) and the country map (`country-map`): the people groups of each country. The production maps work by country and region; city-level planning (Prayer Walk Planner) is still experimental |
|
||||
| **Mobilize for people groups** | Big Picture (`dashboard-plus-grid`); Simple Research (`research-map-plus-grid`) |
|
||||
| **Plan outreach** | Gospel Resources (`gospel-resources`): what exists in each language; Cities with Internet (`cities-internet`): whether a group can be reached online |
|
||||
| **Pray: packets and prayer resources** | UPG 100 List (`my-upg-100-list`), Regions and Simple Research: posters and prayer packets; Prayer Progress (`doxa-simple-map`) |
|
||||
| **Pray: packets and prayer resources** | UPG100 Posters (`my-upg-100-list`), Regions and Simple Research: posters and prayer packets; Prayer Progress (`doxa-simple-map`) |
|
||||
|
||||
- **Start with the skill:** [`skills/plugin-designer`](./skills/plugin-designer) teaches the patterns, the shared
|
||||
library and the checks.
|
||||
|
|
@ -149,7 +149,7 @@ the Research Gallery, which DX Life publishes as **Deep Research**. An eighth pr
|
|||
<td width="25%" valign="top"><img src="documentation/readme-images/cap2-prayer.png" alt="Prayer Progress"><br><b>Prayer Progress</b><br><sub>where prayer is happening, and where it is still needed</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/cap4-upg100-mfe.png" alt="UPG-100 List"><br><b>UPG-100 List</b><br><sub>prayer lists of 100 people groups, with posters to print</sub></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/cap4-upg100-mfe.png" alt="UPG100 Posters"><br><b>UPG100 Posters</b><br><sub>prayer lists of 100 people groups, with posters to print</sub></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-cities-internet.png" alt="Cities with Internet"><br><b>Cities with Internet</b><br><sub>internet access where unreached people groups live</sub></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-gospel-resources.png" alt="Gospel Resources"><br><b>Gospel Resources</b><br><sub>which people groups have scripture, film or audio — and which have nothing</sub></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/cap5-doxa-maps-spa.png" alt="Research Gallery"><br><b>Research Gallery</b><br><sub>all seven in one app — capability 5</sub></td>
|
||||
|
|
@ -163,7 +163,7 @@ the Research Gallery, which DX Life publishes as **Deep Research**. An eighth pr
|
|||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-simple-research-phone.png" alt="Simple Research on a phone"><br><b>Simple Research</b></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-regions-phone.png" alt="Regions on a phone"><br><b>Regions</b></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-prayer-phone.png" alt="Prayer Progress on a phone"><br><b>Prayer Progress</b></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="UPG-100 List on a phone"><br><b>UPG-100 List</b></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="UPG100 Posters on a phone"><br><b>UPG100 Posters</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-cities-internet-phone.png" alt="Cities with Internet on a phone"><br><b>Cities with Internet</b></td>
|
||||
|
|
@ -173,12 +173,36 @@ the Research Gallery, which DX Life publishes as **Deep Research**. An eighth pr
|
|||
</tr>
|
||||
</table>
|
||||
|
||||
### Print your first poster
|
||||
|
||||
1. **Open a poster wall:** *UPG100 Posters*, *Country Posters*, or *Regions* (its **View country posters**
|
||||
button). Every card on the wall has a **Print Options** strip. India's whole-country poster isn't
|
||||
available yet; its state posters are.
|
||||
2. **Pick what to print:** **Poster Map** (the poster), **Prayer List** (choose **Full detail** or
|
||||
**Lightweight**) or **Card Deck** (choose a card size). On *UPG100 Posters*, clicking the poster
|
||||
picture prints it too.
|
||||
3. **Your browser's print window opens, and it is the preview.** Choose the printer and print. Nothing
|
||||
is downloaded; to keep a file, choose **Save as PDF** instead of a printer.
|
||||
|
||||
Posters, lists and packets print on Letter-size pages (8.5 × 11 in). Cards print at their own size:
|
||||
2.5 × 3.5 in, 4 × 6 in or A6.
|
||||
|
||||
**Bigger posters.** *Bible Posters* and the *Wall of People Groups* also offer bigger sizes, up to 5 × 8
|
||||
ft, for a print shop: in the print window choose **Save as PDF** and send the file. The Wall's 5 × 8 ft
|
||||
poster prints as 3 sheets, one per page, stacked so that each overlaps the next by 6 in; every sheet is
|
||||
numbered (1 / 3, 2 / 3 …) and marked **glue here** where the next one goes.
|
||||
|
||||
**On a phone.** The same print window rises over the page; choose **Save as PDF** to keep the file or
|
||||
send it on. A phone prints page sizes only (Letter or A4 paper), and the largest poster sizes are
|
||||
switched off there. A few prints say they need a computer: *Bless India Posters*, the *Wall of People
|
||||
Groups* and a selection of more than 1,000 groups on *UPG100 Posters*.
|
||||
|
||||
### Every capability ships every way
|
||||
|
||||
| Capability | Example | iframe | Mobile app | JavaScript embed | WordPress / Disciple Tools | Desktop |
|
||||
|---|---|:-:|:-:|:-:|:-:|:-:|
|
||||
| **5 — app shell** | Research Gallery | ✅ | ✅ | ✅ | 🧪 | ✅ |
|
||||
| **4 — composite** | UPG-100 List | ✅ | ✅ | ✅ | 🧪 | ✅ |
|
||||
| **4 — composite** | UPG100 Posters | ✅ | ✅ | ✅ | 🧪 | ✅ |
|
||||
| **3 — multi-view** | research map | ✅ | ✅ | ✅ | 🧪 | ✅ |
|
||||
| **2 — presets** | Prayer Progress | ✅ | ✅ | ✅ | 🧪 | ✅ |
|
||||
| **1 — single element** | all people groups | ✅ | ✅ | ✅ | 🧪 | ✅ |
|
||||
|
|
@ -251,8 +275,8 @@ embed just the one you need.
|
|||
|---|---|---|
|
||||
| a **translator** (or an AI translation pipeline) | Gospel Resources (map, list, data tables) | which people groups have no Scripture, film or audio in their language, and which bridge language already reaches them |
|
||||
| **media to movements** | Cities with Internet · Gospel × Internet | where unreached people groups live with internet access, country by country and down to province where data exists |
|
||||
| a **catalyst** or mobilizer | Big Picture · Prayer Walk Planner · UPG 100 List · Gospel × Internet | find **gospel resources** for the people you are reaching real time, build prayer walking maps for real outreach, as you prayer walk each key zone in your cities of choice, find indigenous disciples, groups, and leaders mobilize them each to have prayer packets, cards and posters to blanket all the people groups in prayer as a prayer mobilizer catalyst |
|
||||
| **praying** | Prayer Progress · UPG-100 List | where prayer is covered and where it is still needed; print a **Prayer List**, **Poster Map** or **Card Deck** for a list of 100 |
|
||||
| a **catalyst** or mobilizer | Big Picture · Prayer Walk Planner · UPG100 Posters · Gospel × Internet | find **gospel resources** for the people you are reaching real time, build prayer walking maps for real outreach, as you prayer walk each key zone in your cities of choice, find indigenous disciples, groups, and leaders mobilize them each to have prayer packets, cards and posters to blanket all the people groups in prayer as a prayer mobilizer catalyst |
|
||||
| **praying** | Prayer Progress · UPG100 Posters | where prayer is covered and where it is still needed; print a **Prayer List**, **Poster Map** or **Card Deck** for a list of 100 |
|
||||
| a **teacher** or presenter | the Research Gallery | every map in one app, multiple languages support |
|
||||
|
||||
<details>
|
||||
|
|
@ -317,8 +341,8 @@ Research Gallery <doxa-maps-spa> capability 5
|
|||
│ ├─ regions list regions-list capability 1
|
||||
│ └─ region dashboards region-dashboards capability 1
|
||||
├─ Prayer Mobilization
|
||||
│ ├─ UPG-100 List upg-100-mfe capability 4
|
||||
│ │ ├─ list upg-100-list capability 1
|
||||
│ ├─ UPG100 Posters upg-100-mfe capability 4
|
||||
│ │ ├─ zoned map upg-100-list capability 1
|
||||
│ │ └─ poster wall poster-wall capability 1
|
||||
│ └─ Prayer Progress doxa-simple-map capability 2
|
||||
├─ Gospel Research
|
||||
|
|
@ -340,11 +364,11 @@ Research Gallery <doxa-maps-spa> capability 5
|
|||
|
||||
**Several plugins glued into one**, talking to each other — and each piece still embeddable alone.
|
||||
|
||||

|
||||

|
||||
|
||||
| List (capability 1, embeddable alone) | Poster wall (capability 1, embeddable alone) |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
<details>
|
||||
<summary><b>More about capability 4 — composites</b></summary>
|
||||
|
|
@ -354,7 +378,7 @@ as views you switch between — and ships the set as **one new element**. The pi
|
|||
**props** (settings passed in) and **events** (messages sent out): click a region in one piece and
|
||||
another piece updates. Nothing is copied, so each piece can still be embedded on its own.
|
||||
|
||||
- **Example:** `plugin-designs/my-upg-100-list/` — the UPG-100 list and its poster wall, joined into
|
||||
- **Example:** `plugin-designs/my-upg-100-list/` — the UPG100 Posters zoned map and its poster wall, joined into
|
||||
`upg-100-mfe`. Big Picture, Simple Research and Regions are composites too.
|
||||
- **How to declare one:** a `<name>.meta.json` beside it with `"type": "nested-elements-mfe"` and a
|
||||
`"composes"` list of the plugins inside.
|
||||
|
|
@ -572,13 +596,13 @@ installed.
|
|||
**The same maps, wrapped as a phone app.** No new code — the maps are JAMstack: they fetch their data
|
||||
from a remote API, which is exactly what a mobile app needs.
|
||||
|
||||
<p align="center"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="Mobile app — the UPG-100 list at phone size" width="320"></p>
|
||||
<p align="center"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="Mobile app — UPG100 Posters at phone size" width="320"></p>
|
||||
|
||||
<details>
|
||||
<summary><b>More about the mobile app</b></summary>
|
||||
|
||||
[Capacitor](https://capacitorjs.com) wraps the built folder into an Android app. Shown here: the
|
||||
UPG-100 prayer lists and posters at phone size, each poster with its own print options.
|
||||
UPG100 prayer lists and posters at phone size, each poster with its own print options.
|
||||
|
||||
```bash
|
||||
bun run check:mobile # checks the app is wired up correctly — needs no Android tools
|
||||
|
|
@ -655,9 +679,9 @@ with the bundle. `bun run check:sri` keeps the manifest and the built pages in s
|
|||
**Every map speaks 11 languages** — add `?lang=<code>` to a map's page address, or, when a map is embedded in
|
||||
your own page, set `"locale"` in its `profile-config`. Arabic reads right to left.
|
||||
|
||||

|
||||

|
||||
|
||||
<sub>UPG-100 List with `?lang=ar`: the interface is in Arabic; people-group and place names come from the data.</sub>
|
||||
<sub>UPG100 Posters with `?lang=ar`: the interface is in Arabic; people-group and place names come from the data.</sub>
|
||||
|
||||
| Code | Language | | Code | Language |
|
||||
|---|---|---|---|---|
|
||||
|
|
@ -695,51 +719,68 @@ your own page, set `"locale"` in its `profile-config`. Arabic reads right to lef
|
|||
## Build it with an agent swarm
|
||||
|
||||
**Many AI agents can build many plugins at once without breaking each other's work.** Every plugin
|
||||
has one agent that owns it, and changes are added to `development`, the shared working version, one
|
||||
at a time. `main` is the production version: it moves only when you publish a release.
|
||||
has one agent that owns it, and the work moves through three kinds of branch:
|
||||
|
||||
- **The pilot branch (`development`)** is where reviewed PR branches come together. A pilot server
|
||||
with hot reload follows it, so each change that joins can be seen there within about 15 seconds.
|
||||
- **The landing branch (`main`)** is what ships. It moves only when a person approves a publish, and
|
||||
the landing server rebuilds from it.
|
||||
- **PR branches** hold one plugin or feature each. They start from the landing branch and join the
|
||||
pilot branch, one at a time, after the checks pass and a second reader has read the change.
|
||||
|
||||
**It scales by adding PR branches, not by sharing one.** Many people, each with several agents, can
|
||||
work on many plugins at the same time: every plugin or feature gets its own PR branch, and each one
|
||||
can be seen on the pilot server as soon as it joins the pilot.
|
||||
|
||||
**Bring your own harness.** dx-maps works with whatever AI coding agent or agent harness you already
|
||||
use. The repo gives you the branch strategy below, the checks (`bun run gates:pre`) and the Plugin
|
||||
Studio; it does not ship a harness. Roles such as a coordinator that puts the landings in order, or a
|
||||
gate-keeper that reads each change before it lands, are jobs your harness gives to its agents.
|
||||
Studio; it does not ship a harness. Roles such as a coordinator that puts the merges onto the pilot in order, or a
|
||||
gate-keeper that reads each change before it joins the pilot, are jobs your harness gives to its agents.
|
||||
|
||||

|
||||

|
||||
|
||||
<sub>[Open the diagram full size](documentation/readme-images/delegation-model.png): on a phone its small text needs it.</sub>
|
||||
|
||||
### Set up the branch strategy
|
||||
|
||||
1. **`main` is production.** It moves only when you publish a release (step 7).
|
||||
2. **`development` is the shared working branch.** Create it once, from `main`:
|
||||
1. **The landing branch (`main`) is what ships.** It moves only when a person approves a publish (step 7).
|
||||
2. **The pilot branch (`development`) is the shared working branch.** Create it once, from `main`:
|
||||
```bash
|
||||
git fetch origin && git switch -c development origin/main && git push -u origin development
|
||||
```
|
||||
3. **One branch and worktree per plugin agent**, from `origin/development`, then install once in each:
|
||||
3. **One PR branch and worktree per feature**, each started from the landing branch, then install
|
||||
once in each:
|
||||
```bash
|
||||
git worktree add -b regions/work ../dx-maps-regions origin/development
|
||||
git worktree add -b upg100/work ../dx-maps-upg100 origin/development
|
||||
git worktree add -b regions/work ../dx-maps-regions origin/main
|
||||
git worktree add -b upg100/work ../dx-maps-upg100 origin/main
|
||||
cd ../dx-maps-regions && bun install --frozen-lockfile
|
||||
```
|
||||
4. **One preview server per agent, each on its own port:**
|
||||
4. **One preview server per agent on your own machine, each on its own port** (in the cloud, the one
|
||||
pilot server below takes this role):
|
||||
```bash
|
||||
bun x vite --port 5301 --strictPort # the next agent uses 5302, and so on
|
||||
```
|
||||
5. **Land one at a time, through a lock.** Catch up with `development`, run the checks, and push only
|
||||
if nothing fails (`gates:pre` exits non-zero when a check fails, so the push never runs then):
|
||||
5. **Join the pilot one at a time, through a lock that works like a merge queue.** Inside the lock, merge your PR branch into a
|
||||
throwaway copy of the pilot branch, run the checks, and push that merge only if nothing fails
|
||||
(`gates:pre` exits non-zero when a check fails, so the push never runs then). Your PR branch itself
|
||||
stays one feature on the landing branch; only the pilot gets the merge:
|
||||
```bash
|
||||
flock /tmp/dx-maps-development.lock sh -c '
|
||||
git fetch origin && git rebase origin/development &&
|
||||
bun run gates:pre && git push origin HEAD:development'
|
||||
git fetch origin && git switch --detach origin/development &&
|
||||
git merge --no-edit regions/work &&
|
||||
bun run gates:pre && git push origin HEAD:development
|
||||
git switch regions/work'
|
||||
```
|
||||
`flock` guards one machine. Agents on several machines need a shared lock, such as one your
|
||||
If the merge stops on a conflict, run `git merge --abort`, fix the clash on your PR branch and join
|
||||
again. `flock` guards one machine. Agents on several machines need a shared lock, such as one your
|
||||
harness provides.
|
||||
6. **One agent owns the full build**; the others build only their own plugin:
|
||||
```bash
|
||||
BUILD_ALLOW=1 bun run build # the build owner: every plugin
|
||||
BUNDLE=<name> bun x vite build && bun run tree && bun run fix:sri # everyone else: one plugin
|
||||
```
|
||||
7. **Release:** fast-forward `main` to a `development` commit that passed the checks, tag it, and
|
||||
deploy from the tag:
|
||||
7. **Publish, when a person approves it:** fast-forward the landing branch to a pilot commit that
|
||||
passed the checks, tag it, and deploy from the tag:
|
||||
```bash
|
||||
git fetch origin && git switch main && git merge --ff-only origin/main
|
||||
git merge --ff-only <development-commit> # a commit that passed the checks
|
||||
|
|
@ -747,9 +788,26 @@ gate-keeper that reads each change before it lands, are jobs your harness gives
|
|||
git tag -a v1.0.0 -m "Release v1.0.0" && git push origin main v1.0.0
|
||||
```
|
||||
The tag's `public/dx-maps/` folder is the built site: deploy that
|
||||
([how to deploy](./documentation/DEPLOY.md)).
|
||||
([how to deploy](./documentation/DEPLOY.md)). Publish to a public host, such as GitHub, only from
|
||||
the landing branch and only when it is ready.
|
||||
|
||||
### Developing in the cloud with a swarm
|
||||
|
||||
Keep the repository on a git service you host yourself: a swarm pushes often, and an agent can
|
||||
publish by accident. On a public host, one wrong push publishes work you did not mean to share, so
|
||||
publish there only from the landing branch, when it is ready. Give each app two servers: a **pilot server** that
|
||||
follows the pilot branch with hot reload, and a **landing server** that rebuilds from the landing
|
||||
branch. The cloud container in `deployment/cloud/` runs both today (`BRANCH=development` for the pilot server,
|
||||
`PRODUCTION_BRANCH=main` for the landing server; `BRANCH` defaults to `main`). One pilot server per app, rather
|
||||
than one per pull request, keeps the memory the same however many pull requests are open. Each
|
||||
developer can still build on their own machine with the Plugin Studio and push their branch.
|
||||
|
||||
Planned, not built yet: switching the pilot server to show one pull request at a time (with a table of
|
||||
the open pull requests under it), and rebuilding the landing branch from the accepted pull requests in
|
||||
order, so a rejected one is simply left out of the next rebuild.
|
||||
|
||||
How to contribute, and the bar for shared code: [documentation/CONTRIBUTING.md](./documentation/CONTRIBUTING.md).
|
||||
Taking contributions from a contributor's own forge into your GitHub repository: [documentation/contribute/TUTORIAL.md](./documentation/contribute/TUTORIAL.md).
|
||||
Diagram source: [`delegation-model.svg`](./documentation/readme-images/src/delegation-model.svg).
|
||||
|
||||
<details>
|
||||
|
|
@ -759,19 +817,19 @@ All the maps here share **one project, one library and one build step**. That's
|
|||
work together, and it's also the danger: if one agent's change breaks the shared library, **every
|
||||
map breaks at once**. And when several agents work in the same project at the same time, they step on
|
||||
each other — two agents edit the same file, one change quietly erases another, or half-finished work
|
||||
lands in the version everyone else is using.
|
||||
ends up in the version everyone else is using.
|
||||
|
||||
So the project works like a busy kitchen:
|
||||
|
||||
1. **Each agent works in its own branch and copy of the project** — its own counter, so nobody bumps
|
||||
anybody. One owner per plugin, and one per shared file.
|
||||
2. **Only one change lands at a time.** Whoever holds the *landing lock* first catches up with
|
||||
everyone else's latest work, fixes any clash itself and runs every check; the change goes into
|
||||
`development` only if nothing fails. `development` always works.
|
||||
3. **Someone keeps order.** A coordinator can put the landings in order, and a gate-keeper can read
|
||||
each change by its content before it lands. Your harness gives these jobs to its agents.
|
||||
4. **`main` moves only when you publish a release**, after the checks pass on that exact version.
|
||||
A preview can follow `development`; the published site follows `main`.
|
||||
2. **Only one change joins the pilot at a time.** Whoever holds the *pilot lock* first catches up with
|
||||
everyone else's latest work, fixes any clash itself and runs every check; the change goes into the
|
||||
pilot branch only if nothing fails. The pilot branch always works.
|
||||
3. **Someone keeps order.** A coordinator can put the merges onto the pilot in order, and a gate-keeper can read
|
||||
each change by its content before it joins the pilot. Your harness gives these jobs to its agents.
|
||||
4. **The landing branch moves only when a person approves a publish**, after the checks pass on that exact version.
|
||||
The pilot server follows the pilot branch; the landing server rebuilds from the landing branch.
|
||||
|
||||
That's how many plugins get built at the same time, in one project, without the maps breaking.
|
||||
|
||||
|
|
@ -783,12 +841,12 @@ That's how many plugins get built at the same time, in one project, without the
|
|||
| Word | In plain terms |
|
||||
|---|---|
|
||||
| **repo** | the project folder, with its full history |
|
||||
| **development** | the shared working version: every agent's change is added here, one at a time |
|
||||
| **main** | the production version: it moves only when `development` is published |
|
||||
| **development** (the pilot) | the shared working version: every agent's change is added here, one at a time |
|
||||
| **main** (the landing) | the version that ships: it moves only when a person approves publishing `development` |
|
||||
| **worktree** | a private copy of the project for one agent to work in |
|
||||
| **merge conflict** | two changes touched the same lines; someone has to decide how to combine them |
|
||||
| **rebase** | catching your work up with everyone else's latest before adding it |
|
||||
| **landing lock** | only one change lands on `development` at a time; its holder catches up and runs every check first |
|
||||
| **merge** | combining your branch with everyone else's latest work; here it happens inside the pilot lock, in a throwaway copy of the pilot |
|
||||
| **pilot lock** | only one change merges onto `development` at a time; its holder catches up and runs every check first |
|
||||
| **tag** | a fixed name for one release commit, such as `v1.0.0`, so you can always deploy exactly it |
|
||||
|
||||
</details>
|
||||
|
|
@ -908,7 +966,7 @@ doxa-maps/
|
|||
├── plugin-designs/ YOUR maps — one folder per plugin (the free zone)
|
||||
│ ├── doxa-maps-spa/ the Research Gallery (capability 5)
|
||||
│ ├── plugin-studio/ the Plugin Studio (capability 5)
|
||||
│ ├── my-upg-100-list/ UPG-100 List (capability 4)
|
||||
│ ├── my-upg-100-list/ UPG100 Posters (capability 4)
|
||||
│ ├── doxa-simple-map/ Prayer Progress and its presets (capability 2)
|
||||
│ ├── all-people-groups/ a single map (capability 1)
|
||||
│ └── template-bundle/ copy this to start a new plugin
|
||||
|
|
|
|||
|
|
@ -423,7 +423,7 @@ func (e *engine) startWith(lang, device string, src *recorderProc, sink func(eve
|
|||
recMu.Unlock()
|
||||
writeMu.Lock()
|
||||
// The streaming model holds back the last fraction of a second (its right context) until more audio
|
||||
// or a pause arrives, and a commit alone does not flush it (rega: a Stop mid-sentence lost "what
|
||||
// or a pause arrives, and a commit alone does not flush it (measured: a Stop mid-sentence lost "what
|
||||
// your country…"). 1.2 s of silence first lets it finish the words already spoken.
|
||||
silence := make([]byte, 3200)
|
||||
for i := 0; i < 12; i++ {
|
||||
|
|
@ -750,7 +750,7 @@ func (e *engine) startWith(lang, device string, src *recorderProc, sink func(eve
|
|||
e.logs.add("speech server error: %s", string(msg))
|
||||
emit(event{"type": "error", "message": string(msg)})
|
||||
case t == "input_audio_buffer.committed":
|
||||
// The commit acknowledges Stop; the words for the audio before it can still follow (rega: a
|
||||
// The commit acknowledges Stop; the words for the audio before it can still follow (measured: a
|
||||
// Stop mid-word lost "what your country…"). Keep reading until the final text or 6 s of quiet (a loaded server can take seconds per backlog chunk).
|
||||
committed = true
|
||||
e.logs.add("speech server committed the stream; reading its last words")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"_what": "Provenance for the Mark 5:1-20 passage on packet page 8 (prayer point 4), per locale.",
|
||||
"_rule": "Scripture is NEVER machine-translated and never reproduced from memory. Each text is a published PUBLIC-DOMAIN translation fetched from a citable source that ships licence metadata, stored with the translation's NAME. dx-shepherd END-DXS-MK5GO, 2026-09-27.",
|
||||
"_rule": "Scripture is NEVER machine-translated and never reproduced from memory. Each text is a published PUBLIC-DOMAIN translation fetched from a citable source that ships licence metadata, stored with the translation's NAME (added 2026-09-27).",
|
||||
"_verified": "Verse numbering checked against the English (World English Bible): all five extract exactly verses 1-20, verse 1 matches 'they came to the other side of the sea, into the country of the Gadarenes' and verse 20 ends at 'Decapolis ... everyone marveled'.",
|
||||
"_api": "https://bible.helloao.org/api/<id>/MRK/5.json",
|
||||
"locales": {
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 211 KiB |
BIN
data/posters/country-ind.webp
Normal file
|
After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 246 KiB |
|
|
@ -2,11 +2,11 @@
|
|||
"meta": {
|
||||
"title": "Which province each people group falls in — India and China",
|
||||
"why": "Published so no other build has to repeat this point-in-polygon. gospel-internet ran the identical ray-casting over the identical geometry and could only check itself against our aggregates; a second implementation of the same geometry is a second place for it to drift.",
|
||||
"generated": "2026-09-27",
|
||||
"generated": "2026-10-02",
|
||||
"grain": "one row per PLACED people group; the four that fall outside every polygon are in the aggregate file's `unassigned`",
|
||||
"rows": 1009,
|
||||
"agrees_with": "gospel-by-province.json — per-province counts and the assigned total are reconciled at build time, and neither file is written if they differ",
|
||||
"approximate": "Every row is approximate. The coordinate marks where a PEOPLE is found, not a settlement, and near a border it can fall on the wrong side. No surface may present a province as a fact about a group."
|
||||
"approximate": "Every row is approximate. The coordinate marks a PEOPLE group, not a settlement; its position is approximate, and near a border it can fall on the wrong side. No surface may present a province as a fact about a group."
|
||||
},
|
||||
"columns": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"meta": {
|
||||
"title": "Gospel access by province — India and China",
|
||||
"why": "These two countries hold 47.9% of Doxa's roster (855 + 154 of 2,107). No other country is close; the third is Sudan at 105. A national fill cannot answer \"where would a team go\" for half the map.",
|
||||
"generated": "2026-09-27",
|
||||
"generated": "2026-10-02",
|
||||
"provinces_with_groups": 46,
|
||||
"provinces_available": 48,
|
||||
"groups_assigned": 1009,
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"near_border_note": "NONE unassigned now, and two rows say why. Jing and Pana fell in no province: measured 8.7 km outside Guangxi and 5.4 km outside Yunnan — across the border, not outside the file, which is what an earlier version of this note wrongly said (both provinces are in it). Sourcing China's complete 34-unit ADM1 moved neither distance: same source geometry, 22 more provinces, none of them theirs — so better data is not the answer and nobody need repeat it. A point within 15 km of exactly ONE province now joins it and carries near_border_km; two provinces within the tolerance leave it unassigned, because a hint between two answers is not one. A single coordinate for a whole people is not precise to 9 km, which is why this is a correction and not a guess.",
|
||||
"china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.",
|
||||
"ways_in": "Chosen FOR THE PROVINCE where the province can answer: the languages of groups standing in it that already have Scripture AND film, ranked by the people here who already hold them. The column says which basis was used. This replaces a country-level answer that produced a measured absurdity — Arunachal Pradesh, in the far north-east, was offered TELUGU, because the curated bridge table is keyed by country and a curated bridge outranks a computed one. A province with no tier-2 language of its own still falls back to the country answer, and ways_in_basis says so rather than hiding it. It is a NEIGHBOUR, not a recommendation: whether a group actually speaks it is a question for someone on the ground.",
|
||||
"approximate": "A province is assigned by point-in-polygon on the group's coordinate, and that coordinate marks where a PEOPLE is found, not a settlement — 108 groups across the roster even share a coordinate with another group. Near a border the point can fall on the wrong side. Every row is flagged approximate and the unassigned are counted, never dropped. No surface may present a province as a fact about a group."
|
||||
"approximate": "A province is assigned by point-in-polygon on the group's coordinate, and that coordinate marks a PEOPLE group, not a settlement; its position is approximate, and 108 groups across the roster even share a coordinate with another group. Near a border the point can fall on the wrong side. Every row is flagged approximate and the unassigned are counted, never dropped. No surface may present a province as a fact about a group."
|
||||
},
|
||||
"columns": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"meta": {
|
||||
"title": "Key Cities / Location Clusters",
|
||||
"grain": "one row per ~25km location cluster",
|
||||
"caveat": "A cluster is people-group CENTROIDS, not a settlement. place_name comes from a cited gazetteer (Nominatim/OSM, ODbL); an unnamed row is honest, not missing.",
|
||||
"caveat": "A cluster is people-group PINS (each marks a people group, not a town), not a settlement. place_name comes from a cited gazetteer (Nominatim/OSM, ODbL); an unnamed row is honest, not missing.",
|
||||
"internet_caveat": "Internet figures are NATIONAL. Per-city connectivity is not publicly available at this scale."
|
||||
},
|
||||
"columns": [
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"title": "Languages per City x Resources",
|
||||
"grain": "one row per location cluster x language spoken there",
|
||||
"why": "399 of 1,646 clusters are multi-language — a city-level bridge can exist that a country average hides.",
|
||||
"place_caveat": "A location is a cluster of people-group centroids within ~25km, NOT a municipal boundary. An empty place_name is an unnamed cluster, never an invented one.",
|
||||
"place_caveat": "A location is a cluster of people-group pins within ~25km, NOT a municipal boundary. An empty place_name is an unnamed cluster, never an invented one.",
|
||||
"resolution_caveat": "resolved:false means the language LABEL did not match an ISO 639-3 record here — its resource status is UNKNOWN, not absent.",
|
||||
"pools_caveat": "Resource status is from eBible + JESUS Film only. GRN and FCBH audio are key-blocked."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@
|
|||
AGENT-BRIEF.md
|
||||
_archive
|
||||
data-private
|
||||
library/scripts/posters/snapshots/_granular-experiment.mjs.shelved
|
||||
plugin-designs/key-cities
|
||||
name:*field-access*
|
||||
# The bracket forms below ([-], [ ]) match the same names and text; they keep this list from matching its own content rule.
|
||||
name:*field[-]access*
|
||||
name:*visa*.json
|
||||
name:.env
|
||||
name:.env.*
|
||||
name:*.pem
|
||||
name:*.key
|
||||
keep:.env.example
|
||||
content:field-access|Visa Requirements
|
||||
content:field[-]access|Visa[ ]Requirements
|
||||
|
|
|
|||
|
|
@ -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 the
|
||||
Research Gallery.
|
||||
**Live example:** DX-life's drop-in branch copies the folder into its `public/` and fills `/research` with
|
||||
Deep Research.
|
||||
|
||||
**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
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ desktop path has never been run by anyone, and the steps say so where that is th
|
|||
| 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 the Research Gallery, builds only a Linux `.deb`, and has never been packaged on macOS or Windows. |
|
||||
| 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 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. |
|
||||
|
|
@ -124,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 the Research Gallery need the token host to allow the Tauri origin
|
||||
- Mapbox basemaps in Deep Research 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.
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ The scripts these commands are grounded in (from this branch's `package.json`):
|
|||
"build": "bash internal/build-lock.sh",
|
||||
"build:unlocked":"bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post",
|
||||
"gates:pre":"node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
|
||||
"gates:post":"node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed",
|
||||
"gates:post":"node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed",
|
||||
"build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time
|
||||
"preview": "bun x vite preview",
|
||||
"serve": "node internal/serve-strict.mjs", // 404s a directory, like production
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ Two things to know before you publish a link:
|
|||
|
||||
- **No zoom or centre parameter.** The frame is derived from the pins the link selects, so a link
|
||||
stays correct when the data moves under it.
|
||||
- **Placement is approximate.** A dot marks where a *people* is found, not a town or a boundary,
|
||||
- **Placement is approximate.** A dot marks a *people group*, not a town or a boundary,
|
||||
and 108 of the placed groups share a coordinate with another group. The sheet says so in the
|
||||
reader's language.
|
||||
- **An unknown parameter is ignored, never fatal.** `?country=ZZZ` renders the map, not an error —
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ served folder". The licence travels with the asset; the explanation travels with
|
|||
| `boundaries/gb-CHN-ADM1.geojson` | geoBoundaries gbOpen, boundaryID `CHN-ADM1-43563684` | **Public Domain**, licenceSource `commons.wikimedia.org`, year 2019 | recorded in `boundaries/ATTRIBUTION` | none — upstream records no attribution duty |
|
||||
| `boundaries/world-adm0*.geojson` (2 files, 162 countries) | geoBoundaries gbOpen, assembled per country (its features carry geoBoundaries' own `iso3`/`shapeGroup`/`shapeName`; corrected — this register first guessed Natural Earth) | **MIXED, and ESTABLISHED per country as of 2026-09-28**: 162 upstream licences. **32** duty-free (28 Public Domain + 4 CC0) · **65** attribution-only · **65 SHARE-ALIKE CLASS** (58 ODbL 1.0 + 7 explicit ShareAlike: AGO GAB GEO GIN MMR NER TUR) | **Yes** — the per-country table (licence + licenseSource + boundaryID + boundarySource, fetched verbatim from the geoBoundaries API, 162 of 162, 0 failures) ships at `plugin-designs/my-upg-100-list/data/adm0/PROVENANCE.json`. `boundaries/ATTRIBUTION` references it by path rather than copying it | **OPEN, legal — not a measurement**: whether a redistributed boundary file is a "Derivative Database" under ODbL is unanswered, and half the duty-bearing countries turn on it. It needs an owner's decision on data licensing |
|
||||
| `people-groups/*` (3 files, 8 MB) | peoplegroups.org | **not established** | no terms ship | **OPEN, judgment** — redistribution terms unknown |
|
||||
| `pplr/*` (6 files, 16 MB) | **PPLR ArcGIS Online org** `services2.arcgis.com/S4ydGgujXcif36k3`, aggregating IMB (peoplegroups.org), Joshua Project, the ROP registry, and carrying WCD (World Christian Database) IDs. Built from a local 78 MB extract not present in this repo — evidence from exp-shepherd | **UNRESOLVED.** `aCountry` and `aIMBPeople` publish no `copyrightText`, description or licence (queried `f=json`) | nothing ships | **OPEN, judgment** — redistribution not established. WCD is a proprietary Brill database, so even the IDs need a check |
|
||||
| `pplr/*` (6 files, 16 MB) | **PPLR ArcGIS Online org** `services2.arcgis.com/S4ydGgujXcif36k3`, aggregating IMB (peoplegroups.org), Joshua Project, the ROP registry, and carrying WCD (World Christian Database) IDs. Built from a local 78 MB extract not present in this repo | **UNRESOLVED.** `aCountry` and `aIMBPeople` publish no `copyrightText`, description or licence (queried `f=json`) | nothing ships | **OPEN, judgment** — redistribution not established. WCD is a proprietary Brill database, so even the IDs need a check |
|
||||
| `tables/*` (304 files, 36 MB) | derived by `library/scripts/research/fetch-*.mjs` from several upstream APIs | **varies, not established per table** | nothing ships; there is no source or licence doc in `tables/` | **OPEN, judgment** — the largest gap by volume |
|
||||
| `images/worldmap.svg` | recorded in `images/PROVENANCE` | see that file | the PROVENANCE file ships and explains why no licence file sits beside it | none claimed; reasoning recorded rather than assumed |
|
||||
| `previews/*` (378 files) | our own poster previews | ours | n/a | none |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
The Research Gallery (`doxa-maps-spa/doxa-maps-spa/index.html`) is a direct-embed page like any
|
||||
Deep Research (`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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Architecture — UPG-100 poster generation
|
||||
# Architecture — UPG100 List poster generation
|
||||
|
||||
> The simple overview is in `library/scripts/posters/README.md`. This is the full reference for the
|
||||
> generator: files, environment, pipeline, and the gotchas that have bitten before.
|
||||
|
|
@ -42,7 +42,6 @@ the app is rebuilt.**
|
|||
| `build-gallery.mjs` | `out/index.html` — browsable preview grid, sectioned by region. |
|
||||
| `build-hd-gallery.mjs` | `out/hd.html` — full-resolution review + copy-to-clipboard. |
|
||||
| `export-public.mjs` | Copies PNGs into region folders OUTSIDE the repo (see below). |
|
||||
| `snapshots/` | Frozen template versions. v5 is the approved current; v1–v4 are the lineage. |
|
||||
|
||||
## Environment
|
||||
|
||||
|
|
@ -54,7 +53,7 @@ the app is rebuilt.**
|
|||
| `VITE_API_BASE_URL` | — | Satisfies the fail-closed config gate. No request is made under `OFFLINE=1`. |
|
||||
| `ONLY` | — | Build a single zone by `zoneKey`. |
|
||||
| `UPG100_DATA_PATH` | the profile in this repo | Only override to test against other data. |
|
||||
| `SERIES_INDEX` / `SERIES_NAME` | derived / `List of 100 UPG's` | Series line on the sheet. |
|
||||
| `SERIES_INDEX` / `SERIES_NAME` | derived / `UPG100 List` | Series line on the sheet. |
|
||||
|
||||
## Two things that will bite you
|
||||
|
||||
|
|
|
|||
48
documentation/contribute/TUTORIAL.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Connect a contributor's repo to your GitHub
|
||||
|
||||
*For the repository's GitHub admin. About 10 minutes. The contributor needs no GitHub account, and you keep control of every step.*
|
||||
|
||||
**What you'll have at the end:** a GitHub Action in your repo that, about once an hour, copies the contributor's `release` branch from their own forge into a `contributor` branch of yours. Nothing reaches your main branch until you review and merge it.
|
||||
|
||||
**Before you start:** the contributor sends you the URL of their release repo. Try it on a test repo first if you like; the steps are the same.
|
||||
|
||||
## 1. Add the secret
|
||||
|
||||
**Settings → Secrets and variables → Actions → New repository secret.**
|
||||
- Name: `UPSTREAM_URL`
|
||||
- Value: the release-repo URL the contributor sent you
|
||||
|
||||
It's a secret so the URL stays out of your public files.
|
||||
|
||||
## 2. Add the workflow
|
||||
|
||||
Create `.github/workflows/pull-contributor.yml` **on your default branch** with the contents of [`pull-contributor.yml`](pull-contributor.yml). Scheduled workflows run only from the default branch. Read it first: it's about 25 lines, and it:
|
||||
- fetches the contributor's `release` branch from `UPSTREAM_URL`;
|
||||
- **stops** if the contributor's commits add, change or delete anything under `.github/`, so nothing from outside can touch a workflow. Your own workflows are fine: it compares their commits with the history you share. If you share none (a fresh release), any `.github/` content is refused;
|
||||
- updates your `contributor` branch **without force-pushing**, so if their history is ever rewritten, the run fails and waits for you.
|
||||
|
||||
## 3. Run it once
|
||||
|
||||
**Actions → Pull contributor → Run workflow.** When it turns green, you'll see a new `contributor` branch under **Code → Branches**.
|
||||
If the push step fails with a permission error, check **Settings → Actions → General → Workflow permissions**, or your organization's Actions policy.
|
||||
|
||||
## 4. Protect your main branch
|
||||
|
||||
**Settings → Rules → Rulesets → New branch ruleset**, targeting your default branch:
|
||||
- **Require a pull request before merging**, with at least one approval;
|
||||
- **Block force pushes.**
|
||||
|
||||
(Or use **Settings → Branches → Add branch protection rule** with the same two settings.) Don't restrict pushes to `contributor` itself, because the Action has to update it.
|
||||
|
||||
## 5. Review and merge
|
||||
|
||||
**Pull requests → New pull request**, base: your main branch, compare: `contributor`. Review it like any outside contribution, and merge when you're happy. If the pull request stays open, it updates itself each time the Action brings new commits.
|
||||
|
||||
## Good to know
|
||||
|
||||
- **Turn it off:** delete the file, or use **Actions → Pull contributor → ⋯ → Disable workflow**.
|
||||
- **A red run** means the Action refused something (a change under `.github/`, or a rewritten history). Nothing was changed. Tell the contributor.
|
||||
- **"Entirely different commit histories":** the release was made fresh, as a new project. Ask the contributor to base it on your main branch, or use it as the start of a new repo.
|
||||
- **Quiet repos:** GitHub pauses scheduled workflows in a public repo after 60 days with no activity. Re-enable it under **Actions**.
|
||||
- **Please don't** download the code and re-upload it by hand. It's slower, and it breaks the link to the source.
|
||||
- **Check the guard yourself:** `bash test-pull-contributor.sh` (next to this file) runs the workflow's exact lines against throwaway local repos, with no network, and shows what it accepts and refuses.
|
||||
27
documentation/contribute/pull-contributor.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# .github/workflows/pull-contributor.yml
|
||||
# Copies the contributor's `release` branch into this repo's `contributor` branch,
|
||||
# about once an hour. Commit it to your default branch. Delete it to stop.
|
||||
name: Pull contributor
|
||||
on:
|
||||
schedule: [{ cron: '17 * * * *' }]
|
||||
workflow_dispatch: {}
|
||||
permissions: { contents: write }
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- env: { URL: '${{ secrets.UPSTREAM_URL }}' }
|
||||
run: |
|
||||
git fetch --no-tags "$URL" release
|
||||
# refuse any change they make to .github/
|
||||
BASE=$(git merge-base HEAD FETCH_HEAD) \
|
||||
|| BASE=$(git mktree </dev/null)
|
||||
CHANGED=$(git diff --name-only \
|
||||
"$BASE" FETCH_HEAD -- .github)
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "refusing .github/ changes"; exit 1
|
||||
fi
|
||||
git push origin \
|
||||
FETCH_HEAD:refs/heads/contributor
|
||||
75
documentation/contribute/test-pull-contributor.sh
Executable file
|
|
@ -0,0 +1,75 @@
|
|||
#!/usr/bin/env bash
|
||||
# Runs the sync step of pull-contributor.yml against throwaway local repos and
|
||||
# checks what it accepts and refuses. Needs only git and bash; touches no network.
|
||||
# Usage: bash test-pull-contributor.sh [path/to/pull-contributor.yml]
|
||||
# What it can't check: GitHub's own behaviour (checkout auth, GITHUB_TOKEN, the schedule).
|
||||
set -uo pipefail
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
wf="${1:-$here/pull-contributor.yml}"
|
||||
t="$(mktemp -d)"; trap 'rm -rf "$t"' EXIT
|
||||
# the run: block, unindented: exactly what GitHub executes
|
||||
sed -n '/run: |/,$p' "$wf" | tail -n +2 | sed 's/^ //' > "$t/sync.sh"
|
||||
export GIT_AUTHOR_NAME=Test GIT_AUTHOR_EMAIL=test@example.invalid
|
||||
export GIT_COMMITTER_NAME=Test GIT_COMMITTER_EMAIL=test@example.invalid
|
||||
fails=0
|
||||
c() { git -C "$t/$1" "${@:2}" -q; }
|
||||
sync() { # $1 = receiver bare repo, $2 = contributor bare repo, $3 = clone depth (0 = full)
|
||||
rm -rf "$t/runner"
|
||||
if [ "${3:-0}" = 0 ]; then git clone -q "$t/$1" "$t/runner"; else git clone -q --depth "$3" "file://$t/$1" "$t/runner"; fi
|
||||
(cd "$t/runner" && URL="$t/$2" bash --noprofile --norc -eo pipefail "$t/sync.sh" >/dev/null 2>&1)
|
||||
}
|
||||
check() { # $1 = name, $2 = expected (ok|refused), then the sync arguments
|
||||
local name="$1" want="$2"; shift 2
|
||||
local before after got; before=$(git -C "$t/$1" rev-parse -q --verify refs/heads/contributor || echo none)
|
||||
if sync "$@"; then got=ok; else got=refused; fi
|
||||
after=$(git -C "$t/$1" rev-parse -q --verify refs/heads/contributor || echo none)
|
||||
if [ "$got" = refused ] && [ "$before" != "$after" ]; then got="refused BUT the branch moved"; fi
|
||||
if [ "$got" = "$want" ]; then echo "PASS $name ($got)"; else echo "FAIL $name: wanted $want, got $got"; fails=$((fails+1)); fi
|
||||
}
|
||||
commit() { (cd "$t/$1" && git add -A && git commit -qm "$2"); }
|
||||
|
||||
# A receiver whose main already has workflows; the contributor works on top of it.
|
||||
git init -q --bare -b main "$t/gh.git"; git init -q --bare -b release "$t/up.git"
|
||||
git init -q -b main "$t/pm"; mkdir -p "$t/pm/.github/workflows"
|
||||
echo ci > "$t/pm/.github/workflows/ci.yml"; echo m > "$t/pm/m"; commit pm m1; c pm push "$t/gh.git" main
|
||||
git clone -q "$t/gh.git" "$t/cw"; c cw checkout -b release
|
||||
echo work > "$t/cw/w"; commit cw w1; c cw push "$t/up.git" release
|
||||
echo pull > "$t/pm/.github/workflows/pull-contributor.yml"; commit pm add-pull; c pm push "$t/gh.git" main
|
||||
check "receiver has workflows; release = its main + 1 commit" ok gh.git up.git
|
||||
echo ci2 > "$t/pm/.github/workflows/ci.yml"; commit pm ci2; c pm push "$t/gh.git" main
|
||||
echo more >> "$t/cw/w"; commit cw w2; c cw push "$t/up.git" release
|
||||
check "receiver changed .github/ since; contributor didn't" ok gh.git up.git
|
||||
good=$(git -C "$t/cw" rev-parse HEAD)
|
||||
reset() { c cw reset --hard "$good"; c cw push -f "$t/up.git" release; }
|
||||
echo evil > "$t/cw/.github/workflows/ci.yml"; commit cw edit; c cw push "$t/up.git" release
|
||||
check "contributor edits a workflow" refused gh.git up.git
|
||||
reset; echo new > "$t/cw/.github/workflows/new.yml"; commit cw add; c cw push "$t/up.git" release
|
||||
check "contributor adds a workflow" refused gh.git up.git
|
||||
reset; rm "$t/cw/.github/workflows/ci.yml"; commit cw delete; c cw push "$t/up.git" release
|
||||
check "contributor deletes a workflow" refused gh.git up.git
|
||||
reset
|
||||
c cw fetch origin; (cd "$t/cw" && git merge -q --no-edit origin/main)
|
||||
echo m3 >> "$t/cw/w"; commit cw w3; c cw push "$t/up.git" release
|
||||
check "contributor merges the receiver's main (its .github/ changes come along)" ok gh.git up.git
|
||||
(cd "$t/cw" && git commit -q --amend -m rewritten); c cw push -f "$t/up.git" release
|
||||
check "contributor rewrites history (no force-push)" refused gh.git up.git
|
||||
|
||||
# A fresh-history release: no history shared with the receiver.
|
||||
git init -q --bare -b main "$t/gh2.git"; git init -q --bare -b release "$t/up2.git"
|
||||
git init -q -b main "$t/pm2"; mkdir -p "$t/pm2/.github/workflows"; echo ci > "$t/pm2/.github/workflows/ci.yml"
|
||||
commit pm2 init; c pm2 push "$t/gh2.git" main
|
||||
git init -q -b release "$t/fr"; echo app > "$t/fr/app"; commit fr fresh; c fr push "$t/up2.git" release
|
||||
check "fresh history without .github/" ok gh2.git up2.git
|
||||
mkdir -p "$t/fr/.github/workflows"; echo x > "$t/fr/.github/workflows/x.yml"; commit fr add-gh; c fr push "$t/up2.git" release
|
||||
check "fresh history with .github/ (nothing shared to compare: strict)" refused gh2.git up2.git
|
||||
|
||||
# Fails closed when the checkout is shallow and no shared base can be found.
|
||||
git init -q --bare -b main "$t/gh3.git"; git init -q --bare -b release "$t/up3.git"
|
||||
git init -q -b main "$t/pm3"; mkdir -p "$t/pm3/.github/workflows"; echo ci > "$t/pm3/.github/workflows/ci.yml"
|
||||
commit pm3 m1; echo 2 > "$t/pm3/f2"; commit pm3 m2; echo 3 > "$t/pm3/f3"; commit pm3 m3; c pm3 push "$t/gh3.git" main
|
||||
git clone -q "$t/gh3.git" "$t/cw3"; c cw3 checkout -b release; c cw3 reset --hard HEAD~2
|
||||
echo evil > "$t/cw3/.github/workflows/ci.yml"; commit cw3 edit; c cw3 push "$t/up3.git" release
|
||||
check "shallow checkout + a workflow edit" refused gh3.git up3.git 1
|
||||
|
||||
[ "$(git -C "$t/gh.git" rev-list --count main)" = 3 ] && echo "PASS the receiver's main was never touched" || { echo "FAIL main moved"; fails=$((fails+1)); }
|
||||
echo; [ "$fails" = 0 ] && echo "ALL PASS" || { echo "$fails FAILED"; exit 1; }
|
||||
|
|
@ -7,14 +7,15 @@ being duplicated.
|
|||
|
||||
## The one rule that keeps everything clean
|
||||
|
||||
> **Parameterized + genuinely reused by more than one map → the shared library**
|
||||
> **Could serve more than one map → build it in the shared library**, parameterized
|
||||
> (`library/`, alias `@map`).
|
||||
> **Not reused → it lives in your app-profile folder** (`plugin-designs/<your-map>/`).
|
||||
> **One-off or experimental → it lives in your app-profile folder** (`plugin-designs/<your-map>/`).
|
||||
|
||||
"**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).
|
||||
**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.
|
||||
|
||||
## Start here
|
||||
|
||||
|
|
|
|||
3
documentation/deploy/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Deploy workflows
|
||||
|
||||
To deploy to GitHub Pages, copy `github-pages.workflow.yml` into `.github/workflows/` in your own repository.
|
||||
|
|
@ -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.** The Research Gallery (`doxa-maps-spa`) reads the built `manifest.json`
|
||||
**Your plugin surfaces itself.** Deep Research (`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
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Deep Research is the plugin that is already a whole app. It is the app shell in
|
|||
- Simple Research;
|
||||
- Regions;
|
||||
- Prayer Progress;
|
||||
- UPG-100 List;
|
||||
- UPG100 Posters;
|
||||
- Cities with Internet;
|
||||
- Gospel Resources.
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ It is the same command with another start page. Name the page's folder inside `p
|
|||
|
||||
```bash
|
||||
APP_START=doxa-simple-map/doxa-simple-map bun run phone:from-tree # Prayer Progress
|
||||
bun run phone:from-tree # unset: the UPG-100 list
|
||||
bun run phone:from-tree # unset: UPG100 Posters
|
||||
```
|
||||
|
||||
Pick a page that works on a phone; Big Picture, for one, is desktop only. The start-page step
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ snippets (leave it empty for relative URLs that work wherever the folder is drop
|
|||
## Hosting the dx-maps folder: caching, compression, CORS
|
||||
|
||||
**Why it matters.** On a cold load a map's layers draw about 10–17 s after the page opens (measured
|
||||
on the UPG-100 list page). The bundle and the map engine take under 2 s of that; the rest is basemap
|
||||
on the UPG100 Posters page). The bundle and the map engine take under 2 s of that; the rest is basemap
|
||||
tiles and data files such as the country-boundary file (1,866 KB raw, 597 KB gzipped). Compression
|
||||
cuts every visit; caching means a returning reader doesn't download the same files again.
|
||||
|
||||
|
|
@ -415,6 +415,7 @@ every third-party script is pinned with an integrity hash (`library/constants/cd
|
|||
| `style-src` | `'unsafe-inline'`, the same CDN hosts | each plugin styles its own shadow root with a `<style>` element |
|
||||
| `worker-src` | `blob:` | the map engines run their tile workers from a blob URL |
|
||||
| `connect-src` | the tile/style hosts you use (`https://tiles.openfreemap.org` for MapLibre, `https://*.mapbox.com` for Mapbox), your data API | tiles, styles, glyphs, data |
|
||||
| `connect-src` (Bible Posters) | `https://bible.helloao.org` | the public-domain Bible text, fetched per book at render time (no text is bundled) |
|
||||
| `img-src` | `data:` `blob:` and the same tile hosts | sprites and raster tiles |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
# needs-eyes — partner-embed fixtures
|
||||
|
||||
Under the team's Playwright policy (2026-09-25) only **rega** runs a browser on the VPS, and a
|
||||
maintainer's laptop is the other pair of eyes. So a check that can only be seen has to be *openable by
|
||||
someone else* — not a construct that lives inside one agent's gate process.
|
||||
|
||||
These pages are generated, not hand-written:
|
||||
A check that can only be seen has to be *openable by someone else*, not a construct that lives inside
|
||||
one gate's process. These pages are generated on demand and are not committed; write them with:
|
||||
|
||||
```
|
||||
node internal/check-embedded-rtl.mjs --emit
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>partner</title>
|
||||
<style>html,body{margin:0}#w{height:900px}</style></head>
|
||||
<body><h1 id="partner-heading">Partner page</h1><div id="w"></div>
|
||||
<!-- A REAL PARTNER EMBED SETS THIS. The generated leaf pages bake window.MAP_APP_API_URL, and
|
||||
without it every data fetch resolves same-origin against the partner and fails, so the wall
|
||||
renders zero cards and the locale has nothing to translate. The first run of this gate
|
||||
omitted it and reported "never mounted" — a harness gap, not the RTL defect. -->
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<script src="http://127.0.0.1:47395/dx-maps/my-upg-100-list/my-upg-100-list.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
var el = document.createElement("my-upg-100-list-map");
|
||||
el.setAttribute('profile-config', JSON.stringify({ profile: "poster-wall",
|
||||
assetsBase: "http://127.0.0.1:47395/dx-maps/assets" }));
|
||||
el.style.cssText = 'display:block;width:100%;height:900px';
|
||||
document.getElementById('w').appendChild(el);
|
||||
</script></body></html>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>partner</title>
|
||||
<style>html,body{margin:0}#w{height:900px}</style></head>
|
||||
<body><h1 id="partner-heading">Partner page</h1><div id="w"></div>
|
||||
<!-- A REAL PARTNER EMBED SETS THIS. The generated leaf pages bake window.MAP_APP_API_URL, and
|
||||
without it every data fetch resolves same-origin against the partner and fails, so the wall
|
||||
renders zero cards and the locale has nothing to translate. The first run of this gate
|
||||
omitted it and reported "never mounted" — a harness gap, not the RTL defect. -->
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<script src="http://127.0.0.1:47395/dx-maps/my-upg-100-list/my-upg-100-list.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
var el = document.createElement("my-upg-100-list-map");
|
||||
el.setAttribute('profile-config', JSON.stringify({ profile: "poster-wall", locale: 'ar',
|
||||
assetsBase: "http://127.0.0.1:47395/dx-maps/assets" }));
|
||||
el.style.cssText = 'display:block;width:100%;height:900px';
|
||||
document.getElementById('w').appendChild(el);
|
||||
</script></body></html>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>partner</title>
|
||||
<style>html,body{margin:0}#w{height:900px}</style></head>
|
||||
<body><h1 id="partner-heading">Partner page</h1><div id="w"></div>
|
||||
<!-- A REAL PARTNER EMBED SETS THIS. The generated leaf pages bake window.MAP_APP_API_URL, and
|
||||
without it every data fetch resolves same-origin against the partner and fails, so the wall
|
||||
renders zero cards and the locale has nothing to translate. The first run of this gate
|
||||
omitted it and reported "never mounted" — a harness gap, not the RTL defect. -->
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<script src="http://127.0.0.1:47395/dx-maps/my-upg-100-list/my-upg-100-list.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
var el = document.createElement("my-upg-100-list-map");
|
||||
el.setAttribute('profile-config', JSON.stringify({ profile: "upg-100-mfe", locale: 'ar',
|
||||
assetsBase: "http://127.0.0.1:47395/dx-maps/assets" }));
|
||||
el.style.cssText = 'display:block;width:100%;height:900px';
|
||||
document.getElementById('w').appendChild(el);
|
||||
</script></body></html>
|
||||
|
Before Width: | Height: | Size: 858 KiB After Width: | Height: | Size: 926 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="2086" viewBox="0 0 1500 2086" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="2256" viewBox="0 0 1500 2256" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
|
||||
<defs>
|
||||
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2563eb"/></marker>
|
||||
<marker id="aS" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#7c3aed"/></marker>
|
||||
|
|
@ -9,8 +9,8 @@
|
|||
<path id="agL" d="M 194,850 A 556,556 0 0 1 1306,850"/>
|
||||
<path id="mqL" d="M 679,884 A 71,71 0 0 0 821,884"/>
|
||||
</defs>
|
||||
<rect width="1500" height="2086" fill="#ffffff"/>
|
||||
<text x="750" y="54" text-anchor="middle" font-size="34" font-weight="800" fill="#0f172a">How dx-maps is built: one owner per plugin, one landing at a time</text>
|
||||
<rect width="1500" height="2256" fill="#ffffff"/>
|
||||
<text x="750" y="54" text-anchor="middle" font-size="34" font-weight="800" fill="#0f172a">How dx-maps is built: one owner per plugin, one merge at a time</text>
|
||||
<circle cx="750" cy="850" r="540" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-dasharray="3 7" opacity=".75"/>
|
||||
<circle cx="750" cy="850" r="355" fill="none" stroke="#15803d" stroke-width="2.2" opacity=".85"/>
|
||||
<circle cx="750" cy="850" r="606" fill="none" stroke="#7c3aed" stroke-width="2" stroke-dasharray="8 7" opacity=".6"/>
|
||||
|
|
@ -29,8 +29,8 @@
|
|||
<circle cx="750" cy="850" r="120" fill="#fef2f2" stroke="#dc2626" stroke-width="3" stroke-dasharray="10 6"/>
|
||||
<path id="mqT" d="M 646,850 A 104,104 0 0 1 854,850" fill="none"/>
|
||||
<path id="mqB" d="M 644,850 A 106,106 0 0 0 856,850" fill="none"/>
|
||||
<text font-size="13" font-weight="800" fill="#b91c1c" letter-spacing="1.6"><textPath href="#mqT" startOffset="50%" text-anchor="middle">LANDING LOCK</textPath></text>
|
||||
<text font-size="12" font-weight="700" fill="#b91c1c" letter-spacing="1.2"><textPath href="#mqB" startOffset="50%" text-anchor="middle">ONE LANDING AT A TIME</textPath></text>
|
||||
<text font-size="13" font-weight="800" fill="#b91c1c" letter-spacing="1.6"><textPath href="#mqT" startOffset="50%" text-anchor="middle">PILOT LOCK</textPath></text>
|
||||
<text font-size="12" font-weight="700" fill="#b91c1c" letter-spacing="1.2"><textPath href="#mqB" startOffset="50%" text-anchor="middle">ONE MERGE AT A TIME</textPath></text>
|
||||
<rect x="617" y="846" width="26" height="20" rx="4" fill="#dc2626"/>
|
||||
<path d="M 622,846 v -6 a 8,8 0 0 1 16,0 v 6" fill="none" stroke="#dc2626" stroke-width="3.5"/>
|
||||
<circle cx="630" cy="856" r="3" fill="#fff"/>
|
||||
|
|
@ -38,9 +38,10 @@
|
|||
<path d="M 862,846 v -6 a 8,8 0 0 1 16,0 v 6" fill="none" stroke="#dc2626" stroke-width="3.5"/>
|
||||
<circle cx="870" cy="856" r="3" fill="#fff"/>
|
||||
<circle cx="750" cy="850" r="72" fill="#166534" stroke="#14532d" stroke-width="3"/>
|
||||
<text x="750" y="850" text-anchor="middle" font-size="18" font-weight="800" fill="#fff">development</text>
|
||||
<text x="750" y="872" text-anchor="middle" font-size="12.5" fill="#dcfce7">every change</text>
|
||||
<text x="750" y="888" text-anchor="middle" font-size="12.5" fill="#dcfce7">lands here</text>
|
||||
<text x="750" y="842" text-anchor="middle" font-size="24" font-weight="800" fill="#fff">pilot</text>
|
||||
<text x="750" y="862" text-anchor="middle" font-size="13" font-weight="700" fill="#dcfce7">development</text>
|
||||
<text x="750" y="882" text-anchor="middle" font-size="12.5" fill="#dcfce7">every change</text>
|
||||
<text x="750" y="897" text-anchor="middle" font-size="12.5" fill="#dcfce7">joins here</text>
|
||||
<text font-size="16" font-weight="800" fill="#15803d" letter-spacing="3"><textPath href="#plL" startOffset="50%" text-anchor="middle">PLUGINS</textPath></text>
|
||||
<text font-size="16" font-weight="800" fill="#2563eb" letter-spacing="3"><textPath href="#agL" startOffset="18%" text-anchor="middle">AGENTS</textPath></text>
|
||||
<line x1="862.1" y1="579.3" x2="822.7" y2="674.5" stroke="#15803d" stroke-width="1.4" opacity=".45"/>
|
||||
|
|
@ -71,8 +72,8 @@
|
|||
<line x1="862.1" y1="1120.7" x2="822.7" y2="1025.5" stroke="#15803d" stroke-width="1.4" opacity=".45"/>
|
||||
<line x1="940.6" y1="1310.1" x2="912.3" y2="1241.7" stroke="#2563eb" stroke-width="2.4" marker-end="url(#aA)"/>
|
||||
<circle cx="885.9" cy="1178.0" r="62" fill="#f0fdf4" stroke="#15803d" stroke-width="2.6"/>
|
||||
<text x="885.9" y="1168.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">UPG-100</text>
|
||||
<text x="885.9" y="1185.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">List</text>
|
||||
<text x="885.9" y="1168.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">UPG100</text>
|
||||
<text x="885.9" y="1185.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Posters</text>
|
||||
<rect x="859.9" y="1193.0" width="52" height="20" rx="10" fill="#15803d"/>
|
||||
<text x="885.9" y="1208.0" text-anchor="middle" font-size="11.5" font-weight="800" fill="#fff">cap 4</text>
|
||||
<circle cx="956.6" cy="1348.9" r="40" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
|
|
@ -121,14 +122,14 @@
|
|||
<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">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="192" font-size="17" fill="#4c1d95">a coordinator puts the merges 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">development — shared branch</text>
|
||||
<text x="88" y="147" font-size="15" fill="#334155">pilot branch (development)</text>
|
||||
<circle cx="64" cy="174" r="11" fill="#fef2f2" stroke="#dc2626" stroke-width="2.5"/>
|
||||
<text x="88" y="179" font-size="15" fill="#334155">landing lock — one at a time</text>
|
||||
<text x="88" y="179" font-size="15" fill="#334155">pilot lock — one at a time</text>
|
||||
<circle cx="64" cy="206" r="11" fill="#fffbeb" stroke="#d97706" stroke-width="2.5"/>
|
||||
<text x="88" y="211" font-size="15" fill="#334155">Plugin Studio — the one repo</text>
|
||||
<circle cx="64" cy="238" r="11" fill="#f0fdf4" stroke="#15803d" stroke-width="2.5"/>
|
||||
|
|
@ -138,11 +139,11 @@
|
|||
<circle cx="64" cy="302" r="11" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="88" y="307" font-size="15" fill="#334155">agent — owns one plugin</text>
|
||||
<circle cx="64" cy="334" r="11" fill="#f5f3ff" stroke="#7c3aed" stroke-width="2.5"/>
|
||||
<text x="88" y="339" font-size="15" fill="#334155">coordinator — orders landings</text>
|
||||
<text x="88" y="339" font-size="15" fill="#334155">coordinator — orders merges</text>
|
||||
<circle cx="64" cy="366" r="11" fill="#f1f5f9" stroke="#334155" stroke-width="2.5"/>
|
||||
<text x="88" y="371" font-size="15" fill="#334155">gate-keeper — checks landings</text>
|
||||
<text x="88" y="371" font-size="15" fill="#334155">gate-keeper — checks each merge</text>
|
||||
<rect x="60" y="1536" width="1380" height="232" rx="20" fill="#fef2f2" stroke="#fca5a5" stroke-width="2"/>
|
||||
<text x="750" y="1578" text-anchor="middle" font-size="22" font-weight="800" fill="#7f1d1d">How a change lands — one at a time, so many plugins can be built at once</text>
|
||||
<text x="750" y="1578" text-anchor="middle" font-size="22" font-weight="800" fill="#7f1d1d">How a change joins the pilot — one at a time, so many plugins can be built at once</text>
|
||||
<rect x="80.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
|
||||
<circle cx="114.0" cy="1634" r="18" fill="#dc2626"/>
|
||||
<text x="114.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">1</text>
|
||||
|
|
@ -157,27 +158,27 @@
|
|||
<text x="487.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Second reader</text>
|
||||
<text x="445.0" y="1680" font-size="15" fill="#475569">another agent reads every</text>
|
||||
<text x="445.0" y="1702" font-size="15" fill="#475569">change by its content</text>
|
||||
<text x="445.0" y="1724" font-size="15" fill="#475569">before it may land</text>
|
||||
<text x="445.0" y="1724" font-size="15" fill="#475569">before it may join</text>
|
||||
<text x="750.0" y="1682" text-anchor="middle" font-size="26" font-weight="800" fill="#dc2626">→</text>
|
||||
<rect x="770.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
|
||||
<circle cx="804.0" cy="1634" r="18" fill="#dc2626"/>
|
||||
<text x="804.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">3</text>
|
||||
<text x="832.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">One at a time</text>
|
||||
<text x="790.0" y="1680" font-size="15" fill="#475569">in the lock: merge development,</text>
|
||||
<text x="790.0" y="1680" font-size="15" fill="#475569">in the lock: merge the pilot,</text>
|
||||
<text x="790.0" y="1702" font-size="15" fill="#475569">rebuild, run every check;</text>
|
||||
<text x="790.0" y="1724" font-size="15" fill="#475569">push only if nothing fails</text>
|
||||
<text x="1095.0" y="1682" text-anchor="middle" font-size="26" font-weight="800" fill="#dc2626">→</text>
|
||||
<rect x="1115.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
|
||||
<circle cx="1149.0" cy="1634" r="18" fill="#dc2626"/>
|
||||
<text x="1149.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">4</text>
|
||||
<text x="1177.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Promote to main</text>
|
||||
<text x="1135.0" y="1680" font-size="15" fill="#475569">main is production: it moves</text>
|
||||
<text x="1135.0" y="1702" font-size="15" fill="#475569">only when the owner approves,</text>
|
||||
<text x="1177.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Publish to landing</text>
|
||||
<text x="1135.0" y="1680" font-size="15" fill="#475569">landing (main), production:</text>
|
||||
<text x="1135.0" y="1702" font-size="15" fill="#475569">moves only when a person approves,</text>
|
||||
<text x="1135.0" y="1724" font-size="15" fill="#475569">after full checks on that version</text>
|
||||
<line x1="750" y1="956" x2="750" y2="1786" stroke="#334155" stroke-width="2.5" stroke-dasharray="2 5" opacity=".0"/>
|
||||
<rect x="60" y="1812" width="1380" height="254" rx="20" fill="#f8fafc" stroke="#cbd5e1" stroke-width="2"/>
|
||||
<text x="750" y="1856" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">main → ONE build → one web folder → three outputs</text>
|
||||
<text x="750" y="1882" text-anchor="middle" font-size="16" fill="#475569">the dev preview follows development · the published site follows main</text>
|
||||
<text x="750" y="1856" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">landing (main) → ONE build → one web folder → three outputs</text>
|
||||
<text x="750" y="1882" text-anchor="middle" font-size="16" fill="#475569">the pilot server follows development · the landing server rebuilds from main</text>
|
||||
<rect x="80.0" y="1908" width="420" height="130" rx="14" fill="#fff" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="290.0" y="1930" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">OUTPUT 1</text>
|
||||
<text x="290.0" y="1962" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">The web folder</text>
|
||||
|
|
@ -196,4 +197,9 @@
|
|||
<text x="1210.0" y="1988" text-anchor="middle" font-size="15" fill="#475569">the same folder, wrapped by Tauri</text>
|
||||
<rect x="1110.0" y="2002" width="200" height="26" rx="13" fill="#15803d" opacity=".13"/>
|
||||
<text x="1210.0" y="2020" text-anchor="middle" font-size="12" font-weight="800" fill="#15803d">WORKING</text>
|
||||
</svg>
|
||||
<rect x="60" y="2090" width="1380" height="146" rx="20" fill="#eff6ff" stroke="#93c5fd" stroke-width="2"/>
|
||||
<text x="750" y="2132" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">At scale: many people × many agents × many plugins</text>
|
||||
<text x="750" y="2164" text-anchor="middle" font-size="16" fill="#475569">one PR branch per plugin or feature → joins the pilot through the lock → previewed on the pilot server</text>
|
||||
<text x="750" y="2192" text-anchor="middle" font-size="16" fill="#475569">in the cloud: a self-hosted git service, because an agent can publish by accident</text>
|
||||
<text x="750" y="2218" text-anchor="middle" font-size="16" fill="#475569">publish to a public host only from landing (main), when it is ready</text>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
|
@ -13,7 +13,7 @@
|
|||
> bundle `parameterized` (cap-2) no matter how light it looks, and a `meta.json` `type`
|
||||
> declares tiers 3/4/5.
|
||||
|
||||
Generated from 27 bundle(s).
|
||||
Generated from 30 bundle(s).
|
||||
|
||||
### One canonical exemplar per tier (generated — quote these)
|
||||
|
||||
|
|
@ -37,12 +37,15 @@ Generated from 27 bundle(s).
|
|||
| Bundle | Profile | Machine key |
|
||||
|---|---|---|
|
||||
| `all-people-groups` | `all-people-groups` | `single` |
|
||||
| `bible-posters` | `bible-posters` | `single` |
|
||||
| `bless-india-posters` | `bless-india-posters` | `single` |
|
||||
| `cities-internet` | `connectivity-list` | `single` |
|
||||
| `cities-internet` | `connectivity-map` | `single` |
|
||||
| `cities-internet` | `data-tables` | `single` |
|
||||
| `country-map` | `country-map` | `single` |
|
||||
| `country-posters` | `countries-map` | `single` |
|
||||
| `country-posters` | `country-poster-wall` | `single` |
|
||||
| `country-posters` | `poster-section` | `single` |
|
||||
| `golden-path-check` | `golden-path-check` | `single` |
|
||||
| `gospel-internet` | `board` | `single` |
|
||||
| `gospel-resources` | `charts` | `single` |
|
||||
|
|
@ -54,6 +57,7 @@ Generated from 27 bundle(s).
|
|||
| `poster-design` | `poster-sizes` | `single` |
|
||||
| `poster-design` | `poster-styles` | `single` |
|
||||
| `prayer-walk-planner` | `prayer-walk-planner` | `single` |
|
||||
| `regions-dashboard` | `country-poster-sections` | `single` |
|
||||
| `regions-dashboard` | `region-dashboards` | `single` |
|
||||
| `regions-dashboard` | `regions-list` | `single` |
|
||||
|
||||
|
|
@ -65,6 +69,7 @@ Generated from 27 bundle(s).
|
|||
| `doxa-simple-map` | `doxa-simple-map` | `parameterized` |
|
||||
| `doxa-vision-card` | `doxa-vision-card` | `parameterized` |
|
||||
| `people-groups-grid` | `people-groups-grid` | `parameterized` |
|
||||
| `poster-sections` | `poster-sections` | `parameterized` |
|
||||
|
||||
## Capability 3 — multi-view-element (monolithic)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ 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
|
||||
|
|
@ -96,6 +98,10 @@ and a change here can break other maps.
|
|||
`import … from '@map/components/poster/PosterDialog.vue'`
|
||||
- **PosterPreview** — PosterPreview.vue — scaled live preview of the poster composition.
|
||||
`import … from '@map/components/poster/PosterPreview.vue'`
|
||||
- **PosterSection** — PosterSection.vue — ONE section of a poster wall: its head (the name, the count, the collapse toggle) and its
|
||||
`import … from '@map/components/poster/PosterSection.vue'`
|
||||
- **PosterSectionView** — PosterSectionView.vue — ONE poster section on its own: the profile behind <dxm-poster-section>.
|
||||
`import … from '@map/components/poster/PosterSectionView.vue'`
|
||||
- **PosterWall** — PosterWall.vue — THE SHARED POSTER WALL.
|
||||
`import … from '@map/components/poster/PosterWall.vue'`
|
||||
- **FooterSlot** — FooterSlot.vue — STUB. Poster-rendering slot for FooterSlot.
|
||||
|
|
@ -264,8 +270,12 @@ 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'`
|
||||
- **scrollport** — findScrollportAncestor — the nearest ancestor that actually scrolls.
|
||||
|
|
|
|||
|
|
@ -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 the Research Gallery
|
||||
- **Plugins:** none reads `profile-config.tokenUrl` today. The studio and Deep Research
|
||||
forward it, but no element consumes it (see the open points below).
|
||||
|
||||
## 5. People-group list: `'doxa'` | `'unreached'` | `'frontier'` | `'frontier-500k'` | `'unengaged'`
|
||||
|
|
@ -158,7 +158,7 @@ the IMB / peoplegroups.org dataset (never a hand-kept roster). Aliases name them
|
|||
|
||||
## Open points found while writing this down
|
||||
|
||||
Each is a defect or a decision, routed through dx-shepherd.
|
||||
Each is a defect or a decision, still open.
|
||||
|
||||
1. **`profile-config.tokenUrl` has no reader.** It is written by the studio and the gallery and
|
||||
read by nothing. The reader's own URL is also never forwarded as `tokenUrl`, only as the token
|
||||
|
|
|
|||
|
|
@ -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. the Research Gallery (`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. Deep Research (`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 UPG-100 profiles share one tag). A pass count alone cannot catch an SPA fallback that renders
|
||||
(the two UPG100 Posters profiles share one tag). A pass count alone cannot catch an SPA fallback that renders
|
||||
*a* map for every URL.
|
||||
- **Why SRI identity, not HTTP status.** A platform's app URL can return 200 with a sign-in page; comparing
|
||||
each live bundle's bytes against the manifest's `integrity` hash is a cryptographic identity proof that
|
||||
|
|
@ -356,8 +356,6 @@ The measurements behind the rules above, kept for whoever needs the reasoning.
|
|||
- **Undocumented gates (2026-09-22).** 26 gates lived outside the build and only nine were listed; two
|
||||
unlisted ones had been failing for two sessions. A standalone gate nobody knows about does not run —
|
||||
which is why the table above is the list to run before publishing.
|
||||
- **`check:dxlife`.** It exits 2 ("clone not found") on any machine without a sibling DX-life clone — a
|
||||
deliberate third value that `bun run` reports as a failure. It is not in the build, so a clone still builds.
|
||||
- **Screenshot distinctness.** Two README slots once showed the same view under two captions: the shot
|
||||
chased a "Next" control that did not exist inside a `try/catch`, and the PNGs differed by 0.16% render
|
||||
noise, so no checksum caught them.
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ export function backendDevPlugin(root, env = process.env) {
|
|||
name: 'dx-backend-dev',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
// ENGINE NOT RUNNING, QUIETLY (dx-shepherd, R500): with no backend process the proxy answered
|
||||
// ENGINE NOT RUNNING, QUIETLY (R500): with no backend process the proxy answered
|
||||
// 500 ECONNREFUSED, which every page showed as console errors. While a backend is down its routes get
|
||||
// well-formed answers instead: /events streams ONE status event saying the engine is offline (the
|
||||
// plugin renders its own "engine not running" state from it), anything else returns an empty JSON
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ done
|
|||
|
||||
# Everything busy: keep POLLING EVERY SLOT until the deadline, and report a timeout as UNMEASURED
|
||||
# rather than as a verdict. This used to queue on slot 1 alone (flock -w on slot 1), so a slot that
|
||||
# freed up was never taken: measured 2026-09-25 (dx-shepherd), ten waiters were piled on slot 1 while
|
||||
# freed up was never taken: measured 2026-09-25, ten waiters were piled on slot 1 while
|
||||
# slot 2 sat free, and with slot 1 held 12 s and slot 2 freed at ~3 s a caller waited 11.5 s — the
|
||||
# two-slot semaphore had collapsed into ONE queue. Polling takes the first slot that frees (~4 s in
|
||||
# the same test). Trade-off, accepted: polling is not first-come-first-served, so under heavy
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
*
|
||||
* Usage: node internal/build-packet-fixtures.mjs
|
||||
*/
|
||||
import { existsSync, renameSync, readdirSync } from 'node:fs'
|
||||
import { existsSync, renameSync, readdirSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
|
@ -38,6 +38,11 @@ const OUT = resolve(REPO, 'library/scripts/posters/out/packets')
|
|||
// Every fixture is built against this zone: the records come from the fixture, and the zone only
|
||||
// supplies page 1's map. Keeping it constant means a shape difference is the ONLY variable.
|
||||
const ZONE = 'asia-china-tibet-autonomous-region'
|
||||
// ★ BUILT IN THEIR OWN DIRECTORY. packet.mjs writes <zone>.html into its output dir, and every fixture is built
|
||||
// against the REAL Tibet zone; built straight into out/packets, each run overwrote the real Tibet packet and the
|
||||
// rename then removed it, so check:packets-all reported a generated zone as missing. PACKET_OUT sends each build
|
||||
// to a private directory; only the renamed fixture-<name>.html reaches out/packets.
|
||||
const BUILD = join(OUT, '.fixture-build')
|
||||
|
||||
if (!existsSync(FIXTURES)) {
|
||||
console.log(`PACKET-FIXTURES: SKIP — no fixtures directory at ${FIXTURES}`)
|
||||
|
|
@ -54,14 +59,16 @@ console.log(`PACKET-FIXTURES: building ${fixtures.length} fixture(s)`)
|
|||
const built = []
|
||||
const failed = []
|
||||
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
mkdirSync(BUILD, { recursive: true })
|
||||
for (const fx of fixtures) {
|
||||
const name = fx.replace(/^records-/, '').replace(/\.json$/, '')
|
||||
const res = spawnSync(process.execPath, [join(REPO, 'library/scripts/posters/packet.mjs')], {
|
||||
cwd: REPO,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PACKET_RECORDS: join(FIXTURES, fx), ONLY: ZONE },
|
||||
env: { ...process.env, PACKET_RECORDS: join(FIXTURES, fx), ONLY: ZONE, PACKET_OUT: BUILD },
|
||||
})
|
||||
const produced = join(OUT, `${ZONE}.html`)
|
||||
const produced = join(BUILD, `${ZONE}.html`)
|
||||
if (res.status !== 0 || !existsSync(produced)) {
|
||||
// The ERROR line, not the last line: Node ends a crash with its version banner, so the last line
|
||||
// of a TDZ ReferenceError read "Node.js v20.20.2" and named nothing.
|
||||
|
|
@ -77,6 +84,8 @@ for (const fx of fixtures) {
|
|||
console.log(` ✓ ${fx} -> fixture-${name}.html`)
|
||||
}
|
||||
|
||||
try { rmSync(BUILD, { recursive: true, force: true }) } catch { /* best effort */ }
|
||||
|
||||
if (failed.length) {
|
||||
console.error(`\nPACKET-FIXTURES: FAIL — ${failed.length} fixture(s) did not build`)
|
||||
for (const f of failed) console.error(' - ' + f)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ check('a GeoJSON feature is matched to rows on the same point', colocatedWith(id
|
|||
check('a lone point shares with nobody', colocatedWith(idx, rows[3]).length === 0, colocatedWith(idx, rows[3]))
|
||||
check('a row without a position is ignored', colocatedWith(idx, rows[4]).length === 0, colocatedWith(idx, rows[4]))
|
||||
// A click hands over the RENDERED feature: the engine rebuilt its geometry from tile coordinates, so it is
|
||||
// near the roster point but not equal to it: ~0.001 degree at low zoom (rega, row 46, a shared point that never matched).
|
||||
// near the roster point but not equal to it: ~0.001 degree at low zoom (a browser check, row 46: a shared point that never matched).
|
||||
const rendered = { type: 'Feature', properties: { name: 'Koraga' }, geometry: { type: 'Point', coordinates: [75.2486, 12.8431] } }
|
||||
check('a rendered (tile-rounded) feature still finds its co-located groups', colocatedWith(idx, rendered).sort().join() === 'Godda,Maila', colocatedWith(idx, rendered))
|
||||
// The same people in two countries: the nearest roster row wins, not the first.
|
||||
|
|
@ -39,7 +39,7 @@ const renderedFar = { type: 'Feature', properties: { name: 'Koraga' }, geometry:
|
|||
check('a repeated name resolves to the nearest roster point', colocatedWith(twin, renderedFar).join() === 'Other', colocatedWith(twin, renderedFar))
|
||||
// The rows a map DRAWS are normalized people groups (name + latitude/longitude, no geometry), and every
|
||||
// map built on useMapData must offer them to its legends: the prayer map did not, so its legends read the
|
||||
// raw API cache and neither the shared-point sentence nor the standing note appeared (rega, row 46 at 835ec1ac).
|
||||
// raw API cache and neither the shared-point sentence nor the standing note appeared (a browser check, row 46 at 835ec1ac).
|
||||
const drawn = [{ name: 'A', latitude: 12.85, longitude: 75.25 }, { name: 'B', latitude: '12.85', longitude: '75.25' }]
|
||||
const clickedA = { type: 'Feature', properties: { name: 'A' }, geometry: { type: 'Point', coordinates: [75.2504, 12.8497] } }
|
||||
check('normalized rows (no geometry) index and match a clicked pin', colocatedWith(buildColocationIndex(drawn), clickedA).join() === 'B', colocatedWith(buildColocationIndex(drawn), clickedA))
|
||||
|
|
@ -74,4 +74,4 @@ try {
|
|||
check('Arabic joins the names with و, never an English comma inside Arabic prose', /Koraga وMaila/.test(ar2) && !/Koraga, Maila/.test(ar2), ar2)
|
||||
} finally { await server.close() }
|
||||
if (bad) { console.log(`APPROX-PIN-NOTE: FAIL — ${bad} case(s).`); process.exit(1) }
|
||||
console.log('APPROX-PIN-NOTE: PASS — pins say they are localities, and name the groups that share a point.')
|
||||
console.log('APPROX-PIN-NOTE: PASS — pins say what they are, and name the groups that share a point.')
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-backlog-refs.mjs — a card may not point at a card that does not exist.
|
||||
*
|
||||
* THE DEFECT THIS CATCHES. `depends_on` is the board's readiness mechanism: a card is ready when
|
||||
* its dependencies are done. A dependency naming a card that is NOT IN THE BACKLOG can never be
|
||||
* done, so the card can never become ready — and nothing says so. It reads as ordinary blocked
|
||||
* work forever.
|
||||
*
|
||||
* MEASURED 2026-09-24, three dangling references across the builder backlog:
|
||||
* IDEA-SLIM-MAP-SEARCHBAR-HOW-DO-FILTERS-FIT <- BUILD-PEOPLE-GROUP-SEARCH-…depends_on
|
||||
* IDEA-REGIONS-DASHBOARD-REVERSE-ENGINEER-DXLIFE <- two regions cards' depends_on
|
||||
* PRINCIPLE-MAPS-ARE-SECONDARY-LISTS-AND-GRIDS-ARE-PRIMARY <- two cards' parent
|
||||
* The first one matters most: that card is status:todo and waiting on something unfindable.
|
||||
* The last is cited by the loop prompt itself as an open card ("PRINCIPLE-MA"), so a reader is
|
||||
* being pointed at work that has no record.
|
||||
*
|
||||
* ★ SEVERITY IS NOT UNIFORM, so this gate grades it. A dangling `depends_on` on an OPEN card is a
|
||||
* FAILURE — that card is unreachable. A dangling `parent`, or a dangling anything on a card that is
|
||||
* already done, is REPORTED but does not fail: losing a parent is a filing problem, not a blocked
|
||||
* one, and failing on it would only teach people to delete the reference rather than restore the
|
||||
* card. Naming the difference is the point.
|
||||
*
|
||||
* ⚠ IT ONLY CHECKS REFERENCES THAT LOOK LIKE CARD IDS. Fields like `parent` also legitimately hold
|
||||
* prose in some cards ("the regions epic"), and treating those as broken links would drown the
|
||||
* real ones. The shape is SCREAMING-KEBAB, 9+ chars — the convention every id in this board uses.
|
||||
*
|
||||
* Usage: node internal/check-backlog-refs.mjs
|
||||
* SKIPs when _qa-cycle is absent (it lives outside every repo, by design).
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const QA = process.env.QA_CYCLE || resolve(HERE, '..', '..', '_qa-cycle')
|
||||
if (!existsSync(QA)) {
|
||||
console.log('BACKLOG-REFS: SKIP — _qa-cycle not present (expected in CI; it is in no repo).')
|
||||
console.log(' This gate reads the AGENTIC BACKLOGS, which live outside the repo and are not')
|
||||
console.log(' cloned with it. Absence is expected; it is NOT a pass.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const PHASES = ['01-ideation-research', '02-builder-cycle', '03-deployment-cycle']
|
||||
const REF_FIELDS = ['parent', 'depends_on', 'blocks', 'supersedes', 'supersedes_mechanism_of']
|
||||
// The id convention every card in this board follows. Prose in a `parent` field must NOT match.
|
||||
const ID_SHAPE = /^[A-Z][A-Z0-9-]{8,}$/
|
||||
|
||||
const cards = []
|
||||
for (const phase of PHASES) {
|
||||
const p = join(QA, phase, 'backlog.json')
|
||||
if (!existsSync(p)) continue
|
||||
const j = JSON.parse(readFileSync(p, 'utf8'))
|
||||
for (const c of j.cards || []) cards.push({ ...c, _phase: phase })
|
||||
}
|
||||
if (!cards.length) {
|
||||
console.log('BACKLOG-REFS: SKIP — no cards found in any phase backlog.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const ids = new Set(cards.map((c) => c.id))
|
||||
const OPEN = new Set(['todo', 'doing', 'in-progress', 'blocked'])
|
||||
|
||||
const blocking = [] // dangling depends_on on an OPEN card — the card can never become ready
|
||||
const noted = [] // everything else dangling — filing debt, reported not failed
|
||||
|
||||
for (const c of cards) {
|
||||
const f = c.fields || {}
|
||||
for (const key of REF_FIELDS) {
|
||||
const raw = f[key] !== undefined ? f[key] : c[key]
|
||||
if (!raw) continue
|
||||
for (const ref of (Array.isArray(raw) ? raw : [raw])) {
|
||||
if (typeof ref !== 'string' || !ID_SHAPE.test(ref)) continue
|
||||
if (ids.has(ref)) continue
|
||||
const entry = { from: c.id, key, ref, status: c.status || '(none)' }
|
||||
if (key === 'depends_on' && OPEN.has(String(c.status || '').toLowerCase())) blocking.push(entry)
|
||||
else noted.push(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`BACKLOG-REFS: ${cards.length} card(s) across ${PHASES.length} backlog(s), `
|
||||
+ `${REF_FIELDS.length} reference field(s) checked.`)
|
||||
if (noted.length) {
|
||||
console.log(` ${noted.length} dangling reference(s) that do NOT block anything:`)
|
||||
for (const n of noted) console.log(` ${n.from} [${n.status}] .${n.key} -> ${n.ref}`)
|
||||
console.log(' Filing debt: a lost parent or a stale link on a finished card. Restore the card')
|
||||
console.log(' or drop the reference — but do not delete it just to quiet this line.')
|
||||
}
|
||||
|
||||
if (blocking.length) {
|
||||
console.error(`\nBACKLOG-REFS: FAIL — ${blocking.length} OPEN card(s) depend on a card that does not exist.`)
|
||||
for (const b of blocking) {
|
||||
console.error(` ✗ ${b.from} [${b.status}] depends_on ${b.ref}`)
|
||||
}
|
||||
console.error('\n A dependency that is not in the backlog can never be done, so these cards can')
|
||||
console.error(' never become ready — and nothing says so. They read as ordinary blocked work.')
|
||||
console.error(' Fix: restore the missing card, or remove the dependency if it is obsolete.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('BACKLOG-REFS: PASS — every open card\'s dependencies name a card that exists.')
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-backlog-status-enum.mjs — a card's `status` must be a machine-readable enum, not prose.
|
||||
*
|
||||
* THE DEFECT THIS CATCHES, and it was MY OWN reporting error for an entire flight. Cards were being
|
||||
* written with explanatory statuses:
|
||||
*
|
||||
* "done — reconciled: big-picture + dashboard-plus-grid are current, unified-research-dashboard…"
|
||||
* "blocked — Steps 1+3 DONE and re-verified 2026-09-22 at 19/19 bundles…"
|
||||
* "ready"
|
||||
*
|
||||
* The prose is genuinely valuable — several of those strings carry measurements — but it made
|
||||
* `status` unqueryable. Every count I reported used `status !== 'done'`, which is FALSE for
|
||||
* `"done — reconciled: …"`, so finished cards kept being counted as open.
|
||||
*
|
||||
* MEASURED 2026-09-22 across the three phase backlogs: 130 of 395 cards carried a prose status, and
|
||||
* 71 of those were ALREADY DONE. Reported open was 176; the real number was 105. The P0 figure I had
|
||||
* been quoting every tick was 21; it was actually 14.
|
||||
*
|
||||
* ★ WHY THIS IS WORSE THAN AN ORDINARY OFF-BY-N. A backlog is a decision instrument — it answers
|
||||
* "what is left" and "is the remaining work human-gated". Inflating the open count makes a drained
|
||||
* board look stalled, which pushes toward manufacturing work rather than holding honestly. And the
|
||||
* error is self-concealing: every individual card reads correctly to a human, because the prose says
|
||||
* exactly what is true. Only the AGGREGATE is wrong.
|
||||
*
|
||||
* THE FIX PRESERVES THE PROSE. Migration moved it to `fields.status_detail` and normalised `status`
|
||||
* to the enum; 130/130 preserved, 0 lost. The explanation was never the problem — putting it in the
|
||||
* field that tooling reads was.
|
||||
*
|
||||
* WHAT IT ASSERTS: every card's `status` is ONE WORD from a closed set — the five workflow
|
||||
* states plus four terminal ones (superseded / deferred / decided / verified-already-true).
|
||||
* The point is not the particular words: it is that `status` stays machine-readable so the
|
||||
* open-count is right. Prose belongs in fields.status_detail.
|
||||
*
|
||||
* It deliberately does NOT require `status_detail` to exist. Most cards need no explanation, and
|
||||
* demanding one would turn a real check into paperwork that gets satisfied with filler.
|
||||
*
|
||||
* NEGATIVE-TESTED: setting one card's status back to "done — reconciled: …" fails by name.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const QA = join(ROOT, '../_qa-cycle')
|
||||
const PHASES = ['01-ideation-research', '02-builder-cycle', '03-deployment-cycle']
|
||||
// ★ THE ENUM WAS TOO NARROW, not the cards wrong. MEASURED 2026-09-24 across 633 cards:
|
||||
// EIGHT distinct status values are in use and EVERY ONE IS A SINGLE WORD — there is no
|
||||
// prose left anywhere in the field. The nine this gate was failing carried legitimate
|
||||
// TERMINAL states the original enum simply predated: superseded (5), deferred (2),
|
||||
// decided, verified-already-true.
|
||||
//
|
||||
// This gate exists to keep the AGGREGATE honest — "a prose status makes every open-count
|
||||
// wrong while each card still READS correctly". A one-word terminal state serves that
|
||||
// purpose perfectly; it is exactly what the 130-card migration was FOR. Rewriting nine
|
||||
// accurate statuses to 'done' would have destroyed real distinctions (a SUPERSEDED card
|
||||
// was replaced by another; a DEFERRED one was consciously postponed; neither was
|
||||
// finished) to satisfy a list — losing information to make a checker green.
|
||||
//
|
||||
// OPEN vs CLOSED is the property that matters, and all four additions are CLOSED:
|
||||
// open = todo + doing + in-progress (66), closed = done + these four (507), blocked (60).
|
||||
const TERMINAL_EXTRA = ['superseded', 'deferred', 'decided', 'verified-already-true']
|
||||
const ALLOWED = new Set(['todo', 'doing', 'in-progress', 'blocked', 'done', ...TERMINAL_EXTRA])
|
||||
|
||||
if (!existsSync(QA)) {
|
||||
// _qa-cycle lives outside every repo the CI clones, so absence is EXPECTED there. Say so loudly
|
||||
// rather than passing silently — a gate that reports success when it never ran is the failure
|
||||
// mode this repo has already been bitten by.
|
||||
console.log('BACKLOG-STATUS: SKIP — _qa-cycle not present (expected in CI; it is in no repo).')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const offenders = []
|
||||
let total = 0
|
||||
const counts = {}
|
||||
|
||||
for (const ph of PHASES) {
|
||||
const p = join(QA, ph, 'backlog.json')
|
||||
if (!existsSync(p)) continue
|
||||
let cards
|
||||
try {
|
||||
cards = JSON.parse(readFileSync(p, 'utf8')).cards || []
|
||||
} catch (e) {
|
||||
console.error(`BACKLOG-STATUS: FAIL — ${ph}/backlog.json does not parse: ${e.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
for (const c of cards) {
|
||||
total++
|
||||
const s = String(c.status)
|
||||
counts[s] = (counts[s] || 0) + 1
|
||||
if (!ALLOWED.has(s)) offenders.push({ ph, id: c.id, s })
|
||||
}
|
||||
}
|
||||
|
||||
if (!total) {
|
||||
console.error('BACKLOG-STATUS: FAIL — found 0 cards across three phase backlogs.')
|
||||
console.error(' A gate with nothing to assert is not passing; it is not running.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (offenders.length) {
|
||||
console.error(`BACKLOG-STATUS: FAIL — ${offenders.length} of ${total} card(s) carry a non-enum status.\n`)
|
||||
for (const o of offenders.slice(0, 12)) {
|
||||
console.error(` ${o.ph} ${o.id}`)
|
||||
console.error(` status: "${o.s.slice(0, 84)}${o.s.length > 84 ? '…' : ''}"`)
|
||||
}
|
||||
if (offenders.length > 12) console.error(` …and ${offenders.length - 12} more`)
|
||||
console.error('\n Allowed: ' + [...ALLOWED].join(' / '))
|
||||
console.error(' Keep the explanation — move it to fields.status_detail. The prose is valuable and')
|
||||
console.error(' often carries measurements; it just must not live in the field tooling filters on.')
|
||||
console.error(' A prose status makes every open-count wrong while each card still READS correctly,')
|
||||
console.error(' so the error hides in the aggregate: 130 of 395 cards once carried one, 71 of them')
|
||||
console.error(' already done, and the reported open count was 176 against a real 105.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`BACKLOG-STATUS: PASS — ${total} card(s), every status is a queryable enum.`)
|
||||
const order = ['todo', 'doing', 'in-progress', 'blocked', 'done']
|
||||
console.log(' ' + order.filter((k) => counts[k]).map((k) => `${k} ${counts[k]}`).join(' · '))
|
||||
console.log(' Explanations live in fields.status_detail, which is not required — most cards need')
|
||||
console.log(' none, and demanding one would turn this into paperwork satisfied with filler.')
|
||||
|
|
@ -112,6 +112,92 @@ 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 +
|
||||
// a country names both; a province with no table beside it is not named; Mizoram (no groups listed) alone gives the
|
||||
// keyed "no people groups" text. NEGATIVE CONTROL: the old naming (zones + countries) gives [] and reduce() throws.
|
||||
{
|
||||
const { keyedError, errorText } = await import(pathToFileURL(P('library/i18n/keyedError.js')).href)
|
||||
const wallEn = JSON.parse(readFileSync(P('library/i18n/locales/en/wall.json'), 'utf8'))
|
||||
const tr = (k) => k.split('.').slice(1).reduce((o, x) => (o == null ? o : o[x]), wallEn) ?? k
|
||||
const states = JSON.parse(readFileSync(P('data/people-groups/india-states.json'), 'utf8')).states
|
||||
const st = (shape) => states.find((x) => x.shapeName === shape)
|
||||
const guj = st('Gujarāt'), miz = st('Mizoram')
|
||||
const fromOf = (pairs) => { const m = new Map(); for (const [slugs, name] of pairs) for (const s of slugs) m.set(s, [...(m.get(s) || []), name]); return m }
|
||||
const noRecords = () => keyedError('wall.hud.noRecords', 'that list has no people groups to print')
|
||||
const join = (a, b) => `${a} + ${b}`
|
||||
const T = (kind, name) => ({ kind, name })
|
||||
const alone = X.selectionTitleNames([], [T('adm1', 'Gujarāt')], fromOf([[guj.slugs, 'Gujarāt']]), noRecords)
|
||||
const both = X.selectionTitleNames([], [T('adm0', 'Nepal'), T('adm1', 'Gujarāt')], fromOf([[['npl-a', 'npl-b'], 'Nepal'], [guj.slugs, 'Gujarāt']]), noRecords)
|
||||
const withProvince = X.selectionTitleNames([], [T('adm1', 'Guizhou Province'), T('adm1', 'Gujarāt')], fromOf([[guj.slugs, 'Gujarāt']]), noRecords)
|
||||
let empty = ''
|
||||
try { X.selectionTitleNames([], [T('adm1', 'Mizoram')], fromOf([[miz.slugs, 'Mizoram']]), noRecords); empty = 'no throw' } catch (e) { empty = errorText(e, tr) }
|
||||
let oldThrows = false
|
||||
try { [].reduce(join) } catch (_) { oldThrows = true }
|
||||
const oldNames = [...[], ...[T('adm1', 'Gujarāt')].filter((t) => t.kind === 'adm0').map((t) => t.name)]
|
||||
ok(guj.count === 90 && miz.count === 0 && oldNames.length === 0 && oldThrows,
|
||||
`NEGATIVE CONTROL: Gujarāt ${guj.count} / Mizoram ${miz.count} groups, the old naming gave ${JSON.stringify(oldNames)}`,
|
||||
'negative control: on the old naming a Gujarāt-only selection has no names, and reduce() throws')
|
||||
ok(alone.length === 1 && alone.reduce(join) === 'Gujarāt' && both.join('|') === 'Nepal|Gujarāt' && withProvince.join('|') === 'Gujarāt' && empty === tr('wall.hud.noRecords'),
|
||||
`titles: alone ${JSON.stringify(alone)}, with a country ${JSON.stringify(both)}, with a province ${JSON.stringify(withProvince)}, Mizoram alone "${empty}"`,
|
||||
`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
|
||||
|
|
@ -148,10 +234,15 @@ 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 && /selectionPeople\(\)\.then/.test(vue),
|
||||
'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /settleWithin\(selectionPeople\(\)/.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 rega's; this pins the guard in the source.
|
||||
// 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),
|
||||
// …and that tap toggles the territory EXACTLY ONCE: the tapestry listener already does it, so the pin handler
|
||||
// must not toggle a second time (select-then-deselect in one tap).
|
||||
|
|
@ -160,7 +251,34 @@ 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)
|
||||
&& !/const names = \[\.\.\.zoneNames, \.\.\.countryNames\]/.test(vue),
|
||||
'India states detected from the served table': /import \{ loadIndiaStates \} from '@map\/data\/indiaStates\.js'/.test(vue)
|
||||
&& /india\.byShapeName\.get\(t\.name\)/.test(vue) && /t\.kind === 'adm1'\) indiaStates\(\)\.catch/.test(vue),
|
||||
}
|
||||
|
|
@ -170,7 +288,8 @@ 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 = {
|
||||
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
|
||||
poster: ['phoneLimitAll'],
|
||||
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'peopleStillLoading', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
|
||||
const cat = (l, f) => JSON.parse(readFileSync(P('library/i18n/locales', l, `${f}.json`), 'utf8'))
|
||||
const bad = []
|
||||
for (const [f, keys] of Object.entries(KEYS)) {
|
||||
|
|
|
|||