Update from source 7c360016f
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

This commit is contained in:
Doxa Maps 2026-09-28 07:37:08 +00:00
parent 8d4866e4d0
commit 7bed09f2a1
55 changed files with 703 additions and 1047 deletions

3
.gitignore vendored
View file

@ -150,3 +150,6 @@ data-private/
/backend/bin/
# The local PocketBase's data (`bun run dev`, internal/backend-dev-plugin.mjs): per laptop, never committed.
/.pb/
# bun run desktop:build <plugin>: the staged plugin and the built sidecar engines (never committed)
/.desktop/
/deployment/src-tauri/binaries/

View file

@ -1,16 +1,46 @@
// main.rs — the whole desktop shell.
//
// A Tauri app around the SAME public/ tree Capacitor and the CDN serve. The window, its
// size and the entry page all come from ../tauri.conf.json (app.windows[0]) so there is
// ONE place to change them and no value is duplicated between config and code.
// A Tauri app around a built web tree. The window, its size and the entry page all come from
// ../tauri.conf.json (app.windows[0]) — or from the --config override `bun run desktop:build <plugin>`
// passes — so there is ONE place to change them and no value is duplicated between config and code.
//
// No commands, no IPC, no state. The maps are a compiled Vue custom element loaded by the
// webview exactly as a browser loads them; giving this shell logic would fork behaviour
// between desktop and web, which is the opposite of what one-build-four-consumers buys us.
// No commands, no IPC. The ONE piece of logic: a desktop plugin's engine. When the app bundles a
// `rudder-engine` sidecar (Tauri places it next to this executable), it is started with the webview's
// microphone mode (RUDDER_AUDIO=browser: the page captures the mic and streams it to the engine) and its
// runtime + model under the app's data directory, and it is stopped when the app exits. An app without the
// sidecar (the map gallery) starts nothing.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::process::{Child, Command};
use std::sync::Mutex;
use tauri::{Manager, RunEvent};
struct Sidecar(Mutex<Option<Child>>);
fn main() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running the Doxa Maps desktop shell");
let app = tauri::Builder::default()
.manage(Sidecar(Mutex::new(None)))
.setup(|app| {
let exe_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf()));
if let Some(dir) = exe_dir {
let bin = dir.join(if cfg!(windows) { "rudder-engine.exe" } else { "rudder-engine" });
if bin.exists() {
let home = app.path().app_data_dir().map(|d| d.join("rudder")).unwrap_or_default();
match Command::new(&bin).env("RUDDER_AUDIO", "browser").env("RUDDER_HOME", &home).spawn() {
Ok(child) => *app.state::<Sidecar>().0.lock().unwrap() = Some(child),
Err(e) => eprintln!("rudder-engine did not start: {e}"),
}
}
}
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building the Doxa Maps desktop shell");
app.run(|handle, event| {
if let RunEvent::Exit = event {
if let Some(mut child) = handle.state::<Sidecar>().0.lock().unwrap().take() {
let _ = child.kill();
}
}
});
}

View file

@ -18,7 +18,9 @@ desktop path has never been run by anyone, and the steps say so where that is th
| The engine **compiling** for macOS (arm64, x86_64) and Windows (x86_64) | Proven by cross-compiling. It has **never been run** on either. |
| The engine accepting a WebSocket from the Tauri webview (`tauri://localhost`, `http://tauri.localhost`) | Proven against a running engine; another site's origin is refused. |
| NeMo-Speech.cpp runtime for macOS / Windows | Published by NVIDIA (release v0.1.0: `macos-aarch64-cpu`, `macos-aarch64-metal`, `macos-x86_64-cpu`, `windows-x86_64-cpu` / `-cuda` / `-vulkan`). **Never run here.** |
| Rudder as a sidecar, first-run model download, installer | **Not built.** Steps 5–8 below are the work to do. |
| `bun run desktop:build rudder-voice` on **Linux** | Proven: produced `Rudder_0.1.0_amd64.deb` (8.8 MB) holding the app and the `rudder-engine` sidecar side by side. The app has **not been launched** from it yet (no display on the build box). |
| The shell starting and stopping the engine sidecar | Built (`deployment/src-tauri/src/main.rs`), compiled into that installer; not yet run. |
| First-run model download | **Not built.** Run `setup-runtime.sh` into the app's data folder by hand (step 7). |
## 1. Prerequisites
@ -50,65 +52,35 @@ cd dx-maps
bun install
```
## 3. Build the web tree
## 3. No web build needed
`desktop:build` builds the one plugin it packages; `bun run build` (the web tree) is not a prerequisite.
## 4. Build the app: one command
```bash
bun run build
bun run desktop:build rudder-voice
```
This writes `public/dx-maps/`, the tree every deployment serves, including
`public/dx-maps/rudder-voice/`. The Tauri config points at that folder (`frontendDist`); it does **not**
build it for you (`beforeBuildCommand` is deliberately empty; see `deployment/src-tauri/README.md`).
**Build first, then package.**
It packages ONE plugin whose `<profile>.meta.json` `"targets"` include `desktop` (see
`skills/plugin-designer/references/targets.md`), and nothing in `public/` or the committed tree changes:
## 4. Build the engine for this machine
1. the plugin is built into `.desktop/rudder-voice/`, with a one-element page that gives it the engine's
address (`apiBase: http://127.0.0.1:5193/api/rudder`);
2. its `"backend"` (`backend/rudder-engine`) is built for this machine as
`deployment/src-tauri/binaries/rudder-engine-<target triple>` (Tauri's sidecar naming);
3. `tauri build` runs with a `--config` override (the window opens that page, the app is named from the
meta label, the engine is bundled); `tauri.conf.json` is not edited.
Tauri expects a sidecar binary named with the target triple:
The first run compiles Tauri's dependencies (several minutes, about 2 GB under
`deployment/src-tauri/target/`); later runs reuse it.
```bash
# macOS, Apple Silicon
cd backend/rudder-engine && go build -o ../../deployment/src-tauri/binaries/rudder-engine-aarch64-apple-darwin . && cd ../..
# macOS, Intel
cd backend/rudder-engine && go build -o ../../deployment/src-tauri/binaries/rudder-engine-x86_64-apple-darwin . && cd ../..
# Windows (PowerShell)
cd backend/rudder-engine; go build -o ..\..\deployment\src-tauri\binaries\rudder-engine-x86_64-pc-windows-msvc.exe .; cd ..\..
```
The shell (`deployment/src-tauri/src/main.rs`) starts the bundled `rudder-engine` with
`RUDDER_AUDIO=browser` (the webview captures the mic; the engine's own recorders are Linux-only) and
`RUDDER_HOME=<the app's data folder>/rudder`, and stops it when the app quits. The engine accepts the
webview's origins (`tauri://localhost`, `http://tauri.localhost`).
`rustc -Vv` prints the exact triple (`host:` line) if unsure.
## 5. Make the Tauri shell start the engine (NOT BUILT: this is the work)
1. **Declare the sidecar** in `deployment/src-tauri/tauri.conf.json`:
`"bundle": { "externalBin": ["binaries/rudder-engine"], "targets": ["dmg"] }` on macOS, or
`["nsis"]` (or `["msi"]`) on Windows. Keep the file schema-pure: **no `//` comment keys**, Tauri
rejects unknown fields. Run `bun run check:tauri` after editing it.
2. **Add the shell plugin** (`tauri-plugin-shell`) to `deployment/src-tauri/Cargo.toml` and register it
in `src/main.rs`, then spawn the sidecar at startup with these environment variables:
- `RUDDER_AUDIO=browser`: the engine takes the **webview's** microphone. Its own recorders
(`parec` / `pw-record` / `arecord`) exist only on Linux, so on macOS and Windows this is required.
- `RUDDER_HOME=<the app's data directory>` (Tauri's `app_data_dir()`): where the runtime and model live.
- `RUDDER_PORT=5193` (the default) or a free port you pass on to the page (step 6).
Kill the sidecar when the window closes. On macOS and Windows the engine cannot ask the OS to kill
its own speech server if the engine crashes (Linux-only feature), so the shell should own that.
3. `src/main.rs` currently has no logic by design ("a window around the web tree"). Rudder is the first
desktop plugin with a backend, so this is the first logic it gains; keep it to starting and
stopping the sidecar.
## 6. Open Rudder, and tell it where the engine is (NOT BUILT)
The window opens `doxa-maps-spa/doxa-maps-spa/index.html` today. For a Rudder app, open Rudder's own
page, `rudder-voice/rudder-voice/index.html`, and give the element the engine's **absolute** address:
```
profile-config = {"apiBase": "http://127.0.0.1:5193/api/rudder"}
```
A relative `/api/rudder` would resolve against `tauri://localhost` and reach nothing. How the page
receives that config (a small wrapper page, a query parameter, or the shell injecting it) is not
decided and not built; pick the smallest option and say which in your report. The engine already
answers CORS for the Tauri origins and accepts their WebSocket.
## 7. The speech runtime and model on first run (NOT BUILT)
## 5. The speech runtime and model (first run, still by hand)
`backend/rudder-engine/setup-runtime.sh` is the reference: it pins the runtime (NeMo-Speech.cpp
**0.1.0**) and the model (`nemotron-3.5-asr-streaming-0.6b.q8_0.gguf`, Hugging Face revision
@ -128,16 +100,9 @@ macOS and Windows the app must do the same on first run:
real path of the `nemo-speech` binary.
- Show progress: the first start downloads ~750 MB and warms the model (~20 s on a laptop CPU).
## 8. Package
## 6. Where the installer lands
```bash
bun run tauri:build
```
Run it from the repo root. If the CLI cannot find the config, run `cargo tauri build` inside
`deployment/src-tauri/` (untested either way on macOS/Windows).
The installer lands under `deployment/src-tauri/target/release/bundle/`: `dmg/` on macOS, `nsis/` or
`desktop:build` prints the path. The installer lands under `deployment/src-tauri/target/release/bundle/`: `dmg/` on macOS, `nsis/` or
`msi/` on Windows.
- **macOS:** the webview needs `NSMicrophoneUsageDescription` in the app's `Info.plist` (Tauri: an
@ -146,7 +111,7 @@ The installer lands under `deployment/src-tauri/target/release/bundle/`: `dmg/`
to give it to others.
- **Windows:** WebView2 asks for microphone permission on first use.
## 9. Check it works
## 7. Check it works
1. Launch the app. Rudder's status reads "Loading speech model…" and then **Ready**.
2. Press **Record**, speak, and watch the words stream into the box. Press **Stop**: the last words
@ -156,7 +121,7 @@ The installer lands under `deployment/src-tauri/target/release/bundle/`: `dmg/`
## Known gaps (untested; report what you find)
- Nothing in steps 5–8 has been run on macOS or Windows.
- `desktop:build` has run on Linux only; the app it produced has not been launched yet. Nothing has run on macOS or Windows.
- The macOS/Windows runtime archive layout, and whether the engine's derived path matches it.
- The first-run download, including resuming an interrupted 742 MB download.
- Mapbox basemaps in the Research Gallery need the token host to allow the Tauri origin

View file

@ -96,7 +96,6 @@ Generated by a per-file scan. The `@map/…` import path mirrors each `library/`
| `library/composables/useMapFly.js` | Camera navigation: fly/fitBounds helpers, panel-offset centering, antimeridian-safe legend-row zoom. | zoomToLegendRow reads getSource()._data and re-implements Mapbox filter evaluation in JS — extend _matchesFilter/_resolveExpr for new expression ops; wide spans ease to a world overview instead of fitBounds. |
| `library/composables/useMapInstance.js` | One map's lifecycle: shadow-DOM CSS injection, aspect-aware dynamic minZoom floor, edge scroll strips, teardown. Returns map, isMapReady, initializeMap, destroy. | initializeMap() in onMounted with a real DOM element ref (string IDs break in shadow DOM); minZoom is a lower bound raised to a desktop-only no-duplicate-world floor; expects sibling `.map-scroll-edge` strips and global mapboxgl; always call destroy(). |
| `library/composables/useMapLayers.js` | Creates/manages all Mapbox layers (pin GeoJSON with family derivation + co-location fan-out, regions fill/border, connection lines, prayer-glow rAF ripple) and theme-aware pin ring widths. | Do NOT add a style.load listener here — the host profile owns post-setStyle reconciliation (flash race). addPrayerGlowLayers is idempotent; keep zoom 'case' inside interpolate OUTPUTS; dark-ring exceptions are config-only, keyed by strategy name. |
| `library/composables/useMapPacket.js` | Multi-page "prayer packet" PDF: page 1 = poster map raster, then one jsPDF vector card per people group. Returns renderPacket, progress, isRendering. | Page 1 reuses useMapPoster.render — keep that the single source of truth; jspdf lazy-imported; footer attribution on every page is a ToS requirement; field reads deliberately defensive across 3 object shapes. |
| `library/composables/useMapSelection.js` | Applies region/family selection to layer paint props (highlight/fade opacity, connection lines). | Region matching is on iso_3166_1_alpha_3 (tileset has no `region` prop); labels fuzzy-normalized to slugs; selection state lives in the Pinia mapStore. |
| `library/composables/useMapTheme.js` | Seamless light/dark toggle by flipping Mapbox Standard's lightPreset/theme config props. Returns bootStyle, applyTheme, swapTheme, isDarkTheme. | Never setStyle to switch themes (reloads map, wipes custom layers); applyTheme must run after style.load and re-asserts once on 'idle' (fast-toggle grey-film race); profiles should watch(uiStore.theme) → applyTheme. |
| `library/composables/usePplrInstance.js` | Per-map mediator "instance store" (provide/inject refs) so map, geocoder, legend in one cluster communicate. Exports createPplrInstance, provideInstance, useInstance, selectNode, clearAll. | Not Pinia — plain refs under key 'pplrInstance'; useInstance() returns null when standalone, so consumers must null-guard; profiles bridge instance.selection to their own store. |

View file

@ -163,8 +163,6 @@ and a change here can break other maps.
`import … from '@map/composables/useMapInstance.js'`
- **useMapLayers** — useMapLayers.js - Map Layers Composable
`import … from '@map/composables/useMapLayers.js'`
- **useMapPacket** — useMapPacket — render a multi-page "prayer packet" PDF for a map selection.
`import … from '@map/composables/useMapPacket.js'`
- **useMapProvider** — useMapProvider.js — resolve the active map engine ("provider").
`import … from '@map/composables/useMapProvider.js'`
- **useMapSelection** — useMapSelection.js - Map Selection Composable

View file

@ -36,6 +36,7 @@
* a fresh tree — the mtime false alarm is gone.
*/
import { readFileSync, existsSync, readdirSync, mkdtempSync, rmSync } from 'node:fs'
import { isWebBuilt } from './plugin-targets.mjs'
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
@ -64,7 +65,9 @@ const manifest = JSON.parse(readFileSync(join(TREE, 'manifest.json'), 'utf8'))
// tag -> owning bundle, so a composes entry naming an ELEMENT still resolves.
const byTag = new Map()
for (const b of manifest.bundles || []) for (const t of b.elements || []) byTag.set(t, b.bundle)
const bundles = new Set(readdirSync(DESIGNS).filter((d) => existsSync(join(DESIGNS, d, 'index.js'))))
// A plugin that is not built into the web tree (plugin-targets.mjs: targets without "web") has no committed
// bundle by design, so it is not held to one.
const bundles = new Set(readdirSync(DESIGNS).filter((d) => existsSync(join(DESIGNS, d, 'index.js')) && isWebBuilt(join(DESIGNS, d))))
// A bare PROFILE name of the same bundle (gospel-resources composes "resources-map", "data-tables" —
// its own sibling profiles) resolves to that bundle, as the studio resolves it.
const profilesOf = new Map((manifest.bundles || []).map((b) => [b.bundle, new Set((b.profiles || []).map((p) => p.profile))]))

View file

@ -47,8 +47,9 @@
* NEGATIVE-TESTED: reintroducing a private `const CATEGORICAL = ['#...']` into the aggregations
* composable fails assertion 2 by name; an `[i % CATEGORICAL.length]` lookup fails assertion 3.
*/
import { readFileSync, existsSync, readdirSync } from 'node:fs'
import { readFileSync, existsSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
@ -96,7 +97,32 @@ export function paletteHexes(src) {
return [...new Set(strings.flatMap((t) => [...t.matchAll(/#[0-9a-fA-F]{6}\b/g)].map((m) => m[0].toLowerCase())))]
}
/**
* The strategy palettes in one colors folder: library/colors/, or a bundle's own
* plugin-designs/<bundle>/src/colors/ (bundle-private strategies merge OVER the library set for that
* bundle — see that folder's README). Filename → key as the registry names it is not needed here; the
* file stem is the key both places use.
*/
export function collectStrategies(dir) {
const out = {}
if (!existsSync(dir)) return out
for (const f of readdirSync(dir)) {
if (!f.endsWith('.js') || f.startsWith('_') || f.startsWith('.') || f === 'index.js') continue
const hexes = paletteHexes(readFileSync(join(dir, f), 'utf8'))
if (hexes && hexes.length) out[f.replace(/\.js$/, '')] = hexes
}
return out
}
if (process.argv.includes('--self-test')) {
// A bundle's PRIVATE palette (src/colors/<mode>.js) must be seen, not only library/colors/ (the gate was
// blind to it: upg100's census, BUG-CHECK-ONE-PALETTE-BLIND-TO-PRIVATE-PALETTES).
const tmp = mkdtempSync(join(tmpdir(), 'one-palette-'))
writeFileSync(join(tmp, 'religion.js'), "export const PALETTE = { islam: '#010203', hinduism: '#040506' }\n")
writeFileSync(join(tmp, '_skip.js'), "export const PALETTE = { x: '#999999' }\n")
const seen = JSON.stringify(collectStrategies(tmp))
rmSync(tmp, { recursive: true, force: true })
if (seen !== JSON.stringify({ religion: ['#010203', '#040506'] })) { console.error(`ONE-PALETTE: SELF-TEST FAIL — a planted private palette read as ${seen}`); process.exit(1) }
const src = [
"export const PALETTE = {",
" a: '#112233', // not #445566 (a comment inside the export)",
@ -107,18 +133,14 @@ if (process.argv.includes('--self-test')) {
].join('\n')
const got = paletteHexes(src).join(',')
if (got !== '#112233,#aabbcc') { console.error(`ONE-PALETTE: SELF-TEST FAIL — read ${got}, want #112233,#aabbcc`); process.exit(1) }
console.log('ONE-PALETTE: SELF-TEST PASS — only the PALETTE export\'s string values are read; comments and later code are not.')
console.log('ONE-PALETTE: SELF-TEST PASS — only the PALETTE export\'s string values are read; comments and later code are not; a bundle\'s private src/colors/ palette is seen.')
process.exit(0)
}
// ── the expected palettes, read from the strategy modules the MAP uses ────────────────────────
const strategies = {}
for (const f of readdirSync(COLORS)) {
if (!f.endsWith('.js') || f.startsWith('_') || f.startsWith('.')) continue
const src = readFileSync(join(COLORS, f), 'utf8')
const hexes = paletteHexes(src)
if (hexes && hexes.length) strategies[f.replace(/\.js$/, '')] = hexes
}
// The library set, and per bundle its own src/colors/ strategies merged over it (same key overrides).
const strategies = collectStrategies(COLORS)
const privateOf = (bundle) => collectStrategies(join(ROOT, 'plugin-designs', bundle, 'src', 'colors'))
// ── the consumers that must agree ─────────────────────────────────────────────────────────────
const CONSUMERS = [
@ -142,7 +164,7 @@ for (const c of CONSUMERS) {
const p = join(BUILT, c.bundle)
if (!existsSync(p)) { errs.push(`bundle missing: ${c.bundle} — build is incomplete, so this gate cannot judge it.`); continue }
const js = readFileSync(p, 'utf8').toLowerCase()
for (const [strat, hexes] of Object.entries(strategies)) {
for (const [strat, hexes] of Object.entries({ ...strategies, ...privateOf(c.name) })) {
const missing = hexes.filter((h) => !js.includes(h))
checkedPairs++
// A consumer need not use EVERY strategy, but if it uses one at all it must use the whole

View file

@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* check-targets — where each plugin runs, as its <profile>.meta.json "targets" says. Headless.
*
* Rules (plugin-targets.mjs holds the shared reading):
* - targets, when declared, is a non-empty list of known values: web, mobile, desktop, widget.
* Absent = web: a meta never has to declare it.
* - "widget" in targets and a meta "widget": { "corner" } go together (the Studio mounts a widget by its corner).
* - "backend": "backend/<name>" names an existing folder (desktop:build packages it as the app's sidecar).
* - A plugin that is not built for the web (targets without "web") is NOT in the built web tree: no
* public/<tree>/<bundle>/ folder and no manifest entry, so neither the /cdn export nor the Research
* Gallery can carry it. desktop:build packages it instead.
* --self-test plants each violation and confirms it is caught.
* EXIT 0 pass · 1 fail
*/
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { KNOWN_TARGETS, profileTargets, isWebBuilt } from './plugin-targets.mjs'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
// One profile's meta against the rules; returns the problems.
export function metaProblems(where, meta, backendExists = (p) => existsSync(join(ROOT, p))) {
const out = []
const t = meta.targets
if (t !== undefined) {
if (!Array.isArray(t) || !t.length) out.push(`${where}: "targets" must be a non-empty list`)
else for (const v of t) if (!KNOWN_TARGETS.includes(v)) out.push(`${where}: unknown target "${v}" (known: ${KNOWN_TARGETS.join(', ')})`)
if (Array.isArray(t) && t.includes('widget') && !meta.widget?.corner) out.push(`${where}: targets include "widget" but there is no "widget": { "corner" }`)
if (Array.isArray(t) && meta.widget && !t.includes('widget')) out.push(`${where}: has a "widget" corner but its targets leave out "widget"`)
}
if (meta.backend !== undefined && !(typeof meta.backend === 'string' && backendExists(meta.backend))) out.push(`${where}: "backend" names no folder ${meta.backend}`)
return out
}
if (process.argv.includes('--self-test')) {
const planted = [
['unknown value', { targets: ['tv'] }],
['empty list', { targets: [] }],
['widget without corner', { targets: ['widget'] }],
['corner without widget target', { targets: ['desktop'], widget: { corner: 'bottom-right' } }],
['missing backend', { targets: ['desktop'], backend: 'backend/no-such-engine' }],
]
let missed = 0
for (const [name, meta] of planted) if (!metaProblems('planted', meta, () => false).length) { missed++; console.log(` MISSED ${name}`) }
if (metaProblems('ok', { targets: ['desktop', 'widget'], widget: { corner: 'bottom-right' }, backend: 'backend/x' }, () => true).length) { missed++; console.log(' FALSE ALARM on a valid meta') }
if (missed) { console.log(`TARGETS self-test: FAIL — ${missed}`); process.exit(1) }
console.log(`TARGETS self-test: PASS — ${planted.length} planted violations caught, a valid meta passes.`)
process.exit(0)
}
const problems = []
const DESIGNS = join(ROOT, 'plugin-designs')
const TREE = join(ROOT, 'public', TREE_NAME)
let manifest = null
try { manifest = JSON.parse(readFileSync(join(TREE, 'manifest.json'), 'utf8')) } catch { /* no built tree: tree rules skipped */ }
let declared = 0, notWeb = 0
for (const b of readdirSync(DESIGNS)) {
const dir = join(DESIGNS, b)
if (!statSync(dir).isDirectory()) continue
for (const p of profileTargets(dir)) {
if (p.targets) declared++
problems.push(...metaProblems(`${b}/${p.profile}.meta.json`, p.meta))
}
if (!isWebBuilt(dir)) {
notWeb++
if (existsSync(join(TREE, b))) problems.push(`${b} is not a web plugin but public/${TREE_NAME}/${b}/ exists (it would ship in the /cdn export)`)
if (manifest && (manifest.bundles || []).some((x) => x.bundle === b)) problems.push(`${b} is not a web plugin but the built manifest lists it (the gallery would offer it)`)
}
}
for (const p of problems) console.log(' FAIL ' + p)
if (problems.length) { console.log(`TARGETS: FAIL — ${problems.length} problem(s).`); process.exit(1) }
console.log(`TARGETS: PASS — ${declared} profile(s) declare targets (absent = web); ${notWeb} non-web plugin(s), none in the built web tree.`)

View file

@ -0,0 +1,84 @@
#!/usr/bin/env node
/**
* desktop-build — `bun run desktop:build <plugin>`: package ONE desktop plugin as a desktop app (Tauri).
*
* Only a plugin whose <profile>.meta.json "targets" include "desktop" can be packaged (plugin-targets.mjs).
* Nothing here touches public/ or the committed tree:
* 1. the plugin's bundle is built into .desktop/<plugin>/ (DOXA_OUT_ROOT), with a one-element page that
* tells it where its engine listens;
* 2. the plugin's engine, when its meta names one ("backend": "backend/<name>", Go), is built for this
* machine as deployment/src-tauri/binaries/<name>-<target triple>, Tauri's sidecar layout;
* 3. `tauri build` runs with a --config override that points the window at that page, names the app,
* and bundles the sidecar; deployment/src-tauri/tauri.conf.json itself is not edited.
* The shell (deployment/src-tauri/src/main.rs) starts the sidecar with RUDDER_AUDIO=browser and
* RUDDER_HOME=<app data dir>/rudder, and stops it when the window closes.
* See documentation/HOWTO-build-the-desktop-app.md for prerequisites and what is still unproven.
*/
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, rmSync, writeFileSync, readdirSync } from 'node:fs'
import { join, resolve, dirname, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { desktopProfile } from './plugin-targets.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const TAURI_DIR = join(ROOT, 'deployment', 'src-tauri')
const plugin = process.argv[2]
const die = (m) => { console.error('desktop:build: ' + m); process.exit(1) }
const run = (cmd, args, opts = {}) => {
console.log('→ ' + [cmd, ...args].join(' '))
const r = spawnSync(cmd, args, { stdio: 'inherit', cwd: ROOT, ...opts, env: { ...process.env, ...(opts.env || {}) } })
if (r.status !== 0) die(`${cmd} failed (exit ${r.status})`)
}
if (!plugin) die('usage: bun run desktop:build <plugin> (a plugin-designs/<plugin>/ whose meta targets include "desktop")')
const dir = join(ROOT, 'plugin-designs', plugin)
if (!existsSync(dir)) die(`no plugin-designs/${plugin}/`)
const dp = desktopProfile(dir)
if (!dp) die(`${plugin} does not declare "desktop" in any <profile>.meta.json "targets"; it is not a desktop plugin`)
// 1. The plugin, built outside public/.
const stage = join(ROOT, '.desktop', plugin)
rmSync(stage, { recursive: true, force: true })
mkdirSync(stage, { recursive: true })
run('bun', ['x', 'vite', 'build'], { env: { BUNDLE: plugin, DOXA_OUT_ROOT: stage } })
const tag = dp.meta.element || plugin
const port = Number(process.env.RUDDER_PORT || 5193)
const name = String(dp.meta.label || plugin).split(/\s+[—–-]\s+/)[0].trim() || plugin
const cfg = { apiBase: `http://127.0.0.1:${port}/api/rudder` }
writeFileSync(join(stage, 'index.html'), `<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${name}</title><style>html,body{margin:0;height:100%;background:#0b0f14}</style></head>
<body><${tag} profile-config='${JSON.stringify(cfg)}' style="display:block;height:100vh"></${tag}>
<script src="${plugin}/${plugin}.js"></script></body></html>
`)
// 2. The engine, when the plugin has one.
const externalBin = []
const backend = dp.meta.backend
if (backend) {
const src = join(ROOT, backend)
const sidecar = backend.split('/').filter(Boolean).pop()
if (!existsSync(src)) die(`meta names backend "${backend}" but that folder does not exist`)
const vv = spawnSync('rustc', ['-vV'], { encoding: 'utf8' })
const triple = ((vv.stdout || '').match(/^host:\s*(\S+)/m) || [])[1]
if (!triple) die('rustc not found: install Rust (https://rustup.rs) to build a desktop app')
const exe = process.platform === 'win32' ? '.exe' : ''
mkdirSync(join(TAURI_DIR, 'binaries'), { recursive: true })
run('go', ['build', '-o', join(TAURI_DIR, 'binaries', `${sidecar}-${triple}${exe}`), '.'], { cwd: src })
externalBin.push(`binaries/${sidecar}`)
}
// 3. The app.
const targets = process.platform === 'darwin' ? ['dmg'] : process.platform === 'win32' ? ['nsis'] : ['deb']
const override = {
productName: name,
identifier: `life.doxa.${plugin.replace(/[^a-z0-9]+/gi, '')}`,
build: { frontendDist: relative(TAURI_DIR, stage) },
app: { windows: [{ title: name, width: 760, height: 820, minWidth: 360, minHeight: 480, resizable: true, url: 'index.html' }] },
bundle: { active: true, targets, ...(externalBin.length ? { externalBin } : {}) },
}
run(join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tauri.cmd' : 'tauri'), ['build', '--config', JSON.stringify(override)])
const bundleDir = join(process.env.CARGO_TARGET_DIR ? resolve(process.env.CARGO_TARGET_DIR) : join(TAURI_DIR, 'target'), 'release', 'bundle')
const found = existsSync(bundleDir) ? readdirSync(bundleDir).flatMap((d) => readdirSync(join(bundleDir, d)).map((f) => join(bundleDir, d, f))) : []
console.log(`\ndesktop:build ${plugin}: done. Installer(s):\n` + (found.length ? found.map((f) => ' ' + f).join('\n') : ` (none found under ${bundleDir})`))

View file

@ -31,6 +31,7 @@
* Wired into: bun run build (runs after the per-bundle vite builds)
*/
import { CDN } from '../library/constants/cdn.js'
import { isWebBuilt } from './plugin-targets.mjs'
import {
readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync,
cpSync, rmSync, mkdtempSync,
@ -1167,6 +1168,9 @@ function scanBundles() {
for (const entry of readdirSync(APP_PROFILES).sort()) {
const dir = join(APP_PROFILES, entry)
if (!isBundleDir(entry) || !statSync(dir).isDirectory()) continue
// Not a web plugin (plugin-targets.mjs: targets without "web", e.g. Rudder): no leaf pages, no manifest
// entry. desktop:build packages it.
if (!isWebBuilt(dir)) continue
const vueFiles = readdirSync(dir).filter((f) => f.endsWith('.vue')).sort()
if (vueFiles.length === 0) continue

View file

@ -0,0 +1,37 @@
/**
* plugin-targets.mjs — where a plugin runs, read from its own <profile>.meta.json "targets".
*
* Known values: web (a published map or tool), mobile, desktop (the Tauri app, `bun run desktop:build`),
* widget (the Plugin Studio's corner). ABSENT = web: most plugins never declare it and never need to.
* A plugin is built into the WEB tree (bun run build, the /cdn export, the Research Gallery) unless every
* profile declares targets and none of them is "web" — Rudder (["desktop","widget"]) is built only by
* desktop:build, and the dev server still discovers it from source for the Studio's corner.
* Read by run-parallel (--bundles), check-composite-freshness, check-shared-component-reached-bundles,
* desktop-build and check:targets, so there is ONE rule.
*/
import { readdirSync, readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
export const KNOWN_TARGETS = ['web', 'mobile', 'desktop', 'widget']
/** [{ profile, targets|null, meta }] for every <profile>.meta.json in a bundle folder. */
export function profileTargets(bundleDir) {
if (!existsSync(bundleDir)) return []
return readdirSync(bundleDir).filter((f) => f.endsWith('.meta.json')).map((f) => {
let meta = {}
try { meta = JSON.parse(readFileSync(join(bundleDir, f), 'utf8')) } catch { /* unreadable meta = no declaration */ }
return { profile: f.replace(/\.meta\.json$/, ''), targets: Array.isArray(meta.targets) ? meta.targets : null, meta }
})
}
/** True unless every profile declares targets and none of them is "web". No meta at all = web. */
export function isWebBuilt(bundleDir) {
const p = profileTargets(bundleDir)
if (!p.length) return true
return p.some((x) => !x.targets || x.targets.includes('web'))
}
/** The profile a desktop app opens, or null: the first profile whose targets include "desktop". */
export function desktopProfile(bundleDir) {
return profileTargets(bundleDir).find((x) => x.targets && x.targets.includes('desktop')) || null
}

View file

@ -45,6 +45,7 @@
*/
import { spawn, spawnSync } from 'node:child_process'
import { readdirSync, existsSync, readFileSync } from 'node:fs'
import { isWebBuilt } from './plugin-targets.mjs'
import { cpus } from 'node:os'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -195,6 +196,8 @@ function buildSteps() {
const dir = resolve(ROOT, 'plugin-designs')
return readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory())
// Not a web plugin (targets without "web", e.g. Rudder: desktop + widget): built by desktop:build only.
.filter((e) => isWebBuilt(resolve(dir, e.name)))
.map((e) => e.name)
.sort()
.map((name) => ({ name, cmd: 'bun', args: ['x', 'vite', 'build'], env: { BUNDLE: name } }))

View file

@ -1,300 +0,0 @@
/**
* useMapPacket — render a multi-page "prayer packet" PDF for a map selection.
*
* A packet is one cohesive PDF whose:
* - Page 1 = the full-bleed map image (reuses useMapPoster.render(spec,'png')
* so the map raster, hidden-map spin-up, and Mapbox attribution path are
* identical to the poster export — single source of truth).
* - Pages 2..N = one clean "detail card" per people group, drawn with jsPDF
* vector primitives (setFontSize / text / rect / line) so the cards stay
* crisp at any zoom and add almost nothing to the file size.
*
* People-group field reads are DEFENSIVE: the same object may arrive as a
* normalized record from useMapData (top-level `name`, `country`, `population`,
* `peoplePraying`, …), as a backward-compat `peopleGroups[]` entry, or as a
* GeoJSON-feature `.properties` bag. We probe the known keys in priority order
* and fall back to an em-dash so a card never renders blank labels.
*
* Mapbox / OpenStreetMap attribution is rendered into every page footer —
* non-negotiable (Mapbox ToS), mirroring useMapPoster's footer slot.
*
* @example
* const packet = useMapPacket(getMap);
* const blob = await packet.renderPacket(spec, peopleGroups, { source: 'Doxa Life' });
* download(blob, 'prayer-packet.pdf');
*
* // Deps (already in package.json): "jspdf": "^4.2.1"
*/
import { ref, readonly } from 'vue';
import { useMapPoster } from './poster/useMapPoster.js';
/**
* @param {() => any} getMap function returning the live Mapbox map
* @returns {{
* renderPacket: (spec: import('./poster/useMapPoster.js').PosterSpec, peopleGroups: Array<Object>, meta?: Object) => Promise<Blob>,
* progress: import('vue').Ref<{ phase: string, completed: number, total: number, message: string }>,
* isRendering: import('vue').Ref<boolean>
* }}
*/
export function useMapPacket(getMap) {
const progress = ref({ phase: 'idle', completed: 0, total: 0, message: '' });
const isRendering = ref(false);
function _setProgress(phase, completed, total, message) {
progress.value = { phase, completed, total, message };
}
/**
* Render the multi-page packet PDF.
* @param {import('./poster/useMapPoster.js').PosterSpec} spec same shape useMapPoster uses
* @param {Array<Object>} peopleGroups normalized people-group objects
* @param {Object} [meta] { source?, date?, attribution? }
* @returns {Promise<Blob>}
*/
async function renderPacket(spec, peopleGroups, meta = {}) {
if (isRendering.value) throw new Error('useMapPacket: already rendering');
isRendering.value = true;
const groups = Array.isArray(peopleGroups) ? peopleGroups : [];
const total = 1 + groups.length; // page 1 = map, then one page per group
try {
_setProgress('map', 0, total, 'rendering map page');
// Page 1 raster — reuse the poster renderer so the map image, attribution,
// and hidden-map machinery are identical to the poster export path.
const mapBlob = await useMapPoster(getMap).render(spec, 'png');
const mapDataUrl = await _blobToDataUrl(mapBlob);
_setProgress('pdf-init', 1, total, 'building PDF');
// Lazy ESM import — keep jspdf out of the main bundle until a packet is
// actually requested. Mirrors useMapPoster._toPdfBlob exactly.
let jsPDFCtor;
try {
const mod = await import('jspdf');
jsPDFCtor = mod.jsPDF || mod.default;
} catch (_) {
throw new Error('useMapPacket: jspdf not installed (npm i jspdf)');
}
const doc = new jsPDFCtor({
orientation: spec.widthIn > spec.heightIn ? 'l' : 'p',
unit: 'in',
format: [spec.widthIn, spec.heightIn],
});
const pageW = spec.widthIn;
const pageH = spec.heightIn;
const footer = _footerText(meta);
// ─── Page 1: full-page map image ──────────────────────────────────────
doc.addImage(mapDataUrl, 'PNG', 0, 0, pageW, pageH);
_drawFooter(doc, pageW, pageH, footer);
// ─── Pages 2..N: one detail card per people group ────────────────────
for (let i = 0; i < groups.length; i++) {
const pg = groups[i] || {};
doc.addPage();
_drawDetailCard(doc, pageW, pageH, pg, footer);
_setProgress('cards', 2 + i, total, `card ${i + 1}/${groups.length}`);
}
_setProgress('done', total, total, 'packet complete');
return doc.output('blob');
} finally {
isRendering.value = false;
}
}
return {
renderPacket,
progress: readonly(progress),
isRendering: readonly(isRendering),
};
}
// ──────────────────────────────────────────────────────────────────────────
// Detail-card drawing (module-private)
// ──────────────────────────────────────────────────────────────────────────
/**
* Draw a single people-group detail card onto the current PDF page.
* Uses only jsPDF vector primitives so output is crisp and tiny.
*/
function _drawDetailCard(doc, pageW, pageH, pg, footer) {
const margin = Math.min(pageW, pageH) * 0.08;
const x = margin;
let y = margin;
// Heading: people-group name
const name = _pgName(pg);
doc.setFont('helvetica', 'bold');
doc.setFontSize(28);
doc.setTextColor(20, 20, 20);
doc.text(name, x, y + 0.32);
y += 0.7;
// Underline rule beneath the heading
doc.setDrawColor(146, 177, 149); // #92b195 — the Doxa accent green
doc.setLineWidth(0.02);
doc.line(x, y, pageW - margin, y);
y += 0.45;
// Field rows
const rows = [
['Country', _pgCountry(pg)],
['Population', _pgPopulation(pg)],
['% Evangelical', _pgPercent(pg, 'evangelical')],
['% Christian', _pgPercent(pg, 'christian')],
['Prayer count', _pgPrayerCount(pg)],
];
const labelX = x;
const valueX = x + 2.4;
const rowH = 0.5;
doc.setFontSize(14);
for (const [label, value] of rows) {
doc.setFont('helvetica', 'normal');
doc.setTextColor(110, 110, 110);
doc.text(String(label), labelX, y);
doc.setFont('helvetica', 'bold');
doc.setTextColor(40, 40, 40);
doc.text(String(value), valueX, y);
// hairline separator under each row
doc.setDrawColor(225, 225, 225);
doc.setLineWidth(0.008);
doc.line(labelX, y + 0.14, pageW - margin, y + 0.14);
y += rowH;
}
_drawFooter(doc, pageW, pageH, footer);
}
/**
* Footer line drawn on every page: source • date • Mapbox/OSM attribution.
*/
function _drawFooter(doc, pageW, pageH, footer) {
const margin = Math.min(pageW, pageH) * 0.04;
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(120, 120, 120);
doc.text(footer, margin, pageH - margin, { baseline: 'bottom' });
}
function _footerText(meta = {}) {
const date = meta.date || new Date().toISOString().slice(0, 10);
const attribution = meta.attribution || '© Mapbox © OpenStreetMap';
const parts = [];
if (meta.source) parts.push(String(meta.source));
parts.push(String(date));
parts.push(attribution);
return parts.join(' • ');
}
// ──────────────────────────────────────────────────────────────────────────
// Defensive people-group field reads
// ──────────────────────────────────────────────────────────────────────────
// People-group objects reach us in three shapes (see useMapData.js +
// PeopleGroupDetail.vue): the normalized record (top-level keys), the
// backward-compat peopleGroups[] entry, and a GeoJSON feature `.properties`
// bag. `_props` flattens those so a single probe order works for all three.
function _props(pg) {
// Prefer an explicit GeoJSON `.properties`, fall back to the object itself.
return (pg && pg.properties) ? pg.properties : (pg || {});
}
function _firstDefined(...vals) {
for (const v of vals) {
if (v !== undefined && v !== null && v !== '') return v;
}
return undefined;
}
function _pgName(pg) {
const p = _props(pg);
return _firstDefined(
p.name,
p.peopleName,
p.PeopNameInCountry,
p._raw?.PeopNameInCountry,
p._raw?.peo_name,
pg.name,
) ?? '—';
}
function _pgCountry(pg) {
const p = _props(pg);
return _firstDefined(
p.countryName,
p.countryIsoLabel,
p.country,
p.countryIso,
p._raw?.Country,
pg.country,
) ?? '—';
}
function _pgPopulation(pg) {
const p = _props(pg);
const raw = _firstDefined(
p.population,
p._raw?.Population,
pg.population,
);
const n = Number(String(raw ?? '').replace(/,/g, ''));
if (raw === undefined || Number.isNaN(n)) return '—';
return n.toLocaleString();
}
/**
* Percent fields. The pray-tools / UUPG records expose evangelical &
* Christian percentages under several historical keys; probe them all and
* format as a percentage with one decimal.
*/
function _pgPercent(pg, kind) {
const p = _props(pg);
const candidates = kind === 'evangelical'
? [p.percentEvangelical, p.pctEvangelical, p.evangelical, p._raw?.PercentEvangelical, p._raw?.percent_evangelical]
: [p.percentChristian, p.pctChristian, p.percentAdherents, p.christian, p._raw?.PercentChristianPC, p._raw?.percent_christian, p._raw?.PercentAdherents];
const raw = _firstDefined(...candidates);
if (raw === undefined) return '—';
const n = Number(raw);
if (Number.isNaN(n)) return '—';
// Some sources store 0..1 fractions, others 0..100; normalize to percent.
const pct = n > 0 && n <= 1 ? n * 100 : n;
return `${pct.toFixed(1)}%`;
}
function _pgPrayerCount(pg) {
const p = _props(pg);
const raw = _firstDefined(
p.peoplePraying,
p._raw?.people_praying,
pg.peoplePraying,
);
if (raw === undefined) return '—';
const n = Number(raw);
if (Number.isNaN(n)) return '—';
return n.toLocaleString();
}
// ──────────────────────────────────────────────────────────────────────────
// Blob → dataURL
// ──────────────────────────────────────────────────────────────────────────
function _blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('useMapPacket: failed to read map blob'));
reader.readAsDataURL(blob);
});
}

View file

@ -80,10 +80,8 @@
import { defineComponent } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { bundleTreeRoot } from '@map/utils/embedBase.js'
import { loadScriptOnce } from '@map/utils/embedScript.js'
// Inject a bundle script exactly once per URL (deduped across all layout instances). Same helper the
// tabs layout + the SPA use — resolves after the IIFE's customElements.define has run.
const _stlLoaded = {}
// ★ ANCHOR A SIBLING-BUNDLE src TO THE TREE ROOT, NOT TO A GUESSED DEPTH.
// THE BUG (the agent-reported 2026-09-23, verbatim console text):
// Could not load <doxa-research-map> from ../../doxa-research-map/doxa-research-map.js
@ -134,30 +132,11 @@ function resolveBundleSrc(url) {
return url
}
// Inject a bundle script exactly once per URL, through the ONE shared loader (library/utils/embedScript.js:
// the dedupe cache, the dev-server ES-module rule, the empty-url no-op). Only the src resolution is this
// layout's own: a sibling-bundle src is anchored to the tree root first (resolveBundleSrc above).
function injectScriptOnce(rawUrl) {
const url = resolveBundleSrc(rawUrl)
if (!url) return Promise.resolve()
if (_stlLoaded[url]) return _stlLoaded[url]
_stlLoaded[url] = new Promise((ok, fail) => {
const s = document.createElement('script')
s.src = url
// ★ A DEV ENTRY IS AN ES MODULE, AND A CLASSIC <script> LIES ABOUT LOADING IT.
// Pointing a classic script at /plugin-designs/<bundle>/index.js fires onload SUCCESSFULLY and
// only THEN throws "Cannot use import statement outside a module", asynchronously. So this
// promise resolved, waitForDefine() below timed out, the tag never registered, and the section
// rendered empty — with NO failed request and NO console error at the injection site. onload is
// structurally incapable of detecting this.
// MEASURED on stack-demo's dev leaf 2026-09-22: 0 failed requests, and yet
// customElements.get('research-dashboard-map') === false with 0 rows rendered.
// Same rule as plugin-studio.vue:255, which hit this first. It belongs HERE because StackLayout
// is the SHARED layout — dashboard-plus-grid and research-map-plus-grid inject through this same
// function, so one fix covers all three consumers instead of becoming a fourth private copy.
if (/^\/plugin-designs\//.test(url) || /\/index\.js(\?|$)/.test(url)) s.type = 'module'
s.onload = () => ok()
s.onerror = () => { delete _stlLoaded[url]; fail(new Error('failed to load ' + url)) }
document.head.appendChild(s)
})
return _stlLoaded[url]
return loadScriptOnce(resolveBundleSrc(rawUrl))
}
function waitForDefine(tag, timeoutMs = 5000) {

View file

@ -246,22 +246,12 @@ import { tStatic } from '../i18n/index.js'
const tr = (key, en) => tStatic(key) || en
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { icon as localIcon } from '@map/layouts/app-wrapper-material/icons.js'
import { loadScriptOnce } from '@map/utils/embedScript.js'
// Inject a bundle script exactly once per URL (deduped across all layout instances). Resolves after
// the IIFE has executed (so its customElements.define has run). Mirrors the SPA/studio's once().
const _vtlLoaded = {}
function injectScriptOnce(url) {
if (!url) return Promise.resolve()
if (_vtlLoaded[url]) return _vtlLoaded[url]
_vtlLoaded[url] = new Promise((ok, fail) => {
const s = document.createElement('script')
s.src = url
s.onload = () => ok()
s.onerror = () => { delete _vtlLoaded[url]; fail(new Error('failed to load ' + url)) }
document.head.appendChild(s)
})
return _vtlLoaded[url]
}
// Inject a bundle script exactly once per URL, through the ONE shared loader (library/utils/embedScript.js).
// This layout's private copy lacked the dev-server ES-module rule, so a bundle nested through it under
// bun run dev loaded as a classic script, reported success, and never defined its tag.
const injectScriptOnce = (url) => loadScriptOnce(url)
// Wait until a custom element tag is defined (up to ~5s). If a src bundle was injected, its
// define() has already run by onload; this covers elements registered async after script exec.

View file

@ -0,0 +1,89 @@
/**
* embedScript — load another bundle's script and wait for its element to register.
*
* ★ WHAT AN APP SHELL DOES. A capability-5 plugin nests other plugins, so it needs three things:
* work out the tree root it was served from, load a bundle's `.js` exactly once, and wait for the
* custom element to actually exist before mounting it. Both shells in this repo — the Research
* Gallery (`doxa-maps-spa`) and the Plugin Studio (`plugin-studio`) — had their own copy of all
* three, identical in code (DEDUP-AUDIT.md, 2026-09-28: 33 lines, the only cross-plugin cluster
* that is identical, closes over nothing, and spans one owner's plugins).
*
* Moved verbatim; the comments below are the originals, because each records a measurement.
*
* ★ ONE BEHAVIOUR CHANGE, AND IT IS AN IMPROVEMENT: `_loaded` is now shared. Two shells on one page
* previously kept separate caches and would each load the same bundle. Now the second one reuses
* the first's promise. Same result, one network request instead of two.
*/
/**
* Load a bundle exactly once per URL; resolves after its script has executed.
*
* ★ A CLASSIC SCRIPT FIRES onload AND THEN FAILS, for a dev-server bundle. Measured 2026-09-22:
* pointing a plain <script> at a dev entry (/plugin-designs/<bundle>/index.js, a real ES MODULE)
* fires onload — so a loader watching onload reports SUCCESS — and only THEN throws "Cannot use
* import statement outside a module" as an ASYNC page error. The tag is never defined,
* document.createElement(tag) yields an inert HTMLUnknownElement, and the pane sits empty while its
* own state reads 'ready'. onload is structurally incapable of seeing this, so it is not a usable
* success signal by itself — which is why every mount below also waits on whenTagDefined().
*
* The built tree ships IIFEs, where a classic tag is correct, so this only ever bites under the dev
* server — exactly the surface a developer works on. Rule: a rooted /plugin-designs/ entry (or any
* .../index.js) is a dev ES module; everything else is a built IIFE.
* MEASURED a second time, independently, on stack-demo's dev leaf 2026-09-22 (StackLayout, before it
* used this file): 0 failed requests, and yet customElements.get('research-dashboard-map') === false
* with 0 rows rendered. VerticalTabsLayout's private copy LACKED this rule until it moved here, so a
* bundle nested through it on the dev server never defined its tag.
*
* An empty url is a no-op: a <script src=""> would re-fetch the page itself.
*
* SRI is optional (attrs.integrity).
*/
const _loaded = {}
export function loadScriptOnce(url, attrs) {
if (!url) return Promise.resolve()
if (_loaded[url]) return _loaded[url]
_loaded[url] = new Promise((ok, fail) => {
const s = document.createElement('script')
s.src = url
if (/^\/plugin-designs\//.test(url) || /\/index\.js(\?|$)/.test(url)) s.type = 'module'
if (attrs) for (const k in attrs) if (attrs[k]) s.setAttribute(k, attrs[k])
s.onload = () => ok()
s.onerror = () => { delete _loaded[url]; fail(new Error('failed to load ' + url)) }
document.head.appendChild(s)
})
return _loaded[url]
}
// Wait for a tag to ACTUALLY register. loadScriptOnce resolving is not proof the element exists (see
// above), and a module script's define() runs only after its import graph settles, so the tag can
// appear a tick or two after onload. customElements.whenDefined is the browser's own authoritative
// answer; the timeout turns a never-registered tag into THIS tab's error overlay rather than a silent
// blank pane that claims to be ready.
export function whenTagDefined(tag, ms) {
if (typeof customElements === 'undefined') return Promise.resolve()
if (customElements.get(tag)) return Promise.resolve()
return Promise.race([
customElements.whenDefined(tag),
new Promise((_, fail) => setTimeout(() => {
if (customElements.get(tag)) return
fail(new Error('<' + tag + '> never registered (bundle loaded but defined no element)'))
}, ms || 15000))
])
}
/**
* The tree root this shell was served from, with a trailing slash, or '' when it cannot be known.
*
* ⚠ READS document.currentScript, so it is only valid AT MODULE EVALUATION TIME. Call it once at the
* top of an entry file (`const TREE_BASE = deriveTreeBase()`), never later — `currentScript` is null
* once the script has finished executing, and a late call silently returns ''.
*/
export function deriveTreeBase() {
try {
const cur = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || ''
if (!cur) return ''
// Strip the trailing "<bundle>/<bundle>.js" → the tree root, with trailing slash.
const root = cur.replace(/[^/]+\/[^/]+$/, '')
return root.replace(/\/+$/, '') + '/'
} catch { return '' }
}

View file

@ -234,6 +234,8 @@
"check:tab-radius:self-test": "node internal/check-tab-radius-token.mjs --self-test",
"check:text-highlight": "node plugin-designs/regions-dashboard/scripts/check-text-highlight.mjs",
"check:tabs-localized": "node internal/check-tabs-localized.mjs",
"check:targets": "node internal/check-targets.mjs",
"check:targets:self-test": "node internal/check-targets.mjs --self-test",
"check:tauri": "node internal/check-tauri-config.mjs",
"check:template-literal": "node internal/template-literal-check.mjs",
"check:tree-weight": "node internal/check-published-tree-weight.mjs",
@ -247,11 +249,12 @@
"check:zoning-declared:self-test": "node internal/check-zoning-declared.mjs --self-test",
"clean": "node internal/clean.mjs",
"deploy:dev": "bun run build && node internal/sync-dropin.mjs --from-config --prune",
"desktop:build": "node internal/desktop-build.mjs",
"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:capability-derived 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:zoning-declared 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 check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge",
"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 check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print",
"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 check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -442,6 +445,8 @@
"check:stale-sweep-claim": "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": "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:tabs-localized": "a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.",
"check:targets": "where each plugin runs: a <profile>.meta.json \"targets\" is a list of known values (web, mobile, desktop, widget; absent = web), a widget target has a widget corner, a backend key names an existing folder, and a plugin that is not for the web (Rudder) is in neither the built tree nor its manifest, so the /cdn export and the gallery cannot carry it.",
"check:targets:self-test": "plants each check:targets violation and confirms it is caught, and that a valid meta passes.",
"check:text-highlight": "useTextHighlight (library/composables) matches the inline search-highlight construction it replaced in regions-dashboard UupgsList and people-groups-grid across six range shapes, and pins the two deliberate escaping deltas (backtick escaped; null returns '' instead of throwing) with controls proving each is real.",
"check:tree-weight": "the tree we publish has a weight and nothing was watching it. A publish was ALREADY BLOCKED once at a 74MB asset tree (Forgejo rejected the pack); it has since grown to 132MB unnoticed. Reports the SPLIT, because a bare total invites deleting the wrong half: the 176 city posters are the heavy half AND the half that cannot be regenerated (they need OSM tiles). Raw bytes, not packed — see publish-html.mjs for why a raw saving is not a shipped one.",
"check:upg-map-data": "the UPG-100 map must carry OUR polygons/zones/pins, not just a basemap — reads the counter the profile itself writes. Pass live URLs to audit what is published.",
@ -450,6 +455,7 @@
"check:zone-census": "every zone a USER CAN SELECT must have a poster (asks the LEGEND, not clusters.json — two collapsed deaf lists are unreachable and not a gap). CONDITIONAL: SKIPs without out/posters/, which is gitignored — run `bun run posters` first. It cannot be fixtured like check:packets-all, because it asserts COVERAGE of real generated output and a fixture would only assert the fixture is complete.",
"check:zoning-declared": "A project list's ZONING matches what its author DECLARED (data/zoning-declarations.json). Zoning is human judgement — geography first, balanced with language — so a gate cannot decide whether a zoning is GOOD; it can decide whether it still matches what was stated, which is what makes UPG-100 reusable as a PATTERN for any project list (7000/4700/3000/2000/700) zoned by an agent who did not write this code. ★ EVERY OBVIOUS RULE FAILED THE REFERENCE IMPLEMENTATION when measured on the live 28 zones / 2107 records: 'every group in exactly one zone' is wrong (112 double-claims, 110 of them the design-approved deaf bloc double-count, legend-tree.js:151) and 'no group unzoned' is wrong (11 JPN/CHN/MNG groups, plausibly deliberate). So it asserts A1 no zone resolves to zero groups, A2 at most one GEOGRAPHIC zone per group with bloc zones declared as a second partition, A3 the orphan set EQUALS the declared set (a RESOLVED orphan fails too — a stale declaration stops being evidence), A4 every selector type is one selector-evaluator supports, A5 no zone over the declared page budget. A1 and A4 would both have caught this cycle's real defects (deaf + 5 wagf zones resolving to nothing) before a browser did, and A2 caught a third overlap my own hand analysis had missed. Exit 75 without records or declarations. Run :self-test to prove the five still fire.",
"deploy:dev": "destination + allowlist come from vite.doxa-maps-build-config.json (dropin.dest / dropin.include); DOXA_DROPIN_DEST=<dir> overrides the destination",
"desktop:build": "bun run desktop:build <plugin>: packages ONE plugin whose meta targets include desktop as a desktop app (Tauri): builds it into .desktop/<plugin>/, builds its sidecar engine for this machine, and runs tauri build with an override pointing the window at it. See documentation/HOWTO-build-the-desktop-app.md.",
"gallery": "writes public/__gallery/index.html — the ARCHIVED bundle-index gallery, from public/doxa-maps/manifest.json. It is NO LONGER the root page: as of 2026-09-22 `/` mounts <plugin-studio> in BOTH trees (the built root page is written by `bun run tree`), and the gallery is the opt-in index at /__gallery. Archived, not deleted — the dev server imports renderGallery()/readBuildConfig() from this module.",
"gen:iso3-to-alpha2": "derive library/data/iso3-to-alpha2.json from vendored CLDR (48.2.2, Unicode-3.0) joined against the 242 codes in library/data/countries.json, so Intl.DisplayNames can produce a LOCALIZED country name — it takes alpha-2 and our data speaks alpha-3. Offline, no dependency. GRADES ITS OWN OUTPUT and writes nothing on failure: 242 coverage, injectivity, and KOS -> XK as the one documented exception (Kosovo has no ISO assignment). Cross-checks the 176-country research spine map and REPORTS disagreements without rewriting it — that file drives fetches and is not this generator's to edit.",
"internet:adm1": "regenerate data/tables/internet-by-adm1.json (internet use by first-level area; India from NFHS-5 2019-21, CC BY 4.0) from data/internet/ through a strict name crosswalk to the shipped gb-IND-ADM1 boundaries. Fails on an unmatched name, never guesses. Generic rows (iso3, adm1, measure, sex, area, value, source, year) so other plugins and go-int can reuse it.",

View file

@ -159,6 +159,7 @@ import { defineComponent, inject } from 'vue'
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/layouts/app-wrapper-material/languages.js'
import { translate, LOCALES, hasKey } from './src/strings.js'
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
// THE HOST PAGE'S LANGUAGE, by the same ladder every nested map already uses (library/i18n
// detectLocale): ?lang= / ?locale= on the page URL, then <html lang>. '' when the page says nothing.
@ -680,54 +681,7 @@ function resolveTabsFromManifest(manifest, defs) {
return out
}
/**
* Load a bundle exactly once per URL; resolves after its script has executed.
*
* ★ A CLASSIC SCRIPT FIRES onload AND THEN FAILS, for a dev-server bundle. Measured 2026-09-22:
* pointing a plain <script> at a dev entry (/plugin-designs/<bundle>/index.js, a real ES MODULE)
* fires onload — so a loader watching onload reports SUCCESS — and only THEN throws "Cannot use
* import statement outside a module" as an ASYNC page error. The tag is never defined,
* document.createElement(tag) yields an inert HTMLUnknownElement, and the pane sits empty while its
* own state reads 'ready'. onload is structurally incapable of seeing this, so it is not a usable
* success signal by itself — which is why every mount below also waits on whenTagDefined().
*
* The built tree ships IIFEs, where a classic tag is correct, so this only ever bites under the dev
* server — exactly the surface a developer works on. Rule: a rooted /plugin-designs/ entry (or any
* .../index.js) is a dev ES module; everything else is a built IIFE.
*
* SRI is optional (attrs.integrity).
*/
const _loaded = {}
function loadScriptOnce(url, attrs) {
if (_loaded[url]) return _loaded[url]
_loaded[url] = new Promise((ok, fail) => {
const s = document.createElement('script')
s.src = url
if (/^\/plugin-designs\//.test(url) || /\/index\.js(\?|$)/.test(url)) s.type = 'module'
if (attrs) for (const k in attrs) if (attrs[k]) s.setAttribute(k, attrs[k])
s.onload = () => ok()
s.onerror = () => { delete _loaded[url]; fail(new Error('failed to load ' + url)) }
document.head.appendChild(s)
})
return _loaded[url]
}
// Wait for a tag to ACTUALLY register. loadScriptOnce resolving is not proof the element exists (see
// above), and a module script's define() runs only after its import graph settles, so the tag can
// appear a tick or two after onload. customElements.whenDefined is the browser's own authoritative
// answer; the timeout turns a never-registered tag into THIS tab's error overlay rather than a silent
// blank pane that claims to be ready.
function whenTagDefined(tag, ms) {
if (typeof customElements === 'undefined') return Promise.resolve()
if (customElements.get(tag)) return Promise.resolve()
return Promise.race([
customElements.whenDefined(tag),
new Promise((_, fail) => setTimeout(() => {
if (customElements.get(tag)) return
fail(new Error('<' + tag + '> never registered (bundle loaded but defined no element)'))
}, ms || 15000))
])
}
export default defineComponent({
name: 'DoxaMapsSpa',

View file

@ -30,19 +30,11 @@
import { defineCustomElement } from 'vue'
import DoxaMapsSpa from './doxa-maps-spa.vue'
import { deriveTreeBase } from '@map/utils/embedScript.js'
// Capture the running <script>'s own URL synchronously at IIFE load, BEFORE any async runs, so
// document.currentScript is still this bundle's tag. Derive the tree root = two levels up
// (.../doxa-maps/doxa-maps-spa/doxa-maps-spa.js → .../doxa-maps/). Guarded for non-browser.
function deriveTreeBase() {
try {
const cur = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || ''
if (!cur) return ''
// Strip the trailing "<bundle>/<bundle>.js" → the tree root, with trailing slash.
const root = cur.replace(/[^/]+\/[^/]+$/, '')
return root.replace(/\/+$/, '') + '/'
} catch { return '' }
}
const TREE_BASE = deriveTreeBase()
const DoxaMapsSpaElement = defineCustomElement(DoxaMapsSpa, {

View file

@ -1111,9 +1111,52 @@ function _buildFlatLegendMatchExpr(node, legendType) {
const REGION_IDLE_FILL = ['case', ['has', 'iso_3166_1_alpha_3'], 0.7, 0.7]
const REGION_IDLE_COLOR = ['case', ['has', 'iso_3166_1_alpha_3'], 'rgba(60,60,80,0.35)', 'rgba(60,60,80,0.35)']
const REGION_IDLE_WIDTH = ['case', ['has', 'iso_3166_1_alpha_3'], 0.6, 0.6]
// Make the region paints data-driven WITHOUT changing what they show: each property is read back and
// re-written wrapped in a case whose two branches are the value it already had. A property the layer
// does not define is skipped rather than invented, and one already wrapped is left alone.
function prewarmRegionPaint(m) {
if (!m || !m.getLayer('regions-fill')) return
const wrap = (v) => ['case', ['has', 'iso_3166_1_alpha_3'], v, v]
// ★ SKIP WHAT IS ALREADY DATA-DRIVEN, and note the test is not "is it an array". exp-shepherd wrote
// their copy to skip any array, reasoning that an expression is already data-driven so there is no
// variant to warm. That is right for this layer — line-color arrives as a match on a feature property
// — but it is right by accident: a ZOOM expression like ['interpolate',['linear'],['zoom'],…] is also
// an array and is NOT data-driven, so skipping it would leave that property to compile its variant on
// the first gesture and produce exactly the partial pre-warm this is trying to avoid.
// The property that matters is whether the expression reads the FEATURE. Wrapping one that already
// does is pointless anyway — it would rewrite a region-coloured match into a case around it, which is
// what my first version did.
const readsFeature = (e) => Array.isArray(e)
&& (e[0] === 'get' || e[0] === 'has' || e[0] === 'feature-state' || e.some(readsFeature))
const warm = (layer, prop) => {
if (!m.getLayer(layer)) return
const cur = m.getPaintProperty(layer, prop)
if (cur === undefined || cur === null) return
if (readsFeature(cur)) return
m.setPaintProperty(layer, prop, wrap(cur))
}
try {
warm('regions-fill', 'fill-opacity')
warm('regions-border', 'line-color')
warm('regions-border', 'line-width')
} catch (_) { /* style still loading - the first real paint carries it */ }
}
function paintRegionScope(m, isoCodes) {
if (!m || !m.getLayer('regions-fill')) return
const list = Array.isArray(isoCodes) ? isoCodes.filter(Boolean) : []
// ★ CANONICAL FIRST, OR THE COMPARE BELOW IS A LIE (rega, L43: still 9 writes per click, not 3).
// The two sources build the same region from different lists — node.isoCodes and
// mapLayers.regionIsoMap[slug] — and they do not agree on ORDER. rega had already told me that in
// the very first stack trace ("a case over ['IND',…]" then "a case over ['MYS',…], a different
// literal order") and I wrote a JSON-string compare anyway, which can never match two orderings of
// the same set. Sorting and de-duplicating makes the expression a function of the SET, which is what
// the two callers actually agree on.
//
// It is also a test: if the write count does not drop to 3, the two sources disagree about WHICH
// countries are in the region, which is a data defect this compare would otherwise have hidden.
const list = Array.isArray(isoCodes)
? [...new Set(isoCodes.filter(Boolean).map((c) => String(c).toUpperCase()))].sort()
: []
const isoArr = ['literal', list]
const fill = list.length ? ['case', ['in', ['get', 'iso_3166_1_alpha_3'], isoArr], 0.85, 0.15] : REGION_IDLE_FILL
const color = list.length ? ['case', ['in', ['get', 'iso_3166_1_alpha_3'], isoArr], '#111827', 'rgba(60,60,80,0.15)'] : REGION_IDLE_COLOR
@ -1232,7 +1275,22 @@ function onSemanticTreeSelect(node) {
// Mirror region selection into mapStore (for geocoder / external sync).
if (id.startsWith('region:')) mapStore.selectRegion?.(id.slice(7))
// Highlight matching polygons using isoCodes (alpha-3) on iso_3166_1_alpha_3.
paintRegionScope(m, node.isoCodes || [])
// ★ ONE SOURCE, NOT JUST ONE WRITER. A region: click has TWO painters — this line, from
// node.isoCodes, and the canonical path it triggers one line above (selectRegion -> the store
// watcher -> applyDimFilter, which paints from mapLayers.regionIsoMap). Canonicalising the list
// made them comparable; it could not make them EQUAL, because the two sets are different by
// construction:
// · node.isoCodes comes from useWagfRegionsLegendData, which builds its regions by walking the
// PEOPLE-GROUP LIST — so it holds only countries that have pins.
// · regionIsoMap comes from regionsData.isoToRegion — every country assigned to the region.
// So a region highlight painted from the legend has HOLES wherever a country has no unreached
// group in the current list, and which version you saw depended on which writer ran last.
// The dataset is the right answer for "highlight the countries belonging to this region", so
// region: rows defer to it and stop painting here.
//
// block: and country: rows keep painting from node.isoCodes, because nothing else paints them —
// only region: reaches selectRegion above — and for those two the legend's own list IS the scope.
if (!id.startsWith('region:')) paintRegionScope(m, node.isoCodes || [])
}
}
// Theme bridge: keep the SemanticTreeLegend in sync with the app's dark-mode
@ -1402,9 +1460,16 @@ async function openPacket() {
//
// library/composables/usePosterPrints.js ALREADY has printPacket: the same pages, authored as HTML
// with @page rules, printed in place. This map was running a private second implementation of a
// shared product feature. Routing to it deletes the duplicate, drops jspdf from this bundle, and
// the packet gains the pages the shared renderer has that the local one never did (the life
// calendar, the funnel, the prayer wheel — see packet-render.buildPacket's page order).
// shared product feature. Routing to it deletes the duplicate, and the packet gains the pages the
// shared renderer has that the local one never did (the life calendar, the funnel, the prayer
// wheel — see packet-render.buildPacket's page order).
//
// ⚠ THIS USED TO CLAIM IT DROPPED JSPDF FROM THE BUNDLE. It does not, and I checked the built
// artifact only after saying so: jspdf arrives through useMapPoster.toPdf, which this map still
// uses for the poster, so it was never this path's to remove. The bundle in fact GREW about 131 KB
// (983,264 -> 1,117,687 on L42) because the shared HTML renderer is inlined — each bundle is a
// single .js with no code splitting, so the dynamic imports above are build-time inlined, not lazy.
// Correct on the record here because the wrong sentence was copied verbatim into country-posters.
const [{ usePosterPrints }, { defaultPosterSpec }, { useMapPoster }, { selectedPinIds }] = await Promise.all([
import('@map/composables/usePosterPrints.js'),
import('@map/constants/posterDefaults.js'),
@ -1584,9 +1649,20 @@ async function ensureRegionsLoaded() {
// ★ PRE-WARM HERE, which is the only moment that helps. These layers are added on first visit
// to the Regions tab, and rega measured the first ROW click paying 5.5 s (MapLibre) / 11.1 s
// (Mapbox) of shader compile because that click was when their paint first became data-driven.
// Painting the idle state through paintRegionScope makes it data-driven from the first draw, so
// the variant compiles here — where a 6.4 MB GeoJSON is already loading — instead of mid-gesture.
paintRegionScope(map.value, [])
// Making the paint data-driven at layer-add moves that compile here, where a 6.4 MB GeoJSON is
// already loading, instead of into the middle of a gesture.
//
// WARNING IT WRAPS WHAT THE LAYER ALREADY PAINTS, and does NOT paint the cleared state. The
// first version called paintRegionScope(map.value, []) — the CLEARED look, flat grey borders at
// width 0.6. addRegionsLayer does not start there: it sets line-color to the region-coloured
// match expression and line-width 1 (useMapLayers). So pre-warming with the cleared values
// silently repainted every border grey and thinner the moment the layer appeared — a visible
// change nobody asked for, and invisible to both things being measured at the time, a timing
// check and a write count.
// Reading each property back and re-writing it wrapped in an identical-branch case keeps the
// appearance exactly as the layer defined it while still making the property data-driven, which
// is all the shader variant depends on.
prewarmRegionPaint(map.value)
// Hide regions on non-Doxa-Regions tabs so they don't show through.
if (map.value.getLayer('regions-fill') && activeTab.value?.id !== 'doxa-regions') {
map.value.setLayoutProperty('regions-fill', 'visibility', 'none')

View file

@ -33,18 +33,11 @@
import { defineCustomElement } from 'vue'
import PluginStudio from './plugin-studio.vue'
import { deriveTreeBase } from '@map/utils/embedScript.js'
// Capture the running <script>'s own URL synchronously at IIFE load, BEFORE any async runs, so
// document.currentScript is still this bundle's tag. Derive the tree root = two levels up
// (.../doxa-maps/plugin-studio/plugin-studio.js -> .../doxa-maps/). Guarded for non-browser.
function deriveTreeBase() {
try {
const cur = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || ''
if (!cur) return ''
const root = cur.replace(/[^/]+\/[^/]+$/, '')
return root.replace(/\/+$/, '') + '/'
} catch { return '' }
}
const TREE_BASE = deriveTreeBase()
const PluginStudioElement = defineCustomElement(PluginStudio, {

View file

@ -218,6 +218,7 @@ import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
import StudioTree from './src/StudioTree.vue'
import { LANGUAGES, loadLocale, saveLocale, getLanguage, normaliseLocale } from '@map/layouts/app-wrapper-material/languages.js'
import { publishMapToken, clearMapToken } from '@map/composables/useSharedMapToken.js'
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
// The reader's own map token / token-service URL. localStorage only: never written into the build,
// never sent anywhere but Mapbox (the token) or the URL the reader typed (the service).
@ -428,47 +429,7 @@ function rowLabel(bundle, p) {
return p.profile === bundle ? humanize(bundle) : humanize(bundle) + ' · ' + humanize(p.profile)
}
// Load a bundle exactly once per URL; resolves after its define() has run.
//
// ★ A CLASSIC SCRIPT FIRES onload AND THEN FAILS, for a dev-server bundle. Measured 2026-09-22:
// pointing a plain <script> at the dev entry (/plugin-designs/<bundle>/index.js, a real ES
// module) fires onload — so the old loader resolved "success" — and only THEN throws
// "Cannot use import statement outside a module" as an async page error. The tag is never
// defined, createElement yields an inert HTMLUnknownElement, and the pane sits empty while its
// state reads 'ready'. onload is structurally incapable of seeing this failure, so it is not a
// usable success signal on its own. A built tree ships IIFEs, where a classic tag is correct, so
// this only ever bites under the dev server — exactly where the studio is developed.
// Rule: a rooted /plugin-designs/ entry is a dev ES module; everything else is a built IIFE.
const _loaded = {}
function loadScriptOnce(url, attrs) {
if (_loaded[url]) return _loaded[url]
_loaded[url] = new Promise((ok, fail) => {
const s = document.createElement('script')
s.src = url
if (/^\/plugin-designs\//.test(url) || /\/index\.js(\?|$)/.test(url)) s.type = 'module'
if (attrs) for (const k in attrs) if (attrs[k]) s.setAttribute(k, attrs[k])
s.onload = () => ok()
s.onerror = () => { delete _loaded[url]; fail(new Error('failed to load ' + url)) }
document.head.appendChild(s)
})
return _loaded[url]
}
// Wait for a tag to actually register. loadScriptOnce resolving is NOT proof the element exists
// (see above), and a module script's define() runs after its import graph settles, so the tag can
// appear a tick or two after onload. whenDefined is the browser's own authoritative answer; the
// timeout turns a never-registered tag into THIS row's error overlay instead of a silent blank pane.
function whenTagDefined(tag, ms) {
if (typeof customElements === 'undefined') return Promise.resolve()
if (customElements.get(tag)) return Promise.resolve()
return Promise.race([
customElements.whenDefined(tag),
new Promise((_, fail) => setTimeout(() => {
if (customElements.get(tag)) return
fail(new Error('<' + tag + '> never registered (bundle loaded but defined no element)'))
}, ms || 15000))
])
}
// Corner widgets: a round chat-style bubble; open, a small panel above it (a bottom sheet on a phone).
const WIDGET_CSS = `

View file

@ -3,6 +3,6 @@
"type": "single",
"widget": { "corner": "bottom-right" },
"label": "Rudder — voice to text (desktop)",
"targets": ["desktop"],
"targets": ["desktop", "widget"],
"backend": "backend/rudder-engine"
}

View file

@ -707,8 +707,10 @@ const statusText = computed(() => ({
the engine runs on. Open the Plugin Studio on your computer (<code>bun run dev</code>) or use the Rudder desktop app.
</p>
<p v-else-if="engine === 'offline'" class="notice" data-kind="warn">
The local speech engine isn't reachable at <code>{{ apiBase }}</code>. In the Plugin Studio it starts with
<code>bun run dev</code> once built: <code>cd backend/rudder-engine &amp;&amp; go build -o ../bin/rudder-engine .</code>
The local speech engine isn't reachable at <code>{{ apiBase }}</code>. It needs <b>Go 1.22 or newer</b> and a
one-time setup that downloads the speech model (about 750 MB): <code>bash backend/rudder-engine/setup-runtime.sh</code>.
Then <code>bun run dev</code> starts it here. For the desktop app, <code>bun run desktop:build rudder-voice</code>;
both are described in <code>documentation/HOWTO-build-the-desktop-app.md</code>.
</p>
<section class="compose">

View file

@ -2,7 +2,7 @@
This folder is GENERATED by `bun run build`; do not edit it by hand. It holds exactly one thing:
- **`dx-maps/`** — the output folder: 25 plugin bundles and 37 pages, self-contained.
- **`dx-maps/`** — the output folder: 24 plugin bundles and 36 pages, self-contained.
Copy this one folder to any web host (or open it from disk) and it works.
- `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).

File diff suppressed because one or more lines are too long

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../../dashboard-plus-grid.js" integrity="sha384-P15DN0neJhlrI9+vzXDozhY8RZHDsJkVnV3Yxun8ewNUNYFEyCdSETY1vBVu+6ee" crossorigin="anonymous"></script>
<script src="../../dashboard-plus-grid.js" integrity="sha384-X3AEM/H4IPUfLH80IHRCRQHDmUwmuAAE+/ChGsH/3qdFV6KB7NhipFlWJtC3F/7t" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../dashboard-plus-grid.js" integrity="sha384-P15DN0neJhlrI9+vzXDozhY8RZHDsJkVnV3Yxun8ewNUNYFEyCdSETY1vBVu+6ee" crossorigin="anonymous"></script>
<script src="../dashboard-plus-grid.js" integrity="sha384-X3AEM/H4IPUfLH80IHRCRQHDmUwmuAAE+/ChGsH/3qdFV6KB7NhipFlWJtC3F/7t" 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

@ -32,7 +32,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-CKJT8BCWZubQKLenstpsz19Kd87akZiO1SGu5+Ma3byRUdyXz0gcjrtmmkQxzw7i" crossorigin="anonymous"></script>
<script src="../../doxa-maps-spa.js" integrity="sha384-AmW0CbWRRmUZJW0me3crwWEAS4XiX93Z7KM9Vpu5rBKYXgPOJYTf4LEVp8oDG4li" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,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-CKJT8BCWZubQKLenstpsz19Kd87akZiO1SGu5+Ma3byRUdyXz0gcjrtmmkQxzw7i" crossorigin="anonymous"></script>
<script src="../doxa-maps-spa.js" integrity="sha384-AmW0CbWRRmUZJW0me3crwWEAS4XiX93Z7KM9Vpu5rBKYXgPOJYTf4LEVp8oDG4li" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 25,
"profileCount": 37,
"bundleCount": 24,
"profileCount": 36,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life"
},
@ -229,7 +229,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-P15DN0neJhlrI9+vzXDozhY8RZHDsJkVnV3Yxun8ewNUNYFEyCdSETY1vBVu+6ee",
"integrity": "sha384-X3AEM/H4IPUfLH80IHRCRQHDmUwmuAAE+/ChGsH/3qdFV6KB7NhipFlWJtC3F/7t",
"elements": [
"dashboard-plus-grid"
],
@ -252,7 +252,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-P15DN0neJhlrI9+vzXDozhY8RZHDsJkVnV3Yxun8ewNUNYFEyCdSETY1vBVu+6ee\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-X3AEM/H4IPUfLH80IHRCRQHDmUwmuAAE+/ChGsH/3qdFV6KB7NhipFlWJtC3F/7t\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
},
"instances": [
{
@ -321,7 +321,7 @@
"bundle": "doxa-maps-spa",
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
"integrity": "sha384-CKJT8BCWZubQKLenstpsz19Kd87akZiO1SGu5+Ma3byRUdyXz0gcjrtmmkQxzw7i",
"integrity": "sha384-AmW0CbWRRmUZJW0me3crwWEAS4XiX93Z7KM9Vpu5rBKYXgPOJYTf4LEVp8oDG4li",
"elements": [
"doxa-maps-spa"
],
@ -349,7 +349,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-CKJT8BCWZubQKLenstpsz19Kd87akZiO1SGu5+Ma3byRUdyXz0gcjrtmmkQxzw7i\" 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-AmW0CbWRRmUZJW0me3crwWEAS4XiX93Z7KM9Vpu5rBKYXgPOJYTf4LEVp8oDG4li\" 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": [
{
@ -376,7 +376,7 @@
"bundle": "doxa-research-map",
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"output": "dx-maps/doxa-research-map/doxa-research-map.js",
"integrity": "sha384-11TPej/kB1nQL0lXCw/gOjTmj/VznycM5fmIfsMNah74SMwH8i+NwFSgbr5zoDZs",
"integrity": "sha384-kC4KWUvDI3yN7HgjzUQVZTm2dYxTM0pRBlBxhFL9VQALYpLhmASkvjVthQ3dvbdC",
"elements": [
"doxa-research-map"
],
@ -395,7 +395,7 @@
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-11TPej/kB1nQL0lXCw/gOjTmj/VznycM5fmIfsMNah74SMwH8i+NwFSgbr5zoDZs\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-kC4KWUvDI3yN7HgjzUQVZTm2dYxTM0pRBlBxhFL9VQALYpLhmASkvjVthQ3dvbdC\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
},
"instances": [
{
@ -737,7 +737,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "dx-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe",
"integrity": "sha384-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja",
"elements": [
"gospel-resources-map"
],
@ -759,7 +759,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-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe\" 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-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja\" 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>"
}
},
{
@ -774,7 +774,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-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe\" 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-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja\" 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>"
}
},
{
@ -790,7 +790,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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe\" 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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja\" 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>"
}
},
{
@ -805,7 +805,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-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe\" 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-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja\" 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>"
}
},
{
@ -821,7 +821,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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe\" 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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja\" 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>"
}
}
]
@ -956,7 +956,7 @@
"bundle": "plugin-studio",
"scriptUrl": "./plugin-studio/plugin-studio.js",
"output": "dx-maps/plugin-studio/plugin-studio.js",
"integrity": "sha384-NsG6VNbCUAcOh9jEf5+8DeaKFm7xRySJO8yIUiE0O0AQ3FhuILru4eBHgaXCPQUD",
"integrity": "sha384-gC2dBaw7+rJERXYB7VaEtRlo1wIkVkA5CzKHyuid0QAmu0qPYmezGM9kdY9pPAOc",
"elements": [
"plugin-studio"
],
@ -987,7 +987,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-NsG6VNbCUAcOh9jEf5+8DeaKFm7xRySJO8yIUiE0O0AQ3FhuILru4eBHgaXCPQUD\" 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-gC2dBaw7+rJERXYB7VaEtRlo1wIkVkA5CzKHyuid0QAmu0qPYmezGM9kdY9pPAOc\" 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": [
{
@ -1163,7 +1163,7 @@
"bundle": "research-map-plus-grid",
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
"output": "dx-maps/research-map-plus-grid/research-map-plus-grid.js",
"integrity": "sha384-malo2pC/K0/1Xi9kyDubh+4tY9HC56zcO/VsgppNf5c4AXvXG9RMxqVHYoFAoOr5",
"integrity": "sha384-sIu4F0HzdbO4/0FF1bUGfe7xj/8bjMklygdB3/KmaG8bHHXa300y9nPDYg1bCouM",
"elements": [
"research-map-plus-grid"
],
@ -1186,7 +1186,7 @@
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./research-map-plus-grid/research-map-plus-grid/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=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-malo2pC/K0/1Xi9kyDubh+4tY9HC56zcO/VsgppNf5c4AXvXG9RMxqVHYoFAoOr5\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-sIu4F0HzdbO4/0FF1bUGfe7xj/8bjMklygdB3/KmaG8bHHXa300y9nPDYg1bCouM\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
},
"instances": [
{
@ -1206,45 +1206,11 @@
}
]
},
{
"bundle": "rudder-voice",
"scriptUrl": "./rudder-voice/rudder-voice.js",
"output": "dx-maps/rudder-voice/rudder-voice.js",
"integrity": "sha384-a4oi+6HstA7vCZFrrX2qSEKq56mkDBJAkg485g8BRPeZb6C6QtJ+zxmjeBDg1/XT",
"elements": [
"rudder-voice"
],
"stage": "experimental",
"profiles": [
{
"profile": "rudder-voice",
"stage": "experimental",
"label": "Rudder — voice to text (desktop)",
"targets": [
"desktop"
],
"widget": {
"corner": "bottom-right"
},
"file": "rudder-voice.vue",
"description": "A DESKTOP-target plugin (rudder-voice.meta.json \"targets\": [\"desktop\"]). The speech",
"element": "rudder-voice",
"type": "single",
"isMap": false,
"iframeUrl": "./rudder-voice/rudder-voice/index.html",
"scriptUrl": "./rudder-voice/rudder-voice.js",
"embed": {
"iframe": "<iframe src=\"./rudder-voice/rudder-voice/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=\"./rudder-voice/rudder-voice.js\" integrity=\"sha384-a4oi+6HstA7vCZFrrX2qSEKq56mkDBJAkg485g8BRPeZb6C6QtJ+zxmjeBDg1/XT\" crossorigin=\"anonymous\"></script>\n<rudder-voice profile-config='{\"profile\":\"rudder-voice\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></rudder-voice>"
}
}
]
},
{
"bundle": "stack-demo",
"scriptUrl": "./stack-demo/stack-demo.js",
"output": "dx-maps/stack-demo/stack-demo.js",
"integrity": "sha384-Pg93Oyzao9WxHJMSJZPF798o1/Klk2ie7EDLTpVpQd0HaKCDDEHRDWUjt6OYwzqi",
"integrity": "sha384-zUkv1c/B+mQcflM2pwrczz+OpZxzl8JynKVV+5IyYiajtfQNU5RMJDJaF7nk/0oy",
"elements": [
"stack-demo"
],
@ -1267,7 +1233,7 @@
"scriptUrl": "./stack-demo/stack-demo.js",
"embed": {
"iframe": "<iframe src=\"./stack-demo/stack-demo/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=\"./stack-demo/stack-demo.js\" integrity=\"sha384-Pg93Oyzao9WxHJMSJZPF798o1/Klk2ie7EDLTpVpQd0HaKCDDEHRDWUjt6OYwzqi\" crossorigin=\"anonymous\"></script>\n<stack-demo profile-config='{\"profile\":\"stack-demo\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></stack-demo>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./stack-demo/stack-demo.js\" integrity=\"sha384-zUkv1c/B+mQcflM2pwrczz+OpZxzl8JynKVV+5IyYiajtfQNU5RMJDJaF7nk/0oy\" crossorigin=\"anonymous\"></script>\n<stack-demo profile-config='{\"profile\":\"stack-demo\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></stack-demo>"
},
"instances": [
{

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../doxa-research-map.js" integrity="sha384-11TPej/kB1nQL0lXCw/gOjTmj/VznycM5fmIfsMNah74SMwH8i+NwFSgbr5zoDZs" crossorigin="anonymous"></script>
<script src="../doxa-research-map.js" integrity="sha384-kC4KWUvDI3yN7HgjzUQVZTm2dYxTM0pRBlBxhFL9VQALYpLhmASkvjVthQ3dvbdC" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -33,7 +33,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../../doxa-research-map.js" integrity="sha384-11TPej/kB1nQL0lXCw/gOjTmj/VznycM5fmIfsMNah74SMwH8i+NwFSgbr5zoDZs" crossorigin="anonymous"></script>
<script src="../../doxa-research-map.js" integrity="sha384-kC4KWUvDI3yN7HgjzUQVZTm2dYxTM0pRBlBxhFL9VQALYpLhmASkvjVthQ3dvbdC" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,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-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,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-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,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-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja" 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

@ -32,7 +32,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-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,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-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -19,6 +19,6 @@
</head>
<body>
<plugin-studio tree-base="."></plugin-studio>
<script src="./plugin-studio/plugin-studio.js" integrity="sha384-NsG6VNbCUAcOh9jEf5+8DeaKFm7xRySJO8yIUiE0O0AQ3FhuILru4eBHgaXCPQUD" crossorigin="anonymous"></script>
<script src="./plugin-studio/plugin-studio.js" integrity="sha384-gC2dBaw7+rJERXYB7VaEtRlo1wIkVkA5CzKHyuid0QAmu0qPYmezGM9kdY9pPAOc" crossorigin="anonymous"></script>
</body>
</html>

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 25,
"profileCount": 37,
"bundleCount": 24,
"profileCount": 36,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life"
},
@ -229,7 +229,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-P15DN0neJhlrI9+vzXDozhY8RZHDsJkVnV3Yxun8ewNUNYFEyCdSETY1vBVu+6ee",
"integrity": "sha384-X3AEM/H4IPUfLH80IHRCRQHDmUwmuAAE+/ChGsH/3qdFV6KB7NhipFlWJtC3F/7t",
"elements": [
"dashboard-plus-grid"
],
@ -252,7 +252,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-P15DN0neJhlrI9+vzXDozhY8RZHDsJkVnV3Yxun8ewNUNYFEyCdSETY1vBVu+6ee\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-X3AEM/H4IPUfLH80IHRCRQHDmUwmuAAE+/ChGsH/3qdFV6KB7NhipFlWJtC3F/7t\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
},
"instances": [
{
@ -321,7 +321,7 @@
"bundle": "doxa-maps-spa",
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
"integrity": "sha384-CKJT8BCWZubQKLenstpsz19Kd87akZiO1SGu5+Ma3byRUdyXz0gcjrtmmkQxzw7i",
"integrity": "sha384-AmW0CbWRRmUZJW0me3crwWEAS4XiX93Z7KM9Vpu5rBKYXgPOJYTf4LEVp8oDG4li",
"elements": [
"doxa-maps-spa"
],
@ -349,7 +349,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-CKJT8BCWZubQKLenstpsz19Kd87akZiO1SGu5+Ma3byRUdyXz0gcjrtmmkQxzw7i\" 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-AmW0CbWRRmUZJW0me3crwWEAS4XiX93Z7KM9Vpu5rBKYXgPOJYTf4LEVp8oDG4li\" 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": [
{
@ -376,7 +376,7 @@
"bundle": "doxa-research-map",
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"output": "dx-maps/doxa-research-map/doxa-research-map.js",
"integrity": "sha384-11TPej/kB1nQL0lXCw/gOjTmj/VznycM5fmIfsMNah74SMwH8i+NwFSgbr5zoDZs",
"integrity": "sha384-kC4KWUvDI3yN7HgjzUQVZTm2dYxTM0pRBlBxhFL9VQALYpLhmASkvjVthQ3dvbdC",
"elements": [
"doxa-research-map"
],
@ -395,7 +395,7 @@
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-11TPej/kB1nQL0lXCw/gOjTmj/VznycM5fmIfsMNah74SMwH8i+NwFSgbr5zoDZs\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-kC4KWUvDI3yN7HgjzUQVZTm2dYxTM0pRBlBxhFL9VQALYpLhmASkvjVthQ3dvbdC\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
},
"instances": [
{
@ -737,7 +737,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "dx-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe",
"integrity": "sha384-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja",
"elements": [
"gospel-resources-map"
],
@ -759,7 +759,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-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe\" 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-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja\" 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>"
}
},
{
@ -774,7 +774,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-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe\" 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-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja\" 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>"
}
},
{
@ -790,7 +790,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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe\" 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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja\" 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>"
}
},
{
@ -805,7 +805,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-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe\" 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-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja\" 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>"
}
},
{
@ -821,7 +821,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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-lcwI2RPIjOWv3A0eY1Ej8qmWmdR0IYP3XKKFyZpwfE47av3KkP+f7D4ukhVw+qEe\" 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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-QVzftttPIYeK56Jo4vXx2mZ5lCxyapxR+YfayYABQ0ufom3AeWbw7cq8SpGSHeja\" 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>"
}
}
]
@ -956,7 +956,7 @@
"bundle": "plugin-studio",
"scriptUrl": "./plugin-studio/plugin-studio.js",
"output": "dx-maps/plugin-studio/plugin-studio.js",
"integrity": "sha384-NsG6VNbCUAcOh9jEf5+8DeaKFm7xRySJO8yIUiE0O0AQ3FhuILru4eBHgaXCPQUD",
"integrity": "sha384-gC2dBaw7+rJERXYB7VaEtRlo1wIkVkA5CzKHyuid0QAmu0qPYmezGM9kdY9pPAOc",
"elements": [
"plugin-studio"
],
@ -987,7 +987,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-NsG6VNbCUAcOh9jEf5+8DeaKFm7xRySJO8yIUiE0O0AQ3FhuILru4eBHgaXCPQUD\" 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-gC2dBaw7+rJERXYB7VaEtRlo1wIkVkA5CzKHyuid0QAmu0qPYmezGM9kdY9pPAOc\" 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": [
{
@ -1163,7 +1163,7 @@
"bundle": "research-map-plus-grid",
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
"output": "dx-maps/research-map-plus-grid/research-map-plus-grid.js",
"integrity": "sha384-malo2pC/K0/1Xi9kyDubh+4tY9HC56zcO/VsgppNf5c4AXvXG9RMxqVHYoFAoOr5",
"integrity": "sha384-sIu4F0HzdbO4/0FF1bUGfe7xj/8bjMklygdB3/KmaG8bHHXa300y9nPDYg1bCouM",
"elements": [
"research-map-plus-grid"
],
@ -1186,7 +1186,7 @@
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./research-map-plus-grid/research-map-plus-grid/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=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-malo2pC/K0/1Xi9kyDubh+4tY9HC56zcO/VsgppNf5c4AXvXG9RMxqVHYoFAoOr5\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-sIu4F0HzdbO4/0FF1bUGfe7xj/8bjMklygdB3/KmaG8bHHXa300y9nPDYg1bCouM\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
},
"instances": [
{
@ -1206,45 +1206,11 @@
}
]
},
{
"bundle": "rudder-voice",
"scriptUrl": "./rudder-voice/rudder-voice.js",
"output": "dx-maps/rudder-voice/rudder-voice.js",
"integrity": "sha384-a4oi+6HstA7vCZFrrX2qSEKq56mkDBJAkg485g8BRPeZb6C6QtJ+zxmjeBDg1/XT",
"elements": [
"rudder-voice"
],
"stage": "experimental",
"profiles": [
{
"profile": "rudder-voice",
"stage": "experimental",
"label": "Rudder — voice to text (desktop)",
"targets": [
"desktop"
],
"widget": {
"corner": "bottom-right"
},
"file": "rudder-voice.vue",
"description": "A DESKTOP-target plugin (rudder-voice.meta.json \"targets\": [\"desktop\"]). The speech",
"element": "rudder-voice",
"type": "single",
"isMap": false,
"iframeUrl": "./rudder-voice/rudder-voice/index.html",
"scriptUrl": "./rudder-voice/rudder-voice.js",
"embed": {
"iframe": "<iframe src=\"./rudder-voice/rudder-voice/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=\"./rudder-voice/rudder-voice.js\" integrity=\"sha384-a4oi+6HstA7vCZFrrX2qSEKq56mkDBJAkg485g8BRPeZb6C6QtJ+zxmjeBDg1/XT\" crossorigin=\"anonymous\"></script>\n<rudder-voice profile-config='{\"profile\":\"rudder-voice\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></rudder-voice>"
}
}
]
},
{
"bundle": "stack-demo",
"scriptUrl": "./stack-demo/stack-demo.js",
"output": "dx-maps/stack-demo/stack-demo.js",
"integrity": "sha384-Pg93Oyzao9WxHJMSJZPF798o1/Klk2ie7EDLTpVpQd0HaKCDDEHRDWUjt6OYwzqi",
"integrity": "sha384-zUkv1c/B+mQcflM2pwrczz+OpZxzl8JynKVV+5IyYiajtfQNU5RMJDJaF7nk/0oy",
"elements": [
"stack-demo"
],
@ -1267,7 +1233,7 @@
"scriptUrl": "./stack-demo/stack-demo.js",
"embed": {
"iframe": "<iframe src=\"./stack-demo/stack-demo/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=\"./stack-demo/stack-demo.js\" integrity=\"sha384-Pg93Oyzao9WxHJMSJZPF798o1/Klk2ie7EDLTpVpQd0HaKCDDEHRDWUjt6OYwzqi\" crossorigin=\"anonymous\"></script>\n<stack-demo profile-config='{\"profile\":\"stack-demo\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></stack-demo>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./stack-demo/stack-demo.js\" integrity=\"sha384-zUkv1c/B+mQcflM2pwrczz+OpZxzl8JynKVV+5IyYiajtfQNU5RMJDJaF7nk/0oy\" crossorigin=\"anonymous\"></script>\n<stack-demo profile-config='{\"profile\":\"stack-demo\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></stack-demo>"
},
"instances": [
{

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,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-NsG6VNbCUAcOh9jEf5+8DeaKFm7xRySJO8yIUiE0O0AQ3FhuILru4eBHgaXCPQUD" crossorigin="anonymous"></script>
<script src="../plugin-studio.js" integrity="sha384-gC2dBaw7+rJERXYB7VaEtRlo1wIkVkA5CzKHyuid0QAmu0qPYmezGM9kdY9pPAOc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,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-NsG6VNbCUAcOh9jEf5+8DeaKFm7xRySJO8yIUiE0O0AQ3FhuILru4eBHgaXCPQUD" crossorigin="anonymous"></script>
<script src="../../plugin-studio.js" integrity="sha384-gC2dBaw7+rJERXYB7VaEtRlo1wIkVkA5CzKHyuid0QAmu0qPYmezGM9kdY9pPAOc" 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

@ -43,7 +43,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <research-map-plus-grid> custom element. -->
<script src="../../research-map-plus-grid.js" integrity="sha384-malo2pC/K0/1Xi9kyDubh+4tY9HC56zcO/VsgppNf5c4AXvXG9RMxqVHYoFAoOr5" crossorigin="anonymous"></script>
<script src="../../research-map-plus-grid.js" integrity="sha384-sIu4F0HzdbO4/0FF1bUGfe7xj/8bjMklygdB3/KmaG8bHHXa300y9nPDYg1bCouM" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -43,7 +43,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <research-map-plus-grid> custom element. -->
<script src="../research-map-plus-grid.js" integrity="sha384-malo2pC/K0/1Xi9kyDubh+4tY9HC56zcO/VsgppNf5c4AXvXG9RMxqVHYoFAoOr5" crossorigin="anonymous"></script>
<script src="../research-map-plus-grid.js" integrity="sha384-sIu4F0HzdbO4/0FF1bUGfe7xj/8bjMklygdB3/KmaG8bHHXa300y9nPDYg1bCouM" 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

@ -1,214 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<title>rudder-voice · rudder-voice</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, rudder-voice { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }
</style>
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <rudder-voice> custom element. -->
<script src="../rudder-voice.js" integrity="sha384-a4oi+6HstA7vCZFrrX2qSEKq56mkDBJAkg485g8BRPeZb6C6QtJ+zxmjeBDg1/XT" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
// 2. ?tk= URL param — token carried in the shared link
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
// *endpoint* is resolved and fetched:
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
// means "read that global"; anything else is used as a URL — so both a global
// name and a plain URL are safe (no SyntaxError footgun).
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"profile":"rudder-voice","dataSource":"pray-tools","instanceId":"rudder-voice-rudder-voice","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
var TOKEN_FREE = true;
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
var NESTS_MAPS = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
function askToken() {
if (!status) return;
status.className = 'err';
status.innerHTML = 'map needs a Mapbox token — ' +
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
var go = document.getElementById('tkGo');
if (go) go.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (!v) return;
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
mount(v);
});
}
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
mounted element does not cover shows it through, because every layer above body is
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
unpainted canvas as first supposed. Clearing it here (the one place we already know the
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
function done() {
if (status && status.parentNode) { status.parentNode.removeChild(status); }
document.documentElement.style.background = '#f3f3f1';
document.body.style.background = '#f3f3f1';
}
function mount(token) {
if (!token && !TOKEN_FREE) { return askToken(); }
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement("rudder-voice");
el.setAttribute('profile-config', JSON.stringify(cfg));
slot.innerHTML = '';
slot.appendChild(el);
done();
} catch (e) { fail(e && e.message ? e.message : String(e)); }
}
function qp(name) {
try { return new URLSearchParams(location.search).get(name) || ''; }
catch (e) { return ''; }
}
function extractToken(data) {
if (typeof data === 'string') return data.trim();
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
return '';
}
// Rungs 1-3b: an actual token → mount directly, no fetch.
// 2b = localStorage: the paste-once store shared with the staging pages
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
var stored = '';
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
var urlTk = qp('tk');
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN;
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins.
// 'auto' = Mapbox when a token is available (a rung above, a host-set window.MAP_TOKEN, or a token
// endpoint to ask), otherwise MapLibre, keyless: no token prompt, no request to Mapbox. The
// element decides the same way from the token this page hands it, so the two cannot disagree.
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
if (TOKEN_FREE) {
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
mount('');
// A token-free composite with no token anywhere: its content renders, but its nested maps
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
var bar = document.createElement('div');
bar.id = 'status';
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
document.body.appendChild(bar);
status = bar;
askToken();
var goBtn = document.getElementById('tkGo');
if (goBtn) goBtn.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (v) { location.reload(); }
});
}
return;
}
if (directToken) { return mount(directToken); }
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
// A single hard-coded pick used to kill the page when its one url 404'd or was
// blocked by CORS; a shared link must work wherever the tree is hosted.
var candidates = [];
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
addCandidate(window.MAP_TOKEN_URL);
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
addCandidate(DEFAULT_TOKEN_URL);
function fetchToken(i, errors) {
if (i >= candidates.length) {
// Every endpoint failed. Under 'auto' that means no token: draw with MapLibre, keyless.
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
askToken();
if (status) {
var d = document.createElement('div');
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
status.insertBefore(d, status.firstChild);
}
return;
}
var url = candidates[i];
return fetch(url)
.then(function (r) {
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) {
var tk = extractToken(data);
if (!tk) throw new Error(url + ' → no token in response');
mount(tk);
})
.catch(function (e) {
errors.push(e && e.message ? e.message : String(e));
return fetchToken(i + 1, errors);
});
}
fetchToken(0, []);
})();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View file

@ -43,7 +43,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <stack-demo> custom element. -->
<script src="../stack-demo.js" integrity="sha384-Pg93Oyzao9WxHJMSJZPF798o1/Klk2ie7EDLTpVpQd0HaKCDDEHRDWUjt6OYwzqi" crossorigin="anonymous"></script>
<script src="../stack-demo.js" integrity="sha384-zUkv1c/B+mQcflM2pwrczz+OpZxzl8JynKVV+5IyYiajtfQNU5RMJDJaF7nk/0oy" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -43,7 +43,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <stack-demo> custom element. -->
<script src="../../stack-demo.js" integrity="sha384-Pg93Oyzao9WxHJMSJZPF798o1/Klk2ie7EDLTpVpQd0HaKCDDEHRDWUjt6OYwzqi" crossorigin="anonymous"></script>
<script src="../../stack-demo.js" integrity="sha384-zUkv1c/B+mQcflM2pwrczz+OpZxzl8JynKVV+5IyYiajtfQNU5RMJDJaF7nk/0oy" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -1,7 +1,7 @@
# Capabilities and targets
Two separate questions about a plugin: **how composed is it** (capability 1–5) and **where can it
run** (`targets`). Only the first is enforced.
run** (`targets`). Both are enforced: `check:capability-derived` and `check:targets`.
## Capability 1–5
@ -52,14 +52,24 @@ Worth knowing because they will bite you the same way:
A `<profile>.meta.json` may carry `"targets"`, naming where the plugin is meant to run:
```json
{ "type": "single", "label": "Rudder", "targets": ["desktop"], "backend": "backend/rudder-engine" }
{ "type": "single", "label": "Rudder", "targets": ["desktop", "widget"], "widget": { "corner": "bottom-right" }, "backend": "backend/rudder-engine" }
```
**Omitting it means "everywhere", and that is the right default for almost every plugin.** The
whole point of the build is one folder that runs as a web embed, a mobile app and a desktop app.
Known values: **`web`** (a published map or tool), **`mobile`**, **`desktop`** (a desktop app, `bun run desktop:build`),
**`widget`** (the Plugin Studio's corner; needs a `"widget": { "corner" }`). `internal/plugin-targets.mjs`
holds the one reading every tool shares, and `bun run check:targets` rejects an unknown value, an empty list,
a widget target without a corner (or a corner without the target), and a `backend` naming a missing folder.
**Declaring it has a real consequence**, so declare it only when you mean it:
- **A plugin whose targets leave out `web` is not built for the web at all.** `bun run build` (and the
`/cdn` export) skip it, `generate-tree` writes no leaf page or manifest entry for it, and `check:targets`
fails if one appears in `public/`. The dev server still discovers it from source, so a `widget` plugin
keeps its Studio corner on `bun run dev`.
- `internal/profile-meta.mjs:28` reads the field (string entries only) and `:36` carries it into
the manifest, so every consumer of the manifest sees it.
- **The Research Gallery skips a bundle whose every profile is a widget or lacks the `web`
@ -90,7 +100,7 @@ The three outputs, all from the same built folder:
|---|---|---|
| Web folder | `public/dx-maps/`, embeddable by iframe or script tag from any host | copy the folder; snippets in `manifest.json` |
| Mobile app | the same folder wrapped by Capacitor (`capacitor.config.json` `webDir`) | `bun run phone` |
| Desktop app | the same folder in Tauri (`deployment/src-tauri/`), a proof of concept | `bun run tauri:build` |
| Desktop app | ONE plugin whose targets include `desktop`, in Tauri (`deployment/src-tauri/`), with its `backend` as a sidecar | `bun run desktop:build <plugin>` (see `documentation/HOWTO-build-the-desktop-app.md`) |
WordPress (`deployment/wordpress/`) embeds the web folder's snippets; it is experimental.