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
5
.main-capture/prepush
Executable 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
|
|
@ -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 |
|
||||
|
||||

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

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

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

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

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

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

|
||||

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

|
||||
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "0.1.0",
|
||||
"identifier": "life.doxa.maps",
|
||||
"build": {
|
||||
"frontendDist": "../../public/doxa-maps",
|
||||
"frontendDist": "../../public/dx-maps",
|
||||
"beforeBuildCommand": ""
|
||||
},
|
||||
"app": {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 …
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 820 KiB After Width: | Height: | Size: 816 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 828 KiB After Width: | Height: | Size: 828 KiB |
|
Before Width: | Height: | Size: 2 MiB After Width: | Height: | Size: 2 MiB |
|
Before Width: | Height: | Size: 2 MiB After Width: | Height: | Size: 2 MiB |
BIN
documentation/readme-images/lang-arabic-upg100.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 425 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 891 KiB After Width: | Height: | Size: 954 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 751 KiB After Width: | Height: | Size: 760 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 706 KiB After Width: | Height: | Size: 678 KiB |
|
Before Width: | Height: | Size: 676 KiB After Width: | Height: | Size: 702 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.6 MiB |
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.` |
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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 } })()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
76
internal/landing-preconditions.mjs
Normal 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.')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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…": "جارٍ تحميل لوحات المناطق…",
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@
|
|||
"sections": {
|
||||
"within": "ضمن {selection}",
|
||||
"allData": "كل بيانات DOXA"
|
||||
}
|
||||
},
|
||||
"upg100Placeholder": "ابحث عن شعب أو قائمة UPG-100 أو منطقة…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@
|
|||
"languageKind": "Sprache",
|
||||
"dialect": "Dialekt",
|
||||
"religionKind": "Religion"
|
||||
}
|
||||
},
|
||||
"upg100Placeholder": "Volksgruppe, UPG-100-Liste oder Region suchen…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@
|
|||
"sections": {
|
||||
"within": "Within {selection}",
|
||||
"allData": "All DOXA Data"
|
||||
}
|
||||
},
|
||||
"upg100Placeholder": "Search people group, UPG-100 list, or region…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@
|
|||
"sections": {
|
||||
"within": "Dentro de {selection}",
|
||||
"allData": "Todos los datos DOXA"
|
||||
}
|
||||
},
|
||||
"upg100Placeholder": "Buscar pueblo, lista UPG-100 o región…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@
|
|||
"languageKind": "भाषा",
|
||||
"dialect": "बोली",
|
||||
"religionKind": "धर्म"
|
||||
}
|
||||
},
|
||||
"upg100Placeholder": "जनसमूह, UPG-100 सूची या क्षेत्र खोजें…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 टोकन नहीं चाहिए"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@
|
|||
"languageKind": "Lingua",
|
||||
"dialect": "Dialetto",
|
||||
"religionKind": "Religione"
|
||||
}
|
||||
},
|
||||
"upg100Placeholder": "Cerca popolo, elenco UPG-100 o regione…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@
|
|||
"sections": {
|
||||
"within": "Dentro de {selection}",
|
||||
"allData": "Todos os dados DOXA"
|
||||
}
|
||||
},
|
||||
"upg100Placeholder": "Procurar povo, lista UPG-100 ou região…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@
|
|||
"languageKind": "Limbă",
|
||||
"dialect": "Dialect",
|
||||
"religionKind": "Religie"
|
||||
}
|
||||
},
|
||||
"upg100Placeholder": "Caută popor, listă UPG-100 sau regiune…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@
|
|||
"sections": {
|
||||
"within": "В пределах {selection}",
|
||||
"allData": "Все данные DOXA"
|
||||
}
|
||||
},
|
||||
"upg100Placeholder": "Поиск народа, списка UPG-100 или региона…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@
|
|||
"languageKind": "语言",
|
||||
"dialect": "方言",
|
||||
"religionKind": "宗教"
|
||||
}
|
||||
},
|
||||
"upg100Placeholder": "搜索民族、UPG-100 名单或区域…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 令牌"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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.`)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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.`)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||