Merge commit '580dff6369ae6dce621131a6b112ae2f095af94a' into bundler-1/work

# Conflicts:
#	public/doxa-maps/index.html
This commit is contained in:
doxa-maps-88655c70 2026-09-25 03:44:13 +00:00
commit 7f71ed0ffc
24 changed files with 463 additions and 459 deletions

View file

@ -60,34 +60,33 @@ The scripts these commands are grounded in (from this branch's `package.json`):
"phone": "bun run apk:debug && bun run apk:install"
```
`bun run build` writes the whole deployable tree to `public/doxa-maps/`, including
`manifest.json` — the machine-readable index that carries the ready-to-paste
**iframe** and **`<script src>` + SRI** embed snippets for every map.
`BUILD_ALLOW=1 bun run build` writes the whole deployable tree to `public/doxa-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.
### Summary table
| # | Deploy method | Install first (prereqs) | Command(s) |
|---|---------------|-------------------------|------------|
| 1 | **iframe embed** | Bun | `bun install` → `bun run build` → `bun run serve` (or drop `public/doxa-maps/` on any host) |
| 2 | **Mobile app (Android)** | Bun ALONE for `check:mobile`; + Android SDK · `adb` on PATH · JDK 17 (Gradle) for the APK | `bun run check:mobile` (sync + webDir assertion, NO toolchain needed) · `bun run phone` (= `apk:debug` + `apk:install` to a device/emulator) |
| 3 | **Direct-JS / dashboard** | Bun | `bun run build`, then paste the `<script src> + integrity` (SRI) snippet from `manifest.json`. *Proven same-root AND foreign-host (verified 2026-09-20: gl + attribution, 0 errors, 0 404s). Fill the `YOUR_*` placeholders the snippet ships with* |
| 4 | **WordPress** | Bun (+ `php` CLI to run the contract gate) | `bun run build`, then either paste the iframe/script snippet into a **Custom HTML** block, **or** install `deployment/wordpress/doxa-maps-embed/` (shortcode + Gutenberg block). `bun run check:wp-embed` verifies the emitted markup. *(Contract-proven; not yet shot on a live WP install.)* |
| 5 | **Desktop (Tauri)** | Bun ALONE for `check:tauri`; + Rust toolchain · Tauri CLI · system webview libs for the binary | `bun run check:tauri` (config contract, NO toolchain needed) · `cargo tauri build` (needs the toolchain). *(Built, not shot — a 65 MB binary + 58 MB `.deb` WERE emitted; only the GUI launch is unverified.)* |
| # | 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 |
| 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 |
| 5 | **Desktop (Tauri)** | Bun for the check; + Rust · Tauri CLI · webview libs for the binary | `bun run check:tauri` (no toolchain) · `bun run tauri:build` | 🧪 proof of concept |
---
### 1. iframe embed *(required)*
### 1. iframe
**Install:** Bun.
```bash
bun install
BUILD_ALLOW=1 bun run build # builds every plugin-designs/* bundle -> public/doxa-maps/
bun run serve # optional: serves public/ at http://localhost:4173
bun run serve # optional: serves public/ at http://localhost:4173
```
The build writes `public/doxa-maps/manifest.json`. Each profile carries an
`embed.iframe` snippet, e.g.:
Each profile in `manifest.json` carries an `embed.iframe` snippet:
```html
<iframe src="./all-people-groups/all-people-groups/index.html"
@ -95,184 +94,114 @@ The build writes `public/doxa-maps/manifest.json`. Each profile carries an
style="border:none;border-radius:12px;" allowfullscreen></iframe>
```
URLs are root-relative, so the whole `doxa-maps/` folder is position-independent —
drop it on Cloudflare/Vercel/any static host and every iframe resolves. (For an
external absolute base, set `embedBaseUrl` in the build config before building.)
**The folder is position-independent.** Snippet URLs are relative to the tree root, so `doxa-maps/`
works dropped on any static host. For absolute URLs, set `embedBaseUrl` in the build config.
### 2. Mobile app — Android / Capacitor *(required)*
### 2. Mobile app — Android / Capacitor
**Install:** Bun · **Android SDK** (`sdkmanager`, `adb` on your PATH) · **JDK 17**
(the Gradle wrapper needs it) · Capacitor (`@capacitor/*` are already devDependencies,
so `bun install` covers them). A connected device or running emulator for install.
**Install:** Bun · **Android SDK** (`sdkmanager`, `adb` on your PATH) · **JDK 17** · a device or
emulator. The `@capacitor/*` packages are already devDependencies.
```bash
bun run check:mobile # CONTRACT check — needs NO JDK/SDK/device
bun run phone # full path — needs the toolchain above
```
**`check:mobile` is runnable anywhere.** `cap sync` is a file copy plus plugin wiring, so it
needs no Android toolchain at all; `cap:verify` then asserts
`deployment/capacitor/android/app/src/main/assets/public/index.html` exists. That guards the soft
failure `capacitor.config.json` warns about — *a wrong `webDir` yields an EMPTY
`assets/public` and a BLANK WHITE SCREEN at launch, not an error*. Verified 2026-09-20 with
`java`, `gradle`, `adb` and `sdkmanager` all absent: sync EXIT=0, the whole `public/` tree
staged into the native assets, `cap:verify` OK. Negative-tested by removing `index.html` — EXIT=1.
> **Why this says "the whole tree" and not a bundle count.** It used to read "21 bundles staged".
> That number matched nothing measurable: the tree today has **18 built bundles**, **17** of them
> listed in `manifest.json` (`key-cities` is withheld by `gatedProfiles`), and **19**
> subdirectories under `public/doxa-maps/` — one of which is `assets/`, not a bundle at all.
> `cap sync` copies `webDir` wholesale, so *bundles* was never the unit it operates on. A count
> that names the wrong noun goes stale the moment the tree changes and cannot be re-checked,
> which is exactly how the original figure survived unnoticed.
**Status: built + contract-proven; not shot.** The APK itself (`gradlew assembleDebug`) and
its launch on a device still need JDK 17 + the Android SDK + `adb`.
`phone` chains the whole path: `apk:debug` (which runs `bun run build`, then
`cap:sync` to copy the web output into the Android project, then `cap:verify`, then
`./gradlew assembleDebug`) and finally `apk:install` (`adb install -r ...`) onto the
device. Run the halves separately if you want to inspect between steps:
```bash
bun run apk:debug # build the APK only
bun run check:mobile # contract check — needs NO JDK, SDK or device
bun run phone # the full path: build → cap:sync → cap:verify → gradlew assembleDebug → adb install
bun run apk:debug # or the halves separately: build the APK only
bun run apk:install # push the built APK to a connected device
```
Only Android is scaffolded on this branch (`deployment/capacitor/android`, `capacitor.config.json`);
iOS is not set up.
**`check:mobile` runs anywhere.** It copies the built tree into the Android project and asserts
`deployment/capacitor/android/app/src/main/assets/public/index.html` exists, which catches the one
silent failure: a wrong `webDir` launches to a blank white screen, not an error. Only Android is
scaffolded; iOS is not set up.
### 3. Direct-JS / dashboard *(required)*
### 3. JavaScript embed
**Install:** Bun.
```bash
BUILD_ALLOW=1 bun run build
```
Then paste the profile's `embed.script` snippet from `manifest.json` — a `<script src>`
with a Subresource-Integrity (SRI) hash plus the custom-element tag:
**Install:** Bun. Build, then paste the plugin's `embed.script` snippet from `manifest.json`: a
`<script src>` with a Subresource-Integrity hash, plus the custom element.
```html
<script src="./all-people-groups/all-people-groups.js"
integrity="sha384-j5Ypx+EI9DGlTClzgLs1R8wAnmTnkQ7HgAhHrxyGoVoHDMTlFVY7slU6JNicn77T"
crossorigin="anonymous"></script>
integrity="sha384-…" crossorigin="anonymous"></script>
<all-people-groups
profile-config='{"profile":"all-people-groups","tk":"YOUR_MAPBOX_TOKEN"}'>
</all-people-groups>
```
The element accepts props / emits events, so it can wire into a surrounding page as a
unified research dashboard (deploy-3 proof). The SRI hash regenerates on every build.
**The hash changes on every build**, so always copy it from the current `manifest.json`. The element
takes props and emits events, so it can wire into the surrounding page.
#### Embedding on a FOREIGN host (a partner site) — the two things that differ
**On a partner's site, a JS embed needs two values an iframe does not.** An iframe carries its own
document base; a script runs in the host's document, so relative asset and API paths would resolve
against the host and 404. The snippet already ships both:
Both embed methods were run to their first failure on a foreign host page (2026-09-20).
**deploy-1 needed nothing**; **deploy-3 needed two values**, and the snippet now ships both.
*Why they differ:* an **iframe carries its own document base**, so the leaf page's relative
asset and API paths resolve correctly. A **direct-JS embed executes in the host's document**,
so those same relative paths resolve against the *host* — and 404. Same bundle, same data,
different containing document.
| | iframe (deploy-1) | direct-JS (deploy-3) |
| | iframe | JavaScript embed |
|---|---|---|
| `assetsBase` | not needed | **required** — in `profile-config` |
| API base | not needed | **required** — `window.MAP_APP_API_URL` (a host global, *not* a profile-config key) |
| Mapbox token | inherited by the leaf | replace `YOUR_MAPBOX_TOKEN` |
| `assetsBase` | not needed | **required**, in `profile-config` (`YOUR_TREE_URL/assets` until you set `assetsBaseUrl`) |
| API base | not needed | **required**, `window.MAP_APP_API_URL` (a host global) |
| Mapbox token | inherited by the leaf page | replace `YOUR_MAPBOX_TOKEN` |
Both are emitted into `embed.script` already: the API base uses the configured value, and
`assetsBase` falls back to a `YOUR_TREE_URL/assets` placeholder when `assetsBaseUrl` is unset.
### 4. WordPress / Disciple Tools — proof of concept
**One rewrite either way.** Snippet URLs are **tree-relative** (`./bundle/...`) because
`embedBaseUrl` is empty by default — that is what keeps a dropped folder position-independent
and the `check:drop` gate green. A partner whose page is not *at* the tree root must either
prefix the path (`./doxa-maps/...`) or set **`embedBaseUrl`** in the build config to emit
absolute URLs. That is a deployment choice, not a defect.
**No plugin needed for the simple path:** build, then paste the iframe or script snippet into a
WordPress **Custom HTML** block.
### 4. WordPress *(built + contract-proven; not yet shot on a live WP install)*
> **Corrected 2026-09-20.** This section previously read *"deferred POC — no dedicated
> WordPress build step exists on this branch"*. That is no longer true: a first-class
> plugin exists at `deployment/wordpress/doxa-maps-embed/` (a `[doxa_map]` shortcode + a Gutenberg
> block), and its emitted markup is now verified by a gate.
**The simple path** — no plugin needed, same artifacts as methods 1 and 3:
```bash
BUILD_ALLOW=1 bun run build
```
Then paste either the iframe snippet **or** the `<script src> + SRI` snippet from
`manifest.json` into a WordPress **Custom HTML** block.
**The plugin path** — `deployment/wordpress/doxa-maps-embed/` registers the bundle through WordPress's
own dependency graph, so the SRI hash and `crossorigin` survive and the script loads once
per page rather than once per embed.
**The plugin path:** `deployment/wordpress/doxa-maps-embed/` adds a `[doxa_map]` shortcode and a
Gutenberg block, registering the bundle through WordPress's own dependency graph so the SRI hash and
`crossorigin` survive and the script loads once per page.
```bash
bun run check:wp-embed # renders the REAL shortcode with no WordPress installed
```
That gate stubs the ~20 WordPress functions the plugin actually calls (`deployment/wordpress/tests/`),
renders the shortcode against the REAL built `manifest.json`, and asserts the contract
`HOW-TO-TEST.md` defines: the element tag is emitted, `profile-config` decodes to valid
JSON, the script `src` equals the catalog path, the SRI `integrity` is injected by the
`script_loader_tag` filter, `crossorigin="anonymous"` is present — plus a negative test
that a **malformed** integrity is rejected.
**What is proven, and what is not.** The check renders the shortcode against the real `manifest.json`
and asserts the element tag, valid `profile-config` JSON, the script `src`, the injected SRI hash and
`crossorigin` — and rejects a malformed hash. It does not prove that WordPress renders the page or a
browser runs it; that needs a live install (`wp-env start`, see
`deployment/wordpress/doxa-maps-embed/HOW-TO-TEST.md`).
**Status honesty — say "contract-proven", not "proven".** What is proven: the plugin emits
the correct markup, repeatably and in CI. What is *not*: that WordPress renders the page,
that the block editor registers it, or that a browser executes the result. Those still need
a real WP install (`wp-env start` — see `deployment/wordpress/doxa-maps-embed/HOW-TO-TEST.md`).
### 5. Desktop — Tauri — proof of concept
### 5. Desktop — Tauri *(built, not shot — a binary and a `.deb` ARE emitted; only the GUI launch is unverified)*
**Install:** Bun ALONE for the contract check. For the binary: the Rust toolchain (`rustup`),
the Tauri CLI (`cargo install tauri-cli`), and the platform's system webview libs.
**Install:** Bun for the check. For the binary: Rust (`rustup`), the Tauri CLI
(`cargo install tauri-cli`) and the platform's webview libraries.
```bash
bun run check:tauri # CONTRACT check — needs NO Rust, NO Tauri CLI
cargo tauri build # the binary — needs the toolchain above
bun run check:tauri # contract check — needs NO Rust, NO Tauri CLI
bun run tauri:build # the binary and a .deb — needs the toolchain above
```
**What "built, not shot" means here, measured rather than asserted.** `cargo tauri build` has been run
and its outputs are on disk: `deployment/src-tauri/target/release/doxa-maps-desktop` is a 67 MB ELF
64-bit executable, and `target/release/bundle/deb/` holds a 60 MB `.deb`. Eight source files are
git-tracked under `deployment/src-tauri/`. What has *not* happened is a window opening: this build box
is headless with no Xvfb, so nothing has confirmed the app paints rather than launching to a blank
frame. Say "built, not shot" — not "proven", and not "aspirational".
**The desktop app wraps the same tree.** `deployment/src-tauri/tauri.conf.json` points
`build.frontendDist` at `../public`, the desktop twin of Capacitor's `webDir`; the Rust crate carries
no application logic. `check:tauri` asserts that path resolves, every window `url` exists in the built
tree, the identifier matches Capacitor's `appId`, and the crate files are present.
`deployment/src-tauri/tauri.conf.json` + the `deployment/src-tauri/` Rust crate wrap the **same `public/` tree**
Capacitor and the CDN serve — `build.frontendDist` is `../public`, the desktop counterpart of
Capacitor's `webDir`. One `bun run build` now feeds four consumers: CDN, Capacitor, local
staging, and Tauri. The crate carries no application logic; the product is the web tree.
**Token caveat:** a Tauri webview serves from `tauri://localhost` (or `http://tauri.localhost` on
Windows), so a `tokenUrl` endpoint must send CORS headers for that origin. The fix is a response header
on the token host, not a change in `tauri.conf.json`. Details:
[`deployment/src-tauri/README.md`](../deployment/src-tauri/README.md).
**`check:tauri` is runnable anywhere**, exactly like `check:mobile`. It asserts the config points
at files that exist: `frontendDist` resolves (Tauri resolves it relative to `deployment/src-tauri/` — the
easiest thing to get wrong), every window `url` exists in the built tree, the identifier matches
Capacitor's `appId`, and the three crate files are present. That guards the soft failure Tauri
shares with Capacitor — *a wrong path yields a BLANK WHITE WINDOW at launch, not an error*.
Negative-tested five ways (wrong `frontendDist`, missing window url, identifier drift, empty
windows array, missing crate file); each exits 1.
---
> **The token/CORS caveat, stated before you hit it.** A Tauri webview serves from a custom origin
> (`tauri://localhost`, or `http://tauri.localhost` on Windows), *not* from your token host. The
> `tokenUrl` endpoint must send CORS headers permitting that origin — the same surprise
> `capacitor.config.json` documents for `https://localhost`. **The fix is a response header on the
> token host, not a change in `tauri.conf.json`.**
## Notes and history
**Status: BUILT — artifacts emitted; launch unverified.** Run 2026-09-21 with the real toolchain
(`tauri info` all ✔: webkit2gtk 2.50.6, rustc/cargo 1.96.1, frontendDist `../public`, Vue.js/Vite):
`bun run tauri:build` produced a **65 MB ELF executable** and a **58 MB `.deb`**
(`Package: doxa-maps`, `Depends: libwebkit2gtk-4.1-0, libgtk-3-0`). The web tree is compiled INTO
the binary — 19 distinct `doxa-maps/...` asset paths are embedded in the ELF, which is why the
`.deb` holds only 6 entries.
The measurements behind the sections above.
`bundle.targets` is `["deb"]`, not `"all"`: `rpmbuild` and `linuxdeploy` (AppImage) are absent on
this box, so `"all"` would fail two of three formats at the very end of a ~6-minute release build.
Still **not "proven"** — say *built, not shot*. The GUI launch is unverified because this VPS is
headless: the binary panics in `tao` with "Failed to initialize GTK" (no display, not a defect) and
no Xvfb is installed. See [`deployment/src-tauri/README.md`](../deployment/src-tauri/README.md) for placement rules, the
token/CORS caveat and the full artifact table.
- **Mobile — `check:mobile` needs no toolchain (verified 2026-09-20).** With `java`, `gradle`, `adb` and
`sdkmanager` all absent, sync exited 0, the whole `public/` tree was staged into the native assets and
`cap:verify` passed; removing `index.html` made it exit 1. The doc once said "21 bundles staged"; that
count named the wrong unit (`cap sync` copies `webDir` wholesale) and went stale, so it says "the whole
tree" now.
- **JavaScript embed — foreign-host run (2026-09-20).** Both embed methods were run on a foreign host page
until they failed: the iframe needed nothing; the script embed needed `assetsBase` and the API base,
which the snippet now emits. Same-root and foreign-host both then passed (map painted, attribution
shown, 0 errors, 0 404s).
- **WordPress — corrected 2026-09-20.** This section once said no WordPress build step existed; the
shortcode + block plugin and its contract check were added after. The check stubs the ~20 WordPress
functions the plugin calls (`deployment/wordpress/tests/`).
- **Tauri — the build ran (2026-09-21).** With the real toolchain (`tauri info` all ✔: webkit2gtk 2.50.6,
rustc/cargo 1.96.1) `bun run tauri:build` produced a 65 MB ELF executable and a 58 MB `.deb`
(`Depends: libwebkit2gtk-4.1-0, libgtk-3-0`); a later run measured 67 MB and 60 MB. The web tree is
compiled into the binary, which is why the `.deb` holds only 6 entries. `bundle.targets` is `["deb"]`
because `rpmbuild` and `linuxdeploy` were absent, so `"all"` would fail two formats at the end of a
~6-minute build. The window has not been opened: the build box is headless, and the binary stops with
"Failed to initialize GTK" (no display, not a defect). `check:tauri` is negative-tested five ways
(wrong `frontendDist`, missing window url, identifier drift, empty windows array, missing crate file).

View file

@ -1,32 +1,32 @@
# Develop a plugin and see it live — the golden path
> **The one thing that matters:** author a map plugin, run one build, and see it live on the platform.
> This page THREADS the existing docs into that read-order — it does not duplicate them. Follow the six
> steps; each links to the doc that teaches that piece in depth. Brand-neutral: any team on any platform
> inherits this same path.
**Author a map plugin, build it once, prove it mounts, and see it live — in six steps.** Each step links
to the doc that teaches that piece in depth.
## The path at a glance
| Step | You do | Command | Done when |
|---|---|---|---|
| **1. Pick a capability** | choose how much your plugin nests | — | you know your tier (1–5) |
| **2. Author** | write 3 files in `plugin-designs/<your-plugin>/` | — | the folder has `.vue` + `index.js` + `index.html` |
| **3. See it in the studio** | iterate with hot reload, no build | `bun run dev` | your plugin appears at http://localhost:5173/ |
| **4. Build** | produce the deployable tree | `BUILD_ALLOW=1 bun run build` | exit 0, all guards PASS |
| **5. Prove it mounts** | open every plugin in a real browser | `bun run smoke:embeds` | `N/N pages mount cleanly` |
| **6. Publish and see it live** | copy the tree to your host, embed it | see Step 6 | the live URL shows it |
```
author build publish see it live
plugin-designs/ → bun run build → merge to catalog → https://<platform>/plugin-assets/…
(one folder) (one step) (the runbook) (a tab, an iframe, a <script>)
```
Everything you build is a **compiled Vue custom element**: one folder → one bundle → one `<tag>`. A static
HTML page is never a plugin (it can only be iframed).
Everything you build is a **compiled Vue custom element** — one folder → one bundle → one `<tag>`. A static
HTML page is never a plugin (it can only be iframed). See [reference.md](reference.md) for the capability model.
---
## Step 1 — Understand the 5 capabilities (30 sec)
## Step 1 — Pick a capability
A plugin is one of five tiers, simplest → most composable:
**single-element → prop-table → monolithic → nested-element SPA → nested-SPA (app-shell)**.
You declare the tier in `<bundle>.meta.json` (`{"type": …}`) for the composite tiers. Read
[reference.md](reference.md) §capabilities; imitate the nearest reference plugin in `plugin-designs/`.
**single element → prop-table → multi-view → composite (nested elements) → app shell (nested SPAs)**.
Composite tiers declare themselves in `<bundle>.meta.json` (`{"type": …}`). Imitate the nearest reference
plugin: [reference-capability-exemplars.md](reference-capability-exemplars.md) names one per tier.
## Step 2 — Author the plugin (the 4-file pattern)
## Step 2 — Author: three files are enough
In **`plugin-designs/<your-plugin>/`** create these files (imitate the nearest reference plugin, never author
a plugin as a page). **Three are required; the rest are opt-in:**
In **`plugin-designs/<your-plugin>/`**:
| File | Required? | What it is |
|---|---|---|
@ -34,219 +34,118 @@ a plugin as a page). **Three are required; the rest are opt-in:**
| `index.js` | **required** | `defineCustomElement(RootVue)` → `customElements.define('<your-tag>', El)` — register ONLY your own tag |
| `index.html` | **required** | the staging entry page that mounts `<your-tag>` — the build fails without it |
| `<your-plugin>.meta.json` | composite tiers only | `{"type": "<machine key>", "composes": […], "label": "…"}` |
| `<your-plugin>.instances.json` | optional | preset instances — supplying one makes the bundle **prop-table** (cap-2) |
| `<your-plugin>.instances.json` | optional | preset instances — supplying one makes the bundle **prop-table** (cap 2) |
| `src/` | optional | private components used only by this plugin |
> **Measured 2026-09-21, so you can calibrate.** Across the 18 shipped bundles the folder holds **3 to 8
> files** — not one has "exactly four." `all-people-groups` is the true minimum at **3** (root `.vue` +
> `index.js` + `index.html`) and is a fully working, manifest-listed plugin. **9 of 18 carry no `.meta.json`
> at all**, because single and prop-table tiers are inferred rather than declared. Start at three and add a
> file only when you need what it does.
>
> `index.html` really is load-bearing: moving it out of `demo-map/` made the build error and dropped the
> bundle from the buildable list (18 → 17). Restoring it returned a clean build.
**`plugin-designs/` is your free zone.** Contribute to the shared `library/` (`@map`) only when a component
is genuinely **reusable and parameterized** — a library edit reaches every plugin. Walkthrough:
[tutorial.md](tutorial.md).
**`plugin-designs/` is your FREE ZONE** — build freely here. Only contribute a component to the shared
`library/` (`@map`) if it is genuinely **reusable + parameterized** — a library edit reaches every plugin, so
it's a HIGH BAR (see [reference.md](reference.md) §governance). Full walkthrough: [tutorial.md](tutorial.md).
## Step 3 — See it in the studio (seconds, no build)
## Step 3 — Build (one step)
```sh
bun run dev # vite on :5173; `/` is the Plugin Studio, every plugin in one place
bun run smoke:studio # ~5s: asserts the studio mounts and every plugin is a real subtree
```
From the repo root:
This is the loop to live in while authoring: about 5 seconds, against a full build of 1–4 minutes.
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` is deliberate: without it the build refuses (exit 75), because a full build first
wipes `public/doxa-maps/`, which every other agent or developer in a shared checkout is reading. On
your own machine, set it. To check just your plugin without touching the rest of the tree, use
`BUNDLE=<your-plugin> bun x vite build`.
**Exit 0 + every guard PASS = a good build.** Your plugin now exists under `public/doxa-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
your plugin: `BUNDLE=<your-plugin> bun x vite build`. Recipes: [how-to.md](how-to.md).
Exit 0 + all DX guards PASS + `inline-parse-check` PASS = a good build. Your plugin now exists as a real
custom-element bundle under `public/doxa-maps/<your-plugin>/`, with a standalone leaf page and a manifest
entry. Recipes: [how-to.md](how-to.md).
## Step 5 — Prove it mounts in a real browser
## Step 3a — The FAST loop: see it in the studio (~5s, no build)
Before the four-minute build, iterate in the **plugin studio** — the single staging environment, where
every plugin appears in one place as a real mounted element:
**A green build is a static result**: it proves files exist, parse and carry correct hashes. It never opens
your element, and an element that throws on setup renders nothing while the build stays green.
```sh
bun run dev # vite on :5173; `/` mounts <plugin-studio>, one tab per profile
bun run smoke:studio # ~5s: asserts the studio mounts and every tab is a real subtree
```
Measured 2026-09-22: **PASS in 4.7s — 29 tabs, 0 failed requests**, against a full `bun run build` of
**43s–4min** on the same box (it runs 19 bundles sequentially and queues behind any other build, so
the spread is real). An order of magnitude either way: this is the loop to live in while authoring.
The build is for publishing, not for looking.
> **★ The studio catches a class the built tree structurally cannot.** The dev manifest hands
> **rooted** refs (`/plugin-designs/…`) while the built manifest hands **relative** ones
> (`./bundle/bundle.js`). A base-join bug is therefore invisible in one surface and fatal in the
> other — which is why both this step and the next exist, rather than one replacing the other.
There is exactly **one** staging environment. If you find yourself looking at a second index of
plugins, it is the archived gallery at `/__gallery` — kept because the dev server still imports its
renderer, not because it is a place to review work.
## Step 3b — Prove it MOUNTS in a browser (before you publish)
A green build is a **static** result: the guards check that files exist, parse, carry a correct SRI hash and
resolve. None of them opens your element. A bundle can pass every guard above and still render nothing —
a thrown `setup()` removes the subtree silently, and the build stays green.
> **This was measured, not assumed (2026-09-21).** A `throw` was injected into `template-map-a.vue`'s
> `setup()` and the bundle rebuilt. Result: the build **succeeded** (206 kB emitted, exit 0), `check:parse`
> **PASS**, `check:drop` **PASS** — and `smoke:embeds` reported **0/2 pages mount cleanly**. One deliberate
> defect, invisible to every static guard, caught only in a browser. That gap is the entire reason this
> step exists.
```sh
# A token is required or map profiles cannot mount. Take it from the build config, which is
# the one source that works from a local gate — see the note below before changing this.
MAPBOX_TOKEN=$(python3 -c "import json;print(json.load(open('vite.doxa-maps-build-config.json'))['token'])")
MAPBOX_TOKEN=$MAPBOX_TOKEN bun run smoke:embeds # every profile in the manifest
MAPBOX_TOKEN=$MAPBOX_TOKEN bun run smoke:embeds # every plugin
MAPBOX_TOKEN=$MAPBOX_TOKEN node internal/embed-smoke.mjs --grep <your-plugin> # just yours
```
> **Do not fetch the token from `https://doxa.life/api/maps/token` here — measured 2026-09-23, it does
> not work for this.** That endpoint serves a token URL-restricted to `doxa.life`, so a tile request
> without that Referer returns **403** and every basemap in your smoke test renders blank while the pins
> draw fine. That is the exact symptom first reported as *"it's loading the pins, but it's not
> loading the actual underlying map"*, and it is why `tokenUrl` was emptied in the build config on
> 2026-09-22. This doc used to hand you that command, so an inheritor's first run reproduced the bug.
>
> The `token` field in `vite.doxa-maps-build-config.json` is the one that works: measured, the config
> token returns **200** for a tile request with no Referer while the endpoint token returns **403**. It
> is a `pk.` public key, baked deliberately (a project decision, 2026-09-21) so a map works on any host —
> protected by URL restrictions rather than secrecy. `check:public-hygiene` allows `pk.` and fails the
> build on `sk.`/`tk.`, so this is safe to read and never a secret to guard.
**Expect `N/N pages mount cleanly`.** The token matters: without it every *map* plugin fails to mount while
every non-map plugin passes, which looks like many broken plugins and is not. Take the token from the
build config, as above ([why](#notes-and-history)).
`smoke:embeds` serves `public/` and opens **every bundle × profile × preconfigured instance** from
`manifest.json`, asserting each custom element defines, attaches a shadow root and fills it. Expect
`N/N pages mount cleanly`. Measured 2026-09-21: **40/40**.
## Step 6 — Publish and see it live
> **The token is not optional, and its absence is loud.** Without `MAPBOX_TOKEN` every *map* profile fails
> its mount assertion while every *DOM* profile passes — 27/40, which reads like 13 broken plugins and is
> not. The run prints a two-line warning when the token is missing; believe the warning, not the failures.
> `?tk=` alone is also not enough for a composite: it reaches only the top-level element's `profile-config`,
> while **nested children read `window.MAP_TOKEN`**. The smoke sets both.
**This step is not in CI.** CI is the single authoritative builder but installs no browser binary (no
`playwright install` step), so the mount check is a LOCAL gate an author runs deliberately. That is exactly
why it belongs here in the golden path rather than being left to be remembered.
## Step 4 — Publish to the live catalog
The platform serves from the **catalog repo** (`platform/mfe-index`), NOT from where you develop. Publishing
= merging your built bundles into the catalog. **The tested procedure, in one block:**
**Copy the built tree to any static host, then embed from it.** The tree is `public/doxa-maps/`; every
plugin's ready-made snippets are in its `manifest.json`.
```sh
set -e # ← FIRST LINE, not a style choice. Explained below; without it this block is unsafe.
# 0. PRECONDITION: never publish a tree a build is rewriting. `clean` empties public/doxa-maps at
# the START of every build, so a concurrent build makes the source momentarily EMPTY.
bun run check:settled
# 1. clone the catalog fresh (fetch-first, never force)
git clone --depth 1 <catalog-repo-url> /tmp/catalog
# 2. merge your built bundles into its doxa-maps subtree (--delete mirrors removals)
rsync -a --delete public/doxa-maps/ /tmp/catalog/app/doxa-maps/
# 3. VERIFY — these are commands, not a reminder. Under `set -e` a failure aborts before the push.
bun run check:sri # every leaf's baked integrity matches the bundle it loads
bun run check:embed-targets # every embed.iframe src in the manifest resolves to a real file
git -C /tmp/catalog diff --stat -- . ':(exclude)app/doxa-maps' | grep -q . \
&& { echo 'ABORT: diff touches files outside app/doxa-maps'; exit 1; } || true
# 4. commit (product-only message), then push with a RAISED BUFFER (see below):
git -C /tmp/catalog -c http.postBuffer=524288000 push origin HEAD:main
set -e # first line: a failing check must stop the publish
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/
# commit and push the host checkout; for a large tree: git -c http.postBuffer=524288000 push …
```
> **★ Why `set -e` is the first line, and why step 3 is commands rather than a comment.** Both halves
> of this once took the live catalog down with a 404. The verify was chained into a compound command,
> so a *failing* check still fell through to the push; and `rsync --delete` ran from a source a
> concurrent build had just emptied, which faithfully mirrored that emptiness into the catalog.
> `--delete` is what makes a mid-churn source catastrophic — it deletes the live tree to match your
> empty one. So the settled check is a precondition, not a courtesy, and a verify that cannot abort
> is not a verify.
> **★ `http.postBuffer=524288000` is load-bearing, not optional.** The tree is ~39 MB and git's 1 MB
> default cannot stream it. Without the flag the push fails with `The remote end hung up unexpectedly`
> — which reads as a SERVER rejection (permissions? branch protection? repo full?) and sends you
> debugging the wrong machine entirely. It is a local buffer limit. This one line is the difference
> between step 4 working and step 4 looking like an access problem.
The platform serves the catalog directly, so a pushed change is live at once — verify at the URL in Step 5.
(The fuller runbook with every gotcha lives in the project's deploy-cycle notes; the block above is the
load-bearing sequence.)
(The end-state is the Forgejo CI build-gate — push source, CI builds + publishes automatically. Until a runner
is registered, the runbook is the manual path. Same `build:unlocked` seam.)
## Step 5 — See it live (the 5 deployment methods)
Your one element deploys five ways (only an element supports all five — a page supports iframe only):
| # | Method | How | Status today |
| # | Deployment method | How | Status |
|---|---|---|---|
| 1 | **iframe** | point an `<iframe>` at the plugin's leaf page | ✅ proven |
| 3 | **JavaScript embed** | `<script src=".../<plugin>.js">` + `<your-tag>` (SRI-hashed) | ✅ proven |
| 2 | **mobile** | `bun run phone` (Capacitor) — build step, no new code | 🔶 built, not shot |
| 4 | **WordPress plugin** | the `deployment/wordpress/doxa-maps-embed/` PHP wrapper emits the embed snippet | 🔶 built, not shot |
| 5 | **desktop (Tauri)** | `bun run tauri:build` → a real binary + `.deb` wrapping the same `public/` tree | 🔶 built, not shot |
| 1 | **iframe** | point an `<iframe>` at the plugin's leaf page | ✅ production |
| 2 | **mobile app** | `bun run phone` (Capacitor) — same tree, no new code | ✅ built |
| 3 | **JavaScript embed** | `<script src=".../<plugin>.js">` + `<your-tag>` (SRI-pinned) | ✅ production |
| 4 | **WordPress / Disciple Tools** | `deployment/wordpress/doxa-maps-embed/` emits the embed snippet | 🧪 proof of concept |
| 5 | **desktop (Tauri)** | `bun run tauri:build` — a binary and `.deb` wrapping the same tree | 🧪 proof of concept |
Say "proven" ONLY for 1 & 3. For 2, 4 and 5 say "built, not shot" — each is scaffolded and
contract-checked, and what is missing differs per method: 2 has no APK (no Android SDK here), 4 has
no capture of a live WordPress install, and 5 **does** emit artifacts — a 67 MB binary and a 60 MB
`.deb` are on disk — but no window has been opened, because this box is headless. "Not shot" means
nobody has watched it run, which is not the same as "nothing was produced". Verify at
`https://<platform>/plugin-assets/doxa-maps/<plugin>/<plugin>/index.html`.
**Done = the live site shows it, not a green build.**
**Done = the live site shows it, not a green build.** To check the whole pipe at once, open the
`golden-path-check` plugin on your host: it reads the manifest beside it and reports how many bundles
it can see and whether it finds itself. To check that what you published is *current*, run
`bun run check:catalog-currency`.
> **Check the path itself, not just your plugin.** The catalog ships a one-element self-test of
> everything above — open
> `https://<platform>/plugin-assets/doxa-maps/golden-path-check/golden-path-check/index.html`.
> It reads the manifest it was published beside and reports four things: how many bundles and
> profiles the catalog holds, how many profiles describe themselves, and whether it can find
> *itself* listed. If it answers, authoring → build → publish → serve all worked on this platform,
> which is a different question from whether *your* plugin did. Verified on the live catalog
> 2026-09-23: 19 bundles, 28 profiles, 28 of 28 self-describing, listed = yes, zero console errors.
>
> Read what it does **not** claim. Those are questions about the catalog's SHAPE, so it stays green
> even if a bundle's CONTENT is stale — freshness belongs to `bun run check:catalog-currency`, which
> compares every live bundle's bytes against your build. Run both: the self-test says the pipe
> works, the currency gate says what came through it is current.
## Step 6 — Surface it (make it discoverable)
A plugin reachable only by direct URL isn't really "seen live." Good news: **you usually don't have to do
anything.**
**It is already surfaced — automatically.** The cap-5 SPA reads the built `manifest.json` at runtime and
auto-discovers every bundle that is neither already placed in a curated section nor on the small
`NOT_EXPERIMENTAL` exclusion list (the two shells, the template, and bundles that are nested *inside* another
tab). Your plugin appears in the collapsible **Experimental** folder at the bottom of the rail, marked 🧪, with
its label and description read from the manifest. No `TAB_DEFS` entry, no `NAV_MODEL` row, no code edit.
> Verified 2026-09-21 in a browser: the rail rendered **11 rows — 6 curated, then 5 auto-discovered**
> (`all-people-groups`, `country-posters`, `demo-map`, `doxa-vision-card`, `stack-demo`), each carrying the
> 🧪 marker. Discovery is fail-soft: if the manifest is unreachable the folder stays empty and the curated
> rail is unaffected — a discovery failure never takes down the app.
**Promote it to a curated tab (optional).** The curated sections encode a deliberate teaching order, so a
plugin graduates out of Experimental only when it earns a place in that order. That is the edit Step 6 used to
describe as mandatory: add a `TAB_DEFS` entry plus a `NAV_MODEL` row in `plugin-designs/doxa-maps-spa/`, then
rebuild + republish.
**One rail row per BUNDLE, not per profile.** A bundle's profiles (gospel-resources ships four) are alternate
views reached *inside* the pane. The rail is an onboarding ramp, not an inventory.
**Your plugin surfaces itself.** The Research Gallery (`doxa-maps-spa`) reads the built `manifest.json`
and puts every bundle it doesn't already curate into its folded **Experimental** shelf, with the label and
description from the manifest — no code edit. Promoting it to a curated tab is an optional edit to
`TAB_DEFS` and `NAV_MODEL` in `plugin-designs/doxa-maps-spa/`. One row per bundle: a bundle's extra
profiles are views inside its pane.
---
**That's the whole path: author in `plugin-designs/` → `bun run build` → prove it MOUNTS (`smoke:embeds`) →
publish via the runbook → see it live → and it surfaces itself in the SPA's Experimental folder.** Only two
of those six steps are edits you write: the plugin, and the optional promotion out of Experimental. Deep
dives: [tutorial.md](tutorial.md) · [how-to.md](how-to.md) · [reference.md](reference.md) · [why.md](why.md).
**The whole path:** author in `plugin-designs/` → see it in the studio → `BUILD_ALLOW=1 bun run build`
→ prove it mounts (`smoke:embeds`) → publish → it appears on the Experimental shelf. Deep dives:
[tutorial.md](tutorial.md) · [how-to.md](how-to.md) · [reference.md](reference.md) · [why.md](why.md).
---
## Notes and history
The measurements behind the steps above, kept for whoever needs the reasoning.
- **Step 2 — how many files a plugin really has (measured 2026-09-21).** Across 18 bundles a folder held
3 to 8 files. `all-people-groups` is the true minimum at 3 (root `.vue` + `index.js` + `index.html`), and
9 of 18 had no `.meta.json` because single and prop-table tiers are inferred. Removing `index.html` from
`demo-map/` made the build error and dropped the bundle (18 → 17 buildable).
- **Step 3 — studio vs build timing (measured 2026-09-22).** `smoke:studio` passed in 4.7 s (29 tabs,
0 failed requests); a full build took 43 s–4 min on the same box. The studio also catches a bug class the
built tree cannot: the dev manifest hands rooted refs (`/plugin-designs/…`), the built one relative refs
(`./bundle/bundle.js`), so a base-join bug is invisible in one and fatal in the other.
- **Step 5 — why the browser check exists (measured 2026-09-21).** A `throw` injected into
`template-map-a.vue`'s `setup()` still built (exit 0), passed `check:parse` and `check:drop`, and
`smoke:embeds` reported 0/2 mounted. Without a token, 27/40 mount — every map profile fails. A composite
also needs `window.MAP_TOKEN`, because `?tk=` reaches only the top-level element; the smoke sets both.
The browser check is not in CI (no browser binary there), so it is a local gate you run on purpose.
- **Step 5 — which token (measured 2026-09-23).** The `https://doxa.life/api/maps/token` endpoint serves a
token restricted to `doxa.life`: a tile request without that Referer returns 403, so basemaps render
blank while pins draw. The build-config `token` returns 200 from any origin. It is a `pk.` public key,
restricted by URL rather than kept secret; `check:public-hygiene` allows `pk.` and fails on `sk.`/`tk.`.
- **Step 6 — why `set -e` comes first.** An earlier publish took the live catalog down with a 404 twice
over: the verify was chained so a failing check fell through to the push, and `rsync --delete` ran from
a tree a concurrent build had just emptied, mirroring the emptiness to the host. Hence `check:settled`
as a precondition and a verify that can abort.
- **Step 6 — `http.postBuffer=524288000`.** The tree is ~39 MB; git's 1 MB default cannot stream it, and
the failure (`The remote end hung up unexpectedly`) reads like a server rejection when it is a local
buffer limit.
- **Step 6 — deployment status wording.** "Proof of concept" means the code exists and its contract check
passes, but it is not production-ready: method 4 has no capture on a live WordPress install, and method 5
emits a real binary and `.deb` (~67 MB and ~60 MB) that has not been launched on a desktop.

View file

@ -24,6 +24,10 @@
* 2. every tab reaches state 'ready' (never 'error', never stuck 'idle'/'loading').
* 3. every CURATED tab paints a WebGL canvas — they are all maps or map composites.
* Experimental tabs need only mount: some are cards or legacy frames with no canvas.
* 5. GOOD NEIGHBOUR (full runs only): embedded below the fold on a phone-width host page with hostile
* typography, the gallery must not scroll the host, rewrite its URL or title, or inherit its
* font metrics — after load and after every tab. Nested-plugin violations are listed, owned and
* printed in NEIGHBOUR_KNOWN until their owners fix them.
* 4. zero failed requests to the TREE's own origin (a 404/CORS on our assets is our bug; third-
* party tile/glyph hiccups are reported, not failed — they are not this repo's to fix).
*
@ -48,6 +52,22 @@ const ONLY = argOf('--only', '') ? argOf('--only', '').split(',') : null
const PROVIDER = argOf('--provider', '')
// KNOWN, ROUTED exemptions from the zero-Mapbox rule — each names its owner. Printed on every run so
// it cannot become invisible; DELETE the entry when the owner lands the fix (the gate then enforces it).
// KNOWN, ROUTED good-neighbour violations by NESTED plugins (key = '<tab id>:<scroll|url|title>').
// Same contract as MAPBOX_KNOWN: printed on every run, owner named, and DELETED in the same commit
// as the owner's fix so main never carries a stale exemption or a red gate on someone else's bug.
const NEIGHBOUR_KNOWN = {
// doxa-research-map/research-map.vue:647 — btn.scrollIntoView({block:'nearest'}) on the active
// .rm-tab when it mounts. 'nearest' still scrolls the PAGE whenever the embed is below the fold:
// on a phone the DX-life page jumps to y≈640 on load. Fix = scroll the tab bar's own scrollLeft
// (the SemanticTreeLegend bringRowToTop pattern, regression 554bf10). Routed 2026-09-25.
'research-map-plus-grid:scroll': 'research-map owner (research-map.vue:647 scrollIntoView)',
'research-map:scroll': 'research-map owner (research-map.vue:647 scrollIntoView)',
// regions-dashboard/region-dashboards.vue:499 URL_SYNC is unconditional, so a nested Regions
// rewrites the host's #anchor (writeUrl) and document.title (syncTitle). The gallery passes
// profile-config.urlSync:false for exactly this; Regions needs to honour it. Routed 2026-09-25.
'regions-dashboard:url': 'regions owner (region-dashboards.vue URL_SYNC ignores urlSync:false)',
'regions-dashboard:title': 'regions owner (region-dashboards.vue syncTitle)',
}
const MAPBOX_KNOWN = {
// EMPTY ON PURPOSE. The one entry here — my-upg-100-list/upg-100-list.vue:1464 pinning
// mapbox-gl.css v3.3.0 regardless of engine — was fixed and the exemption deleted in the SAME
@ -81,7 +101,13 @@ try { ({ chromium } = await import('playwright')) } catch {
}
let mode = 'js'
// NEIGHBOUR mode: the partner-page shape that exposes a badly-behaved guest. The gallery sits BELOW
// THE FOLD, under hostile inherited typography, on a page with its own #anchor and <title>.
const NEIGHBOUR_TITLE = 'Host research page'
const hostPage = () => {
if (mode === 'neighbour') return `<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>${NEIGHBOUR_TITLE}</title>
<style>body{margin:0;font:20px/3 Georgia,serif;letter-spacing:2px;color:#900} .fold{height:1400px}</style></head>
<body><div class=fold>host content above the fold</div><script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa${PROVIDER ? ` provider="${PROVIDER}"` : ''}></doxa-maps-spa><p>after</p></body></html>`
const embed = mode === 'iframe'
? `<iframe src="${TREE}/doxa-maps-spa/doxa-maps-spa/index.html${PROVIDER ? '?provider=' + PROVIDER : ''}" style="width:100%;height:760px;border:0"></iframe>`
: `<script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa${PROVIDER ? ` provider="${PROVIDER}"` : ''}></doxa-maps-spa>`
@ -113,7 +139,10 @@ try {
page.on('requestfailed', q => { const e = (q.failure() && q.failure().errorText) || ''; if (!/ABORTED/.test(e)) note(q.url(), e) })
await page.goto(`http://localhost:${HOST_PORT}/`)
const frame = mode === 'iframe' ? await (await page.waitForSelector('iframe')).contentFrame() : page.mainFrame()
await frame.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 30000 })
// A gallery that never boots is a named FAIL for this mode, never an uncaught crash: a crash
// skips every later mode and the summary, so it reads as "the gate broke" rather than a finding.
const booted = await frame.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 60000 }).then(() => true, () => false)
if (!booted) { console.log(`FAIL [${mode}] the gallery did not boot within 60s`); fails.push(`[${mode}] the gallery did not boot within 60s`); await page.close(); continue }
if (mode === 'js') {
const g = await page.evaluate(() => {
@ -167,13 +196,61 @@ try {
for (const f of foreign) console.log(`note [${mode}] third-party request failed (not gated): ${f}`)
await page.close()
}
// ── GOOD-NEIGHBOUR PHASE (script embed only: an iframe has its own document, title and URL) ──
// A guest on a partner page must leave the page as it found it. Four rules, each measured after the
// gallery loads and again after every tab opens, at PHONE width (where Simple Research is the
// default tab, and where a scroll jump on load was measured, 2026-09-25):
// 1. the host page does not SCROLL (scrollIntoView on a nested element scrolls every ancestor,
// including the partner's page — a visitor is yanked down to the embed with no action);
// 2. the host URL (path, query, #anchor) is unchanged; 3. document.title is unchanged;
// 4. the host's inherited typography does not reach the gallery root.
if (!argOf('--mode', '') && !ONLY) {
mode = 'neighbour'
const page = await browser.newPage({ viewport: { width: 390, height: 800 } })
const START = `http://localhost:${HOST_PORT}/?page=research#top`
await page.goto(START)
const booted = await page.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 60000 }).then(() => true, () => false)
if (!booted) { fails.push('[neighbour] the gallery did not boot within 60s'); console.log('FAIL [neighbour] the gallery did not boot within 60s') }
await page.waitForTimeout(5000)
const look = () => page.evaluate(() => ({ y: Math.round(scrollY), href: location.href, title: document.title, active: document.querySelector('doxa-maps-spa')._instance.proxy.activeKey }))
const typo = await page.evaluate(() => { const c = getComputedStyle(document.querySelector('doxa-maps-spa').shadowRoot.querySelector('.vtl')); return { size: c.fontSize, ls: c.letterSpacing, lh: c.lineHeight } })
const typoOk = typo.size === '16px' && typo.ls === 'normal' && typo.lh === 'normal'
console.log(`${typoOk ? 'PASS' : 'FAIL'} [neighbour] host typography stops at the box — gallery root ${JSON.stringify(typo)}`)
if (!typoOk) fails.push(`[neighbour] host typography leaked into the gallery ${JSON.stringify(typo)}`)
const judge = (at, v) => {
const bad = []
if (v.y !== 0) bad.push(['scroll', `host page scrolled to y=${v.y}`])
if (v.href !== START) bad.push(['url', `host URL became ${v.href.replace(/^https?:\/\/[^/]+/, '')}`])
if (v.title !== NEIGHBOUR_TITLE) bad.push(['title', `host title became "${v.title}"`])
for (const [kind, why] of bad) {
const known = NEIGHBOUR_KNOWN[`${at}:${kind}`]
if (known) console.log(`KNOWN [neighbour] ${at}: ${why} — owner: ${known}`)
else { console.log(`FAIL [neighbour] ${at}: ${why}`); fails.push(`[neighbour] ${at}: ${why}`) }
}
if (!bad.length) console.log(`PASS [neighbour] ${at} leaves the host page alone`)
return bad.length
}
const first = await look()
judge(first.active, first)
const ids = await page.evaluate(() => document.querySelector('doxa-maps-spa')._instance.proxy.tabs.map(t => t.id))
for (const id of ids) {
// Reset the host between tabs, so each tab is judged on what IT did, not on a predecessor's.
await page.evaluate(({ u, t }) => { scrollTo(0, 0); history.replaceState(null, '', u); document.title = t }, { u: START, t: NEIGHBOUR_TITLE })
await page.evaluate(id => document.querySelector('doxa-maps-spa')._instance.proxy.onSelect(id), id)
await page.waitForTimeout(4000)
judge(id, await look())
}
checked.neighbour = ids.length
await page.close()
}
} finally { await browser.close(); cleanup() }
// ★ A MODE THAT RAN NOTHING HAS NOT PASSED. A flag lost on the way in (package.json once ended
// this entry in an inline # comment, so an appended --mode landed inside the comment) must not
// read as "both modes green": each requested mode has to have driven at least one tab.
for (const m of MODES) if (!checked[m]) fails.push(`[${m}] checked 0 tabs — this mode did not run`)
const tally = MODES.map(m => `${m} ${checked[m] || 0} tab(s)`).join(', ')
const tally = [...MODES, ...(checked.neighbour ? ['neighbour'] : [])].map(m => `${m} ${checked[m] || 0} tab(s)`).join(', ')
if (fails.length) {
console.log(`\nchecked: ${tally}`)
console.log(`\ncheck-research-gallery-embed: FAIL (${fails.length})`); for (const f of fails) console.log(' - ' + f)

View file

@ -86,7 +86,18 @@ export function galleryModel(reg) {
// and nothing else — the dev equivalent of the built tree's <bundle>/<profile>/index.html, which
// is what this URL means in production. Same contract, so the studio behaves identically in both.
const link = (...parts) => '/__leaf/' + parts.map(encodeURIComponent).join('/')
const bundles = [...reg].sort((a, b) => a.bundle.localeCompare(b.bundle)).map((b) => {
// GATED bundles (vite.doxa-maps-build-config.json `gatedProfiles`, e.g. key-cities) are withheld
// from every public surface the BUILD emits. The dev model listed them anyway, so the studio and the
// Research Gallery's Experimental shelf showed under the dev server — which is what DX runs first
// after cloning — a plugin the built tree deliberately hides. Same rule here, so dev == built.
let gated = new Set()
// Read from the config file itself, as generate-tree does: readBuildConfig() returns a projection
// that does not carry gatedProfiles.
try {
const cfg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'vite.doxa-maps-build-config.json'), 'utf8'))
if (Array.isArray(cfg.gatedProfiles)) gated = new Set(cfg.gatedProfiles.map(String))
} catch { /* no config: gate nothing */ }
const bundles = [...reg].filter((b) => !gated.has(b.bundle)).sort((a, b) => a.bundle.localeCompare(b.bundle)).map((b) => {
const scriptUrl = `/plugin-designs/${b.bundle}/index.js`
const presets = new Map() // profile → its preset instances (empty for every other kind)
const typeOf = new Map() // profile → composition tier machine key (single … nested-mfe-spa)

View file

@ -159,9 +159,11 @@
"check:snapshot-guard:poison": "node internal/check-snapshot-guard.mjs --poison",
"check:coverage-consistent": "node internal/check-gospel-coverage-consistent.mjs # the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
"check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison",
"check:click-sheet": "node plugin-designs/gospel-resources/scripts/verify-gospel-click-sheet.mjs # the COUNTRY-CLICK SHEET without a GPU: every country in the roster resolves to >=1 pin, every link the sheet would render is absolute, and has_audio stays the STRING \"unknown\" where the pool was never checked (collapsing it to false would read as \"no audio exists\"). It had been CRASHING on a path deleted when data moved out of public/, and its two inputs were a private by-country copy and the curated 2,106-row subset the sheet stopped reading — so it was unrunnable AND aimed at files the surface no longer uses. Joins by ISO3 now, not by country-name substring. Carries its own 4-case self-test. Static, sub-second.",
"check:stale-sweep-claim": "node internal/check-no-stale-sweep-claim.mjs # a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.",
"check:surfaces-read-the-tier": "node internal/check-surfaces-read-the-tier.mjs # a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.",
"check:surfaces-read-the-tier:poison": "node internal/check-surfaces-read-the-tier.mjs --poison",
"check:link-rot": "node internal/check-link-rot-sample.mjs # re-tests a stratified sample of links we already VERIFIED, because nothing else ever re-tests one: a publisher can retire a page and the map keeps offering it, still labelled 'link checked'. Only hosts whose HTTP status is real evidence are sampled — jesusfilm.org, live.bible.is, knowgod.com and fivefish.org never 404, so status-checking them would report perfect health forever. Three impossible URLs run in band every time; if one answers 2xx it reports PROVED NOTHING instead of a pass. ~60s, network.",
"check:link-rot": "node internal/check-link-rot-sample.mjs # re-tests a stratified sample of links we already VERIFIED, because nothing else ever re-tests one: a publisher can retire a page and the map keeps offering it, still labelled 'link checked'. THREE BANDS, each measured rather than assumed, each with its controls in band every run: plain STATUS where a status is evidence (jesusfilm.org among them — it does 404 on the /watch/jesus/<slug> shape the map ships, and the old exclusion was inherited from the retired ?languageId= URL); API-MAPPED for live.bible.is, whose page answers 200 for any fileset while /api/bibles/<id> answers 404; and REDIRECT-LANDING for fivefish.org, where a published code lands on its numbered language page and an unknown one is swept to /regions. If a control misbehaves it reports PROVED NOTHING rather than a pass. Only knowgod.com is left out — byte-identical page for any URL; the verifier proves those through Cru's API. ~3 minutes, network.",
"check:embed-basemap": "node internal/check-embed-basemap-paints.mjs # the gospel map's BASEMAP must paint inside a genuinely cross-origin iframe on the token the page BAKES, not one a probe forces on it. Every other browser gate here forces a restricted token (u=doxalife) that 403s on the basemap, then counts PINS — which come from our own GeoJSON and paint with no token at all — so 'the map renders' had never been measured. Runs a negative control every time by rewriting the token on the wire; if the control passes it reports PROVED NOTHING rather than a pass. ~60s, needs a browser.",
"check:synced-css:self-test": "node internal/check-synced-css-copies.mjs --self-test",
"serve:permissive": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public # LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host.",

View file

@ -59,6 +59,14 @@ document.querySelector('doxa-maps-spa')
.addEventListener('tab-change', (e) => console.log(e.detail[0].id, e.detail[0].label))
```
## A good guest on your page
The gallery is tested to leave its host page as it found it: it does not scroll your page, rewrite
your URL or `<title>`, or inherit your body typography (a blog's `font: 20px/3` stays outside the
box). It does add `@font-face` rules and the map engine's stylesheet to your `<head>`; fonts have to
be declared at document level to reach a shadow root, and the engine styles only target map classes.
Right-to-left pages keep their direction. `bun run check:gallery-embed` enforces all of this.
## What the server must send
Both headers are **required** on a host other than the one serving the tree:

View file

@ -1256,6 +1256,11 @@ export default defineComponent({
// cannot guess it, and guessing is what produced the original 404 class.
if (this.base) pc.treeBase = this.base
if (this.assetsBase) pc.assetsBase = this.assetsBase
// THE HOST OWNS ITS URL AND TITLE. A nested map must not write the page's hash, history or
// document.title: measured 2026-09-25, opening Regions on a host page stripped the host's own
// #anchor and retitled the host's browser tab "Regions". A child that syncs its state into the
// URL standalone should read this and stand down; children that never write the URL ignore it.
pc.urlSync = false
return pc
},
// The PAGE GLOBALS a generated leaf page bakes and a foreign host page does not — set here, and
@ -1355,6 +1360,20 @@ export default defineComponent({
style="height:…" (or any page rule) outranks :host, and --doxa-maps-spa-height tunes it without
CSS specificity games. */
:host { display: block; position: relative; height: var(--doxa-maps-spa-height, 85vh); min-height: 480px; }
/* ★ THE HOST'S TYPOGRAPHY STOPS AT THE BOX. Shadow DOM isolates SELECTORS, not INHERITANCE: every
inherited property on the host page flows straight into the gallery and into every nested map.
Measured 2026-09-25 on a host with `body{font:20px/3 Georgia; letter-spacing:2px}`: the gallery
root computed font-size 20px, line-height 60px, letter-spacing 2px — every rail row and map legend
re-flowed by the partner's blog styles. These are reset to the values the gallery is designed
against (its own leaf page's defaults). `direction` is deliberately NOT reset, so an RTL host page
still lays the gallery out RTL; `all: initial` would have reset it too. Family and colour are set by
the layout itself (.vtl). */
:host {
font-size: 16px; line-height: normal; font-weight: 400; font-style: normal; font-variant: normal;
font-stretch: normal; letter-spacing: normal; word-spacing: normal; text-transform: none;
text-align: start; text-indent: 0; text-shadow: none; white-space: normal; hyphens: manual;
visibility: visible; cursor: auto;
}
:host([hidden]) { display: none; }
/* The shell (top bar + rail + content frame) is the shared VerticalTabsLayout. Only the PANE-local

View file

@ -143,7 +143,24 @@ const table = computed(() => {
: [String(r.iso3 || '').toUpperCase()]
return set.includes(fCountry.value)
})
return { ...t, rows }
/* THE TITLE TRAVELS WITH THE ROWS.
*
* Export three filtered views and you get three files called
* gospel-resources-every-link-and-who-it-reaches.csv — DataTable names the download from
* meta.title, and meta.title did not know about the filter. Measured: China exported 265
* lines and Germany 7, both correct, both under the same name, so the second download
* either overwrote the first or landed as "(1)". The rows were right and the file was
* unidentifiable, which is the failure mode for the person this export exists for: someone
* pulling a list per country to hand to a team.
*
* Naming it here rather than teaching DataTable a new prop keeps the decision this file
* already made — that component has other consumers and none of them need this — and it
* fixes the on-screen heading in the same move: it read "every link, and who it reaches"
* over a table showing one country's. */
const title = t.meta?.title
? `${t.meta.title} — ${fLabel.value || fIso.value || fCountry.value}`
: t.meta?.title
return { ...t, meta: { ...t.meta, title }, rows }
})
const filterCount = computed(() => (table.value?.rows || []).length)
const totalCount = computed(() => (rawTable.value?.rows || []).length)

View file

@ -1,6 +1,6 @@
// verify-gospel-click-sheet.mjs — prove the COUNTRY-CLICK SHEET is correct without a GPU.
//
// node plugin-designs/gospel-resources/scripts/verify-gospel-click-sheet.mjs # exits 1 on any failure
// node plugin-designs/gospel-resources/scripts/verify-gospel-click-sheet.mjs # exits 1 on failure
//
// ═══ WHY THIS EXISTS ════════════════════════════════════════════════════════
// Clicking a country on the gospel map needs Mapbox TILES (WebGL), which headless
@ -8,70 +8,107 @@
// But the click is two separate things:
// 1. THE SHEET CONTENT what a click RESOLVES TO <- verifiable, and this does it
// 2. THE TAP -> OPEN the GPU-rendered gesture <- needs a real device
// Leaving BOTH unverified because one is impossible is the lazy read (same split as
// verify-map-paint.mjs). This closes half the gap and NAMES the half that remains.
// Leaving BOTH unverified because one is impossible is the lazy read. This closes half
// the gap and NAMES the half that remains.
//
// WHAT IT ASSERTS, for every country the map can paint:
// - the iso3 the click handler looks up EXISTS in the country roster (byIso hit)
// - that country resolves to >=1 people-group row (the sheet would not be empty)
// - every resource link rendered is a real absolute URL, never fabricated
// - has_audio is the STRING 'unknown' where the pool was never checked - never
// ═══ IT HAD STOPPED RUNNING AT ALL, AND THEN IT WAS CHECKING THE WRONG FILE ═
// Found 2026-09-25, by running it. It crashed with ENOENT on
// app/doxa-maps/gospel-resources/assets/tables/people-group-resources.json
// a tree deleted when data moved out of public/. A gate that cannot start proves nothing,
// and nothing noticed, because nothing runs it.
//
// The dead path was the smaller half. Its other input was a private copy of by-country
// under plugin-designs/, and its people-group table was the CURATED 2,106-row subset —
// which resources-map.vue stopped fetching some time ago, saying so in its own comments:
// the country sheet is built from the PIN INDEX now, 12,379 groups. So even repaired, it
// would have verified two files the sheet no longer reads, and reported green about a
// surface it was no longer looking at. Both inputs are now the live ones.
//
// ═══ THE JOIN IS ISO3, NOT A NAME ═══════════════════════════════════════════
// The old country match was a SUBSTRING test against a comma-joined name blob, with a
// comment explaining that country names contain commas so splitting under-counted. That
// is the shape of bug this bundle refuses everywhere else: names collide, ISO codes do
// not. The pin index carries iso3 per pin, so the join is exact.
//
// WHAT IT ASSERTS:
// - every country in the roster resolves to >=1 pin (the sheet would not be empty)
// - every URL the sheet would render for those groups is absolute — never fabricated
// - has_audio stays the STRING 'unknown' where the pool was never checked, never
// collapsed to false, which would read as "no audio exists" (persecution-critical)
//
// PROVEN TO GO RED — see the SELF-TEST at the bottom: it corrupts each invariant in
// memory and asserts the check fails. A check that has never failed proves nothing.
// PROVEN TO GO RED — the SELF-TEST corrupts each invariant in memory and requires the
// check to fail. A check that has never failed proves nothing.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { resourceRows } from '../gospel-resource-rows.js'
const __dirname = dirname(fileURLToPath(import.meta.url));
const A = join(__dirname, '..', '..', '..', 'app', 'doxa-maps', 'gospel-resources', 'assets', 'tables');
const P = join(__dirname, '..', '..', '..', 'plugin-designs', 'gospel-resources', 'data');
const HERE = dirname(fileURLToPath(import.meta.url))
const T = join(HERE, '..', '..', '..', 'data', 'tables')
const load = (p) => JSON.parse(readFileSync(p, 'utf8'));
const countries = (() => { const d = load(join(P, 'by-country.json')).countries; return Array.isArray(d) ? d : Object.values(d); })();
const pgRows = load(join(A, 'people-group-resources.json')).rows;
const load = (p) => JSON.parse(readFileSync(join(T, p), 'utf8'))
const roster = load('gospel-by-country.json').countries
const { codes, pins } = load('people-group-pins.json')
/* Still shipped as the "Resources by People Group" TAB, so its URLs are still worth
checking — but it is the table, not the sheet, and this no longer confuses the two. */
const legacyRows = load('people-group-resources.json').rows
function check(rows, roster) {
const fail = [];
// NOTE: `countries` is a COMMA-joined string, but country NAMES themselves contain
// commas ("Micronesia, Federated States of"). Splitting on comma UNDER-COUNTS and
// reported a false 167/170. Match by SUBSTRING against the whole field instead —
// that is also what a UI filter would do. (pm-dx 2026-08-02, caught by cross-check.)
const blob = rows.map((r) => String(r.countries || ''));
let painted = 0, resolved = 0;
for (const c of roster) {
if (!c.iso3) { fail.push(`country with no iso3: ${c.country}`); continue; }
painted++;
if (blob.some((b) => b.includes(c.country))) resolved++;
else fail.push(`country paints but resolves to ZERO people groups: ${c.country} (${c.iso3})`);
const ABS = (v) => v == null || v === '' || /^https?:\/\//.test(String(v))
function check(rosterIn, pinsIn, codesIn, legacyIn) {
const fail = []
const byIso = new Set()
for (const p of pinsIn) if (p[3]) byIso.add(p[3])
let painted = 0, resolved = 0
for (const c of rosterIn) {
if (!c.iso3) { fail.push(`country with no iso3: ${c.country}`); continue }
painted++
if (byIso.has(c.iso3)) resolved++
else fail.push(`country paints but resolves to ZERO pins: ${c.country} (${c.iso3})`)
}
for (const r of rows) {
for (const k of ['scripture_url', 'film_url']) {
const v = r[k];
if (v != null && v !== '' && !/^https?:\/\//.test(String(v)))
fail.push(`${r.people_group}: ${k} is not an absolute URL: ${JSON.stringify(v)}`);
/* Every link the sheet would actually render, through the one flattener the sheet uses. */
let links = 0
for (const [code, c] of Object.entries(codesIn)) {
for (const r of resourceRows(c)) {
links++
if (!ABS(r.url)) fail.push(`${code}: ${r.type} link is not an absolute URL: ${JSON.stringify(r.url)}`)
}
if (r.has_audio === false && r.checked_pools && !String(r.checked_pools).includes('GRN'))
fail.push(`${r.people_group}: has_audio===false although GRN was never checked (must be the STRING 'unknown')`);
}
return { painted, resolved, fail };
for (const r of legacyIn) {
for (const k of ['scripture_url', 'film_url']) {
if (!ABS(r[k])) fail.push(`${r.people_group}: ${k} is not an absolute URL: ${JSON.stringify(r[k])}`)
}
if (r.has_audio === false && r.checked_pools && !/\bgrn\b/i.test(String(r.checked_pools))) {
fail.push(`${r.people_group}: has_audio===false although GRN was never checked (must be the STRING 'unknown')`)
}
}
return { painted, resolved, links, fail }
}
const res = check(pgRows, countries);
console.log(`[click-sheet] countries in roster: ${res.painted}`);
console.log(`[click-sheet] countries resolving to >=1 people group: ${res.resolved}`);
console.log(`[click-sheet] link/honesty violations: ${res.fail.length}`);
res.fail.slice(0, 5).forEach((f) => console.log(' -', f));
const res = check(roster, pins, codes, legacyRows)
console.log(`[click-sheet] countries in roster: ${res.painted}`)
console.log(`[click-sheet] countries resolving to >=1 pin: ${res.resolved}`)
console.log(`[click-sheet] resource links the sheet would render: ${res.links}`)
console.log(`[click-sheet] link/honesty violations: ${res.fail.length}`)
res.fail.slice(0, 5).forEach((f) => console.log(' -', f))
// ── SELF-TEST: the guard must go RED on each corrupted invariant ──────────────
const bad1 = check([{ ...pgRows[0], scripture_url: 'not-a-url' }], countries);
const bad2 = check([{ ...pgRows[0], has_audio: false, checked_pools: 'eBible' }], countries);
const redOk = bad1.fail.length > 0 && bad2.fail.length > 0;
console.log(`[click-sheet] SELF-TEST (guard goes RED on corruption): ${redOk ? 'PASS' : 'FAIL'}`);
const firstCode = Object.keys(codes)[0]
const bad = [
['relative sheet link', check(roster, pins, { x: { l: 'X', s: 'not-a-url' } }, [])],
['legacy relative url', check(roster, pins, {}, [{ ...legacyRows[0], scripture_url: 'not-a-url' }])],
['audio collapsed to false', check(roster, pins, {}, [{ ...legacyRows[0], has_audio: false, checked_pools: 'ebible' }])],
['country resolving to no pin', check([...roster, { iso3: 'ZZZ', country: 'Nowhere' }], pins, {}, [])],
]
const redOk = bad.every(([, r]) => r.fail.length > 0)
for (const [what, r] of bad) if (!r.fail.length) console.log(` ! SELF-TEST did not catch: ${what}`)
console.log(`[click-sheet] SELF-TEST (guard goes RED on ${bad.length} corruptions): ${redOk ? 'PASS' : 'FAIL'}`)
const green = res.fail.length === 0 && res.resolved > 0 && redOk;
console.log(`[click-sheet] ${green ? 'GREEN' : 'RED'}`);
console.log('[click-sheet] NOT VERIFIED HERE: the tap->open gesture on a GPU-rendered map. Needs a real device.');
process.exit(green ? 0 : 1);
const green = res.fail.length === 0 && res.resolved > 0 && res.links > 0 && redOk
console.log(`[click-sheet] ${green ? 'GREEN' : 'RED'} (first code sampled: ${firstCode})`)
console.log('[click-sheet] NOT VERIFIED HERE: the tap->open gesture on a GPU-rendered map. Needs a real device.')
process.exit(green ? 0 : 1)

View file

@ -767,6 +767,11 @@ export default defineComponent({
// own guess, so this lands through the existing config path with no new prop.
if (this.base) pc.treeBase = this.base
el.setAttribute('profile-config', JSON.stringify(pc))
// …and as a plain `tree-base` ATTRIBUTE, for elements that take the base that way instead of
// through profile-config (golden-path-check does, deliberately — it has no ProfileLoader).
// Without it that check guessed the tree from the PAGE URL, which in the studio is the page
// root, and read /manifest.json: a 404 and an empty self-test. Unused attributes are inert.
if (this.base) el.setAttribute('tree-base', this.base.replace(/\/+$/, ''))
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
slot.appendChild(el)
row.state = 'ready'

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../../doxa-maps-spa.js" integrity="sha384-j+2dQDsN+9KytIjgxVIRzcpXECccCuZhRT+CXc5B89vbo1IhaB8ogkO2Dc6KM1oy" crossorigin="anonymous"></script>
<script src="../../doxa-maps-spa.js" integrity="sha384-0kpyOLYSyDAS7sKrvfh60jFxPqlWP7Gi4ftn1rGwNmEzdSLY9ijkowlKw77v/3Xa" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../doxa-maps-spa.js" integrity="sha384-j+2dQDsN+9KytIjgxVIRzcpXECccCuZhRT+CXc5B89vbo1IhaB8ogkO2Dc6KM1oy" crossorigin="anonymous"></script>
<script src="../doxa-maps-spa.js" integrity="sha384-0kpyOLYSyDAS7sKrvfh60jFxPqlWP7Gi4ftn1rGwNmEzdSLY9ijkowlKw77v/3Xa" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-wPiv/279loGHfuNJbFFSDSaeu4T2sqdeDZmF1UaEyyHA7VvsihtUX0xnw/yPmozM" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-muBwL0JGuEgF9nEiranHSAJ1y7Ip/o8jdt8jhTVns34CxvEKgKmanIN6SeQbIazw" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-wPiv/279loGHfuNJbFFSDSaeu4T2sqdeDZmF1UaEyyHA7VvsihtUX0xnw/yPmozM" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-muBwL0JGuEgF9nEiranHSAJ1y7Ip/o8jdt8jhTVns34CxvEKgKmanIN6SeQbIazw" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-wPiv/279loGHfuNJbFFSDSaeu4T2sqdeDZmF1UaEyyHA7VvsihtUX0xnw/yPmozM" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-muBwL0JGuEgF9nEiranHSAJ1y7Ip/o8jdt8jhTVns34CxvEKgKmanIN6SeQbIazw" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-wPiv/279loGHfuNJbFFSDSaeu4T2sqdeDZmF1UaEyyHA7VvsihtUX0xnw/yPmozM" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-muBwL0JGuEgF9nEiranHSAJ1y7Ip/o8jdt8jhTVns34CxvEKgKmanIN6SeQbIazw" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-wPiv/279loGHfuNJbFFSDSaeu4T2sqdeDZmF1UaEyyHA7VvsihtUX0xnw/yPmozM" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-muBwL0JGuEgF9nEiranHSAJ1y7Ip/o8jdt8jhTVns34CxvEKgKmanIN6SeQbIazw" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -302,7 +302,7 @@
"bundle": "doxa-maps-spa",
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"output": "doxa-maps/doxa-maps-spa/doxa-maps-spa.js",
"integrity": "sha384-j+2dQDsN+9KytIjgxVIRzcpXECccCuZhRT+CXc5B89vbo1IhaB8ogkO2Dc6KM1oy",
"integrity": "sha384-0kpyOLYSyDAS7sKrvfh60jFxPqlWP7Gi4ftn1rGwNmEzdSLY9ijkowlKw77v/3Xa",
"elements": [
"doxa-maps-spa"
],
@ -327,7 +327,7 @@
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"embed": {
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-j+2dQDsN+9KytIjgxVIRzcpXECccCuZhRT+CXc5B89vbo1IhaB8ogkO2Dc6KM1oy\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-0kpyOLYSyDAS7sKrvfh60jFxPqlWP7Gi4ftn1rGwNmEzdSLY9ijkowlKw77v/3Xa\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
},
"instances": [
{
@ -596,7 +596,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "doxa-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-wPiv/279loGHfuNJbFFSDSaeu4T2sqdeDZmF1UaEyyHA7VvsihtUX0xnw/yPmozM",
"integrity": "sha384-muBwL0JGuEgF9nEiranHSAJ1y7Ip/o8jdt8jhTVns34CxvEKgKmanIN6SeQbIazw",
"elements": [
"gospel-resources-map"
],
@ -616,7 +616,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/charts/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-wPiv/279loGHfuNJbFFSDSaeu4T2sqdeDZmF1UaEyyHA7VvsihtUX0xnw/yPmozM\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-muBwL0JGuEgF9nEiranHSAJ1y7Ip/o8jdt8jhTVns34CxvEKgKmanIN6SeQbIazw\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -630,7 +630,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/data-tables/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-wPiv/279loGHfuNJbFFSDSaeu4T2sqdeDZmF1UaEyyHA7VvsihtUX0xnw/yPmozM\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-muBwL0JGuEgF9nEiranHSAJ1y7Ip/o8jdt8jhTVns34CxvEKgKmanIN6SeQbIazw\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -644,7 +644,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/gospel-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-wPiv/279loGHfuNJbFFSDSaeu4T2sqdeDZmF1UaEyyHA7VvsihtUX0xnw/yPmozM\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-muBwL0JGuEgF9nEiranHSAJ1y7Ip/o8jdt8jhTVns34CxvEKgKmanIN6SeQbIazw\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -658,7 +658,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-wPiv/279loGHfuNJbFFSDSaeu4T2sqdeDZmF1UaEyyHA7VvsihtUX0xnw/yPmozM\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-muBwL0JGuEgF9nEiranHSAJ1y7Ip/o8jdt8jhTVns34CxvEKgKmanIN6SeQbIazw\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -672,7 +672,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-wPiv/279loGHfuNJbFFSDSaeu4T2sqdeDZmF1UaEyyHA7VvsihtUX0xnw/yPmozM\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-muBwL0JGuEgF9nEiranHSAJ1y7Ip/o8jdt8jhTVns34CxvEKgKmanIN6SeQbIazw\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
}
]
@ -799,7 +799,7 @@
"bundle": "plugin-studio",
"scriptUrl": "./plugin-studio/plugin-studio.js",
"output": "doxa-maps/plugin-studio/plugin-studio.js",
"integrity": "sha384-N70Sfwq4XTc7BHosJDT1r9/qUrzr1NZ5DiLb3QUYST8vY7+quBQzytqcpOs4Rp1E",
"integrity": "sha384-PWsWQsPshQsOrFOoSZ3rPg6231BJmKV/pvopXruEZgKovSfp8P2MEjfEEEuBWqMl",
"elements": [
"plugin-studio"
],
@ -827,7 +827,7 @@
"scriptUrl": "./plugin-studio/plugin-studio.js",
"embed": {
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-N70Sfwq4XTc7BHosJDT1r9/qUrzr1NZ5DiLb3QUYST8vY7+quBQzytqcpOs4Rp1E\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-PWsWQsPshQsOrFOoSZ3rPg6231BJmKV/pvopXruEZgKovSfp8P2MEjfEEEuBWqMl\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
},
"instances": [
{

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
<script src="../plugin-studio.js" integrity="sha384-N70Sfwq4XTc7BHosJDT1r9/qUrzr1NZ5DiLb3QUYST8vY7+quBQzytqcpOs4Rp1E" crossorigin="anonymous"></script>
<script src="../plugin-studio.js" integrity="sha384-PWsWQsPshQsOrFOoSZ3rPg6231BJmKV/pvopXruEZgKovSfp8P2MEjfEEEuBWqMl" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
<script src="../../plugin-studio.js" integrity="sha384-N70Sfwq4XTc7BHosJDT1r9/qUrzr1NZ5DiLb3QUYST8vY7+quBQzytqcpOs4Rp1E" crossorigin="anonymous"></script>
<script src="../../plugin-studio.js" integrity="sha384-PWsWQsPshQsOrFOoSZ3rPg6231BJmKV/pvopXruEZgKovSfp8P2MEjfEEEuBWqMl" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: