Compare commits
No commits in common. "main" and "poster-design-plugin/catalogue" have entirely different histories.
main
...
poster-des
237
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.
|
||||
- **UPG100 List:** the most strategic. It divides the task into prayer packets.
|
||||
- **UPG-100:** the most strategic. It divides the task into prayer packets.
|
||||
- **Gospel maps:** the resources that exist in each language.
|
||||
- **UPG Neighbor:** the reached neighbors near each unreached group.
|
||||
- **Gospel × Internet:** all of it, together. Built for linguists, catalysts, translators (human
|
||||
|
|
@ -37,9 +37,8 @@ Built with love, with the prayer that these maps bless the nations.
|
|||
**A blessing for you.** These maps and posters are given to bless the people who bless the nations.
|
||||
We love you, and we are thankful for your work. We bless you in Jesus' name.
|
||||
|
||||
Posterverse (`plugin-designs/posterverse/`) brings the six poster plugins together: UPG100 Posters, Country
|
||||
Posters, Poster Design, Wall of People Groups, Bible Posters and Bless India Posters. They are all in the
|
||||
Plugin Studio (`bun run dev`) too.
|
||||
Every poster is in Posterverse (`plugin-designs/posterverse/`): the UPG 100 List, Country Posters,
|
||||
Poster Design and Wall of People Groups. They are all in the Plugin Studio (`bun run dev`) too.
|
||||
|
||||
# dx-maps — one build, maps that embed everywhere
|
||||
|
||||
|
|
@ -61,7 +60,7 @@ capabilities, three outputs, one shared library, one build.
|
|||
| 🔗 **Maps that talk to each other** | Maps share props and events, so one app can nest them and a click in one can move another. |
|
||||
| 🪶 **Small bundles** | Each map's file carries only the parts of the shared library it uses (tree-shaken). |
|
||||
| ⚙️ **One build step** | `bun run build` makes every map at once. |
|
||||
| 📦 **Three outputs** | A web folder (iframe, script embed), a mobile app, and a desktop app built from the same code you run in the browser — [the 3 outputs](#what-the-build-makes--3-outputs). |
|
||||
| 📦 **Three outputs** | A web folder (iframe, script embed), a mobile app, a desktop app — [the 3 outputs](#what-the-build-makes--3-outputs). |
|
||||
|
||||
| What dx-maps set out to do | Proof |
|
||||
|---|---|
|
||||
|
|
@ -71,7 +70,7 @@ capabilities, three outputs, one shared library, one build.
|
|||
| **One app nests every map** — and each stays embeddable on its own | [capability 5](#capability-5--app-shell-the-research-gallery) · `bun run check:composite-reg` |
|
||||
| **5 capabilities, every one shippable in every output** | `bun run verify:5-caps` |
|
||||
| **Shared library, private plugins** — reuse without breaking each other | `bun run check:library-index` |
|
||||
| **One agent per plugin, the repo agent reads every change, one merge at a time** | [agent swarm](#build-it-with-an-agent-swarm) |
|
||||
| **One owner per plugin, a second reader for every change, one merge at a time** | [agent swarm](#build-it-with-an-agent-swarm) |
|
||||
|
||||
## For vibe coders: build, open the Studio, embed a map
|
||||
|
||||
|
|
@ -120,15 +119,15 @@ people groups of the world. When you work here, the goal is one of these:
|
|||
| **Reach a city** | Regions (`regions-dashboard`) and the country map (`country-map`): the people groups of each country. The production maps work by country and region; city-level planning (Prayer Walk Planner) is still experimental |
|
||||
| **Mobilize for people groups** | Big Picture (`dashboard-plus-grid`); Simple Research (`research-map-plus-grid`) |
|
||||
| **Plan outreach** | Gospel Resources (`gospel-resources`): what exists in each language; Cities with Internet (`cities-internet`): whether a group can be reached online |
|
||||
| **Pray: packets and prayer resources** | UPG100 Posters (`my-upg-100-list`), Regions and Simple Research: posters and prayer packets; Prayer Progress (`doxa-simple-map`) |
|
||||
| **Pray: packets and prayer resources** | UPG 100 List (`my-upg-100-list`), Regions and Simple Research: posters and prayer packets; Prayer Progress (`doxa-simple-map`) |
|
||||
|
||||
- **Start with the skill:** [`skills/plugin-designer`](./skills/plugin-designer) teaches the patterns, the shared
|
||||
library and the checks.
|
||||
- **One plugin per folder** in `plugin-designs/`; shared code goes in `library/` only if it stays reusable for
|
||||
every other plugin.
|
||||
- **Join `development`** (the pilot), one change at a time through the lock, with `gates:pre`, `gates:post` and the landing checks passing. `main` is
|
||||
- **Land on `development`**, one change at a time through the lock, with `bun run gates:pre` passing. `main` is
|
||||
production and moves only on a publish.
|
||||
- These 8 maps are in production: the 7 production maps in the gallery, plus the country map for DX Life's country pages. The
|
||||
- These 8 maps are in production: the 7 in the gallery, plus the country map for DX Life's country pages. The
|
||||
other maps are experimental: leave them as they are unless your task is one
|
||||
of them.
|
||||
|
||||
|
|
@ -136,12 +135,11 @@ people groups of the world. When you work here, the goal is one of these:
|
|||
|
||||
---
|
||||
|
||||
## The production maps
|
||||
## The 7 production maps
|
||||
|
||||
**Seven production maps DX uses today, all together in the Research Gallery**, which DX Life publishes
|
||||
as **Deep Research**; each one is also a plugin you can embed on its own. The gallery also carries two
|
||||
poster plugins still marked experimental, Country Posters and Poster Design (see Posterverse). An eighth
|
||||
production map, the country map (`country-map`), draws DX Life's country pages; it isn't in the gallery.
|
||||
**Seven maps DX uses today** — each one a plugin you can embed on its own, and all seven together in
|
||||
the Research Gallery, which DX Life publishes as **Deep Research**. An eighth production map, the country map
|
||||
(`country-map`), draws DX Life's country pages; it isn't in the gallery.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
|
|
@ -151,10 +149,10 @@ production map, the country map (`country-map`), draws DX Life's country pages;
|
|||
<td width="25%" valign="top"><img src="documentation/readme-images/cap2-prayer.png" alt="Prayer Progress"><br><b>Prayer Progress</b><br><sub>where prayer is happening, and where it is still needed</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/cap4-upg100-mfe.png" alt="UPG100 Posters"><br><b>UPG100 Posters</b><br><sub>prayer lists of 100 people groups, with posters to print</sub></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/cap4-upg100-mfe.png" alt="UPG-100 List"><br><b>UPG-100 List</b><br><sub>prayer lists of 100 people groups, with posters to print</sub></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-cities-internet.png" alt="Cities with Internet"><br><b>Cities with Internet</b><br><sub>internet access where unreached people groups live</sub></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-gospel-resources.png" alt="Gospel Resources"><br><b>Gospel Resources</b><br><sub>which people groups have scripture, film or audio — and which have nothing</sub></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/cap5-doxa-maps-spa.png" alt="Research Gallery"><br><b>Research Gallery</b><br><sub>all nine in one app — capability 5</sub></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/cap5-doxa-maps-spa.png" alt="Research Gallery"><br><b>Research Gallery</b><br><sub>all seven in one app — capability 5</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
|
@ -165,7 +163,7 @@ production map, the country map (`country-map`), draws DX Life's country pages;
|
|||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-simple-research-phone.png" alt="Simple Research on a phone"><br><b>Simple Research</b></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-regions-phone.png" alt="Regions on a phone"><br><b>Regions</b></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-prayer-phone.png" alt="Prayer Progress on a phone"><br><b>Prayer Progress</b></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="UPG100 Posters on a phone"><br><b>UPG100 Posters</b></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="UPG-100 List on a phone"><br><b>UPG-100 List</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-cities-internet-phone.png" alt="Cities with Internet on a phone"><br><b>Cities with Internet</b></td>
|
||||
|
|
@ -175,36 +173,12 @@ production map, the country map (`country-map`), draws DX Life's country pages;
|
|||
</tr>
|
||||
</table>
|
||||
|
||||
### Print your first poster
|
||||
|
||||
1. **Open a poster wall:** *UPG100 Posters*, *Country Posters*, or *Regions* (its **View country posters**
|
||||
button). Every card on the wall has a **Print Options** strip. India's whole-country poster isn't
|
||||
available yet; its state posters are.
|
||||
2. **Pick what to print:** **Poster Map** (the poster), **Prayer List** (choose **Full detail** or
|
||||
**Lightweight**) or **Card Deck** (choose a card size). On *UPG100 Posters*, clicking the poster
|
||||
picture prints it too.
|
||||
3. **Your browser's print window opens, and it is the preview.** Choose the printer and print. Nothing
|
||||
is downloaded; to keep a file, choose **Save as PDF** instead of a printer.
|
||||
|
||||
Posters, lists and packets print on Letter-size pages (8.5 × 11 in). Cards print at their own size:
|
||||
2.5 × 3.5 in, 4 × 6 in or A6.
|
||||
|
||||
**Bigger posters.** *Bible Posters* and the *Wall of People Groups* also offer bigger sizes, up to 5 × 8
|
||||
ft, for a print shop: in the print window choose **Save as PDF** and send the file. The Wall's 5 × 8 ft
|
||||
poster prints as 3 sheets, one per page, stacked so that each overlaps the next by 6 in; every sheet is
|
||||
numbered (1 / 3, 2 / 3 …) and marked **glue here** where the next one goes.
|
||||
|
||||
**On a phone.** The same print window rises over the page; choose **Save as PDF** to keep the file or
|
||||
send it on. A phone prints page sizes only (Letter or A4 paper), and the largest poster sizes are
|
||||
switched off there. A few prints say they need a computer: *Bless India Posters*, the *Wall of People
|
||||
Groups* and a selection of more than 1,000 groups on *UPG100 Posters*.
|
||||
|
||||
### Every capability ships every way
|
||||
|
||||
| Capability | Example | iframe | Mobile app | JavaScript embed | WordPress / Disciple Tools | Desktop |
|
||||
|---|---|:-:|:-:|:-:|:-:|:-:|
|
||||
| **5 — app shell** | Research Gallery | ✅ | ✅ | ✅ | 🧪 | ✅ |
|
||||
| **4 — composite** | UPG100 Posters | ✅ | ✅ | ✅ | 🧪 | ✅ |
|
||||
| **4 — composite** | UPG-100 List | ✅ | ✅ | ✅ | 🧪 | ✅ |
|
||||
| **3 — multi-view** | research map | ✅ | ✅ | ✅ | 🧪 | ✅ |
|
||||
| **2 — presets** | Prayer Progress | ✅ | ✅ | ✅ | 🧪 | ✅ |
|
||||
| **1 — single element** | all people groups | ✅ | ✅ | ✅ | 🧪 | ✅ |
|
||||
|
|
@ -261,9 +235,10 @@ If you build with AI agents, this repo is made for you:
|
|||
updating the moment you save.
|
||||
- **Speak instead of typing.** Rudder, an offline voice-to-text tool, sits as a corner widget in the
|
||||
Studio: look at a plugin, say what you see, and hand the text to your agent. Nothing leaves your
|
||||
computer. It also runs as its own desktop app.
|
||||
- **One plugin, several outputs.** The same code ships as a web embed, a mobile app or a desktop app
|
||||
([the desktop app](#output-3--the-desktop-app--works-today)).
|
||||
computer (Linux today).
|
||||
- **One plugin, several outputs.** The same plugin ships as a web embed or a mobile app, and a plugin
|
||||
can target the desktop: Rudder is the desktop example, a corner widget in the Studio and its own
|
||||
desktop app (`bun run desktop:build rudder-voice`, see [HOWTO-build-the-desktop-app](./documentation/HOWTO-build-the-desktop-app.md)).
|
||||
- **Build together.** [Run it in the cloud](#run-it-in-the-cloud) gives a whole team one shared dev
|
||||
server.
|
||||
|
||||
|
|
@ -276,8 +251,8 @@ embed just the one you need.
|
|||
|---|---|---|
|
||||
| a **translator** (or an AI translation pipeline) | Gospel Resources (map, list, data tables) | which people groups have no Scripture, film or audio in their language, and which bridge language already reaches them |
|
||||
| **media to movements** | Cities with Internet · Gospel × Internet | where unreached people groups live with internet access, country by country and down to province where data exists |
|
||||
| a **catalyst** or mobilizer | Big Picture · Prayer Walk Planner · UPG100 Posters · Gospel × Internet | find **gospel resources** for the people you are reaching real time, build prayer walking maps for real outreach, as you prayer walk each key zone in your cities of choice, find indigenous disciples, groups, and leaders mobilize them each to have prayer packets, cards and posters to blanket all the people groups in prayer as a prayer mobilizer catalyst |
|
||||
| **praying** | Prayer Progress · UPG100 Posters | where prayer is covered and where it is still needed; print a **Prayer List**, **Poster Map** or **Card Deck** for a list of 100 |
|
||||
| a **catalyst** or mobilizer | Big Picture · Prayer Walk Planner · UPG 100 List · Gospel × Internet | find **gospel resources** for the people you are reaching real time, build prayer walking maps for real outreach, as you prayer walk each key zone in your cities of choice, find indigenous disciples, groups, and leaders mobilize them each to have prayer packets, cards and posters to blanket all the people groups in prayer as a prayer mobilizer catalyst |
|
||||
| **praying** | Prayer Progress · UPG-100 List | where prayer is covered and where it is still needed; print a **Prayer List**, **Poster Map** or **Card Deck** for a list of 100 |
|
||||
| a **teacher** or presenter | the Research Gallery | every map in one app, multiple languages support |
|
||||
|
||||
<details>
|
||||
|
|
@ -327,7 +302,7 @@ improvement on the next build.
|
|||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What the Research Gallery is made of — seven production maps, two poster plugins, an Experimental shelf, all five capabilities in one plugin</b></summary>
|
||||
<summary><b>What the Research Gallery is made of — seven production maps, an Experimental shelf, all five capabilities in one plugin</b></summary>
|
||||
|
||||
```text
|
||||
Research Gallery <doxa-maps-spa> capability 5
|
||||
|
|
@ -342,12 +317,10 @@ Research Gallery <doxa-maps-spa> capability 5
|
|||
│ ├─ regions list regions-list capability 1
|
||||
│ └─ region dashboards region-dashboards capability 1
|
||||
├─ Prayer Mobilization
|
||||
│ ├─ Prayer Progress doxa-simple-map capability 2
|
||||
│ ├─ UPG100 Posters upg-100-mfe capability 4
|
||||
│ │ ├─ zoned map upg-100-list capability 1
|
||||
│ ├─ UPG-100 List upg-100-mfe capability 4
|
||||
│ │ ├─ list upg-100-list capability 1
|
||||
│ │ └─ poster wall poster-wall capability 1
|
||||
│ ├─ Country Posters country-posters-mfe capability 4
|
||||
│ └─ Poster Design poster-design-mfe capability 4
|
||||
│ └─ Prayer Progress doxa-simple-map capability 2
|
||||
├─ Gospel Research
|
||||
│ ├─ Cities with Internet connectivity-map capability 1
|
||||
│ └─ Gospel Resources resources-map capability 1
|
||||
|
|
@ -355,6 +328,7 @@ Research Gallery <doxa-maps-spa> capability 5
|
|||
├─ prayer walk planner prayer-walk-planner capability 1
|
||||
├─ UPG neighbor upg-neighbor capability 3
|
||||
├─ all people groups all-people-groups capability 1
|
||||
├─ country posters country-posters capability 4
|
||||
├─ vision card doxa-vision-card capability 2
|
||||
├─ PPLR data maps pplr-data-maps capability 3
|
||||
└─ Wall of People Groups wall-of-people-groups capability 3
|
||||
|
|
@ -366,11 +340,11 @@ Research Gallery <doxa-maps-spa> capability 5
|
|||
|
||||
**Several plugins glued into one**, talking to each other — and each piece still embeddable alone.
|
||||
|
||||

|
||||

|
||||
|
||||
| List (capability 1, embeddable alone) | Poster wall (capability 1, embeddable alone) |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
<details>
|
||||
<summary><b>More about capability 4 — composites</b></summary>
|
||||
|
|
@ -380,7 +354,7 @@ as views you switch between — and ships the set as **one new element**. The pi
|
|||
**props** (settings passed in) and **events** (messages sent out): click a region in one piece and
|
||||
another piece updates. Nothing is copied, so each piece can still be embedded on its own.
|
||||
|
||||
- **Example:** `plugin-designs/my-upg-100-list/` — the UPG100 Posters zoned map and its poster wall, joined into
|
||||
- **Example:** `plugin-designs/my-upg-100-list/` — the UPG-100 list and its poster wall, joined into
|
||||
`upg-100-mfe`. Big Picture, Simple Research and Regions are composites too.
|
||||
- **How to declare one:** a `<name>.meta.json` beside it with `"type": "nested-elements-mfe"` and a
|
||||
`"composes"` list of the plugins inside.
|
||||
|
|
@ -500,7 +474,7 @@ files in the repo. The `Dockerfile` and `compose.yaml` sit at the repo root; rec
|
|||
|
||||
**Plugin Studio: the public Studio, with PocketBase.** The highest route: the same container with
|
||||
`ACCESS=public`, on your research subdomain.
|
||||
- The Plugin Studio follows `development` and is open to everyone, so what agents add to the pilot shows up live.
|
||||
- The Plugin Studio follows `development` and is open to everyone, so what agents land shows up live.
|
||||
- Its `/cdn`, built from `main`, serves your site's production maps.
|
||||
- Publishing and PocketBase's dashboard ask for a password you set when you deploy; OIDC stays available.
|
||||
|
||||
|
|
@ -598,13 +572,13 @@ installed.
|
|||
**The same maps, wrapped as a phone app.** No new code — the maps are JAMstack: they fetch their data
|
||||
from a remote API, which is exactly what a mobile app needs.
|
||||
|
||||
<p align="center"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="Mobile app — UPG100 Posters at phone size" width="320"></p>
|
||||
<p align="center"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="Mobile app — the UPG-100 list at phone size" width="320"></p>
|
||||
|
||||
<details>
|
||||
<summary><b>More about the mobile app</b></summary>
|
||||
|
||||
[Capacitor](https://capacitorjs.com) wraps the built folder into an Android app. Shown here: the
|
||||
UPG100 prayer lists and posters at phone size, each poster with its own print options.
|
||||
UPG-100 prayer lists and posters at phone size, each poster with its own print options.
|
||||
|
||||
```bash
|
||||
bun run check:mobile # checks the app is wired up correctly — needs no Android tools
|
||||
|
|
@ -617,38 +591,21 @@ bun run phone # builds the app and installs it on a phone or emulator
|
|||
|
||||
### Output 3 — the desktop app ✅ works today
|
||||
|
||||
**Run it in your browser, then ship the same code as a desktop app.** [Tauri](https://tauri.app) wraps
|
||||
it in a small Rust program that runs on your computer. Rudder, the offline voice-to-text plugin, is the
|
||||
proof: its Linux desktop app works and is used to dictate the work on many of these maps. Its goal is
|
||||
private voice-to-text that you own: offline, no telemetry, no cloud.
|
||||
**We got the JAMstack map app running as a desktop app.**
|
||||
|
||||

|
||||
|
||||
**Rudder, the voice-to-text plugin, runs as its own desktop app too**, built with
|
||||
`bun run desktop:build rudder-voice` ([HOWTO-build-the-desktop-app](./documentation/HOWTO-build-the-desktop-app.md)).
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary><b>More: how it works, Rudder's goal, and what isn't run yet</b></summary>
|
||||
<summary><b>More about the desktop app</b></summary>
|
||||
|
||||
**How it works.** What you build and try on the development server (`bun run dev`) is what ships to
|
||||
the desktop; there is no second code base. Rudder runs as a corner widget in the Plugin Studio and as
|
||||
its own desktop app, built with `bun run desktop:build rudder-voice`
|
||||
([HOWTO-build-the-desktop-app](./documentation/HOWTO-build-the-desktop-app.md)): look at a plugin, say
|
||||
what you see, and hand the text to an agent. The map app runs as a desktop app the same way.
|
||||
|
||||
**Rudder's goal, from the start: private voice-to-text that you own.**
|
||||
|
||||
- **Offline, with no telemetry and no cloud.** Your own code and your own self-hosted speech model
|
||||
(NVIDIA's open NeMo-Speech.cpp runtime and a small speech model, about 742 MB) run on a modest amount
|
||||
of your computer's CPU. After a one-time download of the runtime and the model, nothing needs the
|
||||
network; a trace of the speech runtime showed zero sockets and zero DNS lookups. The aim is that it
|
||||
works out of the box: today the first-run download is still a manual step.
|
||||
- **Be careful who commits to Rudder.** A voice-to-text tool hears everything said near it, so one
|
||||
bad change could turn it into spyware. Take changes to `plugin-designs/rudder-voice/` and
|
||||
`backend/rudder-engine/` only from people you trust, and have a second person read every one.
|
||||
- **Planned: an egress monitor** that shows you, as it runs, that Rudder sends no data out to anyone.
|
||||
|
||||
**What isn't run yet.** Tauri builds a Linux app and a `.deb` installer today. The macOS and Windows
|
||||
builds are set up but have not been run ([the HOWTO](./documentation/HOWTO-build-the-desktop-app.md)
|
||||
says which steps are proven). The desktop build runs on demand with the commands below; the automatic
|
||||
production build doesn't make it yet. The Research Gallery could become a strong desktop app too.
|
||||
[Tauri](https://tauri.app) wraps the same built folder into a desktop program: it produced a Linux
|
||||
app and a `.deb` installer. It isn't in the dx bundler for production yet — and the Research Gallery
|
||||
could become a strong desktop app.
|
||||
|
||||
```bash
|
||||
bun run check:tauri # checks the desktop setup — needs no Rust tools
|
||||
|
|
@ -698,9 +655,9 @@ with the bundle. `bun run check:sri` keeps the manifest and the built pages in s
|
|||
**Every map speaks 11 languages** — add `?lang=<code>` to a map's page address, or, when a map is embedded in
|
||||
your own page, set `"locale"` in its `profile-config`. Arabic reads right to left.
|
||||
|
||||

|
||||

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

|
||||

|
||||
|
||||
<sub>[Open the diagram full size](documentation/readme-images/delegation-model.png): on a phone its small text needs it.</sub>
|
||||
|
||||
### Set up the branch strategy
|
||||
|
||||
1. **The landing branch (`main`) is what ships.** It moves only when a person approves a publish (step 7).
|
||||
1. **The landing branch (`main`) is production.** It moves only when you 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 PR branch and worktree per feature**, each started from the pilot branch, then install
|
||||
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 on your own machine, each on its own port** (in the cloud, the one
|
||||
|
|
@ -782,50 +732,40 @@ these jobs to its agents.
|
|||
```bash
|
||||
bun x vite --port 5301 --strictPort # the next agent uses 5302, and so on
|
||||
```
|
||||
5. **Join the pilot one at a time, through a lock that works like a merge queue.** First merge the
|
||||
latest pilot into your PR branch (merge, never rebase) and fix any clash there. A second agent reads
|
||||
the change, and looks at it on screen where it shows. Then, inside the lock, merge the pilot once more
|
||||
in case it moved, run the checks (`gates:pre`, `gates:post`) and the landing checks (fresh bundles,
|
||||
the tree, the integrity hashes, no conflict markers), and push only if nothing fails:
|
||||
5. **Land on the pilot one at a time, through a lock.** 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
|
||||
git fetch origin && git merge --no-edit origin/development # on your PR branch: the latest pilot
|
||||
flock /tmp/dx-maps-development.lock sh -c '
|
||||
git fetch origin && git merge --no-edit origin/development &&
|
||||
bun run gates:pre && bun run gates:post &&
|
||||
node internal/landing-preconditions.mjs &&
|
||||
git push origin HEAD:development'
|
||||
git fetch origin && git switch --detach origin/development &&
|
||||
git merge --no-edit regions/work &&
|
||||
bun run gates:pre && git push origin HEAD:development
|
||||
git switch regions/work'
|
||||
```
|
||||
If the merge stops on a conflict, run `git merge --abort`, fix the clash on your PR branch and join
|
||||
If the merge stops on a conflict, run `git merge --abort`, fix the clash on your PR branch and land
|
||||
again. `flock` guards one machine. Agents on several machines need a shared lock, such as one your
|
||||
harness provides.
|
||||
6. **Each change rebuilds what it touches.** There is no single build owner: rebuild every bundle your
|
||||
change reaches, then the tree and the integrity hashes, and commit them with the change. The landing
|
||||
checks refuse a change whose bundles are stale:
|
||||
6. **One agent owns the full build**; the others build only their own plugin:
|
||||
```bash
|
||||
BUNDLE=<name> bun x vite build # each bundle your change reaches
|
||||
bun run tree && bun run fix:sri # then the tree and the integrity hashes
|
||||
BUILD_ALLOW=1 bun run build # the build owner: every plugin
|
||||
BUNDLE=<name> bun x vite build && bun run tree && bun run fix:sri # everyone else: one plugin
|
||||
```
|
||||
Only a change to the build itself (its config or its dependencies) reaches every bundle:
|
||||
`BUILD_ALLOW=1 bun run build`.
|
||||
7. **Publish, when a person approves it:** make sure the release the landing branch is on now has its
|
||||
own version tag (that tag is the rollback point), fast-forward the landing branch to a pilot commit
|
||||
that passed the checks, tag it, and deploy from the tag:
|
||||
7. **Publish:** fast-forward the landing branch to a pilot commit that passed the checks, tag it, and
|
||||
deploy from the tag:
|
||||
```bash
|
||||
git fetch origin && git switch main && git merge --ff-only origin/main
|
||||
git tag -a v0.2.0 -m "Release v0.2.0" && git push origin v0.2.0 # the outgoing release, if untagged
|
||||
git merge --ff-only <development-commit> # a commit that passed the checks
|
||||
bun run gates:pre && bun run gates:post # the checks must pass on exactly this commit
|
||||
bun run gates:pre # the checks must pass on exactly this commit
|
||||
git tag -a v1.0.0 -m "Release v1.0.0" && git push origin main v1.0.0
|
||||
```
|
||||
The tag's `public/dx-maps/` folder is the built site: deploy that, and build the landing demo from
|
||||
the same tag ([how to deploy](./documentation/DEPLOY.md)). To go back, deploy the previous release's tag. Publish to a public host, such as GitHub, only from
|
||||
the landing branch and only when it is ready.
|
||||
The tag's `public/dx-maps/` folder is the built site: deploy that
|
||||
([how to deploy](./documentation/DEPLOY.md)).
|
||||
|
||||
### 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
|
||||
Keep the repository on a forge you host yourself: a swarm pushes often, and on a public host one wrong
|
||||
push publishes work you did not mean to share. Give each app two servers: a **pilot server** that
|
||||
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
|
||||
|
|
@ -847,20 +787,19 @@ All the maps here share **one project, one library and one build step**. That's
|
|||
work together, and it's also the danger: if one agent's change breaks the shared library, **every
|
||||
map breaks at once**. And when several agents work in the same project at the same time, they step on
|
||||
each other — two agents edit the same file, one change quietly erases another, or half-finished work
|
||||
ends up in the version everyone else is using.
|
||||
lands in the version everyone else is using.
|
||||
|
||||
So the project works like a busy kitchen:
|
||||
|
||||
1. **Each agent works in its own branch and copy of the project** — its own counter, so nobody bumps
|
||||
anybody. One owner per plugin, and one per shared file.
|
||||
2. **Only one change joins the pilot at a time.** Whoever holds the *pilot lock* first catches up with
|
||||
2. **Only one change lands at a time.** Whoever holds the *pilot lock* first catches up with
|
||||
everyone else's latest work, fixes any clash itself and runs every check; the change goes into the
|
||||
pilot branch only if nothing fails. The pilot branch always works.
|
||||
3. **Someone keeps order.** One shepherd agent, which all the other agents talk to, keeps order and puts
|
||||
the merges onto the pilot in order; one agent per repository handles its branches and watches the work
|
||||
being done in it. Your harness gives these jobs to its agents.
|
||||
4. **The landing branch moves only when a person approves a publish**, after the checks pass on that exact version.
|
||||
The pilot server follows the pilot branch; the landing server rebuilds from the landing branch.
|
||||
3. **Someone keeps order.** A coordinator can put the merges onto the pilot in order, and a gate-keeper can read
|
||||
each change by its content before it lands. Your harness gives these jobs to its agents.
|
||||
4. **The landing branch moves only when you publish**, after the checks pass on that exact version.
|
||||
The pilot server follows the pilot branch; the production server rebuilds from the landing branch.
|
||||
|
||||
That's how many plugins get built at the same time, in one project, without the maps breaking.
|
||||
|
||||
|
|
@ -873,10 +812,10 @@ That's how many plugins get built at the same time, in one project, without the
|
|||
|---|---|
|
||||
| **repo** | the project folder, with its full history |
|
||||
| **development** (the pilot) | the shared working version: every agent's change is added here, one at a time |
|
||||
| **main** (the landing) | the version that ships: it moves only when a person approves publishing `development` |
|
||||
| **main** (the landing) | the production version: it moves only when `development` is published |
|
||||
| **worktree** | a private copy of the project for one agent to work in |
|
||||
| **merge conflict** | two changes touched the same lines; someone has to decide how to combine them |
|
||||
| **merge** | taking everyone else's latest work into your branch: the latest pilot goes into your PR branch (never a rebase), before joining and once more inside the pilot lock |
|
||||
| **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 |
|
||||
|
||||
|
|
@ -960,7 +899,7 @@ The studio is a plugin like any other: `plugin-designs/plugin-studio/plugin-stud
|
|||
### Run it in the cloud
|
||||
|
||||
**One shared dev server for the whole team, no laptop setup.** A container clones the repo, follows
|
||||
`development`, and hot-reloads every change that joins the pilot, so everyone sees the Plugin Studio update in
|
||||
`development`, and hot-reloads every change an agent lands, so everyone sees the Plugin Studio update in
|
||||
their browser within seconds, with no rebuild. Add OIDC settings and it puts a "Sign in with *your
|
||||
provider*" page in front of everything, open only to the groups you allow. Recipe and settings:
|
||||
[deployment/cloud/](./deployment/cloud/README.md).
|
||||
|
|
@ -971,7 +910,7 @@ The three ways dx-maps ships (a drop-in into your own site, the container on its
|
|||
|
||||
### Cloud development environment
|
||||
|
||||
**Your laptop can follow what agents add to the pilot somewhere else.** By default `bun run dev` is fully local: no
|
||||
**Your laptop can follow what agents land somewhere else.** By default `bun run dev` is fully local: no
|
||||
settings, nothing fetched, and only your own edits hot-reload. To also see what agents push to a shared
|
||||
branch (an agent on a server, or the whole team), add two lines to `.env.local` (gitignored):
|
||||
|
||||
|
|
@ -997,7 +936,7 @@ doxa-maps/
|
|||
├── plugin-designs/ YOUR maps — one folder per plugin (the free zone)
|
||||
│ ├── doxa-maps-spa/ the Research Gallery (capability 5)
|
||||
│ ├── plugin-studio/ the Plugin Studio (capability 5)
|
||||
│ ├── my-upg-100-list/ UPG100 Posters (capability 4)
|
||||
│ ├── my-upg-100-list/ UPG-100 List (capability 4)
|
||||
│ ├── doxa-simple-map/ Prayer Progress and its presets (capability 2)
|
||||
│ ├── all-people-groups/ a single map (capability 1)
|
||||
│ └── template-bundle/ copy this to start a new plugin
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 236 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-10-02",
|
||||
"generated": "2026-09-27",
|
||||
"grain": "one row per PLACED people group; the four that fall outside every polygon are in the aggregate file's `unassigned`",
|
||||
"rows": 1009,
|
||||
"agrees_with": "gospel-by-province.json — per-province counts and the assigned total are reconciled at build time, and neither file is written if they differ",
|
||||
"approximate": "Every row is approximate. The coordinate marks a PEOPLE group, not a settlement; its position is approximate, and near a border it can fall on the wrong side. No surface may present a province as a fact about a group."
|
||||
"approximate": "Every row is approximate. The coordinate marks where a PEOPLE is found, not a settlement, and near a border it can fall on the wrong side. No surface may present a province as a fact about a group."
|
||||
},
|
||||
"columns": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"meta": {
|
||||
"title": "Gospel access by province — India and China",
|
||||
"why": "These two countries hold 47.9% of Doxa's roster (855 + 154 of 2,107). No other country is close; the third is Sudan at 105. A national fill cannot answer \"where would a team go\" for half the map.",
|
||||
"generated": "2026-10-02",
|
||||
"generated": "2026-09-27",
|
||||
"provinces_with_groups": 46,
|
||||
"provinces_available": 48,
|
||||
"groups_assigned": 1009,
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"near_border_note": "NONE unassigned now, and two rows say why. Jing and Pana fell in no province: measured 8.7 km outside Guangxi and 5.4 km outside Yunnan — across the border, not outside the file, which is what an earlier version of this note wrongly said (both provinces are in it). Sourcing China's complete 34-unit ADM1 moved neither distance: same source geometry, 22 more provinces, none of them theirs — so better data is not the answer and nobody need repeat it. A point within 15 km of exactly ONE province now joins it and carries near_border_km; two provinces within the tolerance leave it unassigned, because a hint between two answers is not one. A single coordinate for a whole people is not precise to 9 km, which is why this is a correction and not a guess.",
|
||||
"china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.",
|
||||
"ways_in": "Chosen FOR THE PROVINCE where the province can answer: the languages of groups standing in it that already have Scripture AND film, ranked by the people here who already hold them. The column says which basis was used. This replaces a country-level answer that produced a measured absurdity — Arunachal Pradesh, in the far north-east, was offered TELUGU, because the curated bridge table is keyed by country and a curated bridge outranks a computed one. A province with no tier-2 language of its own still falls back to the country answer, and ways_in_basis says so rather than hiding it. It is a NEIGHBOUR, not a recommendation: whether a group actually speaks it is a question for someone on the ground.",
|
||||
"approximate": "A province is assigned by point-in-polygon on the group's coordinate, and that coordinate marks a PEOPLE group, not a settlement; its position is approximate, and 108 groups across the roster even share a coordinate with another group. Near a border the point can fall on the wrong side. Every row is flagged approximate and the unassigned are counted, never dropped. No surface may present a province as a fact about a group."
|
||||
"approximate": "A province is assigned by point-in-polygon on the group's coordinate, and that coordinate marks where a PEOPLE is found, not a settlement — 108 groups across the roster even share a coordinate with another group. Near a border the point can fall on the wrong side. Every row is flagged approximate and the unassigned are counted, never dropped. No surface may present a province as a fact about a group."
|
||||
},
|
||||
"columns": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"meta": {
|
||||
"title": "Key Cities / Location Clusters",
|
||||
"grain": "one row per ~25km location cluster",
|
||||
"caveat": "A cluster is people-group PINS (each marks a people group, not a town), not a settlement. place_name comes from a cited gazetteer (Nominatim/OSM, ODbL); an unnamed row is honest, not missing.",
|
||||
"caveat": "A cluster is people-group CENTROIDS, not a settlement. place_name comes from a cited gazetteer (Nominatim/OSM, ODbL); an unnamed row is honest, not missing.",
|
||||
"internet_caveat": "Internet figures are NATIONAL. Per-city connectivity is not publicly available at this scale."
|
||||
},
|
||||
"columns": [
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"title": "Languages per City x Resources",
|
||||
"grain": "one row per location cluster x language spoken there",
|
||||
"why": "399 of 1,646 clusters are multi-language — a city-level bridge can exist that a country average hides.",
|
||||
"place_caveat": "A location is a cluster of people-group pins within ~25km, NOT a municipal boundary. An empty place_name is an unnamed cluster, never an invented one.",
|
||||
"place_caveat": "A location is a cluster of people-group centroids within ~25km, NOT a municipal boundary. An empty place_name is an unnamed cluster, never an invented one.",
|
||||
"resolution_caveat": "resolved:false means the language LABEL did not match an ISO 639-3 record here — its resource status is UNKNOWN, not absent.",
|
||||
"pools_caveat": "Resource status is from eBible + JESUS Film only. GRN and FCBH audio are key-blocked."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ Rudder (`plugin-designs/rudder-voice`) is voice-to-text that runs on your own co
|
|||
local engine (`backend/rudder-engine`, Go) that drives NVIDIA's NeMo-Speech.cpp speech server. The
|
||||
desktop app is a Tauri window around the built web tree, with the engine started as a **sidecar**.
|
||||
|
||||
This page is written for an agent following it cold. **Read "What is proven" first**: the Linux app is
|
||||
proven and in use; the macOS and Windows path has not been run yet, and the steps say so where that is
|
||||
the case.
|
||||
This page is written for an agent following it cold. **Read "What is proven" first**: most of the
|
||||
desktop path has never been run by anyone, and the steps say so where that is the case.
|
||||
|
||||
## What is proven, and what is not
|
||||
|
||||
|
|
@ -19,8 +18,8 @@ the case.
|
|||
| The engine **compiling** for macOS (arm64, x86_64) and Windows (x86_64) | Proven by cross-compiling. It has **never been run** on either. |
|
||||
| The engine accepting a WebSocket from the Tauri webview (`tauri://localhost`, `http://tauri.localhost`) | Proven against a running engine; another site's origin is refused. |
|
||||
| NeMo-Speech.cpp runtime for macOS / Windows | Published by NVIDIA (release v0.1.0: `macos-aarch64-cpu`, `macos-aarch64-metal`, `macos-x86_64-cpu`, `windows-x86_64-cpu` / `-cuda` / `-vulkan`). **Never run here.** |
|
||||
| `bun run desktop:build rudder-voice` on **Linux** | Proven: produced `Rudder_0.1.0_amd64.deb` (8.8 MB) holding the app and the `rudder-engine` sidecar side by side. The installed Linux app has since been launched and is in use: it is used to dictate the work on these maps. |
|
||||
| The shell starting and stopping the engine sidecar | Built (`deployment/src-tauri/src/main.rs`), compiled into that installer. The installed Linux app works (the row above); not yet run on macOS or Windows. |
|
||||
| `bun run desktop:build rudder-voice` on **Linux** | Proven: produced `Rudder_0.1.0_amd64.deb` (8.8 MB) holding the app and the `rudder-engine` sidecar side by side. The app has **not been launched** from it yet (no display on the build box). |
|
||||
| The shell starting and stopping the engine sidecar | Built (`deployment/src-tauri/src/main.rs`), compiled into that installer; not yet run. |
|
||||
| First-run model download | **Not built.** Run `setup-runtime.sh` into the app's data folder by hand (step 7). |
|
||||
|
||||
## 1. Prerequisites
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ Two things to know before you publish a link:
|
|||
|
||||
- **No zoom or centre parameter.** The frame is derived from the pins the link selects, so a link
|
||||
stays correct when the data moves under it.
|
||||
- **Placement is approximate.** A dot marks a *people group*, not a town or a boundary,
|
||||
- **Placement is approximate.** A dot marks where a *people* is found, not a town or a boundary,
|
||||
and 108 of the placed groups share a coordinate with another group. The sheet says so in the
|
||||
reader's language.
|
||||
- **An unknown parameter is ignored, never fatal.** `?country=ZZZ` renders the map, not an error —
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Architecture — UPG100 List poster generation
|
||||
# Architecture — UPG-100 poster generation
|
||||
|
||||
> The simple overview is in `library/scripts/posters/README.md`. This is the full reference for the
|
||||
> generator: files, environment, pipeline, and the gotchas that have bitten before.
|
||||
|
|
@ -53,7 +53,7 @@ the app is rebuilt.**
|
|||
| `VITE_API_BASE_URL` | — | Satisfies the fail-closed config gate. No request is made under `OFFLINE=1`. |
|
||||
| `ONLY` | — | Build a single zone by `zoneKey`. |
|
||||
| `UPG100_DATA_PATH` | the profile in this repo | Only override to test against other data. |
|
||||
| `SERIES_INDEX` / `SERIES_NAME` | derived / `UPG100 List` | Series line on the sheet. |
|
||||
| `SERIES_INDEX` / `SERIES_NAME` | derived / `List of 100 UPG's` | Series line on the sheet. |
|
||||
|
||||
## Two things that will bite you
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Deep Research is the plugin that is already a whole app. It is the app shell in
|
|||
- Simple Research;
|
||||
- Regions;
|
||||
- Prayer Progress;
|
||||
- UPG100 Posters;
|
||||
- UPG-100 List;
|
||||
- Cities with Internet;
|
||||
- Gospel Resources.
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ It is the same command with another start page. Name the page's folder inside `p
|
|||
|
||||
```bash
|
||||
APP_START=doxa-simple-map/doxa-simple-map bun run phone:from-tree # Prayer Progress
|
||||
bun run phone:from-tree # unset: UPG100 Posters
|
||||
bun run phone:from-tree # unset: the UPG-100 list
|
||||
```
|
||||
|
||||
Pick a page that works on a phone; Big Picture, for one, is desktop only. The start-page step
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ snippets (leave it empty for relative URLs that work wherever the folder is drop
|
|||
## Hosting the dx-maps folder: caching, compression, CORS
|
||||
|
||||
**Why it matters.** On a cold load a map's layers draw about 10–17 s after the page opens (measured
|
||||
on the UPG100 Posters page). The bundle and the map engine take under 2 s of that; the rest is basemap
|
||||
on the UPG-100 list page). The bundle and the map engine take under 2 s of that; the rest is basemap
|
||||
tiles and data files such as the country-boundary file (1,866 KB raw, 597 KB gzipped). Compression
|
||||
cuts every visit; caching means a returning reader doesn't download the same files again.
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 931 KiB After Width: | Height: | Size: 767 KiB |
BIN
documentation/readme-images/deploy5-desktop.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="2256" viewBox="0 0 1500 2256" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="2086" viewBox="0 0 1500 2086" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
|
||||
<defs>
|
||||
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2563eb"/></marker>
|
||||
<marker id="aS" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#7c3aed"/></marker>
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
<path id="agL" d="M 194,850 A 556,556 0 0 1 1306,850"/>
|
||||
<path id="mqL" d="M 679,884 A 71,71 0 0 0 821,884"/>
|
||||
</defs>
|
||||
<rect width="1500" height="2256" fill="#ffffff"/>
|
||||
<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 merge at a time</text>
|
||||
<circle cx="750" cy="850" r="540" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-dasharray="3 7" opacity=".75"/>
|
||||
<circle cx="750" cy="850" r="355" fill="none" stroke="#15803d" stroke-width="2.2" opacity=".85"/>
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
<text x="750" y="842" text-anchor="middle" font-size="24" font-weight="800" fill="#fff">pilot</text>
|
||||
<text x="750" y="862" text-anchor="middle" font-size="13" font-weight="700" fill="#dcfce7">development</text>
|
||||
<text x="750" y="882" text-anchor="middle" font-size="12.5" fill="#dcfce7">every change</text>
|
||||
<text x="750" y="897" text-anchor="middle" font-size="12.5" fill="#dcfce7">joins here</text>
|
||||
<text x="750" y="897" text-anchor="middle" font-size="12.5" fill="#dcfce7">lands here</text>
|
||||
<text font-size="16" font-weight="800" fill="#15803d" letter-spacing="3"><textPath href="#plL" startOffset="50%" text-anchor="middle">PLUGINS</textPath></text>
|
||||
<text font-size="16" font-weight="800" fill="#2563eb" letter-spacing="3"><textPath href="#agL" startOffset="18%" text-anchor="middle">AGENTS</textPath></text>
|
||||
<line x1="862.1" y1="579.3" x2="822.7" y2="674.5" stroke="#15803d" stroke-width="1.4" opacity=".45"/>
|
||||
|
|
@ -72,8 +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">UPG100</text>
|
||||
<text x="885.9" y="1185.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Posters</text>
|
||||
<text x="885.9" y="1168.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">UPG-100</text>
|
||||
<text x="885.9" y="1185.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">List</text>
|
||||
<rect x="859.9" y="1193.0" width="52" height="20" rx="10" fill="#15803d"/>
|
||||
<text x="885.9" y="1208.0" text-anchor="middle" font-size="11.5" font-weight="800" fill="#fff">cap 4</text>
|
||||
<circle cx="956.6" cy="1348.9" r="40" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
|
|
@ -116,15 +116,15 @@
|
|||
<text x="614.1" y="556.0" text-anchor="middle" font-size="12" font-weight="800" fill="#155e75">Experimental</text>
|
||||
<text x="614.1" y="570.0" text-anchor="middle" font-size="10.5" fill="#155e75">shelf · 5 plugins</text>
|
||||
<circle cx="543.4" cy="351.1" r="40" fill="#ecfeff" stroke="#0891b2" stroke-width="2.5"/>
|
||||
<text x="543.4" y="349.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">an agent</text>
|
||||
<text x="543.4" y="364.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">per plugin</text>
|
||||
<text x="543.4" y="349.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">roaming</text>
|
||||
<text x="543.4" y="364.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">agent</text>
|
||||
<circle cx="690" cy="248" r="56" fill="#f5f3ff" stroke="#7c3aed" stroke-width="3.5"/>
|
||||
<text x="690" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#4c1d95">shepherd</text>
|
||||
<text x="690" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#4c1d95">coordinator</text>
|
||||
<circle cx="812" cy="248" r="56" fill="#f1f5f9" stroke="#334155" stroke-width="3.5"/>
|
||||
<text x="812" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#1e293b">repo agent</text>
|
||||
<text x="884" y="192" font-size="17" fill="#4c1d95">one shepherd: every agent talks to it; it orders the merges;</text>
|
||||
<text x="884" y="216" font-size="17" fill="#1e293b">a repo agent per repo runs its branches and watches the work</text>
|
||||
<text x="750" y="1502" text-anchor="middle" font-size="20" font-weight="800" fill="#1e3a8a">one agent per plugin, in its own branch · the repo agent reads every change</text>
|
||||
<text x="812" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#1e293b">gate-keeper</text>
|
||||
<text x="884" y="192" font-size="17" fill="#4c1d95">a coordinator puts the 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">pilot branch (development)</text>
|
||||
|
|
@ -139,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">shepherd — orders merges</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">repo agent — one per repo</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 joins the pilot — one at a time, so many plugins can be built at once</text>
|
||||
<text x="750" y="1578" text-anchor="middle" font-size="22" font-weight="800" fill="#7f1d1d">How a change lands — one at a time, so many plugins can be built at once</text>
|
||||
<rect x="80.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
|
||||
<circle cx="114.0" cy="1634" r="18" fill="#dc2626"/>
|
||||
<text x="114.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">1</text>
|
||||
|
|
@ -155,10 +155,10 @@
|
|||
<rect x="425.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
|
||||
<circle cx="459.0" cy="1634" r="18" fill="#dc2626"/>
|
||||
<text x="459.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">2</text>
|
||||
<text x="487.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Repo agent reads</text>
|
||||
<text x="445.0" y="1680" font-size="15" fill="#475569">the repo agent reads every</text>
|
||||
<text x="487.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Second reader</text>
|
||||
<text x="445.0" y="1680" font-size="15" fill="#475569">another agent reads every</text>
|
||||
<text x="445.0" y="1702" font-size="15" fill="#475569">change by its content</text>
|
||||
<text x="445.0" y="1724" font-size="15" fill="#475569">before it may join</text>
|
||||
<text x="445.0" y="1724" font-size="15" fill="#475569">before it may land</text>
|
||||
<text x="750.0" y="1682" text-anchor="middle" font-size="26" font-weight="800" fill="#dc2626">→</text>
|
||||
<rect x="770.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
|
||||
<circle cx="804.0" cy="1634" r="18" fill="#dc2626"/>
|
||||
|
|
@ -173,12 +173,12 @@
|
|||
<text x="1149.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">4</text>
|
||||
<text x="1177.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Publish to landing</text>
|
||||
<text x="1135.0" y="1680" font-size="15" fill="#475569">landing (main), production:</text>
|
||||
<text x="1135.0" y="1702" font-size="15" fill="#475569">moves only when a person approves,</text>
|
||||
<text x="1135.0" y="1702" font-size="15" fill="#475569">moves only when the owner approves,</text>
|
||||
<text x="1135.0" y="1724" font-size="15" fill="#475569">after full checks on that version</text>
|
||||
<line x1="750" y1="956" x2="750" y2="1786" stroke="#334155" stroke-width="2.5" stroke-dasharray="2 5" opacity=".0"/>
|
||||
<rect x="60" y="1812" width="1380" height="254" rx="20" fill="#f8fafc" stroke="#cbd5e1" stroke-width="2"/>
|
||||
<text x="750" y="1856" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">landing (main) → ONE build → one web folder → three outputs</text>
|
||||
<text x="750" y="1882" text-anchor="middle" font-size="16" fill="#475569">the pilot server follows development · the landing server rebuilds from main</text>
|
||||
<text x="750" y="1882" text-anchor="middle" font-size="16" fill="#475569">the pilot server follows development · the production server rebuilds from main</text>
|
||||
<rect x="80.0" y="1908" width="420" height="130" rx="14" fill="#fff" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="290.0" y="1930" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">OUTPUT 1</text>
|
||||
<text x="290.0" y="1962" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">The web folder</text>
|
||||
|
|
@ -197,9 +197,4 @@
|
|||
<text x="1210.0" y="1988" text-anchor="middle" font-size="15" fill="#475569">the same folder, wrapped by Tauri</text>
|
||||
<rect x="1110.0" y="2002" width="200" height="26" rx="13" fill="#15803d" opacity=".13"/>
|
||||
<text x="1210.0" y="2020" text-anchor="middle" font-size="12" font-weight="800" fill="#15803d">WORKING</text>
|
||||
<rect x="60" y="2090" width="1380" height="146" rx="20" fill="#eff6ff" stroke="#93c5fd" stroke-width="2"/>
|
||||
<text x="750" y="2132" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">At scale: many people × many agents × many plugins</text>
|
||||
<text x="750" y="2164" text-anchor="middle" font-size="16" fill="#475569">one PR branch per plugin or feature → joins the pilot through the lock → previewed on the pilot server</text>
|
||||
<text x="750" y="2192" text-anchor="middle" font-size="16" fill="#475569">in the cloud: a self-hosted git service, because an agent can publish by accident</text>
|
||||
<text x="750" y="2218" text-anchor="middle" font-size="16" fill="#475569">publish to a public host only from landing (main), when it is ready</text>
|
||||
</svg>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
12
documentation/readme-images/src/deploy5-desktop.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
|
||||
<rect width="1200" height="630" rx="28" fill="#f8fafc"/>
|
||||
<rect x="1.5" y="1.5" width="1197" height="627" rx="27" fill="none" stroke="#e2e8f0" stroke-width="3"/>
|
||||
<g transform="translate(110,175) scale(11.5)"><path d="M13.912 0a8.72 8.72 0 0 0-8.308 6.139c1.05-.515 2.18-.845 3.342-.976 2.415-3.363 7.4-3.412 9.88-.097 2.48 3.315 1.025 8.084-2.883 9.45a6.131 6.131 0 0 1-.3 2.762 8.72 8.72 0 0 0 3.01-1.225A8.72 8.72 0 0 0 13.913 0zm.082 6.451a2.284 2.284 0 1 0-.15 4.566 2.284 2.284 0 0 0 .15-4.566zm-5.629.27a8.72 8.72 0 0 0-3.031 1.235 8.72 8.72 0 1 0 13.06 9.9131 10.173 10.174 0 0 1-3.343.965 6.125 6.125 0 1 1-7.028-9.343 6.114 6.115 0 0 1 .342-2.772zm1.713 6.27a2.284 2.284 0 0 0-2.284 2.283 2.284 2.284 0 0 0 2.284 2.284 2.284 2.284 0 0 0 2.284-2.284 2.284 2.284 0 0 0-2.284-2.284z" fill="#24C8DB"/></g>
|
||||
<text x="480" y="250" font-size="54" font-weight="800" fill="#0f172a">Desktop app (Tauri)</text>
|
||||
<text x="480" y="315" font-size="27" fill="#475569">We got the JAMstack map app running</text>
|
||||
<text x="480" y="355" font-size="27" fill="#475569">as a desktop app.</text>
|
||||
<rect x="480" y="400" width="420" height="62" rx="31" fill="#fef3c7" stroke="#d97706" stroke-width="2.5"/>
|
||||
<text x="690" y="441" text-anchor="middle" font-size="26" font-weight="800" fill="#92400e">PROVEN · NOT YET LIVE</text>
|
||||
<text x="480" y="508" font-size="22" fill="#64748b">Proven, not yet in production. The Research</text>
|
||||
<text x="480" y="538" font-size="22" fill="#64748b">Gallery could become a strong desktop app.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -13,7 +13,7 @@
|
|||
> bundle `parameterized` (cap-2) no matter how light it looks, and a `meta.json` `type`
|
||||
> declares tiers 3/4/5.
|
||||
|
||||
Generated from 30 bundle(s).
|
||||
Generated from 29 bundle(s).
|
||||
|
||||
### One canonical exemplar per tier (generated — quote these)
|
||||
|
||||
|
|
@ -38,7 +38,6 @@ Generated from 30 bundle(s).
|
|||
|---|---|---|
|
||||
| `all-people-groups` | `all-people-groups` | `single` |
|
||||
| `bible-posters` | `bible-posters` | `single` |
|
||||
| `bless-india-posters` | `bless-india-posters` | `single` |
|
||||
| `cities-internet` | `connectivity-list` | `single` |
|
||||
| `cities-internet` | `connectivity-map` | `single` |
|
||||
| `cities-internet` | `data-tables` | `single` |
|
||||
|
|
@ -57,6 +56,7 @@ Generated from 30 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` |
|
||||
|
||||
|
|
|
|||
|
|
@ -237,8 +237,6 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/useSharedMapToken.js'`
|
||||
- **useTextHighlight** — highlightRanges(value, indices) — wrap each matched index range in
|
||||
`import … from '@map/composables/useTextHighlight.js'`
|
||||
- **useViewAddress** — useViewAddress.js — the page's ADDRESS for the view a reader is on: which app tab, which view inside it, which
|
||||
`import … from '@map/composables/useViewAddress.js'`
|
||||
|
||||
### Utilities (pure helper functions)
|
||||
|
||||
|
|
@ -280,8 +278,6 @@ and a change here can break other maps.
|
|||
`import … from '@map/utils/peopleGroupName.js'`
|
||||
- **peopleGroupPoints** — peopleGroupPoints.js — SHARED (@map/utils/peopleGroupPoints.js).
|
||||
`import … from '@map/utils/peopleGroupPoints.js'`
|
||||
- **publicEmbed** — publicEmbed.js: the Share menu's Embed codes for THIS plugin, from the tree's PUBLIC address.
|
||||
`import … from '@map/utils/publicEmbed.js'`
|
||||
- **scrollport** — findScrollportAncestor — the nearest ancestor that actually scrolls.
|
||||
`import … from '@map/utils/scrollport.js'`
|
||||
- **viewportScrim** — viewportScrim.js — make a position:fixed full-screen SCRIM actually cover the VIEWPORT,
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ The measurements behind the rules above, kept for whoever needs the reasoning.
|
|||
one. `verify:map-paint` passes under both tokens (it checks GL + attribution, not tiles), so judge tile
|
||||
loading by the tile response count, not a screenshot — the basemap is Mapbox Light, which looks bare.
|
||||
- **Share round-trip (2026-09-22).** 8/8 passed with six distinct reopened tags, each matching its plugin
|
||||
(the two UPG100 Posters profiles share one tag). A pass count alone cannot catch an SPA fallback that renders
|
||||
(the two UPG-100 profiles share one tag). A pass count alone cannot catch an SPA fallback that renders
|
||||
*a* map for every URL.
|
||||
- **Why SRI identity, not HTTP status.** A platform's app URL can return 200 with a sign-in page; comparing
|
||||
each live bundle's bytes against the manifest's `integrity` hash is a cryptographic identity proof that
|
||||
|
|
|
|||
|
|
@ -50,19 +50,6 @@ function readGated() {
|
|||
}
|
||||
export const GATED_PROFILES = readGated()
|
||||
|
||||
// The PUBLIC address of the tree for the Share menu's Embed tab (the config's shareBaseUrl): an https URL with no
|
||||
// trailing slash, or '' (unset). vite.config.js bakes it as __DOXA_SHARE_BASE__; library/utils/embedBase.js reads it.
|
||||
function readShareBase() {
|
||||
let v = ''
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(BUILD_CONFIG_FILE, 'utf8'))
|
||||
v = typeof cfg.shareBaseUrl === 'string' ? cfg.shareBaseUrl.trim().replace(/\/+$/, '') : ''
|
||||
} catch { return '' }
|
||||
if (v && !/^https:\/\/[^\s"'<>]+$/i.test(v)) throw new Error(`[_tree-name] build config "shareBaseUrl" must be an https URL, got ${JSON.stringify(v)}`)
|
||||
return v
|
||||
}
|
||||
export const SHARE_BASE = readShareBase()
|
||||
|
||||
// The name escaped for use inside a RegExp source (a '.' in a future name must not match any char).
|
||||
export const TREE_NAME_RE = TREE_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
|
|
|
|||
|
|
@ -74,4 +74,4 @@ try {
|
|||
check('Arabic joins the names with و, never an English comma inside Arabic prose', /Koraga وMaila/.test(ar2) && !/Koraga, Maila/.test(ar2), ar2)
|
||||
} finally { await server.close() }
|
||||
if (bad) { console.log(`APPROX-PIN-NOTE: FAIL — ${bad} case(s).`); process.exit(1) }
|
||||
console.log('APPROX-PIN-NOTE: PASS — pins say what they are, and name the groups that share a point.')
|
||||
console.log('APPROX-PIN-NOTE: PASS — pins say they are localities, and name the groups that share a point.')
|
||||
|
|
|
|||
|
|
@ -1,136 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-dev-leaf-escape.mjs — a dev leaf URL cannot write markup into the page it opens.
|
||||
*
|
||||
* THE DEFECT. The dev server's leaf route, /__leaf/<bundle>/<profile>[/<instance>] (and a production embed URL,
|
||||
* <bundle>/<profile>[/<instance>]/index.html, rewritten onto it), writes the URL's parts into the page it serves: the
|
||||
* <title> and the element's profile-config='…' attribute. It refused only `..` and `/`, so a quote or a tag in the path
|
||||
* was written raw, and a crafted link broke out of the attribute on a publicly reachable dev server.
|
||||
* internal/plugin-gallery/dev-plugin.mjs now refuses any part outside LEAF_PART (the shape every bundle folder, profile
|
||||
* id and instance id has) before either sink, and escapes the <title> as well.
|
||||
*
|
||||
* The plugin's own middleware answers each request (no server, no browser):
|
||||
* A. every leaf the dev manifest advertises (galleryModel), as /__leaf/… AND as its production embed URL → 200 with
|
||||
* its element: the rule refuses no real page;
|
||||
* B. a hostile part (a quote, a double quote, a tag, an ampersand, a space, a backtick, an encoded slash or
|
||||
* backslash, `..`, `.`, an attribute break-out) in each position, both URL forms → refused here (400) or left to
|
||||
* Vite, never a page;
|
||||
* C. escHtml leaves none of & < > " ' raw;
|
||||
* D. with the part rule set back to the old one (`..` and `/` only), a tag in the profile still reaches the <title>
|
||||
* escaped: the title has its own escaping, not only the rule in front of it.
|
||||
* Response bodies are never printed: a leaf page carries the build config's token.
|
||||
* `--self-test`: the old guard, a rule too strict for a real profile, an unescaped <title> and an escHtml that misses
|
||||
* the quote must each FAIL.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { discoverSpaRegistry } from './plugin-gallery/discover.mjs'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const PLUGIN = join(HERE, 'plugin-gallery/dev-plugin.mjs')
|
||||
const SRC = readFileSync(PLUGIN, 'utf8')
|
||||
const GUARD = 'parts.some(s => !LEAF_PART.test(s))'
|
||||
const OLD_GUARD = "parts.some(s => !s || s.includes('..') || s.includes('/'))"
|
||||
const RULE = 'export const LEAF_PART = /^[A-Za-z0-9][\\w-]{0,63}$/'
|
||||
const TITLE = '${escHtml(bundle)} · ${escHtml(profile)}'
|
||||
const ESC_CLASS = `/[&<>"']/g`
|
||||
|
||||
// A copy of the plugin, altered or not, imported from beside the original so its relative imports resolve; the
|
||||
// file is removed as soon as it is loaded.
|
||||
let seq = 0
|
||||
async function load(src) {
|
||||
if (src === SRC) return import(pathToFileURL(PLUGIN).href)
|
||||
const tmp = join(HERE, 'plugin-gallery', `.tmp-check-dev-leaf-escape-${process.pid}-${++seq}.mjs`)
|
||||
writeFileSync(tmp, src)
|
||||
try { return await import(pathToFileURL(tmp).href) } finally { rmSync(tmp, { force: true }) }
|
||||
}
|
||||
|
||||
// The plugin's middleware, answering one GET: { status, body } ('next' = not answered here, Vite would).
|
||||
function client(mod) {
|
||||
let mw = null
|
||||
mod.autoSpaDevPlugin('').configureServer({ middlewares: { use: (f) => { if (!mw) mw = f } } })
|
||||
return (url) => new Promise((done) => {
|
||||
const res = { statusCode: 200, setHeader() {}, end: (b) => done({ status: res.statusCode, body: String(b ?? '') }) }
|
||||
try { mw({ url, method: 'GET', headers: {} }, res, () => done({ status: 'next', body: '' })) }
|
||||
catch (e) { done({ status: 'threw', body: String(e && e.message) }) }
|
||||
})
|
||||
}
|
||||
|
||||
// Every /__leaf/ URL the dev manifest advertises, with the tag its page must mount.
|
||||
function advertised(mod) {
|
||||
const out = []
|
||||
const walk = (o, tag) => {
|
||||
if (!o || typeof o !== 'object') return
|
||||
for (const v of Object.values(o)) {
|
||||
if (typeof v === 'string' && v.startsWith('/__leaf/')) out.push({ url: v.split(/[?#]/)[0], tag })
|
||||
else walk(v, tag)
|
||||
}
|
||||
}
|
||||
for (const b of mod.galleryModel(discoverSpaRegistry()).bundles) walk(b, (b.elements || [])[0])
|
||||
return [...new Map(out.map((x) => [x.url, x])).values()]
|
||||
}
|
||||
|
||||
const HOSTILE = ["a'b", 'a"b', 'a<b', 'a>b', 'a&b', 'a b', 'a`b', 'a/b', 'a\\b', '..', '.', "x'><img src=x onerror=alert(1)>"]
|
||||
|
||||
async function audit(src) {
|
||||
const f = []
|
||||
let mod
|
||||
try { mod = await load(src) } catch (e) { return [`load: the plugin does not import (${e.message})`] }
|
||||
const get = client(mod)
|
||||
const leaves = advertised(mod)
|
||||
if (leaves.length < 20) f.push(`A: only ${leaves.length} advertised leaves found (the manifest walk is broken)`)
|
||||
for (const { url, tag } of leaves) {
|
||||
for (const u of [url, url.replace('/__leaf/', '/') + '/index.html']) {
|
||||
const r = await get(u)
|
||||
if (r.status !== 200 || !r.body.includes(`<${tag} `)) f.push(`A: ${u} → ${r.status}${r.status === 200 ? `, no <${tag}>` : ''} (a real page refused)`)
|
||||
}
|
||||
}
|
||||
const [b0, p0] = (leaves[0] ? leaves[0].url : '/__leaf/x/x').slice('/__leaf/'.length).split('/')
|
||||
for (const h of HOSTILE) {
|
||||
const e = encodeURIComponent(h)
|
||||
for (const parts of [[e, p0], [b0, e], [b0, p0, e]]) {
|
||||
const leaf = '/__leaf/' + parts.join('/')
|
||||
const r = await get(leaf)
|
||||
if (r.status !== 400) f.push(`B: ${leaf} → ${r.status} (want 400)`)
|
||||
const embed = '/' + parts.join('/') + '/index.html'
|
||||
const r2 = await get(embed)
|
||||
if (r2.status !== 400 && r2.status !== 'next') f.push(`B: ${embed} → ${r2.status} (want 400, or left to Vite)`)
|
||||
}
|
||||
}
|
||||
const esc = mod.escHtml ? mod.escHtml(`&<>"'`) : null
|
||||
if (esc !== '&<>"'') f.push(`C: escHtml(& < > " ') = ${JSON.stringify(esc)}`)
|
||||
if (!src.includes(GUARD)) f.push('D: the leaf guard is not the LEAF_PART rule, so the title cannot be tested behind the old one')
|
||||
else {
|
||||
let loose
|
||||
try { loose = await load(src.replace(GUARD, OLD_GUARD)) } catch (e) { f.push(`D: ${e.message}`) }
|
||||
if (loose) {
|
||||
const r = await client(loose)(`/__leaf/${b0}/${encodeURIComponent('<b>x')}`)
|
||||
const title = (/<title>([^]*?)<\/title>/.exec(r.body) || [])[1]
|
||||
if (r.status !== 200 || title === undefined) f.push(`D: behind the old rule the page did not render (${r.status})`)
|
||||
else if (title.includes('<b>') || !title.includes('<b>')) f.push('D: behind the old rule a tag in the profile reached the <title> raw')
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
if (process.argv.includes('--self-test')) {
|
||||
const poisons = [
|
||||
['the old guard (`..` and `/` only)', SRC.replace(GUARD, OLD_GUARD), /^B: /],
|
||||
['a rule too strict for a real profile', SRC.replace(RULE, 'export const LEAF_PART = /^[a-z]{1,63}$/'), /^A: /],
|
||||
['an unescaped <title>', SRC.replace(TITLE, '${bundle} · ${profile}'), /^D: /],
|
||||
['an escHtml that misses the quote', SRC.replace(ESC_CLASS, `/[&<>"]/g`), /^C: /],
|
||||
]
|
||||
let missed = 0
|
||||
for (const [name, src, rx] of poisons) {
|
||||
if (src === SRC) { console.log(`✗ MISSED: ${name} (not planted: its source line was not found)`); missed++; continue }
|
||||
const f = await audit(src); const hit = f.find((x) => rx.test(x))
|
||||
console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`)
|
||||
if (!hit) missed++
|
||||
}
|
||||
if (missed) process.exitCode = 1
|
||||
} else {
|
||||
const f = await audit(SRC)
|
||||
if (f.length) { for (const x of f.slice(0, 40)) console.log('✗ ' + x); console.log(`FAIL check:dev-leaf-escape (${f.length})`); process.exitCode = 1 }
|
||||
else console.log(`PASS check:dev-leaf-escape — every advertised dev leaf is served in both URL forms; ${HOSTILE.length} hostile parts are refused in every position of both forms; the <title> escapes on its own`)
|
||||
}
|
||||
|
|
@ -90,28 +90,15 @@ const notes = []
|
|||
if (!partial.includes(`5 ${ppl}`) || !partial.includes(`995 ${ppl}`)) {
|
||||
fails.push(`a 0.5% list of 1,000 people does not state both totals ("5 ${ppl}" who follow Jesus, "995 ${ppl}" who do not yet)`)
|
||||
} else notes.push(' ok both totals are stated in people: 5 who follow Jesus, 995 who do not yet')
|
||||
// The grid is one SVG now (lifeGrid): its week numbers are <text class="lc-t lc-tw">, its year numbers
|
||||
// "lc-t lc-ty", and its markup names `data-weeks` as geometry, so the WORDS a reader sees are read with the
|
||||
// tags stripped.
|
||||
const axis = /class="lc-t lc-t[wy]\b/
|
||||
if (/week/i.test(partial.replace(/<[^>]+>/g, ' ')) || axis.test(partial)) fails.push('the page still counts in weeks or draws a week/year axis')
|
||||
if (/week/i.test(partial) || /class="lc-(wk|y)"/.test(partial)) fails.push('the page still counts in weeks or draws a week/year axis')
|
||||
else notes.push(' ok no weeks and no week/year axis on the page')
|
||||
// ★ ITS SIDE LABELS ARE SHARES (2026-10-02): each 10-row block is 520 boxes, a tenth of the list's people,
|
||||
// labelled 10% ... 100% in the page's own numerals; exactly those ten, in order.
|
||||
const shares = [...partial.matchAll(/class="lc-t lc-tp[^"]*"[^>]*>([^<]*)</g)].map((m) => m[1])
|
||||
const want = Array.from({ length: 10 }, (_, i) => `${((i + 1) * 10).toLocaleString('en')}%`)
|
||||
if (shares.join('|') !== want.join('|')) fails.push(`the people grid's side labels should read ${want.join(' ')}, got ${shares.join(' ') || 'none'}`)
|
||||
else notes.push(' ok the people grid labels each 10-row block with its share: 10% ... 100%')
|
||||
// A TINY SHARE: the totals come from the unrounded figure. 0.004% of 1,000,000,000 is 40,000 people,
|
||||
// which must not print as 0 because the percentage rounds to 0.00.
|
||||
const tiny = page({ groupsInList: 5, groupsMatched: 5, population: 1e9, evangelicalPopulation: 40000, pctEvangelical: 0.004 })
|
||||
if (!tiny.includes(`40,000 ${ppl}`) || !/ev-pct"><0\.01%</.test(tiny)) fails.push('a 0.004% share of a billion does not print 40,000 people and "<0.01%"')
|
||||
else notes.push(' ok a share below the printed digits keeps its real total (40,000 people) and reads "<0.01%"')
|
||||
// NEG CONTROL: the life page itself keeps both axes, so the axis test above can see one when it is there,
|
||||
// and it carries no share labels, so the share test above is not satisfied by any grid.
|
||||
const life = R.lifePage(zone)
|
||||
if (!/class="lc-t lc-tw"/.test(life) || !/class="lc-t lc-ty/.test(life)) fails.push('the life page lost its week or year axis (the axis check above cannot fail)')
|
||||
if (/class="lc-t lc-tp/.test(life)) fails.push('the life page carries share labels: a week of a life is not a share of people')
|
||||
// NEG CONTROL: the life page itself keeps its axes, so the axis test above can see one when it is there.
|
||||
if (!/class="lc-wk"/.test(R.lifePage(zone))) fails.push('the life page lost its week axis (the axis check above cannot fail)')
|
||||
}
|
||||
|
||||
for (const n of notes) console.log(n)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ const STATIC = [
|
|||
['check:click-sheet', 'the country sheet resolves, links are absolute'],
|
||||
['check:snapshot-guard', 'no fetcher writes around the guard'],
|
||||
['check:gospel-tables-staged', 'every asset the map fetches is in the shipped tree'],
|
||||
['check:gospel-clear-filter-url', 'Show all drops only the filter keys, not a partner\'s'],
|
||||
]
|
||||
const BROWSER = [
|
||||
['check:leaves-mount', 'all five leaves actually mount'],
|
||||
|
|
|
|||
|
|
@ -68,11 +68,6 @@ const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json'))
|
|||
// translations, add the locale here with its count and lower it as they arrive.
|
||||
// Never raise a number; never add a locale that is already complete.
|
||||
const DEBT = {
|
||||
// 2026-10-03, the Share menu's 10 new share.json keys (shareable pages P3): localization is English-only for now.
|
||||
// tStatic falls back to the English text, so these locales show English until the translations arrive;
|
||||
// lower each to 0 then, and empty the map again.
|
||||
// + the Embed tab's 9 keys (URL / Iframe / JavaScript, P3b), the same English-only rule.
|
||||
ar: 19, de: 19, es: 19, fr: 19, hi: 19, it: 19, pt: 19, ro: 19, ru: 19, zh: 19,
|
||||
}
|
||||
|
||||
const fails = []
|
||||
|
|
|
|||
|
|
@ -39,19 +39,7 @@ const m = code.match(/host\.value\s*=\s*([^\n]+)/)
|
|||
if (!m) {
|
||||
fails.push('the area resolution moved — this gate cannot find `host.value = …` and would pass blind.')
|
||||
} else {
|
||||
let expr = m[1]
|
||||
// The resolution may live in a named function (resolveHost(), shareable pages P3: a standalone Share button
|
||||
// re-resolves at open time). Then judge THAT function's body, and fail if it cannot be found.
|
||||
const fn = expr.match(/^\s*(\w+)\(\)/)
|
||||
if (fn) {
|
||||
const body = code.match(new RegExp('function\\s+' + fn[1] + '\\s*\\(\\)\\s*\\{([\\s\\S]*?)\\n\\}'))
|
||||
// every return that resolves a named area must carry its structural fallback on the SAME expression
|
||||
// a same-line comment is not a fallback: strip `//…` before testing each return
|
||||
const rets = body ? [...body[1].matchAll(/return\s+([^\n]*closest\([^\n]*)/g)].map((r) => r[1].replace(/\s*\/\/.*$/, '')) : []
|
||||
expr = rets.length && rets.every((r) => /offsetParent|parentElement|document\.body/.test(r)) ? rets.join(' ;; ') : (rets.find((r) => !/offsetParent|parentElement|document\.body/.test(r)) || '')
|
||||
if (!body) fails.push(`the area resolution calls ${fn[1]}(), and this gate cannot find that function to read it.`)
|
||||
else if (!rets.length) fails.push(`${fn[1]}() returns no named-area resolution, so this gate would pass blind.`)
|
||||
}
|
||||
const expr = m[1]
|
||||
if (!/offsetParent|parentElement|document\.body/.test(expr)) {
|
||||
fails.push('MapControlMenu resolves its anchor from a class allowlist with no structural fallback:\n'
|
||||
+ ` ${expr.trim()}\n`
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ function sequence(html) {
|
|||
: /word-title/.test(seg) ? 'word' // point 4's passage in full + the Father's-heart list (2026-09-30)
|
||||
: /points-title|points_title|Prayer Points/.test(seg) ? 'points'
|
||||
: `?${cls.trim()}`
|
||||
if ((kind === 'profiles' || kind === 'worksheet' || kind === 'points') && out[out.length - 1] === kind) return // one entry per run
|
||||
if ((kind === 'profiles' || kind === 'worksheet') && out[out.length - 1] === kind) return // one entry per run
|
||||
out.push(kind)
|
||||
})
|
||||
return out.join(' · ')
|
||||
|
|
@ -119,9 +119,9 @@ const box = (html) => {
|
|||
ok(p1.indexOf('class="map-sheet"') >= 0 && p1.indexOf('class="map-sheet"') < p1.indexOf('map-series'),
|
||||
'page 1 draws its title BEFORE the map, so the map-only sheet\'s background paints over it', 'on a map-only sheet the map is drawn first, the title over it')
|
||||
// two-sided: the series line WITH a packet count, and NOT without one
|
||||
ok(/class="map-series"[\s\S]{0,200}One of 28 UPG100 prayer lists/.test(p1), 'page 1 (map-only) does not say it is one of the 28 UPG100 prayer lists')
|
||||
ok(/class="map-series"[\s\S]{0,200}One of 28 UPG 100 prayer lists/.test(p1), 'page 1 (map-only) does not say it is one of the 28 UPG 100 prayer lists')
|
||||
const noKp1 = R.mapPage(zone, groups, liftedMap, noK)
|
||||
ok(!/map-series/.test(noKp1), 'page 1 prints a series line with NO packet count to state', 'a map-only page 1 says "One of 28 UPG100 prayer lists" — and stays silent without a count')
|
||||
ok(!/map-series/.test(noKp1), 'page 1 prints a series line with NO packet count to state', 'a map-only page 1 says "One of 28 UPG 100 prayer lists" — and stays silent without a count')
|
||||
// ★ A POSTER PAGE 1 HAS ONE HEADER, THE POSTER'S — two-sided against the map-only page above, which has the packet's.
|
||||
const posterP1 = R.mapPage(zone, groups, R.liftMap(POSTER_SHEET), withK)
|
||||
ok(/sheet-map-poster/.test(posterP1) && !/map-series/.test(posterP1) && !posterP1.includes(strings.en.map_page_sub.split('{count}')[0]),
|
||||
|
|
@ -143,10 +143,10 @@ const box = (html) => {
|
|||
ok(tiers === funnelRaw.steps.length + 2 && nums === tiers,
|
||||
`with a packet count the triangle should have ${funnelRaw.steps.length + 2} tiers each with its number; got ${tiers} tiers, ${nums} numbers`,
|
||||
`${tiers} tiers, every one with its number: ${funnelRaw.steps.length} global steps, the packets, this packet`)
|
||||
ok(/UPG100 prayer packets divide/.test(page), 'the packets tier is missing although a packet count was supplied')
|
||||
ok(/UPG 100 prayer packets divide/.test(page), 'the packets tier is missing although a packet count was supplied')
|
||||
const pageNoK = tri(R.buildPacket(zone, groups, new Map(), liftedMap, noK, christian, 'full', sets))
|
||||
const tiersNoK = (pageNoK.match(/class="funnel-tier/g) || []).length
|
||||
ok(tiersNoK === funnelRaw.steps.length + 1 && !/UPG100 prayer packets divide/.test(pageNoK),
|
||||
ok(tiersNoK === funnelRaw.steps.length + 1 && !/UPG 100 prayer packets divide/.test(pageNoK),
|
||||
`without a packet count the packets tier must be absent (${tiersNoK} tiers)`, 'no packet count, no packets tier')
|
||||
ok(/class="[^"]*funnel-mine/.test(page), 'the reader\'s own count (.funnel-mine) is missing from the triangle')
|
||||
// two-sided: a step is subtracted where the sets nest, and only there
|
||||
|
|
|
|||
|
|
@ -117,11 +117,8 @@ const layout = await page.evaluate(() => {
|
|||
const tb = rng.getBoundingClientRect()
|
||||
if (tb.width < 20 || box.width < 50 || box.width - tb.width < 12) continue
|
||||
if (cs.direction === 'rtl') out.rtlComputed += 1
|
||||
// Against the CONTENT box: a padded table cell (the pace table's) starts its run at its padding, which is
|
||||
// still the left (or right) edge the direction pins it to.
|
||||
const pl = parseFloat(cs.paddingLeft) || 0, pr = parseFloat(cs.paddingRight) || 0
|
||||
if (Math.round(box.right - pr - tb.right) <= 1) out.pinnedRight += 1
|
||||
else if (Math.round(tb.left - box.left - pl) <= 1) out.pinnedLeft += 1
|
||||
if (Math.round(box.right - tb.right) <= 1) out.pinnedRight += 1
|
||||
else if (Math.round(tb.left - box.left) <= 1) out.pinnedLeft += 1
|
||||
out.sampled += 1
|
||||
if (out.sampled >= 25) break
|
||||
}
|
||||
|
|
@ -184,24 +181,6 @@ const scriptureIsEnglish = lang !== 'en'
|
|||
&& !(catalog[lang] || {}).point4_passage
|
||||
await page.evaluate((words) => { window.__EXPECTED_RHYTHMS = words }, expected)
|
||||
|
||||
// The life grid's decoder: every square subpath (M x y …) of an .lc-svg back to its box index, from the geometry
|
||||
// the SVG declares. Used by the life-calendar checks below.
|
||||
await page.evaluate(() => {
|
||||
window.__lcBoxes = (svg) => {
|
||||
// data-geom x0,y0,px,dec[,py]: the box pitch across and down (one pitch when py is absent).
|
||||
const [x0, y0, px, dec, py = px] = String(svg.dataset.geom || '').split(',').map(Number)
|
||||
const weeks = Number(svg.dataset.weeks)
|
||||
const idx = (d) => [...String(d || '').matchAll(/M([\d.]+) ([\d.]+)/g)].map((m) => {
|
||||
const x = Number(m[1]) - 0.5 - x0, y = Number(m[2]) - 0.5 - y0
|
||||
const b = Math.floor(y / (10 * py + dec))
|
||||
return (b * 10 + Math.round((y - b * (10 * py + dec)) / py)) * weeks + Math.round(x / px)
|
||||
})
|
||||
const off = idx((svg.querySelector('path.lc-off') || {}).getAttribute?.('d'))
|
||||
const sh = svg.querySelector('path.lc-on, path.lc-reached')
|
||||
const shaded = sh ? idx(sh.getAttribute('d')).sort((a, b) => a - b) : []
|
||||
return { total: off.length + shaded.length, shaded, distinct: new Set([...off, ...shaded]).size }
|
||||
}
|
||||
})
|
||||
const r = await page.evaluate(() => {
|
||||
const body = document.body.innerText
|
||||
const imgs = [...document.querySelectorAll('.card img, img.ws-ph')] // profile cards, or lightweight rows (2026-09-30)
|
||||
|
|
@ -271,43 +250,27 @@ const r = await page.evaluate(() => {
|
|||
// stays exact whether or not the caller supplied set data.
|
||||
setsSheets: document.querySelectorAll('.sheet-sets').length,
|
||||
christianSheets: document.querySelectorAll('.sheet-christian').length,
|
||||
// ★ THE LIFE GRIDS ARE ONE SVG EACH (2026-10-02): two paths of square subpaths (empty and shaded) placed by
|
||||
// the geometry the SVG states (data-geom x0,y0,px,decadeGap,py). Each subpath is decoded back to its box index
|
||||
// in reading order, so the checks below still see box by box what is drawn, not a count the renderer asserts.
|
||||
lifeCells: [...document.querySelectorAll('.lc-frame .lc-svg')].reduce((a, g) => a + window.__lcBoxes(g).total, 0),
|
||||
lifeCells: document.querySelectorAll('.lc-frame .lc-c').length,
|
||||
prayBoxes: document.querySelectorAll('.pray-box').length,
|
||||
fdTopOver: (() => { const b = document.querySelector('.fd-top'); if (!b) return 0; const top = b.getBoundingClientRect().top
|
||||
return Math.round(Math.max(0, ...[...b.children].map((k) => k.getBoundingClientRect().bottom + parseFloat(getComputedStyle(k).marginBottom))) - top - b.clientHeight) })(),
|
||||
// The points page is ONE sheet by design and fits only because its sizes were tuned to the longest locale;
|
||||
// measure its content against its box (studio's ask), as fdTopOver does for the list's first page.
|
||||
// Each points sheet's body is measured, the worst one reported (one sheet today).
|
||||
pointsOver: (() => { const bs = [...document.querySelectorAll('.points-body')]; if (!bs.length) return null
|
||||
return Math.max(...bs.map((b) => { const top = b.getBoundingClientRect().top
|
||||
return Math.round(Math.max(b.scrollHeight - b.clientHeight, ...[...b.children].map((k) => k.getBoundingClientRect().bottom - top - b.clientHeight))) })) })(),
|
||||
pointsSheets: document.querySelectorAll('.sheet-points').length,
|
||||
// ★ THE POINTS PAGE SIZES ITSELF FROM AN ESTIMATE (2026-10-03, data-pt-est): the page must never be taller
|
||||
// than the estimate it was sized by, or the estimate is drifting and only its 3% margin still saves the page.
|
||||
pointsUnder: (() => { const b = document.querySelector('.points-body[data-pt-est]'); if (!b) return null
|
||||
const top = b.getBoundingClientRect().top, kids = [...b.children]
|
||||
const real = Math.max(...kids.map((k) => k.getBoundingClientRect().bottom - top)) + parseFloat(getComputedStyle(kids[kids.length - 1]).marginBottom)
|
||||
return { real: Math.round(real), est: Number(b.dataset.ptEst), tier: Number(b.dataset.ptTier) } })(),
|
||||
funnelTip: document.querySelectorAll('.funnel-tip').length,
|
||||
lifeShaded: (() => { const g = document.querySelector('.sheet-christian .lc-svg'); return g ? window.__lcBoxes(g).shaded.length : null })(),
|
||||
lifeShaded: document.querySelectorAll('.lc-frame .lc-reached').length,
|
||||
// ★ FILLED FROM THE TOP, in reading order (the packet's design), measured on the DOM order of the cells:
|
||||
// the shaded boxes must be EXACTLY the first N. This used to require a SCATTER, the opposite design;
|
||||
// a count alone still cannot tell a top fill from a scatter or a fill from the bottom.
|
||||
lifeTopFill: (() => {
|
||||
const g = document.querySelector('.sheet-christian .lc-svg')
|
||||
if (!g) return null
|
||||
const s = window.__lcBoxes(g).shaded
|
||||
return s.every((v, i) => v === i)
|
||||
const grid = document.querySelector('.sheet-christian .lc-frame')
|
||||
if (!grid) return null
|
||||
const cells = [...grid.querySelectorAll('.lc-c')]
|
||||
const n = cells.filter((c) => c.classList.contains('lc-reached')).length
|
||||
return cells.slice(0, n).every((c) => c.classList.contains('lc-reached'))
|
||||
})(),
|
||||
// The life page: Jesus' 33 years, the first 33 x 52 = 1,716 boxes and no others.
|
||||
jesusTopFill: (() => {
|
||||
const g = document.querySelector('.sheet-life .lc-svg')
|
||||
if (!g) return null
|
||||
const s = window.__lcBoxes(g).shaded
|
||||
return { on: s.length, top: s.every((v, i) => v === i) }
|
||||
const grid = document.querySelector('.sheet-life .lc-frame')
|
||||
if (!grid) return null
|
||||
const cells = [...grid.querySelectorAll('.lc-c')]
|
||||
const on = cells.filter((c) => c.classList.contains('lc-on')).length
|
||||
return { on, top: cells.slice(0, on).every((c) => c.classList.contains('lc-on')) }
|
||||
})(),
|
||||
funnelEq: document.querySelectorAll('.funnel-eq').length,
|
||||
funnelSteps: document.querySelectorAll('.funnel-mine').length,
|
||||
|
|
@ -405,15 +368,7 @@ const r = await page.evaluate(() => {
|
|||
.filter((e) => { const r = e.getBoundingClientRect(); return r.width < 4 || r.height < 4 }).length,
|
||||
rhythms: RHYTHMS.filter((w) => body.includes(w)).length,
|
||||
rhythmsTotal: RHYTHMS.length,
|
||||
// The segments are the wheel sheet's filled ARC paths (M centre L rim A …), whatever their colour notation:
|
||||
// counting fill^="hsl" read 0 once the wheel took the source's hex palette, and the gate went red on every
|
||||
// packet while the wheel was fine. The "Start here" arrow is a path too, but unfilled and arc-less.
|
||||
wheelSegs: (() => {
|
||||
const title = document.querySelector('.wheel-title')
|
||||
const svg = title ? title.closest('.sheet')?.querySelector('svg') : null
|
||||
return svg ? [...svg.querySelectorAll('path')].filter((p) => / A /.test(p.getAttribute('d') || '')
|
||||
&& (p.getAttribute('fill') || 'none') !== 'none').length : 0
|
||||
})(),
|
||||
wheelSegs: document.querySelectorAll('svg path[fill^="hsl"]').length,
|
||||
// ⚠ COUNT THE WHEEL'S OWN LABELS, SCOPED TO THE WHEEL SHEET. `rhythms` below
|
||||
// searches document.body.innerText, and the SAME rhythm vocabulary is rendered
|
||||
// TWICE — once per wheel segment, and once per prayer point (each point prints
|
||||
|
|
@ -434,11 +389,11 @@ const r = await page.evaluate(() => {
|
|||
const sheet = title ? title.closest('.sheet') : null
|
||||
const svg = sheet ? sheet.querySelector('svg') : null
|
||||
if (!svg) return -1
|
||||
// The segment labels are the wheel's own label runs (class="wheel-label", packet-render wheelPage), counted
|
||||
// when they carry words. Counting every non-numeric <text> read 25 once each rim badge gained its
|
||||
// "minutes" tag and the wheel its "Start here": those are furniture, not segment labels.
|
||||
return [...svg.querySelectorAll('text.wheel-label')]
|
||||
.filter((t) => (t.textContent || '').trim()).length
|
||||
// The segment labels are the non-numeric <text> runs: the pips are all "5" and
|
||||
// the footer reads "5 <minutes> x 12".
|
||||
return [...svg.querySelectorAll('text')]
|
||||
.map((t) => (t.textContent || '').trim())
|
||||
.filter((s) => s && !/^\d+$/.test(s) && !/^\d[\s\S]*×\s*12$/.test(s)).length
|
||||
})(),
|
||||
// ⚠ NOT body.includes('Gadarenes'). That was an ENGLISH proper noun, so this
|
||||
// assertion would go RED on a correctly-translated packet and stays green today
|
||||
|
|
@ -464,10 +419,6 @@ const r = await page.evaluate(() => {
|
|||
if (!title) return -1
|
||||
const host = title.parentElement?.parentElement
|
||||
if (!host) return -1
|
||||
// Each point's heading carries class="pt-h": a numbered LINE is not a point. German cites "1. Petrus 5:6–11"
|
||||
// (its own name for 1 Peter), and that reference line was counted as a thirteenth point.
|
||||
const heads = document.querySelectorAll('.pt-h')
|
||||
if (heads.length) return heads.length
|
||||
return (host.innerText || '').split('\n').filter((l) => /^\s*\d{1,2}\.\s/.test(l)).length
|
||||
})(),
|
||||
metacamp: (body.match(/metacamp\.org/g) || []).length,
|
||||
|
|
@ -519,7 +470,7 @@ const pdfBytes = pdf.length
|
|||
const fails = []
|
||||
if (!r.sheets) fails.push('no .sheet elements — the packet did not render')
|
||||
if (!r.cards && !r.wsRows) fails.push('no people-group cards or worksheet rows — a packet of nobody')
|
||||
if (r.wsRows && r.wsBoxes !== r.wsRows * 3) fails.push(`${r.wsRows} worksheet rows should carry ${r.wsRows * 3} tally cells but the packet has ${r.wsBoxes}`)
|
||||
if (r.wsRows && r.wsBoxes !== r.wsRows * 3) fails.push(`${r.wsRows} worksheet rows should carry ${r.wsRows * 3} tick boxes but the packet has ${r.wsBoxes}`)
|
||||
if (r.cardsBlank) {
|
||||
fails.push(`${r.cardsBlank} of ${r.cards} people-group card(s) have no NAME — a card that `
|
||||
+ 'names nobody is a slot on the page, not an entry in a prayer list. The counts above cannot '
|
||||
|
|
@ -771,31 +722,19 @@ if (!boxes.length) {
|
|||
// sheets — a 100-year life in weeks, then the SAME grid shaded by the evangelical share of these
|
||||
// groups' population — so they are DETECTED and added as known terms for exactly the reason the
|
||||
// funnel was, and for the reason stated above: a tolerance here would hide a dropped list page.
|
||||
// THE PAGE SIZES ARE THE RENDERER'S, read from it rather than retyped: the first list page of each packet
|
||||
// gives rows to the explanation band (2026-10-02), so a typed number drifted the moment the band changed.
|
||||
const { createPacketRenderer: makeR } = await import(resolve(dirname(fileURLToPath(import.meta.url)), '../library/packet/packet-render.js'))
|
||||
const RR = makeR({ strings: catalog, lang: 'en' })
|
||||
const PER_PAGE = RR.PER_PAGE
|
||||
const PER_PAGE = 20
|
||||
const funnelSheets = r.funnel ? 1 : 0
|
||||
const lifeSheets = (r.lifeSheets || 0) + (r.christianSheets || 0)
|
||||
// ⚠ AND THE SETS PAGES (bounded, centered): back matter, DETECTED for the same reason as the rest.
|
||||
// Before they were counted the arithmetic only held when a fixture omitted set data.
|
||||
const setsSheets = r.setsSheets || 0
|
||||
// Both lists give their first page to the explanation band: the worksheet holds WS_FIRST rows there and
|
||||
// WS_REST after; the profile pages FD_FIRST, then PER_PAGE.
|
||||
const wsPages = (n) => (n <= 0 ? 0 : 1 + Math.ceil(Math.max(0, n - RR.WS_FIRST) / RR.WS_REST))
|
||||
const fdPages = (n) => (n <= 0 ? 0 : 1 + Math.ceil(Math.max(0, n - RR.FD_FIRST) / PER_PAGE))
|
||||
const listSheets = r.cards ? fdPages(r.cards) : wsPages(r.wsRows)
|
||||
// The Full detail's first page reserves a block for the explanation and CLIPS what outgrows it (rather than
|
||||
// let it overlap the rows): only a measurement sees a clipped line, so measure it.
|
||||
if (r.pointsOver !== null && r.pointsOver > 0) fails.push(`the prayer-points page outgrows its sheet by ${r.pointsOver}px — the twelve points and the themes must fit ONE page in every language`)
|
||||
if (r.pointsUnder && r.pointsUnder.real > r.pointsUnder.est) fails.push(`the prayer-points page is ${r.pointsUnder.real}px tall, over the ${r.pointsUnder.est}px its size tier ${r.pointsUnder.tier} was chosen by — the estimate (packet-render pointsHeight) under-measures this language`)
|
||||
if (r.fdTopOver > 0) fails.push(`the Full detail's explanation block outgrows its reserved height by ${r.fdTopOver}px — text is clipped (raise FD_TOP_H)`)
|
||||
// The worksheet paginates 16 rows on its first page and 18 after (packet-render WS_FIRST / WS_REST; 20 and 24
|
||||
// before its rows gained a photo and a description, 2026-09-30).
|
||||
const wsPages = (n) => (n <= 0 ? 0 : 1 + Math.ceil(Math.max(0, n - 16) / 18))
|
||||
const listSheets = r.cards ? Math.ceil(r.cards / PER_PAGE) : wsPages(r.wsRows)
|
||||
if (r.wsRows && r.wsSheets !== wsPages(r.wsRows)) fails.push(`${r.wsRows} worksheet rows should take ${wsPages(r.wsRows)} page(s) but the packet has ${r.wsSheets}`)
|
||||
// + 2 again (2026-09-30, the second revision): point 4's Mark 5:18-20 and the Father's-heart list are back ON the
|
||||
// points page, so the back matter is the wheel and the points.
|
||||
// + 2: the wheel and the points, the twelve points and the themes on ONE sheet (2026-10-02, at the demo).
|
||||
if (r.pointsSheets !== 1) fails.push(`the twelve prayer points and the themes should fit ONE sheet, found ${r.pointsSheets}`)
|
||||
const expectedSheets = 1 + lifeSheets + funnelSheets + listSheets + setsSheets + 2
|
||||
// ── THE LIFE-CALENDAR PAGES, asserted rather than merely counted ──────────────────────────────────
|
||||
// A page that EXISTS is not a page that is RIGHT: an empty grid, a grid of the wrong size, or a
|
||||
|
|
@ -832,10 +771,10 @@ if (r.christianSheets) {
|
|||
+ `reached and 5200 would claim everybody is; both mean the percentage did not arrive`)
|
||||
}
|
||||
}
|
||||
// The three prayer columns: one tally cell per group per question, so the count is exactly 3 x cards.
|
||||
// The three prayer columns: one tick box per group per question, so the count is exactly 3 x cards.
|
||||
if (r.cards && r.prayBoxes !== r.cards * 3) {
|
||||
fails.push(`${r.cards} people cards should carry ${r.cards * 3} prayer tally cells `
|
||||
+ `(did I bless them / what do you want me to know / what do you want me to do) but the packet has ${r.prayBoxes}`)
|
||||
fails.push(`${r.cards} people cards should carry ${r.cards * 3} prayer tick boxes `
|
||||
+ `(blessed / what to know / what to do) but the packet has ${r.prayBoxes}`)
|
||||
}
|
||||
if (r.cards && r.sheets !== expectedSheets) {
|
||||
fails.push(`${r.cards} people cards should lay out as ${expectedSheets} sheets `
|
||||
|
|
@ -885,8 +824,6 @@ if (r.funnel && r.funnelRows !== r.funnelBars) {
|
|||
// an allowlist entry added BEFORE translating would have hidden fifteen untranslated strings.
|
||||
// 'UPG' is the same product name as renamed, "UPG 100" with a space: the digits break the run, so it
|
||||
// surfaces as 'UPG' (page 2's kicker and packets tier). Settled, like 'UPG-' — a name, not prose.
|
||||
// The series is now written as ONE term, "UPG100" (2026-10-02): the digits still break the run, so it too
|
||||
// surfaces as 'UPG'.
|
||||
const LATIN_OK = new Set(['IMB', 'peoplegroups.org', 'Project', 'UPG-', 'UPG'])
|
||||
const NON_LATIN_LOCALES = new Set(['hi', 'ar', 'ru', 'zh', 'he', 'fa', 'ur', 'bn', 'ta', 'th', 'ko', 'ja'])
|
||||
if (r.funnel && NON_LATIN_LOCALES.has(String(lang).split('-')[0])) {
|
||||
|
|
@ -970,7 +907,6 @@ console.log(` funnel (page 2) : ${r.funnel
|
|||
? `${r.funnelRows} row(s), ${r.funnelBars} bar(s)${r.funnelShare ? ` — "${r.funnelShare}"` : ' — no share line'}`
|
||||
: 'absent' + (EXPECT_FUNNEL ? ' (REQUIRED — see FAIL above)' : ' (not required)')}`)
|
||||
console.log(` prayer points : ${r.points} / ${POINTS_EXPECT}`)
|
||||
if (r.pointsUnder) console.log(` points size tier : ${r.pointsUnder.tier} (estimated ${r.pointsUnder.est}px, measured ${r.pointsUnder.real}px)`)
|
||||
console.log(` wheel segments : ${r.wheelSegs} (${r.wheelLabels}/${WHEEL_LABELS} labelled)`)
|
||||
console.log(` point 4 : Mark 5:18-20 ${r.mark5Len} chars, verse 19 bold ${r.v19Len} chars, ${r.heartItems} readings`)
|
||||
console.log(` metacamp.org : ${r.metacamp}`)
|
||||
|
|
|
|||
|
|
@ -56,10 +56,6 @@ const DECLARED_ABSENT = {
|
|||
point4_passage: 'Mark 5:1-20 — awaiting a CITED public-domain translation for hi/zh/pt/it/ro',
|
||||
point4_passage_credit: 'the attribution line for the passage above',
|
||||
point4_passage_bold: 'verse 19, verbatim from the passage above (so only where that passage is on file)',
|
||||
// Numbers 6:24-26 on the lightweight list's first page: ro and hi have no strict public-domain text on the
|
||||
// pipeline that vetted the other nine, so they print the English per key rather than an unvetted rendering.
|
||||
blessing_text: 'Numbers 6:24-26 — no strict public-domain text for ro (only a Cyrillic-script 1924) or hi',
|
||||
blessing_credit: 'the attribution line for the blessing above',
|
||||
// ── The new packet pages, 2026-09-27: a 100-year life calendar, the same grid shaded by
|
||||
// the evangelical share of these groups' population, and the funnel's equation sidebar + tip.
|
||||
// ENGLISH-ONLY ON PURPOSE, and declared rather than back-filled: English text copied into ten
|
||||
|
|
@ -125,38 +121,6 @@ for (const loc of locales) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── SCRIPTURE ADDRESSES ARE ENGLISH'S, AND THE THEMES ARE WHOLE ───────────────────
|
||||
// A point's refs are canonical addresses: a language may not keep an older set (point 12 kept Acts 2 / Matthew 28
|
||||
// in ten languages after English moved to John 17). The prayer themes are seven and the scenes three in every
|
||||
// language, or the page falls back to English for the whole list.
|
||||
for (const loc of locales) {
|
||||
const b = cat[loc]
|
||||
;(b.points || []).forEach((pt, i) => {
|
||||
if (pt && pt.refs !== undefined && JSON.stringify(pt.refs) !== JSON.stringify((en.points[i] || {}).refs)) {
|
||||
fails.push(`${loc} point ${i + 1} carries references ${JSON.stringify(pt.refs)}, not English's ${JSON.stringify((en.points[i] || {}).refs)}`)
|
||||
}
|
||||
})
|
||||
if (!Array.isArray(b.themes) || b.themes.length !== 7 || b.themes.some((x) => typeof x !== 'string' || !x.trim())) fails.push(`${loc} needs 7 prayer themes`)
|
||||
if (!Array.isArray(b.scenes) || b.scenes.length !== 3 || b.scenes.some((x) => typeof x !== 'string' || !x.trim())) fails.push(`${loc} needs 3 prayer scenes`)
|
||||
}
|
||||
if (!Array.isArray(en.scene_refs) || en.scene_refs.length !== 3 || en.scene_refs.some((r) => !Array.isArray(r) || r.length !== 2)) fails.push('en scene_refs must give each of the 3 scenes two references')
|
||||
|
||||
// ── ARABIC: NO ALEF WASLA (U+0671) ─────────────────────────────────────────────
|
||||
// The Van Dyck texts write the article with alef wasla (ٱ). A device whose fonts lack U+0671 printed it as a
|
||||
// BOX, three times in the blessing and eight in Mark 5. The stored ar text uses plain alef (U+0627), the
|
||||
// ordinary print form: display only, no word or vowel changes (_meta.note_on_blessing). Asserted on EVERY ar
|
||||
// value, nested ones included, so the next passage pasted from the same source cannot bring it back.
|
||||
{
|
||||
const hits = []
|
||||
const walk = (v, path) => {
|
||||
if (typeof v === 'string') { const n = [...v].filter((ch) => ch === '\u0671').length; if (n) hits.push(`${path} (${n})`) }
|
||||
else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, `${path}.${k}`)
|
||||
}
|
||||
walk(cat.ar || {}, 'ar')
|
||||
if (hits.length) fails.push(`ar carries U+0671 alef wasla, which prints as a box where a font lacks it — store plain alef (U+0627): ${hits.slice(0, 4).join(', ')}`)
|
||||
else notes.push('ar: no U+0671 alef wasla in any value (plain alef, the print form every Arabic font draws)')
|
||||
}
|
||||
|
||||
console.log('PACKET-STRINGS-PARITY')
|
||||
console.log(` catalog : ${CATALOG.replace(REPO + '/', '')}`)
|
||||
console.log(` locales : ${locales.length} (${locales.join(' ')})`)
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ ok(count(light, /sheet-sets-(bounded|centered)/g) === 0, 'lightweight still carr
|
|||
// ⚠ THE DELTA IS DERIVED, never relaxed into a range: lightweight drops four back pages (life, % grid,
|
||||
// bounded, centered) AND lists the same people as a worksheet, whose pages hold more rows than profiles.
|
||||
{
|
||||
const profilePages = 1 + Math.ceil(Math.max(0, 45 - R.FD_FIRST) / R.PER_PAGE)
|
||||
const profilePages = Math.ceil(45 / R.PER_PAGE)
|
||||
const worksheetPages = count(light, /<div class="sheet sheet-worksheet/g)
|
||||
const want = 4 + profilePages - worksheetPages
|
||||
ok(worksheetPages >= 1 && count(light, /class="ws-row"/g) === 45, `the lightweight worksheet should list all 45 groups, got ${count(light, /class="ws-row"/g)}`)
|
||||
|
|
@ -116,95 +116,27 @@ ok(count(light, /sheet-sets-(bounded|centered)/g) === 0, 'lightweight still carr
|
|||
`each lightweight row should carry ONE photo cell (${pics.size} photos + ${45 - pics.size} "no photo"), got ${imgs} + ${none} in ${rows} rows`)
|
||||
ok(count(lw, /class="ws-desc"/g) === 45, `each lightweight row should carry its group's description, got ${count(lw, /class="ws-desc"/g)} of 45`)
|
||||
}
|
||||
// ★ BOTH LISTS EXPLAIN THEMSELVES FIRST (2026-10-02, v2): the first list page of EACH packet carries, before any
|
||||
// row, the blessing in full with its credit, ONE line of further references, the wheel pointer, and ONE band
|
||||
// (class="xb"): a five-pace table whose loads are COMPUTED from the list (45 groups: all 45 a day, 6 at a
|
||||
// weekly pace), what the three columns ask, and an example row of three tally cells under the table's own
|
||||
// question heads. The question heads stand ONCE per page over the tally cells, never per group.
|
||||
for (const [name, html, rowRe, cellRe, headRe, pageRe] of [
|
||||
['lightweight', light, /class="ws-row"/g, /class="ws-box"/g, /class="ws-head"/g, /<div class="sheet sheet-worksheet/g],
|
||||
['full', full, /class="card"/g, /class="pray-box"/g, /class="pray-head"/g, /<div class="hd">/g],
|
||||
]) {
|
||||
const E = strings.en
|
||||
const band = html.indexOf('<section class="xb"')
|
||||
ok(count(html, /<section class="xb"/g) === 1 && band > 0 && band < html.search(rowRe),
|
||||
`the ${name} list should explain itself ONCE, on its first list page, before the first row`)
|
||||
ok(html.includes(E.blessing_text.replace(/&/g, '&')) && html.includes(E.blessing_credit), `the ${name} list should print the blessing (Numbers 6:24-26) with its credit`)
|
||||
ok(count(html, /class="ws-refs"/g) === 1 && count(html, /Psalm 134:3/g) === 1, `the ${name} list should carry ONE line of blessing references`)
|
||||
ok(html.includes(E.howto_wheel), `the ${name} list should point to the prayer wheel at the end of the packet`)
|
||||
const pace = (html.match(/<table class="ws-pace-t">([\s\S]*?)<\/table>/) || [])[1] || ''
|
||||
ok(count(pace, /<tr/g) === 5 && pace.includes('all 45, every day') && pace.includes('about 6 a day') && count(pace, /ws-pace-best/g) === 1,
|
||||
`the ${name} pace table should show five paces, daily = all 45 and weekly = about 6 a day (from the list size), weekly marked: ${pace.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160)}`)
|
||||
ok(count(html, /class="xb-cell /g) === 3 && count(html, /class="xb-marks"/g) === 3, `the ${name} band's example should be ONE row of three tally cells with marks`)
|
||||
ok(count(html, rowRe) === 45 && count(html, cellRe) === 45 * 3, `every ${name} row should carry three tally cells, got ${count(html, cellRe)} for ${count(html, rowRe)} rows`)
|
||||
ok(count(html, headRe) === count(html, pageRe), `the ${name} question heads should stand once per list page: ${count(html, headRe)} heads on ${count(html, pageRe)} pages`)
|
||||
}
|
||||
ok(!/no pace to keep/.test(light), 'the lightweight list should no longer say there is no pace to keep')
|
||||
// ★ THE PRAYER THEMES HEAD THE POINTS PAGE (2026-10-02): seven themes, then three scenes with two references
|
||||
// each, in both packets; point 12 is John 17 (its references are John 17:3, John 17:11–23, Ephesians 3:20).
|
||||
for (const [name, html] of [['lightweight', light], ['full', full]]) {
|
||||
const th = (html.match(/<div class="pt-themes">([\s\S]*?)<\/div>\s*<div style="margin-bottom/) || [])[1] || ''
|
||||
const themes = count(th.split('pt-sc-list')[0] || '', /<li>/g), scenes = count(th.split('pt-sc-list')[1] || '', /<li>/g)
|
||||
ok(themes === 7 && scenes === 3 && count(th, /class="pt-sc-r"/g) === 3 && /Haggai 2:7/.test(th) && /Matthew 8:26/.test(th),
|
||||
`the ${name} points page should open with 7 themes and 3 scenes (2 references each): ${themes} themes, ${scenes} scenes`)
|
||||
ok(/John 17:3/.test(html) && /Ephesians 3:20/.test(html) && !/Acts 2:42/.test(html), `the ${name} point 12 should be John 17 (John 17:3 · John 17:11–23 · Ephesians 3:20)`)
|
||||
}
|
||||
ok(!/<em>[^<]*<\/em><\/span>/.test(full.slice(full.indexOf('class="card"'))), 'the full packet repeats a question under each tally cell; the heads stand once per page')
|
||||
// ★ THE LIFE CALENDAR, HEADLESS — the grid's CONTENT, not just its page. An adversarial review mutated the
|
||||
// renderer to shade the LAST 33 years, to shade nothing, and to draw 90 rows; every packet gate stayed green
|
||||
// because none looked inside the grid. Read the cells of each frame, in order (the legend's sample boxes
|
||||
// sit outside the frame and are excluded):
|
||||
{
|
||||
// The grid is ONE SVG per page (packet-render lifeGrid): decode each square subpath back to its box index
|
||||
// from the geometry the SVG declares, so the check reads the boxes as drawn, in reading order.
|
||||
const grid = (html, cls) => {
|
||||
const frame = (html, cls) => {
|
||||
const i = html.indexOf(`<div class="sheet ${cls}`)
|
||||
const a = html.indexOf('<svg class="lc-svg"', i)
|
||||
return a < 0 || i < 0 ? '' : html.slice(a, html.indexOf('</svg>', a))
|
||||
// By class PREFIX: the knows-Christ grid is `lc-frame lc-plain` (drawn without week/year axes).
|
||||
const a = html.indexOf('<div class="lc-frame', i)
|
||||
return a < 0 ? '' : html.slice(a, html.indexOf('<div class="lc-legend">', a))
|
||||
}
|
||||
const boxes = (svg) => {
|
||||
// data-geom x0,y0,px,dec[,py]: the box pitch across and down (one pitch when py is absent).
|
||||
const [x0, y0, px, dec, py = px] = ((svg.match(/data-geom="([^"]+)"/) || [])[1] || '').split(',').map(Number)
|
||||
const weeks = Number((svg.match(/data-weeks="(\d+)"/) || [])[1])
|
||||
const idx = (d) => [...String(d || '').matchAll(/M([\d.]+) ([\d.]+)/g)].map((m) => {
|
||||
const x = Number(m[1]) - 0.5 - x0, y = Number(m[2]) - 0.5 - y0, b = Math.floor(y / (10 * py + dec))
|
||||
return (b * 10 + Math.round((y - b * (10 * py + dec)) / py)) * weeks + Math.round(x / px)
|
||||
})
|
||||
const off = idx((svg.match(/<path class="lc-off" d="([^"]*)"/) || [])[1])
|
||||
const on = idx((svg.match(/<path class="lc-(?:on|reached)" d="([^"]*)"/) || [])[1]).sort((a, b) => a - b)
|
||||
return { total: off.length + on.length, distinct: new Set([...off, ...on]).size, on }
|
||||
}
|
||||
const life = boxes(grid(full, 'sheet-life'))
|
||||
ok(life.total === 5200 && life.distinct === 5200, `the life calendar should hold 100 x 52 = 5,200 distinct boxes, got ${life.total} (${life.distinct} distinct)`)
|
||||
ok(life.on.length === 33 * 52 && life.on.every((v, i) => v === i),
|
||||
`the life calendar should shade Jesus' 33 years as its FIRST 1,716 boxes — got ${life.on.length}, from the top: ${life.on.every((v, i) => v === i)}`)
|
||||
const ev = boxes(grid(full, 'sheet-christian'))
|
||||
ok(ev.total === 5200 && ev.on.length === Math.round(0.032 * 5200) && ev.on.every((v, i) => v === i),
|
||||
`the %-evangelical grid should shade 3.2% = ${Math.round(0.032 * 5200)} boxes FROM THE TOP, got ${ev.on.length} of ${ev.total}`)
|
||||
// THE SAME TABLE: the people grid has the life grid's exact shape (one aspect ratio for both pages).
|
||||
const vb = (svg) => (svg.match(/viewBox="([^"]+)"/) || [])[1]
|
||||
ok(vb(grid(full, 'sheet-life')) && vb(grid(full, 'sheet-life')) === vb(grid(full, 'sheet-christian')), 'the people grid should have the life grid\'s exact shape')
|
||||
}
|
||||
|
||||
// ★ THE POINTS PAGE FITS ITSELF (2026-10-03): per language it takes the LARGEST size tier whose estimate fits
|
||||
// one sheet, from the one-sheet sizes up to the two-sheet sizes before them (the cap). Asserted both ways: the
|
||||
// tier chosen is the largest that fits, and SHORTER words print LARGER (halving the English points raises
|
||||
// the English tier) while the real words keep theirs. check:packet-render holds the estimate to the real page.
|
||||
{
|
||||
for (const lang of ['en', 'ar', 'zh', 'fr']) {
|
||||
const R = createPacketRenderer({ strings, lang })
|
||||
const f = R.pointsFit, k = f.chosen(), budget = f.bodyH * f.safe
|
||||
ok(k === f.tiers || f.height(k + 1) > budget, `${lang}: points tier ${k} is not the largest that fits (tier ${k + 1} estimates ${Math.round(f.height(k + 1))}px of ${Math.round(budget)})`)
|
||||
ok(k === 0 || f.height(k) <= budget, `${lang}: points tier ${k} estimates ${Math.round(f.height(k))}px, over the ${Math.round(budget)}px budget`)
|
||||
ok(R.pointsPage().includes(`data-pt-tier="${k}"`), `${lang}: the points page does not print at the tier it chose (${k})`)
|
||||
}
|
||||
const top = (lang, s) => createPacketRenderer({ strings: s, lang }).pointsFit
|
||||
const short = JSON.parse(JSON.stringify(strings))
|
||||
short.en.points = short.en.points.map((p) => ({ ...p, text: p.text.slice(0, Math.ceil(p.text.length / 2)) }))
|
||||
const k0 = top('en', strings).chosen(), k1 = top('en', short).chosen()
|
||||
ok(k1 > k0, `halving the English points' text should raise the English points tier, got ${k0} -> ${k1}`)
|
||||
// the cap is the two-sheet sizes: the largest tier prints the point text at 22.5px
|
||||
ok(/font:400 22\.5px Georgia/.test(createPacketRenderer({ strings, lang: 'en' }).pointsPage(top('en', strings).tiers)), 'the largest points tier should print the text at 22.5px (the two-sheet size, the cap)')
|
||||
const cells = (h) => [...h.matchAll(/<i class="lc-c( lc-on| lc-reached)?"><\/i>/g)].map((m) => (m[1] || '').trim())
|
||||
const life = cells(frame(full, 'sheet-life'))
|
||||
ok(life.length === 5200, `the life calendar should hold 100 x 52 = 5,200 boxes, got ${life.length}`)
|
||||
const gold = life.filter((c) => c === 'lc-on').length
|
||||
ok(gold === 33 * 52 && life.slice(0, gold).every((c) => c === 'lc-on'),
|
||||
`the life calendar should shade Jesus' 33 years as its FIRST 1,716 boxes — got ${gold}, from the top: ${life.slice(0, gold).every((c) => c === 'lc-on')}`)
|
||||
const ev = cells(frame(full, 'sheet-christian'))
|
||||
const shaded = ev.filter((c) => c === 'lc-reached').length
|
||||
ok(ev.length === 5200 && shaded === Math.round(0.032 * 5200) && ev.slice(0, shaded).every((c) => c === 'lc-reached'),
|
||||
`the %-evangelical grid should shade 3.2% = ${Math.round(0.032 * 5200)} boxes FROM THE TOP, got ${shaded} of ${ev.length}`)
|
||||
}
|
||||
|
||||
// ★ UNKNOWN POINTS ARE STATED EVEN THOUGH THEY CANNOT BE PLOTTED. layoutBoundedSet gives them no
|
||||
|
|
@ -217,22 +149,6 @@ ok(!/<em>[^<]*<\/em><\/span>/.test(full.slice(full.indexOf('class="card"'))), 't
|
|||
ok(/10/.test(caps.join(' ')), 'the bounded tally does not state its unknown count — an un-inked category reads as zero in print')
|
||||
}
|
||||
|
||||
// ★ EVERY GROUP OF THE LIST IS COUNTED (2026-10-03): a group with no record in a figure's data is named in
|
||||
// that figure's caption ("15 without data"), so each caption's parts add up to the list. Both ways: the
|
||||
// fixture's figures lack records (45 groups; 30 bounded, 24 centered), and complete data prints no such part.
|
||||
{
|
||||
const sum = (cap) => [...cap.matchAll(/\d[\d,]*/g)].reduce((a, m) => a + Number(m[0].replace(/,/g, '')), 0)
|
||||
const capsOf = (html) => [...html.matchAll(/class="set-cap">([^<]*)</g)].map((m) => m[1])
|
||||
const nd = String(strings.en.sets_no_data).replace('{n}', '').trim()
|
||||
const short = capsOf(full)
|
||||
ok(short.length === 2 && short.every((c) => sum(c) === 45 && c.includes(nd)),
|
||||
`each set caption should add up to the list's 45 groups, naming the ones ${nd}: ${short.join(' | ')}`)
|
||||
const whole = capsOf(R.buildPacket(zone, groups, new Map(), '<svg/>', funnel, christian, 'full', {
|
||||
bounded: groups.map((g, i) => ({ id: g.slug, reached: i % 2 === 0 })), centered: groups.map((g, i) => ({ id: g.slug, ring: (i % 10) + 1 })) }))
|
||||
ok(whole.length === 2 && whole.every((c) => sum(c) === 45 && !c.includes(nd)),
|
||||
`with a record for every group, each caption should add up to 45 and name none ${nd}: ${whole.join(' | ')}`)
|
||||
}
|
||||
|
||||
// ── THE RING LEGEND: THE AGREED VOCABULARY, AND ONLY THE RINGS THAT HOLD SOMEONE ────────────────
|
||||
// ★ The names are COMPOSED from band x culture rather than a flat 1..10 table, because "same
|
||||
// people" (provisional, pending a product decision) appears in rings 1, 4 AND 7 — three cells that a
|
||||
|
|
|
|||
|
|
@ -1,199 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-phone-print-keep.mjs — on a phone, the in-page print document outlives afterprint, so a saved PDF is the preview.
|
||||
*
|
||||
* THE DEFECT (2026-10-02, on a phone): UPG 100 → Print prayer packet. The print preview showed all 11 pages; "Save as
|
||||
* PDF" saved the WEBSITE (one letter page with the host page's title). On a phone printDocument mounts the document into
|
||||
* the page (#dxm-print-root plus a print-only style) and calls window.print(). It dropped both at afterprint, but
|
||||
* Android Chrome's print() does not block, afterprint fires once the PREVIEW is built, and the saved file is rendered
|
||||
* later, after the reader taps Save, from the page as it is then.
|
||||
*
|
||||
* A timeline on fake timers and a stub DOM, with an Android user agent (Playwright cannot reproduce Android's print
|
||||
* pipeline; a real phone is the real check):
|
||||
* A. after print() and afterprint, the document and its print style are still mounted 30 s on (the Save render);
|
||||
* B. it goes once the reader is BACK: the page shown again after being hidden, the window focused again after
|
||||
* losing focus, or a touch after print(), then BACK_GRACE_MS later, not before;
|
||||
* C. a late print (needs-tap): the Print tap itself does not count as being back, so the document is still
|
||||
* mounted through the dialog;
|
||||
* D. a reader who never comes back: dropped at FRAME_CEILING_MS, not before;
|
||||
* E. printImageBlob: its object URL is NOT revoked at afterprint, and is revoked when the document goes;
|
||||
* F. a new print replaces the previous document (one root mounted, and the previous one released);
|
||||
* G. the desktop path is the frame, as before (printDocument's first line, verbatim);
|
||||
* H. a touch AFTER the first TOUCH_SETTLE_MS, then the print UI opening (the page hidden, or the window blurred):
|
||||
* the pending drop is cancelled, so the document is still mounted well past BACK_GRACE_MS in the UI, and goes
|
||||
* after the next "back" (a measured hole in the first cut, found on a second read);
|
||||
* I. a touch in the first TOUCH_SETTLE_MS after print() (a double tap while the preview builds) is not "back".
|
||||
* `--impl=<path>` runs the timeline against another copy (the pilot's, before this fix, fails A and E: the cause).
|
||||
* `--self-test`: the old drop at afterprint, no release when the reader is back, the listeners armed at mount (so the
|
||||
* Print tap drops it), no grace, printImageBlob revoking at afterprint, no ceiling, no cancel when the page goes away
|
||||
* again during the grace, and no touch settle window must each FAIL.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const implArg = process.argv.find((a) => a.startsWith('--impl='))
|
||||
const SRC = implArg ? implArg.slice(7) : join(ROOT, 'library/composables/printInPlace.js')
|
||||
const ROOT_ID = 'dxm-print-root'
|
||||
|
||||
// ── fake timers ─────────────────────────────────────────────────────────────────────────────────
|
||||
let now = 0, seq = 0
|
||||
const timers = new Map()
|
||||
globalThis.setTimeout = (fn, ms = 0) => { const id = ++seq; timers.set(id, { at: now + Math.max(0, ms), fn }); return id }
|
||||
globalThis.clearTimeout = (id) => { timers.delete(id) }
|
||||
function advance(ms) {
|
||||
const t = now + ms
|
||||
for (;;) {
|
||||
let next = null
|
||||
for (const [id, x] of timers) if (x.at <= t && (!next || x.at < next[1].at || (x.at === next[1].at && id < next[0]))) next = [id, x]
|
||||
if (!next) break
|
||||
timers.delete(next[0]); now = next[1].at; next[1].fn()
|
||||
}
|
||||
now = t
|
||||
}
|
||||
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve() }
|
||||
|
||||
// ── a stub DOM: just what mountPrintRoot touches ────────────────────────────────────────────────────
|
||||
function events() {
|
||||
const L = new Map()
|
||||
return {
|
||||
addEventListener(t, f) { if (!L.has(t)) L.set(t, []); L.get(t).push(f) },
|
||||
removeEventListener(t, f) { const a = L.get(t); if (a && a.indexOf(f) >= 0) a.splice(a.indexOf(f), 1) },
|
||||
fire(t) { for (const f of [...(L.get(t) || [])]) f({ type: t }) },
|
||||
}
|
||||
}
|
||||
function node(tag) {
|
||||
const n = {
|
||||
tag, id: '', attrs: {}, children: [], parent: null, textContent: '', style: {}, ...events(),
|
||||
setAttribute(k, v) { this.attrs[k] = String(v) }, getAttribute(k) { return k in this.attrs ? this.attrs[k] : null },
|
||||
appendChild(c) { c.parent = this; this.children.push(c); return c },
|
||||
append(...cs) { for (const c of cs) this.appendChild(c) },
|
||||
remove() { if (this.parent) { this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null } },
|
||||
querySelectorAll() { return [] },
|
||||
}
|
||||
return n
|
||||
}
|
||||
let revoked = []
|
||||
function world({ android = true, active = true } = {}) {
|
||||
now = 0; seq = 0; timers.clear(); revoked = []
|
||||
const body = node('body'), head = node('head')
|
||||
const doc = {
|
||||
body, head, visibilityState: 'visible', ...events(),
|
||||
createElement: (t) => node(t), importNode: (n) => n,
|
||||
getElementById: (id) => [...body.children, ...head.children].find((n) => n.id === id) || null,
|
||||
querySelectorAll: () => [...body.children.filter((n) => n.id === ROOT_ID), ...head.children.filter((n) => 'data-dxm-print' in n.attrs)],
|
||||
}
|
||||
globalThis.document = doc
|
||||
globalThis.window = { ...events(), prints: [], print() { this.prints.push(mounted()) } }
|
||||
globalThis.DOMParser = class { parseFromString() { return { querySelectorAll: (s) => (s === 'style' ? [{ textContent: '@page{size:letter portrait;margin:0}' }] : []), documentElement: { getAttribute: () => null }, body: { childNodes: [node('div')] } } } }
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: {
|
||||
userAgent: android ? 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Mobile Safari/537.36' : 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36',
|
||||
userActivation: { isActive: active }, maxTouchPoints: android ? 5 : 0 } })
|
||||
globalThis.URL.createObjectURL = () => 'blob:stub/1'
|
||||
globalThis.URL.revokeObjectURL = (u) => { revoked.push([u, now]) }
|
||||
}
|
||||
const mounted = () => Boolean(document.body.children.find((n) => n.id === ROOT_ID)) && Boolean(document.head.children.find((n) => 'data-dxm-print' in n.attrs))
|
||||
const rootCount = () => document.body.children.filter((n) => n.id === ROOT_ID).length
|
||||
let bust = 0
|
||||
const load = (src) => import('data:text/javascript;base64,' + Buffer.from(src + `\n// ${++bust}`).toString('base64'))
|
||||
|
||||
async function audit(src) {
|
||||
const fails = []
|
||||
const sample = await load(src)
|
||||
const FRAME_CEILING_MS = sample.FRAME_CEILING_MS || 600000, BACK_GRACE_MS = sample.BACK_GRACE_MS || 0, TOUCH_SETTLE_MS = sample.TOUCH_SETTLE_MS || 0
|
||||
if (!sample.FRAME_CEILING_MS || !sample.BACK_GRACE_MS || !sample.TOUCH_SETTLE_MS) fails.push('G: printInPlace.js does not export FRAME_CEILING_MS, BACK_GRACE_MS and TOUCH_SETTLE_MS (the timeline runs on, with 0 for each missing one)')
|
||||
const HTML = '<!doctype html><html><head><style>@page{size:letter portrait}</style></head><body><div>packet</div></body></html>'
|
||||
|
||||
// A + B (hidden → visible)
|
||||
{ world(); const m = await load(src); const how = await m.printDocument(HTML)
|
||||
if (how !== 'page' || window.prints.length !== 1 || !window.prints[0]) fails.push(`A: a phone print did not print the mounted document (returned ${how}, prints ${JSON.stringify(window.prints)})`)
|
||||
window.fire('afterprint'); advance(30000)
|
||||
if (!mounted()) fails.push('A: the print document was gone 30 s after afterprint; a PDF saved then is the bare page')
|
||||
document.visibilityState = 'hidden'; document.fire('visibilitychange'); advance(20000)
|
||||
if (!mounted()) fails.push('B: the document went while the page was hidden (the print dialog was still up)')
|
||||
document.visibilityState = 'visible'; document.fire('visibilitychange'); advance(BACK_GRACE_MS - 1)
|
||||
if (!mounted()) fails.push('B: the document went at once when the page was shown again (no grace for a job still rendering)')
|
||||
advance(2)
|
||||
if (mounted()) fails.push('B: the document stayed after the reader was back on the page (shown again) and the grace')
|
||||
}
|
||||
// B (blur → focus) and B (a touch after print)
|
||||
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); window.fire('blur'); advance(10000)
|
||||
if (!mounted()) fails.push('B: the document went while the window had lost focus')
|
||||
window.fire('focus'); advance(BACK_GRACE_MS + 1)
|
||||
if (mounted()) fails.push('B: the document stayed after the window was focused again (back on the page)') }
|
||||
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(TOUCH_SETTLE_MS + 3000); window.fire('pointerdown'); advance(BACK_GRACE_MS + 1)
|
||||
if (mounted()) fails.push('B: the document stayed after the page was touched again') }
|
||||
// C: a late print (the tap's activation expired): the Print tap is not "back"
|
||||
{ world({ active: false }); const m = await load(src); let printNow = null
|
||||
const how = await m.printDocument(HTML, { onNeedsTap: (p) => { printNow = p } })
|
||||
if (how !== 'needs-tap' || !printNow) fails.push(`C: an expired tap did not hand out printNow (returned ${how})`)
|
||||
else {
|
||||
// the reader taps Print a few seconds after the document is ready, then spends longer than the grace in the dialog
|
||||
advance(TOUCH_SETTLE_MS + 3000); window.fire('pointerdown'); printNow(); window.fire('afterprint'); advance(BACK_GRACE_MS + 20000)
|
||||
if (window.prints.length !== 1 || !window.prints[0]) fails.push('C: printNow did not print the mounted document')
|
||||
if (!mounted()) fails.push(`C: the Print tap counted as being back: the document was gone ${(BACK_GRACE_MS + 20000) / 1000} s into the dialog`)
|
||||
} }
|
||||
// D: never back
|
||||
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(FRAME_CEILING_MS - 1)
|
||||
if (!mounted()) fails.push('D: the document went before the ceiling with the reader never back')
|
||||
advance(2)
|
||||
if (mounted()) fails.push('D: the document outlived FRAME_CEILING_MS') }
|
||||
// E: printImageBlob's object URL
|
||||
{ world(); const m = await load(src); m.printImageBlob({ size: 1 }, { title: 'poster' }); await flush()
|
||||
window.fire('afterprint'); advance(30000)
|
||||
if (revoked.length) fails.push(`E: printImageBlob revoked its object URL at ${revoked[0][1]} ms, before the reader was back (a saved PDF has no image)`)
|
||||
if (!mounted()) fails.push('E: printImageBlob\'s document was gone 30 s after afterprint')
|
||||
window.fire('pointerdown'); advance(BACK_GRACE_MS + 1)
|
||||
if (revoked.length !== 1) fails.push(`E: printImageBlob's object URL was revoked ${revoked.length} time(s) after the reader was back (expected once)`) }
|
||||
// F: a new print replaces the previous one
|
||||
{ world(); const m = await load(src); let first = 0
|
||||
await m.printDocument(HTML, { onRelease: () => { first++ } }); window.fire('afterprint'); await m.printDocument(HTML)
|
||||
if (rootCount() !== 1) fails.push(`F: ${rootCount()} print roots mounted after a second print (expected 1)`)
|
||||
if (first !== 1) fails.push(`F: the first print's onRelease ran ${first} time(s) when a second print replaced it (expected 1)`) }
|
||||
// H: a touch after the settle window, then the print UI opens: away again cancels the pending drop
|
||||
for (const via of ['hidden', 'blur']) {
|
||||
world(); const m = await load(src); await m.printDocument(HTML); advance(TOUCH_SETTLE_MS + 500); window.fire('pointerdown'); advance(500)
|
||||
if (via === 'hidden') { document.visibilityState = 'hidden'; document.fire('visibilitychange') } else window.fire('blur')
|
||||
window.fire('afterprint'); advance(BACK_GRACE_MS + 20000)
|
||||
if (!mounted()) fails.push(`H: a touch, then the print UI (${via}): the grace the touch started was not cancelled, and the document went while the reader was still in the UI (a PDF saved then is the bare page)`)
|
||||
else {
|
||||
if (via === 'hidden') { document.visibilityState = 'visible'; document.fire('visibilitychange') } else window.fire('focus')
|
||||
advance(BACK_GRACE_MS + 1)
|
||||
if (mounted()) fails.push(`H: after the print UI (${via}) closed, the document stayed past the grace (the next "back" did not count)`)
|
||||
}
|
||||
}
|
||||
// I: a touch in the settle window (no hide, no blur: an in-page sheet) is not "back"
|
||||
{ world(); const m = await load(src); await m.printDocument(HTML); advance(Math.max(1, TOUCH_SETTLE_MS - 1500)); window.fire('pointerdown'); advance(BACK_GRACE_MS + 30000)
|
||||
if (!mounted()) fails.push('I: a touch right after print() (a double tap while the preview builds) counted as being back, and the document went with the dialog still up') }
|
||||
// G: desktop unchanged (the frame path)
|
||||
if (!src.includes("if (!isPhonePrint()) return openPrintFrame(html, { autoprint: true }) ? 'frame' : 'blocked'")) fails.push('G: printDocument\'s desktop path is no longer the frame, verbatim')
|
||||
return fails
|
||||
}
|
||||
|
||||
const src = readFileSync(SRC, 'utf8')
|
||||
if (process.argv.includes('--self-test')) {
|
||||
const MOUNT_END = ' ceiling = setTimeout(drop, FRAME_CEILING_MS)\n currentJob = job\n'
|
||||
const poisons = [
|
||||
['the old drop at afterprint', src.replace(MOUNT_END, " window.addEventListener('afterprint', () => setTimeout(drop, 0))\n" + MOUNT_END), /^A: /],
|
||||
['no release when the reader is back', src.replace('const back = () => { if (leaving) return; leaving = true; clearTimeout(ceiling); ceiling = setTimeout(drop, BACK_GRACE_MS) }', 'const back = () => {}'), /^B: the document stayed/],
|
||||
['the listeners armed at mount (the Print tap drops it)', src.replace(MOUNT_END, ' ceiling = setTimeout(drop, FRAME_CEILING_MS)\n job.printing()\n currentJob = job\n'), /^C: the Print tap counted/],
|
||||
['no grace once the reader is back', src.replace('ceiling = setTimeout(drop, BACK_GRACE_MS) }', 'drop() }'), /^B: the document went at once/],
|
||||
['printImageBlob revoking at afterprint', src.replace(" const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n", " const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n window.addEventListener('afterprint', () => setTimeout(release, 0))\n"), /^E: printImageBlob revoked/],
|
||||
['no ceiling', src.replace(MOUNT_END, ' currentJob = job\n'), /^D: the document outlived/],
|
||||
['no cancel when the page goes away again', src.replace('const awayAgain = () => { away = true; if (leaving) { leaving = false; clearTimeout(ceiling); ceiling = setTimeout(drop, FRAME_CEILING_MS) } }', 'const awayAgain = () => { away = true }'), /^H: a touch, then the print UI/],
|
||||
['no touch settle window', src.replace('const onTouch = () => { if (!settling) back() }', 'const onTouch = () => back()'), /^I: /],
|
||||
]
|
||||
let missed = 0
|
||||
for (const [name, poisoned, rx] of poisons) {
|
||||
if (poisoned === src) { console.log(`NOT MEASURED: the poison anchor for "${name}" moved`); process.exitCode = 75; continue }
|
||||
const f = await audit(poisoned); const hit = f.find((x) => rx.test(x))
|
||||
console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`)
|
||||
if (!hit) missed++
|
||||
}
|
||||
if (missed) process.exitCode = 1
|
||||
} else {
|
||||
const f = await audit(src)
|
||||
if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:phone-print-keep (${f.length})`); process.exitCode = 1 }
|
||||
else console.log('PASS check:phone-print-keep — on a phone the print document (and printImageBlob\'s image) outlives afterprint, so a PDF saved after the preview is the document; it goes once the reader is back (shown, focused or touched again) plus a grace, or at the ceiling; the Print tap of a late print and a touch right after print() do not count; going away again cancels a pending drop; a new print replaces it; desktop prints through the frame as before')
|
||||
}
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* check-pinned-fetch-fresh.mjs — a STALE cached copy of a pinned file never decides a print.
|
||||
*
|
||||
* THE DEFECT (2026-10-02, on a phone). The print-time files are pinned (poster-render.js, its classic copy,
|
||||
* packet-strings.json, ne-places.json, india-states.json) and fetched with cache: 'force-cache', which returns ANY
|
||||
* cached copy, however old. After a re-pin, a reader who had printed before kept the previous copy: it failed its pin
|
||||
* on every print and the print fell back without a word. Page 1 became the stored poster (the phone's packet), a
|
||||
* non-English packet would print in English, and India's state cards would be lost. library/packet/sha384.js
|
||||
* fetchPinned fetches force-cache first and, on a copy that is not served or does not match, ONCE more with cache:
|
||||
* 'reload'; every pinned loader calls it.
|
||||
*
|
||||
* A browser's HTTP cache is modelled: force-cache answers from it when it holds the URL, reload always asks the
|
||||
* server and refreshes it. The SERVER holds the current files (public/dx-maps/assets); the CACHE holds a stale copy.
|
||||
* A. fetchPinned: a stale cached copy → the current text, after exactly one fresh refetch;
|
||||
* B. a cached copy that matches is used as is: ONE request, force-cache (the fast, offline-friendly path stays);
|
||||
* C. a server that also serves the wrong bytes → rejected, err.integrity, after one fresh refetch;
|
||||
* D. a file that is not served → rejected (not an integrity failure);
|
||||
* D2. a page without crypto.subtle (not secure: plain http by LAN address) → refused saying "secure page";
|
||||
* E. loadPacketStrings with a stale cached table → the full table in every language (not the English fallback);
|
||||
* F. loadIndiaStates with a stale cached table → India's 36 states (not a rejection);
|
||||
* G. generateSheet with a stale cached generator → a drawn sheet (not an integrity refusal);
|
||||
* H. wiring: every pinned loader fetches through fetchPinned with its own pin, and none fetches its pinned file
|
||||
* directly; the classic-script path refreshes the HTTP cache and retries a refused tag once.
|
||||
* `--self-test`: fetchPinned without its fresh refetch, one that never uses the cache, one that accepts a
|
||||
* mismatch, a hash that reaches for crypto.subtle unguarded, and a loader that fetches its pinned file directly must
|
||||
* each FAIL.
|
||||
* Two-sided by hand at the change: the previous loaders (the pilot before this) fail E, F and G.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const REPO = new URL('..', import.meta.url).pathname
|
||||
const ASSETS = join(REPO, 'public/dx-maps/assets')
|
||||
const SHA_SRC = join(REPO, 'library/packet/sha384.js')
|
||||
const LOADERS = {
|
||||
'library/packet/posterSheets.js': ['POSTER_RENDER_SHA384', 'NE_PLACES_SHA384'],
|
||||
'library/packet/packetStrings.js': ['PACKET_STRINGS_SHA384'],
|
||||
'library/data/indiaStates.js': ['INDIA_STATES_SHA384'],
|
||||
}
|
||||
const PINNED_FILES = ['lib/poster-render.js', 'lib/packet-strings.json', 'boundaries/ne-places.json', 'people-groups/india-states.json']
|
||||
const served = (p) => { const f = join(ASSETS, p); return existsSync(f) ? readFileSync(f, 'utf8') : null }
|
||||
const asModule = (src) => import('data:text/javascript;base64,' + Buffer.from(src).toString('base64'))
|
||||
const realFetch = globalThis.fetch
|
||||
|
||||
// The browser, modelled: { cached: path -> stale text } in the HTTP cache, { wrong: path -> text } served instead of
|
||||
// the real file, { missing: [path] } not served at all.
|
||||
function world(host, { cached = {}, wrong = {}, missing = [] } = {}) {
|
||||
const http = new Map(Object.entries(cached).map(([p, t]) => [`${host}/${p}`, t]))
|
||||
const requests = []
|
||||
globalThis.fetch = async (input, init = {}) => {
|
||||
const url = String(input)
|
||||
if (!url.startsWith(host + '/')) return realFetch(input, init)
|
||||
const path = url.slice(host.length + 1).split('?')[0]
|
||||
const mode = init.cache || 'default'
|
||||
requests.push({ path, mode })
|
||||
let text = null
|
||||
if (mode !== 'reload' && mode !== 'no-store' && http.has(url)) text = http.get(url)
|
||||
else {
|
||||
text = missing.includes(path) ? null : (path in wrong ? wrong[path] : served(path))
|
||||
if (text !== null) http.set(url, text)
|
||||
}
|
||||
return text === null ? new Response('not found', { status: 404 }) : new Response(text, { status: 200 })
|
||||
}
|
||||
return { requests }
|
||||
}
|
||||
const stale = (p) => `/* an older build */\n${served(p)}`
|
||||
|
||||
async function auditFetchPinned(shaSrc) {
|
||||
const fails = []
|
||||
const { fetchPinned, sha384 } = await asModule(shaSrc)
|
||||
if (typeof fetchPinned !== 'function') return ['A: library/packet/sha384.js exports no fetchPinned']
|
||||
const P = 'lib/packet-strings.json', current = served(P), pin = await sha384(current)
|
||||
// A
|
||||
{ const w = world('https://a.test', { cached: { [P]: stale(P) } }); let got = null, err = null
|
||||
try { got = await fetchPinned(`https://a.test/${P}`, pin) } catch (e) { err = e }
|
||||
if (got !== current) fails.push(`A: a stale cached copy did not give the current file (${err ? `rejected: ${err.message}` : 'returned other bytes'})`)
|
||||
else if (w.requests.length !== 2 || w.requests[0].mode !== 'force-cache' || w.requests[1].mode !== 'reload') fails.push(`A: expected force-cache then ONE reload, got ${JSON.stringify(w.requests)}`) }
|
||||
// B
|
||||
{ const w = world('https://b.test', { cached: { [P]: current } }); let got = null
|
||||
try { got = await fetchPinned(`https://b.test/${P}`, pin) } catch { /* reported below */ }
|
||||
if (got !== current || w.requests.length !== 1 || w.requests[0].mode !== 'force-cache') fails.push(`B: a matching cached copy should be used with ONE force-cache request, got ${JSON.stringify(w.requests)}`) }
|
||||
// C
|
||||
{ const bad = current.replace('"en"', '"en" ') + ' '
|
||||
const w = world('https://c.test', { cached: { [P]: stale(P) }, wrong: { [P]: bad } }); let got = null, err = null
|
||||
try { got = await fetchPinned(`https://c.test/${P}`, pin) } catch (e) { err = e }
|
||||
if (got !== null || !err || err.integrity !== true) fails.push(`C: wrong bytes on the server were not refused as an integrity failure (${got !== null ? 'ACCEPTED' : err?.message})`)
|
||||
else if (w.requests.length !== 2) fails.push(`C: expected exactly one fresh refetch before refusing, got ${w.requests.length} request(s)`) }
|
||||
// D
|
||||
{ world('https://d.test', { missing: [P] }); let err = null
|
||||
try { await fetchPinned(`https://d.test/${P}`, pin) } catch (e) { err = e }
|
||||
if (!err || err.integrity) fails.push(`D: an unserved file should reject as not served (${err ? err.message : 'it resolved'})`) }
|
||||
// D2: a page that is not secure (plain http by LAN address) has no crypto.subtle: refused in WORDS, not a bare TypeError
|
||||
{ world('https://e.test', { cached: { [P]: current } }); const realCrypto = globalThis.crypto; let err = null
|
||||
Object.defineProperty(globalThis, 'crypto', { value: {}, configurable: true, writable: true })
|
||||
try { await fetchPinned(`https://e.test/${P}`, pin) } catch (e) { err = e }
|
||||
finally { Object.defineProperty(globalThis, 'crypto', { value: realCrypto, configurable: true, writable: true }) }
|
||||
if (!err || err.integrity || !/secure page/.test(err.message)) fails.push(`D2: on a page without crypto.subtle the refusal should say "secure page", got ${err ? JSON.stringify(err.message) : 'a resolve'}`) }
|
||||
globalThis.fetch = realFetch
|
||||
return fails
|
||||
}
|
||||
|
||||
async function auditLoaders() {
|
||||
const fails = []
|
||||
// E: the packet text
|
||||
{ world('https://e.test', { cached: { 'lib/packet-strings.json': stale('lib/packet-strings.json') } })
|
||||
const { loadPacketStrings } = await import(join(REPO, 'library/packet/packetStrings.js'))
|
||||
const warn = console.warn; console.warn = () => {}
|
||||
const t = await loadPacketStrings('https://e.test').finally(() => { console.warn = warn })
|
||||
const src = JSON.parse(served('lib/packet-strings.json'))
|
||||
if (Object.keys(t).join() === 'en' || t.zh?.zone_affinity_A017 !== src.zh?.zone_affinity_A017) fails.push(`E: a stale cached packet-strings.json made the packet English (got languages: ${Object.keys(t).join(',')})`) }
|
||||
// F: India's states
|
||||
{ world('https://f.test', { cached: { 'people-groups/india-states.json': stale('people-groups/india-states.json') } })
|
||||
const { loadIndiaStates } = await import(join(REPO, 'library/data/indiaStates.js'))
|
||||
let n = -1, err = null
|
||||
try { n = (await loadIndiaStates('https://f.test')).states.length } catch (e) { err = e }
|
||||
const want = JSON.parse(served('people-groups/india-states.json')).states.length
|
||||
if (n !== want) fails.push(`F: a stale cached india-states.json lost India's states (${err ? `rejected: ${err.message}` : `${n} of ${want}`})`) }
|
||||
// G: page 1's generator
|
||||
{ world('https://g.test', { cached: { 'lib/poster-render.js': stale('lib/poster-render.js'), 'boundaries/ne-places.json': stale('boundaries/ne-places.json') } })
|
||||
const { generateSheet, countryZone } = await import(join(REPO, 'library/packet/posterSheets.js'))
|
||||
const zone = countryZone({ id: 'country-tcd', iso3: 'TCD', label: 'Chad', count: 1, region: 'Africa' })
|
||||
const pins = [[12.1, 15.0], [13.8, 20.8], [9.1, 18.4]].map(([lat, lng], i) => ({ name: `Probe ${i + 1}`, lat, lng, slug: `probe-${i}`, country_code: 'TCD', population: 1000 }))
|
||||
const adm0 = JSON.parse(served('boundaries/world-adm0.geojson'))
|
||||
let html = '', err = null
|
||||
const warn = console.warn; console.warn = () => {}
|
||||
try { html = await generateSheet({ zone, pins, format: '3x3', seriesId: 'country', adm0, assetsBase: 'https://g.test', date: '2026-09-29' }) } catch (e) { err = e }
|
||||
console.warn = warn
|
||||
if (!(typeof html === 'string' && /<svg\b/.test(html))) fails.push(`G: a stale cached generator stopped the sheet from drawing (${err ? err.message.slice(0, 110) : 'no sheet'})`) }
|
||||
globalThis.fetch = realFetch
|
||||
return fails
|
||||
}
|
||||
|
||||
function auditWiring(srcOf) {
|
||||
const fails = []
|
||||
for (const [file, pins] of Object.entries(LOADERS)) {
|
||||
const s = srcOf(file)
|
||||
if (!/import \{[^}]*\bfetchPinned\b[^}]*\} from '[^']*sha384\.js'/.test(s)) fails.push(`H: ${file} does not import fetchPinned`)
|
||||
for (const pin of pins) if (!new RegExp(`fetchPinned\\([^)]*,\\s*${pin}\\)`).test(s)) fails.push(`H: ${file} does not fetch its ${pin} file through fetchPinned`)
|
||||
if (/fetch\(url,\s*\{\s*cache:\s*'force-cache'\s*\}\)/.test(s)) fails.push(`H: ${file} still fetches a pinned file directly with force-cache`)
|
||||
}
|
||||
const ps = srcOf('library/packet/posterSheets.js')
|
||||
const classic = (ps.match(/function loadClassic\(url\) \{[\s\S]*?\n\}/) || [''])[0]
|
||||
if (!/fetch\(url, \{ cache: 'reload' \}\)/.test(classic) || !/return tag\(\)\s*\n?\s*\}\)/.test(classic)) fails.push('H: the classic-script path does not refresh the HTTP cache and retry a refused tag once')
|
||||
for (const p of PINNED_FILES) if (served(p) === null) fails.push(`H: ${p} is not in the served tree (the gate would measure nothing)`)
|
||||
return fails
|
||||
}
|
||||
|
||||
const shaSrc = readFileSync(SHA_SRC, 'utf8')
|
||||
const read = (f) => readFileSync(join(REPO, f), 'utf8')
|
||||
if (process.argv.includes('--self-test')) {
|
||||
const FOR = "for (const cache of ['force-cache', 'reload']) {"
|
||||
const poisons = [
|
||||
['fetchPinned without its fresh refetch', () => auditFetchPinned(shaSrc.replace(FOR, "for (const cache of ['force-cache']) {")), /^A: /],
|
||||
['fetchPinned that never uses the cache', () => auditFetchPinned(shaSrc.replace(FOR, "for (const cache of ['reload', 'reload']) {")), /^B: /],
|
||||
['fetchPinned that accepts a mismatch', () => auditFetchPinned(shaSrc.replace(' if (got === pin) return text\n', ' return text\n')), /^C: /],
|
||||
['a hash that reaches for crypto.subtle unguarded', () => auditFetchPinned(shaSrc.replace(/ if \(!subtle\) throw [^\n]*\n/, '')), /^D2: /],
|
||||
['a loader that fetches its pinned file directly', () => auditWiring((f) => (f === 'library/packet/packetStrings.js'
|
||||
? read(f).replace('fetchPinned(url, PACKET_STRINGS_SHA384)', "(await fetch(url, { cache: 'force-cache' })).text()") : read(f))), /^H: library\/packet\/packetStrings\.js/],
|
||||
]
|
||||
let missed = 0
|
||||
for (const [name, run, rx] of poisons) {
|
||||
const f = await run(); const hit = f.find((x) => rx.test(x))
|
||||
console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`)
|
||||
if (!hit) missed++
|
||||
}
|
||||
if (missed) process.exitCode = 1
|
||||
} else {
|
||||
const f = [...await auditFetchPinned(shaSrc), ...await auditLoaders(), ...auditWiring(read)]
|
||||
if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:pinned-fetch-fresh (${f.length})`); process.exitCode = 1 }
|
||||
else console.log('PASS check:pinned-fetch-fresh — a stale cached copy of a pinned file is refetched once, fresh: page 1 still draws, the packet keeps its language, India keeps its states; a matching cached copy is used as is; wrong bytes are refused; a page that is not secure is told so in words; every pinned loader goes through fetchPinned')
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
* ★ IT CALLS WHAT THE BROWSER CALLS. The first version rebuilt the feed itself and so could not see a
|
||||
* defect in the real entry points: generateCountrySheet passed no province boundaries, and a country
|
||||
* beside India or China printed without them — while this gate, feeding them by hand, stayed green. So:
|
||||
* · a UPG-100 zone through makeZoneSheet (data/zone-sheet-print.js), the packet's page 1 (the full letter poster), an India
|
||||
* · a UPG-100 zone through makeZoneSheet (data/zone-sheet-print.js), the map-only packet sheet, an India
|
||||
* zone so the ADM1 path runs;
|
||||
* · a country through generateCountrySheet, NEPAL, which sits between India and China;
|
||||
* both fetching the served boundaries exactly as the page does (file:// here, the asset URL there).
|
||||
|
|
@ -110,9 +110,7 @@ const results = []
|
|||
const { makeZoneSheet } = await import(join(REPO, 'plugin-designs/my-upg-100-list/data/zone-sheet-print.js'))
|
||||
const zoneSheet = makeZoneSheet({ allRecords: async () => records, assetsBase: () => assetsBase, profileConfig: null, date: DATE })
|
||||
const id = 'asia-india-gujarat'
|
||||
// The UPG-100 packet's page 1 is the FULL letter poster (zone-sheet-print answers 'packet' with it), so the
|
||||
// Node side is the letter format without SPARSE.
|
||||
results.push({ what: `${id} (packet page 1 = the full letter poster, makeZoneSheet)`, browser: await zoneSheet({ id }, 'packet'), node: nodeSheet({ FORMAT: 'letter', ONLY: id }, `${id}.letter.html`) })
|
||||
results.push({ what: `${id} (packet sheet, makeZoneSheet)`, browser: await zoneSheet({ id }, 'packet'), node: nodeSheet({ FORMAT: 'packet', ONLY: id }, `${id}.packet.html`) })
|
||||
}
|
||||
{ // a country, through the function the country wall calls — Nepal, between India and China
|
||||
const { generateCountrySheet } = await import(join(REPO, 'library/packet/posterSheets.js'))
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const G = (i) => ({ slug: `g${i}`, name: `Group ${i}`, country_code: 'MYS', prim
|
|||
// which meant the assertion checked MY arithmetic against MY arithmetic and stayed green when the
|
||||
// source's ROWS changed — it was the pagination check that caught that mutant, by luck. PER_PAGE is
|
||||
// exported; the rest come out of the rendered geometry, so this compares the SOURCE to the sheet.
|
||||
const { PER_PAGE, FD_FIRST, W, H } = mk(describe)
|
||||
const { PER_PAGE, W, H } = mk(describe)
|
||||
const SRC = read('library/packet/packet-render.js')
|
||||
const numOf = (re, what) => {
|
||||
const m = SRC.match(re)
|
||||
|
|
@ -91,10 +91,7 @@ for (const n of [1, 19, 20, 21, 45, 100]) {
|
|||
const rows = (html.match(/class="card"/g) || []).length
|
||||
ok(rows === n, `${n} groups rendered ${rows} rows — a layout rewrite dropped people`)
|
||||
const pages = R.listPages({ id: 'z', label: 'Z' }, Array.from({ length: n }, (_, i) => G(i)), new Map()).length
|
||||
// The first page gives its top to the explanation band (2026-10-02) and holds FD_FIRST rows; the rest PER_PAGE.
|
||||
const want = 1 + Math.ceil(Math.max(0, n - FD_FIRST) / PER_PAGE)
|
||||
ok(Number.isInteger(FD_FIRST) && FD_FIRST > 0 && FD_FIRST < PER_PAGE, `FD_FIRST should be a row count below PER_PAGE, got ${FD_FIRST}`)
|
||||
ok(pages === want, `${n} groups made ${pages} pages, expected ${want} (${FD_FIRST} on the first, ${PER_PAGE} after)`)
|
||||
ok(pages === Math.ceil(n / 20), `${n} groups made ${pages} pages, expected ${Math.ceil(n / 20)}`)
|
||||
}
|
||||
|
||||
// Each row must carry its three prayer boxes — the packet is a WORKING sheet, and a row without
|
||||
|
|
@ -107,11 +104,7 @@ for (const n of [1, 19, 20, 21, 45, 100]) {
|
|||
// ── 3. THE FIT LADDER PICKS THE SIZE IT CLAIMS, AND EVERY TIER FIRES ───────────────────────────
|
||||
// Budgets are recomputed here from the same inputs rather than hardcoded, so this tests the LADDER
|
||||
// and not my arithmetic twice.
|
||||
// DESC_W READ FROM THE SOURCE'S OWN INPUTS, not restated: it was a typed 1036 and went stale the moment the tally
|
||||
// cells widened (PRAY_CELL 84 → 96). The row is the sheet less its margins, the photo, the three tally cells and
|
||||
// two gutters.
|
||||
const PRAY_CELL = numOf(/const PRAY_CELL = (\d+)/, 'PRAY_CELL')
|
||||
const DESC_W = (W - 2 * M) - Math.min(CH - 8, 64) - 3 * PRAY_CELL - GUT * 2, ADV = 0.52, LINES = 2
|
||||
const DESC_W = 1036, ADV = 0.52, LINES = 2
|
||||
const budget = (px) => Math.floor(DESC_W / (px * ADV)) * LINES
|
||||
const probe = (txt) => {
|
||||
const html = mk(() => txt).listPages({ id: 'z' }, [G(0)], new Map()).join('')
|
||||
|
|
@ -121,9 +114,7 @@ const probe = (txt) => {
|
|||
const words = (n) => 'word '.repeat(n).trim()
|
||||
ok(probe(words(10)).px === 17, 'a short description did not render at the full 17px')
|
||||
ok(probe(words(Math.floor(budget(17) / 5))).px === 17, 'a description just inside the 17px budget stepped down anyway')
|
||||
// Between the 17px and the 15px budgets, wherever they fall for this row width: '+6 words' was 30 characters, and
|
||||
// when the tally cells widened the gap between the two budgets shrank to 24, so the probe skipped 15 for 14.
|
||||
ok(probe(words(Math.floor((budget(17) + budget(15)) / 2 / 5))).px === 15,
|
||||
ok(probe(words(Math.floor(budget(17) / 5) + 6)).px === 15,
|
||||
'a description over the 17px budget did not step to 15px')
|
||||
const long = probe(words(Math.floor(budget(14) / 5) + 40))
|
||||
ok(long.px === 14, `a very long description did not clamp at the smallest size (got ${long.px}px)`)
|
||||
|
|
|
|||
|
|
@ -1,499 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// check:view-address — the page's address (library/composables/useViewAddress.js) and its ONE history write.
|
||||
// A. the pure helpers: the id whitelist + 64 cap; an unknown or malformed value reads as absent (the default opens);
|
||||
// the level rule (a tab change drops pane and place unless the change sets them; pane and place are siblings);
|
||||
// foreign params keep their exact text.
|
||||
// B. the writes, on a simulated browser history: an 'app' owner pushes once and keeps history.state with `current`
|
||||
// and the hash; an unchanged address writes nothing; a partner page (no owner) writes nothing; a 'host' owner
|
||||
// writes nothing and hands the host one dxm-address event; nested elements announce dxm-view; Back/Forward is
|
||||
// only read, never written.
|
||||
// C. COEXISTENCE with the filters (useShareableView, which writes through the same commitUrl): a filter write keeps
|
||||
// tab/pane/place, the hash or the query it does not own, and the router's history.state; a tab/pane/place write
|
||||
// keeps the filters (query AND hash form); both survive Back and Forward, and re-applying the old tab after Back
|
||||
// writes nothing, so Forward still exists.
|
||||
// D. THE CENSUS of history writers, enumerated from the tree: every history.pushState/replaceState call in library/
|
||||
// and plugin-designs/ is the helper's or a listed writer. A new one fails until it is listed here with its reason.
|
||||
// E. ONE WRITER, NESTED: claimView writes for an owner and announces for anyone else (one event per action, view keys
|
||||
// only); ownAddress records an announcement only while it owns (and stops it there), so a pane and its selection
|
||||
// clicked together are ONE Back step.
|
||||
// F. pane-wrapper's opt-in address, on a simulated column: a link opens its pane without writing, also on a FRESH
|
||||
// load where the column is built before its layout (it waits for a height, measured in a browser); a click is a push
|
||||
// (the first pane is the default and is never written); a click's selection rides in the same push; where the
|
||||
// reader's scroll settles is a replace; Back/Forward scroll and never write; nested it announces; without the
|
||||
// opt-in it never writes; a kept-alive column shown again claims its pane back (a replace), unless the address
|
||||
// names one.
|
||||
// G. THE WIRING in Regions: regions-mfe opts its column in, owns its leaf, and carries the place with its pane;
|
||||
// region-dashboards claims ?place= (replace at first paint, push on a choice) and re-applies it on Back. And
|
||||
// each shell (Plugin Studio, the Research Gallery, Posterverse) records its nested composites' choices
|
||||
// (ownAddress) and releases the listener before any hot-update return.
|
||||
// --self-test: each poison below must FAIL its arm. Pure node: no Vite, no build, no browser.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
// The history writers that exist today besides the helper, by call count. Adding one means a row here with a reason.
|
||||
const KNOWN = {
|
||||
'library/composables/useViewAddress.js': [1, 'THE write (commitUrl)'],
|
||||
'library/utils/languageRequest.js': [1, '?lang= on a generated page only (MAP_OWNS_PAGE); keeps state and every other param'],
|
||||
'library/layouts/app-wrapper-material/index.html': [1, "the layout page's own #/<map> hash; a fragment-only URL keeps the query"],
|
||||
'plugin-designs/gospel-internet/src/useBoard.js': [1, 'its own keys on its own leaves (writeUrl); keeps state, foreign params and the hash'],
|
||||
}
|
||||
// SOURCE files only: gates and node scripts may simulate history. A write is `.pushState(` / `.replaceState(` (an aliased
|
||||
// history included) or a computed `history[… 'pushState' …]`.
|
||||
const WRITE = /\.(?:push|replace)State\s*\(|\[[^\]\n]*['"](?:push|replace)State['"]/g
|
||||
function census() {
|
||||
const out = execFileSync('git', ['grep', '--untracked', '-cE', '(push|replace)State', '--', 'library', 'plugin-designs',
|
||||
':!*.md', ':!*.mjs', ':!**/scripts/**', ':!**/test/**', ':!**/*.test.*', ':!**/*.spec.*'], { cwd: ROOT, encoding: 'utf8' }).trim()
|
||||
const counts = {}
|
||||
for (const line of out.split('\n').filter(Boolean)) {
|
||||
const i = line.lastIndexOf(':')
|
||||
const file = line.slice(0, i)
|
||||
// -c counts LINES; count the calls themselves (a ternary writes twice on one line).
|
||||
const n = (readFileSync(join(ROOT, file), 'utf8').match(WRITE) || []).length
|
||||
if (n) counts[file] = n
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
// ── a browser's history, simulated: entries + index, popstate on back/forward, write counters ──────────────────────
|
||||
function browser(href, state = null) {
|
||||
const entries = [{ url: new URL(href), state }]
|
||||
let i = 0
|
||||
const writes = { push: 0, replace: 0 }
|
||||
const listeners = new Set()
|
||||
const loc = {
|
||||
get href() { return entries[i].url.href },
|
||||
get pathname() { return entries[i].url.pathname },
|
||||
get search() { return entries[i].url.search },
|
||||
get hash() { return entries[i].url.hash },
|
||||
}
|
||||
const copy = (s) => (s == null ? s : JSON.parse(JSON.stringify(s)))
|
||||
const hist = {
|
||||
get state() { return entries[i].state },
|
||||
get length() { return entries.length },
|
||||
pushState(s, _, url) { writes.push++; entries.splice(i + 1); entries.push({ url: new URL(url, entries[i].url), state: copy(s) }); i++ },
|
||||
replaceState(s, _, url) { writes.replace++; entries[i] = { url: new URL(url, entries[i].url), state: copy(s) } },
|
||||
back() { if (i > 0) { i--; listeners.forEach((h) => h()) } },
|
||||
forward() { if (i < entries.length - 1) { i++; listeners.forEach((h) => h()) } },
|
||||
}
|
||||
globalThis.location = loc; globalThis.history = hist
|
||||
globalThis.addEventListener = (t, h) => t === 'popstate' && listeners.add(h)
|
||||
globalThis.removeEventListener = (t, h) => t === 'popstate' && listeners.delete(h)
|
||||
const at = () => loc.pathname + loc.search + loc.hash
|
||||
const q = () => new URLSearchParams(loc.search)
|
||||
return { loc, hist, writes, at, q, listeners }
|
||||
}
|
||||
const unset = () => { for (const k of ['location', 'history', 'addEventListener', 'removeEventListener']) delete globalThis[k] }
|
||||
|
||||
// A column of panes for pane-wrapper, simulated: each pane is 100 px tall, scrolling dispatches 'scroll'.
|
||||
class FakeEl extends EventTarget {
|
||||
constructor() { super(); this.className = ''; this.dataset = {}; this.children = []; this.parent = null; this.scrollTop = 0; this.clientHeight = FakeEl.h0 ?? 100; this.innerHTML = '' }
|
||||
appendChild(c) { c.parent = this; this.children.push(c); return c }
|
||||
remove() { if (this.parent) this.parent.children = this.parent.children.filter((x) => x !== this) }
|
||||
// A pane's top is its index times the column's height: 0 while the column has no layout.
|
||||
get offsetTop() { return this.parent ? this.parent.children.filter((c) => c.className === 'pane').indexOf(this) * this.parent.clientHeight : 0 }
|
||||
querySelector(sel) {
|
||||
const id = /data-pane-id="([^"]+)"/.exec(sel)?.[1]
|
||||
const walk = (n) => { for (const c of n.children) { if (c.className === 'pane' && c.dataset.paneId === id) return c; const r = walk(c); if (r) return r } return null }
|
||||
return walk(this)
|
||||
}
|
||||
scrollTo({ top }) { this.scrollTop = top; this.dispatchEvent(new Event('scroll')) }
|
||||
}
|
||||
const SPEC = { paneHint: false, panes: [{ id: 'list', label: 'List', at: [0, 0] }, { id: 'dashboard', label: 'Dashboard', at: [1, 0] }] }
|
||||
const settle = () => new Promise((r) => setTimeout(r, 125)) // just past the 120 ms debounce; an earlier-due timer always fires first
|
||||
|
||||
async function arms({ va, sv, counts, pw, src }, { skipF = false } = {}) {
|
||||
const fails = []
|
||||
const eq = (tag, got, want) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`${tag}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) }
|
||||
try {
|
||||
// ── A. pure helpers ──
|
||||
eq('A: a plain id is kept', va.cleanKey('regions-dashboard'), 'regions-dashboard')
|
||||
eq('A: a 64-char id is kept', va.cleanKey('a'.repeat(64)), 'a'.repeat(64))
|
||||
for (const bad of ['a'.repeat(65), '<b>', 'a b', '-lead', '../x', 'x?y', 'x#y', '']) {
|
||||
if (va.cleanKey(bad) !== '') fails.push(`A: the whitelist lets ${JSON.stringify(bad.slice(0, 20))} through`)
|
||||
}
|
||||
eq('A: an unknown or malformed value reads as absent', va.readViewKeys('?tab=a%20b&pane=%E0%A4%A&place=angola&tab=second'), { tab: '', pane: '', place: 'angola' })
|
||||
eq('A: a tab change drops pane and place', va.viewSearch('?tab=a&pane=b&place=c&lang=ar', { tab: 'd' }), '?tab=d&lang=ar')
|
||||
eq('A: a pane change keeps tab and place (siblings)', va.viewSearch('?tab=a&pane=b&place=c', { pane: 'e' }), '?tab=a&pane=e&place=c')
|
||||
eq('A: a place change keeps tab and pane', va.viewSearch('?tab=a&pane=b&place=c', { place: 'f' }), '?tab=a&pane=b&place=f')
|
||||
eq('A: a change that sets the deeper level keeps it', va.viewSearch('?tab=a&pane=b&place=c', { tab: 'd', pane: 'p' }), '?tab=d&pane=p')
|
||||
eq('A: the same tab changes nothing', va.viewSearch('?tab=a&pane=b&place=c', { tab: 'a' }), '?tab=a&pane=b&place=c')
|
||||
eq('A: foreign params keep their exact text', va.viewSearch('?lang=ar&access=yes%7Cpartial&q=rice+%26+beans&tab=a', { tab: 'b' }), '?tab=b&lang=ar&access=yes%7Cpartial&q=rice+%26+beans')
|
||||
eq('A: removing the last key leaves no "?"', va.viewSearch('?tab=a', { tab: '' }), '')
|
||||
eq('A: urlOwner', ['app', 'host', 'App', true, undefined, 'partner'].map((o) => va.urlOwnerOf({ urlOwner: o })), ['app', 'host', '', '', '', ''])
|
||||
|
||||
// ── B. writes ──
|
||||
{
|
||||
const b = browser('https://x.test/deep-research?lang=ar#top', { back: '/', current: '/deep-research?lang=ar#top', position: 3 })
|
||||
const r = va.writeViewKeys({ tab: 'regions' }, { owner: 'app', push: true })
|
||||
if (r !== 'written' || b.writes.push !== 1) fails.push(`B: an app owner's tab change is not ONE push (${r}, ${b.writes.push} pushes)`)
|
||||
if (b.at() !== '/deep-research?tab=regions&lang=ar#top') fails.push(`B: the write lost the hash or a foreign param: ${b.at()}`)
|
||||
const s = b.hist.state || {}
|
||||
if (s.position !== 3 || s.back !== '/') fails.push(`B: history.state was not kept: ${JSON.stringify(s)}`)
|
||||
if (s.current !== b.at()) fails.push(`B: history.state.current is not the new URL (${s.current}): a Vue Router host loses the link`)
|
||||
const r2 = va.writeViewKeys({ tab: 'regions' }, { owner: 'app', push: true })
|
||||
if (r2 !== 'unchanged' || b.writes.push + b.writes.replace !== 1) fails.push(`B: an unchanged address wrote (${r2})`)
|
||||
if (va.commitUrl({}) !== false || b.writes.push + b.writes.replace !== 1) fails.push('B: commitUrl wrote an identical URL')
|
||||
// the same values in another order is unchanged too (a link written by someone else, a host's own params first)
|
||||
va.commitUrl({ search: '?owner=x&pane=map&tab=regions' })
|
||||
const n = b.writes.push + b.writes.replace
|
||||
const r3 = va.writeViewKeys({ pane: 'map' }, { owner: 'app', push: false })
|
||||
if (r3 !== 'unchanged' || b.writes.push + b.writes.replace !== n) fails.push(`B: the same keys in another order were rewritten (${r3}): ${b.at()}`)
|
||||
}
|
||||
{
|
||||
const b = browser('https://partner.test/page?tab=theirs')
|
||||
for (const owner of ['', undefined, 'partner']) va.writeViewKeys({ tab: 'regions' }, { owner, push: true })
|
||||
if (b.writes.push + b.writes.replace) fails.push(`B: a partner page (no owner) was written ${b.writes.push + b.writes.replace}×`)
|
||||
}
|
||||
{
|
||||
const b = browser('https://dx.test/deep-research?lang=ar')
|
||||
const el = new EventTarget(); const got = []
|
||||
el.addEventListener(va.ADDRESS_EVENT, (e) => got.push(e))
|
||||
const r = va.writeViewKeys({ tab: 'posters' }, { owner: 'host', push: true, el })
|
||||
if (b.writes.push + b.writes.replace) fails.push("B: a 'host' owner wrote history itself (the host's router must)")
|
||||
if (r !== 'announced' || got.length !== 1) fails.push(`B: a 'host' owner did not hand the host ONE ${va.ADDRESS_EVENT} (${r}, ${got.length})`)
|
||||
else {
|
||||
const e = got[0]
|
||||
if (!e.bubbles || !e.composed) fails.push(`B: ${va.ADDRESS_EVENT} cannot leave a shadow root (bubbles ${e.bubbles}, composed ${e.composed})`)
|
||||
if (e.detail?.keys?.tab !== 'posters' || e.detail?.push !== true || e.detail?.href !== '/deep-research?tab=posters&lang=ar') fails.push(`B: ${va.ADDRESS_EVENT} detail is wrong: ${JSON.stringify(e.detail)}`)
|
||||
}
|
||||
// the host has not navigated yet; once it has, the same choice again must not ask it to navigate twice
|
||||
b.hist.replaceState(b.hist.state, '', '/deep-research?tab=posters&lang=ar'); b.writes.replace--
|
||||
const r2 = va.writeViewKeys({ tab: 'posters' }, { owner: 'host', push: true, el })
|
||||
if (r2 !== 'unchanged' || got.length !== 1) fails.push(`B: a 'host' owner announced an unchanged address (${r2}, ${got.length} events)`)
|
||||
}
|
||||
{
|
||||
browser('https://x.test/p')
|
||||
const el = new EventTarget(); const got = []
|
||||
el.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
|
||||
va.announceView(el, { pane: 'dashboard' })
|
||||
if (va.announceView(el, { filters: 'x' }) !== false) fails.push('B: announceView accepts a key that is not tab/pane/place')
|
||||
if (got.length !== 1 || !got[0].bubbles || !got[0].composed || got[0].detail?.changes?.pane !== 'dashboard') fails.push(`B: a nested element's ${va.VIEW_EVENT} is wrong or cannot leave its shadow root`)
|
||||
}
|
||||
|
||||
// ── C. coexistence with the filters, and Back/Forward ──
|
||||
{
|
||||
const b = browser('https://x.test/board?tab=a&pane=b&place=c#top', { current: '/board?tab=a&pane=b&place=c#top', position: 2 })
|
||||
sv.writeFilterView({ access_level: 'yes' }, 'rice', { mode: 'query', facetKeys: { access: 'access_level' } })
|
||||
const q = b.q()
|
||||
if (q.get('tab') !== 'a' || q.get('pane') !== 'b' || q.get('place') !== 'c') fails.push(`C: a filter write (query) dropped tab/pane/place: ${b.at()}`)
|
||||
if (q.get('access') !== 'yes' || q.get('q') !== 'rice' || b.loc.hash !== '#top') fails.push(`C: a filter write (query) lost its own params or the hash: ${b.at()}`)
|
||||
if (b.hist.state?.position !== 2 || b.hist.state?.current !== b.at()) fails.push(`C: a filter write dropped the router's history.state or its current: ${JSON.stringify(b.hist.state)}`)
|
||||
if (b.writes.push) fails.push('C: a filter write pushed (filters replace)')
|
||||
}
|
||||
{
|
||||
const b = browser('https://x.test/research?tab=a&pane=b')
|
||||
sv.writeFilterView({ region: 'asia' }, '', {})
|
||||
if (b.loc.search !== '?tab=a&pane=b' || !b.loc.hash.startsWith('#f=region:asia')) fails.push(`C: a filter write (hash) dropped tab/pane/place: ${b.at()}`)
|
||||
}
|
||||
{
|
||||
const b = browser('https://x.test/board?access=yes&q=rice#f=region:asia')
|
||||
va.writeViewKeys({ tab: 'map' }, { owner: 'app', push: true })
|
||||
const q = b.q()
|
||||
if (q.get('access') !== 'yes' || q.get('q') !== 'rice' || b.loc.hash !== '#f=region:asia') fails.push(`C: a tab write dropped the filters: ${b.at()}`)
|
||||
va.writeViewKeys({ place: 'angola' }, { owner: 'app', push: true })
|
||||
if (b.q().get('access') !== 'yes' || b.loc.hash !== '#f=region:asia') fails.push(`C: a place write dropped the filters: ${b.at()}`)
|
||||
}
|
||||
{
|
||||
// A shell with the filters inside: first paint, a filter, a tab change, another filter, then Back and Forward.
|
||||
// The app re-applies the tab on popstate the way an owner does: it sets its tab, and its tab watcher writes.
|
||||
const b = browser('https://x.test/deep-research')
|
||||
const app = { tab: '' }
|
||||
const setTab = (t, push) => { app.tab = t; va.writeViewKeys({ tab: t }, { owner: 'app', push }) }
|
||||
const off = va.onAddressChange((keys) => setTab(keys.tab || 'home', true))
|
||||
setTab('a', false)
|
||||
sv.writeFilterView({ access_level: 'yes' }, '', { mode: 'query', facetKeys: { access: 'access_level' } })
|
||||
setTab('b', true)
|
||||
sv.writeFilterView({ access_level: 'no' }, '', { mode: 'query', facetKeys: { access: 'access_level' } })
|
||||
const len = b.hist.length; const w = b.writes.push + b.writes.replace
|
||||
b.hist.back()
|
||||
if (b.q().get('tab') !== 'a' || b.q().get('access') !== 'yes') fails.push(`C: after Back the entry lost its tab or its filters: ${b.at()}`)
|
||||
if (app.tab !== 'a') fails.push('C: Back did not re-apply the tab')
|
||||
if (b.writes.push + b.writes.replace !== w) fails.push('C: re-applying the tab after Back WROTE history (Forward is lost)')
|
||||
b.hist.forward()
|
||||
if (b.hist.length !== len || b.q().get('tab') !== 'b' || b.q().get('access') !== 'no') fails.push(`C: after Forward the entry lost its tab or its filters, or Forward was gone: ${b.at()} (${b.hist.length}/${len})`)
|
||||
off()
|
||||
if (b.listeners.size) fails.push('C: onAddressChange off() left its popstate listener')
|
||||
}
|
||||
|
||||
// ── E. one writer, nested ──
|
||||
{
|
||||
const b = browser('https://x.test/p?tab=regions')
|
||||
const el = new EventTarget(); const got = []
|
||||
el.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
|
||||
const r = va.claimView({ pane: 'dashboard', place: 'angola', filters: 'x' }, { owner: '', el, push: true })
|
||||
if (b.writes.push + b.writes.replace) fails.push('E: a claim by an element that does not own the address WROTE history')
|
||||
if (r !== 'announced' || got.length !== 1) fails.push(`E: a non-owner's claim is not ONE announcement (${r}, ${got.length})`)
|
||||
else if (JSON.stringify(got[0].detail?.changes) !== JSON.stringify({ pane: 'dashboard', place: 'angola' }) || got[0].detail?.push !== true) fails.push(`E: an announcement carries more or less than the view keys: ${JSON.stringify(got[0].detail)}`)
|
||||
const r2 = va.claimView({ pane: 'dashboard' }, { owner: 'app', el })
|
||||
if (r2 !== 'written' || got.length !== 1 || b.writes.push !== 1) fails.push(`E: an owner's claim did not write it itself (${r2}, ${got.length} events)`)
|
||||
}
|
||||
{
|
||||
const b = browser('https://x.test/shell?tab=regions&lang=ar')
|
||||
const el = new EventTarget(); let owner = 'app'
|
||||
const off = va.ownAddress(el, () => owner)
|
||||
const fire = (changes, push = true) => { const e = new CustomEvent(va.VIEW_EVENT, { bubbles: true, composed: true, detail: { changes, push } }); el.dispatchEvent(e); return e }
|
||||
const e1 = fire({ pane: 'dashboard', place: 'angola' })
|
||||
if (b.writes.push !== 1 || b.at() !== '/shell?tab=regions&pane=dashboard&place=angola&lang=ar') fails.push(`E: an owner did not record a nested pane + place as ONE push: ${b.at()} (${b.writes.push} pushes)`)
|
||||
if (!e1.cancelBubble) fails.push('E: a recorded announcement went on bubbling (an outer owner could write it twice)')
|
||||
fire({ place: 'angola' })
|
||||
if (b.writes.push !== 1) fails.push('E: the selection claimed again after its pane click was a second Back step')
|
||||
owner = ''
|
||||
const e2 = fire({ place: 'benin' })
|
||||
if (b.writes.push !== 1 || b.writes.replace) fails.push('E: ownAddress wrote while the page has no owner')
|
||||
if (e2.cancelBubble) fails.push('E: an announcement an element does not own was stopped (the real owner further out never hears it)')
|
||||
owner = 'app'
|
||||
const before = b.at()
|
||||
fire({ tab: 'posters' })
|
||||
if (b.at() !== before) fails.push(`E: a nested announcement switched the OWNER's tab: ${b.at()}`)
|
||||
off(); fire({ place: 'benin' })
|
||||
if (b.writes.push !== 1) fails.push('E: ownAddress off() left its listener')
|
||||
b.hist.back()
|
||||
if (b.at() !== '/shell?tab=regions&lang=ar') fails.push(`E: Back after a pane + place click did not return in one step: ${b.at()}`)
|
||||
}
|
||||
|
||||
// ── F. pane-wrapper's address, on a simulated column (the slow arm: it waits out the scroll debounce) ──
|
||||
globalThis.document = { createElement: () => new FakeEl() }
|
||||
if (!skipF) {
|
||||
const b = browser('https://x.test/regions-mfe?pane=dashboard&lang=ar')
|
||||
const host = new FakeEl(); const got = []
|
||||
host.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
|
||||
const c = pw.buildPaneColumn(SPEC, { host, address: { owner: () => 'app' } })
|
||||
const col = host.children[0]
|
||||
await settle()
|
||||
if (col.scrollTop !== 100) fails.push(`F: a link to ?pane=dashboard did not open that pane (scrollTop ${col.scrollTop})`)
|
||||
if (b.writes.push + b.writes.replace) fails.push('F: opening a link wrote the address')
|
||||
c.scrollTo('list'); await settle()
|
||||
if (b.writes.push !== 1 || b.at() !== '/regions-mfe?lang=ar') fails.push(`F: a click on the first pane is not ONE push that removes ?pane= (the default is never written): ${b.at()}`)
|
||||
c.scrollTo('dashboard', { changes: { place: 'angola' } }); await settle()
|
||||
if (b.writes.push !== 2 || b.at() !== '/regions-mfe?pane=dashboard&place=angola&lang=ar') fails.push(`F: a click's pane and its selection are not ONE push: ${b.at()} (${b.writes.push} pushes)`)
|
||||
const w2 = b.writes.push + b.writes.replace
|
||||
b.hist.back(); await settle()
|
||||
if (col.scrollTop !== 0 || b.writes.push + b.writes.replace !== w2) fails.push(`F: Back did not show the address's pane, or it WROTE (scrollTop ${col.scrollTop}, ${b.at()})`)
|
||||
b.hist.forward(); await settle()
|
||||
if (col.scrollTop !== 100 || b.writes.push + b.writes.replace !== w2) fails.push(`F: Forward wrote, or showed the wrong pane (scrollTop ${col.scrollTop})`)
|
||||
const w = b.writes.push + b.writes.replace
|
||||
col.scrollTo({ top: 0 }); await settle()
|
||||
if (b.writes.push + b.writes.replace !== w + 1 || b.writes.push !== 2 || b.at() !== '/regions-mfe?place=angola&lang=ar') fails.push(`F: where the reader's scroll settles is not ONE replace (place kept): ${b.at()}`)
|
||||
c.destroy()
|
||||
if (b.listeners.size) fails.push('F: destroy() left its popstate listener')
|
||||
if (got.length) fails.push('F: an owner composite announced instead of writing')
|
||||
}
|
||||
if (!skipF) {
|
||||
// A FRESH LOAD: the column is built before it has a layout (measured in a browser), and a ResizeObserver's
|
||||
// FIRST callback comes after layout, already with the full height. The link must still open.
|
||||
const ros = []
|
||||
globalThis.ResizeObserver = class { constructor(cb) { this.cb = cb; ros.push(this) } observe() {} disconnect() { this.off = true } }
|
||||
const b = browser('https://x.test/country-posters-mfe?pane=dashboard&lang=ar')
|
||||
const host = new FakeEl()
|
||||
FakeEl.h0 = 0
|
||||
const c = pw.buildPaneColumn(SPEC, { host, address: { owner: () => 'app' } })
|
||||
FakeEl.h0 = undefined
|
||||
const col = host.children[0]
|
||||
col.dispatchEvent(new Event('scroll')); await settle()
|
||||
if (b.at() !== '/country-posters-mfe?pane=dashboard&lang=ar') fails.push(`F: before its layout, the column overwrote the link it has not opened yet: ${b.at()}`)
|
||||
col.clientHeight = 100; ros.forEach((r) => !r.off && r.cb([])); await settle()
|
||||
if (col.scrollTop !== 100) fails.push(`F: a FRESH LOAD with ?pane=dashboard did not open that pane once the column had its layout (scrollTop ${col.scrollTop})`)
|
||||
if (b.writes.push + b.writes.replace) fails.push('F: opening a link on a fresh load wrote the address')
|
||||
c.destroy()
|
||||
delete globalThis.ResizeObserver
|
||||
}
|
||||
if (!skipF) {
|
||||
// The same fresh load, where the observer NEVER fires again after layout (a pane's height arrived after the
|
||||
// column's last resize): the bounded rAF loop must still open the link.
|
||||
const ros = []
|
||||
globalThis.ResizeObserver = class { constructor(cb) { this.cb = cb; ros.push(this) } observe() {} disconnect() { this.off = true } }
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 5)
|
||||
const b = browser('https://x.test/upg-100-mfe?pane=dashboard')
|
||||
const host = new FakeEl()
|
||||
FakeEl.h0 = 0
|
||||
const c = pw.buildPaneColumn(SPEC, { host, address: { owner: () => 'app' } })
|
||||
FakeEl.h0 = undefined
|
||||
const col = host.children[0]
|
||||
col.clientHeight = 100; await settle()
|
||||
if (col.scrollTop !== 100) fails.push(`F: with an observer that never fires again, the bounded rAF did not open the link (scrollTop ${col.scrollTop})`)
|
||||
if (b.writes.push + b.writes.replace) fails.push('F: the rAF-opened link wrote the address')
|
||||
c.destroy()
|
||||
delete globalThis.ResizeObserver; delete globalThis.requestAnimationFrame
|
||||
}
|
||||
if (!skipF) {
|
||||
// A KEPT-ALIVE column whose tab is hidden and shown again (ResizeObserver: no height, then height).
|
||||
const ros = []
|
||||
globalThis.ResizeObserver = class { constructor(cb) { this.cb = cb; ros.push(this) } observe() { this.cb([]) } disconnect() { this.off = true } }
|
||||
const b = browser('https://x.test/shell?tab=regions')
|
||||
const host = new FakeEl()
|
||||
const c = pw.buildPaneColumn(SPEC, { host, address: { owner: () => 'app' } })
|
||||
const col = host.children[0]
|
||||
const resize = async (hgt) => { col.clientHeight = hgt; ros.forEach((r) => !r.off && r.cb([])); await settle() }
|
||||
c.scrollTo('dashboard', null); await settle()
|
||||
if (b.at() !== '/shell?tab=regions&pane=dashboard') fails.push(`F: scrollTo(id, null) did not behave as scrollTo(id): ${b.at()}`)
|
||||
await resize(0)
|
||||
va.writeViewKeys({ tab: 'posters' }, { owner: 'app' }); va.writeViewKeys({ tab: 'regions' }, { owner: 'app' })
|
||||
const w = b.writes.push + b.writes.replace
|
||||
await resize(100)
|
||||
if (b.at() !== '/shell?tab=regions&pane=dashboard' || b.writes.push + b.writes.replace !== w + 1 || b.writes.replace < 1) fails.push(`F: a kept-alive column shown again did not claim its pane back with ONE replace: ${b.at()}`)
|
||||
await resize(0)
|
||||
col.scrollTop = 0
|
||||
va.commitUrl({ search: '?tab=regions&pane=dashboard' })
|
||||
const w2 = b.writes.push + b.writes.replace
|
||||
await resize(100)
|
||||
if (col.scrollTop !== 100 || b.writes.push + b.writes.replace !== w2) fails.push(`F: shown again with an address that names a pane, the column did not show it (or wrote): scrollTop ${col.scrollTop}`)
|
||||
c.destroy()
|
||||
if (!ros.every((r) => r.off)) fails.push('F: destroy() left its ResizeObserver')
|
||||
delete globalThis.ResizeObserver
|
||||
}
|
||||
if (!skipF) {
|
||||
const b = browser('https://x.test/shell?tab=regions')
|
||||
const host = new FakeEl(); const got = []
|
||||
host.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
|
||||
const c = pw.buildPaneColumn(SPEC, { host, address: { owner: () => '' } })
|
||||
c.scrollTo('dashboard'); await settle()
|
||||
if (b.writes.push + b.writes.replace) fails.push('F: a NESTED composite wrote history')
|
||||
if (!got.length || got[0].detail?.changes?.pane !== 'dashboard' || got[0].detail?.push !== true) fails.push('F: a nested composite did not announce its pane to the owner')
|
||||
c.destroy()
|
||||
}
|
||||
if (!skipF) {
|
||||
const b = browser('https://x.test/plain?pane=dashboard')
|
||||
const host = new FakeEl(); const got = []
|
||||
host.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
|
||||
const c = pw.buildPaneColumn(SPEC, { host })
|
||||
c.scrollTo('dashboard'); await settle(); c.scrollTo('list'); await settle()
|
||||
if (b.writes.push + b.writes.replace || got.length || host.children[0].scrollTop !== 0) fails.push('F: a column WITHOUT the address opt-in read or wrote the address')
|
||||
c.destroy()
|
||||
}
|
||||
delete globalThis.document
|
||||
} finally { unset(); delete globalThis.document }
|
||||
|
||||
// ── G. the wiring in Regions ──
|
||||
{
|
||||
const m = src.mfe, d = src.dash
|
||||
if (!/buildPaneColumn\(spec\.value, \{ host: stage\.value, address: \{ owner \} \}\)/.test(m)) fails.push('G: regions-mfe does not opt its column into the address')
|
||||
if (!/ownAddress\(stage\.value, owner\)/.test(m)) fails.push("G: regions-mfe does not record its panes' announcements on its own leaf (ownAddress)")
|
||||
if (!/go\('dashboard', \{ place: detail\.countrySlug \|\| detail\.regionSlug/.test(m)) fails.push('G: regions-mfe opens the dashboard without its place (two Back steps for one click)')
|
||||
if (!/claimView\(\{ place: currentSlug\(\) \}/.test(d)) fails.push('G: region-dashboards does not claim ?place=')
|
||||
if (!/claimPlace\(false\)/.test(d) || !/claimPlace\(true\)/.test(d)) fails.push('G: region-dashboards does not replace at first paint and push on a choice')
|
||||
if (!/onAddressChange\(onAddress\)/.test(d)) fails.push('G: region-dashboards does not re-apply ?place= on Back/Forward')
|
||||
if (!/new ResizeObserver\([\s\S]{0,400}else if \(!place\) claimPlace\(false\)/.test(d)) fails.push('G: a kept-alive region-dashboards shown again does not claim its place back')
|
||||
// A composite that opts its column in also owns its own leaf: it records its panes' choices (ownAddress) and
|
||||
// releases that listener on unmount, or a choice made inside it on its own leaf is never written.
|
||||
if (!Object.keys(src.opted || {}).length) fails.push('G: no composite opts its pane column into the address (the census of opted composites is empty)')
|
||||
for (const [f, t] of Object.entries(src.opted || {})) {
|
||||
if (!/ownAddress\(stage\.value, owner\)/.test(t)) fails.push(`G: ${f} opts its column in but does not record its panes' choices (ownAddress)`)
|
||||
else if (!/offOwn\?\.\(\)/.test(t)) fails.push(`G: ${f} never releases ownAddress`)
|
||||
}
|
||||
// Each shell that owns a page records its nested composites' choices, and releases the listener on unmount
|
||||
// BEFORE any hot-update early return (or a hot remount stacks a second one).
|
||||
for (const [f, t] of Object.entries(src.shells || {})) {
|
||||
if (!/this\._offOwn = ownAddress\(this\.\$el, \(\) => urlOwnerOf\(this\.cfg\)\)/.test(t)) { fails.push(`G: the shell ${f} does not record nested choices (ownAddress)`); continue }
|
||||
const bu = t.indexOf('beforeUnmount()')
|
||||
const rel = bu < 0 ? -1 : t.indexOf('this._offOwn()', bu)
|
||||
const hot = bu < 0 ? -1 : t.indexOf('if (import.meta.hot) return', bu)
|
||||
if (rel < 0 || (hot >= 0 && hot < rel)) fails.push(`G: the shell ${f} does not release ownAddress in beforeUnmount before any hot-update return`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── D. the census ──
|
||||
for (const [file, n] of Object.entries(counts)) {
|
||||
if (!KNOWN[file]) fails.push(`D: a new history writer: ${file} (${n}). Route it through useViewAddress (commitUrl), or list it in KNOWN with its reason`)
|
||||
else if (KNOWN[file][0] !== n) fails.push(`D: ${file} now has ${n} history writes, the census says ${KNOWN[file][0]}`)
|
||||
}
|
||||
for (const file of Object.keys(KNOWN)) if (!counts[file]) fails.push(`D: ${file} is listed as a writer but no longer writes: drop its row`)
|
||||
return fails
|
||||
}
|
||||
|
||||
const va = await import(join(ROOT, 'library/composables/useViewAddress.js'))
|
||||
const sv = await import(join(ROOT, 'library/composables/useShareableView.js'))
|
||||
const PW = 'library/layouts/pane-wrapper/pane-wrapper.js'
|
||||
const pw = await import(join(ROOT, PW))
|
||||
const SHELLS = ['plugin-studio/plugin-studio.vue', 'doxa-maps-spa/doxa-maps-spa.vue', 'posterverse/posterverse.vue']
|
||||
// Every composite that opts its pane column into the address, enumerated from the tree.
|
||||
const OPTED = execFileSync('git', ['grep', '--untracked', '-lE', 'buildPaneColumn\\([^)]*address:', '--', 'plugin-designs', 'library', ':!*.md'], { cwd: ROOT, encoding: 'utf8' }).trim().split('\n').filter(Boolean)
|
||||
const src = {
|
||||
mfe: readFileSync(join(ROOT, 'plugin-designs/regions-dashboard/regions-mfe.vue'), 'utf8'),
|
||||
dash: readFileSync(join(ROOT, 'plugin-designs/regions-dashboard/region-dashboards.vue'), 'utf8'),
|
||||
shells: Object.fromEntries(SHELLS.map((f) => [f, readFileSync(join(ROOT, 'plugin-designs', f), 'utf8')])),
|
||||
opted: Object.fromEntries(OPTED.map((f) => [f, readFileSync(join(ROOT, f), 'utf8')])),
|
||||
}
|
||||
const counts = census()
|
||||
const real = { va, sv, counts, pw, src }
|
||||
// A pane-wrapper with one line changed, loaded from a sibling file so its relative import still resolves.
|
||||
let nth = 0
|
||||
async function pwVariant(from, to) {
|
||||
const s = readFileSync(join(ROOT, PW), 'utf8')
|
||||
if (!s.includes(from)) throw new Error('poison anchor not found in pane-wrapper.js: ' + from)
|
||||
const f = join(ROOT, `library/layouts/pane-wrapper/.tmp-check-view-address-${process.pid}-${nth++}.js`)
|
||||
writeFileSync(f, s.replace(from, to))
|
||||
try { return await import(f) } finally { unlinkSync(f) }
|
||||
}
|
||||
let code = 0
|
||||
if (process.argv.includes('--self-test')) {
|
||||
// The old filter write, as it was: its own replaceState(null, …) of the whole URL.
|
||||
const oldFilterWrite = (fs, term, o = {}) => {
|
||||
const url = o.mode === 'query' ? sv.serializeQuery(fs, term, location.search, o) + location.hash : location.search + sv.serializeView(fs, term, o)
|
||||
history.replaceState(null, '', location.pathname + url); return true
|
||||
}
|
||||
const poisons = [
|
||||
['a write that drops history.state.current', { ...real, va: { ...va, writeViewKeys: (c, o) => { const s = va.viewSearch(location.search, c); history[o.push ? 'pushState' : 'replaceState']({ ...history.state }, '', location.pathname + s + location.hash); return 'written' } } }, /^B: history\.state\.current/],
|
||||
['a write when not the owner', { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, owner: 'app' }) } }, /^B: a partner page/],
|
||||
['a tab change that keeps a stale place', { ...real, va: { ...va, viewSearch: (s, c) => { const k = { ...va.readViewKeys(s), ...Object.fromEntries(Object.entries(c).map(([a, b]) => [a, va.cleanKey(b)])) }; const f = s.replace(/^\?/, '').split('&').filter((p) => p && !va.VIEW_KEYS.includes(p.split('=')[0])); const all = [...va.VIEW_KEYS.filter((x) => k[x]).map((x) => `${x}=${k[x]}`), ...f]; return all.length ? '?' + all.join('&') : '' } } }, /^A: a tab change drops pane and place/],
|
||||
['a whitelist with no length cap', { ...real, va: { ...va, cleanKey: (v) => (/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(String(v ?? '')) ? String(v) : '') } }, /^A: the whitelist lets/],
|
||||
['unchanged judged by the string, not the values', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'app') return va.writeViewKeys(c, o); const s = va.viewSearch(location.search, c); if (s === location.search) return 'unchanged'; return va.commitUrl({ search: s }, { push: o.push }) ? 'written' : 'unchanged' } } }, /^B: the same keys in another order were rewritten/],
|
||||
['a write when the address is unchanged', { ...real, va: { ...va, writeViewKeys: (c, o) => { const s = va.viewSearch(location.search, c); history[o.push ? 'pushState' : 'replaceState']({ ...(history.state || {}), current: location.pathname + s + location.hash }, '', location.pathname + s + location.hash); return 'written' } } }, /^B: an unchanged address wrote|^C: re-applying the tab after Back WROTE/],
|
||||
["a 'host' owner that writes history itself", { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, owner: o.owner === 'host' ? 'app' : o.owner }) } }, /^B: a 'host' owner wrote/],
|
||||
['a dxm-address that cannot leave a shadow root', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'host') return va.writeViewKeys(c, o); const s = va.viewSearch(location.search, c); o.el.dispatchEvent(new CustomEvent(va.ADDRESS_EVENT, { bubbles: true, detail: { keys: va.readViewKeys(s), href: location.pathname + s, push: o.push } })); return 'announced' } } }, /^B: dxm-address cannot leave/],
|
||||
// the three coexistence poisons
|
||||
['a FILTER write that drops tab/pane/place (no foreign params kept)', { ...real, sv: { ...sv, writeFilterView: (fs, term, o = {}) => va.commitUrl(o.mode === 'query' ? { search: sv.serializeQuery(fs, term, '', o) } : { search: '', hash: sv.serializeView(fs, term, o) }) } }, /^C: a filter write \((query|hash)\) dropped tab\/pane\/place/],
|
||||
['the old filter write: its own replaceState(null) (the router state is wiped)', { ...real, sv: { ...sv, writeFilterView: oldFilterWrite } }, /^C: a filter write dropped the router's history\.state/],
|
||||
['a TAB write that drops the filters (foreign params and hash)', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'app') return va.writeViewKeys(c, o); const k = va.readViewKeys(va.viewSearch(location.search, c)); const s = va.VIEW_KEYS.filter((x) => k[x]).map((x) => `${x}=${k[x]}`).join('&'); return va.commitUrl({ search: s ? '?' + s : '', hash: '' }, { push: o.push }) ? 'written' : 'unchanged' } } }, /^C: a (tab|place) write dropped the filters/],
|
||||
['a tab change written as a REPLACE (Back leaves the page)', { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, push: false }) } }, /^B: an app owner's tab change is not ONE push|^C: after Back/],
|
||||
['a popstate handler that writes the address', { ...real, va: { ...va, onAddressChange: (cb) => va.onAddressChange((k) => { cb(k); history.pushState(history.state, '', location.href) }) } }, /^C: re-applying the tab after Back WROTE|^C: after Forward/],
|
||||
// the census
|
||||
['a new history writer in the tree', { ...real, counts: { ...counts, 'plugin-designs/some-plugin/page.vue': 1 } }, /^D: a new history writer/],
|
||||
['useShareableView writing history itself again', { ...real, counts: { ...counts, 'library/composables/useShareableView.js': 1 } }, /^D: a new history writer: library\/composables\/useShareableView\.js/],
|
||||
['region-dashboards writing its #slug itself again', { ...real, counts: { ...counts, 'plugin-designs/regions-dashboard/region-dashboards.vue': 2 } }, /^D: a new history writer: plugin-designs\/regions-dashboard/],
|
||||
// P2: nested writes, pane-wrapper, the Regions wiring
|
||||
['the old level rule: a pane change drops place', { ...real, va: { ...va, viewSearch: (s, c) => va.viewSearch(s, 'pane' in c && !('place' in c) ? { ...c, place: '' } : c) } }, /^A: a pane change keeps tab and place/],
|
||||
['a claim by a non-owner that writes', { ...real, va: { ...va, claimView: (c, o) => va.writeViewKeys(c, { ...o, owner: 'app' }) } }, /^E: a claim by an element that does not own/],
|
||||
['an announcement that carries any key', { ...real, va: { ...va, claimView: (c, o) => (o.owner ? va.claimView(c, o) : (o.el.dispatchEvent(new CustomEvent(va.VIEW_EVENT, { bubbles: true, composed: true, detail: { changes: c, push: o.push } })), 'announced')) } }, /^E: an announcement carries more or less/],
|
||||
['ownAddress that writes with no owner', { ...real, va: { ...va, ownAddress: (el, g) => va.ownAddress(el, () => g() || 'app') } }, /^E: ownAddress wrote while the page has no owner/],
|
||||
['ownAddress that lets a recorded announcement bubble on', { ...real, va: { ...va, ownAddress: (el, g) => { const h = (e) => { const o = g(); if (o) va.writeViewKeys(e.detail.changes, { owner: o, el, push: e.detail.push }) }; el.addEventListener(va.VIEW_EVENT, h); return () => el.removeEventListener(va.VIEW_EVENT, h) } } }, /^E: a recorded announcement went on bubbling/],
|
||||
['pane-wrapper writing the default pane', { ...real, pw: await pwVariant("pane: id === column[0].id ? '' : id", 'pane: id') }, /^F: a click on the first pane/],
|
||||
["pane-wrapper writing a click's selection separately", { ...real, pw: await pwVariant('if (write && known(paneId)) claimPane(paneId, push, changes)', 'if (write && known(paneId)) { claimPane(paneId, push); if (Object.keys(changes).length) claimView(changes, { owner: address.owner(), el: host, push }) }') }, /^F: a click's pane and its selection are not ONE push/],
|
||||
['pane-wrapper pushing where a scroll settles', { ...real, pw: await pwVariant('claimPane(current(), false)', 'claimPane(current(), true)') }, /^F: where the reader's scroll settles/],
|
||||
['pane-wrapper writing on Back (the pane it is leaving)', { ...real, pw: await pwVariant("show(known(pane) ? pane : column[0].id, 'smooth')", "(claimPane(current(), true), show(known(pane) ? pane : column[0].id, 'smooth'))") }, /^F: Back did not show the address's pane, or it WROTE/],
|
||||
['pane-wrapper opening a link at build time only (before the column has a layout)', { ...real, pw: await pwVariant("let pending = known(linked) && linked !== column[0].id ? linked : ''", "let pending = ''; if (known(linked)) show(linked, 'instant')") }, /^F: a FRESH LOAD with \?pane=dashboard did not open/],
|
||||
['pane-wrapper whose settle overwrites a link still waiting to open', { ...real, pw: await pwVariant('if (col.clientHeight && !pending) claimPane(current(), false)', 'if (!pending || true) claimPane(current(), false)') }, /^F: before its layout, the column overwrote the link/],
|
||||
['pane-wrapper retrying a pending link by rAF only when there is no observer', { ...real, pw: await pwVariant("if (pending && typeof requestAnimationFrame !== 'undefined') {", "if (pending && typeof ResizeObserver === 'undefined' && typeof requestAnimationFrame !== 'undefined') {") }, /^F: with an observer that never fires again/],
|
||||
['pane-wrapper ignoring a link', { ...real, pw: await pwVariant("let pending = known(linked) && linked !== column[0].id ? linked : ''", "let pending = ''") }, /^F: a link to \?pane=dashboard did not open/],
|
||||
['pane-wrapper writing without the opt-in', { ...real, pw: await pwVariant("=> address && claimView({ pane: id === column[0].id ? '' : id, ...changes }, { owner: address.owner(),", "=> claimView({ pane: id === column[0].id ? '' : id, ...changes }, { owner: address ? address.owner() : 'app',") }, /^F: a column WITHOUT the address opt-in/],
|
||||
['ownAddress that lets a nested element switch the tab', { ...real, va: { ...va, ownAddress: (el, g) => { const h = (e) => { const o = g(); if (o) { e.stopPropagation(); va.writeViewKeys(e.detail.changes, { owner: o, el, push: e.detail.push }) } }; el.addEventListener(va.VIEW_EVENT, h); return () => el.removeEventListener(va.VIEW_EVENT, h) } } }, /^E: a nested announcement switched the OWNER's tab/],
|
||||
['pane-wrapper not claiming its pane when shown again', { ...real, pw: await pwVariant("if (typeof ResizeObserver !== 'undefined') {", 'if (false) {') }, /^F: a kept-alive column shown again/],
|
||||
['pane-wrapper letting the screen beat an address that names a pane', { ...real, pw: await pwVariant('if (known(asked) && asked !== current()) show(asked, \'instant\')\n else if (!asked) claimPane', 'if (false) show(asked, \'instant\')\n else claimPane') }, /^F: shown again with an address that names a pane/],
|
||||
['pane-wrapper whose scrollTo(id, null) throws (the old default)', { ...real, pw: await pwVariant('const { write = true, push = true, changes = {} } = o || {}', 'const { write = true, push = true, changes = {} } = o === undefined ? {} : o') }, /^F: scrollTo\(id, null\)|^THREW: .*null/],
|
||||
['region-dashboards not claiming its place when shown again', { ...real, src: { ...src, dash: src.dash.replace('else if (!place) claimPlace(false)', 'else if (!place) void 0') } }, /^G: a kept-alive region-dashboards shown again/],
|
||||
['regions-mfe opening the dashboard without its place', { ...real, src: { ...src, mfe: src.mfe.replace("go('dashboard', { place: detail.countrySlug || detail.regionSlug || '' })", "go('dashboard')") } }, /^G: regions-mfe opens the dashboard without its place/],
|
||||
['regions-mfe without the address opt-in', { ...real, src: { ...src, mfe: src.mfe.replace('{ host: stage.value, address: { owner } }', '{ host: stage.value }') } }, /^G: regions-mfe does not opt its column in/],
|
||||
['a composite that opts in without ownAddress', { ...real, src: { ...src, opted: { ...src.opted, 'plugin-designs/x/x-mfe.vue': "column = buildPaneColumn(spec.value, { host: stage.value, address: { owner } })" } } }, /^G: plugin-designs\/x\/x-mfe\.vue opts its column in but does not record/],
|
||||
['no composite opted in at all', { ...real, src: { ...src, opted: {} } }, /^G: no composite opts its pane column/],
|
||||
['Plugin Studio without ownAddress', { ...real, src: { ...src, shells: { ...src.shells, 'plugin-studio/plugin-studio.vue': src.shells['plugin-studio/plugin-studio.vue'].replace('this._offOwn = ownAddress(this.$el, () => urlOwnerOf(this.cfg))', 'this._offOwn = null') } } }, /^G: the shell plugin-studio\/plugin-studio\.vue does not record/],
|
||||
['Plugin Studio releasing ownAddress after its hot-update return', { ...real, src: { ...src, shells: { ...src.shells, 'plugin-studio/plugin-studio.vue': src.shells['plugin-studio/plugin-studio.vue'].replace(' if (this._offOwn) { this._offOwn(); this._offOwn = null }\n if (import.meta.hot) return', ' if (import.meta.hot) return\n if (this._offOwn) { this._offOwn(); this._offOwn = null }') } } }, /^G: the shell plugin-studio\/plugin-studio\.vue does not release/],
|
||||
['the Research Gallery without ownAddress', { ...real, src: { ...src, shells: { ...src.shells, 'doxa-maps-spa/doxa-maps-spa.vue': src.shells['doxa-maps-spa/doxa-maps-spa.vue'].replace('this._offOwn = ownAddress(', 'this._offOwn = (') } } }, /^G: the shell doxa-maps-spa/],
|
||||
['Posterverse never releasing ownAddress', { ...real, src: { ...src, shells: { ...src.shells, 'posterverse/posterverse.vue': src.shells['posterverse/posterverse.vue'].replace('if (this._offOwn) { this._offOwn(); this._offOwn = null }', 'void 0') } } }, /^G: the shell posterverse\/posterverse\.vue does not release/],
|
||||
['region-dashboards not re-applying ?place= on Back', { ...real, src: { ...src, dash: src.dash.replace('onAddressChange(onAddress)', 'null') } }, /^G: region-dashboards does not re-apply/],
|
||||
]
|
||||
for (const [name, inp, rx] of poisons) {
|
||||
let f
|
||||
// Only a pane-wrapper poison needs the slow arm F; every other poison must fail its own, faster arm.
|
||||
try { f = await arms(inp, { skipF: !/^\/\^F|THREW/.test(String(rx)) }) } catch (e) { f = ['THREW: ' + e.message] }
|
||||
const ok = f.some((x) => rx.test(x))
|
||||
console.log(ok ? `✓ caught: ${name} -> ${f.find((x) => rx.test(x))}` : `✗ MISSED: ${name}${f.length ? ' (failed instead: ' + f[0] + ')' : ''}`)
|
||||
if (!ok) code = 1
|
||||
}
|
||||
} else {
|
||||
const f = await arms(real)
|
||||
if (f.length) { f.forEach((x) => console.log('FAIL ' + x)); code = 1 }
|
||||
else console.log(`PASS check:view-address — the id whitelist and the level rule; one push per tab change with history.state + current kept; nothing written when unchanged, on a partner page, or by a 'host' owner (one dxm-address instead); filters and tab/pane/place keep each other through writes, Back and Forward; nested choices announced and recorded by the one owner, a pane and its selection as one Back step; pane-wrapper's opt-in ?pane= (link, click, scroll, Back) and the Regions wiring; ${Object.keys(counts).length} history writers in the tree, all accounted for`)
|
||||
}
|
||||
process.exit(code)
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
"neg": { "leaf": "regions-dashboard/regions-list/index.html", "text": "View country posters" },
|
||||
"sanctioned": [
|
||||
{
|
||||
"match": "^(Select view|Regions List|Dashboard)$", "flags": "i",
|
||||
"match": "^(Select view|Regions List|Dashboard|Country Posters)$", "flags": "i",
|
||||
"what": "the SELECT VIEW tab strip — sanctioned addition #1",
|
||||
"ruling": "the tabs added, and what the dashboard tab shows before a region or country is selected",
|
||||
"date": "2026-09-24", "card": "SPEC-REGIONS-IS-A-CLONE-PLUS-EXACTLY-TWO-ADDITIONS"
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ function assetsBaseFor(depth) {
|
|||
// depth is measured from the WEB-ROOT; the tree root is one level in, hence depth - 1.
|
||||
return DEST_CONFIG.assetsBaseUrl || `${'../'.repeat(Math.max(0, depth - 1))}assets`
|
||||
}
|
||||
// The staging TOP must not mention deployment orgs (doxa/GO/...). The visible
|
||||
// design note 2026-07-11: the staging TOP must not mention deployment orgs (doxa/GO/...). The visible
|
||||
// title is the PRODUCT title (config displayTitle, default org-free), never the deployment TREE_NAME
|
||||
// (TREE_NAME stays in URLs/paths where it is a folder name, not branding).
|
||||
const DISPLAY_TITLE = (typeof DEST_CONFIG.displayTitle === 'string' && DEST_CONFIG.displayTitle.trim())
|
||||
|
|
@ -925,10 +925,7 @@ function mapPageHtml(opts) {
|
|||
? `\n <script>window.MAP_APP_API_URL = ${JSON.stringify(apiBaseUrl)};</script>`
|
||||
: ''
|
||||
// Bake the QA corner widget onto the host platform's embed surface (opt-out via build-config qaWidget:false).
|
||||
// urlOwner 'app' (library/composables/useViewAddress.js): this generated page is OURS, so its shell may write the
|
||||
// view address (?tab=…) for a reload and a sent link. Only the page's own config gets it, never an embed snippet
|
||||
// (embedCfg), so a partner's page is never written to; and a FRAMED page drops it at runtime (below).
|
||||
const pageConfig = { ...(DEST_CONFIG.qaWidget ? { ...config, qa: true } : config), urlOwner: 'app' }
|
||||
const pageConfig = DEST_CONFIG.qaWidget ? { ...config, qa: true } : config
|
||||
// PROVIDER-AWARE PAGE (see library/composables/useMapProvider.js): a profile or
|
||||
// instance whose config says provider:'maplibre' gets the MapLibre peer (token-
|
||||
// free, no Mapbox account) and SKIPS the token ladder entirely — the map mounts
|
||||
|
|
@ -1102,9 +1099,6 @@ ${apiScript}
|
|||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = ${JSON.stringify(pageConfig)};
|
||||
// Framed (the iframe embed on a partner's page): READ the address, never write it. A push inside a frame adds
|
||||
// entries to the partner page's own Back button (the joint session history).
|
||||
try { if (window.self !== window.top) delete BASE_CONFIG.urlOwner } catch (e) { delete BASE_CONFIG.urlOwner }
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
* a rect×rect overlap of the sheet against .mapboxgl-ctrl-bottom-left/-right
|
||||
* (their VISIBLE children) in every sheet state.
|
||||
*
|
||||
* WHY: a sense that the sheet feels wrong on a phone is not evidence a fix can be checked against. This
|
||||
* WHY: "it feels wrong on my phone" is not evidence a fix can be checked against. This
|
||||
* probe turns the gesture into a per-step table (finger delta vs measured sheet height),
|
||||
* the release into an expected/actual state, and the attribution into overlap px² —
|
||||
* run it BEFORE a fix (baseline json) and AFTER (same json shape) and diff.
|
||||
|
|
|
|||
|
|
@ -145,18 +145,6 @@ export function galleryModel(reg) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── ONE PART OF A LEAF URL ───────────────────────────────────────────────────────────────────
|
||||
// /__leaf/<bundle>/<profile>[/<instance>] parts are written INTO the leaf page: its <title> and the
|
||||
// element's profile-config='…' attribute. Refusing only `..` and `/` let a quote or a tag through, so a
|
||||
// crafted link broke out of the attribute on a publicly reachable dev server. Every bundle folder,
|
||||
// profile id and instance id in the tree has this shape (the address keys' own pattern,
|
||||
// library/composables/useViewAddress.js), so anything else is refused before it reaches either sink;
|
||||
// it also rules out `..` and `/`. check:dev-leaf-escape proves both directions.
|
||||
export const LEAF_PART = /^[A-Za-z0-9][\w-]{0,63}$/
|
||||
|
||||
// For an HTML text node or a quoted attribute value.
|
||||
export const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
||||
|
||||
// ── THE PRODUCTION EMBED URL, ANSWERED BY THE DEV SERVER ─────────────────────────────────────
|
||||
// The manifest advertises, and a person copies, tree-relative embed URLs of the shape
|
||||
// `<bundle>/<profile>/index.html` and `<bundle>/<profile>/<instance>/index.html` — served in
|
||||
|
|
@ -209,16 +197,6 @@ export function leafRewrite(path, treeName = TREE_NAME) {
|
|||
return '/__leaf/' + segs.join('/')
|
||||
}
|
||||
|
||||
// urlOwner 'app' (library/composables/useViewAddress.js) for a dev page that is OURS, the rule generate-tree bakes
|
||||
// into the built leaf: its shell writes ?tab=, so a clicked tab survives a reload and a copied link opens it. A
|
||||
// FRAMED page drops it: a partner page may iframe a leaf (the iframe embed), and a history push from an iframe
|
||||
// lands in the top page's Back stack. (Plugin Studio mounts its rows directly; its panes are unowned by paneConfig.) Write the element unowned and put this classic script right after it; it runs
|
||||
// before the deferred module script upgrades the element. `<` is escaped: profile/instance come from the URL.
|
||||
export function ownIfTopScript(cfg = {}) {
|
||||
const owned = JSON.stringify(JSON.stringify({ ...cfg, urlOwner: 'app' })).replace(/</g, '\\u003c')
|
||||
return `<script>try{if(window.self===window.top)document.currentScript.previousElementSibling.setAttribute('profile-config',${owned})}catch(e){}</script>`
|
||||
}
|
||||
|
||||
export function autoSpaDevPlugin(token = '') {
|
||||
// One generator for both galleries (internal/generate-gallery.mjs), driven by the same build
|
||||
// config the built page bakes. What dev changes: the model comes from discovery, the live
|
||||
|
|
@ -359,8 +337,8 @@ export function autoSpaDevPlugin(token = '') {
|
|||
try { return decodeURIComponent(s) } catch { return '' }
|
||||
})
|
||||
const [bundle, profile, instance] = parts
|
||||
// These land in a filesystem-ish URL and in the page's HTML below: LEAF_PART shapes only.
|
||||
if (!bundle || !profile || parts.some(s => !LEAF_PART.test(s))) {
|
||||
// Path traversal guard: these land in a filesystem-ish URL below.
|
||||
if (!bundle || !profile || parts.some(s => !s || s.includes('..') || s.includes('/'))) {
|
||||
res.statusCode = 400; return res.end('bad leaf path')
|
||||
}
|
||||
let reg
|
||||
|
|
@ -387,8 +365,7 @@ export function autoSpaDevPlugin(token = '') {
|
|||
// renders the SAME view here as in production rather than the bundle's default.
|
||||
// assetsBase too, as the built leaf's BASE_CONFIG carries it ("../../assets" there; root-absolute
|
||||
// here, because a /__leaf/ URL's depth differs from the tree's and the pool is served at /assets).
|
||||
const leafPc = instance ? { profile, instanceId: instance, assetsBase: '/assets' } : { profile, assetsBase: '/assets' }
|
||||
const pc = JSON.stringify(leafPc)
|
||||
const pc = JSON.stringify(instance ? { profile, instanceId: instance, assetsBase: '/assets' } : { profile, assetsBase: '/assets' })
|
||||
// The composition tier of THIS profile, read from the same discovery the gallery uses.
|
||||
// `nested-elements-mfe` (cap-4) is the StackLayout scroll-stack tier — see the style note
|
||||
// below. An unknown profile falls through to the fixed-viewport leaf, the safe default.
|
||||
|
|
@ -399,7 +376,7 @@ export function autoSpaDevPlugin(token = '') {
|
|||
res.setHeader('Cache-Control', 'no-cache')
|
||||
return res.end([
|
||||
'<!DOCTYPE html>',
|
||||
`<html lang="en"><head><meta charset="UTF-8" /><title>${escHtml(bundle)} · ${escHtml(profile)}</title>`,
|
||||
`<html lang="en"><head><meta charset="UTF-8" /><title>${bundle} · ${profile}</title>`,
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
|
||||
'<link rel="icon" href="data:,">',
|
||||
leafTok ? `<script>window.MAP_TOKEN=${JSON.stringify(leafTok)};</script>` : '',
|
||||
|
|
@ -453,7 +430,6 @@ export function autoSpaDevPlugin(token = '') {
|
|||
// live 19-bundle tree. Passing the base makes a nested shell see exactly what the top
|
||||
// level sees. Elements that ignore tree-base simply drop an unknown attribute.
|
||||
`<${tag} tree-base="/__plugin-gallery" profile-config='${pc}'></${tag}>`,
|
||||
ownIfTopScript(leafPc),
|
||||
`<script type="module" src="/plugin-designs/${bundle}/index.js"></script>`,
|
||||
'</body></html>',
|
||||
].filter(Boolean).join('\n'))
|
||||
|
|
@ -523,7 +499,6 @@ export function autoSpaDevPlugin(token = '') {
|
|||
// manifest-shaped model the built tree bakes (19 bundles / 30 profiles), so pointing the
|
||||
// studio at it gives dev the same tab list production has.
|
||||
'<plugin-studio tree-base="/__plugin-gallery"></plugin-studio>',
|
||||
ownIfTopScript(),
|
||||
'<script type="module" src="/plugin-designs/plugin-studio/index.js"></script>',
|
||||
'</body></html>',
|
||||
].filter(Boolean).join('\n'))
|
||||
|
|
|
|||
|
|
@ -12,42 +12,9 @@
|
|||
* posterDesign { family, name, size, mobileFriendly, labelKey?, instance? } or an array of them: the poster
|
||||
* design(s) this profile shows, listed by Poster Design's Styles. Carried as written, ONLY when present
|
||||
* (every other manifest keeps its bytes); check:poster-design-catalogue validates it.
|
||||
* posterDesignLabels GENERATED beside it: { <design name>: { <locale>: title } } for every block with a labelKey,
|
||||
* resolved here from library/i18n/locales/<locale>/<area>.json. Poster Design's bundle ships only the
|
||||
* catalogue areas its own code names, so a plugin's own area (upgMap, …) would be missing there and the card
|
||||
* would read English; carrying the titles in the manifest works for any area, with no Poster Design rebuild.
|
||||
*/
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const LOCALES = join(dirname(fileURLToPath(import.meta.url)), '..', 'library', 'i18n', 'locales')
|
||||
// A catalogue key ("area.a.b") in every locale that has it: { en: '…', ar: '…' }; {} when no locale does.
|
||||
function catalogueTitles(key) {
|
||||
const [area, ...path] = String(key || '').split('.')
|
||||
const out = {}
|
||||
if (!area || !path.length || !existsSync(LOCALES)) return out
|
||||
for (const loc of readdirSync(LOCALES)) {
|
||||
const f = join(LOCALES, loc, `${area}.json`)
|
||||
if (!existsSync(f)) continue
|
||||
try {
|
||||
let o = JSON.parse(readFileSync(f, 'utf8'))
|
||||
for (const p of path) o = o && typeof o === 'object' ? o[p] : undefined
|
||||
if (typeof o === 'string' && o.trim()) out[loc] = o
|
||||
} catch { /* a locale file that does not parse names nothing */ }
|
||||
}
|
||||
return out
|
||||
}
|
||||
function posterDesignLabels(pd) {
|
||||
const out = {}
|
||||
for (const b of Array.isArray(pd) ? pd : [pd]) {
|
||||
if (b && typeof b.name === 'string' && typeof b.labelKey === 'string') {
|
||||
const titles = catalogueTitles(b.labelKey)
|
||||
if (Object.keys(titles).length) out[b.name] = titles
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left']
|
||||
|
||||
|
|
@ -115,7 +82,7 @@ export function profileMetaFields(dir, profileName) {
|
|||
const m = readProfileMeta(dir, profileName)
|
||||
// visibility is emitted ONLY when it is "public": absent means team, so every manifest in the
|
||||
// tree today keeps the bytes it has and nothing becomes visible by upgrading the generator.
|
||||
return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}), ...(m.mobile ? { mobile: m.mobile } : {}), ...(m.posterDesign ? { posterDesign: m.posterDesign } : {}), ...(m.posterDesign && Object.keys(posterDesignLabels(m.posterDesign)).length ? { posterDesignLabels: posterDesignLabels(m.posterDesign) } : {}) }
|
||||
return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}), ...(m.mobile ? { mobile: m.mobile } : {}), ...(m.posterDesign ? { posterDesign: m.posterDesign } : {}) }
|
||||
}
|
||||
|
||||
/** A bundle is production if ANY of its profiles says so. */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<!--
|
||||
ApproxPinNote.vue — the ONE wording every map with people-group pins uses (APPROXPIN, 2026-09-26).
|
||||
|
||||
The first sentence always: a dot marks a people group, not a town or a boundary. The second only
|
||||
The first sentence always: a dot is where a people is found, not a town or a boundary. The second only
|
||||
when the pin's point is SHARED, naming the other groups (from library/utils/colocation.js, built from the
|
||||
roster the map draws), because "approximate" alone hides that some dots are the same dot.
|
||||
Never shows the raw coordinate: six decimal places beside a caveat that says "approximate" reads as a
|
||||
|
|
@ -21,8 +21,8 @@ const props = defineProps({
|
|||
// vue-i18n, inside the custom-element boundary, and the reader gets an empty shadow root. Without it
|
||||
// the English below renders, with the same placeholders filled.
|
||||
const APPROX_FALLBACK = {
|
||||
'pins.approximate': 'The dot marks a people group, not a town or a boundary.',
|
||||
'pins.sharedPoint': 'It sits on the same point as {names} — {n} groups share this dot.',
|
||||
'pins.approximate': 'The dot marks where this people is found, not a town or a boundary.',
|
||||
'pins.sharedPoint': 'It sits on the same point as {names} — {n} groups share this coordinate, so the position tells you the locality and not which group is where.',
|
||||
}
|
||||
// C4: the guard is library/composables/useOptionalT.js now. The fallback MAP stays exactly as it was,
|
||||
// passed in, so a bare host renders the same English it rendered before.
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ const packetOpen = ref(false)
|
|||
const { scrimRef, scrimStyle } = useViewportScrim(exportMenuOpen)
|
||||
// Feature flag: show the Export affordance. Gated here in the SHARED legend so
|
||||
// one switch covers every bundle. It was false by an earlier coder decision;
|
||||
// the button came back on the 100-list legend (2026-07-31), and the
|
||||
// design note 2026-07-31 asked for the button back on the 100-list legend, and the
|
||||
// export path is functional (the menu emits 'export' with 'poster' | 'packet',
|
||||
// and a profile wires @export to a real download).
|
||||
// A profile STILL has to opt in with :export-enabled="true" — this flag only
|
||||
|
|
|
|||
|
|
@ -14,11 +14,6 @@
|
|||
RIGHT of left-hand controls. The side is measured, so a right-to-left page that
|
||||
mirrors the toolbar mirrors the menu as well.
|
||||
• Width: as wide as fits between the toolbar and the opposite edge, capped at `width`.
|
||||
• A STANDALONE anchor (ShareButton in a page header, no map toolbar: `standalone` on the
|
||||
provided anchor) opens BELOW its button instead, toward the side with room, inside the
|
||||
VIEWPORT, at `width` (the maps' readable 340) or the viewport minus its edges, whichever is
|
||||
less. Where it lives and where it goes are resolved when it OPENS, and its coordinates are
|
||||
converted into the block that really contains it (see placeBeside).
|
||||
• One z tier for menus (--z-menu, default 3000); only one menu is open per map at a
|
||||
time; closes on Escape or on a tap on the bare map canvas (never on legend/search).
|
||||
|
||||
|
|
@ -100,17 +95,23 @@ const AREA = '.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]'
|
|||
function place() {
|
||||
const tb = toolbar?.el?.value, area = host.value
|
||||
if (!tb || !area) { style.value = {}; return }
|
||||
if (toolbar.standalone) return placeBeside(tb)
|
||||
const ar = area.getBoundingClientRect(), tr = tb.getBoundingClientRect()
|
||||
const onRight = (tr.left + tr.right) / 2 > (ar.left + ar.right) / 2
|
||||
const gap = 8, edge = 12
|
||||
const offset = onRight ? (ar.right - tr.left + gap) : (tr.right - ar.left + gap)
|
||||
const width = Math.max(200, Math.min(props.width, ar.width - offset - edge))
|
||||
const top = Math.max(0, tr.top - ar.top)
|
||||
// A menu never outgrows the MAP's walls. (A header button is not placed here: placeBeside bounds it by the
|
||||
// viewport, so it opens at natural height; measured on upg-100-mfe, a header band as the wall gave a 291px
|
||||
// menu a 69px scrolling body, BUG-SHARE-MENU-HAS-A-SCROLLBAR-SHOULD-BE-FULLY-EXPANDED.)
|
||||
let maxHeight = Math.max(120, ar.height - top - edge)
|
||||
// "Fit inside the map" is the rule ONLY for a real map area. When ShareButton mounted in a
|
||||
// page HEADER, its fallback self-tagged that header band as the area (data-map-area=
|
||||
// "share-fallback", see ShareButton.bindAnchor) -- and this line then dutifully clamped the
|
||||
// menu INSIDE a ~56px band: measured on upg-100-mfe, a 291px share menu rendered with a 69px
|
||||
// scrolling body (BUG-SHARE-MENU-HAS-A-SCROLLBAR-SHOULD-BE-FULLY-EXPANDED). A menu must never
|
||||
// outgrow a MAP's walls, but a header band is not a wall -- nothing clips below it -- so the
|
||||
// fallback area bounds against the VIEWPORT instead and the menu opens at natural height.
|
||||
const isFallbackArea = area.getAttribute && area.getAttribute('data-map-area') === 'share-fallback'
|
||||
let maxHeight = isFallbackArea
|
||||
? Math.max(120, (window.innerHeight || 900) - tr.top - edge) // header host: the page below is available
|
||||
: Math.max(120, ar.height - top - edge) // map host: fit inside the map
|
||||
if (props.matchToolbar) maxHeight = Math.max(120, Math.min(maxHeight, tr.height))
|
||||
if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight)
|
||||
style.value = {
|
||||
|
|
@ -122,45 +123,6 @@ function place() {
|
|||
}
|
||||
}
|
||||
|
||||
// ★ BESIDE ITS BUTTON, INSIDE THE VIEWPORT (a standalone anchor: a Share button in a page header). Below the
|
||||
// button, toward the side with room (a button right of centre opens leftward, its end edges aligned; RTL headers
|
||||
// mirror with no special case, because the side is measured), at the readable width the maps use, clamped inside
|
||||
// the viewport, as tall as its content up to the viewport's bottom edge.
|
||||
// ★ THE BLOCK THAT CONTAINS IT IS MEASURED, NOT ASSUMED. The menu is position:absolute, so its coordinates are
|
||||
// relative to its containing block, which the HOST's markup decides: an unpositioned wrapper hands it to the
|
||||
// header above. Measuring against one element and positioning against another is what sent the header menu far
|
||||
// to the left (the UPG-100 list page, 2026-10-03). So the target point is computed in viewport coordinates and converted into
|
||||
// the containing block's own (its padding edge, scrolled), whatever element that turns out to be.
|
||||
function placeBeside(tb) {
|
||||
const vw = document.documentElement.clientWidth || window.innerWidth || 1024
|
||||
const vh = window.innerHeight || 800
|
||||
const r = tb.getBoundingClientRect(), gap = 8, edge = 12
|
||||
const width = Math.min(props.width, Math.max(160, vw - 2 * edge))
|
||||
const onRight = (r.left + r.right) / 2 > vw / 2
|
||||
const x = Math.max(edge, Math.min(onRight ? r.right - width : r.left, vw - edge - width))
|
||||
const y = r.bottom + gap
|
||||
let maxHeight = Math.max(120, vh - y - edge)
|
||||
if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight)
|
||||
const o = blockOrigin(el.value)
|
||||
style.value = {
|
||||
top: `${Math.round(y - o.y)}px`,
|
||||
left: `${Math.round(x - o.x)}px`,
|
||||
width: `${Math.round(width)}px`,
|
||||
maxHeight: `${Math.round(maxHeight)}px`,
|
||||
...(props.height ? { height: `${Math.round(Math.min(props.height, maxHeight))}px` } : {}),
|
||||
}
|
||||
}
|
||||
// The viewport position of the origin an absolutely positioned element is placed from: its offsetParent's padding
|
||||
// edge, scrolled. A static body (or none) means the initial containing block: the document origin.
|
||||
function blockOrigin(m) {
|
||||
const cb = m && m.offsetParent
|
||||
if (!cb || ((cb === document.body || cb === document.documentElement) && getComputedStyle(cb).position === 'static')) {
|
||||
return { x: -(window.scrollX || 0), y: -(window.scrollY || 0) }
|
||||
}
|
||||
const b = cb.getBoundingClientRect()
|
||||
return { x: b.left + cb.clientLeft - cb.scrollLeft, y: b.top + cb.clientTop - cb.scrollTop }
|
||||
}
|
||||
|
||||
// Only one menu open per map: opening this one closes any other.
|
||||
const OPEN_EVENT = 'doxa-map-menu-open'
|
||||
function onOtherOpened(e) { if (props.open && e.detail !== el.value) emit('close') }
|
||||
|
|
@ -177,7 +139,6 @@ const announceClose = () => { try { host.value?.dispatchEvent(new CustomEvent(PA
|
|||
|
||||
watch(() => props.open, async (open, was) => {
|
||||
if (!open) { if (was) announceClose(); return }
|
||||
if (toolbar?.standalone) host.value = resolveHost() // layout is known now: where the menu lives
|
||||
await nextTick()
|
||||
place()
|
||||
try { host.value?.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: el.value })) } catch (_) {}
|
||||
|
|
@ -195,27 +156,15 @@ function onClickOutside(e) {
|
|||
if (path.some(n => n?.classList?.contains?.('mapboxgl-canvas') || n?.classList?.contains?.('maplibregl-canvas'))) emit('close')
|
||||
}
|
||||
|
||||
// Where the menu lives (the Teleport target). A map control: the map area, else the toolbar's offset parent. A
|
||||
// standalone button: a named area if its host declares one, else its offset parent, kept in the button's own
|
||||
// (shadow) root so the menu keeps this root's styles; else the root's top element.
|
||||
function resolveHost() {
|
||||
const tbEl = toolbar?.el?.value || null
|
||||
if (!tbEl) return null
|
||||
if (!toolbar.standalone) return tbEl.closest(AREA) || tbEl.offsetParent || tbEl.parentElement || null
|
||||
const root = tbEl.getRootNode(), mine = (n) => n && n.getRootNode() === root ? n : null
|
||||
let top = tbEl; while (top.parentElement) top = top.parentElement
|
||||
return mine(tbEl.closest(AREA)) || mine(tbEl.offsetParent) || (root === document ? document.body : top)
|
||||
}
|
||||
// The "one menu at a time" listener follows the host, which a standalone button re-resolves on every open.
|
||||
watch(host, (h, was) => { was?.removeEventListener(OPEN_EVENT, onOtherOpened); h?.addEventListener(OPEN_EVENT, onOtherOpened) })
|
||||
|
||||
onMounted(() => {
|
||||
// ★ NEVER null. A named area is still preferred -- it is the MAP's walls, and a menu must not outgrow
|
||||
// them -- but when a plugin has not tagged one, the toolbar's own offsetParent is the containing block
|
||||
// this menu is absolutely positioned against, so placing against it is geometrically correct by
|
||||
// definition rather than by convention. A plugin that adopts a class or data-map-area later simply
|
||||
// gets the better anchor; nothing has to be registered anywhere for the menu to be on screen.
|
||||
host.value = resolveHost()
|
||||
const tbEl = toolbar?.el?.value || null
|
||||
host.value = tbEl?.closest(AREA) || tbEl?.offsetParent || tbEl?.parentElement || null
|
||||
host.value?.addEventListener(OPEN_EVENT, onOtherOpened)
|
||||
window.addEventListener('resize', onResize)
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@
|
|||
ShareButton.vue — Non-technical share modal for the map toolbar.
|
||||
|
||||
Redesigned to match consumer-grade UX from YouTube, Spotify, Canva, and
|
||||
Google Maps. Two tabs, share FIRST (shareable pages P3):
|
||||
Google Maps. Two tabs:
|
||||
|
||||
1. "Share this page" — the link to exactly what the reader is looking at (the page's own
|
||||
address, with its tab, where the page owns its address) + one-click copy. It is what a
|
||||
person who presses Share expects: something to send to someone.
|
||||
2. "Embed" — for putting the map on ANOTHER website: what embedding is and why it helps, the
|
||||
multiplication steps, then the code with size presets.
|
||||
1. "Share Link" — clean URL display + one-click copy + social share icons
|
||||
2. "Add to Your Website" — friendly embed guidance with size presets
|
||||
|
||||
The word "iframe" never appears in any user-visible text. The embed tab
|
||||
leads with a plain-English description, then shows the code below.
|
||||
|
|
@ -33,9 +30,7 @@ import { tStatic } from '@map/i18n/index.js'
|
|||
const T = (key, en) => tStatic(key) || en
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
import { resolveShareUrl, resolvePublicBase, resolveEmbedBase } from '../../utils/embedBase.js'
|
||||
import { publicLeafUrl, publicScriptCode } from '../../utils/publicEmbed.js'
|
||||
import { urlOwnerOf } from '../../composables/useViewAddress.js'
|
||||
import { resolveShareUrl } from '../../utils/embedBase.js'
|
||||
import MapControlButton from './MapControlButton.vue'
|
||||
import MapControlMenu from './MapControlMenu.vue'
|
||||
|
||||
|
|
@ -56,7 +51,7 @@ useShadowStyles(`
|
|||
/* The ring itself rotates, so the colours travel around the button in every browser. */
|
||||
@keyframes share-ring-turn{to{transform:rotate(360deg);}}
|
||||
@media(prefers-reduced-motion:reduce){.share-wrap::before{animation:none;}}
|
||||
.share-steps{margin:0 0 12px;padding:0 0 0 15px;list-style:'- ';font:14px/1.55 system-ui,sans-serif;color:#c9d1d9;}
|
||||
.share-steps{margin:10px 14px 6px;padding:0 0 0 15px;list-style:'- ';font:14px/1.55 system-ui,sans-serif;color:#c9d1d9;}
|
||||
.share-steps li{margin:0 0 3px;padding-inline-start:3px;}
|
||||
.share-steps li:last-child{font-weight:600;color:#92b195;}
|
||||
.share-pop.light .share-steps{color:#3b463d;}
|
||||
|
|
@ -353,13 +348,10 @@ const rootEl = ref(null)
|
|||
const isOpen = ref(false)
|
||||
const popoverEl = ref(null)
|
||||
const btnEl = ref(null)
|
||||
const activeTab = ref('page') // 'page' | 'embed'
|
||||
const pageUrl = ref('') // "Share this page": read when the menu opens (see pageLink)
|
||||
const activeTab = ref('link') // 'link' | 'embed'
|
||||
const linkCopied = ref(false)
|
||||
const embedCopied = ref(false)
|
||||
const embedSize = ref('medium') // 'small' | 'medium' | 'large'
|
||||
const embedKind = ref('url') // 'url' | 'iframe' | 'script': the Embed tab's three codes
|
||||
const scriptState = ref({ loading: false }) // the JavaScript code from the public manifest: { code } | { missing } | { failed }
|
||||
|
||||
const sizePresets = {
|
||||
small: { label: 'Small', height: 400 },
|
||||
|
|
@ -367,9 +359,6 @@ const sizePresets = {
|
|||
large: { label: 'Large', height: 800 }
|
||||
}
|
||||
|
||||
// The size names, each key written out so the catalogue gate sees it.
|
||||
const sizeLabel = (k) => ({ small: T('share.size_small', 'Small'), medium: T('share.size_medium', 'Medium'), large: T('share.size_large', 'Large') })[k] || k
|
||||
|
||||
/* ---- computed ---- */
|
||||
/* The standalone embed page this map is shared/iframed as — resolved by
|
||||
utils/embedBase.js (its header documents the full ladder). In short:
|
||||
|
|
@ -393,76 +382,18 @@ const shareUrl = computed(() => resolveShareUrl({
|
|||
profileConfig: unref(profileConfig) || null,
|
||||
}))
|
||||
|
||||
/* ★ EMBED THIS PLUGIN, FROM THE PUBLIC SITE (shareable pages P3b). The Embed tab's three codes are for the plugin
|
||||
whose Share was pressed (its own page; its own bundle + element), even nested in Plugin Studio, Deep Research or
|
||||
Posterverse, and they point at the tree's PUBLIC address (resolvePublicBase: the build's shareBaseUrl), never a dev
|
||||
server or the page's own host. With no public address configured, the tree this bundle loaded from stands in. */
|
||||
const embedBase = computed(() => resolvePublicBase(unref(profileConfig) || null) || resolveEmbedBase(unref(profileConfig) || null))
|
||||
const embedPathOf = () => props.embedPath || (unref(profileConfig) || {}).shareEmbedPath || ''
|
||||
// URL: the plugin's own generated page, and nothing else: never the app page it is shown in. With no base (the dev
|
||||
// server, where modules are not bundled) or no tree-relative path, it is '' and the tab shows the plain note.
|
||||
const leafUrl = computed(() => publicLeafUrl(embedBase.value, embedPathOf()))
|
||||
const embedSnippet = computed(() => {
|
||||
const h = sizePresets[embedSize.value].height
|
||||
return '<iframe src="' + leafUrl.value + '" width="100%" height="' + h + '" style="border:none; border-radius:12px;" allowfullscreen></iframe>'
|
||||
return '<iframe src="' + shareUrl.value + '" width="100%" height="' + h + '" style="border:none; border-radius:12px;" allowfullscreen></iframe>'
|
||||
})
|
||||
// JavaScript: the plugin's embed.script from the public manifest, read once the Embed tab opens. "missing" means the
|
||||
// public site does not carry this plugin yet, so the tab shows a plain note instead of codes that would 404.
|
||||
function loadScriptCode() {
|
||||
if (scriptState.value.code || scriptState.value.loading) return
|
||||
scriptState.value = { loading: true }
|
||||
publicScriptCode(embedBase.value, embedPathOf()).then((r) => { scriptState.value = r })
|
||||
}
|
||||
const embedText = computed(() => embedKind.value === 'url' ? leafUrl.value
|
||||
: embedKind.value === 'iframe' ? embedSnippet.value : (scriptState.value.code || ''))
|
||||
const EMBED_KINDS = ['url', 'iframe', 'script']
|
||||
const kindLabel = (k) => ({ url: T('share.kind_url', 'URL'), iframe: T('share.kind_iframe', 'Iframe'), script: T('share.kind_script', 'JavaScript') })[k]
|
||||
// One plain sentence per code, for a reader who has never embedded anything.
|
||||
const kindHint = (k) => ({
|
||||
url: T('share.url_hint', 'A link to open the map on its own page.'),
|
||||
iframe: T('share.iframe_hint', 'Put the map in your page, in a frame.'),
|
||||
script: T('share.script_hint', 'Load the map straight into your page, with no frame.'),
|
||||
})[k]
|
||||
|
||||
/* ★ SHARE THIS PAGE. Where the page OWNS its address (useViewAddress: urlOwner 'app' on our own pages,
|
||||
'host' on DX Life), the address bar already names the exact view (the tab, and the filters), so the
|
||||
link is the page's own URL, read when the menu opens and again at copy time, since the reader can
|
||||
switch tabs in between. The owner is this map's own profile-config, or the nearest element above it,
|
||||
across shadow roots, whose profile-config names one: a map nested in the gallery is told nothing, and
|
||||
the gallery is the page. On a partner's page (no owner) the address is the partner's, so the link is
|
||||
this map's own page, as before. */
|
||||
const cfgOf = (c) => { if (typeof c !== 'string') return c || null; try { return JSON.parse(c) } catch { return null } }
|
||||
function pageOwner() {
|
||||
if (urlOwnerOf(cfgOf(unref(profileConfig)))) return true
|
||||
for (let n = rootEl.value; n; ) {
|
||||
n = n.parentNode || n.host
|
||||
if (n && n.nodeType === 1 && urlOwnerOf(cfgOf(n.profileConfig ?? n.getAttribute('profile-config')))) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
// A leaf page reads a token from ?tk= and leaves it in its address, so the page link drops it: a reader must
|
||||
// never send a credential by pressing Share. The URL is rewritten only when tk is there, so every other link is
|
||||
// the address exactly as it stands.
|
||||
function pageLink() {
|
||||
try {
|
||||
if (pageOwner() && window.location.href) {
|
||||
const u = new URL(window.location.href)
|
||||
if (u.searchParams.has('tk')) { u.searchParams.delete('tk'); return u.href }
|
||||
return window.location.href
|
||||
}
|
||||
} catch { /* no window */ }
|
||||
return shareUrl.value
|
||||
}
|
||||
|
||||
/* ---- actions ---- */
|
||||
function toggle() {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
activeTab.value = 'page'
|
||||
pageUrl.value = pageLink()
|
||||
activeTab.value = 'link'
|
||||
linkCopied.value = false
|
||||
embedCopied.value = false
|
||||
embedKind.value = 'url'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -494,15 +425,14 @@ async function copyText(text) {
|
|||
}
|
||||
|
||||
async function copyLink() {
|
||||
pageUrl.value = pageLink()
|
||||
if (await copyText(pageUrl.value)) {
|
||||
if (await copyText(shareUrl.value)) {
|
||||
linkCopied.value = true
|
||||
setTimeout(() => { linkCopied.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyEmbed() {
|
||||
if (embedText.value && await copyText(embedText.value)) {
|
||||
if (await copyText(embedSnippet.value)) {
|
||||
embedCopied.value = true
|
||||
setTimeout(() => { embedCopied.value = false }, 2000)
|
||||
}
|
||||
|
|
@ -547,24 +477,39 @@ async function copyEmbed() {
|
|||
const providedToolbar = inject('mapToolbar', null)
|
||||
const selfAnchor = ref(null)
|
||||
// Re-provide UNCONDITIONALLY so the value is stable for the child's inject(); `el` stays null when
|
||||
// a real toolbar was found, and MapControlMenu keeps using that toolbar instead. `standalone` tells
|
||||
// MapControlMenu this anchor is ONE button outside any map (a page header), placed beside the button
|
||||
// inside the viewport, at open time (MapControlMenu.placeBeside).
|
||||
provide('mapToolbar', providedToolbar || { el: selfAnchor, standalone: true })
|
||||
// a real toolbar was found, and MapControlMenu keeps using that toolbar instead.
|
||||
provide('mapToolbar', providedToolbar || { el: selfAnchor })
|
||||
const hasOwnAnchor = !providedToolbar
|
||||
|
||||
/*
|
||||
★ THE ANCHOR IS THE BUTTON, AND NOTHING IS DECIDED AT RENDER TIME (shareable pages P3). This used to tag
|
||||
`el.offsetParent || el.parentElement` as the menu's area DURING RENDER (a function ref, so it ran before
|
||||
MapControlMenu latched its area). Before layout offsetParent is null, so the tag fell on the button's
|
||||
immediate parent: in a header with a narrow, unpositioned wrapper (measured on the UPG-100 list page: .hdr-btns) the menu
|
||||
measured against that wrapper, clamped to its 200px floor, and was positioned against the header above
|
||||
it: far to the left, and narrow (reported 2026-10-03). Now the button only names itself;
|
||||
MapControlMenu resolves where the menu lives and where it goes when it OPENS, with layout known.
|
||||
★ THIS IS A FUNCTION REF, AND onMounted WOULD NOT HAVE WORKED — the hazard is worth naming,
|
||||
because the fix silently does NOTHING if it is written the obvious way.
|
||||
|
||||
MapControlMenu latches its area ONCE, in its own onMounted:
|
||||
host.value = toolbar?.el?.value?.closest(AREA) || null
|
||||
and place() bails forever after if that is null. MapControlMenu is a CHILD of this component, and
|
||||
Vue runs a child's mounted hook BEFORE its parent's. So an onMounted here would set the anchor
|
||||
one tick too late: the child would have already read `null`, latched `host = null`, and no later
|
||||
assignment would revive it — the menu would go on opening at its static position and the fix
|
||||
would look applied while changing nothing.
|
||||
|
||||
A function ref is invoked during RENDER, before any mounted hook on either component, so the
|
||||
anchor and the area tag are in place by the time the child looks for them.
|
||||
|
||||
`data-map-area` is what MapControlMenu.AREA finds via closest(). It is tagged on the OFFSET
|
||||
PARENT, not on .share-wrap: the wrapper is a 32px button, and an area that small would clamp the
|
||||
menu to its 200px minimum jammed against the edge. The offset parent is the top bar, which spans
|
||||
the row, so the menu has the full width to open leftward into.
|
||||
*/
|
||||
function bindAnchor(el) {
|
||||
rootEl.value = el || null
|
||||
selfAnchor.value = hasOwnAnchor && el ? el : null
|
||||
if (!hasOwnAnchor || !el) { if (!el) selfAnchor.value = null; return }
|
||||
const area = el.offsetParent || el.parentElement
|
||||
// Never re-tag a host that already declares itself an area — that host owns the decision.
|
||||
if (area && !area.closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]')) {
|
||||
area.setAttribute('data-map-area', 'share-fallback')
|
||||
}
|
||||
selfAnchor.value = el
|
||||
}
|
||||
|
||||
/* Placement, stacking, outside-click and Escape live in MapControlMenu (shared by every
|
||||
|
|
@ -572,8 +517,8 @@ function bindAnchor(el) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<!-- :ref is a FUNCTION, deliberately: the anchor must be set before MapControlMenu's mounted hook
|
||||
reads it (a child mounts before its parent). See bindAnchor() in <script>. -->
|
||||
<!-- :ref is a FUNCTION, deliberately — it must run during render, before MapControlMenu's
|
||||
mounted hook latches its area. See bindAnchor() in <script> for why onMounted is too late. -->
|
||||
<div :ref="bindAnchor" class="share-wrap" :class="{ 'share-wrap--rounded-square': shape === 'rounded-square' }" data-share-root>
|
||||
<MapControlButton
|
||||
ref="btnEl"
|
||||
|
|
@ -598,26 +543,29 @@ function bindAnchor(el) {
|
|||
<!-- =================== Menu (shared MapControlMenu owns placement + chrome) =================== -->
|
||||
<MapControlMenu :open="isOpen" :title="t('buttons.share')" :is-dark="isDark" @close="close">
|
||||
<div ref="popoverEl" class="share-pop" :class="{ light: !isDark }">
|
||||
<!-- Tabs: sharing the PAGE first (what a person pressing Share expects), Embed second -->
|
||||
<div class="share-tabs" role="tablist">
|
||||
<!-- The multiplication path — one line each, top of the menu -->
|
||||
<ul class="share-steps">
|
||||
<li v-for="(step, i) in tm('buttons.shareSteps')" :key="i">{{ rt(step) }}</li>
|
||||
</ul>
|
||||
<!-- Tabs -->
|
||||
<div class="share-tabs">
|
||||
<button
|
||||
class="share-tab" role="tab" data-share-tab="page"
|
||||
:class="{ active: activeTab === 'page' }" :aria-selected="String(activeTab === 'page')"
|
||||
@click="activeTab = 'page'"
|
||||
>{{ T('share.tab_page', 'Share this page') }}</button>
|
||||
class="share-tab"
|
||||
:class="{ active: activeTab === 'link' }"
|
||||
@click="activeTab = 'link'"
|
||||
>{{ T('share.embed_link', 'Embed link') }}</button>
|
||||
<button
|
||||
class="share-tab" role="tab" data-share-tab="embed"
|
||||
:class="{ active: activeTab === 'embed' }" :aria-selected="String(activeTab === 'embed')"
|
||||
@click="activeTab = 'embed'; loadScriptCode()"
|
||||
>{{ T('share.tab_embed', 'Embed') }}</button>
|
||||
class="share-tab"
|
||||
:class="{ active: activeTab === 'embed' }"
|
||||
@click="activeTab = 'embed'"
|
||||
>{{ T('share.add_to_website', 'Add to Your Website') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== Tab 1: Share this page ===== -->
|
||||
<div v-if="activeTab === 'page'" class="share-body">
|
||||
<div class="share-embed-desc">{{ T('share.page_hint', 'A link that opens this exact page for whoever you send it to.') }}</div>
|
||||
<!-- ===== Tab 1: Share Link ===== -->
|
||||
<div v-if="activeTab === 'link'" class="share-body">
|
||||
<!-- URL display -->
|
||||
<div class="share-url-box">
|
||||
<div class="share-url-text" data-share-url>{{ pageUrl }}</div>
|
||||
<div class="share-url-text">{{ shareUrl }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Copy Link primary CTA -->
|
||||
|
|
@ -638,72 +586,50 @@ function bindAnchor(el) {
|
|||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
{{ linkCopied ? T('share.copied', 'Copied!') : T('share.copy_link', 'Copy link') }}
|
||||
{{ linkCopied ? 'Copied!' : 'Copy Link' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== Tab 2: Embed (for another website): THIS plugin, from the public site ===== -->
|
||||
<!-- ===== Tab 2: Add to Your Website ===== -->
|
||||
<div v-if="activeTab === 'embed'" class="share-body">
|
||||
<!-- What embedding is and why it helps, in plain words, before any code -->
|
||||
<!-- Friendly description -->
|
||||
<div class="share-embed-desc">
|
||||
{{ T('share.embed_what', 'Embedding puts this live map inside your own website, so your visitors can explore it and pray without leaving your site. It stays up to date by itself.') }}
|
||||
{{ T('share.embed_paste_hint', 'Paste this code into your website builder (WordPress, Squarespace, Wix, or any site that accepts HTML widgets).') }}
|
||||
</div>
|
||||
<!-- The multiplication path, one line each -->
|
||||
<ul class="share-steps">
|
||||
<li v-for="(step, i) in tm('buttons.shareSteps')" :key="i">{{ rt(step) }}</li>
|
||||
</ul>
|
||||
<!-- Not on the public site yet (or no page of its own to point at): one plain note instead of codes that would
|
||||
404 or that would embed the app around it -->
|
||||
<div v-if="scriptState.missing || !leafUrl" class="share-embed-desc" data-embed-note>
|
||||
{{ T('share.not_public', "This map isn't on the public site yet. Its embed codes will appear here once it's released.") }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Three ways, one at a time: a link, a frame, or straight into the page -->
|
||||
<div class="share-size-pills" role="tablist">
|
||||
<button
|
||||
v-for="k in EMBED_KINDS" :key="k" type="button" role="tab"
|
||||
class="share-size-pill" :data-embed-kind="k"
|
||||
:class="{ active: embedKind === k }" :aria-selected="String(embedKind === k)"
|
||||
@click="embedKind = k"
|
||||
>{{ kindLabel(k) }}</button>
|
||||
</div>
|
||||
<div class="share-embed-desc">{{ kindHint(embedKind) }}</div>
|
||||
<!-- Iframe: its height -->
|
||||
<div v-if="embedKind === 'iframe'" class="share-size-pills">
|
||||
<button
|
||||
v-for="(preset, key) in sizePresets"
|
||||
:key="key"
|
||||
class="share-size-pill"
|
||||
:class="{ active: embedSize === key }"
|
||||
@click="embedSize = key"
|
||||
>{{ sizeLabel(key) }}</button>
|
||||
</div>
|
||||
<!-- JavaScript: read from the public site when this tab opens -->
|
||||
<div v-if="embedKind === 'script' && !scriptState.code" class="share-embed-desc">
|
||||
{{ scriptState.failed ? T('share.public_failed', "Couldn't reach the public site just now. Try again in a moment.") : T('share.script_loading', 'Reading the public site…') }}
|
||||
</div>
|
||||
<div v-else class="share-code-box" data-embed-code>{{ embedText }}</div>
|
||||
<!-- One Copy button -->
|
||||
|
||||
<!-- Size presets -->
|
||||
<div class="share-size-pills">
|
||||
<button
|
||||
v-if="embedText"
|
||||
class="share-secondary-btn"
|
||||
:class="{ copied: embedCopied }"
|
||||
@click="copyEmbed"
|
||||
>
|
||||
<svg v-if="!embedCopied" width="15" height="15" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
<svg v-else width="15" height="15" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
{{ embedCopied ? T('share.copied', 'Copied!') : (embedKind === 'url' ? T('share.copy_link', 'Copy link') : T('share.copy_code', 'Copy code')) }}
|
||||
</button>
|
||||
</template>
|
||||
v-for="(preset, key) in sizePresets"
|
||||
:key="key"
|
||||
class="share-size-pill"
|
||||
:class="{ active: embedSize === key }"
|
||||
@click="embedSize = key"
|
||||
>{{ preset.label }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Code box -->
|
||||
<div class="share-code-box">{{ embedSnippet }}</div>
|
||||
|
||||
<!-- Copy Code button -->
|
||||
<button
|
||||
class="share-secondary-btn"
|
||||
:class="{ copied: embedCopied }"
|
||||
@click="copyEmbed"
|
||||
>
|
||||
<svg v-if="!embedCopied" width="15" height="15" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
<svg v-else width="15" height="15" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
{{ embedCopied ? 'Copied!' : 'Copy Code' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</MapControlMenu>
|
||||
|
|
|
|||
|
|
@ -74,27 +74,20 @@ export function openPrintFrame(bodyHtml, { autoprint = false } = {}) {
|
|||
* may ignore, and a cancel that removes the document if the reader walks away. A caller without its own progress UI
|
||||
* passes none and gets the shared Print bar (showTapToPrint).
|
||||
*
|
||||
* ★ ON A PHONE, AFTERPRINT IS NOT THE END OF THE PRINT (2026-10-02). Android Chrome's window.print() does not block,
|
||||
* and afterprint fires once the PREVIEW is built; the file "Save as PDF" writes is rendered later, after the reader
|
||||
* taps Save, from the page as it is THEN. The document used to be dropped at afterprint, so the preview showed a
|
||||
* packet's 11 pages and the saved PDF was the bare host page (one letter page with the page's own title). The
|
||||
* document is now kept until the reader is BACK on the page (mountPrintRoot says how), and onRelease is called then.
|
||||
*
|
||||
* @returns Promise<'frame' | 'page' | 'needs-tap' | 'blocked'>
|
||||
*/
|
||||
export async function printDocument(html, { onNeedsTap = null, imageWaitMs = 20000, labels = null, onRelease = null } = {}) {
|
||||
export async function printDocument(html, { onNeedsTap = null, imageWaitMs = 20000, labels = null } = {}) {
|
||||
if (!isPhonePrint()) return openPrintFrame(html, { autoprint: true }) ? 'frame' : 'blocked'
|
||||
const job = mountPrintRoot(html, { onRelease })
|
||||
const job = mountPrintRoot(html)
|
||||
if (!job) return 'blocked'
|
||||
await job.imagesLoaded(imageWaitMs)
|
||||
const act = typeof navigator !== 'undefined' ? navigator.userActivation : null
|
||||
if (act && act.isActive === false) {
|
||||
const printNow = () => { if (job.live()) { job.printing(); window.print() } }
|
||||
const printNow = () => { if (job.live()) window.print() }
|
||||
if (typeof onNeedsTap === 'function') onNeedsTap(printNow, job.drop)
|
||||
else showTapToPrint(printNow, job.drop, labels || {})
|
||||
return 'needs-tap'
|
||||
}
|
||||
job.printing()
|
||||
window.print()
|
||||
return 'page'
|
||||
}
|
||||
|
|
@ -147,7 +140,7 @@ function showTapToPrint(printNow, cancel, labels = {}) {
|
|||
export const PRINT_ROOT_ID = 'dxm-print-root'
|
||||
let currentJob = null
|
||||
|
||||
function mountPrintRoot(html, { onRelease = null } = {}) {
|
||||
function mountPrintRoot(html) {
|
||||
if (typeof document === 'undefined' || !document.body || typeof DOMParser === 'undefined') return null
|
||||
if (currentJob) currentJob.drop()
|
||||
// EVERY BUNDLE CARRIES ITS OWN COPY OF THIS FILE, so currentJob only knows this bundle's root: a root (and its
|
||||
|
|
@ -176,35 +169,11 @@ function mountPrintRoot(html, { onRelease = null } = {}) {
|
|||
document.head.appendChild(style)
|
||||
document.body.appendChild(root)
|
||||
|
||||
// ★ KEPT UNTIL THE READER IS BACK, never dropped at afterprint (printDocument says why). From the moment print() is
|
||||
// called (printing(), not at mount: the Print tap that starts a late print must not count), the reader is back when
|
||||
// the page is shown again after being hidden, the window is focused again after losing focus, or the page is
|
||||
// touched (the system print UI covers the page, so a touch there means it has gone). A touch in the first
|
||||
// TOUCH_SETTLE_MS after print() does not count (a double tap while the preview builds). It goes BACK_GRACE_MS after
|
||||
// that, since a slow phone can still be writing the file after its dialog closes; if the page goes AWAY again during
|
||||
// the grace (the print UI opened after an early touch), the pending drop is cancelled and the next "back" counts. A
|
||||
// new print replaces it, a cancelled one drops it, and FRAME_CEILING_MS is the ceiling. On screen it is display:none,
|
||||
// so keeping it shows nothing.
|
||||
let ceiling = null, settle = null, armed = false, away = false, leaving = false, settling = false
|
||||
const back = () => { if (leaving) return; leaving = true; clearTimeout(ceiling); ceiling = setTimeout(drop, BACK_GRACE_MS) }
|
||||
const awayAgain = () => { away = true; if (leaving) { leaving = false; clearTimeout(ceiling); ceiling = setTimeout(drop, FRAME_CEILING_MS) } }
|
||||
const onVisibility = () => { if (document.visibilityState === 'hidden') awayAgain(); else if (away) back() }
|
||||
const onBlur = () => { awayAgain() }
|
||||
const onFocus = () => { if (away) back() }
|
||||
const onTouch = () => { if (!settling) back() }
|
||||
let ceiling = null
|
||||
const onAfter = () => setTimeout(drop, 0)
|
||||
const job = {
|
||||
live: () => currentJob === job,
|
||||
drop,
|
||||
printing: () => {
|
||||
if (armed) return
|
||||
armed = true
|
||||
settling = true
|
||||
settle = setTimeout(() => { settling = false }, TOUCH_SETTLE_MS)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
window.addEventListener('blur', onBlur)
|
||||
window.addEventListener('focus', onFocus)
|
||||
window.addEventListener('pointerdown', onTouch, true)
|
||||
},
|
||||
imagesLoaded: (ms) => {
|
||||
const pending = [...root.querySelectorAll('img')].filter((img) => !img.complete)
|
||||
if (!pending.length) return Promise.resolve()
|
||||
|
|
@ -217,16 +186,12 @@ function mountPrintRoot(html, { onRelease = null } = {}) {
|
|||
}
|
||||
function drop() {
|
||||
clearTimeout(ceiling)
|
||||
clearTimeout(settle)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
window.removeEventListener('focus', onFocus)
|
||||
window.removeEventListener('pointerdown', onTouch, true)
|
||||
window.removeEventListener('afterprint', onAfter)
|
||||
root.remove()
|
||||
style.remove()
|
||||
if (currentJob === job) currentJob = null
|
||||
if (onRelease) { const done = onRelease; onRelease = null; try { done() } catch { /* the caller's own clean-up */ } }
|
||||
}
|
||||
window.addEventListener('afterprint', onAfter)
|
||||
ceiling = setTimeout(drop, FRAME_CEILING_MS)
|
||||
currentJob = job
|
||||
return job
|
||||
|
|
@ -236,10 +201,6 @@ function mountPrintRoot(html, { onRelease = null } = {}) {
|
|||
export const FRAME_KEEP_MS = 60000
|
||||
/** The absolute ceiling for a print frame whose load never ends. */
|
||||
export const FRAME_CEILING_MS = 600000
|
||||
/** A phone's in-page print document goes this long after the reader is back on the page (mountPrintRoot). */
|
||||
export const BACK_GRACE_MS = 60000
|
||||
/** A touch this soon after print() is not the reader coming back (a double tap while the preview builds). */
|
||||
export const TOUCH_SETTLE_MS = 2000
|
||||
|
||||
/**
|
||||
* Print a rendered poster IMAGE, at its real paper size, through the same in-place frame.
|
||||
|
|
@ -273,11 +234,14 @@ export function printImageBlob(blob, { widthIn = 8.5, heightIn = 11, title = '',
|
|||
+ 'html,body{margin:0;padding:0;background:#fff}'
|
||||
+ 'img{display:block;width:100%;height:100%;object-fit:contain}</style></head>'
|
||||
+ `<body><img src="${url}" alt="${esc(alt || title)}"></body></html>`
|
||||
// A PHONE PRINTS THE PAGE (printDocument). The object URL lives as long as the in-page document does: until the
|
||||
// reader is back on the page, a new print or the ceiling, NEVER afterprint (a saved PDF is rendered after it).
|
||||
// A PHONE PRINTS THE PAGE (printDocument). The object URL lives until the page has printed, or the ceiling.
|
||||
if (isPhonePrint()) {
|
||||
const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }
|
||||
printDocument(html, { onNeedsTap, labels, onRelease: release }).then((how) => { if (how === 'blocked') release() }, release)
|
||||
let clock = null
|
||||
const onAfter = () => setTimeout(release, 0)
|
||||
const release = () => { clearTimeout(clock); window.removeEventListener('afterprint', onAfter); try { URL.revokeObjectURL(url) } catch { /* gone */ } }
|
||||
window.addEventListener('afterprint', onAfter)
|
||||
clock = setTimeout(release, FRAME_CEILING_MS)
|
||||
printDocument(html, { onNeedsTap, labels }).then((how) => { if (how === 'blocked') release() }, release)
|
||||
return true
|
||||
}
|
||||
const win = openPrintFrame(html, { autoprint: true })
|
||||
|
|
|
|||