Merge remote-tracking branch 'origin/main' into rega/research-gallery-embed

# Conflicts:
#	public/doxa-maps/index.html
This commit is contained in:
doxa-maps-88655c70 2026-09-25 04:23:12 +00:00
commit 47ced6d6b2
128 changed files with 8895 additions and 1478 deletions

View file

@ -137,11 +137,17 @@ every build:
A shortcode + block plugin in `deployment/wordpress/`; its contract test passes
(`bun run check:wp-embed`). Proven, not production-ready.
![Method 4 — WordPress / Disciple Tools plugin, proof of concept](documentation/readme-images/deploy4-wordpress.png)
### 5. Desktop app (Tauri) — 🧪 proof of concept
A desktop binary and `.deb` build from `deployment/src-tauri/` (`bun run check:tauri`). Proven,
not production-ready.
![Method 5 — desktop app (Tauri), proof of concept](documentation/readme-images/deploy5-desktop.png)
<sub>WordPress and Tauri marks from [Simple Icons](https://simpleicons.org) (CC0); the trademarks belong to their owners.</sub>
> **Mapbox token:** the dev server bakes one in; built pages take `?tk=pk.…`, a pasted token, or a
> token endpoint. Or skip Mapbox entirely: `provider: "maplibre"` on any instance renders on MapLibre
> with no key. Every command and prerequisite:

File diff suppressed because one or more lines are too long

View file

@ -911,7 +911,7 @@
"pct_with_nothing": 9.8,
"people_with_nothing": 146600,
"groups_with_scripture": 19,
"groups_with_film": 24,
"groups_with_film": 23,
"band": "partial",
"growing_unreached": 7,
"deaths_day": 14.5,
@ -1121,7 +1121,7 @@
"pct_with_nothing": 10.7,
"people_with_nothing": 98950,
"groups_with_scripture": 21,
"groups_with_film": 25,
"groups_with_film": 24,
"band": "partial",
"growing_unreached": 0,
"deaths_day": 1.1,

View file

@ -337,7 +337,7 @@
"no_resource": 4,
"pop_no_resource": 146600,
"scripture": 19,
"film": 24,
"film": 23,
"growing_unreached": 7,
"deaths_day": 14.5,
"growth": 2.4,
@ -2241,7 +2241,7 @@
"no_resource": 3,
"pop_no_resource": 98950,
"scripture": 21,
"film": 25,
"film": 24,
"growing_unreached": 0,
"deaths_day": 1.1,
"growth": 2.12,

View file

@ -1,7 +1,7 @@
{
"meta": {
"title": "Gospel resource link verification",
"generated": "2026-09-25T02:30:53.812Z",
"generated": "2026-09-25T03:43:08.467Z",
"method": "HEAD, redirects followed. verified = HTTP 200. broken = 404/410. unknown = unreachable, an ambiguous status, or a URL shape that cannot be checked by fetch.",
"scope": "Every URL in data/tables/people-group-pins.json, deduplicated.",
"states": {
@ -96,12 +96,12 @@
]
},
"summary": {
"records": 8513,
"checkedThisPass": 40,
"records": 8516,
"checkedThisPass": 3,
"carriedForward": 74,
"pruned": 0,
"broken": 74,
"verified": 8434,
"verified": 8437,
"unknown": 5
},
"byHost": {
@ -119,11 +119,11 @@
"verified": 3
},
"jesusfilm.org": {
"verified": 2048,
"verified": 2050,
"broken": 1
},
"arc.gt": {
"verified": 32,
"verified": 33,
"broken": 1
},
"zume.training": {
@ -42711,6 +42711,21 @@
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/2235?language=Mehinaku (the site answers 200 for any path)",
"at": "2026-09-25T02:30:53.528Z"
},
"https://jesusfilm.org/watch/magdalena-director-cut/mazanderani": {
"state": "verified",
"detail": "HTTP 200 (via https://www.jesusfilm.org/watch/magdalena-director-cut.html/mazanderani.html)",
"at": "2026-09-25T03:43:07.735Z"
},
"https://jesusfilm.org/watch/magdalena-director-cut/kachhi-gujerati": {
"state": "verified",
"detail": "HTTP 200 (via https://www.jesusfilm.org/watch/magdalena-director-cut.html/kachhi-gujerati.html)",
"at": "2026-09-25T03:43:07.768Z"
},
"https://arc.gt/durve": {
"state": "verified",
"detail": "HTTP 200 (via https://www.jesusfilm.org/watch/the-savior.html/arabic-najdi.html)",
"at": "2026-09-25T03:43:07.944Z"
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -260,7 +260,7 @@
"iso639_3": "ars",
"population": 12970000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 24.56,
@ -1562,7 +1562,7 @@
"iso639_3": "ars",
"population": 1990000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 31.77,
@ -2066,7 +2066,7 @@
"iso639_3": "ars",
"population": 1485000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 34.54,
@ -2122,7 +2122,7 @@
"iso639_3": "ars",
"population": 1440000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 24.69,
@ -2248,7 +2248,7 @@
"iso639_3": "ars",
"population": 1365000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 32.5,
@ -6014,7 +6014,7 @@
"iso639_3": "ars",
"population": 382000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 29.34,
@ -14288,7 +14288,7 @@
"iso639_3": "ars",
"population": 79000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 34.09,
@ -23752,7 +23752,7 @@
"iso639_3": "ars",
"population": 20000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 45.44,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 671 KiB

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View file

@ -17,30 +17,30 @@
},
"cap3-monolithic-research": {
"bundle": "doxa-research-map",
"sha256": "69ad4bce104fa445de704e7a308913e4e4a19601d54c4d43e85bcc406197dfdb"
"sha256": "56845dc5bd33198a7dd8fb42895700f21b4bc1c3b7f98f57832e8373c7782a83"
},
"cap4-upg100-list": {
"bundle": "my-upg-100-list",
"sha256": "31c438fa0c17075b3de9ec45ac72ca345d4cd254778ac871b862627bc1063445"
"sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
},
"cap4-upg100-mfe": {
"bundle": "my-upg-100-list",
"sha256": "31c438fa0c17075b3de9ec45ac72ca345d4cd254778ac871b862627bc1063445"
"sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
},
"cap4-upg100-poster-wall": {
"bundle": "my-upg-100-list",
"sha256": "31c438fa0c17075b3de9ec45ac72ca345d4cd254778ac871b862627bc1063445"
"sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
},
"cap5-doxa-maps-spa": {
"bundle": "doxa-maps-spa",
"sha256": "c7b1aad1e3bd4bbe936858f2159c566c1ef2e24fc9636a3446b1e10723d86502"
"sha256": "428e3741bd0bdbbbbd8ddbfca51dd0bacd471d5387c33f5869f2d121bf5d40c1"
},
"cap5-plugin-studio": {
"bundle": "plugin-studio",
"sha256": "c3d32d94d2df407fc35cea91faf0d46670abf52a63d37000486760361a588a5c"
"sha256": "951dcb60f59ce134fca591376cd65f54750bcb1ed50366f569be4cc863af92d2"
},
"deploy2-mobile-upg100": {
"bundle": "my-upg-100-list",
"sha256": "31c438fa0c17075b3de9ec45ac72ca345d4cd254778ac871b862627bc1063445"
"sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
}
}

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
<rect width="1200" height="630" rx="28" fill="#f8fafc"/>
<rect x="1.5" y="1.5" width="1197" height="627" rx="27" fill="none" stroke="#e2e8f0" stroke-width="3"/>
<g transform="translate(110,175) scale(11.5)"><path d="M21.469 6.825c.84 1.537 1.318 3.3 1.318 5.175 0 3.979-2.156 7.456-5.363 9.325l3.295-9.527c.615-1.54.82-2.771.82-3.864 0-.405-.026-.78-.07-1.11m-7.981.105c.647-.03 1.232-.105 1.232-.105.582-.075.514-.93-.067-.899 0 0-1.755.135-2.88.135-1.064 0-2.85-.15-2.85-.15-.585-.03-.661.855-.075.885 0 0 .54.061 1.125.09l1.68 4.605-2.37 7.08L5.354 6.9c.649-.03 1.234-.1 1.234-.1.585-.075.516-.93-.065-.896 0 0-1.746.138-2.874.138-.2 0-.438-.008-.69-.015C4.911 3.15 8.235 1.215 12 1.215c2.809 0 5.365 1.072 7.286 2.833-.046-.003-.091-.009-.141-.009-1.06 0-1.812.923-1.812 1.914 0 .89.513 1.643 1.06 2.531.411.72.89 1.643.89 2.977 0 .915-.354 1.994-.821 3.479l-1.075 3.585-3.9-11.61.001.014zM12 22.784c-1.059 0-2.081-.153-3.048-.437l3.237-9.406 3.315 9.087c.024.053.05.101.078.149-1.12.393-2.325.609-3.582.609M1.211 12c0-1.564.336-3.05.935-4.39L7.29 21.709C3.694 19.96 1.212 16.271 1.211 12M12 0C5.385 0 0 5.385 0 12s5.385 12 12 12 12-5.385 12-12S18.615 0 12 0" fill="#21759B"/></g>
<text x="480" y="250" font-size="44" font-weight="800" fill="#0f172a">WordPress / Disciple Tools</text>
<text x="480" y="315" font-size="27" fill="#475569">A plugin for WordPress sites, including Disciple Tools:</text>
<text x="480" y="355" font-size="27" fill="#475569">a [doxa_map] shortcode and a block editor block.</text>
<rect x="480" y="400" width="360" height="62" rx="31" fill="#fef3c7" stroke="#d97706" stroke-width="2.5"/>
<text x="660" y="441" text-anchor="middle" font-size="26" font-weight="800" fill="#92400e">PROOF OF CONCEPT</text>
<text x="480" y="510" font-size="21" fill="#64748b">Contract-tested and working — not yet production-ready.</text>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
<rect width="1200" height="630" rx="28" fill="#f8fafc"/>
<rect x="1.5" y="1.5" width="1197" height="627" rx="27" fill="none" stroke="#e2e8f0" stroke-width="3"/>
<g transform="translate(110,175) scale(11.5)"><path d="M13.912 0a8.72 8.72 0 0 0-8.308 6.139c1.05-.515 2.18-.845 3.342-.976 2.415-3.363 7.4-3.412 9.88-.097 2.48 3.315 1.025 8.084-2.883 9.45a6.131 6.131 0 0 1-.3 2.762 8.72 8.72 0 0 0 3.01-1.225A8.72 8.72 0 0 0 13.913 0zm.082 6.451a2.284 2.284 0 1 0-.15 4.566 2.284 2.284 0 0 0 .15-4.566zm-5.629.27a8.72 8.72 0 0 0-3.031 1.235 8.72 8.72 0 1 0 13.06 9.9131 10.173 10.174 0 0 1-3.343.965 6.125 6.125 0 1 1-7.028-9.343 6.114 6.115 0 0 1 .342-2.772zm1.713 6.27a2.284 2.284 0 0 0-2.284 2.283 2.284 2.284 0 0 0 2.284 2.284 2.284 2.284 0 0 0 2.284-2.284 2.284 2.284 0 0 0-2.284-2.284z" fill="#24C8DB"/></g>
<text x="480" y="250" font-size="54" font-weight="800" fill="#0f172a">Desktop app (Tauri)</text>
<text x="480" y="315" font-size="27" fill="#475569">The same map tree wrapped as a desktop app:</text>
<text x="480" y="355" font-size="27" fill="#475569">a native binary and a .deb installer.</text>
<rect x="480" y="400" width="360" height="62" rx="31" fill="#fef3c7" stroke="#d97706" stroke-width="2.5"/>
<text x="660" y="441" text-anchor="middle" font-size="26" font-weight="800" fill="#92400e">PROOF OF CONCEPT</text>
<text x="480" y="510" font-size="21" fill="#64748b">Contract-tested and working — not yet production-ready.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1,12 +1,25 @@
# Reference
Look-up tables and contracts for doxa-maps. For learning, see the
**[tutorial](./tutorial.md)**; for tasks, **[how-to.md](./how-to.md)**; for the *why*,
**[why.md](./why.md)**; for the short "what it is", **[explanation.md](./explanation.md)**.
**Look-up tables and contracts for doxa-maps: one shared library, many independent embeddable bundles.**
Each bundle is one self-contained `.js` file that registers a custom HTML element (`<doxa-map>`,
`<doxa-research-map>`, …) you can drop on any web page. For learning, see the
**[tutorial](./tutorial.md)**; for tasks, **[how-to.md](./how-to.md)**; for the *why*, **[why.md](./why.md)**.
`doxa-maps` is a Vite bundler: **one shared library, N independent embeddable bundles.** Each
bundle is a single self-contained `.js` file that registers a custom HTML element
(`<doxa-map>`, `<doxa-research-map>`, …) you can drop on any web page.
## Quick reference — "I want to…"
| I want to… | Do this |
|---|---|
| **Add a new map** | copy `plugin-designs/template-bundle/` → rename the folder and the tag in `index.js` |
| **Add a screen to an existing bundle** | drop a `.vue` flat in that bundle's folder; request it via `profile-config.profile` |
| **Fix a legend or control for ALL maps** | edit it once in `library/` (`@map`); rebuild |
| **Add a colour strategy** | a new file in `@map/colors/` (shared) or the bundle's `src/colors/` (map-private) |
| **See my change while working** | `bun run dev` → http://localhost:5173/ (the Plugin Studio) |
| **Build everything** | `BUILD_ALLOW=1 bun run build` → `public/doxa-maps/<bundle>/<bundle>.js` |
| **Build just one bundle** | `BUNDLE=<name> bun x vite build` |
| **See a change on the served site** | rebuild, then refresh (the dev server never writes `public/`) |
| **Repoint the API without a rebuild** | set `window.MAP_APP_API_URL` before the bundle loads |
| **Repoint the Mapbox token** | pass `tk` in `profile-config`, or set `window.MAP_TOKEN_URL` / build-config `tokenUrl` |
| **Run a map without a Mapbox key** | `"provider": "maplibre"` in `profile-config` (or `?provider=maplibre`) |
---
@ -14,53 +27,32 @@ bundle is a single self-contained `.js` file that registers a custom HTML elemen
| Zone | Path | Alias | License | You edit it… |
|---|---|---|---|---|
| **Shared library** | `library/` | `@map` | Apache-2.0 | …once; the fix reaches every bundle on next build |
| **Your app-profile** | `plugin-designs/<name>/` | — | yours | …freely; nothing here leaks to other maps |
| **The build machine** | `internal/` | — | Apache-2.0 | …never — it's the bundler's own scripts |
| **Shared library** | `library/` | `@map` | Apache-2.0 | …rarely, and only for reusable, parameterized code; a fix reaches every bundle on the next build |
| **Your plugin** | `plugin-designs/<name>/` | — | yours | …freely; nothing here leaks to other maps |
| **The build machine** | `internal/` | — | Apache-2.0 | …not as a map author — it is the bundler's own scripts and gates |
**Terminology.** "Profile" is used at two levels: a **bundle / app-profile** is a folder under
`plugin-designs/`; a **profile screen** is one `.vue` file sitting **flat** in that folder (no
`profiles/` subfolder — the entry globs `./*.vue`). A bundle can hold several screens; the host
picks which to render with the `profile` field of `profile-config`.
**Terminology.** An **element** is a custom HTML tag this repo defines, such as `<doxa-map>`; each pasted tag
is one live *instance*. A **bundle** is a folder under `plugin-designs/`. A **profile** (app profile) is one
`.vue` sitting **flat** in that folder; a bundle can hold several, and the host picks one with the `profile`
field of `profile-config`. In class-and-object terms: the element is the class, each tag an instance, the
profile the screen that instance loads.
**Two words first.** An **element** is a custom HTML tag this repo invents, such as `<doxa-map>`: the browser
learns it once, then you can paste it into any page as often as you like; each pasted tag is one live copy, an
*instance* (people also say "web component"). An **app profile** (this repo also says *mounting file*) is one
flat `.vue` in `plugin-designs/<bundle>/` that defines a whole map app — its main component; the settings on the
tag (`profile-config`) name which app profile to load and the shared `ProfileLoader` picks it. In class-and-object
terms: the element class is the class, each tag is an instance, the app profile is the screen that instance loads.
Most repos need one such file per app; doxa-maps builds hundreds of maps from one shared `library/`, so every map
gets its own app profile — and those files come in five shapes:
## The five capabilities (profile types)
**Profile types — the ladder**, smallest first (head words are established terms; the `-element` compounds are
this repo's own; the coined names stay as a.k.a.):
`single-element` → `preset-element` → `multi-view-element` → `composite-element` → `app-shell`.
Smallest first. Which bundle is the current example of each: **[reference-capability-exemplars.md](./reference-capability-exemplars.md)**
(generated from `manifest.json`; cite it rather than memory).
| Type (a.k.a.) | Machine key | What it is | How the build recognises it |
|---|---|---|---|
| **single-element** (single) | `single` | one app profile, one map — paste its tag anywhere, set it up in `profile-config`; the default | a flat `.vue` with no `<profile>.meta.json` and no `*.instances.json` in the folder |
| **preset-element** (parameterized) | `parameterized` | one app profile + a small table of named presets; each row is a ready-made map with its settings baked in, with its **own embed page** and sidebar entry — same code, same tag | the bundle ships `<name>.instances.json` — `{ "instances": [{ "id", "title", "props" }] }` or a bare array; the build writes one embed page per row at `<bundle>/<profile>/<id>/index.html` |
| **multi-view-element** (monolithic) | `monolithic` | one app profile holding several screens you switch between — tabs or steps — in one `.vue`, shown one screen at a time | declared: `<profile>.meta.json` → `{ "type": "monolithic" }` (or `"multi-view-element"`) |
| **composite-element** (nested-elements-mfe) | `nested-elements-mfe` | an app profile whose template places other app profiles' maps together (stacked or side by side, e.g. a map + a poster wall) and ships the set as one new element — one tag embeds it all | declared: `<profile>.meta.json` → `{ "type": "nested-elements-mfe", "composes": [ … ] }` (or `"composite-element"`) |
| **app-shell** (nested-mfe-spa) | `nested-mfe-spa` | the outer frame — sidebar, header, navigation — that loads any of the other kinds and switches between them without a reload; the top rung, the full doxa-maps app | a compiled Vue custom element **today** — `plugin-designs/doxa-maps-spa/doxa-maps-spa.vue`, built via `defineCustomElement` like every other rung (its shell layout is `library/layouts/app-wrapper-material/`). `app-wrapper-material/index.html` is now only the dev-time plugin-gallery scaffold, not the shipped shell |
| # | Type (a.k.a.) | Machine key | What it is | How the build recognises it |
|---|---|---|---|---|
| 1 | **single-element** (single) | `single` | one profile, one map; the default | a flat `.vue` with no `<profile>.meta.json` and no `*.instances.json` in the folder |
| 2 | **preset-element** (prop-table) | `parameterized` | one profile + a table of named presets; each row is a ready-made map with its own embed page | the bundle ships `<name>.instances.json` — `{ "instances": [{ "id", "title", "props" }] }`; one page per row at `<bundle>/<profile>/<id>/index.html` |
| 3 | **multi-view-element** (monolithic) | `monolithic` | one profile holding several screens (tabs or steps), one shown at a time | declared: `<profile>.meta.json` → `{ "type": "monolithic" }` |
| 4 | **composite-element** (nested elements) | `nested-elements-mfe` | a profile that places other plugins together and ships the set as one new element | declared: `<profile>.meta.json` → `{ "type": "nested-elements-mfe", "composes": [ … ] }` |
| 5 | **app-shell** (nested SPAs) | `nested-mfe-spa` | the outer frame — rail, header, navigation — that mounts any of the others; e.g. the Research Gallery (`doxa-maps-spa`) and the Plugin Studio | declared the same way; a compiled Vue custom element like every other rung |
> **Which bundle is the capability-N example *right now*?** See
> **[reference-capability-exemplars.md](./reference-capability-exemplars.md)** — generated from
> `manifest.json` by `bun run capability-exemplars` and gated by `bun run check:capability-exemplars`.
> **Cite that file; never cite memory.** This one question has been answered wrongly in three separate
> places — a gate, this README's capability captions, and the `/plugin-designer` skill — because a
> folder's apparent simplicity does not decide its tier. `doxa-vision-card` looks like the lightest
> thing in the repo and is **capability 2**, since it ships an `*.instances.json`.
Composite kinds are always **declared** in `<profile>.meta.json` (`{ "type": …, "label": … }`, either spelling —
see `TYPE_ALIASES` in `internal/plugin-gallery/discover.mjs`) — the build never guesses them from source (tag-scanning
produced false positives). Discovery is one pure module,
`internal/plugin-gallery/discover.mjs`; `bun run dev` renders its result as the **plugin gallery** at `/__gallery`
(the same generated page the build writes to `public/__gallery/index.html` — see "Plugin gallery" under Build
scripts: a list grouped by bundle, every map deep-linkable at `#/<bundle>/<profile>[/<instance>]`), and
`bun run smoke:gallery` proves every element actually boots **inside that archived gallery page** — not in the
studio, which `bun run smoke:studio` covers in ~5s. **`/` itself serves the plugin studio** in both trees, not
the gallery.
**Tiers 3–5 are always declared, never guessed** (either spelling; see `TYPE_ALIASES` in
`internal/plugin-gallery/discover.mjs`). A folder's apparent simplicity does not decide its tier: an
`*.instances.json` makes a bundle capability 2 however light it looks.
---
@ -73,27 +65,28 @@ doxa-maps/
│ ├── ProfileLoader.vue ← the ONE component that reads profile-config & loads a screen
│ ├── components/ ← map UI: legends/, map-controls/, poster/, dashboards/, drawers
│ ├── composables/ ← useMapInstance, useMapData, useMapLayers, … (+ legends/, poster/)
│ ├── layouts/ ← VerticalTabsLayout, StackLayout — how composites compose
│ ├── api/ ← DataSourceManager (csv | api | rest-api) + sources.json
│ ├── colors/ ← one color strategy per file, auto-registered by _registry.js
│ ├── colors/ ← one colour strategy per file, auto-registered by _registry.js
│ ├── constants/ ← never-change values: mapDefaults.js, zoom.js, poster*.js
│ ├── i18n/locales/ ← 11 base locales: ar de en es fr hi it pt ro ru zh
│ ├── stores/ ← Pinia: dataStore, mapStore, uiStore (per-instance)
│ └── utils/ ← pure helpers (geoUtils, clustering)
│
├── plugin-designs/ ← ALL bundles live here (each subfolder = 1 bundle)
├── plugin-designs/ ← ALL bundles live here (each subfolder = 1 bundle)
│ ├── <bundle>/
│ │ ├── index.js ← bundle ENTRY — globs ./*.vue, registers the web component
│ │ ├── index.html ← Vite html entry = the dev STAGING page
│ │ ├── index.js ← bundle ENTRY — registers the web component
│ │ ├── index.html ← the dev staging page (the build requires it)
│ │ ├── <profile>.vue ← 1..N FLAT profile screens (no profiles/ subfolder)
│ │ └── src/ ← everything else the map needs (its own colors/, api/, …)
│ │ └── src/ ← everything else only this map needs
│ └── template-bundle/ ← minimal copy-me reference bundle
│ (folders starting with _ or . are SKIPPED by the build)
│
├── internal/ ← the bundler's own build scripts — never edited by map builders
├── deployment/capacitor/ ← native projects (android/, ios/)
├── internal/ ← the build generator and the verification gates
├── deployment/ ← capacitor/ (mobile) · wordpress/ · src-tauri/ (desktop)
├── public/ ← THE SERVED WEB-ROOT, committed:
│ ├── index.html the app shell
│ ├── doxa-maps/<bundle>/ built bundles + manifest.json + staging HTML
│ ├── index.html the Plugin Studio host page
│ ├── doxa-maps/<bundle>/ built bundles + manifest.json + leaf pages
│ └── assets/ posters & data the maps fetch at runtime
├── vite.config.js ← multi-entry: discovers plugin-designs/<name>/ folders
└── vite.doxa-maps-build-config.json ← output tree name + copy destinations + token settings
@ -103,56 +96,41 @@ doxa-maps/
## The `profile-config` runtime contract
A bundle exposes ONE attribute, `profile-config` — a JSON string parsed by
`ProfileLoader.vue` at runtime. It is the entire wiring between host page and map, so the same
built bundle re-points to different data/colors/language with **no rebuild**.
**A bundle exposes ONE attribute, `profile-config`** — a JSON string `ProfileLoader.vue` parses at runtime.
It is the entire wiring between host page and map, so the same built bundle re-points to different data,
colours or language with **no rebuild**.
```html
<doxa-map profile-config='{
"profile": "doxa-simple-map", // which flat .vue screen to render (required)
"tk": "pk.eyJ...", // Mapbox token (required; host-supplied, never baked)
"tk": "pk.eyJ...", // Mapbox token (host-supplied)
"instanceId": "my-map", // optional; isolates window events between embeds
"dataSource": "pray-tools", // or { "type":"rest-api","endpoint":"https://…" }
"colorSet": "default",
"locale": "en",
"provider": "mapbox", // map engine: "mapbox" (default) | "maplibre" (preview);
// also flippable per page via ?provider= or window.MAP_PROVIDER
"provider": "mapbox", // map engine: "mapbox" (default) | "maplibre" (no key needed);
// also per page via ?provider= or window.MAP_PROVIDER
"assetsBase": "/assets", // where tables/, boundaries/, previews/ are served (see below)
"shareUrl": "https://…", // optional; the WHOLE url the Share button links/embeds (beats everything)
"embedBaseUrl": "https://…/doxa-maps", // optional; served tree root the Share button joins embed pages to
"shareUrl": "https://…", // optional; the WHOLE url the Share button links/embeds
"embedBaseUrl": "https://…/doxa-maps", // optional; tree root the Share button joins embed pages to
"tabs": [ /* parameterized screens read this */ ]
}'></doxa-map>
```
- `assetsBase` — data-driven profiles (connectivity-map, resources-map, cities-map, the poster walls
and data tables) fetch `${assetsBase}/tables/*.json` and `${assetsBase}/boundaries/*.geojson`
**before** they create their map. Unset, it defaults to `./assets` **relative to the host page**;
if the host doesn't serve that path the fetch gets an HTML fallback, `JSON.parse` throws, and the
map is never created (no console error, only the profile's status line). The generated pages and
the shell set it for you (`../../../assets` or the absolute `assetsBaseUrl` from the build config);
any other host must point it at a copy of `public/assets/`.
| Field | What to know |
|---|---|
| `profile` | **must** match a flat `.vue` filename in the bundle; a mismatch shows an inline "Profile not found" box listing the names |
| `tk` | supplied at runtime; dev reads `.env`'s `VITE_MAPBOX_TOKEN` |
| `instanceId` | scopes cross-instance window events so several embeds never interfere |
| `dataSource` | handed to `DataSourceManager` (`csv` · `api` · `rest-api`) |
| `assetsBase` | data-driven profiles fetch `${assetsBase}/tables/*.json` and `${assetsBase}/boundaries/*.geojson` **before** creating their map. Unset, it is `./assets` relative to the host page; if the host doesn't serve that path the map is never created. Generated pages set it for you; any other host must point it at a copy of `public/assets/` |
| `shareUrl` / `embedBaseUrl` | the **Share ladder** (`library/utils/embedBase.js`): `shareUrl` > the profile's embed page joined to `embedBaseUrl` > `window.MAP_EMBED_BASE_URL` > the root derived from the bundle's own script URL (so a dropped tree works on any host) > the page's own location |
- **Share ladder** (`library/utils/embedBase.js`) — what the Share button links and iframes:
`shareUrl` (a whole url, e.g. the plugin gallery's own deep link) > the profile's **tree-relative**
embed page (`<bundle>/<profile>/index.html`, the `embedPath` prop or `shareEmbedPath`) joined to an
embed base — `embedBaseUrl` > `window.MAP_EMBED_BASE_URL` > the root the built bundle derives from
its **own script url** (two segments above `<bundle>/<bundle>.js`, so a dropped tree works on any
host/CDN with no config) > the page's own location. Never hardcode `/js/doxa-maps/…` in a profile.
- `profile` **must** match a flat `.vue` filename in the bundle. Mismatch → an inline
"Profile not found" error box listing the available names.
- `tk` is supplied at runtime so the token is never compiled in (dev uses `.env`'s
`VITE_MAPBOX_TOKEN`).
- `instanceId` scopes cross-instance window events so multiple embeds never interfere.
- `dataSource` is handed to `DataSourceManager` (`csv` | `api` | `rest-api`).
- `ProfileLoader` `provide()`s to descendants: `mapboxToken`, `dataSource` (both **computed
refs** — `unref` before use), `colorSet`, `profileConfig`, `instanceId`, `lang`, and the
instance-scoped Pinia stores `uiStore` / `mapStore` / `dataStore`. Descendants **inject**;
never call `useXxxStore()` directly (it would cross-bleed between embeds).
Each bundle registers a **shadow-DOM** web component via `defineCustomElement`, with its own
ShadowRoot, styles, Pinia, and i18n. `customElements.define()` is one-shot per tag name, so
each bundle registers a **distinct** tag.
**What descendants receive.** `ProfileLoader` `provide()`s `mapboxToken` and `dataSource` (both **computed
refs** — `unref` before use), `colorSet`, `profileConfig`, `instanceId`, `lang`, and the instance-scoped Pinia
stores `uiStore` / `mapStore` / `dataStore`. Descendants **inject**; never call `useXxxStore()` directly (it
would bleed between embeds). Each bundle registers a **shadow-DOM** element with its own styles, Pinia and
i18n, under a **distinct** tag, since `customElements.define()` is one-shot per name.
---
@ -160,156 +138,69 @@ each bundle registers a **distinct** tag.
| Script | What it does |
|---|---|
| `bun run dev` | Vite dev server + HMR (default `http://localhost:5173`, or the next free port — read the terminal). `/` is the **plugin gallery** of every element — `internal/generate-gallery.mjs` in mode `dev` over `internal/plugin-gallery/{discover,dev-plugin}.mjs`; every bundle is also served at its own html path. Dev-only routes: `/__plugin-gallery/manifest.json` (the manifest-shaped model the page fetches), `/__plugin-gallery/registry.json` (the discovery registry the smoke tools read), `/assets/*` (served from `public/assets/`, else `plugin-designs/*/data/`). **Writes nothing to `public/`** — no HMR across that boundary. |
| `bun run smoke:gallery` | Opens every element by deep link in Firefox **on the ARCHIVED gallery page at `/__gallery`** and proves it boots there: element defined, shadow DOM populated, no error card, a WebGL canvas for map profiles (checked through nested shadow roots), no HTML-for-JSON data responses. `--json <file>` writes the evidence; exit 1 on any failure. It is the only gate watching the archived surface — it found the `tree` base-join bug (404 `/manifest.json`) that no other gate could see, because the studio carries `tree-base` and the gallery did not. **It does NOT cover the studio**: that is `smoke:studio` (~5s), and `/` serves the studio in both trees. |
| `bun run smoke:share` | The share round-trip gate (Firefox): opens a host page (`--plugin-gallery` = every canvas element of the dev plugin gallery; `--url <page> --tag <element>` = the website or any CDN page), clicks Share, reads the URL, fetches it, reopens it in a fresh page and asserts the SAME custom element mounts with a WebGL canvas. `--json <file>`; exit 1 on any failure — proves REQ-1 ("links work wherever the tree is hosted"). |
| `bun run check:public-hygiene [<dir>] [--strict]` | Proves a served folder is fit to publish: scans for local paths, internal folder names, dev-server URLs, JWT-shaped tokens, `.env`/token files, source maps, documentation and staging/preview pages, and prints an inventory (bundles, leaf pages, assets by folder). Under `--strict` any root entry outside the drop-in contract is a finding; the allowed set is READ FROM `vite.doxa-maps-build-config.json` → `dropin.include` (`index.html`, `__gallery/`, `assets/`, `doxa-maps/`) rather than restated here, so the gate cannot disagree with the contract. Exit 1 on a finding. Run it on the website's drop-in before any public push. |
| `bun run sync:dropin <dest> [--from-config \| --only a,b] [--prune]` / `bun run deploy:dev` | Syncs the built `public/` into a host checkout (e.g. the website's `public/embed/doxa-maps-build/`). **The drop-in contract** lives in ONE place — `vite.doxa-maps-build-config.json` → `dropin.include`, four root entries: `index.html` (the **plugin studio** host page generated by `internal/generate-tree.mjs`, which mounts `<plugin-studio>`), `__gallery/` (the archived bundle-index gallery generated by `internal/generate-gallery.mjs`, listing every bundle/profile/instance and mounting the chosen one), `assets/` (data) and `doxa-maps/` (the bundle folders + `manifest.json`). `--from-config` copies only those, `--prune` removes everything else in the destination, so a host carries exactly the served web-root and nothing else at its root. `dropin.exclude` (same file) lists built files inside the allowlist that no page fetches — the full people-groups CSV, the tables only internal scripts read, the table index; they stay in `public/` for the scripts, are skipped by `--from-config` (counted as "excluded (not shipped)") and removed from the destination if a copy is there. Without an allowlist it is a plain full mirror. Never copies credentials, docs or staging pages; `--protect a,b` keeps host-owned files; `--dry-run` first; prints a copied/unchanged/deleted/pruned/excluded summary and the resulting top-level tree. `deploy:dev` = `build` → sync `--from-config --prune` into the website (`DOXA_DROPIN_DEST` overrides the path) — the only sanctioned way a map changes on the site; then commit in both repos. |
| `bun run build` | `clean` → one `vite build` per `plugin-designs/*/` folder → `tree` → `check:drop` → `check:parse` → `verify` → `gallery` (writes `public/__gallery/index.html`, the archived bundle-index gallery, from the manifest). The root page `public/index.html` — the studio host — is written by `tree`. |
| `bun run build:bundle` | Builds ONE bundle (needs `BUNDLE=<name>`). |
| `bun run build:watch` | Rebuilds into `public/` on every change (integration loop). |
| `bun run clean` | Wipes + recreates the `public/<name>/` output tree. |
| `bun run tree` | Writes `manifest.json` + self-contained staging HTML, then copies the tree to every build-config `paths` destination. |
| `bun run verify` | Data-integrity + shipped-bundle + network-policy guards (fast, no browser). |
| `bun run verify:full` | + rendered posters and live apps in Firefox. |
| `bun run dev` | Vite dev server with hot reload at http://localhost:5173. **`/` is the Plugin Studio**; `/__gallery` is the archived bundle index. Serves every bundle at its own html path and `/assets/*` from `public/assets/`. **Writes nothing to `public/`.** |
| `BUILD_ALLOW=1 bun run build` | `clean` → one `vite build` per `plugin-designs/*/` folder → `tree` → static gates. Refuses (exit 75) without `BUILD_ALLOW=1`, because it wipes `public/doxa-maps/` first. Takes a lock; `BUILD_LOCK_WAIT=600` queues instead of failing |
| `BUNDLE=<name> bun x vite build` | builds ONE bundle without cleaning the rest of the tree |
| `bun run build:watch` | rebuilds into `public/` on every change |
| `bun run clean` | wipes and recreates the `public/<name>/` output tree |
| `bun run tree` | writes `manifest.json` + the leaf pages, then copies the tree to every build-config `paths` destination |
| `bun run verify` · `bun run verify:full` | data-integrity, shipped-bundle and network-policy guards (fast, no browser) · plus rendered posters and live apps in a browser |
| `bun run sync:dropin <dest> [--from-config] [--prune]` | mirrors the built `public/` into a host checkout. The allowlist lives in ONE place, `vite.doxa-maps-build-config.json` → `dropin.include`; `dropin.exclude` lists built files no page fetches. Never copies credentials, docs or staging pages; `--dry-run` first |
| `bun run check:public-hygiene [<dir>] [--strict]` | proves a served folder is fit to publish: no local paths, internal names, dev URLs, token-shaped secrets, source maps or staging pages. Run it on a drop-in before any public push |
**Plugin gallery.** One generator, two modes. `internal/generate-gallery.mjs` exports
`renderGallery(model, { mode })`, a pure function from a manifest-shaped model (bundles → profiles →
instances, with `scriptUrl` / `iframeUrl` / `embed` / `integrity`) to the whole page: the list, the stage,
`#/<bundle>/<profile>[/<instance>]` deep links, Open page, Copy embed, the token ladder, lazy engine load,
the phone layout and the baked fallback. Mode `build` (`bun run gallery`) renders
`public/doxa-maps/manifest.json` into `public/__gallery/index.html` — ARCHIVED beside the root, which is now
the plugin studio. Mode `dev` is what `bun run dev` serves at `/__gallery`: the
same page over `galleryModel(discoverSpaRegistry())` (`internal/plugin-gallery/dev-plugin.mjs`), differing
only in the loader (it imports `/plugin-designs/<bundle>/index.js` instead of adding the built IIFE), the live
model route (`/__plugin-gallery/manifest.json`), the `/assets/*` dev route and the composability ladder's
"?" popovers in the header — kept out of the built page to keep it lean. Nothing about the gallery is
written twice.
**How a build works.** `vite.config.js` registers every `plugin-designs/` subfolder that doesn't start with
`_`/`.` and has **both** `index.html` and `index.js`. One `vite build` builds **exactly one** bundle (selected
by `BUNDLE=<name>`) as a self-contained **IIFE**; the full build loops every folder. Sizes differ with what each
bundle imports (simple ≈ 420 KB, research ≈ 1.3 MB).
**How a build works.** `vite.config.js` is multi-entry by discovery: at build time it
registers every `plugin-designs/` subfolder that (a) doesn't start with `_`/`.` and (b) has
**both** `index.html` and `index.js`. One `vite build` builds **exactly one** bundle, selected
by `BUNDLE=<name>`, so the output is a self-contained **IIFE** (`inlineDynamicImports: true`).
`bun run build` loops every folder. Each bundle only bundles what its screens import, so sizes
differ (simple ≈ 420 KB, research ≈ 1.3 MB).
**Where output goes.** One bundle folder → one file: `public/doxa-maps/<bundle>/<bundle>.js`.
CSS is injected by JS (`cssCodeSplit: false`), so there is no separate `.css`. The tree root
name comes from `vite.doxa-maps-build-config.json` `name` (default `doxa-maps`).
`emptyOutDir: false` keeps sibling bundles intact when you rebuild one.
**Shadow-style comments are stripped from production bundles.** Component CSS is injected
from `useShadowStyles(\`…\`)` template literals that ship verbatim, so `internal/vite-plugin-strip-shadow-css-comments.mjs`
(build only, `enforce: 'pre'`) removes the `/* … */` blocks inside those literals and collapses the blank lines
they leave; a literal with `${…}` is left untouched with a warning, and dev mode keeps every comment.
**What a built bundle folder contains.** Beyond the IIFE, `bun run tree` generates an
`index.html` **per bundle** (staging/showcase), **per profile**, and **per instance** —
each profile/instance page is a standalone, directly linkable/iframe-able map — plus
`public/doxa-maps/manifest.json` with ready-made `embed.iframe`/`embed.script` snippets for
every profile. Full design: [architecture/embeddability.md](./architecture/embeddability.md).
**Preset instances.** A `<profile>.instances.json` beside a parameterized profile
(`{ profile, apiBaseUrl?, instances: [{ id, title?, props }] }`) makes the build emit one
standalone page per instance at `<bundle>/<id>/index.html`. Recipe:
**Where output goes.** One bundle → one file: `public/doxa-maps/<bundle>/<bundle>.js`, with CSS injected by
JS (no separate `.css`). Beyond it, `tree` writes a standalone, iframe-able `index.html` per bundle, per
profile and per preset instance, plus `public/doxa-maps/manifest.json` with ready-made
`embed.iframe` / `embed.script` snippets for every profile. Full design:
[architecture/embeddability.md](./architecture/embeddability.md). Preset instances:
[how-to.md](./how-to.md#ship-n-preset-variants-of-one-screen-instances).
**Bun is required.** `bun.lock` is the committed lockfile; `bun x vite` runs project-local
Vite. `npm run …` resolves the same scripts but Bun must be on PATH.
**Bun is required.** `bun.lock` is the committed lockfile; use `bun x vite`, not `bunx`.
### Standalone gates — the SILENT failure classes
### Standalone gates — run these before publishing
These are **not** in `build:unlocked`, deliberately: each checks something a fresh build would mask,
so running it inside the build would make it unfalsifiable. Run them before publishing.
These live **outside** the build on purpose: each checks something a fresh build would mask, or needs a
browser or network.
| Script | Catches | Why it cannot live in the build |
|---|---|---|
| `bun run check:composite` | a composite (cap-4/cap-5) still shipping an OLD copy of a child it nests — a one-bundle rebuild does NOT recompile its parents | a full build makes every composite fresh *by construction*, so it could never fail there |
| `bun run check:shots` | a README screenshot that predates the bundle it shows — **a stale screenshot looks exactly like a current one** | needs a browser to repair (`bun run shots`), which CI does not have |
| `bun run check:library-index` | `documentation/reference-library-index.md` fallen behind `library/` — its header promises it "can't drift", which is only true if regenerated (`bun run library-index`) | a fresh checkout gives every file the same mtime, so mtime comparison always passes in CI (CI regenerates + diffs instead) |
| `bun run check:done-evidence` | a backlog card marked `done` with no bluf **and** no fields — a status word the next team cannot reconcile against the code | reads `_qa-cycle/`, which is not in any repo CI clones |
| `bun run verify:gate1` | the Gate-1 chain end-to-end against LIVE data: header filter → composite props → real grid re-filter → count back up | needs network; it is an acceptance test, not a build step |
| `bun run check:catalog-identity` | the live server serving something **other than** the catalog — a stale CDN copy, a half-finished publish, or the login wall. Hashes each live bundle and compares sha384 against the manifest's own `integrity` | needs network egress, which CI cannot guarantee; it SKIPs when unreachable, and only "reached and wrong" fails |
| `bun run check:catalog-currency` | the live catalog being **an older build** than yours (identity passes on a catalog three publishes behind), **and** a locally-built bundle that is **absent** from the catalog — the "nothing was lost in the merge" guarantee | needs network egress; SKIPs when the catalog cannot be reached at all, because an offline box must not fail a build |
| `bun run smoke:embeds` | an element that **builds, lists in the manifest, and renders nothing** — a thrown `setup()` removes the subtree silently, so every build-time gate still passes. Mounts every profile page and asserts real shadow content | needs a browser; CI has none |
| `bun run verify:map-paint` | a map that **lays out but never paints** — Mapbox creates a full-size `<canvas>` and only *then* tries for a GL context, so "canvas is 1440×900" is evidence of layout, never of paint. Asserts a live GL context + attribution | needs a browser **launched with the SwiftShader flags** (the MCP/default browser here has no GL at all) |
| `bun run verify:5-caps` | a capability tier that no longer has a working exemplar — the 5×5 product claim going stale | needs a browser + a Mapbox token |
| `bun run check:quoted-scripts` | a **doc that quotes a command which has since changed** — an omitted step in a quoted chain is invisible: there is no wrong character to notice, just a check the reader believes is in the chain | compares docs against `package.json`; cheap, but it guards documentation rather than build output |
| `bun run check:capability-exemplars` | `documentation/reference-capability-exemplars.md` drifting from the manifest's real tiers (regenerate with `bun run capability-exemplars`) | the table is generated *from* the manifest, so running it mid-build would compare a file against the thing that just wrote it |
| `bun run check:dep-names` | a DECLARED DEPENDENCY THAT IS NOT A REAL PACKAGE NAME. In `build:unlocked`. Checks two things offline: the name resolves under `node_modules/`, AND the installed package's own `name` field equals the declared key — the second catches a name that resolves to a DIFFERENT package, which a path-only check misses. SKIPs honestly when `node_modules/` is absent. Found the hard way 2026-09-22: a folder rename rewrote `@capacitor/{android,cli,core}` to `@deployment/capacitor/*`, so **`bun install` failed in a fresh clone** while everything here passed, because the real packages were already installed from before the rename. |
| `bun run check:no-abs-paths` | a MACHINE-SPECIFIC PATH in tracked source — a `/home/<user>/` prefix, its macOS equivalent, or the Windows user-profile form. It is in `build:unlocked`, listed here because the failure it prevents is a HAND-OFF failure: an absolute path works perfectly for whoever wrote it and breaks only on the inheritor's machine, so no test run by its author can catch it. Scans `git ls-files` (the set a clone receives), which excludes node_modules and build output without an ignore list. Measured 2026-09-22: the repo was clean BY ACCIDENT — a planted `/home/…` line passed check:public-hygiene, check:parse and check:quoted-scripts, because public-hygiene scans the SERVED tree, not source. |
| `bun run check:settled` | measuring or publishing a tree **mid-build** — a build's `clean` step `rm -rf`s `public/doxa-maps`, so samples taken seconds apart give opposite answers | it exists to be run *around* builds, not inside one |
| Script | Catches |
|---|---|
| `bun run smoke:embeds` | an element that **builds, lists in the manifest, and renders nothing** — mounts every profile page in a browser |
| `bun run smoke:studio` | the Plugin Studio at `/` not mounting every plugin (~5 s, needs `bun run dev`) |
| `bun run check:composite` | a composite still shipping an OLD copy of a child it nests — a one-bundle rebuild does not recompile its parents |
| `bun run verify:map-paint` | a map that **lays out but never paints** — asserts a live GL context and attribution |
| `bun run verify:5-caps` · `bun run verify:interop` | a capability tier with no working exemplar · cap-4 plugins not interoperating through props and events |
| `bun run check:shots` | a README screenshot shot from a different bundle than the one built now (compares a recorded bundle hash) |
| `bun run check:shot-distinctness` · `check:shot-fidelity` | two screenshots showing the same view · a machine that cannot render what a shot must show (e.g. no emoji font) |
| `bun run check:library-index` · `check:capability-exemplars` · `check:quoted-scripts` | a generated doc drifting from `library/`, from the manifest, or from `package.json` |
| `bun run check:catalog-identity` · `check:catalog-currency` | a live host serving something other than your build (SRI hash comparison) · an older build, or a bundle missing from it |
| `bun run check:settled` | measuring or publishing a tree **mid-build** (a build's `clean` step empties it) |
| `bun run check:mobile` · `check:tauri` · `check:wp-embed` | the Capacitor sync, the Tauri config contract, the WordPress embed markup — deploy methods 2, 5 and 4 |
| `bun run smoke:gallery` · `smoke:share` | the archived `/__gallery` page · share links round-tripping to the same element |
| `bun run check:a11y-button-names` | an icon-only control with no accessible name |
### Before you run the list — three gates need a PRECONDITION, or they look broken
A bare `bun run <gate>` is not always meaningful, and the failure text does not always say so.
Measured 2026-09-22, by running each one:
**Some gates need a precondition, or they look broken.** Read the message before trusting the exit code:
| Gate | Needs | What a bare run does |
|---|---|---|
| `smoke:gallery` (and `smoke:playground`, its alias) | a dev server: `bun x vite` on **:5173** (`bun run dev`'s own port) | exits non-zero with *"cannot read http://localhost:5173/__plugin-gallery/registry.json — is `bun x vite` running?"* — a **precondition**, not a defect. It audits the ARCHIVED `/__gallery` page; `--page` re-aims it, `--url` stays at the server root where the registry lives |
| `smoke:share` | `--url <page>` arguments, a served tree, a token, and **`--browser chromium` on this box** | prints `usage:` and exits non-zero; with default Firefox it stops with "Verified on a BLANK page, so it is the engine/box, not the product" and names the chromium flag itself |
#### ★ WRITING A NEW GATE: you cannot `import()` plugin or library source into plain Node
This is the trap that will bite the next gate author, so it is written here rather than left to be
rediscovered. Plugin and library source is written for **Vite**, not for Node, and it fails two
different ways — counted 2026-09-22, not estimated:
| Vite-only feature | Where | What plain `node` does |
|---|---|---|
| the **`@map` alias** (`@map/ProfileLoader.vue`) | resolved by `vite.config`, not by Node | `Cannot find package '@map/ProfileLoader.vue'` — this is what you hit **first**, before any glob |
| **`import.meta.glob`** | **18 files**: 11 of the 20 plugin `index.js`, plus `library/ProfileLoader.vue`, `library/i18n/index.js`, `library/composables/useShadowStyles.js`, `library/colors/_registry.js`, and 3 more | `TypeError: (intermediate value).glob is not a function` |
`import.meta.glob` is a **build-time** feature: Vite statically replaces it with real imports while
bundling. Plain `node` leaves it undefined, so the module throws the instant it is evaluated:
```
TypeError: (intermediate value).glob is not a function
at library/colors/_registry.js:26
```
The glob failure is the nastier of the two because it can arrive **transitively**, from a module you
never named — you import a config, the config imports a colour strategy, and the registry throws.
Measured 2026-09-22: a new `check:facet-cascade` gate did `await import()` on the dashboard config,
which transitively reached the colour registry, and crashed — **while already wired into
`build:unlocked`**, so `bun run build` failed for a reason that had nothing to do with the code being
built. The library file was correct and pre-existing; the defect was entirely in the gate reaching it.
So when a gate needs to exercise real plugin logic, pick one:
| Approach | When | Cost |
|---|---|---|
| Import only the **leaf module** under test | the logic is in a plain `.js` with no Vite features (most composables are) | cheapest and most reliable — keep the dependency cone small |
| Test against the **BUILT bundle** over `bun run serve` | the logic only exists after bundling, or needs the DOM | needs a browser + a served tree, so it belongs in `verify:browser-all`, **not** `build:unlocked` |
| Parse the **source text** instead of executing it | you are asserting a structural property (a rule exists, a field is declared) | cannot catch runtime behaviour — say so in the gate's header |
**Two hard rules that follow.** A gate goes in `build:unlocked` only if it runs clean in plain Node
with no browser and no network — everything else is a standalone or browser gate, the way
`check:a11y-button-names` and `check:composite` are handled. And **run the gate before wiring it in**:
a broken gate inside the build chain is worse than no gate, because it goes red for the wrong reason
and trains whoever hits it to skip the step.
**Other runtime constraints on the build box**, all of which have produced a wrong answer at least
once: Node is **v20** (no `fs.glob`, which is 22+); `find` is **bfs**, which *rejects* a relative
`-newermt '-45 seconds'` and errors to stderr while printing nothing — use `bun run check:settled`,
or an absolute `$(date -u -d '-45 seconds' +%Y-%m-%dT%H:%M:%SZ)`; there is **no hardware WebGL** (judge
maps with `verify:map-paint`'s SwiftShader path, never by expecting a painted basemap); and `bunx`
does not exist here — use the spaced `bun x`.
**Copy-paste: the full share round-trip over every plugin that has a Share button.** Assembled and run
2026-09-22 — **8/8 pass**. Without this recipe an inheritor has to work out the server, the token and the
eight `--url` flags from scratch, which is why the gate had never been run across all of them.
| `smoke:gallery` | a dev server on :5173 (`bun run dev`) | exits non-zero: "cannot read …/registry.json — is `bun x vite` running?" |
| `smoke:share` | `--url <page>` arguments, a served tree, a token, `--browser chromium` | prints `usage:` and exits non-zero |
| `smoke:embeds` · `verify:map-paint` · `verify:5-caps` | `MAPBOX_TOKEN` in the environment (below) | **degrades rather than fails**: map profiles time out, which looks like broken plugins and is not |
```sh
cd doxa-maps-plugin-studio
( node internal/serve-strict.mjs --port 4950 & ) # serve the built tree, LOOPBACK ONLY
# ★ Take the token from the BUILD CONFIG, not from doxa.life/api/maps/token — see the note below.
# The token every map gate wants: read it from the build config (it works from any origin).
export MAPBOX_TOKEN=$(python3 -c "import json;print(json.load(open('vite.doxa-maps-build-config.json'))['token'])")
```
B=http://127.0.0.1:4950/doxa-maps
ARGS=""
**Share round-trip over every plugin with a Share button**, from the repo root:
```sh
( node internal/serve-strict.mjs --port 4950 & ) # serve the built tree, loopback only
B=http://127.0.0.1:4950/doxa-maps; ARGS=""
for p in demo-map/demo-map cities-internet/connectivity-map doxa-research-map/research-map \
country-posters/countries-map doxa-simple-map/doxa-simple-map \
gospel-resources/resources-map my-upg-100-list/upg-100-list my-upg-100-list/upg-100-mfe; do
@ -318,138 +209,42 @@ done
node internal/share-roundtrip-smoke.mjs $ARGS --browser chromium
```
> **The pass count alone proves nothing — check the reopened TAGS.** The failure this gate exists to catch
> (an SPA fallback, or a host that strips the `#` deep-link) renders *a* map for every share URL, so every
> target passes while every link is wrong. The discriminating evidence is that the run returned **six
> distinct custom-element tags** — `demo-map`, `cities-internet-map`, `doxa-research-map`,
> `country-posters-map`, `doxa-map`, `gospel-resources-map` — each matching its own plugin, with
> `canvas:y` on all eight. (The two `my-upg-100-list` profiles legitimately share one tag.) Derive the
> plugin list the same way it was derived here rather than pasting it stale:
> `grep -rln ShareButton plugin-designs/*/*.vue`.
| `check:dxlife` | a sibling `root-dx-life/` clone beside this repo | exits **2** — *"clone not found — nothing to check"*. That is a deliberate THIRD value (`0` unchanged · `1` drift · `2` no clone), because this gate exists after a check that returned the PASS value BECAUSE IT FAILED. But `bun run` and any `&&` chain see only zero/non-zero, so it surfaces as FAIL. Measured 2026-09-22 inside a fresh clone: every other browser-free gate passed and only this one went red, for want of a directory an inheritor has no reason to have. It is NOT in `build:unlocked`, so a clone still builds. |
| `smoke:embeds` · `verify:map-paint` · `verify:5-caps` | `MAPBOX_TOKEN` in the environment | **does not fail — it DEGRADES.** Without a token map profiles cannot mount, and `smoke:embeds` reports them as TIMEOUTS. It once read 27/40 for this reason and 40/40 with a token. A timeout here means "no token" far more often than "broken plugin" |
Check the reopened **tags**, not just the pass count: each share URL must reopen its *own* element. List the
plugins fresh rather than trusting this one: `grep -rln ShareButton plugin-designs/*/*.vue`.
```sh
# The token every map gate wants. Read it from the build config: measured 2026-09-23, the config
# token serves tiles (200) from any origin, while the doxa.life endpoint's token returns 403 from
# anywhere but doxa.life. Both are 90-odd-char pk. public keys; only one of them works here.
export MAPBOX_TOKEN=$(python3 -c "import json;print(json.load(open('vite.doxa-maps-build-config.json'))['token'])")
```
### Writing a new gate
> **THIS LIMITATION WAS SELF-INFLICTED, and it is fixed — corrected 2026-09-23.** This paragraph
> used to say a blank basemap was simply the cost of working here. It measured the right thing about
> the WRONG TOKEN: the one served by `https://doxa.life/api/maps/token` is URL-restricted to
> `doxa.life`, returning **403** from `127.0.0.1`, `localhost`, `nuxtbase.app` or no Referer. That is
> a property of that token, not of this box — and the command above used to fetch it, so the doc
> diagnosed the blank basemap and then kept prescribing its cause.
>
> The `token` field in `vite.doxa-maps-build-config.json` returns **200** on the identical request
> from any origin. It is a `pk.` public key baked deliberately (a project decision, 2026-09-21), protected
> by URL restrictions rather than secrecy; `check:public-hygiene` allows `pk.` and fails the build on
> `sk.`/`tk.`. So the SHELL COMMANDS above — the ones that curl a token and hand it to a gate as
> `MAPBOX_TOKEN` — now work where they previously fetched a token that 403s.
>
> **★ BE PRECISE ABOUT WHAT THIS FIXES, because an earlier draft of this very paragraph overstated
> it.** Measured 2026-09-23 with the doxa.life token: curl returns **403** with no Referer, **403**
> with `Referer: http://127.0.0.1:4173/`, and **200** with `Referer: https://doxa.life/`. So the
> restriction is real and it does block the curl path. But a browser gate loading a leaf still fetched
> **9 `/v4/` tiles with 0 failures** — because the page resolves its token through a LADDER and the
> built bundle carries the working baked token, so the `?tk=` rung is not necessarily what the map
> ends up using. The fix is to the SHELL commands; the browser gates were never the ones affected.
>
> Two corrections that came out of measuring this, both worth inheriting. First, the basemap here is
> **Mapbox Light** — pale land, grey water, minimal detail so the choropleth dominates. Looking at a
> screenshot I briefly called it "not painting" because I expected terrain and roads; the country
> labels and borders ARE the basemap. Judge tile loading by the `/v4/` response count, not by whether
> the image looks like a road map. Second, `verify:map-paint` passes under BOTH tokens, because it
> asserts a live GL context and attribution rather than loaded tiles — so it is not the signal to
> consult about whether tiles arrived.
**Plugin and library source is written for Vite, not Node** — `import()`ing it from a plain Node gate fails:
> **★ AN EXIT CODE IS NOT A VERDICT UNTIL YOU HAVE READ THE MESSAGE.** Two of these read as FAIL in a
> batch run and are neither passing nor failing — they never started. A checklist that turns a
> precondition into a red line trains its reader to ignore red lines, which is how a real failure
> gets skipped.
The rest, grouped by what they guard — same rule (outside the build, run before publishing):
| Script | Catches |
| Vite-only feature | What plain `node` does |
|---|---|
| `bun run smoke` · `bun run smoke:gallery` · `bun run smoke:share` | the built leaf pages, the ARCHIVED `/__gallery` page, and share-link round-tripping — each needs a browser. The studio at `/` is `bun run smoke:studio` |
| `bun run check:a11y-button-names` | an icon-only control with no accessible name (needs a browser to read the computed name) |
| `bun run check:capability-citations` | the README claiming a capability the manifest does not back |
| `bun run check:dxlife` | **any edit to the read-only DX-life clone** — the standing law that tree is never written |
| `bun run check:mobile` · `bun run check:tauri` · `bun run check:wp-embed` | the Capacitor sync, the Tauri config contract, and the WordPress PHP embed — deploy methods 2, 5 and 4 |
| `bun run verify:full` | the research corpus end-to-end |
| the **`@map` alias** (`@map/ProfileLoader.vue`) | `Cannot find package '@map/ProfileLoader.vue'` |
| **`import.meta.glob`** (used across plugin entries and the library registries) | `TypeError: (intermediate value).glob is not a function` — often arriving **transitively**, from a module you never named |
> **★ A STANDALONE GATE ONLY FIRES IF SOMEONE REMEMBERS IT — so this table IS the mechanism.**
> Measured 2026-09-22: **26** gates live outside `build:unlocked`; only **nine** were listed here. Two
> of the unlisted ones (`check:quoted-scripts`, `check:capability-exemplars`) were already FAILING,
> and had been for two sessions, because a build step and a bundle were added without their docs
> being regenerated. Nothing was broken in the product — the gates were doing their job — but nobody
> ran them, because an inheritor reads this table as the list of what to run before publishing.
> **An undocumented standalone gate is indistinguishable from one that does not exist.**
| Approach | When |
|---|---|
| Import only the **leaf module** under test | the logic is in a plain `.js` with no Vite features (most composables) |
| Test the **built bundle** over `bun run serve` | the logic only exists after bundling, or needs the DOM — a browser gate, not a build step |
| Parse the **source text** | you are asserting a structural property; say in the gate's header that it cannot catch runtime behaviour |
**Why SRI and not an HTTP status.** `https://nuxtbase.app/app/doxa-maps/manifest.json` returns **200**
and is a 2,872-byte HTML **sign-in page** — the app-shell login wall. A status check reads that as
healthy. The public door is `/plugin-assets/doxa-maps/`. Every profile already carries an `integrity`
hash the build computed, so comparing it against the live bytes is a cryptographic identity proof
that costs nothing extra to maintain. Re-measured 2026-09-22: **18 of 18 bundles identity-proven**, and
the live manifest byte-identical to `platform/mfe-index`. (It read "17 of 17" on 2026-09-21; the catalog
has since gained `golden-path-check`.)
> **A dated measurement is not a stale claim — but it still misleads once the underlying number moves.**
> "17 of 17" was true when written and the gate reads the live manifest, so nothing was broken. The
> problem is what a READER concludes: seeing 17 beside a catalog of 18, they cannot tell whether a bundle
> went missing or the sentence simply aged. ★ If you quote a count here, re-run the gate and restate it —
> `bun run check:catalog-identity` prints the current figure in one line.
### The screenshot gates — three different lies a picture can tell
A README image can fail in three ways that all look identical to a passing shot run. `bun run shots`
reports `OK` for every one of them, because it proves a page *loaded* — never that it looks right.
| Script | Catches | In CI? |
|---|---|---|
| `bun run check:shots` | a PNG older than the bundle it shows — **a stale screenshot looks exactly like a current one** | report-only (repair needs a browser) |
| `bun run check:shot-distinctness` | two slots capturing the same view — shipped as "List — page 1"/"page 2", the same picture under two captions | **blocking** (parses one file) |
| `bun run check:shot-fidelity` | this machine cannot *render* what the shots must *show* (no emoji font ⇒ every icon a tofu box) | not yet — reports locally |
The distinctness case is the instructive one: the shot chased a "Next" control and a scroll that
**do not exist** (the list has no pagination), every attempt sat inside `try/catch` so the miss was
silent, and the two PNGs differed by 0.16% render noise — so they were *not* byte-identical and no
checksum would have caught them. Meanwhile a real third profile (`poster-wall`) had no shot at all.
**A slot faking a view it cannot reach, while a genuine artifact goes unshot, is the recurring
shape** — the same one that captioned the product SPA as the plugin-studio.
**`bun run check:quoted-scripts` is the exception that proves the rule above** — it is the one
doc-integrity gate that DOES run in CI (blocking), because it compares two files already in the
checkout: it needs no browser, no network, and no mtime. It asserts that the `scripts` block
quoted verbatim in `documentation/README-SECTION-3-cli-commands.md` still matches `package.json`. That doc
carries its own hand-written plea to re-transcribe after every edit, and drifted anyway — twice.
The second drift omitted `check:public-hygiene` (the secret scan) from the build chain it
documents, which is the dangerous shape: **an omission in a quoted list is invisible**, since
there is no wrong character to notice. `package.json` is what runs, so the fix is always to update
the doc.
`bun run verify:5-caps` proves all five capability tiers mount and render. It needs a token —
`MAPBOX_TOKEN=$(curl -s <token-endpoint>) bun run verify:5-caps` — and it sets **both** `?tk=`
(the top-level element) **and** `window.MAP_TOKEN` (a composite's nested children read the global,
not the query param). Without a token it reports the token outage rather than the capabilities.
**A gate goes inside the build only if it runs clean in plain Node, with no browser and no network — and run
it before wiring it in.** Build-box constraints worth knowing: Node **v20** (no `fs.glob`), and use
`bun x`, not `bunx`.
---
## `vite.doxa-maps-build-config.json` fields
Edit this one file, then `bun run build`.
Edit this one file, then rebuild.
| Field | What it does |
|---|---|
| `name` | Output folder name (default `doxa-maps`). |
| `paths` | **Extra** folders to copy the whole built tree into. Point at a host repo's `public/` → that host serves the maps and becomes the CDN. Empty (the default) ⇒ written to `public/<name>/` only, since `public/` is already the committed web-root. |
| `embedBaseUrl` | The public URL the tree is served from, used to generate **absolute, paste-anywhere** embed snippets. Empty ⇒ relative URLs (position-independent; keeps the drop-portability gate green; required for Capacitor, which loads from `https://localhost`). |
| `tokenUrl` | Endpoint each generated page fetches its Mapbox token from — a URL, **not** a secret. Set absolute (`https://…/api/maps/token`) so a CDN folder and the mobile app both work; endpoint must send CORS for the embedding origins. `window.MAP_TOKEN_URL` overrides at runtime. |
| `token` | Opt-in literal token baked into every page. **Leave empty** — prefer `tokenUrl`. |
| `assetsBaseUrl` | Where the shared asset tree (previews, posters, boundaries) is served from. Empty ⇒ a relative path per page depth (self-contained under one web-root). Set absolute for partner embeds and mobile, where there is no shared web-root to climb to. |
| `name` | output folder name (default `doxa-maps`) |
| `paths` | **extra** folders to copy the whole built tree into — point at a host repo's `public/` and that host serves the maps. Empty ⇒ `public/<name>/` only |
| `embedBaseUrl` | the public URL the tree is served from, for **absolute, paste-anywhere** snippets. Empty ⇒ relative URLs (position-independent; required for Capacitor) |
| `token` | a `pk.` public Mapbox token baked into every page, so a map works on any host. It is public by design — protected by URL restrictions in the Mapbox dashboard, not by secrecy; `check:public-hygiene` allows `pk.` and fails on `sk.` |
| `tokenUrl` | an endpoint each page may fetch its token from (a URL, not a secret); it must send CORS for the embedding origins. `window.MAP_TOKEN_URL` overrides at runtime |
| `assetsBaseUrl` | where the shared asset tree is served from. Empty ⇒ a relative path per page depth; set it absolute for partner embeds and mobile |
**Token resolution ladder:** per-map `tk` prop > `?tk=` > baked `token` > `window.MAP_TOKEN_URL` > `tokenUrl` > same-origin `/api/maps/token`.
@ -457,39 +252,7 @@ Edit this one file, then `bun run build`.
## Data sources
### ★ Persecution-safety — a rule about WHICH FIELDS a profile may carry
Read this before adding a field or a link to any profile. **Some profiles are deliberately kept free of
gospel/church/believer data, and a profile failing a "should have resource links" check may be OBEYING
that rule rather than missing a feature.**
The binding instance, stated in the code it governs (`plugin-designs/cities-internet/connectivity-list.vue`):
> **PERSECUTION-SAFETY:** this profile carries connectivity + population + language only. It has **NO
> gospel-resource, church or believer field and must not gain one.**
Measured 2026-09-22: all three `cities-internet` profiles have **zero** `href` bindings, and the live leaf
renders 1,567 nodes with **0 anchors**. That is correct. Its `source` field —
`{name: "World Bank Open Data", url: "https://api.worldbank.org/v2"}` — is **API provenance**, not a
user-facing destination, and it is surfaced as a citation paragraph when a region is expanded, which is the
separate promise that header makes ("every figure carries its source, so the numbers can be audited").
★ **Why this is written down here and not left in one file header.** A standing work item reads
*"gospel-resources **+ cities-internet** maps must render with REAL resource links"* — one line batching two
profiles whose contracts are opposite. `gospel-resources` genuinely renders 155 real eBible links; taking
the same line literally for `cities-internet` means **adding a gospel-resource field to a profile whose
header forbids exactly that**. A requirement that names two subjects deserves a per-subject check before
anyone drains it.
> **"Persecution-safe" is used for three different things in this repo** — don't assume one rule. Here it
> means FIELD SEPARATION. In `key-cities/cities-list.vue` it means placename precision ("a wrong placename
> in a persecution-sensitive dataset is the dangerous kind of wrong"). In
> `research-dashboard/.../DashboardDataTable.vue` it means a dependency-free table (no third-party grid).
> Only the first governs what data a profile may hold.
---
Maps read data through `@map/api/` driven by `library/api/sources.json`. Three source types:
Maps read data through `@map/api/`, driven by `library/api/sources.json`:
| Type | What | Points at |
|---|---|---|
@ -497,32 +260,36 @@ Maps read data through `@map/api/` driven by `library/api/sources.json`. Three s
| `csv` | a local CSV parsed at load | the Joshua Project dataset (+ photo fallbacks) |
| `api` | a single JSON URL | any plain JSON endpoint |
Country/region polygons are **not** a `sources.json` connection — they render from Mapbox's built-in
`country-boundaries-v1` tileset plus a lazy-loaded WAGF-regions GeoJSON.
**Adding or changing a source?** Full contract + checklist:
Country and region polygons are not a `sources.json` connection: they come from Mapbox's
`country-boundaries-v1` tileset plus a lazy-loaded WAGF-regions GeoJSON. Adding a source:
[architecture/data-sources.md](./architecture/data-sources.md).
### Persecution-safety — which fields a profile may carry
**Some profiles are deliberately kept free of gospel, church and believer data.** A profile failing a
"should have resource links" check may be *obeying* this rule. The binding instance, in
`plugin-designs/cities-internet/connectivity-list.vue`:
> **PERSECUTION-SAFETY:** this profile carries connectivity + population + language only. It has **NO
> gospel-resource, church or believer field and must not gain one.**
Its `source` field (World Bank Open Data) is API provenance shown as a citation, not a user-facing link.
"Persecution-safe" means two other things elsewhere in the repo — placename precision in
`key-cities/cities-list.vue`, a dependency-free table in `research-dashboard` — and only this one governs
what data a profile may hold.
---
## Colors
## Colours
**Reused by more than one map → shared `library/colors/`. Only your map uses it → your bundle's
`src/colors/`** (merges over the shared set, that bundle only). Filename derives the mode key
(`language-family.js` → `languageFamily`).
**Writing or overriding a strategy?** Full contract + checklist:
[architecture/color-strategies.md](./architecture/color-strategies.md) ·
two homes in depth: [../contributing/COLORS.md](./contributing/COLORS.md).
---
`src/colors/`** (merges over the shared set, for that bundle only). The filename sets the mode key
(`language-family.js` → `languageFamily`). Contract: [architecture/color-strategies.md](./architecture/color-strategies.md) ·
the two homes in depth: [contributing/COLORS.md](./contributing/COLORS.md).
## Importing from `@map`
Everything under `library/` is importable via the `@map/…` alias (`vite.config.js` maps `@map`
→ `library/`). The import path mirrors the file path: a file at `library/<sub>/<file>` imports
as `@map/<sub>/<file>`. Examples:
Everything under `library/` imports via the `@map/…` alias, mirroring the file path:
```js
import SemanticTreeLegend from '@map/components/legends/SemanticTreeLegend.vue'
@ -531,47 +298,69 @@ import { useMapData } from '@map/composables/useMapData.js'
import langFamilyByIso from '@map/data/langFamilyByIso.json'
```
Prefer subpath imports over the thin `@map` barrel (which exports `MAP_CORE_VERSION` only) so
tree-shaking works. The per-file catalog in [architecture/file-map.md](./architecture/file-map.md) is the
authoritative menu — every `library/` file listed is importable this way. A generated one-line import index can also be produced with
`node internal/generate-library-index.mjs`.
---
Prefer subpath imports over the thin `@map` barrel so tree-shaking works. The menu of what exists:
[reference-library-index.md](./reference-library-index.md) (generated) and
[architecture/file-map.md](./architecture/file-map.md) (every file, one line each).
## Going deeper — the architecture folder
Simple lookups live here; the full contracts, catalogs, and wiring detail live in
[`architecture/`](./architecture/):
| Doc | What it holds |
|---|---|
| [file-map.md](./architecture/file-map.md) | every file, one line each, with the gotcha each carries |
| [data-sources.md](./architecture/data-sources.md) | the `api/` seam: connection contract + checklist |
| [color-strategies.md](./architecture/color-strategies.md) | the strategy contract + legend wiring |
| [components.md](./architecture/components.md) | shared component catalog + conventions |
| [components.md](./architecture/components.md) | shared component catalogue + conventions |
| [i18n.md](./architecture/i18n.md) | the locale contract + add-a-language checklist |
| [embeddability.md](./architecture/embeddability.md) | generated per-map pages + manifest — embeddable by default |
| [app-wrappers.md](./architecture/app-wrappers.md) | the SPA shell: `nav.js` + `PROFILES` contracts |
| [app-wrappers.md](./architecture/app-wrappers.md) | the app shell: `nav.js` + `PROFILES` contracts |
| [poster-generation.md](./architecture/poster-generation.md) | the poster/preview generator pipeline |
| [patterns-and-provenance.md](./architecture/patterns-and-provenance.md) | the two multi-map patterns + provenance |
The **Semantic Tree Legend** component (a reusable hierarchical legend) is documented in
[file-map.md](./architecture/file-map.md#the-semantic-tree-legend-mapcomponentslegendssemantictreelegendvue).
---
## Quick reference — "I want to…"
## Notes and history
| I want to… | Do this |
|---|---|
| Add a new map | copy `plugin-designs/template-bundle/` → rename folder + tag in `index.js` |
| Add a screen to an existing bundle | drop a `.vue` flat in that bundle's folder, request it via `profile-config.profile` |
| Fix a legend / control for ALL maps | edit it once in `library/` (`@map`); rebuild bundles |
| Add a color strategy | new file in `@map/colors/` (shared) or the bundle's `src/colors/` (map-private) |
| Build everything | `bun run build` → `public/doxa-maps/<bundle>/<bundle>.js` |
| Build just one | `BUNDLE=<name> bun run build:bundle` |
| Preview in isolation | `bun run dev` → `http://localhost:5173/plugin-designs/<name>/index.html` |
| See a change on the served site | `bun run build`, then refresh (NOT `dev`) |
| Repoint the API without a rebuild | set `window.MAP_APP_API_URL` before the bundle loads |
| Repoint the Mapbox token | pass `tk` in `profile-config`, or set `window.MAP_TOKEN_URL` / build-config `tokenUrl` |
The measurements behind the rules above, kept for whoever needs the reasoning.
- **Capability citations (2026-09-22).** "Which bundle is the capability-N example" was answered wrongly in
three places — a gate, the README's captions and an authoring skill — because `doxa-vision-card` looks
like the lightest plugin and is capability 2 (it ships an `*.instances.json`). Hence the generated
exemplars file and `check:capability-citations`.
- **Why gates live outside the build.** `check:composite` could never fail inside a full build (every
composite is fresh by construction); `check:library-index` compared mtimes, which a fresh CI checkout sets
equal; `check:shots` first compared dates, then git commit times, and now a recorded bundle hash, because
a byte-identical retake kept its old commit date and read as stale.
- **The Node-import trap (2026-09-22).** A new gate `import()`ed the dashboard config, which transitively
reached the colour registry's `import.meta.glob`, and crashed — while already wired into the build, so
`bun run build` failed for a reason unrelated to the code being built. 18 files used `import.meta.glob`
at the time.
- **Missing dependency names (2026-09-22).** A folder rename rewrote `@capacitor/{android,cli,core}` to a
path-like name, so `bun install` failed in a fresh clone while every existing checkout passed. Hence
`check:dep-names`, which also checks each installed package's own `name`.
- **Machine-specific paths (2026-09-22).** A planted `/home/…` line passed `check:public-hygiene` (which
scans the served tree, not source); `check:no-abs-paths` scans `git ls-files`.
- **Tokens (2026-09-23).** The `doxa.life` token endpoint serves a token restricted to that domain: tile
requests return 403 without its Referer, so shell-driven gates got blank basemaps. The build-config
`pk.` token returns 200 from any origin, which is why every command above reads it. Browser gates were
never affected: pages resolve their token through the ladder and the built bundle carries the working
one. `verify:map-paint` passes under both tokens (it checks GL + attribution, not tiles), so judge tile
loading by the tile response count, not a screenshot — the basemap is Mapbox Light, which looks bare.
- **Share round-trip (2026-09-22).** 8/8 passed with six distinct reopened tags, each matching its plugin
(the two UPG-100 profiles share one tag). A pass count alone cannot catch an SPA fallback that renders
*a* map for every URL.
- **Why SRI identity, not HTTP status.** A platform's app URL can return 200 with a sign-in page; comparing
each live bundle's bytes against the manifest's `integrity` hash is a cryptographic identity proof that
costs nothing to maintain. Re-run `bun run check:catalog-identity` for today's count rather than quoting
an old one.
- **Undocumented gates (2026-09-22).** 26 gates lived outside the build and only nine were listed; two
unlisted ones had been failing for two sessions. A standalone gate nobody knows about does not run —
which is why the table above is the list to run before publishing.
- **`check:dxlife`.** It exits 2 ("clone not found") on any machine without a sibling DX-life clone — a
deliberate third value that `bun run` reports as a failure. It is not in the build, so a clone still builds.
- **Screenshot distinctness.** Two README slots once showed the same view under two captions: the shot
chased a "Next" control that did not exist inside a `try/catch`, and the PNGs differed by 0.16% render
noise, so no checksum caught them.
- **Persecution-safety (2026-09-22).** All three `cities-internet` profiles had zero link bindings and the
live leaf rendered 0 anchors — correct. A work item once batched "gospel-resources + cities-internet must
render real resource links"; taken literally for `cities-internet` it would add exactly the field its
header forbids.

View file

@ -56,12 +56,6 @@ const PROVIDER = argOf('--provider', '')
// Same contract as MAPBOX_KNOWN: printed on every run, owner named, and DELETED in the same commit
// as the owner's fix so main never carries a stale exemption or a red gate on someone else's bug.
const NEIGHBOUR_KNOWN = {
// doxa-research-map/research-map.vue:647 — btn.scrollIntoView({block:'nearest'}) on the active
// .rm-tab when it mounts. 'nearest' still scrolls the PAGE whenever the embed is below the fold:
// on a phone the DX-life page jumps to y≈640 on load. Fix = scroll the tab bar's own scrollLeft
// (the SemanticTreeLegend bringRowToTop pattern, regression 554bf10). Routed 2026-09-25.
'research-map-plus-grid:scroll': 'research-map owner (research-map.vue:647 scrollIntoView)',
'research-map:scroll': 'research-map owner (research-map.vue:647 scrollIntoView)',
// regions-dashboard/region-dashboards.vue:499 URL_SYNC is unconditional, so a nested Regions
// rewrites the host's #anchor (writeUrl) and document.title (syncTitle). The gallery passes
// profile-config.urlSync:false for exactly this; Regions needs to honour it. Routed 2026-09-25.

View file

@ -156,7 +156,12 @@ const HEADER_BYTES = 8192 // only scan the top of each file (header l
const TAG_RE = /^\s*\*\s*@([\w-]+)\s+(.+?)\s*$/gm // JSDoc `* @tag value` lines
const DEFINE_RE = /customElements\.define\(\s*['"]([^'"]+)['"]/g // registered element tags
const isBundleDir = (name) => !name.startsWith('_') && !name.startsWith('.') && !DEST_CONFIG.gatedProfiles.includes(name)
// SCAFFOLDS are source DX copies to start a plugin, not plugins: their SOURCE ships, their placeholder
// pages must not be published. Same by-name rule as internal/plugin-gallery/discover.mjs, so the built
// manifest lists exactly what the dev manifest lists (a blank template-map-a/b in the built studio was
// the only difference, found 2026-09-25). Not gatedProfiles: that list is governance (private data).
const SCAFFOLD_BUNDLES = ['template-bundle']
const isBundleDir = (name) => !name.startsWith('_') && !name.startsWith('.') && !DEST_CONFIG.gatedProfiles.includes(name) && !SCAFFOLD_BUNDLES.includes(name)
// ★ CUSTOM-ELEMENT NAME RULE (HTML spec): lowercase, must contain a hyphen, no spaces.
// @element is NOT a comment — the build copies it VERBATIM into manifest.json and consumers feed

View file

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

View file

@ -41,7 +41,19 @@ import { ref, inject, computed, onMounted, onBeforeUnmount, getCurrentInstance,
import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
const { t, te } = useI18n()
// ★ i18n IS OPTIONAL HERE, NOT REQUIRED. useI18n() THROWS ("Need to install with `app.use`
// function") on any host that never installed vue-i18n, and seven bundles in this repo do not --
// big-picture, doxa-maps-spa, doxa-vision-card, golden-path-check, people-groups-grid,
// plugin-studio, stack-demo. An unguarded call in a LIBRARY component is therefore a blank page
// waiting for the first bare host that renders it: that is exactly how library FilterDropdown
// blanked people-groups-grid everywhere (a production plugin), fixed by studio in 6603dc17.
// This call was mine, added in 9ba6ad7a. Same shape of fix: use the host's translations when it
// has them, the English literal otherwise. `te` becomes a "no, I don't have it" so every caller
// falls through to its own default.
const PINHINT_FALLBACK = { 'messages.pinHint.tapDot': 'Tap a dot to see who still needs prayer' }
let t = (key) => PINHINT_FALLBACK[key] || key
let te = () => false
try { const i18n = useI18n(); t = i18n.t; te = i18n.te } catch (_) { /* no i18n on this host */ }
const uiStore = inject('uiStore', null)
const profileConfig = inject('profileConfig', null)
const isDark = computed(() => uiStore?.theme === 'dark')

View file

@ -29,7 +29,18 @@ import LegendProgressBar from './LegendProgressBar.vue'
// one proven on .dt-scrim / .lt-overlay / .modal-scrim; it is correct-if-enabled, not observed.
import { useViewportScrim } from '../../utils/viewportScrim.js'
const { t, te } = useI18n()
// ★ i18n IS OPTIONAL IN A LIBRARY COMPONENT. useI18n() THROWS on a host that never installed
// vue-i18n, and seven bundles here do not. Unguarded, this legend renders NOTHING on such a host —
// the failure that blanked people-groups-grid via library FilterDropdown (fixed in 6603dc17).
// Same shape of fix as PinHint.vue and PosterWall.vue.
// The fallback humanizes the key's last segment, so a bare host sees readable English
// ("legend.columns.name" -> "Name") rather than a raw key. `te` answers false so every caller
// falls through to its own default — which is what the defaultTab and columnLabel paths expect.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
let t = (k) => humanizeKey(k)
let te = () => false
try { const i18n = useI18n(); t = i18n.t; te = i18n.te } catch (_) { /* no i18n on this host */ }
// Translate a tab's display name from the `tabs.<key>` catalog, keyed by the
// tab's stable `i18nKey` (preferred) or `id`. Falls back to the hardcoded

View file

@ -324,7 +324,25 @@ async function exportPoster(item) {
// `t` for the wall's own labels, `locale` for the card deck's language. Both come
// from the SAME instance so a label and a printed deck can never disagree about
// which language the reader chose.
const { t, locale: wallLocale } = useI18n()
// ★ i18n IS OPTIONAL IN A LIBRARY COMPONENT, NOT REQUIRED. useI18n() THROWS ("Need to install
// with `app.use` function") on any host that never installed vue-i18n, and seven bundles here do
// not — big-picture, doxa-maps-spa, doxa-vision-card, golden-path-check, people-groups-grid,
// plugin-studio, stack-demo. An unguarded call in a component this widely consumed is a BLANK
// PAGE waiting for the first bare host that renders it: exactly how library FilterDropdown
// blanked people-groups-grid, a production plugin, fixed by studio in 6603dc17. Same shape of
// fix as PinHint.vue.
//
// ⚠ THE FALLBACK IS DERIVED, NOT A TABLE. Hardcoding English for these eleven keys would put a
// second copy of the `en` catalog inside this file, and two copies of one fact drift — the exact
// trap behind the embedBase asset-path bug this week. Humanizing the last path segment gives
// "buttons.prayerList" -> "Prayer List", "buttons.cardDeck" -> "Card Deck",
// "buttons.posterMap" -> "Poster Map": readable English that CANNOT go stale, rather than a raw
// key shown to a reader.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
let t = (k) => humanizeKey(k)
let wallLocale = 'en'
try { const i18n = useI18n(); t = i18n.t; wallLocale = i18n.locale } catch (_) { /* no i18n on this host */ }
// ⚠ ONE exportState, not two. The composable owns a ref of its own; if the HUD
// keeps rendering the wall's while the print actions write to the composable's, a
// card-deck print shows NO progress and its errors vanish silently — the mechanism

View file

@ -93,6 +93,34 @@ export function detectLocale() {
/**
* Build a fresh i18n instance. Called once per <doxa-map> element.
*/
/**
* Read a catalogue string WITHOUT the vue-i18n plugin.
*
* WHY THIS EXISTS. Some components are mounted as their OWN custom element by a bundle entry that
* calls defineCustomElement(Component) and installs nothing — people-groups-grid is one. useI18n()
* inside such a component THROWS, and a thrown setup() removes the subtree silently (this repo has
* already paid for that once: three buttons vanished behind ignorable console noise). So a shared
* component that must work in BOTH mounts needs a plugin-free reader, which is all this is.
*
* Falls back locale -> en -> undefined, so a caller can keep its own literal as the last resort and
* a missing translation degrades to English rather than to a key.
*
* @param {string} path dotted, e.g. 'grid.hide_filters'
* @param {string} [locale] defaults to the detected one (?lang= / host / browser)
*/
export function tStatic(path, locale) {
const tryTree = (loc) => {
let node = messages[loc]
for (const part of String(path).split('.')) {
if (!node || typeof node !== 'object') return undefined
node = node[part]
}
return typeof node === 'string' ? node : undefined
}
const want = locale || detectLocale()
return tryTree(want) ?? tryTree('en')
}
export function createAppI18n() {
const locale = detectLocale()
// ⚠ REFLECT AN RTL LOCALE ON THE DOCUMENT. Only one component reads RTL_LOCALES

View file

@ -0,0 +1,83 @@
{
"_note": "SHORT UI LABELS ONLY for the gospel-resources bundle. The explanatory prose on those surfaces stays in English on purpose and is NOT in this file; prose.english is the one translated line that says so.",
"prose": {
"english": "الملاحظات التوضيحية أدناه باللغة الإنجليزية."
},
"mode": {
"gap": "الفجوة",
"coverage": "الموارد المتوفرة"
},
"search": {
"placeholder": "ابحث عن شعب أو لغة أو رمز ISO",
"clear": "مسح البحث",
"none": "لا شيء يطابق هذا الاسم أو اللغة أو الرمز.",
"placeholderShort": "شعب أو لغة أو رمز ISO"
},
"legend": {
"title": "شعوب غير مبلَّغة ولا يوجد لها أي مورد إنجيلي",
"band60": "⁦60%+⁩ بلا أي مورد",
"band30": "⁦30–59%⁩",
"bandSome": "بعض الشعوب",
"bandAll": "كل الشعوب لديها شيء",
"tier2": "الكتاب المقدس والفيلم",
"tier1": "وُجد بعض الموارد",
"tier0": "لا شيء",
"hnr": "يمكنهم السماع، ولا يوجد نص",
"showAll": "عرض كل الشعوب",
"titleCoverage": "شعوب غير مبلَّغة ويوجد لها بعض الموارد الإنجيلية",
"cov80": "⁦80%+⁩ لديهم شيء",
"cov50": "⁦50–79%⁩",
"cov1": "⁦1–49%⁩",
"cov0": "لا شيء لأي منهم",
"tallyEach": "كل",
"tallyIs": "هو شعب واحد —",
"tallyOf": "منها،",
"tallyLink": "لديها رابط"
},
"tabs": {
"groups": "أكبر الشعوب",
"countries": "حسب البلد",
"regions": "حسب المنطقة",
"noprayer": "لا أحد يصلّي",
"hnr": "يسمعون، بلا نص"
},
"stat": {
"language": "اللغة",
"population": "عدد السكان",
"countries": "البلدان",
"peopleGroups": "الشعوب",
"noResource": "لا يوجد أي مورد",
"peopleAffected": "الأشخاص المتأثرون",
"haveScripture": "لديهم الكتاب المقدس",
"haveFilm": "لديهم الفيلم",
"haveAudio": "لديهم كتاب مقدس مسموع",
"signLanguages": "لغات الإشارة",
"growingUnreached": "متزايدون وغير مبلَّغين",
"deathsDay": "الوفيات المقدَّرة يوميًا"
},
"sheet": {
"ownLanguage": "بلغتهم الأم",
"close": "إغلاق",
"loading": "جارٍ تحميل الموارد…"
},
"res": {
"scripture": "الكتاب المقدس",
"film": "الفيلم",
"audio": "صوتي",
"bibleVideo": "فيديو كتابي",
"bibleStories": "Open Bible Stories",
"gospelAudio": "صوتيات الإنجيل",
"gospelPresentation": "عرض للإنجيل",
"scriptureEarth": "Scripture Earth",
"training": "تدريب على التلمذة",
"jesusFilm": "فيلم يسوع"
},
"state": {
"noneFound": "لا شيء",
"noneAtFcbh": "لا شيء في FCBH",
"noneAtGrn": "لا شيء في GRN",
"notInZume": "ليست ضمن لغات Zume الـ54",
"linkChecked": "تم التحقق من الرابط",
"unchecked": "لم يُتحقق منه"
}
}

View file

@ -1,12 +1,13 @@
{
"title": {
"prayer": "تقدم الصلاة",
"engagement": "تقدم الانخراط",
"adoption": "تقدم التبني",
"languageFamilies": "العائلات اللغوية",
"doxaRegions": "مناطق Doxa",
"affinityBlocs": "كتل القرابة",
"gospelResources": "موارد الإنجيل"
"doxaRegions": "مناطق Doxa",
"engagement": "تقدم الانخراط",
"gospelResources": "موارد الإنجيل",
"languageFamilies": "العائلات اللغوية",
"prayer": "تقدم الصلاة",
"upg100List": "قائمة UPG 100 الخاصة بي"
},
"header": {
"collapsed": "وسيلة الإيضاح",

View file

@ -0,0 +1,30 @@
{
"title": "استوديو الإضافات",
"navLabel": "الإضافات حسب القدرة",
"countLabel": "{bundles} حزمة · {elements} إضافة مدرجة هنا",
"changeLanguage": "تغيير اللغة",
"copyEmbed": "نسخ رمز التضمين",
"copy": "نسخ",
"copied": "تم النسخ",
"scriptEmbed": "تضمين عبر سكربت",
"loading": "جارٍ تحميل {name}…",
"couldNotLoad": "تعذّر تحميل {name}.",
"chooseElement": "اختر إضافة من القائمة.",
"filterPlaceholder": "تصفية الإضافات",
"collapseAll": "طي الكل",
"noMatch": "لا توجد إضافة تطابق «{query}».",
"openSection": "فتح {cap}، {label}",
"part": "جزء",
"noStandalone": "بلا صفحة مستقلة",
"cap5": "هيكل التطبيق",
"cap4": "مركّب",
"cap3": "متعدد العروض",
"cap2": "إعداد مسبق",
"cap1": "مفرد",
"toggleList": "إظهار القائمة أو إخفاؤها",
"openViews": "فتح قائمة العروض",
"expandList": "توسيع القائمة",
"collapseList": "طي القائمة",
"collapse": "طي",
"partOf": "جزء من {name}، غير منشور بمفرده"
}

View file

@ -0,0 +1,83 @@
{
"_note": "SHORT UI LABELS ONLY for the gospel-resources bundle. The explanatory prose on those surfaces stays in English on purpose and is NOT in this file; prose.english is the one translated line that says so.",
"prose": {
"english": "Die erläuternden Hinweise unten sind auf Englisch."
},
"mode": {
"gap": "Lücke",
"coverage": "Gefundene Ressourcen"
},
"search": {
"placeholder": "Volksgruppe, Sprache oder ISO-Code suchen",
"clear": "Suche löschen",
"none": "Nichts passt zu diesem Namen, dieser Sprache oder diesem Code.",
"placeholderShort": "Gruppe, Sprache oder ISO-Code"
},
"legend": {
"title": "Unerreichte Volksgruppen ohne gefundene Evangeliums-Ressource",
"band60": "60 %+ ohne jede Ressource",
"band30": "30–59 %",
"bandSome": "einige Gruppen",
"bandAll": "alle Gruppen haben etwas",
"tier2": "Bibeltext und Film",
"tier1": "etwas gefunden",
"tier0": "nichts gefunden",
"hnr": "können hören, kein Text",
"showAll": "Alle Gruppen anzeigen",
"titleCoverage": "Unerreichte Volksgruppen mit einer gefundenen Evangeliums-Ressource",
"cov80": "80 %+ haben etwas",
"cov50": "50–79 %",
"cov1": "1–49 %",
"cov0": "für keinen etwas gefunden",
"tallyEach": "Jeder",
"tallyIs": "ist EINE Volksgruppe —",
"tallyOf": "davon,",
"tallyLink": "mit einem Link"
},
"tabs": {
"groups": "Größte Gruppen",
"countries": "Nach Land",
"regions": "Nach Region",
"noprayer": "Niemand betet",
"hnr": "Hören ja, Text nein"
},
"stat": {
"language": "Sprache",
"population": "Bevölkerung",
"countries": "Länder",
"peopleGroups": "Volksgruppen",
"noResource": "Keine Ressource gefunden",
"peopleAffected": "Betroffene Menschen",
"haveScripture": "Haben Bibeltext",
"haveFilm": "Haben Film",
"haveAudio": "Haben Hörbibel",
"signLanguages": "Gebärdensprachen",
"growingUnreached": "Wachsend & unerreicht",
"deathsDay": "Geschätzte Todesfälle/Tag"
},
"sheet": {
"ownLanguage": "In ihrer eigenen Sprache",
"close": "Schließen",
"loading": "Ressourcen werden geladen…"
},
"res": {
"scripture": "Bibeltext",
"film": "Film",
"audio": "Audio",
"bibleVideo": "Bibelvideo",
"bibleStories": "Open Bible Stories",
"gospelAudio": "Evangeliums-Audio",
"gospelPresentation": "Evangeliums-Darstellung",
"scriptureEarth": "Scripture Earth",
"training": "Jüngerschaftstraining",
"jesusFilm": "JESUS Film"
},
"state": {
"noneFound": "nichts gefunden",
"noneAtFcbh": "nichts bei FCBH",
"noneAtGrn": "nichts bei GRN",
"notInZume": "nicht in Zumes 54",
"linkChecked": "Link geprüft",
"unchecked": "ungeprüft"
}
}

View file

@ -1,12 +1,13 @@
{
"title": {
"prayer": "Gebetsfortschritt",
"engagement": "Engagement-Fortschritt",
"adoption": "Adoptionsfortschritt",
"languageFamilies": "Sprachfamilien",
"doxaRegions": "Doxa-Regionen",
"affinityBlocs": "Affinitätsblöcke",
"gospelResources": "Evangeliumsressourcen"
"doxaRegions": "Doxa-Regionen",
"engagement": "Engagement-Fortschritt",
"gospelResources": "Evangeliumsressourcen",
"languageFamilies": "Sprachfamilien",
"prayer": "Gebetsfortschritt",
"upg100List": "Meine UPG-100-Liste"
},
"header": {
"collapsed": "Legende",

View file

@ -0,0 +1,30 @@
{
"title": "Plugin-Studio",
"navLabel": "Plugins nach Fähigkeit",
"countLabel": "{bundles} Bundles · {elements} Plugins hier aufgeführt",
"changeLanguage": "Sprache ändern",
"copyEmbed": "Einbettung kopieren",
"copy": "Kopieren",
"copied": "Kopiert",
"scriptEmbed": "Skript-Einbettung",
"loading": "{name} wird geladen…",
"couldNotLoad": "{name} konnte nicht geladen werden.",
"chooseElement": "Wählen Sie ein Plugin aus der Liste.",
"filterPlaceholder": "Plugins filtern",
"collapseAll": "Alle einklappen",
"noMatch": "Kein Plugin entspricht „{query}“.",
"openSection": "{cap} öffnen, {label}",
"part": "Teil",
"noStandalone": "keine eigene Seite",
"cap5": "App-Shell",
"cap4": "Komposit",
"cap3": "Mehrfachansicht",
"cap2": "Voreinstellung",
"cap1": "Einzeln",
"toggleList": "Liste ein-/ausblenden",
"openViews": "Ansichtsliste öffnen",
"expandList": "Liste ausklappen",
"collapseList": "Liste einklappen",
"collapse": "Einklappen",
"partOf": "Teil von {name}, nicht eigenständig veröffentlicht"
}

View file

@ -0,0 +1,83 @@
{
"_note": "SHORT UI LABELS ONLY for the gospel-resources bundle. The explanatory prose on those surfaces stays in English on purpose and is NOT in this file: it carries doctrinal and statistical precision — which pools were searched, what \"nothing found\" does and does not claim, why Open Bible Stories is not Scripture — and a mistranslated caveat there would read as authoritative. `prose.english` is the one translated line that says so. When a human-reviewed translation of the prose exists, it gets its own area file and that line comes out.",
"prose": {
"english": "Explanatory notes below are in English."
},
"mode": {
"gap": "Gap",
"coverage": "Resources found"
},
"search": {
"placeholder": "Find a people group, language or ISO code",
"clear": "Clear search",
"none": "Nothing matches that name, language or code.",
"placeholderShort": "Group, language or ISO code"
},
"legend": {
"title": "Unengaged groups with no gospel resource found",
"band60": "60%+ with nothing found",
"band30": "30–59%",
"bandSome": "some groups",
"bandAll": "all groups have some",
"tier2": "Scripture and film",
"tier1": "some resource found",
"tier0": "nothing found",
"hnr": "can hear it, no text found",
"showAll": "Show all groups",
"titleCoverage": "Unengaged groups with some gospel resource found",
"cov80": "80%+ have something found",
"cov50": "50–79%",
"cov1": "1–49%",
"cov0": "nothing found for any",
"tallyEach": "Each",
"tallyIs": "is ONE people group —",
"tallyOf": "of them,",
"tallyLink": "with a link"
},
"tabs": {
"groups": "Largest groups",
"countries": "By country",
"regions": "By region",
"noprayer": "Nobody praying",
"hnr": "Can hear, no text"
},
"stat": {
"language": "Language",
"population": "Population",
"countries": "Countries",
"peopleGroups": "People groups",
"noResource": "No resource found",
"peopleAffected": "People affected",
"haveScripture": "Have scripture",
"haveFilm": "Have film",
"haveAudio": "Have audio Scripture",
"signLanguages": "Sign languages",
"growingUnreached": "Growing & unreached",
"deathsDay": "Est. deaths/day"
},
"sheet": {
"ownLanguage": "In their own language",
"close": "Close",
"loading": "Loading resources…"
},
"res": {
"scripture": "Scripture",
"film": "Film",
"audio": "Audio",
"bibleVideo": "Bible video",
"bibleStories": "Open Bible Stories",
"gospelAudio": "Gospel audio",
"gospelPresentation": "Gospel presentation",
"scriptureEarth": "Scripture Earth",
"training": "Discipleship training",
"jesusFilm": "JESUS Film"
},
"state": {
"noneFound": "none found",
"noneAtFcbh": "none found at FCBH",
"noneAtGrn": "none found at GRN",
"notInZume": "not in Zume's 54",
"linkChecked": "link checked",
"unchecked": "unchecked"
}
}

View file

@ -1,12 +1,13 @@
{
"title": {
"prayer": "Prayer Progress",
"engagement": "Engagement Progress",
"adoption": "Adoption Progress",
"languageFamilies": "Language Families",
"doxaRegions": "Doxa Regions",
"affinityBlocs": "Affinity Blocks",
"gospelResources": "Gospel Resources"
"doxaRegions": "Doxa Regions",
"engagement": "Engagement Progress",
"gospelResources": "Gospel Resources",
"languageFamilies": "Language Families",
"prayer": "Prayer Progress",
"upg100List": "My UPG 100 List"
},
"header": {
"default": "Legend",

View file

@ -0,0 +1,30 @@
{
"title": "Plugin Studio",
"navLabel": "Plugins by capability",
"countLabel": "{bundles} bundles · {elements} plugins listed here",
"changeLanguage": "Change language",
"copyEmbed": "Copy embed",
"copy": "Copy",
"copied": "Copied",
"scriptEmbed": "Script embed",
"loading": "Loading {name}…",
"couldNotLoad": "Could not load {name}.",
"chooseElement": "Choose a plugin from the list.",
"filterPlaceholder": "Filter plugins",
"collapseAll": "Collapse all",
"noMatch": "No plugin matches “{query}”.",
"openSection": "Open {cap}, {label}",
"part": "part",
"noStandalone": "no standalone page",
"cap5": "App shell",
"cap4": "Composite",
"cap3": "Multi-view",
"cap2": "Preset",
"cap1": "Single",
"toggleList": "Toggle list",
"openViews": "Open the list of views",
"expandList": "Expand list",
"collapseList": "Collapse list",
"collapse": "Collapse",
"partOf": "part of {name}, not published on its own"
}

View file

@ -0,0 +1,83 @@
{
"_note": "SHORT UI LABELS ONLY for the gospel-resources bundle. The explanatory prose on those surfaces stays in English on purpose and is NOT in this file; prose.english is the one translated line that says so.",
"prose": {
"english": "Las notas explicativas siguientes están en inglés."
},
"mode": {
"gap": "Brecha",
"coverage": "Recursos encontrados"
},
"search": {
"placeholder": "Busca un pueblo, idioma o código ISO",
"clear": "Borrar búsqueda",
"none": "Nada coincide con ese nombre, idioma o código.",
"placeholderShort": "Pueblo, idioma o código ISO"
},
"legend": {
"title": "Pueblos no alcanzados sin ningún recurso del evangelio",
"band60": "60 %+ sin nada encontrado",
"band30": "30–59 %",
"bandSome": "algunos pueblos",
"bandAll": "todos los pueblos tienen algo",
"tier2": "Escrituras y película",
"tier1": "se encontró algún recurso",
"tier0": "no se encontró nada",
"hnr": "pueden oírlo, sin texto",
"showAll": "Mostrar todos los pueblos",
"titleCoverage": "Pueblos no alcanzados con algún recurso del evangelio",
"cov80": "80 %+ tienen algo",
"cov50": "50–79 %",
"cov1": "1–49 %",
"cov0": "nada para ninguno",
"tallyEach": "Cada",
"tallyIs": "es UN pueblo —",
"tallyOf": "de ellos,",
"tallyLink": "con un enlace"
},
"tabs": {
"groups": "Pueblos más grandes",
"countries": "Por país",
"regions": "Por región",
"noprayer": "Nadie ora",
"hnr": "Oyen, sin texto"
},
"stat": {
"language": "Idioma",
"population": "Población",
"countries": "Países",
"peopleGroups": "Pueblos",
"noResource": "Ningún recurso encontrado",
"peopleAffected": "Personas afectadas",
"haveScripture": "Tienen Escrituras",
"haveFilm": "Tienen película",
"haveAudio": "Tienen Escrituras en audio",
"signLanguages": "Lenguas de señas",
"growingUnreached": "En crecimiento y no alcanzados",
"deathsDay": "Muertes estimadas/día"
},
"sheet": {
"ownLanguage": "En su propio idioma",
"close": "Cerrar",
"loading": "Cargando recursos…"
},
"res": {
"scripture": "Escrituras",
"film": "Película",
"audio": "Audio",
"bibleVideo": "Video bíblico",
"bibleStories": "Open Bible Stories",
"gospelAudio": "Audio del evangelio",
"gospelPresentation": "Presentación del evangelio",
"scriptureEarth": "Scripture Earth",
"training": "Formación de discipulado",
"jesusFilm": "Película JESÚS"
},
"state": {
"noneFound": "nada encontrado",
"noneAtFcbh": "nada en FCBH",
"noneAtGrn": "nada en GRN",
"notInZume": "no está entre los 54 de Zume",
"linkChecked": "enlace verificado",
"unchecked": "sin verificar"
}
}

View file

@ -1,12 +1,13 @@
{
"title": {
"prayer": "Progreso de oración",
"engagement": "Progreso de implicación",
"adoption": "Progreso de adopción",
"languageFamilies": "Familias lingüísticas",
"doxaRegions": "Regiones Doxa",
"affinityBlocs": "Bloques de afinidad",
"gospelResources": "Recursos del Evangelio"
"doxaRegions": "Regiones Doxa",
"engagement": "Progreso de implicación",
"gospelResources": "Recursos del Evangelio",
"languageFamilies": "Familias lingüísticas",
"prayer": "Progreso de oración",
"upg100List": "Mi lista UPG 100"
},
"header": {
"collapsed": "Leyenda",

View file

@ -0,0 +1,30 @@
{
"title": "Estudio de plugins",
"navLabel": "Plugins por capacidad",
"countLabel": "{bundles} paquetes · {elements} plugins listados aquí",
"changeLanguage": "Cambiar idioma",
"copyEmbed": "Copiar código de inserción",
"copy": "Copiar",
"copied": "Copiado",
"scriptEmbed": "Inserción por script",
"loading": "Cargando {name}…",
"couldNotLoad": "No se pudo cargar {name}.",
"chooseElement": "Elija un plugin de la lista.",
"filterPlaceholder": "Filtrar plugins",
"collapseAll": "Contraer todo",
"noMatch": "Ningún plugin coincide con «{query}».",
"openSection": "Abrir {cap}, {label}",
"part": "parte",
"noStandalone": "sin página propia",
"cap5": "Contenedor de aplicación",
"cap4": "Compuesto",
"cap3": "Multivista",
"cap2": "Preajuste",
"cap1": "Individual",
"toggleList": "Mostrar u ocultar la lista",
"openViews": "Abrir la lista de vistas",
"expandList": "Expandir la lista",
"collapseList": "Contraer la lista",
"collapse": "Contraer",
"partOf": "parte de {name}, no publicada por separado"
}

View file

@ -0,0 +1,83 @@
{
"_note": "SHORT UI LABELS ONLY for the gospel-resources bundle. The explanatory prose on those surfaces stays in English on purpose and is NOT in this file; prose.english is the one translated line that says so.",
"prose": {
"english": "Les notes explicatives ci-dessous sont en anglais."
},
"mode": {
"gap": "Écart",
"coverage": "Ressources trouvées"
},
"search": {
"placeholder": "Chercher un peuple, une langue ou un code ISO",
"clear": "Effacer la recherche",
"none": "Rien ne correspond à ce nom, cette langue ou ce code.",
"placeholderShort": "Peuple, langue ou code ISO"
},
"legend": {
"title": "Peuples non atteints sans aucune ressource de l'Évangile",
"band60": "60 %+ sans rien trouvé",
"band30": "30–59 %",
"bandSome": "quelques peuples",
"bandAll": "tous les peuples ont quelque chose",
"tier2": "Écritures et film",
"tier1": "une ressource trouvée",
"tier0": "rien trouvé",
"hnr": "peuvent l'entendre, aucun texte",
"showAll": "Afficher tous les peuples",
"titleCoverage": "Peuples non atteints avec une ressource de l'Évangile",
"cov80": "80 %+ ont quelque chose",
"cov50": "50–79 %",
"cov1": "1–49 %",
"cov0": "rien pour aucun",
"tallyEach": "Chaque",
"tallyIs": "est UN peuple —",
"tallyOf": "d'entre eux,",
"tallyLink": "avec un lien"
},
"tabs": {
"groups": "Peuples les plus nombreux",
"countries": "Par pays",
"regions": "Par région",
"noprayer": "Personne ne prie",
"hnr": "Entendent, sans texte"
},
"stat": {
"language": "Langue",
"population": "Population",
"countries": "Pays",
"peopleGroups": "Peuples",
"noResource": "Aucune ressource trouvée",
"peopleAffected": "Personnes concernées",
"haveScripture": "Ont les Écritures",
"haveFilm": "Ont le film",
"haveAudio": "Ont les Écritures en audio",
"signLanguages": "Langues des signes",
"growingUnreached": "En croissance et non atteints",
"deathsDay": "Décès estimés/jour"
},
"sheet": {
"ownLanguage": "Dans leur propre langue",
"close": "Fermer",
"loading": "Chargement des ressources…"
},
"res": {
"scripture": "Écritures",
"film": "Film",
"audio": "Audio",
"bibleVideo": "Vidéo biblique",
"bibleStories": "Open Bible Stories",
"gospelAudio": "Audio de l'Évangile",
"gospelPresentation": "Présentation de l'Évangile",
"scriptureEarth": "Scripture Earth",
"training": "Formation au discipulat",
"jesusFilm": "Film JÉSUS"
},
"state": {
"noneFound": "rien trouvé",
"noneAtFcbh": "rien chez FCBH",
"noneAtGrn": "rien chez GRN",
"notInZume": "pas dans les 54 de Zume",
"linkChecked": "lien vérifié",
"unchecked": "non vérifié"
}
}

View file

@ -1,12 +1,13 @@
{
"title": {
"prayer": "Progression de la prière",
"engagement": "Progression de l'engagement",
"adoption": "Progression de l'adoption",
"languageFamilies": "Familles de langues",
"doxaRegions": "Régions Doxa",
"affinityBlocs": "Blocs d'affinité",
"gospelResources": "Ressources de l'Évangile"
"doxaRegions": "Régions Doxa",
"engagement": "Progression de l'engagement",
"gospelResources": "Ressources de l'Évangile",
"languageFamilies": "Familles de langues",
"prayer": "Progression de la prière",
"upg100List": "Ma liste UPG 100"
},
"header": {
"collapsed": "Légende",

View file

@ -0,0 +1,30 @@
{
"title": "Studio de plugins",
"navLabel": "Plugins par capacité",
"countLabel": "{bundles} paquets · {elements} plugins listés ici",
"changeLanguage": "Changer de langue",
"copyEmbed": "Copier le code d’intégration",
"copy": "Copier",
"copied": "Copié",
"scriptEmbed": "Intégration par script",
"loading": "Chargement de {name}…",
"couldNotLoad": "Impossible de charger {name}.",
"chooseElement": "Choisissez un plugin dans la liste.",
"filterPlaceholder": "Filtrer les plugins",
"collapseAll": "Tout replier",
"noMatch": "Aucun plugin ne correspond à « {query} ».",
"openSection": "Ouvrir {cap}, {label}",
"part": "partie",
"noStandalone": "pas de page autonome",
"cap5": "Coquille d’application",
"cap4": "Composite",
"cap3": "Multivue",
"cap2": "Préréglage",
"cap1": "Simple",
"toggleList": "Afficher ou masquer la liste",
"openViews": "Ouvrir la liste des vues",
"expandList": "Déplier la liste",
"collapseList": "Replier la liste",
"collapse": "Replier",
"partOf": "partie de {name}, non publiée seule"
}

View file

@ -0,0 +1,83 @@
{
"_note": "SHORT UI LABELS ONLY for the gospel-resources bundle. The explanatory prose on those surfaces stays in English on purpose and is NOT in this file; prose.english is the one translated line that says so.",
"prose": {
"english": "नीचे दी गई व्याख्यात्मक टिप्पणियाँ अंग्रेज़ी में हैं।"
},
"mode": {
"gap": "कमी",
"coverage": "उपलब्ध संसाधन"
},
"search": {
"placeholder": "जनसमूह, भाषा या ISO कोड खोजें",
"clear": "खोज साफ़ करें",
"none": "इस नाम, भाषा या कोड से कुछ भी मेल नहीं खाता।",
"placeholderShort": "जनसमूह, भाषा या ISO कोड"
},
"legend": {
"title": "ऐसे जनसमूह जिन तक सुसमाचार नहीं पहुँचा और कोई संसाधन नहीं मिला",
"band60": "60%+ के पास कुछ नहीं",
"band30": "30–59%",
"bandSome": "कुछ जनसमूह",
"bandAll": "सभी जनसमूहों के पास कुछ है",
"tier2": "पवित्र शास्त्र और फ़िल्म",
"tier1": "कुछ संसाधन मिला",
"tier0": "कुछ नहीं मिला",
"hnr": "सुन सकते हैं, पाठ नहीं",
"showAll": "सभी जनसमूह दिखाएँ",
"titleCoverage": "ऐसे अनपहुँचे जनसमूह जिनके लिए कुछ सुसमाचार संसाधन मिला",
"cov80": "80%+ के पास कुछ है",
"cov50": "50–79%",
"cov1": "1–49%",
"cov0": "किसी के लिए कुछ नहीं",
"tallyEach": "हर",
"tallyIs": "एक जनसमूह है —",
"tallyOf": "में से,",
"tallyLink": "के पास लिंक है"
},
"tabs": {
"groups": "सबसे बड़े जनसमूह",
"countries": "देश के अनुसार",
"regions": "क्षेत्र के अनुसार",
"noprayer": "कोई प्रार्थना नहीं कर रहा",
"hnr": "सुन सकते हैं, पाठ नहीं"
},
"stat": {
"language": "भाषा",
"population": "जनसंख्या",
"countries": "देश",
"peopleGroups": "जनसमूह",
"noResource": "कोई संसाधन नहीं मिला",
"peopleAffected": "प्रभावित लोग",
"haveScripture": "पवित्र शास्त्र उपलब्ध",
"haveFilm": "फ़िल्म उपलब्ध",
"haveAudio": "ऑडियो शास्त्र उपलब्ध",
"signLanguages": "सांकेतिक भाषाएँ",
"growingUnreached": "बढ़ते और अनपहुँचे",
"deathsDay": "अनुमानित मृत्यु/दिन"
},
"sheet": {
"ownLanguage": "उनकी अपनी भाषा में",
"close": "बंद करें",
"loading": "संसाधन लोड हो रहे हैं…"
},
"res": {
"scripture": "पवित्र शास्त्र",
"film": "फ़िल्म",
"audio": "ऑडियो",
"bibleVideo": "बाइबल वीडियो",
"bibleStories": "Open Bible Stories",
"gospelAudio": "सुसमाचार ऑडियो",
"gospelPresentation": "सुसमाचार प्रस्तुति",
"scriptureEarth": "Scripture Earth",
"training": "शिष्यत्व प्रशिक्षण",
"jesusFilm": "यीशु फ़िल्म"
},
"state": {
"noneFound": "कुछ नहीं मिला",
"noneAtFcbh": "FCBH में कुछ नहीं",
"noneAtGrn": "GRN में कुछ नहीं",
"notInZume": "Zume की 54 भाषाओं में नहीं",
"linkChecked": "लिंक जाँचा गया",
"unchecked": "अनजाँचा"
}
}

View file

@ -1,12 +1,13 @@
{
"title": {
"prayer": "प्रार्थना प्रगति",
"engagement": "सहभागिता प्रगति",
"adoption": "गोद लेने की प्रगति",
"languageFamilies": "भाषा परिवार",
"doxaRegions": "Doxa क्षेत्र",
"affinityBlocs": "समानता समूह",
"gospelResources": "सुसमाचार संसाधन"
"doxaRegions": "Doxa क्षेत्र",
"engagement": "सहभागिता प्रगति",
"gospelResources": "सुसमाचार संसाधन",
"languageFamilies": "भाषा परिवार",
"prayer": "प्रार्थना प्रगति",
"upg100List": "मेरी UPG 100 सूची"
},
"header": {
"collapsed": "किंवदंती",

View file

@ -0,0 +1,30 @@
{
"title": "प्लगइन स्टूडियो",
"navLabel": "क्षमता के अनुसार प्लगइन",
"countLabel": "{bundles} बंडल · यहाँ {elements} प्लगइन सूचीबद्ध",
"changeLanguage": "भाषा बदलें",
"copyEmbed": "एम्बेड कोड कॉपी करें",
"copy": "कॉपी करें",
"copied": "कॉपी हो गया",
"scriptEmbed": "स्क्रिप्ट एम्बेड",
"loading": "{name} लोड हो रहा है…",
"couldNotLoad": "{name} लोड नहीं हो सका।",
"chooseElement": "सूची से कोई प्लगइन चुनें।",
"filterPlaceholder": "प्लगइन फ़िल्टर करें",
"collapseAll": "सभी संक्षिप्त करें",
"noMatch": "“{query}” से कोई प्लगइन मेल नहीं खाता।",
"openSection": "{cap} खोलें, {label}",
"part": "भाग",
"noStandalone": "कोई अलग पेज नहीं",
"cap5": "ऐप शेल",
"cap4": "संयुक्त",
"cap3": "बहु-दृश्य",
"cap2": "प्रीसेट",
"cap1": "एकल",
"toggleList": "सूची दिखाएँ या छिपाएँ",
"openViews": "दृश्यों की सूची खोलें",
"expandList": "सूची फैलाएँ",
"collapseList": "सूची संक्षिप्त करें",
"collapse": "संक्षिप्त करें",
"partOf": "{name} का भाग, अलग से प्रकाशित नहीं"
}

View file

@ -0,0 +1,83 @@
{
"_note": "SHORT UI LABELS ONLY for the gospel-resources bundle. The explanatory prose on those surfaces stays in English on purpose and is NOT in this file; prose.english is the one translated line that says so.",
"prose": {
"english": "Le note esplicative qui sotto sono in inglese."
},
"mode": {
"gap": "Divario",
"coverage": "Risorse trovate"
},
"search": {
"placeholder": "Cerca un popolo, una lingua o un codice ISO",
"clear": "Cancella ricerca",
"none": "Nessuna corrispondenza per quel nome, lingua o codice.",
"placeholderShort": "Popolo, lingua o codice ISO"
},
"legend": {
"title": "Popoli non raggiunti senza alcuna risorsa del vangelo",
"band60": "60%+ senza nulla",
"band30": "30–59%",
"bandSome": "alcuni popoli",
"bandAll": "tutti i popoli hanno qualcosa",
"tier2": "Scritture e film",
"tier1": "qualche risorsa trovata",
"tier0": "nulla trovato",
"hnr": "possono ascoltarlo, nessun testo",
"showAll": "Mostra tutti i popoli",
"titleCoverage": "Popoli non raggiunti con qualche risorsa del vangelo",
"cov80": "80%+ hanno qualcosa",
"cov50": "50–79%",
"cov1": "1–49%",
"cov0": "nulla per nessuno",
"tallyEach": "Ogni",
"tallyIs": "è UN popolo —",
"tallyOf": "di questi,",
"tallyLink": "con un link"
},
"tabs": {
"groups": "Popoli più numerosi",
"countries": "Per paese",
"regions": "Per regione",
"noprayer": "Nessuno prega",
"hnr": "Ascoltano, senza testo"
},
"stat": {
"language": "Lingua",
"population": "Popolazione",
"countries": "Paesi",
"peopleGroups": "Popoli",
"noResource": "Nessuna risorsa trovata",
"peopleAffected": "Persone coinvolte",
"haveScripture": "Hanno le Scritture",
"haveFilm": "Hanno il film",
"haveAudio": "Hanno le Scritture in audio",
"signLanguages": "Lingue dei segni",
"growingUnreached": "In crescita e non raggiunti",
"deathsDay": "Decessi stimati/giorno"
},
"sheet": {
"ownLanguage": "Nella loro lingua",
"close": "Chiudi",
"loading": "Caricamento risorse…"
},
"res": {
"scripture": "Scritture",
"film": "Film",
"audio": "Audio",
"bibleVideo": "Video biblico",
"bibleStories": "Open Bible Stories",
"gospelAudio": "Audio del Vangelo",
"gospelPresentation": "Presentazione del Vangelo",
"scriptureEarth": "Scripture Earth",
"training": "Formazione al discepolato",
"jesusFilm": "Film GESÙ"
},
"state": {
"noneFound": "nulla trovato",
"noneAtFcbh": "nulla presso FCBH",
"noneAtGrn": "nulla presso GRN",
"notInZume": "non tra le 54 di Zume",
"linkChecked": "link verificato",
"unchecked": "non verificato"
}
}

View file

@ -1,12 +1,13 @@
{
"title": {
"prayer": "Avanzamento della preghiera",
"engagement": "Avanzamento dell'impegno",
"adoption": "Avanzamento dell'adozione",
"languageFamilies": "Famiglie linguistiche",
"doxaRegions": "Regioni Doxa",
"affinityBlocs": "Blocchi di affinità",
"gospelResources": "Risorse del Vangelo"
"doxaRegions": "Regioni Doxa",
"engagement": "Avanzamento dell'impegno",
"gospelResources": "Risorse del Vangelo",
"languageFamilies": "Famiglie linguistiche",
"prayer": "Avanzamento della preghiera",
"upg100List": "La mia lista UPG 100"
},
"header": {
"collapsed": "Legenda",

View file

@ -0,0 +1,30 @@
{
"title": "Studio dei plugin",
"navLabel": "Plugin per capacità",
"countLabel": "{bundles} pacchetti · {elements} plugin elencati qui",
"changeLanguage": "Cambia lingua",
"copyEmbed": "Copia codice di incorporamento",
"copy": "Copia",
"copied": "Copiato",
"scriptEmbed": "Incorporamento tramite script",
"loading": "Caricamento di {name}…",
"couldNotLoad": "Impossibile caricare {name}.",
"chooseElement": "Scegli un plugin dall’elenco.",
"filterPlaceholder": "Filtra i plugin",
"collapseAll": "Comprimi tutto",
"noMatch": "Nessun plugin corrisponde a «{query}».",
"openSection": "Apri {cap}, {label}",
"part": "parte",
"noStandalone": "nessuna pagina propria",
"cap5": "Shell dell’app",
"cap4": "Composito",
"cap3": "Multivista",
"cap2": "Preimpostato",
"cap1": "Singolo",
"toggleList": "Mostra o nascondi l’elenco",
"openViews": "Apri l’elenco delle viste",
"expandList": "Espandi l’elenco",
"collapseList": "Comprimi l’elenco",
"collapse": "Comprimi",
"partOf": "parte di {name}, non pubblicata da sola"
}

View file

@ -0,0 +1,83 @@
{
"_note": "SHORT UI LABELS ONLY for the gospel-resources bundle. The explanatory prose on those surfaces stays in English on purpose and is NOT in this file; prose.english is the one translated line that says so.",
"prose": {
"english": "As notas explicativas abaixo estão em inglês."
},
"mode": {
"gap": "Lacuna",
"coverage": "Recursos encontrados"
},
"search": {
"placeholder": "Procure um povo, idioma ou código ISO",
"clear": "Limpar busca",
"none": "Nada corresponde a esse nome, idioma ou código.",
"placeholderShort": "Povo, idioma ou código ISO"
},
"legend": {
"title": "Povos não alcançados sem nenhum recurso do evangelho",
"band60": "60%+ sem nada encontrado",
"band30": "30–59%",
"bandSome": "alguns povos",
"bandAll": "todos os povos têm algo",
"tier2": "Escrituras e filme",
"tier1": "algum recurso encontrado",
"tier0": "nada encontrado",
"hnr": "podem ouvir, sem texto",
"showAll": "Mostrar todos os povos",
"titleCoverage": "Povos não alcançados com algum recurso do evangelho",
"cov80": "80%+ têm algo",
"cov50": "50–79%",
"cov1": "1–49%",
"cov0": "nada para ninguém",
"tallyEach": "Cada",
"tallyIs": "é UM povo —",
"tallyOf": "deles,",
"tallyLink": "com um link"
},
"tabs": {
"groups": "Maiores povos",
"countries": "Por país",
"regions": "Por região",
"noprayer": "Ninguém orando",
"hnr": "Ouvem, sem texto"
},
"stat": {
"language": "Idioma",
"population": "População",
"countries": "Países",
"peopleGroups": "Povos",
"noResource": "Nenhum recurso encontrado",
"peopleAffected": "Pessoas afetadas",
"haveScripture": "Têm Escrituras",
"haveFilm": "Têm filme",
"haveAudio": "Têm Escrituras em áudio",
"signLanguages": "Línguas de sinais",
"growingUnreached": "Em crescimento e não alcançados",
"deathsDay": "Mortes estimadas/dia"
},
"sheet": {
"ownLanguage": "No próprio idioma",
"close": "Fechar",
"loading": "Carregando recursos…"
},
"res": {
"scripture": "Escrituras",
"film": "Filme",
"audio": "Áudio",
"bibleVideo": "Vídeo bíblico",
"bibleStories": "Open Bible Stories",
"gospelAudio": "Áudio do evangelho",
"gospelPresentation": "Apresentação do evangelho",
"scriptureEarth": "Scripture Earth",
"training": "Treinamento de discipulado",
"jesusFilm": "Filme JESUS"
},
"state": {
"noneFound": "nada encontrado",
"noneAtFcbh": "nada na FCBH",
"noneAtGrn": "nada na GRN",
"notInZume": "não está entre as 54 da Zume",
"linkChecked": "link verificado",
"unchecked": "não verificado"
}
}

View file

@ -1,12 +1,13 @@
{
"title": {
"prayer": "Progresso da oração",
"engagement": "Progresso do envolvimento",
"adoption": "Progresso da adoção",
"languageFamilies": "Famílias linguísticas",
"doxaRegions": "Regiões Doxa",
"affinityBlocs": "Blocos de afinidade",
"gospelResources": "Recursos do Evangelho"
"doxaRegions": "Regiões Doxa",
"engagement": "Progresso do envolvimento",
"gospelResources": "Recursos do Evangelho",
"languageFamilies": "Famílias linguísticas",
"prayer": "Progresso da oração",
"upg100List": "A minha lista UPG 100"
},
"header": {
"collapsed": "Legenda",

View file

@ -0,0 +1,30 @@
{
"title": "Estúdio de plugins",
"navLabel": "Plugins por capacidade",
"countLabel": "{bundles} pacotes · {elements} plugins listados aqui",
"changeLanguage": "Mudar idioma",
"copyEmbed": "Copiar código de incorporação",
"copy": "Copiar",
"copied": "Copiado",
"scriptEmbed": "Incorporação por script",
"loading": "Carregando {name}…",
"couldNotLoad": "Não foi possível carregar {name}.",
"chooseElement": "Escolha um plugin na lista.",
"filterPlaceholder": "Filtrar plugins",
"collapseAll": "Recolher tudo",
"noMatch": "Nenhum plugin corresponde a “{query}”.",
"openSection": "Abrir {cap}, {label}",
"part": "parte",
"noStandalone": "sem página própria",
"cap5": "Shell do aplicativo",
"cap4": "Composto",
"cap3": "Multivisualização",
"cap2": "Predefinição",
"cap1": "Único",
"toggleList": "Mostrar ou ocultar a lista",
"openViews": "Abrir a lista de visualizações",
"expandList": "Expandir a lista",
"collapseList": "Recolher a lista",
"collapse": "Recolher",
"partOf": "parte de {name}, não publicada separadamente"
}

View file

@ -0,0 +1,83 @@
{
"_note": "SHORT UI LABELS ONLY for the gospel-resources bundle. The explanatory prose on those surfaces stays in English on purpose and is NOT in this file; prose.english is the one translated line that says so.",
"prose": {
"english": "Notele explicative de mai jos sunt în limba engleză."
},
"mode": {
"gap": "Golul",
"coverage": "Resurse găsite"
},
"search": {
"placeholder": "Caută un popor, o limbă sau un cod ISO",
"clear": "Șterge căutarea",
"none": "Nimic nu se potrivește cu acel nume, limbă sau cod.",
"placeholderShort": "Popor, limbă sau cod ISO"
},
"legend": {
"title": "Popoare neatinse, fără nicio resursă a Evangheliei",
"band60": "60%+ fără nimic găsit",
"band30": "30–59%",
"bandSome": "unele popoare",
"bandAll": "toate popoarele au ceva",
"tier2": "Scripturi și film",
"tier1": "s-a găsit o resursă",
"tier0": "nimic găsit",
"hnr": "pot auzi, fără text",
"showAll": "Arată toate popoarele",
"titleCoverage": "Popoare neatinse cu o resursă a Evangheliei",
"cov80": "80%+ au ceva",
"cov50": "50–79%",
"cov1": "1–49%",
"cov0": "nimic pentru niciunul",
"tallyEach": "Fiecare",
"tallyIs": "este UN popor —",
"tallyOf": "dintre ele,",
"tallyLink": "cu un link"
},
"tabs": {
"groups": "Cele mai mari popoare",
"countries": "După țară",
"regions": "După regiune",
"noprayer": "Nimeni nu se roagă",
"hnr": "Aud, fără text"
},
"stat": {
"language": "Limba",
"population": "Populație",
"countries": "Țări",
"peopleGroups": "Popoare",
"noResource": "Nicio resursă găsită",
"peopleAffected": "Persoane afectate",
"haveScripture": "Au Scripturile",
"haveFilm": "Au filmul",
"haveAudio": "Au Scripturi audio",
"signLanguages": "Limbaje semne",
"growingUnreached": "În creștere și neatinse",
"deathsDay": "Decese estimate/zi"
},
"sheet": {
"ownLanguage": "În limba lor",
"close": "Închide",
"loading": "Se încarcă resursele…"
},
"res": {
"scripture": "Scripturi",
"film": "Film",
"audio": "Audio",
"bibleVideo": "Video biblic",
"bibleStories": "Open Bible Stories",
"gospelAudio": "Audio cu Evanghelia",
"gospelPresentation": "Prezentarea Evangheliei",
"scriptureEarth": "Scripture Earth",
"training": "Instruire în ucenicie",
"jesusFilm": "Filmul ISUS"
},
"state": {
"noneFound": "nimic găsit",
"noneAtFcbh": "nimic la FCBH",
"noneAtGrn": "nimic la GRN",
"notInZume": "nu este între cele 54 Zume",
"linkChecked": "link verificat",
"unchecked": "neverificat"
}
}

View file

@ -1,12 +1,13 @@
{
"title": {
"prayer": "Progresul rugăciunii",
"engagement": "Progresul implicării",
"adoption": "Progresul adopției",
"languageFamilies": "Familii de limbi",
"doxaRegions": "Regiuni Doxa",
"affinityBlocs": "Blocuri de afinitate",
"gospelResources": "Resurse ale Evangheliei"
"doxaRegions": "Regiuni Doxa",
"engagement": "Progresul implicării",
"gospelResources": "Resurse ale Evangheliei",
"languageFamilies": "Familii de limbi",
"prayer": "Progresul rugăciunii",
"upg100List": "Lista mea UPG 100"
},
"header": {
"collapsed": "Legendă",

View file

@ -0,0 +1,30 @@
{
"title": "Studio de pluginuri",
"navLabel": "Pluginuri după capacitate",
"countLabel": "{bundles} pachete · {elements} pluginuri listate aici",
"changeLanguage": "Schimbă limba",
"copyEmbed": "Copiază codul de încorporare",
"copy": "Copiază",
"copied": "Copiat",
"scriptEmbed": "Încorporare prin script",
"loading": "Se încarcă {name}…",
"couldNotLoad": "{name} nu a putut fi încărcat.",
"chooseElement": "Alegeți un plugin din listă.",
"filterPlaceholder": "Filtrează pluginurile",
"collapseAll": "Restrânge tot",
"noMatch": "Niciun plugin nu corespunde cu „{query}”.",
"openSection": "Deschide {cap}, {label}",
"part": "parte",
"noStandalone": "fără pagină proprie",
"cap5": "Cadru de aplicație",
"cap4": "Compus",
"cap3": "Vizualizări multiple",
"cap2": "Presetat",
"cap1": "Simplu",
"toggleList": "Afișează sau ascunde lista",
"openViews": "Deschide lista de vizualizări",
"expandList": "Extinde lista",
"collapseList": "Restrânge lista",
"collapse": "Restrânge",
"partOf": "parte din {name}, nepublicată separat"
}

View file

@ -0,0 +1,83 @@
{
"_note": "SHORT UI LABELS ONLY for the gospel-resources bundle. The explanatory prose on those surfaces stays in English on purpose and is NOT in this file; prose.english is the one translated line that says so.",
"prose": {
"english": "Пояснительные примечания ниже — на английском языке."
},
"mode": {
"gap": "Пробел",
"coverage": "Найденные ресурсы"
},
"search": {
"placeholder": "Найдите народ, язык или код ISO",
"clear": "Очистить поиск",
"none": "Ничего не найдено по этому названию, языку или коду.",
"placeholderShort": "Народ, язык или код ISO"
},
"legend": {
"title": "Недостигнутые народы, для которых не найдено ни одного ресурса",
"band60": "60%+ без единого ресурса",
"band30": "30–59%",
"bandSome": "часть народов",
"bandAll": "у всех народов что-то есть",
"tier2": "Писание и фильм",
"tier1": "что-то найдено",
"tier0": "ничего не найдено",
"hnr": "могут услышать, текста нет",
"showAll": "Показать все народы",
"titleCoverage": "Недостигнутые народы, для которых найдены ресурсы",
"cov80": "у 80%+ что-то есть",
"cov50": "50–79%",
"cov1": "1–49%",
"cov0": "ни для кого ничего",
"tallyEach": "Каждая",
"tallyIs": "— это ОДИН народ,",
"tallyOf": "из них",
"tallyLink": "со ссылкой"
},
"tabs": {
"groups": "Крупнейшие народы",
"countries": "По странам",
"regions": "По регионам",
"noprayer": "Никто не молится",
"hnr": "Слышат, но не читают"
},
"stat": {
"language": "Язык",
"population": "Численность",
"countries": "Страны",
"peopleGroups": "Народы",
"noResource": "Ресурсов не найдено",
"peopleAffected": "Затронутые люди",
"haveScripture": "Есть Писание",
"haveFilm": "Есть фильм",
"haveAudio": "Есть аудио-Писание",
"signLanguages": "Жестовые языки",
"growingUnreached": "Растущие и недостигнутые",
"deathsDay": "Оценка смертей в день"
},
"sheet": {
"ownLanguage": "На их родном языке",
"close": "Закрыть",
"loading": "Загрузка ресурсов…"
},
"res": {
"scripture": "Писание",
"film": "Фильм",
"audio": "Аудио",
"bibleVideo": "Библейское видео",
"bibleStories": "Open Bible Stories",
"gospelAudio": "Аудио с Евангелием",
"gospelPresentation": "Изложение Евангелия",
"scriptureEarth": "Scripture Earth",
"training": "Обучение ученичеству",
"jesusFilm": "Фильм «Иисус»"
},
"state": {
"noneFound": "ничего не найдено",
"noneAtFcbh": "нет в FCBH",
"noneAtGrn": "нет в GRN",
"notInZume": "нет среди 54 языков Zume",
"linkChecked": "ссылка проверена",
"unchecked": "не проверено"
}
}

View file

@ -1,12 +1,13 @@
{
"title": {
"prayer": "Прогресс молитвы",
"engagement": "Прогресс вовлечённости",
"adoption": "Прогресс усыновления",
"languageFamilies": "Языковые семьи",
"doxaRegions": "Регионы Doxa",
"affinityBlocs": "Блоки родства",
"gospelResources": "Евангельские ресурсы"
"doxaRegions": "Регионы Doxa",
"engagement": "Прогресс вовлечённости",
"gospelResources": "Евангельские ресурсы",
"languageFamilies": "Языковые семьи",
"prayer": "Прогресс молитвы",
"upg100List": "Мой список UPG 100"
},
"header": {
"collapsed": "Условные обозначения",

View file

@ -0,0 +1,30 @@
{
"title": "Студия плагинов",
"navLabel": "Плагины по возможностям",
"countLabel": "Здесь: пакетов {bundles} · плагинов {elements}",
"changeLanguage": "Сменить язык",
"copyEmbed": "Скопировать код встраивания",
"copy": "Копировать",
"copied": "Скопировано",
"scriptEmbed": "Встраивание через скрипт",
"loading": "Загрузка {name}…",
"couldNotLoad": "Не удалось загрузить {name}.",
"chooseElement": "Выберите плагин из списка.",
"filterPlaceholder": "Фильтр плагинов",
"collapseAll": "Свернуть всё",
"noMatch": "Нет плагинов, соответствующих «{query}».",
"openSection": "Открыть {cap}, {label}",
"part": "часть",
"noStandalone": "нет отдельной страницы",
"cap5": "Оболочка приложения",
"cap4": "Составной",
"cap3": "Несколько видов",
"cap2": "Предустановка",
"cap1": "Одиночный",
"toggleList": "Показать или скрыть список",
"openViews": "Открыть список представлений",
"expandList": "Развернуть список",
"collapseList": "Свернуть список",
"collapse": "Свернуть",
"partOf": "часть {name}, не публикуется отдельно"
}

View file

@ -0,0 +1,83 @@
{
"_note": "SHORT UI LABELS ONLY for the gospel-resources bundle. The explanatory prose on those surfaces stays in English on purpose and is NOT in this file; prose.english is the one translated line that says so.",
"prose": {
"english": "以下说明性文字为英文。"
},
"mode": {
"gap": "缺口",
"coverage": "已找到的资源"
},
"search": {
"placeholder": "搜索族群、语言或 ISO 代码",
"clear": "清除搜索",
"none": "没有符合该名称、语言或代码的结果。",
"placeholderShort": "族群、语言或 ISO 代码"
},
"legend": {
"title": "尚未接触且未找到任何福音资源的族群",
"band60": "60% 以上一无所有",
"band30": "30–59%",
"bandSome": "部分族群",
"bandAll": "所有族群都有一些",
"tier2": "圣经与影片",
"tier1": "找到部分资源",
"tier0": "一无所获",
"hnr": "能听,但没有文字",
"showAll": "显示所有族群",
"titleCoverage": "尚未接触但已找到部分福音资源的族群",
"cov80": "80% 以上有资源",
"cov50": "50–79%",
"cov1": "1–49%",
"cov0": "无人拥有",
"tallyEach": "每个",
"tallyIs": "代表一个族群 —",
"tallyOf": "个族群中,",
"tallyLink": "个有链接"
},
"tabs": {
"groups": "最大的族群",
"countries": "按国家",
"regions": "按地区",
"noprayer": "无人代祷",
"hnr": "能听,无文字"
},
"stat": {
"language": "语言",
"population": "人口",
"countries": "国家",
"peopleGroups": "族群",
"noResource": "未找到资源",
"peopleAffected": "受影响人数",
"haveScripture": "有圣经",
"haveFilm": "有影片",
"haveAudio": "有有声圣经",
"signLanguages": "手语",
"growingUnreached": "增长中且未接触",
"deathsDay": "每日估计死亡人数"
},
"sheet": {
"ownLanguage": "以他们自己的语言",
"close": "关闭",
"loading": "正在加载资源…"
},
"res": {
"scripture": "圣经",
"film": "影片",
"audio": "音频",
"bibleVideo": "圣经视频",
"bibleStories": "Open Bible Stories",
"gospelAudio": "福音音频",
"gospelPresentation": "福音简介",
"scriptureEarth": "Scripture Earth",
"training": "门徒训练",
"jesusFilm": "《耶稣传》"
},
"state": {
"noneFound": "一无所获",
"noneAtFcbh": "FCBH 没有",
"noneAtGrn": "GRN 没有",
"notInZume": "不在 Zume 的 54 种语言内",
"linkChecked": "链接已核实",
"unchecked": "未核实"
}
}

View file

@ -1,12 +1,13 @@
{
"title": {
"prayer": "祷告进度",
"engagement": "参与进度",
"adoption": "认领进度",
"languageFamilies": "语系",
"doxaRegions": "Doxa 区域",
"affinityBlocs": "亲缘板块",
"gospelResources": "福音资源"
"doxaRegions": "Doxa 区域",
"engagement": "参与进度",
"gospelResources": "福音资源",
"languageFamilies": "语系",
"prayer": "祷告进度",
"upg100List": "我的 UPG 100 名单"
},
"header": {
"collapsed": "图例",

View file

@ -0,0 +1,30 @@
{
"title": "插件工作室",
"navLabel": "按能力分类的插件",
"countLabel": "此处列出 {bundles} 个包 · {elements} 个插件",
"changeLanguage": "切换语言",
"copyEmbed": "复制嵌入代码",
"copy": "复制",
"copied": "已复制",
"scriptEmbed": "脚本嵌入",
"loading": "正在加载 {name}…",
"couldNotLoad": "无法加载 {name}。",
"chooseElement": "请从列表中选择一个插件。",
"filterPlaceholder": "筛选插件",
"collapseAll": "全部折叠",
"noMatch": "没有与“{query}”匹配的插件。",
"openSection": "打开 {cap},{label}",
"part": "部分",
"noStandalone": "无独立页面",
"cap5": "应用外壳",
"cap4": "组合",
"cap3": "多视图",
"cap2": "预设",
"cap1": "单一",
"toggleList": "显示或隐藏列表",
"openViews": "打开视图列表",
"expandList": "展开列表",
"collapseList": "折叠列表",
"collapse": "折叠",
"partOf": "{name} 的一部分,不单独发布"
}

View file

@ -94,7 +94,7 @@
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header }">
<header class="vtl-top" v-if="header">
<button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
aria-label="Toggle list">☰</button>
:aria-label="label('toggleList')">☰</button>
<span class="vtl-brand"><slot name="brand" /></span>
<b class="vtl-title">{{ title }}</b>
<span class="vtl-meta"><slot name="meta" /></span>
@ -114,7 +114,7 @@
floating button over an open drawer would just cover a row. -->
<button v-if="narrow && !header && !navOpen"
class="vtl-fab" type="button" :aria-expanded="String(navOpen)"
@click="navOpen = true" aria-label="Open the list of views">☰</button>
@click="navOpen = true" :aria-label="label('openViews')">☰</button>
<div class="vtl-body">
<!-- Rail visibility is CSS-driven via `transform`, never v-show/display:none — a display:none element
@ -130,10 +130,10 @@
<button
type="button" class="vtl-collapse"
:aria-expanded="String(!railCollapsed)"
:aria-label="railCollapsed ? 'Expand list' : 'Collapse list'"
:aria-label="railCollapsed ? label('expandList') : label('collapseList')"
@click="toggleRail">
<span class="vtl-collapse-ic" aria-hidden="true" v-html="iconSvg('chevron')"></span>
<span class="vtl-collapse-tx">Collapse</span>
<span class="vtl-collapse-tx">{{ label('collapse') }}</span>
</button>
<!-- #rail: a host that needs MORE than tabs (e.g. plugin-studio's tree) supplies the whole rail
@ -475,7 +475,12 @@ const VTL_SHADOW_CSS = `
drawer that slides in/out via transform (never a layout property), so opening/closing the drawer
never reflows the content. Content is full-width (left:0) and stays put; the drawer floats over it. */
.vtl-nav { z-index:20; width:256px; box-shadow:0 0 28px rgba(0,0,0,.55); transform:translateX(-100%); }
.vtl.nav-open .vtl-nav { transform:translateX(0); }
/* OPEN DRAWER ABOVE THE PLUGIN'S OWN LAYERS. Mounted content shares this stacking context, and
plugins raise their chrome (the research dashboard's filter bar is z-index:55), so at z-index 20
the open drawer had a plugin's filter row painted across it — measured 2026-09-25 at 390px.
Only while OPEN on a phone: that is when the drawer is the one thing you can use, and desktop
and plugin modal scrims keep their existing order. */
.vtl.nav-open .vtl-nav { transform:translateX(0); z-index:1000; }
.vtl-main { left:0; }
/* The desktop slim-rail collapse has no meaning on a phone — reconcileForWidth() clears railCollapsed
at this width, but neutralize its CSS too (belt-and-suspenders) so a full labelled drawer always
@ -506,6 +511,9 @@ export default defineComponent({
// When `groups` is non-empty it takes precedence over `items`.
groups: { type: Array, default: () => [] },
navLabel: { type: String, default: 'Sections' },
// The shell's own words, for a localised host: { toggleList, openViews, expandList, collapseList,
// collapse }. Any key left out keeps its English default, so existing hosts are unchanged.
labels: { type: Object, default: () => ({}) },
// Start with the rail open (desktop). Auto-collapses on narrow screens after mount.
initialNavOpen: { type: Boolean, default: true },
// Render the top bar (#brand / title / #meta). Pass false for a sidebar-only, full-page element
@ -613,6 +621,10 @@ export default defineComponent({
this.navOpen = true
}
},
label(key) {
const EN = { toggleList: 'Toggle list', openViews: 'Open the list of views', expandList: 'Expand list', collapseList: 'Collapse list', collapse: 'Collapse' }
return (this.labels && this.labels[key]) || EN[key]
},
// Open the desktop rail if it is in its slim state (a #rail host calls this when its slim-strip
// icons are clicked: "show me that section" means the full rail). No-op when already open.
expandRail() { if (!this.narrow && this.railCollapsed) this.railCollapsed = false },
@ -921,7 +933,12 @@ export default defineComponent({
drawer that slides in/out via transform (never a layout property), so opening/closing the drawer
never reflows the content. Content is full-width (left:0) and stays put; the drawer floats over it. */
.vtl-nav { z-index:20; width:256px; box-shadow:0 0 28px rgba(0,0,0,.55); transform:translateX(-100%); }
.vtl.nav-open .vtl-nav { transform:translateX(0); }
/* OPEN DRAWER ABOVE THE PLUGIN'S OWN LAYERS. Mounted content shares this stacking context, and
plugins raise their chrome (the research dashboard's filter bar is z-index:55), so at z-index 20
the open drawer had a plugin's filter row painted across it — measured 2026-09-25 at 390px.
Only while OPEN on a phone: that is when the drawer is the one thing you can use, and desktop
and plugin modal scrims keep their existing order. */
.vtl.nav-open .vtl-nav { transform:translateX(0); z-index:1000; }
.vtl-main { left:0; }
/* The desktop slim-rail collapse has no meaning on a phone — reconcileForWidth() clears railCollapsed
at this width, but neutralize its CSS too (belt-and-suspenders) so a full labelled drawer always

View file

@ -32,6 +32,7 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { DataSourceManager } from '@map/utils/DataSourceManager.js'
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
useShadowStyles(`
.apg-root { position:absolute;inset:0;overflow:hidden;
@ -90,8 +91,9 @@ const isDark = computed(() => profileConfig.value?.isDark === true)
const SHARE_EMBED_PATH = 'all-people-groups/all-people-groups/index.html'
const mapProvider = resolveMapProvider(profileConfig)
const assetsBase = computed(() =>
String(profileConfig?.value?.assetsBase || '/assets').replace(/\/+$/, ''))
// Through the shared resolver, not a '/assets' fallback: that root path 404s whenever the tree
// is served below the site root, and inside the Research Gallery no assetsBase is passed at all.
const assetsBase = computed(() => resolveAssetsBase(profileConfig?.value || {}))
// ─── The four categories (the Driver's palette) ──────────────────────────────
const CATS = [

View file

@ -643,8 +643,26 @@ watch(() => activeTabId?.value, async (newTabId, oldTabId) => {
else uiStore.collapseLegend?.()
}
await nextTick()
const btn = tabBar.value?.querySelector('.rm-tab.active')
btn?.scrollIntoView?.({ inline: 'center', block: 'nearest', behavior: 'smooth' })
// ★ SCROLL THE TAB BAR, NOT THE PAGE. This was
// btn.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' })
// and 'nearest' does not mean "do not scroll the page" — it means "scroll every scrollable
// ancestor the least amount needed", and the HOST PAGE is an ancestor. MEASURED by rega's embed
// sweep: a partner page carrying this map below the fold jumps to y≈640 the moment it mounts, on
// a phone, where Simple Research is the default tab. The reader asked for nothing; the page moved
// under them.
// Centre the button INSIDE its own strip instead — the same shape as SemanticTreeLegend's
// bringRowToTop, which scrolls the container it found rather than asking the platform to walk
// ancestors. An element that only ever writes its own scrollLeft cannot move anybody's page.
const bar = tabBar.value
const btn = bar?.querySelector('.rm-tab.active')
if (bar && btn && bar.scrollWidth > bar.clientWidth + 1) {
const br = bar.getBoundingClientRect(), tr = btn.getBoundingClientRect()
const target = bar.scrollLeft + (tr.left - br.left) - (br.width - tr.width) / 2
const max = bar.scrollWidth - bar.clientWidth
const left = Math.max(0, Math.min(max, target))
if (typeof bar.scrollTo === 'function') bar.scrollTo({ left, behavior: 'smooth' })
else bar.scrollLeft = left
}
})
const mapContainer = ref(null)
const appReady = ref(false)

View file

@ -400,7 +400,7 @@ useShadowStyles(
</script>
<template>
<div class="gc" :class="{ 'is-dark': isDark }">
<div class="gc" dir="ltr" :class="{ 'is-dark': isDark }">
<h2 class="gc-h">Gospel Resources — by Language</h2>
<p class="gc-sub">
{{ fmt(nGroups) }} people groups collapse to {{ fmt(nLang) }} languages, joined on exact

View file

@ -2,12 +2,12 @@
/**
* data-tables.vue — the GOSPEL RESOURCES list view.
*
* @description Eight tables covering both halves of one question: every gospel resource with its real URL and how much Scripture it is, and the 3,457 people groups where nothing was found with the pools searched recorded per row, plus bridge languages, city bridges, the deaf gap and access against population growth.
* @description Eleven tables covering both halves of one question: every gospel resource with its real URL and how much Scripture it is, and the 1,613 people groups where nothing was found with the pools searched recorded per row, plus translation priority, bridge languages, city bridges, the deaf gap and access against population growth.
*
* Eight tables, and the first two answer the two halves of one question. "Every Resource
* + Link" lists what EXISTS — 3,550 real, openable resources across 2,686 languages, each
* Eleven tables, and the first two answer the two halves of one question. "Every Resource
* + Link" lists what EXISTS — 8,437 real, openable resources across 4,231 languages, each
* named and scoped as a Full Bible, New Testament or portions. "Where Nothing Was Found"
* lists what does NOT — 3,457 people groups, 347 million people — with each row carrying
* lists what does NOT — 1,613 people groups, 208 million people — with each row carrying
* the pools that were searched and the pools that were not, so an exported CSV can never
* be read as a claim about the world.
*
@ -206,7 +206,7 @@ useShadowStyles(
</script>
<template>
<div class="gt" :class="{ 'is-dark': isDark }">
<div class="gt" dir="ltr" :class="{ 'is-dark': isDark }">
<h2 class="gt-h">Gospel Resources — Data</h2>
<p class="gt-sub">
<!-- Named three media while the tables below carry seven. Kept short by naming the

View file

@ -2,8 +2,15 @@
/**
* gospel-dashboard.vue — Capability-4 composite: the gospel map above its own tables.
*
* @element gospel-resources-dashboard
* @description The gospel map and its tables as one scrolling element: 12,379 people-group pins with the real Scripture, film and Bible-story links for each group's own language, above every resource with its URL and the 3,457 groups where nothing was found, exportable as CSV.
* @element gospel-resources-map
*
* ★ THE TAG IS THE BUNDLE'S, NOT THE PROFILE'S. This said gospel-resources-dashboard, a tag
* nothing defines: index.js registers exactly one element and every profile is selected
* through its profile-config, which is why all five generated leaves embed
* <gospel-resources-map>. The manifest reads this line, so it published an element name a
* consumer could copy into a page and get nothing at all — invisible in dev, where the leaf
* HTML is what gets loaded, and only findable on a built tree.
* @description The gospel map and its tables as one scrolling element: 12,379 people-group pins with the real Scripture, film and Bible-story links for each group's own language, above every resource with its URL and the 1,613 groups where nothing was found, exportable as CSV.
*
* WHY A COMPOSITE AND NOT A THIRD DATASET. The Driver asked for "a gospel resources
* dashboard if possible", and the two halves already existed as separate profiles that a

View file

@ -41,7 +41,10 @@ export function resourceRows(c) {
}
add(c.sv || 'Scripture in this language', 'Scripture', c.s, c.ss || '')
add('JESUS Film', 'Film', c.f)
/* c.fT is set only where the JESUS Film component does NOT carry this language and a
different JESUS Film Project feature does — The Savior, Magdalena. Calling that "JESUS
Film" would be the same class of lie as calling Open Bible Stories Scripture. */
add(c.fT || 'JESUS Film', 'Film', c.f)
for (const d of c.d || []) {
add(d.s, /^Open Bible Stories/.test(d.s) ? 'Bible Stories' : 'Scripture', d.u, d.sc || '')
}

View file

@ -284,7 +284,7 @@ useShadowStyles(
</script>
<template>
<div class="gr" :class="{ 'is-dark': isDark }">
<div class="gr" dir="ltr" :class="{ 'is-dark': isDark }">
<h2 class="gr-h">Gospel Resources by Language</h2>
<p class="gr-sub">
Which unengaged people groups have scripture <strong>in their own language</strong> —

View file

@ -69,6 +69,8 @@
* disagree, and the 5-country string above parses to exactly its 5 real names.
*/
import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import enGospel from '@map/i18n/locales/en/gospel.json'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { addMapAttribution } from '@map/composables/useMapAttribution'
@ -111,18 +113,20 @@ const mode = ref('gap')
// Bands, not a continuous ramp: a reader can hold four categories, not a gradient.
const BANDS = [
{ id: 'critical', label: '60%+ with nothing found', color: '#b42318' },
{ id: 'high', label: '30–59%', color: '#e07a2b' },
{ id: 'partial', label: 'some groups', color: '#e8c15a' },
{ id: 'covered', label: 'all groups have some', color: '#10b981' },
/* labelKey, not label: the band names are rendered through t() so they translate with
the rest of the chrome. The id and the colour are data and stay as they are. */
{ id: 'critical', labelKey: 'gospel.legend.band60', color: '#b42318' },
{ id: 'high', labelKey: 'gospel.legend.band30', color: '#e07a2b' },
{ id: 'partial', labelKey: 'gospel.legend.bandSome', color: '#e8c15a' },
{ id: 'covered', labelKey: 'gospel.legend.bandAll', color: '#10b981' },
]
// Same four colours read the other way round: green now means "most groups have
// something found", so the eye is not asked to re-learn the palette per mode.
const COVER_BANDS = [
{ id: 'c-high', label: '80%+ have something found', color: '#10b981' },
{ id: 'c-mid', label: '50–79%', color: '#e8c15a' },
{ id: 'c-low', label: '1–49%', color: '#e07a2b' },
{ id: 'c-none', label: 'nothing found for any', color: '#b42318' },
{ id: 'c-high', labelKey: 'gospel.legend.cov80', color: '#10b981' },
{ id: 'c-mid', labelKey: 'gospel.legend.cov50', color: '#e8c15a' },
{ id: 'c-low', labelKey: 'gospel.legend.cov1', color: '#e07a2b' },
{ id: 'c-none', labelKey: 'gospel.legend.cov0', color: '#b42318' },
]
const activeBands = computed(() => (mode.value === 'gap' ? BANDS : COVER_BANDS))
@ -597,6 +601,42 @@ const pinsByIso = computed(() => {
// drift: a key built differently here would silently match nothing.
// Their directory names are file-format shorthand; a worker reading the sheet should see
// what they can actually open, not the folder name.
/**
* SHORT LABELS ARE TRANSLATED; THE EXPLANATORY PROSE IS NOT, AND THAT IS A DECISION.
*
* Every string on this surface used to be hard-coded English, so the map rendered identical
* English under ?lang=ar, zh, ru and hi — measured, 3,018 words either way and not one
* non-Latin character. The chrome a reader navigates by is now translated in all eleven
* locales from library/i18n/locales/<lc>/gospel.json.
*
* The prose is deliberately left in English and marked dir="ltr" where it sits. It carries
* doctrinal and statistical precision — which pools were searched, what "nothing found" does
* and does not claim, why Open Bible Stories is not Scripture — and a machine-translated
* caveat would read as authoritative while being wrong. gospel.prose.english is the one
* translated line that tells the reader, in their own language, that the notes are English.
* It stays extractable: when a human-reviewed translation exists it gets its own area file
* and that line comes out.
*
* i18n IS OPTIONAL. useI18n() throws when the host never called app.use(i18n), and this
* bundle is embeddable in pages that have not — the same failure that blanked a production
* plugin from an unguarded call in FilterDropdown. English is the fallback, never a blank.
*/
/* The fallback resolves against the SHIPPED ENGLISH CATALOG (imported at the top), not the
key. Returning the last dot-segment would have put "gap" and "deathsDay" on screen — a
broken-looking label is not graceful degradation, it just moves the bug. */
const enLookup = (key) =>
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), enGospel)
let t = (key) => enLookup(key) ?? String(key)
try {
const i18n = useI18n()
t = (key) => { const v = i18n.t(key); return v === key ? (enLookup(key) ?? key) : v }
} catch (_) { /* no i18n on this host: the English catalog above is the fallback */ }
/* Whether to show the "these notes are in English" line at all. Read from the same i18n
instance the labels come from, so the two can never disagree. */
const isEnglish = (() => {
try { return String(useI18n().locale.value || 'en').split('-')[0] === 'en' } catch { return true }
})()
const SE_KIND = { PDF: 'PDF', audio: 'audio', sab: 'app', study: 'study notes', viewer: 'read online' }
const seKinds = (k) => (Array.isArray(k) ? k : []).map((x) => SE_KIND[x] || x).join(', ')
@ -630,6 +670,10 @@ function groupAt(i) {
_iso3: r[3] || '',
scripture_url: c.s || '',
film_url: c.f || '',
/* Empty except where the JESUS component does not carry this language and another
JESUS Film Project feature does. See gospel-resource-rows.js — the chip must say
which film it is, not which film we usually mean. */
film_title: c.fT || '',
has_audio: c.a === 1 ? true : c.a === 0 ? false : 'unknown',
has_open_bible_stories: !!c.o,
door43: Array.isArray(c.d) ? c.d : [],
@ -1014,6 +1058,7 @@ useShadowStyles(
'padding:3px 8px;border-radius:6px;cursor:pointer;margin-left:4px;min-height:24px}' +
'.gm-pinkey-h{font-size:11px;font-weight:600;margin-bottom:4px}' +
'.gm-caveat{margin-top:5px;font-size:10.5px;opacity:.72;line-height:1.4}' +
'.gm-prose-note{opacity:.9;font-weight:600}' +
// DETAIL: a bottom sheet on a phone, a corner card on desktop.
// ON A PHONE THE SHEET IS THE PAGE, and the shared pin hint sits on top of it. The hint
// is a library component with z-index 2147482000 and a deliberate "only the reader closes
@ -1146,6 +1191,14 @@ useShadowStyles(
</script>
<template>
<!-- THE ROOT FOLLOWS THE LOCALE; THE ENGLISH PROSE DOES NOT.
This root carried dir="ltr" while every string here was English — an RTL flip over
English text had clipped the legend title's first letter, rendered "30-59%" as
"59%-30", and thrown sentence-ending periods to the START of lines. The chrome is
translated now, so Arabic gets the RTL layout it should have, and the blanket
override is gone. What stays LTR is each block of English EXPLANATORY PROSE, marked
where it sits: those are still English by decision, and English inside an RTL box is
the defect this replaced. -->
<div class="gm" :class="{ 'is-dark': isDark, 'has-sheet': !!(selected || showGapList) }">
<div ref="mapEl" class="gm-map"></div>
@ -1166,12 +1219,12 @@ useShadowStyles(
a reader an ISO CODE is accepted at all. The short form keeps all three things it
accepts; the aria-label stays long, because a screen reader has no width. -->
<input type="search" class="gm-q" v-model="q"
:placeholder="narrow ? 'Group, language or ISO code' : 'Find a people group, language or ISO code'"
:placeholder="narrow ? t('gospel.search.placeholderShort') : t('gospel.search.placeholder')"
aria-label="Find a people group, language or ISO code">
<button v-if="q" type="button" class="gm-qx" @click="q = ''" aria-label="Clear search">&times;</button>
<button v-if="q" type="button" class="gm-qx" @click="q = ''" :aria-label="t('gospel.search.clear')">&times;</button>
</div>
<div v-if="q.trim().length >= 2" class="gm-qres">
<div v-if="!matches.length" class="gm-qnone">Nothing matches that name, language or code.</div>
<div v-if="!matches.length" class="gm-qnone">{{ t('gospel.search.none') }}</div>
<template v-else>
<button v-for="m in matches.slice(0, 8)" :key="m.i" type="button" class="gm-qrow" @click="gotoMatch(m)">
<i class="gm-dot" :style="{ background: PIN_COLOR[m.tier] }"></i>
@ -1183,8 +1236,8 @@ useShadowStyles(
</div>
<div class="gm-modes">
<button type="button" class="gm-mode" :class="{ on: mode === 'gap' }" @click="setMode('gap')">Gap</button>
<button type="button" class="gm-mode" :class="{ on: mode === 'coverage' }" @click="setMode('coverage')">Resources found</button>
<button type="button" class="gm-mode" :class="{ on: mode === 'gap' }" @click="setMode('gap')">{{ t('gospel.mode.gap') }}</button>
<button type="button" class="gm-mode" :class="{ on: mode === 'coverage' }" @click="setMode('coverage')">{{ t('gospel.mode.coverage') }}</button>
</div>
<button v-if="narrow" type="button" class="gm-fold" @click="legendOpen = !legendOpen"
:aria-expanded="legendOpen ? 'true' : 'false'">
@ -1197,18 +1250,26 @@ useShadowStyles(
</button>
<div v-show="!narrow || legendOpen" class="gm-title">
{{ mode === 'gap'
? 'Unengaged groups with no gospel resource found'
: 'Unengaged groups with some gospel resource found' }}
? t('gospel.legend.title')
: t('gospel.legend.titleCoverage') }}
</div>
<div v-show="!narrow || legendOpen" class="gm-keys">
<span v-for="b in activeBands" :key="b.id" class="gm-key">
<i class="gm-sw" :style="{ background: b.color }"></i>{{ b.label }}
<i class="gm-sw" :style="{ background: b.color }"></i>{{ t(b.labelKey) }}
</span>
</div>
<!-- The tally sentence is still English prose, so it declares its own direction
like the caveat below it. -->
<div v-show="!narrow || legendOpen" class="gm-pinkey" v-if="pinTally.total">
<!-- Translated rather than pinned LTR. Pinning it left-aligned an English sentence
inside the RTL panel and the second line ran off the left edge — "th a link".
It is a COUNT, not doctrinal prose, so it belongs in the catalogue with the
rest of the chrome, and then the bidi problem does not exist. -->
<div class="gm-pinkey-h">
Each <i class="gm-dot" style="background:#6d766e"></i> is ONE people group —
{{ fmt(pinTally.total) }} of them, {{ fmt(pinTally.linked) }} with a link
{{ t('gospel.legend.tallyEach') }} <i class="gm-dot" style="background:#6d766e"></i>
{{ t('gospel.legend.tallyIs') }}
{{ fmt(pinTally.total) }} {{ t('gospel.legend.tallyOf') }}
{{ fmt(pinTally.linked) }} {{ t('gospel.legend.tallyLink') }}
</div>
<!-- The key IS the filter: a reader who has just understood what a colour means is
exactly the reader who wants to see only that colour. Two controls would be one
@ -1217,19 +1278,19 @@ useShadowStyles(
<button type="button" class="gm-key gm-kbtn" :class="{ on: pinFilter === 'both' }"
@click="setPinFilter(pinFilter === 'both' ? 'all' : 'both')"
:aria-pressed="pinFilter === 'both'">
<i class="gm-dot" :style="{ background: PIN_COLOR[2] }"></i>Scripture <em>and</em> film
<i class="gm-dot" :style="{ background: PIN_COLOR[2] }"></i>{{ t('gospel.legend.tier2') }}
<b>{{ fmt(pinTally.both) }}</b>
</button>
<button type="button" class="gm-key gm-kbtn" :class="{ on: pinFilter === 'some' }"
@click="setPinFilter(pinFilter === 'some' ? 'all' : 'some')"
:aria-pressed="pinFilter === 'some'">
<i class="gm-dot" :style="{ background: PIN_COLOR[1] }"></i>some resource found
<i class="gm-dot" :style="{ background: PIN_COLOR[1] }"></i>{{ t('gospel.legend.tier1') }}
<b>{{ fmt(pinTally.partial) }}</b>
</button>
<button type="button" class="gm-key gm-kbtn" :class="{ on: pinFilter === 'none' }"
@click="setPinFilter(pinFilter === 'none' ? 'all' : 'none')"
:aria-pressed="pinFilter === 'none'">
<i class="gm-dot" :style="{ background: PIN_COLOR[0] }"></i>nothing found
<i class="gm-dot" :style="{ background: PIN_COLOR[0] }"></i>{{ t('gospel.legend.tier0') }}
<b>{{ fmt(pinTally.none) }}</b>
</button>
<!-- Not a colour, so it carries no dot: this cuts ACROSS the three tiers rather
@ -1238,7 +1299,7 @@ useShadowStyles(
@click="setPinFilter(pinFilter === 'hnr' ? 'all' : 'hnr')"
:aria-pressed="pinFilter === 'hnr'"
title="Groups with recorded audio, the JESUS Film, a Bible video or Words of Life, but no text Scripture found in the pools checked. Not a claim that none exists.">
can hear it, no text found
{{ t('gospel.legend.hnr') }}
<b>{{ fmt(pinTally.hnr) }}</b>
</button>
</div>
@ -1253,7 +1314,13 @@ useShadowStyles(
</button>
</div>
</div>
<div v-show="!narrow || legendOpen" class="gm-caveat">
<!-- The ONE translated line about the English below it. Rendered outside the LTR
block so it reads in the reader's own direction, and only when the locale is
not English — telling an English reader that the English is English is noise. -->
<div v-show="!narrow || legendOpen" v-if="!isEnglish" class="gm-caveat gm-prose-note">
{{ t('gospel.prose.english') }}
</div>
<div v-show="!narrow || legendOpen" class="gm-caveat" dir="ltr">
<!-- The colour and the dots are now computed from the SAME index. They used to be
two datasets: the fill came from a curated set that had one group for Papua New
Guinea while the map drew 831 pins there, so 135 of 170 countries were coloured
@ -1289,9 +1356,9 @@ useShadowStyles(
: gapBy === 'noprayer' ? 'Nothing found, and nobody praying'
: gapBy === 'hnr' ? 'Can hear the gospel, no text to read'
: 'Where the gap concentrates' }}</span>
<button type="button" class="gm-x" @click="closeGapList">Close</button>
<button type="button" class="gm-x" @click="closeGapList">{{ t('gospel.sheet.close') }}</button>
</div>
<p class="gm-note" style="margin-top:0">
<p class="gm-note" dir="ltr" style="margin-top:0">
<!-- The hnr tab describes a DIFFERENT population from the other three, so it must
not inherit the nothing-found sentence: those groups do have something. -->
<template v-if="gapBy === 'hnr'">
@ -1368,15 +1435,15 @@ useShadowStyles(
</p>
<div class="gm-gswitch">
<button type="button" class="gm-gtab" :class="{ on: gapBy === 'groups' }"
@click="gapBy = 'groups'">Largest groups</button>
@click="gapBy = 'groups'">{{ t('gospel.tabs.groups') }}</button>
<button type="button" class="gm-gtab" :class="{ on: gapBy === 'countries' }"
@click="gapBy = 'countries'">By country</button>
@click="gapBy = 'countries'">{{ t('gospel.tabs.countries') }}</button>
<button type="button" class="gm-gtab" :class="{ on: gapBy === 'regions' }"
@click="gapBy = 'regions'">By region</button>
@click="gapBy = 'regions'">{{ t('gospel.tabs.regions') }}</button>
<button v-if="gapNoPrayer.length" type="button" class="gm-gtab" :class="{ on: gapBy === 'noprayer' }"
@click="gapBy = 'noprayer'">Nobody praying</button>
@click="gapBy = 'noprayer'">{{ t('gospel.tabs.noprayer') }}</button>
<button v-if="gapHnr.length" type="button" class="gm-gtab" :class="{ on: gapBy === 'hnr' }"
@click="gapBy = 'hnr'">Can hear, no text</button>
@click="gapBy = 'hnr'">{{ t('gospel.tabs.hnr') }}</button>
</div>
<!-- Links, not fly-to buttons: every row here HAS a prayer page, so the useful
@ -1432,18 +1499,18 @@ useShadowStyles(
<template v-else-if="selectedGroup">
<div class="gm-sh-head">
<button type="button" class="gm-back" @click="selectedGroup = null">‹ {{ selected.country }}</button>
<button type="button" class="gm-x" @click="closeSheet">Close</button>
<button type="button" class="gm-x" @click="closeSheet">{{ t('gospel.sheet.close') }}</button>
</div>
<div class="gm-sh-name" style="margin-bottom:6px">{{ selectedGroup.people_group }}</div>
<div class="gm-stat">
<span>Language</span>
<span>{{ t('gospel.stat.language') }}</span>
<b>{{ selectedGroup.language }}<code class="gm-code">{{ selectedGroup.iso639_3 || 'no code' }}</code></b>
</div>
<div class="gm-stat"><span>Population</span><b>{{ fmt(selectedGroup.population) }}</b></div>
<div class="gm-stat"><span>Countries</span><b>{{ selectedGroup.countries }}</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.population') }}</span><b>{{ fmt(selectedGroup.population) }}</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.countries') }}</span><b>{{ selectedGroup.countries }}</b></div>
<div class="gm-sec">
<div class="gm-sec-h">In their own language</div>
<div class="gm-sec-h">{{ t('gospel.sheet.ownLanguage') }}</div>
<div class="gm-chips">
<a v-if="selectedGroup.scripture_url" class="gm-chip" :class="vchip(selectedGroup.scripture_url).cls"
:href="selectedGroup.scripture_url" target="_blank" rel="noopener noreferrer"
@ -1462,7 +1529,7 @@ useShadowStyles(
:title="/[?&]languageId=/.test(selectedGroup.film_url)
? 'The JESUS Film Project lists a feature film in this language, but no per-language link is published for it — this opens their site and you may need to choose the language there.'
: 'Opens the JESUS Film in this language on jesusfilm.org.'">
Film · {{ vchip(selectedGroup.film_url).label }}
{{ selectedGroup.film_title || 'Film' }} · {{ vchip(selectedGroup.film_url).label }}
</a>
<span v-else class="gm-chip meh">Film · none found</span>
@ -1682,7 +1749,7 @@ useShadowStyles(
</div>
</div>
<p class="gm-note">
<p class="gm-note" dir="ltr">
Matched on ISO 639-3 <b>{{ selectedGroup.iso639_3 || '—' }}</b> — an exact code match, never a
language-name match, because names collide across unrelated languages.
Checked: {{ selectedGroup.checked_pools || '—' }}. Not checked:
@ -1696,18 +1763,18 @@ useShadowStyles(
<template v-else>
<div class="gm-sh-head">
<span class="gm-sh-name">{{ selected.country }}</span>
<button type="button" class="gm-x" @click="closeSheet">Close</button>
<button type="button" class="gm-x" @click="closeSheet">{{ t('gospel.sheet.close') }}</button>
</div>
<div class="gm-stat"><span>People groups</span><b>{{ fmt(countryStats.groups) }}</b></div>
<div class="gm-stat"><span>No resource found</span><b>{{ fmt(countryStats.none) }} ({{ countryStats.pctNone }}%)</b></div>
<div class="gm-stat"><span>People affected</span><b>{{ fmt(countryStats.popNone) }}</b></div>
<div class="gm-stat"><span>Have scripture</span><b>{{ fmt(countryStats.scripture) }}</b></div>
<div class="gm-stat"><span>Have film</span><b>{{ fmt(countryStats.film) }}</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.peopleGroups') }}</span><b>{{ fmt(countryStats.groups) }}</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.noResource') }}</span><b>{{ fmt(countryStats.none) }} ({{ countryStats.pctNone }}%)</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.peopleAffected') }}</span><b>{{ fmt(countryStats.popNone) }}</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.haveScripture') }}</span><b>{{ fmt(countryStats.scripture) }}</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.haveFilm') }}</span><b>{{ fmt(countryStats.film) }}</b></div>
<!-- Audio Scripture is counted here for the same reason it is chipped below: without
it the summary said a country had two media when it often has three, and the
rows underneath already showed the third. -->
<div class="gm-stat"><span>Have audio Scripture</span><b>{{ fmt(countryStats.audio) }}</b></div>
<div class="gm-stat" v-if="countryStats.sign"><span>Sign languages</span><b>{{ fmt(countryStats.sign) }}</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.haveAudio') }}</span><b>{{ fmt(countryStats.audio) }}</b></div>
<div class="gm-stat" v-if="countryStats.sign"><span>{{ t('gospel.stat.signLanguages') }}</span><b>{{ fmt(countryStats.sign) }}</b></div>
<!-- These two are NOT derivable from the pins: they come from the curated country
analysis, over its own narrower set of groups. Labelled so the different basis is
visible rather than implied.
@ -1716,9 +1783,9 @@ useShadowStyles(
not a measurement of anything. An absent figure drawn as a zero is worse than a
missing row, because a zero is a claim. -->
<template v-if="hasCurated">
<div class="gm-stat gm-stat-alt"><span>Growing &amp; unreached<sup>†</sup></span><b>{{ fmt(selected.growing_unreached) }}</b></div>
<div class="gm-stat gm-stat-alt"><span>Est. deaths/day<sup>†</sup></span><b>{{ fmt(Math.round(selected.deaths_day || 0)) }}</b></div>
<p class="gm-note" style="margin:4px 0 0">
<div class="gm-stat gm-stat-alt"><span>{{ t('gospel.stat.growingUnreached') }}<sup>†</sup></span><b>{{ fmt(selected.growing_unreached) }}</b></div>
<div class="gm-stat gm-stat-alt"><span>{{ t('gospel.stat.deathsDay') }}<sup>†</sup></span><b>{{ fmt(Math.round(selected.deaths_day || 0)) }}</b></div>
<p class="gm-note" dir="ltr" style="margin:4px 0 0">
<sup>†</sup> from the curated country analysis, over
<template v-if="selected.curated_groups">{{ fmt(selected.curated_groups) }} of these
{{ fmt(countryStats.groups) }} groups</template><template v-else>a different set of
@ -1821,7 +1888,7 @@ useShadowStyles(
</div>
</template>
<p class="gm-note">
<p class="gm-note" dir="ltr">
Deaths/day applies a national crude death rate to a sub-population — order of magnitude, not a count.
<!-- This footnote still told readers audio was key-blocked and unchecked, long after
Faith Comes By Hearing and GRN were both searched. A caveat that has stopped

View file

@ -121,7 +121,23 @@ try { arc = JSON.parse(readFileSync(ARC, 'utf8')).codes || {} } catch { arc = {}
// exists it replaces that URL entirely rather than sitting alongside it.
let share = {}
try { share = JSON.parse(readFileSync(ARC_SHARE, 'utf8')).codes || {} } catch { share = {} }
const filmUrl = (code, aa) => httpsify(share[code] || (aa ? aa.u : ''))
/**
* The share table answers in three shapes, and each means something different:
* a STRING the JESUS Film deep link for this language
* an OBJECT {u,t} a different JESUS Film Project feature — the JESUS component has no
* entry for this language — and t is what it must actually be called
* null no feature exists at all; ship NO film rather than the retired ?languageId=
* URL, which nothing can verify and which claimed a film that is not there
* undefined (never fetched) keeps the old behaviour and falls back to the roster URL.
*/
const shareEntry = (code, aa) => {
const v = share[code]
if (v === null) return { u: '', t: '' }
if (v && typeof v === 'object') return { u: httpsify(v.u || ''), t: String(v.t || '') }
return { u: httpsify(v || (aa ? aa.u : '')), t: '' }
}
const filmUrl = (code, aa) => shareEntry(code, aa).u
const filmTitle = (code, aa) => shareEntry(code, aa).t
// Zume Training snapshot (scripts/fetch-zume-index.mjs). Same optional contract as above.
// Zume is DISCIPLESHIP TRAINING, not Scripture and not film, so it is kept in its own
@ -251,9 +267,22 @@ for (const [c, aa] of Object.entries(arc)) {
// An existing film link that is the ignored ?languageId= form is REPLACED where a real
// deep link exists: keeping it would mean curation preserving a URL that goes nowhere
// specific, which is not what the fill-never-overwrite rule is protecting.
if (codes[c]?.f && share[c] && /[?&]languageId=/.test(codes[c].f)) { codes[c].f = httpsify(share[c]); upgraded++ }
if (codes[c]) { if (!codes[c].f) codes[c].f = filmUrl(c, aa); continue }
/* The UPGRADE path reads the share table too, and read it RAW — so an {u,t} entry
stringified straight into the URL field as "[object Object]" for ars, whose film is
its only resource. Both readers go through shareEntry now; there is exactly one place
that knows the three shapes. */
if (codes[c]?.f && share[c] !== undefined && /[?&]languageId=/.test(codes[c].f)) {
const e = shareEntry(c, null)
if (e.u) { codes[c].f = e.u; if (e.t) codes[c].fT = e.t; upgraded++ }
else if (share[c] === null) { delete codes[c].f; delete codes[c].fT }
}
const ft = filmTitle(c, aa)
if (codes[c]) {
if (!codes[c].f) { codes[c].f = filmUrl(c, aa); if (ft) codes[c].fT = ft }
continue
}
codes[c] = { l: aa.n || '', f: filmUrl(c, aa), ck: 'arclight', un: 'ebible, door43, grn, api_bible, dbl, progress_bible' }
if (ft) codes[c].fT = ft
arcOnly++
}
for (const [c, ee] of Object.entries(eb)) {

View file

@ -29,6 +29,21 @@ const ARC = resolve(REPO, 'data/tables/arclight-index.json')
const OUT = resolve(REPO, 'data/tables/arclight-share-urls.json')
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
const JF = '1_jf-0-0'
/**
* `jos` IS SKIPPED, AND THE REASON IS UPSTREAM. ISO 639-3 jos is Jordanian Sign Language.
* Arclight's roster files iso3 "jos" under languageId 139080, whose own name field reads
* "Lebanese Sign Language" — a different language (lbs). Meanwhile the JESUS Film DOES exist
* at languageId 7138, named Jordanian Sign Language, which is the id the pin index carries.
*
* So the ISO join — the thing this whole map is built on — disagrees with the publisher's own
* label for this one code, and the fallback below resolved 139080 to Magdalena: the wrong film
* AND the wrong language, shown to three people groups. Left exactly as it was rather than
* guessed at. Resolving it means deciding whether the code or the name is authoritative here,
* and that is a data question for someone who can check it against Ethnologue, not something
* to settle inside a fetcher.
*/
const SKIP = new Set(['jos'])
const DELAY = Number(process.env.DELAY_MS || 250)
const arc = JSON.parse(readFileSync(ARC, 'utf8')).codes
@ -39,7 +54,8 @@ let fetched = 0, cached = 0, missing = 0
for (let i = 0; i < entries.length; i++) {
const [code, v] = entries[i]
if (out[code]) { cached++; continue }
if (SKIP.has(code)) { cached++; continue }
if (out[code] !== undefined) { cached++; continue }
try {
const r = await fetch(`https://api.arclight.org/v2/media-components/${JF}/languages/${v.id}`, { headers: { 'User-Agent': UA } })
if (r.ok) {
@ -48,7 +64,57 @@ for (let i = 0; i < entries.length; i++) {
// is a downgrade nobody asked for.
const u = String(d.shareUrl || '').replace(/^http:\/\//, 'https://')
if (u) { out[code] = u; fetched++ } else missing++
} else missing++
} else if (r.status !== 404) {
/* ONLY A 404 MEANS "THE JESUS COMPONENT HAS NO ENTRY FOR THIS LANGUAGE". A 429, a 500
* or a blip means we do not know yet, and the fallback below must not run on it.
* Measured: the first version treated ANY non-ok as "no JESUS Film" and wrote Magdalena
* over Jordanian Sign Language, which HAS the JESUS Film at arc.gt/g8wmw — a transient
* failure silently downgrading a language to a different film. Left undefined so the
* next run retries it. */
missing++
} else {
/* ═══ THE JESUS FILM COMPONENT DOES NOT CARRY EVERY LANGUAGE THE ROSTER COUNTS ═══
*
* counts.featureFilm >= 1 says a FEATURE exists in this language. It does not say the
* feature is JESUS. For five codes this endpoint 404s, and the build was falling back
* to the retired ?languageId= URL — unverifiable by fetch, and labelled "JESUS Film"
* on a language the JESUS component has no entry for. Measured 2026-09-25:
* ars Arabic, Najdi -> The Savior (8 groups, film was their ONLY resource)
* gjk Kachi Koli -> Magdalena — Director's Cut
* mzn Mazanderani -> Magdalena — Director's Cut
* lia Limba, West-Central -> NO component at all
* jos Jordanian Sign Language -> the JESUS Film after all, at arc.gt/g8wmw
*
* So ask what feature this language actually has. A real JESUS Film Project feature in
* someone's own language is worth shipping — dropping ars would have pushed 8 groups
* into the gap while a gospel film exists in their language — but it must be shipped
* UNDER ITS OWN NAME. This bundle already refuses to call Open Bible Stories Scripture;
* calling The Savior the JESUS Film is the same lie.
*
* Same publisher, so the doctrinal screen is unchanged: these are JESUS Film Project
* features, the catalogue the brief names explicitly. Nothing outside it is taken.
*
* `null` is recorded when no feature exists, and the pin build DROPS the film rather
* than falling back to a URL nobody can check. */
let alt = null
try {
const c = await fetch(`https://api.arclight.org/v2/media-components?languageIds=${v.id}&limit=25`, { headers: { 'User-Agent': UA } })
if (c.ok) {
const cj = await c.json()
const feature = (cj._embedded?.mediaComponents || []).find((m) => m.subType === 'featureFilm')
if (feature) {
const fr = await fetch(`https://api.arclight.org/v2/media-components/${feature.mediaComponentId}/languages/${v.id}`, { headers: { 'User-Agent': UA } })
if (fr.ok) {
const fd = await fr.json()
const fu = String(fd.shareUrl || '').replace(/^http:\/\//, 'https://')
if (fu) alt = { u: fu, t: feature.title || feature.mediaComponentId }
}
}
}
} catch { /* fall through to null */ }
out[code] = alt
if (alt) fetched++; else missing++
}
} catch { missing++ }
// The CHECKPOINT write is deliberately unguarded: this script resumes from the existing
// file as a cache, so `out` starts full and only grows. It cannot shrink, which is the
@ -62,7 +128,7 @@ for (let i = 0; i < entries.length; i++) {
await new Promise((r) => setTimeout(r, DELAY))
}
writeSnapshot(OUT, JSON.stringify({
_note: 'Per-language JESUS Film deep links (shareUrl) from Arclight media-components 1_jf-0-0. Replaces ?languageId=, which the site ignores.',
_note: 'Per-language JESUS Film Project deep links (shareUrl) from Arclight media-components. A STRING is the JESUS Film (component 1_jf-0-0). An OBJECT {u,t} is a different JESUS Film Project feature in that language — the JESUS component has no entry for it — and t is the real title, which the surfaces must show instead of "JESUS Film". null means no feature exists and the film is dropped rather than linked to the retired ?languageId= URL, which nothing can verify.',
generated: new Date().toISOString().slice(0, 10),
stats: { languages: entries.length, withShareUrl: Object.keys(out).length, missing },
codes: out,

View file

@ -271,7 +271,6 @@ const geocoderRef = ref(null) // <GeocoderComponent> exposed ref
// generic list rather than this map's own — the drift the rebuild spec flagged.
// The legend names the MAP, not just the taxonomy — with several maps in one app
// "Legend" alone does not say which one you are looking at.
const LEGEND_TITLE = 'My UPG 100 List — Legend'
// ★ NAME THE LEGEND'S DEPTH TABS. Without a `tabs` prop SemanticTreeLegend falls through to
// `src[i] ?? { id: 'gen'+(i+1), label: 'Generation '+(i+1) }` (SemanticTreeLegend.vue:157) — a
@ -357,7 +356,20 @@ const API_BASE = (typeof window !== 'undefined' && window.MAP_APP_API_URL)
// shared usePosterPrints composable — it prints through a hidden same-page iframe,
// so the dialog opens in this tab rather than a popup a browser may block. The
// packet's locale follows the app's, so a reader gets it in their own language.
const { locale: packetLocale } = useI18n()
const { locale: packetLocale, t } = useI18n()
// ⚠ IT WAS A HARDCODED ENGLISH LITERAL, and SemanticTreeLegend passes `title` through RAW —
// the caller owns localizing it. MEASURED 2026-09-25 on the live Chinese render: the legend
// header read "My UPG 100 List — Legend" in full English inside an otherwise localized panel,
// beside translated tabs (区域 · UPG-100 名单) and a translated column header (人口).
// The Driver's gift policy is explicit — "BEAUTIFUL: no clipped text, no placeholders, no English
// leaking" — and an English title on the mobile showcase is exactly that.
// ★ THE HOUSE STYLE FOR A PROPER NOUN INSIDE A TRANSLATED PHRASE ALREADY EXISTS in this catalog:
// "Doxa Regions" renders as "Doxa 区域" / "مناطق Doxa" / "Регионы Doxa" — the identifier stays
// Latin, the frame translates. "UPG 100" is treated the same way rather than transliterated, so
// this follows a convention demonstrated across 11 locales instead of inventing terminology.
// COMPUTED, not a constant: a locale change must move it. A `const` captured at setup would keep
// the mounting language forever, which is the subtler half of the same bug.
const LEGEND_TITLE = computed(() => t('legend.title.upg100List'))
const DEAF_COUNT = 123
// Shadow-root CSS. (No backticks inside this string — a stray backtick closes the

View file

@ -45,6 +45,7 @@ import Fuse from '@map/components/search/fuzzy.js'
import SearchBar from '@map/components/search/SearchBar.vue'
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { tStatic } from '@map/i18n/index.js'
import { countrySlug } from './src/countries.js'
// ── DERIVED family axes (BUG-SEMANTIC-TREE-SIDEBAR-NOT-INTEROPERABLE-WITH-GRID) ──
// The Religion/Language trees' TOP TIERS emit DERIVED family names ("Hinduism",
@ -253,7 +254,28 @@ const languageCode = computed(() => props.languageCode || cfg.value.languageCode
const researchUrl = computed(() => props.researchUrl || cfg.value.researchUrl
|| (((typeof window !== 'undefined' && window.DOXA_SITE_URL) || 'https://doxa.life').replace(/\/+$/, '') + '/research/'))
const selectUrl = computed(() => props.selectUrl || cfg.value.selectUrl || '')
const t = computed(() => ({ ...(cfg.value.t || {}), ...(props.t || {}) }))
// ★ EVERY LABEL IN THIS GRID WAS ENGLISH IN ALL ELEVEN LOCALES.
// The `t` object is a HOST-SUPPLIED map (the Nuxt page passes its own strings), with an English
// literal at each call site as the fallback. Nothing else ever filled it, so a reader on ?lang=ru
// got a fully Russian map with 'Search people groups…', 'Hide Filters', 'Exact', 'Load more' and
// 'Full profile' underneath it — MEASURED on the composite: every Latin-script string on that page
// belonged to this grid, the map half above it was entirely translated.
// The catalogue now carries them (locales/<loc>/grid.json) and this reads it directly rather than
// through useI18n, because this component is ALSO mounted as its own custom element by an entry
// that installs no i18n plugin — useI18n() there throws, and a thrown setup() removes the subtree
// with no build error. Order: host `t` wins (it always did), then the catalogue, then the English
// literal already at each call site, so a locale missing a key degrades to today's behaviour.
const t = computed(() => {
const host = { ...(cfg.value.t || {}), ...(props.t || {}) }
const locale = cfg.value.locale || cfg.value.lang || undefined
return new Proxy(host, {
get(target, key) {
if (key in target) return target[key]
if (typeof key !== 'string') return undefined
return tStatic('grid.' + key, locale)
},
})
})
// ── Cat-2 show/hide (accept the DOM-attribute string 'false') ──────────────────
const truthy = (v) => !(v === false || v === 'false' || v === '0' || v === 0)

View file

@ -51,8 +51,9 @@
model + owns everything in the content area: the embed bar (as #toolbar) and the per-element
panes with their direct-element mount + heavy-row teardown machinery. -->
<VerticalTabsLayout
:title="heading"
nav-label="Plugin elements · by capability 5 → 1"
:title="heading || tr('title')"
:nav-label="tr('navLabel')"
:labels="{ toggleList: tr('toggleList'), openViews: tr('openViews'), expandList: tr('expandList'), collapseList: tr('collapseList'), collapse: tr('collapse') }"
:header="true"
:active="activeKey"
@update:active="select">
@ -60,7 +61,8 @@
<!-- #rail: the Explorer tree (src/StudioTree.vue) — capability sections, each composite folding
to show what it is made of. The layout keeps the rail frame (collapse, drawer, slim strip). -->
<template #rail="{ collapsed, expand, pick }">
<StudioTree :sections="treeSections" :active="activeKey" :collapsed="collapsed" :expand="expand" :pick="pick" />
<StudioTree :sections="treeSections" :active="activeKey" :collapsed="collapsed" :expand="expand" :pick="pick"
:strings="{ filterPlaceholder: tr('filterPlaceholder'), collapseAll: tr('collapseAll'), noMatch: tr('noMatch'), treeLabel: tr('navLabel'), openSection: tr('openSection'), part: tr('part') }" />
</template>
<!-- #brand: the studio's logo mark (a plug/puzzle glyph — "plugin studio"), left of the title.
@ -82,7 +84,7 @@
<span class="ps-count">{{ countLabel }}</span>
<span class="ps-locale">
<button type="button" class="ps-locale-btn" :aria-expanded="String(localeOpen)"
aria-label="Change language" @click="localeOpen = !localeOpen">
:aria-label="tr('changeLanguage')" @click="localeOpen = !localeOpen">
<span class="ps-flag">{{ activeLang.flag }}</span>
<span class="ps-locale-name">{{ activeLang.nativeName }}</span>
<span class="ps-dots" aria-hidden="true">⋮</span>
@ -111,12 +113,12 @@
<span class="ps-bar-sub">{{ active.bundle }} · &lt;{{ active.tag }}&gt;<span v-if="active.description"> · {{ active.description }}</span></span>
</div>
<button type="button" class="ps-btn" :aria-expanded="String(embedOpen)"
@click="embedOpen = !embedOpen" v-if="active.embed">Copy embed</button>
@click="embedOpen = !embedOpen" v-if="active.embed">{{ tr('copyEmbed') }}</button>
</div>
<div class="ps-embed" v-if="embedOpen && active.embed">
<label>script embed
<button type="button" class="ps-btn" @click="copyText(active.embed, $event.target)">Copy</button>
<label>{{ tr('scriptEmbed') }}
<button type="button" class="ps-btn" @click="copyText(active.embed, $event.target)">{{ tr('copy') }}</button>
</label>
<textarea readonly :value="active.embed"></textarea>
</div>
@ -142,13 +144,13 @@
<template v-if="!row.heavy || row.key === activeKey">
<div class="ps-slot" :ref="el => setSlot(row.key, el)"></div>
<div v-if="row.state === 'loading'" class="ps-hint">Loading {{ row.label }}…</div>
<div v-if="row.state === 'loading'" class="ps-hint">{{ tr('loading', { name: row.label }) }}</div>
<div v-else-if="row.state === 'error'" class="ps-hint err">
Could not load {{ row.label }}.<br /><small>{{ row.error }}</small>
{{ tr('couldNotLoad', { name: row.label }) }}<br /><small>{{ row.error }}</small>
</div>
</template>
</section>
<div class="ps-hint" v-if="!activeKey">Choose an element from the list.</div>
<div class="ps-hint" v-if="!activeKey">{{ tr('chooseElement') }}</div>
</div>
</VerticalTabsLayout>
</template>
@ -255,8 +257,9 @@ const capFor = (t) => (PT[t] != null ? 5 - PT[t] : 1)
// Rail row icon per capability: LOCAL inline SVG names (app-wrapper-material/icons.js), not emoji.
// These were emoji until 2026-09-24, and on a box with no emoji font (measured: this one) every rail
// row led with an empty tofu box — the first thing a developer saw on every plugin.
// Short tier names for the rail's section headers (the full CAP_LABEL is their tooltip).
const CAP_SHORT = { 5: 'App shell', 4: 'Composite', 3: 'Multi-view', 2: 'Preset', 1: 'Single' }
// Machine names of the five tiers — technical terms, shown untranslated after the localised tier name
// in a section header's tooltip (the localised names live in studio.json cap1…cap5).
const MACHINE_TYPE = { 5: 'nested-mfe-spa', 4: 'nested-elements-mfe', 3: 'monolithic', 2: 'parameterized', 1: 'single' }
const CAP_ICON = { 5: 'monitor', 4: 'tree', 3: 'layers', 2: 'grid', 1: 'embed' }
// MAP ELEMENTS. These own a live WebGL context, so they are the rows the studio DESTROYS on
@ -302,6 +305,17 @@ const HEAVY_ELEMENTS = new Set([
// doxa-maps-spa's NOT_EXPERIMENTAL already hides big-picture for the same reason.
const STUDIO_HIDDEN = new Set(['big-picture'])
// THE STUDIO'S OWN WORDS, in every locale the switcher offers (library/i18n/locales/<lc>/studio.json,
// held to the same key parity as every other catalog by check:i18n-parity). The studio has no
// vue-i18n instance — it is a shell, not a ProfileLoader plugin — so it looks strings up directly
// and falls back to English per key. Before this, choosing a language translated the plugins
// but left the studio's own chrome in English.
const STUDIO_STRINGS = {}
for (const [path, mod] of Object.entries(import.meta.glob('../../library/i18n/locales/*/studio.json', { eager: true, import: 'default' }))) {
const m = path.match(/locales\/([^/]+)\/studio\.json$/)
if (m) STUDIO_STRINGS[m[1]] = mod
}
// DISPLAY NAMES — what a row is CALLED, keyed by "<bundle>/<profile>". Display only: the row key, the
// URL hash, the stored last-tab and the <tag> under the name are all unchanged, so links and memory
// keep working. The Driver's name for the cap-5 shell is "Research Gallery" (its meta.json label);
@ -371,7 +385,8 @@ export default defineComponent({
// Renaming the PROP is safe because nothing outside ever set it: no host passes title=, no
// profile-config reads cfg.title, and the value is only forwarded to VerticalTabsLayout — an
// ordinary Vue component, never a custom element, so its own `title` prop has no collision.
heading: { type: String, default: 'Plugin Studio' },
// Empty = the localised "Plugin Studio" (studio.json `title`); a host may still set its own.
heading: { type: String, default: '' },
tokenUrl: { type: String, default: '' },
tk: { type: String, default: '' }
},
@ -441,7 +456,7 @@ export default defineComponent({
const t = treeOf.get(r.key)
nodes.push({
id: r.key, parent: '', depth: 0, label: r.label, desc: r.tag, icon: r.icon, target: r.key,
kids: t ? t.nodes[0].kids : 0, title: r.label + ' · <' + r.tag + '> · CAP ' + r.cap + ' ' + (CAP_LABEL[r.cap] || '')
kids: t ? t.nodes[0].kids : 0, title: r.label + ' · <' + r.tag + '> · CAP ' + r.cap + ' ' + this.tr('cap' + r.cap)
})
if (!t) continue
const nid = (id) => (id === 0 ? r.key : r.key + '>' + id)
@ -450,16 +465,16 @@ export default defineComponent({
id: nid(n.id), parent: nid(n.ancestors[n.ancestors.length - 1]), depth: n.depth,
label: n.label, part: !!n.part, target: n.selects || '',
icon: n.part ? 'dots' : (CAP_ICON[n.cap] || 'embed'),
desc: n.part ? 'no standalone page' : 'cap ' + n.cap + ' · ' + n.tag,
desc: n.part ? this.tr('noStandalone') : 'cap ' + n.cap + ' · ' + n.tag,
kids: n.kids || 0,
title: n.part ? n.label + ' · part of ' + r.label + ', not published on its own'
: n.label + ' · <' + n.tag + '> · CAP ' + n.cap + ' ' + (CAP_LABEL[n.cap] || '')
title: n.part ? n.label + ' · ' + this.tr('partOf', { name: r.label })
: n.label + ' · <' + n.tag + '> · CAP ' + n.cap + ' ' + this.tr('cap' + n.cap)
})
}
}
// Short section names: the header shares a 256px rail with its CAP badge and count, and the
// full "App shell · nested-mfe-spa" truncated to "APP SHELL · NEST…". The long name is the tooltip.
return { id: 'cap-' + g.cap, cap: g.cap, label: CAP_SHORT[g.cap] || g.label, title: g.label, icon: CAP_ICON[g.cap] || 'embed', nodes }
return { id: 'cap-' + g.cap, cap: g.cap, label: this.tr('cap' + g.cap), title: this.tr('cap' + g.cap) + ' · ' + (MACHINE_TYPE[g.cap] || ''), icon: CAP_ICON[g.cap] || 'embed', nodes }
})
},
// ★ ONE TREE PER COMPOSITE, built ENTIRELY from the manifest's `composes` edges — no bundle names
@ -530,7 +545,7 @@ export default defineComponent({
// total reads as a deliberate exclusion rather than an off-by-one.
countLabel() {
const nb = new Set(this.rows.map(r => r.bundle)).size
return nb + ' bundles · ' + this.rows.length + ' elements listed here'
return this.tr('countLabel', { bundles: nb, elements: this.rows.length })
},
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } }
},
@ -538,6 +553,13 @@ export default defineComponent({
this.loadManifest()
},
methods: {
// Localised studio string: current locale → English → the key itself; {name}-style placeholders.
tr(key, vars) {
const base = (this.locale || 'en').split('-')[0]
let str = (STUDIO_STRINGS[this.locale] || {})[key] || (STUDIO_STRINGS[base] || {})[key] || (STUDIO_STRINGS.en || {})[key] || key
if (vars) for (const k in vars) str = str.split('{' + k + '}').join(String(vars[k]))
return str
},
// Resolve one `composes` entry to a studio row. The manifest's refs are NOT one shape: an
// element TAG, a "<bundle>/<profile>" row KEY, a BUNDLE name whose tag differs,
// or a bare SIBLING profile name (gospel-resources composes "resources-map", "data-tables" — its
@ -767,6 +789,11 @@ export default defineComponent({
// own guess, so this lands through the existing config path with no new prop.
if (this.base) pc.treeBase = this.base
el.setAttribute('profile-config', JSON.stringify(pc))
// …and as a plain `tree-base` ATTRIBUTE, for elements that take the base that way instead of
// through profile-config (golden-path-check does, deliberately — it has no ProfileLoader).
// Without it that check guessed the tree from the PAGE URL, which in the studio is the page
// root, and read /manifest.json: a 404 and an empty self-test. Unused attributes are inert.
if (this.base) el.setAttribute('tree-base', this.base.replace(/\/+$/, ''))
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
slot.appendChild(el)
row.state = 'ready'
@ -780,7 +807,7 @@ export default defineComponent({
})
},
copyText(text, btn) {
const ok = () => { const t = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = t }, 1200) }
const ok = () => { const t = btn.textContent; btn.textContent = this.tr('copied'); setTimeout(() => { btn.textContent = t }, 1200) }
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, () => {})
else {
try { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); ok() } catch { /* ignore */ }

View file

@ -36,7 +36,7 @@
<div v-if="collapsed" class="st-activity">
<button v-for="s in sections" :key="s.id" type="button" class="st-act"
:class="{ active: s.id === activeSectionId }"
:aria-label="'Open CAP ' + s.cap + ', ' + s.label"
:aria-label="str('openSection', { cap: 'CAP ' + s.cap, label: s.label })"
@click="openSectionFromActivity(s.id)">
<span class="st-act-ic" aria-hidden="true" v-html="svg(s.icon)"></span>
<span class="st-act-n" aria-hidden="true">{{ s.cap }}</span>
@ -47,15 +47,15 @@
<div class="st-bar">
<label class="st-find">
<span class="st-find-ic" aria-hidden="true" v-html="svg('search')"></span>
<input ref="find" type="search" class="st-find-in" v-model="query" placeholder="Filter plugins"
aria-label="Filter plugins" @keydown="onFindKey" />
<input ref="find" type="search" class="st-find-in" v-model="query" :placeholder="str('filterPlaceholder')"
:aria-label="str('filterPlaceholder')" @keydown="onFindKey" />
</label>
<button type="button" class="st-tool" aria-label="Collapse all" title="Collapse all" @click="collapseAll">
<button type="button" class="st-tool" :aria-label="str('collapseAll')" :title="str('collapseAll')" @click="collapseAll">
<span aria-hidden="true" v-html="collapseAllSvg"></span>
</button>
</div>
<div class="st-tree" role="tree" aria-label="Plugins by capability" @keydown="onTreeKey">
<div class="st-tree" role="tree" :aria-label="str('treeLabel')" @keydown="onTreeKey">
<div v-for="item in visible" :key="item.id"
:ref="el => setRow(item.id, el)"
role="treeitem"
@ -80,12 +80,12 @@
<span v-else class="st-twisty-sp" aria-hidden="true"></span>
<span class="st-ic" aria-hidden="true" v-html="svg(item.icon)"></span>
<span class="st-label">{{ item.label }}</span>
<span class="st-part" v-if="item.part">part</span>
<span class="st-part" v-if="item.part">{{ str('part') }}</span>
<span class="st-desc">{{ item.desc }}</span>
<span class="st-count" v-if="item.expandable">{{ item.kids }}</span>
</template>
</div>
<p class="st-empty" v-if="query && !visible.length">No plugin matches “{{ query }}”.</p>
<p class="st-empty" v-if="query && !visible.length">{{ str('noMatch', { query }) }}</p>
</div>
</div>
</template>
@ -139,6 +139,10 @@ const ST_CSS = '' +
'.st-act-n { position:absolute; right:6px; bottom:5px; font:700 9px/1 system-ui,sans-serif; color:#5f8a63; }'
const COLLAPSE_ALL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="3"/><line x1="8.5" y1="12" x2="15.5" y2="12"/></svg>'
const EN = {
filterPlaceholder: 'Filter plugins', collapseAll: 'Collapse all', noMatch: 'No plugin matches “{query}”.',
treeLabel: 'Plugins by capability', openSection: 'Open {cap}, {label}', part: 'part'
}
const CHEV = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>'
export default defineComponent({
@ -150,7 +154,9 @@ export default defineComponent({
active: { type: String, default: '' },
collapsed: { type: Boolean, default: false },
pick: { type: Function, default: null },
expand: { type: Function, default: null }
expand: { type: Function, default: null },
// The host's localised strings (plugin-studio passes studio.json values); English fallbacks below.
strings: { type: Object, default: () => ({}) }
},
emits: ['select'],
setup() { useShadowStyles(ST_CSS, 'studio-tree') },
@ -221,6 +227,11 @@ export default defineComponent({
},
methods: {
svg(name) { return name ? localIcon(name) : '' },
str(key, vars) {
let v = this.strings[key] || EN[key] || key
if (vars) for (const k in vars) v = v.split('{' + k + '}').join(String(vars[k]))
return v
},
storageKey() { return 'dx-studio-tree:' + ((typeof location !== 'undefined' && location.pathname) || '') },
// Guarded: storage THROWS in private mode / under policy, and a throw here would blank the studio.
readState() { try { return JSON.parse(window.localStorage.getItem(this.storageKey()) || '{}') || {} } catch (_) { return {} } },

View file

@ -738,6 +738,19 @@ useShadowStyles(
// and country page. Their .region-goal is 0px and ours matches, so that one was never a defect.
// Porting the reset properly and deleting the class-level margin:0 restores their cascade exactly.
'h1,h2,h3,h4,p,figure,blockquote,dl,dd{ margin:0; }' +
// ★ AND THEIR IMAGE RESET, the other half of base/_reset.scss that the shadow boundary blocked:
// img, picture { max-width: 100%; display: block }
// MEASURED on a COUNTRY page, where the uupg photos actually render: their img.uupg__image has
// max-width 100%, ours had max-width NONE, so an image wider than its grid cell overflows our
// card where theirs is constrained. display already matched, and height/border-radius/object-fit
// already match because our .uupg__image class sets them (100px, 7.5px, cover -- identical to
// theirs), so max-width was the whole gap.
// NOT PORTED, deliberately: _globals.scss:80's `img { border-radius: var(--border-radius-lg) }`.
// On their site that rounds every image because every image there is theirs. Applying it inside
// our root would also round the POSTER WALL previews, which are part of a sanctioned addition
// that does not exist on their page -- so porting it would change something they have no opinion
// about. The elements that ARE clones already carry the right radius from their own class.
'img,picture{ max-width:100%; display:block; }' +
'@media (prefers-reduced-motion: reduce){' +
' .rd-root, .rd-root *, .rd-root *::before, .rd-root *::after{' +
' animation-duration:0.01ms !important; animation-iteration-count:1 !important;' +

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
<script src="../all-people-groups.js" integrity="sha384-j2rnWAW0lVUG05WREAfkQZo0jtreSIebwV3nHCbUcXhAGzMfTESwA1PqTYy5M3QS" crossorigin="anonymous"></script>
<script src="../all-people-groups.js" integrity="sha384-wDwlEPqFMo1DGCSAZQqHdBUM9OKTByNW1wSZPNu5auSubOuSBz4eHR4CIf0uvglf" 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

@ -911,7 +911,7 @@
"pct_with_nothing": 9.8,
"people_with_nothing": 146600,
"groups_with_scripture": 19,
"groups_with_film": 24,
"groups_with_film": 23,
"band": "partial",
"growing_unreached": 7,
"deaths_day": 14.5,
@ -1121,7 +1121,7 @@
"pct_with_nothing": 10.7,
"people_with_nothing": 98950,
"groups_with_scripture": 21,
"groups_with_film": 25,
"groups_with_film": 24,
"band": "partial",
"growing_unreached": 0,
"deaths_day": 1.1,

View file

@ -337,7 +337,7 @@
"no_resource": 4,
"pop_no_resource": 146600,
"scripture": 19,
"film": 24,
"film": 23,
"growing_unreached": 7,
"deaths_day": 14.5,
"growth": 2.4,
@ -2241,7 +2241,7 @@
"no_resource": 3,
"pop_no_resource": 98950,
"scripture": 21,
"film": 25,
"film": 24,
"growing_unreached": 0,
"deaths_day": 1.1,
"growth": 2.12,

View file

@ -1,7 +1,7 @@
{
"meta": {
"title": "Gospel resource link verification",
"generated": "2026-09-25T02:30:53.812Z",
"generated": "2026-09-25T03:43:08.467Z",
"method": "HEAD, redirects followed. verified = HTTP 200. broken = 404/410. unknown = unreachable, an ambiguous status, or a URL shape that cannot be checked by fetch.",
"scope": "Every URL in data/tables/people-group-pins.json, deduplicated.",
"states": {
@ -96,12 +96,12 @@
]
},
"summary": {
"records": 8513,
"checkedThisPass": 40,
"records": 8516,
"checkedThisPass": 3,
"carriedForward": 74,
"pruned": 0,
"broken": 74,
"verified": 8434,
"verified": 8437,
"unknown": 5
},
"byHost": {
@ -119,11 +119,11 @@
"verified": 3
},
"jesusfilm.org": {
"verified": 2048,
"verified": 2050,
"broken": 1
},
"arc.gt": {
"verified": 32,
"verified": 33,
"broken": 1
},
"zume.training": {
@ -42711,6 +42711,21 @@
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/2235?language=Mehinaku (the site answers 200 for any path)",
"at": "2026-09-25T02:30:53.528Z"
},
"https://jesusfilm.org/watch/magdalena-director-cut/mazanderani": {
"state": "verified",
"detail": "HTTP 200 (via https://www.jesusfilm.org/watch/magdalena-director-cut.html/mazanderani.html)",
"at": "2026-09-25T03:43:07.735Z"
},
"https://jesusfilm.org/watch/magdalena-director-cut/kachhi-gujerati": {
"state": "verified",
"detail": "HTTP 200 (via https://www.jesusfilm.org/watch/magdalena-director-cut.html/kachhi-gujerati.html)",
"at": "2026-09-25T03:43:07.768Z"
},
"https://arc.gt/durve": {
"state": "verified",
"detail": "HTTP 200 (via https://www.jesusfilm.org/watch/the-savior.html/arabic-najdi.html)",
"at": "2026-09-25T03:43:07.944Z"
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -260,7 +260,7 @@
"iso639_3": "ars",
"population": 12970000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 24.56,
@ -1562,7 +1562,7 @@
"iso639_3": "ars",
"population": 1990000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 31.77,
@ -2066,7 +2066,7 @@
"iso639_3": "ars",
"population": 1485000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 34.54,
@ -2122,7 +2122,7 @@
"iso639_3": "ars",
"population": 1440000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 24.69,
@ -2248,7 +2248,7 @@
"iso639_3": "ars",
"population": 1365000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 32.5,
@ -6014,7 +6014,7 @@
"iso639_3": "ars",
"population": 382000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 29.34,
@ -14288,7 +14288,7 @@
"iso639_3": "ars",
"population": 79000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 34.09,
@ -23752,7 +23752,7 @@
"iso639_3": "ars",
"population": 20000,
"can_hear": "the JESUS Film",
"first_link": "https://www.jesusfilm.org/watch/?languageId=184591",
"first_link": "https://arc.gt/durve",
"checked_pools": "arclight, door43, ebible, fcbh, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion, grn (not yet swept)",
"lat": 45.44,

File diff suppressed because one or more lines are too long

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-zwM56YT56Crw0AFHQsG9fHnIPzkHVRwtLd/tXg9MXedfWyixXRdK7BVA/8S8JjhB" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-Rht3EoXP3Ckvwn2fauBsh9BvcUJiFzMb49udbtOh34wbKLdHQ+RQcFGR8fdHdF2t" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-zwM56YT56Crw0AFHQsG9fHnIPzkHVRwtLd/tXg9MXedfWyixXRdK7BVA/8S8JjhB" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-Rht3EoXP3Ckvwn2fauBsh9BvcUJiFzMb49udbtOh34wbKLdHQ+RQcFGR8fdHdF2t" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../big-picture.js" integrity="sha384-zwM56YT56Crw0AFHQsG9fHnIPzkHVRwtLd/tXg9MXedfWyixXRdK7BVA/8S8JjhB" crossorigin="anonymous"></script>
<script src="../big-picture.js" integrity="sha384-Rht3EoXP3Ckvwn2fauBsh9BvcUJiFzMb49udbtOh34wbKLdHQ+RQcFGR8fdHdF2t" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-6BMY4s31pHynQ7rtUkG2xWcZeluGOVbQtYJWwn7VJ+byd8sK/o4gj5/DOMwROgUI" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-ravBExKROrutu+/bYRm5G3kIoOytPz3YdQToKRUpUPpKE2n6i/vAZjpxK4jTY4y6" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-6BMY4s31pHynQ7rtUkG2xWcZeluGOVbQtYJWwn7VJ+byd8sK/o4gj5/DOMwROgUI" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-ravBExKROrutu+/bYRm5G3kIoOytPz3YdQToKRUpUPpKE2n6i/vAZjpxK4jTY4y6" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-6BMY4s31pHynQ7rtUkG2xWcZeluGOVbQtYJWwn7VJ+byd8sK/o4gj5/DOMwROgUI" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-ravBExKROrutu+/bYRm5G3kIoOytPz3YdQToKRUpUPpKE2n6i/vAZjpxK4jTY4y6" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -40,7 +40,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-oWvpE6qF7AlXl/L/4dx/1uT493xz3fbpqNuSRcTXlTYdyryVVv1t/hlPOQTaNXg5" crossorigin="anonymous"></script>
<script src="../../dashboard-plus-grid.js" integrity="sha384-xR8W+FnaWZ8B5JzTbivTfMcMU5zTtpHKyMTXKMDzLMO3Pwi/XKR9INs1pib1YVjj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../dashboard-plus-grid.js" integrity="sha384-oWvpE6qF7AlXl/L/4dx/1uT493xz3fbpqNuSRcTXlTYdyryVVv1t/hlPOQTaNXg5" crossorigin="anonymous"></script>
<script src="../dashboard-plus-grid.js" integrity="sha384-xR8W+FnaWZ8B5JzTbivTfMcMU5zTtpHKyMTXKMDzLMO3Pwi/XKR9INs1pib1YVjj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

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