Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work

# Conflicts:
#	internal/check-gospel-rtl-shaping.mjs
#	public/dx-maps/assets/tables/country-languages.json
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/gospel-resources/charts/index.html
#	public/dx-maps/gospel-resources/data-tables/index.html
#	public/dx-maps/gospel-resources/gospel-dashboard/index.html
#	public/dx-maps/gospel-resources/gospel-resources.js
#	public/dx-maps/gospel-resources/resources-list/index.html
#	public/dx-maps/gospel-resources/resources-map/index.html
#	public/dx-maps/manifest.json
This commit is contained in:
doxa-maps-88655c70 2026-09-25 21:38:20 +00:00
commit d38488d799
645 changed files with 7875 additions and 6839 deletions

5
.main-capture/prepush Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Repo precondition for a landing, run by `main-capture scan` (and the per-worktree pre-push hook) on the
# exact commit about to reach main. Non-zero = the landing is refused. See internal/landing-preconditions.mjs.
cd "$(git rev-parse --show-toplevel)" || exit 1
exec node internal/landing-preconditions.mjs

248
README.md
View file

@ -8,15 +8,15 @@ Made by Vibe Coders for Vibe Coders to prepare the way of the Lord and make His
**Write a map once in `plugin-designs/<your-map>/`, run one build, and drop it on any site** —
iframe, script embed, mobile app — or open every map at once as one app. Five plugin
capabilities, five deployment methods, one shared library, one build.
capabilities, three outputs, one shared library, one build.
| What dx-maps set out to do | Proof |
|---|---|
| **One build → one folder** you can drop on any host | `bun run check:drop` |
| **Every map embeddable** from a copy-paste snippet | [deployment methods](#the-5-deployment-methods) · `bun run check:embed-targets` |
| **Every map embeddable** from a copy-paste snippet | [the 3 outputs](#what-the-build-makes--3-outputs) · `bun run check:embed-targets` |
| **Every map interoperable** through props and events | `bun run verify:interop` |
| **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 × 5 deployment methods** | `bun run verify:5-caps` |
| **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, one shepherd, one working `main`** | [agent swarm](#build-it-with-an-agent-swarm) |
@ -88,7 +88,7 @@ and graduates to a named tab when it's ready.
<sub>Also on the shelf: UPG neighbor, all people groups, country posters, the vision card, and the PPLR data maps.</sub>
<details>
<summary><b>How one build becomes five ways to ship — the "map cruncher"</b></summary>
<summary><b>How one build becomes three outputs — the "map cruncher"</b></summary>
```text
map · map · map · map · map · map · map many maps, each once its own codebase
@ -99,8 +99,12 @@ and graduates to a named tab when it's ready.
|
ONE build step
|
public/doxa-maps/ one drop-anywhere folder + manifest.json
/ | \ \ iframe mobile JS embed WordPress desktop
public/dx-maps/ OUTPUT 1: one drop-anywhere web folder
/ | \ how a page shows a map:
iframe JS embed WordPress (WordPress just for fun)
the same folder, wrapped:
mobile app (OUTPUT 2) · desktop app (OUTPUT 3)
```
</details>
@ -259,43 +263,134 @@ one custom HTML tag, like `<all-people-groups>`, that works on any web page.
---
## The 5 deployment methods
## What the build makes — 3 outputs
### 1. iframe — the prayer map on any site ✅ production
**One build step makes one folder. That folder is the product** — every other choice is *where you
put it* and *how a page shows it*.
**Paste one line of HTML and the map appears** — on any website, no coding.
| # | Output | What it is | Status |
|---|---|---|---|
| **1** | **The web folder** `public/dx-maps/` | every map, each embeddable on its own, all working together — 536 files, 76 MiB (about 80 MB) | ✅ production |
| **2** | **Mobile app** | the same folder, wrapped as an Android app by Capacitor | ✅ built |
| **3** | **Desktop app** | the same folder, wrapped as a desktop app by Tauri | 🧪 proven, not yet in production |
![Method 1 — the prayer map embedded in a host page's iframe](documentation/readme-images/deploy1-iframe.png)
### Output 1 — the web folder
Two questions: **where does the folder go** (hosting), and **how does a page show a map** (embedding).
#### Where the folder goes
| Put the folder… | How | iframe | JS embed | Good to know |
|---|---|:-:|:-:|---|
| **in dx-life's `public/` folder** | copy one folder; dx-life becomes the host | ✅ | ✅ | this is how the Research Gallery runs on the dx-life research page (below) |
| **on Cloudflare Pages** | drag and drop the folder in the dashboard | ✅ | ✅ | fits the drag-and-drop limits (1,000 files, 25 MiB per file); add the `_headers` file below for JS embeds on other sites |
| **on GitHub, served by jsDelivr** | push the repo to a public GitHub repo | — | ✅ | a free CDN for scripts; it serves HTML as plain text, so iframes won't render — use it for JS embeds only |
| **on any web server** | copy the folder | ✅ | ✅ | send `Access-Control-Allow-Origin: *` for JS embeds on other sites |
**Live example:** the dx-life branch [`research-gallery`](https://git.nuxtbase.app/doxa-life/doxa-maps-cdn/src/branch/research-gallery) drops this folder into dx-life's `public/` and fills `/research` with the Research Gallery. Run it with `bun install && bun run dev`, then open `/research/`.
<details>
<summary><b>More about method 1 — iframe</b></summary>
<summary><b>More about hosting the folder</b></summary>
An iframe is a window in your page that shows another page — here, the map's own page. It's the
easiest way to share a map: it works on any site that accepts HTML, and the map keeps its own look.
Every plugin's exact snippet is `embed.iframe` in `public/doxa-maps/manifest.json`:
- **The folder works from any address.** Every link inside it is relative, so you can put it at
`https://your-site/dx-maps/` or at a CDN root and nothing breaks (`bun run check:drop` proves it).
- **iframes need nothing special.** A map's page loads inside the frame from wherever the folder is.
- **JS embeds on another site need one header.** The embed loads scripts and `manifest.json` across
sites with a security hash, so the host must allow it. On Cloudflare Pages (or Netlify), put a file
named `_headers` in the folder you upload:
```html
<iframe src="https://YOUR_HOST/doxa-maps/doxa-simple-map/doxa-simple-map/prayer/index.html?utm_source=YOUR_SOURCE"
width="100%" height="600" style="border:none;border-radius:12px;" allowfullscreen></iframe>
```
```text
/*
Access-Control-Allow-Origin: *
```
Host the built `public/doxa-maps/` folder anywhere (it works from any address), then point the
iframe at it.
- **jsDelivr** serves any public GitHub repo as a CDN, e.g.
`https://cdn.jsdelivr.net/gh/<owner>/<repo>@<tag>/public/dx-maps/<plugin>/<plugin>.js`. It needs a
**GitHub** repo (this project's public copy is on its own git host), files under 20 MB (the largest
here is 7.5 MB), and it returns HTML as plain text — hence scripts only.
- **Netlify Drop** (drag and drop at app.netlify.com/drop) works best under 50 MB; at about 80 MB, use the
Netlify CLI instead.
</details>
### 2. Mobile app — UPG-100 on a phone ✅ built
#### How a page shows a map
**The same maps, wrapped as a phone app.**
##### iframe — the prayer map on any site ✅ production
<p align="center"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="Method 2 — the UPG-100 list at phone size" width="320"></p>
**Paste one line of HTML and the map appears** — on any website, no coding.
![iframe — the prayer map embedded in a host page](documentation/readme-images/deploy1-iframe.png)
<details>
<summary><b>More about method 2 — mobile app</b></summary>
<summary><b>More about the iframe</b></summary>
[Capacitor](https://capacitorjs.com) wraps the built maps into an Android app — the same files,
no new code. Shown here: the UPG-100 prayer lists and posters at phone size, each poster with its
own print options.
An iframe is a window in your page that shows another page — here, the map's own page. It's the
easiest way to share a map: it works on any site that accepts HTML, and the map keeps its own look.
Every plugin's exact snippet is `embed.iframe` in `public/dx-maps/manifest.json`:
```html
<iframe src="https://YOUR_HOST/dx-maps/doxa-simple-map/doxa-simple-map/prayer/index.html?utm_source=YOUR_SOURCE"
width="100%" height="600" style="border:none;border-radius:12px;" allowfullscreen></iframe>
```
</details>
##### JavaScript embed — the Research Gallery on dx-life ✅ production
**Two lines of code put a whole app inside your own page** — here, the Research Gallery on the
dx-life research page.
![JavaScript embed — the Research Gallery on the dx-life research page](documentation/readme-images/deploy3-js-embed-dxlife.png)
<details>
<summary><b>More about the JavaScript embed</b></summary>
Instead of a window onto another page, the map becomes **part of your page**, so your page can talk
to it. On dx-life, one embed of the Research Gallery replaces the separate research map, list and
regions dashboards. The exact snippet is `embed.script` in `manifest.json`; copy its `integrity`
hash from there, because it changes with every build:
```html
<script>window.MAP_APP_API_URL="https://pray.doxa.life";</script>
<script src="https://YOUR_HOST/dx-maps/doxa-maps-spa/doxa-maps-spa.js"
integrity="sha384-…" crossorigin="anonymous"></script>
<doxa-maps-spa profile-config='{"profile":"doxa-maps-spa","tk":"YOUR_MAPBOX_TOKEN","utmSource":"YOUR_SOURCE","assetsBase":"https://YOUR_HOST/dx-maps/assets"}'></doxa-maps-spa>
```
</details>
##### WordPress / Disciple Tools — 🧪 proven, just for fun
**We got a JAMstack map running inside WordPress and Disciple Tools.**
![WordPress / Disciple Tools — proven, not yet in production](documentation/readme-images/deploy4-wordpress.png)
<details>
<summary><b>More about WordPress / Disciple Tools</b></summary>
It works, but it isn't yet a production feature of the map bundler. Two ways to try it:
- **Easiest:** paste the iframe or JavaScript snippet into a WordPress **Custom HTML** block.
- **As a plugin:** install `deployment/wordpress/doxa-maps-embed/` in `wp-content/plugins/`. It adds
a `[doxa_map]` shortcode and a block for the block editor.
`bun run check:wp-embed` confirms the plugin writes the right HTML, without needing WordPress
installed.
</details>
### Output 2 — the mobile app ✅ built
**The same maps, wrapped as a phone app.** No new code — the maps are JAMstack: they fetch their data
from a remote API, which is exactly what a mobile app needs.
<p align="center"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="Mobile app — the UPG-100 list at phone size" width="320"></p>
<details>
<summary><b>More about the mobile app</b></summary>
[Capacitor](https://capacitorjs.com) wraps the built folder into an Android app. Shown here: the
UPG-100 prayer lists and posters at phone size, each poster with its own print options.
```bash
bun run check:mobile # checks the app is wired up correctly — needs no Android tools
@ -306,59 +401,16 @@ bun run phone # builds the app and installs it on a phone or emulator
</details>
### 3. JavaScript embed — the Research Gallery on dx-life ✅ production
**Two lines of code put a whole app inside your own page** — here, the Research Gallery on dx-life.
![Method 3 — the Research Gallery JS-embedded on the dx-life research page](documentation/readme-images/deploy3-js-embed-dxlife.png)
<details>
<summary><b>More about method 3 — JavaScript embed</b></summary>
Instead of a window onto another page, the map becomes **part of your page**, so your page can talk
to it. The dx-life research page replaced its separate research map, list and regions dashboards
with this one embed of the Research Gallery. The exact snippet is `embed.script` in `manifest.json`;
copy its `integrity` hash from there, because it changes with every build:
```html
<script>window.MAP_APP_API_URL="https://pray.doxa.life";</script>
<script src="https://YOUR_HOST/doxa-maps/doxa-maps-spa/doxa-maps-spa.js"
integrity="sha384-…" crossorigin="anonymous"></script>
<doxa-maps-spa profile-config='{"profile":"doxa-maps-spa","tk":"YOUR_MAPBOX_TOKEN","utmSource":"YOUR_SOURCE","assetsBase":"https://YOUR_HOST/doxa-maps/assets"}'></doxa-maps-spa>
```
</details>
### 4. WordPress / Disciple Tools — 🧪 proven, not yet in production
**We got a JAMstack map running inside WordPress and Disciple Tools.**
![Method 4 — WordPress / Disciple Tools, proven but not yet in production](documentation/readme-images/deploy4-wordpress.png)
<details>
<summary><b>More about method 4 — WordPress / Disciple Tools</b></summary>
It works, but it isn't yet a production feature of the map bundler. Two ways to try it:
- **Easiest:** paste a method 1 or method 3 snippet into a WordPress **Custom HTML** block.
- **As a plugin:** install `deployment/wordpress/doxa-maps-embed/` in `wp-content/plugins/`. It adds
a `[doxa_map]` shortcode and a block for the block editor.
`bun run check:wp-embed` confirms the plugin writes the right HTML, without needing WordPress
installed.
</details>
### 5. Desktop app (Tauri) — 🧪 proven, not yet in production
### Output 3 — the desktop app 🧪 proven, not yet in production
**We got the JAMstack map app running as a desktop app.**
![Method 5 — desktop app (Tauri), proven but not yet in production](documentation/readme-images/deploy5-desktop.png)
![Desktop app (Tauri) — proven, not yet in production](documentation/readme-images/deploy5-desktop.png)
<details>
<summary><b>More about method 5 — desktop app</b></summary>
<summary><b>More about the desktop app</b></summary>
[Tauri](https://tauri.app) wraps the same built maps into a desktop program: it produced a Linux
[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.
@ -373,6 +425,48 @@ bun run tauri:build # builds the app — needs Rust, the Tauri CLI and webvie
---
## Languages
**Every map speaks 11 languages** — add `?lang=<code>` to a map's page address, or, when a map is embedded in
your own page, set `"locale"` in its `profile-config`. Arabic reads right to left.
![UPG-100 List in Arabic, right to left](documentation/readme-images/lang-arabic-upg100.png)
<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 |
|---|---|---|---|---|
| `en` | English | | `hi` | हिन्दी — Hindi |
| `ar` | العربية — Arabic (right to left) | | `it` | Italiano — Italian |
| `de` | Deutsch — German | | `pt` | Português — Portuguese |
| `es` | Español — Spanish | | `ro` | Română — Romanian |
| `fr` | Français — French | | `ru` | Русский — Russian |
| `zh` | 中文 — Chinese | | | |
<details>
<summary><b>More about languages</b></summary>
- **Pick a language with the address:** `…/prayer/index.html?lang=fr` shows the map in French. It
works on every map's own page and on the Research Gallery. `?locale=` works too.
- **Set it from your own page:** when you embed the Research Gallery or a single map with the
JavaScript embed, put the language in its `profile-config` (`"lang"` works too). Set it again
later and the map switches language live.
```html
<doxa-maps-spa profile-config='{"locale":"ar"}'></doxa-maps-spa>
```
- **Without either,** a map follows the page's own language (`<html lang>`, which WordPress sets), and
otherwise shows English.
- **Right to left:** in Arabic the map's layout mirrors — the Research Gallery's tab rail moves to
the right. Set through `profile-config`, only the map's own box mirrors; the rest of your page is
left alone. A `?lang=ar` address mirrors the whole page, which is what a map's own page wants.
- **Where the words live:** `library/i18n/locales/<code>/`, one folder per language. Adding a
language: [documentation/architecture/i18n.md](./documentation/architecture/i18n.md).
</details>
---
## Build it with an agent swarm
**Many AI agents can build many plugins at once without breaking each other's work.** Every plugin
@ -504,8 +598,8 @@ The studio is a plugin like any other: `plugin-designs/plugin-studio/plugin-stud
|---|---|
| `bun run dev` | The Plugin Studio, updating as you save, at http://localhost:5173 |
| `BUNDLE=<name> bun x vite build` | Build one plugin only |
| `BUILD_ALLOW=1 bun run build` | Build everything into `public/doxa-maps/` (~4 min; refuses without the flag, to protect a shared project) |
| `bun run serve` | Serve the built maps at http://localhost:4173 |
| `BUILD_ALLOW=1 bun run build` | Build everything into `public/dx-maps/` (~4 min; refuses without the flag, to protect a shared project) |
| `bun run serve` | Serve the built output folder — the Plugin Studio is http://localhost:4173/dx-maps/index.html |
| `bun run gates:pre` · `bun run gates:post` | The checks that keep `main` working |
| `bun run shots` | Retake these screenshots (`ONLY=<name>` for one) |
@ -533,7 +627,7 @@ doxa-maps/
│ ├── colors/ styles/ colour strategies and the house style
│ ├── i18n/locales/ the 11 languages
│ └── data/ api/ lookup tables and data sources
├── public/doxa-maps/ the BUILD OUTPUT — the drop-anywhere folder + manifest.json
├── public/dx-maps/ the BUILD OUTPUT — the drop-anywhere folder + manifest.json
├── deployment/ mobile (Capacitor) · WordPress · desktop (Tauri)
├── internal/ the build and its checks
└── documentation/ tutorial · how-to · reference · architecture

View file

@ -15,7 +15,7 @@
"\u26a0\ufe0f SOFT FAILURE: a wrong webDir yields an EMPTY assets/public and a BLANK WHITE SCREEN at launch, not an",
"error. After every sync, verify deployment/capacitor/android/app/src/main/assets/public/index.html exists (`bun run cap:verify`)."
],
"webDir": "public/doxa-maps",
"webDir": "public/dx-maps",
"server": {
"//": [
"Capacitor defaults are deliberately kept: iosScheme 'capacitor', androidScheme 'https', hostname",

View file

@ -4,7 +4,7 @@
"version": "0.1.0",
"identifier": "life.doxa.maps",
"build": {
"frontendDist": "../../public/doxa-maps",
"frontendDist": "../../public/dx-maps",
"beforeBuildCommand": ""
},
"app": {

View file

@ -52,7 +52,7 @@ $out = do_shortcode_call('doxa_map', [
'bundle' => $target['bundle'],
'element' => $prof['element'],
'profile' => $prof['id'] ?? $prof['name'] ?? $target['bundle'],
'base' => 'https://nuxtbase.app/plugin-assets/doxa-maps',
'base' => 'https://nuxtbase.app/plugin-assets/'.$tree,
'integrity' => $integrity,
]);
global $SCRIPTS, $INLINE;
@ -76,7 +76,7 @@ $cfg = json_decode($json, true);
$assert('profile-config is valid JSON', is_array($cfg), $json ? substr($json,0,60) : 'no match');
// 3. the script src equals the manifest's catalog path for this bundle
$src = reset($SCRIPTS)['src'] ?? '';
$want = "https://nuxtbase.app/plugin-assets/doxa-maps/{$target['bundle']}/{$target['bundle']}.js";
$want = "https://nuxtbase.app/plugin-assets/{$tree}/{$target['bundle']}/{$target['bundle']}.js";
$assert('script src matches catalog path', $src === $want, $src);
// 4. SRI: the filter must add integrity to the emitted <script> tag
$handle = array_key_first($SCRIPTS);
@ -86,7 +86,7 @@ $assert('crossorigin anonymous present', str_contains($tag, 'crossorigin="anonym
// 5. a BAD integrity must be REJECTED (negative test — the format regex must actually fire)
$SCRIPTS = [];
$out2 = do_shortcode_call('doxa_map', ['bundle'=>$target['bundle'],'element'=>$prof['element'],
'profile'=>$prof['id']??$target['bundle'],'base'=>'https://nuxtbase.app/plugin-assets/doxa-maps',
'profile'=>$prof['id']??$target['bundle'],'base'=>'https://nuxtbase.app/plugin-assets/'.$tree,
'integrity'=>'not-a-valid-hash!!']);
$h2 = array_key_first($SCRIPTS);
$tag2 = apply_filters('script_loader_tag', "<script src='x' id='$h2-js'></script>", $h2, 'x');

View file

@ -33,7 +33,7 @@ Full walkthrough + the migration story:
| Command | What | Writes to `public/`? |
|---|---|---|
| `bun run dev` | HMR plugin gallery at `localhost:5173` — iterate on a map in isolation | **No** |
| `bun run build` | every bundle → `public/doxa-maps/<bundle>/<bundle>.js` + generated pages | **Yes** |
| `bun run build` | every bundle → `public/dx-maps/<bundle>/<bundle>.js` + generated pages | **Yes** |
To see a change on the served site: `bun run build` (or `BUNDLE=<name> bun run build:bundle`),
then refresh. `dev` never updates the served tree.

View file

@ -47,9 +47,9 @@ The scripts these commands are grounded in (from this branch's `package.json`):
// claiming to be ground truth, and this one had silently stopped being it.
"dev": "bun x vite",
"build": "bash internal/build-lock.sh",
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed",
"build:unlocked":"bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post",
"gates:pre":"node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
"gates:post":"node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed",
"build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time
"preview": "bun x vite preview",
"serve": "node internal/serve-strict.mjs", // 404s a directory, like production
@ -60,7 +60,7 @@ The scripts these commands are grounded in (from this branch's `package.json`):
"phone": "bun run apk:debug && bun run apk:install"
```
`BUILD_ALLOW=1 bun run build` writes the whole deployable tree to `public/doxa-maps/`, including
`BUILD_ALLOW=1 bun run build` writes the whole deployable tree to `public/dx-maps/`, including
`manifest.json`: the machine-readable index that carries a ready-to-paste **iframe** snippet and a
**`<script src>` + SRI** snippet for every plugin.
@ -68,7 +68,7 @@ The scripts these commands are grounded in (from this branch's `package.json`):
| # | Deployment method | Install first | Command(s) | Status |
|---|---|---|---|---|
| 1 | **iframe** | Bun | `bun install` → `BUILD_ALLOW=1 bun run build` → drop `public/doxa-maps/` on any host | ✅ production |
| 1 | **iframe** | Bun | `bun install` → `BUILD_ALLOW=1 bun run build` → drop `public/dx-maps/` on any host | ✅ production |
| 2 | **Mobile app (Android)** | Bun for the check; + Android SDK · `adb` · JDK 17 for the APK | `bun run check:mobile` (no toolchain) · `bun run phone` | ✅ built |
| 3 | **JavaScript embed** | Bun | `BUILD_ALLOW=1 bun run build`, then paste the `embed.script` snippet from `manifest.json` | ✅ production |
| 4 | **WordPress / Disciple Tools** | Bun (+ `php` for the check) | paste a snippet into a Custom HTML block, or install `deployment/wordpress/doxa-maps-embed/` · `bun run check:wp-embed` | 🧪 proof of concept |
@ -82,7 +82,7 @@ The scripts these commands are grounded in (from this branch's `package.json`):
```bash
bun install
BUILD_ALLOW=1 bun run build # builds every plugin-designs/* bundle -> public/doxa-maps/
BUILD_ALLOW=1 bun run build # builds every plugin-designs/* bundle -> public/dx-maps/
bun run serve # optional: serves public/ at http://localhost:4173
```
@ -94,7 +94,7 @@ Each profile in `manifest.json` carries an `embed.iframe` snippet:
style="border:none;border-radius:12px;" allowfullscreen></iframe>
```
**The folder is position-independent.** Snippet URLs are relative to the tree root, so `doxa-maps/`
**The folder is position-independent.** Snippet URLs are relative to the tree root, so `dx-maps/`
works dropped on any static host. For absolute URLs, set `embedBaseUrl` in the build config.
### 2. Mobile app — Android / Capacitor

View file

@ -54,12 +54,12 @@ The build is for publishing, not for looking.
## Step 4 — Build the deployable tree
```sh
BUILD_ALLOW=1 bun run build # discovers plugin-designs/*, bundles each, writes public/doxa-maps/<plugin>/<plugin>.js
BUILD_ALLOW=1 bun run build # discovers plugin-designs/*, bundles each, writes public/dx-maps/<plugin>/<plugin>.js
```
**Exit 0 + every guard PASS = a good build.** Your plugin now exists under `public/doxa-maps/<your-plugin>/`
**Exit 0 + every guard PASS = a good build.** Your plugin now exists under `public/dx-maps/<your-plugin>/`
with a standalone leaf page and a `manifest.json` entry. `BUILD_ALLOW=1` is deliberate: a full build first
wipes `public/doxa-maps/`, so it refuses (exit 75) in a shared checkout unless you mean it. To compile only
wipes `public/dx-maps/`, so it refuses (exit 75) in a shared checkout unless you mean it. To compile only
your plugin: `BUNDLE=<your-plugin> bun x vite build`. Recipes: [how-to.md](how-to.md).
## Step 5 — Prove it mounts in a real browser
@ -79,7 +79,7 @@ build config, as above ([why](#notes-and-history)).
## Step 6 — Publish and see it live
**Copy the built tree to any static host, then embed from it.** The tree is `public/doxa-maps/`; every
**Copy the built tree to any static host, then embed from it.** The tree is `public/dx-maps/`; every
plugin's ready-made snippets are in its `manifest.json`.
```sh
@ -87,7 +87,7 @@ set -e # first line: a failing check must stop
bun run check:settled # never publish a tree a build is rewriting
bun run check:sri # every leaf's integrity hash matches its bundle
bun run check:embed-targets # every embed snippet points at a real file
rsync -a --delete public/doxa-maps/ <your-host-checkout>/doxa-maps/
rsync -a --delete public/dx-maps/ <your-host-checkout>/dx-maps/
# commit and push the host checkout; for a large tree: git -c http.postBuffer=524288000 push …
```

View file

@ -30,7 +30,7 @@ element with its own `profile-config`.
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- The bundle (relative path = host serves it; absolute https = external CDN) -->
<script src="/doxa-maps/my-map/my-map.js"></script>
<script src="/dx-maps/my-map/my-map.js"></script>
<!-- The map. `profile` = your .vue filename. `tk` = the Mapbox token. -->
<my-map profile-config='{"profile":"my-map","tk":"pk.eyJ..."}'></my-map>
@ -48,18 +48,18 @@ You don't have to use the script tag. **Every profile also builds its own standa
a bare, full-screen map at:
```
https://<your-cdn>/doxa-maps/<bundle>/<profile>/index.html
https://<your-cdn>/dx-maps/<bundle>/<profile>/index.html
```
Link it, share it, or iframe it (perfect for CMSs and tools that only accept iframes, like
Start.me or Notion):
```html
<iframe src="https://<your-cdn>/doxa-maps/doxa-research-map/research-map/index.html"
<iframe src="https://<your-cdn>/dx-maps/doxa-research-map/research-map/index.html"
width="100%" height="600" style="border:none;border-radius:12px;" allowfullscreen></iframe>
```
You never write these snippets by hand — `public/doxa-maps/manifest.json` lists a ready-made
You never write these snippets by hand — `public/dx-maps/manifest.json` lists a ready-made
`embed.iframe` and `embed.script` snippet for every profile, and the staging pages show them
copy-paste ready. Design details: [architecture/embeddability.md](./architecture/embeddability.md).
@ -94,7 +94,7 @@ with `_` or `.` — those are skipped by the build.
Import each with the `@map/…` alias; do not copy their code into your folder.
6. **Build and verify.**
```bash
BUNDLE=my-new-map bun run build:bundle # builds just yours → public/doxa-maps/my-new-map/my-new-map.js
BUNDLE=my-new-map bun run build:bundle # builds just yours → public/dx-maps/my-new-map/my-new-map.js
bun run verify # data guards (fast, no browser)
```
A green build is not a working map — the build proves the bundle *compiles*; the guards
@ -126,7 +126,7 @@ One parameterized profile can emit **many standalone embed pages** — no extra
}
```
`bun run build` then emits a page **per instance** at `doxa-maps/<bundle>/<id>/index.html` —
`bun run build` then emits a page **per instance** at `dx-maps/<bundle>/<id>/index.html` —
each directly linkable/iframe-able with its preset props baked into the page (the base profile
still accepts live props). Instances *split* one profile into many render targets; `tabs`
*nest* many views into one element — different tools.
@ -219,7 +219,7 @@ Pick one home and **every partner site embeds the same map from it** — change
updates everywhere.
**How it's wired** (one file: `vite.doxa-maps-build-config.json`; full field table in
**[reference.md](./reference.md)**): `bun run build` always writes to `public/doxa-maps/<bundle>/`.
**[reference.md](./reference.md)**): `bun run build` always writes to `public/dx-maps/<bundle>/`.
Set `paths` to also copy the tree into another host's folder (e.g. doxa-life's `public/`). Set
`embedBaseUrl` to the public URL of your chosen home — it generates absolute, paste-anywhere embed
snippets (leave it empty for relative URLs that work wherever the folder is dropped).
@ -240,13 +240,12 @@ Several maps + one layout = a single-page app, served from the same `public/` tr
- `nav.js` — add a sidebar entry (`{ kind, id, label, icon, target }`; `target` controls
desktop/mobile packaging).
- the `PROFILES` object in its `index.html` — tell the shell what to mount:
`{ tag: '<your-element>', src: './doxa-maps/<bundle>/<bundle>.js', views: ['map'] }`.
`{ tag: '<your-element>', src: './dx-maps/<bundle>/<bundle>.js', views: ['map'] }`.
3. **Install the shell**: `bun run shell` — copies the wrapper to `public/index.html`, so the
site root IS the SPA and the bundles sit beneath it. (Also required for the mobile app.)
> Note: this OVERWRITES the generated root page, which since 2026-09-22 is the **plugin studio**
> host. That is the intended trade for this opt-in wrapper, and it is not sticky — the next
> `bun run build` regenerates the studio root. The studio's own leaf stays at
> `public/doxa-maps/plugin-studio/plugin-studio/`, and the archived gallery at `/__gallery`.
> Note: the build's own front page — the **Plugin Studio** — is `public/dx-maps/index.html`,
> inside the output folder, so installing the shell at `public/index.html` does not replace it.
> The next `bun run build` leaves the shell's copy alone and regenerates the output folder.
4. **Build & serve**: `bun run build`, then serve `public/` — the shell lazy-loads each bundle
the first time you open its nav entry.
@ -257,7 +256,7 @@ Contracts and gotchas (PROFILES shape, tag-vs-bundle names, `--no-token`):
## Build the Android / iOS app (optional)
**Mobile is separate from `bun run build`.** The default build produces **only** the `doxa-maps`
**Mobile is separate from `bun run build`.** The default build produces **only** the `dx-maps`
web folder — the embeddable maps (and the SPA, if configured). It never touches Capacitor. The
Android/iOS app is an **opt-in** step that *reuses* that web build: Capacitor's `webDir` points at
`public/` (`appId: life.doxa.maps`, `appName: "Doxa Maps"`), so `cap sync` wraps the already-built
@ -296,7 +295,7 @@ The engine is a **runtime parameter** — same bundle, no rebuild. Resolution or
<my-map profile-config='{"profile":"my-map","provider":"maplibre"}'></my-map>
<!-- or flip any generated page from the URL -->
https://<cdn>/doxa-maps/<bundle>/<profile>/index.html?provider=maplibre
https://<cdn>/dx-maps/<bundle>/<profile>/index.html?provider=maplibre
```
`maplibre` runs token-free on the OpenFreeMap style (the right GL script/CSS is injected

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 KiB

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 828 KiB

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 MiB

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 MiB

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 891 KiB

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 KiB

After

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 706 KiB

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 KiB

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -1,98 +1,102 @@
{
"cap1-single-element": {
"bundle": "all-people-groups",
"sha256": "c990929277d9aa5fd0d4ef78362f7b43e328a7c073ac6bf97aadfde5cb942de9"
"sha256": "1a2ddbee5f7ade4c59be1e729334fbaabb9a4bb52fe91aaae70df0b8a3e817a2"
},
"cap2-adoption": {
"bundle": "doxa-simple-map",
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
"sha256": "01973724b4f6b656d4f4743cf2167b8d5a984f48401e4738cec83fb3719d289b"
},
"cap2-engagement": {
"bundle": "doxa-simple-map",
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
"sha256": "01973724b4f6b656d4f4743cf2167b8d5a984f48401e4738cec83fb3719d289b"
},
"cap2-prayer": {
"bundle": "doxa-simple-map",
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
"sha256": "01973724b4f6b656d4f4743cf2167b8d5a984f48401e4738cec83fb3719d289b"
},
"cap3-monolithic-research": {
"bundle": "doxa-research-map",
"sha256": "b849fb71c4a96810d840d9f2dfc208c3e13648429e0ae1a1c5784693e6e5774f"
"sha256": "5f73cb71c0a374a5506df426ac4b43648f28d7128b9583da1b358c1ca7519bda"
},
"cap4-upg100-list": {
"bundle": "my-upg-100-list",
"sha256": "fab621d68bdad964da2be67135d0385d40417c6180f008f36cbc0f50076f5e4a"
"sha256": "237de597f1080ca4893650ed19f6fc3a31b7ea30da498e8fa7d1f74ca88818b2"
},
"cap4-upg100-mfe": {
"bundle": "my-upg-100-list",
"sha256": "fab621d68bdad964da2be67135d0385d40417c6180f008f36cbc0f50076f5e4a"
"sha256": "237de597f1080ca4893650ed19f6fc3a31b7ea30da498e8fa7d1f74ca88818b2"
},
"cap4-upg100-poster-wall": {
"bundle": "my-upg-100-list",
"sha256": "fab621d68bdad964da2be67135d0385d40417c6180f008f36cbc0f50076f5e4a"
"sha256": "237de597f1080ca4893650ed19f6fc3a31b7ea30da498e8fa7d1f74ca88818b2"
},
"cap5-doxa-maps-spa": {
"bundle": "doxa-maps-spa",
"sha256": "3d5467fd47ada47979fb878a5a355329ddc3ad88071ec947bf6c4a61d53df143"
"sha256": "9093c700a38d42a9444650690d99ce3b8b66f0c73e03e00ac49780c3833807c5"
},
"cap5-plugin-studio": {
"bundle": "plugin-studio",
"sha256": "4d3910b06d55c7fccdba7102be4f9420a0e1368efd1695a6abaf12442b4fcda0"
"sha256": "0034c901862e25c575966dd48f78d2fa739834db401cf37f1de9653e3034ae05"
},
"deploy2-mobile-upg100": {
"bundle": "my-upg-100-list",
"sha256": "fab621d68bdad964da2be67135d0385d40417c6180f008f36cbc0f50076f5e4a"
"sha256": "237de597f1080ca4893650ed19f6fc3a31b7ea30da498e8fa7d1f74ca88818b2"
},
"exp-prayer-walk-planner": {
"bundle": "prayer-walk-planner",
"sha256": "cee5f2806bd5cc8a7fa4c49db200df643d07cf303fd75ee739e606dd9ee93aee"
"sha256": "0b695f70da64b8d5c4f0c6ca765571eb621acac08f41e49bc3da57df687ed0a5"
},
"exp-wall-of-people-groups": {
"bundle": "wall-of-people-groups",
"sha256": "ba718e082f77f50d5775ebf43007385b7293f8099e2ca4d79e8ad1753ac4133f"
"sha256": "2dfc3a3c4918b574862c07271d8594e56b6d1d7ef96553fe67359bbe58c2cad2"
},
"lang-arabic-upg100": {
"bundle": "my-upg-100-list",
"sha256": "237de597f1080ca4893650ed19f6fc3a31b7ea30da498e8fa7d1f74ca88818b2"
},
"prod-big-picture": {
"bundle": "dashboard-plus-grid",
"sha256": "e90739f622678db4c421c76855a5c39882aabd4ed8dbdd65cbe1385a45c67edc"
"sha256": "27b9a2e9198b0f3274218faec09796636307d3d6af18c21ed4ba580b2c515457"
},
"prod-cities-internet": {
"bundle": "cities-internet",
"sha256": "ad145b5a63e7d4b7468a54db53f2f71f397216956fec698d357ae5b841fa8e3b"
"sha256": "65e98efcd79016282460e3141ccb2995ad5a11d7039c3303d7c4ae2a7a5ee32d"
},
"prod-cities-internet-phone": {
"bundle": "cities-internet",
"sha256": "ad145b5a63e7d4b7468a54db53f2f71f397216956fec698d357ae5b841fa8e3b"
"sha256": "65e98efcd79016282460e3141ccb2995ad5a11d7039c3303d7c4ae2a7a5ee32d"
},
"prod-gospel-resources": {
"bundle": "gospel-resources",
"sha256": "943fa394060c009d88a3a8e17651980e9c40077be315fb69c98fed92aa17dce0"
"sha256": "00c05aa0437f0304a0403037af640795f6f18797371cf14c2cd50817be1c145c"
},
"prod-gospel-resources-phone": {
"bundle": "gospel-resources",
"sha256": "943fa394060c009d88a3a8e17651980e9c40077be315fb69c98fed92aa17dce0"
"sha256": "00c05aa0437f0304a0403037af640795f6f18797371cf14c2cd50817be1c145c"
},
"prod-prayer-phone": {
"bundle": "doxa-simple-map",
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
"sha256": "01973724b4f6b656d4f4743cf2167b8d5a984f48401e4738cec83fb3719d289b"
},
"prod-regions": {
"bundle": "regions-dashboard",
"sha256": "8420509dde0ea1390e0d4ae8d49f80a303d17fa8a38c37755a82b07ab09567f5"
"sha256": "e90b0055e2251c51b5cdc8b419377463ec8919cfbb774725e3c6c47c370433d6"
},
"prod-regions-phone": {
"bundle": "regions-dashboard",
"sha256": "8420509dde0ea1390e0d4ae8d49f80a303d17fa8a38c37755a82b07ab09567f5"
"sha256": "e90b0055e2251c51b5cdc8b419377463ec8919cfbb774725e3c6c47c370433d6"
},
"prod-research-gallery-phone": {
"bundle": "doxa-maps-spa",
"sha256": "3d5467fd47ada47979fb878a5a355329ddc3ad88071ec947bf6c4a61d53df143"
"sha256": "9093c700a38d42a9444650690d99ce3b8b66f0c73e03e00ac49780c3833807c5"
},
"prod-simple-research": {
"bundle": "research-map-plus-grid",
"sha256": "9e081907bd697b5ac63531cedbdb34d3f47a09d1d5aee3634722128af1136d0f"
"sha256": "110de278cb89a41a95114fb37ea6a2809b19ae9cd99a79ef2debcd63a130de68"
},
"prod-simple-research-phone": {
"bundle": "research-map-plus-grid",
"sha256": "9e081907bd697b5ac63531cedbdb34d3f47a09d1d5aee3634722128af1136d0f"
"sha256": "110de278cb89a41a95114fb37ea6a2809b19ae9cd99a79ef2debcd63a130de68"
}
}

View file

@ -161,35 +161,23 @@
<text x="1032.0" y="1710" font-size="16" fill="#475569">the checks must pass, main stays working</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="1780" width="1380" height="230" rx="20" fill="#f8fafc" stroke="#cbd5e1" stroke-width="2"/>
<text x="750" y="1824" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">main → ONE build → public/doxa-maps/ → five ways to ship</text>
<rect x="80.0" y="1852" width="236" height="130" rx="14" fill="#fff" stroke="#15803d" stroke-width="2.5"/>
<text x="198.0" y="1874" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">METHOD 1</text>
<text x="198.0" y="1906" text-anchor="middle" font-size="21" font-weight="800" fill="#0f172a">iframe</text>
<text x="198.0" y="1932" text-anchor="middle" font-size="15" fill="#475569">any website</text>
<rect x="90.0" y="1946" width="216" height="26" rx="13" fill="#15803d" opacity=".13"/>
<text x="198.0" y="1964" text-anchor="middle" font-size="11.5" font-weight="800" fill="#15803d">PRODUCTION</text>
<rect x="356.0" y="1852" width="236" height="130" rx="14" fill="#fff" stroke="#15803d" stroke-width="2.5"/>
<text x="474.0" y="1874" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">METHOD 2</text>
<text x="474.0" y="1906" text-anchor="middle" font-size="21" font-weight="800" fill="#0f172a">Mobile app</text>
<text x="474.0" y="1932" text-anchor="middle" font-size="15" fill="#475569">Capacitor</text>
<rect x="366.0" y="1946" width="216" height="26" rx="13" fill="#15803d" opacity=".13"/>
<text x="474.0" y="1964" text-anchor="middle" font-size="11.5" font-weight="800" fill="#15803d">BUILT</text>
<rect x="632.0" y="1852" width="236" height="130" rx="14" fill="#fff" stroke="#15803d" stroke-width="2.5"/>
<text x="750.0" y="1874" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">METHOD 3</text>
<text x="750.0" y="1906" text-anchor="middle" font-size="21" font-weight="800" fill="#0f172a">JavaScript embed</text>
<text x="750.0" y="1932" text-anchor="middle" font-size="15" fill="#475569">script + SRI</text>
<rect x="642.0" y="1946" width="216" height="26" rx="13" fill="#15803d" opacity=".13"/>
<text x="750.0" y="1964" text-anchor="middle" font-size="11.5" font-weight="800" fill="#15803d">PRODUCTION</text>
<rect x="908.0" y="1852" width="236" height="130" rx="14" fill="#fff" stroke="#d97706" stroke-width="2.5"/>
<text x="1026.0" y="1874" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">METHOD 4</text>
<text x="1026.0" y="1906" text-anchor="middle" font-size="21" font-weight="800" fill="#0f172a">WordPress</text>
<text x="1026.0" y="1932" text-anchor="middle" font-size="15" fill="#475569">Disciple Tools</text>
<rect x="918.0" y="1946" width="216" height="26" rx="13" fill="#d97706" opacity=".13"/>
<text x="1026.0" y="1964" text-anchor="middle" font-size="11.5" font-weight="800" fill="#d97706">PROVEN · NOT YET LIVE</text>
<rect x="1184.0" y="1852" width="236" height="130" rx="14" fill="#fff" stroke="#d97706" stroke-width="2.5"/>
<text x="1302.0" y="1874" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">METHOD 5</text>
<text x="1302.0" y="1906" text-anchor="middle" font-size="21" font-weight="800" fill="#0f172a">Desktop</text>
<text x="1302.0" y="1932" text-anchor="middle" font-size="15" fill="#475569">Tauri</text>
<rect x="1194.0" y="1946" width="216" height="26" rx="13" fill="#d97706" opacity=".13"/>
<text x="1302.0" y="1964" text-anchor="middle" font-size="11.5" font-weight="800" fill="#d97706">PROVEN · NOT YET LIVE</text>
<text x="750" y="1824" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">main → ONE build → one web folder → three outputs</text>
<rect x="80.0" y="1852" width="420" height="130" rx="14" fill="#fff" stroke="#15803d" stroke-width="2.5"/>
<text x="290.0" y="1874" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">OUTPUT 1</text>
<text x="290.0" y="1906" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">The web folder</text>
<text x="290.0" y="1932" text-anchor="middle" font-size="15" fill="#475569">iframe · JS embed · WordPress (for fun)</text>
<rect x="190.0" y="1946" width="200" height="26" rx="13" fill="#15803d" opacity=".13"/>
<text x="290.0" y="1964" text-anchor="middle" font-size="12" font-weight="800" fill="#15803d">PRODUCTION</text>
<rect x="540.0" y="1852" width="420" height="130" rx="14" fill="#fff" stroke="#15803d" stroke-width="2.5"/>
<text x="750.0" y="1874" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">OUTPUT 2</text>
<text x="750.0" y="1906" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">Mobile app</text>
<text x="750.0" y="1932" text-anchor="middle" font-size="15" fill="#475569">the same folder, wrapped by Capacitor</text>
<rect x="650.0" y="1946" width="200" height="26" rx="13" fill="#15803d" opacity=".13"/>
<text x="750.0" y="1964" text-anchor="middle" font-size="12" font-weight="800" fill="#15803d">BUILT</text>
<rect x="1000.0" y="1852" width="420" height="130" rx="14" fill="#fff" stroke="#d97706" stroke-width="2.5"/>
<text x="1210.0" y="1874" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">OUTPUT 3</text>
<text x="1210.0" y="1906" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">Desktop app</text>
<text x="1210.0" y="1932" text-anchor="middle" font-size="15" fill="#475569">the same folder, wrapped by Tauri</text>
<rect x="1110.0" y="1946" width="200" height="26" rx="13" fill="#d97706" opacity=".13"/>
<text x="1210.0" y="1964" text-anchor="middle" font-size="12" font-weight="800" fill="#d97706">PROVEN · NOT YET LIVE</text>
</svg>

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -4,7 +4,7 @@
> check:capability-exemplars` fails if this file falls behind the manifest.
>
> This answers one question authoritatively: **which bundles are the capability-N
> examples right now?** It is derived from `public/doxa-maps/manifest.json`, which is what
> examples right now?** It is derived from `public/dx-maps/manifest.json`, which is what
> the build computed from each folder's real shape — so it cannot drift from what ships.
>
> **Cite this file rather than remembering.** The same citation has been wrong in three

View file

@ -14,7 +14,7 @@ Each bundle is one self-contained `.js` file that registers a custom HTML elemen
| **Fix a legend or control for ALL maps** | edit it once in `library/` (`@map`); rebuild |
| **Add a colour strategy** | a new file in `@map/colors/` (shared) or the bundle's `src/colors/` (map-private) |
| **See my change while working** | `bun run dev` → http://localhost:5173/ (the Plugin Studio) |
| **Build everything** | `BUILD_ALLOW=1 bun run build` → `public/doxa-maps/<bundle>/<bundle>.js` |
| **Build everything** | `BUILD_ALLOW=1 bun run build` → `public/dx-maps/<bundle>/<bundle>.js` |
| **Build just one bundle** | `BUNDLE=<name> bun x vite build` |
| **See a change on the served site** | rebuild, then refresh (the dev server never writes `public/`) |
| **Repoint the API without a rebuild** | set `window.MAP_APP_API_URL` before the bundle loads |
@ -86,7 +86,7 @@ doxa-maps/
├── deployment/ ← capacitor/ (mobile) · wordpress/ · src-tauri/ (desktop)
├── public/ ← THE SERVED WEB-ROOT, committed:
│ ├── index.html the Plugin Studio host page
│ ├── doxa-maps/<bundle>/ built bundles + manifest.json + leaf pages
│ ├── dx-maps/<bundle>/ built bundles + manifest.json + leaf pages
│ └── assets/ posters & data the maps fetch at runtime
├── vite.config.js ← multi-entry: discovers plugin-designs/<name>/ folders
└── vite.doxa-maps-build-config.json ← output tree name + copy destinations + token settings
@ -112,7 +112,7 @@ colours or language with **no rebuild**.
// also per page via ?provider= or window.MAP_PROVIDER
"assetsBase": "/assets", // where tables/, boundaries/, previews/ are served (see below)
"shareUrl": "https://…", // optional; the WHOLE url the Share button links/embeds
"embedBaseUrl": "https://…/doxa-maps", // optional; tree root the Share button joins embed pages to
"embedBaseUrl": "https://…/dx-maps", // optional; tree root the Share button joins embed pages to
"tabs": [ /* parameterized screens read this */ ]
}'></doxa-map>
```
@ -139,7 +139,7 @@ i18n, under a **distinct** tag, since `customElements.define()` is one-shot per
| Script | What it does |
|---|---|
| `bun run dev` | Vite dev server with hot reload at http://localhost:5173. **`/` is the Plugin Studio**; `/__gallery` is the archived bundle index. Serves every bundle at its own html path and `/assets/*` from `public/assets/`. **Writes nothing to `public/`.** |
| `BUILD_ALLOW=1 bun run build` | `clean` → one `vite build` per `plugin-designs/*/` folder → `tree` → static gates. Refuses (exit 75) without `BUILD_ALLOW=1`, because it wipes `public/doxa-maps/` first. Takes a lock; `BUILD_LOCK_WAIT=600` queues instead of failing |
| `BUILD_ALLOW=1 bun run build` | `clean` → one `vite build` per `plugin-designs/*/` folder → `tree` → static gates. Refuses (exit 75) without `BUILD_ALLOW=1`, because it wipes `public/dx-maps/` first. Takes a lock; `BUILD_LOCK_WAIT=600` queues instead of failing |
| `BUNDLE=<name> bun x vite build` | builds ONE bundle without cleaning the rest of the tree |
| `bun run build:watch` | rebuilds into `public/` on every change |
| `bun run clean` | wipes and recreates the `public/<name>/` output tree |
@ -153,9 +153,9 @@ i18n, under a **distinct** tag, since `customElements.define()` is one-shot per
by `BUNDLE=<name>`) as a self-contained **IIFE**; the full build loops every folder. Sizes differ with what each
bundle imports (simple ≈ 420 KB, research ≈ 1.3 MB).
**Where output goes.** One bundle → one file: `public/doxa-maps/<bundle>/<bundle>.js`, with CSS injected by
**Where output goes.** One bundle → one file: `public/dx-maps/<bundle>/<bundle>.js`, with CSS injected by
JS (no separate `.css`). Beyond it, `tree` writes a standalone, iframe-able `index.html` per bundle, per
profile and per preset instance, plus `public/doxa-maps/manifest.json` with ready-made
profile and per preset instance, plus `public/dx-maps/manifest.json` with ready-made
`embed.iframe` / `embed.script` snippets for every profile. Full design:
[architecture/embeddability.md](./architecture/embeddability.md). Preset instances:
[how-to.md](./how-to.md#ship-n-preset-variants-of-one-screen-instances).
@ -200,7 +200,7 @@ export MAPBOX_TOKEN=$(python3 -c "import json;print(json.load(open('vite.doxa-ma
```sh
( node internal/serve-strict.mjs --port 4950 & ) # serve the built tree, loopback only
B=http://127.0.0.1:4950/doxa-maps; ARGS=""
B=http://127.0.0.1:4950/dx-maps; ARGS=""
for p in demo-map/demo-map cities-internet/connectivity-map doxa-research-map/research-map \
country-posters/countries-map doxa-simple-map/doxa-simple-map \
gospel-resources/resources-map my-upg-100-list/upg-100-list my-upg-100-list/upg-100-mfe; do
@ -239,7 +239,7 @@ Edit this one file, then rebuild.
| Field | What it does |
|---|---|
| `name` | output folder name (default `doxa-maps`) |
| `name` | output folder name (`dx-maps`; a config with no name falls back to `doxa-maps`) |
| `paths` | **extra** folders to copy the whole built tree into — point at a host repo's `public/` and that host serves the maps. Empty ⇒ `public/<name>/` only |
| `embedBaseUrl` | the public URL the tree is served from, for **absolute, paste-anywhere** snippets. Empty ⇒ relative URLs (position-independent; required for Capacitor) |
| `token` | a `pk.` public Mapbox token baked into every page, so a map works on any host. It is public by design — protected by URL restrictions in the Mapbox dashboard, not by secrecy; `check:public-hygiene` allows `pk.` and fails on `sk.` |

View file

@ -29,9 +29,11 @@ const MANIFEST = `${TREE_NAME}/manifest.json`
/**
* @param {string} base e.g. http://127.0.0.1:4173 (a trailing /doxa-maps/ is tolerated)
* @param {string} repo absolute path to the repo root
* @param {{assets?: string[]}} [opts] data files this gate JUDGES (tree-relative, e.g.
* 'assets/tables/doxa-gospel-access.json'); their served bytes must equal disk too
* @returns {Promise<{ok:boolean, why:string}>}
*/
export async function servesThisTree(base, repo) {
export async function servesThisTree(base, repo, { assets = [] } = {}) {
let local
try {
local = createHash('sha256').update(readFileSync(resolve(repo, 'public', MANIFEST))).digest('hex')
@ -45,7 +47,25 @@ export async function servesThisTree(base, repo) {
if (served !== local) {
return { ok: false, why: `${root} serves a DIFFERENT build than public/ — another worktree, or a server that needs restarting` }
}
return { ok: true, why: 'same build' }
// ★ THE MANIFEST DESCRIBES BUNDLES, NOT DATA. MEASURED 2026-09-25 (gospel): verify:gospel-map adopted
// another worktree's server on :4173 and reported PASS — the three worktrees' manifests were
// byte-identical because the change under test was a DATA asset (doxa-gospel-access.json), which the
// manifest never mentions; a planted jw.org link went undetected. So a gate whose subject is a data
// file DECLARES it (paths relative to the tree, e.g. 'assets/tables/doxa-gospel-access.json') and its
// served bytes are compared with disk too. Any difference is a different tree for THIS gate.
for (const rel of assets) {
let disk
try { disk = readFileSync(resolve(repo, 'public', TREE_NAME, rel)) } catch {
return { ok: false, why: `no local public/${TREE_NAME}/${rel} to compare against — build the tree first` }
}
const a = await fetch(`${root}/${TREE_NAME}/${rel}`).catch(() => null)
if (!a || !a.ok) return { ok: false, why: `${root} serves a DIFFERENT build — ${rel} is not served there (${a ? a.status : 'no answer'})` }
const got = Buffer.from(await a.arrayBuffer())
if (!got.equals(disk)) {
return { ok: false, why: `${root} serves a DIFFERENT build — the manifest matches but ${rel} does not (another worktree's data, or a stale server)` }
}
}
return { ok: true, why: assets.length ? `same build, and ${assets.length} declared data file(s) byte-identical` : 'same build' }
}
@ -100,7 +120,7 @@ export async function serveThisTree(dir) {
* @param {string} repo absolute path to the repo root
* @param {string} label the gate's own name, for the message
*/
export async function requireThisTree(base, repo, label) {
export async function requireThisTree(base, repo, label, { assets = [] } = {}) {
let origin = String(base || '')
// A gate that loads file:// URLs is reading THIS checkout by construction — there is no server to
// be wrong about, so the guard stays silent rather than inventing a precondition.
@ -116,7 +136,7 @@ export async function requireThisTree(base, repo, label) {
if (!/^(localhost|127\.|0\.0\.0\.0$|\[?::1\]?$)/.test(host)) return { ok: true, why: 'remote target, not a worktree port' }
} catch { /* unparseable — fall through to the content check */ }
try { origin = new URL(origin).origin } catch { /* already an origin, or unusable — pass through */ }
const verdict = await servesThisTree(origin, repo)
const verdict = await servesThisTree(origin, repo, { assets })
if (/DIFFERENT build/.test(verdict.why)) {
console.log(`${label}: NOT MEASURED — ${verdict.why}`)
console.log(' Point this gate at a server for THIS checkout (bun run serve) and re-run.')

View file

@ -18,7 +18,8 @@
# Usage: internal/browser-slot.sh <command> [args...]
# Env: BROWSER_SLOTS (default 2), BROWSER_SLOT_DIR (default /tmp),
# BROWSER_MEM_FLOOR_MB (default 2500), BROWSER_LOCK_WAIT (default 900),
# BROWSER_MEM_WAIT (default 300), BROWSER_MAX_HOLD (default 900 s)
# BROWSER_MEM_WAIT (default 300), BROWSER_MAX_HOLD (default 900 s),
# BROWSER_DISK_FLOOR_MB (default 1500; checked on / and on $TMPDIR or /tmp)
set -u
SLOTS="${BROWSER_SLOTS:-2}"
@ -33,6 +34,7 @@ MEM_WAIT_S="${BROWSER_MEM_WAIT:-300}"
BUSY=75
MAX_HOLD="${BROWSER_MAX_HOLD:-900}"
KILL_AFTER="${BROWSER_KILL_AFTER:-15}"
DISK_FLOOR_MB="${BROWSER_DISK_FLOOR_MB:-1500}"
# ★ A GATE THAT IS LEGITIMATELY LONG DECLARES ITS OWN LIMIT, in its header: @browser-slot-max-hold 2700
# check:host-locale-contract loads every leaf twice and hit the 900 s default three times on full
@ -68,21 +70,67 @@ fi
# the command named so its owner can find it. A long gate that legitimately needs more says so:
# BROWSER_MAX_HOLD=1800 internal/browser-slot.sh node internal/<long-gate>.mjs
run_held() {
timeout --kill-after="$KILL_AFTER" "$MAX_HOLD" "$@" 9>&-
local rc=$?
# The gate's output still flows to the caller untouched (stdout and stderr kept apart); a COPY goes to
# RAM (/dev/shm, never the disk that may be full) so a failure can be classified afterwards.
local log="" rc
if [ -d /dev/shm ] && [ -w /dev/shm ]; then log=$(mktemp /dev/shm/browser-slot.XXXXXX 2>/dev/null) || log=""; fi
if [ -n "$log" ]; then
{ timeout --kill-after="$KILL_AFTER" "$MAX_HOLD" "$@" 9>&- 2> >(tee -a "$log" >&2); } | tee -a "$log"
rc=${PIPESTATUS[0]}
sleep 0.2 # let the stderr tee finish writing its copy
else
timeout --kill-after="$KILL_AFTER" "$MAX_HOLD" "$@" 9>&-
rc=$?
fi
if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
echo "browser-slot: '$*' held a browser slot past BROWSER_MAX_HOLD=${MAX_HOLD}s and was killed (timeout rc $rc) — NOT MEASURED. Give it a timeout of its own, or set BROWSER_MAX_HOLD if it is legitimately that long." >&2
exit "$BUSY"
rm -f "$log"; exit "$BUSY"
fi
# ★ AN INFRASTRUCTURE FAILURE IS NOT A PRODUCT VERDICT (measured 2026-09-25: with the disk at 48 MB free,
# three browser gates went RED with Chromium "Page crashed" — an out-of-disk box wearing a product
# gate's exit code). ENOSPC is always infrastructure. A browser crash is classified as such ONLY when
# the box is below a floor at that moment: a renderer crash on a healthy box can be a real product
# bug (a leak, a runaway loop), and blanket-excusing it would hide exactly that.
if [ "$rc" -ne 0 ] && [ "$rc" -ne "$BUSY" ] && [ -n "$log" ]; then
local infra=""
if grep -qiE 'ENOSPC|No space left on device' "$log"; then
infra="the disk is full (ENOSPC)"
elif grep -qE 'Page crashed|Target (page, context or browser )?has been closed|Target closed|Browser (has been )?closed|browser has disconnected' "$log"; then
local d m; d=$(disk_available_mb); m=$(mem_available_mb)
if [ "$d" -lt "$DISK_FLOOR_MB" ] || [ "$m" -lt "$FLOOR_MB" ]; then
infra="the browser crashed while the box was below a floor (disk ${d}MB / floor ${DISK_FLOOR_MB}MB, memory ${m}MB / floor ${FLOOR_MB}MB)"
fi
fi
if [ -n "$infra" ]; then
echo "browser-slot: '$*' failed from INFRASTRUCTURE, not the product — ${infra}. NOT MEASURED (the command exited $rc)." >&2
rm -f "$log"; exit "$BUSY"
fi
fi
rm -f "$log"
exit "$rc"
}
slot_path() { [ "$1" = "1" ] && echo "$SLOT_BASE" || echo "$SLOT_BASE$1"; }
# Free disk, the lower of / and the temp dir (they may be different filesystems). BROWSER_FAKE_DISK_FILE is
# a TEST HOOK: when set, the reading comes from that file, so a test can "fill the disk" mid-run.
disk_available_mb() {
if [ -n "${BROWSER_FAKE_DISK_FILE:-}" ] && [ -f "$BROWSER_FAKE_DISK_FILE" ]; then cat "$BROWSER_FAKE_DISK_FILE"; return; fi
local a b; a=$(df --output=avail -m / 2>/dev/null | tail -1 | tr -d ' '); b=$(df --output=avail -m "${TMPDIR:-/tmp}" 2>/dev/null | tail -1 | tr -d ' ')
a=${a:-999999}; b=${b:-999999}; [ "$a" -lt "$b" ] && echo "$a" || echo "$b"
}
mem_available_mb() {
awk '/^MemAvailable:/ { printf "%d", $2 / 1024 }' /proc/meminfo 2>/dev/null || echo 999999
}
# ★ REFUSE ON A FULL DISK, before taking a slot: Chromium needs disk for its profile and cache, and a
# measurement started with none ends as a "Page crashed" that reads like a product failure.
if [ "$(disk_available_mb)" -lt "$DISK_FLOOR_MB" ]; then
echo "browser-slot: only $(disk_available_mb)MB free on disk (floor ${DISK_FLOOR_MB}MB) — not measuring" >&2
exit "$BUSY"
fi
# Hold off while the box is below the floor. Proceeding anyway is not an option here the way it is
# for a load average: load makes a measurement slow, memory makes it get killed halfway through.
waited=0

View file

@ -25,7 +25,9 @@
* CENSUS IS BLIND and exits 75. No preset or ad-hoc run is accepted without one.
*
* PRESETS: internal/element-census/<name>.json — { owner, pages:[{name, leaf, live}], neg:{leaf, text},
* sanctioned:[{match, flags, what, driver, date, card}] }. An allowlist entry without the Driver's words,
* sanctioned:[{match, flags, what, driver, date, card, theirs?, theirsFlags?}] }. `theirs` makes the entry a
* SANCTIONED PAIR: their string for the same control (e.g. our "Hide Filters" / their "Show Filters"),
* excused as an omission only while our half is on the page. An allowlist entry without the Driver's words,
* a date and a card is REFUSED (exit 2): it is evidence, not an ignore list.
*
* USAGE node internal/check-element-census-vs-live.mjs every preset
@ -64,7 +66,12 @@ function validate(p, from) {
for (const k of ['match', 'driver', 'date', 'card']) if (!s[k]) bad(`${from}: sanctioned[${i}] has no "${k}" — every allowance carries the Driver's words, the date and the card`)
if (!/^\d{4}-\d{2}-\d{2}$/.test(s.date)) bad(`${from}: sanctioned[${i}].date must be YYYY-MM-DD`)
let re; try { re = new RegExp(s.match, s.flags || '') } catch (e) { bad(`${from}: sanctioned[${i}].match is not a regex: ${e.message}`) }
return { ...s, re }
// A SANCTIONED PAIR: `theirs` names THEIR string for the same control in its other state (their
// "Show Filters" vs our sanctioned "Hide Filters") under the same citation, so a sanctioned
// difference does not leave a phantom omission behind.
let theirsRe = null
if (s.theirs !== undefined) { try { theirsRe = new RegExp(s.theirs, s.theirsFlags ?? s.flags ?? '') } catch (e) { bad(`${from}: sanctioned[${i}].theirs is not a regex: ${e.message}`) } }
return { ...s, re, theirsRe }
})
return p
}
@ -171,6 +178,13 @@ for (const [name, p] of presets) {
}
const driftShapes = new Set(drift.map(([s]) => shape(s)))
theirsOnly = theirsOnly.filter((t) => !driftShapes.has(shape(t)))
// Pairs: THEIR half is excused only when OUR half of the same entry is actually on this page. If
// our control vanished, their string is a real omission again — a pair can never hide a gap.
const pairedTheirs = []
for (const x of p.sanctioned.filter((x) => x.theirsRe)) {
if (!ours.list.some((t) => x.re.test(t))) continue
theirsOnly = theirsOnly.filter((t) => { if (x.theirsRe.test(t)) { pairedTheirs.push([t, x]); return false } return true })
}
console.log(` OURS-ONLY: ${allowed.length} sanctioned · ${invented.length} unaccounted`)
for (const [s, why] of allowed) console.log(` ok "${s.slice(0, 60)}" — ${why.slice(0, 140)}`)
for (const s of invented) console.log(` ?? "${s.slice(0, 80)}"`)
@ -179,6 +193,7 @@ for (const [name, p] of presets) {
console.log(` DATA DRIFT (same label, different figure — their page is an SSR snapshot, ours is live): ${drift.length}`)
for (const [o, t] of drift.slice(0, 10)) console.log(` ~ ours ${JSON.stringify(o.slice(0, 44))} vs theirs ${JSON.stringify(t.slice(0, 44))}`)
}
for (const [t, x] of pairedTheirs) console.log(` ok their "${t.slice(0, 50)}" — the other half of a sanctioned pair (${x.what || x.card}, Driver ${x.date})`)
console.log(` THEIRS-ONLY (ported nowhere on our side): ${theirsOnly.length}`)
for (const s of theirsOnly.slice(0, 25)) console.log(` !! "${s.slice(0, 80)}"`)
if (theirsOnly.length > 25) console.log(` … and ${theirsOnly.length - 25} more`)

View file

@ -18,6 +18,12 @@
* not one baked into the bundle). A MapLibre-only plugin takes no token: reported n/a
* D its tab inside the Research Gallery
* B and C test exactly what a partner pastes, so a broken snippet fails here too.
* Every surface with a map also gets the PAINT arm (after gospel's check-gospel-map-paints):
* distinct colours read off the map's own canvas (floor 40) and the map's height over the plugin
* element's (floor 70%). Feature counts cannot see a white rectangle; these two can. Each has its
* own self-test poison and must be the arm that catches it: 'blank' (every layer's opacity 0,
* every feature still "drawn") and 'collapse' (the container squeezed to 300px).
* Drawn counts are read SETTLED: equal on two consecutive polls, never the first non-zero read.
* A surface passes when:
* - the element mounted with content (its shadow tree has real nodes),
* - 0 page errors and 0 failed requests (4xx/5xx; Mapbox telemetry and favicons excluded),
@ -59,6 +65,10 @@ const ONLY = arg('--only', '') ? arg('--only', '').split(',') : null
const SURFACES = arg('--surfaces', 'A,B,C,D').split(',')
const SELF_TEST = !argv.includes('--no-self-test')
const WAIT_MS = Number(process.env.SHELF_WAIT_MS || 75000)
// the PAINT arm (after gospel's check-gospel-map-paints, 0574fe7f): every assertion above counts
// FEATURES, and queryRenderedFeatures answers happily over a white rectangle. These two look.
const MIN_COLOURS = Number(process.env.MIN_MAP_COLOURS || 40)
const MIN_FILL = Number(process.env.MIN_MAP_FILL || 0.7)
const { chromium } = await import(resolve(ROOT, 'node_modules/playwright/index.mjs'))
const manifest = JSON.parse(readFileSync(resolve(ROOT, `public/${TREE_NAME}/manifest.json`), 'utf8'))
@ -174,6 +184,48 @@ const PROBE = (tag) => `(() => {
return { own, sized, host: !!host, busy, maps }
})()`.replace('__TAG__', tag)
// ── PAINT: does each map put pixels on the screen, and does it fill its plugin? ───────────────
// COLOURS are read off the map's OWN WebGL canvas inside its 'render' event (the drawing buffer is
// still intact there), so a legend or panel floating over the map cannot inflate the count, and
// no per-plugin clip rectangle is needed. A basemap with land, water, borders and data has hundreds
// of colours; a blank map has one. FILL is the map's height over the PLUGIN ELEMENT's height, not
// canvas over container: in gospel's incident a stylesheet collapsed the container and the canvas
// followed it, so canvas/container stayed 1.0 over a 300px strip in a 900px box.
// POISON (self-test only): 'blank' zeroes every layer's opacity (the map is white and every
// feature still counts as drawn); 'collapse' squeezes the container to 300px (the real incident).
const PAINT = (tag, poison) => `(async () => {
const acc = []; const w = (r) => { for (const el of r.querySelectorAll('*')) { acc.push(el); if (el.shadowRoot) w(el.shadowRoot) } }; w(document)
const host = acc.find(e => e.tagName === ${JSON.stringify('__TAG__')}.toUpperCase())
const hb = host ? host.getBoundingClientRect() : null
const maps = (window.__shelfMaps || []).filter(m => { try { return m.getContainer() && m.getContainer().isConnected } catch (_) { return false } })
const poison = ${JSON.stringify('__POISON__')}
const OPACITY = { background: ['background-opacity'], fill: ['fill-opacity'], line: ['line-opacity'], circle: ['circle-opacity', 'circle-stroke-opacity'], symbol: ['text-opacity', 'icon-opacity'], raster: ['raster-opacity'], 'fill-extrusion': ['fill-extrusion-opacity'], heatmap: ['heatmap-opacity'] }
for (const m of maps) {
if (poison === 'blank') for (const l of (m.getStyle().layers || [])) for (const k of (OPACITY[l.type] || [])) { try { m.setPaintProperty(l.id, k, 0) } catch (_) {} }
if (poison === 'collapse') { const c = m.getContainer(); c.style.position = 'relative'; c.style.inset = 'auto'; c.style.height = '300px'; c.style.flex = 'none'; m.resize() }
}
if (poison) await new Promise(r => setTimeout(r, 1200))
const out = []
for (const m of maps) {
const cb = m.getContainer().getBoundingClientRect()
const colours = await new Promise(res => {
const t = setTimeout(() => res(-1), 8000)
m.once('render', () => { clearTimeout(t); try {
const cv = m.getCanvas(), W = Math.min(480, cv.width), H = Math.max(1, Math.round(cv.height * W / cv.width))
const c = document.createElement('canvas'); c.width = W; c.height = H; const x = c.getContext('2d'); x.drawImage(cv, 0, 0, W, H)
const d = x.getImageData(0, 0, W, H).data, seen = new Set()
for (let i = 0; i < d.length; i += 4) seen.add(((d[i] >> 3) << 11) | ((d[i + 1] >> 3) << 6) | ((d[i + 2] >> 3) << 1) | (d[i + 3] > 127 ? 1 : 0))
res(seen.size)
} catch (_) { res(-2) } })
m.triggerRepaint()
})
let drawn = 0
try { const st = m.getStyle(), srcs = st.sources || {}, geo = Object.keys(srcs).filter(k => srcs[k].type === 'geojson'); const layers = (st.layers || []).filter(l => geo.includes(l.source)).map(l => l.id); if (layers.length) drawn = m.queryRenderedFeatures({ layers }).length } catch (_) {}
out.push({ colours, fill: hb && hb.height ? cb.height / hb.height : 0, drawn })
}
return out
})()`.replace('__TAG__', tag).replace('__POISON__', poison || '')
const b = await chromium.launch({ headless: true, args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--enable-webgl', '--no-sandbox'] })
async function measure(url, s, poisonBundle, opts = {}) {
@ -193,16 +245,21 @@ async function measure(url, s, poisonBundle, opts = {}) {
const host0 = opts.frame ? await p.evaluate(() => ({ title: document.title, href: location.href, y: scrollY })) : null
// B: the plugin lives in the iframe whose document comes from the tree
const ctx = () => (opts.frame ? p.frames().find(f => f.url().startsWith(BASE)) : p.mainFrame()) || p.mainFrame()
let st = null
let st = null, prevDrawn = ''
const t0 = Date.now()
// provenance needs one tile request, not a drawn map: a fake token cannot draw anything
while (Date.now() - t0 < (opts.provenanceOnly ? Math.min(WAIT_MS, 25000) : WAIT_MS)) {
await p.waitForTimeout(2500)
try { st = await ctx().evaluate(PROBE(s.tag)) } catch (_) { st = null; continue }
const mapsReady = st.maps.every(m => m.loaded && m.drawn > 0)
// SETTLED, not first-non-zero: the drawn counts must hold across two polls, or a count read
// mid-fit (1752 of 2107 while tiles were still arriving) passes as the answer
const drawnNow = st.maps.map(m => m.drawn).join(','), steady = drawnNow === prevDrawn; prevDrawn = drawnNow
const mapsReady = st.maps.every(m => m.loaded && m.drawn > 0) && steady
if (opts.provenanceOnly ? tokens.has(FAKE_TK) : (st.own > 8 && st.sized && st.busy === 0 && mapsReady && Date.now() - t0 > 6000)) break
}
const host1 = opts.frame ? await p.evaluate(() => ({ title: document.title, href: location.href, y: scrollY })) : null
let paint = []
if (!opts.provenanceOnly && st && st.maps.length) { try { paint = await ctx().evaluate(PAINT(s.tag, opts.poisonPaint)) } catch (e) { paint = [{ colours: -3, fill: 0, drawn: 0 }] } }
await p.close()
if (opts.provenanceOnly) {
// C provenance: with a FAKE host token the tiles cannot load, so only one thing is asserted:
@ -215,11 +272,16 @@ async function measure(url, s, poisonBundle, opts = {}) {
else if (st.own <= 8 || !st.sized) why.push(`<${s.tag}> mounted empty (${st.own} node(s)${st.sized ? '' : ', not sized'})`)
if (st && st.busy) why.push(`${st.busy} loading label(s) left`)
if (st) st.maps.forEach((m, i) => { if (!m.loaded) why.push(`map ${i + 1}: 'load' never fired`); else if (!m.drawn) why.push(`map ${i + 1}: no own data drawn (${m.geo} geojson source(s))`) })
paint.forEach((q, i) => {
if (q.colours < 0) why.push(`map ${i + 1}: could not read its pixels (${q.colours})`)
else if (q.colours < MIN_COLOURS) why.push(`map ${i + 1}: only ${q.colours} colour(s) on its canvas, a blank map (floor ${MIN_COLOURS})${q.drawn ? `, while ${q.drawn} features still count as drawn` : ''}`)
if (q.fill < MIN_FILL) why.push(`map ${i + 1}: fills ${Math.round(q.fill * 100)}% of <${s.tag}>'s height, collapsed (floor ${Math.round(MIN_FILL * 100)}%)`)
})
if (host0 && host1 && (host0.title !== host1.title || host0.href !== host1.href || host0.y !== host1.y)) why.push(`the host page changed (title/URL/scroll): ${JSON.stringify(host0)} -> ${JSON.stringify(host1)}`)
if (opts.noMapbox && mapboxReqs.length) why.push(`${mapboxReqs.length} api.mapbox.com request(s) under maplibre, e.g. ${mapboxReqs[0]}`)
if (errs.length) why.push(`page error: ${errs[0]}`)
if (fails.length) why.push(`failed request: ${fails[0]}`)
return { pass: !why.length, why, maps: st ? st.maps.length : 0, mapboxMaps: st ? st.maps.filter(m => m.mapbox).length : 0 }
return { pass: !why.length, why, maps: st ? st.maps.length : 0, paint, drawn: st ? st.maps.map(m => m.drawn) : [], mapboxMaps: st ? st.maps.filter(m => m.mapbox).length : 0 }
}
const leaf = (s, eng) => `${BASE}/${s.bundle}/${s.profile}/index.html?provider=${eng}` + (eng === 'mapbox' ? `&tk=${TK}` : '')
@ -233,6 +295,14 @@ try {
const c = await measure(`${HOST}/C/${s.bundle}/maplibre`, s, s.bundle, { noMapbox: true })
if (a.pass || d.pass || c.pass) { console.error(`SELF-TEST FAILED: a deliberately broken ${s.bundle} still passed (${a.pass ? 'leaf ' : ''}${d.pass ? 'gallery ' : ''}${c.pass ? 'script-embed' : ''}). This gate cannot see a broken bundle.`); exit = 3 }
else console.log(`self-test: a deliberately broken ${s.bundle} fails as it must (leaf: ${a.why[0]}; script-embed: ${c.why[0]}; gallery: ${d.why[0]})`)
// each PAINT arm gets its own poison and must be the arm that catches it: a collapse caught
// only by the colour floor (or a blank map caught only by fill) proves nothing about the other
const sm = shelf.find(x => x.isMap)
if (sm) for (const [poison, arm] of [['blank', /colour\(s\) on its canvas/], ['collapse', /of <.*>'s height, collapsed/]]) {
const r = await measure(leaf(sm, 'maplibre'), sm, null, { poisonPaint: poison })
if (!r.why.some(w => arm.test(w))) { console.error(`SELF-TEST FAILED: a ${poison} ${sm.bundle} map was not caught by its own arm (${r.pass ? 'it passed' : r.why.join('; ')}). This gate cannot see a ${poison} map.`); exit = 3 }
else console.log(`self-test: a ${poison} ${sm.bundle} map fails as it must (${r.why.find(w => arm.test(w))})`)
}
}
if (exit !== 3) {
for (const [k, v] of Object.entries(SEED)) if (shelf.some(s => s.bundle === k)) console.log(`SEEDED ${k}: ${v.why}`)
@ -242,7 +312,7 @@ try {
// That is a fleet-wide leaf property, reported separately; C is where a partner chooses.
const row = (r, s, eng, surf, t0) => {
if (!r.pass) exit = 1
console.log(`${r.pass ? 'PASS' : 'FAIL'} ${s.bundle.padEnd(24)} ${eng.padEnd(9)} ${surf.padEnd(10)} ${(r.maps ? r.maps + ' map(s)' : 'no map').padEnd(9)} ${String(Math.round((Date.now() - t0) / 1000)).padStart(3)}s${r.pass ? '' : ' — ' + r.why.join('; ')}`)
console.log(`${r.pass ? 'PASS' : 'FAIL'} ${s.bundle.padEnd(24)} ${eng.padEnd(9)} ${surf.padEnd(10)} ${(r.maps ? r.maps + ' map(s)' : 'no map').padEnd(9)} ${(r.paint && r.paint.length ? r.paint.map(q => `${q.colours}c ${Math.round(q.fill * 100)}%`).join(' ') : '').padEnd(12)} ${String(Math.round((Date.now() - t0) / 1000)).padStart(3)}s${r.pass ? '' : ' — ' + r.why.join('; ')}`)
}
for (const s of shelf) for (const eng of ENGINES) for (const [surf, url, opts] of [
['A leaf', leaf(s, eng), {}],
@ -261,7 +331,7 @@ try {
row(await measure(`${HOST}/C/${s.bundle}/mapbox/fake`, s, null, { provenanceOnly: true }), s, eng, 'C host-tk', t0)
}
}
console.log(exit ? 'EXPERIMENTAL-SHELF: FAIL' : `EXPERIMENTAL-SHELF: PASS — every experimental plugin works on its own page, iframed and script-embedded on another site, and in the Gallery, under ${ENGINES.join(' and ')}`)
console.log(exit ? 'EXPERIMENTAL-SHELF: FAIL' : `EXPERIMENTAL-SHELF: PASS — every experimental plugin works ${SURFACES.map(x => ({ A: 'on its own page', B: 'iframed on another site', C: 'script-embedded on another site', D: 'in the Gallery' })[x]).filter(Boolean).join(', ')}, and every map paints, under ${ENGINES.join(' and ')}`)
}
} finally { await b.close(); reap() }
process.exit(exit)

View file

@ -39,10 +39,19 @@ import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const PORT = Number(process.env.RTL_SHAPE_PORT || 5393)
const POISON = process.argv.includes('--poison')
/* ⚠ READ THE TREE NAME, NEVER SPELL IT. The tree was renamed doxa-maps -> dx-maps and every
hardcoded /doxa-maps/ path began 404ing; a probe that reports "0 problems" against a 404 reads
exactly like success. The build config is the one place that knows. */
const LEAF = `${TREE_NAME}/gospel-resources/resources-map/index.html`
// ⚠ PARAMETERISED, not forked. dx-shepherd routed this gate to the other RAW-map owners
// (cities-internet, key-cities, upg-100-list, RegionCountryMap) — copying it would give each of
// them their own drifting copy of the pan-first logic and the poison arm, which are the two parts
// that took corrections to get right. The leaf is the only thing that differs.
// node internal/check-gospel-rtl-shaping.mjs [--poison] [--leaf <tree>/<bundle>/<profile>/index.html]
//
// ⚠ AND THE DEFAULT READS THE TREE NAME RATHER THAN SPELLING IT. The tree was renamed
// doxa-maps -> dx-maps; a default of "doxa-maps/..." 404s, and a probe reporting "0 problems"
// against a 404 reads exactly like success. The build config is the one place that knows.
const leafArg = process.argv.indexOf('--leaf')
const LEAF = (leafArg > -1 && process.argv[leafArg + 1])
|| process.env.RTL_SHAPE_LEAF
|| `${TREE_NAME}/gospel-resources/resources-map/index.html`
/* Arabia: the densest concentration of rendered Arabic on the basemap. */
const RTL_VIEW = { center: [39.2, 24.5], zoom: 5 }

View file

@ -43,9 +43,22 @@ const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173'
const LOCALES = (process.env.LOCALES || 'ar,zh,ru,hi').split(',').map((s) => s.trim()).filter(Boolean)
// ★ --self-test PROVES THE EXCLUSIONS DO NOT BLIND THE GATE.
// The numeric-sibling rule is GENERAL: "an element whose own text sits beside a sibling rendering
// a number is enumerating data". A general exclusion can go green by excluding too much, which is
// the same failure as never checking — and this one shipped before it was ever confirmed by a run.
// So the self-test INJECTS a real chrome leak in the exact position the rule is most likely to
// swallow: an English catalogue value in a label that HAS a numeric sibling, plus one with no
// numeric sibling as a control. The first MUST still be caught; if it is not, the rule is too
// broad and the gate is lying.
const SELF_TEST = process.argv.includes('--self-test')
// <bundle>/<leaf>:<custom-element-tag>
const TARGETS = (process.argv.slice(2).length ? process.argv.slice(2) : [
// ⚠ FILTER THE FLAGS OUT. Every non-flag argv is a target, so `--self-test` was consumed as a
// leaf path and all four locales reported HTTP 404 — the NOT MEASURED guard caught it instead of
// reporting a clean run over nothing, which is exactly what that guard is for.
const ARGS = process.argv.slice(2).filter((a) => !a.startsWith('--'))
const TARGETS = (ARGS.length ? ARGS : [
'dashboard-plus-grid/dashboard-plus-grid:dashboard-plus-grid',
'research-map-plus-grid/research-map-plus-grid:research-map-plus-grid',
]).map((s) => {
@ -77,6 +90,11 @@ console.log('LEAF-ENGLISH-LEAKS')
console.log(` tree identity: OK (served manifest matches this checkout)`)
const en = catalogue('en')
// A value that IS translated in every locale, so it is a genuine leak when it renders in English.
// Chosen from the catalogue rather than invented, so the self-test exercises the same comparison
// the real run does.
const SELF_TEST_KEY = 'buttons.prayerList'
const SELF_TEST_VALUE = en[SELF_TEST_KEY] || 'Prayer List'
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
const rows = []
const fails = []
@ -92,6 +110,23 @@ for (const t of TARGETS) {
{ waitUntil: 'load', timeout: 60000 })
if (!res || !res.ok()) { rows.push({ t: t.path, lc, skipped: `HTTP ${res ? res.status() : '?'}` }); await page.close(); continue }
await page.waitForTimeout(16000)
if (SELF_TEST) {
await page.evaluate(([tag, enVal]) => {
const host = document.querySelector(tag)
const sr = host && host.shadowRoot
if (!sr) return
const mk = (withNumber) => {
const row = document.createElement('div')
const lbl = document.createElement('span')
lbl.textContent = enVal
row.appendChild(lbl)
if (withNumber) { const n = document.createElement('b'); n.textContent = '1,234'; row.appendChild(n) }
sr.appendChild(row)
}
mk(true) // the hard case: a leak sitting beside a count
mk(false) // control: a leak with no numeric sibling
}, [t.tag, SELF_TEST_VALUE])
}
labels = await page.evaluate((tag) => {
const host = document.querySelector(tag)
const sr = host && host.shadowRoot
@ -126,9 +161,27 @@ for (const t of TARGETS) {
// EXCLUDED BY THE COUNT, NOT BY THE CLASS: a rail row that reports a tally is
// enumerating data. Excluding .fr-lbl wholesale would also blind this to the rail's
// own section headings, which ARE chrome and SHOULD be caught.
const isFacetValue = /\bfr-(lbl|cnt|bar|sw)\b/.test(cls)
&& !!child.parentElement?.querySelector?.('.fr-cnt')
const skip = excluded || isData || isFacetValue
// ⚠ AND THE SAME SHAPE APPEARS IN THE CHART LEGEND AND THE BAR LABELS — the third
// false finding, which is why this is now a RULE rather than a third special case.
// Tracing the gate's own walk (rather than re-scanning the page, which kept showing
// me the facet hits I had already excluded) found the survivors in .rd-lt inside
// .rd-li and .rd-bl inside .rd-bar. DashboardChartsRail.vue renders
// `<span class="rd-lt">{{ s.key }}</span><b>{{ s.value }}</b>` over chart.geometry:
// a DATA KEY beside its VALUE, exactly like the facet rail's label beside its count.
//
// THE GENERAL RULE: an element whose own text sits beside a sibling rendering a
// NUMBER is enumerating data, not naming the UI. A legend entry, a facet row and a
// bar label are all the same thing wearing different class names, and chasing them
// one class at a time is how I got this wrong three times.
// Section headings and column titles have no numeric sibling, so they stay in scope.
const numericSibling = (() => {
const sibs = child.parentElement ? [...child.parentElement.children] : []
return sibs.some((s) => s !== child
&& /^[\d,.\s%]+$/.test((s.textContent || '').trim())
&& (s.textContent || '').trim().length > 0)
})()
const isEnumeratedValue = numericSibling
const skip = excluded || isData || isEnumeratedValue
|| (child.getAttribute && child.getAttribute('dir') === 'ltr')
const own = [...child.childNodes].filter((n) => n.nodeType === 3)
.map((n) => n.textContent.trim()).filter(Boolean).join(' ').trim()
@ -174,6 +227,29 @@ for (const r of rows) {
console.log(` ${r.t} @ ${r.lc} labels ${r.labels} · comparable ${r.comparable ?? 0} · `
+ `${r.leaks ? `LEAKS ${r.leaks}` : 'no leaks'} · ${r.errs ?? 0} error(s)`)
}
// ── SELF-TEST VERDICT ───────────────────────────────────────────────────────
// Inverted on purpose: here a CAUGHT leak is the pass. Two were injected per leaf x locale — one
// beside a number (the case the numeric-sibling rule could swallow) and one without (the control).
// Both must be caught. Catching only the control would mean the rule is too broad and every real
// leak that happens to sit beside a count is invisible.
if (SELF_TEST) {
const caught = fails.filter((f) => f.includes(SELF_TEST_KEY)).length
const expected = rows.filter((r) => !r.skipped).length
console.log(`\nSELF-TEST: injected 2 leaks per combination (one beside a number, one not).`)
console.log(` combinations measured : ${expected}`)
console.log(` ${SELF_TEST_KEY} caught: ${caught}`)
if (!expected) { console.error('SELF-TEST: NOT MEASURED — no combination rendered.'); process.exit(2) }
if (caught < expected) {
console.error(`\nSELF-TEST: FAIL — the gate missed the injected leak in `
+ `${expected - caught} of ${expected} combination(s). An exclusion is too broad: a real leak`)
console.error(' beside a count would be invisible. Narrow the rule rather than shipping it.')
process.exit(1)
}
console.log(`\nSELF-TEST: PASS — every injected leak was caught, including the one beside a `
+ 'number, so the data/facet/enumerated exclusions narrow the population without blinding it.')
process.exit(0)
}
if (fails.length) {
console.error(`\nLEAF-ENGLISH-LEAKS: FAIL — ${fails.length} problem(s)`)
for (const f of fails.slice(0, 20)) console.error(` - ${f}`)

View file

@ -64,8 +64,24 @@ if (!body) {
console.error(' stopped testing anything.')
process.exit(1)
}
// …and every top-level helper of the same file that detectLocale CALLS, transitively. 09d03c60 moved
// the URL half into localeFromUrl(), and a gate that extracted detectLocale alone died with
// "localeFromUrl is not defined" on every case — reporting a working feature as broken. Following the
// calls keeps the gate on the shipped code whatever it is factored into.
const helpers = []
const seen = new Set(['detectLocale'])
const queue = [body]
while (queue.length) {
const code = queue.shift()
for (const [, name] of code.matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g)) {
if (seen.has(name)) continue
seen.add(name)
const fn = extractFunction(src, name)
if (fn) { const clean = fn.replace(/\bexport\s+/g, ''); helpers.push(clean); queue.push(clean) }
}
}
const make = new Function('SUPPORTED_LOCALES', 'window', 'document',
`${body}\n; return detectLocale;`)
`${helpers.join('\n')}\n${body}\n; return detectLocale;`)
const detect = (search, htmlLang) =>
make(SUPPORTED, { location: { search } }, { documentElement: { lang: htmlLang } })()

View file

@ -50,21 +50,25 @@ if (!existsSync(SRC)) {
const src = readFileSync(SRC, 'utf8')
// Each `await shot('name', '/path', { ...opts })` — capture the opts blob so we can read `vp`.
const RE = /shot\(\s*'([^']+)'\s*,\s*'([^']*)'\s*(?:,\s*(\{[\s\S]*?\}))?\s*\)/g
const RE = /shot\(\s*'([^']+)'\s*,\s*(?:'([^']*)'|`([^`]*)`)\s*(?:,\s*(\{[\s\S]*?\}))?\s*\)/g
const shots = []
for (const m of src.matchAll(RE)) {
const [, name, path, opts = ''] = m
const [, name, quoted, templated, opts = ''] = m
const path = quoted ?? templated // '…' or `/${TREE_NAME}/…` — compared as written, so either form is consistent
const vp = opts.match(/vp:\s*\{\s*width:\s*(\d+)\s*,\s*height:\s*(\d+)/)
shots.push({ name, path, vp: vp ? `${vp[1]}x${vp[2]}` : 'default' })
}
// A matcher that finds almost nothing reads exactly like a clean pass — see check-quoted-scripts.
const MIN_EXPECTED = 8
// FLOOR: every `await shot(` call must be parsed. If not, the matcher has drifted from the file (it did
// once: a codemod turned the paths into template literals and this gate saw 2 of 26), so the result is
// NOT MEASURED (exit 75), never a pass and never a product failure.
const CALLS = src.split('\n').filter((l, i, a) => /await shot\(/.test(l) && !/shot-floor: dynamic/.test(a[i - 1] || '')).length
console.log(`SHOT-DISTINCTNESS: ${shots.length} shot(s) declared.`)
if (shots.length < MIN_EXPECTED) {
console.error(`\nSHOT-DISTINCTNESS: FAIL — only ${shots.length} shots parsed (expected >= ${MIN_EXPECTED}).`)
console.error(' The MATCHER is broken, not the shot list. Fix the regex before trusting this.')
process.exit(1)
if (shots.length !== CALLS) {
console.error(`\nSHOT-DISTINCTNESS: NOT MEASURED — parsed ${shots.length} of ${CALLS} shot( calls in readme-shots.mjs.`)
console.error(' The MATCHER is out of date with the file, not the shot list. Fix the regex, then re-run.')
process.exit(75)
}
const bySignature = new Map()

View file

@ -80,7 +80,19 @@ function stamp(file) {
// Parse `await shot('name', '/path/...')` — the generator is the source of truth for its own map.
const src = readFileSync(SHOTS_SRC, 'utf8')
const shots = [...src.matchAll(/shot\(\s*'([^']+)'\s*,\s*'([^']*)'/g)].map(m => ({ name: m[1], path: m[2] }))
// Paths are written either as '...' or as `/${TREE_NAME}/...` (the tree name comes from the build
// config); accept both and resolve the placeholder, or a template-literal shot would be silently skipped.
const shots = [...src.matchAll(/shot\(\s*'([^']+)'\s*,\s*(['`])([^'`]*)\2/g)]
.map(m => ({ name: m[1], path: m[3].replace(/\$\{TREE_NAME\}/g, TREE_NAME) }))
// FLOOR: every `await shot(` call must be parsed, or the gate would silently check fewer shots than
// exist (it did: after the TREE_NAME codemod it compared 2 of 26 and passed). Mismatch = NOT MEASURED.
{
const calls = src.split('\n').filter((l, i, a) => /await shot\(/.test(l) && !/shot-floor: dynamic/.test(a[i - 1] || '')).length
if (shots.length !== calls) {
console.error(`SHOT-FRESHNESS: NOT MEASURED — parsed ${shots.length} of ${calls} shot( calls in readme-shots.mjs; the matcher is out of date with the file.`)
process.exit(75)
}
}
const stale = [], missing = [], skipped = []
let checked = 0

View file

@ -1451,6 +1451,10 @@ function main() {
// sibling it used to be was also a trap: it made a sibling-relative asset path resolve locally
// while it 404'd live (library/utils/embedBase.js records that incident).
const pubAssets = mkdtempSync(join(tmpdir(), `${TREE_NAME}-assets-`))
// …and removed even if this script dies part-way: an 'exit' handler runs on a crash too. A pool left
// behind by a failed build is ~50 MB in /tmp, and on a full disk (2026-09-25) that is what fails the NEXT build.
const dropPool = () => { try { rmSync(pubAssets, { recursive: true, force: true }) } catch { /* already gone */ } }
process.once('exit', dropPool)
// EVERY profile's previews, not just the 100-list's. This was hardcoded to
// my-upg-100-list, so country-posters shipped with zero thumbnails and its
// whole wall fell back to "poster not generated yet" placeholders even though

View file

@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* landing-preconditions — what must be true of a commit BEFORE it reaches main. Run by `main-capture scan`
* (via .main-capture/prepush) inside the lock, on the exact commit about to be pushed; any failure REFUSES
* the landing. A check beside the push can be skipped; a precondition of the push cannot.
*
* WHY (2026-09-25): check:composite-freshness found SEVEN composites stale on main — their committed
* bundles were not what their source builds. Cause: a fleet rebuild (d49b6c80) ran in a worktree that had
* not merged the dx-maps rename, so it wrote the OLD folder (public/doxa-maps/) and left public/dx-maps/
* stale; later landings resolved artifact conflicts by taking one side and rebuilding only a subset.
* Every one of those landings passed its own checks. Nothing refused them.
*
* PRECONDITIONS, in order (cheap first):
* 1. CLEAN the working tree equals HEAD for everything the checks read — otherwise they would judge
* files that are not in the commit.
* 2. ONE FOLDER public/ holds exactly the configured tree (vite.doxa-maps-build-config.json "name") and
* README.md. A build made under a different tree name writes a different folder: refused.
* 3. SRI every leaf's baked integrity matches the bundle it loads (check:sri).
* 4. FRESH every composite rebuilds to exactly its committed bytes (check:composite-freshness).
*
* USAGE node internal/landing-preconditions.mjs exit 0 = may land · 1 = refused
* DOXA_SKIP_FRESHNESS=1 skips (4) — for a DRY check only; main-capture scan never sets it.
*/
import { spawnSync } from 'node:child_process'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const refuse = (why, detail = '') => {
console.error(`LANDING-PRECONDITIONS: REFUSED — ${why}`)
if (detail) console.error(detail.split('\n').map((l) => ' ' + l).join('\n'))
process.exit(1)
}
const run = (cmd, args) => spawnSync(cmd, args, { cwd: ROOT, encoding: 'utf8', env: process.env })
// 1. CLEAN
const dirty = run('git', ['status', '--porcelain', '--', 'public', 'plugin-designs', 'library', 'internal', 'vite.config.js', 'vite.doxa-maps-build-config.json', 'package.json'])
if (dirty.status !== 0) refuse('could not read git status')
if (dirty.stdout.trim()) refuse('the working tree differs from the commit being landed — the checks would judge files that are not in it', dirty.stdout.trim().split('\n').slice(0, 8).join('\n'))
console.log('LANDING-PRECONDITIONS: 1 clean — the working tree is the commit being landed')
// 2. ONE FOLDER, and it is the configured one
// What LANDS is the commit, so read public/ from HEAD, not from the disk: a gitignored, untracked leftover
// (a pre-rename public/assets/ in an older worktree) refused a clean README-only landing (repo, 2026-09-25).
const tree = run('git', ['ls-tree', '--name-only', 'HEAD', 'public/'])
if (tree.status !== 0) refuse('could not read public/ from HEAD')
const entries = tree.stdout.split('\n').filter(Boolean).map((l) => l.replace(/^public\//, '')).sort()
const want = ['README.md', TREE_NAME].sort()
if (JSON.stringify(entries) !== JSON.stringify(want)) {
const extra = entries.filter((e) => !want.includes(e)), missing = want.filter((e) => !entries.includes(e))
refuse(`public/ must hold exactly ${want.join(' + ')} (the tree name comes from vite.doxa-maps-build-config.json)`,
[extra.length ? `extra: ${extra.join(', ')} — e.g. a build made under an OLD tree name writes its old folder` : '', missing.length ? `missing: ${missing.join(', ')}` : ''].filter(Boolean).join('\n'))
}
console.log(`LANDING-PRECONDITIONS: 2 one folder — public/ = README.md + ${TREE_NAME}/`)
// 3. SRI
const sri = run(process.execPath, ['internal/sri-consistency-check.mjs'])
if (sri.status !== 0) refuse('check:sri failed', (sri.stdout + sri.stderr).trim().split('\n').slice(-6).join('\n'))
console.log('LANDING-PRECONDITIONS: 3 sri — every leaf loads the bundle its integrity names')
// 4. FRESH
if (process.env.DOXA_SKIP_FRESHNESS === '1') {
console.log('LANDING-PRECONDITIONS: 4 fresh — SKIPPED (DOXA_SKIP_FRESHNESS=1; dry check only, never set by main-capture scan)')
} else {
const fresh = run(process.execPath, ['internal/check-composite-freshness.mjs'])
if (fresh.status !== 0) refuse(
'a committed composite is not what its source builds (stale consumers).\n' +
'README rule 5: ONE agent owns the full build. If your change touches library/ or anything compiled into\n' +
'other plugins, do not rebuild everyone yourself: commit it on your branch and hand the landing to studio\n' +
'(msg-agent studio "please land <branch>@<sha>: library change, consumers need rebuilding"). If only YOUR\n' +
'own plugin is stale, rebuild just it: BUNDLE=<name> bun x vite build && bun run tree && bun run fix:sri.',
(fresh.stdout + fresh.stderr).trim().split('\n').filter((l) => /✗|STALE|FAIL/.test(l)).slice(0, 8).join('\n'))
console.log('LANDING-PRECONDITIONS: 4 fresh — every composite rebuilds to its committed bytes')
}
console.log('LANDING-PRECONDITIONS: PASS — this commit may land.')

View file

@ -199,7 +199,10 @@ const rootEntries = [...new Set(files.map(f => f.rel.split('/')[0]))].sort()
for (const e of rootEntries) {
if (CONTRACT.has(e)) continue
const label = files.some(f => f.rel.startsWith(`${e}/`)) ? `${e}/` : e
hit(STRICT ? findings : warnings, 'contract', label, `outside the drop-in contract (${[...CONTRACT].join(', ')} only)`)
// ALWAYS a finding, not a strict-mode warning: public/ holding ONLY the output folder + README.md is a
// Driver decision (BUILD-OUTPUT-IS-ONE-FOLDER), not a style preference. MEASURED 2026-09-25: a rebuild
// under the OLD tree name re-added public/doxa-maps/ beside public/dx-maps/, and this gate only warned.
hit(findings, 'contract', label, `outside the drop-in contract (${[...CONTRACT].join(', ')} only)`)
}
// inventory

View file

@ -390,6 +390,9 @@ await shot('prod-gospel-resources-phone', `/${TREE_NAME}/gospel-resources/resour
await shot('exp-wall-of-people-groups', `/${TREE_NAME}/wall-of-people-groups/wall-of-people-groups/index.html`, { wait: 12000, minElements: 40 })
await shot('exp-prayer-walk-planner', `/${TREE_NAME}/prayer-walk-planner/prayer-walk-planner/index.html`, { wait: 12000, minElements: 40 })
// ---- LANGUAGES: UPG-100 in Arabic (?lang=ar), right-to-left — the most completely translated map ----
await shot('lang-arabic-upg100', `/${TREE_NAME}/my-upg-100-list/upg-100-list/index.html?lang=ar`, { wait: 12000, minElements: 200 })
// ---- DEPLOY2: mobile — the UPG-100 list as the phone app ----
// The mobile example is UPG-100 (ruled 2026-09-24; Simple Research was retired as the example because
// it was never built for phones). Shot once its owner marked the phone layout ready at 88a566f:
@ -403,6 +406,7 @@ await shot('deploy2-mobile-upg100', `/${TREE_NAME}/my-upg-100-list/upg-100-mfe/i
// server is up: DXLIFE_URL=http://127.0.0.1:<port> bun run shots. Without it the shot is skipped
// and the committed image stays as it is.
if (process.env.DXLIFE_URL) {
// shot-floor: dynamic — the URL comes from DXLIFE_URL at run time, so the gates cannot parse it
await shot('deploy3-js-embed-dxlife', process.env.DXLIFE_URL.replace(/\/$/, '') + '/research',
{ vp: { width: 1440, height: 1100 }, wait: 16000, tokenFree: true, minElements: 300 })
} else if (!ONLY || ONLY.includes('deploy3-js-embed-dxlife')) {

View file

@ -49,6 +49,8 @@ import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
// The data files this gate judges; declared to servesThisTree so their served bytes must equal disk.
const GOSPEL_DATA = ['assets/tables/people-group-pins.json', 'assets/tables/doxa-gospel-access.json']
const BASE = (process.env.BASE_URL || process.argv[2] || 'http://127.0.0.1:4173').replace(/\/+$/, '')
const GL = ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--enable-webgl', '--no-sandbox']
// The screened publishers. Anything else is a doctrine failure, not a typo.
@ -79,13 +81,22 @@ let server = null
const alive = async (b) => {
const r = await fetch(`${b}/${TREE_NAME}/gospel-resources/resources-map/index.html`).catch(() => null)
if (!(r && r.ok)) return false
const tree = await servesThisTree(b, REPO)
// The tables this gate judges are declared, so a server whose manifest matches but whose DATA does
// not (another worktree) is not adopted — the gate starts its own server for THIS checkout instead.
const tree = await servesThisTree(b, REPO, { assets: GOSPEL_DATA })
if (tree.ok) return true
console.log(`GOSPEL-MAP: not adopting ${b} — ${tree.why}`)
return false
return 'different' // reachable, but not THIS tree — distinct from "nothing there"
}
if (!(await alive(base))) {
const adopted = await alive(base)
if (adopted === 'different' && EXPLICIT) {
// You NAMED this server and it is another tree: that is NOT MEASURED, never a skip (exit 0 read as
// green while the gate had measured nothing of this checkout).
console.log(`GOSPEL-MAP: NOT MEASURED — ${base} is not serving THIS checkout (see above).`)
process.exit(75)
}
if (adopted !== true) {
if (EXPLICIT) {
console.log(`GOSPEL-MAP: SKIP — nothing served at ${base} (you named it explicitly, so none was started).`)
process.exit(0)
@ -175,22 +186,15 @@ try {
* The general weakness belongs to _serves-this-tree.mjs and every gate that relies on it;
* this only closes it for the two files this gate actually measures.
*/
const sameAsDisk = async (rel) => {
const res = await fetch(`${base}/${TREE_NAME}/${rel}`)
if (!res.ok) return `not served (${res.status})`
const served = await res.text()
const disk = readFileSync(resolve(REPO, `public/${TREE_NAME}/${rel}`), 'utf8')
return served === disk ? null : 'differs from this checkout — the server is another tree'
}
for (const rel of ['assets/tables/people-group-pins.json', 'assets/tables/doxa-gospel-access.json']) {
const why = await sameAsDisk(rel)
if (why) {
console.error(`GOSPEL-MAP: NOT MEASURED — ${rel} ${why}.`)
console.error(` Serving ${base}. Point BASE_URL at a server for THIS checkout, or stop the`)
console.error(' one holding the port; a PASS here would be about somebody else\'s data.')
if (server) server.kill()
process.exit(2)
}
// (The per-gate copy of this check that used to live here is now the shared helper's `assets`
// option — the weakness was in _serves-this-tree.mjs, so the fix is there, for every gate.)
const ident = await servesThisTree(base, REPO, { assets: GOSPEL_DATA })
if (!ident.ok) {
console.error(`GOSPEL-MAP: NOT MEASURED — ${ident.why}.`)
console.error(` Serving ${base}. Point BASE_URL at a server for THIS checkout, or stop the`)
console.error(' one holding the port; a PASS here would be about somebody else\'s data.')
if (server) server.kill()
process.exit(75)
}
const idxRes = await fetch(`${base}/${TREE_NAME}/assets/tables/people-group-pins.json`)

View file

@ -1,6 +1,6 @@
{
"%count% people groups in %countries% countries": "%count% مجموعة شعبية في %countries% دولة",
"%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.": "تضم %region% %count% مجموعة بشرية في %countries% دولة. يشير لون كل نقطة إلى عدد الأشخاص الذين يصلّون من أجل تلك المجموعة البشرية.",
"%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.": "تضم %region% %count% مجموعة شعبية في %countries% دولة. يشير لون كل نقطة إلى عدد الأشخاص الذين يصلّون من أجل تلك المجموعة الشعبية.",
"1+ People Praying": "شخص واحد أو أكثر يصلّي",
"100+ People Praying": "أكثر من 100 شخص يصلّون",
"Adopted": "متبنَّاة",
@ -12,11 +12,11 @@
"Countries": "الدول",
"Countries in this region": "الدول في هذه المنطقة",
"Country": "الدولة",
"Engaged": "مُخْدُومَة",
"Engaged": "مَخْدُومَة",
"Engagement Progress": "تقدُّم الانخراط",
"Every unengaged people group adopted by a church committed to prayer, giving, and sending": "كل مجموعة بشرية غير مبشرة تتبناها كنيسة ملتزمة بالصلاة والعطاء والإرسال",
"Every unengaged people group adopted by a church committed to prayer, giving, and sending": "كل مجموعة شعبية غير مَخْدُومَة تتبنّاها كنيسة ملتزمة بالصلاة والعطاء والإرسال",
"Exact Search": "بحث دقيق",
"Find unreached people groups by region and country.": "ابحث عن المجموعات البشرية غير المُبلَّغة حسب المنطقة والدولة.",
"Find unreached people groups by region and country.": "ابحث عن المجموعات الشعبية غير المبلَّغة حسب المنطقة والدولة.",
"Full Profile": "الملف الكامل",
"Goal": "الهدف",
"Hide Filters": "إخفاء المرشحات",
@ -25,18 +25,18 @@
"Load More": "تحميل المزيد",
"Loading results...": "النتائج قيد التحميل...",
"Map": "الخريطة",
"Mobilize 100+ people praying daily for each DOXA people group": "حشد أكثر من 100 شخص للصلاة يوميًا من أجل كل مجموعة بشرية من مجموعات دوكسا",
"Mobilize 100+ people praying daily for each DOXA people group": "حشد أكثر من 100 شخص للصلاة يوميًا من أجل كل مجموعة شعبية من مجموعات دوكسا",
"No One Praying": "لا أحد يصلّي",
"No options found": "لم يتم العثور على أي خيارات",
"No poster has been generated for this country yet.": "لم يُنشأ أي ملصق لهذه الدولة بعد.",
"No unengaged people groups by 2033": "الهدف هو القضاء على جميع الشعوب الغير مخدومة روحيا بحلول عام 2033",
"No unengaged people groups by 2033": "لا مجموعات شعبية غير مَخْدُومَة بحلول عام 2033",
"Not Adopted": "لم يتم انتماؤه",
"Other": "أخرى",
"Page Not Found": "الصفحة غير موجودة",
"People Group": "مجموعة بشرية",
"People Group": "مجموعة شعبية",
"People Groups": "مجموعات شعبية",
"People committed to praying": "الأشخاص الذين التزموا بالصلاة",
"Pick a region above, or click any dot to open that people group's country.": "اختر منطقة أعلاه، أو انقر على أي نقطة لفتح دولة تلك المجموعة البشرية.",
"Pick a region above, or click any dot to open that people group's country.": "اختر منطقة أعلاه، أو انقر على أي نقطة لفتح دولة تلك المجموعة الشعبية.",
"Pick a region or a country on the Regions List tab to narrow this dashboard.": "اختر منطقة أو دولة من تبويب قائمة المناطق لتضييق نطاق لوحة المعلومات هذه.",
"Population": "عدد السكان",
"Prayer Coverage": "تغطية الصلاة",
@ -52,7 +52,7 @@
"Select": "اختر",
"Select View": "اختر العرض",
"Show Filters": "إظهار عوامل التصفية",
"There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.": "توجد %count% مجموعات بشرية في %country%. تحقق من الخريطة لمعرفة المجموعات البشرية الواقعة على حدود هذه الدولة أيضًا.",
"There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.": "توجد %count% مجموعة شعبية في %country%. تحقق من الخريطة لمعرفة المجموعات الشعبية الواقعة على حدود هذه الدولة أيضًا.",
"Total": "المجموع",
"Type to search...": "اكتب ما تريد البحث عنه...",
"Unengaged people groups": "المجموعات الشعبية غير المَخْدُومَة",
@ -61,7 +61,7 @@
"WAGF Block": "كتلة WAGF",
"WAGF Region": "منطقة WAGF",
"people groups adopted": "مجموعات شعبية متبنَّاة",
"people groups engaged": "مجموعات شعبية مُخْدُومَة",
"people groups engaged": "مجموعات شعبية مَخْدُومَة",
"Outside this region": "خارج هذه المنطقة",
"Pray": "صلّي",
"Loading region dashboards…": "جارٍ تحميل لوحات المناطق…",

View file

@ -21,5 +21,6 @@
"sections": {
"within": "ضمن {selection}",
"allData": "كل بيانات DOXA"
}
},
"upg100Placeholder": "ابحث عن شعب أو قائمة UPG-100 أو منطقة…"
}

View file

@ -39,5 +39,11 @@
"tokenSourceNone": "لا يوجد رمز Mapbox: لا يمكن تحميل خرائط Mapbox (خرائط MapLibre لا تحتاج رمزًا)",
"tokenSecret": "هذا رمز سري (sk.…). الصق رمزًا عامًا (pk.…): هذه الصفحة عامة.",
"tokenNotPk": "يبدأ رمز Mapbox العام بـ pk.",
"tokenUrlFailed": "تعذّر الحصول على رمز من هذا الرابط: {why}"
"tokenUrlFailed": "تعذّر الحصول على رمز من هذا الرابط: {why}",
"settings": "الإعدادات",
"mapEngine": "محرّك الخرائط",
"engineDefault": "الافتراضي لكل خريطة",
"engineMapboxNote": "يحتاج إلى رمز",
"engineMaplibreNote": "لا يحتاج إلى رمز",
"tokenNotNeeded": "تم اختيار MapLibre: خرائطه لا تحتاج إلى رمز Mapbox"
}

View file

@ -21,5 +21,6 @@
"languageKind": "Sprache",
"dialect": "Dialekt",
"religionKind": "Religion"
}
},
"upg100Placeholder": "Volksgruppe, UPG-100-Liste oder Region suchen…"
}

View file

@ -39,5 +39,11 @@
"tokenSourceNone": "Kein Mapbox-Token: Mapbox-Karten können nicht laden (MapLibre-Karten brauchen keinen)",
"tokenSecret": "Das ist ein geheimer Token (sk.…). Fügen Sie einen öffentlichen ein (pk.…): Diese Seite ist öffentlich.",
"tokenNotPk": "Ein öffentlicher Mapbox-Token beginnt mit pk.",
"tokenUrlFailed": "Von dieser URL kam kein Token: {why}"
"tokenUrlFailed": "Von dieser URL kam kein Token: {why}",
"settings": "Einstellungen",
"mapEngine": "Karten-Engine",
"engineDefault": "Je Karte eigenes",
"engineMapboxNote": "braucht einen Token",
"engineMaplibreNote": "kein Token nötig",
"tokenNotNeeded": "MapLibre ist gewählt: Seine Karten brauchen keinen Mapbox-Token"
}

View file

@ -21,5 +21,6 @@
"sections": {
"within": "Within {selection}",
"allData": "All DOXA Data"
}
},
"upg100Placeholder": "Search people group, UPG-100 list, or region…"
}

View file

@ -39,5 +39,11 @@
"tokenSourceNone": "No Mapbox token: Mapbox maps cannot load (MapLibre maps need none)",
"tokenSecret": "That is a secret token (sk.…). Paste a public one (pk.…): this page is public.",
"tokenNotPk": "A Mapbox public token starts with pk.",
"tokenUrlFailed": "Could not get a token from that URL: {why}"
"tokenUrlFailed": "Could not get a token from that URL: {why}",
"settings": "Settings",
"mapEngine": "Map engine",
"engineDefault": "Each map’s own",
"engineMapboxNote": "needs a token",
"engineMaplibreNote": "no token needed",
"tokenNotNeeded": "MapLibre is selected: its maps need no Mapbox token"
}

View file

@ -21,5 +21,6 @@
"sections": {
"within": "Dentro de {selection}",
"allData": "Todos los datos DOXA"
}
},
"upg100Placeholder": "Buscar pueblo, lista UPG-100 o región…"
}

View file

@ -39,5 +39,11 @@
"tokenSourceNone": "Sin token de Mapbox: los mapas de Mapbox no cargan (los de MapLibre no lo necesitan)",
"tokenSecret": "Ese es un token secreto (sk.…). Pega uno público (pk.…): esta página es pública.",
"tokenNotPk": "Un token público de Mapbox empieza por pk.",
"tokenUrlFailed": "No se pudo obtener un token de esa URL: {why}"
"tokenUrlFailed": "No se pudo obtener un token de esa URL: {why}",
"settings": "Ajustes",
"mapEngine": "Motor de mapas",
"engineDefault": "El de cada mapa",
"engineMapboxNote": "necesita un token",
"engineMaplibreNote": "no necesita token",
"tokenNotNeeded": "MapLibre está seleccionado: sus mapas no necesitan token de Mapbox"
}

View file

@ -21,5 +21,6 @@
"sections": {
"within": "Dans {selection}",
"allData": "Toutes les données DOXA"
}
},
"upg100Placeholder": "Rechercher un peuple, une liste UPG-100 ou une région…"
}

View file

@ -39,5 +39,11 @@
"tokenSourceNone": "Aucun jeton Mapbox : les cartes Mapbox ne peuvent pas charger (MapLibre n’en a pas besoin)",
"tokenSecret": "C’est un jeton secret (sk.…). Collez un jeton public (pk.…) : cette page est publique.",
"tokenNotPk": "Un jeton public Mapbox commence par pk.",
"tokenUrlFailed": "Impossible d’obtenir un jeton depuis cette URL : {why}"
"tokenUrlFailed": "Impossible d’obtenir un jeton depuis cette URL : {why}",
"settings": "Paramètres",
"mapEngine": "Moteur de carte",
"engineDefault": "Celui de chaque carte",
"engineMapboxNote": "nécessite un jeton",
"engineMaplibreNote": "aucun jeton requis",
"tokenNotNeeded": "MapLibre est sélectionné : ses cartes n’ont pas besoin de jeton Mapbox"
}

View file

@ -21,5 +21,6 @@
"languageKind": "भाषा",
"dialect": "बोली",
"religionKind": "धर्म"
}
},
"upg100Placeholder": "जनसमूह, UPG-100 सूची या क्षेत्र खोजें…"
}

View file

@ -39,5 +39,11 @@
"tokenSourceNone": "कोई Mapbox टोकन नहीं: Mapbox मानचित्र लोड नहीं हो सकते (MapLibre को इसकी ज़रूरत नहीं)",
"tokenSecret": "यह एक गुप्त टोकन है (sk.…)। सार्वजनिक टोकन (pk.…) चिपकाएँ: यह पेज सार्वजनिक है।",
"tokenNotPk": "Mapbox सार्वजनिक टोकन pk. से शुरू होता है",
"tokenUrlFailed": "उस URL से टोकन नहीं मिल सका: {why}"
"tokenUrlFailed": "उस URL से टोकन नहीं मिल सका: {why}",
"settings": "सेटिंग्स",
"mapEngine": "मानचित्र इंजन",
"engineDefault": "हर मानचित्र का अपना",
"engineMapboxNote": "टोकन चाहिए",
"engineMaplibreNote": "टोकन की ज़रूरत नहीं",
"tokenNotNeeded": "MapLibre चुना गया है: इसके मानचित्रों को Mapbox टोकन नहीं चाहिए"
}

View file

@ -21,5 +21,6 @@
"languageKind": "Lingua",
"dialect": "Dialetto",
"religionKind": "Religione"
}
},
"upg100Placeholder": "Cerca popolo, elenco UPG-100 o regione…"
}

View file

@ -39,5 +39,11 @@
"tokenSourceNone": "Nessun token Mapbox: le mappe Mapbox non si caricano (MapLibre non ne ha bisogno)",
"tokenSecret": "Questo è un token segreto (sk.…). Incolla un token pubblico (pk.…): questa pagina è pubblica.",
"tokenNotPk": "Un token pubblico Mapbox inizia con pk.",
"tokenUrlFailed": "Impossibile ottenere un token da quell’URL: {why}"
"tokenUrlFailed": "Impossibile ottenere un token da quell’URL: {why}",
"settings": "Impostazioni",
"mapEngine": "Motore mappe",
"engineDefault": "Quello di ogni mappa",
"engineMapboxNote": "richiede un token",
"engineMaplibreNote": "nessun token necessario",
"tokenNotNeeded": "MapLibre è selezionato: le sue mappe non richiedono un token Mapbox"
}

View file

@ -21,5 +21,6 @@
"sections": {
"within": "Dentro de {selection}",
"allData": "Todos os dados DOXA"
}
},
"upg100Placeholder": "Procurar povo, lista UPG-100 ou região…"
}

View file

@ -39,5 +39,11 @@
"tokenSourceNone": "Sem token do Mapbox: os mapas Mapbox não carregam (os MapLibre não precisam)",
"tokenSecret": "Esse é um token secreto (sk.…). Cole um público (pk.…): esta página é pública.",
"tokenNotPk": "Um token público do Mapbox começa por pk.",
"tokenUrlFailed": "Não foi possível obter um token desse URL: {why}"
"tokenUrlFailed": "Não foi possível obter um token desse URL: {why}",
"settings": "Definições",
"mapEngine": "Motor de mapas",
"engineDefault": "O de cada mapa",
"engineMapboxNote": "precisa de um token",
"engineMaplibreNote": "não precisa de token",
"tokenNotNeeded": "MapLibre está selecionado: os seus mapas não precisam de token do Mapbox"
}

View file

@ -21,5 +21,6 @@
"languageKind": "Limbă",
"dialect": "Dialect",
"religionKind": "Religie"
}
},
"upg100Placeholder": "Caută popor, listă UPG-100 sau regiune…"
}

View file

@ -39,5 +39,11 @@
"tokenSourceNone": "Niciun token Mapbox: hărțile Mapbox nu se pot încărca (MapLibre nu are nevoie)",
"tokenSecret": "Acesta este un token secret (sk.…). Lipește unul public (pk.…): pagina este publică.",
"tokenNotPk": "Un token public Mapbox începe cu pk.",
"tokenUrlFailed": "Nu s-a putut obține un token de la acea adresă: {why}"
"tokenUrlFailed": "Nu s-a putut obține un token de la acea adresă: {why}",
"settings": "Setări",
"mapEngine": "Motor hărți",
"engineDefault": "Al fiecărei hărți",
"engineMapboxNote": "necesită un token",
"engineMaplibreNote": "nu necesită token",
"tokenNotNeeded": "MapLibre este selectat: hărțile sale nu necesită token Mapbox"
}

View file

@ -21,5 +21,6 @@
"sections": {
"within": "В пределах {selection}",
"allData": "Все данные DOXA"
}
},
"upg100Placeholder": "Поиск народа, списка UPG-100 или региона…"
}

View file

@ -39,5 +39,11 @@
"tokenSourceNone": "Нет токена Mapbox: карты Mapbox не загрузятся (MapLibre он не нужен)",
"tokenSecret": "Это секретный токен (sk.…). Вставьте публичный (pk.…): эта страница общедоступна.",
"tokenNotPk": "Публичный токен Mapbox начинается с pk.",
"tokenUrlFailed": "Не удалось получить токен по этому адресу: {why}"
"tokenUrlFailed": "Не удалось получить токен по этому адресу: {why}",
"settings": "Настройки",
"mapEngine": "Движок карт",
"engineDefault": "Свой у каждой карты",
"engineMapboxNote": "нужен токен",
"engineMaplibreNote": "токен не нужен",
"tokenNotNeeded": "Выбран MapLibre: его картам не нужен токен Mapbox"
}

View file

@ -21,5 +21,6 @@
"languageKind": "语言",
"dialect": "方言",
"religionKind": "宗教"
}
},
"upg100Placeholder": "搜索民族、UPG-100 名单或区域…"
}

View file

@ -39,5 +39,11 @@
"tokenSourceNone": "没有 Mapbox 令牌:Mapbox 地图无法加载(MapLibre 地图不需要)",
"tokenSecret": "这是秘密令牌 (sk.…)。请粘贴公开令牌 (pk.…):此页面是公开的。",
"tokenNotPk": "Mapbox 公开令牌以 pk. 开头",
"tokenUrlFailed": "无法从该网址获取令牌:{why}"
"tokenUrlFailed": "无法从该网址获取令牌:{why}",
"settings": "设置",
"mapEngine": "地图引擎",
"engineDefault": "各地图自身的",
"engineMapboxNote": "需要令牌",
"engineMaplibreNote": "无需令牌",
"tokenNotNeeded": "已选择 MapLibre:其地图无需 Mapbox 令牌"
}

View file

@ -101,11 +101,15 @@ const _stlLoaded = {}
// Anything already absolute, protocol-relative or root-relative is passed through untouched; if no
// /doxa-maps/ segment is found (a foreign mount) the original string is returned, so this can only
// ever fix a path, never break one that was already working.
// eslint-disable-next-line no-undef
const TREE_SEGMENT = (typeof __DOXA_TREE_NAME__ !== 'undefined' ? __DOXA_TREE_NAME__ : 'doxa-maps').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
function resolveBundleSrc(url) {
if (!url || /^(https?:)?\/\//.test(url) || url.startsWith('/')) return url
const bare = url.replace(/^(\.\.\/)+/, '').replace(/^\.\//, '')
// (1) The document is somewhere inside the tree — anchor on its own /doxa-maps/ segment.
const m = /^(.*\/doxa-maps\/)/.exec(window.location.pathname)
// (1) The document is somewhere inside the tree — anchor on its own /<tree>/ segment. The name is
// baked in at build time (vite define __DOXA_TREE_NAME__, from the build config), never a literal:
// a hardcoded '/doxa-maps/' here broke every composite's nested bundles on a renamed tree.
const m = new RegExp('^(.*/' + TREE_SEGMENT + '/)').exec(window.location.pathname)
if (m) return m[1] + bare
// (2) THE DOCUMENT IS NOT INSIDE THE TREE AT ALL, so there is no segment to anchor on.
// Driver-reported 2026-09-23: <plugin-studio> mounts at the SERVER ROOT (/index.html) in both

View file

@ -87,6 +87,7 @@
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs",
"check:label-joins": "node internal/check-label-joins.mjs",
"check:leaf-english-leaks": "node internal/check-leaf-english-leaks.mjs",
"check:leaf-english-leaks:self-test": "node internal/check-leaf-english-leaks.mjs --self-test",
"check:leaves-mount": "node internal/check-gospel-leaves-mount.mjs",
"check:leaves-mount:poison": "node internal/check-gospel-leaves-mount.mjs --poison",
"check:legend-export-reachable": "node internal/check-legend-export-reachable.mjs",
@ -128,6 +129,8 @@
"check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs",
"check:prayer-walk-planner": "node internal/check-prayer-walk-planner.mjs",
"check:print-geometry": "node internal/check-print-geometry.mjs",
"check:pin-dim-slider": "node plugin-designs/doxa-research-map/scripts/check-pin-dim-slider.mjs",
"check:pin-dim-slider": "the side menu's pin-dim slider and the legend's dimming must COMPOSE, not compete. Both want the pin layer's circle-opacity, so there is ONE writer and the value is the PRODUCT: slider x (1 matched, 0 dimmed). Asserts the LIVE paint property, not the store: flat 1 with clustering off, flat 0.5 with clustering on, ['case', expr, 0.5, 0] once something is dimmed, and ['case', expr, 1, 0] when the slider returns to 1. Dims by RELIGION deliberately - a region selection sets a layer FILTER rather than an opacity expression, so testing that path would report a failure about code that never writes opacity.",
"check:provider-switch": "node internal/check-provider-switch.mjs",
"check:public-hygiene": "node internal/public-hygiene-check.mjs",
"check:published": "node internal/check-published-matches-build.mjs",
@ -176,7 +179,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
"library-index": "node internal/generate-library-index.mjs",
"packet-fixture": "node internal/generate-packet-fixture.mjs",

View file

@ -1113,11 +1113,7 @@ function onSemanticTreeSelect(node) {
if (m && m.getLayer('language-family-pins')) {
try { m.setFilter('language-family-pins', null) } catch (_) {}
_syncHitboxFilter(m, null)
m.setPaintProperty('language-family-pins', 'circle-opacity', 1)
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
if (m.getLayer('language-family-pins-shadow')) {
m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', 1)
}
applyPinOpacity(m, null)
mapLayers.syncGlowFilter(null)
}
// Reset region polygon highlight when deselecting on the WAGF Regions tab.
@ -1147,8 +1143,7 @@ function onSemanticTreeSelect(node) {
if (m && m.getLayer('language-family-pins') && node.filter) {
try { m.setFilter('language-family-pins', node.filter) } catch (_) {}
_syncHitboxFilter(m, node.filter)
m.setPaintProperty('language-family-pins', 'circle-opacity', 1)
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
applyPinOpacity(m, null)
mapLayers.syncGlowFilter(node.filter)
}
// Mirror selection into mapStore (loop-safe via legend's _lastInternalId).
@ -1172,19 +1167,8 @@ function onSemanticTreeSelect(node) {
// hitbox stays full-size for every pin and the user can tap an
// apparent empty area to select a hidden pin.
_syncHitboxFilter(m, node.filter)
// Dim via case expression — matched pins at 1.0, others at 0 (matches
// applyDimFilter values).
m.setPaintProperty('language-family-pins', 'circle-opacity', [
'case', node.filter, 1, 0
])
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', [
'case', node.filter, 1, 0
])
if (m.getLayer('language-family-pins-shadow')) {
m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', [
'case', node.filter, 1, 0
])
}
// Dim via the one writer: matched pins at the slider's scale, others at 0.
applyPinOpacity(m, node.filter)
mapLayers.syncGlowFilter(node.filter)
}
}
@ -1198,8 +1182,7 @@ function onSemanticTreeSelect(node) {
if (m && m.getLayer('language-family-pins') && node.filter) {
try { m.setFilter('language-family-pins', node.filter) } catch (_) {}
_syncHitboxFilter(m, node.filter)
m.setPaintProperty('language-family-pins', 'circle-opacity', 1)
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
applyPinOpacity(m, null)
}
// Mirror region selection into mapStore (for geocoder / external sync).
if (id.startsWith('region:')) mapStore.selectRegion?.(id.slice(7))
@ -1604,15 +1587,49 @@ function onLegendHighlight(evt) {
mapFly.fitBounds?.([[minLng, minLat], [maxLng, maxLat]], 60, { maxZoom })
}
// ─── ONE WRITER FOR PIN OPACITY ──────────────────────────────────────────────
// ★ THE SLIDER AND THE LEGEND DIMMING DO NOT COMPETE, THEY COMPOSE.
// Two features want this one paint property: the side menu's dim slider (fade the individual pins
// while clustering is on, so the clusters read) and legend dimming (hide everything that is not the
// selected row). Written as two owners they would overwrite each other — the slider would erase
// dimming, or dimming would erase the slider, depending on click order. So there is exactly one
// writer and the value is the PRODUCT: opacity = slider × (1 for a matched pin, 0 for a dimmed one).
// The slider scales the whole layer; dimming still dims relative to it; neither silently wins.
//
// The slider was inert before this (mapStore never declared clusterPinOpacity, so dragging it
// changed a number nobody read) — the same defect as the clustering picker beside it, and found
// while fixing that one.
let _pinMatchExpr = null // the current dim expression, or null for "show everything"
function pinBaseAlpha() {
// The slider only applies while clustering is actually drawing — it is hidden in the side menu
// when clustering is off, and applying it then would dim a map with nothing to dim FOR.
const on = mapStore?.showClusters && (mapStore?.clusteringMode || 'off') !== 'off'
const a = Number(mapStore?.clusterPinOpacity)
return on && Number.isFinite(a) ? Math.min(1, Math.max(0, a)) : 1
}
// matchExpr: an expression to dim by, null to clear dimming, undefined to re-apply what is current
// (which is what the slider's watcher needs — it changes the scale, not the selection).
function applyPinOpacity(m, matchExpr) {
if (matchExpr !== undefined) _pinMatchExpr = matchExpr || null
if (!m || !m.getLayer?.('language-family-pins')) return
const a = pinBaseAlpha()
const value = _pinMatchExpr ? ['case', _pinMatchExpr, a, 0] : a
try {
m.setPaintProperty('language-family-pins', 'circle-opacity', value)
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', value)
if (m.getLayer('language-family-pins-shadow')) {
m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', value)
}
} catch (_) { /* style still loading — the next apply will carry it */ }
}
watch(() => [mapStore?.clusterPinOpacity, mapStore?.clusteringMode, mapStore?.showClusters],
() => applyPinOpacity(map.value))
// Last-clicked highlight key per kind — clicking the same row again clears.
const _lastHL = { family: null, language: null, dialect: null, region: null, prayer: null, engagement: null, adoption: null }
function clearAllHighlights(m) {
m.setPaintProperty('language-family-pins', 'circle-opacity', 1)
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
if (m.getLayer('language-family-pins-shadow')) {
m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', 1)
}
applyPinOpacity(m, null)
m.setFilter('language-family-pins', null)
_syncHitboxFilter(m, null)
mapLayers.syncGlowFilter(null)
@ -1728,8 +1745,7 @@ function applyDimFilter(detail) {
// only pins within that region" UX request (2026-04-26).
m.setFilter('language-family-pins', ['==', ['get', 'doxaRegion'], expectedValue])
_syncHitboxFilter(m, ['==', ['get', 'doxaRegion'], expectedValue])
m.setPaintProperty('language-family-pins', 'circle-opacity', 1)
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
applyPinOpacity(m, null)
if (m.getLayer('regions-fill')) {
// Build a match expression on iso_3166_1_alpha_3 (vector tile property) to
// highlight only the countries belonging to the selected WAGF region.
@ -1832,17 +1848,7 @@ function applyDimFilter(detail) {
matchExpr = ['==', ['get', property], expectedValue]
}
m.setPaintProperty('language-family-pins', 'circle-opacity', [
'case', matchExpr, 1, 0
])
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', [
'case', matchExpr, 1, 0
])
if (m.getLayer('language-family-pins-shadow')) {
m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', [
'case', matchExpr, 1, 0
])
}
applyPinOpacity(m, matchExpr)
// Restrict the invisible hitbox layer to the matched bucket so dimmed
// (opacity-0) pins are no longer hit-testable. Without this the user
// can tap on apparent empty space and select a hidden pin, and the
@ -2070,6 +2076,14 @@ if (typeof window !== 'undefined') {
// many features are actually RENDERED right now. Layers are found by prefix rather than from a
// list, so a clustering layer added later shows up here without anyone remembering to add it.
// Same shape and purpose as the two handles beside it.
// THE PIN LAYER'S ACTUAL OPACITY EXPRESSION, for the same reason as the handle below: the slider
// and legend dimming COMPOSE into one paint property, and "the store value changed" is not evidence
// that they composed. Returns whatever GL is holding — a number when nothing is dimmed, a ['case',…]
// when something is.
window.__researchMapPinOpacity = () => {
try { return map.value?.getPaintProperty?.('language-family-pins', 'circle-opacity') ?? null } catch (_) { return null }
}
window.__researchMapClusterDraw = () => {
const m = map.value
const out = {}

View file

@ -0,0 +1,168 @@
#!/usr/bin/env node
/**
* check-pin-dim-slider.mjs — the side menu's pin-dim slider and the legend's dimming must COMPOSE,
* not compete, and the slider must actually reach the map.
*
* THE DEFECT. The slider wrote mapStore.clusterPinOpacity, which mapStore never declared, so
* dragging it changed a number nobody read — the same shape of defect as the clustering picker
* beside it, found while fixing that one. And the naive fix would have created a worse one: the pin
* layer's circle-opacity is ALSO owned by legend dimming (a ['case', …, 1, 0] expression), so a flat
* slider value written by a second owner would silently erase dimming, or be erased by it, depending
* on which the reader touched last.
*
* ★ SO THE VALUE IS A PRODUCT, AND THAT IS WHAT THIS ASSERTS: opacity = slider × (1 matched, 0
* dimmed). The slider scales the whole layer; dimming still dims relative to it; neither wins.
*
* ASSERTIONS, reading the LIVE paint property rather than the store:
* clustering off a plain 1 — the slider is hidden then, and dimming a map with nothing
* to dim FOR would be a regression of its own.
* clustering on, 0.5 a plain 0.5.
* + a dimming selection ['case', <expr>, 0.5, 0] — dimming survives the slider.
* slider back to 1.0 ['case', <expr>, 1, 0] — the slider survives dimming.
*
* Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail.
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const TAG = 'PIN-DIM-SLIDER'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) }
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
let chromium
try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const bundle = (manifest.bundles || []).find((b) => b.bundle === 'doxa-research-map')
const profile = bundle && (bundle.profiles || []).find((p) => p.iframeUrl && /research-map/.test(p.iframeUrl))
if (!profile) skip('the manifest declares no research-map leaf to load.')
const freePort = () => new Promise((ok) => {
const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) })
})
const PORT = await freePort()
const TREE = `http://127.0.0.1:${PORT}/doxa-maps`
const LEAF = `${TREE}/${String(profile.iframeUrl).replace(/^\.\//, '')}`
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT)], { cwd: ROOT, stdio: 'ignore' })
const cleanup = () => { try { tree.kill() } catch { /* already gone */ } }
await new Promise((r) => setTimeout(r, 900))
if (!await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false)) {
cleanup(); skip('could not serve the built tree locally.')
}
const { requireThisTree } = await import(join(ROOT, 'internal', '_serves-this-tree.mjs'))
await requireThisTree(TREE, ROOT, TAG)
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
const fails = []
const lines = []
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
await page.goto(LEAF, { waitUntil: 'domcontentloaded', timeout: 60000 })
const out = await page.evaluate(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
let read = null
for (let i = 0; i < 120 && !read; i++) {
if (typeof window.__researchMapPinOpacity === 'function') read = window.__researchMapPinOpacity
else await sleep(500)
}
if (!read) return { skip: 'the map never exposed its pin-opacity report' }
for (let i = 0; i < 120; i++) {
if ((window.__researchMapVocabulary?.('religionFamily') || []).length) break
await sleep(500)
}
// Fresh walk each time: the slider is behind v-if="clusterMode !== 'off'", so it does not EXIST
// until a clustering mode is chosen. Caching the visited set across calls (as the clustering gate
// can) would mean searching a DOM snapshot taken before the element was created — which is how
// this gate first reported "the slider is not in the DOM" on a build where it works.
const findAll = (sel) => {
const seen = new Set()
const hits = []
const walk = (root) => {
if (!root || seen.has(root)) return
seen.add(root)
for (const el of root.querySelectorAll('*')) {
if (el.matches && el.matches(sel)) hits.push(el)
if (el.shadowRoot) walk(el.shadowRoot)
}
}
walk(document)
return hits
}
const buttons = findAll('.rm-cluster-btn')
if (buttons.length !== 4) return { skip: `expected 4 clustering buttons, found ${buttons.length}` }
const setSlider = (v) => {
const slider = findAll('#rm-pin-op')[0] || findAll('.rm-slider-row input[type=range]')[0]
if (!slider) return false
slider.value = String(v)
slider.dispatchEvent(new Event('input', { bubbles: true }))
return true
}
const host = document.querySelector('doxa-research-map')
// ★ DIM BY RELIGION, NOT BY REGION, AND THAT IS NOT ARBITRARY. MEASURED while writing this: a
// REGION selection does not dim at all — it sets a layer FILTER, so non-matching pins are
// removed rather than faded, and the opacity stays flat. Only the case-expression dim paths
// (religion / prayer / engagement / adoption / language-family) compose with the slider, so
// testing region here would have reported "the slider erased the dimming" about a path that
// never writes opacity.
const dim = () => {
let cfg = {}
try { cfg = JSON.parse(host.getAttribute('profile-config') || '{}') } catch (_) { cfg = {} }
cfg.externalFilters = { region: '', religion: 'Buddhism', language: '' }
host.setAttribute('profile-config', JSON.stringify(cfg))
}
const out = {}
buttons[0].click(); await sleep(4000) // Off
out.off = read()
buttons[2].click(); await sleep(8000) // MST — the slider only applies while clustering draws
if (!setSlider(0.5)) return { skip: 'the pin-dim slider never appeared after choosing a clustering mode' }
await sleep(2500)
out.clusteredHalf = read()
dim(); await sleep(9000)
out.dimmedHalf = read()
setSlider(1); await sleep(2500)
out.dimmedFull = read()
return out
})
if (out.skip) { await browser.close(); cleanup(); skip(out.skip) }
const show = (v) => JSON.stringify(v)
lines.push(` · clustering off ${show(out.off)}`)
lines.push(` · clustering on, 0.5 ${show(out.clusteredHalf)}`)
lines.push(` · + dimming, 0.5 ${show(out.dimmedHalf)}`)
lines.push(` · + dimming, 1.0 ${show(out.dimmedFull)}`)
const isCase = (v) => Array.isArray(v) && v[0] === 'case' && v.length === 4
if (out.off !== 1) fails.push(`with clustering off the pins should be fully opaque, got ${show(out.off)} — the slider is applying where it is not even shown.`)
if (out.clusteredHalf !== 0.5) fails.push(`the slider at 0.5 with clustering on should give a flat 0.5, got ${show(out.clusteredHalf)} — the slider is not reaching the map.`)
if (!isCase(out.dimmedHalf)) fails.push(`dimming while the slider is at 0.5 should give a ['case', …] expression, got ${show(out.dimmedHalf)} — the slider ERASED the dimming.`)
else {
if (out.dimmedHalf[2] !== 0.5) fails.push(`matched pins should render at the slider's 0.5 while dimming, got ${show(out.dimmedHalf[2])} — the dimming ERASED the slider.`)
if (out.dimmedHalf[3] !== 0) fails.push(`dimmed pins should render at 0, got ${show(out.dimmedHalf[3])}.`)
}
if (!isCase(out.dimmedFull)) fails.push(`returning the slider to 1.0 should keep the dimming expression, got ${show(out.dimmedFull)} — the slider erased it on the way back.`)
else if (out.dimmedFull[2] !== 1) fails.push(`at slider 1.0 matched pins should be fully opaque, got ${show(out.dimmedFull[2])}.`)
} catch (err) {
fails.push(`the measurement itself failed: ${err && err.message}`)
} finally {
await browser.close().catch(() => {})
cleanup()
}
for (const l of lines) console.log(l)
if (fails.length) {
console.log(`\n${TAG}: FAIL`)
for (const f of fails) console.log(` ✗ ${f}`)
console.log('\n opacity = slider × (1 matched, 0 dimmed). One writer, one product — see applyPinOpacity.')
process.exit(1)
}
console.log(`${TAG}: PASS — the slider scales the layer, dimming still dims relative to it, and neither erases the other.`)

View file

@ -72,6 +72,10 @@ import ThemeToggleButton from '@map/components/map-controls/ThemeToggleButton.vu
import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { ensureEngineScript, engineGlobal, engineStylesheetUrl } from '@map/composables/useMapInstance.js'
// Shapes Arabic / Persian / Hebrew BASEMAP labels. A raw map inherits nothing from the bundle
// entry, so this profile has to install it itself — see the call site below for why the entry's
// registration only ever worked by accident.
import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
import { resolveNamedStyle } from '@map/styles/registry.js'
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
import { lists as clusterLists, asia_lists as clusterAsiaLists, uncovered_lists as clusterUncoveredLists } from './data/clusters.json'
@ -335,8 +339,7 @@ const LEGEND_TABS = [
// should meet the lists first and reach for Region only to sort them.
const LEGEND_DEFAULT_TAB = 1
const geocoderContext = 'people-groups'
const geocoderPlaceholder = 'Search people group, zone, or territory…'
const geocoderContext = 'people-groups'
let searchIndex = [] // [{ label, kind, target, _hay }] (gate handle)
const SEARCH_LIMIT = 8
@ -370,6 +373,17 @@ const { locale: packetLocale, t } = useI18n()
// COMPUTED, not a constant: a locale change must move it. A `const` captured at setup would keep
// the mounting language forever, which is the subtler half of the same bug.
const LEGEND_TITLE = computed(() => t('legend.title.upg100List'))
// ★ THE SEARCH PLACEHOLDER WAS THE LAST ENGLISH STRING ON THE ARABIC SCREEN. repo's ?lang=ar
// screenshot was clean except for this one hardcoded literal.
// ⚠ A computed, and declared HERE rather than beside geocoderContext, because t() is not
// initialised until line 359 — a const at 339 would capture the fallback and never re-evaluate
// when the locale changes.
// NO NEW TERMINOLOGY: composed from wording this product already ships and has already approved —
// search.kinds.peopleGroup for the group, tabs.upg100Zone for what this plugin calls a "zone"
// (its legend names them "UPG-100 Lists" / "قوائم UPG-100" / "UPG-100 名单"), and
// search.kinds.region for the territory. That is why this could be translated and the UUPGs
// column header could not: there, no source existed for the acronym.
const geocoderPlaceholder = computed(() => t('search.upg100Placeholder'))
const DEAF_COUNT = 123
// Shadow-root CSS. (No backticks inside this string — a stray backtick closes the
@ -1576,6 +1590,23 @@ onMounted(async () => {
// Load the engine the PROVIDER selects, not the one this file used to assume.
try { await ensureEngineScript(upgProvider) } catch (_) { /* fall through to the !gl notice below */ }
const gl = engineGlobal(upgProvider) || window.mapboxgl
// ★ SHAPE RTL BASEMAP LABELS — HERE, not in index.js, and not after the map exists.
// This profile builds its map RAW (`new gl.Map` below) instead of going through
// useMapInstance, so it inherits none of the entry's side effects. It appeared to work only
// because the leaf head PRELOADED the Mapbox peer, which made an entry-time registration land
// in time; studio is removing that preload, and with the engine injected lazily an index.js
// call runs before `gl` exists and does nothing at all.
//
// ⚠ IT IS NOT A UI-LANGUAGE FEATURE, which is why it matters more than it looks. A basemap
// draws each place in its OWN script, so over the Middle East, Iran and Israel MOST labels are
// Arabic, Persian or Hebrew for EVERY viewer in EVERY UI language. Unshaped, they render
// reversed — a WRONG NAME for exactly the groups this map is about — and it fails silently:
// no error, no warning, just mirrored text that reads as a font problem to anyone who does not
// know the script. Arab World and Persian-Median are both in scope here.
//
// Idempotent: Mapbox throws if the plugin is set twice in a session and the helper swallows it,
// so this is safe alongside any other caller on the same page.
if (gl) installRtlTextPlugin(gl)
const token = mapboxToken?.value || profileConfig?.value?.tk || ''
// Degraded exits: keep the terse telemetry in `status`, put the human-readable
// notice in the MAP CELL, and still enrich the legend with live population data

View file

@ -83,34 +83,9 @@
app-wrapper-material/languages.js (persisted, offline, no dep) — same switcher as the SPA. -->
<template #meta>
<span class="ps-count">{{ countLabel }}</span>
<!-- MAP TOKEN (Driver request 2026-09-25): paste a Mapbox public token, or point the studio at a
token service (e.g. https://doxa.life/api/maps/token). Kept in THIS browser only; every map
pane (and every map nested inside a composite) is re-mounted with it. -->
<span class="ps-token">
<button type="button" class="ps-locale-btn ps-token-btn" :aria-expanded="String(tokenOpen)"
:title="tokenStatus" @click="tokenOpen = !tokenOpen">
<span aria-hidden="true">🔑</span>
<span class="ps-locale-name">{{ tr('mapToken') }}</span>
<span class="ps-token-dot" :class="'is-' + tokenSource" aria-hidden="true"></span>
</button>
<form v-if="tokenOpen" class="ps-token-panel" @submit.prevent="applyToken">
<label class="ps-token-label">{{ tr('tokenPaste') }}
<input class="ps-token-input" v-model.trim="tokenDraft" placeholder="pk.eyJ…" autocomplete="off" spellcheck="false" />
</label>
<label class="ps-token-label">{{ tr('tokenUrlLabel') }}
<input class="ps-token-input" v-model.trim="urlDraft" type="url" placeholder="https://doxa.life/api/maps/token" autocomplete="off" spellcheck="false" />
</label>
<div class="ps-token-row">
<button type="submit" class="ps-token-apply" :disabled="tokenBusy">{{ tr('tokenApply') }}</button>
<button type="button" class="ps-token-clear" @click="clearToken">{{ tr('tokenClear') }}</button>
</div>
<p class="ps-token-status" :class="{ err: !!tokenErr }" role="status">{{ tokenErr || tokenStatus }}</p>
<p class="ps-token-note">{{ tr('tokenStoredHere') }}</p>
</form>
</span>
<span class="ps-locale">
<button type="button" class="ps-locale-btn" :aria-expanded="String(localeOpen)"
:aria-label="tr('changeLanguage')" @click="localeOpen = !localeOpen">
:aria-label="tr('changeLanguage')" @click="localeOpen = !localeOpen; engineOpen = false; settingsOpen = false">
<span class="ps-flag">{{ activeLang.flag }}</span>
<span class="ps-locale-name">{{ activeLang.nativeName }}</span>
<span class="ps-dots" aria-hidden="true">⋮</span>
@ -125,6 +100,49 @@
</li>
</ul>
</span>
<!-- THE HEADER SETS THE PROPS EVERY PLUGIN GETS (Driver, 2026-09-25): language (above), the map
ENGINE, and — under Settings — the Mapbox token or the URL it comes from. Each is kept in THIS
browser (localStorage), handed to every pane as its profile-config prop, and published on the
window for maps nested inside composites; changing one re-mounts the maps. -->
<span class="ps-locale ps-engine">
<button type="button" class="ps-locale-btn" :aria-expanded="String(engineOpen)"
:aria-label="tr('mapEngine')" :title="tr('mapEngine')" @click="engineOpen = !engineOpen; localeOpen = false; settingsOpen = false">
<span aria-hidden="true">🗺</span>
<span class="ps-locale-name">{{ engineLabel }}</span>
<span class="ps-dots" aria-hidden="true">⋮</span>
</button>
<ul class="ps-locale-menu" v-if="engineOpen" role="listbox" :aria-label="tr('mapEngine')">
<li v-for="e in engines" :key="e.id || 'default'" role="option" :aria-selected="String(e.id === userProvider)">
<button type="button" class="ps-locale-opt" :class="{ active: e.id === userProvider }" @click="setProvider(e.id)">
<span class="ps-locale-name">{{ e.label }}</span>
<span class="ps-locale-en">{{ e.note }}</span>
</button>
</li>
</ul>
</span>
<span class="ps-token">
<button type="button" class="ps-locale-btn ps-token-btn" :aria-expanded="String(settingsOpen)"
:aria-label="tr('settings')" :title="tokenStatus" @click="settingsOpen = !settingsOpen; localeOpen = false; engineOpen = false">
<span aria-hidden="true">⚙</span>
<span class="ps-locale-name">{{ tr('settings') }}</span>
<span class="ps-token-dot" :class="'is-' + tokenSource" aria-hidden="true"></span>
</button>
<form v-if="settingsOpen" class="ps-token-panel" @submit.prevent="applyToken">
<p class="ps-token-head">{{ tr('mapToken') }}</p>
<label class="ps-token-label">{{ tr('tokenPaste') }}
<input class="ps-token-input" v-model.trim="tokenDraft" placeholder="pk.eyJ…" autocomplete="off" spellcheck="false" />
</label>
<label class="ps-token-label">{{ tr('tokenUrlLabel') }}
<input class="ps-token-input" v-model.trim="urlDraft" type="url" placeholder="https://doxa.life/api/maps/token" autocomplete="off" spellcheck="false" />
</label>
<div class="ps-token-row">
<button type="submit" class="ps-token-apply" :disabled="tokenBusy">{{ tr('tokenApply') }}</button>
<button type="button" class="ps-token-clear" @click="clearToken">{{ tr('tokenClear') }}</button>
</div>
<p class="ps-token-status" :class="{ err: !!tokenErr }" role="status">{{ tokenErr || tokenStatus }}</p>
<p class="ps-token-note">{{ tr('tokenStoredHere') }}</p>
</form>
</span>
</template>
<!-- Toolbar strip: the active element's identity and copy-embed. Kept to ONE line on purpose —
@ -192,6 +210,7 @@ import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage, normaliseLocal
// never sent anywhere but Mapbox (the token) or the URL the reader typed (the service).
const TOKEN_KEY = 'plugin-studio:mapbox-token'
const TOKEN_URL_KEY = 'plugin-studio:mapbox-token-url'
const PROVIDER_KEY = 'plugin-studio:map-provider' // 'mapbox' | 'maplibre' | '' (each map's own default)
function readStored(k) { try { return (typeof window !== 'undefined' && window.localStorage.getItem(k)) || '' } catch (_) { return '' } }
function writeStored(k, v) { try { if (v) window.localStorage.setItem(k, v); else window.localStorage.removeItem(k) } catch (_) { /* storage unavailable */ } }
@ -204,6 +223,13 @@ const PS_SHADOW_CSS = `
.ps-logo { display:inline-flex; align-items:center; }
.ps-logo svg { display:block; }
.ps-count { color:#6d766e; }
/* THE HEADER'S MENUS SIT ABOVE EVERY PLUGIN (Driver, 2026-09-25: "the Mapbox token menu is not on top …
it is underlapping the plugins"). The header row and the pane area are siblings with no stacking context
of their own, so a menu at z-index 30 competed with each plugin's own chrome (a filter bar at 55, a drawer
at 1000) and lost. The header now stacks above the content, and the pane area is ISOLATED: whatever
z-index a plugin uses stays inside its pane. */
.vtl-top { position:relative; z-index:3; }
.vtl-body { isolation:isolate; }
.ps-token { position:relative; display:inline-flex; }
.ps-token-dot { width:8px; height:8px; border-radius:50%; background:#b8bdb4; }
.ps-token-dot.is-yours, .ps-token-dot.is-url { background:#92b195; }
@ -217,6 +243,7 @@ const PS_SHADOW_CSS = `
.ps-token-apply { background:#92b195; border-color:#92b195; color:#fff; }
.ps-token-apply:disabled { opacity:.6; cursor:progress; }
.ps-token-status { margin:0; }
.ps-token-head { margin:0; font-weight:600; font-size:12px; }
.ps-token-status.err { color:#b3412f; }
.ps-token-note { margin:0; color:#6d766e; }
.ps-locale { position:relative; display:inline-flex; }
@ -465,7 +492,9 @@ export default defineComponent({
locale: this.initialLocale(),
localeOpen: false,
// Map token control — the reader's own entry, persisted in localStorage (never in the build).
tokenOpen: false,
settingsOpen: false,
engineOpen: false,
userProvider: readStored(PROVIDER_KEY),
tokenBusy: false,
tokenErr: '',
tokenDraft: readStored(TOKEN_KEY),
@ -618,6 +647,20 @@ export default defineComponent({
effectiveTk() {
return this.userTk || this.fetchedTk || this.tk || this.cfg.tk || this.bakedTk || ''
},
effectiveProvider() {
const p = this.userProvider || this.provider || this.cfg.provider || ''
return p === 'maplibre' || p === 'mapbox' ? p : ''
},
engines() {
return [
{ id: '', label: this.tr('engineDefault'), note: '' },
{ id: 'mapbox', label: 'Mapbox', note: this.tr('engineMapboxNote') },
{ id: 'maplibre', label: 'MapLibre', note: this.tr('engineMaplibreNote') },
]
},
engineLabel() {
return this.userProvider === 'mapbox' ? 'Mapbox' : this.userProvider === 'maplibre' ? 'MapLibre' : this.tr('engineDefault')
},
tokenSource() {
if (this.userTk) return 'yours'
if (this.fetchedTk) return 'url'
@ -625,6 +668,7 @@ export default defineComponent({
return 'none'
},
tokenStatus() {
if (this.effectiveProvider === 'maplibre') return this.tr('tokenNotNeeded')
if (this.tokenSource === 'yours') return this.tr('tokenSourceYours')
if (this.tokenSource === 'url') return this.tr('tokenSourceUrl', { url: this.userTokenUrl })
if (this.tokenSource === 'built') return this.tr('tokenSourceBuilt')
@ -643,6 +687,7 @@ export default defineComponent({
mounted() {
// Read once: after applyToken sets window.MAP_TOKEN it would no longer be "the page's own".
this.bakedTk = (typeof window !== 'undefined' && window.MAP_TOKEN) || ''
if (this.userProvider) this.publishProvider()
if (this.userTk) this.publishToken()
else if (this.userTokenUrl) this.fetchToken(this.userTokenUrl).then((t) => { if (t) { this.fetchedTk = t; this.publishToken() } }).catch(() => {})
this.loadManifest()
@ -684,9 +729,24 @@ export default defineComponent({
writeStored(TOKEN_URL_KEY, this.userTokenUrl)
this.publishToken()
this.remountPanes()
this.tokenOpen = false
this.settingsOpen = false
} finally { this.tokenBusy = false }
},
setProvider(id) {
this.engineOpen = false
if (id === this.userProvider) return
this.userProvider = id
writeStored(PROVIDER_KEY, id)
this.publishProvider()
this.remountPanes()
},
// Maps nested inside a composite fall back to window.MAP_PROVIDER (resolveMapProvider's ladder:
// profile-config.provider → ?provider= → window.MAP_PROVIDER), so the choice is published there too.
publishProvider() {
if (typeof window === 'undefined') return
if (this.effectiveProvider) window.MAP_PROVIDER = this.effectiveProvider
else if ('MAP_PROVIDER' in window) delete window.MAP_PROVIDER
},
clearToken() {
this.tokenDraft = ''; this.urlDraft = ''; this.tokenErr = ''
this.userTk = ''; this.userTokenUrl = ''; this.fetchedTk = ''
@ -744,8 +804,7 @@ export default defineComponent({
const pc = { profile: row.profile, instanceId: 'ps-' + row.key.replace(/\//g, '-'), locale }
if (this.effectiveTk) pc.tk = this.effectiveTk
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
const wantProvider = this.provider || this.cfg.provider
if (wantProvider === 'maplibre' || wantProvider === 'mapbox') pc.provider = wantProvider
if (this.effectiveProvider) pc.provider = this.effectiveProvider
if (this.base) pc.treeBase = this.base
return pc
},
@ -1006,6 +1065,13 @@ export default defineComponent({
.ps-logo { display: inline-flex; align-items: center; }
.ps-logo svg { display: block; }
.ps-count { color: #6d766e; }
/* THE HEADER'S MENUS SIT ABOVE EVERY PLUGIN (Driver, 2026-09-25: "the Mapbox token menu is not on top …
it is underlapping the plugins"). The header row and the pane area are siblings with no stacking context
of their own, so a menu at z-index 30 competed with each plugin's own chrome (a filter bar at 55, a drawer
at 1000) and lost. The header now stacks above the content, and the pane area is ISOLATED: whatever
z-index a plugin uses stays inside its pane. */
.vtl-top { position:relative; z-index:3; }
.vtl-body { isolation:isolate; }
.ps-token { position:relative; display:inline-flex; }
.ps-token-dot { width:8px; height:8px; border-radius:50%; background:#b8bdb4; }
.ps-token-dot.is-yours, .ps-token-dot.is-url { background:#92b195; }
@ -1019,6 +1085,7 @@ export default defineComponent({
.ps-token-apply { background:#92b195; border-color:#92b195; color:#fff; }
.ps-token-apply:disabled { opacity:.6; cursor:progress; }
.ps-token-status { margin:0; }
.ps-token-head { margin:0; font-weight:600; font-size:12px; }
.ps-token-status.err { color:#b3412f; }
.ps-token-note { margin:0; color:#6d766e; }
.ps-locale { position: relative; display: inline-flex; }

View file

@ -45,7 +45,7 @@ import { useRegionsI18n } from './src/useRegionsI18n.js'
// ar renders right-to-left. RTL_LOCALES is the shared set and :dir on our own root is the
// house pattern (doxa-simple-map.vue:18, :246, :1346) — an EMBEDDED element must not reach for
// document.documentElement.dir, which belongs to the host page.
import { RTL_LOCALES } from '@map/i18n/index.js'
import { RTL_LOCALES, localeFromUrl } from '@map/i18n/index.js'
import { useI18n } from 'vue-i18n'
import { useDocumentFont, BEBAS_NEUE_FACES, POPPINS_FACES, FIGTREE_FACES, OSWALD_FACES } from '@map/composables/useDocumentFont.js'
import { summarizeRegions, PEOPLE_GROUP_LIST_FIELDS } from '@map/utils/countries-meta.js'
@ -100,8 +100,15 @@ const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl |
// English, because this read only cfg.languageCode while the locale arrives as
// cfg.locale (ProfileLoader's contract, ProfileLoader.vue:156 accepts locale OR lang).
// Falling through to the same keys means translating the chrome also translates the data.
// ★ AND THE URL, LAST — the same fix region-dashboards.vue got, on the leaf that did not get it.
// I fixed the dashboard leaf and stopped, and the ENGLISH census could not see what was left:
// its ours-vs-theirs sets matched because both sides were in English. The ARABIC census found it
// on the first run — our h1 and subtitle rendered "المناطق" while all 41 country names in the
// table stayed "Angola", "Benin", "Cameroon". A shared /regions-list/?lang=ar link translated the
// chrome and then asked the prayer API in English, which is the defect this comment block already
// describes; it was only ever half fixed.
const languageCode = computed(() =>
props.languageCode || cfg.value.languageCode || cfg.value.locale || cfg.value.lang || '')
props.languageCode || cfg.value.languageCode || cfg.value.locale || cfg.value.lang || localeFromUrl() || '')
// Where the shared asset tree is served from: the generator bakes it per page depth and the
// composite forwards it via INHERITED_PAGE_KEYS. Needed for the vendored @font-face path below.
const assetsBase = computed(() => cfg.value.assetsBase || './assets')

View file

@ -27,15 +27,14 @@
*/
import { ref, computed, watch, onMounted, onBeforeUnmount, inject } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { isMapboxMap } from '@map/composables/useMapProvider.js'
// ★ THE INSTANCE SNIFF LIVES IN library/, ONCE. This was a local glFor.js copied from
// pplr-data-maps, and I flagged the duplication as a decision on the record. It turns out
// library/composables/useMapProvider.js already exports isMapboxMap(map) — the same per-instance
// test, documented for the same reason ("works even on dual-engine pages where both globals are
// loaded"). So the copy is deleted and the part that could drift is delegated; what remains here
// is only the namespace choice, which is one expression and has nowhere to drift to.
// When exp-shepherd's engineFor(map) lands this line goes too.
const glFor = (m) => (isMapboxMap(m) ? window.mapboxgl : (window.maplibregl || window.mapboxgl))
// ★ THE ENGINE RESOLVER NOW LIVES IN library/, ONCE, AND NOTHING IS LEFT HERE. This started as a
// local glFor.js copied from pplr-data-maps, then became one expression delegating to
// useMapProvider's isMapboxMap; exp-shepherd has landed engineFor(map), which is stricter than
// what I had — it instance-tests BOTH engines instead of inferring the second from the first — so
// the expression is gone too. Three implementations were compared before swapping (the deleted
// copy, my interim expression, and this) and they decide identically in every engine combination:
// _qa-cycle/02-builder-cycle/_chk-glfor-equivalence.mjs.
import { engineFor } from '@map/composables/useMapProvider.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
@ -331,8 +330,8 @@ function onPinClick(e) {
// leaf <head> statically loads mapbox-gl — so a MapLibre map was opening a MAPBOX popup. studio
// is removing those static tags (they leak a visitor's IP to Mapbox for partners who chose
// MapLibre), and the moment that lands this popup would vanish with no error at all: the pin
// click would simply do nothing. glFor() asks which namespace owns the map instance.
const g = glFor(map.value)
// click would simply do nothing. engineFor() asks which namespace owns the map instance.
const g = engineFor(map.value)
if (!g) return
const p = f.properties || {}
const html = popupHtml(p)
@ -612,7 +611,7 @@ onMounted(async () => {
// Same question as the popup: which engine owns THIS map. The old `mapboxgl || maplibregl`
// happened to work because the fallback existed, but it preferred Mapbox even on a MapLibre
// map — the identical shape of bug, one line away from the same silent failure.
const gl = glFor(map.value)
const gl = engineFor(map.value)
if (gl) {
try { map.value.addControl(new gl.NavigationControl({ showCompass: false }), 'top-right') } catch (e) { console.warn('[RegionCountryMap] NavigationControl unavailable:', e && e.message) }
try { map.value.addControl(new gl.FullscreenControl(), 'top-right') } catch (e) { console.warn('[RegionCountryMap] FullscreenControl unavailable:', e && e.message) }

View file

@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* check-dashboard-geocoder-provider.mjs — a dashboard running on MapLibre must not call Mapbox.
*
* THE CONTRACT: a partner who selects MapLibre has selected the keyless engine precisely so that no
* Mapbox key and no Mapbox traffic is involved. One request to api.mapbox.com sends that visitor's
* IP to Mapbox and breaks the promise, whatever the page looks like afterwards.
*
* ★ WHAT THIS MEASURED, AND IT IS NOT WHERE THE AUDIT POINTED. studio's Mapbox-globals audit named
* DashboardSearchHeader.vue, which did preload api.mapbox.com/.../mapbox-gl-geocoder.min.js
* unconditionally and never passed `provider` to the GeocoderComponent it mounts — both now fixed.
* But that component is NOT MOUNTED by this dashboard (research-dashboard.vue:23 says so in as many
* words), so fixing it changes nothing here. The four requests this leaf actually makes under
* ?provider=maplibre are all from the GENERATED LEAF PAGE's own tags:
*
* mapbox-gl.css · mapbox-gl.js · mapbox-gl-geocoder.css · mapbox-gl-geocoder.min.js
*
* i.e. the leaf ships Mapbox GL and the Mapbox geocoder to every visitor regardless of the engine
* it was asked for. That is the leaf GENERATOR's doing, not this plugin's, and it is why this gate
* is deliberately NOT wired into gates:post yet: it would be a red gate about somebody else's file.
* Run it by name; it names the exact URLs.
*
* ★ ZERO IS THE ASSERTION, not "it rendered". A build that quietly kept the Mapbox geocoder would
* look identical: the bars appear, suggestions work, nothing errors. Only the request log tells the
* truth, which is why this counts requests rather than looking at the page.
*
* ASSERTIONS, on the built tree:
* maplibre ZERO requests to api.mapbox.com.
* both whatever search bars this leaf renders still produce suggestions — a zero bought by
* breaking search is not a fix. MEASURED: this leaf renders NONE, because the search
* header is not mounted here, so that half reports rather than asserts. Point the gate
* at a leaf that mounts the bars and it starts asserting again.
*
* Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail.
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const TAG = 'DASHBOARD-GEOCODER-PROVIDER'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) }
const { TREE_NAME } = await import(join(ROOT, 'internal', '_tree-name.mjs'))
const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
let chromium
try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const bundle = (manifest.bundles || []).find((b) => b.bundle === 'research-dashboard')
const profile = bundle && (bundle.profiles || []).find((p) => p.iframeUrl)
if (!profile) skip('the manifest declares no research-dashboard leaf to load.')
const freePort = () => new Promise((ok) => {
const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) })
})
const PORT = await freePort()
const TREE = `http://127.0.0.1:${PORT}/${TREE_NAME}`
const LEAF = `${TREE}/${String(profile.iframeUrl).replace(/^\.\//, '')}`
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT)], { cwd: ROOT, stdio: 'ignore' })
const cleanup = () => { try { tree.kill() } catch { /* already gone */ } }
await new Promise((r) => setTimeout(r, 900))
if (!await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false)) {
cleanup(); skip('could not serve the built tree locally.')
}
const { requireThisTree } = await import(join(ROOT, 'internal', '_serves-this-tree.mjs'))
await requireThisTree(TREE, ROOT, TAG)
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
const fails = []
const lines = []
try {
for (const engine of ['maplibre', 'mapbox']) {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
const mapboxCalls = []
page.on('request', (r) => { const u = r.url(); if (u.includes('api.mapbox.com')) mapboxCalls.push(u) })
await page.goto(`${LEAF}?provider=${engine}`, { waitUntil: 'domcontentloaded', timeout: 60000 })
await page.waitForTimeout(20000)
const search = await page.evaluate(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const seen = new Set(); const hits = []
const walk = (root) => {
if (!root || seen.has(root)) return
seen.add(root)
for (const el of root.querySelectorAll('*')) {
if (el.matches && el.matches('input.mapboxgl-ctrl-geocoder--input, input.maplibregl-ctrl-geocoder--input')) hits.push(el)
if (el.shadowRoot) walk(el.shadowRoot)
}
}
walk(document)
if (!hits.length) return { bars: 0, suggestions: 0 }
const input = hits[0]
input.focus()
input.value = 'Afar'
input.dispatchEvent(new Event('input', { bubbles: true }))
for (let i = 0; i < 40; i++) {
await sleep(500)
const root = input.getRootNode()
const n = root.querySelectorAll('.mapboxgl-ctrl-geocoder .suggestions li, .maplibregl-ctrl-geocoder .suggestions li, .suggestions li').length
if (n) return { bars: hits.length, suggestions: n }
}
return { bars: hits.length, suggestions: 0 }
})
await page.close()
lines.push(` · ${engine.padEnd(9)} api.mapbox.com requests: ${mapboxCalls.length} · search bars: ${search.bars} · suggestions for "Afar": ${search.suggestions}`)
if (engine === 'maplibre' && mapboxCalls.length) {
fails.push(`under maplibre the dashboard made ${mapboxCalls.length} request(s) to api.mapbox.com:`)
for (const u of [...new Set(mapboxCalls)].slice(0, 8)) fails.push(` ${u.slice(0, 150)}`)
}
// Reported, not asserted, when the leaf mounts no bars at all — see the header note.
if (search.bars && !search.suggestions) {
fails.push(`under ${engine} the search bar rendered but typing produced no suggestions — zero Mapbox traffic bought by a broken search is not a fix.`)
}
}
} catch (err) {
fails.push(`the measurement itself failed: ${err && err.message}`)
} finally {
await browser.close().catch(() => {})
cleanup()
}
for (const l of lines) console.log(l)
if (fails.length) {
console.log(`\n${TAG}: FAIL`)
for (const f of fails) console.log(` ✗ ${f}`)
process.exit(1)
}
console.log(`${TAG}: PASS — MapLibre calls Mapbox zero times and still searches; Mapbox is unchanged.`)

View file

@ -11,6 +11,12 @@ import { useMapInstance } from '@map/composables/useMapInstance.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useMapFly } from '@map/composables/useMapFly.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { tStatic } from '@map/i18n/index.js'
// This panel's chrome, read out of the catalogue. tStatic and not useI18n, for the reason the rest
// of this tree gives: these components mount inside custom elements that install no i18n plugin,
// and an unguarded useI18n() throws on the way in and takes the whole panel with it.
const T = (key, en) => tStatic(key) || en
// Table selection -> map camera. The table never touches the map: it commits to the bus,
// this composable watches bus.selectionVersion and flies ONCE per settled change.
import { useSelectionCamera } from '../../composables/useSelectionCamera.js'
@ -1569,14 +1575,17 @@ useShadowStyles(`
.rd-proj{ position:absolute; right:8px; top:8px; z-index:3; font-size:11px; font-weight:700; cursor:pointer; user-select:none;
background:#1a1a2ecc; color:#fff; border:1px solid #ffffff33; border-radius:6px; padding:4px 9px; backdrop-filter:blur(4px); }
.rd-proj:hover{ background:#1a1a2e; }
/* ★ TOUCH TARGET: 24px, STATED. Measured on a phone (390x844, pointer:coarse) this rendered
/* ★ TOUCH TARGET: 24px, STATED, AT EVERY WIDTH. Measured on a phone (390x844) this rendered
74x23 — one pixel under WCAG 2.5.8 AA. The fix is to name the 24, not to add padding: the
height is padding PLUS a line box the renderer derives from the font, and padding arithmetic
lands at 23.7px, which rounds to the same failure. Mouse sizing is untouched; only a coarse
pointer gets the floor. (Found by regions' phone sweep across every map.) */
@media (pointer: coarse) {
.rd-proj{ min-height:24px; display:inline-flex; align-items:center; }
}
lands at 23.7px, which rounds to the same failure.
★ AND IT MUST NOT BE BEHIND pointer:coarse. The first fix scoped this to a coarse pointer,
reasoning that a mouse does not need a touch floor. That was wrong twice over: 2.5.8 is a
TARGET SIZE criterion, not a touch criterion, so it applies to the desktop pointer too — and
the media query meant phones passed at 34px while desktop stayed at 73.5x23, the same one-pixel
failure, now invisible to a phone sweep. Measured again at 1440x900 by upg100 (2a2ea36e). One
rule, every width, no query to be right about. */
.rd-proj{ min-height:24px; display:inline-flex; align-items:center; }
/* Zoom cluster. MapToolbar defaults to top:6px on the inline-end edge, which is exactly where
the Globe/Flat pill already sits, so drop this instance below it. 34px clears the pill
(top:8px + roughly 22px tall) with a small breathing gap.
@ -1671,7 +1680,13 @@ useShadowStyles(`
{{ projection === 'globe' ? '🌐 Globe' : '▭ Flat' }}
</span>
<div class="rd-legend-map" data-map-legend aria-label="UPG count legend">
<div class="lg-h">UPGs / country</div>
<!-- ★ COMPOSED FROM TWO EXISTING CATALOGUE ENTRIES, not from new wording. This legend title
was the last piece of English chrome here that could be translated without inventing a
phrase in ten languages: legend.columns.upgs and dashboard.country both already ship in
all eleven locales, and a slash-joined pair of them says exactly what the column is. The
English shifts from "UPGs / country" to "UUPGs / Country", which is the tree's own
spelling of both words everywhere else. -->
<div class="lg-h">{{ T('legend.columns.upgs', 'UUPGs') }} / {{ T('dashboard.country', 'Country') }}</div>
<div v-for="b in LEGEND_BINS" :key="b.l" class="lg-row">
<span class="lg-sw" :style="{ background: b.c }"></span><span>{{ b.l }}</span>
</div>

View file

@ -32,6 +32,7 @@ import { inject, ref, computed, unref, onMounted } from 'vue'
import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue'
import { useGeocoderSearch } from '@map/composables/useGeocoderSearch.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
const bus = inject('selectionBus', null)
const dash = inject('dashboardData', null)
@ -43,6 +44,15 @@ const mapboxToken = inject('mapboxToken', ref(''))
// map profiles wire :data-source-id="dataSource" (DASH-FIX-2).
const searchSourceId = inject('searchSourceId', '')
// ★ WHICH ENGINE THIS DASHBOARD IS RUNNING ON, because the search bar has to agree with it.
// The same ladder every map panel uses: profile-config.provider -> ?provider= -> window.MAP_PROVIDER
// -> mapbox.
// inject() at setup top level, NOT inside the computed: Vue only resolves an injection during
// setup, and a call from inside a computed returns undefined with a warning — which would have
// silently resolved every host to the default engine, i.e. exactly the bug being fixed.
const profileConfig = inject('profileConfig', null)
const provider = computed(() => resolveMapProvider(profileConfig))
const token = computed(() => unref(mapboxToken) || '')
// ── Host-independent geocoder-plugin loader ────────────────────────────────────
@ -52,10 +62,21 @@ const token = computed(() => unref(mapboxToken) || '')
// host remembering to add that script. So load the plugin from the Mapbox CDN
// ourselves (mirrors how GeocoderComponent injects the geocoder CSS at runtime)
// and only mount the bars once it's ready — works in ANY host with zero wiring.
// ★ AND UNDER MapLibre IT MUST NOT BE THE MAPBOX ONE. This preloader fetched
// api.mapbox.com/.../mapbox-gl-geocoder.min.js unconditionally, so a partner who chose MapLibre
// precisely to avoid Mapbox still sent every visitor's IP to api.mapbox.com the moment this
// dashboard mounted — the keyless embed's whole reason for existing, cancelled by one hardcoded
// URL. (Found by studio's Mapbox-globals audit.)
//
// GeocoderComponent already loads whichever plugin its `provider` says (ensureMapboxGeocoder /
// ensureMaplibreGeocoder), so under MapLibre the right thing to do here is NOTHING: mark ready and
// let the component fetch its own from jsDelivr. The mapbox preload stays for the mapbox path,
// where it is what keeps a host from having to remember a <script> tag.
const GEOCODER_JS = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js'
const pluginReady = ref(false)
function ensureGeocoderPlugin() {
if (typeof window === 'undefined') return
if (provider.value === 'maplibre') { pluginReady.value = true; return }
if (typeof window.MapboxGeocoder !== 'undefined') { pluginReady.value = true; return }
let s = document.querySelector(`script[src="${GEOCODER_JS}"]`)
if (!s) {
@ -200,6 +221,7 @@ useShadowStyles(`
v-if="pluginReady"
:ref="(el) => { bar.geocoderRef.value = el }"
standalone
:provider="provider"
:access-token="token"
:data-source-id="searchSourceId"
:placeholder="bar.entry.label"

View file

@ -18,6 +18,16 @@
<div class="spinner"></div>
<p>Loading people groups...</p>
</div>
<!-- OFFLINE / BLOCKED: the live API could not be reached. Measured on a local dev server,
where pray.doxa.life refuses a 127.0.0.1 origin (CORS): the wall used to sit at
"0 Groups Loaded" with nothing saying why. Say what failed and offer a retry. -->
<div v-else-if="loadError && totalGroups === 0" class="wall-offline" role="status">
<p class="wall-offline-title">The live prayer data could not be reached.</p>
<p>The wall reads its people groups from pray.doxa.life. You may be offline, or, on a
local development server, the API may not allow this page's address.</p>
<button type="button" class="control-btn" @click="fetchData">Try again</button>
</div>
</div>
</template>
@ -50,6 +60,10 @@ export default {
peopleGroups() {
return this.$store?.peopleGroups || [];
},
loadError() {
return this.$store?.error || null;
}
},
mounted() {

View file

@ -1,5 +1,7 @@
<template>
<div v-if="sources.length > 1">
<!-- availableSources, the computed below: the template read an undefined `sources`, so this
threw "reading 'length'" on EVERY render, online or not (Vue logs it; nothing crashes) -->
<div v-if="availableSources.length > 1">
<div class="source-selector">
<div class="source-header">
<h3>📊 Data Source</h3>

View file

@ -58,7 +58,9 @@ export const usePeopleGroupsStore = (pinia) => {
console.log('First 3 groups:', this.peopleGroups.slice(0, 3));
} catch (error) {
console.error(`❌ Error fetching people groups from ${this.dataSource}:`, error);
// warn, not error: the wall now SAYS so on screen (PeopleGroupsWall's offline notice)
// and offers a retry; an unreachable API is an expected state, not a crash.
console.warn(`Could not fetch people groups from ${this.dataSource}:`, error && error.message);
this.error = error.message;
this.peopleGroups = [];
} finally {

View file

@ -116,6 +116,28 @@ body {
overflow: hidden;
}
/* Offline / blocked notice: the live API could not be reached. Sits over the empty canvas,
inside the wall (not position:fixed), so an embedded wall never covers its host page. */
.wall-container { position: relative; }
.wall-offline {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
max-width: min(440px, calc(100% - 32px));
padding: 20px 24px;
background: white;
border: 1px solid #dbdbdb;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
color: var(--text-color);
font-size: 14px;
line-height: 1.5;
text-align: center;
}
.wall-offline-title { font-weight: 600; font-size: 16px; margin-bottom: 6px; }
.wall-offline p { margin: 0 0 12px; }
/* Loading Overlay */
.loading-overlay {
position: fixed;

View file

@ -2,13 +2,13 @@
This folder is GENERATED by `bun run build`; do not edit it by hand. It holds exactly one thing:
- **`doxa-maps/`** — the output folder: 23 plugin bundles and 35 pages, self-contained.
- **`dx-maps/`** — the output folder: 23 plugin bundles and 35 pages, self-contained.
Copy this one folder to any web host (or open it from disk) and it works.
- `doxa-maps/index.html` — the front page: the Plugin Studio, every plugin in one place.
- `doxa-maps/<plugin>/<plugin>.js` — a plugin's bundle (a custom element; load it with a `<script>` tag).
- `doxa-maps/<plugin>/<page>/index.html` — a ready-made page for one plugin view (embed it in an `<iframe>`).
- `doxa-maps/manifest.json` — every plugin, page and embed snippet, machine-readable.
- `doxa-maps/assets/` — the data, fonts and images the plugins fetch at runtime.
- `dx-maps/index.html` — the front page: the Plugin Studio, every plugin in one place.
- `dx-maps/<plugin>/<plugin>.js` — a plugin's bundle (a custom element; load it with a `<script>` tag).
- `dx-maps/<plugin>/<page>/index.html` — a ready-made page for one plugin view (embed it in an `<iframe>`).
- `dx-maps/manifest.json` — every plugin, page and embed snippet, machine-readable.
- `dx-maps/assets/` — the data, fonts and images the plugins fetch at runtime.
The folder's name comes from `name` in `vite.doxa-maps-build-config.json`. Sources live in
`plugin-designs/`, `library/` and `data/`; `rm -rf public/ && bun run build` recreates all of this.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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